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
28 changes: 16 additions & 12 deletions scripts/research_hk_equity_combo_backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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()
Expand All @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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})

Expand Down
46 changes: 46 additions & 0 deletions tests/test_hk_equity_combo_backtest.py
Original file line number Diff line number Diff line change
@@ -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))
Loading