Files
qlib/backend/tests/test_selection_backtest_consistency.py
Simon 67d3aa1349 feat(portfolio): C2 单股上限约束真实执行(Portfolio v1.1)
- portfolio.allocate_with_max_position:无上限=等权(与原实现一致);有上限=迭代
  把超过 cap×当日组合市值的标的封顶并把剩余现金在其余标的中再分配,超出留现金
- TopKBacktestRunner 买入按约束分流(默认等权路径位级不变,回归数值保持)
- unimplemented 只保留行业上限(依赖行业元数据注入,如实标注)
- tests/test_portfolio_constraints.py(分配数值/封顶留现金/回测持仓权重≤上限+容差、
  config_snapshot)+ 旧断言更新(单股不再标注);全量 pytest 通过
2026-09-09 07:33:52 +08:00

152 lines
6.0 KiB
Python
Raw Permalink 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:
"""C2:单股上限已建模(不再进 unimplemented);行业上限仍如实标注。"""
from app.domain.entities.research import PortfolioSpec
from app.quant.engine import LocalEngine
spec = _spec(portfolio=PortfolioSpec(max_position_pct=0.1, max_industry_weight_pct=0.25))
result = LocalEngine().run_backtest(daily_df, spec)
assert not any("单股" in u for u in result.unimplemented)
assert any("行业" in u for u in result.unimplemented)
# config_snapshot 记录组合配置
assert result.config_snapshot["portfolio"]["max_position_pct"] == 0.1
assert result.config_snapshot["portfolio"]["max_industry_weight_pct"] == 0.25