diff --git a/docs/research/hk_combo_policy_correction_20261003.zh-CN.md b/docs/research/hk_combo_policy_correction_20261003.zh-CN.md new file mode 100644 index 0000000..ac177d3 --- /dev/null +++ b/docs/research/hk_combo_policy_correction_20261003.zh-CN.md @@ -0,0 +1,21 @@ +# 港股组合历史证据与政策修正 + +`hk_equity_combo_backtest_20260628.json` 保留为历史记录。它使用股息腿 +40% / 20% / 0% 的旧政策,不能作为当前 40% / 49% / 70% 政策的收益、 +回撤或晋级证据;当前代码与历史指标不得拼接成一次有效回测。 + +当前动态政策降低 ETF 腿预算,并把补集转给股息腿:默认 risk-on 为 +60% / 40%,soft-defense 为 51% / 49%,hard-defense 为 30% / 70%。 +预算变化不是自动清仓,也不是 Kelly 或分布鲁棒优化。各腿本身的现金 +余量继续保留,不把总风险权重重新归一化为满仓。 + +脚本的股息篮子仍是代理模型,不能把代理历史结果称为真实股息策略回放。 +本次没有下载行情或重跑历史数据。重算需要原冻结输入、可验证的数据来源、 +当前政策参数、成本及成交规则;生成新输出时保留旧工件,并对两版政策 +分别标明输入与源码版本。 + +## 配置兼容 + +组合只把 `etf_weight` 当作腿间预算输入,股息腿预算始终为其补集 `1 - etf_weight`,再按状态应用现有防御比例。旧配置中的独立 `dividend_weight` 已弃用但仍兼容读取;它不会改变有效预算。比如 `etf_weight=0.60` 与旧值 `dividend_weight=0.20` 仍得到 risk-on 的 60% / 40%,不会留下 20% 的新增现金预算。运行诊断将 `raw_dividend_weight` 标为 `deprecated_ignored`,其数值只表示收到的旧请求。 + +HK 策略目录不再发布独立的 `dividend_weight` 默认值。ETF 与股息子策略各自未投资的余量继续保留;组合对重叠标的累加两腿的加权目标,不会把最终权重重新归一化到满仓。这是目标权重组合语义,不构成账户实有现金、预算授权或生产运行证据。 diff --git a/scripts/research_hk_equity_combo_backtest.py b/scripts/research_hk_equity_combo_backtest.py index b1c66b1..b332cf9 100644 --- a/scripts/research_hk_equity_combo_backtest.py +++ b/scripts/research_hk_equity_combo_backtest.py @@ -9,10 +9,13 @@ The dividend leg is simulated from the same underlying ETF data with a lower volatility / return profile (defensive dividend stocks). -Dynamic mode applies a "breadth-regime" overlay to the dividend leg weight: +Dynamic mode reduces the ETF budget and sends the complement to the dividend leg: - risk_on : full 40 % weight - - soft_defense : cut to 20 % - - hard_defense : cut to 0 % + - soft_defense : dividend 49 %, ETF 51 % + - hard_defense : dividend 70 %, ETF 30 % + +The 2026-06-28 JSON records an earlier 40/20/0 dividend policy. Its historical +metrics have not been rerun for this policy; see the research correction note. Usage: python scripts/research_hk_equity_combo_backtest.py [--json-output PATH] diff --git a/src/hk_equity_strategies/catalog.py b/src/hk_equity_strategies/catalog.py index 2e5db0a..94fe262 100644 --- a/src/hk_equity_strategies/catalog.py +++ b/src/hk_equity_strategies/catalog.py @@ -86,7 +86,6 @@ }, HK_EQUITY_COMBO_PROFILE: { "etf_weight": 0.60, - "dividend_weight": 0.40, "execution_cash_reserve_ratio": 0.02, "rebalance_frequency": "monthly", }, diff --git a/src/hk_equity_strategies/combo_entrypoints.py b/src/hk_equity_strategies/combo_entrypoints.py index 4129b57..4af94e0 100644 --- a/src/hk_equity_strategies/combo_entrypoints.py +++ b/src/hk_equity_strategies/combo_entrypoints.py @@ -28,8 +28,13 @@ def evaluate_hk_equity_combo(ctx: StrategyContext) -> StrategyDecision: config=config, ) diagnostics = { - **metadata, "signal_description": f"etf={config.get('etf_weight', 0.60):.0%} div={config.get('dividend_weight', 0.40):.0%}", - "status_description": f"etf={config.get('etf_weight', 0.60):.0%} div={config.get('dividend_weight', 0.40):.0%}", + **metadata, + "signal_description": ( + f"etf={metadata['etf_weight']:.0%} div={metadata['dividend_weight']:.0%}" + ), + "status_description": ( + f"etf={metadata['etf_weight']:.0%} div={metadata['dividend_weight']:.0%}" + ), "signal_source": hk_equity_combo.SIGNAL_SOURCE, "actionable": True, } return StrategyDecision( diff --git a/src/hk_equity_strategies/strategies/hk_equity_combo.py b/src/hk_equity_strategies/strategies/hk_equity_combo.py index b6d0b8b..a08cb1c 100644 --- a/src/hk_equity_strategies/strategies/hk_equity_combo.py +++ b/src/hk_equity_strategies/strategies/hk_equity_combo.py @@ -17,6 +17,7 @@ STATUS_ICON = "\U0001f1ed\U0001f1f0" # Hong Kong flag DEFAULT_ETF_WEIGHT = 0.60 +# Legacy backtest config import; build_target_weights ignores this independent input. DEFAULT_DIVIDEND_WEIGHT = 0.40 DEFAULT_REBALANCE_THRESHOLD = 0.05 # 5% drift triggers rebalance @@ -74,7 +75,9 @@ def build_target_weights( config : dict[str, Any] | None Optional override dictionary. Recognised keys: - * ``etf_weight`` / ``dividend_weight`` (float, default 0.60 / 0.40) + * ``etf_weight`` (float, default 0.60). The effective dividend-leg + weight is always its complement. Legacy ``dividend_weight`` is + accepted for config compatibility, but deprecated and ignored. * ``dividend_regime`` (str | None) — dynamic regime override (``"risk_on"``, ``"soft_defense"``, or ``"hard_defense"``). When set, the ETF weight is scaled down: ``risk_on`` = normal (no @@ -95,7 +98,7 @@ def build_target_weights( config = {} raw_etf_weight = config.get("etf_weight", DEFAULT_ETF_WEIGHT) - raw_dividend_weight = config.get("dividend_weight", DEFAULT_DIVIDEND_WEIGHT) + requested_dividend_weight = config.get("dividend_weight") etf_weight, dividend_weight, regime_label = _apply_dividend_regime( raw_etf_weight, @@ -122,7 +125,8 @@ def build_target_weights( "etf_weight": etf_weight, "dividend_weight": dividend_weight, "raw_etf_weight": raw_etf_weight, - "raw_dividend_weight": raw_dividend_weight, + "raw_dividend_weight": requested_dividend_weight, + "raw_dividend_weight_status": "deprecated_ignored", "dividend_regime": regime_label, "profile": PROFILE_NAME, "rebalance": compute_portfolio_drift( diff --git a/tests/test_hk_equity_combo_contract.py b/tests/test_hk_equity_combo_contract.py new file mode 100644 index 0000000..adae266 --- /dev/null +++ b/tests/test_hk_equity_combo_contract.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import pytest + +from hk_equity_strategies.catalog import ( + HK_EQUITY_COMBO_PROFILE, + STRATEGY_DEFAULT_CONFIG, +) +from hk_equity_strategies.combo_entrypoints import evaluate_hk_equity_combo +from hk_equity_strategies.strategies import hk_equity_combo as combo +from quant_platform_kit.common.strategy_contracts import StrategyContext + + +@pytest.fixture +def fixed_sublegs(monkeypatch): + etf_weights = {"SHARED": 0.5, "ETF_ONLY": 0.3} + dividend_weights = {"SHARED": 0.2, "DIV_ONLY": 0.5} + monkeypatch.setattr( + combo._etf, + "build_target_weights", + lambda _history, **_kwargs: (etf_weights, {"cash_weight": 0.2}), + ) + monkeypatch.setattr( + combo._dividend, + "build_target_weights", + lambda _snapshot, **_kwargs: (dividend_weights, None, {"cash_weight": 0.3}), + ) + return etf_weights, dividend_weights + + +def _build(config=None): + return combo.build_target_weights("history", "snapshot", config=config) + + +def test_legacy_dividend_weight_is_ignored_and_reported_as_deprecated(fixed_sublegs): + weights, metadata = _build({"etf_weight": 0.60, "dividend_weight": 0.20}) + + assert metadata["etf_weight"] == pytest.approx(0.60) + assert metadata["dividend_weight"] == pytest.approx(0.40) + assert metadata["raw_dividend_weight"] == pytest.approx(0.20) + assert metadata["raw_dividend_weight_status"] == "deprecated_ignored" + assert weights["ETF_ONLY"] == pytest.approx(0.18) + assert weights["DIV_ONLY"] == pytest.approx(0.20) + + +def test_default_uses_only_etf_weight_and_catalog_has_no_dividend_default(fixed_sublegs): + weights, metadata = _build() + + assert STRATEGY_DEFAULT_CONFIG[HK_EQUITY_COMBO_PROFILE]["etf_weight"] == pytest.approx(0.60) + assert "dividend_weight" not in STRATEGY_DEFAULT_CONFIG[HK_EQUITY_COMBO_PROFILE] + assert metadata["raw_dividend_weight"] is None + assert metadata["raw_dividend_weight_status"] == "deprecated_ignored" + assert metadata["dividend_weight"] == pytest.approx(0.40) + assert weights["ETF_ONLY"] == pytest.approx(0.18) + assert weights["DIV_ONLY"] == pytest.approx(0.20) + + +@pytest.mark.parametrize( + ("regime", "expected_etf", "expected_dividend"), + ( + ("risk_on", 0.60, 0.40), + ("soft_defense", 0.51, 0.49), + ("hard_defense", 0.30, 0.70), + ), +) +def test_regime_complement_preserves_component_cash_and_combines_overlap( + fixed_sublegs, regime, expected_etf, expected_dividend +): + etf_weights, dividend_weights = fixed_sublegs + weights, metadata = _build( + {"etf_weight": 0.60, "dividend_weight": 0.20, "dividend_regime": regime} + ) + + assert metadata["etf_weight"] == pytest.approx(expected_etf) + assert metadata["dividend_weight"] == pytest.approx(expected_dividend) + assert sum(etf_weights.values()) == pytest.approx(0.80) + assert sum(dividend_weights.values()) == pytest.approx(0.70) + assert weights["ETF_ONLY"] == pytest.approx(0.30 * expected_etf) + assert weights["DIV_ONLY"] == pytest.approx(0.50 * expected_dividend) + assert weights["SHARED"] == pytest.approx( + 0.50 * expected_etf + 0.20 * expected_dividend + ) + assert sum(weights.values()) == pytest.approx(0.80 * expected_etf + 0.70 * expected_dividend) + assert sum(weights.values()) < 1.0 + + +@pytest.mark.parametrize( + ("runtime_config", "expected_description"), + ( + ( + {"etf_weight": 0.60, "dividend_weight": 0.20}, + "etf=60% div=40%", + ), + ( + {"etf_weight": 0.60, "dividend_weight": 0.20, "dividend_regime": "soft_defense"}, + "etf=51% div=49%", + ), + ), +) +def test_entrypoint_describes_effective_weights_not_legacy_request( + fixed_sublegs, runtime_config, expected_description +): + decision = evaluate_hk_equity_combo( + StrategyContext( + as_of="2026-10-03", + market_data={"market_history": "history", "dividend_snapshot": "snapshot"}, + runtime_config=runtime_config, + ) + ) + + assert decision.diagnostics["raw_dividend_weight"] == pytest.approx(0.20) + assert decision.diagnostics["raw_dividend_weight_status"] == "deprecated_ignored" + assert decision.diagnostics["signal_description"] == expected_description + assert decision.diagnostics["status_description"] == expected_description