search_eth_adaptive_state_exit.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784
  1. from __future__ import annotations
  2. import argparse
  3. import sys
  4. from collections import Counter
  5. from dataclasses import dataclass
  6. from pathlib import Path
  7. import pandas as pd
  8. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  9. from okx_codex_trader.models import Candle
  10. from okx_codex_trader.sampled_report import SegmentResult, mark_to_market, trade_equity
  11. ETH_SYMBOL = "ETH-USDT-SWAP"
  12. BTC_SYMBOL = "BTC-USDT-SWAP"
  13. BAR = "15m"
  14. YEARS = 10.0
  15. LEVERAGE = 3
  16. INITIAL_EQUITY = 10_000.0
  17. DATA_DIR = Path("data/okx-candles")
  18. OUTPUT_DIR = Path("reports/eth-exploration")
  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. ("full", 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. ("30d", pd.DateOffset(days=30)),
  32. )
  33. @dataclass(frozen=True)
  34. class Variant:
  35. band_length: int
  36. bandwidth_lookback: int
  37. bandwidth_quantile: float
  38. side_mode: str
  39. btc_filter: str
  40. eth_vol_cap: float | None
  41. cooldown_bars: int
  42. stop_loss_pct: float
  43. trend_momentum_bars: int
  44. trend_middle_buffer_pct: float
  45. trend_middle_confirm_bars: int
  46. neutral_middle_buffer_pct: float
  47. neutral_middle_confirm_bars: int
  48. protect_trigger_pct: float
  49. protect_lock_pct: float
  50. protect_trail_giveback_pct: float
  51. protect_middle_confirm_bars: int
  52. fast_vol_pct: float
  53. fast_bandwidth_ratio: float
  54. fast_middle_buffer_pct: float
  55. fast_middle_confirm_bars: int
  56. max_hold_bars: int
  57. @property
  58. def name(self) -> str:
  59. vol = "none" if self.eth_vol_cap is None else f"{self.eth_vol_cap:g}"
  60. return (
  61. f"eth-adaptive-state-exit-l{self.band_length}-bw{self.bandwidth_lookback}"
  62. f"-q{self.bandwidth_quantile:g}-{self.side_mode}-{self.btc_filter}-vc{vol}"
  63. f"-sl{self.stop_loss_pct:g}-tr{self.trend_momentum_bars}"
  64. f"-tb{self.trend_middle_buffer_pct:g}-tc{self.trend_middle_confirm_bars}"
  65. f"-nb{self.neutral_middle_buffer_pct:g}-nc{self.neutral_middle_confirm_bars}"
  66. f"-pt{self.protect_trigger_pct:g}-pl{self.protect_lock_pct:g}"
  67. f"-pg{self.protect_trail_giveback_pct:g}-pc{self.protect_middle_confirm_bars}"
  68. f"-fv{self.fast_vol_pct:g}-fr{self.fast_bandwidth_ratio:g}"
  69. f"-fb{self.fast_middle_buffer_pct:g}-fc{self.fast_middle_confirm_bars}"
  70. f"-mh{self.max_hold_bars}"
  71. )
  72. def _format_ts(ts: int) -> str:
  73. return pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
  74. def _load_candles(symbol: str, bar: str) -> list[Candle]:
  75. frame = pd.read_csv(DATA_DIR / symbol / f"{bar}.csv")
  76. return [
  77. Candle(
  78. symbol=symbol,
  79. ts=int(row.ts),
  80. open=float(row.open),
  81. high=float(row.high),
  82. low=float(row.low),
  83. close=float(row.close),
  84. volume=float(row.volume),
  85. )
  86. for row in frame.itertuples(index=False)
  87. ]
  88. def _align_pair(left: list[Candle], right: list[Candle]) -> tuple[list[Candle], list[Candle]]:
  89. right_by_ts = {candle.ts: candle for candle in right}
  90. left_out: list[Candle] = []
  91. right_out: list[Candle] = []
  92. for candle in left:
  93. other = right_by_ts.get(candle.ts)
  94. if other is not None:
  95. left_out.append(candle)
  96. right_out.append(other)
  97. return left_out, right_out
  98. def close_position(
  99. *,
  100. trades: list[dict[str, object]],
  101. exits: list[dict[str, object]],
  102. position: dict[str, object],
  103. candle: Candle,
  104. exit_price: float,
  105. reason: str,
  106. ) -> tuple[float, bool]:
  107. margin_used = float(position["margin_used"])
  108. exit_equity = trade_equity(
  109. side=str(position["side"]),
  110. margin_used=margin_used,
  111. entry_price=float(position["entry_price"]),
  112. exit_price=exit_price,
  113. leverage=LEVERAGE,
  114. )
  115. pnl = exit_equity - margin_used
  116. trades.append(
  117. {
  118. "side": "Long" if position["side"] == "long" else "Short",
  119. "entry_ts": int(position["entry_time"]),
  120. "exit_ts": candle.ts,
  121. "entry_time": _format_ts(int(position["entry_time"])),
  122. "exit_time": _format_ts(candle.ts),
  123. "entry_price": round(float(position["entry_price"]), 4),
  124. "exit_price": round(exit_price, 4),
  125. "pnl": round(pnl, 4),
  126. "return_pct": round(pnl / margin_used * 100.0, 4),
  127. "cost_weight": 1.0,
  128. "exit_reason": reason,
  129. "entry_state": str(position["state"]),
  130. "mfe_pct": round(float(position["mfe_pct"]) * 100.0, 4),
  131. "hold_bars": int(position["hold_bars"]),
  132. }
  133. )
  134. exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
  135. return exit_equity, pnl > 0.0
  136. def favorable_move(side: str, entry_price: float, candle: Candle) -> float:
  137. if side == "long":
  138. return candle.high / entry_price - 1.0
  139. return entry_price / candle.low - 1.0
  140. def close_profit(side: str, entry_price: float, close: float) -> float:
  141. if side == "long":
  142. return close / entry_price - 1.0
  143. return entry_price / close - 1.0
  144. def hard_stop_exit(position: dict[str, object], candle: Candle) -> tuple[float, str] | None:
  145. side = str(position["side"])
  146. stop = float(position["stop_price"])
  147. if side == "long":
  148. if candle.open <= stop:
  149. return candle.open, "stop_gap"
  150. if candle.low <= stop:
  151. return stop, "stop"
  152. else:
  153. if candle.open >= stop:
  154. return candle.open, "stop_gap"
  155. if candle.high >= stop:
  156. return stop, "stop"
  157. return None
  158. def protection_stop_exit(position: dict[str, object], candle: Candle, variant: Variant) -> tuple[float, str] | None:
  159. if str(position["state"]) != "protect":
  160. return None
  161. side = str(position["side"])
  162. entry_price = float(position["entry_price"])
  163. mfe = float(position["mfe_pct"])
  164. if side == "long":
  165. lock_stop = entry_price * (1.0 + variant.protect_lock_pct)
  166. trail_stop = entry_price * (1.0 + mfe - variant.protect_trail_giveback_pct)
  167. stop = max(float(position["stop_price"]), lock_stop, trail_stop)
  168. if candle.open <= stop:
  169. return candle.open, "protect_gap"
  170. if candle.low <= stop:
  171. return stop, "protect_trail"
  172. else:
  173. lock_stop = entry_price * (1.0 - variant.protect_lock_pct)
  174. trail_stop = entry_price * (1.0 - mfe + variant.protect_trail_giveback_pct)
  175. stop = min(float(position["stop_price"]), lock_stop, trail_stop)
  176. if candle.open >= stop:
  177. return candle.open, "protect_gap"
  178. if candle.high >= stop:
  179. return stop, "protect_trail"
  180. return None
  181. def adverse_middle(position: dict[str, object], candle: Candle, middle: float, buffer_pct: float) -> bool:
  182. if position["side"] == "long":
  183. return candle.close < middle * (1.0 - buffer_pct)
  184. return candle.close > middle * (1.0 + buffer_pct)
  185. def trend_continues(position: dict[str, object], candle: Candle, middle: float, momentum: float, required_bars: int) -> bool:
  186. if position["side"] == "long":
  187. above_middle = candle.close > middle
  188. same_direction = momentum > 0.0
  189. else:
  190. above_middle = candle.close < middle
  191. same_direction = momentum < 0.0
  192. if above_middle and same_direction:
  193. position["trend_bars"] = int(position["trend_bars"]) + 1
  194. else:
  195. position["trend_bars"] = 0
  196. return int(position["trend_bars"]) >= required_bars
  197. def update_state(
  198. *,
  199. position: dict[str, object],
  200. candle: Candle,
  201. middle: float,
  202. momentum: float,
  203. realized_vol: float,
  204. bandwidth: float,
  205. threshold: float,
  206. variant: Variant,
  207. ) -> None:
  208. position["hold_bars"] = int(position["hold_bars"]) + 1
  209. position["mfe_pct"] = max(float(position["mfe_pct"]), favorable_move(str(position["side"]), float(position["entry_price"]), candle))
  210. if float(position["mfe_pct"]) >= variant.protect_trigger_pct:
  211. position["state"] = "protect"
  212. return
  213. if realized_vol >= variant.fast_vol_pct or bandwidth >= threshold * variant.fast_bandwidth_ratio:
  214. position["state"] = "fast"
  215. return
  216. if trend_continues(position, candle, middle, momentum, variant.trend_momentum_bars):
  217. position["state"] = "trend"
  218. else:
  219. position["state"] = "neutral"
  220. def state_signal_exit(position: dict[str, object], candle: Candle, middle: float, variant: Variant) -> str | None:
  221. state = str(position["state"])
  222. if state == "trend":
  223. buffer_pct = variant.trend_middle_buffer_pct
  224. confirm_bars = variant.trend_middle_confirm_bars
  225. elif state == "protect":
  226. buffer_pct = 0.0
  227. confirm_bars = variant.protect_middle_confirm_bars
  228. elif state == "fast":
  229. buffer_pct = variant.fast_middle_buffer_pct
  230. confirm_bars = variant.fast_middle_confirm_bars
  231. else:
  232. buffer_pct = variant.neutral_middle_buffer_pct
  233. confirm_bars = variant.neutral_middle_confirm_bars
  234. if adverse_middle(position, candle, middle, buffer_pct):
  235. position["middle_exit_streak"] = int(position["middle_exit_streak"]) + 1
  236. else:
  237. position["middle_exit_streak"] = 0
  238. if int(position["middle_exit_streak"]) >= confirm_bars:
  239. return f"{state}_middle"
  240. if int(position["hold_bars"]) >= variant.max_hold_bars:
  241. return "time_exit"
  242. return None
  243. def run_variant(eth: list[Candle], btc: list[Candle], variant: Variant) -> tuple[SegmentResult, dict[str, int]]:
  244. eth_close = pd.Series([candle.close for candle in eth], dtype=float)
  245. btc_close = pd.Series([candle.close for candle in btc], dtype=float)
  246. middle_series = eth_close.rolling(variant.band_length).mean()
  247. stdev_series = eth_close.rolling(variant.band_length).std(ddof=0)
  248. upper_values = middle_series + 2.0 * stdev_series
  249. lower_values = middle_series - 2.0 * stdev_series
  250. middle = middle_series.tolist()
  251. upper = upper_values.tolist()
  252. lower = lower_values.tolist()
  253. bandwidth = ((upper_values - lower_values) / middle_series).tolist()
  254. threshold = pd.Series(bandwidth, dtype=float).rolling(variant.bandwidth_lookback).quantile(variant.bandwidth_quantile).tolist()
  255. btc_sma = btc_close.rolling(480).mean().tolist()
  256. btc_momentum = (btc_close / btc_close.shift(96) - 1.0).tolist()
  257. eth_realized_vol = eth_close.pct_change().rolling(96).std(ddof=0).tolist()
  258. eth_momentum = (eth_close / eth_close.shift(variant.band_length // 2) - 1.0).tolist()
  259. warmup_bars = max(variant.band_length, variant.bandwidth_lookback, 480, 96)
  260. equity = INITIAL_EQUITY
  261. ending_equity = equity
  262. peak_equity = equity
  263. max_drawdown = 0.0
  264. wins = 0
  265. trades: list[dict[str, object]] = []
  266. entries: list[dict[str, object]] = []
  267. exits: list[dict[str, object]] = []
  268. equity_curve: list[dict[str, float | int]] = []
  269. position: dict[str, object] | None = None
  270. pending_entry_side: str | None = None
  271. pending_exit_reason: str | None = None
  272. cooldown_until = -1
  273. exit_counts: Counter[str] = Counter()
  274. for index in range(warmup_bars, len(eth)):
  275. candle = eth[index]
  276. if pending_exit_reason is not None and position is not None:
  277. equity, won = close_position(
  278. trades=trades,
  279. exits=exits,
  280. position=position,
  281. candle=candle,
  282. exit_price=candle.open,
  283. reason=pending_exit_reason,
  284. )
  285. wins += int(won)
  286. exit_counts[pending_exit_reason] += 1
  287. position = None
  288. pending_exit_reason = None
  289. cooldown_until = index + variant.cooldown_bars
  290. if pending_entry_side is not None and position is None and equity > 0.0:
  291. entry_price = candle.open
  292. position = {
  293. "side": pending_entry_side,
  294. "entry_time": candle.ts,
  295. "entry_price": entry_price,
  296. "margin_used": equity,
  297. "stop_price": entry_price * (1.0 - variant.stop_loss_pct if pending_entry_side == "long" else 1.0 + variant.stop_loss_pct),
  298. "mfe_pct": 0.0,
  299. "state": "neutral",
  300. "trend_bars": 0,
  301. "middle_exit_streak": 0,
  302. "hold_bars": 0,
  303. }
  304. entries.append({"ts": candle.ts, "price": entry_price, "side": pending_entry_side})
  305. pending_entry_side = None
  306. current_equity = equity
  307. if position is not None:
  308. risk_exit = hard_stop_exit(position, candle)
  309. if risk_exit is None:
  310. values = (middle[index], bandwidth[index], threshold[index], eth_realized_vol[index], eth_momentum[index])
  311. if not any(value != value for value in values):
  312. update_state(
  313. position=position,
  314. candle=candle,
  315. middle=float(middle[index]),
  316. momentum=float(eth_momentum[index]),
  317. realized_vol=float(eth_realized_vol[index]),
  318. bandwidth=float(bandwidth[index]),
  319. threshold=float(threshold[index]),
  320. variant=variant,
  321. )
  322. risk_exit = protection_stop_exit(position, candle, variant)
  323. if risk_exit is None:
  324. signal_reason = state_signal_exit(position, candle, float(middle[index]), variant)
  325. if signal_reason is not None:
  326. pending_exit_reason = signal_reason
  327. if risk_exit is not None:
  328. exit_price, reason = risk_exit
  329. equity, won = close_position(
  330. trades=trades,
  331. exits=exits,
  332. position=position,
  333. candle=candle,
  334. exit_price=exit_price,
  335. reason=reason,
  336. )
  337. wins += int(won)
  338. exit_counts[reason] += 1
  339. current_equity = equity
  340. position = None
  341. pending_exit_reason = None
  342. cooldown_until = index + variant.cooldown_bars
  343. if position is not None:
  344. current_equity = mark_to_market(
  345. side=str(position["side"]),
  346. margin_used=float(position["margin_used"]),
  347. entry_price=float(position["entry_price"]),
  348. mark_price=candle.close,
  349. leverage=LEVERAGE,
  350. )
  351. peak_equity = max(peak_equity, current_equity)
  352. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  353. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  354. ending_equity = current_equity
  355. if index == len(eth) - 1 or equity <= 0.0:
  356. continue
  357. values = (middle[index], upper[index], lower[index], bandwidth[index], threshold[index], btc_sma[index], btc_momentum[index], eth_realized_vol[index])
  358. if any(value != value for value in values):
  359. continue
  360. if position is not None or index < cooldown_until:
  361. continue
  362. if variant.eth_vol_cap is not None and float(eth_realized_vol[index]) > variant.eth_vol_cap:
  363. continue
  364. if variant.btc_filter == "btc-up" and not (btc_close.iloc[index] > float(btc_sma[index])):
  365. continue
  366. if variant.btc_filter == "btc-up-momo" and not (
  367. btc_close.iloc[index] > float(btc_sma[index]) and float(btc_momentum[index]) > 0.0
  368. ):
  369. continue
  370. if bandwidth[index] <= threshold[index]:
  371. if candle.close > float(upper[index]):
  372. pending_entry_side = "long"
  373. elif variant.side_mode == "both" and candle.close < float(lower[index]):
  374. pending_entry_side = "short"
  375. trade_count = len(trades)
  376. result = SegmentResult(
  377. trade_count=trade_count,
  378. total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
  379. win_rate=wins / trade_count if trade_count else 0.0,
  380. max_drawdown=max_drawdown,
  381. trades=trades,
  382. open_position=position,
  383. candles=eth[warmup_bars:],
  384. equity_curve=equity_curve,
  385. entries=entries,
  386. exits=exits,
  387. )
  388. return result, dict(exit_counts)
  389. def cost_equity_frame(result: SegmentResult, cost: float) -> pd.DataFrame:
  390. rows = [{"ts": pd.to_datetime(result.equity_curve[0]["ts"], unit="ms", utc=True), "equity": INITIAL_EQUITY}]
  391. equity = INITIAL_EQUITY
  392. for trade in result.trades:
  393. equity *= 1.0 + float(trade["return_pct"]) / 100.0 - cost * float(trade.get("cost_weight", 1.0))
  394. rows.append({"ts": pd.to_datetime(int(trade["exit_ts"]), unit="ms", utc=True), "equity": equity})
  395. return pd.DataFrame(rows)
  396. def max_drawdown(values: list[float]) -> float:
  397. peak = values[0]
  398. dd = 0.0
  399. for value in values:
  400. peak = max(peak, value)
  401. dd = max(dd, (peak - value) / peak if peak else 0.0)
  402. return dd
  403. def equity_metrics(frame: pd.DataFrame, first_ts: int, last_ts: int) -> dict[str, float]:
  404. years = (last_ts - first_ts) / 86_400_000 / 365
  405. total_return = float(frame["equity"].iloc[-1] / frame["equity"].iloc[0] - 1.0)
  406. annualized = (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 and years > 0.0 else 0.0
  407. dd = max_drawdown([float(value) for value in frame["equity"]])
  408. return {
  409. "net_total_return": total_return,
  410. "net_annualized_return": annualized,
  411. "net_max_drawdown": dd,
  412. "net_calmar": annualized / dd if dd else 0.0,
  413. }
  414. def trade_stats(trades: list[dict[str, object]]) -> dict[str, float | int]:
  415. if not trades:
  416. return {
  417. "trades": 0,
  418. "win_rate": 0.0,
  419. "profit_factor": 0.0,
  420. "payoff_ratio": 0.0,
  421. "avg_return_pct": 0.0,
  422. "avg_mfe_pct": 0.0,
  423. "avg_hold_bars": 0.0,
  424. }
  425. returns = [float(trade["return_pct"]) for trade in trades]
  426. wins = [value for value in returns if value > 0.0]
  427. losses = [-value for value in returns if value < 0.0]
  428. return {
  429. "trades": len(trades),
  430. "win_rate": len(wins) / len(trades),
  431. "profit_factor": sum(wins) / sum(losses) if losses else 0.0,
  432. "payoff_ratio": (sum(wins) / len(wins)) / (sum(losses) / len(losses)) if wins and losses else 0.0,
  433. "avg_return_pct": sum(returns) / len(returns),
  434. "avg_mfe_pct": sum(float(trade["mfe_pct"]) for trade in trades) / len(trades),
  435. "avg_hold_bars": sum(int(trade["hold_bars"]) for trade in trades) / len(trades),
  436. }
  437. def exit_reason_counts(trades: list[dict[str, object]]) -> dict[str, int]:
  438. counts = Counter(str(trade["exit_reason"]) for trade in trades)
  439. return {
  440. "stop_exits": counts["stop"] + counts["stop_gap"],
  441. "protect_exits": counts["protect_trail"] + counts["protect_gap"],
  442. "trend_middle_exits": counts["trend_middle"],
  443. "protect_middle_exits": counts["protect_middle"],
  444. "fast_middle_exits": counts["fast_middle"],
  445. "neutral_middle_exits": counts["neutral_middle"],
  446. "time_exits": counts["time_exit"],
  447. }
  448. def horizon_frame(frame: pd.DataFrame, first_ts: int, last_ts: int, offset: pd.DateOffset | None) -> tuple[pd.DataFrame, int, str, str]:
  449. end_time = pd.to_datetime(last_ts, unit="ms", utc=True)
  450. if offset is None:
  451. start_time = pd.to_datetime(first_ts, unit="ms", utc=True)
  452. return frame[["ts", "equity"]].copy(), first_ts, start_time.strftime("%Y-%m-%d %H:%M"), end_time.strftime("%Y-%m-%d %H:%M")
  453. cutoff = end_time - offset
  454. before = frame[frame["ts"] <= cutoff]
  455. if len(before):
  456. start_equity = float(before["equity"].iloc[-1])
  457. after = frame[frame["ts"] > cutoff]
  458. out = pd.concat([pd.DataFrame([{"ts": cutoff, "equity": start_equity}]), after[["ts", "equity"]]], ignore_index=True)
  459. else:
  460. out = frame[["ts", "equity"]].copy()
  461. cutoff = pd.Timestamp(out["ts"].iloc[0])
  462. return out, int(cutoff.timestamp() * 1000), cutoff.strftime("%Y-%m-%d %H:%M"), end_time.strftime("%Y-%m-%d %H:%M")
  463. def horizon_rows(result: SegmentResult, frame: pd.DataFrame, first_ts: int, last_ts: int) -> list[dict[str, object]]:
  464. rows: list[dict[str, object]] = []
  465. for label, offset in HORIZONS:
  466. sliced_frame, start_ts, start_text, end_text = horizon_frame(frame, first_ts, last_ts, offset)
  467. trades = [trade for trade in result.trades if int(trade["exit_ts"]) >= start_ts]
  468. rows.append(
  469. {
  470. "horizon": label,
  471. "horizon_start": start_text,
  472. "horizon_end": end_text,
  473. **equity_metrics(sliced_frame, start_ts, last_ts),
  474. **trade_stats(trades),
  475. **exit_reason_counts(trades),
  476. }
  477. )
  478. return rows
  479. def worst_month(frame: pd.DataFrame) -> tuple[str, float]:
  480. monthly = frame.set_index("ts")["equity"].resample("ME").last().ffill().pct_change().dropna()
  481. if not len(monthly):
  482. return "", 0.0
  483. idx = monthly.idxmin()
  484. return idx.strftime("%Y-%m"), float(monthly.loc[idx])
  485. def build_variants() -> list[Variant]:
  486. bases = (
  487. (48, 960, 0.25, "both", "none", 0.006, 0.010),
  488. (48, 960, 0.25, "both", "none", 0.006, 0.012),
  489. (96, 960, 0.25, "both", "btc-up", 0.006, 0.012),
  490. (96, 960, 0.25, "both", "btc-up-momo", 0.006, 0.012),
  491. (96, 480, 0.15, "both", "none", 0.006, 0.010),
  492. (48, 960, 0.25, "long", "btc-up", 0.006, 0.010),
  493. )
  494. exits = (
  495. (2, 0.0015, 2, 0.0005, 1, 0.006, 0.0000, 0.006, 1, 0.0070, 1.40, 0.0, 1, 192),
  496. (2, 0.0025, 3, 0.0010, 1, 0.008, 0.0005, 0.007, 1, 0.0065, 1.30, 0.0, 1, 288),
  497. (3, 0.0035, 3, 0.0010, 2, 0.010, 0.0010, 0.008, 2, 0.0060, 1.25, 0.0, 1, 384),
  498. (3, 0.0045, 4, 0.0015, 2, 0.012, 0.0010, 0.010, 2, 0.0060, 1.20, 0.0, 1, 480),
  499. (2, 0.0020, 2, 0.0000, 1, 0.006, 0.0005, 0.005, 1, 0.0055, 1.15, 0.0, 1, 192),
  500. (4, 0.0050, 4, 0.0015, 2, 0.015, 0.0015, 0.012, 2, 0.0065, 1.35, 0.0, 1, 576),
  501. )
  502. variants: list[Variant] = []
  503. for band_length, lookback, quantile, side_mode, btc_filter, vol_cap, stop_loss in bases:
  504. for exit_spec in exits:
  505. variants.append(
  506. Variant(
  507. band_length=band_length,
  508. bandwidth_lookback=lookback,
  509. bandwidth_quantile=quantile,
  510. side_mode=side_mode,
  511. btc_filter=btc_filter,
  512. eth_vol_cap=vol_cap,
  513. cooldown_bars=24,
  514. stop_loss_pct=stop_loss,
  515. trend_momentum_bars=exit_spec[0],
  516. trend_middle_buffer_pct=exit_spec[1],
  517. trend_middle_confirm_bars=exit_spec[2],
  518. neutral_middle_buffer_pct=exit_spec[3],
  519. neutral_middle_confirm_bars=exit_spec[4],
  520. protect_trigger_pct=exit_spec[5],
  521. protect_lock_pct=exit_spec[6],
  522. protect_trail_giveback_pct=exit_spec[7],
  523. protect_middle_confirm_bars=exit_spec[8],
  524. fast_vol_pct=exit_spec[9],
  525. fast_bandwidth_ratio=exit_spec[10],
  526. fast_middle_buffer_pct=exit_spec[11],
  527. fast_middle_confirm_bars=exit_spec[12],
  528. max_hold_bars=exit_spec[13],
  529. )
  530. )
  531. return variants
  532. def format_cell(value: object) -> str:
  533. if isinstance(value, float):
  534. return f"{value:.6g}"
  535. return str(value).replace("|", "\\|")
  536. def markdown_table(frame: pd.DataFrame) -> str:
  537. columns = list(frame.columns)
  538. rows = [columns, ["---" for _ in columns]]
  539. for record in frame.to_dict("records"):
  540. rows.append([record[column] for column in columns])
  541. return "\n".join("| " + " | ".join(format_cell(value) for value in row) + " |" for row in rows)
  542. def write_report(*, summary: pd.DataFrame, horizon: pd.DataFrame, first_ts: int, last_ts: int, requested_years: float, command: str) -> str:
  543. primary = summary[summary["cost"] == PRIMARY_COST]
  544. top = primary.head(10)
  545. horizon_top = (
  546. horizon[horizon["cost"] == PRIMARY_COST]
  547. .sort_values(["horizon", "net_calmar", "net_annualized_return"], ascending=[True, False, False])
  548. .groupby("horizon", observed=True)
  549. .head(3)
  550. )
  551. lines = [
  552. "# ETH BB squeeze adaptive state-exit exploration",
  553. "",
  554. f"Run command: `{command}`",
  555. f"Requested years: {requested_years:g}",
  556. f"Actual continuous local history: `{_format_ts(first_ts)}` to `{_format_ts(last_ts)}`.",
  557. "",
  558. "State machine:",
  559. "- `trend`: ETH move remains aligned with the breakout and close stays on the favorable side of the BB middle; middle exit uses a wider buffer and more confirmations.",
  560. "- `protect`: trade reaches floating-profit trigger; a lock/trailing stop and faster middle exit protect profit.",
  561. "- `fast`: realized volatility or bandwidth expansion crosses the variant threshold; middle exit is immediate.",
  562. "- `neutral`: default state, between trend continuation and protection.",
  563. "",
  564. "Top 10 by maker_taker full-period Calmar:",
  565. markdown_table(
  566. top[
  567. [
  568. "name",
  569. "trades",
  570. "win_rate",
  571. "net_total_return",
  572. "net_annualized_return",
  573. "net_max_drawdown",
  574. "net_calmar",
  575. "profit_factor",
  576. "payoff_ratio",
  577. "avg_return_pct",
  578. "avg_mfe_pct",
  579. "protect_exits",
  580. "trend_middle_exits",
  581. "fast_middle_exits",
  582. "neutral_middle_exits",
  583. ]
  584. ]
  585. ),
  586. "",
  587. "Horizon leaders:",
  588. markdown_table(
  589. horizon_top[
  590. [
  591. "horizon",
  592. "name",
  593. "trades",
  594. "win_rate",
  595. "net_total_return",
  596. "net_annualized_return",
  597. "net_max_drawdown",
  598. "net_calmar",
  599. "profit_factor",
  600. "payoff_ratio",
  601. "protect_exits",
  602. "fast_middle_exits",
  603. ]
  604. ]
  605. ),
  606. ]
  607. return "\n".join(lines) + "\n"
  608. def main() -> int:
  609. parser = argparse.ArgumentParser()
  610. parser.add_argument("--bar", default=BAR)
  611. parser.add_argument("--years", type=float, default=YEARS)
  612. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  613. args = parser.parse_args()
  614. eth = _load_candles(ETH_SYMBOL, args.bar)
  615. btc = _load_candles(BTC_SYMBOL, args.bar)
  616. eth, btc = _align_pair(eth, btc)
  617. requested_bars = int(args.years * 365 * 24 * 60 / 15)
  618. eth = eth[-requested_bars:]
  619. btc = btc[-requested_bars:]
  620. summary_rows: list[dict[str, object]] = []
  621. horizon_rows_out: list[dict[str, object]] = []
  622. variants = build_variants()
  623. for index, variant in enumerate(variants, start=1):
  624. result, exit_counts = run_variant(eth, btc, variant)
  625. if not result.equity_curve:
  626. print(f"skip {index}/{len(variants)} {variant.name}", flush=True)
  627. continue
  628. stats = trade_stats(result.trades)
  629. reason_counts = exit_reason_counts(result.trades)
  630. for cost_name, cost in COSTS:
  631. frame = cost_equity_frame(result, cost)
  632. metrics = equity_metrics(frame, eth[0].ts, eth[-1].ts)
  633. month, month_return = worst_month(frame)
  634. row = {
  635. "family": "eth_adaptive_state_exit",
  636. "cost": cost_name,
  637. "symbol": ETH_SYMBOL,
  638. "signal_symbol": BTC_SYMBOL if variant.btc_filter != "none" else "",
  639. "bar": args.bar,
  640. "name": variant.name,
  641. "band_length": variant.band_length,
  642. "bandwidth_lookback": variant.bandwidth_lookback,
  643. "bandwidth_quantile": variant.bandwidth_quantile,
  644. "side_mode": variant.side_mode,
  645. "btc_filter": variant.btc_filter,
  646. "eth_vol_cap": variant.eth_vol_cap,
  647. "cooldown_bars": variant.cooldown_bars,
  648. "stop_loss_pct": variant.stop_loss_pct,
  649. "trend_momentum_bars": variant.trend_momentum_bars,
  650. "trend_middle_buffer_pct": variant.trend_middle_buffer_pct,
  651. "trend_middle_confirm_bars": variant.trend_middle_confirm_bars,
  652. "neutral_middle_buffer_pct": variant.neutral_middle_buffer_pct,
  653. "neutral_middle_confirm_bars": variant.neutral_middle_confirm_bars,
  654. "protect_trigger_pct": variant.protect_trigger_pct,
  655. "protect_lock_pct": variant.protect_lock_pct,
  656. "protect_trail_giveback_pct": variant.protect_trail_giveback_pct,
  657. "protect_middle_confirm_bars": variant.protect_middle_confirm_bars,
  658. "fast_vol_pct": variant.fast_vol_pct,
  659. "fast_bandwidth_ratio": variant.fast_bandwidth_ratio,
  660. "fast_middle_buffer_pct": variant.fast_middle_buffer_pct,
  661. "fast_middle_confirm_bars": variant.fast_middle_confirm_bars,
  662. "max_hold_bars": variant.max_hold_bars,
  663. "first_candle": _format_ts(eth[0].ts),
  664. "last_candle": _format_ts(eth[-1].ts),
  665. "years": (eth[-1].ts - eth[0].ts) / 86_400_000 / 365,
  666. "gross_total_return": result.total_return,
  667. "gross_max_drawdown_mark_to_market": result.max_drawdown,
  668. "worst_month": month,
  669. "worst_month_return": month_return,
  670. **stats,
  671. **reason_counts,
  672. **metrics,
  673. }
  674. summary_rows.append(row)
  675. for horizon_row in horizon_rows(result, frame, eth[0].ts, eth[-1].ts):
  676. horizon_rows_out.append(
  677. {
  678. "family": "eth_adaptive_state_exit",
  679. "cost": cost_name,
  680. "symbol": ETH_SYMBOL,
  681. "signal_symbol": BTC_SYMBOL if variant.btc_filter != "none" else "",
  682. "bar": args.bar,
  683. "name": variant.name,
  684. **horizon_row,
  685. }
  686. )
  687. print(f"done {index}/{len(variants)} {variant.name} exits={exit_counts}", flush=True)
  688. summary = pd.DataFrame(summary_rows).sort_values(
  689. ["cost", "net_calmar", "net_annualized_return", "profit_factor"],
  690. ascending=[True, False, False, False],
  691. )
  692. primary = summary[summary["cost"] == PRIMARY_COST]
  693. summary = pd.concat([primary, summary[summary["cost"] != PRIMARY_COST]], ignore_index=True)
  694. horizon = pd.DataFrame(horizon_rows_out)
  695. horizon["horizon"] = pd.Categorical(horizon["horizon"], categories=[label for label, _ in HORIZONS], ordered=True)
  696. horizon = horizon.sort_values(["cost", "horizon", "net_calmar", "net_annualized_return"], ascending=[True, True, False, False])
  697. args.output_dir.mkdir(parents=True, exist_ok=True)
  698. summary_path = args.output_dir / "eth-adaptive-state-exit-summary.csv"
  699. horizon_path = args.output_dir / "eth-adaptive-state-exit-horizon.csv"
  700. report_path = args.output_dir / "eth-adaptive-state-exit-report.md"
  701. summary.to_csv(summary_path, index=False)
  702. horizon.to_csv(horizon_path, index=False)
  703. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --bar {args.bar} --years {args.years}"
  704. report_path.write_text(
  705. write_report(summary=summary, horizon=horizon, first_ts=eth[0].ts, last_ts=eth[-1].ts, requested_years=args.years, command=command),
  706. encoding="utf-8",
  707. )
  708. print(primary.head(10).to_string(index=False))
  709. return 0
  710. if __name__ == "__main__":
  711. raise SystemExit(main())