diff --git a/backend/app/domain/entities/research.py b/backend/app/domain/entities/research.py index 21e48a3..62f06f5 100644 --- a/backend/app/domain/entities/research.py +++ b/backend/app/domain/entities/research.py @@ -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") diff --git a/backend/app/quant/local_engine.py b/backend/app/quant/local_engine.py index f57ec98..58747cb 100644 --- a/backend/app/quant/local_engine.py +++ b/backend/app/quant/local_engine.py @@ -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"), ) diff --git a/backend/app/quant/portfolio.py b/backend/app/quant/portfolio.py new file mode 100644 index 0000000..d771d10 --- /dev/null +++ b/backend/app/quant/portfolio.py @@ -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 diff --git a/backend/tests/test_selection_backtest_consistency.py b/backend/tests/test_selection_backtest_consistency.py index 60d3ccf..a70e758 100644 --- a/backend/tests/test_selection_backtest_consistency.py +++ b/backend/tests/test_selection_backtest_consistency.py @@ -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