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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user