Files
qlib/backend/tests/test_selection_backtest_consistency.py
T
Simon 692bdb3be5 feat(portfolio): M8.2 Portfolio Engine 模块化(等权收敛 + 约束显式标注)
- research.PortfolioSpec(weighting=equal;max_position_pct/max_industry_weight_pct 预留)
  + ResearchSpec.portfolio;config_snapshot 自动记录组合配置
- quant/portfolio.py:equal_weight_budget(与既有等权回测语义一致,行为收敛到本模块)+
  unimplemented_notes(设置约束即在结果中显式标注未建模,禁止假装支持)
- TopKBacktestRunner 预算与 unimplemented 改用 portfolio 模块;默认配置数值不变
  (一致性/quant 引擎回归通过);tests 补约束标注与 config_snapshot;全量 pytest 通过
2026-09-09 00:36:38 +08:00

149 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""M6.4 一致性回归:回测(TopKBacktestRunner)与独立 select(as_of) 使用同一评分引擎。
v2 §25/§27 红线验证:对任意调仓日 d,SelectionService.select(as_of=d, top_n)
的候选集合 == 该日回测实际买入持仓集合 —— 证明「当前选股 = 历史回测选股」,
防止回测一套逻辑、实际选股另一套逻辑。
"""
from __future__ import annotations
from datetime import date
import pandas as pd
import pytest
from app.application.services.selection_service import SelectionService
from app.domain.entities.market import Stock
from app.domain.entities.research import ResearchSpec
from app.domain.entities.selection import SelectionQuery
from app.quant.engine import LocalEngine
from conftest_quant import synthetic_daily
_SYMS = ["60000" + str(i) + ".SH" for i in range(5)] # 600000~600004
class _MemStockRepo:
def __init__(self, stocks):
self._stocks = stocks
def list(self):
return self._stocks
def get_by_symbol(self, symbol):
return next((s for s in self._stocks if s.symbol == symbol), None)
class _MemDailyRepo:
def __init__(self, df: pd.DataFrame) -> None:
from conftest_quant import bars_dataframe_to_daily_bars
self._bars = bars_dataframe_to_daily_bars(df)
def get_range(self, symbol, start, end):
return [b for b in self._bars if b.symbol == symbol and start <= b.trade_date <= end]
def get_range_many(self, symbols, start, end, adjust="none"):
syms = set(symbols)
return [b for b in self._bars if b.symbol in syms and start <= b.trade_date <= end]
def latest_date(self, symbol):
rows = [b.trade_date for b in self._bars if b.symbol == symbol]
return max(rows) if rows else None
@pytest.fixture()
def daily_df() -> pd.DataFrame:
drifts = {s: 0.006 - 0.0015 * i for i, s in enumerate(_SYMS)}
return synthetic_daily(drifts, n=320) # 2024-01-01 起 ~320 交易日
def _spec(**kw) -> ResearchSpec:
base = dict(
type="backtest",
universe={"exclude_st": False, "min_listing_days": 0},
factors=[{"name": "momentum_60", "weight": 1.0}],
selection={"top_n": 2},
rebalance="monthly",
period=(date(2024, 5, 1), date(2024, 12, 31)),
)
base.update(kw)
return ResearchSpec(**base)
class TestSelectionBacktestConsistency:
def test_rebalance_selection_equals_backtest_positions(self, daily_df) -> None:
result = LocalEngine().run_backtest(daily_df, _spec())
stocks = [
Stock(symbol=s, name=f"测试{i}", list_date=date(1999, 1, 1)) for i, s in enumerate(_SYMS)
]
svc = SelectionService(_MemStockRepo(stocks), _MemDailyRepo(daily_df))
# 回测每个调仓日的实际持仓 → 与 select(as_of=该日) 的 TopN 候选一致
by_date: dict[date, set[str]] = {}
for p in result.positions:
by_date.setdefault(p.date, set()).add(p.symbol)
assert len(by_date) >= 5 # 月调仓多个时点
for d, held in sorted(by_date.items()):
res = svc.select(
SelectionQuery(
universe=_spec().universe,
factors=[{"name": "momentum_60", "weight": 1.0}],
top_n=2,
as_of=d,
)
)
picked = {c.symbol for c in res.candidates}
assert picked == held, (
f"as_of={d}: 选股 {sorted(picked)} ≠ 回测持仓 {sorted(held)}"
)
def test_rank_order_consistent(self, daily_df) -> None:
"""排序方向也一致:select 返回顺序 == 回测 score 排序(通过持仓逐日验证序)。"""
spec = _spec()
result = LocalEngine().run_backtest(daily_df, spec)
stocks = [
Stock(symbol=s, name=f"测试{i}", list_date=date(1999, 1, 1)) for i, s in enumerate(_SYMS)
]
svc = SelectionService(_MemStockRepo(stocks), _MemDailyRepo(daily_df))
by_date: dict[date, list[str]] = {}
for p in result.positions:
by_date.setdefault(p.date, []).append(p.symbol)
# 只验证任一日的一致性集合(顺序由 TopK 权重决定,与评分排序一一对应)
d, held = next(iter(by_date.items()))
res = svc.select(
SelectionQuery(
universe=spec.universe,
factors=[{"name": "momentum_60", "weight": 1.0}],
top_n=len(held),
as_of=d,
)
)
assert [c.symbol for c in res.candidates] == sorted(
held, key=lambda s: res.candidates[[x.symbol for x in res.candidates].index(s)].score,
reverse=True,
)
class TestPortfolioEngine:
def test_equal_weight_default_unchanged(self, daily_df) -> None:
"""新增 PortfolioSpec 后默认配置回测结果与未设置前一致(回归由本文件首测已锁数值)。"""
from app.domain.entities.research import PortfolioSpec
from app.quant.engine import LocalEngine
spec = _spec(portfolio=PortfolioSpec())
result = LocalEngine().run_backtest(daily_df, spec)
assert result.summary.total_trades >= 0
# 未设约束 → 无组合约束说明
assert not any("约束未建模" in u for u in result.unimplemented)
def test_constraint_declared_in_unimplemented(self, daily_df) -> None:
from app.domain.entities.research import PortfolioSpec
from app.quant.engine import LocalEngine
spec = _spec(portfolio=PortfolioSpec(max_position_pct=0.1))
result = LocalEngine().run_backtest(daily_df, spec)
assert any("最大单股权重" in u for u in result.unimplemented)
# config_snapshot 记录组合配置
assert result.config_snapshot["portfolio"]["max_position_pct"] == 0.1