Skip to content

Commit ad072d8

Browse files
Pigbibiclaudecursoragent
committed
feat(backtest): add crypto_equity_combo orchestrator migration (wave 3)
Wire CryptoEquityComboBacktestRunner, combo simulator, walk-forward CLI, and research script --orchestrator path following HK equity combo pattern. Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7b75dd1 commit ad072d8

8 files changed

Lines changed: 592 additions & 12 deletions

scripts/research_crypto_combo_backtest.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,37 @@ def load_crypto_data() -> pd.DataFrame:
207207

208208
def run_backtest(
209209
prices: pd.DataFrame,
210+
*,
211+
orchestrator: bool = False,
210212
) -> dict[str, dict[str, Any]]:
211213
"""Run the three-strategy backtest.
212214
213215
Returns nested dict keyed by strategy name, each containing an equity
214216
curve DataFrame and per-period metrics.
215217
"""
218+
if orchestrator:
219+
from crypto_strategies.backtest.orchestrator_research import run_combo_profile_backtest
220+
from crypto_strategies.strategies.crypto_equity_combo import PROFILE_NAME
221+
222+
rows = []
223+
for day in prices.index:
224+
rows.append({"date": day, "symbol": "BTCUSDT", "close": float(prices.loc[day, "btc_close"])})
225+
rows.append({"date": day, "symbol": "ETHUSDT", "close": float(prices.loc[day, "eth_close"])})
226+
market_history = pd.DataFrame(rows)
227+
payload = run_combo_profile_backtest(
228+
PROFILE_NAME,
229+
market_history=market_history,
230+
params={"combo_mode": "dynamic"},
231+
)
232+
return {
233+
"orchestrator": {
234+
"equity": pd.Series(dtype=float),
235+
"metrics": payload["metrics"],
236+
"profile": payload["profile"],
237+
"source": payload["source"],
238+
}
239+
}
240+
216241
btc_close = prices["btc_close"].dropna()
217242
eth_close = prices["eth_close"].dropna()
218243

@@ -475,6 +500,11 @@ def main() -> None:
475500
action="store_true",
476501
help="Output results as JSON to stdout",
477502
)
503+
parser.add_argument(
504+
"--orchestrator",
505+
action="store_true",
506+
help="Thin path via CryptoEquityComboBacktestRunner (single dynamic combo window).",
507+
)
478508
args = parser.parse_args()
479509

480510
print("Loading crypto price data via yfinance ...", file=sys.stderr)
@@ -485,9 +515,23 @@ def main() -> None:
485515
)
486516

487517
print("Running backtest simulation ...", file=sys.stderr)
488-
results = run_backtest(prices)
518+
results = run_backtest(prices, orchestrator=args.orchestrator)
489519
print(" Done.", file=sys.stderr)
490520

521+
if args.orchestrator:
522+
payload = results["orchestrator"]
523+
text = json.dumps(
524+
{
525+
"profile": payload["profile"],
526+
"metrics": payload["metrics"],
527+
"source": payload["source"],
528+
"orchestrator": True,
529+
},
530+
indent=2,
531+
)
532+
print(text)
533+
return
534+
491535
if args.json_output:
492536
# Strip equity curves for JSON output (too large)
493537
json_results: dict[str, Any] = {}

scripts/research_crypto_proxy_orchestrator_backtest.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@
1414
sys.path.insert(0, str(SRC))
1515

1616
from crypto_strategies.backtest.orchestrator_runner import ( # noqa: E402
17+
COMBO_DEFAULT_MIN_HISTORY_DAYS,
1718
DEFAULT_MIN_HISTORY_DAYS,
1819
PROFILE_NAME,
1920
SUPPORTED_PROFILES,
20-
CryptoLivePoolBacktestRunner,
21+
build_backtest_runner,
2122
)
23+
from crypto_strategies.strategies.crypto_equity_combo import PROFILE_NAME as CRYPTO_EQUITY_COMBO_PROFILE
2224
from scripts.run_walk_forward_backtest import run_walk_forward # noqa: E402
2325

2426

@@ -38,8 +40,11 @@ def main() -> int:
3840
if args.mode == "walk_forward":
3941
payload = run_walk_forward(profile=args.profile, synthetic_days=args.synthetic_days)
4042
else:
41-
runner = CryptoLivePoolBacktestRunner(synthetic_days=args.synthetic_days)
42-
params = {"min_history_days": DEFAULT_MIN_HISTORY_DAYS, "top_n": 2, "rebalance_every": 7}
43+
runner = build_backtest_runner(args.profile, synthetic_days=args.synthetic_days)
44+
if args.profile == CRYPTO_EQUITY_COMBO_PROFILE:
45+
params = {"min_history_days": COMBO_DEFAULT_MIN_HISTORY_DAYS, "combo_mode": "dynamic"}
46+
else:
47+
params = {"min_history_days": DEFAULT_MIN_HISTORY_DAYS, "top_n": 2, "rebalance_every": 7}
4348
result = runner.run(args.profile, params)
4449
payload = {
4550
"profile": args.profile,
@@ -48,7 +53,7 @@ def main() -> int:
4853
"max_drawdown": result.max_drawdown,
4954
"cagr": result.cagr,
5055
},
51-
"source": "CryptoLivePoolBacktestRunner",
56+
"source": type(runner).__name__,
5257
}
5358

5459
text = json.dumps(payload, indent=2, sort_keys=True, default=str)

scripts/run_walk_forward_backtest.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010
from typing import Any
1111

1212
from crypto_strategies.backtest.orchestrator_runner import (
13+
COMBO_DEFAULT_MIN_HISTORY_DAYS,
1314
DEFAULT_MIN_HISTORY_DAYS,
1415
PROFILE_NAME,
1516
SUPPORTED_PROFILES,
16-
CryptoLivePoolBacktestRunner,
17+
build_backtest_runner,
1718
)
19+
from crypto_strategies.strategies.crypto_equity_combo import PROFILE_NAME as CRYPTO_EQUITY_COMBO_PROFILE
1820

1921
DEFAULT_WINDOWS: tuple[tuple[date, date], ...] = (
2022
(date(2023, 6, 1), date(2024, 5, 31)),
@@ -23,6 +25,10 @@
2325

2426
PROFILE_DEFAULTS: dict[str, dict[str, Any]] = {
2527
PROFILE_NAME: {"min_history_days": DEFAULT_MIN_HISTORY_DAYS, "top_n": 2, "rebalance_every": 7},
28+
CRYPTO_EQUITY_COMBO_PROFILE: {
29+
"min_history_days": COMBO_DEFAULT_MIN_HISTORY_DAYS,
30+
"combo_mode": "dynamic",
31+
},
2632
}
2733

2834

@@ -45,6 +51,8 @@ def run_walk_forward(
4551
windows: tuple[tuple[date, date], ...] = DEFAULT_WINDOWS,
4652
synthetic_days: int = 1600,
4753
store_root: Path | None = None,
54+
panel: Any = None,
55+
market_history: Any = None,
4856
) -> dict[str, Any]:
4957
from quant_platform_kit.strategy_lifecycle.backtest_orchestrator import BacktestOrchestrator
5058
from quant_platform_kit.strategy_lifecycle.performance_store import PerformanceStore
@@ -53,7 +61,12 @@ def run_walk_forward(
5361
raise ValueError(f"unsupported profile={profile!r}; supported={sorted(SUPPORTED_PROFILES)}")
5462

5563
params = dict(PROFILE_DEFAULTS.get(profile, {"min_history_days": DEFAULT_MIN_HISTORY_DAYS}))
56-
runner = CryptoLivePoolBacktestRunner(synthetic_days=synthetic_days)
64+
runner = build_backtest_runner(
65+
profile,
66+
panel=panel,
67+
market_history=market_history,
68+
synthetic_days=synthetic_days,
69+
)
5770
store = PerformanceStore(local_root=store_root or Path("/tmp/crypto_wf_store"))
5871
orchestrator = BacktestOrchestrator(store=store)
5972
orchestrator.register_runner("crypto", runner)
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""Simplified crypto equity combo backtest for orchestrator integration."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from typing import Any, Literal
7+
8+
import numpy as np
9+
import pandas as pd
10+
11+
from crypto_strategies.backtest.live_pool_simulator import LivePoolBacktestResult, _performance_metrics
12+
from crypto_strategies.strategies.crypto_equity_combo import (
13+
DEFAULT_BTC_WEIGHT,
14+
DEFAULT_TREND_WEIGHT,
15+
DYNAMIC_REGIME_OFF_CUT,
16+
)
17+
18+
ComboMode = Literal["static", "dynamic"]
19+
BTC_SYMBOL = "BTCUSDT"
20+
ETH_SYMBOL = "ETHUSDT"
21+
ALTS = ("ETH", "SOL", "AVAX", "MATIC", "DOT")
22+
VOL_MULTIPLIERS = {"ETH": 1.0, "SOL": 1.8, "AVAX": 2.2, "MATIC": 2.0, "DOT": 1.6}
23+
SMA_SHORT = 20
24+
SMA_LONG = 60
25+
BTC_SMA_REGIME = 200
26+
27+
28+
@dataclass(frozen=True)
29+
class CryptoComboBacktestConfig:
30+
btc_weight: float = DEFAULT_BTC_WEIGHT
31+
trend_weight: float = DEFAULT_TREND_WEIGHT
32+
combo_mode: ComboMode = "dynamic"
33+
min_history_days: int = 260
34+
dca_amount_usd: float = 100.0
35+
dynamic_trend_cut: float = DYNAMIC_REGIME_OFF_CUT
36+
37+
38+
def build_close_matrix(
39+
market_history: pd.DataFrame,
40+
*,
41+
symbols: tuple[str, ...] = (BTC_SYMBOL, ETH_SYMBOL),
42+
) -> pd.DataFrame:
43+
frame = market_history.copy()
44+
frame["date"] = pd.to_datetime(frame["date"], utc=False).dt.tz_localize(None).dt.normalize()
45+
close = (
46+
frame.pivot_table(index="date", columns="symbol", values="close", aggfunc="last")
47+
.sort_index()
48+
.reindex(columns=list(symbols))
49+
)
50+
return close.astype(float)
51+
52+
53+
def _simulate_alt_returns(eth_returns: pd.Series, *, seed: int = 42) -> pd.DataFrame:
54+
rng = np.random.default_rng(seed)
55+
simulated: dict[str, pd.Series] = {}
56+
for alt in ALTS:
57+
mult = VOL_MULTIPLIERS.get(alt, 1.0)
58+
noise = rng.normal(0, 0.005, size=len(eth_returns))
59+
raw = np.clip(eth_returns.values * mult + noise, -0.25, 0.25)
60+
simulated[alt] = pd.Series(raw, index=eth_returns.index)
61+
return pd.DataFrame(simulated)
62+
63+
64+
def _compute_sma(series: pd.Series, window: int) -> pd.Series:
65+
return series.rolling(window=window, min_periods=window).mean()
66+
67+
68+
def _combo_daily_returns(
69+
close: pd.DataFrame,
70+
*,
71+
combo_config: CryptoComboBacktestConfig,
72+
) -> pd.Series:
73+
btc_col = BTC_SYMBOL if BTC_SYMBOL in close.columns else close.columns[0]
74+
eth_col = ETH_SYMBOL if ETH_SYMBOL in close.columns else close.columns[min(1, len(close.columns) - 1)]
75+
76+
btc_close = close[btc_col].dropna()
77+
eth_close = close[eth_col].dropna()
78+
idx = btc_close.index.intersection(eth_close.index).sort_values()
79+
if len(idx) < combo_config.min_history_days:
80+
return pd.Series(dtype=float)
81+
82+
eth_returns = eth_close.pct_change().dropna()
83+
alt_returns = _simulate_alt_returns(eth_returns.reindex(idx).fillna(0.0))
84+
alt_prices: dict[str, pd.Series] = {}
85+
for alt in ALTS:
86+
cum = (1.0 + alt_returns[alt]).cumprod()
87+
start_price = float(eth_close.reindex(cum.index).iloc[0] or 1.0)
88+
alt_prices[alt] = start_price * cum / cum.iloc[0]
89+
90+
btc_sma200 = _compute_sma(btc_close, BTC_SMA_REGIME)
91+
btc_below_sma200 = btc_close < btc_sma200
92+
93+
alt_dfs: dict[str, pd.DataFrame] = {}
94+
for alt in ALTS:
95+
ap = alt_prices[alt].reindex(idx)
96+
alt_dfs[alt] = pd.DataFrame(
97+
{
98+
"close": ap,
99+
"sma_short": _compute_sma(ap, SMA_SHORT),
100+
"sma_long": _compute_sma(ap, SMA_LONG),
101+
},
102+
index=idx,
103+
)
104+
105+
portfolio_values: list[float] = []
106+
alt_positions: dict[str, float] = {}
107+
btc_units = 0.0
108+
cash_held = 0.0
109+
dynamic = combo_config.combo_mode == "dynamic"
110+
111+
for date in idx:
112+
btc_p = float(btc_close.loc[date])
113+
trend_weight = combo_config.trend_weight
114+
extra_btc_alloc = 0.0
115+
if dynamic and bool(btc_below_sma200.loc[date]):
116+
trend_weight *= 1.0 - combo_config.dynamic_trend_cut
117+
extra_btc_alloc = combo_config.dca_amount_usd * combo_config.trend_weight * combo_config.dynamic_trend_cut
118+
119+
btc_alloc = combo_config.dca_amount_usd * combo_config.btc_weight
120+
trend_alloc = combo_config.dca_amount_usd * trend_weight
121+
btc_units += (btc_alloc + extra_btc_alloc) / btc_p
122+
123+
alt_prices_today: dict[str, float] = {}
124+
alt_candidates: list[str] = []
125+
for alt in ALTS:
126+
row = alt_dfs[alt].loc[date]
127+
alt_price = float(row["close"])
128+
alt_prices_today[alt] = alt_price
129+
short_sma = row["sma_short"]
130+
long_sma = row["sma_long"]
131+
if not np.isnan(short_sma) and not np.isnan(long_sma) and short_sma > long_sma:
132+
alt_candidates.append(alt)
133+
134+
if alt_candidates and trend_alloc > 0:
135+
per_alt = trend_alloc / len(alt_candidates)
136+
for alt in alt_candidates:
137+
alt_positions[alt] = alt_positions.get(alt, 0.0) + per_alt / alt_prices_today[alt]
138+
else:
139+
cash_held += trend_alloc
140+
141+
btc_value = btc_units * btc_p
142+
alt_value = sum(
143+
alt_positions.get(alt, 0.0) * alt_prices_today.get(alt, 0.0)
144+
for alt in ALTS
145+
)
146+
portfolio_values.append(btc_value + alt_value + cash_held)
147+
148+
equity = pd.Series(portfolio_values, index=idx)
149+
return equity.pct_change().fillna(0.0)
150+
151+
152+
def run_combo_backtest(
153+
market_history: pd.DataFrame,
154+
*,
155+
combo_config: CryptoComboBacktestConfig | None = None,
156+
universe_symbols: Any = None,
157+
) -> LivePoolBacktestResult:
158+
combo = combo_config or CryptoComboBacktestConfig()
159+
symbols = tuple(universe_symbols or (BTC_SYMBOL, ETH_SYMBOL))
160+
close = build_close_matrix(market_history, symbols=symbols)
161+
if len(close) < int(combo.min_history_days):
162+
raise ValueError(
163+
f"market_history requires at least {int(combo.min_history_days)} overlapping trading days"
164+
)
165+
returns = _combo_daily_returns(close, combo_config=combo)
166+
return LivePoolBacktestResult(metrics=_performance_metrics(returns), returns=returns)
167+
168+
169+
__all__ = [
170+
"BTC_SYMBOL",
171+
"ComboMode",
172+
"CryptoComboBacktestConfig",
173+
"build_close_matrix",
174+
"run_combo_backtest",
175+
]

0 commit comments

Comments
 (0)