search_eth_bb_squeeze_fast_fail_exit.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  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.candles import align_candles_by_ts, load_candles_csv
  9. from okx_codex_trader.models import Candle
  10. from okx_codex_trader.research_metrics import (
  11. DEFAULT_INITIAL_EQUITY,
  12. DEFAULT_PRIMARY_COST,
  13. equity_metrics,
  14. format_utc_ts,
  15. max_drawdown,
  16. trade_stats,
  17. )
  18. from okx_codex_trader.sampled_report import SegmentResult, mark_to_market, trade_equity
  19. from okx_codex_trader.time_rules import entry_allowed, is_us_open_window
  20. ETH_SYMBOL = "ETH-USDT-SWAP"
  21. BTC_SYMBOL = "BTC-USDT-SWAP"
  22. BAR = "15m"
  23. YEARS = 10.0
  24. LEVERAGE = 3
  25. INITIAL_EQUITY = DEFAULT_INITIAL_EQUITY
  26. DATA_DIR = Path("data/okx-candles")
  27. OUTPUT_DIR = Path("reports/eth-exploration")
  28. PRIMARY_COST = DEFAULT_PRIMARY_COST
  29. PRIMARY_COST_RATE = 0.0021
  30. HORIZONS = (
  31. ("full", None),
  32. ("3y", pd.DateOffset(years=3)),
  33. ("1y", pd.DateOffset(years=1)),
  34. ("6m", pd.DateOffset(months=6)),
  35. ("3m", pd.DateOffset(months=3)),
  36. ("4w", pd.DateOffset(weeks=4)),
  37. )
  38. @dataclass(frozen=True)
  39. class FastFailRule:
  40. kind: str
  41. bars: int
  42. threshold_pct: float | None = None
  43. @property
  44. def label(self) -> str:
  45. if self.kind == "none":
  46. return "baseline"
  47. if self.kind == "band_reclaim":
  48. return f"band-reclaim-n{self.bars}"
  49. threshold = 0.0 if self.threshold_pct is None else self.threshold_pct
  50. return f"entry-adverse-n{self.bars}-x{threshold:g}"
  51. @dataclass(frozen=True)
  52. class Variant:
  53. band_length: int = 96
  54. bandwidth_lookback: int = 960
  55. bandwidth_quantile: float = 0.25
  56. stop_loss_pct: float = 0.01
  57. reward_risk: float = 3.0
  58. exit_mode: str = "hybrid_signal_rr"
  59. side_mode: str = "both"
  60. btc_filter: str = "btc-up"
  61. eth_vol_cap: float = 0.006
  62. dd_overlay: float = 0.25
  63. cooldown_bars: int = 24
  64. middle_exit_buffer_pct: float = 0.001
  65. middle_exit_confirm_bars: int = 1
  66. fast_fail: FastFailRule = FastFailRule("none", 0)
  67. @property
  68. def take_profit_pct(self) -> float:
  69. return self.stop_loss_pct * self.reward_risk
  70. @property
  71. def name(self) -> str:
  72. return (
  73. f"bb-squeeze-rr-l{self.band_length}-bw{self.bandwidth_lookback}"
  74. f"-q{self.bandwidth_quantile:g}-sl{self.stop_loss_pct:g}-rr{self.reward_risk:g}"
  75. f"-{self.exit_mode}-{self.side_mode}-{self.btc_filter}-vc{self.eth_vol_cap:g}"
  76. f"-dd{self.dd_overlay:g}-cd{self.cooldown_bars}-mxbuf{self.middle_exit_buffer_pct:g}"
  77. f"-mxc{self.middle_exit_confirm_bars}-{self.fast_fail.label}"
  78. )
  79. def _format_ts(ts: int) -> str:
  80. return format_utc_ts(ts)
  81. def close_position(
  82. *,
  83. trades: list[dict[str, object]],
  84. exits: list[dict[str, object]],
  85. position: dict[str, object],
  86. candle: Candle,
  87. exit_price: float,
  88. reason: str,
  89. ) -> tuple[float, bool]:
  90. margin_used = float(position["margin_used"])
  91. exit_equity = trade_equity(
  92. side=str(position["side"]),
  93. margin_used=margin_used,
  94. entry_price=float(position["entry_price"]),
  95. exit_price=exit_price,
  96. leverage=LEVERAGE,
  97. )
  98. pnl = exit_equity - margin_used
  99. trades.append(
  100. {
  101. "side": "Long" if position["side"] == "long" else "Short",
  102. "entry_time": _format_ts(int(position["entry_time"])),
  103. "exit_time": _format_ts(candle.ts),
  104. "entry_ts": int(position["entry_time"]),
  105. "exit_ts": candle.ts,
  106. "entry_price": round(float(position["entry_price"]), 4),
  107. "exit_price": round(exit_price, 4),
  108. "pnl": round(pnl, 4),
  109. "return_pct": round(pnl / margin_used * 100.0, 4),
  110. "cost_weight": 1.0,
  111. "exit_reason": reason,
  112. "bars_held": int(position["bars_held"]),
  113. }
  114. )
  115. exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
  116. return exit_equity, pnl > 0.0
  117. def risk_exit_price(position: dict[str, object], candle: Candle) -> tuple[float, str] | None:
  118. side = str(position["side"])
  119. stop = float(position["stop_price"])
  120. take = float(position["take_price"])
  121. if side == "long":
  122. if candle.open <= stop:
  123. return candle.open, "stop_gap"
  124. if candle.open >= take:
  125. return candle.open, "take_gap"
  126. stop_hit = candle.low <= stop
  127. take_hit = candle.high >= take
  128. else:
  129. if candle.open >= stop:
  130. return candle.open, "stop_gap"
  131. if candle.open <= take:
  132. return candle.open, "take_gap"
  133. stop_hit = candle.high >= stop
  134. take_hit = candle.low <= take
  135. if stop_hit:
  136. return stop, "stop"
  137. if take_hit:
  138. return take, "take_profit"
  139. return None
  140. def fast_fail_exit_price(
  141. *,
  142. position: dict[str, object],
  143. candle: Candle,
  144. upper: float,
  145. lower: float,
  146. rule: FastFailRule,
  147. ) -> tuple[float, str] | None:
  148. if rule.kind == "none" or int(position["bars_held"]) > rule.bars:
  149. return None
  150. side = str(position["side"])
  151. entry_price = float(position["entry_price"])
  152. if rule.kind == "band_reclaim":
  153. if side == "long" and candle.close < upper:
  154. return candle.close, "fast_fail_band"
  155. if side == "short" and candle.close > lower:
  156. return candle.close, "fast_fail_band"
  157. return None
  158. threshold = 0.0 if rule.threshold_pct is None else rule.threshold_pct
  159. if side == "long" and candle.close < entry_price * (1.0 - threshold):
  160. return candle.close, "fast_fail_price"
  161. if side == "short" and candle.close > entry_price * (1.0 + threshold):
  162. return candle.close, "fast_fail_price"
  163. return None
  164. def run_variant(eth: list[Candle], btc: list[Candle], variant: Variant) -> tuple[SegmentResult, dict[str, int]]:
  165. eth_close = pd.Series([candle.close for candle in eth], dtype=float)
  166. btc_close = pd.Series([candle.close for candle in btc], dtype=float)
  167. middle_series = eth_close.rolling(variant.band_length).mean()
  168. stdev_series = eth_close.rolling(variant.band_length).std(ddof=0)
  169. upper_values = middle_series + 2.0 * stdev_series
  170. lower_values = middle_series - 2.0 * stdev_series
  171. middle = middle_series.tolist()
  172. upper = upper_values.tolist()
  173. lower = lower_values.tolist()
  174. bandwidth = ((upper_values - lower_values) / middle_series).tolist()
  175. threshold = pd.Series(bandwidth, dtype=float).rolling(variant.bandwidth_lookback).quantile(variant.bandwidth_quantile).tolist()
  176. btc_sma = btc_close.rolling(480).mean().tolist()
  177. eth_realized_vol = eth_close.pct_change().rolling(96).std(ddof=0).tolist()
  178. warmup_bars = max(variant.band_length, variant.bandwidth_lookback, 480, 96)
  179. equity = INITIAL_EQUITY
  180. ending_equity = equity
  181. peak_equity = equity
  182. gross_max_drawdown = 0.0
  183. wins = 0
  184. trades: list[dict[str, object]] = []
  185. entries: list[dict[str, object]] = []
  186. exits: list[dict[str, object]] = []
  187. equity_curve: list[dict[str, float | int]] = []
  188. position: dict[str, object] | None = None
  189. pending_entry_side: str | None = None
  190. pending_exit = False
  191. middle_exit_streak = 0
  192. cooldown_until = -1
  193. exit_counts = {
  194. "stop_exits": 0,
  195. "take_profit_exits": 0,
  196. "signal_exits": 0,
  197. "fast_fail_exits": 0,
  198. "fast_fail_band_exits": 0,
  199. "fast_fail_price_exits": 0,
  200. }
  201. for index in range(warmup_bars, len(eth)):
  202. candle = eth[index]
  203. if pending_exit and position is not None:
  204. equity, won = close_position(
  205. trades=trades,
  206. exits=exits,
  207. position=position,
  208. candle=candle,
  209. exit_price=candle.open,
  210. reason="signal_middle",
  211. )
  212. wins += int(won)
  213. exit_counts["signal_exits"] += 1
  214. position = None
  215. pending_exit = False
  216. middle_exit_streak = 0
  217. cooldown_until = index + variant.cooldown_bars
  218. if pending_entry_side is not None and position is None and equity > 0.0:
  219. entry_price = candle.open
  220. position = {
  221. "side": pending_entry_side,
  222. "entry_time": candle.ts,
  223. "entry_price": entry_price,
  224. "margin_used": equity,
  225. "stop_price": entry_price * (1.0 - variant.stop_loss_pct if pending_entry_side == "long" else 1.0 + variant.stop_loss_pct),
  226. "take_price": entry_price * (1.0 + variant.take_profit_pct if pending_entry_side == "long" else 1.0 - variant.take_profit_pct),
  227. "bars_held": 1,
  228. }
  229. entries.append({"ts": candle.ts, "price": entry_price, "side": pending_entry_side})
  230. pending_entry_side = None
  231. current_equity = equity
  232. if position is not None:
  233. risk_exit = risk_exit_price(position, candle)
  234. if risk_exit is not None:
  235. exit_price, reason = risk_exit
  236. equity, won = close_position(
  237. trades=trades,
  238. exits=exits,
  239. position=position,
  240. candle=candle,
  241. exit_price=exit_price,
  242. reason=reason,
  243. )
  244. wins += int(won)
  245. if reason.startswith("stop"):
  246. exit_counts["stop_exits"] += 1
  247. else:
  248. exit_counts["take_profit_exits"] += 1
  249. current_equity = equity
  250. position = None
  251. middle_exit_streak = 0
  252. cooldown_until = index + variant.cooldown_bars
  253. if position is not None:
  254. values = (upper[index], lower[index])
  255. if all(value == value for value in values):
  256. fast_fail_exit = fast_fail_exit_price(
  257. position=position,
  258. candle=candle,
  259. upper=float(upper[index]),
  260. lower=float(lower[index]),
  261. rule=variant.fast_fail,
  262. )
  263. if fast_fail_exit is not None:
  264. exit_price, reason = fast_fail_exit
  265. equity, won = close_position(
  266. trades=trades,
  267. exits=exits,
  268. position=position,
  269. candle=candle,
  270. exit_price=exit_price,
  271. reason=reason,
  272. )
  273. wins += int(won)
  274. exit_counts["fast_fail_exits"] += 1
  275. if reason == "fast_fail_band":
  276. exit_counts["fast_fail_band_exits"] += 1
  277. else:
  278. exit_counts["fast_fail_price_exits"] += 1
  279. current_equity = equity
  280. position = None
  281. middle_exit_streak = 0
  282. cooldown_until = index + variant.cooldown_bars
  283. if position is not None:
  284. current_equity = mark_to_market(
  285. side=str(position["side"]),
  286. margin_used=float(position["margin_used"]),
  287. entry_price=float(position["entry_price"]),
  288. mark_price=candle.close,
  289. leverage=LEVERAGE,
  290. )
  291. peak_equity = max(peak_equity, current_equity)
  292. gross_max_drawdown = max(gross_max_drawdown, (peak_equity - current_equity) / peak_equity)
  293. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  294. ending_equity = current_equity
  295. if index == len(eth) - 1 or equity <= 0.0:
  296. continue
  297. values = (middle[index], upper[index], lower[index], bandwidth[index], threshold[index], btc_sma[index], eth_realized_vol[index])
  298. if any(value != value for value in values):
  299. if position is not None:
  300. position["bars_held"] = int(position["bars_held"]) + 1
  301. continue
  302. if position is not None:
  303. middle_exit = (
  304. position["side"] == "long" and candle.close < float(middle[index]) * (1.0 - variant.middle_exit_buffer_pct)
  305. ) or (
  306. position["side"] == "short" and candle.close > float(middle[index]) * (1.0 + variant.middle_exit_buffer_pct)
  307. )
  308. if middle_exit and is_us_open_window(candle.ts):
  309. middle_exit = False
  310. middle_exit_streak = middle_exit_streak + 1 if middle_exit else 0
  311. if middle_exit_streak >= variant.middle_exit_confirm_bars:
  312. pending_exit = True
  313. position["bars_held"] = int(position["bars_held"]) + 1
  314. continue
  315. if index < cooldown_until:
  316. continue
  317. if float(eth_realized_vol[index]) > variant.eth_vol_cap:
  318. continue
  319. if (peak_equity - current_equity) / peak_equity > variant.dd_overlay:
  320. continue
  321. if not entry_allowed(candle.ts, "weekday"):
  322. continue
  323. if not (btc_close.iloc[index] > float(btc_sma[index])):
  324. continue
  325. if bandwidth[index] <= threshold[index]:
  326. if candle.close > float(upper[index]):
  327. pending_entry_side = "long"
  328. elif candle.close < float(lower[index]):
  329. pending_entry_side = "short"
  330. trade_count = len(trades)
  331. result = SegmentResult(
  332. trade_count=trade_count,
  333. total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
  334. win_rate=wins / trade_count if trade_count else 0.0,
  335. max_drawdown=gross_max_drawdown,
  336. trades=trades,
  337. open_position=position,
  338. candles=eth[warmup_bars:],
  339. equity_curve=equity_curve,
  340. entries=entries,
  341. exits=exits,
  342. )
  343. return result, exit_counts
  344. def build_variants() -> list[Variant]:
  345. rules = [FastFailRule("none", 0)]
  346. rules.extend(FastFailRule("band_reclaim", bars) for bars in (1, 2, 3, 4, 6))
  347. rules.extend(FastFailRule("entry_adverse", bars, threshold) for bars in (1, 2, 3, 4, 6) for threshold in (0.0, 0.001, 0.002, 0.003))
  348. return [Variant(fast_fail=rule) for rule in rules]
  349. def net_equity_frame(result: SegmentResult, cost: float) -> pd.DataFrame:
  350. rows = [{"ts": pd.to_datetime(result.equity_curve[0]["ts"], unit="ms", utc=True), "equity": INITIAL_EQUITY}]
  351. equity = INITIAL_EQUITY
  352. for trade in result.trades:
  353. equity *= 1.0 + float(trade["return_pct"]) / 100.0 - cost * float(trade.get("cost_weight", 1.0))
  354. rows.append({"ts": pd.to_datetime(int(trade["exit_ts"]), unit="ms", utc=True), "equity": equity})
  355. return pd.DataFrame(rows)
  356. def rows_for_horizons(*, result: SegmentResult, frame: pd.DataFrame, variant: Variant, first_ts: int, last_ts: int) -> list[dict[str, object]]:
  357. rows: list[dict[str, object]] = []
  358. end_time = pd.to_datetime(last_ts, unit="ms", utc=True)
  359. for label, offset in HORIZONS:
  360. start_time = pd.to_datetime(first_ts, unit="ms", utc=True) if offset is None else end_time - offset
  361. before = frame[frame["ts"] <= start_time]
  362. start_equity = float(before["equity"].iloc[-1]) if len(before) else float(frame["equity"].iloc[0])
  363. after = frame[frame["ts"] > start_time]
  364. horizon_frame = pd.concat([pd.DataFrame([{"ts": start_time, "equity": start_equity}]), after[["ts", "equity"]]], ignore_index=True)
  365. trades = [trade for trade in result.trades if int(trade["exit_ts"]) > int(start_time.timestamp() * 1000)]
  366. stats = trade_stats(trades)
  367. returns = [float(trade["return_pct"]) for trade in trades]
  368. rows.append(
  369. {
  370. "family": "bb_squeeze_fast_fail_exit",
  371. "cost": PRIMARY_COST,
  372. "symbol": ETH_SYMBOL,
  373. "signal_symbol": BTC_SYMBOL,
  374. "bar": BAR,
  375. "name": variant.name,
  376. "fast_fail_rule": variant.fast_fail.label,
  377. "fast_fail_kind": variant.fast_fail.kind,
  378. "fast_fail_bars": variant.fast_fail.bars,
  379. "fast_fail_threshold_pct": variant.fast_fail.threshold_pct,
  380. "horizon": label,
  381. "horizon_start": start_time.strftime("%Y-%m-%d %H:%M"),
  382. "horizon_end": end_time.strftime("%Y-%m-%d %H:%M"),
  383. "trades": len(trades),
  384. "win_rate": sum(1 for value in returns if value > 0.0) / len(returns) if returns else 0.0,
  385. **stats,
  386. **equity_metrics(horizon_frame, int(start_time.timestamp() * 1000), last_ts),
  387. }
  388. )
  389. return rows
  390. def exit_reason_rows(*, result: SegmentResult, variant: Variant) -> list[dict[str, object]]:
  391. rows: list[dict[str, object]] = []
  392. for reason, trades in pd.DataFrame(result.trades).groupby("exit_reason"):
  393. records = trades.to_dict("records")
  394. stats = trade_stats(records)
  395. returns = [float(trade["return_pct"]) for trade in records]
  396. rows.append(
  397. {
  398. "family": "bb_squeeze_fast_fail_exit",
  399. "name": variant.name,
  400. "fast_fail_rule": variant.fast_fail.label,
  401. "exit_reason": reason,
  402. "trades": len(records),
  403. "win_rate": sum(1 for value in returns if value > 0.0) / len(returns) if returns else 0.0,
  404. "gross_return_sum_pct": sum(returns),
  405. **stats,
  406. }
  407. )
  408. return rows
  409. def format_cell(value: object) -> str:
  410. if isinstance(value, float):
  411. return f"{value:.6g}"
  412. return str(value).replace("|", "\\|")
  413. def markdown_table(frame: pd.DataFrame) -> str:
  414. columns = list(frame.columns)
  415. rows = [columns, ["---" for _ in columns]]
  416. for record in frame.to_dict("records"):
  417. rows.append([record[column] for column in columns])
  418. return "\n".join("| " + " | ".join(format_cell(value) for value in row) + " |" for row in rows)
  419. def write_report(*, horizon: pd.DataFrame, exits: pd.DataFrame, first_ts: int, last_ts: int, command: str) -> str:
  420. full = horizon[horizon["horizon"] == "full"].sort_values(["net_calmar", "net_annualized_return"], ascending=[False, False])
  421. baseline = horizon[(horizon["horizon"] == "full") & (horizon["fast_fail_rule"] == "baseline")]
  422. top = full.head(5)
  423. compare_cols = [
  424. "fast_fail_rule",
  425. "trades",
  426. "net_total_return",
  427. "net_annualized_return",
  428. "net_max_drawdown",
  429. "net_calmar",
  430. "win_rate",
  431. "avg_return_pct",
  432. "payoff_ratio",
  433. "profit_factor",
  434. ]
  435. top_rules = set(top["fast_fail_rule"].tolist()) | {"baseline"}
  436. top_horizons = horizon[horizon["fast_fail_rule"].isin(top_rules)].sort_values(["fast_fail_rule", "horizon"])
  437. fast_fail_exits = exits[exits["exit_reason"].astype(str).str.startswith("fast_fail")].sort_values(
  438. ["name", "gross_return_sum_pct"], ascending=[True, True]
  439. )
  440. lines = [
  441. "# ETH BB squeeze fast-fail exit exploration",
  442. "",
  443. f"Run command: `{command}`",
  444. f"Actual continuous local history: `{_format_ts(first_ts)}` to `{_format_ts(last_ts)}`.",
  445. "",
  446. "Baseline parameters: ETH 15m, band_length=96, bandwidth_lookback=960, bandwidth_quantile=0.25, stop_loss=1%, take_profit=3%, middle_exit_buffer=0.1%, middle_confirm=1, eth_vol_cap=0.006, cooldown=24, btc_up filter, weekday entry, us_open_exit skip, both sides.",
  447. "",
  448. "Top 5 full-history maker_taker results:",
  449. markdown_table(top[compare_cols]),
  450. "",
  451. "Baseline full-history row:",
  452. markdown_table(baseline[compare_cols]),
  453. "",
  454. "Full/3y/1y/6m/3m/4w metrics for Top 5 plus baseline:",
  455. markdown_table(
  456. top_horizons[
  457. [
  458. "fast_fail_rule",
  459. "horizon",
  460. "trades",
  461. "net_total_return",
  462. "net_annualized_return",
  463. "net_max_drawdown",
  464. "net_calmar",
  465. "win_rate",
  466. "avg_return_pct",
  467. "payoff_ratio",
  468. "profit_factor",
  469. ]
  470. ]
  471. ),
  472. "",
  473. "Fast-fail exit reason contribution:",
  474. markdown_table(
  475. fast_fail_exits[
  476. [
  477. "fast_fail_rule",
  478. "exit_reason",
  479. "trades",
  480. "win_rate",
  481. "avg_return_pct",
  482. "gross_return_sum_pct",
  483. "profit_factor",
  484. ]
  485. ].head(20)
  486. ),
  487. ]
  488. return "\n".join(lines) + "\n"
  489. def main() -> int:
  490. parser = argparse.ArgumentParser()
  491. parser.add_argument("--bar", default=BAR)
  492. parser.add_argument("--years", type=float, default=YEARS)
  493. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  494. args = parser.parse_args()
  495. eth = load_candles_csv(DATA_DIR, ETH_SYMBOL, args.bar)
  496. btc = load_candles_csv(DATA_DIR, BTC_SYMBOL, args.bar)
  497. eth, btc = align_candles_by_ts(eth, btc)
  498. requested_bars = int(args.years * 365 * 24 * 60 / 15)
  499. eth = eth[-requested_bars:]
  500. btc = btc[-requested_bars:]
  501. summary_rows: list[dict[str, object]] = []
  502. horizon_rows_out: list[dict[str, object]] = []
  503. exit_rows: list[dict[str, object]] = []
  504. variants = build_variants()
  505. for index, variant in enumerate(variants, start=1):
  506. result, exit_counts = run_variant(eth, btc, variant)
  507. frame = net_equity_frame(result, PRIMARY_COST_RATE)
  508. metrics = equity_metrics(frame, eth[0].ts, eth[-1].ts)
  509. stats = trade_stats(result.trades)
  510. summary_rows.append(
  511. {
  512. "family": "bb_squeeze_fast_fail_exit",
  513. "cost": PRIMARY_COST,
  514. "symbol": ETH_SYMBOL,
  515. "signal_symbol": BTC_SYMBOL,
  516. "bar": args.bar,
  517. "name": variant.name,
  518. "fast_fail_rule": variant.fast_fail.label,
  519. "fast_fail_kind": variant.fast_fail.kind,
  520. "fast_fail_bars": variant.fast_fail.bars,
  521. "fast_fail_threshold_pct": variant.fast_fail.threshold_pct,
  522. "band_length": variant.band_length,
  523. "bandwidth_lookback": variant.bandwidth_lookback,
  524. "bandwidth_quantile": variant.bandwidth_quantile,
  525. "stop_loss_pct": variant.stop_loss_pct,
  526. "take_profit_pct": variant.take_profit_pct,
  527. "reward_risk": variant.reward_risk,
  528. "exit_mode": variant.exit_mode,
  529. "side_mode": variant.side_mode,
  530. "btc_filter": variant.btc_filter,
  531. "eth_vol_cap": variant.eth_vol_cap,
  532. "dd_overlay": variant.dd_overlay,
  533. "cooldown_bars": variant.cooldown_bars,
  534. "middle_exit_buffer_pct": variant.middle_exit_buffer_pct,
  535. "middle_exit_confirm_bars": variant.middle_exit_confirm_bars,
  536. "first_candle": _format_ts(eth[0].ts),
  537. "last_candle": _format_ts(eth[-1].ts),
  538. "trades": result.trade_count,
  539. "win_rate": result.win_rate,
  540. "gross_total_return": result.total_return,
  541. "gross_max_drawdown_mark_to_market": result.max_drawdown,
  542. **stats,
  543. **exit_counts,
  544. **metrics,
  545. }
  546. )
  547. horizon_rows_out.extend(rows_for_horizons(result=result, frame=frame, variant=variant, first_ts=eth[0].ts, last_ts=eth[-1].ts))
  548. exit_rows.extend(exit_reason_rows(result=result, variant=variant))
  549. print(f"done {index}/{len(variants)} {variant.name}")
  550. summary = pd.DataFrame(summary_rows).sort_values(["net_calmar", "net_annualized_return"], ascending=[False, False])
  551. horizon = pd.DataFrame(horizon_rows_out)
  552. horizon["horizon"] = pd.Categorical(horizon["horizon"], categories=[label for label, _ in HORIZONS], ordered=True)
  553. horizon = horizon.sort_values(["horizon", "net_calmar", "net_annualized_return"], ascending=[True, False, False])
  554. exits = pd.DataFrame(exit_rows).sort_values(["fast_fail_rule", "exit_reason"])
  555. args.output_dir.mkdir(parents=True, exist_ok=True)
  556. summary_path = args.output_dir / "eth-bb-squeeze-fast-fail-exit-summary.csv"
  557. horizon_path = args.output_dir / "eth-bb-squeeze-fast-fail-exit-horizon.csv"
  558. exits_path = args.output_dir / "eth-bb-squeeze-fast-fail-exit-reasons.csv"
  559. report_path = args.output_dir / "eth-bb-squeeze-fast-fail-exit-report.md"
  560. summary.to_csv(summary_path, index=False)
  561. horizon.to_csv(horizon_path, index=False)
  562. exits.to_csv(exits_path, index=False)
  563. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --bar {args.bar} --years {args.years}"
  564. report_path.write_text(write_report(horizon=horizon, exits=exits, first_ts=eth[0].ts, last_ts=eth[-1].ts, command=command), encoding="utf-8")
  565. print(summary.head(10).to_string(index=False))
  566. return 0
  567. if __name__ == "__main__":
  568. raise SystemExit(main())