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
74 changes: 33 additions & 41 deletions scripts/research_hk_global_etf_tactical_rotation_backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,14 @@

import pandas as pd


YAHOO_SYMBOLS = {
"02800": "2800.HK", # Tracker Fund of Hong Kong, HSI exposure
"02822": "2822.HK", # CSOP FTSE China A50 ETF
"03188": "3188.HK", # ChinaAMC CSI 300 ETF
"03033": "3033.HK", # CSOP Hang Seng TECH Index ETF
"02834": "2834.HK", # iShares NASDAQ 100 ETF
"02840": "2840.HK", # SPDR Gold Shares
"03175": "3175.HK", # Samsung S&P GSCI Crude Oil ER Futures ETF
"03110": "3110.HK", # Global X Hang Seng High Dividend Yield ETF
}

ETF_DESCRIPTIONS = {
"02800": "Tracker Fund of Hong Kong / HSI",
"02822": "CSOP FTSE China A50 ETF",
"03188": "ChinaAMC CSI 300 ETF",
"03033": "CSOP Hang Seng TECH Index ETF",
"02834": "iShares NASDAQ 100 ETF",
"02840": "SPDR Gold Shares",
"03175": "Samsung S&P GSCI Crude Oil ER Futures ETF",
"03110": "Global X Hang Seng High Dividend Yield ETF",
}
from hk_equity_strategies.backtest.orchestrator_research import run_etf_rotation_profile_backtest
from hk_equity_strategies.backtest.yfinance_market_data import (
ETF_DESCRIPTIONS,
YAHOO_SYMBOLS,
download_close_matrix,
download_market_history,
)
from hk_equity_strategies.strategies.hk_global_etf_tactical_rotation import DEFAULT_MIN_HISTORY_DAYS

WATCHLIST_SYMBOLS = {
"03010": "iShares Core MSCI AC Asia ex Japan Index ETF; yfinance adjusted series had a large discontinuity in this run.",
Expand Down Expand Up @@ -70,22 +56,7 @@ class RotationConfig:


def _download_close(config: BacktestConfig) -> pd.DataFrame:
try:
import yfinance as yf
except Exception as exc: # pragma: no cover - research helper only
raise SystemExit("yfinance is required for this research script; install it outside production deps") from exc

raw = yf.download(
list(YAHOO_SYMBOLS.values()),
start=config.start,
end=config.end,
auto_adjust=True,
progress=False,
threads=False,
)
close = raw["Close"].rename(columns={yahoo: symbol for symbol, yahoo in YAHOO_SYMBOLS.items()})
close = close.loc[:, list(YAHOO_SYMBOLS)].ffill().dropna(how="any")
return close
return download_close_matrix(start=config.start, end=config.end)


def _eligible_scores(
Expand Down Expand Up @@ -230,7 +201,23 @@ def _slice(series: pd.Series, start: str | None, end: str | None) -> pd.Series:
return output


def run(config: BacktestConfig, rotation: RotationConfig) -> dict[str, Any]:
def run(config: BacktestConfig, rotation: RotationConfig, *, orchestrator: bool = False) -> dict[str, Any]:
if orchestrator:
market_history = download_market_history(start=config.start, end=config.end)
payload = run_etf_rotation_profile_backtest(
"hk_global_etf_tactical_rotation",
market_history=market_history,
params={"min_history_days": DEFAULT_MIN_HISTORY_DAYS},
)
return {
"config": asdict(config),
"orchestrator": True,
"profile": payload["profile"],
"metrics": payload["metrics"],
"source": payload["source"],
"data_rows": int(len(market_history)),
}

close = _download_close(config)
strategy_returns, targets = _strategy_returns(close, rotation)
strategy_returns = strategy_returns.loc[pd.Timestamp(config.analysis_start) :]
Expand Down Expand Up @@ -282,9 +269,14 @@ def run(config: BacktestConfig, rotation: RotationConfig) -> dict[str, Any]:
def main() -> None:
parser = argparse.ArgumentParser(description="Backtest a HK-listed global ETF rotation research candidate.")
parser.add_argument("--json-output", type=Path)
parser.add_argument(
"--orchestrator",
action="store_true",
help="Run strategy leg via HkEtfRotationBacktestRunner (BacktestOrchestrator path).",
)
args = parser.parse_args()
payload = run(BacktestConfig(), RotationConfig())
text = json.dumps(payload, indent=2, sort_keys=True)
payload = run(BacktestConfig(), RotationConfig(), orchestrator=args.orchestrator)
text = json.dumps(payload, indent=2, sort_keys=True, default=str)
if args.json_output:
args.json_output.write_text(text + "\n")
print(text)
Expand Down
20 changes: 18 additions & 2 deletions scripts/research_hk_proxy_orchestrator_backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,33 @@ def main() -> int:
parser.add_argument("--list-profiles", action="store_true")
parser.add_argument("--mode", choices=("single", "walk_forward"), default="walk_forward")
parser.add_argument("--synthetic-days", type=int, default=700)
parser.add_argument("--use-yfinance", action="store_true")
parser.add_argument("--start", default="2020-08-27")
parser.add_argument("--end", default="2026-06-01")
parser.add_argument("--json-output", type=Path)
args = parser.parse_args()

if args.list_profiles:
print(json.dumps({"profiles": sorted(SUPPORTED_PROFILES)}, indent=2))
return 0

market_history = None
if args.use_yfinance:
from hk_equity_strategies.backtest.yfinance_market_data import download_market_history

market_history = download_market_history(start=args.start, end=args.end)

if args.mode == "walk_forward":
payload = run_walk_forward(profile=args.profile, synthetic_days=args.synthetic_days)
payload = run_walk_forward(
profile=args.profile,
synthetic_days=args.synthetic_days,
market_history=market_history,
)
else:
runner = HkEtfRotationBacktestRunner(synthetic_days=args.synthetic_days)
runner = HkEtfRotationBacktestRunner(
market_history=market_history,
synthetic_days=args.synthetic_days,
)
params = {"min_history_days": 200}
result = runner.run(args.profile, params)
payload = {
Expand Down
16 changes: 15 additions & 1 deletion scripts/run_walk_forward_backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def run_walk_forward(
windows: tuple[tuple[date, date], ...] = DEFAULT_WINDOWS,
synthetic_days: int = 700,
store_root: Path | None = None,
market_history: Any = None,
) -> dict[str, Any]:
from quant_platform_kit.strategy_lifecycle.backtest_orchestrator import BacktestOrchestrator
from quant_platform_kit.strategy_lifecycle.performance_store import PerformanceStore
Expand All @@ -49,7 +50,10 @@ def run_walk_forward(
raise ValueError(f"unsupported profile={profile!r}; supported={sorted(SUPPORTED_PROFILES)}")

params = dict(PROFILE_DEFAULTS.get(profile, {"min_history_days": DEFAULT_MIN_HISTORY_DAYS}))
runner = HkEtfRotationBacktestRunner(synthetic_days=synthetic_days)
runner = HkEtfRotationBacktestRunner(
market_history=market_history,
synthetic_days=synthetic_days,
)
store = PerformanceStore(local_root=store_root or Path("/tmp/hk_equity_wf_store"))
orchestrator = BacktestOrchestrator(store=store)
orchestrator.register_runner("hk_equity", runner)
Expand Down Expand Up @@ -78,16 +82,26 @@ def main() -> int:
parser.add_argument("--json-output", type=Path)
parser.add_argument("--synthetic-days", type=int, default=700)
parser.add_argument("--store-root", type=Path)
parser.add_argument("--use-yfinance", action="store_true", help="Load live ETF history via yfinance.")
parser.add_argument("--start", default="2020-08-27", help="yfinance start date (YYYY-MM-DD).")
parser.add_argument("--end", default="2026-06-01", help="yfinance end date (YYYY-MM-DD).")
args = parser.parse_args()

if args.list_profiles:
print(json.dumps({"profiles": sorted(SUPPORTED_PROFILES)}, indent=2))
return 0

market_history = None
if args.use_yfinance:
from hk_equity_strategies.backtest.yfinance_market_data import download_market_history

market_history = download_market_history(start=args.start, end=args.end)

payload = run_walk_forward(
profile=args.profile,
synthetic_days=args.synthetic_days,
store_root=args.store_root,
market_history=market_history,
)
text = json.dumps(payload, indent=2, sort_keys=True, default=str)
if args.json_output:
Expand Down
60 changes: 60 additions & 0 deletions src/hk_equity_strategies/backtest/orchestrator_research.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Shared helpers for HK research scripts calling BacktestOrchestrator adapters."""

from __future__ import annotations

from datetime import date
from typing import Any, Mapping

import pandas as pd

from hk_equity_strategies.backtest.orchestrator_runner import HkEtfRotationBacktestRunner
from hk_equity_strategies.strategies.hk_global_etf_tactical_rotation import (
DEFAULT_MIN_HISTORY_DAYS,
PROFILE_NAME,
)


def _result_to_metrics(result: Any) -> dict[str, Any]:
return {
"sharpe_ratio": result.sharpe_ratio,
"max_drawdown": result.max_drawdown,
"annual_return": result.cagr,
"total_return": result.total_return,
"annual_volatility": result.volatility,
"days": result.observation_count,
}


def run_etf_rotation_profile_backtest(
profile: str,
*,
market_history: pd.DataFrame | None = None,
synthetic_days: int = 700,
start_date: date | None = None,
end_date: date | None = None,
params: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Run a single-window HK ETF rotation backtest through HkEtfRotationBacktestRunner."""
if profile != PROFILE_NAME:
raise ValueError(f"unsupported profile={profile!r}")

runner = HkEtfRotationBacktestRunner(
market_history=market_history,
synthetic_days=synthetic_days,
)
merged_params = {"min_history_days": DEFAULT_MIN_HISTORY_DAYS}
if params:
merged_params.update(dict(params))
result = runner.run(profile, merged_params, start_date=start_date, end_date=end_date)
return {
"profile": profile,
"params": merged_params,
"start_date": result.start_date.isoformat() if result.start_date else None,
"end_date": result.end_date.isoformat() if result.end_date else None,
"metrics": _result_to_metrics(result),
"source": "HkEtfRotationBacktestRunner",
"run_id": getattr(result, "run_id", None),
}


__all__ = ["run_etf_rotation_profile_backtest"]
88 changes: 88 additions & 0 deletions src/hk_equity_strategies/backtest/yfinance_market_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Yahoo Finance market history helpers for HK ETF research backtests."""

from __future__ import annotations

from typing import Mapping

import pandas as pd

from hk_equity_strategies.strategies.hk_global_etf_tactical_rotation import DEFAULT_UNIVERSE_SYMBOLS

YAHOO_SYMBOLS: Mapping[str, str] = {
"02800": "2800.HK",
"02822": "2822.HK",
"03188": "3188.HK",
"03033": "3033.HK",
"02834": "2834.HK",
"02840": "2840.HK",
"03175": "3175.HK",
"03110": "3110.HK",
}

ETF_DESCRIPTIONS: Mapping[str, str] = {
"02800": "Tracker Fund of Hong Kong / HSI",
"02822": "CSOP FTSE China A50 ETF",
"03188": "ChinaAMC CSI 300 ETF",
"03033": "CSOP Hang Seng TECH Index ETF",
"02834": "iShares NASDAQ 100 ETF",
"02840": "SPDR Gold Shares",
"03175": "Samsung S&P GSCI Crude Oil ER Futures ETF",
"03110": "Global X Hang Seng High Dividend Yield ETF",
}


def download_close_matrix(
*,
start: str,
end: str,
symbols: tuple[str, ...] | None = None,
) -> pd.DataFrame:
"""Download adjusted close prices (wide matrix, HK symbol columns)."""
try:
import yfinance as yf
except ImportError as exc: # pragma: no cover - research helper only
raise RuntimeError(
"yfinance is required for live HK ETF backtests; pip install yfinance"
) from exc

universe = tuple(symbols or DEFAULT_UNIVERSE_SYMBOLS)
tickers = [YAHOO_SYMBOLS[symbol] for symbol in universe]
raw = yf.download(
tickers,
start=start,
end=end,
auto_adjust=True,
progress=False,
threads=False,
)
close = raw["Close"]
if isinstance(close, pd.Series):
close = close.to_frame()
close = close.rename(columns={yahoo: symbol for symbol, yahoo in YAHOO_SYMBOLS.items()})
ordered = [symbol for symbol in universe if symbol in close.columns]
close = close.loc[:, ordered].ffill().dropna(how="any")
return close


def download_market_history(
*,
start: str,
end: str,
symbols: tuple[str, ...] | None = None,
) -> pd.DataFrame:
"""Return long-format market history for orchestrator runners."""
close = download_close_matrix(start=start, end=end, symbols=symbols)
rows: list[dict[str, object]] = []
for day, values in close.iterrows():
day_norm = pd.Timestamp(day).tz_localize(None).normalize()
for symbol, price in values.items():
rows.append({"date": day_norm, "symbol": str(symbol), "close": float(price)})
return pd.DataFrame(rows).sort_values(["date", "symbol"]).reset_index(drop=True)


__all__ = [
"ETF_DESCRIPTIONS",
"YAHOO_SYMBOLS",
"download_close_matrix",
"download_market_history",
]
52 changes: 52 additions & 0 deletions tests/test_yfinance_orchestrator_research.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from __future__ import annotations

import unittest
from unittest.mock import patch

import pandas as pd

from hk_equity_strategies.backtest.orchestrator_research import run_etf_rotation_profile_backtest
from hk_equity_strategies.backtest.yfinance_market_data import download_market_history
from hk_equity_strategies.strategies.hk_global_etf_tactical_rotation import (
DEFAULT_MIN_HISTORY_DAYS,
DEFAULT_UNIVERSE_SYMBOLS,
PROFILE_NAME,
)


class YfinanceMarketDataTests(unittest.TestCase):
def test_download_market_history_returns_long_format(self) -> None:
index = pd.bdate_range("2023-01-03", periods=300)
wide = pd.DataFrame(
{symbol: float(idx) for idx, symbol in enumerate(("02800", "02822"))},
index=index,
)
with patch(
"hk_equity_strategies.backtest.yfinance_market_data.download_close_matrix",
return_value=wide,
):
history = download_market_history(start="2023-01-01", end="2024-01-01", symbols=("02800", "02822"))
self.assertEqual({"date", "symbol", "close"}, set(history.columns))
self.assertGreater(len(history), 0)
self.assertTrue(history["symbol"].isin(["02800", "02822"]).all())


class OrchestratorResearchTests(unittest.TestCase):
def test_run_etf_rotation_profile_backtest_with_fixture_history(self) -> None:
rows = []
for day in pd.bdate_range("2022-01-03", periods=400):
for symbol in DEFAULT_UNIVERSE_SYMBOLS:
rows.append({"date": day, "symbol": symbol, "close": 10.0 + hash(symbol) % 5})
history = pd.DataFrame(rows)
payload = run_etf_rotation_profile_backtest(
PROFILE_NAME,
market_history=history,
params={"min_history_days": DEFAULT_MIN_HISTORY_DAYS},
)
self.assertEqual(payload["profile"], PROFILE_NAME)
self.assertEqual(payload["source"], "HkEtfRotationBacktestRunner")
self.assertGreater(payload["metrics"]["days"], 0)


if __name__ == "__main__":
unittest.main()
Loading