search_eth_twap_taker_entry.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  1. from __future__ import annotations
  2. import argparse
  3. import multiprocessing
  4. import sys
  5. from concurrent.futures import ProcessPoolExecutor, as_completed
  6. from itertools import product
  7. from pathlib import Path
  8. import pandas as pd
  9. ROOT = Path(__file__).resolve().parents[1]
  10. sys.path.insert(0, str(ROOT))
  11. from okx_codex_trader.models import Candle
  12. from okx_codex_trader.sampled_report import SegmentResult, mark_to_market
  13. from scripts.explore_ultrashort import (
  14. CANDLE_BAR_MS,
  15. CANDLE_CACHE_DIR,
  16. INITIAL_EQUITY,
  17. LEVERAGE,
  18. _compute_rsi,
  19. _format_ts,
  20. annualized_metrics_from_equity,
  21. cost_adjusted_trade_equity_frame,
  22. history_bars_for_years,
  23. load_cached_candles,
  24. )
  25. SYMBOL = "ETH-USDT-SWAP"
  26. BAR = "15m"
  27. YEARS = 10.0
  28. MAX_HOLD_BARS = 48
  29. ENTRY_SLIPPAGE = 0.0005
  30. OUTPUT_DIR = Path("reports/eth-exploration")
  31. PREFIX = "eth-twap-taker-entry"
  32. COSTS = {
  33. "maker_taker": 0.0021,
  34. "taker_taker": 0.0030,
  35. }
  36. HORIZONS = (
  37. ("3y", pd.DateOffset(years=3)),
  38. ("1y", pd.DateOffset(years=1)),
  39. ("6m", pd.DateOffset(months=6)),
  40. ("3m", pd.DateOffset(months=3)),
  41. )
  42. CANDLES = None
  43. def init_worker(candles: list[Candle]) -> None:
  44. global CANDLES
  45. CANDLES = candles
  46. def candidate_specs() -> list[dict[str, object]]:
  47. specs: list[dict[str, object]] = []
  48. for trend_sma, rsi_threshold, exit_rsi, stop_loss_pct, entry_steps in product(
  49. (50, 60, 80),
  50. (2.0, 3.0, 4.0, 5.0),
  51. (45.0, 50.0, 55.0),
  52. (0.008, 0.010, 0.012, 0.015),
  53. (1, 2, 3),
  54. ):
  55. base = {
  56. "trend_sma": trend_sma,
  57. "rsi_threshold": rsi_threshold,
  58. "exit_rsi": exit_rsi,
  59. "stop_loss_pct": stop_loss_pct,
  60. "max_hold_bars": MAX_HOLD_BARS,
  61. "entry_steps": entry_steps,
  62. "entry_slippage": ENTRY_SLIPPAGE,
  63. }
  64. specs.append({**base, "entry_mode": "time_twap"})
  65. specs.append({**base, "entry_mode": "price_trigger"})
  66. return specs
  67. def strategy_name(spec: dict[str, object]) -> str:
  68. return (
  69. f"rsi2-long-guarded-taker-{spec['entry_mode']}{spec['entry_steps']}"
  70. f"-slip{float(spec['entry_slippage']):.4f}"
  71. f"-t{spec['trend_sma']}-l{spec['rsi_threshold']}-x{spec['exit_rsi']}"
  72. f"-sl{spec['stop_loss_pct']}-mh{spec['max_hold_bars']}"
  73. )
  74. def close_position(
  75. *,
  76. trades: list[dict[str, object]],
  77. exits: list[dict[str, object]],
  78. position: dict[str, object],
  79. account_equity: float,
  80. candle: Candle,
  81. exit_price: float,
  82. ) -> tuple[float, bool]:
  83. margin_used = float(position["margin_used"])
  84. exit_equity = mark_to_market(
  85. side="long",
  86. margin_used=margin_used,
  87. entry_price=float(position["entry_price"]),
  88. mark_price=exit_price,
  89. leverage=LEVERAGE,
  90. )
  91. pnl = exit_equity - margin_used
  92. trades.append(
  93. {
  94. "side": "Long",
  95. "entry_time": _format_ts(int(position["entry_time"])),
  96. "exit_time": _format_ts(candle.ts),
  97. "entry_price": round(float(position["entry_price"]), 4),
  98. "exit_price": round(exit_price, 4),
  99. "pnl": round(pnl, 4),
  100. "return_pct": round(pnl / account_equity * 100, 4),
  101. "cost_weight": round(margin_used / account_equity, 8),
  102. }
  103. )
  104. exits.append({"ts": candle.ts, "price": exit_price, "side": "long"})
  105. return account_equity + pnl, pnl > 0.0
  106. def add_entry(
  107. *,
  108. entries: list[dict[str, object]],
  109. position: dict[str, object] | None,
  110. equity: float,
  111. candle: Candle,
  112. entry_price: float,
  113. entry_steps: int,
  114. stop_loss_pct: float,
  115. ) -> dict[str, object]:
  116. slice_margin = equity / entry_steps
  117. if position is None:
  118. position = {
  119. "side": "long",
  120. "entry_time": candle.ts,
  121. "entry_price": entry_price,
  122. "entry_index": int(candle.ts),
  123. "margin_used": slice_margin,
  124. "stop_price": entry_price * (1.0 - stop_loss_pct),
  125. }
  126. else:
  127. old_margin = float(position["margin_used"])
  128. new_margin = old_margin + slice_margin
  129. weighted_entry = (float(position["entry_price"]) * old_margin + entry_price * slice_margin) / new_margin
  130. position["entry_price"] = weighted_entry
  131. position["margin_used"] = new_margin
  132. position["stop_price"] = weighted_entry * (1.0 - stop_loss_pct)
  133. entries.append({"ts": candle.ts, "price": entry_price, "side": "long"})
  134. return position
  135. def run_taker_entry_segment(candles: list[Candle], spec: dict[str, object]) -> SegmentResult:
  136. closes = pd.Series([candle.close for candle in candles], dtype=float)
  137. trend = closes.rolling(int(spec["trend_sma"])).mean().tolist()
  138. rsi_values = _compute_rsi(closes, 2)
  139. equity = INITIAL_EQUITY
  140. ending_equity = equity
  141. peak_equity = equity
  142. max_drawdown = 0.0
  143. wins = 0
  144. trades: list[dict[str, object]] = []
  145. entries: list[dict[str, object]] = []
  146. exits: list[dict[str, object]] = []
  147. equity_curve: list[dict[str, float | int]] = []
  148. position: dict[str, object] | None = None
  149. pending_exit = False
  150. pending_time_slices = 0
  151. pending_trigger: dict[str, float | int] | None = None
  152. warmup_bars = max(int(spec["trend_sma"]), 3)
  153. entry_steps = int(spec["entry_steps"])
  154. entry_slippage = float(spec["entry_slippage"])
  155. stop_loss_pct = float(spec["stop_loss_pct"])
  156. for index in range(warmup_bars, len(candles)):
  157. candle = candles[index]
  158. if pending_exit and position is not None:
  159. equity, won = close_position(
  160. trades=trades,
  161. exits=exits,
  162. position=position,
  163. account_equity=equity,
  164. candle=candle,
  165. exit_price=candle.open,
  166. )
  167. wins += 1 if won else 0
  168. position = None
  169. pending_exit = False
  170. pending_time_slices = 0
  171. pending_trigger = None
  172. if pending_time_slices and equity > 0.0:
  173. entry_price = candle.open * (1.0 + entry_slippage)
  174. position = add_entry(
  175. entries=entries,
  176. position=position,
  177. equity=equity,
  178. candle=candle,
  179. entry_price=entry_price,
  180. entry_steps=entry_steps,
  181. stop_loss_pct=stop_loss_pct,
  182. )
  183. position["entry_index"] = index
  184. pending_time_slices -= 1
  185. if pending_trigger is not None and equity > 0.0:
  186. if index <= int(pending_trigger["expires_index"]) and candle.low <= float(pending_trigger["price"]):
  187. entry_price = float(pending_trigger["price"]) * (1.0 + entry_slippage)
  188. position = add_entry(
  189. entries=entries,
  190. position=position,
  191. equity=equity,
  192. candle=candle,
  193. entry_price=entry_price,
  194. entry_steps=1,
  195. stop_loss_pct=stop_loss_pct,
  196. )
  197. position["entry_index"] = index
  198. pending_trigger = None
  199. elif index > int(pending_trigger["expires_index"]):
  200. pending_trigger = None
  201. current_equity = equity
  202. if position is not None and candle.low <= float(position["stop_price"]):
  203. equity, won = close_position(
  204. trades=trades,
  205. exits=exits,
  206. position=position,
  207. account_equity=equity,
  208. candle=candle,
  209. exit_price=float(position["stop_price"]),
  210. )
  211. wins += 1 if won else 0
  212. current_equity = equity
  213. position = None
  214. pending_time_slices = 0
  215. pending_trigger = None
  216. if position is not None:
  217. position_equity = mark_to_market(
  218. side="long",
  219. margin_used=float(position["margin_used"]),
  220. entry_price=float(position["entry_price"]),
  221. mark_price=candle.close,
  222. leverage=LEVERAGE,
  223. )
  224. current_equity = equity - float(position["margin_used"]) + position_equity
  225. peak_equity = max(peak_equity, current_equity)
  226. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  227. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  228. ending_equity = current_equity
  229. if index == len(candles) - 1 or equity <= 0.0:
  230. continue
  231. current_rsi = rsi_values[index]
  232. current_trend = trend[index]
  233. if current_rsi != current_rsi or current_trend != current_trend:
  234. continue
  235. if position is not None:
  236. held_bars = index - int(position["entry_index"])
  237. if current_rsi >= float(spec["exit_rsi"]) or held_bars >= int(spec["max_hold_bars"]):
  238. pending_exit = True
  239. pending_time_slices = 0
  240. pending_trigger = None
  241. continue
  242. if pending_time_slices == 0 and pending_trigger is None and candle.close > float(current_trend) and current_rsi <= float(spec["rsi_threshold"]):
  243. if spec["entry_mode"] == "time_twap":
  244. pending_time_slices = entry_steps
  245. else:
  246. pending_trigger = {"price": candle.close, "expires_index": index + entry_steps}
  247. trade_count = len(trades)
  248. return SegmentResult(
  249. trade_count=trade_count,
  250. total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
  251. win_rate=(wins / trade_count) if trade_count else 0.0,
  252. max_drawdown=max_drawdown,
  253. trades=trades,
  254. open_position=position,
  255. candles=candles[warmup_bars:],
  256. equity_curve=equity_curve,
  257. entries=entries,
  258. exits=exits,
  259. )
  260. def horizon_metrics(frame: pd.DataFrame, last_ts: int) -> list[dict[str, object]]:
  261. rows: list[dict[str, object]] = []
  262. end_time = pd.to_datetime(last_ts, unit="ms", utc=True)
  263. for label, offset in HORIZONS:
  264. cutoff = end_time - offset
  265. before_cutoff = frame[frame["ts"] <= cutoff]
  266. if len(before_cutoff):
  267. start_equity = float(before_cutoff["equity"].iloc[-1])
  268. start_time = cutoff
  269. horizon_frame = pd.concat(
  270. [
  271. pd.DataFrame([{"ts": start_time, "equity": start_equity}]),
  272. frame[frame["ts"] > cutoff][["ts", "equity"]],
  273. ],
  274. ignore_index=True,
  275. )
  276. else:
  277. horizon_frame = frame[["ts", "equity"]].copy()
  278. start_time = pd.Timestamp(horizon_frame["ts"].iloc[0])
  279. rows.append(
  280. {
  281. "horizon": label,
  282. "horizon_start": start_time.strftime("%Y-%m-%d %H:%M"),
  283. "horizon_end": end_time.strftime("%Y-%m-%d %H:%M"),
  284. **annualized_metrics_from_equity(horizon_frame, int(start_time.timestamp() * 1000), last_ts),
  285. }
  286. )
  287. return rows
  288. def evaluate_spec(spec: dict[str, object]) -> tuple[list[dict[str, object]], list[dict[str, object]], str, int]:
  289. if CANDLES is None:
  290. raise RuntimeError("candles are not initialized")
  291. candles = CANDLES
  292. result = run_taker_entry_segment(candles, spec)
  293. gross_years = (candles[-1].ts - candles[0].ts) / 86_400_000 / 365
  294. gross_annualized = (1.0 + result.total_return) ** (1.0 / gross_years) - 1.0 if result.total_return > -1.0 else 0.0
  295. name = strategy_name(spec)
  296. total_rows: list[dict[str, object]] = []
  297. horizon_rows: list[dict[str, object]] = []
  298. for cost_label, roundtrip_cost in COSTS.items():
  299. net_equity = cost_adjusted_trade_equity_frame(result, roundtrip_cost)
  300. total_metrics = annualized_metrics_from_equity(net_equity, candles[0].ts, candles[-1].ts)
  301. base_row = {
  302. "symbol": SYMBOL,
  303. "bar": BAR,
  304. "cost_model": cost_label,
  305. "roundtrip_cost_on_margin": roundtrip_cost,
  306. "name": name,
  307. "first_candle": _format_ts(candles[0].ts),
  308. "last_candle": _format_ts(candles[-1].ts),
  309. "actual_bars": len(candles),
  310. "actual_years": gross_years,
  311. "trades": result.trade_count,
  312. "gross_total_return": result.total_return,
  313. "gross_annualized_return": gross_annualized,
  314. "gross_max_drawdown_mark_to_market": result.max_drawdown,
  315. **spec,
  316. }
  317. total_rows.append({**base_row, **total_metrics})
  318. for horizon_row in horizon_metrics(net_equity, candles[-1].ts):
  319. horizon_rows.append({**base_row, **horizon_row})
  320. return total_rows, horizon_rows, name, result.trade_count
  321. def load_10y_cached_candles() -> list[Candle]:
  322. candles, _ = load_cached_candles(CANDLE_CACHE_DIR, SYMBOL, BAR)
  323. if not candles:
  324. raise RuntimeError(f"missing cached candles for {SYMBOL} {BAR}")
  325. candles = sorted(candles, key=lambda candle: candle.ts)
  326. candles = candles[-history_bars_for_years(BAR, YEARS) :]
  327. interval = CANDLE_BAR_MS[BAR]
  328. gaps = [(left.ts, right.ts) for left, right in zip(candles, candles[1:]) if right.ts - left.ts != interval]
  329. if gaps:
  330. first_gap = gaps[0]
  331. raise RuntimeError(f"non-continuous candle cache: {_format_ts(first_gap[0])} -> {_format_ts(first_gap[1])}")
  332. return candles
  333. def run_search(max_candidates: int | None, workers: int) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
  334. candles = load_10y_cached_candles()
  335. specs = candidate_specs()
  336. if max_candidates is not None:
  337. specs = specs[:max_candidates]
  338. total_rows: list[dict[str, object]] = []
  339. horizon_rows: list[dict[str, object]] = []
  340. with ProcessPoolExecutor(max_workers=workers, mp_context=multiprocessing.get_context("fork"), initializer=init_worker, initargs=(candles,)) as executor:
  341. futures = [executor.submit(evaluate_spec, spec) for spec in specs]
  342. for index, future in enumerate(as_completed(futures), start=1):
  343. spec_totals, spec_horizons, name, trade_count = future.result()
  344. total_rows.extend(spec_totals)
  345. horizon_rows.extend(spec_horizons)
  346. print(f"{index}/{len(specs)} {name} trades={trade_count}", flush=True)
  347. totals = pd.DataFrame(total_rows)
  348. horizons = pd.DataFrame(horizon_rows)
  349. horizons["horizon"] = pd.Categorical(horizons["horizon"], categories=["3y", "1y", "6m", "3m"], ordered=True)
  350. primary_horizons = horizons[horizons["cost_model"] == "taker_taker"].pivot_table(
  351. index="name",
  352. columns="horizon",
  353. values="net_calmar",
  354. aggfunc="first",
  355. observed=False,
  356. )
  357. eligible_names = primary_horizons[
  358. (primary_horizons["3y"] > 0.0)
  359. & (primary_horizons["1y"] > 0.0)
  360. & (primary_horizons["6m"] > 0.0)
  361. & (primary_horizons["3m"] > 0.0)
  362. ].index
  363. ranked = horizons[(horizons["cost_model"] == "taker_taker") & (horizons["horizon"] == "3y")].copy()
  364. ranked["eligible_all_horizons_positive"] = ranked["name"].isin(eligible_names)
  365. ranked = ranked[ranked["eligible_all_horizons_positive"]].sort_values(
  366. ["net_calmar", "net_annualized_return"],
  367. ascending=False,
  368. )
  369. totals = totals.sort_values(["cost_model", "net_calmar", "net_annualized_return"], ascending=[True, False, False])
  370. horizons = horizons.sort_values(["cost_model", "horizon", "net_calmar", "net_annualized_return"], ascending=[True, True, False, False])
  371. return totals, horizons, ranked
  372. def markdown_table(frame: pd.DataFrame, columns: list[str]) -> str:
  373. if frame.empty:
  374. return "_empty_"
  375. rows = [["" if pd.isna(value) else str(value) for value in row] for row in frame[columns].itertuples(index=False, name=None)]
  376. return "\n".join(
  377. [
  378. "| " + " | ".join(columns) + " |",
  379. "| " + " | ".join("---" for _ in columns) + " |",
  380. *["| " + " | ".join(row) + " |" for row in rows],
  381. ]
  382. )
  383. def markdown_summary(totals: pd.DataFrame, horizons: pd.DataFrame, ranked: pd.DataFrame) -> str:
  384. top = ranked.head(15)
  385. top_names = set(top["name"])
  386. horizon_top = horizons[(horizons["cost_model"].isin(("taker_taker", "maker_taker"))) & (horizons["name"].isin(top_names))].sort_values(["name", "cost_model", "horizon"])
  387. total_top = totals[(totals["cost_model"].isin(("taker_taker", "maker_taker"))) & (totals["name"].isin(top_names))].sort_values(["name", "cost_model"])
  388. best_noneligible = horizons[(horizons["cost_model"] == "taker_taker") & (horizons["horizon"] == "3y")].head(10)
  389. best_maker_taker = horizons[(horizons["cost_model"] == "maker_taker") & (horizons["horizon"] == "3y")].head(10)
  390. columns = [
  391. "name",
  392. "trades",
  393. "entry_mode",
  394. "entry_steps",
  395. "trend_sma",
  396. "rsi_threshold",
  397. "exit_rsi",
  398. "stop_loss_pct",
  399. "entry_slippage",
  400. "net_annualized_return",
  401. "net_max_drawdown",
  402. "net_calmar",
  403. "net_sharpe_daily",
  404. ]
  405. verdict = (
  406. "Found taker_taker candidates with positive 3y/1y/6m/3m net Calmar."
  407. if len(top)
  408. else "No ETH taker-entry TWAP candidate in this grid had positive taker_taker net Calmar across 3y/1y/6m/3m."
  409. )
  410. actual = totals.iloc[0]
  411. return "\n".join(
  412. [
  413. "# ETH TWAP taker entry search",
  414. "",
  415. f"Verdict: {verdict}",
  416. "",
  417. f"Data: cached continuous `{SYMBOL}` `{BAR}` candles from {actual.first_candle} to {actual.last_candle}; actual span {float(actual.actual_years):.2f} years, requested horizon cap {YEARS:.1f} years.",
  418. "",
  419. "Execution: RSI2 long with trend guard. Time TWAP enters over the next 1/2/3 bar opens plus 0.05% entry slippage. Price trigger arms at the signal close for 1/2/3 bars and fills as taker at trigger price plus 0.05% entry slippage. Exits use next open for signal/time exit and stop price for stop exit. Costs are applied after gross trades; primary sort is taker_taker=0.003 roundtrip cost on margin, with maker_taker=0.0021 retained only for comparison.",
  420. "",
  421. "Eligibility: taker_taker 3y/1y/6m/3m net Calmar all strictly positive. Ranking: taker_taker 3y net Calmar, then 3y net annualized return.",
  422. "",
  423. "## Eligible taker_taker top 15",
  424. "",
  425. markdown_table(top, columns),
  426. "",
  427. "## Total metrics for eligible names",
  428. "",
  429. markdown_table(total_top, ["cost_model", *columns]),
  430. "",
  431. "## Horizon metrics for eligible names",
  432. "",
  433. markdown_table(
  434. horizon_top,
  435. [
  436. "cost_model",
  437. "name",
  438. "horizon",
  439. "horizon_start",
  440. "horizon_end",
  441. "net_total_return",
  442. "net_annualized_return",
  443. "net_max_drawdown",
  444. "net_calmar",
  445. "net_sharpe_daily",
  446. ],
  447. ),
  448. "",
  449. "## Best taker_taker 3y rows before all-horizon filter",
  450. "",
  451. markdown_table(best_noneligible, columns),
  452. "",
  453. "## Best maker_taker 3y rows for comparison",
  454. "",
  455. markdown_table(best_maker_taker, columns),
  456. "",
  457. ]
  458. )
  459. def main() -> int:
  460. parser = argparse.ArgumentParser()
  461. parser.add_argument("--max-candidates", type=int, default=None)
  462. parser.add_argument("--workers", type=int, default=8)
  463. args = parser.parse_args()
  464. OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
  465. totals, horizons, ranked = run_search(args.max_candidates, args.workers)
  466. all_path = OUTPUT_DIR / f"{PREFIX}-all.csv"
  467. horizon_path = OUTPUT_DIR / f"{PREFIX}-horizons.csv"
  468. top_path = OUTPUT_DIR / f"{PREFIX}-top15.csv"
  469. summary_path = OUTPUT_DIR / f"{PREFIX}-summary.md"
  470. totals.to_csv(all_path, index=False)
  471. horizons.to_csv(horizon_path, index=False)
  472. ranked.head(15).to_csv(top_path, index=False)
  473. summary_path.write_text(markdown_summary(totals, horizons, ranked), encoding="utf-8")
  474. print(f"wrote {all_path}")
  475. print(f"wrote {horizon_path}")
  476. print(f"wrote {top_path}")
  477. print(f"wrote {summary_path}")
  478. print(ranked.head(15).to_string(index=False))
  479. return 0
  480. if __name__ == "__main__":
  481. raise SystemExit(main())