Skip to content

Commit bc85c08

Browse files
Pigbibiclaudecursoragent
authored
feat(backtest): crypto live pool walk_forward pilot (3c) (#120)
* feat(backtest): crypto live pool BacktestOrchestrator walk_forward pilot Wire run_single_backtest into QPK BacktestRunner for crypto_live_pool_rotation with synthetic panel pilot script and orchestrator tests (roadmap 3c). Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): align QuantPlatformKit pin for crypto walk-forward Update quant-platform-kit dependency to commit that provides BacktestOrchestrator.walk_forward used by the 3c orchestrator pilot tests. Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): update requirements pins for QuantPlatformKit walk_forward CI was installing from requirements*.txt, so pyproject pin alone wasn't sufficient. Align both files to the QuantPlatformKit commit with BacktestOrchestrator.walk_forward(). Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): align QPK_DEPENDENCY constant with requirements pins Monthly publish config tests enforce that requirements lock in the expected QuantPlatformKit commit. Update expectation to match the roadmap 3c/5a walk-forward compatible QPK pin. Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8c654a7 commit bc85c08

7 files changed

Lines changed: 280 additions & 4 deletions

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ description = "Live-pool rotation pipelines for crypto strategy runtime compatib
99
readme = "README.md"
1010
requires-python = ">=3.11"
1111
dependencies = [
12-
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@37c81901160c5b31127a27dba1c63944933fb6bf",
12+
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@0c69df08144872ccd1d8bf523738e80748d8d664",
1313
"pandas==3.0.3",
1414
"numpy==2.4.6",
1515
"requests==2.34.2",

requirements-lock.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@37c81901160c5b31127a27dba1c63944933fb6bf
1+
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@0c69df08144872ccd1d8bf523738e80748d8d664
22
pandas==3.0.3
33
numpy==2.4.6
44
requests==2.34.2

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@37c81901160c5b31127a27dba1c63944933fb6bf
1+
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@0c69df08144872ccd1d8bf523738e80748d8d664
22
pandas>=3.0.3
33
numpy>=2.4.6,<2.5
44
requests>=2.34.2
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
#!/usr/bin/env python3
2+
"""Pilot: run crypto_live_pool_rotation through BacktestOrchestrator.walk_forward()."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import json
8+
import sys
9+
from datetime import date
10+
from pathlib import Path
11+
12+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
13+
if str(PROJECT_ROOT) not in sys.path:
14+
sys.path.insert(0, str(PROJECT_ROOT))
15+
16+
from src.strategy_lifecycle.orchestrator_runner import ( # noqa: E402
17+
CryptoLivePoolBacktestRunner,
18+
PROFILE_NAME,
19+
)
20+
21+
DEFAULT_WINDOWS: tuple[tuple[date, date], ...] = (
22+
(date(2023, 6, 1), date(2024, 5, 31)),
23+
(date(2024, 6, 1), date(2025, 5, 31)),
24+
)
25+
26+
27+
def main() -> int:
28+
parser = argparse.ArgumentParser(description="Crypto live pool walk-forward pilot")
29+
parser.add_argument("--output", type=Path, default=Path("crypto_live_pool_walk_forward_pilot.json"))
30+
parser.add_argument("--synthetic-days", type=int, default=400)
31+
args = parser.parse_args()
32+
33+
from quant_platform_kit.strategy_lifecycle.backtest_orchestrator import BacktestOrchestrator
34+
from quant_platform_kit.strategy_lifecycle.performance_store import PerformanceStore
35+
36+
runner = CryptoLivePoolBacktestRunner(synthetic_days=args.synthetic_days)
37+
params: dict[str, object] = {}
38+
store = PerformanceStore(local_root=args.output.parent / ".wf_store")
39+
orchestrator = BacktestOrchestrator(store=store)
40+
orchestrator.register_runner("crypto", runner)
41+
42+
baseline = runner.run(PROFILE_NAME, params)
43+
results = orchestrator.walk_forward(
44+
PROFILE_NAME,
45+
domain="crypto",
46+
params=params,
47+
windows=DEFAULT_WINDOWS,
48+
param_set_id="crypto_live_pool_wf_pilot",
49+
)
50+
payload = {
51+
"profile": PROFILE_NAME,
52+
"baseline": {
53+
"sharpe_ratio": baseline.sharpe_ratio,
54+
"max_drawdown": baseline.max_drawdown,
55+
"cagr": baseline.cagr,
56+
},
57+
"windows": [
58+
{
59+
"start": item.start_date.isoformat() if item.start_date else None,
60+
"end": item.end_date.isoformat() if item.end_date else None,
61+
"sharpe_ratio": item.sharpe_ratio,
62+
"max_drawdown": item.max_drawdown,
63+
"cagr": item.cagr,
64+
}
65+
for item in results
66+
],
67+
}
68+
args.output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
69+
print(json.dumps(payload, ensure_ascii=False, indent=2))
70+
return 0
71+
72+
73+
if __name__ == "__main__":
74+
raise SystemExit(main())
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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"]

tests/test_monthly_publish_workflow_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
README_ZH_PATH = PROJECT_ROOT / "README.zh-CN.md"
1010
QPK_DEPENDENCY = (
1111
"quant-platform-kit @ "
12-
"git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@37c81901160c5b31127a27dba1c63944933fb6bf"
12+
"git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@0c69df08144872ccd1d8bf523738e80748d8d664"
1313
)
1414

1515

tests/test_orchestrator_runner.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
import tempfile
5+
import unittest
6+
from datetime import date
7+
from pathlib import Path
8+
9+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
10+
if str(PROJECT_ROOT) not in sys.path:
11+
sys.path.insert(0, str(PROJECT_ROOT))
12+
13+
from src.strategy_lifecycle.orchestrator_runner import ( # noqa: E402
14+
PROFILE_NAME,
15+
SUPPORTED_PROFILES,
16+
CryptoLivePoolBacktestRunner,
17+
)
18+
19+
20+
class CryptoOrchestratorRunnerTests(unittest.TestCase):
21+
def test_supported_profile(self) -> None:
22+
self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES)
23+
24+
def test_run_returns_backtest_result(self) -> None:
25+
runner = CryptoLivePoolBacktestRunner(synthetic_days=1600)
26+
result = runner.run(
27+
PROFILE_NAME,
28+
{},
29+
start_date=date(2023, 6, 1),
30+
end_date=date(2024, 3, 1),
31+
)
32+
self.assertEqual(result.strategy_profile, PROFILE_NAME)
33+
self.assertEqual(result.domain, "crypto")
34+
self.assertIsNotNone(result.sharpe_ratio)
35+
36+
def test_walk_forward_produces_one_result_per_window(self) -> None:
37+
from quant_platform_kit.strategy_lifecycle.backtest_orchestrator import BacktestOrchestrator
38+
from quant_platform_kit.strategy_lifecycle.performance_store import PerformanceStore
39+
40+
with tempfile.TemporaryDirectory() as tmp:
41+
store = PerformanceStore(local_root=Path(tmp))
42+
orchestrator = BacktestOrchestrator(store=store)
43+
orchestrator.register_runner("crypto", CryptoLivePoolBacktestRunner(synthetic_days=1600))
44+
windows = (
45+
(date(2023, 6, 1), date(2023, 12, 31)),
46+
(date(2024, 1, 1), date(2024, 6, 30)),
47+
)
48+
results = orchestrator.walk_forward(
49+
PROFILE_NAME,
50+
domain="crypto",
51+
params={},
52+
windows=windows,
53+
)
54+
self.assertEqual(len(results), 2)
55+
56+
57+
if __name__ == "__main__":
58+
unittest.main()

0 commit comments

Comments
 (0)