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
@@ -0,0 +1,50 @@
"""选股用例入口(ARCHITECTURE_v2 §14 Selection Engine · 业务层)。
- 输入:SelectionQuery(universe + method + factors/conditions + top_n/pct + as_of)
- 装配:股票池(universe 过滤)→ 行情长表(含预热窗口)→ Selection Engine
- 输出:SelectionResult(可解释:factor_values / selection_reason)
- 未来函数红线:全部数据只取到 <= as_of(v2 §9);财务条件(后续)只取已公告值
MVP 为同步执行(单日全市场因子计算量轻);如需异步可复用 Job 链路(M6.3 决策)。
"""
from __future__ import annotations
from datetime import date, timedelta
import pandas as pd
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.quant.service import filter_stocks, load_daily_df
class SelectionService:
"""选股用例入口:select(query) → SelectionResult(当前或历史 as_of)。"""
def __init__(
self,
stock_repo: StockRepository,
daily_repo: DailyBarRepository,
) -> None:
self._stock_repo = stock_repo
self._daily_repo = daily_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)
symbols = [s.symbol for s in stocks]
columns = sorted(factor_columns(query)) if query.method == "score" else ["close"]
daily = load_daily_df(
self._daily_repo,
symbols,
as_of - timedelta(days=query.warmup_days),
as_of,
columns,
)
if daily.empty:
return run_score_selection(daily, query, as_of)
return run_score_selection(daily, query, as_of)