feat(selection): M6.0 选股契约与评分引擎(SelectionQuery/Result + select(as_of))

- domain/entities/selection.py:SelectionQuery(universe+method+factors+top_n/top_pct/
  min_score+as_of+预热)与 SelectionResult/Candidate/Statistics(v2 §14.2/§21.1 DTO);
  ConditionSpec 字段就位供 M6.2 条件选股
- quant/selection.py:Selection Engine method=score —— 复合分(zscore×权重×方向)
  → TopN/Top% 截断;observation_date=<=as_of 最近交易日(防未来函数,v2 §9);
  候选带 factor_values 与 selection_reason(可解释)
- application/services/selection_service.py:选股用例(universe 过滤 → 装配 → 引擎)
- quant/service.py:抽取公共 load_daily_df 供研究/选股共用(行为不变)
- tests/test_selection.py:11 例 —— TopN/排序/理由、as_of 防未来函数、ST/上市天数/
  退市过滤、top_pct/min_score、空数据与查询校验;全量 pytest 通过
This commit is contained in:
Simon
2026-09-09 00:12:28 +08:00
parent 697ffc767b
commit f3586adb25
5 changed files with 606 additions and 21 deletions
+175
View File
@@ -0,0 +1,175 @@
"""Selection Engine(ARCHITECTURE_v2 §14)—— 纯 pandas 执行层。
当前实现 method=score:因子加权复合分 → TopN/Top% 截断,输出 SelectionResult。
M6.2 在同一模块加入 method=condition(结构化条件选股)。
未来函数纪律:面板只在 <= observation_date 的数据上计算;observation_date 是
<= as_of 的最近可用交易日(as_of 显式传入即历史选股,None 则到数据最新)。
data 长表由 Service 装配(已按 universe 过滤 symbol、含预热窗口)。
"""
from __future__ import annotations
from datetime import date
import pandas as pd
from app.domain.entities.selection import (
SelectionCandidate,
SelectionQuery,
SelectionResult,
SelectionStatistics,
)
from app.quant.factors import FactorError, compute_factor, get_factor
from app.quant.local_engine import build_factor_panels, composite_score
_UNIMPLEMENTED_DEFAULT = [
"exclude_suspended 依赖停牌数据,当前未建模(结果可能包含停牌股)",
]
def resolve_observation_date(daily: pd.DataFrame, as_of: date | None) -> pd.Timestamp | None:
"""<= as_of 的最近可用交易日;as_of=None 取数据最新一日。"""
if daily.empty:
return None
dates = pd.to_datetime(daily["trade_date"])
if as_of is None:
return dates.max()
avail = dates[dates <= pd.Timestamp(as_of)]
return avail.max() if len(avail) else None
def factor_columns(query: SelectionQuery) -> set[str]:
"""score 模式所需行情数值列(数据装配裁剪用)。"""
needed = {"close"}
for fs in query.factors:
try:
defn, _fn = get_factor(fs.name)
except FactorError:
continue # 未知因子由执行期统一报错(score_selection 中 build_factor_panels)
needed.update(defn.requires)
return needed
def run_score_selection(
daily: pd.DataFrame,
query: SelectionQuery,
as_of: date | None,
) -> SelectionResult:
"""因子评分选股(v2 §14.1B):复合分 → 排序 → TopN/Top%。"""
if query.method != "score":
raise ValueError(f"run_score_selection 需要 method=score,当前 {query.method}")
obs = resolve_observation_date(daily, as_of)
if obs is None:
resolved = as_of or date.today()
return SelectionResult(
as_of_date=resolved,
method=query.method,
statistics=SelectionStatistics(),
candidates=[],
unimplemented=list(_UNIMPLEMENTED_DEFAULT),
config_snapshot=query.model_dump(mode="json"),
)
resolved = obs.date()
# 只允许使用 <= obs 的数据(面板计算在截断后数据上进行)
view = daily[pd.to_datetime(daily["trade_date"]) <= obs]
if view.empty:
return SelectionResult(
as_of_date=resolved,
method=query.method,
statistics=SelectionStatistics(),
candidates=[],
unimplemented=list(_UNIMPLEMENTED_DEFAULT),
config_snapshot=query.model_dump(mode="json"),
)
panels = build_factor_panels(view, query.factors) # 未知因子在此抛 FactorError
score = composite_score(panels).loc[obs].dropna().sort_values(ascending=False)
# 每因子在 obs 行的原始值(factor_values 供展示与解释;与 build_factor_panels 同数据)
raw: dict[str, pd.Series] = {}
for fs in query.factors:
_defn, panel = compute_factor(fs.name, view)
if obs in panel.index:
raw[fs.name] = panel.loc[obs]
candidates_df = _truncate(score, query)
evaluated = int(len(score)) # score 已 dropna,长度即有分股票数
candidates: list[SelectionCandidate] = []
for rank, (sym, sc) in enumerate(candidates_df.items(), start=1):
factor_values = {
name: _to_float(series.get(sym))
for name, series in raw.items()
if isinstance(series, pd.Series)
}
factor_values = {k: v for k, v in factor_values.items() if v is not None}
candidates.append(
SelectionCandidate(
symbol=sym,
rank=rank,
score=round(float(sc), 6),
factor_values=factor_values,
selection_reason=_score_reason(query, sym, raw),
)
)
return SelectionResult(
as_of_date=resolved,
method=query.method,
statistics=SelectionStatistics(
universe_size=_symbol_count(view),
evaluated=evaluated,
selected=len(candidates),
),
candidates=candidates,
unimplemented=list(_UNIMPLEMENTED_DEFAULT),
config_snapshot=query.model_dump(mode="json"),
)
def _truncate(score: pd.Series, query: SelectionQuery) -> pd.Series:
"""按 top_n / top_pct / min_score 截断(入参已按分数降序)。"""
s = score
if query.min_score is not None:
s = s[s >= query.min_score]
if query.top_pct is not None:
n = max(int(round(len(s) * query.top_pct)), 1)
s = s.head(n)
elif query.top_n is not None:
s = s.head(query.top_n)
return s
def _score_reason(query: SelectionQuery, symbol: str, raw: dict[str, pd.Series]) -> list[str]:
"""生成可读的入选理由:列每个因子的观测值与权重。"""
reasons: list[str] = []
for fs in query.factors:
try:
defn, _fn = get_factor(fs.name)
except FactorError:
continue
series = raw.get(fs.name)
val = _to_float(series.get(symbol)) if isinstance(series, pd.Series) else None
if val is None:
continue
good = defn.direction == "higher_is_better"
reasons.append(
f"{fs.name}={val:.4f}(权重 {fs.weight},{'越高越好' if good else '越低越好'})"
)
return reasons
def _symbol_count(daily: pd.DataFrame) -> int:
return int(daily["symbol"].nunique()) if not daily.empty and "symbol" in daily else 0
def _to_float(v) -> float | None:
if v is None:
return None
try:
f = float(v)
except (TypeError, ValueError):
return None
if f != f: # NaN
return None
return f
+33 -21
View File
@@ -88,6 +88,36 @@ def _frame_from_stream(rows: Iterable[tuple], columns: list[str]) -> pd.DataFram
return df
def load_daily_df(
daily_repo,
symbols: list[str],
start: date,
end: date,
columns: list[str],
) -> pd.DataFrame:
"""从 Repository 装配行情长表(供研究/选股共用)。
优先走流式列裁剪(stream_range_many_columns,SQL 侧转 REAL、分批),
失败或实现缺失时回退 get_range_many / 逐只 get_range。
"""
if not symbols:
return pd.DataFrame()
streamer = getattr(daily_repo, "stream_range_many_columns", None)
if streamer is not None:
try:
return _frame_from_stream(streamer(symbols, start, end, sorted(columns)), sorted(columns))
except Exception: # noqa: BLE001 —— 流式路径失败回退旧路径(兼容非 SQL 实现)
pass
get_many = getattr(daily_repo, "get_range_many", None)
if get_many is not None:
bars = list(get_many(symbols, start, end))
else: # 兜底:逐只查询
bars = []
for sym in symbols:
bars.extend(daily_repo.get_range(sym, start, end))
return bars_to_daily_df(bars)
class ResearchService:
"""研究用例入口(因子测试 / 回测)。依赖注入 Repository 与引擎。"""
@@ -120,26 +150,8 @@ class ResearchService:
# 回测前预留因子 warmup(lookback≤120 交易日,取 300 自然日余量)
data_start = start - timedelta(days=300)
stocks = filter_stocks(self._stock_repo.list(), spec.universe, as_of=start)
if not stocks:
return pd.DataFrame()
symbols = [s.symbol for s in stocks]
# 引擎所需列裁剪(LocalEngine 只取 close + 因子字段;Qlib 回测取全 OHLCV)
required = self._engine.required_columns(spec)
streamer = getattr(self._daily_repo, "stream_range_many_columns", None)
if streamer is not None:
try:
return _frame_from_stream(
streamer(symbols, data_start, end, sorted(required)), sorted(required)
)
except Exception: # noqa: BLE001 —— 流式路径失败回退旧路径(兼容非 SQL 实现)
pass
# 旧路径:逐实体(供内存 / Fake 仓储等实现使用)
get_many = getattr(self._daily_repo, "get_range_many", None)
if get_many is not None:
bars = list(get_many(symbols, data_start, end))
else: # 兜底:逐只查询
bars = []
for s in stocks:
bars.extend(self._daily_repo.get_range(s.symbol, data_start, end))
return bars_to_daily_df(bars)
return load_daily_df(
self._daily_repo, [s.symbol for s in stocks], data_start, end, sorted(required)
)