feat(universe): B1-1 指数历史成分(index_weight)+ Universe 按 as_of 成分过滤

- 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 通过
This commit is contained in:
Simon
2026-09-09 07:27:13 +08:00
parent 03fb463216
commit 9cc4bfccac
13 changed files with 379 additions and 16 deletions
+7 -2
View File
@@ -25,7 +25,7 @@ from app.domain.repositories.market import (
StockRepository,
)
from app.quant.engine import QuantEngine
from app.quant.universe import filter_stocks # noqa: F401 —— 选股/回测共用范围过滤
from app.quant.universe import filter_stocks, resolve_members # noqa: F401 —— 范围过滤
# 流式路径每攒多少行落一个 DataFrame 分片(控制 concat 峰值)
_FRAME_CHUNK_ROWS = 50_000
@@ -110,10 +110,12 @@ class ResearchService:
stock_repo: StockRepository,
daily_repo: DailyBarRepository,
engine: QuantEngine,
index_repo=None,
) -> None:
self._stock_repo = stock_repo
self._daily_repo = daily_repo
self._engine = engine
self._index_repo = index_repo
def run_factor_test(self, spec: ResearchSpec, horizon_days: int = 21) -> FactorTestReport:
if spec.type != "factor_test":
@@ -133,7 +135,10 @@ class ResearchService:
start, end = spec.period
# 回测前预留因子 warmup(lookback≤120 交易日,取 300 自然日余量)
data_start = start - timedelta(days=300)
stocks = filter_stocks(self._stock_repo.list(), spec.universe, as_of=start)
stocks = filter_stocks(
self._stock_repo.list(), spec.universe, as_of=start,
members=resolve_members(self._index_repo, spec.universe, start),
)
# 引擎所需列裁剪(LocalEngine 只取 close + 因子字段;Qlib 回测取全 OHLCV)
required = self._engine.required_columns(spec)
return load_daily_df(
+14 -1
View File
@@ -17,17 +17,30 @@ from app.domain.entities.market import Stock
from app.domain.entities.research import UniverseSpec
def resolve_members(index_repo, universe: UniverseSpec, as_of: date) -> set[str] | None:
"""若 universe 指定指数成分 → 取 as_of 当日历史成分;否则 None(不过滤)。"""
if index_repo is None or not universe.index_code:
return None
return index_repo.members_at(universe.index_code, as_of)
def filter_stocks(
stocks: Sequence[Stock],
universe: UniverseSpec,
as_of: date,
members: set[str] | None = None,
) -> list[Stock]:
"""按股票池口径过滤,返回 as_of 时点应纳入的股票列表。"""
"""按股票池口径过滤,返回 as_of 时点应纳入的股票列表。
members:指数历史成分集合(resolve_members 结果);提供时取交集。
"""
symbols = set(universe.symbols) if universe.symbols else None
out: list[Stock] = []
for s in stocks:
if symbols is not None and s.symbol not in symbols:
continue
if members is not None and s.symbol not in members:
continue
if s.delist_date is not None and s.delist_date < as_of:
continue
if universe.exclude_st and s.name and "ST" in s.name.upper():