- 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 通过
116 lines
4.2 KiB
Python
116 lines
4.2 KiB
Python
"""选股用例入口(ARCHITECTURE_v2 §14 Selection Engine · 业务层)。
|
||
|
||
- 输入:SelectionQuery(universe + method + factors/conditions + top_n/pct + as_of)
|
||
- 装配:股票池(universe 过滤)→ 行情长表(含预热窗口)→ Selection Engine
|
||
(method=score 因子评分 / method=condition 结构化条件)
|
||
- 输出:SelectionResult(可解释:factor_values / filter_status / selection_reason)
|
||
- 未来函数红线:行情只取 <= as_of;财务条件只取 announce_date <= as_of 的已公告值(v2 §9)
|
||
|
||
MVP 为同步执行(单日全市场因子/条件计算量轻);如需异步可复用 Job 链路。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
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,
|
||
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)。"""
|
||
|
||
def __init__(
|
||
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 self._run(query, pd.DataFrame(), stocks, as_of, financial={})
|
||
symbols = [s.symbol for s in stocks]
|
||
if query.method == "score":
|
||
columns = sorted(factor_columns(query))
|
||
else:
|
||
columns = sorted(condition_needed_columns(query))
|
||
daily = load_daily_df(
|
||
self._daily_repo,
|
||
symbols,
|
||
as_of - timedelta(days=query.warmup_days),
|
||
as_of,
|
||
columns,
|
||
)
|
||
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_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
|