feat(selection): M6.2 条件选股(method=condition + 财务可见性防护)

- quant/selection.run_condition_selection:结构化条件 AND 求值 —— 字段域
  static.*(行业/市场…)、技术列与派生量(close/volume/ma20/ma60)、已注册因子
  (momentum_60 等)、fundamental.*(announce_date<=as_of 的最新已公告财务值);
  条件支持 value 字面量与 ref 字段比较(如 close > ma60);结果带 filter_status/reason
- SelectionQuery 校验调整:condition 模式为纯过滤(不再强制 top_n/top_pct)
- FinancialRepository 新增 list_announced_many(批量防未来函数读取)+ SQLAlchemy 实现;
  SelectionService 注入 financial_repo 并按 announce_date 取每股最新一版
- tests/test_selection_condition.py:8 例(行业 in/ne、动量>0、close>ma60 ref、阈值、
  ROE 过滤且未来公告不可见、缺财务 repo 报错、更早 as_of 排除);全量 pytest 通过
This commit is contained in:
Simon
2026-09-09 00:16:45 +08:00
parent 25a1d9531a
commit 75c5472c31
6 changed files with 530 additions and 13 deletions
@@ -2,10 +2,11 @@
- 输入:SelectionQuery(universe + method + factors/conditions + top_n/pct + as_of)
- 装配:股票池(universe 过滤)→ 行情长表(含预热窗口)→ Selection Engine
- 输出:SelectionResult(可解释:factor_values / selection_reason)
- 未来函数红线:全部数据只取到 <= as_of(v2 §9);财务条件(后续)只取已公告值
(method=score 因子评分 / method=condition 结构化条件)
- 输出:SelectionResult(可解释:factor_values / filter_status / selection_reason)
- 未来函数红线:行情只取 <= as_of;财务条件只取 announce_date <= as_of 的已公告值(v2 §9)
MVP 为同步执行(单日全市场因子计算量轻);如需异步可复用 Job 链路(M6.3 决策)。
MVP 为同步执行(单日全市场因子/条件计算量轻);如需异步可复用 Job 链路。
"""
from __future__ import annotations
@@ -14,11 +15,23 @@ from datetime import date, timedelta
import pandas as pd
from app.domain.entities.market import FinancialIndicator
from app.domain.entities.selection import SelectionQuery, SelectionResult
from app.domain.repositories.market import DailyBarRepository, StockRepository
from app.quant.selection import factor_columns, run_score_selection
from app.domain.repositories.market import (
DailyBarRepository,
FinancialRepository,
StockRepository,
)
from app.quant.selection import (
condition_needed_columns,
factor_columns,
run_condition_selection,
run_score_selection,
)
from app.quant.service import filter_stocks, load_daily_df
_FUNDAMENTAL_PREFIX = "fundamental."
class SelectionService:
"""选股用例入口:select(query) → SelectionResult(当前或历史 as_of)。"""
@@ -27,17 +40,22 @@ class SelectionService:
self,
stock_repo: StockRepository,
daily_repo: DailyBarRepository,
financial_repo: FinancialRepository | None = None,
) -> None:
self._stock_repo = stock_repo
self._daily_repo = daily_repo
self._financial_repo = financial_repo
def select(self, query: SelectionQuery) -> SelectionResult:
as_of = query.as_of or date.today()
stocks = filter_stocks(self._stock_repo.list(), query.universe, as_of=as_of)
if not stocks:
return run_score_selection(pd.DataFrame(), query, as_of)
return self._run(query, pd.DataFrame(), stocks, as_of, financial={})
symbols = [s.symbol for s in stocks]
columns = sorted(factor_columns(query)) if query.method == "score" else ["close"]
if query.method == "score":
columns = sorted(factor_columns(query))
else:
columns = sorted(condition_needed_columns(query))
daily = load_daily_df(
self._daily_repo,
symbols,
@@ -45,6 +63,53 @@ class SelectionService:
as_of,
columns,
)
if daily.empty:
financial: dict[str, FinancialIndicator] = {}
if query.method == "condition" and self._uses_fundamental(query):
financial = self._load_financial(symbols, as_of)
return self._run(query, daily, stocks, as_of, financial)
# ---- 内部 ----
def _run(
self,
query: SelectionQuery,
daily: pd.DataFrame,
stocks: list,
as_of: date,
financial: dict[str, FinancialIndicator],
) -> SelectionResult:
if query.method == "score":
return run_score_selection(daily, query, as_of)
return run_score_selection(daily, query, as_of)
return run_condition_selection(daily, stocks, query, as_of, financial)
@staticmethod
def _uses_fundamental(query: SelectionQuery) -> bool:
for c in query.conditions:
if c.field.startswith(_FUNDAMENTAL_PREFIX) or (
c.ref is not None and c.ref.startswith(_FUNDAMENTAL_PREFIX)
):
return True
return False
def _load_financial(
self, symbols: list[str], as_of: date
) -> dict[str, FinancialIndicator]:
"""按 announce_date <= as_of 批量取财务,每 symbol 保留最新一版。"""
if self._financial_repo is None:
raise ValueError("condition 引用了 fundamental.* 字段,但未注入 FinancialRepository")
getter = getattr(self._financial_repo, "list_announced_many", None)
if getter is not None:
rows = list(getter(symbols, as_of))
else: # 回退逐只
rows = []
for sym in symbols:
rows.extend(self._financial_repo.list_announced(sym, as_of))
by_symbol: dict[str, FinancialIndicator] = {}
for row in rows:
cur = by_symbol.get(row.symbol)
if cur is None or (row.announce_date, row.report_date) > (
cur.announce_date,
cur.report_date,
):
by_symbol[row.symbol] = row
return by_symbol