- index_weight 表(migration f5e0d1c2b3a4,MySQL 已应用;index_code+date+symbol 唯一) + IndexWeight 实体 + IndexConstituentRepository(members_at:取 <=as_of 最近一期快照, Survivorship-free / 无未来成分;latest_date) - UniverseSpec.index_code + universe.filter_stocks members 交集 + resolve_members; Research/Selection/Signal/Replay 服务注入 index repo(历史成分过滤,选股/回测共用) - tests/test_index_universe.py:快照历史成分(成分变更不入早期结果)、幂等、 空快照期空集、index_code 过滤下 as_of 一致性;全量 pytest 通过
123 lines
4.5 KiB
Python
123 lines
4.5 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 load_daily_df
|
||
from app.quant.universe import filter_stocks, resolve_members
|
||
|
||
_FUNDAMENTAL_PREFIX = "fundamental."
|
||
|
||
|
||
class SelectionService:
|
||
"""选股用例入口:select(query) → SelectionResult(当前或历史 as_of)。"""
|
||
|
||
def __init__(
|
||
self,
|
||
stock_repo: StockRepository,
|
||
daily_repo: DailyBarRepository,
|
||
financial_repo: FinancialRepository | None = None,
|
||
index_repo=None,
|
||
) -> None:
|
||
self._stock_repo = stock_repo
|
||
self._daily_repo = daily_repo
|
||
self._financial_repo = financial_repo
|
||
self._index_repo = index_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,
|
||
members=resolve_members(self._index_repo, query.universe, 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,
|
||
adjust=query.price_adjustment,
|
||
)
|
||
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
|