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:
@@ -34,7 +34,11 @@ from app.quant.composite import ( # noqa: F401 —— re-export(模块化后
|
|||||||
cross_sectional_zscore,
|
cross_sectional_zscore,
|
||||||
)
|
)
|
||||||
from app.quant.evaluation import run_factor_test
|
from app.quant.evaluation import run_factor_test
|
||||||
from app.quant.portfolio import equal_weight_budget, unimplemented_notes
|
from app.quant.portfolio import (
|
||||||
|
allocate_with_max_position,
|
||||||
|
equal_weight_budget,
|
||||||
|
unimplemented_notes,
|
||||||
|
)
|
||||||
|
|
||||||
TRADING_DAYS = 252
|
TRADING_DAYS = 252
|
||||||
_DEFAULT_UNIMPLEMENTED = [
|
_DEFAULT_UNIMPLEMENTED = [
|
||||||
@@ -204,8 +208,25 @@ class TopKBacktestRunner:
|
|||||||
|
|
||||||
# BUY 信号/成交记录:意图入选(filled)或意图被拒(原因);替补成交同样如实记录
|
# BUY 信号/成交记录:意图入选(filled)或意图被拒(原因);替补成交同样如实记录
|
||||||
if targets:
|
if targets:
|
||||||
|
cap = self.spec.portfolio.max_position_pct
|
||||||
|
if cap is None:
|
||||||
|
# 默认等权(与原实现位级一致,保持回归数值不变)
|
||||||
budget = equal_weight_budget(cash, len(targets))
|
budget = equal_weight_budget(cash, len(targets))
|
||||||
|
spends = {s: budget for s in targets}
|
||||||
|
total_spend = budget * len(targets)
|
||||||
|
else:
|
||||||
|
# Portfolio v1.1:按单股上限(相对当日组合市值)分配,超出部分留现金
|
||||||
|
equity_now = cash + sum(
|
||||||
|
float(self.close.at[d, s] * qty)
|
||||||
|
for s, qty in shares.items()
|
||||||
|
if qty > 0 and not _nan(self.close.at[d, s])
|
||||||
|
)
|
||||||
|
spends = allocate_with_max_position(cash, targets, equity_now, cap)
|
||||||
|
total_spend = sum(spends.values())
|
||||||
for s in targets:
|
for s in targets:
|
||||||
|
budget = spends[s]
|
||||||
|
if budget <= 1e-9:
|
||||||
|
continue
|
||||||
c = float(close_d[s])
|
c = float(close_d[s])
|
||||||
price_in = c * (1 + self.costs.slippage_rate)
|
price_in = c * (1 + self.costs.slippage_rate)
|
||||||
invest = budget * (1 - self.costs.commission_rate)
|
invest = budget * (1 - self.costs.commission_rate)
|
||||||
@@ -217,7 +238,7 @@ class TopKBacktestRunner:
|
|||||||
ActionRecord(date=day, symbol=s, signal="BUY", filled=True,
|
ActionRecord(date=day, symbol=s, signal="BUY", filled=True,
|
||||||
price=round(price_in, 4))
|
price=round(price_in, 4))
|
||||||
)
|
)
|
||||||
cash -= budget * len(targets)
|
cash -= total_spend
|
||||||
for sym in picks:
|
for sym in picks:
|
||||||
if sym in target_set:
|
if sym in target_set:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -18,14 +18,53 @@ def equal_weight_budget(cash: float, target_count: int) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def unimplemented_notes(portfolio: PortfolioSpec) -> list[str]:
|
def unimplemented_notes(portfolio: PortfolioSpec) -> list[str]:
|
||||||
"""组合层未建模项说明(默认空;设置约束即显式标注)。"""
|
"""组合层未建模项说明(默认空;设置约束即显式标注)。
|
||||||
|
|
||||||
|
max_position_pct 已建模(Portfolio v1.1 单股上限分配);行业上限依赖行业元数据
|
||||||
|
注入(v1.1 起仍标注未建模,禁止假装支持)。
|
||||||
|
"""
|
||||||
notes: list[str] = []
|
notes: list[str] = []
|
||||||
if portfolio.max_position_pct is not None:
|
|
||||||
notes.append(
|
|
||||||
f"最大单股权重 {portfolio.max_position_pct:.0%} 约束未建模(Portfolio v1 仅等权)"
|
|
||||||
)
|
|
||||||
if portfolio.max_industry_weight_pct is not None:
|
if portfolio.max_industry_weight_pct is not None:
|
||||||
notes.append(
|
notes.append(
|
||||||
f"最大行业权重 {portfolio.max_industry_weight_pct:.0%} 约束未建模(Portfolio v1 仅等权)"
|
f"最大行业权重 {portfolio.max_industry_weight_pct:.0%} 约束未建模(需行业元数据注入)"
|
||||||
)
|
)
|
||||||
return notes
|
return notes
|
||||||
|
|
||||||
|
|
||||||
|
def allocate_with_max_position(
|
||||||
|
cash: float,
|
||||||
|
targets: list[str],
|
||||||
|
equity: float,
|
||||||
|
max_position_pct: float | None,
|
||||||
|
) -> dict[str, float]:
|
||||||
|
"""按单股上限的等权资金分配(M9/C2,Portfolio v1.1)。
|
||||||
|
|
||||||
|
- 无上限 → 现金均分(与原等权语义一致)
|
||||||
|
- 有上限 cap:迭代把「均分份额超过 cap×equity」的标的封顶,剩余现金在其余标的中
|
||||||
|
继续均分,直至收敛;未分配现金留在组合(现金管理)。
|
||||||
|
"""
|
||||||
|
n = len(targets)
|
||||||
|
if n == 0:
|
||||||
|
return {}
|
||||||
|
if max_position_pct is None:
|
||||||
|
return {t: cash / n for t in targets}
|
||||||
|
cap_value = max_position_pct * equity
|
||||||
|
if cap_value <= 0:
|
||||||
|
return {t: 0.0 for t in targets}
|
||||||
|
spend: dict[str, float] = {}
|
||||||
|
left = cash
|
||||||
|
pool = list(targets)
|
||||||
|
while pool and left > 1e-6:
|
||||||
|
share = left / len(pool)
|
||||||
|
capped = [t for t in pool if share > cap_value + 1e-9]
|
||||||
|
if not capped:
|
||||||
|
for t in pool:
|
||||||
|
spend[t] = share
|
||||||
|
break
|
||||||
|
for t in capped:
|
||||||
|
spend[t] = cap_value
|
||||||
|
left -= cap_value
|
||||||
|
pool = [x for x in pool if x != t]
|
||||||
|
for t in targets:
|
||||||
|
spend.setdefault(t, 0.0)
|
||||||
|
return spend
|
||||||
|
|||||||
@@ -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)
|
assert not any("约束未建模" in u for u in result.unimplemented)
|
||||||
|
|
||||||
def test_constraint_declared_in_unimplemented(self, daily_df) -> None:
|
def test_constraint_declared_in_unimplemented(self, daily_df) -> None:
|
||||||
|
"""C2:单股上限已建模(不再进 unimplemented);行业上限仍如实标注。"""
|
||||||
from app.domain.entities.research import PortfolioSpec
|
from app.domain.entities.research import PortfolioSpec
|
||||||
from app.quant.engine import LocalEngine
|
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)
|
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 记录组合配置
|
# config_snapshot 记录组合配置
|
||||||
assert result.config_snapshot["portfolio"]["max_position_pct"] == 0.1
|
assert result.config_snapshot["portfolio"]["max_position_pct"] == 0.1
|
||||||
|
assert result.config_snapshot["portfolio"]["max_industry_weight_pct"] == 0.25
|
||||||
|
|||||||
Reference in New Issue
Block a user