search_eth_dynamic_atr_exits.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734
  1. from __future__ import annotations
  2. import argparse
  3. import sys
  4. from dataclasses import dataclass
  5. from pathlib import Path
  6. import pandas as pd
  7. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  8. from okx_codex_trader.models import Candle
  9. from okx_codex_trader.sampled_report import SegmentResult, mark_to_market, trade_equity
  10. ETH_SYMBOL = "ETH-USDT-SWAP"
  11. BTC_SYMBOL = "BTC-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. PRIMARY_COST = "maker_taker"
  19. COSTS = (
  20. ("maker_maker", 0.0012),
  21. ("maker_taker", 0.0021),
  22. ("taker_taker", 0.0030),
  23. )
  24. HORIZONS = (
  25. ("full", None),
  26. ("3y", pd.DateOffset(years=3)),
  27. ("1y", pd.DateOffset(years=1)),
  28. ("6m", pd.DateOffset(months=6)),
  29. ("3m", pd.DateOffset(months=3)),
  30. ("30d", pd.DateOffset(days=30)),
  31. )
  32. @dataclass(frozen=True)
  33. class RegimeExit:
  34. low_stop_atr: float
  35. mid_stop_atr: float
  36. high_stop_atr: float
  37. low_take_atr: float
  38. mid_take_atr: float
  39. high_take_atr: float
  40. low_trail_atr: float
  41. mid_trail_atr: float
  42. high_trail_atr: float
  43. trail_activation_atr: float
  44. def params(self, regime: str) -> tuple[float, float, float]:
  45. if regime == "low":
  46. return self.low_stop_atr, self.low_take_atr, self.low_trail_atr
  47. if regime == "high":
  48. return self.high_stop_atr, self.high_take_atr, self.high_trail_atr
  49. return self.mid_stop_atr, self.mid_take_atr, self.mid_trail_atr
  50. @dataclass(frozen=True)
  51. class Variant:
  52. band_length: int
  53. bandwidth_lookback: int
  54. bandwidth_quantile: float
  55. side_mode: str
  56. btc_filter: str
  57. cooldown_bars: int
  58. atr_length: int
  59. rv_length: int
  60. rv_quantile_lookback: int
  61. low_vol_quantile: float
  62. high_vol_quantile: float
  63. middle_exit_buffer_pct: float
  64. middle_exit_confirm_bars: int
  65. exit: RegimeExit
  66. @property
  67. def name(self) -> str:
  68. return (
  69. f"bb-squeeze-dyn-atr-l{self.band_length}-bw{self.bandwidth_lookback}"
  70. f"-q{self.bandwidth_quantile:g}-{self.side_mode}-{self.btc_filter}"
  71. f"-atr{self.atr_length}-rv{self.rv_length}x{self.rv_quantile_lookback}"
  72. f"-vq{self.low_vol_quantile:g}_{self.high_vol_quantile:g}"
  73. f"-sl{self.exit.low_stop_atr:g}_{self.exit.mid_stop_atr:g}_{self.exit.high_stop_atr:g}"
  74. f"-tp{self.exit.low_take_atr:g}_{self.exit.mid_take_atr:g}_{self.exit.high_take_atr:g}"
  75. f"-tr{self.exit.low_trail_atr:g}_{self.exit.mid_trail_atr:g}_{self.exit.high_trail_atr:g}"
  76. f"-act{self.exit.trail_activation_atr:g}"
  77. f"-cd{self.cooldown_bars}-mxbuf{self.middle_exit_buffer_pct:g}-mxc{self.middle_exit_confirm_bars}"
  78. )
  79. def _format_ts(ts: int) -> str:
  80. return pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
  81. def _load_candles(symbol: str, bar: str) -> list[Candle]:
  82. frame = pd.read_csv(DATA_DIR / symbol / f"{bar}.csv")
  83. return [
  84. Candle(
  85. symbol=symbol,
  86. ts=int(row.ts),
  87. open=float(row.open),
  88. high=float(row.high),
  89. low=float(row.low),
  90. close=float(row.close),
  91. volume=float(row.volume),
  92. )
  93. for row in frame.itertuples(index=False)
  94. ]
  95. def _align_pair(left: list[Candle], right: list[Candle]) -> tuple[list[Candle], list[Candle]]:
  96. right_by_ts = {candle.ts: candle for candle in right}
  97. left_out: list[Candle] = []
  98. right_out: list[Candle] = []
  99. for candle in left:
  100. other = right_by_ts.get(candle.ts)
  101. if other is not None:
  102. left_out.append(candle)
  103. right_out.append(other)
  104. return left_out, right_out
  105. def close_position(
  106. *,
  107. trades: list[dict[str, object]],
  108. exits: list[dict[str, object]],
  109. position: dict[str, object],
  110. candle: Candle,
  111. exit_price: float,
  112. reason: str,
  113. ) -> tuple[float, bool]:
  114. margin_used = float(position["margin_used"])
  115. exit_equity = trade_equity(
  116. side=str(position["side"]),
  117. margin_used=margin_used,
  118. entry_price=float(position["entry_price"]),
  119. exit_price=exit_price,
  120. leverage=LEVERAGE,
  121. )
  122. pnl = exit_equity - margin_used
  123. trades.append(
  124. {
  125. "side": "Long" if position["side"] == "long" else "Short",
  126. "entry_time": _format_ts(int(position["entry_time"])),
  127. "exit_time": _format_ts(candle.ts),
  128. "entry_ts": int(position["entry_time"]),
  129. "exit_ts": candle.ts,
  130. "entry_price": round(float(position["entry_price"]), 4),
  131. "exit_price": round(exit_price, 4),
  132. "pnl": round(pnl, 4),
  133. "return_pct": round(pnl / margin_used * 100.0, 4),
  134. "cost_weight": 1.0,
  135. "exit_reason": reason,
  136. "entry_vol_regime": position["entry_vol_regime"],
  137. "stop_atr": position["stop_atr"],
  138. "take_atr": position["take_atr"],
  139. "trail_atr": position["trail_atr"],
  140. "mfe_pct": round(float(position["mfe_pct"]) * 100.0, 4),
  141. }
  142. )
  143. exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
  144. return exit_equity, pnl > 0.0
  145. def true_range_series(eth: list[Candle]) -> pd.Series:
  146. high = pd.Series([candle.high for candle in eth], dtype=float)
  147. low = pd.Series([candle.low for candle in eth], dtype=float)
  148. close = pd.Series([candle.close for candle in eth], dtype=float)
  149. previous_close = close.shift(1)
  150. return pd.concat([(high - low), (high - previous_close).abs(), (low - previous_close).abs()], axis=1).max(axis=1)
  151. def vol_regime(realized_vol: float, low_threshold: float, high_threshold: float) -> str:
  152. if realized_vol <= low_threshold:
  153. return "low"
  154. if realized_vol >= high_threshold:
  155. return "high"
  156. return "mid"
  157. def favorable_move(side: str, entry_price: float, candle: Candle) -> float:
  158. if side == "long":
  159. return candle.high / entry_price - 1.0
  160. return entry_price / candle.low - 1.0
  161. def exit_position(
  162. position: dict[str, object],
  163. candle: Candle,
  164. atr_pct: float,
  165. variant: Variant,
  166. ) -> tuple[float, str] | None:
  167. side = str(position["side"])
  168. entry_price = float(position["entry_price"])
  169. stop_price = float(position["stop_price"])
  170. take_price = position["take_price"]
  171. mfe_atr = float(position["mfe_pct"]) / atr_pct if atr_pct > 0.0 else 0.0
  172. if mfe_atr >= variant.exit.trail_activation_atr:
  173. if side == "long":
  174. trail_stop = float(position["best_price"]) * (1.0 - float(position["trail_atr"]) * atr_pct)
  175. stop_price = max(stop_price, trail_stop)
  176. else:
  177. trail_stop = float(position["best_price"]) * (1.0 + float(position["trail_atr"]) * atr_pct)
  178. stop_price = min(stop_price, trail_stop)
  179. position["stop_price"] = stop_price
  180. if side == "long":
  181. if candle.open <= stop_price:
  182. return candle.open, "trail_gap" if stop_price != float(position["initial_stop_price"]) else "stop_gap"
  183. if take_price is not None and candle.open >= float(take_price):
  184. return candle.open, "take_gap"
  185. stop_hit = candle.low <= stop_price
  186. take_hit = take_price is not None and candle.high >= float(take_price)
  187. else:
  188. if candle.open >= stop_price:
  189. return candle.open, "trail_gap" if stop_price != float(position["initial_stop_price"]) else "stop_gap"
  190. if take_price is not None and candle.open <= float(take_price):
  191. return candle.open, "take_gap"
  192. stop_hit = candle.high >= stop_price
  193. take_hit = take_price is not None and candle.low <= float(take_price)
  194. if stop_hit:
  195. return stop_price, "trailing_stop" if stop_price != float(position["initial_stop_price"]) else "stop"
  196. if take_hit:
  197. return float(take_price), "take_profit"
  198. return None
  199. def run_variant(eth: list[Candle], btc: list[Candle], variant: Variant) -> tuple[SegmentResult, dict[str, int]]:
  200. eth_close = pd.Series([candle.close for candle in eth], dtype=float)
  201. btc_close = pd.Series([candle.close for candle in btc], dtype=float)
  202. middle_series = eth_close.rolling(variant.band_length).mean()
  203. stdev_series = eth_close.rolling(variant.band_length).std(ddof=0)
  204. upper_values = middle_series + 2.0 * stdev_series
  205. lower_values = middle_series - 2.0 * stdev_series
  206. bandwidth_series = (upper_values - lower_values) / middle_series
  207. threshold_series = bandwidth_series.rolling(variant.bandwidth_lookback).quantile(variant.bandwidth_quantile)
  208. btc_sma = btc_close.rolling(480).mean()
  209. btc_momentum = btc_close / btc_close.shift(96) - 1.0
  210. realized_vol = eth_close.pct_change().rolling(variant.rv_length).std(ddof=0)
  211. low_vol = realized_vol.rolling(variant.rv_quantile_lookback).quantile(variant.low_vol_quantile)
  212. high_vol = realized_vol.rolling(variant.rv_quantile_lookback).quantile(variant.high_vol_quantile)
  213. atr_pct = true_range_series(eth).rolling(variant.atr_length).mean() / eth_close
  214. warmup_bars = max(
  215. variant.band_length,
  216. variant.bandwidth_lookback,
  217. variant.rv_length + variant.rv_quantile_lookback,
  218. variant.atr_length,
  219. 480,
  220. 96,
  221. )
  222. equity = INITIAL_EQUITY
  223. ending_equity = equity
  224. peak_equity = equity
  225. max_drawdown = 0.0
  226. wins = 0
  227. trades: list[dict[str, object]] = []
  228. entries: list[dict[str, object]] = []
  229. exits: list[dict[str, object]] = []
  230. equity_curve: list[dict[str, float | int]] = []
  231. position: dict[str, object] | None = None
  232. pending_entry_side: str | None = None
  233. pending_exit = False
  234. middle_exit_streak = 0
  235. cooldown_until = -1
  236. exit_counts = {
  237. "stop_exits": 0,
  238. "take_profit_exits": 0,
  239. "trailing_stop_exits": 0,
  240. "signal_exits": 0,
  241. "low_vol_entries": 0,
  242. "mid_vol_entries": 0,
  243. "high_vol_entries": 0,
  244. }
  245. middle = middle_series.tolist()
  246. upper = upper_values.tolist()
  247. lower = lower_values.tolist()
  248. bandwidth = bandwidth_series.tolist()
  249. threshold = threshold_series.tolist()
  250. btc_sma_values = btc_sma.tolist()
  251. btc_momentum_values = btc_momentum.tolist()
  252. realized_vol_values = realized_vol.tolist()
  253. low_vol_values = low_vol.tolist()
  254. high_vol_values = high_vol.tolist()
  255. atr_pct_values = atr_pct.tolist()
  256. for index in range(warmup_bars, len(eth)):
  257. candle = eth[index]
  258. if pending_exit and position is not None:
  259. equity, won = close_position(
  260. trades=trades,
  261. exits=exits,
  262. position=position,
  263. candle=candle,
  264. exit_price=candle.open,
  265. reason="signal_middle",
  266. )
  267. wins += int(won)
  268. exit_counts["signal_exits"] += 1
  269. position = None
  270. pending_exit = False
  271. middle_exit_streak = 0
  272. cooldown_until = index + variant.cooldown_bars
  273. if pending_entry_side is not None and position is None and equity > 0.0:
  274. entry_price = candle.open
  275. regime = vol_regime(float(realized_vol_values[index - 1]), float(low_vol_values[index - 1]), float(high_vol_values[index - 1]))
  276. stop_atr, take_atr, trail_atr = variant.exit.params(regime)
  277. entry_atr_pct = float(atr_pct_values[index - 1])
  278. stop_distance = stop_atr * entry_atr_pct
  279. take_distance = take_atr * entry_atr_pct
  280. stop_price = entry_price * (1.0 - stop_distance if pending_entry_side == "long" else 1.0 + stop_distance)
  281. take_price = None
  282. if take_atr > 0.0:
  283. take_price = entry_price * (1.0 + take_distance if pending_entry_side == "long" else 1.0 - take_distance)
  284. position = {
  285. "side": pending_entry_side,
  286. "entry_time": candle.ts,
  287. "entry_price": entry_price,
  288. "margin_used": equity,
  289. "initial_stop_price": stop_price,
  290. "stop_price": stop_price,
  291. "take_price": take_price,
  292. "best_price": candle.high if pending_entry_side == "long" else candle.low,
  293. "mfe_pct": 0.0,
  294. "entry_vol_regime": regime,
  295. "stop_atr": stop_atr,
  296. "take_atr": take_atr,
  297. "trail_atr": trail_atr,
  298. }
  299. exit_counts[f"{regime}_vol_entries"] += 1
  300. entries.append({"ts": candle.ts, "price": entry_price, "side": pending_entry_side})
  301. pending_entry_side = None
  302. current_equity = equity
  303. if position is not None:
  304. if position["side"] == "long":
  305. position["best_price"] = max(float(position["best_price"]), candle.high)
  306. else:
  307. position["best_price"] = min(float(position["best_price"]), candle.low)
  308. position["mfe_pct"] = max(float(position["mfe_pct"]), favorable_move(str(position["side"]), float(position["entry_price"]), candle))
  309. risk_exit = exit_position(position, candle, float(atr_pct_values[index]), variant)
  310. if risk_exit is not None:
  311. exit_price, reason = risk_exit
  312. equity, won = close_position(
  313. trades=trades,
  314. exits=exits,
  315. position=position,
  316. candle=candle,
  317. exit_price=exit_price,
  318. reason=reason,
  319. )
  320. wins += int(won)
  321. if reason.startswith("take"):
  322. exit_counts["take_profit_exits"] += 1
  323. elif reason.startswith("trail"):
  324. exit_counts["trailing_stop_exits"] += 1
  325. else:
  326. exit_counts["stop_exits"] += 1
  327. current_equity = equity
  328. position = None
  329. middle_exit_streak = 0
  330. cooldown_until = index + variant.cooldown_bars
  331. if position is not None:
  332. current_equity = mark_to_market(
  333. side=str(position["side"]),
  334. margin_used=float(position["margin_used"]),
  335. entry_price=float(position["entry_price"]),
  336. mark_price=candle.close,
  337. leverage=LEVERAGE,
  338. )
  339. peak_equity = max(peak_equity, current_equity)
  340. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  341. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  342. ending_equity = current_equity
  343. if index == len(eth) - 1 or equity <= 0.0:
  344. continue
  345. values = (
  346. middle[index],
  347. upper[index],
  348. lower[index],
  349. bandwidth[index],
  350. threshold[index],
  351. btc_sma_values[index],
  352. btc_momentum_values[index],
  353. realized_vol_values[index],
  354. low_vol_values[index],
  355. high_vol_values[index],
  356. atr_pct_values[index],
  357. )
  358. if any(value != value for value in values):
  359. continue
  360. if position is not None:
  361. middle_exit = (
  362. position["side"] == "long" and candle.close < float(middle[index]) * (1.0 - variant.middle_exit_buffer_pct)
  363. ) or (
  364. position["side"] == "short" and candle.close > float(middle[index]) * (1.0 + variant.middle_exit_buffer_pct)
  365. )
  366. middle_exit_streak = middle_exit_streak + 1 if middle_exit else 0
  367. if middle_exit_streak >= variant.middle_exit_confirm_bars:
  368. pending_exit = True
  369. continue
  370. if index < cooldown_until:
  371. continue
  372. if variant.btc_filter == "btc-up" and not (btc_close.iloc[index] > float(btc_sma_values[index])):
  373. continue
  374. if variant.btc_filter == "btc-up-momo" and not (
  375. btc_close.iloc[index] > float(btc_sma_values[index]) and float(btc_momentum_values[index]) > 0.0
  376. ):
  377. continue
  378. if bandwidth[index] <= threshold[index]:
  379. if candle.close > float(upper[index]):
  380. pending_entry_side = "long"
  381. elif variant.side_mode == "both" and candle.close < float(lower[index]):
  382. pending_entry_side = "short"
  383. return (
  384. SegmentResult(
  385. trade_count=len(trades),
  386. total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
  387. win_rate=wins / len(trades) if trades else 0.0,
  388. max_drawdown=max_drawdown,
  389. trades=trades,
  390. open_position=position,
  391. candles=eth[warmup_bars:],
  392. equity_curve=equity_curve,
  393. entries=entries,
  394. exits=exits,
  395. ),
  396. exit_counts,
  397. )
  398. def cost_equity_frame(result: SegmentResult, cost: float) -> pd.DataFrame:
  399. rows = [{"ts": pd.to_datetime(result.equity_curve[0]["ts"], unit="ms", utc=True), "equity": INITIAL_EQUITY}]
  400. equity = INITIAL_EQUITY
  401. for trade in result.trades:
  402. equity *= 1.0 + float(trade["return_pct"]) / 100.0 - cost * float(trade.get("cost_weight", 1.0))
  403. rows.append({"ts": pd.to_datetime(int(trade["exit_ts"]), unit="ms", utc=True), "equity": equity})
  404. return pd.DataFrame(rows)
  405. def max_drawdown(values: list[float]) -> float:
  406. peak = values[0]
  407. dd = 0.0
  408. for value in values:
  409. peak = max(peak, value)
  410. dd = max(dd, (peak - value) / peak if peak else 0.0)
  411. return dd
  412. def equity_metrics(frame: pd.DataFrame, start_time: pd.Timestamp, end_time: pd.Timestamp) -> dict[str, float]:
  413. years = (end_time - start_time).total_seconds() / 86_400 / 365
  414. total_return = float(frame["equity"].iloc[-1] / frame["equity"].iloc[0] - 1.0)
  415. annualized = (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 and years > 0.0 else 0.0
  416. dd = max_drawdown([float(value) for value in frame["equity"]])
  417. return {
  418. "net_total_return": total_return,
  419. "net_annualized_return": annualized,
  420. "net_max_drawdown": dd,
  421. "net_calmar": annualized / dd if dd else 0.0,
  422. }
  423. def trade_stats(trades: list[dict[str, object]]) -> dict[str, float | int]:
  424. returns = [float(trade["return_pct"]) for trade in trades]
  425. wins = [value for value in returns if value > 0.0]
  426. losses = [-value for value in returns if value < 0.0]
  427. reasons = {str(trade["exit_reason"]) for trade in trades}
  428. return {
  429. "trades": len(trades),
  430. "win_rate": len(wins) / len(trades) if trades else 0.0,
  431. "avg_return_pct": sum(returns) / len(returns) if returns else 0.0,
  432. "avg_mfe_pct": sum(float(trade["mfe_pct"]) for trade in trades) / len(trades) if trades else 0.0,
  433. "payoff_ratio": (sum(wins) / len(wins)) / (sum(losses) / len(losses)) if wins and losses else 0.0,
  434. "profit_factor": sum(wins) / sum(losses) if losses else 0.0,
  435. **{f"exit_{reason}": sum(1 for trade in trades if trade["exit_reason"] == reason) for reason in sorted(reasons)},
  436. }
  437. def horizon_rows(frame: pd.DataFrame, trades: list[dict[str, object]], first_ts: int, last_ts: int) -> list[dict[str, object]]:
  438. rows: list[dict[str, object]] = []
  439. start = pd.to_datetime(first_ts, unit="ms", utc=True)
  440. end = pd.to_datetime(last_ts, unit="ms", utc=True)
  441. for label, offset in HORIZONS:
  442. cutoff = start if offset is None else end - offset
  443. before = frame[frame["ts"] <= cutoff]
  444. if len(before):
  445. start_equity = float(before["equity"].iloc[-1])
  446. after = frame[frame["ts"] > cutoff]
  447. horizon_frame = pd.concat([pd.DataFrame([{"ts": cutoff, "equity": start_equity}]), after[["ts", "equity"]]], ignore_index=True)
  448. else:
  449. horizon_frame = frame[["ts", "equity"]].copy()
  450. cutoff = pd.Timestamp(horizon_frame["ts"].iloc[0])
  451. cutoff_ms = int(cutoff.timestamp() * 1000)
  452. horizon_trades = [trade for trade in trades if int(trade["exit_ts"]) >= cutoff_ms]
  453. rows.append(
  454. {
  455. "horizon": label,
  456. "horizon_start": cutoff.strftime("%Y-%m-%d %H:%M"),
  457. "horizon_end": end.strftime("%Y-%m-%d %H:%M"),
  458. **equity_metrics(horizon_frame, cutoff, end),
  459. **trade_stats(horizon_trades),
  460. }
  461. )
  462. return rows
  463. def worst_month(frame: pd.DataFrame) -> tuple[str, float]:
  464. monthly = frame.set_index("ts")["equity"].resample("ME").last().ffill().pct_change().dropna()
  465. if not len(monthly):
  466. return "", 0.0
  467. idx = monthly.idxmin()
  468. return idx.strftime("%Y-%m"), float(monthly.loc[idx])
  469. def build_variants() -> list[Variant]:
  470. bases = (
  471. (48, 960, 0.25, "both", "none", 24, 0.0005, 1),
  472. (48, 960, 0.25, "both", "none", 24, 0.0010, 1),
  473. (96, 480, 0.15, "both", "none", 24, 0.0010, 1),
  474. (96, 960, 0.25, "both", "btc-up", 24, 0.0010, 1),
  475. (96, 960, 0.25, "both", "btc-up-momo", 24, 0.0010, 1),
  476. )
  477. exits = (
  478. RegimeExit(1.6, 1.9, 2.3, 3.0, 3.8, 5.0, 1.2, 1.5, 1.9, 1.8),
  479. RegimeExit(1.8, 2.2, 2.8, 3.2, 4.5, 6.0, 1.3, 1.8, 2.4, 2.0),
  480. RegimeExit(2.0, 2.6, 3.2, 4.0, 5.5, 7.0, 1.5, 2.1, 2.8, 2.2),
  481. RegimeExit(1.4, 1.8, 2.4, 2.8, 3.5, 4.8, 1.0, 1.4, 2.0, 1.6),
  482. RegimeExit(2.2, 2.8, 3.6, 4.2, 6.0, 8.0, 1.8, 2.4, 3.2, 2.4),
  483. RegimeExit(1.8, 2.4, 3.0, 0.0, 0.0, 0.0, 1.2, 1.8, 2.5, 1.8),
  484. )
  485. variants: list[Variant] = []
  486. for band_length, lookback, quantile, side_mode, btc_filter, cooldown, middle_buffer, middle_confirm in bases:
  487. for exit_spec in exits:
  488. variants.append(
  489. Variant(
  490. band_length=band_length,
  491. bandwidth_lookback=lookback,
  492. bandwidth_quantile=quantile,
  493. side_mode=side_mode,
  494. btc_filter=btc_filter,
  495. cooldown_bars=cooldown,
  496. atr_length=96,
  497. rv_length=96,
  498. rv_quantile_lookback=960,
  499. low_vol_quantile=0.35,
  500. high_vol_quantile=0.70,
  501. middle_exit_buffer_pct=middle_buffer,
  502. middle_exit_confirm_bars=middle_confirm,
  503. exit=exit_spec,
  504. )
  505. )
  506. return variants
  507. def format_cell(value: object) -> str:
  508. if isinstance(value, float):
  509. return f"{value:.6g}"
  510. return str(value).replace("|", "\\|")
  511. def markdown_table(frame: pd.DataFrame) -> str:
  512. columns = list(frame.columns)
  513. rows = [columns, ["---" for _ in columns]]
  514. for record in frame.to_dict("records"):
  515. rows.append([record[column] for column in columns])
  516. return "\n".join("| " + " | ".join(format_cell(value) for value in row) + " |" for row in rows)
  517. def write_report(summary: pd.DataFrame, horizon: pd.DataFrame, first_ts: int, last_ts: int, command: str) -> str:
  518. primary = summary[summary["cost"] == PRIMARY_COST]
  519. top = primary.head(10)
  520. horizon_top = (
  521. horizon[horizon["cost"] == PRIMARY_COST]
  522. .sort_values(["horizon", "net_calmar", "net_annualized_return"], ascending=[True, False, False])
  523. .groupby("horizon", observed=True)
  524. .head(3)
  525. )
  526. return "\n".join(
  527. [
  528. "# ETH dynamic ATR exit exploration",
  529. "",
  530. f"Run command: `{command}`",
  531. f"Actual continuous local history: `{_format_ts(first_ts)}` to `{_format_ts(last_ts)}`.",
  532. "",
  533. "Scope: ETH/BTC local OKX 15m candle cache. Entry remains BB squeeze breakout. Exits use entry realized-vol regime to choose ATR stop, ATR take-profit, and ATR trailing distance.",
  534. "",
  535. "Top 10 by maker_taker Calmar:",
  536. markdown_table(
  537. top[
  538. [
  539. "name",
  540. "trades",
  541. "win_rate",
  542. "net_total_return",
  543. "net_annualized_return",
  544. "net_max_drawdown",
  545. "net_calmar",
  546. "profit_factor",
  547. "payoff_ratio",
  548. "stop_exits",
  549. "take_profit_exits",
  550. "trailing_stop_exits",
  551. "signal_exits",
  552. "low_vol_entries",
  553. "mid_vol_entries",
  554. "high_vol_entries",
  555. ]
  556. ]
  557. ),
  558. "",
  559. "Horizon leaders:",
  560. markdown_table(
  561. horizon_top[
  562. [
  563. "horizon",
  564. "name",
  565. "trades",
  566. "win_rate",
  567. "net_total_return",
  568. "net_annualized_return",
  569. "net_max_drawdown",
  570. "net_calmar",
  571. "profit_factor",
  572. "payoff_ratio",
  573. ]
  574. ]
  575. ),
  576. ]
  577. ) + "\n"
  578. def main() -> int:
  579. parser = argparse.ArgumentParser()
  580. parser.add_argument("--bar", default=BAR)
  581. parser.add_argument("--years", type=float, default=YEARS)
  582. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  583. args = parser.parse_args()
  584. eth = _load_candles(ETH_SYMBOL, args.bar)
  585. btc = _load_candles(BTC_SYMBOL, args.bar)
  586. eth, btc = _align_pair(eth, btc)
  587. requested_bars = int(args.years * 365 * 24 * 60 / 15)
  588. eth = eth[-requested_bars:]
  589. btc = btc[-requested_bars:]
  590. summary_rows: list[dict[str, object]] = []
  591. horizon_rows_out: list[dict[str, object]] = []
  592. variants = build_variants()
  593. for index, variant in enumerate(variants, start=1):
  594. result, exit_counts = run_variant(eth, btc, variant)
  595. if not result.equity_curve:
  596. continue
  597. for cost_name, cost in COSTS:
  598. frame = cost_equity_frame(result, cost)
  599. metrics = equity_metrics(
  600. frame,
  601. pd.to_datetime(eth[0].ts, unit="ms", utc=True),
  602. pd.to_datetime(eth[-1].ts, unit="ms", utc=True),
  603. )
  604. month, month_return = worst_month(frame)
  605. stats = trade_stats(result.trades)
  606. row = {
  607. "family": "bb_squeeze_dynamic_atr_exits",
  608. "cost": cost_name,
  609. "symbol": ETH_SYMBOL,
  610. "signal_symbol": BTC_SYMBOL if variant.btc_filter != "none" else "",
  611. "bar": args.bar,
  612. "name": variant.name,
  613. "band_length": variant.band_length,
  614. "bandwidth_lookback": variant.bandwidth_lookback,
  615. "bandwidth_quantile": variant.bandwidth_quantile,
  616. "side_mode": variant.side_mode,
  617. "btc_filter": variant.btc_filter,
  618. "cooldown_bars": variant.cooldown_bars,
  619. "atr_length": variant.atr_length,
  620. "rv_length": variant.rv_length,
  621. "rv_quantile_lookback": variant.rv_quantile_lookback,
  622. "low_vol_quantile": variant.low_vol_quantile,
  623. "high_vol_quantile": variant.high_vol_quantile,
  624. "middle_exit_buffer_pct": variant.middle_exit_buffer_pct,
  625. "middle_exit_confirm_bars": variant.middle_exit_confirm_bars,
  626. "first_candle": _format_ts(eth[0].ts),
  627. "last_candle": _format_ts(eth[-1].ts),
  628. "years": (eth[-1].ts - eth[0].ts) / 86_400_000 / 365,
  629. "gross_total_return": result.total_return,
  630. "gross_max_drawdown_mark_to_market": result.max_drawdown,
  631. "worst_month": month,
  632. "worst_month_return": month_return,
  633. **exit_counts,
  634. **stats,
  635. **metrics,
  636. }
  637. summary_rows.append(row)
  638. for horizon_row in horizon_rows(frame, result.trades, eth[0].ts, eth[-1].ts):
  639. horizon_rows_out.append(
  640. {
  641. "family": "bb_squeeze_dynamic_atr_exits",
  642. "cost": cost_name,
  643. "symbol": ETH_SYMBOL,
  644. "bar": args.bar,
  645. "name": variant.name,
  646. **horizon_row,
  647. }
  648. )
  649. print(f"done {index}/{len(variants)} {variant.name}", flush=True)
  650. summary = pd.DataFrame(summary_rows).sort_values(
  651. ["cost", "net_calmar", "net_annualized_return", "profit_factor"],
  652. ascending=[True, False, False, False],
  653. )
  654. primary = summary[summary["cost"] == PRIMARY_COST]
  655. summary = pd.concat([primary, summary[summary["cost"] != PRIMARY_COST]], ignore_index=True)
  656. horizon = pd.DataFrame(horizon_rows_out)
  657. horizon["horizon"] = pd.Categorical(horizon["horizon"], categories=[label for label, _ in HORIZONS], ordered=True)
  658. horizon = horizon.sort_values(["cost", "horizon", "net_calmar", "net_annualized_return"], ascending=[True, True, False, False])
  659. args.output_dir.mkdir(parents=True, exist_ok=True)
  660. summary_path = args.output_dir / "eth-dynamic-atr-exits-summary.csv"
  661. horizon_path = args.output_dir / "eth-dynamic-atr-exits-horizon.csv"
  662. report_path = args.output_dir / "eth-dynamic-atr-exits-report.md"
  663. summary.to_csv(summary_path, index=False)
  664. horizon.to_csv(horizon_path, index=False)
  665. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --bar {args.bar} --years {args.years}"
  666. report_path.write_text(write_report(summary, horizon, eth[0].ts, eth[-1].ts, command), encoding="utf-8")
  667. print(primary.head(10).to_string(index=False))
  668. return 0
  669. if __name__ == "__main__":
  670. raise SystemExit(main())