search_eth_twap_10y_refine.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  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. from typing import Iterable
  9. import pandas as pd
  10. ROOT = Path(__file__).resolve().parents[1]
  11. sys.path.insert(0, str(ROOT))
  12. from okx_codex_trader.okx_client import OkxClient
  13. from scripts.explore_ultrashort import (
  14. LEVERAGE,
  15. _format_ts,
  16. annualized_metrics_from_equity,
  17. build_rsi2_long_guarded_price_twap_candidate,
  18. cost_adjusted_trade_equity_frame,
  19. get_candles_cached,
  20. history_bars_for_years,
  21. )
  22. SYMBOL = "ETH-USDT-SWAP"
  23. BAR = "15m"
  24. YEARS = 10.0
  25. MAX_HOLD_BARS = 48
  26. OUTPUT_DIR = Path("reports/eth-exploration")
  27. COSTS = {
  28. "maker_maker": 0.0012,
  29. "maker_taker": 0.0021,
  30. "taker_taker": 0.0030,
  31. }
  32. HORIZONS = (
  33. ("3y", pd.DateOffset(years=3)),
  34. ("1y", pd.DateOffset(years=1)),
  35. ("6m", pd.DateOffset(months=6)),
  36. ("3m", pd.DateOffset(months=3)),
  37. )
  38. ENTRY_OFFSET_SETS = (
  39. (0.0015, 0.004, 0.007),
  40. (0.002, 0.005, 0.008),
  41. (0.003, 0.006, 0.009),
  42. (0.004, 0.007, 0.010),
  43. )
  44. CANDLES = None
  45. def init_worker(candles: list[object]) -> None:
  46. global CANDLES
  47. CANDLES = candles
  48. def candidate_specs() -> Iterable[dict[str, object]]:
  49. for trend_sma, rsi_threshold, exit_rsi, stop_loss_pct, entry_offsets, entry_valid_bars, fill_buffer in product(
  50. (40, 50, 60, 80),
  51. (2.0, 3.0, 4.0, 5.0),
  52. (45.0, 50.0, 55.0),
  53. (0.008, 0.010, 0.012, 0.015),
  54. ENTRY_OFFSET_SETS,
  55. (2, 3, 4),
  56. (0.0, 0.0002),
  57. ):
  58. yield {
  59. "trend_sma": trend_sma,
  60. "rsi_threshold": rsi_threshold,
  61. "exit_rsi": exit_rsi,
  62. "stop_loss_pct": stop_loss_pct,
  63. "max_hold_bars": MAX_HOLD_BARS,
  64. "entry_offsets": entry_offsets,
  65. "entry_valid_bars": entry_valid_bars,
  66. "fill_buffer": fill_buffer,
  67. }
  68. def offset_label(entry_offsets: tuple[float, ...]) -> str:
  69. return "-".join(f"{value:.4f}" for value in entry_offsets)
  70. def markdown_table(frame: pd.DataFrame, columns: list[str]) -> str:
  71. rows = [["" if pd.isna(value) else str(value) for value in row] for row in frame[columns].itertuples(index=False, name=None)]
  72. return "\n".join(
  73. [
  74. "| " + " | ".join(columns) + " |",
  75. "| " + " | ".join("---" for _ in columns) + " |",
  76. *["| " + " | ".join(row) + " |" for row in rows],
  77. ]
  78. )
  79. def horizon_metrics(frame: pd.DataFrame, last_ts: int) -> list[dict[str, object]]:
  80. rows: list[dict[str, object]] = []
  81. end_time = pd.to_datetime(last_ts, unit="ms", utc=True)
  82. for label, offset in HORIZONS:
  83. cutoff = end_time - offset
  84. before_cutoff = frame[frame["ts"] <= cutoff]
  85. if len(before_cutoff):
  86. start_equity = float(before_cutoff["equity"].iloc[-1])
  87. start_time = cutoff
  88. horizon_frame = pd.concat(
  89. [
  90. pd.DataFrame([{"ts": start_time, "equity": start_equity}]),
  91. frame[frame["ts"] > cutoff][["ts", "equity"]],
  92. ],
  93. ignore_index=True,
  94. )
  95. else:
  96. horizon_frame = frame[["ts", "equity"]].copy()
  97. start_time = pd.Timestamp(horizon_frame["ts"].iloc[0])
  98. rows.append(
  99. {
  100. "horizon": label,
  101. "horizon_start": start_time.strftime("%Y-%m-%d %H:%M"),
  102. "horizon_end": end_time.strftime("%Y-%m-%d %H:%M"),
  103. **annualized_metrics_from_equity(
  104. horizon_frame,
  105. int(start_time.timestamp() * 1000),
  106. last_ts,
  107. ),
  108. }
  109. )
  110. return rows
  111. def evaluate_spec(spec: dict[str, object]) -> tuple[list[dict[str, object]], list[dict[str, object]], str, int]:
  112. if CANDLES is None:
  113. raise RuntimeError("candles are not initialized")
  114. candles = CANDLES
  115. entry_offsets = tuple(float(value) for value in spec["entry_offsets"])
  116. candidate = build_rsi2_long_guarded_price_twap_candidate(
  117. int(spec["trend_sma"]),
  118. float(spec["rsi_threshold"]),
  119. float(spec["exit_rsi"]),
  120. float(spec["stop_loss_pct"]),
  121. int(spec["max_hold_bars"]),
  122. entry_offsets,
  123. int(spec["entry_valid_bars"]),
  124. float(spec["fill_buffer"]),
  125. )
  126. result = candidate.run(candles=candles, leverage=LEVERAGE, warmup_bars=candidate.warmup_bars)
  127. gross_years = (candles[-1].ts - candles[0].ts) / 86_400_000 / 365
  128. gross_annualized = (1.0 + result.total_return) ** (1.0 / gross_years) - 1.0 if result.total_return > -1.0 else 0.0
  129. total_rows: list[dict[str, object]] = []
  130. horizon_rows: list[dict[str, object]] = []
  131. for cost_label, roundtrip_cost in COSTS.items():
  132. net_equity = cost_adjusted_trade_equity_frame(result, roundtrip_cost)
  133. total_metrics = annualized_metrics_from_equity(net_equity, candles[0].ts, candles[-1].ts)
  134. base_row = {
  135. "symbol": SYMBOL,
  136. "bar": BAR,
  137. "cost_model": cost_label,
  138. "roundtrip_cost_on_margin": roundtrip_cost,
  139. "name": candidate.name,
  140. "first_candle": _format_ts(candles[0].ts),
  141. "last_candle": _format_ts(candles[-1].ts),
  142. "actual_bars": len(candles),
  143. "trades": result.trade_count,
  144. "gross_total_return": result.total_return,
  145. "gross_annualized_return": gross_annualized,
  146. "gross_max_drawdown_mark_to_market": result.max_drawdown,
  147. **spec,
  148. "entry_offsets": offset_label(entry_offsets),
  149. }
  150. total_rows.append({**base_row, **total_metrics})
  151. for horizon_row in horizon_metrics(net_equity, candles[-1].ts):
  152. horizon_rows.append({**base_row, **horizon_row})
  153. return total_rows, horizon_rows, candidate.name, result.trade_count
  154. def run_search(max_candidates: int | None, workers: int) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
  155. candles = get_candles_cached(OkxClient(), SYMBOL, BAR, history_bars_for_years(BAR, YEARS))
  156. specs = list(candidate_specs())
  157. if max_candidates is not None:
  158. specs = specs[:max_candidates]
  159. total_rows: list[dict[str, object]] = []
  160. horizon_rows: list[dict[str, object]] = []
  161. with ProcessPoolExecutor(max_workers=workers, mp_context=multiprocessing.get_context("fork"), initializer=init_worker, initargs=(candles,)) as executor:
  162. futures = [executor.submit(evaluate_spec, spec) for spec in specs]
  163. for index, future in enumerate(as_completed(futures), start=1):
  164. spec_totals, spec_horizons, name, trade_count = future.result()
  165. total_rows.extend(spec_totals)
  166. horizon_rows.extend(spec_horizons)
  167. print(f"{index}/{len(specs)} {name} trades={trade_count}", flush=True)
  168. totals = pd.DataFrame(total_rows)
  169. horizons = pd.DataFrame(horizon_rows)
  170. horizons["horizon"] = pd.Categorical(horizons["horizon"], categories=["3y", "1y", "6m", "3m"], ordered=True)
  171. maker_taker_horizons = horizons[horizons["cost_model"] == "maker_taker"].pivot_table(
  172. index="name",
  173. columns="horizon",
  174. values="net_calmar",
  175. aggfunc="first",
  176. observed=False,
  177. )
  178. eligible_names = maker_taker_horizons[
  179. (maker_taker_horizons["1y"] >= 0.0)
  180. & (maker_taker_horizons["6m"] >= 0.0)
  181. & (maker_taker_horizons["3m"] >= 0.0)
  182. ].index
  183. ranked = horizons[(horizons["cost_model"] == "maker_taker") & (horizons["horizon"] == "3y")].copy()
  184. ranked["eligible_recent_nonnegative"] = ranked["name"].isin(eligible_names)
  185. ranked = ranked[ranked["eligible_recent_nonnegative"]].sort_values(
  186. ["net_calmar", "net_annualized_return"],
  187. ascending=False,
  188. )
  189. totals = totals.sort_values(["cost_model", "net_calmar", "net_annualized_return"], ascending=[True, False, False])
  190. horizons = horizons.sort_values(["cost_model", "horizon", "net_calmar", "net_annualized_return"], ascending=[True, True, False, False])
  191. return totals, horizons, ranked
  192. def markdown_summary(totals: pd.DataFrame, horizons: pd.DataFrame, ranked: pd.DataFrame) -> str:
  193. top15 = ranked.head(15)
  194. top_names = set(top15["name"])
  195. horizon_top = horizons[(horizons["cost_model"] == "maker_taker") & (horizons["name"].isin(top_names))].sort_values(["name", "horizon"])
  196. total_top = totals[(totals["cost_model"] == "maker_taker") & (totals["name"].isin(top_names))].sort_values("name")
  197. columns = [
  198. "name",
  199. "trades",
  200. "trend_sma",
  201. "rsi_threshold",
  202. "exit_rsi",
  203. "stop_loss_pct",
  204. "max_hold_bars",
  205. "entry_offsets",
  206. "entry_valid_bars",
  207. "fill_buffer",
  208. "net_annualized_return",
  209. "net_max_drawdown",
  210. "net_calmar",
  211. "net_sharpe_daily",
  212. ]
  213. candidate_lines = [
  214. f"- `{row.name}`: 3y Calmar {float(row.net_calmar):.4f}, 3y annualized {float(row.net_annualized_return):.4f}, trades {int(row.trades)}"
  215. for row in top15.head(5).itertuples(index=False)
  216. ]
  217. return "\n".join(
  218. [
  219. "# ETH TWAP 10y refine",
  220. "",
  221. "Evaluation: run 10 years through `scripts/explore_ultrashort.py` price-TWAP runner, then derive cost scenarios and 3y/1y/6m/3m horizons from the same cost-adjusted equity curve.",
  222. "",
  223. "Primary sort: maker_taker 3y net_calmar, then 3y net_annualized_return. Eligibility: maker_taker 1y/6m/3m net_calmar are all nonnegative.",
  224. "",
  225. "## Top 15 maker_taker 3y eligible candidates",
  226. "",
  227. markdown_table(top15, columns),
  228. "",
  229. "## 10y total metrics for top 15",
  230. "",
  231. markdown_table(total_top, columns),
  232. "",
  233. "## Recent horizons for top 15",
  234. "",
  235. markdown_table(
  236. horizon_top,
  237. [
  238. "name",
  239. "horizon",
  240. "horizon_start",
  241. "horizon_end",
  242. "net_total_return",
  243. "net_annualized_return",
  244. "net_max_drawdown",
  245. "net_calmar",
  246. "net_sharpe_daily",
  247. ],
  248. ),
  249. "",
  250. "## Suggested ETH candidates for main report",
  251. "",
  252. *(candidate_lines if candidate_lines else ["No maker_taker candidate passed the nonnegative 1y/6m/3m Calmar filter."]),
  253. "",
  254. ]
  255. )
  256. def main() -> int:
  257. parser = argparse.ArgumentParser()
  258. parser.add_argument("--max-candidates", type=int, default=None)
  259. parser.add_argument("--workers", type=int, default=8)
  260. args = parser.parse_args()
  261. OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
  262. totals, horizons, ranked = run_search(args.max_candidates, args.workers)
  263. all_path = OUTPUT_DIR / "eth-twap-10y-refine-all.csv"
  264. horizon_path = OUTPUT_DIR / "eth-twap-10y-refine-horizons.csv"
  265. top_path = OUTPUT_DIR / "eth-twap-10y-refine-top15.csv"
  266. summary_path = OUTPUT_DIR / "eth-twap-10y-refine-summary.md"
  267. totals.to_csv(all_path, index=False)
  268. horizons.to_csv(horizon_path, index=False)
  269. ranked.head(15).to_csv(top_path, index=False)
  270. summary_path.write_text(markdown_summary(totals, horizons, ranked), encoding="utf-8")
  271. print(f"wrote {all_path}")
  272. print(f"wrote {horizon_path}")
  273. print(f"wrote {top_path}")
  274. print(f"wrote {summary_path}")
  275. print(ranked.head(15).to_string(index=False))
  276. return 0
  277. if __name__ == "__main__":
  278. raise SystemExit(main())