search_eth_bb_squeeze_profit_protection.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  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_COSTS,
  12. DEFAULT_HORIZONS,
  13. DEFAULT_INITIAL_EQUITY,
  14. DEFAULT_PRIMARY_COST,
  15. cost_equity_frame,
  16. equity_metrics,
  17. format_utc_ts,
  18. horizon_rows,
  19. worst_month,
  20. )
  21. from okx_codex_trader.sampled_report import SegmentResult, mark_to_market, trade_equity
  22. ETH_SYMBOL = "ETH-USDT-SWAP"
  23. BTC_SYMBOL = "BTC-USDT-SWAP"
  24. BAR = "15m"
  25. YEARS = 10.0
  26. LEVERAGE = 3
  27. INITIAL_EQUITY = DEFAULT_INITIAL_EQUITY
  28. DATA_DIR = Path("data/okx-candles")
  29. OUTPUT_DIR = Path("reports/eth-exploration")
  30. PRIMARY_COST = DEFAULT_PRIMARY_COST
  31. COSTS = DEFAULT_COSTS
  32. HORIZONS = DEFAULT_HORIZONS
  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. middle_exit_buffer_pct: float
  44. middle_exit_confirm_bars: int
  45. breakeven_trigger_pct: float | None
  46. breakeven_lock_pct: float
  47. trail_trigger_pct: float | None
  48. trail_giveback_pct: float | None
  49. max_giveback_trigger_pct: float | None
  50. max_giveback_pct: float | None
  51. @property
  52. def name(self) -> str:
  53. vol = "none" if self.eth_vol_cap is None else f"{self.eth_vol_cap:g}"
  54. be = "none" if self.breakeven_trigger_pct is None else f"{self.breakeven_trigger_pct:g}-{self.breakeven_lock_pct:g}"
  55. trail = "none" if self.trail_trigger_pct is None else f"{self.trail_trigger_pct:g}-{self.trail_giveback_pct:g}"
  56. gb = "none" if self.max_giveback_trigger_pct is None else f"{self.max_giveback_trigger_pct:g}-{self.max_giveback_pct:g}"
  57. return (
  58. f"bb-squeeze-protect-l{self.band_length}-bw{self.bandwidth_lookback}"
  59. f"-q{self.bandwidth_quantile:g}-sl{self.stop_loss_pct:g}"
  60. f"-{self.side_mode}-{self.btc_filter}-vc{vol}-cd{self.cooldown_bars}"
  61. f"-mxbuf{self.middle_exit_buffer_pct:g}-mxc{self.middle_exit_confirm_bars}"
  62. f"-be{be}-tr{trail}-gb{gb}"
  63. )
  64. def _format_ts(ts: int) -> str:
  65. return format_utc_ts(ts)
  66. def close_position(
  67. *,
  68. trades: list[dict[str, object]],
  69. exits: list[dict[str, object]],
  70. position: dict[str, object],
  71. candle: Candle,
  72. exit_price: float,
  73. reason: str,
  74. ) -> tuple[float, bool]:
  75. margin_used = float(position["margin_used"])
  76. exit_equity = trade_equity(
  77. side=str(position["side"]),
  78. margin_used=margin_used,
  79. entry_price=float(position["entry_price"]),
  80. exit_price=exit_price,
  81. leverage=LEVERAGE,
  82. )
  83. pnl = exit_equity - margin_used
  84. trades.append(
  85. {
  86. "side": "Long" if position["side"] == "long" else "Short",
  87. "entry_time": _format_ts(int(position["entry_time"])),
  88. "exit_time": _format_ts(candle.ts),
  89. "entry_price": round(float(position["entry_price"]), 4),
  90. "exit_price": round(exit_price, 4),
  91. "pnl": round(pnl, 4),
  92. "return_pct": round(pnl / margin_used * 100.0, 4),
  93. "cost_weight": 1.0,
  94. "exit_reason": reason,
  95. "mfe_pct": round(float(position["mfe_pct"]) * 100.0, 4),
  96. }
  97. )
  98. exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
  99. return exit_equity, pnl > 0.0
  100. def favorable_move(side: str, entry_price: float, candle: Candle) -> float:
  101. if side == "long":
  102. return candle.high / entry_price - 1.0
  103. return entry_price / candle.low - 1.0
  104. def protection_exit(position: dict[str, object], candle: Candle, variant: Variant) -> tuple[float, str] | None:
  105. side = str(position["side"])
  106. entry_price = float(position["entry_price"])
  107. mfe = float(position["mfe_pct"])
  108. stop_price = float(position["stop_price"])
  109. if variant.breakeven_trigger_pct is not None and mfe >= variant.breakeven_trigger_pct:
  110. be_stop = entry_price * (1.0 + variant.breakeven_lock_pct if side == "long" else 1.0 - variant.breakeven_lock_pct)
  111. stop_price = max(stop_price, be_stop) if side == "long" else min(stop_price, be_stop)
  112. if variant.trail_trigger_pct is not None and variant.trail_giveback_pct is not None and mfe >= variant.trail_trigger_pct:
  113. if side == "long":
  114. trail_stop = entry_price * (1.0 + mfe - variant.trail_giveback_pct)
  115. stop_price = max(stop_price, trail_stop)
  116. else:
  117. trail_stop = entry_price * (1.0 - mfe + variant.trail_giveback_pct)
  118. stop_price = min(stop_price, trail_stop)
  119. if side == "long":
  120. if candle.open <= stop_price:
  121. return candle.open, "protect_gap" if stop_price != float(position["stop_price"]) else "stop_gap"
  122. if candle.low <= stop_price:
  123. return stop_price, "profit_protect" if stop_price != float(position["stop_price"]) else "stop"
  124. else:
  125. if candle.open >= stop_price:
  126. return candle.open, "protect_gap" if stop_price != float(position["stop_price"]) else "stop_gap"
  127. if candle.high >= stop_price:
  128. return stop_price, "profit_protect" if stop_price != float(position["stop_price"]) else "stop"
  129. if variant.max_giveback_trigger_pct is None or variant.max_giveback_pct is None or mfe < variant.max_giveback_trigger_pct:
  130. return None
  131. if side == "long":
  132. close_profit = candle.close / entry_price - 1.0
  133. else:
  134. close_profit = entry_price / candle.close - 1.0
  135. if close_profit <= mfe - variant.max_giveback_pct:
  136. return candle.close, "giveback_close"
  137. return None
  138. def run_variant(eth: list[Candle], btc: list[Candle], variant: Variant) -> tuple[SegmentResult, dict[str, int]]:
  139. eth_close = pd.Series([candle.close for candle in eth], dtype=float)
  140. btc_close = pd.Series([candle.close for candle in btc], dtype=float)
  141. middle_series = eth_close.rolling(variant.band_length).mean()
  142. stdev_series = eth_close.rolling(variant.band_length).std(ddof=0)
  143. upper_values = middle_series + 2.0 * stdev_series
  144. lower_values = middle_series - 2.0 * stdev_series
  145. middle = middle_series.tolist()
  146. upper = upper_values.tolist()
  147. lower = lower_values.tolist()
  148. bandwidth = ((upper_values - lower_values) / middle_series).tolist()
  149. threshold = pd.Series(bandwidth, dtype=float).rolling(variant.bandwidth_lookback).quantile(variant.bandwidth_quantile).tolist()
  150. btc_sma = btc_close.rolling(480).mean().tolist()
  151. btc_momentum = (btc_close / btc_close.shift(96) - 1.0).tolist()
  152. eth_realized_vol = eth_close.pct_change().rolling(96).std(ddof=0).tolist()
  153. warmup_bars = max(variant.band_length, variant.bandwidth_lookback, 480, 96)
  154. equity = INITIAL_EQUITY
  155. ending_equity = equity
  156. peak_equity = equity
  157. max_drawdown = 0.0
  158. wins = 0
  159. trades: list[dict[str, object]] = []
  160. entries: list[dict[str, object]] = []
  161. exits: list[dict[str, object]] = []
  162. equity_curve: list[dict[str, float | int]] = []
  163. position: dict[str, object] | None = None
  164. pending_entry_side: str | None = None
  165. pending_exit = False
  166. middle_exit_streak = 0
  167. cooldown_until = -1
  168. exit_counts = {"stop_exits": 0, "protect_exits": 0, "giveback_exits": 0, "signal_exits": 0}
  169. for index in range(warmup_bars, len(eth)):
  170. candle = eth[index]
  171. if pending_exit and position is not None:
  172. equity, won = close_position(
  173. trades=trades,
  174. exits=exits,
  175. position=position,
  176. candle=candle,
  177. exit_price=candle.open,
  178. reason="signal_middle",
  179. )
  180. wins += int(won)
  181. exit_counts["signal_exits"] += 1
  182. position = None
  183. pending_exit = False
  184. middle_exit_streak = 0
  185. cooldown_until = index + variant.cooldown_bars
  186. if pending_entry_side is not None and position is None and equity > 0.0:
  187. entry_price = candle.open
  188. position = {
  189. "side": pending_entry_side,
  190. "entry_time": candle.ts,
  191. "entry_price": entry_price,
  192. "margin_used": equity,
  193. "stop_price": entry_price * (1.0 - variant.stop_loss_pct if pending_entry_side == "long" else 1.0 + variant.stop_loss_pct),
  194. "mfe_pct": 0.0,
  195. }
  196. entries.append({"ts": candle.ts, "price": entry_price, "side": pending_entry_side})
  197. pending_entry_side = None
  198. current_equity = equity
  199. if position is not None:
  200. risk_exit = protection_exit(position, candle, variant)
  201. if risk_exit is not None:
  202. exit_price, reason = risk_exit
  203. equity, won = close_position(
  204. trades=trades,
  205. exits=exits,
  206. position=position,
  207. candle=candle,
  208. exit_price=exit_price,
  209. reason=reason,
  210. )
  211. wins += int(won)
  212. if reason.startswith("stop"):
  213. exit_counts["stop_exits"] += 1
  214. elif reason == "giveback_close":
  215. exit_counts["giveback_exits"] += 1
  216. else:
  217. exit_counts["protect_exits"] += 1
  218. current_equity = equity
  219. position = None
  220. middle_exit_streak = 0
  221. cooldown_until = index + variant.cooldown_bars
  222. if position is not None:
  223. position["mfe_pct"] = max(float(position["mfe_pct"]), favorable_move(str(position["side"]), float(position["entry_price"]), candle))
  224. if position is not None:
  225. current_equity = mark_to_market(
  226. side=str(position["side"]),
  227. margin_used=float(position["margin_used"]),
  228. entry_price=float(position["entry_price"]),
  229. mark_price=candle.close,
  230. leverage=LEVERAGE,
  231. )
  232. peak_equity = max(peak_equity, current_equity)
  233. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  234. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  235. ending_equity = current_equity
  236. if index == len(eth) - 1 or equity <= 0.0:
  237. continue
  238. values = (middle[index], upper[index], lower[index], bandwidth[index], threshold[index], btc_sma[index], btc_momentum[index], eth_realized_vol[index])
  239. if any(value != value for value in values):
  240. continue
  241. if position is not None:
  242. middle_exit = (
  243. position["side"] == "long" and candle.close < float(middle[index]) * (1.0 - variant.middle_exit_buffer_pct)
  244. ) or (
  245. position["side"] == "short" and candle.close > float(middle[index]) * (1.0 + variant.middle_exit_buffer_pct)
  246. )
  247. middle_exit_streak = middle_exit_streak + 1 if middle_exit else 0
  248. if middle_exit_streak >= variant.middle_exit_confirm_bars:
  249. pending_exit = True
  250. continue
  251. if index < cooldown_until:
  252. continue
  253. if variant.eth_vol_cap is not None and float(eth_realized_vol[index]) > variant.eth_vol_cap:
  254. continue
  255. if variant.btc_filter == "btc-up" and not (btc_close.iloc[index] > float(btc_sma[index])):
  256. continue
  257. if variant.btc_filter == "btc-up-momo" and not (
  258. btc_close.iloc[index] > float(btc_sma[index]) and float(btc_momentum[index]) > 0.0
  259. ):
  260. continue
  261. if bandwidth[index] <= threshold[index]:
  262. if candle.close > float(upper[index]):
  263. pending_entry_side = "long"
  264. elif variant.side_mode == "both" and candle.close < float(lower[index]):
  265. pending_entry_side = "short"
  266. result = SegmentResult(
  267. trade_count=len(trades),
  268. total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
  269. win_rate=wins / len(trades) if trades else 0.0,
  270. max_drawdown=max_drawdown,
  271. trades=trades,
  272. open_position=position,
  273. candles=eth[warmup_bars:],
  274. equity_curve=equity_curve,
  275. entries=entries,
  276. exits=exits,
  277. )
  278. return result, exit_counts
  279. def trade_stats(trades: list[dict[str, object]]) -> dict[str, float]:
  280. if not trades:
  281. return {"avg_mfe_pct": 0.0, "avg_return_pct": 0.0, "payoff_ratio": 0.0, "profit_factor": 0.0}
  282. returns = [float(trade["return_pct"]) for trade in trades]
  283. wins = [value for value in returns if value > 0.0]
  284. losses = [-value for value in returns if value < 0.0]
  285. return {
  286. "avg_mfe_pct": sum(float(trade["mfe_pct"]) for trade in trades) / len(trades),
  287. "avg_return_pct": sum(returns) / len(returns),
  288. "payoff_ratio": (sum(wins) / len(wins)) / (sum(losses) / len(losses)) if wins and losses else 0.0,
  289. "profit_factor": sum(wins) / sum(losses) if losses else 0.0,
  290. }
  291. def build_variants() -> list[Variant]:
  292. base_specs = (
  293. (48, 960, 0.25, "both", "none", 0.006, 0.01, 0.0005, 1),
  294. (48, 960, 0.25, "both", "none", 0.006, 0.01, 0.0010, 1),
  295. (48, 960, 0.25, "both", "none", 0.006, 0.012, 0.0005, 1),
  296. (96, 960, 0.25, "both", "btc-up", 0.006, 0.012, 0.001, 1),
  297. (96, 960, 0.25, "both", "btc-up-momo", 0.006, 0.012, 0.001, 1),
  298. (96, 480, 0.15, "both", "none", 0.006, 0.01, 0.001, 1),
  299. )
  300. protections = [
  301. (None, 0.0, None, None, None, None),
  302. (0.004, 0.000, None, None, None, None),
  303. (0.006, 0.000, None, None, None, None),
  304. (0.008, 0.001, None, None, None, None),
  305. (0.004, 0.000, 0.010, 0.006, None, None),
  306. (0.006, 0.000, 0.012, 0.006, None, None),
  307. (0.008, 0.001, 0.015, 0.008, None, None),
  308. (0.004, 0.000, None, None, 0.010, 0.006),
  309. (0.006, 0.000, None, None, 0.012, 0.007),
  310. (0.008, 0.001, None, None, 0.015, 0.010),
  311. (0.004, 0.000, 0.010, 0.006, 0.012, 0.008),
  312. (0.006, 0.000, 0.012, 0.006, 0.015, 0.010),
  313. (0.008, 0.001, 0.015, 0.008, 0.020, 0.012),
  314. ]
  315. variants: list[Variant] = []
  316. for band_length, lookback, quantile, side_mode, btc_filter, vol_cap, stop_loss, mxbuf, mxc in base_specs:
  317. for be_trigger, be_lock, trail_trigger, trail_giveback, gb_trigger, gb_pct in protections:
  318. variants.append(
  319. Variant(
  320. band_length=band_length,
  321. bandwidth_lookback=lookback,
  322. bandwidth_quantile=quantile,
  323. side_mode=side_mode,
  324. btc_filter=btc_filter,
  325. eth_vol_cap=vol_cap,
  326. cooldown_bars=24,
  327. stop_loss_pct=stop_loss,
  328. middle_exit_buffer_pct=mxbuf,
  329. middle_exit_confirm_bars=mxc,
  330. breakeven_trigger_pct=be_trigger,
  331. breakeven_lock_pct=be_lock,
  332. trail_trigger_pct=trail_trigger,
  333. trail_giveback_pct=trail_giveback,
  334. max_giveback_trigger_pct=gb_trigger,
  335. max_giveback_pct=gb_pct,
  336. )
  337. )
  338. return variants
  339. def format_cell(value: object) -> str:
  340. if isinstance(value, float):
  341. return f"{value:.6g}"
  342. return str(value).replace("|", "\\|")
  343. def markdown_table(frame: pd.DataFrame) -> str:
  344. columns = list(frame.columns)
  345. rows = [columns, ["---" for _ in columns]]
  346. for record in frame.to_dict("records"):
  347. rows.append([record[column] for column in columns])
  348. return "\n".join("| " + " | ".join(format_cell(value) for value in row) + " |" for row in rows)
  349. def write_report(summary: pd.DataFrame, horizon: pd.DataFrame, first_ts: int, last_ts: int, command: str) -> str:
  350. primary = summary[summary["cost"] == PRIMARY_COST]
  351. top = primary.head(10)
  352. baseline = primary[primary["breakeven_trigger_pct"].isna() & primary["trail_trigger_pct"].isna() & primary["max_giveback_trigger_pct"].isna()]
  353. baseline_top = baseline.sort_values(["net_calmar", "net_annualized_return"], ascending=[False, False]).head(5)
  354. horizon_top = (
  355. horizon[horizon["cost"] == PRIMARY_COST]
  356. .sort_values(["horizon", "net_calmar", "net_annualized_return"], ascending=[True, False, False])
  357. .groupby("horizon", observed=True)
  358. .head(3)
  359. )
  360. return "\n".join(
  361. [
  362. "# ETH BB squeeze profit-protection exploration",
  363. "",
  364. f"Run command: `{command}`",
  365. f"Actual continuous local history: `{_format_ts(first_ts)}` to `{_format_ts(last_ts)}`.",
  366. "",
  367. "The entry logic matches existing BB squeeze families. Variants only change exits after a trade has floating profit: breakeven stop, trailing protection, and close-based giveback.",
  368. "",
  369. "Top 10 by maker_taker Calmar:",
  370. markdown_table(
  371. top[
  372. [
  373. "name",
  374. "trades",
  375. "net_total_return",
  376. "net_annualized_return",
  377. "net_max_drawdown",
  378. "net_calmar",
  379. "avg_mfe_pct",
  380. "avg_return_pct",
  381. "profit_factor",
  382. "stop_exits",
  383. "protect_exits",
  384. "giveback_exits",
  385. "signal_exits",
  386. ]
  387. ]
  388. ),
  389. "",
  390. "Best no-protection baselines in same grid:",
  391. markdown_table(
  392. baseline_top[
  393. [
  394. "name",
  395. "trades",
  396. "net_annualized_return",
  397. "net_max_drawdown",
  398. "net_calmar",
  399. "avg_mfe_pct",
  400. "avg_return_pct",
  401. "profit_factor",
  402. ]
  403. ]
  404. ),
  405. "",
  406. "Recent horizon leaders:",
  407. markdown_table(
  408. horizon_top[
  409. [
  410. "horizon",
  411. "name",
  412. "trades",
  413. "net_total_return",
  414. "net_annualized_return",
  415. "net_max_drawdown",
  416. "net_calmar",
  417. ]
  418. ]
  419. ),
  420. ]
  421. ) + "\n"
  422. def main() -> int:
  423. parser = argparse.ArgumentParser()
  424. parser.add_argument("--bar", default=BAR)
  425. parser.add_argument("--years", type=float, default=YEARS)
  426. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  427. args = parser.parse_args()
  428. eth = load_candles_csv(DATA_DIR, ETH_SYMBOL, args.bar)
  429. btc = load_candles_csv(DATA_DIR, BTC_SYMBOL, args.bar)
  430. eth, btc = align_candles_by_ts(eth, btc)
  431. requested_bars = int(args.years * 365 * 24 * 60 / 15)
  432. eth = eth[-requested_bars:]
  433. btc = btc[-requested_bars:]
  434. summary_rows: list[dict[str, object]] = []
  435. horizon_rows_out: list[dict[str, object]] = []
  436. variants = build_variants()
  437. for index, variant in enumerate(variants, start=1):
  438. result, exit_counts = run_variant(eth, btc, variant)
  439. if not result.equity_curve:
  440. continue
  441. stats = trade_stats(result.trades)
  442. for cost_name, cost in COSTS:
  443. frame = cost_equity_frame(result, cost)
  444. metrics = equity_metrics(frame, eth[0].ts, eth[-1].ts)
  445. month, month_return = worst_month(frame)
  446. row = {
  447. "family": "bb_squeeze_profit_protection",
  448. "cost": cost_name,
  449. "symbol": ETH_SYMBOL,
  450. "signal_symbol": BTC_SYMBOL if variant.btc_filter != "none" else "",
  451. "bar": args.bar,
  452. "name": variant.name,
  453. "band_length": variant.band_length,
  454. "bandwidth_lookback": variant.bandwidth_lookback,
  455. "bandwidth_quantile": variant.bandwidth_quantile,
  456. "side_mode": variant.side_mode,
  457. "btc_filter": variant.btc_filter,
  458. "eth_vol_cap": variant.eth_vol_cap,
  459. "cooldown_bars": variant.cooldown_bars,
  460. "stop_loss_pct": variant.stop_loss_pct,
  461. "middle_exit_buffer_pct": variant.middle_exit_buffer_pct,
  462. "middle_exit_confirm_bars": variant.middle_exit_confirm_bars,
  463. "breakeven_trigger_pct": variant.breakeven_trigger_pct,
  464. "breakeven_lock_pct": variant.breakeven_lock_pct,
  465. "trail_trigger_pct": variant.trail_trigger_pct,
  466. "trail_giveback_pct": variant.trail_giveback_pct,
  467. "max_giveback_trigger_pct": variant.max_giveback_trigger_pct,
  468. "max_giveback_pct": variant.max_giveback_pct,
  469. "first_candle": _format_ts(eth[0].ts),
  470. "last_candle": _format_ts(eth[-1].ts),
  471. "years": (eth[-1].ts - eth[0].ts) / 86_400_000 / 365,
  472. "trades": result.trade_count,
  473. "gross_total_return": result.total_return,
  474. "gross_max_drawdown_mark_to_market": result.max_drawdown,
  475. "worst_month": month,
  476. "worst_month_return": month_return,
  477. **exit_counts,
  478. **stats,
  479. **metrics,
  480. }
  481. summary_rows.append(row)
  482. for horizon_row in horizon_rows(frame, eth[-1].ts, HORIZONS):
  483. horizon_rows_out.append(
  484. {
  485. "family": "bb_squeeze_profit_protection",
  486. "cost": cost_name,
  487. "symbol": ETH_SYMBOL,
  488. "bar": args.bar,
  489. "name": variant.name,
  490. "trades": result.trade_count,
  491. **horizon_row,
  492. }
  493. )
  494. print(f"done {index}/{len(variants)} {variant.name}", flush=True)
  495. summary = pd.DataFrame(summary_rows).sort_values(
  496. ["cost", "net_calmar", "net_annualized_return", "profit_factor"],
  497. ascending=[True, False, False, False],
  498. )
  499. primary = summary[summary["cost"] == PRIMARY_COST]
  500. summary = pd.concat([primary, summary[summary["cost"] != PRIMARY_COST]], ignore_index=True)
  501. horizon = pd.DataFrame(horizon_rows_out)
  502. horizon["horizon"] = pd.Categorical(horizon["horizon"], categories=[label for label, _ in HORIZONS], ordered=True)
  503. horizon = horizon.sort_values(["cost", "horizon", "net_calmar", "net_annualized_return"], ascending=[True, True, False, False])
  504. args.output_dir.mkdir(parents=True, exist_ok=True)
  505. summary_path = args.output_dir / "eth-bb-squeeze-profit-protection-summary.csv"
  506. horizon_path = args.output_dir / "eth-bb-squeeze-profit-protection-horizon.csv"
  507. report_path = args.output_dir / "eth-bb-squeeze-profit-protection-report.md"
  508. summary.to_csv(summary_path, index=False)
  509. horizon.to_csv(horizon_path, index=False)
  510. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --bar {args.bar} --years {args.years}"
  511. report_path.write_text(write_report(summary, horizon, eth[0].ts, eth[-1].ts, command), encoding="utf-8")
  512. print(primary.head(10).to_string(index=False))
  513. return 0
  514. if __name__ == "__main__":
  515. raise SystemExit(main())