Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/research/hk_combo_policy_correction_20261003.zh-CN.md
Original file line number Diff line number Diff line change
@@ -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 与股息子策略各自未投资的余量继续保留;组合对重叠标的累加两腿的加权目标,不会把最终权重重新归一化到满仓。这是目标权重组合语义,不构成账户实有现金、预算授权或生产运行证据。
9 changes: 6 additions & 3 deletions scripts/research_hk_equity_combo_backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 0 additions & 1 deletion src/hk_equity_strategies/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down
9 changes: 7 additions & 2 deletions src/hk_equity_strategies/combo_entrypoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 7 additions & 3 deletions src/hk_equity_strategies/strategies/hk_equity_combo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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(
Expand Down
114 changes: 114 additions & 0 deletions tests/test_hk_equity_combo_contract.py
Original file line number Diff line number Diff line change
@@ -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
Loading