search_recent_regime_router_v2.py 25 KB

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