search_eth_microstructure_variants.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975
  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. from typing import Callable
  8. import pandas as pd
  9. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  10. from okx_codex_trader.models import Candle
  11. SYMBOL = "ETH-USDT-SWAP"
  12. BAR = "15m"
  13. YEARS = 10.0
  14. LEVERAGE = 3
  15. INITIAL_EQUITY = 10_000.0
  16. DATA_DIR = Path("data/okx-candles")
  17. OUTPUT_DIR = Path("reports/eth-exploration")
  18. PREFIX = "eth-microstructure"
  19. PRIMARY_COST = "maker_taker"
  20. COSTS = (
  21. ("maker_maker", 0.0012),
  22. ("maker_taker", 0.0021),
  23. ("taker_taker", 0.0030),
  24. )
  25. HORIZONS = (
  26. ("all", None),
  27. ("3y", pd.DateOffset(years=3)),
  28. ("1y", pd.DateOffset(years=1)),
  29. ("6m", pd.DateOffset(months=6)),
  30. ("3m", pd.DateOffset(months=3)),
  31. )
  32. @dataclass(frozen=True)
  33. class Position:
  34. side: str
  35. entry_time: int
  36. entry_index: int
  37. entry_price: float
  38. account_at_entry: float
  39. margin_used: float
  40. stop_price: float
  41. take_price: float | None
  42. max_hold_bars: int
  43. @dataclass(frozen=True)
  44. class SegmentResult:
  45. trade_count: int
  46. win_rate: float
  47. gross_total_return: float
  48. gross_max_drawdown: float
  49. trades: list[dict[str, object]]
  50. equity_curve: list[dict[str, float | int]]
  51. @dataclass(frozen=True)
  52. class Variant:
  53. family: str
  54. name: str
  55. params: dict[str, object]
  56. run: Callable[[list[Candle]], SegmentResult]
  57. def _format_ts(ts: int) -> str:
  58. return pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
  59. def _load_candles(symbol: str, bar: str) -> list[Candle]:
  60. frame = pd.read_csv(DATA_DIR / symbol / f"{bar}.csv")
  61. return [
  62. Candle(
  63. symbol=symbol,
  64. ts=int(row.ts),
  65. open=float(row.open),
  66. high=float(row.high),
  67. low=float(row.low),
  68. close=float(row.close),
  69. volume=float(row.volume),
  70. )
  71. for row in frame.itertuples(index=False)
  72. ]
  73. def _trade_equity(side: str, margin_used: float, entry_price: float, exit_price: float) -> float:
  74. if side == "long":
  75. price_return = (exit_price - entry_price) / entry_price
  76. else:
  77. price_return = (entry_price - exit_price) / entry_price
  78. return margin_used + margin_used * LEVERAGE * price_return
  79. def _mark_account(equity: float, position: Position | None, mark_price: float) -> float:
  80. if position is None:
  81. return equity
  82. marked = _trade_equity(position.side, position.margin_used, position.entry_price, mark_price)
  83. return equity - position.margin_used + marked
  84. def _max_drawdown(values: list[float]) -> float:
  85. peak = values[0]
  86. drawdown = 0.0
  87. for value in values:
  88. peak = max(peak, value)
  89. drawdown = max(drawdown, (peak - value) / peak if peak else 0.0)
  90. return drawdown
  91. def _close_position(
  92. *,
  93. trades: list[dict[str, object]],
  94. position: Position,
  95. candle: Candle,
  96. exit_price: float,
  97. ) -> tuple[float, bool]:
  98. exit_margin_equity = _trade_equity(position.side, position.margin_used, position.entry_price, exit_price)
  99. account_equity = position.account_at_entry - position.margin_used + exit_margin_equity
  100. pnl = exit_margin_equity - position.margin_used
  101. trades.append(
  102. {
  103. "side": "Long" if position.side == "long" else "Short",
  104. "entry_time": _format_ts(position.entry_time),
  105. "exit_time": _format_ts(candle.ts),
  106. "entry_price": round(position.entry_price, 4),
  107. "exit_price": round(exit_price, 4),
  108. "pnl": pnl,
  109. "return_pct": pnl / position.account_at_entry * 100.0,
  110. "return_on_margin_pct": pnl / position.margin_used * 100.0,
  111. "cost_weight": position.margin_used / position.account_at_entry,
  112. "hold_bars": int((candle.ts - position.entry_time) / 900_000),
  113. }
  114. )
  115. return account_equity, pnl > 0.0
  116. def _open_position(
  117. *,
  118. side: str,
  119. candle: Candle,
  120. index: int,
  121. equity: float,
  122. margin_fraction: float,
  123. stop_loss_pct: float,
  124. take_profit_pct: float | None,
  125. max_hold_bars: int,
  126. ) -> Position:
  127. margin_used = equity * margin_fraction
  128. return Position(
  129. side=side,
  130. entry_time=candle.ts,
  131. entry_index=index,
  132. entry_price=candle.open,
  133. account_at_entry=equity,
  134. margin_used=margin_used,
  135. stop_price=candle.open * (1.0 - stop_loss_pct if side == "long" else 1.0 + stop_loss_pct),
  136. take_price=None if take_profit_pct is None else candle.open * (1.0 + take_profit_pct if side == "long" else 1.0 - take_profit_pct),
  137. max_hold_bars=max_hold_bars,
  138. )
  139. def _empty_result(candles: list[Candle]) -> SegmentResult:
  140. return SegmentResult(0, 0.0, 0.0, 0.0, [], [{"ts": candles[0].ts, "equity": INITIAL_EQUITY, "close": candles[0].close}])
  141. def _finalize_result(trades: list[dict[str, object]], equity_curve: list[dict[str, float | int]]) -> SegmentResult:
  142. wins = sum(1 for trade in trades if float(trade["pnl"]) > 0.0)
  143. values = [float(point["equity"]) for point in equity_curve]
  144. return SegmentResult(
  145. trade_count=len(trades),
  146. win_rate=wins / len(trades) if trades else 0.0,
  147. gross_total_return=values[-1] / INITIAL_EQUITY - 1.0,
  148. gross_max_drawdown=_max_drawdown(values),
  149. trades=trades,
  150. equity_curve=equity_curve,
  151. )
  152. def _session_ok(candle: Candle, session: str) -> bool:
  153. hour = pd.to_datetime(candle.ts, unit="ms", utc=True).hour
  154. if session == "all":
  155. return True
  156. if session == "us":
  157. return 13 <= hour < 22
  158. if session == "asia":
  159. return 0 <= hour < 8
  160. return 8 <= hour < 16
  161. def run_range_retest(
  162. candles: list[Candle],
  163. *,
  164. lookback: int,
  165. pullback_pct: float,
  166. stop_loss_pct: float,
  167. take_profit_pct: float,
  168. max_hold_bars: int,
  169. margin_fraction: float,
  170. session: str,
  171. ) -> SegmentResult:
  172. if len(candles) <= lookback + max_hold_bars:
  173. return _empty_result(candles)
  174. highs = pd.Series([c.high for c in candles], dtype=float).shift(1).rolling(lookback).max().tolist()
  175. lows = pd.Series([c.low for c in candles], dtype=float).shift(1).rolling(lookback).min().tolist()
  176. trades: list[dict[str, object]] = []
  177. equity_curve: list[dict[str, float | int]] = []
  178. equity = INITIAL_EQUITY
  179. position: Position | None = None
  180. pending_entry: str | None = None
  181. pending_exit_price: float | None = None
  182. breakout: dict[str, object] | None = None
  183. for index in range(lookback, len(candles)):
  184. candle = candles[index]
  185. if pending_exit_price is not None and position is not None:
  186. equity, _ = _close_position(trades=trades, position=position, candle=candle, exit_price=pending_exit_price)
  187. position = None
  188. pending_exit_price = None
  189. if pending_entry is not None and position is None and equity > 0.0:
  190. position = _open_position(
  191. side=pending_entry,
  192. candle=candle,
  193. index=index,
  194. equity=equity,
  195. margin_fraction=margin_fraction,
  196. stop_loss_pct=stop_loss_pct,
  197. take_profit_pct=take_profit_pct,
  198. max_hold_bars=max_hold_bars,
  199. )
  200. pending_entry = None
  201. if position is not None:
  202. stop_hit = (position.side == "long" and candle.low <= position.stop_price) or (position.side == "short" and candle.high >= position.stop_price)
  203. take_hit = position.take_price is not None and (
  204. (position.side == "long" and candle.high >= position.take_price) or (position.side == "short" and candle.low <= position.take_price)
  205. )
  206. held = index - position.entry_index
  207. if stop_hit or take_hit or held >= position.max_hold_bars:
  208. exit_price = position.stop_price if stop_hit else position.take_price if take_hit else candle.close
  209. equity, _ = _close_position(trades=trades, position=position, candle=candle, exit_price=float(exit_price))
  210. position = None
  211. current_equity = _mark_account(equity, position, candle.close)
  212. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  213. if index == len(candles) - 1 or equity <= 0.0:
  214. continue
  215. if position is not None or not _session_ok(candle, session):
  216. continue
  217. if breakout is not None:
  218. side = str(breakout["side"])
  219. level = float(breakout["level"])
  220. age = index - int(breakout["index"])
  221. if age > 4:
  222. breakout = None
  223. elif side == "long" and candle.low <= level * (1.0 + pullback_pct) and candle.close > level:
  224. pending_entry = "long"
  225. breakout = None
  226. elif side == "short" and candle.high >= level * (1.0 - pullback_pct) and candle.close < level:
  227. pending_entry = "short"
  228. breakout = None
  229. continue
  230. if highs[index] == highs[index] and candle.close > float(highs[index]):
  231. breakout = {"side": "long", "level": float(highs[index]), "index": index}
  232. elif lows[index] == lows[index] and candle.close < float(lows[index]):
  233. breakout = {"side": "short", "level": float(lows[index]), "index": index}
  234. return _finalize_result(trades, equity_curve)
  235. def run_atr_expansion(
  236. candles: list[Candle],
  237. *,
  238. range_window: int,
  239. atr_window: int,
  240. atr_quantile_window: int,
  241. atr_quantile: float,
  242. stop_loss_pct: float,
  243. take_profit_pct: float,
  244. max_hold_bars: int,
  245. margin_fraction: float,
  246. session: str,
  247. ) -> SegmentResult:
  248. if len(candles) <= max(range_window, atr_window, atr_quantile_window):
  249. return _empty_result(candles)
  250. highs = pd.Series([c.high for c in candles], dtype=float)
  251. lows = pd.Series([c.low for c in candles], dtype=float)
  252. closes = pd.Series([c.close for c in candles], dtype=float)
  253. prev_close = closes.shift(1)
  254. true_range = pd.concat([(highs - lows), (highs - prev_close).abs(), (lows - prev_close).abs()], axis=1).max(axis=1)
  255. atr = true_range.rolling(atr_window).mean() / closes
  256. atr_limit = atr.rolling(atr_quantile_window).quantile(atr_quantile).tolist()
  257. atr_values = atr.tolist()
  258. range_high = highs.shift(1).rolling(range_window).max().tolist()
  259. range_low = lows.shift(1).rolling(range_window).min().tolist()
  260. warmup = max(range_window, atr_window, atr_quantile_window)
  261. trades: list[dict[str, object]] = []
  262. equity_curve: list[dict[str, float | int]] = []
  263. equity = INITIAL_EQUITY
  264. position: Position | None = None
  265. pending_entry: str | None = None
  266. for index in range(warmup, len(candles)):
  267. candle = candles[index]
  268. if pending_entry is not None and position is None and equity > 0.0:
  269. position = _open_position(
  270. side=pending_entry,
  271. candle=candle,
  272. index=index,
  273. equity=equity,
  274. margin_fraction=margin_fraction,
  275. stop_loss_pct=stop_loss_pct,
  276. take_profit_pct=take_profit_pct,
  277. max_hold_bars=max_hold_bars,
  278. )
  279. pending_entry = None
  280. if position is not None:
  281. stop_hit = (position.side == "long" and candle.low <= position.stop_price) or (position.side == "short" and candle.high >= position.stop_price)
  282. take_hit = position.take_price is not None and (
  283. (position.side == "long" and candle.high >= position.take_price) or (position.side == "short" and candle.low <= position.take_price)
  284. )
  285. held = index - position.entry_index
  286. if stop_hit or take_hit or held >= position.max_hold_bars:
  287. exit_price = position.stop_price if stop_hit else position.take_price if take_hit else candle.close
  288. equity, _ = _close_position(trades=trades, position=position, candle=candle, exit_price=float(exit_price))
  289. position = None
  290. current_equity = _mark_account(equity, position, candle.close)
  291. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  292. if index == len(candles) - 1 or position is not None or not _session_ok(candle, session):
  293. continue
  294. values = (atr_values[index - 1], atr_limit[index - 1], range_high[index], range_low[index])
  295. if any(value != value for value in values):
  296. continue
  297. compressed = float(atr_values[index - 1]) <= float(atr_limit[index - 1])
  298. if compressed and candle.close > float(range_high[index]):
  299. pending_entry = "long"
  300. elif compressed and candle.close < float(range_low[index]):
  301. pending_entry = "short"
  302. return _finalize_result(trades, equity_curve)
  303. def run_false_breakout(
  304. candles: list[Candle],
  305. *,
  306. lookback: int,
  307. reclaim_buffer: float,
  308. stop_loss_pct: float,
  309. take_profit_pct: float,
  310. max_hold_bars: int,
  311. margin_fraction: float,
  312. session: str,
  313. ) -> SegmentResult:
  314. highs = pd.Series([c.high for c in candles], dtype=float).shift(1).rolling(lookback).max().tolist()
  315. lows = pd.Series([c.low for c in candles], dtype=float).shift(1).rolling(lookback).min().tolist()
  316. trades: list[dict[str, object]] = []
  317. equity_curve: list[dict[str, float | int]] = []
  318. equity = INITIAL_EQUITY
  319. position: Position | None = None
  320. pending_entry: str | None = None
  321. for index in range(lookback, len(candles)):
  322. candle = candles[index]
  323. if pending_entry is not None and position is None and equity > 0.0:
  324. position = _open_position(
  325. side=pending_entry,
  326. candle=candle,
  327. index=index,
  328. equity=equity,
  329. margin_fraction=margin_fraction,
  330. stop_loss_pct=stop_loss_pct,
  331. take_profit_pct=take_profit_pct,
  332. max_hold_bars=max_hold_bars,
  333. )
  334. pending_entry = None
  335. if position is not None:
  336. stop_hit = (position.side == "long" and candle.low <= position.stop_price) or (position.side == "short" and candle.high >= position.stop_price)
  337. take_hit = position.take_price is not None and (
  338. (position.side == "long" and candle.high >= position.take_price) or (position.side == "short" and candle.low <= position.take_price)
  339. )
  340. held = index - position.entry_index
  341. if stop_hit or take_hit or held >= position.max_hold_bars:
  342. exit_price = position.stop_price if stop_hit else position.take_price if take_hit else candle.close
  343. equity, _ = _close_position(trades=trades, position=position, candle=candle, exit_price=float(exit_price))
  344. position = None
  345. current_equity = _mark_account(equity, position, candle.close)
  346. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  347. if index == len(candles) - 1 or position is not None or not _session_ok(candle, session):
  348. continue
  349. if highs[index] == highs[index] and candle.high > float(highs[index]) and candle.close < float(highs[index]) * (1.0 - reclaim_buffer):
  350. pending_entry = "short"
  351. elif lows[index] == lows[index] and candle.low < float(lows[index]) and candle.close > float(lows[index]) * (1.0 + reclaim_buffer):
  352. pending_entry = "long"
  353. return _finalize_result(trades, equity_curve)
  354. def run_vwap_midline_deviation(
  355. candles: list[Candle],
  356. *,
  357. window: int,
  358. entry_z: float,
  359. exit_z: float,
  360. stop_loss_pct: float,
  361. max_hold_bars: int,
  362. margin_fraction: float,
  363. session: str,
  364. ) -> SegmentResult:
  365. closes = pd.Series([c.close for c in candles], dtype=float)
  366. volumes = pd.Series([c.volume for c in candles], dtype=float)
  367. highs = pd.Series([c.high for c in candles], dtype=float)
  368. lows = pd.Series([c.low for c in candles], dtype=float)
  369. typical = (highs + lows + closes) / 3.0
  370. vwap = (typical * volumes).rolling(window).sum() / volumes.rolling(window).sum()
  371. deviation = (closes - vwap) / vwap
  372. deviation_std = deviation.rolling(window).std(ddof=0)
  373. zscore = (deviation / deviation_std).tolist()
  374. trades: list[dict[str, object]] = []
  375. equity_curve: list[dict[str, float | int]] = []
  376. equity = INITIAL_EQUITY
  377. position: Position | None = None
  378. pending_entry: str | None = None
  379. pending_exit = False
  380. warmup = window * 2
  381. for index in range(warmup, len(candles)):
  382. candle = candles[index]
  383. if pending_exit and position is not None:
  384. equity, _ = _close_position(trades=trades, position=position, candle=candle, exit_price=candle.open)
  385. position = None
  386. pending_exit = False
  387. if pending_entry is not None and position is None and equity > 0.0:
  388. position = _open_position(
  389. side=pending_entry,
  390. candle=candle,
  391. index=index,
  392. equity=equity,
  393. margin_fraction=margin_fraction,
  394. stop_loss_pct=stop_loss_pct,
  395. take_profit_pct=None,
  396. max_hold_bars=max_hold_bars,
  397. )
  398. pending_entry = None
  399. if position is not None:
  400. stop_hit = (position.side == "long" and candle.low <= position.stop_price) or (position.side == "short" and candle.high >= position.stop_price)
  401. held = index - position.entry_index
  402. if stop_hit or held >= position.max_hold_bars:
  403. exit_price = position.stop_price if stop_hit else candle.close
  404. equity, _ = _close_position(trades=trades, position=position, candle=candle, exit_price=float(exit_price))
  405. position = None
  406. current_equity = _mark_account(equity, position, candle.close)
  407. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  408. if index == len(candles) - 1 or not _session_ok(candle, session):
  409. continue
  410. z = zscore[index]
  411. if z != z:
  412. continue
  413. if position is not None:
  414. if (position.side == "long" and z >= -exit_z) or (position.side == "short" and z <= exit_z):
  415. pending_exit = True
  416. continue
  417. if z <= -entry_z:
  418. pending_entry = "long"
  419. elif z >= entry_z:
  420. pending_entry = "short"
  421. return _finalize_result(trades, equity_curve)
  422. def run_opening_split(
  423. candles: list[Candle],
  424. *,
  425. first_bars: int,
  426. confirm_bars: int,
  427. min_range_pct: float,
  428. stop_loss_pct: float,
  429. take_profit_pct: float,
  430. max_hold_bars: int,
  431. margin_fraction: float,
  432. direction: str,
  433. ) -> SegmentResult:
  434. by_day: dict[str, list[int]] = {}
  435. for index, candle in enumerate(candles):
  436. day = pd.to_datetime(candle.ts, unit="ms", utc=True).strftime("%Y-%m-%d")
  437. by_day.setdefault(day, []).append(index)
  438. trades: list[dict[str, object]] = []
  439. equity_curve: list[dict[str, float | int]] = []
  440. equity = INITIAL_EQUITY
  441. position: Position | None = None
  442. pending_by_index: dict[int, str] = {}
  443. days = set(by_day)
  444. for index, candle in enumerate(candles):
  445. day = pd.to_datetime(candle.ts, unit="ms", utc=True).strftime("%Y-%m-%d")
  446. if day in days and by_day[day][0] == index and len(by_day[day]) > first_bars + confirm_bars:
  447. first = by_day[day][:first_bars]
  448. confirm = by_day[day][first_bars : first_bars + confirm_bars]
  449. first_high = max(candles[i].high for i in first)
  450. first_low = min(candles[i].low for i in first)
  451. first_open = candles[first[0]].open
  452. range_pct = (first_high - first_low) / first_open
  453. if range_pct >= min_range_pct:
  454. confirm_close = candles[confirm[-1]].close
  455. entry_index = confirm[-1] + 1
  456. if entry_index < len(candles):
  457. if direction in ("follow", "both") and confirm_close > first_high:
  458. pending_by_index[entry_index] = "long"
  459. elif direction in ("fade", "both") and confirm_close < first_low:
  460. pending_by_index[entry_index] = "short"
  461. side = pending_by_index.pop(index, None)
  462. if side is not None and position is None and equity > 0.0:
  463. position = _open_position(
  464. side=side,
  465. candle=candle,
  466. index=index,
  467. equity=equity,
  468. margin_fraction=margin_fraction,
  469. stop_loss_pct=stop_loss_pct,
  470. take_profit_pct=take_profit_pct,
  471. max_hold_bars=max_hold_bars,
  472. )
  473. if position is not None:
  474. stop_hit = (position.side == "long" and candle.low <= position.stop_price) or (position.side == "short" and candle.high >= position.stop_price)
  475. take_hit = position.take_price is not None and (
  476. (position.side == "long" and candle.high >= position.take_price) or (position.side == "short" and candle.low <= position.take_price)
  477. )
  478. held = index - position.entry_index
  479. if stop_hit or take_hit or held >= position.max_hold_bars:
  480. exit_price = position.stop_price if stop_hit else position.take_price if take_hit else candle.close
  481. equity, _ = _close_position(trades=trades, position=position, candle=candle, exit_price=float(exit_price))
  482. position = None
  483. current_equity = _mark_account(equity, position, candle.close)
  484. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  485. return _finalize_result(trades, equity_curve)
  486. def build_variants() -> list[Variant]:
  487. variants: list[Variant] = []
  488. for lookback in (48, 96, 192):
  489. for pullback_pct in (0.000, 0.0015):
  490. for margin_fraction in (0.25, 0.4):
  491. for session in ("all", "us", "eu"):
  492. params = {
  493. "lookback": lookback,
  494. "pullback_pct": pullback_pct,
  495. "stop_loss_pct": 0.008,
  496. "take_profit_pct": 0.014,
  497. "max_hold_bars": 32,
  498. "margin_fraction": margin_fraction,
  499. "session": session,
  500. }
  501. name = f"range-retest-l{lookback}-pb{pullback_pct:g}-sl0.008-tp0.014-mf{margin_fraction:g}-{session}"
  502. variants.append(Variant("range_breakout_retest", name, params, lambda candles, params=params: run_range_retest(candles, **params)))
  503. for range_window in (48, 96):
  504. for atr_quantile in (0.15, 0.25):
  505. for margin_fraction in (0.25, 0.4):
  506. for session in ("all", "us"):
  507. params = {
  508. "range_window": range_window,
  509. "atr_window": 48,
  510. "atr_quantile_window": 480,
  511. "atr_quantile": atr_quantile,
  512. "stop_loss_pct": 0.008,
  513. "take_profit_pct": 0.016,
  514. "max_hold_bars": 32,
  515. "margin_fraction": margin_fraction,
  516. "session": session,
  517. }
  518. name = f"atr-compress-expand-r{range_window}-q{atr_quantile:g}-sl0.008-tp0.016-mf{margin_fraction:g}-{session}"
  519. variants.append(Variant("atr_compression_expansion", name, params, lambda candles, params=params: run_atr_expansion(candles, **params)))
  520. for lookback in (48, 96, 192):
  521. for reclaim_buffer in (0.000, 0.001):
  522. for margin_fraction in (0.25, 0.4):
  523. for session in ("all", "us"):
  524. params = {
  525. "lookback": lookback,
  526. "reclaim_buffer": reclaim_buffer,
  527. "stop_loss_pct": 0.007,
  528. "take_profit_pct": 0.010,
  529. "max_hold_bars": 24,
  530. "margin_fraction": margin_fraction,
  531. "session": session,
  532. }
  533. name = f"false-breakout-l{lookback}-rb{reclaim_buffer:g}-sl0.007-tp0.010-mf{margin_fraction:g}-{session}"
  534. variants.append(Variant("donchian_false_breakout", name, params, lambda candles, params=params: run_false_breakout(candles, **params)))
  535. for window in (96, 192):
  536. for entry_z in (2.0, 2.5):
  537. for margin_fraction in (0.25, 0.4):
  538. for session in ("all", "us"):
  539. params = {
  540. "window": window,
  541. "entry_z": entry_z,
  542. "exit_z": 0.25,
  543. "stop_loss_pct": 0.008,
  544. "max_hold_bars": 48,
  545. "margin_fraction": margin_fraction,
  546. "session": session,
  547. }
  548. name = f"vwap-mid-dev-w{window}-z{entry_z:g}-x0.25-sl0.008-mf{margin_fraction:g}-{session}"
  549. variants.append(Variant("vwap_midline_deviation", name, params, lambda candles, params=params: run_vwap_midline_deviation(candles, **params)))
  550. for first_bars in (1, 2):
  551. for confirm_bars in (1, 2):
  552. for min_range_pct in (0.003, 0.006):
  553. for margin_fraction in (0.25, 0.4):
  554. params = {
  555. "first_bars": first_bars,
  556. "confirm_bars": confirm_bars,
  557. "min_range_pct": min_range_pct,
  558. "stop_loss_pct": 0.007,
  559. "take_profit_pct": 0.012,
  560. "max_hold_bars": 24,
  561. "margin_fraction": margin_fraction,
  562. "direction": "follow",
  563. }
  564. name = f"opening-split-f{first_bars}-c{confirm_bars}-r{min_range_pct:g}-sl0.007-tp0.012-mf{margin_fraction:g}"
  565. variants.append(Variant("opening_first_bars_split", name, params, lambda candles, params=params: run_opening_split(candles, **params)))
  566. return variants
  567. def cost_equity_frame(result: SegmentResult, cost: float) -> pd.DataFrame:
  568. rows = [{"ts": pd.to_datetime(result.equity_curve[0]["ts"], unit="ms", utc=True), "equity": INITIAL_EQUITY}]
  569. equity = INITIAL_EQUITY
  570. for trade in result.trades:
  571. equity *= 1.0 + float(trade["return_pct"]) / 100.0 - cost * float(trade["cost_weight"])
  572. rows.append({"ts": pd.to_datetime(str(trade["exit_time"]), utc=True), "equity": equity})
  573. last_ts = pd.to_datetime(result.equity_curve[-1]["ts"], unit="ms", utc=True)
  574. if rows[-1]["ts"] < last_ts:
  575. rows.append({"ts": last_ts, "equity": equity})
  576. return pd.DataFrame(rows)
  577. def equity_metrics(frame: pd.DataFrame, first_ts: int, last_ts: int) -> dict[str, float]:
  578. years = (last_ts - first_ts) / 86_400_000 / 365
  579. total_return = float(frame["equity"].iloc[-1] / frame["equity"].iloc[0] - 1.0)
  580. annualized = (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 and years > 0.0 else 0.0
  581. drawdown = _max_drawdown([float(value) for value in frame["equity"]])
  582. return {
  583. "net_total_return": total_return,
  584. "net_annualized_return": annualized,
  585. "net_max_drawdown": drawdown,
  586. "net_calmar": annualized / drawdown if drawdown else 0.0,
  587. "risk_reward_ratio": total_return / drawdown if drawdown else 0.0,
  588. }
  589. def trade_distribution(result: SegmentResult) -> dict[str, float]:
  590. wins = [float(trade["return_on_margin_pct"]) for trade in result.trades if float(trade["pnl"]) > 0.0]
  591. losses = [float(trade["return_on_margin_pct"]) for trade in result.trades if float(trade["pnl"]) < 0.0]
  592. avg_win = sum(wins) / len(wins) if wins else 0.0
  593. avg_loss = sum(losses) / len(losses) if losses else 0.0
  594. gross_profit = sum(float(trade["pnl"]) for trade in result.trades if float(trade["pnl"]) > 0.0)
  595. gross_loss = -sum(float(trade["pnl"]) for trade in result.trades if float(trade["pnl"]) < 0.0)
  596. return {
  597. "avg_win_on_margin_pct": avg_win,
  598. "avg_loss_on_margin_pct": avg_loss,
  599. "win_loss_ratio": avg_win / abs(avg_loss) if avg_loss else 0.0,
  600. "profit_factor": gross_profit / gross_loss if gross_loss else 0.0,
  601. }
  602. def horizon_rows(frame: pd.DataFrame, last_ts: int) -> list[dict[str, object]]:
  603. rows: list[dict[str, object]] = []
  604. end_time = pd.to_datetime(last_ts, unit="ms", utc=True)
  605. for label, offset in HORIZONS:
  606. if offset is None:
  607. horizon_frame = frame[["ts", "equity"]].copy()
  608. start_time = pd.Timestamp(horizon_frame["ts"].iloc[0])
  609. else:
  610. cutoff = end_time - offset
  611. before = frame[frame["ts"] <= cutoff]
  612. if len(before):
  613. start_equity = float(before["equity"].iloc[-1])
  614. start_time = cutoff
  615. after = frame[frame["ts"] > cutoff]
  616. horizon_frame = pd.concat([pd.DataFrame([{"ts": cutoff, "equity": start_equity}]), after[["ts", "equity"]]], ignore_index=True)
  617. else:
  618. horizon_frame = frame[["ts", "equity"]].copy()
  619. start_time = pd.Timestamp(horizon_frame["ts"].iloc[0])
  620. rows.append(
  621. {
  622. "horizon": label,
  623. "horizon_start": start_time.strftime("%Y-%m-%d %H:%M"),
  624. "horizon_end": end_time.strftime("%Y-%m-%d %H:%M"),
  625. **equity_metrics(horizon_frame, int(start_time.timestamp() * 1000), last_ts),
  626. }
  627. )
  628. return rows
  629. def monthly_rows(frame: pd.DataFrame) -> list[dict[str, object]]:
  630. monthly = frame.set_index("ts")["equity"].resample("ME").last().ffill().pct_change().dropna()
  631. return [{"month": idx.strftime("%Y-%m"), "monthly_return": float(value)} for idx, value in monthly.items()]
  632. def worst_month(frame: pd.DataFrame) -> tuple[str, float]:
  633. rows = monthly_rows(frame)
  634. if not rows:
  635. return "", 0.0
  636. worst = min(rows, key=lambda row: float(row["monthly_return"]))
  637. return str(worst["month"]), float(worst["monthly_return"])
  638. def robust_ranking(summary: pd.DataFrame, horizons: pd.DataFrame) -> pd.DataFrame:
  639. primary = summary[summary["cost"] == PRIMARY_COST].copy()
  640. recent = horizons[(horizons["cost"] == PRIMARY_COST) & (horizons["horizon"] != "all")]
  641. pivot = recent.pivot_table(
  642. index="name",
  643. columns="horizon",
  644. values=["net_total_return", "net_max_drawdown", "net_calmar"],
  645. aggfunc="first",
  646. observed=False,
  647. )
  648. pivot.columns = [f"{metric}_{horizon}" for metric, horizon in pivot.columns]
  649. ranked = primary.merge(pivot.reset_index(), on="name")
  650. ranked["min_recent_return"] = ranked[
  651. ["net_total_return_3y", "net_total_return_1y", "net_total_return_6m", "net_total_return_3m"]
  652. ].min(axis=1)
  653. ranked["max_recent_drawdown"] = ranked[
  654. ["net_max_drawdown_3y", "net_max_drawdown_1y", "net_max_drawdown_6m", "net_max_drawdown_3m"]
  655. ].max(axis=1)
  656. ranked["all_recent_positive"] = ranked["min_recent_return"] > 0.0
  657. return ranked.sort_values(
  658. [
  659. "all_recent_positive",
  660. "min_recent_return",
  661. "max_recent_drawdown",
  662. "net_calmar",
  663. "net_total_return",
  664. ],
  665. ascending=[False, False, True, False, False],
  666. )
  667. def format_cell(value: object) -> str:
  668. if isinstance(value, float):
  669. return f"{value:.6g}"
  670. return str(value).replace("|", "\\|")
  671. def markdown_table(frame: pd.DataFrame) -> str:
  672. columns = list(frame.columns)
  673. rows = [columns, ["---" for _ in columns]]
  674. for record in frame.to_dict("records"):
  675. rows.append([record[column] for column in columns])
  676. return "\n".join("| " + " | ".join(format_cell(value) for value in row) + " |" for row in rows)
  677. def write_report(
  678. *,
  679. summary: pd.DataFrame,
  680. horizons: pd.DataFrame,
  681. monthly: pd.DataFrame,
  682. robust: pd.DataFrame,
  683. output_files: list[Path],
  684. command: str,
  685. first_ts: int,
  686. last_ts: int,
  687. requested_years: float,
  688. ) -> str:
  689. primary = summary[summary["cost"] == PRIMARY_COST].copy()
  690. top = primary.head(10)
  691. low_dd = primary[(primary["net_max_drawdown"] <= 0.35) & (primary["net_total_return"] > 0.0)].head(10)
  692. horizon_top = (
  693. horizons[(horizons["cost"] == PRIMARY_COST) & (horizons["horizon"] != "all")]
  694. .sort_values(["horizon", "net_calmar", "net_annualized_return"], ascending=[True, False, False])
  695. .groupby("horizon", observed=True)
  696. .head(3)
  697. )
  698. family = (
  699. primary.groupby("family", as_index=False)
  700. .agg(
  701. best_calmar=("net_calmar", "max"),
  702. best_annualized=("net_annualized_return", "max"),
  703. best_drawdown=("net_max_drawdown", "min"),
  704. candidates=("name", "count"),
  705. )
  706. .sort_values(["best_calmar", "best_annualized"], ascending=False)
  707. )
  708. best_name = str(primary.iloc[0]["name"]) if len(primary) else ""
  709. best_monthly = monthly[(monthly["cost"] == PRIMARY_COST) & (monthly["name"] == best_name)].sort_values("monthly_return").head(12)
  710. robust_top = robust.head(10)
  711. lines = [
  712. "# ETH microstructure non-RSI exploration",
  713. "",
  714. f"Run command: `{command}`",
  715. f"Requested years: {requested_years:g}",
  716. f"Actual continuous local history: `{_format_ts(first_ts)}` to `{_format_ts(last_ts)}`.",
  717. "",
  718. "No order placement or exchange API path is used; this script only reads local candle CSV files.",
  719. "",
  720. "Output files:",
  721. *[f"- `{path}`" for path in output_files],
  722. "",
  723. "Primary ranking: maker_taker by net Calmar, annualized return, total return, then lower drawdown.",
  724. "",
  725. "Top 10 maker_taker:",
  726. markdown_table(
  727. top[
  728. [
  729. "family",
  730. "name",
  731. "trades",
  732. "net_total_return",
  733. "net_annualized_return",
  734. "net_max_drawdown",
  735. "net_calmar",
  736. "win_rate",
  737. "win_loss_ratio",
  738. "profit_factor",
  739. "risk_reward_ratio",
  740. "worst_month",
  741. "worst_month_return",
  742. ]
  743. ]
  744. ),
  745. "",
  746. "Family leaders:",
  747. markdown_table(family),
  748. "",
  749. "Recent horizon leaders:",
  750. markdown_table(
  751. horizon_top[
  752. [
  753. "horizon",
  754. "family",
  755. "name",
  756. "trades",
  757. "net_total_return",
  758. "net_annualized_return",
  759. "net_max_drawdown",
  760. "net_calmar",
  761. "risk_reward_ratio",
  762. ]
  763. ]
  764. ),
  765. "",
  766. "Robust ranking by recent survival:",
  767. markdown_table(
  768. robust_top[
  769. [
  770. "all_recent_positive",
  771. "family",
  772. "name",
  773. "trades",
  774. "net_total_return",
  775. "net_max_drawdown",
  776. "min_recent_return",
  777. "max_recent_drawdown",
  778. "net_total_return_3y",
  779. "net_total_return_1y",
  780. "net_total_return_6m",
  781. "net_total_return_3m",
  782. ]
  783. ]
  784. ),
  785. "",
  786. "Low drawdown positive candidates:",
  787. markdown_table(
  788. low_dd[
  789. [
  790. "family",
  791. "name",
  792. "trades",
  793. "net_total_return",
  794. "net_annualized_return",
  795. "net_max_drawdown",
  796. "net_calmar",
  797. "worst_month_return",
  798. ]
  799. ]
  800. )
  801. if len(low_dd)
  802. else "No maker_taker candidate met net_max_drawdown <= 0.35 with positive total return.",
  803. "",
  804. f"Worst months for best candidate `{best_name}`:",
  805. markdown_table(best_monthly[["month", "monthly_return"]]) if len(best_monthly) else "No monthly rows.",
  806. ]
  807. return "\n".join(lines) + "\n"
  808. def main() -> int:
  809. parser = argparse.ArgumentParser()
  810. parser.add_argument("--bar", default=BAR)
  811. parser.add_argument("--years", type=float, default=YEARS)
  812. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  813. args = parser.parse_args()
  814. candles = _load_candles(SYMBOL, args.bar)
  815. requested_bars = int(args.years * 365 * 24 * 60 / 15)
  816. candles = candles[-requested_bars:]
  817. variants = build_variants()
  818. summary_rows: list[dict[str, object]] = []
  819. horizon_output_rows: list[dict[str, object]] = []
  820. monthly_output_rows: list[dict[str, object]] = []
  821. for index, variant in enumerate(variants, start=1):
  822. result = variant.run(candles)
  823. distribution = trade_distribution(result)
  824. for cost_name, cost in COSTS:
  825. frame = cost_equity_frame(result, cost)
  826. month, month_return = worst_month(frame)
  827. base = {
  828. "family": variant.family,
  829. "cost": cost_name,
  830. "symbol": SYMBOL,
  831. "bar": args.bar,
  832. "name": variant.name,
  833. "first_candle": _format_ts(candles[0].ts),
  834. "last_candle": _format_ts(candles[-1].ts),
  835. "years": (candles[-1].ts - candles[0].ts) / 86_400_000 / 365,
  836. "trades": result.trade_count,
  837. "win_rate": result.win_rate,
  838. "gross_total_return": result.gross_total_return,
  839. "gross_max_drawdown_mark_to_market": result.gross_max_drawdown,
  840. "worst_month": month,
  841. "worst_month_return": month_return,
  842. **variant.params,
  843. **distribution,
  844. }
  845. summary_rows.append({**base, **equity_metrics(frame, candles[0].ts, candles[-1].ts)})
  846. for row in horizon_rows(frame, candles[-1].ts):
  847. horizon_output_rows.append({**base, **row})
  848. for row in monthly_rows(frame):
  849. monthly_output_rows.append({**base, **row})
  850. print(f"done {index}/{len(variants)} {variant.family} {variant.name} trades={result.trade_count}", flush=True)
  851. summary = pd.DataFrame(summary_rows).sort_values(
  852. ["cost", "net_calmar", "net_annualized_return", "net_total_return", "net_max_drawdown"],
  853. ascending=[True, False, False, False, True],
  854. )
  855. primary = summary[summary["cost"] == PRIMARY_COST]
  856. summary = pd.concat([primary, summary[summary["cost"] != PRIMARY_COST]], ignore_index=True)
  857. horizons = pd.DataFrame(horizon_output_rows)
  858. horizons["horizon"] = pd.Categorical(horizons["horizon"], categories=[label for label, _ in HORIZONS], ordered=True)
  859. horizons = horizons.sort_values(["cost", "horizon", "net_calmar", "net_annualized_return"], ascending=[True, True, False, False])
  860. monthly = pd.DataFrame(monthly_output_rows).sort_values(["cost", "name", "month"])
  861. robust = robust_ranking(summary, horizons)
  862. args.output_dir.mkdir(parents=True, exist_ok=True)
  863. summary_path = args.output_dir / f"{PREFIX}-summary.csv"
  864. horizons_path = args.output_dir / f"{PREFIX}-horizons.csv"
  865. top10_path = args.output_dir / f"{PREFIX}-top10.csv"
  866. monthly_path = args.output_dir / f"{PREFIX}-monthly.csv"
  867. robust_path = args.output_dir / f"{PREFIX}-robust.csv"
  868. json_path = args.output_dir / f"{PREFIX}-best.json"
  869. report_path = args.output_dir / f"{PREFIX}-report.md"
  870. output_files = [summary_path, horizons_path, top10_path, monthly_path, robust_path, json_path, report_path]
  871. summary.to_csv(summary_path, index=False)
  872. horizons.to_csv(horizons_path, index=False)
  873. primary.head(10).to_csv(top10_path, index=False)
  874. monthly.to_csv(monthly_path, index=False)
  875. robust.to_csv(robust_path, index=False)
  876. json_path.write_text(json.dumps(primary.head(10).to_dict("records"), indent=2), encoding="utf-8")
  877. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --bar {args.bar} --years {args.years:g}"
  878. report_path.write_text(
  879. write_report(
  880. summary=summary,
  881. horizons=horizons,
  882. monthly=monthly,
  883. robust=robust,
  884. output_files=output_files,
  885. command=command,
  886. first_ts=candles[0].ts,
  887. last_ts=candles[-1].ts,
  888. requested_years=args.years,
  889. ),
  890. encoding="utf-8",
  891. )
  892. print(primary.head(10).to_string(index=False))
  893. return 0
  894. if __name__ == "__main__":
  895. raise SystemExit(main())