feat(portfolio): C2 单股上限约束真实执行(Portfolio v1.1)

- portfolio.allocate_with_max_position:无上限=等权(与原实现一致);有上限=迭代
  把超过 cap×当日组合市值的标的封顶并把剩余现金在其余标的中再分配,超出留现金
- TopKBacktestRunner 买入按约束分流(默认等权路径位级不变,回归数值保持)
- unimplemented 只保留行业上限(依赖行业元数据注入,如实标注)
- tests/test_portfolio_constraints.py(分配数值/封顶留现金/回测持仓权重≤上限+容差、
  config_snapshot)+ 旧断言更新(单股不再标注);全量 pytest 通过
This commit is contained in:
Simon
2026-09-09 07:33:52 +08:00
parent 0d05bfd187
commit 67d3aa1349
4 changed files with 153 additions and 11 deletions
@@ -0,0 +1,79 @@
"""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 # 正常成交
@@ -138,11 +138,14 @@ class TestPortfolioEngine:
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))
spec = _spec(portfolio=PortfolioSpec(max_position_pct=0.1, max_industry_weight_pct=0.25))
result = LocalEngine().run_backtest(daily_df, spec)
assert any("最大单股权重" in u for u in result.unimplemented)
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