From 67d3aa1349bf219f08f06f73e5b4987e2ca4ea32 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 9 Sep 2026 07:33:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(portfolio):=20C2=20=E5=8D=95=E8=82=A1?= =?UTF-8?q?=E4=B8=8A=E9=99=90=E7=BA=A6=E6=9D=9F=E7=9C=9F=E5=AE=9E=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=EF=BC=88Portfolio=20v1.1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - portfolio.allocate_with_max_position:无上限=等权(与原实现一致);有上限=迭代 把超过 cap×当日组合市值的标的封顶并把剩余现金在其余标的中再分配,超出留现金 - TopKBacktestRunner 买入按约束分流(默认等权路径位级不变,回归数值保持) - unimplemented 只保留行业上限(依赖行业元数据注入,如实标注) - tests/test_portfolio_constraints.py(分配数值/封顶留现金/回测持仓权重≤上限+容差、 config_snapshot)+ 旧断言更新(单股不再标注);全量 pytest 通过 --- backend/app/quant/local_engine.py | 27 ++++++- backend/app/quant/portfolio.py | 51 ++++++++++-- backend/tests/test_portfolio_constraints.py | 79 +++++++++++++++++++ .../test_selection_backtest_consistency.py | 7 +- 4 files changed, 153 insertions(+), 11 deletions(-) create mode 100644 backend/tests/test_portfolio_constraints.py diff --git a/backend/app/quant/local_engine.py b/backend/app/quant/local_engine.py index 66933f5..35df1d6 100644 --- a/backend/app/quant/local_engine.py +++ b/backend/app/quant/local_engine.py @@ -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 diff --git a/backend/app/quant/portfolio.py b/backend/app/quant/portfolio.py index d771d10..6df1b46 100644 --- a/backend/app/quant/portfolio.py +++ b/backend/app/quant/portfolio.py @@ -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 diff --git a/backend/tests/test_portfolio_constraints.py b/backend/tests/test_portfolio_constraints.py new file mode 100644 index 0000000..c8696f8 --- /dev/null +++ b/backend/tests/test_portfolio_constraints.py @@ -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 # 正常成交 diff --git a/backend/tests/test_selection_backtest_consistency.py b/backend/tests/test_selection_backtest_consistency.py index a70e758..31a3447 100644 --- a/backend/tests/test_selection_backtest_consistency.py +++ b/backend/tests/test_selection_backtest_consistency.py @@ -138,11 +138,14 @@ class TestPortfolioEngine: assert not any("约束未建模" in u for u in result.unimplemented) def test_constraint_declared_in_unimplemented(self, daily_df) -> None: + """C2:单股上限已建模(不再进 unimplemented);行业上限仍如实标注。""" from app.domain.entities.research import PortfolioSpec 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) - 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 记录组合配置 assert result.config_snapshot["portfolio"]["max_position_pct"] == 0.1 + assert result.config_snapshot["portfolio"]["max_industry_weight_pct"] == 0.25