From e978aad8a8f7178f3e8c3d8afc6181b1717cdf99 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:48:41 +0800 Subject: [PATCH] Align HK combo dynamic backtest semantics Co-Authored-By: Codex --- scripts/research_hk_equity_combo_backtest.py | 28 +++++++----- tests/test_hk_equity_combo_backtest.py | 46 ++++++++++++++++++++ 2 files changed, 62 insertions(+), 12 deletions(-) create mode 100644 tests/test_hk_equity_combo_backtest.py diff --git a/scripts/research_hk_equity_combo_backtest.py b/scripts/research_hk_equity_combo_backtest.py index 53b9145..474650d 100644 --- a/scripts/research_hk_equity_combo_backtest.py +++ b/scripts/research_hk_equity_combo_backtest.py @@ -58,8 +58,8 @@ DYNAMIC_WEIGHT_MAP: dict[str, float] = { "risk_on": 0.40, - "soft_defense": 0.20, - "hard_defense": 0.00, + "soft_defense": 0.49, + "hard_defense": 0.70, } # Dividend-style simulation parameters applied to the ETF-eligible universe. @@ -69,11 +69,6 @@ DIVIDEND_SYMBOL = "03110" # High-dividend proxy from the ETF universe DIVIDEND_ANNUAL_VOL_SCALE = 0.85 # Dividend stocks are ~15 % less volatile -# Period definitions for the regime signal (uses the same date range as the -# ETF backtest config by default). -BREADTH_REGIME_WINDOW_DAYS = 63 - - # --------------------------------------------------------------------------- # Dividend-leg simulation helpers # --------------------------------------------------------------------------- @@ -122,9 +117,8 @@ def _breadth_regime(close: pd.DataFrame, as_of: pd.Timestamp) -> str: - soft_defense : 0.30 <= breadth < 0.45 - hard_defense : breadth < 0.30 """ - window_start = close.index[close.index.searchsorted(as_of - pd.Timedelta(days=BREADTH_REGIME_WINDOW_DAYS))] - window = close.loc[window_start:as_of] - if window.empty: + window = close.loc[:as_of] + if len(window) < 100: return "risk_on" sma200 = window.rolling(200, min_periods=100).mean() @@ -139,6 +133,17 @@ def _breadth_regime(close: pd.DataFrame, as_of: pd.Timestamp) -> str: return "risk_on" +def _dynamic_leg_weights(combo: "ComboConfig", regime: str) -> tuple[float, float]: + """Mirror production combo regime semantics for ETF/dividend weights.""" + if regime == "soft_defense": + etf_target_weight = min(combo.etf_weight * 0.85, 1.0) + elif regime == "hard_defense": + etf_target_weight = min(combo.etf_weight * 0.50, 1.0) + else: + etf_target_weight = combo.etf_weight + return etf_target_weight, 1.0 - etf_target_weight + + # --------------------------------------------------------------------------- # Combo backtest runner # --------------------------------------------------------------------------- @@ -200,8 +205,7 @@ def _combo_strategy_returns( regime = "static" else: regime = _breadth_regime(close, as_of) - div_target_weight = DYNAMIC_WEIGHT_MAP.get(regime, 0.0) - etf_target_weight = 1.0 - div_target_weight + etf_target_weight, div_target_weight = _dynamic_leg_weights(combo, regime) regime_history.append({"date": as_of, "regime": regime, "div_weight": div_target_weight}) diff --git a/tests/test_hk_equity_combo_backtest.py b/tests/test_hk_equity_combo_backtest.py new file mode 100644 index 0000000..e6e16a4 --- /dev/null +++ b/tests/test_hk_equity_combo_backtest.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pandas as pd +import pytest + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts" +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from research_hk_equity_combo_backtest import ( # noqa: E402 + ComboConfig, + _breadth_regime, + _dynamic_leg_weights, +) + + +def _close_frame(rate: float, *, periods: int = 260) -> pd.DataFrame: + dates = pd.bdate_range("2024-01-02", periods=periods) + data = {} + for idx, symbol in enumerate(("02800", "03110", "03188", "03033")): + start = 20.0 + idx + data[symbol] = [start * (rate**step) for step in range(periods)] + return pd.DataFrame(data, index=dates) + + +def test_breadth_regime_uses_enough_history_for_risk_on(): + close = _close_frame(1.002) + + assert _breadth_regime(close, close.index[-1]) == "risk_on" + + +def test_breadth_regime_detects_hard_defense(): + close = _close_frame(0.998) + + assert _breadth_regime(close, close.index[-1]) == "hard_defense" + + +def test_dynamic_leg_weights_match_production_combo_semantics(): + combo = ComboConfig(etf_weight=0.60, dividend_weight=0.40) + + assert _dynamic_leg_weights(combo, "risk_on") == pytest.approx((0.60, 0.40)) + assert _dynamic_leg_weights(combo, "soft_defense") == pytest.approx((0.51, 0.49)) + assert _dynamic_leg_weights(combo, "hard_defense") == pytest.approx((0.30, 0.70))