diff --git a/scripts/research_hk_global_etf_tactical_rotation_backtest.py b/scripts/research_hk_global_etf_tactical_rotation_backtest.py index 21ef65d..762ccdc 100755 --- a/scripts/research_hk_global_etf_tactical_rotation_backtest.py +++ b/scripts/research_hk_global_etf_tactical_rotation_backtest.py @@ -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.", @@ -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( @@ -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) :] @@ -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) diff --git a/scripts/research_hk_proxy_orchestrator_backtest.py b/scripts/research_hk_proxy_orchestrator_backtest.py index dc9bf05..dd83db2 100644 --- a/scripts/research_hk_proxy_orchestrator_backtest.py +++ b/scripts/research_hk_proxy_orchestrator_backtest.py @@ -22,6 +22,9 @@ 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() @@ -29,10 +32,23 @@ def main() -> int: 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 = { diff --git a/scripts/run_walk_forward_backtest.py b/scripts/run_walk_forward_backtest.py index 52edbf9..00f7136 100644 --- a/scripts/run_walk_forward_backtest.py +++ b/scripts/run_walk_forward_backtest.py @@ -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 @@ -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) @@ -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: diff --git a/src/hk_equity_strategies/backtest/orchestrator_research.py b/src/hk_equity_strategies/backtest/orchestrator_research.py new file mode 100644 index 0000000..dc5b04f --- /dev/null +++ b/src/hk_equity_strategies/backtest/orchestrator_research.py @@ -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"] diff --git a/src/hk_equity_strategies/backtest/yfinance_market_data.py b/src/hk_equity_strategies/backtest/yfinance_market_data.py new file mode 100644 index 0000000..54a8ff4 --- /dev/null +++ b/src/hk_equity_strategies/backtest/yfinance_market_data.py @@ -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", +] diff --git a/tests/test_yfinance_orchestrator_research.py b/tests/test_yfinance_orchestrator_research.py new file mode 100644 index 0000000..89c8462 --- /dev/null +++ b/tests/test_yfinance_orchestrator_research.py @@ -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()