search_eth_price_twap_variants.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. from __future__ import annotations
  2. import argparse
  3. import sys
  4. from itertools import product
  5. from pathlib import Path
  6. from typing import Iterable
  7. import pandas as pd
  8. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  9. from scripts.explore_ultrashort import (
  10. INITIAL_EQUITY,
  11. LEVERAGE,
  12. _format_ts,
  13. _compute_rsi,
  14. annualized_metrics_from_equity,
  15. get_candles_cached,
  16. history_bars_for_years,
  17. recent_horizon_metrics_from_equity,
  18. )
  19. from okx_codex_trader.models import Candle
  20. from okx_codex_trader.okx_client import OkxClient
  21. from okx_codex_trader.sampled_report import SegmentResult, mark_to_market
  22. SYMBOL = "ETH-USDT-SWAP"
  23. BAR = "15m"
  24. YEARS = 3.0
  25. OUTPUT_DIR = Path("reports/eth-exploration")
  26. COSTS = {
  27. "maker_maker": 0.0012,
  28. "maker_taker": 0.0021,
  29. "taker_taker": 0.0030,
  30. }
  31. HORIZONS = (
  32. ("3y", pd.DateOffset(years=3)),
  33. ("1y", pd.DateOffset(years=1)),
  34. ("6m", pd.DateOffset(months=6)),
  35. ("3m", pd.DateOffset(months=3)),
  36. )
  37. ENTRY_OFFSET_SETS = (
  38. (0.001, 0.003, 0.005),
  39. (0.003, 0.006, 0.009),
  40. )
  41. def candidate_specs() -> Iterable[dict[str, object]]:
  42. for (
  43. trend_sma,
  44. rsi_threshold,
  45. exit_rsi,
  46. stop_loss_pct,
  47. max_hold_bars,
  48. entry_offsets,
  49. entry_valid_bars,
  50. fill_buffer,
  51. ) in product(
  52. (80, 160),
  53. (5.0, 8.0, 10.0),
  54. (50.0, 55.0),
  55. (0.008, 0.012),
  56. (48, 96),
  57. ENTRY_OFFSET_SETS,
  58. (2, 4),
  59. (0.0, 0.0002),
  60. ):
  61. yield {
  62. "trend_sma": trend_sma,
  63. "rsi_threshold": rsi_threshold,
  64. "exit_rsi": exit_rsi,
  65. "stop_loss_pct": stop_loss_pct,
  66. "max_hold_bars": max_hold_bars,
  67. "entry_offsets": entry_offsets,
  68. "entry_valid_bars": entry_valid_bars,
  69. "fill_buffer": fill_buffer,
  70. }
  71. def annualized_return(total_return: float, first_ts: int, last_ts: int) -> float:
  72. years = (last_ts - first_ts) / 86_400_000 / 365
  73. return (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 and years > 0.0 else 0.0
  74. def strategy_name(spec: dict[str, object]) -> str:
  75. offsets = "-".join(f"{offset:.4f}" for offset in tuple(spec["entry_offsets"]))
  76. buffer_label = f"-fb{float(spec['fill_buffer']):.4f}" if float(spec["fill_buffer"]) else ""
  77. return (
  78. f"rsi2-long-guarded-price-twap-o{offsets}-v{spec['entry_valid_bars']}{buffer_label}"
  79. f"-t{spec['trend_sma']}-l{spec['rsi_threshold']}-x{spec['exit_rsi']}"
  80. f"-sl{spec['stop_loss_pct']}-mh{spec['max_hold_bars']}"
  81. )
  82. def close_position(
  83. *,
  84. trades: list[dict[str, object]],
  85. exits: list[dict[str, object]],
  86. position: dict[str, object],
  87. account_equity: float,
  88. candle: Candle,
  89. exit_price: float,
  90. roundtrip_cost_on_margin: float,
  91. ) -> tuple[float, bool]:
  92. margin_used = float(position["margin_used"])
  93. exit_equity = mark_to_market(
  94. side="long",
  95. margin_used=margin_used,
  96. entry_price=float(position["entry_price"]),
  97. mark_price=exit_price,
  98. leverage=LEVERAGE,
  99. )
  100. pnl = exit_equity - margin_used
  101. cost = margin_used * roundtrip_cost_on_margin
  102. net_pnl = pnl - cost
  103. trades.append(
  104. {
  105. "side": "Long",
  106. "entry_time": _format_ts(int(position["entry_time"])),
  107. "exit_time": _format_ts(candle.ts),
  108. "entry_price": round(float(position["entry_price"]), 4),
  109. "exit_price": round(exit_price, 4),
  110. "pnl": round(net_pnl, 4),
  111. "return_pct": round(net_pnl / account_equity * 100, 4),
  112. "cost_weight": round(margin_used / account_equity, 8),
  113. }
  114. )
  115. exits.append({"ts": candle.ts, "price": exit_price, "side": "long"})
  116. return account_equity + net_pnl, net_pnl > 0.0
  117. def run_price_twap_segment(
  118. *,
  119. candles: list[Candle],
  120. spec: dict[str, object],
  121. roundtrip_cost_on_margin: float,
  122. ) -> SegmentResult:
  123. closes = pd.Series([candle.close for candle in candles], dtype=float)
  124. trend = closes.rolling(int(spec["trend_sma"])).mean().tolist()
  125. rsi_values = _compute_rsi(closes, 2)
  126. equity = INITIAL_EQUITY
  127. ending_equity = equity
  128. peak_equity = equity
  129. max_drawdown = 0.0
  130. wins = 0
  131. trades: list[dict[str, object]] = []
  132. entries: list[dict[str, object]] = []
  133. exits: list[dict[str, object]] = []
  134. equity_curve: list[dict[str, float | int]] = []
  135. position: dict[str, object] | None = None
  136. pending_limits: list[dict[str, float | int]] = []
  137. pending_exit = False
  138. warmup_bars = max(int(spec["trend_sma"]), 3)
  139. entry_offsets = tuple(float(value) for value in spec["entry_offsets"])
  140. for index in range(warmup_bars, len(candles)):
  141. candle = candles[index]
  142. if pending_exit and position is not None:
  143. equity, won = close_position(
  144. trades=trades,
  145. exits=exits,
  146. position=position,
  147. account_equity=equity,
  148. candle=candle,
  149. exit_price=candle.open,
  150. roundtrip_cost_on_margin=roundtrip_cost_on_margin,
  151. )
  152. wins += 1 if won else 0
  153. position = None
  154. pending_exit = False
  155. pending_limits = []
  156. active_limits: list[dict[str, float | int]] = []
  157. for limit in pending_limits:
  158. if index > int(limit["expires_index"]):
  159. continue
  160. limit_price = float(limit["price"])
  161. if candle.low <= limit_price * (1.0 - float(spec["fill_buffer"])) and equity > 0.0:
  162. slice_margin = equity / len(entry_offsets)
  163. if position is None:
  164. position = {
  165. "side": "long",
  166. "entry_time": candle.ts,
  167. "entry_price": limit_price,
  168. "entry_index": index,
  169. "margin_used": slice_margin,
  170. "stop_price": limit_price * (1 - float(spec["stop_loss_pct"])),
  171. }
  172. else:
  173. old_margin = float(position["margin_used"])
  174. new_margin = old_margin + slice_margin
  175. entry_price = (float(position["entry_price"]) * old_margin + limit_price * slice_margin) / new_margin
  176. position["entry_price"] = entry_price
  177. position["margin_used"] = new_margin
  178. position["stop_price"] = entry_price * (1 - float(spec["stop_loss_pct"]))
  179. entries.append({"ts": candle.ts, "price": limit_price, "side": "long"})
  180. else:
  181. active_limits.append(limit)
  182. pending_limits = active_limits
  183. current_equity = equity
  184. if position is not None and candle.low <= float(position["stop_price"]):
  185. equity, won = close_position(
  186. trades=trades,
  187. exits=exits,
  188. position=position,
  189. account_equity=equity,
  190. candle=candle,
  191. exit_price=float(position["stop_price"]),
  192. roundtrip_cost_on_margin=roundtrip_cost_on_margin,
  193. )
  194. wins += 1 if won else 0
  195. current_equity = equity
  196. position = None
  197. pending_limits = []
  198. if position is not None:
  199. position_equity = mark_to_market(
  200. side="long",
  201. margin_used=float(position["margin_used"]),
  202. entry_price=float(position["entry_price"]),
  203. mark_price=candle.close,
  204. leverage=LEVERAGE,
  205. )
  206. current_equity = equity - float(position["margin_used"]) + position_equity
  207. peak_equity = max(peak_equity, current_equity)
  208. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  209. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  210. ending_equity = current_equity
  211. if index == len(candles) - 1 or equity <= 0.0:
  212. continue
  213. current_rsi = rsi_values[index]
  214. current_trend = trend[index]
  215. if current_rsi != current_rsi or current_trend != current_trend:
  216. continue
  217. if position is not None:
  218. held_bars = index - int(position["entry_index"])
  219. if current_rsi >= float(spec["exit_rsi"]) or held_bars >= int(spec["max_hold_bars"]):
  220. pending_exit = True
  221. pending_limits = []
  222. continue
  223. if not pending_limits and candle.close > float(current_trend) and current_rsi <= float(spec["rsi_threshold"]):
  224. pending_limits = [
  225. {
  226. "price": candle.close * (1.0 - offset),
  227. "expires_index": index + int(spec["entry_valid_bars"]),
  228. }
  229. for offset in entry_offsets
  230. ]
  231. trade_count = len(trades)
  232. return SegmentResult(
  233. trade_count=trade_count,
  234. total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
  235. win_rate=(wins / trade_count) if trade_count else 0.0,
  236. max_drawdown=max_drawdown,
  237. trades=trades,
  238. open_position=position,
  239. candles=candles[warmup_bars:],
  240. equity_curve=equity_curve,
  241. entries=entries,
  242. exits=exits,
  243. )
  244. def equity_frame(result: SegmentResult) -> pd.DataFrame:
  245. frame = pd.DataFrame(result.equity_curve)
  246. frame["ts"] = pd.to_datetime(frame["ts"], unit="ms", utc=True)
  247. return frame[["ts", "equity"]]
  248. def markdown_table(frame: pd.DataFrame, columns: list[str]) -> str:
  249. rows = [["" if pd.isna(value) else str(value) for value in row] for row in frame[columns].itertuples(index=False, name=None)]
  250. header = "| " + " | ".join(columns) + " |"
  251. separator = "| " + " | ".join("---" for _ in columns) + " |"
  252. body = ["| " + " | ".join(row) + " |" for row in rows]
  253. return "\n".join([header, separator, *body])
  254. def run_search(max_candidates: int | None) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
  255. client = OkxClient()
  256. candles = get_candles_cached(client, SYMBOL, BAR, history_bars_for_years(BAR, YEARS))
  257. rows: list[dict[str, object]] = []
  258. horizon_rows: list[dict[str, object]] = []
  259. specs = list(candidate_specs())
  260. if max_candidates is not None:
  261. specs = specs[:max_candidates]
  262. for index, spec in enumerate(specs, start=1):
  263. name = strategy_name(spec)
  264. gross_result = run_price_twap_segment(candles=candles, spec=spec, roundtrip_cost_on_margin=0.0)
  265. gross_annualized = annualized_return(gross_result.total_return, candles[0].ts, candles[-1].ts)
  266. for cost_label, roundtrip_cost in COSTS.items():
  267. result = run_price_twap_segment(candles=candles, spec=spec, roundtrip_cost_on_margin=roundtrip_cost)
  268. net_equity = equity_frame(result)
  269. metrics = annualized_metrics_from_equity(net_equity, candles[0].ts, candles[-1].ts)
  270. rows.append(
  271. {
  272. "symbol": SYMBOL,
  273. "bar": BAR,
  274. "cost_model": cost_label,
  275. "roundtrip_cost_on_margin": roundtrip_cost,
  276. "name": name,
  277. "first_candle": _format_ts(candles[0].ts),
  278. "last_candle": _format_ts(candles[-1].ts),
  279. "actual_bars": len(candles),
  280. "trades": result.trade_count,
  281. "gross_total_return": gross_result.total_return,
  282. "gross_annualized_return": gross_annualized,
  283. "gross_max_drawdown_mark_to_market": gross_result.max_drawdown,
  284. **spec,
  285. "entry_offsets": "-".join(f"{value:.4f}" for value in tuple(spec["entry_offsets"])),
  286. **metrics,
  287. }
  288. )
  289. horizon_frame = recent_horizon_metrics_from_equity(net_equity, candles[-1].ts, HORIZONS)
  290. for horizon_row in horizon_frame.to_dict("records"):
  291. horizon_rows.append(
  292. {
  293. "symbol": SYMBOL,
  294. "bar": BAR,
  295. "cost_model": cost_label,
  296. "roundtrip_cost_on_margin": roundtrip_cost,
  297. "name": name,
  298. "trades": result.trade_count,
  299. **spec,
  300. "entry_offsets": "-".join(f"{value:.4f}" for value in tuple(spec["entry_offsets"])),
  301. **horizon_row,
  302. }
  303. )
  304. print(f"{index}/{len(specs)} {name} trades={gross_result.trade_count}")
  305. all_results = pd.DataFrame(rows)
  306. horizons = pd.DataFrame(horizon_rows)
  307. all_results = all_results.sort_values(
  308. ["cost_model", "net_calmar", "net_annualized_return"],
  309. ascending=[True, False, False],
  310. )
  311. maker_taker = all_results[all_results["cost_model"] == "maker_taker"].sort_values(
  312. ["net_calmar", "net_annualized_return"],
  313. ascending=False,
  314. )
  315. horizons["horizon"] = pd.Categorical(horizons["horizon"], categories=["3y", "1y", "6m", "3m"], ordered=True)
  316. horizons = horizons.sort_values(
  317. ["cost_model", "horizon", "net_calmar", "net_annualized_return"],
  318. ascending=[True, True, False, False],
  319. )
  320. return all_results, maker_taker, horizons
  321. def markdown_summary(maker_taker: pd.DataFrame, horizons: pd.DataFrame) -> str:
  322. top10 = maker_taker.head(10)
  323. top_names = set(top10["name"])
  324. horizon_top = horizons[
  325. (horizons["cost_model"] == "maker_taker")
  326. & (horizons["name"].isin(top_names))
  327. ].sort_values(["name", "horizon"])
  328. columns = [
  329. "name",
  330. "trades",
  331. "trend_sma",
  332. "rsi_threshold",
  333. "exit_rsi",
  334. "stop_loss_pct",
  335. "max_hold_bars",
  336. "entry_offsets",
  337. "entry_valid_bars",
  338. "fill_buffer",
  339. "net_annualized_return",
  340. "net_max_drawdown",
  341. "net_calmar",
  342. "net_sharpe_daily",
  343. ]
  344. return "\n".join(
  345. [
  346. "# ETH price-TWAP variant search",
  347. "",
  348. "Primary sort: maker_taker by net_calmar, then net_annualized_return.",
  349. "",
  350. "## Top 10 maker_taker candidates",
  351. "",
  352. markdown_table(top10, columns),
  353. "",
  354. "## Recent horizons for top 10",
  355. "",
  356. markdown_table(
  357. horizon_top,
  358. [
  359. "name",
  360. "horizon",
  361. "horizon_start",
  362. "horizon_end",
  363. "net_total_return",
  364. "net_annualized_return",
  365. "net_max_drawdown",
  366. "net_calmar",
  367. "net_sharpe_daily",
  368. ],
  369. ),
  370. "",
  371. "## Next narrowing direction",
  372. "",
  373. "Favor the parameter cluster shared by the top maker_taker rows: keep the strongest trend_sma/rsi_threshold/offset combinations, then rerun a narrower grid around adjacent stop_loss_pct, max_hold_bars, entry_valid_bars, and fill_buffer values.",
  374. "",
  375. ]
  376. )
  377. def main() -> int:
  378. parser = argparse.ArgumentParser()
  379. parser.add_argument("--max-candidates", type=int, default=None)
  380. args = parser.parse_args()
  381. OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
  382. all_results, maker_taker, horizons = run_search(args.max_candidates)
  383. all_path = OUTPUT_DIR / "eth-price-twap-search.csv"
  384. top_path = OUTPUT_DIR / "eth-price-twap-top10.csv"
  385. horizon_path = OUTPUT_DIR / "eth-price-twap-horizons.csv"
  386. summary_path = OUTPUT_DIR / "eth-price-twap-summary.md"
  387. all_results.to_csv(all_path, index=False)
  388. maker_taker.head(10).to_csv(top_path, index=False)
  389. horizons.to_csv(horizon_path, index=False)
  390. summary_path.write_text(markdown_summary(maker_taker, horizons), encoding="utf-8")
  391. print(f"wrote {all_path}")
  392. print(f"wrote {top_path}")
  393. print(f"wrote {horizon_path}")
  394. print(f"wrote {summary_path}")
  395. print(maker_taker.head(10).to_string(index=False))
  396. return 0
  397. if __name__ == "__main__":
  398. raise SystemExit(main())