search_eth_recent_regime_router_v3.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3. import argparse
  4. import json
  5. from dataclasses import dataclass
  6. from pathlib import Path
  7. import pandas as pd
  8. from okx_codex_trader.models import Candle
  9. from okx_codex_trader.sampled_report import SegmentResult, mark_to_market, trade_equity
  10. ETH_SYMBOL = "ETH-USDT-SWAP"
  11. BTC_SYMBOL = "BTC-USDT-SWAP"
  12. BAR = "15m"
  13. YEARS = 10.0
  14. LEVERAGE = 3
  15. INITIAL_EQUITY = 10_000.0
  16. DATA_DIR = Path("data/okx-candles")
  17. OUTPUT_DIR = Path("reports/eth-exploration")
  18. PREFIX = "eth-recent-regime-router-v3"
  19. PRIMARY_COST = "maker_taker"
  20. COSTS = (
  21. ("maker_maker", 0.0012),
  22. ("maker_taker", 0.0021),
  23. ("taker_taker", 0.0030),
  24. )
  25. HORIZONS = (
  26. ("full", None),
  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. ("30d", pd.DateOffset(days=30)),
  32. )
  33. @dataclass(frozen=True)
  34. class RouterSpec:
  35. name: str
  36. band_length: int
  37. bandwidth_lookback: int
  38. bandwidth_quantile: float
  39. trend_lookback: int
  40. momentum_lookback: int
  41. vol_lookback: int
  42. btc_bear_momentum: float
  43. btc_bull_momentum: float
  44. eth_bear_momentum: float
  45. high_vol: float
  46. extreme_vol: float
  47. ratio_z_lookback: int
  48. ratio_z_abs_max: float
  49. stop_loss_pct: float
  50. middle_exit_buffer_pct: float
  51. middle_exit_confirm_bars: int
  52. breakeven_trigger_pct: float
  53. breakeven_lock_pct: float
  54. trail_trigger_pct: float
  55. trail_giveback_pct: float
  56. max_giveback_trigger_pct: float
  57. max_giveback_pct: float
  58. cooldown_bars: int
  59. def _format_ts(ts: int) -> str:
  60. return pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
  61. def _load_candles(symbol: str, bar: str, years: float) -> list[Candle]:
  62. frame = pd.read_csv(DATA_DIR / symbol / f"{bar}.csv")
  63. if years > 0.0:
  64. limit = int(years * 365 * 24 * 60 / 15)
  65. frame = frame.tail(limit)
  66. return [
  67. Candle(
  68. symbol=symbol,
  69. ts=int(row.ts),
  70. open=float(row.open),
  71. high=float(row.high),
  72. low=float(row.low),
  73. close=float(row.close),
  74. volume=float(row.volume),
  75. )
  76. for row in frame.itertuples(index=False)
  77. ]
  78. def _align_pair(left: list[Candle], right: list[Candle]) -> tuple[list[Candle], list[Candle]]:
  79. right_by_ts = {candle.ts: candle for candle in right}
  80. left_out: list[Candle] = []
  81. right_out: list[Candle] = []
  82. for candle in left:
  83. other = right_by_ts.get(candle.ts)
  84. if other is not None:
  85. left_out.append(candle)
  86. right_out.append(other)
  87. return left_out, right_out
  88. def _is_nan(value: float) -> bool:
  89. return value != value
  90. def favorable_move(side: str, entry_price: float, candle: Candle) -> float:
  91. if side == "long":
  92. return candle.high / entry_price - 1.0
  93. return entry_price / candle.low - 1.0
  94. def adverse_move(side: str, entry_price: float, candle: Candle) -> float:
  95. if side == "long":
  96. return 1.0 - candle.low / entry_price
  97. return candle.high / entry_price - 1.0
  98. def route_state(
  99. *,
  100. index: int,
  101. frame: pd.DataFrame,
  102. spec: RouterSpec,
  103. position: dict[str, object] | None,
  104. ) -> str:
  105. values = (
  106. frame.at[index, "eth_sma"],
  107. frame.at[index, "btc_sma"],
  108. frame.at[index, "eth_momentum"],
  109. frame.at[index, "btc_momentum"],
  110. frame.at[index, "eth_vol"],
  111. frame.at[index, "ratio_z"],
  112. )
  113. if any(_is_nan(float(value)) for value in values):
  114. return "cash"
  115. if position is not None:
  116. mfe = float(position["mfe_pct"])
  117. mae = float(position["mae_pct"])
  118. giveback = mfe - float(position["open_profit_pct"])
  119. if mfe >= spec.max_giveback_trigger_pct and giveback >= spec.max_giveback_pct:
  120. return "protected_bb"
  121. if mae >= spec.stop_loss_pct * 0.75:
  122. return "protected_bb"
  123. eth_close = float(frame.at[index, "eth_close"])
  124. btc_close = float(frame.at[index, "btc_close"])
  125. eth_sma = float(frame.at[index, "eth_sma"])
  126. btc_sma = float(frame.at[index, "btc_sma"])
  127. eth_momentum = float(frame.at[index, "eth_momentum"])
  128. btc_momentum = float(frame.at[index, "btc_momentum"])
  129. eth_vol = float(frame.at[index, "eth_vol"])
  130. ratio_z = abs(float(frame.at[index, "ratio_z"]))
  131. if eth_vol >= spec.extreme_vol or ratio_z > spec.ratio_z_abs_max:
  132. return "cash"
  133. if btc_close < btc_sma and btc_momentum <= spec.btc_bear_momentum and eth_close < eth_sma and eth_momentum <= spec.eth_bear_momentum:
  134. return "short_bias"
  135. if eth_vol >= spec.high_vol:
  136. return "protected_bb"
  137. if btc_close > btc_sma and btc_momentum >= spec.btc_bull_momentum:
  138. return "baseline_bb"
  139. return "protected_bb"
  140. def close_position(
  141. *,
  142. trades: list[dict[str, object]],
  143. exits: list[dict[str, object]],
  144. position: dict[str, object],
  145. candle: Candle,
  146. exit_price: float,
  147. reason: str,
  148. ) -> tuple[float, bool]:
  149. margin_used = float(position["margin_used"])
  150. exit_equity = trade_equity(
  151. side=str(position["side"]),
  152. margin_used=margin_used,
  153. entry_price=float(position["entry_price"]),
  154. exit_price=exit_price,
  155. leverage=LEVERAGE,
  156. )
  157. pnl = exit_equity - margin_used
  158. trades.append(
  159. {
  160. "side": "Long" if position["side"] == "long" else "Short",
  161. "route": position["route"],
  162. "entry_time": _format_ts(int(position["entry_time"])),
  163. "exit_time": _format_ts(candle.ts),
  164. "entry_price": round(float(position["entry_price"]), 4),
  165. "exit_price": round(exit_price, 4),
  166. "pnl": round(pnl, 4),
  167. "return_pct": round(pnl / margin_used * 100.0, 4),
  168. "cost_weight": 1.0,
  169. "exit_reason": reason,
  170. "mfe_pct": round(float(position["mfe_pct"]) * 100.0, 4),
  171. "mae_pct": round(float(position["mae_pct"]) * 100.0, 4),
  172. }
  173. )
  174. exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"], "route": position["route"]})
  175. return exit_equity, pnl > 0.0
  176. def protection_exit(position: dict[str, object], candle: Candle, spec: RouterSpec) -> tuple[float, str] | None:
  177. side = str(position["side"])
  178. entry_price = float(position["entry_price"])
  179. mfe = float(position["mfe_pct"])
  180. stop_price = float(position["stop_price"])
  181. protected = str(position["route"]) == "protected_bb"
  182. if protected and mfe >= spec.breakeven_trigger_pct:
  183. be_stop = entry_price * (1.0 + spec.breakeven_lock_pct if side == "long" else 1.0 - spec.breakeven_lock_pct)
  184. stop_price = max(stop_price, be_stop) if side == "long" else min(stop_price, be_stop)
  185. if protected and mfe >= spec.trail_trigger_pct:
  186. if side == "long":
  187. stop_price = max(stop_price, entry_price * (1.0 + mfe - spec.trail_giveback_pct))
  188. else:
  189. stop_price = min(stop_price, entry_price * (1.0 - mfe + spec.trail_giveback_pct))
  190. if side == "long":
  191. if candle.open <= stop_price:
  192. return candle.open, "protect_gap" if stop_price != float(position["stop_price"]) else "stop_gap"
  193. if candle.low <= stop_price:
  194. return stop_price, "profit_protect" if stop_price != float(position["stop_price"]) else "stop"
  195. else:
  196. if candle.open >= stop_price:
  197. return candle.open, "protect_gap" if stop_price != float(position["stop_price"]) else "stop_gap"
  198. if candle.high >= stop_price:
  199. return stop_price, "profit_protect" if stop_price != float(position["stop_price"]) else "stop"
  200. if not protected or mfe < spec.max_giveback_trigger_pct:
  201. return None
  202. close_profit = candle.close / entry_price - 1.0 if side == "long" else entry_price / candle.close - 1.0
  203. if close_profit <= mfe - spec.max_giveback_pct:
  204. return candle.close, "giveback_close"
  205. return None
  206. def indicator_frame(eth: list[Candle], btc: list[Candle], spec: RouterSpec) -> pd.DataFrame:
  207. frame = pd.DataFrame(
  208. {
  209. "ts": [candle.ts for candle in eth],
  210. "eth_open": [candle.open for candle in eth],
  211. "eth_high": [candle.high for candle in eth],
  212. "eth_low": [candle.low for candle in eth],
  213. "eth_close": [candle.close for candle in eth],
  214. "btc_close": [candle.close for candle in btc],
  215. }
  216. )
  217. eth_close = frame["eth_close"]
  218. btc_close = frame["btc_close"]
  219. middle = eth_close.rolling(spec.band_length).mean()
  220. stdev = eth_close.rolling(spec.band_length).std(ddof=0)
  221. frame["middle"] = middle
  222. frame["upper"] = middle + 2.0 * stdev
  223. frame["lower"] = middle - 2.0 * stdev
  224. bandwidth = (frame["upper"] - frame["lower"]) / middle
  225. frame["bandwidth"] = bandwidth
  226. frame["bandwidth_threshold"] = bandwidth.rolling(spec.bandwidth_lookback).quantile(spec.bandwidth_quantile)
  227. frame["eth_sma"] = eth_close.rolling(spec.trend_lookback).mean()
  228. frame["btc_sma"] = btc_close.rolling(spec.trend_lookback).mean()
  229. frame["eth_momentum"] = eth_close / eth_close.shift(spec.momentum_lookback) - 1.0
  230. frame["btc_momentum"] = btc_close / btc_close.shift(spec.momentum_lookback) - 1.0
  231. frame["eth_vol"] = eth_close.pct_change().rolling(spec.vol_lookback).std(ddof=0)
  232. ratio = eth_close / btc_close
  233. frame["ratio_z"] = (ratio - ratio.rolling(spec.ratio_z_lookback).mean()) / ratio.rolling(spec.ratio_z_lookback).std(ddof=0)
  234. return frame
  235. def run_router(eth: list[Candle], btc: list[Candle], spec: RouterSpec) -> tuple[SegmentResult, dict[str, int]]:
  236. frame = indicator_frame(eth, btc, spec)
  237. warmup = max(spec.band_length, spec.bandwidth_lookback, spec.trend_lookback, spec.momentum_lookback + 1, spec.vol_lookback, spec.ratio_z_lookback)
  238. equity = INITIAL_EQUITY
  239. ending_equity = equity
  240. peak_equity = equity
  241. max_drawdown = 0.0
  242. wins = 0
  243. trades: list[dict[str, object]] = []
  244. entries: list[dict[str, object]] = []
  245. exits: list[dict[str, object]] = []
  246. equity_curve: list[dict[str, float | int]] = []
  247. position: dict[str, object] | None = None
  248. pending_entry: tuple[str, str] | None = None
  249. pending_exit = False
  250. middle_exit_streak = 0
  251. cooldown_until = -1
  252. state_hits = {"baseline_bb": 0, "protected_bb": 0, "short_bias": 0, "cash": 0}
  253. entry_hits = {"baseline_bb_entries": 0, "protected_bb_entries": 0, "short_bias_entries": 0}
  254. exit_hits = {"stop_exits": 0, "protect_exits": 0, "giveback_exits": 0, "route_exits": 0, "middle_exits": 0}
  255. for index in range(warmup, len(eth)):
  256. candle = eth[index]
  257. if pending_exit and position is not None:
  258. equity, won = close_position(trades=trades, exits=exits, position=position, candle=candle, exit_price=candle.open, reason="route_or_middle")
  259. wins += int(won)
  260. exit_hits["route_exits"] += 1
  261. position = None
  262. pending_exit = False
  263. middle_exit_streak = 0
  264. cooldown_until = index + spec.cooldown_bars
  265. if pending_entry is not None and position is None and equity > 0.0:
  266. side, route = pending_entry
  267. entry_price = candle.open
  268. position = {
  269. "side": side,
  270. "route": route,
  271. "entry_time": candle.ts,
  272. "entry_price": entry_price,
  273. "margin_used": equity,
  274. "stop_price": entry_price * (1.0 - spec.stop_loss_pct if side == "long" else 1.0 + spec.stop_loss_pct),
  275. "mfe_pct": 0.0,
  276. "mae_pct": 0.0,
  277. "open_profit_pct": 0.0,
  278. }
  279. entries.append({"ts": candle.ts, "price": entry_price, "side": side, "route": route})
  280. entry_hits[f"{route}_entries"] += 1
  281. pending_entry = None
  282. if position is not None:
  283. side = str(position["side"])
  284. entry_price = float(position["entry_price"])
  285. position["mfe_pct"] = max(float(position["mfe_pct"]), favorable_move(side, entry_price, candle))
  286. position["mae_pct"] = max(float(position["mae_pct"]), adverse_move(side, entry_price, candle))
  287. position["open_profit_pct"] = candle.close / entry_price - 1.0 if side == "long" else entry_price / candle.close - 1.0
  288. current_state = route_state(index=index, frame=frame, spec=spec, position=position)
  289. state_hits[current_state] += 1
  290. current_equity = equity
  291. if position is not None:
  292. risk_exit = protection_exit(position, candle, spec)
  293. if risk_exit is not None:
  294. exit_price, reason = risk_exit
  295. equity, won = close_position(trades=trades, exits=exits, position=position, candle=candle, exit_price=exit_price, reason=reason)
  296. wins += int(won)
  297. if reason.startswith("stop"):
  298. exit_hits["stop_exits"] += 1
  299. elif reason == "giveback_close":
  300. exit_hits["giveback_exits"] += 1
  301. else:
  302. exit_hits["protect_exits"] += 1
  303. current_equity = equity
  304. position = None
  305. middle_exit_streak = 0
  306. cooldown_until = index + spec.cooldown_bars
  307. if position is not None:
  308. current_equity = mark_to_market(
  309. side=str(position["side"]),
  310. margin_used=float(position["margin_used"]),
  311. entry_price=float(position["entry_price"]),
  312. mark_price=candle.close,
  313. leverage=LEVERAGE,
  314. )
  315. peak_equity = max(peak_equity, current_equity)
  316. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  317. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  318. ending_equity = current_equity
  319. if index == len(eth) - 1 or equity <= 0.0:
  320. continue
  321. values = (
  322. frame.at[index, "middle"],
  323. frame.at[index, "upper"],
  324. frame.at[index, "lower"],
  325. frame.at[index, "bandwidth"],
  326. frame.at[index, "bandwidth_threshold"],
  327. )
  328. if any(_is_nan(float(value)) for value in values):
  329. continue
  330. middle = float(frame.at[index, "middle"])
  331. upper = float(frame.at[index, "upper"])
  332. lower = float(frame.at[index, "lower"])
  333. bandwidth = float(frame.at[index, "bandwidth"])
  334. threshold = float(frame.at[index, "bandwidth_threshold"])
  335. if position is not None:
  336. side = str(position["side"])
  337. middle_exit = (side == "long" and candle.close < middle * (1.0 - spec.middle_exit_buffer_pct)) or (
  338. side == "short" and candle.close > middle * (1.0 + spec.middle_exit_buffer_pct)
  339. )
  340. route_exit = current_state == "cash" or (side == "long" and current_state == "short_bias")
  341. middle_exit_streak = middle_exit_streak + 1 if middle_exit else 0
  342. if route_exit or middle_exit_streak >= spec.middle_exit_confirm_bars:
  343. pending_exit = True
  344. if middle_exit_streak >= spec.middle_exit_confirm_bars:
  345. exit_hits["middle_exits"] += 1
  346. continue
  347. if index < cooldown_until or current_state == "cash" or bandwidth > threshold:
  348. continue
  349. if current_state in ("baseline_bb", "protected_bb") and candle.close > upper:
  350. pending_entry = ("long", current_state)
  351. elif current_state == "short_bias" and candle.close < lower:
  352. pending_entry = ("short", current_state)
  353. result = SegmentResult(
  354. trade_count=len(trades),
  355. total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
  356. win_rate=wins / len(trades) if trades else 0.0,
  357. max_drawdown=max_drawdown,
  358. trades=trades,
  359. open_position=position,
  360. candles=eth[warmup:],
  361. equity_curve=equity_curve,
  362. entries=entries,
  363. exits=exits,
  364. )
  365. return result, {**state_hits, **entry_hits, **exit_hits}
  366. def cost_frame(result: SegmentResult, cost: float, last_ts: int) -> pd.DataFrame:
  367. if not result.equity_curve:
  368. return pd.DataFrame([{"ts": pd.to_datetime(last_ts, unit="ms", utc=True), "equity": INITIAL_EQUITY}])
  369. rows = [{"ts": pd.to_datetime(result.equity_curve[0]["ts"], unit="ms", utc=True), "equity": INITIAL_EQUITY}]
  370. equity = INITIAL_EQUITY
  371. for trade in result.trades:
  372. equity *= 1.0 + float(trade["return_pct"]) / 100.0 - cost * float(trade.get("cost_weight", 1.0))
  373. rows.append({"ts": pd.to_datetime(str(trade["exit_time"]), utc=True), "equity": equity})
  374. end_time = pd.to_datetime(last_ts, unit="ms", utc=True)
  375. if pd.Timestamp(rows[-1]["ts"]) < end_time:
  376. rows.append({"ts": end_time, "equity": equity})
  377. return pd.DataFrame(rows)
  378. def max_drawdown(values: list[float]) -> float:
  379. peak = values[0]
  380. drawdown = 0.0
  381. for value in values:
  382. peak = max(peak, value)
  383. drawdown = max(drawdown, (peak - value) / peak if peak else 0.0)
  384. return drawdown
  385. def equity_metrics(frame: pd.DataFrame, start_ts: int, end_ts: int) -> dict[str, float]:
  386. years = (end_ts - start_ts) / 86_400_000 / 365
  387. total_return = float(frame["equity"].iloc[-1] / frame["equity"].iloc[0] - 1.0)
  388. annualized = (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 and years > 0.0 else 0.0
  389. dd = max_drawdown([float(value) for value in frame["equity"]])
  390. return {
  391. "total_return": total_return,
  392. "annualized_return": annualized,
  393. "max_drawdown": dd,
  394. "calmar": annualized / dd if dd else 0.0,
  395. }
  396. def scoped_trades(trades: list[dict[str, object]], start: pd.Timestamp | None) -> list[dict[str, object]]:
  397. if start is None:
  398. return trades
  399. return [trade for trade in trades if pd.to_datetime(str(trade["exit_time"]), utc=True) >= start]
  400. def trade_stats(trades: list[dict[str, object]], cost: float, start: pd.Timestamp | None = None) -> dict[str, float | int]:
  401. scoped = scoped_trades(trades, start)
  402. returns = [float(trade["return_pct"]) / 100.0 - cost * float(trade.get("cost_weight", 1.0)) for trade in scoped]
  403. wins = [value for value in returns if value > 0.0]
  404. losses = [-value for value in returns if value < 0.0]
  405. avg_win = sum(wins) / len(wins) if wins else 0.0
  406. avg_loss = sum(losses) / len(losses) if losses else 0.0
  407. return {
  408. "trades": len(returns),
  409. "win_rate": len(wins) / len(returns) if returns else 0.0,
  410. "profit_factor": sum(wins) / sum(losses) if losses else 0.0,
  411. "payoff_ratio": avg_win / avg_loss if avg_loss else 0.0,
  412. }
  413. def horizon_rows(name: str, frame: pd.DataFrame, trades: list[dict[str, object]], cost: float) -> list[dict[str, object]]:
  414. rows: list[dict[str, object]] = []
  415. end_time = pd.Timestamp(frame["ts"].iloc[-1])
  416. for label, offset in HORIZONS:
  417. if offset is None:
  418. current = frame[["ts", "equity"]].copy()
  419. start_time = pd.Timestamp(current["ts"].iloc[0])
  420. else:
  421. cutoff = end_time - offset
  422. before = frame[frame["ts"] <= cutoff]
  423. if len(before):
  424. start_equity = float(before["equity"].iloc[-1])
  425. after = frame[frame["ts"] > cutoff]
  426. current = pd.concat([pd.DataFrame([{"ts": cutoff, "equity": start_equity}]), after[["ts", "equity"]]], ignore_index=True)
  427. start_time = cutoff
  428. else:
  429. current = frame[["ts", "equity"]].copy()
  430. start_time = pd.Timestamp(current["ts"].iloc[0])
  431. metrics = equity_metrics(current, int(start_time.timestamp() * 1000), int(end_time.timestamp() * 1000))
  432. rows.append(
  433. {
  434. "name": name,
  435. "horizon": label,
  436. "start": start_time.strftime("%Y-%m-%d %H:%M"),
  437. "end": end_time.strftime("%Y-%m-%d %H:%M"),
  438. **metrics,
  439. **trade_stats(trades, cost, None if offset is None else start_time),
  440. }
  441. )
  442. return rows
  443. def route_rows(name: str, trades: list[dict[str, object]], cost: float, stats: dict[str, int]) -> list[dict[str, object]]:
  444. rows: list[dict[str, object]] = []
  445. trade_frame = pd.DataFrame(trades)
  446. for route in ("baseline_bb", "protected_bb", "short_bias", "cash"):
  447. route_trades = [] if trade_frame.empty or route == "cash" else trade_frame[trade_frame["route"] == route].to_dict("records")
  448. rows.append(
  449. {
  450. "name": name,
  451. "route": route,
  452. "bar_hits": stats.get(route, 0),
  453. "entry_hits": stats.get(f"{route}_entries", 0),
  454. **trade_stats(route_trades, cost),
  455. }
  456. )
  457. return rows
  458. def specs() -> list[RouterSpec]:
  459. out: list[RouterSpec] = []
  460. for high_vol, extreme_vol, bear_momo, bull_momo, ratio_cap, quantile in (
  461. (0.0060, 0.0110, -0.010, 0.006, 2.5, 0.20),
  462. (0.0060, 0.0125, -0.014, 0.008, 3.0, 0.20),
  463. (0.0075, 0.0125, -0.014, 0.006, 3.0, 0.25),
  464. (0.0075, 0.0140, -0.018, 0.008, 3.5, 0.25),
  465. ):
  466. for band_length, bandwidth_lookback, trend_lookback, momentum_lookback in (
  467. (48, 960, 480, 96),
  468. (72, 960, 480, 96),
  469. (96, 1440, 672, 192),
  470. ):
  471. name = (
  472. f"{PREFIX}-l{band_length}-bw{bandwidth_lookback}-q{quantile:g}"
  473. f"-tr{trend_lookback}-m{momentum_lookback}-hv{high_vol:g}"
  474. f"-xv{extreme_vol:g}-bm{bear_momo:g}-um{bull_momo:g}-rz{ratio_cap:g}"
  475. )
  476. out.append(
  477. RouterSpec(
  478. name=name,
  479. band_length=band_length,
  480. bandwidth_lookback=bandwidth_lookback,
  481. bandwidth_quantile=quantile,
  482. trend_lookback=trend_lookback,
  483. momentum_lookback=momentum_lookback,
  484. vol_lookback=96,
  485. btc_bear_momentum=bear_momo,
  486. btc_bull_momentum=bull_momo,
  487. eth_bear_momentum=bear_momo * 0.7,
  488. high_vol=high_vol,
  489. extreme_vol=extreme_vol,
  490. ratio_z_lookback=672,
  491. ratio_z_abs_max=ratio_cap,
  492. stop_loss_pct=0.012,
  493. middle_exit_buffer_pct=0.001,
  494. middle_exit_confirm_bars=2,
  495. breakeven_trigger_pct=0.006,
  496. breakeven_lock_pct=0.000,
  497. trail_trigger_pct=0.012,
  498. trail_giveback_pct=0.006,
  499. max_giveback_trigger_pct=0.014,
  500. max_giveback_pct=0.009,
  501. cooldown_bars=24,
  502. )
  503. )
  504. return out
  505. def format_cell(value: object) -> str:
  506. if isinstance(value, float):
  507. return f"{value:.6g}"
  508. return str(value).replace("|", "\\|")
  509. def markdown_table(frame: pd.DataFrame) -> str:
  510. columns = list(frame.columns)
  511. rows = [columns, ["---" for _ in columns]]
  512. rows.extend(frame.astype(object).where(pd.notna(frame), "").values.tolist())
  513. return "\n".join("| " + " | ".join(format_cell(value) for value in row) + " |" for row in rows)
  514. def report_text(command: str, output_files: list[Path], total: pd.DataFrame, horizon: pd.DataFrame, route: pd.DataFrame) -> str:
  515. primary = total[total["cost_model"] == PRIMARY_COST].head(10)
  516. names = set(primary["name"])
  517. horizon_top = horizon[(horizon["cost_model"] == PRIMARY_COST) & horizon["name"].isin(names)]
  518. route_top = route[(route["cost_model"] == PRIMARY_COST) & route["name"].isin(names)]
  519. return "\n".join(
  520. [
  521. "# ETH recent regime router v3",
  522. "",
  523. f"Run command: `{command}`",
  524. "",
  525. "Scope: offline ETH/BTC 15m local OKX candle cache only. No live executor, private API, env, service, or order path is used.",
  526. "",
  527. "Output files:",
  528. *[f"- `{path}`" for path in output_files],
  529. "",
  530. "Router states are `baseline_bb`, `protected_bb`, `short_bias`, and `cash`. State choice uses recent ETH volatility, ETH/BTC trend and momentum, ETH/BTC ratio z-score, and open-position MFE/MAE giveback pressure.",
  531. "",
  532. "## Top maker_taker routers",
  533. "",
  534. markdown_table(
  535. primary[
  536. [
  537. "name",
  538. "trades",
  539. "total_return",
  540. "annualized_return",
  541. "max_drawdown",
  542. "calmar",
  543. "win_rate",
  544. "profit_factor",
  545. "payoff_ratio",
  546. "min_recent_total_return",
  547. ]
  548. ]
  549. ),
  550. "",
  551. "## Required horizons",
  552. "",
  553. markdown_table(
  554. horizon_top[
  555. [
  556. "name",
  557. "horizon",
  558. "total_return",
  559. "annualized_return",
  560. "max_drawdown",
  561. "calmar",
  562. "trades",
  563. "win_rate",
  564. "profit_factor",
  565. "payoff_ratio",
  566. ]
  567. ]
  568. ),
  569. "",
  570. "## State hit statistics",
  571. "",
  572. markdown_table(route_top[["name", "route", "bar_hits", "entry_hits", "trades", "win_rate", "profit_factor", "payoff_ratio"]]),
  573. ]
  574. ) + "\n"
  575. def main() -> int:
  576. parser = argparse.ArgumentParser()
  577. parser.add_argument("--years", type=float, default=YEARS)
  578. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  579. parser.add_argument("--max-candidates", type=int)
  580. args = parser.parse_args()
  581. eth_raw = _load_candles(ETH_SYMBOL, BAR, args.years)
  582. btc_raw = _load_candles(BTC_SYMBOL, BAR, args.years)
  583. eth, btc = _align_pair(eth_raw, btc_raw)
  584. if not eth:
  585. raise RuntimeError("no aligned ETH/BTC candles")
  586. candidates = specs()
  587. if args.max_candidates is not None:
  588. candidates = candidates[: args.max_candidates]
  589. total_rows: list[dict[str, object]] = []
  590. horizon_output: list[dict[str, object]] = []
  591. route_output: list[dict[str, object]] = []
  592. for index, spec in enumerate(candidates, start=1):
  593. result, stats = run_router(eth, btc, spec)
  594. print(f"done {index}/{len(candidates)} {spec.name} trades={result.trade_count}", flush=True)
  595. for cost_model, cost in COSTS:
  596. frame = cost_frame(result, cost, eth[-1].ts)
  597. start_ts = int(pd.Timestamp(frame["ts"].iloc[0]).timestamp() * 1000)
  598. end_ts = int(pd.Timestamp(frame["ts"].iloc[-1]).timestamp() * 1000)
  599. metrics = equity_metrics(frame, start_ts, end_ts)
  600. current_horizons = horizon_rows(spec.name, frame, result.trades, cost)
  601. min_recent = min(float(row["total_return"]) for row in current_horizons if row["horizon"] != "full")
  602. total_rows.append(
  603. {
  604. "name": spec.name,
  605. "cost_model": cost_model,
  606. "symbol": ETH_SYMBOL,
  607. "signal_symbol": BTC_SYMBOL,
  608. "bar": BAR,
  609. "first_candle": pd.Timestamp(frame["ts"].iloc[0]).strftime("%Y-%m-%d %H:%M"),
  610. "last_candle": pd.Timestamp(frame["ts"].iloc[-1]).strftime("%Y-%m-%d %H:%M"),
  611. "years": (end_ts - start_ts) / 86_400_000 / 365,
  612. **metrics,
  613. "min_recent_total_return": min_recent,
  614. **trade_stats(result.trades, cost),
  615. **stats,
  616. **spec.__dict__,
  617. }
  618. )
  619. for row in current_horizons:
  620. horizon_output.append({"cost_model": cost_model, **row})
  621. for row in route_rows(spec.name, result.trades, cost, stats):
  622. route_output.append({"cost_model": cost_model, **row})
  623. total = pd.DataFrame(total_rows).sort_values(
  624. ["cost_model", "min_recent_total_return", "calmar", "annualized_return", "trades"],
  625. ascending=[True, False, False, False, True],
  626. )
  627. horizon = pd.DataFrame(horizon_output)
  628. horizon["horizon"] = pd.Categorical(horizon["horizon"], categories=[label for label, _ in HORIZONS], ordered=True)
  629. horizon = horizon.sort_values(["cost_model", "name", "horizon"])
  630. route = pd.DataFrame(route_output).sort_values(["cost_model", "name", "bar_hits"], ascending=[True, True, False])
  631. args.output_dir.mkdir(parents=True, exist_ok=True)
  632. total_path = args.output_dir / f"{PREFIX}-total.csv"
  633. horizon_path = args.output_dir / f"{PREFIX}-horizons.csv"
  634. route_path = args.output_dir / f"{PREFIX}-states.csv"
  635. top_path = args.output_dir / f"{PREFIX}-top10.csv"
  636. json_path = args.output_dir / f"{PREFIX}-summary.json"
  637. report_path = args.output_dir / f"{PREFIX}-report.md"
  638. total.to_csv(total_path, index=False)
  639. horizon.to_csv(horizon_path, index=False)
  640. route.to_csv(route_path, index=False)
  641. total[total["cost_model"] == PRIMARY_COST].head(10).to_csv(top_path, index=False)
  642. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --years {args.years}"
  643. output_files = [total_path, horizon_path, route_path, top_path, json_path, report_path]
  644. summary = {
  645. "report": PREFIX,
  646. "command": command,
  647. "primary_cost": PRIMARY_COST,
  648. "candidate_count": len(candidates),
  649. "horizons": [label for label, _ in HORIZONS],
  650. "top_maker_taker": total[total["cost_model"] == PRIMARY_COST].head(10).to_dict("records"),
  651. "output_files": [str(path) for path in output_files],
  652. }
  653. json_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
  654. report_path.write_text(report_text(command, output_files, total, horizon, route), encoding="utf-8")
  655. print(total[total["cost_model"] == PRIMARY_COST].head(10).to_string(index=False))
  656. return 0
  657. if __name__ == "__main__":
  658. raise SystemExit(main())