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
+24 -3
View File
@@ -34,7 +34,11 @@ from app.quant.composite import ( # noqa: F401 —— re-export(模块化后
cross_sectional_zscore,
)
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
_DEFAULT_UNIMPLEMENTED = [
@@ -204,8 +208,25 @@ class TopKBacktestRunner:
# BUY 信号/成交记录:意图入选(filled)或意图被拒(原因);替补成交同样如实记录
if targets:
budget = equal_weight_budget(cash, len(targets))
cap = self.spec.portfolio.max_position_pct
if cap is None:
# 默认等权(与原实现位级一致,保持回归数值不变)
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:
budget = spends[s]
if budget <= 1e-9:
continue
c = float(close_d[s])
price_in = c * (1 + self.costs.slippage_rate)
invest = budget * (1 - self.costs.commission_rate)
@@ -217,7 +238,7 @@ class TopKBacktestRunner:
ActionRecord(date=day, symbol=s, signal="BUY", filled=True,
price=round(price_in, 4))
)
cash -= budget * len(targets)
cash -= total_spend
for sym in picks:
if sym in target_set:
continue
+45 -6
View File
@@ -18,14 +18,53 @@ def equal_weight_budget(cash: float, target_count: int) -> float:
def unimplemented_notes(portfolio: PortfolioSpec) -> list[str]:
"""组合层未建模项说明(默认空;设置约束即显式标注)。"""
"""组合层未建模项说明(默认空;设置约束即显式标注)。
max_position_pct 已建模(Portfolio v1.1 单股上限分配);行业上限依赖行业元数据
注入(v1.1 起仍标注未建模,禁止假装支持)。
"""
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:
notes.append(
f"最大行业权重 {portfolio.max_industry_weight_pct:.0%} 约束未建模(Portfolio v1 仅等权)"
f"最大行业权重 {portfolio.max_industry_weight_pct:.0%} 约束未建模(需行业元数据注入)"
)
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