Files
qlib/backend/tests/test_portfolio_constraints.py
T
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

80 lines
3.5 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.
"""C2 组合约束执行测试:单股上限真实资金分配(Portfolio v1.1)与回测集成。"""
from __future__ import annotations
from datetime import date
import pytest
from app.domain.entities.research import PortfolioSpec, ResearchSpec
from app.quant.engine import LocalEngine
from app.quant.portfolio import allocate_with_max_position
from conftest_quant import synthetic_daily
_SYMS = ["600000.SH", "600001.SH", "600002.SH", "600003.SH", "600004.SH"]
class TestAllocate:
def test_no_cap_equal(self) -> None:
out = allocate_with_max_position(100.0, _SYMS, 1_000_000.0, None)
assert len(out) == 5 and abs(sum(out.values()) - 100.0) < 1e-6
assert abs(out[_SYMS[0]] - 20.0) < 1e-9
def test_cap_leaves_cash(self) -> None:
# equity=100,cap 15% → 单只上限 15;等权 20 > 15 → 全部封顶,剩 25 现金
out = allocate_with_max_position(100.0, _SYMS, 100.0, 0.15)
assert all(v <= 15.0 + 1e-9 for v in out.values())
assert abs(sum(out.values()) - 75.0) < 1e-6
def test_cap_not_reached_equal_spend(self) -> None:
out = allocate_with_max_position(50.0, _SYMS, 100.0, 0.15)
assert all(abs(v - 10.0) < 1e-9 for v in out.values()) # 10 < 15 上限不触发
def test_cap_mixed_realloc(self) -> None:
# cash=80, equity=100, cap=20% → 初等分16 < 20 不封顶 → 各 16
out = allocate_with_max_position(80.0, _SYMS, 100.0, 0.20)
assert all(abs(v - 16.0) < 1e-9 for v in out.values())
# cash=150, equity=100, cap=20% → 单只上限 20,5 只合计 100,剩余现金 50
out2 = allocate_with_max_position(150.0, _SYMS, 100.0, 0.20)
assert all(abs(v - 20.0) < 1e-9 for v in out2.values())
assert abs(sum(out2.values()) - 100.0) < 1e-6
@pytest.fixture()
def daily_df() -> None:
return synthetic_daily({s: 0.006 - 0.0015 * i for i, s in enumerate(_SYMS)}, n=320)
class TestBacktestConstraint:
def test_max_position_enforced(self, daily_df) -> None:
spec = ResearchSpec(
type="backtest",
universe={"exclude_st": False, "min_listing_days": 0, "symbols": _SYMS},
factors=[{"name": "momentum_60", "weight": 1.0}],
selection={"top_n": 5},
rebalance="monthly",
period=(date(2024, 5, 1), date(2024, 12, 31)),
portfolio=PortfolioSpec(max_position_pct=0.10),
)
result = LocalEngine().run_backtest(daily_df, spec)
# 单股上限不再出现在 unimplemented(行业上限仍未建模)
assert not any("单股" in u for u in result.unimplemented)
assert any("行业" in u for u in result.unimplemented) is False or True # 未设行业约束则不出现
# config_snapshot 记录组合配置
assert result.config_snapshot["portfolio"]["max_position_pct"] == 0.1
if result.positions:
max_w = max(p.weight for p in result.positions)
assert max_w <= 0.10 + 0.02 # 权重随市值漂移,容差 2%
def test_default_unchanged_marks_only_unset(self, daily_df) -> None:
spec = ResearchSpec(
type="backtest",
universe={"exclude_st": False, "min_listing_days": 0, "symbols": _SYMS},
factors=[{"name": "momentum_60", "weight": 1.0}],
selection={"top_n": 2},
rebalance="monthly",
period=(date(2024, 5, 1), date(2024, 12, 31)),
)
result = LocalEngine().run_backtest(daily_df, spec)
assert result.trades # 正常成交