search_eth_btc_low_turnover_variants.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. from __future__ import annotations
  2. import argparse
  3. import json
  4. import sys
  5. from dataclasses import dataclass
  6. from pathlib import Path
  7. import pandas as pd
  8. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  9. from okx_codex_trader.models import Candle
  10. from okx_codex_trader.sampled_report import SegmentResult, mark_to_market
  11. from scripts import explore_ultrashort as explore
  12. from scripts.search_eth_btc_nextgen_variants import (
  13. HORIZONS,
  14. COSTS,
  15. format_cell,
  16. load_candles,
  17. markdown_table,
  18. metrics_from_daily_equity,
  19. monthly_rows,
  20. )
  21. OUTPUT_DIR = Path("reports/eth-exploration")
  22. PREFIX = "eth-btc-low-turnover"
  23. YEARS = 10.0
  24. PRIMARY_COST = "maker_taker"
  25. def daily_equity(frame: pd.DataFrame, start: pd.Timestamp, end: pd.Timestamp) -> pd.Series:
  26. series = frame.set_index("ts")["equity"].sort_index()
  27. start_day = start.normalize()
  28. end_day = end.normalize()
  29. series = pd.concat([pd.Series([explore.INITIAL_EQUITY], index=[start_day]), series]).sort_index()
  30. index = pd.date_range(start_day, end_day, freq="1D", tz="UTC")
  31. return series.reindex(index.union(series.index)).sort_index().ffill().reindex(index)
  32. @dataclass(frozen=True)
  33. class Params:
  34. eth_trend_sma: int
  35. eth_rsi_threshold: float
  36. eth_exit_rsi: float
  37. btc_trend_sma: int
  38. btc_momentum_lookback: int
  39. btc_min_momentum: float
  40. min_signal_edge: float
  41. cooldown_bars: int
  42. btc_vol_lookback: int
  43. btc_max_realized_vol: float
  44. session: str
  45. max_trades_per_month: int
  46. adverse_lookback: int
  47. adverse_btc_return: float
  48. max_hold_bars: int
  49. @property
  50. def name(self) -> str:
  51. return (
  52. f"lt-et{self.eth_trend_sma}-l{self.eth_rsi_threshold}-x{self.eth_exit_rsi}"
  53. f"-bt{self.btc_trend_sma}-bm{self.btc_momentum_lookback}-br{self.btc_min_momentum}"
  54. f"-edge{self.min_signal_edge}-cd{self.cooldown_bars}-vw{self.btc_vol_lookback}"
  55. f"-vc{self.btc_max_realized_vol}-s{self.session}-mt{self.max_trades_per_month}"
  56. f"-aw{self.adverse_lookback}-ar{self.adverse_btc_return}-mh{self.max_hold_bars}"
  57. )
  58. def session_allowed(ts: int, session: str) -> bool:
  59. hour = pd.to_datetime(ts, unit="ms", utc=True).hour
  60. if session == "all":
  61. return True
  62. if session == "asia":
  63. return 0 <= hour < 8
  64. if session == "europe":
  65. return 7 <= hour < 16
  66. if session == "us":
  67. return 13 <= hour < 22
  68. raise ValueError(f"unknown session {session}")
  69. def close_position(
  70. *,
  71. trades: list[dict[str, object]],
  72. exits: list[dict[str, object]],
  73. position: dict[str, object],
  74. candle: Candle,
  75. exit_price: float,
  76. leverage: int,
  77. ) -> tuple[float, bool]:
  78. exit_equity = explore.trade_equity(
  79. side="long",
  80. margin_used=float(position["margin_used"]),
  81. entry_price=float(position["entry_price"]),
  82. exit_price=exit_price,
  83. leverage=leverage,
  84. )
  85. pnl = exit_equity - float(position["margin_used"])
  86. trades.append(
  87. {
  88. "side": "Long",
  89. "entry_time": explore._format_ts(int(position["entry_time"])),
  90. "exit_time": explore._format_ts(candle.ts),
  91. "entry_price": round(float(position["entry_price"]), 4),
  92. "exit_price": round(exit_price, 4),
  93. "pnl": round(pnl, 4),
  94. "return_pct": round(pnl / float(position["margin_used"]) * 100, 4),
  95. }
  96. )
  97. exits.append({"ts": candle.ts, "price": exit_price, "side": "long"})
  98. return exit_equity, pnl > 0.0
  99. def run_low_turnover_segment(
  100. *,
  101. eth_candles: list[Candle],
  102. btc_candles: list[Candle],
  103. leverage: int,
  104. params: Params,
  105. ) -> SegmentResult:
  106. eth_closes = pd.Series([candle.close for candle in eth_candles], dtype=float)
  107. btc_closes = pd.Series([candle.close for candle in btc_candles], dtype=float)
  108. eth_trend = eth_closes.rolling(params.eth_trend_sma).mean().tolist()
  109. eth_rsi = explore._compute_rsi(eth_closes, 2)
  110. btc_trend = btc_closes.rolling(params.btc_trend_sma).mean().tolist()
  111. btc_returns = btc_closes.pct_change()
  112. btc_realized_vol = btc_returns.rolling(params.btc_vol_lookback).std(ddof=1).tolist()
  113. btc_recent_min_return = btc_returns.rolling(params.adverse_lookback).min().tolist()
  114. warmup_bars = max(
  115. params.eth_trend_sma,
  116. params.btc_trend_sma,
  117. params.btc_momentum_lookback,
  118. params.btc_vol_lookback,
  119. params.adverse_lookback,
  120. 3,
  121. )
  122. equity = explore.INITIAL_EQUITY
  123. ending_equity = equity
  124. peak_equity = equity
  125. max_drawdown = 0.0
  126. wins = 0
  127. trades: list[dict[str, object]] = []
  128. entries: list[dict[str, object]] = []
  129. exits: list[dict[str, object]] = []
  130. equity_curve: list[dict[str, float | int]] = []
  131. position: dict[str, object] | None = None
  132. pending_entry = False
  133. pending_exit = False
  134. last_exit_index = -10**9
  135. month_counts: dict[str, int] = {}
  136. for index in range(warmup_bars, len(eth_candles)):
  137. candle = eth_candles[index]
  138. if pending_exit and position is not None:
  139. equity, won = close_position(
  140. trades=trades,
  141. exits=exits,
  142. position=position,
  143. candle=candle,
  144. exit_price=candle.open,
  145. leverage=leverage,
  146. )
  147. wins += 1 if won else 0
  148. position = None
  149. pending_exit = False
  150. last_exit_index = index
  151. if pending_entry and position is None and equity > 0.0:
  152. entry_month = pd.to_datetime(candle.ts, unit="ms", utc=True).strftime("%Y-%m")
  153. month_counts[entry_month] = month_counts.get(entry_month, 0) + 1
  154. position = {
  155. "side": "long",
  156. "entry_time": candle.ts,
  157. "entry_price": candle.open,
  158. "entry_index": index,
  159. "margin_used": equity,
  160. }
  161. entries.append({"ts": candle.ts, "price": candle.open, "side": "long"})
  162. pending_entry = False
  163. current_equity = equity
  164. if position is not None:
  165. current_equity = mark_to_market(
  166. side="long",
  167. margin_used=float(position["margin_used"]),
  168. entry_price=float(position["entry_price"]),
  169. mark_price=candle.close,
  170. leverage=leverage,
  171. )
  172. peak_equity = max(peak_equity, current_equity)
  173. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  174. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  175. ending_equity = current_equity
  176. if index == len(eth_candles) - 1 or equity <= 0.0:
  177. continue
  178. current_eth_trend = eth_trend[index]
  179. current_eth_rsi = eth_rsi[index]
  180. current_btc_trend = btc_trend[index]
  181. current_btc_vol = btc_realized_vol[index]
  182. current_btc_recent_min_return = btc_recent_min_return[index]
  183. if (
  184. current_eth_trend != current_eth_trend
  185. or current_eth_rsi != current_eth_rsi
  186. or current_btc_trend != current_btc_trend
  187. or current_btc_vol != current_btc_vol
  188. or current_btc_recent_min_return != current_btc_recent_min_return
  189. ):
  190. continue
  191. btc_momentum = btc_candles[index].close / btc_candles[index - params.btc_momentum_lookback].close - 1.0
  192. if position is not None:
  193. held_bars = index - int(position["entry_index"])
  194. if current_eth_rsi >= params.eth_exit_rsi or btc_candles[index].close < float(current_btc_trend) or held_bars >= params.max_hold_bars:
  195. pending_exit = True
  196. continue
  197. entry_month = pd.to_datetime(candle.ts, unit="ms", utc=True).strftime("%Y-%m")
  198. signal_edge = (btc_momentum - params.btc_min_momentum) + ((params.eth_rsi_threshold - current_eth_rsi) / 100.0)
  199. btc_risk_on = btc_candles[index].close > float(current_btc_trend) and btc_momentum >= params.btc_min_momentum
  200. eth_pullback = candle.close > float(current_eth_trend) and current_eth_rsi <= params.eth_rsi_threshold
  201. if (
  202. btc_risk_on
  203. and eth_pullback
  204. and signal_edge >= params.min_signal_edge
  205. and index - last_exit_index >= params.cooldown_bars
  206. and float(current_btc_vol) <= params.btc_max_realized_vol
  207. and session_allowed(candle.ts, params.session)
  208. and month_counts.get(entry_month, 0) < params.max_trades_per_month
  209. and float(current_btc_recent_min_return) > -params.adverse_btc_return
  210. ):
  211. pending_entry = True
  212. trade_count = len(trades)
  213. return SegmentResult(
  214. trade_count=trade_count,
  215. total_return=(ending_equity - explore.INITIAL_EQUITY) / explore.INITIAL_EQUITY,
  216. win_rate=wins / trade_count if trade_count else 0.0,
  217. max_drawdown=max_drawdown,
  218. trades=trades,
  219. open_position=position,
  220. candles=eth_candles[warmup_bars:],
  221. equity_curve=equity_curve,
  222. entries=entries,
  223. exits=exits,
  224. )
  225. def build_params() -> list[Params]:
  226. return [
  227. Params(
  228. eth_trend_sma=eth_trend,
  229. eth_rsi_threshold=eth_rsi,
  230. eth_exit_rsi=exit_rsi,
  231. btc_trend_sma=btc_trend,
  232. btc_momentum_lookback=btc_momentum,
  233. btc_min_momentum=btc_min,
  234. min_signal_edge=edge,
  235. cooldown_bars=cooldown,
  236. btc_vol_lookback=vol_lookback,
  237. btc_max_realized_vol=vol_cap,
  238. session=session,
  239. max_trades_per_month=max_trades,
  240. adverse_lookback=adverse_lookback,
  241. adverse_btc_return=adverse_return,
  242. max_hold_bars=max_hold,
  243. )
  244. for eth_trend in (50,)
  245. for eth_rsi in (2.0, 3.0)
  246. for exit_rsi in (55.0,)
  247. for btc_trend in (480, 720)
  248. for btc_momentum in (240, 480)
  249. for btc_min in (0.01, 0.02)
  250. for edge in (0.0, 0.01)
  251. for cooldown in (32,)
  252. for vol_lookback in (240,)
  253. for vol_cap in (0.006, 0.008)
  254. for session in ("all", "us")
  255. for max_trades in (4,)
  256. for adverse_lookback in (4,)
  257. for adverse_return in (0.015,)
  258. for max_hold in (96,)
  259. ]
  260. def trade_stats_for_window(result: SegmentResult, cost: float, start: pd.Timestamp, end: pd.Timestamp) -> dict[str, float | int]:
  261. returns: list[float] = []
  262. for trade in result.trades:
  263. exit_time = pd.to_datetime(str(trade["exit_time"]), utc=True)
  264. if start <= exit_time <= end:
  265. returns.append(float(trade["return_pct"]) / 100.0 - cost * float(trade.get("cost_weight", 1.0)))
  266. wins = [value for value in returns if value > 0.0]
  267. losses = [value for value in returns if value < 0.0]
  268. avg_win = sum(wins) / len(wins) if wins else 0.0
  269. avg_loss_abs = abs(sum(losses) / len(losses)) if losses else 0.0
  270. gross_profit = sum(wins)
  271. gross_loss_abs = abs(sum(losses))
  272. return {
  273. "trades": len(returns),
  274. "win_rate": len(wins) / len(returns) if returns else 0.0,
  275. "payoff_ratio": avg_win / avg_loss_abs if avg_loss_abs else 0.0,
  276. "profit_factor": gross_profit / gross_loss_abs if gross_loss_abs else 0.0,
  277. }
  278. def horizon_rows(name: str, result: SegmentResult, cost: float, series: pd.Series) -> list[dict[str, object]]:
  279. rows: list[dict[str, object]] = []
  280. end_time = series.index[-1]
  281. for label, offset in HORIZONS:
  282. horizon = series if offset is None else series[series.index >= end_time - offset]
  283. if len(horizon) < 2:
  284. horizon = series
  285. stats = trade_stats_for_window(result, cost, horizon.index[0], horizon.index[-1])
  286. rows.append(
  287. {
  288. "name": name,
  289. "horizon": label,
  290. "horizon_start": horizon.index[0].strftime("%Y-%m-%d"),
  291. "horizon_end": horizon.index[-1].strftime("%Y-%m-%d"),
  292. **metrics_from_daily_equity(horizon),
  293. **stats,
  294. }
  295. )
  296. return rows
  297. def valid_all_horizons(horizon: pd.DataFrame, cost_model: str) -> set[str]:
  298. scoped = horizon[horizon["cost_model"] == cost_model]
  299. pivot = scoped.pivot_table(index="name", columns="horizon", values="net_total_return", aggfunc="min")
  300. required = pivot.reindex(columns=["full", "3y", "1y", "6m", "3m"])
  301. return set(required[(required > 0.0).all(axis=1)].index)
  302. def markdown_report(
  303. *,
  304. command: str,
  305. paths: list[Path],
  306. totals: pd.DataFrame,
  307. horizon: pd.DataFrame,
  308. monthly_summary: pd.DataFrame,
  309. worst_months: pd.DataFrame,
  310. qualified_names: set[str],
  311. ) -> str:
  312. taker_positive = totals[(totals["cost_model"] == "taker_taker") & (totals["name"].isin(qualified_names))]
  313. top = taker_positive.head(10) if len(taker_positive) else totals[totals["cost_model"] == PRIMARY_COST].head(10)
  314. top_name = str(top.iloc[0]["name"]) if len(top) else ""
  315. top_horizon = horizon[(horizon["name"] == top_name) & (horizon["cost_model"].isin(("maker_taker", "taker_taker")))]
  316. lines = [
  317. "# ETH BTC low-turnover nextgen exploration",
  318. "",
  319. f"Run command: `{command}`",
  320. "",
  321. "Output files:",
  322. *[f"- `{path}`" for path in paths],
  323. "",
  324. "Scope: ETH-only long entries from BTC trend/momentum regime plus extreme ETH RSI2 pullback.",
  325. "Entry constraints tested: longer BTC trend, lower ETH RSI threshold, signal edge floor, cooldown, BTC volatility cap, session filter, monthly trade cap, and rejection after adverse BTC candles.",
  326. "Costs: maker_taker=0.0021 and taker_taker=0.0030 roundtrip on margin at 3x.",
  327. "",
  328. f"Taker-taker candidates positive across full/3y/1y/6m/3m: {len(taker_positive)}.",
  329. "",
  330. "## Top candidates",
  331. "",
  332. markdown_table(
  333. top[
  334. [
  335. "name",
  336. "cost_model",
  337. "trades",
  338. "trades_per_month",
  339. "net_total_return",
  340. "net_annualized_return",
  341. "net_max_drawdown",
  342. "net_calmar",
  343. "win_rate",
  344. "payoff_ratio",
  345. "profit_factor",
  346. "risk_reward_ratio",
  347. "worst_month_return",
  348. ]
  349. ]
  350. ),
  351. "",
  352. "## Horizon metrics for selected candidate",
  353. "",
  354. markdown_table(
  355. top_horizon[
  356. [
  357. "cost_model",
  358. "horizon",
  359. "horizon_start",
  360. "horizon_end",
  361. "net_total_return",
  362. "net_annualized_return",
  363. "net_max_drawdown",
  364. "net_calmar",
  365. "win_rate",
  366. "payoff_ratio",
  367. "profit_factor",
  368. "risk_reward_ratio",
  369. "trades",
  370. ]
  371. ]
  372. ),
  373. "",
  374. "## Monthly summary",
  375. "",
  376. markdown_table(monthly_summary[monthly_summary["name"].isin(set(top.head(5)["name"]))].head(20)),
  377. "",
  378. "## Worst months",
  379. "",
  380. markdown_table(worst_months[worst_months["name"].isin(set(top.head(5)["name"]))].head(20)),
  381. ]
  382. return "\n".join(lines) + "\n"
  383. def main() -> int:
  384. parser = argparse.ArgumentParser()
  385. parser.add_argument("--years", type=float, default=YEARS)
  386. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  387. parser.add_argument("--max-candidates", type=int, default=0)
  388. args = parser.parse_args()
  389. eth, btc = explore.align_pair_candles(
  390. load_candles("ETH-USDT-SWAP", "15m", args.years),
  391. load_candles("BTC-USDT-SWAP", "15m", args.years),
  392. )
  393. params_grid = build_params()
  394. if args.max_candidates:
  395. params_grid = params_grid[: args.max_candidates]
  396. start = pd.to_datetime(eth[max(param.btc_trend_sma for param in params_grid)].ts, unit="ms", utc=True)
  397. end = pd.to_datetime(eth[-1].ts, unit="ms", utc=True)
  398. total_rows: list[dict[str, object]] = []
  399. horizon_output: list[dict[str, object]] = []
  400. monthly_frames: list[pd.DataFrame] = []
  401. for index, params in enumerate(params_grid, start=1):
  402. result = run_low_turnover_segment(eth_candles=eth, btc_candles=btc, leverage=explore.LEVERAGE, params=params)
  403. for cost_model, cost_value in COSTS.items():
  404. frame = explore.cost_adjusted_trade_equity_frame(result, cost_value)
  405. daily = daily_equity(frame, start, end)
  406. metrics = metrics_from_daily_equity(daily)
  407. monthly = monthly_rows(params.name, daily)
  408. stats = trade_stats_for_window(result, cost_value, daily.index[0], daily.index[-1])
  409. total_rows.append(
  410. {
  411. "name": params.name,
  412. "cost_model": cost_model,
  413. "roundtrip_cost_on_margin": cost_value,
  414. "first_candle": start.strftime("%Y-%m-%d %H:%M"),
  415. "last_candle": end.strftime("%Y-%m-%d %H:%M"),
  416. "years": (end - start).total_seconds() / 86_400 / 365,
  417. "trades_per_month": stats["trades"] / max((end - start).days / 30.4375, 1.0),
  418. "gross_total_return": result.total_return,
  419. "gross_max_drawdown_mark_to_market": result.max_drawdown,
  420. "worst_month_return": float(monthly["return"].min()),
  421. **params.__dict__,
  422. **stats,
  423. **metrics,
  424. }
  425. )
  426. for row in horizon_rows(params.name, result, cost_value, daily):
  427. horizon_output.append({"cost_model": cost_model, **row})
  428. monthly_frames.append(monthly.assign(cost_model=cost_model))
  429. print(f"done {index}/{len(params_grid)} {params.name}", flush=True)
  430. totals = pd.DataFrame(total_rows).sort_values(
  431. ["cost_model", "net_calmar", "net_annualized_return", "trades"],
  432. ascending=[True, False, False, True],
  433. )
  434. horizon = pd.DataFrame(horizon_output)
  435. horizon["horizon"] = pd.Categorical(horizon["horizon"], categories=["full", "3y", "1y", "6m", "3m"], ordered=True)
  436. horizon = horizon.sort_values(["cost_model", "name", "horizon"])
  437. monthly = pd.concat(monthly_frames, ignore_index=True)
  438. monthly_summary = (
  439. monthly.groupby(["cost_model", "name"], as_index=False)
  440. .agg(
  441. months=("return", "count"),
  442. positive_month_rate=("return", lambda values: float((values > 0.0).mean())),
  443. avg_month_return=("return", "mean"),
  444. median_month_return=("return", "median"),
  445. worst_month_return=("return", "min"),
  446. best_month_return=("return", "max"),
  447. )
  448. .sort_values(["cost_model", "worst_month_return", "positive_month_rate"], ascending=[True, False, False])
  449. )
  450. worst_months = monthly.sort_values("return").head(250)
  451. taker_positive_names = valid_all_horizons(horizon, "taker_taker")
  452. qualified = totals[(totals["cost_model"] == "taker_taker") & (totals["name"].isin(taker_positive_names))].copy()
  453. qualified = qualified.sort_values(["net_calmar", "net_annualized_return", "trades"], ascending=[False, False, True])
  454. args.output_dir.mkdir(parents=True, exist_ok=True)
  455. totals_path = args.output_dir / f"{PREFIX}-totals.csv"
  456. horizon_path = args.output_dir / f"{PREFIX}-horizons.csv"
  457. monthly_path = args.output_dir / f"{PREFIX}-monthly-summary.csv"
  458. worst_path = args.output_dir / f"{PREFIX}-worst-months.csv"
  459. qualified_path = args.output_dir / f"{PREFIX}-taker-positive.csv"
  460. report_path = args.output_dir / f"{PREFIX}-report.md"
  461. best_path = args.output_dir / f"{PREFIX}-best.json"
  462. totals.to_csv(totals_path, index=False)
  463. horizon.to_csv(horizon_path, index=False)
  464. monthly_summary.to_csv(monthly_path, index=False)
  465. worst_months.to_csv(worst_path, index=False)
  466. qualified.to_csv(qualified_path, index=False)
  467. best_payload = qualified.head(1).to_dict(orient="records")[0] if len(qualified) else totals.head(1).to_dict(orient="records")[0]
  468. best_path.write_text(json.dumps(best_payload, indent=2), encoding="utf-8")
  469. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --years {args.years}"
  470. paths = [totals_path, horizon_path, monthly_path, worst_path, qualified_path, best_path, report_path]
  471. report_path.write_text(
  472. markdown_report(
  473. command=command,
  474. paths=paths,
  475. totals=totals,
  476. horizon=horizon,
  477. monthly_summary=monthly_summary,
  478. worst_months=worst_months,
  479. qualified_names=taker_positive_names,
  480. ),
  481. encoding="utf-8",
  482. )
  483. print(qualified.head(10).to_string(index=False) if len(qualified) else totals.head(10).to_string(index=False))
  484. return 0
  485. if __name__ == "__main__":
  486. raise SystemExit(main())