Files
qlib/backend/app/application/services/replay_service.py
T
Simon 9cc4bfccac 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 通过
2026-09-09 07:27:13 +08:00

96 lines
3.4 KiB
Python

"""Bar Replay 服务(M9-6):线性逐交易日重放选股+信号(as_of 语义)。
- 范围约束:universe.symbols 必填(≤ 40 只)、重放交易日 ≤ 90 —— 避免全市场长任务
- 每日计算只使用 <= as_of 数据(与 select/signal/回测同一引擎与口径)
- ReplayDay.events 按 rank 升序;top 取前 N(意图排名,与回测 selection_history 对齐)
"""
from __future__ import annotations
from datetime import date, timedelta
import pandas as pd
from app.domain.entities.replay import ReplayDay, ReplayResult, ReplayTop
from app.domain.entities.selection import SelectionQuery
from app.domain.entities.signal import SignalRules
from app.domain.repositories.market import DailyBarRepository, StockRepository
from app.quant.selection import factor_columns
from app.quant.service import load_daily_df
from app.quant.signal import generate_signals
from app.quant.universe import filter_stocks, resolve_members
MAX_SYMBOLS = 40
MAX_DAYS = 90
class ReplayService:
def __init__(
self,
stock_repo: StockRepository,
daily_repo: DailyBarRepository,
index_repo=None,
) -> None:
self._stock_repo = stock_repo
self._daily_repo = daily_repo
self._index_repo = index_repo
def replay(
self,
query: SelectionQuery,
rules: SignalRules,
start: date,
end: date,
top_n: int = 5,
) -> ReplayResult:
symbols = list(query.universe.symbols or [])
if not symbols:
raise ValueError("Bar Replay 需要 universe.symbols 白名单(≤40 只),避免全市场长任务")
if len(symbols) > MAX_SYMBOLS:
raise ValueError(f"Bar Replay 白名单最多 {MAX_SYMBOLS} 只,当前 {len(symbols)}")
stocks = filter_stocks(
self._stock_repo.list(), query.universe, as_of=start,
members=resolve_members(self._index_repo, query.universe, start),
)
if not stocks:
return ReplayResult(start=start, end=end, top_n=top_n)
columns = sorted(factor_columns(query))
daily = load_daily_df(
self._daily_repo,
symbols,
start - timedelta(days=query.warmup_days),
end,
columns,
adjust=query.price_adjustment,
)
if daily.empty:
return ReplayResult(start=start, end=end, top_n=top_n)
trading_days = sorted(
pd.to_datetime(daily["trade_date"].unique())
)
days = [d for d in trading_days if start <= d.date() <= end]
if len(days) > MAX_DAYS:
raise ValueError(f"重放区间交易日 {len(days)} > 上限 {MAX_DAYS},请缩短区间")
out_days: list[ReplayDay] = []
for d in days:
res = generate_signals(daily, query, rules, as_of=d.date())
top = [
ReplayTop(symbol=e.symbol, score=e.score or 0.0)
for e in res.events[:top_n]
]
out_days.append(
ReplayDay(
as_of=d.date(),
top=top,
events=res.events,
counts={
"buy": res.statistics.buy,
"watch": res.statistics.watch,
"sell": res.statistics.sell,
},
)
)
return ReplayResult(start=start, end=end, days=out_days, top_n=top_n)