search_live_bb_squeeze_exit_variants.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  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. SYMBOL = "ETH-USDT-SWAP"
  11. BAR = "15m"
  12. YEARS = 10.0
  13. LEVERAGE = 3
  14. INITIAL_EQUITY = 10_000.0
  15. DATA_DIR = Path("data/okx-candles")
  16. OUTPUT_DIR = Path("reports/eth-exploration")
  17. PRIMARY_COST = "maker_taker"
  18. COSTS = (
  19. ("maker_maker", 0.0012),
  20. ("maker_taker", 0.0021),
  21. ("taker_taker", 0.0030),
  22. )
  23. HORIZONS = (
  24. ("3y", pd.DateOffset(years=3)),
  25. ("1y", pd.DateOffset(years=1)),
  26. ("6m", pd.DateOffset(months=6)),
  27. ("3m", pd.DateOffset(months=3)),
  28. )
  29. @dataclass(frozen=True)
  30. class Variant:
  31. middle_exit_buffer_pct: float
  32. middle_exit_confirm_bars: int
  33. @property
  34. def name(self) -> str:
  35. return (
  36. "live-bb-squeeze-l48-bw960-q0.25-sl0.01-tpnone-both-none-vc0.006-ddnone-cd24"
  37. f"-mxbuf{self.middle_exit_buffer_pct:g}-mxc{self.middle_exit_confirm_bars}"
  38. )
  39. def _format_ts(ts: int) -> str:
  40. return pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
  41. def _load_candles(symbol: str, bar: str) -> list[Candle]:
  42. frame = pd.read_csv(DATA_DIR / symbol / f"{bar}.csv")
  43. return [
  44. Candle(
  45. symbol=symbol,
  46. ts=int(row.ts),
  47. open=float(row.open),
  48. high=float(row.high),
  49. low=float(row.low),
  50. close=float(row.close),
  51. volume=float(row.volume),
  52. )
  53. for row in frame.itertuples(index=False)
  54. ]
  55. def _close_position(
  56. *,
  57. trades: list[dict[str, object]],
  58. exits: list[dict[str, object]],
  59. position: dict[str, object],
  60. candle: Candle,
  61. exit_price: float,
  62. ) -> tuple[float, bool]:
  63. margin_used = float(position["margin_used"])
  64. exit_equity = trade_equity(
  65. side=str(position["side"]),
  66. margin_used=margin_used,
  67. entry_price=float(position["entry_price"]),
  68. exit_price=exit_price,
  69. leverage=LEVERAGE,
  70. )
  71. pnl = exit_equity - margin_used
  72. trades.append(
  73. {
  74. "side": "Long" if position["side"] == "long" else "Short",
  75. "entry_time": _format_ts(int(position["entry_time"])),
  76. "exit_time": _format_ts(candle.ts),
  77. "entry_price": round(float(position["entry_price"]), 4),
  78. "exit_price": round(exit_price, 4),
  79. "pnl": round(pnl, 4),
  80. "return_pct": round(pnl / margin_used * 100.0, 4),
  81. "cost_weight": 1.0,
  82. }
  83. )
  84. exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
  85. return exit_equity, pnl > 0.0
  86. def run_variant(candles: list[Candle], variant: Variant) -> SegmentResult:
  87. close = pd.Series([candle.close for candle in candles], dtype=float)
  88. middle_series = close.rolling(48).mean()
  89. stdev = close.rolling(48).std(ddof=0)
  90. upper = (middle_series + 2.0 * stdev).tolist()
  91. lower = (middle_series - 2.0 * stdev).tolist()
  92. middle = middle_series.tolist()
  93. bandwidth = ((pd.Series(upper) - pd.Series(lower)) / middle_series).tolist()
  94. threshold = pd.Series(bandwidth, dtype=float).rolling(960).quantile(0.25).tolist()
  95. eth_vol = close.pct_change().rolling(96).std(ddof=0).tolist()
  96. warmup_bars = 960
  97. equity = INITIAL_EQUITY
  98. ending_equity = equity
  99. peak_equity = equity
  100. max_drawdown = 0.0
  101. wins = 0
  102. trades: list[dict[str, object]] = []
  103. entries: list[dict[str, object]] = []
  104. exits: list[dict[str, object]] = []
  105. equity_curve: list[dict[str, float | int]] = []
  106. position: dict[str, object] | None = None
  107. pending_entry_side: str | None = None
  108. pending_exit = False
  109. middle_exit_streak = 0
  110. cooldown_until = -1
  111. for index in range(warmup_bars, len(candles)):
  112. candle = candles[index]
  113. if pending_exit and position is not None:
  114. equity, won = _close_position(trades=trades, exits=exits, position=position, candle=candle, exit_price=candle.open)
  115. wins += int(won)
  116. position = None
  117. pending_exit = False
  118. middle_exit_streak = 0
  119. cooldown_until = index + 24
  120. if pending_entry_side is not None and position is None and equity > 0.0:
  121. position = {
  122. "side": pending_entry_side,
  123. "entry_time": candle.ts,
  124. "entry_price": candle.open,
  125. "margin_used": equity,
  126. "stop_price": candle.open * (0.99 if pending_entry_side == "long" else 1.01),
  127. }
  128. entries.append({"ts": candle.ts, "price": candle.open, "side": pending_entry_side})
  129. pending_entry_side = None
  130. current_equity = equity
  131. if position is not None:
  132. side = str(position["side"])
  133. stop_hit = (side == "long" and candle.low <= float(position["stop_price"])) or (
  134. side == "short" and candle.high >= float(position["stop_price"])
  135. )
  136. if stop_hit:
  137. equity, won = _close_position(
  138. trades=trades,
  139. exits=exits,
  140. position=position,
  141. candle=candle,
  142. exit_price=float(position["stop_price"]),
  143. )
  144. wins += int(won)
  145. current_equity = equity
  146. position = None
  147. middle_exit_streak = 0
  148. cooldown_until = index + 24
  149. if position is not None:
  150. current_equity = mark_to_market(
  151. side=str(position["side"]),
  152. margin_used=float(position["margin_used"]),
  153. entry_price=float(position["entry_price"]),
  154. mark_price=candle.close,
  155. leverage=LEVERAGE,
  156. )
  157. peak_equity = max(peak_equity, current_equity)
  158. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  159. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  160. ending_equity = current_equity
  161. if index == len(candles) - 1 or equity <= 0.0:
  162. continue
  163. values = (middle[index], upper[index], lower[index], bandwidth[index], threshold[index], eth_vol[index])
  164. if any(value != value for value in values):
  165. continue
  166. if position is not None:
  167. middle_exit = (
  168. position["side"] == "long" and candle.close < float(middle[index]) * (1.0 - variant.middle_exit_buffer_pct)
  169. ) or (
  170. position["side"] == "short" and candle.close > float(middle[index]) * (1.0 + variant.middle_exit_buffer_pct)
  171. )
  172. middle_exit_streak = middle_exit_streak + 1 if middle_exit else 0
  173. if middle_exit_streak >= variant.middle_exit_confirm_bars:
  174. pending_exit = True
  175. continue
  176. if index < cooldown_until:
  177. continue
  178. if float(eth_vol[index]) > 0.006:
  179. continue
  180. if bandwidth[index] <= threshold[index]:
  181. if candle.close > float(upper[index]):
  182. pending_entry_side = "long"
  183. elif candle.close < float(lower[index]):
  184. pending_entry_side = "short"
  185. trade_count = len(trades)
  186. return SegmentResult(
  187. trade_count=trade_count,
  188. total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
  189. win_rate=wins / trade_count if trade_count else 0.0,
  190. max_drawdown=max_drawdown,
  191. trades=trades,
  192. open_position=position,
  193. candles=candles[warmup_bars:],
  194. equity_curve=equity_curve,
  195. entries=entries,
  196. exits=exits,
  197. )
  198. def cost_equity_frame(result: SegmentResult, cost: float) -> pd.DataFrame:
  199. rows = [{"ts": pd.to_datetime(result.equity_curve[0]["ts"], unit="ms", utc=True), "equity": INITIAL_EQUITY}]
  200. equity = INITIAL_EQUITY
  201. for trade in result.trades:
  202. equity *= 1.0 + float(trade["return_pct"]) / 100.0 - cost * float(trade.get("cost_weight", 1.0))
  203. rows.append({"ts": pd.to_datetime(str(trade["exit_time"]), utc=True), "equity": equity})
  204. return pd.DataFrame(rows)
  205. def max_drawdown(values: list[float]) -> float:
  206. peak = values[0]
  207. dd = 0.0
  208. for value in values:
  209. peak = max(peak, value)
  210. dd = max(dd, (peak - value) / peak if peak else 0.0)
  211. return dd
  212. def equity_metrics(frame: pd.DataFrame, first_ts: int, last_ts: int) -> dict[str, float]:
  213. years = (last_ts - first_ts) / 86_400_000 / 365
  214. total_return = float(frame["equity"].iloc[-1] / frame["equity"].iloc[0] - 1.0)
  215. annualized = (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 and years > 0.0 else 0.0
  216. dd = max_drawdown([float(value) for value in frame["equity"]])
  217. return {
  218. "net_total_return": total_return,
  219. "net_annualized_return": annualized,
  220. "net_max_drawdown": dd,
  221. "net_calmar": annualized / dd if dd else 0.0,
  222. }
  223. def horizon_rows(frame: pd.DataFrame, last_ts: int) -> list[dict[str, object]]:
  224. rows: list[dict[str, object]] = []
  225. end_time = pd.to_datetime(last_ts, unit="ms", utc=True)
  226. for label, offset in HORIZONS:
  227. cutoff = end_time - offset
  228. before = frame[frame["ts"] <= cutoff]
  229. if len(before):
  230. start_equity = float(before["equity"].iloc[-1])
  231. start_time = cutoff
  232. after = frame[frame["ts"] > cutoff]
  233. horizon_frame = pd.concat([pd.DataFrame([{"ts": cutoff, "equity": start_equity}]), after[["ts", "equity"]]], ignore_index=True)
  234. else:
  235. horizon_frame = frame[["ts", "equity"]].copy()
  236. start_time = pd.Timestamp(horizon_frame["ts"].iloc[0])
  237. rows.append(
  238. {
  239. "horizon": label,
  240. "horizon_start": start_time.strftime("%Y-%m-%d %H:%M"),
  241. "horizon_end": end_time.strftime("%Y-%m-%d %H:%M"),
  242. **equity_metrics(horizon_frame, int(start_time.timestamp() * 1000), last_ts),
  243. }
  244. )
  245. return rows
  246. def worst_month(frame: pd.DataFrame) -> tuple[str, float]:
  247. monthly = frame.set_index("ts")["equity"].resample("ME").last().ffill().pct_change().dropna()
  248. if not len(monthly):
  249. return "", 0.0
  250. idx = monthly.idxmin()
  251. return idx.strftime("%Y-%m"), float(monthly.loc[idx])
  252. def build_variants() -> list[Variant]:
  253. return [
  254. Variant(buffer, confirm)
  255. for buffer in (0.0, 0.0005, 0.001, 0.0015, 0.002, 0.003)
  256. for confirm in (1, 2, 3)
  257. ]
  258. def main() -> int:
  259. parser = argparse.ArgumentParser()
  260. parser.add_argument("--bar", default=BAR)
  261. parser.add_argument("--years", type=float, default=YEARS)
  262. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  263. args = parser.parse_args()
  264. candles = _load_candles(SYMBOL, args.bar)
  265. requested_bars = int(args.years * 365 * 24 * 60 / 15)
  266. candles = candles[-requested_bars:]
  267. summary_rows: list[dict[str, object]] = []
  268. horizon_rows_out: list[dict[str, object]] = []
  269. for variant in build_variants():
  270. result = run_variant(candles, variant)
  271. for cost_name, cost in COSTS:
  272. frame = cost_equity_frame(result, cost)
  273. metrics = equity_metrics(frame, candles[0].ts, candles[-1].ts)
  274. month, month_return = worst_month(frame)
  275. row = {
  276. "family": "live_bb_squeeze_exit_variant",
  277. "cost": cost_name,
  278. "symbol": SYMBOL,
  279. "bar": args.bar,
  280. "name": variant.name,
  281. "middle_exit_buffer_pct": variant.middle_exit_buffer_pct,
  282. "middle_exit_confirm_bars": variant.middle_exit_confirm_bars,
  283. "first_candle": _format_ts(candles[0].ts),
  284. "last_candle": _format_ts(candles[-1].ts),
  285. "years": (candles[-1].ts - candles[0].ts) / 86_400_000 / 365,
  286. "trades": result.trade_count,
  287. "gross_total_return": result.total_return,
  288. "gross_max_drawdown_mark_to_market": result.max_drawdown,
  289. "worst_month": month,
  290. "worst_month_return": month_return,
  291. **metrics,
  292. }
  293. summary_rows.append(row)
  294. for horizon_row in horizon_rows(frame, candles[-1].ts):
  295. horizon_rows_out.append(
  296. {
  297. "family": "live_bb_squeeze_exit_variant",
  298. "cost": cost_name,
  299. "symbol": SYMBOL,
  300. "bar": args.bar,
  301. "name": variant.name,
  302. "trades": result.trade_count,
  303. **horizon_row,
  304. }
  305. )
  306. summary = pd.DataFrame(summary_rows).sort_values(
  307. ["cost", "net_calmar", "net_annualized_return", "worst_month_return"],
  308. ascending=[True, False, False, False],
  309. )
  310. horizon = pd.DataFrame(horizon_rows_out)
  311. horizon["horizon"] = pd.Categorical(horizon["horizon"], categories=[label for label, _ in HORIZONS], ordered=True)
  312. horizon = horizon.sort_values(["cost", "horizon", "net_calmar", "net_annualized_return"], ascending=[True, True, False, False])
  313. args.output_dir.mkdir(parents=True, exist_ok=True)
  314. summary_path = args.output_dir / "live-bb-squeeze-exit-variants-summary.csv"
  315. horizon_path = args.output_dir / "live-bb-squeeze-exit-variants-horizon.csv"
  316. summary.to_csv(summary_path, index=False)
  317. horizon.to_csv(horizon_path, index=False)
  318. primary = summary[summary["cost"] == PRIMARY_COST]
  319. print(primary.head(10).to_string(index=False))
  320. return 0
  321. if __name__ == "__main__":
  322. raise SystemExit(main())