"""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 # 正常成交