|
| 1 | +"""BacktestRunner adapter for crypto live pool rotation orchestrator integration.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +from datetime import date, datetime, timezone |
| 6 | +from typing import Any, Mapping |
| 7 | + |
| 8 | +import numpy as np |
| 9 | +import pandas as pd |
| 10 | + |
| 11 | +from src.backtest import run_single_backtest |
| 12 | + |
| 13 | +try: |
| 14 | + from quant_platform_kit.strategy_lifecycle.contracts import BacktestResult as QpkBacktestResult |
| 15 | +except ImportError: # pragma: no cover |
| 16 | + QpkBacktestResult = None # type: ignore[misc, assignment] |
| 17 | + |
| 18 | + |
| 19 | +PROFILE_NAME = "crypto_live_pool_rotation" |
| 20 | +DEFAULT_MIN_HISTORY_DAYS = 120 |
| 21 | +SUPPORTED_PROFILES = frozenset({PROFILE_NAME}) |
| 22 | + |
| 23 | +DEFAULT_BACKTEST_CONFIG: dict[str, Any] = { |
| 24 | + "strategy": { |
| 25 | + "rebalance_frequency": "weekly", |
| 26 | + "top_n": 2, |
| 27 | + "weighting": "equal", |
| 28 | + "signal_lag_days": 1, |
| 29 | + "fee_bps": 10, |
| 30 | + "slippage_bps": 5, |
| 31 | + } |
| 32 | +} |
| 33 | + |
| 34 | + |
| 35 | +def _synthetic_panel(*, days: int = 1500, symbols: tuple[str, ...] = ("BTCUSDT", "ETHUSDT", "SOLUSDT")) -> pd.DataFrame: |
| 36 | + dates = pd.date_range("2020-01-01", periods=days, freq="D") |
| 37 | + index = pd.MultiIndex.from_product([dates, symbols], names=["date", "symbol"]) |
| 38 | + panel = pd.DataFrame(index=index) |
| 39 | + panel["in_universe"] = True |
| 40 | + rng = np.random.default_rng(42) |
| 41 | + rows: list[float] = [] |
| 42 | + for symbol in symbols: |
| 43 | + price = 100.0 + hash(symbol) % 50 |
| 44 | + for _ in dates: |
| 45 | + price *= 1.0 + float(rng.normal(0.001, 0.02)) |
| 46 | + rows.append(price) |
| 47 | + panel["open"] = rows |
| 48 | + scores: list[float] = [] |
| 49 | + for day_idx, _day in enumerate(dates): |
| 50 | + for sym_idx, symbol in enumerate(symbols): |
| 51 | + scores.append(float((day_idx + sym_idx * 17 + hash(symbol) % 11) % 100) / 100.0) |
| 52 | + panel["final_score"] = scores |
| 53 | + return panel.sort_index() |
| 54 | + |
| 55 | + |
| 56 | +def _slice_panel(panel: pd.DataFrame, *, start_date: date | None, end_date: date | None) -> pd.DataFrame: |
| 57 | + frame = panel |
| 58 | + level_dates = frame.index.get_level_values("date") |
| 59 | + if start_date is not None: |
| 60 | + frame = frame.loc[level_dates >= pd.Timestamp(start_date)] |
| 61 | + level_dates = frame.index.get_level_values("date") |
| 62 | + if end_date is not None: |
| 63 | + frame = frame.loc[level_dates <= pd.Timestamp(end_date)] |
| 64 | + return frame.sort_index() |
| 65 | + |
| 66 | + |
| 67 | +def _metrics_to_qpk_result( |
| 68 | + *, |
| 69 | + strategy_profile: str, |
| 70 | + params: Mapping[str, Any], |
| 71 | + metrics: Mapping[str, Any], |
| 72 | + start_date: date | None, |
| 73 | + end_date: date | None, |
| 74 | + run_duration_seconds: float, |
| 75 | +) -> Any: |
| 76 | + if QpkBacktestResult is None: |
| 77 | + raise ImportError("quant_platform_kit is required to build BacktestResult") |
| 78 | + cagr = float(metrics.get("CAGR") or 0.0) |
| 79 | + max_drawdown = float(metrics.get("Max Drawdown") or 0.0) |
| 80 | + calmar = abs(cagr / max_drawdown) if max_drawdown else None |
| 81 | + return QpkBacktestResult( |
| 82 | + strategy_profile=strategy_profile, |
| 83 | + domain="crypto", |
| 84 | + param_set_id="", |
| 85 | + params=dict(params), |
| 86 | + sharpe_ratio=float(metrics.get("Sharpe") or 0.0), |
| 87 | + calmar_ratio=calmar, |
| 88 | + max_drawdown=max_drawdown, |
| 89 | + cagr=cagr, |
| 90 | + volatility=float(metrics.get("Annualized Volatility") or 0.0), |
| 91 | + win_rate=float(metrics.get("Win Rate") or 0.0), |
| 92 | + start_date=start_date, |
| 93 | + end_date=end_date, |
| 94 | + observation_count=int(metrics.get("Trading Days") or metrics.get("days") or 0), |
| 95 | + source_script="CryptoLivePoolPipelines.strategy_lifecycle.orchestrator_runner", |
| 96 | + computed_at=datetime.now(timezone.utc).isoformat(), |
| 97 | + run_duration_seconds=run_duration_seconds, |
| 98 | + ) |
| 99 | + |
| 100 | + |
| 101 | +class CryptoLivePoolBacktestRunner: |
| 102 | + """Protocol-compatible BacktestRunner for crypto live pool rotation.""" |
| 103 | + |
| 104 | + def __init__(self, *, panel: pd.DataFrame | None = None, synthetic_days: int = 1600) -> None: |
| 105 | + self._panel = panel |
| 106 | + self._synthetic_days = int(synthetic_days) |
| 107 | + |
| 108 | + def run( |
| 109 | + self, |
| 110 | + strategy_profile: str, |
| 111 | + params: Mapping[str, Any], |
| 112 | + start_date: date | None = None, |
| 113 | + end_date: date | None = None, |
| 114 | + ) -> Any: |
| 115 | + if strategy_profile not in SUPPORTED_PROFILES: |
| 116 | + raise ValueError( |
| 117 | + f"Unsupported strategy_profile={strategy_profile!r}; " |
| 118 | + f"supported={sorted(SUPPORTED_PROFILES)}" |
| 119 | + ) |
| 120 | + |
| 121 | + panel = self._panel |
| 122 | + if panel is None: |
| 123 | + panel = _synthetic_panel(days=max(self._synthetic_days, DEFAULT_MIN_HISTORY_DAYS + 60)) |
| 124 | + sliced = _slice_panel(panel, start_date=start_date, end_date=end_date) |
| 125 | + if sliced.empty: |
| 126 | + raise ValueError("No panel rows for requested window") |
| 127 | + |
| 128 | + started = datetime.now(timezone.utc) |
| 129 | + result = run_single_backtest(sliced, "final_score", DEFAULT_BACKTEST_CONFIG) |
| 130 | + elapsed = (datetime.now(timezone.utc) - started).total_seconds() |
| 131 | + eval_dates = sliced.index.get_level_values("date") |
| 132 | + metrics = dict(result.metrics) |
| 133 | + metrics["days"] = int(len(result.returns.dropna())) |
| 134 | + return _metrics_to_qpk_result( |
| 135 | + strategy_profile=strategy_profile, |
| 136 | + params=params, |
| 137 | + metrics=result.metrics, |
| 138 | + start_date=start_date or eval_dates.min().date(), |
| 139 | + end_date=end_date or eval_dates.max().date(), |
| 140 | + run_duration_seconds=elapsed, |
| 141 | + ) |
| 142 | + |
| 143 | + |
| 144 | +__all__ = ["PROFILE_NAME", "SUPPORTED_PROFILES", "CryptoLivePoolBacktestRunner"] |
0 commit comments