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
+21
View File
@@ -0,0 +1,21 @@
"""指数及其历史成分(v3 §9/§30:Survivorship-free Universe 的数据基础)。
index_weight:指数在某交易日的成分快照(来自指数权重表,每期含当时成分与权重)。
历史成分语义:as_of 某日的成分 = 该日(<=as_of 最近一期)快照中的股票 ——
禁止用今天的成分回测过去(未来函数/幸存者偏差红线)。
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from pydantic import BaseModel, Field
class IndexWeight(BaseModel):
index_code: str = Field(pattern=r"^\d{6}\.(SH|SZ|CSI|CI)$", description="如 000300.SH / 000905.SH")
index_name: str | None = None
trade_date: date # 该快照对应交易日(成分时点)
symbol: str = Field(pattern=r"^\d{6}\.(SH|SZ|BJ)$")
weight: Decimal | None = None
+3
View File
@@ -26,6 +26,9 @@ class UniverseSpec(BaseModel):
exclude_st: bool = True
exclude_suspended: bool = True
min_listing_days: int = Field(default=250, ge=0, description="上市至少 N 个自然日")
index_code: str | None = Field(
default=None, description="指数成分过滤(如 000300.SH):按 as_of 当日历史成分(v3 §9)"
)
symbols: list[str] = Field(
default_factory=list,
description="白名单(可选):非空时仅这些 symbol 参与选股/回测",
+18
View File
@@ -0,0 +1,18 @@
"""指数成分 Repository Protocol(B1)。"""
from __future__ import annotations
from collections.abc import Sequence
from datetime import date
from typing import Protocol
from app.domain.entities.index import IndexWeight
class IndexConstituentRepository(Protocol):
def upsert_many(self, rows: Sequence[IndexWeight]) -> int: ...
def members_at(self, index_code: str, as_of: date) -> set[str]:
"""as_of 当日成分:取 <=as_of 最近一期快照的股票集合(历史成分语义)。"""
def latest_date(self, index_code: str) -> date | None: ...