search_eth_regime_router_variants.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3. import argparse
  4. import json
  5. import sys
  6. from dataclasses import dataclass
  7. from itertools import product
  8. from pathlib import Path
  9. import pandas as pd
  10. ROOT = Path(__file__).resolve().parents[1]
  11. sys.path.insert(0, str(ROOT))
  12. from scripts import explore_ultrashort as explore
  13. ETH_SYMBOL = "ETH-USDT-SWAP"
  14. BTC_SYMBOL = "BTC-USDT-SWAP"
  15. PREFIX = "eth-regime-router"
  16. OUTPUT_DIR = Path("reports/eth-exploration")
  17. YEARS = 10.0
  18. BAR = "15m"
  19. PRIMARY_COST = "maker_taker"
  20. COSTS = {
  21. "maker_taker": 0.0021,
  22. "taker_taker": 0.0030,
  23. }
  24. HORIZONS = (
  25. ("all", None),
  26. ("10y", pd.DateOffset(years=10)),
  27. ("3y", pd.DateOffset(years=3)),
  28. ("1y", pd.DateOffset(years=1)),
  29. ("6m", pd.DateOffset(months=6)),
  30. ("3m", pd.DateOffset(months=3)),
  31. )
  32. @dataclass(frozen=True)
  33. class RouterSpec:
  34. name: str
  35. trend_sma: int
  36. vol_lookback: int
  37. corr_lookback: int
  38. ratio_lookback: int
  39. lead_lookback: int
  40. eth_trend_min: float
  41. btc_trend_min: float
  42. max_eth_vol: float
  43. min_corr: float
  44. ratio_z_entry: float
  45. lead_return_min: float
  46. lag_gap_min: float
  47. min_volume_ratio: float
  48. stop_loss_pct: float
  49. take_profit_pct: float
  50. max_hold_bars: int
  51. def load_candles(symbol: str, bar: str, years: float) -> list[explore.Candle]:
  52. candles, _ = explore.load_cached_candles(explore.CANDLE_CACHE_DIR, symbol, bar)
  53. if not candles:
  54. raise FileNotFoundError(f"missing cached candles for {symbol} {bar}")
  55. requested = explore.history_bars_for_years(bar, years)
  56. return candles[-requested:] if len(candles) > requested else candles
  57. def close_position(
  58. *,
  59. trades: list[dict[str, object]],
  60. exits: list[dict[str, object]],
  61. position: dict[str, object],
  62. account_equity: float,
  63. candle: explore.Candle,
  64. exit_price: float,
  65. leverage: int,
  66. ) -> tuple[float, bool]:
  67. margin_used = float(position["margin_used"])
  68. exit_equity = explore.trade_equity(
  69. side="long",
  70. margin_used=margin_used,
  71. entry_price=float(position["entry_price"]),
  72. exit_price=exit_price,
  73. leverage=leverage,
  74. )
  75. pnl = exit_equity - margin_used
  76. trades.append(
  77. {
  78. "side": "Long",
  79. "entry_time": explore._format_ts(int(position["entry_time"])),
  80. "exit_time": explore._format_ts(candle.ts),
  81. "entry_price": round(float(position["entry_price"]), 4),
  82. "exit_price": round(exit_price, 4),
  83. "pnl": round(pnl, 4),
  84. "return_pct": round(pnl / account_equity * 100, 6),
  85. "cost_weight": 1.0,
  86. "regime": str(position["regime"]),
  87. }
  88. )
  89. exits.append({"ts": candle.ts, "price": exit_price, "side": "long", "regime": str(position["regime"])})
  90. return account_equity + pnl, pnl > 0.0
  91. def regime_action(
  92. *,
  93. index: int,
  94. eth: list[explore.Candle],
  95. btc: list[explore.Candle],
  96. eth_close: pd.Series,
  97. btc_close: pd.Series,
  98. eth_volume: pd.Series,
  99. eth_sma: pd.Series,
  100. btc_sma: pd.Series,
  101. eth_vol: pd.Series,
  102. btc_vol: pd.Series,
  103. corr: pd.Series,
  104. ratio_z: pd.Series,
  105. volume_ratio: pd.Series,
  106. eth_rsi: list[float],
  107. spec: RouterSpec,
  108. ) -> str:
  109. values = (
  110. eth_sma.iloc[index],
  111. btc_sma.iloc[index],
  112. eth_vol.iloc[index],
  113. btc_vol.iloc[index],
  114. corr.iloc[index],
  115. ratio_z.iloc[index],
  116. volume_ratio.iloc[index],
  117. )
  118. if any(value != value for value in values):
  119. return "cash"
  120. eth_trend = eth_close.iloc[index] / eth_sma.iloc[index] - 1.0
  121. btc_trend = btc_close.iloc[index] / btc_sma.iloc[index] - 1.0
  122. eth_ret = eth_close.iloc[index] / eth_close.iloc[index - spec.lead_lookback] - 1.0
  123. btc_ret = btc_close.iloc[index] / btc_close.iloc[index - spec.lead_lookback] - 1.0
  124. current_rsi = eth_rsi[index]
  125. if current_rsi != current_rsi:
  126. return "cash"
  127. hour = pd.to_datetime(eth[index].ts, unit="ms", utc=True).hour
  128. liquid = volume_ratio.iloc[index] >= spec.min_volume_ratio and hour not in (0, 1, 2, 3)
  129. calm = eth_vol.iloc[index] <= spec.max_eth_vol and btc_vol.iloc[index] <= spec.max_eth_vol * 0.85
  130. coupled = corr.iloc[index] >= spec.min_corr
  131. if not liquid:
  132. return "cash"
  133. if coupled and calm and eth_trend >= spec.eth_trend_min and btc_trend >= spec.btc_trend_min and current_rsi <= 8.0:
  134. return "trend_follow"
  135. if coupled and btc_ret >= spec.lead_return_min and btc_ret - eth_ret >= spec.lag_gap_min and calm and current_rsi <= 35.0:
  136. return "btc_lead"
  137. if abs(eth_trend) < spec.eth_trend_min and ratio_z.iloc[index] <= -spec.ratio_z_entry and calm and current_rsi <= 15.0:
  138. return "mean_reversion"
  139. return "cash"
  140. def run_router_segment(
  141. *,
  142. eth: list[explore.Candle],
  143. btc: list[explore.Candle],
  144. spec: RouterSpec,
  145. leverage: int,
  146. ) -> explore.SegmentResult:
  147. eth_close = pd.Series([candle.close for candle in eth], dtype=float)
  148. btc_close = pd.Series([candle.close for candle in btc], dtype=float)
  149. eth_volume = pd.Series([candle.volume for candle in eth], dtype=float)
  150. eth_rsi = explore._compute_rsi(eth_close, 2)
  151. eth_ret = eth_close.pct_change()
  152. btc_ret = btc_close.pct_change()
  153. ratio = eth_close / btc_close
  154. ratio_mean = ratio.rolling(spec.ratio_lookback).mean()
  155. ratio_std = ratio.rolling(spec.ratio_lookback).std(ddof=0)
  156. ratio_z = (ratio - ratio_mean) / ratio_std
  157. eth_sma = eth_close.rolling(spec.trend_sma).mean()
  158. btc_sma = btc_close.rolling(spec.trend_sma).mean()
  159. eth_vol = eth_ret.rolling(spec.vol_lookback).std(ddof=0)
  160. btc_vol = btc_ret.rolling(spec.vol_lookback).std(ddof=0)
  161. corr = eth_ret.rolling(spec.corr_lookback).corr(btc_ret)
  162. volume_ratio = eth_volume / eth_volume.rolling(spec.vol_lookback).median()
  163. warmup = max(spec.trend_sma, spec.vol_lookback, spec.corr_lookback, spec.ratio_lookback, spec.lead_lookback + 1)
  164. equity = explore.INITIAL_EQUITY
  165. ending_equity = equity
  166. peak_equity = equity
  167. max_drawdown = 0.0
  168. wins = 0
  169. trades: list[dict[str, object]] = []
  170. entries: list[dict[str, object]] = []
  171. exits: list[dict[str, object]] = []
  172. equity_curve: list[dict[str, float | int]] = []
  173. position: dict[str, object] | None = None
  174. pending_entry: str | None = None
  175. pending_exit = False
  176. for index in range(warmup, len(eth)):
  177. candle = eth[index]
  178. if pending_exit and position is not None:
  179. equity, won = close_position(
  180. trades=trades,
  181. exits=exits,
  182. position=position,
  183. account_equity=equity,
  184. candle=candle,
  185. exit_price=candle.open,
  186. leverage=leverage,
  187. )
  188. wins += 1 if won else 0
  189. position = None
  190. pending_exit = False
  191. if pending_entry is not None and position is None and equity > 0.0:
  192. position = {
  193. "side": "long",
  194. "regime": pending_entry,
  195. "entry_time": candle.ts,
  196. "entry_price": candle.open,
  197. "entry_index": index,
  198. "margin_used": equity,
  199. "stop_price": candle.open * (1.0 - spec.stop_loss_pct),
  200. "take_profit_price": candle.open * (1.0 + spec.take_profit_pct),
  201. }
  202. entries.append({"ts": candle.ts, "price": candle.open, "side": "long", "regime": pending_entry})
  203. pending_entry = None
  204. current_equity = equity
  205. if position is not None and candle.low <= float(position["stop_price"]):
  206. equity, won = close_position(
  207. trades=trades,
  208. exits=exits,
  209. position=position,
  210. account_equity=equity,
  211. candle=candle,
  212. exit_price=float(position["stop_price"]),
  213. leverage=leverage,
  214. )
  215. wins += 1 if won else 0
  216. current_equity = equity
  217. position = None
  218. if position is not None and candle.high >= float(position["take_profit_price"]):
  219. equity, won = close_position(
  220. trades=trades,
  221. exits=exits,
  222. position=position,
  223. account_equity=equity,
  224. candle=candle,
  225. exit_price=float(position["take_profit_price"]),
  226. leverage=leverage,
  227. )
  228. wins += 1 if won else 0
  229. current_equity = equity
  230. position = None
  231. if position is not None:
  232. current_equity = explore.mark_to_market(
  233. side="long",
  234. margin_used=float(position["margin_used"]),
  235. entry_price=float(position["entry_price"]),
  236. mark_price=candle.close,
  237. leverage=leverage,
  238. )
  239. peak_equity = max(peak_equity, current_equity)
  240. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  241. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  242. ending_equity = current_equity
  243. if index == len(eth) - 1 or equity <= 0.0:
  244. continue
  245. if position is not None:
  246. held_bars = index - int(position["entry_index"])
  247. current_rsi = eth_rsi[index]
  248. current_ratio_z = ratio_z.iloc[index]
  249. current_sma = eth_sma.iloc[index]
  250. regime = str(position["regime"])
  251. if held_bars >= spec.max_hold_bars:
  252. pending_exit = True
  253. elif regime == "trend_follow" and current_rsi == current_rsi and current_rsi >= 55.0:
  254. pending_exit = True
  255. elif regime == "trend_follow" and current_sma == current_sma and candle.close < float(current_sma):
  256. pending_exit = True
  257. elif regime == "mean_reversion" and current_rsi == current_rsi and current_rsi >= 50.0:
  258. pending_exit = True
  259. elif regime == "mean_reversion" and current_ratio_z == current_ratio_z and current_ratio_z >= 0.0:
  260. pending_exit = True
  261. elif regime == "btc_lead" and current_rsi == current_rsi and current_rsi >= 60.0:
  262. pending_exit = True
  263. continue
  264. action = regime_action(
  265. index=index,
  266. eth=eth,
  267. btc=btc,
  268. eth_close=eth_close,
  269. btc_close=btc_close,
  270. eth_volume=eth_volume,
  271. eth_sma=eth_sma,
  272. btc_sma=btc_sma,
  273. eth_vol=eth_vol,
  274. btc_vol=btc_vol,
  275. corr=corr,
  276. ratio_z=ratio_z,
  277. volume_ratio=volume_ratio,
  278. eth_rsi=eth_rsi,
  279. spec=spec,
  280. )
  281. if action != "cash":
  282. pending_entry = action
  283. trade_count = len(trades)
  284. return explore.SegmentResult(
  285. trade_count=trade_count,
  286. total_return=(ending_equity - explore.INITIAL_EQUITY) / explore.INITIAL_EQUITY,
  287. win_rate=wins / trade_count if trade_count else 0.0,
  288. max_drawdown=max_drawdown,
  289. trades=trades,
  290. open_position=position,
  291. candles=eth[warmup:],
  292. equity_curve=equity_curve,
  293. entries=entries,
  294. exits=exits,
  295. )
  296. def specs() -> list[RouterSpec]:
  297. out: list[RouterSpec] = []
  298. for trend_sma, max_eth_vol, min_corr, ratio_z_entry in product(
  299. (96, 192),
  300. (0.018, 0.024),
  301. (0.45, 0.60),
  302. (1.0, 1.5),
  303. ):
  304. eth_trend_min = 0.006
  305. lead_return_min = 0.016
  306. min_volume_ratio = 0.90
  307. out.append(
  308. RouterSpec(
  309. name=(
  310. f"router-t{trend_sma}-et{eth_trend_min}-v{max_eth_vol}-c{min_corr}"
  311. f"-rz{ratio_z_entry}-br{lead_return_min}-liq{min_volume_ratio}"
  312. ),
  313. trend_sma=trend_sma,
  314. vol_lookback=96,
  315. corr_lookback=192,
  316. ratio_lookback=192,
  317. lead_lookback=16,
  318. eth_trend_min=eth_trend_min,
  319. btc_trend_min=eth_trend_min * 0.6,
  320. max_eth_vol=max_eth_vol,
  321. min_corr=min_corr,
  322. ratio_z_entry=ratio_z_entry,
  323. lead_return_min=lead_return_min,
  324. lag_gap_min=0.006,
  325. min_volume_ratio=min_volume_ratio,
  326. stop_loss_pct=0.010,
  327. take_profit_pct=0.022,
  328. max_hold_bars=48,
  329. )
  330. )
  331. return out
  332. def cost_frame(result: explore.SegmentResult, cost: float) -> pd.DataFrame:
  333. rows = [{"ts": pd.to_datetime(result.equity_curve[0]["ts"], unit="ms", utc=True), "equity": explore.INITIAL_EQUITY}]
  334. equity = explore.INITIAL_EQUITY
  335. for trade in result.trades:
  336. equity *= 1.0 + float(trade["return_pct"]) / 100.0 - cost * float(trade.get("cost_weight", 1.0))
  337. rows.append({"ts": pd.to_datetime(str(trade["exit_time"]), utc=True), "equity": equity})
  338. return pd.DataFrame(rows)
  339. def trade_stats(result: explore.SegmentResult, cost: float) -> dict[str, float]:
  340. returns = [float(trade["return_pct"]) / 100.0 - cost * float(trade.get("cost_weight", 1.0)) for trade in result.trades]
  341. wins = [value for value in returns if value > 0.0]
  342. losses = [value for value in returns if value < 0.0]
  343. avg_win = sum(wins) / len(wins) if wins else 0.0
  344. avg_loss = abs(sum(losses) / len(losses)) if losses else 0.0
  345. gross_profit = sum(wins)
  346. gross_loss = abs(sum(losses))
  347. return {
  348. "win_rate": len(wins) / len(returns) if returns else 0.0,
  349. "profit_loss_ratio": avg_win / avg_loss if avg_loss else 0.0,
  350. "profit_factor": gross_profit / gross_loss if gross_loss else 0.0,
  351. }
  352. def horizon_rows(name: str, frame: pd.DataFrame, last_ts: int) -> list[dict[str, object]]:
  353. rows: list[dict[str, object]] = []
  354. end_time = pd.to_datetime(last_ts, unit="ms", utc=True)
  355. for label, offset in HORIZONS:
  356. if offset is None:
  357. horizon = frame[["ts", "equity"]].copy()
  358. start_time = pd.Timestamp(horizon["ts"].iloc[0])
  359. else:
  360. cutoff = end_time - offset
  361. before_cutoff = frame[frame["ts"] <= cutoff]
  362. if len(before_cutoff):
  363. start_equity = float(before_cutoff["equity"].iloc[-1])
  364. after_cutoff = frame[frame["ts"] > cutoff]
  365. horizon = pd.concat(
  366. [pd.DataFrame([{"ts": cutoff, "equity": start_equity}]), after_cutoff[["ts", "equity"]]],
  367. ignore_index=True,
  368. )
  369. start_time = cutoff
  370. else:
  371. horizon = frame[["ts", "equity"]].copy()
  372. start_time = pd.Timestamp(horizon["ts"].iloc[0])
  373. metrics = explore.annualized_metrics_from_equity(horizon, int(start_time.timestamp() * 1000), last_ts)
  374. rows.append(
  375. {
  376. "name": name,
  377. "horizon": label,
  378. "horizon_start": start_time.strftime("%Y-%m-%d %H:%M"),
  379. "horizon_end": end_time.strftime("%Y-%m-%d %H:%M"),
  380. **metrics,
  381. "risk_return_ratio": metrics["net_total_return"] / metrics["net_max_drawdown"] if metrics["net_max_drawdown"] else 0.0,
  382. }
  383. )
  384. return rows
  385. def monthly_rows(name: str, frame: pd.DataFrame) -> pd.DataFrame:
  386. series = frame.set_index("ts")["equity"].resample("ME").last().ffill()
  387. out = pd.DataFrame(
  388. {
  389. "name": name,
  390. "month": series.index.strftime("%Y-%m"),
  391. "start_equity": series.shift(1).fillna(frame["equity"].iloc[0]).to_numpy(),
  392. "end_equity": series.to_numpy(),
  393. }
  394. )
  395. out["return"] = out["end_equity"] / out["start_equity"] - 1.0
  396. return out
  397. def regime_rows(name: str, result: explore.SegmentResult, cost: float) -> list[dict[str, object]]:
  398. rows: list[dict[str, object]] = []
  399. for regime, group in pd.DataFrame(result.trades).groupby("regime") if result.trades else []:
  400. net_returns = group["return_pct"].astype(float) / 100.0 - cost * group.get("cost_weight", 1.0).astype(float)
  401. wins = net_returns[net_returns > 0.0]
  402. losses = net_returns[net_returns < 0.0]
  403. rows.append(
  404. {
  405. "name": name,
  406. "regime": regime,
  407. "trades": len(group),
  408. "net_return_contribution": float(net_returns.sum()),
  409. "win_rate": float(len(wins) / len(group)) if len(group) else 0.0,
  410. "profit_factor": float(wins.sum() / abs(losses.sum())) if len(losses) and abs(losses.sum()) > 0.0 else 0.0,
  411. }
  412. )
  413. if not rows:
  414. rows.append({"name": name, "regime": "none", "trades": 0, "net_return_contribution": 0.0, "win_rate": 0.0, "profit_factor": 0.0})
  415. return rows
  416. def markdown_table(frame: pd.DataFrame) -> str:
  417. columns = list(frame.columns)
  418. rows = [columns, ["---" for _ in columns]]
  419. rows.extend(frame.astype(object).where(pd.notna(frame), "").values.tolist())
  420. return "\n".join("| " + " | ".join(format_cell(value) for value in row) + " |" for row in rows)
  421. def format_cell(value: object) -> str:
  422. if isinstance(value, float):
  423. return f"{value:.6g}"
  424. return str(value).replace("|", "\\|")
  425. def markdown_report(command: str, output_files: list[Path], total: pd.DataFrame, horizon: pd.DataFrame, regime: pd.DataFrame, monthly: pd.DataFrame) -> str:
  426. primary = total[total["cost_model"] == PRIMARY_COST].head(10)
  427. names = set(primary["name"])
  428. horizon_top = horizon[(horizon["cost_model"] == PRIMARY_COST) & horizon["name"].isin(names)].copy()
  429. regime_top = regime[(regime["cost_model"] == PRIMARY_COST) & regime["name"].isin(names)].copy()
  430. worst_months = monthly[(monthly["cost_model"] == PRIMARY_COST) & monthly["name"].isin(names)].sort_values("return").head(20)
  431. best = primary.iloc[0] if len(primary) else pd.Series(dtype=object)
  432. decision = "No router variants produced trades."
  433. if len(best):
  434. decision = (
  435. f"Best maker_taker router `{best['name']}`: annualized={best['net_annualized_return']:.4f}, "
  436. f"DD={best['net_max_drawdown']:.4f}, Calmar={best['net_calmar']:.4f}, "
  437. f"win_rate={best['win_rate']:.4f}, profit_factor={best['profit_factor']:.4f}."
  438. )
  439. lines = [
  440. "# ETH regime router variants",
  441. "",
  442. f"Run command: `{command}`",
  443. "",
  444. "Output files:",
  445. *[f"- `{path}`" for path in output_files],
  446. "",
  447. "Router actions: trend_follow, mean_reversion, btc_lead, or cash. Regime inputs: BTC/ETH volatility, trend distance from SMA, rolling correlation, ETH/BTC ratio z-score, UTC hour, and ETH volume ratio.",
  448. f"Costs: {', '.join(COSTS)}. Primary sort uses {PRIMARY_COST}, qualified horizons, Calmar, annualized return, and drawdown.",
  449. "",
  450. f"Decision: {decision}",
  451. "",
  452. "## Top maker_taker routers",
  453. "",
  454. markdown_table(
  455. primary[
  456. [
  457. "name",
  458. "trades",
  459. "net_total_return",
  460. "net_annualized_return",
  461. "net_max_drawdown",
  462. "net_calmar",
  463. "win_rate",
  464. "profit_loss_ratio",
  465. "profit_factor",
  466. "risk_return_ratio",
  467. "min_horizon_total_return",
  468. ]
  469. ]
  470. ),
  471. "",
  472. "## Horizon checks",
  473. "",
  474. markdown_table(
  475. horizon_top[
  476. [
  477. "name",
  478. "horizon",
  479. "net_total_return",
  480. "net_annualized_return",
  481. "net_max_drawdown",
  482. "net_calmar",
  483. "risk_return_ratio",
  484. ]
  485. ]
  486. ),
  487. "",
  488. "## Regime contribution",
  489. "",
  490. markdown_table(regime_top[["name", "regime", "trades", "net_return_contribution", "win_rate", "profit_factor"]]),
  491. "",
  492. "## Worst months among top routers",
  493. "",
  494. markdown_table(worst_months[["name", "month", "return"]]),
  495. ]
  496. return "\n".join(lines) + "\n"
  497. def main() -> int:
  498. parser = argparse.ArgumentParser()
  499. parser.add_argument("--bar", default=BAR)
  500. parser.add_argument("--years", type=float, default=YEARS)
  501. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  502. parser.add_argument("--max-candidates", type=int)
  503. args = parser.parse_args()
  504. eth_raw = load_candles(ETH_SYMBOL, args.bar, args.years)
  505. btc_raw = load_candles(BTC_SYMBOL, args.bar, args.years)
  506. eth, btc = explore.align_pair_candles(eth_raw, btc_raw)
  507. if not eth or not btc:
  508. raise RuntimeError("no aligned ETH/BTC candles")
  509. candidates = specs()
  510. if args.max_candidates is not None:
  511. candidates = candidates[: args.max_candidates]
  512. total_rows: list[dict[str, object]] = []
  513. horizon_output: list[dict[str, object]] = []
  514. monthly_frames: list[pd.DataFrame] = []
  515. regime_output: list[dict[str, object]] = []
  516. for index, spec in enumerate(candidates, start=1):
  517. result = run_router_segment(eth=eth, btc=btc, spec=spec, leverage=explore.LEVERAGE)
  518. print(f"done {index}/{len(candidates)} {spec.name} trades={result.trade_count}", flush=True)
  519. for cost_model, cost in COSTS.items():
  520. frame = cost_frame(result, cost)
  521. metric_start_ts = int(pd.Timestamp(frame["ts"].iloc[0]).timestamp() * 1000)
  522. metric_end_ts = int(pd.Timestamp(frame["ts"].iloc[-1]).timestamp() * 1000)
  523. metrics = explore.annualized_metrics_from_equity(frame, metric_start_ts, metric_end_ts)
  524. stats = trade_stats(result, cost)
  525. current_horizons = horizon_rows(spec.name, frame, eth[-1].ts)
  526. min_horizon_return = min(float(row["net_total_return"]) for row in current_horizons if row["horizon"] != "all")
  527. total_rows.append(
  528. {
  529. "name": spec.name,
  530. "cost_model": cost_model,
  531. "symbol": ETH_SYMBOL,
  532. "signal_symbol": BTC_SYMBOL,
  533. "bar": args.bar,
  534. "first_candle": pd.Timestamp(frame["ts"].iloc[0]).strftime("%Y-%m-%d %H:%M"),
  535. "last_candle": pd.Timestamp(frame["ts"].iloc[-1]).strftime("%Y-%m-%d %H:%M"),
  536. "years": (metric_end_ts - metric_start_ts) / 86_400_000 / 365,
  537. "trades": result.trade_count,
  538. "gross_total_return": result.total_return,
  539. "gross_max_drawdown_mark_to_market": result.max_drawdown,
  540. "risk_return_ratio": metrics["net_total_return"] / metrics["net_max_drawdown"] if metrics["net_max_drawdown"] else 0.0,
  541. "min_horizon_total_return": min_horizon_return,
  542. **spec.__dict__,
  543. **metrics,
  544. **stats,
  545. }
  546. )
  547. for row in current_horizons:
  548. horizon_output.append({"cost_model": cost_model, **row})
  549. monthly_frames.append(monthly_rows(spec.name, frame).assign(cost_model=cost_model))
  550. for row in regime_rows(spec.name, result, cost):
  551. regime_output.append({"cost_model": cost_model, **row})
  552. total = pd.DataFrame(total_rows).sort_values(
  553. ["cost_model", "min_horizon_total_return", "net_calmar", "net_annualized_return", "net_max_drawdown"],
  554. ascending=[True, False, False, False, True],
  555. )
  556. horizon = pd.DataFrame(horizon_output)
  557. horizon["horizon"] = pd.Categorical(horizon["horizon"], categories=[label for label, _ in HORIZONS], ordered=True)
  558. horizon = horizon.sort_values(["cost_model", "name", "horizon"])
  559. monthly = pd.concat(monthly_frames, ignore_index=True)
  560. regime = pd.DataFrame(regime_output).sort_values(["cost_model", "name", "net_return_contribution"], ascending=[True, True, False])
  561. args.output_dir.mkdir(parents=True, exist_ok=True)
  562. total_path = args.output_dir / f"{PREFIX}-total.csv"
  563. horizon_path = args.output_dir / f"{PREFIX}-horizon.csv"
  564. monthly_path = args.output_dir / f"{PREFIX}-monthly.csv"
  565. regime_path = args.output_dir / f"{PREFIX}-regime-contribution.csv"
  566. top_path = args.output_dir / f"{PREFIX}-top10.csv"
  567. json_path = args.output_dir / f"{PREFIX}-summary.json"
  568. report_path = args.output_dir / f"{PREFIX}-report.md"
  569. total.to_csv(total_path, index=False)
  570. horizon.to_csv(horizon_path, index=False)
  571. monthly.to_csv(monthly_path, index=False)
  572. regime.to_csv(regime_path, index=False)
  573. total[total["cost_model"] == PRIMARY_COST].head(10).to_csv(top_path, index=False)
  574. summary = {
  575. "report": PREFIX,
  576. "command": f"rtk .venv/bin/python {Path(__file__).as_posix()} --bar {args.bar} --years {args.years}",
  577. "primary_cost": PRIMARY_COST,
  578. "candidate_count": len(candidates),
  579. "top_maker_taker": total[total["cost_model"] == PRIMARY_COST].head(10).to_dict("records"),
  580. "output_files": [str(path) for path in [total_path, horizon_path, monthly_path, regime_path, top_path, json_path, report_path]],
  581. }
  582. json_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
  583. report_path.write_text(
  584. markdown_report(
  585. command=summary["command"],
  586. output_files=[total_path, horizon_path, monthly_path, regime_path, top_path, json_path, report_path],
  587. total=total,
  588. horizon=horizon,
  589. regime=regime,
  590. monthly=monthly,
  591. ),
  592. encoding="utf-8",
  593. )
  594. print(total[total["cost_model"] == PRIMARY_COST].head(20).to_string(index=False))
  595. return 0
  596. if __name__ == "__main__":
  597. raise SystemExit(main())