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:
+46
@@ -0,0 +1,46 @@
|
||||
"""index_weight 表(B1 指数历史成分)
|
||||
|
||||
Revision ID: f5e0d1c2b3a4
|
||||
Revises: e1f2a3b4c5d6
|
||||
Create Date: 2026-09-09
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "f5e0d1c2b3a4"
|
||||
down_revision: str | None = "e1f2a3b4c5d6"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"index_weight",
|
||||
sa.Column(
|
||||
"id",
|
||||
sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
|
||||
autoincrement=True,
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("index_code", sa.String(length=12), nullable=False),
|
||||
sa.Column("index_name", sa.String(length=64), nullable=True),
|
||||
sa.Column("trade_date", sa.Date(), nullable=False),
|
||||
sa.Column("symbol", sa.String(length=12), nullable=False),
|
||||
sa.Column("weight", sa.Numeric(precision=10, scale=6), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("index_code", "trade_date", "symbol", name="uq_idxw_code_date_sym"),
|
||||
)
|
||||
op.create_index("ix_index_weight_index_code", "index_weight", ["index_code"])
|
||||
op.create_index("ix_index_weight_trade_date", "index_weight", ["trade_date"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_index_weight_trade_date", table_name="index_weight")
|
||||
op.drop_index("ix_index_weight_index_code", table_name="index_weight")
|
||||
op.drop_table("index_weight")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""指数历史成分表(B1)。
|
||||
|
||||
index_weight:指数成分快照(index_code, trade_date, symbol 唯一)。
|
||||
历史成分查询(members_at)取 <= as_of 最近一期快照 —— Survivorship-free Universe。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import BigInteger, Date, Integer, Numeric, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.infrastructure.persistence.sqlalchemy.base import Base
|
||||
|
||||
PK_INT = BigInteger().with_variant(Integer, "sqlite")
|
||||
|
||||
|
||||
class IndexWeightModel(Base):
|
||||
__tablename__ = "index_weight"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("index_code", "trade_date", "symbol", name="uq_idxw_code_date_sym"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(PK_INT, primary_key=True, autoincrement=True)
|
||||
index_code: Mapped[str] = mapped_column(String(12), index=True)
|
||||
index_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
trade_date: Mapped[date] = mapped_column(Date, index=True)
|
||||
symbol: Mapped[str] = mapped_column(String(12))
|
||||
weight: Mapped[Decimal | None] = mapped_column(Numeric(10, 6), nullable=True)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""指数成分 Repository 的 SQLAlchemy 实现(B1)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.domain.entities.index import IndexWeight
|
||||
from app.infrastructure.persistence.sqlalchemy.models.index import IndexWeightModel
|
||||
|
||||
|
||||
class SqlAlchemyIndexConstituentRepository:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self._session = session
|
||||
|
||||
def upsert_many(self, rows: Sequence[IndexWeight]) -> int:
|
||||
if not rows:
|
||||
return 0
|
||||
existing = {
|
||||
(r.index_code, r.trade_date, r.symbol): r
|
||||
for r in self._session.scalars(
|
||||
select(IndexWeightModel).where(
|
||||
IndexWeightModel.index_code.in_({x.index_code for x in rows})
|
||||
)
|
||||
)
|
||||
}
|
||||
changed = 0
|
||||
for row in rows:
|
||||
key = (row.index_code, row.trade_date, row.symbol)
|
||||
model = existing.get(key)
|
||||
if model is None:
|
||||
self._session.add(IndexWeightModel(**row.model_dump()))
|
||||
changed += 1
|
||||
else:
|
||||
for k, v in row.model_dump(exclude={"index_code", "trade_date", "symbol"}).items():
|
||||
setattr(model, k, v)
|
||||
changed += 1
|
||||
return changed
|
||||
|
||||
def latest_date(self, index_code: str) -> date | None:
|
||||
return self._session.scalar(
|
||||
select(IndexWeightModel.trade_date)
|
||||
.where(IndexWeightModel.index_code == index_code)
|
||||
.order_by(IndexWeightModel.trade_date.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
def members_at(self, index_code: str, as_of: date) -> set[str]:
|
||||
"""<= as_of 最近一期快照的成分(历史成分;无快照 → 空集)。"""
|
||||
latest = self._session.scalar(
|
||||
select(IndexWeightModel.trade_date)
|
||||
.where(
|
||||
IndexWeightModel.index_code == index_code,
|
||||
IndexWeightModel.trade_date <= as_of,
|
||||
)
|
||||
.order_by(IndexWeightModel.trade_date.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if latest is None:
|
||||
return set()
|
||||
rows = self._session.scalars(
|
||||
select(IndexWeightModel.symbol).where(
|
||||
IndexWeightModel.index_code == index_code,
|
||||
IndexWeightModel.trade_date == latest,
|
||||
)
|
||||
).all()
|
||||
return set(rows)
|
||||
Reference in New Issue
Block a user