feat(portfolio): M8.2 Portfolio Engine 模块化(等权收敛 + 约束显式标注)
- research.PortfolioSpec(weighting=equal;max_position_pct/max_industry_weight_pct 预留) + ResearchSpec.portfolio;config_snapshot 自动记录组合配置 - quant/portfolio.py:equal_weight_budget(与既有等权回测语义一致,行为收敛到本模块)+ unimplemented_notes(设置约束即在结果中显式标注未建模,禁止假装支持) - TopKBacktestRunner 预算与 unimplemented 改用 portfolio 模块;默认配置数值不变 (一致性/quant 引擎回归通过);tests 补约束标注与 config_snapshot;全量 pytest 通过
This commit is contained in:
@@ -45,6 +45,20 @@ class SelectionSpec(BaseModel):
|
||||
top_n: int = Field(default=30, ge=1, le=1000)
|
||||
|
||||
|
||||
class PortfolioSpec(BaseModel):
|
||||
"""组合构建(v2 §16)。MVP:等权;单股/行业上限等约束字段预留,
|
||||
未建模约束在回测结果 unimplemented 中如实标注(禁止假装支持)。
|
||||
"""
|
||||
|
||||
weighting: str = Field(default="equal", pattern="^(equal)$")
|
||||
max_position_pct: float | None = Field(
|
||||
default=None, gt=0, le=1, description="单股最大权重(预留,未建模)"
|
||||
)
|
||||
max_industry_weight_pct: float | None = Field(
|
||||
default=None, gt=0, le=1, description="行业最大权重(预留,未建模)"
|
||||
)
|
||||
|
||||
|
||||
class CostSpec(BaseModel):
|
||||
"""交易成本模型(单边比例)。
|
||||
|
||||
@@ -71,6 +85,7 @@ class ResearchSpec(BaseModel):
|
||||
rebalance: str = Field(default="monthly", pattern="^(weekly|monthly)$")
|
||||
period: tuple[date, date]
|
||||
costs: CostSpec = CostSpec()
|
||||
portfolio: PortfolioSpec = PortfolioSpec()
|
||||
initial_capital: float = Field(default=1_000_000.0, gt=0)
|
||||
|
||||
@field_validator("period")
|
||||
|
||||
@@ -32,6 +32,7 @@ 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
|
||||
|
||||
TRADING_DAYS = 252
|
||||
_DEFAULT_UNIMPLEMENTED = [
|
||||
@@ -169,7 +170,7 @@ class TopKBacktestRunner:
|
||||
targets.append(s)
|
||||
|
||||
if targets:
|
||||
budget = cash / len(targets)
|
||||
budget = equal_weight_budget(cash, len(targets))
|
||||
for s in targets:
|
||||
c = float(close_d[s])
|
||||
price_in = c * (1 + self.costs.slippage_rate)
|
||||
@@ -263,7 +264,7 @@ class TopKBacktestRunner:
|
||||
positions=positions,
|
||||
trades=trades,
|
||||
turnover_pct=round(sum(notional) / max(init, 1) * 100, 2),
|
||||
unimplemented=list(_DEFAULT_UNIMPLEMENTED),
|
||||
unimplemented=list(_DEFAULT_UNIMPLEMENTED) + unimplemented_notes(self.spec.portfolio),
|
||||
config_snapshot=self.spec.model_dump(mode="json"),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Portfolio Engine(v2 §16)—— 组合构建模块(M8.2)。
|
||||
|
||||
MVP:等权资金拆分(与既有 TopK 回测等权语义一致,行为收敛到本模块);
|
||||
单股/行业上限等约束为预留字段,未建模时由回测器写入 unimplemented
|
||||
(禁止假装支持,AGENT.md §24)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.domain.entities.research import PortfolioSpec
|
||||
|
||||
|
||||
def equal_weight_budget(cash: float, target_count: int) -> float:
|
||||
"""等权单标的预算:现金均分(target_count>0)。"""
|
||||
if target_count <= 0:
|
||||
return 0.0
|
||||
return cash / target_count
|
||||
|
||||
|
||||
def unimplemented_notes(portfolio: PortfolioSpec) -> 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:
|
||||
notes.append(
|
||||
f"最大行业权重 {portfolio.max_industry_weight_pct:.0%} 约束未建模(Portfolio v1 仅等权)"
|
||||
)
|
||||
return notes
|
||||
@@ -123,3 +123,26 @@ class TestSelectionBacktestConsistency:
|
||||
held, key=lambda s: res.candidates[[x.symbol for x in res.candidates].index(s)].score,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
class TestPortfolioEngine:
|
||||
def test_equal_weight_default_unchanged(self, daily_df) -> None:
|
||||
"""新增 PortfolioSpec 后默认配置回测结果与未设置前一致(回归由本文件首测已锁数值)。"""
|
||||
from app.domain.entities.research import PortfolioSpec
|
||||
from app.quant.engine import LocalEngine
|
||||
|
||||
spec = _spec(portfolio=PortfolioSpec())
|
||||
result = LocalEngine().run_backtest(daily_df, spec)
|
||||
assert result.summary.total_trades >= 0
|
||||
# 未设约束 → 无组合约束说明
|
||||
assert not any("约束未建模" in u for u in result.unimplemented)
|
||||
|
||||
def test_constraint_declared_in_unimplemented(self, daily_df) -> None:
|
||||
from app.domain.entities.research import PortfolioSpec
|
||||
from app.quant.engine import LocalEngine
|
||||
|
||||
spec = _spec(portfolio=PortfolioSpec(max_position_pct=0.1))
|
||||
result = LocalEngine().run_backtest(daily_df, spec)
|
||||
assert any("最大单股权重" in u for u in result.unimplemented)
|
||||
# config_snapshot 记录组合配置
|
||||
assert result.config_snapshot["portfolio"]["max_position_pct"] == 0.1
|
||||
|
||||
Reference in New Issue
Block a user