explore_recent_market_adaptation.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  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. DATA_DIR = Path("data/okx-candles")
  9. OUTPUT_DIR = Path("reports/recent-market-adaptation")
  10. ETH = "ETH-USDT-SWAP"
  11. BTC = "BTC-USDT-SWAP"
  12. BAR = "15m"
  13. INITIAL_EQUITY = 10_000.0
  14. LEVERAGE = 3.0
  15. ROUNDTRIP_COST_ON_MARGIN = 0.0021
  16. HORIZONS = (
  17. ("full", None),
  18. ("3y", pd.DateOffset(years=3)),
  19. ("1y", pd.DateOffset(years=1)),
  20. ("6m", pd.DateOffset(months=6)),
  21. ("3m", pd.DateOffset(months=3)),
  22. ("30d", pd.DateOffset(days=30)),
  23. ("14d", pd.DateOffset(days=14)),
  24. )
  25. @dataclass(frozen=True)
  26. class Strategy:
  27. name: str
  28. description: str
  29. kind: str
  30. STRATEGIES = (
  31. Strategy(
  32. name="btc-lead-momentum",
  33. kind="btc_lead",
  34. description="Trade ETH in the direction of BTC 2h momentum when ETH confirms over 1h; fixed stop, take-profit, or 4h max hold.",
  35. ),
  36. Strategy(
  37. name="eth-compression-breakout",
  38. kind="breakout",
  39. description="Trade ETH 15m close breakouts from a compressed 12h range; fixed stop, take-profit, or 6h max hold.",
  40. ),
  41. Strategy(
  42. name="eth-btc-relative-weakness-short",
  43. kind="relative_weak",
  44. description="Short ETH when BTC is rising over 6h while ETH/BTC keeps falling; fixed stop, take-profit, or 6h max hold.",
  45. ),
  46. Strategy(
  47. name="recent-style-router",
  48. kind="router",
  49. description="Use prior 30d ETH-vs-BTC relative return and prior 90d volatility: relative weakness routes short, trend/high-vol routes breakout, otherwise BTC lead momentum.",
  50. ),
  51. )
  52. def load_frame(symbol: str, bar: str) -> pd.DataFrame:
  53. path = DATA_DIR / symbol / f"{bar}.csv"
  54. frame = pd.read_csv(path)
  55. frame["ts"] = pd.to_datetime(frame["ts"], unit="ms", utc=True)
  56. return frame.sort_values("ts").drop_duplicates("ts", keep="last").set_index("ts")
  57. def load_market() -> pd.DataFrame:
  58. eth = load_frame(ETH, BAR).add_prefix("eth_")
  59. btc = load_frame(BTC, BAR).add_prefix("btc_")
  60. frame = eth.join(btc, how="inner")
  61. frame["eth_ret"] = frame["eth_close"].pct_change()
  62. frame["btc_ret"] = frame["btc_close"].pct_change()
  63. return frame.dropna().copy()
  64. def rsi(series: pd.Series, length: int) -> pd.Series:
  65. delta = series.diff()
  66. gain = delta.clip(lower=0.0).rolling(length).mean()
  67. loss = (-delta.clip(upper=0.0)).rolling(length).mean()
  68. rs = gain / loss
  69. return 100.0 - 100.0 / (1.0 + rs)
  70. def add_signals(frame: pd.DataFrame) -> pd.DataFrame:
  71. out = frame.copy()
  72. out["btc_2h_return"] = out["btc_close"] / out["btc_close"].shift(8) - 1.0
  73. out["eth_1h_return"] = out["eth_close"] / out["eth_close"].shift(4) - 1.0
  74. out["btc_lead_raw"] = 0
  75. out.loc[(out["btc_2h_return"] >= 0.008) & (out["eth_1h_return"] >= 0.002), "btc_lead_raw"] = 1
  76. out.loc[(out["btc_2h_return"] <= -0.008) & (out["eth_1h_return"] <= -0.002), "btc_lead_raw"] = -1
  77. out["btc_lead_signal"] = out["btc_lead_raw"].where(out["btc_lead_raw"] != out["btc_lead_raw"].shift(1), 0)
  78. prior_high = out["eth_high"].shift(1).rolling(48).max()
  79. prior_low = out["eth_low"].shift(1).rolling(48).min()
  80. width = (prior_high - prior_low) / out["eth_close"]
  81. compressed = width <= width.rolling(384).quantile(0.30)
  82. out["breakout_raw"] = 0
  83. out.loc[compressed & (out["eth_close"] > prior_high), "breakout_raw"] = 1
  84. out.loc[compressed & (out["eth_close"] < prior_low), "breakout_raw"] = -1
  85. out["breakout_signal"] = out["breakout_raw"].where(out["breakout_raw"] != out["breakout_raw"].shift(1), 0)
  86. out["eth_rsi2"] = rsi(out["eth_close"], 2)
  87. out["eth_24h_return_abs"] = (out["eth_close"] / out["eth_close"].shift(96) - 1.0).abs()
  88. weak_trend = out["eth_24h_return_abs"] <= 0.035
  89. out["reversion_raw"] = 0
  90. out.loc[weak_trend & (out["eth_rsi2"] <= 8.0), "reversion_raw"] = 1
  91. out.loc[weak_trend & (out["eth_rsi2"] >= 92.0), "reversion_raw"] = -1
  92. out["reversion_signal"] = out["reversion_raw"].where(out["reversion_raw"] != out["reversion_raw"].shift(1), 0)
  93. out["eth_btc_ratio"] = out["eth_close"] / out["btc_close"]
  94. out["btc_6h_return"] = out["btc_close"] / out["btc_close"].shift(24) - 1.0
  95. out["ratio_6h_return"] = out["eth_btc_ratio"] / out["eth_btc_ratio"].shift(24) - 1.0
  96. out["relative_weak_raw"] = 0
  97. out.loc[(out["btc_6h_return"] >= 0.006) & (out["ratio_6h_return"] <= -0.010), "relative_weak_raw"] = -1
  98. out["relative_weak_signal"] = out["relative_weak_raw"].where(out["relative_weak_raw"] != out["relative_weak_raw"].shift(1), 0)
  99. out["eth_30d_return"] = out["eth_close"] / out["eth_close"].shift(96 * 30) - 1.0
  100. out["btc_30d_return"] = out["btc_close"] / out["btc_close"].shift(96 * 30) - 1.0
  101. out["eth_90d_vol"] = out["eth_ret"].rolling(96 * 90).std(ddof=0) * (96 * 365) ** 0.5
  102. relative_weak_style = (out["eth_30d_return"] <= out["btc_30d_return"] - 0.06) & (out["eth_90d_vol"] <= 0.75)
  103. trend_style = (
  104. (out["eth_30d_return"].abs() >= 0.12)
  105. | (out["btc_30d_return"].abs() >= 0.10)
  106. | (out["eth_90d_vol"] >= 0.88)
  107. )
  108. out["router_signal"] = out["btc_lead_signal"]
  109. out.loc[trend_style, "router_signal"] = out.loc[trend_style, "breakout_signal"]
  110. out.loc[relative_weak_style, "router_signal"] = out.loc[relative_weak_style, "relative_weak_signal"]
  111. out["router_regime"] = "btc_lead_momentum"
  112. out.loc[trend_style, "router_regime"] = "trend_or_high_vol_breakout"
  113. out.loc[relative_weak_style, "router_regime"] = "relative_weakness_short"
  114. return out
  115. def exit_hit(side: int, entry: float, row: object, stop_pct: float, take_pct: float) -> float | None:
  116. stop = entry * (1.0 - stop_pct if side == 1 else 1.0 + stop_pct)
  117. take = entry * (1.0 + take_pct if side == 1 else 1.0 - take_pct)
  118. if side == 1:
  119. if float(row.eth_open) <= stop or float(row.eth_open) >= take:
  120. return float(row.eth_open)
  121. if float(row.eth_low) <= stop:
  122. return stop
  123. if float(row.eth_high) >= take:
  124. return take
  125. else:
  126. if float(row.eth_open) >= stop or float(row.eth_open) <= take:
  127. return float(row.eth_open)
  128. if float(row.eth_high) >= stop:
  129. return stop
  130. if float(row.eth_low) <= take:
  131. return take
  132. return None
  133. def exit_by_rule(kind: str, side: int, row: object) -> bool:
  134. if kind == "reversion":
  135. return (side == 1 and float(row.eth_rsi2) >= 52.0) or (side == -1 and float(row.eth_rsi2) <= 48.0)
  136. return False
  137. def params_for(kind: str, row: object) -> tuple[str, int, float, float, int]:
  138. if kind == "router":
  139. if str(row.router_regime) == "relative_weakness_short":
  140. return "relative_weak_signal", 24, 0.007, 0.012, 96 * 90
  141. if str(row.router_regime) == "trend_or_high_vol_breakout":
  142. return "breakout_signal", 24, 0.008, 0.014, 48 + 384
  143. return "btc_lead_signal", 16, 0.007, 0.011, 48 + 384
  144. if kind == "btc_lead":
  145. return "btc_lead_signal", 16, 0.007, 0.011, 8
  146. if kind == "breakout":
  147. return "breakout_signal", 24, 0.008, 0.014, 48 + 384
  148. if kind == "reversion":
  149. return "reversion_signal", 16, 0.006, 0.007, 96
  150. if kind == "relative_weak":
  151. return "relative_weak_signal", 24, 0.007, 0.012, 96 * 30
  152. raise ValueError(f"unknown strategy kind: {kind}")
  153. def run_strategy(frame: pd.DataFrame, strategy: Strategy) -> tuple[pd.DataFrame, list[dict[str, object]]]:
  154. rows = list(frame.itertuples())
  155. equity = INITIAL_EQUITY
  156. curve = [{"ts": frame.index[0], "equity": equity}]
  157. trades: list[dict[str, object]] = []
  158. position: dict[str, object] | None = None
  159. start_index = 96 * 90 if strategy.kind == "router" else 48 + 384
  160. index = start_index
  161. while index < len(rows) - 1:
  162. row = rows[index]
  163. if position is None:
  164. signal_col, hold, stop_pct, take_pct, _ = params_for(strategy.kind, row)
  165. side = int(getattr(row, signal_col))
  166. if side == 0:
  167. index += 1
  168. continue
  169. entry_index = index + 1
  170. entry_row = rows[entry_index]
  171. position = {
  172. "side": side,
  173. "entry_index": entry_index,
  174. "entry_time": frame.index[entry_index],
  175. "entry": float(entry_row.eth_open),
  176. "hold": hold,
  177. "stop_pct": stop_pct,
  178. "take_pct": take_pct,
  179. "routed_kind": "breakout"
  180. if signal_col == "breakout_signal"
  181. else "relative_weak"
  182. if signal_col == "relative_weak_signal"
  183. else "reversion"
  184. if signal_col == "reversion_signal"
  185. else "btc_lead",
  186. "regime": getattr(row, "router_regime", "") if strategy.kind == "router" else "",
  187. }
  188. index = entry_index
  189. continue
  190. side = int(position["side"])
  191. entry = float(position["entry"])
  192. exit_price = exit_hit(side, entry, row, float(position["stop_pct"]), float(position["take_pct"]))
  193. held = index - int(position["entry_index"])
  194. if exit_price is None and exit_by_rule(str(position["routed_kind"]), side, row):
  195. exit_price = float(row.eth_close)
  196. if exit_price is None and held >= int(position["hold"]):
  197. exit_price = float(row.eth_close)
  198. if exit_price is None:
  199. index += 1
  200. continue
  201. gross = exit_price / entry - 1.0 if side == 1 else entry / exit_price - 1.0
  202. net_return = gross * LEVERAGE - ROUNDTRIP_COST_ON_MARGIN
  203. equity *= 1.0 + net_return
  204. trades.append(
  205. {
  206. "strategy": strategy.name,
  207. "side": "long" if side == 1 else "short",
  208. "entry_time": pd.Timestamp(position["entry_time"]).strftime("%Y-%m-%d %H:%M"),
  209. "exit_time": frame.index[index].strftime("%Y-%m-%d %H:%M"),
  210. "entry_price": entry,
  211. "exit_price": exit_price,
  212. "net_return": net_return,
  213. "routed_kind": position["routed_kind"],
  214. "regime": position["regime"],
  215. }
  216. )
  217. curve.append({"ts": frame.index[index], "equity": equity})
  218. position = None
  219. index += 1
  220. if pd.Timestamp(curve[-1]["ts"]) < frame.index[-1]:
  221. curve.append({"ts": frame.index[-1], "equity": equity})
  222. return pd.DataFrame(curve), trades
  223. def max_drawdown(equity: pd.Series) -> float:
  224. peak = equity.cummax()
  225. return float(((peak - equity) / peak).max())
  226. def horizon_curve(curve: pd.DataFrame, start: pd.Timestamp) -> pd.DataFrame:
  227. before = curve[curve["ts"] <= start]
  228. if len(before):
  229. start_equity = float(before["equity"].iloc[-1])
  230. after = curve[curve["ts"] > start]
  231. return pd.concat([pd.DataFrame([{"ts": start, "equity": start_equity}]), after], ignore_index=True)
  232. return curve.copy()
  233. def trade_stats(trades: list[dict[str, object]], start: pd.Timestamp, end: pd.Timestamp) -> dict[str, object]:
  234. scoped = [trade for trade in trades if pd.to_datetime(str(trade["entry_time"]), utc=True) >= start]
  235. returns = [float(trade["net_return"]) for trade in scoped]
  236. wins = [value for value in returns if value > 0.0]
  237. losses = [value for value in returns if value < 0.0]
  238. gross_profit = sum(wins)
  239. gross_loss = abs(sum(losses))
  240. months = max((end - start).total_seconds() / 86_400.0 / 30.0, 1e-9)
  241. return {
  242. "trades": len(returns),
  243. "trades_per_30d": len(returns) / months,
  244. "win_rate": len(wins) / len(returns) if returns else 0.0,
  245. "profit_factor": gross_profit / gross_loss if gross_loss else 0.0,
  246. "avg_trade_return": sum(returns) / len(returns) if returns else 0.0,
  247. }
  248. def metrics(curve: pd.DataFrame, trades: list[dict[str, object]], label: str, start: pd.Timestamp, end: pd.Timestamp) -> dict[str, object]:
  249. current = horizon_curve(curve, start)
  250. years = max((end - start).total_seconds() / 86_400.0 / 365.0, 1e-9)
  251. total_return = float(current["equity"].iloc[-1] / current["equity"].iloc[0] - 1.0)
  252. annualized = (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 else -1.0
  253. drawdown = max_drawdown(current.set_index("ts")["equity"])
  254. return {
  255. "horizon": label,
  256. "start": start.strftime("%Y-%m-%d %H:%M"),
  257. "end": end.strftime("%Y-%m-%d %H:%M"),
  258. "total_return": total_return,
  259. "annualized_return": annualized,
  260. "max_drawdown": drawdown,
  261. "calmar": annualized / drawdown if drawdown else 0.0,
  262. **trade_stats(trades, start, end),
  263. }
  264. def recent_style_rows(frame: pd.DataFrame) -> list[dict[str, object]]:
  265. end = frame.index[-1]
  266. rows = []
  267. for label, offset in (("90d", pd.DateOffset(days=90)), ("30d", pd.DateOffset(days=30))):
  268. start = end - offset
  269. current = frame[frame.index >= start]
  270. eth_return = float(current["eth_close"].iloc[-1] / current["eth_close"].iloc[0] - 1.0)
  271. btc_return = float(current["btc_close"].iloc[-1] / current["btc_close"].iloc[0] - 1.0)
  272. eth_vol = float(current["eth_ret"].std(ddof=0) * (96 * 365) ** 0.5)
  273. corr = float(current["eth_ret"].corr(current["btc_ret"]))
  274. rows.append(
  275. {
  276. "window": label,
  277. "start": start.strftime("%Y-%m-%d %H:%M"),
  278. "end": end.strftime("%Y-%m-%d %H:%M"),
  279. "eth_return": eth_return,
  280. "btc_return": btc_return,
  281. "eth_annualized_vol": eth_vol,
  282. "eth_btc_corr_15m": corr,
  283. }
  284. )
  285. return rows
  286. def markdown_table(frame: pd.DataFrame) -> str:
  287. def cell(value: object) -> str:
  288. if isinstance(value, float):
  289. return f"{value:.6g}"
  290. return str(value).replace("|", "\\|")
  291. rows = [list(frame.columns), ["---" for _ in frame.columns]]
  292. rows.extend(frame.astype(object).where(pd.notna(frame), "").values.tolist())
  293. return "\n".join("| " + " | ".join(cell(value) for value in row) + " |" for row in rows)
  294. def write_report(
  295. path: Path,
  296. command: str,
  297. style: pd.DataFrame,
  298. summary: pd.DataFrame,
  299. horizons: pd.DataFrame,
  300. regime: pd.DataFrame,
  301. outputs: list[Path],
  302. ) -> None:
  303. primary = summary.sort_values(["min_recent_return", "calmar"], ascending=[False, False])
  304. lines = [
  305. "# Recent Market Adaptation Exploration",
  306. "",
  307. f"Command: `{command}`",
  308. "",
  309. "Scope: local OKX ETH/BTC 15m candle cache only. No live executor changes, no deployment, no orders.",
  310. "",
  311. f"Cost model: {ROUNDTRIP_COST_ON_MARGIN:.4f} roundtrip cost on margin, leverage {LEVERAGE:g}x.",
  312. "",
  313. "## Recent 90d/30d Style",
  314. "",
  315. markdown_table(style),
  316. "",
  317. "## Fixed Strategy Set",
  318. "",
  319. markdown_table(pd.DataFrame([strategy.__dict__ for strategy in STRATEGIES])),
  320. "",
  321. "## Summary",
  322. "",
  323. markdown_table(
  324. primary[
  325. [
  326. "strategy",
  327. "total_return",
  328. "annualized_return",
  329. "max_drawdown",
  330. "calmar",
  331. "trades",
  332. "trades_per_30d",
  333. "win_rate",
  334. "profit_factor",
  335. "min_recent_return",
  336. ]
  337. ]
  338. ),
  339. "",
  340. "## Required Horizons",
  341. "",
  342. markdown_table(
  343. horizons[
  344. [
  345. "strategy",
  346. "horizon",
  347. "total_return",
  348. "annualized_return",
  349. "max_drawdown",
  350. "calmar",
  351. "trades",
  352. "trades_per_30d",
  353. "win_rate",
  354. "profit_factor",
  355. ]
  356. ]
  357. ),
  358. "",
  359. "## Router Regime Split",
  360. "",
  361. markdown_table(regime),
  362. "",
  363. "## Output Files",
  364. "",
  365. *[f"- `{output}`" for output in outputs],
  366. "",
  367. ]
  368. path.write_text("\n".join(lines), encoding="utf-8")
  369. def main() -> int:
  370. parser = argparse.ArgumentParser()
  371. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  372. args = parser.parse_args()
  373. frame = add_signals(load_market())
  374. end = frame.index[-1]
  375. style = pd.DataFrame(recent_style_rows(frame))
  376. horizon_rows: list[dict[str, object]] = []
  377. summary_rows: list[dict[str, object]] = []
  378. trade_rows: list[dict[str, object]] = []
  379. regime_rows: list[dict[str, object]] = []
  380. for strategy in STRATEGIES:
  381. curve, trades = run_strategy(frame, strategy)
  382. trade_rows.extend(trades)
  383. rows = []
  384. for label, offset in HORIZONS:
  385. start = frame.index[0] if offset is None else max(frame.index[0], end - offset)
  386. row = {"strategy": strategy.name, **metrics(curve, trades, label, start, end)}
  387. rows.append(row)
  388. horizon_rows.append(row)
  389. full = rows[0]
  390. recent = [row["total_return"] for row in rows if row["horizon"] in {"3m", "30d", "14d"}]
  391. summary_rows.append(
  392. {
  393. "strategy": strategy.name,
  394. "description": strategy.description,
  395. **{key: value for key, value in full.items() if key not in {"horizon", "start", "end"}},
  396. "first_candle": frame.index[0].strftime("%Y-%m-%d %H:%M"),
  397. "last_candle": end.strftime("%Y-%m-%d %H:%M"),
  398. "min_recent_return": min(float(value) for value in recent),
  399. }
  400. )
  401. if strategy.kind == "router":
  402. router_trades = pd.DataFrame(trades)
  403. if len(router_trades):
  404. grouped = router_trades.groupby(["regime", "routed_kind"])["net_return"].agg(["count", "mean", "sum"]).reset_index()
  405. regime_rows.extend(grouped.rename(columns={"count": "trades", "mean": "avg_trade_return", "sum": "sum_trade_return"}).to_dict("records"))
  406. summary = pd.DataFrame(summary_rows).sort_values(["min_recent_return", "calmar"], ascending=[False, False])
  407. horizons = pd.DataFrame(horizon_rows)
  408. horizon_order = [label for label, _ in HORIZONS]
  409. horizons["horizon"] = pd.Categorical(horizons["horizon"], categories=horizon_order, ordered=True)
  410. horizons = horizons.sort_values(["strategy", "horizon"])
  411. trades = pd.DataFrame(trade_rows)
  412. regime = pd.DataFrame(regime_rows) if regime_rows else pd.DataFrame(columns=["regime", "routed_kind", "trades", "avg_trade_return", "sum_trade_return"])
  413. args.output_dir.mkdir(parents=True, exist_ok=True)
  414. style_path = args.output_dir / "recent-style.csv"
  415. summary_path = args.output_dir / "strategy-summary.csv"
  416. horizon_path = args.output_dir / "strategy-horizons.csv"
  417. trades_path = args.output_dir / "strategy-trades.csv"
  418. regime_path = args.output_dir / "router-regime-split.csv"
  419. json_path = args.output_dir / "summary.json"
  420. report_path = args.output_dir / "report.md"
  421. style.to_csv(style_path, index=False)
  422. summary.to_csv(summary_path, index=False)
  423. horizons.to_csv(horizon_path, index=False)
  424. trades.to_csv(trades_path, index=False)
  425. regime.to_csv(regime_path, index=False)
  426. outputs = [style_path, summary_path, horizon_path, trades_path, regime_path, json_path, report_path]
  427. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --output-dir {args.output_dir.as_posix()}"
  428. json_path.write_text(
  429. json.dumps(
  430. {
  431. "command": command,
  432. "first_candle": frame.index[0].isoformat(),
  433. "last_candle": end.isoformat(),
  434. "cost_model": {"roundtrip_cost_on_margin": ROUNDTRIP_COST_ON_MARGIN, "leverage": LEVERAGE},
  435. "top_summary": summary.to_dict("records"),
  436. "outputs": [str(output) for output in outputs],
  437. },
  438. indent=2,
  439. ),
  440. encoding="utf-8",
  441. )
  442. write_report(report_path, command, style, summary, horizons, regime, outputs)
  443. print(summary.to_string(index=False))
  444. print(f"wrote {report_path}")
  445. return 0
  446. if __name__ == "__main__":
  447. raise SystemExit(main())