Files
qlib/backend/app/quant/selection.py
T
Simon f3586adb25 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 通过
2026-09-09 00:12:28 +08:00

176 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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