search_eth_non_rsi_10y.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920
  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.donchian_report import DonchianConfig, run_donchian_segment
  9. from okx_codex_trader.models import Candle
  10. from okx_codex_trader.sampled_report import SegmentResult, mark_to_market, trade_equity
  11. from scripts import explore_ultrashort as explore
  12. ETH_SYMBOL = "ETH-USDT-SWAP"
  13. BTC_SYMBOL = "BTC-USDT-SWAP"
  14. BAR = "15m"
  15. YEARS = 10.0
  16. LEVERAGE = 3
  17. INITIAL_EQUITY = 10_000.0
  18. DATA_DIR = Path("data/okx-candles")
  19. OUTPUT_DIR = Path("reports/eth-exploration")
  20. PRIMARY_COST = "maker_taker"
  21. COSTS = (
  22. ("maker_maker", 0.0012),
  23. ("maker_taker", 0.0021),
  24. ("taker_taker", 0.0030),
  25. )
  26. HORIZONS = (
  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. )
  32. @dataclass(frozen=True)
  33. class Strategy:
  34. family: str
  35. name: str
  36. warmup_bars: int
  37. pair: bool
  38. run: object
  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 _align_pair(left: list[Candle], right: list[Candle]) -> tuple[list[Candle], list[Candle]]:
  56. right_by_ts = {candle.ts: candle for candle in right}
  57. left_out: list[Candle] = []
  58. right_out: list[Candle] = []
  59. for candle in left:
  60. other = right_by_ts.get(candle.ts)
  61. if other is not None:
  62. left_out.append(candle)
  63. right_out.append(other)
  64. return left_out, right_out
  65. def _close_position(
  66. *,
  67. trades: list[dict[str, object]],
  68. exits: list[dict[str, object]],
  69. position: dict[str, object],
  70. candle: Candle,
  71. exit_price: float,
  72. ) -> tuple[float, bool]:
  73. margin_used = float(position["margin_used"])
  74. exit_equity = trade_equity(
  75. side=str(position["side"]),
  76. margin_used=margin_used,
  77. entry_price=float(position["entry_price"]),
  78. exit_price=exit_price,
  79. leverage=LEVERAGE,
  80. )
  81. pnl = exit_equity - margin_used
  82. trades.append(
  83. {
  84. "side": "Long" if position["side"] == "long" else "Short",
  85. "entry_time": _format_ts(int(position["entry_time"])),
  86. "exit_time": _format_ts(candle.ts),
  87. "entry_price": round(float(position["entry_price"]), 4),
  88. "exit_price": round(exit_price, 4),
  89. "pnl": round(pnl, 4),
  90. "return_pct": round(pnl / margin_used * 100.0, 4),
  91. "cost_weight": 1.0,
  92. }
  93. )
  94. exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
  95. return exit_equity, pnl > 0.0
  96. def _empty_result(candles: list[Candle], warmup_bars: int, equity_curve: list[dict[str, float | int]]) -> SegmentResult:
  97. return SegmentResult(
  98. trade_count=0,
  99. total_return=0.0,
  100. win_rate=0.0,
  101. max_drawdown=0.0,
  102. trades=[],
  103. open_position=None,
  104. candles=candles[warmup_bars:],
  105. equity_curve=equity_curve,
  106. entries=[],
  107. exits=[],
  108. )
  109. def run_bb_pullback_segment(
  110. *,
  111. candles: list[Candle],
  112. trend_sma: int,
  113. band_length: int,
  114. std_multiplier: float,
  115. stop_loss_pct: float,
  116. ) -> SegmentResult:
  117. closes = pd.Series([candle.close for candle in candles], dtype=float)
  118. trend = closes.rolling(trend_sma).mean().tolist()
  119. middle = closes.rolling(band_length).mean().tolist()
  120. stdev = closes.rolling(band_length).std(ddof=0).tolist()
  121. upper = [m + std_multiplier * s if m == m and s == s else float("nan") for m, s in zip(middle, stdev)]
  122. lower = [m - std_multiplier * s if m == m and s == s else float("nan") for m, s in zip(middle, stdev)]
  123. warmup_bars = max(trend_sma, band_length)
  124. return _run_indicator_segment(candles, warmup_bars, trend, middle, upper, lower, stop_loss_pct, "pullback")
  125. def run_bb_squeeze_breakout_segment(
  126. *,
  127. candles: list[Candle],
  128. band_length: int,
  129. std_multiplier: float,
  130. bandwidth_lookback: int,
  131. bandwidth_quantile: float,
  132. stop_loss_pct: float,
  133. ) -> SegmentResult:
  134. closes = pd.Series([candle.close for candle in candles], dtype=float)
  135. middle_series = closes.rolling(band_length).mean()
  136. stdev_series = closes.rolling(band_length).std(ddof=0)
  137. upper_series = middle_series + std_multiplier * stdev_series
  138. lower_series = middle_series - std_multiplier * stdev_series
  139. bandwidth = ((upper_series - lower_series) / middle_series).tolist()
  140. threshold = pd.Series(bandwidth, dtype=float).rolling(bandwidth_lookback).quantile(bandwidth_quantile).tolist()
  141. warmup_bars = max(band_length, bandwidth_lookback)
  142. return _run_squeeze_segment(
  143. candles,
  144. warmup_bars,
  145. middle_series.tolist(),
  146. upper_series.tolist(),
  147. lower_series.tolist(),
  148. bandwidth,
  149. threshold,
  150. stop_loss_pct,
  151. )
  152. def _run_indicator_segment(
  153. candles: list[Candle],
  154. warmup_bars: int,
  155. trend: list[float],
  156. middle: list[float],
  157. upper: list[float],
  158. lower: list[float],
  159. stop_loss_pct: float,
  160. mode: str,
  161. ) -> SegmentResult:
  162. if len(candles) <= warmup_bars:
  163. return _empty_result(candles, warmup_bars, [])
  164. equity = INITIAL_EQUITY
  165. ending_equity = equity
  166. peak_equity = equity
  167. max_drawdown = 0.0
  168. wins = 0
  169. trades: list[dict[str, object]] = []
  170. entries: list[dict[str, object]] = []
  171. exits: list[dict[str, object]] = []
  172. equity_curve: list[dict[str, float | int]] = []
  173. position: dict[str, object] | None = None
  174. pending_entry_side: str | None = None
  175. pending_exit = False
  176. for index in range(warmup_bars, len(candles)):
  177. candle = candles[index]
  178. if pending_exit and position is not None:
  179. equity, won = _close_position(trades=trades, exits=exits, position=position, candle=candle, exit_price=candle.open)
  180. wins += int(won)
  181. position = None
  182. pending_exit = False
  183. if pending_entry_side is not None and position is None and equity > 0.0:
  184. position = {
  185. "side": pending_entry_side,
  186. "entry_time": candle.ts,
  187. "entry_price": candle.open,
  188. "margin_used": equity,
  189. "stop_price": candle.open * (1.0 - stop_loss_pct if pending_entry_side == "long" else 1.0 + stop_loss_pct),
  190. }
  191. entries.append({"ts": candle.ts, "price": candle.open, "side": pending_entry_side})
  192. pending_entry_side = None
  193. current_equity = equity
  194. if position is not None:
  195. stop_hit = (position["side"] == "long" and candle.low <= float(position["stop_price"])) or (
  196. position["side"] == "short" and candle.high >= float(position["stop_price"])
  197. )
  198. if stop_hit:
  199. equity, won = _close_position(
  200. trades=trades,
  201. exits=exits,
  202. position=position,
  203. candle=candle,
  204. exit_price=float(position["stop_price"]),
  205. )
  206. wins += int(won)
  207. current_equity = equity
  208. position = None
  209. if position is not None:
  210. current_equity = mark_to_market(
  211. side=str(position["side"]),
  212. margin_used=float(position["margin_used"]),
  213. entry_price=float(position["entry_price"]),
  214. mark_price=candle.close,
  215. leverage=LEVERAGE,
  216. )
  217. peak_equity = max(peak_equity, current_equity)
  218. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  219. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  220. ending_equity = current_equity
  221. if index == len(candles) - 1 or equity <= 0.0:
  222. continue
  223. values = (trend[index], middle[index], upper[index], lower[index])
  224. if any(value != value for value in values):
  225. continue
  226. if position is not None:
  227. if (position["side"] == "long" and candle.close >= float(middle[index])) or (
  228. position["side"] == "short" and candle.close <= float(middle[index])
  229. ):
  230. pending_exit = True
  231. continue
  232. if mode == "pullback":
  233. if candle.close > float(trend[index]) and candle.close <= float(lower[index]):
  234. pending_entry_side = "long"
  235. elif candle.close < float(trend[index]) and candle.close >= float(upper[index]):
  236. pending_entry_side = "short"
  237. trade_count = len(trades)
  238. return SegmentResult(
  239. trade_count=trade_count,
  240. total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
  241. win_rate=wins / trade_count if trade_count else 0.0,
  242. max_drawdown=max_drawdown,
  243. trades=trades,
  244. open_position=position,
  245. candles=candles[warmup_bars:],
  246. equity_curve=equity_curve,
  247. entries=entries,
  248. exits=exits,
  249. )
  250. def _run_squeeze_segment(
  251. candles: list[Candle],
  252. warmup_bars: int,
  253. middle: list[float],
  254. upper: list[float],
  255. lower: list[float],
  256. bandwidth: list[float],
  257. threshold: list[float],
  258. stop_loss_pct: float,
  259. ) -> SegmentResult:
  260. if len(candles) <= warmup_bars:
  261. return _empty_result(candles, warmup_bars, [])
  262. equity = INITIAL_EQUITY
  263. ending_equity = equity
  264. peak_equity = equity
  265. max_drawdown = 0.0
  266. wins = 0
  267. trades: list[dict[str, object]] = []
  268. entries: list[dict[str, object]] = []
  269. exits: list[dict[str, object]] = []
  270. equity_curve: list[dict[str, float | int]] = []
  271. position: dict[str, object] | None = None
  272. pending_entry_side: str | None = None
  273. pending_exit = False
  274. for index in range(warmup_bars, len(candles)):
  275. candle = candles[index]
  276. if pending_exit and position is not None:
  277. equity, won = _close_position(trades=trades, exits=exits, position=position, candle=candle, exit_price=candle.open)
  278. wins += int(won)
  279. position = None
  280. pending_exit = False
  281. if pending_entry_side is not None and position is None and equity > 0.0:
  282. position = {
  283. "side": pending_entry_side,
  284. "entry_time": candle.ts,
  285. "entry_price": candle.open,
  286. "margin_used": equity,
  287. "stop_price": candle.open * (1.0 - stop_loss_pct if pending_entry_side == "long" else 1.0 + stop_loss_pct),
  288. }
  289. entries.append({"ts": candle.ts, "price": candle.open, "side": pending_entry_side})
  290. pending_entry_side = None
  291. current_equity = equity
  292. if position is not None:
  293. stop_hit = (position["side"] == "long" and candle.low <= float(position["stop_price"])) or (
  294. position["side"] == "short" and candle.high >= float(position["stop_price"])
  295. )
  296. if stop_hit:
  297. equity, won = _close_position(
  298. trades=trades,
  299. exits=exits,
  300. position=position,
  301. candle=candle,
  302. exit_price=float(position["stop_price"]),
  303. )
  304. wins += int(won)
  305. current_equity = equity
  306. position = None
  307. if position is not None:
  308. current_equity = mark_to_market(
  309. side=str(position["side"]),
  310. margin_used=float(position["margin_used"]),
  311. entry_price=float(position["entry_price"]),
  312. mark_price=candle.close,
  313. leverage=LEVERAGE,
  314. )
  315. peak_equity = max(peak_equity, current_equity)
  316. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  317. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  318. ending_equity = current_equity
  319. if index == len(candles) - 1 or equity <= 0.0:
  320. continue
  321. values = (middle[index], upper[index], lower[index], bandwidth[index], threshold[index])
  322. if any(value != value for value in values):
  323. continue
  324. if position is not None:
  325. if (position["side"] == "long" and candle.close < float(middle[index])) or (
  326. position["side"] == "short" and candle.close > float(middle[index])
  327. ):
  328. pending_exit = True
  329. continue
  330. if bandwidth[index] <= threshold[index]:
  331. if candle.close > float(upper[index]):
  332. pending_entry_side = "long"
  333. elif candle.close < float(lower[index]):
  334. pending_entry_side = "short"
  335. trade_count = len(trades)
  336. return SegmentResult(
  337. trade_count=trade_count,
  338. total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
  339. win_rate=wins / trade_count if trade_count else 0.0,
  340. max_drawdown=max_drawdown,
  341. trades=trades,
  342. open_position=position,
  343. candles=candles[warmup_bars:],
  344. equity_curve=equity_curve,
  345. entries=entries,
  346. exits=exits,
  347. )
  348. def run_ethbtc_ratio_segment(
  349. *,
  350. eth_candles: list[Candle],
  351. btc_candles: list[Candle],
  352. mode: str,
  353. ratio_length: int,
  354. eth_trend_sma: int,
  355. exit_length: int,
  356. std_multiplier: float,
  357. stop_loss_pct: float,
  358. ) -> SegmentResult:
  359. eth_close = pd.Series([candle.close for candle in eth_candles], dtype=float)
  360. btc_close = pd.Series([candle.close for candle in btc_candles], dtype=float)
  361. ratio = eth_close / btc_close
  362. ratio_high = ratio.shift(1).rolling(ratio_length).max().tolist()
  363. ratio_low = ratio.shift(1).rolling(ratio_length).min().tolist()
  364. ratio_mid = ratio.rolling(exit_length).mean().tolist()
  365. ratio_std = ratio.rolling(exit_length).std(ddof=0).tolist()
  366. trend = eth_close.rolling(eth_trend_sma).mean().tolist()
  367. upper = [m + std_multiplier * s if m == m and s == s else float("nan") for m, s in zip(ratio_mid, ratio_std)]
  368. lower = [m - std_multiplier * s if m == m and s == s else float("nan") for m, s in zip(ratio_mid, ratio_std)]
  369. warmup_bars = max(ratio_length, eth_trend_sma, exit_length)
  370. if len(eth_candles) <= warmup_bars:
  371. return _empty_result(eth_candles, warmup_bars, [])
  372. equity = INITIAL_EQUITY
  373. ending_equity = equity
  374. peak_equity = equity
  375. max_drawdown = 0.0
  376. wins = 0
  377. trades: list[dict[str, object]] = []
  378. entries: list[dict[str, object]] = []
  379. exits: list[dict[str, object]] = []
  380. equity_curve: list[dict[str, float | int]] = []
  381. position: dict[str, object] | None = None
  382. pending_entry_side: str | None = None
  383. pending_exit = False
  384. for index in range(warmup_bars, len(eth_candles)):
  385. candle = eth_candles[index]
  386. if pending_exit and position is not None:
  387. equity, won = _close_position(trades=trades, exits=exits, position=position, candle=candle, exit_price=candle.open)
  388. wins += int(won)
  389. position = None
  390. pending_exit = False
  391. if pending_entry_side is not None and position is None and equity > 0.0:
  392. position = {
  393. "side": pending_entry_side,
  394. "entry_time": candle.ts,
  395. "entry_price": candle.open,
  396. "margin_used": equity,
  397. "stop_price": candle.open * (1.0 - stop_loss_pct if pending_entry_side == "long" else 1.0 + stop_loss_pct),
  398. }
  399. entries.append({"ts": candle.ts, "price": candle.open, "side": pending_entry_side})
  400. pending_entry_side = None
  401. current_equity = equity
  402. if position is not None:
  403. stop_hit = (position["side"] == "long" and candle.low <= float(position["stop_price"])) or (
  404. position["side"] == "short" and candle.high >= float(position["stop_price"])
  405. )
  406. if stop_hit:
  407. equity, won = _close_position(
  408. trades=trades,
  409. exits=exits,
  410. position=position,
  411. candle=candle,
  412. exit_price=float(position["stop_price"]),
  413. )
  414. wins += int(won)
  415. current_equity = equity
  416. position = None
  417. if position is not None:
  418. current_equity = mark_to_market(
  419. side=str(position["side"]),
  420. margin_used=float(position["margin_used"]),
  421. entry_price=float(position["entry_price"]),
  422. mark_price=candle.close,
  423. leverage=LEVERAGE,
  424. )
  425. peak_equity = max(peak_equity, current_equity)
  426. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  427. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  428. ending_equity = current_equity
  429. if index == len(eth_candles) - 1 or equity <= 0.0:
  430. continue
  431. current_ratio = float(ratio.iloc[index])
  432. values = (ratio_high[index], ratio_low[index], ratio_mid[index], trend[index])
  433. if any(value != value for value in values):
  434. continue
  435. if position is not None:
  436. if (position["side"] == "long" and current_ratio < float(ratio_mid[index])) or (
  437. position["side"] == "short" and current_ratio > float(ratio_mid[index])
  438. ):
  439. pending_exit = True
  440. continue
  441. if mode == "breakout":
  442. if candle.close > float(trend[index]) and current_ratio > float(ratio_high[index]):
  443. pending_entry_side = "long"
  444. elif candle.close < float(trend[index]) and current_ratio < float(ratio_low[index]):
  445. pending_entry_side = "short"
  446. elif upper[index] == upper[index] and lower[index] == lower[index]:
  447. if current_ratio <= float(lower[index]):
  448. pending_entry_side = "long"
  449. elif current_ratio >= float(upper[index]):
  450. pending_entry_side = "short"
  451. trade_count = len(trades)
  452. return SegmentResult(
  453. trade_count=trade_count,
  454. total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
  455. win_rate=wins / trade_count if trade_count else 0.0,
  456. max_drawdown=max_drawdown,
  457. trades=trades,
  458. open_position=position,
  459. candles=eth_candles[warmup_bars:],
  460. equity_curve=equity_curve,
  461. entries=entries,
  462. exits=exits,
  463. )
  464. def build_strategies() -> list[Strategy]:
  465. strategies: list[Strategy] = []
  466. for entry in (20, 48, 96):
  467. for exit_window in (10, 24, 48):
  468. for stop in (0.008, 0.012):
  469. name = f"donchian-e{entry}-x{exit_window}-sl{stop}"
  470. config = DonchianConfig(entry_window=entry, exit_window=exit_window, stop_loss_pct=stop)
  471. strategies.append(
  472. Strategy(
  473. "donchian",
  474. name,
  475. max(entry, exit_window),
  476. False,
  477. lambda candles, config=config: run_donchian_segment(
  478. candles=candles,
  479. leverage=LEVERAGE,
  480. warmup_bars=max(config.entry_window, config.exit_window),
  481. config=config,
  482. ),
  483. )
  484. )
  485. for lookback in (16, 32, 64):
  486. for take, stop in ((0.008, 0.006), (0.012, 0.008), (0.018, 0.012)):
  487. strategies.append(
  488. Strategy(
  489. "momentum_breakout",
  490. f"range-momo-l{lookback}-tp{take}-sl{stop}",
  491. lookback,
  492. False,
  493. lambda candles, lookback=lookback, take=take, stop=stop: explore.run_range_momentum_segment(
  494. candles=candles,
  495. leverage=LEVERAGE,
  496. warmup_bars=lookback,
  497. lookback=lookback,
  498. take_profit_pct=take,
  499. stop_loss_pct=stop,
  500. ),
  501. )
  502. )
  503. for window in (48, 96, 192):
  504. for entry_z in (1.5, 2.0, 2.5):
  505. for stop in (0.006, 0.01):
  506. strategies.append(
  507. Strategy(
  508. "vwap_reversion",
  509. f"vwap-revert-w{window}-z{entry_z}-sl{stop}",
  510. window * 2,
  511. False,
  512. lambda candles, window=window, entry_z=entry_z, stop=stop: explore.run_vwap_reversion_segment(
  513. candles=candles,
  514. leverage=LEVERAGE,
  515. warmup_bars=window * 2,
  516. window=window,
  517. entry_z=entry_z,
  518. exit_z=0.2,
  519. stop_loss_pct=stop,
  520. ),
  521. )
  522. )
  523. for trend in (240, 480):
  524. for length in (48, 96):
  525. for stop in (0.008, 0.012):
  526. strategies.append(
  527. Strategy(
  528. "bb_band_pullback",
  529. f"bb-pullback-t{trend}-l{length}-sl{stop}",
  530. max(trend, length),
  531. False,
  532. lambda candles, trend=trend, length=length, stop=stop: run_bb_pullback_segment(
  533. candles=candles,
  534. trend_sma=trend,
  535. band_length=length,
  536. std_multiplier=2.0,
  537. stop_loss_pct=stop,
  538. ),
  539. )
  540. )
  541. for length in (48, 96):
  542. for bandwidth_lookback in (480, 960):
  543. for quantile in (0.15, 0.25):
  544. for stop in (0.008, 0.012):
  545. strategies.append(
  546. Strategy(
  547. "bb_squeeze_breakout",
  548. f"bb-squeeze-l{length}-bw{bandwidth_lookback}-q{quantile}-sl{stop}",
  549. max(length, bandwidth_lookback),
  550. False,
  551. lambda candles, length=length, bandwidth_lookback=bandwidth_lookback, quantile=quantile, stop=stop: run_bb_squeeze_breakout_segment(
  552. candles=candles,
  553. band_length=length,
  554. std_multiplier=2.0,
  555. bandwidth_lookback=bandwidth_lookback,
  556. bandwidth_quantile=quantile,
  557. stop_loss_pct=stop,
  558. ),
  559. )
  560. )
  561. for mode in ("breakout", "mean_reversion"):
  562. for ratio_length in (96, 240):
  563. for trend in (240, 480):
  564. for exit_length in (48, 96):
  565. for stop in (0.008, 0.012):
  566. strategies.append(
  567. Strategy(
  568. f"ethbtc_relative_strength_{mode}",
  569. f"ethbtc-ratio-{mode}-r{ratio_length}-t{trend}-x{exit_length}-sl{stop}",
  570. max(ratio_length, trend, exit_length),
  571. True,
  572. lambda eth, btc, mode=mode, ratio_length=ratio_length, trend=trend, exit_length=exit_length, stop=stop: run_ethbtc_ratio_segment(
  573. eth_candles=eth,
  574. btc_candles=btc,
  575. mode=mode,
  576. ratio_length=ratio_length,
  577. eth_trend_sma=trend,
  578. exit_length=exit_length,
  579. std_multiplier=2.0,
  580. stop_loss_pct=stop,
  581. ),
  582. )
  583. )
  584. return strategies
  585. def cost_equity_frame(result: SegmentResult, cost: float) -> pd.DataFrame:
  586. rows = [{"ts": pd.to_datetime(result.equity_curve[0]["ts"], unit="ms", utc=True), "equity": INITIAL_EQUITY}]
  587. equity = INITIAL_EQUITY
  588. for trade in result.trades:
  589. equity *= 1.0 + float(trade["return_pct"]) / 100.0 - cost * float(trade.get("cost_weight", 1.0))
  590. rows.append({"ts": pd.to_datetime(str(trade["exit_time"]), utc=True), "equity": equity})
  591. return pd.DataFrame(rows)
  592. def max_drawdown(values: list[float]) -> float:
  593. peak = values[0]
  594. dd = 0.0
  595. for value in values:
  596. peak = max(peak, value)
  597. dd = max(dd, (peak - value) / peak if peak else 0.0)
  598. return dd
  599. def equity_metrics(frame: pd.DataFrame, first_ts: int, last_ts: int) -> dict[str, float]:
  600. years = (last_ts - first_ts) / 86_400_000 / 365
  601. total_return = float(frame["equity"].iloc[-1] / frame["equity"].iloc[0] - 1.0)
  602. annualized = (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 and years > 0.0 else 0.0
  603. dd = max_drawdown([float(value) for value in frame["equity"]])
  604. return {
  605. "net_total_return": total_return,
  606. "net_annualized_return": annualized,
  607. "net_max_drawdown": dd,
  608. "net_calmar": annualized / dd if dd else 0.0,
  609. }
  610. def horizon_rows(frame: pd.DataFrame, last_ts: int, horizons: tuple[tuple[str, pd.DateOffset], ...]) -> list[dict[str, object]]:
  611. rows: list[dict[str, object]] = []
  612. end_time = pd.to_datetime(last_ts, unit="ms", utc=True)
  613. for label, offset in horizons:
  614. cutoff = end_time - offset
  615. before = frame[frame["ts"] <= cutoff]
  616. if len(before):
  617. start_equity = float(before["equity"].iloc[-1])
  618. horizon_frame = pd.concat(
  619. [pd.DataFrame([{"ts": cutoff, "equity": start_equity}]), frame[frame["ts"] > cutoff][["ts", "equity"]]],
  620. ignore_index=True,
  621. )
  622. start_time = cutoff
  623. else:
  624. horizon_frame = frame[["ts", "equity"]].copy()
  625. start_time = pd.Timestamp(horizon_frame["ts"].iloc[0])
  626. rows.append(
  627. {
  628. "horizon": label,
  629. "horizon_start": start_time.strftime("%Y-%m-%d %H:%M"),
  630. "horizon_end": end_time.strftime("%Y-%m-%d %H:%M"),
  631. **equity_metrics(horizon_frame, int(start_time.timestamp() * 1000), last_ts),
  632. }
  633. )
  634. return rows
  635. def worst_month(frame: pd.DataFrame) -> tuple[str, float]:
  636. monthly = frame.set_index("ts")["equity"].resample("ME").last().ffill().pct_change().dropna()
  637. if not len(monthly):
  638. return "", 0.0
  639. idx = monthly.idxmin()
  640. return idx.strftime("%Y-%m"), float(monthly.loc[idx])
  641. def markdown_table(frame: pd.DataFrame) -> str:
  642. columns = list(frame.columns)
  643. rows = [columns, ["---" for _ in columns]]
  644. for record in frame.to_dict("records"):
  645. rows.append([record[column] for column in columns])
  646. return "\n".join("| " + " | ".join(format_cell(value) for value in row) + " |" for row in rows)
  647. def format_cell(value: object) -> str:
  648. if isinstance(value, float):
  649. return f"{value:.6g}"
  650. return str(value).replace("|", "\\|")
  651. def write_report(
  652. *,
  653. summary: pd.DataFrame,
  654. horizon: pd.DataFrame,
  655. output_files: list[Path],
  656. command: str,
  657. first_ts: int,
  658. last_ts: int,
  659. requested_years: float,
  660. ) -> str:
  661. primary = summary[summary["cost"] == PRIMARY_COST].head(10)
  662. family = (
  663. summary[summary["cost"] == PRIMARY_COST]
  664. .groupby("family", as_index=False)
  665. .agg(
  666. best_calmar=("net_calmar", "max"),
  667. best_annualized=("net_annualized_return", "max"),
  668. best_total=("net_total_return", "max"),
  669. candidates=("name", "count"),
  670. )
  671. .sort_values(["best_calmar", "best_annualized"], ascending=False)
  672. )
  673. horizon_top = (
  674. horizon[horizon["cost"] == PRIMARY_COST]
  675. .sort_values(["horizon", "net_calmar", "net_annualized_return"], ascending=[True, False, False])
  676. .groupby("horizon", observed=True)
  677. .head(3)
  678. )
  679. primary_frame = summary[summary["cost"] == PRIMARY_COST]
  680. family_best = primary_frame.groupby("family", as_index=False).head(1).set_index("family")
  681. next_round_lines: list[str] = []
  682. if "bb_squeeze_breakout" in family_best.index:
  683. row = family_best.loc["bb_squeeze_breakout"]
  684. next_round_lines.append(
  685. f"- Primary: BB squeeze breakout. Best row `{row['name']}` has Calmar {format_cell(row['net_calmar'])}, annualized {format_cell(row['net_annualized_return'])}, trades {row['trades']}."
  686. )
  687. if "ethbtc_relative_strength_breakout" in family_best.index:
  688. row = family_best.loc["ethbtc_relative_strength_breakout"]
  689. next_round_lines.append(
  690. f"- Secondary: ETH/BTC relative-strength breakout. Best row `{row['name']}` is positive but DD-heavy: Calmar {format_cell(row['net_calmar'])}, DD {format_cell(row['net_max_drawdown'])}."
  691. )
  692. if "bb_band_pullback" in family_best.index:
  693. row = family_best.loc["bb_band_pullback"]
  694. next_round_lines.append(
  695. f"- Secondary: BB band pullback. Lower return than squeeze, but smoother DD profile in the best row `{row['name']}`."
  696. )
  697. weak = [
  698. family
  699. for family in ("vwap_reversion", "momentum_breakout", "ethbtc_relative_strength_mean_reversion")
  700. if family in family_best.index and float(family_best.loc[family]["net_total_return"]) <= -0.99
  701. ]
  702. if weak:
  703. next_round_lines.append(f"- Drop for now: {', '.join(weak)} ended near full loss after maker_taker costs.")
  704. if "donchian" in family_best.index:
  705. row = family_best.loc["donchian"]
  706. next_round_lines.append(
  707. f"- Low priority: Donchian best Calmar is only {format_cell(row['net_calmar'])}; keep only as a benchmark."
  708. )
  709. lines = [
  710. "# ETH non-RSI 10y exploration",
  711. "",
  712. f"Run command: `{command}`",
  713. f"Requested years: {requested_years:g}",
  714. f"Actual continuous local history: `{_format_ts(first_ts)}` to `{_format_ts(last_ts)}`.",
  715. "",
  716. "Output files:",
  717. *[f"- `{path}`" for path in output_files],
  718. "",
  719. "Primary sort: maker_taker by net_calmar, then net_annualized_return, then net_total_return.",
  720. "",
  721. "Top 10:",
  722. markdown_table(
  723. primary[
  724. [
  725. "family",
  726. "name",
  727. "trades",
  728. "net_total_return",
  729. "net_annualized_return",
  730. "net_max_drawdown",
  731. "net_calmar",
  732. "worst_month",
  733. "worst_month_return",
  734. ]
  735. ]
  736. ),
  737. "",
  738. "Family leaders:",
  739. markdown_table(family),
  740. "",
  741. "Recent horizon leaders:",
  742. markdown_table(
  743. horizon_top[
  744. [
  745. "horizon",
  746. "family",
  747. "name",
  748. "trades",
  749. "net_total_return",
  750. "net_annualized_return",
  751. "net_max_drawdown",
  752. "net_calmar",
  753. ]
  754. ]
  755. ),
  756. "",
  757. "Next-round directions:",
  758. *next_round_lines,
  759. ]
  760. return "\n".join(lines) + "\n"
  761. def main() -> int:
  762. parser = argparse.ArgumentParser()
  763. parser.add_argument("--bar", default=BAR)
  764. parser.add_argument("--years", type=float, default=YEARS)
  765. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  766. args = parser.parse_args()
  767. eth = _load_candles(ETH_SYMBOL, args.bar)
  768. btc = _load_candles(BTC_SYMBOL, args.bar)
  769. eth, btc = _align_pair(eth, btc)
  770. requested_bars = int(args.years * 365 * 24 * 60 / 15)
  771. eth = eth[-requested_bars:]
  772. btc = btc[-requested_bars:]
  773. strategies = build_strategies()
  774. summary_rows: list[dict[str, object]] = []
  775. horizon_output_rows: list[dict[str, object]] = []
  776. for index, strategy in enumerate(strategies, start=1):
  777. result = strategy.run(eth, btc) if strategy.pair else strategy.run(eth)
  778. if not result.equity_curve:
  779. print(f"skip {index}/{len(strategies)} {strategy.family} {strategy.name}")
  780. continue
  781. for cost_name, cost in COSTS:
  782. frame = cost_equity_frame(result, cost)
  783. metrics = equity_metrics(frame, eth[0].ts, eth[-1].ts)
  784. month, month_return = worst_month(frame)
  785. summary_rows.append(
  786. {
  787. "family": strategy.family,
  788. "cost": cost_name,
  789. "symbol": ETH_SYMBOL,
  790. "signal_symbol": BTC_SYMBOL if strategy.pair else "",
  791. "bar": args.bar,
  792. "name": strategy.name,
  793. "first_candle": _format_ts(eth[0].ts),
  794. "last_candle": _format_ts(eth[-1].ts),
  795. "years": (eth[-1].ts - eth[0].ts) / 86_400_000 / 365,
  796. "trades": result.trade_count,
  797. "gross_total_return": result.total_return,
  798. "gross_max_drawdown_mark_to_market": result.max_drawdown,
  799. "worst_month": month,
  800. "worst_month_return": month_return,
  801. **metrics,
  802. }
  803. )
  804. for row in horizon_rows(frame, eth[-1].ts, HORIZONS):
  805. horizon_output_rows.append(
  806. {
  807. "family": strategy.family,
  808. "cost": cost_name,
  809. "symbol": ETH_SYMBOL,
  810. "signal_symbol": BTC_SYMBOL if strategy.pair else "",
  811. "bar": args.bar,
  812. "name": strategy.name,
  813. "trades": result.trade_count,
  814. **row,
  815. }
  816. )
  817. print(f"done {index}/{len(strategies)} {strategy.family} {strategy.name}")
  818. summary = pd.DataFrame(summary_rows).sort_values(
  819. ["cost", "net_calmar", "net_annualized_return", "net_total_return"],
  820. ascending=[True, False, False, False],
  821. )
  822. primary = summary[summary["cost"] == PRIMARY_COST]
  823. summary = pd.concat([primary, summary[summary["cost"] != PRIMARY_COST]], ignore_index=True)
  824. horizon = pd.DataFrame(horizon_output_rows)
  825. horizon["horizon"] = pd.Categorical(horizon["horizon"], categories=[label for label, _ in HORIZONS], ordered=True)
  826. horizon = horizon.sort_values(["cost", "horizon", "net_calmar", "net_annualized_return"], ascending=[True, True, False, False])
  827. args.output_dir.mkdir(parents=True, exist_ok=True)
  828. summary_path = args.output_dir / "eth-non-rsi-10y-summary.csv"
  829. horizon_path = args.output_dir / "eth-non-rsi-10y-horizon.csv"
  830. top10_path = args.output_dir / "eth-non-rsi-10y-top10.csv"
  831. report_path = args.output_dir / "eth-non-rsi-10y-report.md"
  832. output_files = [summary_path, horizon_path, top10_path, report_path]
  833. summary.to_csv(summary_path, index=False)
  834. horizon.to_csv(horizon_path, index=False)
  835. summary[summary["cost"] == PRIMARY_COST].head(10).to_csv(top10_path, index=False)
  836. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --bar {args.bar} --years {args.years}"
  837. report_path.write_text(
  838. write_report(
  839. summary=summary,
  840. horizon=horizon,
  841. output_files=output_files,
  842. command=command,
  843. first_ts=eth[0].ts,
  844. last_ts=eth[-1].ts,
  845. requested_years=args.years,
  846. ),
  847. encoding="utf-8",
  848. )
  849. print(summary[summary["cost"] == PRIMARY_COST].head(10).to_string(index=False))
  850. return 0
  851. if __name__ == "__main__":
  852. raise SystemExit(main())