search_eth_regime_price_twap_variants.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710
  1. from __future__ import annotations
  2. import argparse
  3. import sys
  4. from dataclasses import dataclass
  5. from itertools import product
  6. from pathlib import Path
  7. from typing import Iterable
  8. import pandas as pd
  9. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  10. from scripts import explore_ultrashort as explore
  11. ETH_SYMBOL = "ETH-USDT-SWAP"
  12. BTC_SYMBOL = "BTC-USDT-SWAP"
  13. PRIMARY_COST = "maker_taker"
  14. BTC_TREND_SMA = 480
  15. BTC_MOMENTUM_LOOKBACK = 240
  16. BTC_MIN_MOMENTUM = 0.0
  17. MAX_HOLD_BARS = 48
  18. COST_SCENARIOS = (
  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. ENTRY_OFFSET_SETS = (
  30. (0.002, 0.005, 0.008),
  31. (0.003, 0.006, 0.009),
  32. (0.004, 0.007, 0.010),
  33. )
  34. @dataclass(frozen=True)
  35. class Strategy:
  36. family: str
  37. candidate: explore.Candidate | explore.PairCandidate
  38. pair: bool
  39. spec: dict[str, object]
  40. def close_partial_trade(
  41. *,
  42. trades: list[dict[str, object]],
  43. exits: list[dict[str, object]],
  44. position: dict[str, object],
  45. account_equity: float,
  46. candle: explore.Candle,
  47. exit_price: float,
  48. leverage: int,
  49. ) -> tuple[float, bool]:
  50. margin_used = float(position["margin_used"])
  51. exit_equity = explore.trade_equity(
  52. side="long",
  53. margin_used=margin_used,
  54. entry_price=float(position["entry_price"]),
  55. exit_price=exit_price,
  56. leverage=leverage,
  57. )
  58. pnl = exit_equity - margin_used
  59. trades.append(
  60. {
  61. "side": "Long",
  62. "entry_time": explore._format_ts(int(position["entry_time"])),
  63. "exit_time": explore._format_ts(candle.ts),
  64. "entry_price": round(float(position["entry_price"]), 4),
  65. "exit_price": round(exit_price, 4),
  66. "pnl": round(pnl, 4),
  67. "return_pct": round(pnl / account_equity * 100, 4),
  68. "cost_weight": round(margin_used / account_equity, 8),
  69. }
  70. )
  71. exits.append({"ts": candle.ts, "price": exit_price, "side": "long"})
  72. return account_equity + pnl, pnl > 0.0
  73. def run_eth_btc_price_twap_filter_segment(
  74. *,
  75. eth_candles: list[explore.Candle],
  76. btc_candles: list[explore.Candle],
  77. leverage: int,
  78. warmup_bars: int,
  79. eth_trend_sma: int,
  80. eth_rsi_threshold: float,
  81. eth_exit_rsi: float,
  82. stop_loss_pct: float,
  83. max_hold_bars: int,
  84. entry_offsets: tuple[float, ...],
  85. entry_valid_bars: int,
  86. fill_buffer: float,
  87. btc_trend_sma: int,
  88. btc_momentum_lookback: int,
  89. btc_min_momentum: float,
  90. ) -> explore.SegmentResult:
  91. eth_closes = pd.Series([candle.close for candle in eth_candles], dtype=float)
  92. btc_closes = pd.Series([candle.close for candle in btc_candles], dtype=float)
  93. eth_trend = eth_closes.rolling(eth_trend_sma).mean().tolist()
  94. eth_rsi = explore._compute_rsi(eth_closes, 2)
  95. btc_trend = btc_closes.rolling(btc_trend_sma).mean().tolist()
  96. equity = explore.INITIAL_EQUITY
  97. ending_equity = equity
  98. peak_equity = equity
  99. max_drawdown = 0.0
  100. wins = 0
  101. trades: list[dict[str, object]] = []
  102. entries: list[dict[str, object]] = []
  103. exits: list[dict[str, object]] = []
  104. equity_curve: list[dict[str, float | int]] = []
  105. position: dict[str, object] | None = None
  106. pending_limits: list[dict[str, float | int]] = []
  107. pending_exit = False
  108. for index in range(warmup_bars, len(eth_candles)):
  109. candle = eth_candles[index]
  110. if pending_exit and position is not None:
  111. equity, won = close_partial_trade(
  112. trades=trades,
  113. exits=exits,
  114. position=position,
  115. account_equity=equity,
  116. candle=candle,
  117. exit_price=candle.open,
  118. leverage=leverage,
  119. )
  120. wins += 1 if won else 0
  121. position = None
  122. pending_exit = False
  123. pending_limits = []
  124. active_limits: list[dict[str, float | int]] = []
  125. for limit in pending_limits:
  126. if index > int(limit["expires_index"]):
  127. continue
  128. limit_price = float(limit["price"])
  129. if candle.low <= limit_price * (1.0 - fill_buffer) and equity > 0.0:
  130. slice_margin = equity / len(entry_offsets)
  131. if position is None:
  132. position = {
  133. "side": "long",
  134. "entry_time": candle.ts,
  135. "entry_price": limit_price,
  136. "entry_index": index,
  137. "margin_used": slice_margin,
  138. "stop_price": limit_price * (1.0 - stop_loss_pct),
  139. }
  140. else:
  141. old_margin = float(position["margin_used"])
  142. new_margin = old_margin + slice_margin
  143. entry_price = (float(position["entry_price"]) * old_margin + limit_price * slice_margin) / new_margin
  144. position["entry_price"] = entry_price
  145. position["margin_used"] = new_margin
  146. position["stop_price"] = entry_price * (1.0 - stop_loss_pct)
  147. entries.append({"ts": candle.ts, "price": limit_price, "side": "long"})
  148. else:
  149. active_limits.append(limit)
  150. pending_limits = active_limits
  151. current_equity = equity
  152. if position is not None and candle.low <= float(position["stop_price"]):
  153. equity, won = close_partial_trade(
  154. trades=trades,
  155. exits=exits,
  156. position=position,
  157. account_equity=equity,
  158. candle=candle,
  159. exit_price=float(position["stop_price"]),
  160. leverage=leverage,
  161. )
  162. wins += 1 if won else 0
  163. current_equity = equity
  164. position = None
  165. pending_limits = []
  166. if position is not None:
  167. position_equity = explore.mark_to_market(
  168. side="long",
  169. margin_used=float(position["margin_used"]),
  170. entry_price=float(position["entry_price"]),
  171. mark_price=candle.close,
  172. leverage=leverage,
  173. )
  174. current_equity = equity - float(position["margin_used"]) + position_equity
  175. peak_equity = max(peak_equity, current_equity)
  176. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  177. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  178. ending_equity = current_equity
  179. if index == len(eth_candles) - 1 or equity <= 0.0:
  180. continue
  181. current_eth_trend = eth_trend[index]
  182. current_eth_rsi = eth_rsi[index]
  183. current_btc_trend = btc_trend[index]
  184. if current_eth_trend != current_eth_trend or current_eth_rsi != current_eth_rsi or current_btc_trend != current_btc_trend:
  185. continue
  186. if position is not None:
  187. held_bars = index - int(position["entry_index"])
  188. if current_eth_rsi >= eth_exit_rsi or held_bars >= max_hold_bars:
  189. pending_exit = True
  190. pending_limits = []
  191. continue
  192. btc_momentum = btc_candles[index].close / btc_candles[index - btc_momentum_lookback].close - 1.0
  193. btc_risk_on = btc_candles[index].close > float(current_btc_trend) and btc_momentum >= btc_min_momentum
  194. eth_pullback = candle.close > float(current_eth_trend) and current_eth_rsi <= eth_rsi_threshold
  195. if not pending_limits and btc_risk_on and eth_pullback:
  196. pending_limits = [
  197. {
  198. "price": candle.close * (1.0 - offset),
  199. "expires_index": index + entry_valid_bars,
  200. }
  201. for offset in entry_offsets
  202. ]
  203. trade_count = len(trades)
  204. return explore.SegmentResult(
  205. trade_count=trade_count,
  206. total_return=(ending_equity - explore.INITIAL_EQUITY) / explore.INITIAL_EQUITY,
  207. win_rate=wins / trade_count if trade_count else 0.0,
  208. max_drawdown=max_drawdown,
  209. trades=trades,
  210. open_position=position,
  211. candles=eth_candles[warmup_bars:],
  212. equity_curve=equity_curve,
  213. entries=entries,
  214. exits=exits,
  215. )
  216. def build_eth_btc_price_twap_filter_candidate(
  217. *,
  218. eth_trend_sma: int,
  219. eth_rsi_threshold: float,
  220. eth_exit_rsi: float,
  221. stop_loss_pct: float,
  222. entry_offsets: tuple[float, ...],
  223. entry_valid_bars: int,
  224. fill_buffer: float,
  225. ) -> explore.PairCandidate:
  226. offset_label = "-".join(f"{offset:.4f}" for offset in entry_offsets)
  227. buffer_label = f"-fb{fill_buffer:.4f}" if fill_buffer else ""
  228. return explore.PairCandidate(
  229. f"eth-btc-price-twap-o{offset_label}-v{entry_valid_bars}{buffer_label}-et{eth_trend_sma}-l{eth_rsi_threshold}-x{eth_exit_rsi}-sl{stop_loss_pct}-mh{MAX_HOLD_BARS}-bt{BTC_TREND_SMA}-bm{BTC_MOMENTUM_LOOKBACK}-br{BTC_MIN_MOMENTUM}",
  230. max(eth_trend_sma, BTC_TREND_SMA, BTC_MOMENTUM_LOOKBACK, 3),
  231. lambda eth_candles, btc_candles, leverage, warmup_bars, eth_trend_sma=eth_trend_sma, eth_rsi_threshold=eth_rsi_threshold, eth_exit_rsi=eth_exit_rsi, stop_loss_pct=stop_loss_pct, entry_offsets=entry_offsets, entry_valid_bars=entry_valid_bars, fill_buffer=fill_buffer: run_eth_btc_price_twap_filter_segment(
  232. eth_candles=eth_candles,
  233. btc_candles=btc_candles,
  234. leverage=leverage,
  235. warmup_bars=warmup_bars,
  236. eth_trend_sma=eth_trend_sma,
  237. eth_rsi_threshold=eth_rsi_threshold,
  238. eth_exit_rsi=eth_exit_rsi,
  239. stop_loss_pct=stop_loss_pct,
  240. max_hold_bars=MAX_HOLD_BARS,
  241. entry_offsets=entry_offsets,
  242. entry_valid_bars=entry_valid_bars,
  243. fill_buffer=fill_buffer,
  244. btc_trend_sma=BTC_TREND_SMA,
  245. btc_momentum_lookback=BTC_MOMENTUM_LOOKBACK,
  246. btc_min_momentum=BTC_MIN_MOMENTUM,
  247. ),
  248. )
  249. def candidate_specs() -> Iterable[dict[str, object]]:
  250. for trend, rsi, exit_rsi, stop, offsets, valid, fill in product(
  251. (50, 80, 160),
  252. (3.0, 5.0),
  253. (50.0, 55.0),
  254. (0.008, 0.012),
  255. ENTRY_OFFSET_SETS,
  256. (1, 2, 3),
  257. (0.0, 0.0002),
  258. ):
  259. yield {
  260. "eth_trend_sma": trend,
  261. "eth_rsi_threshold": rsi,
  262. "eth_exit_rsi": exit_rsi,
  263. "stop_loss_pct": stop,
  264. "entry_offsets": offsets,
  265. "entry_valid_bars": valid,
  266. "fill_buffer": fill,
  267. "btc_trend_sma": BTC_TREND_SMA,
  268. "btc_momentum_lookback": BTC_MOMENTUM_LOOKBACK,
  269. "btc_min_momentum": BTC_MIN_MOMENTUM,
  270. "max_hold_bars": MAX_HOLD_BARS,
  271. }
  272. def build_strategies(max_candidates: int | None) -> list[Strategy]:
  273. specs = list(candidate_specs())
  274. if max_candidates is not None:
  275. specs = specs[:max_candidates]
  276. strategies = [
  277. Strategy(
  278. "regime_price_twap",
  279. build_eth_btc_price_twap_filter_candidate(
  280. eth_trend_sma=int(spec["eth_trend_sma"]),
  281. eth_rsi_threshold=float(spec["eth_rsi_threshold"]),
  282. eth_exit_rsi=float(spec["eth_exit_rsi"]),
  283. stop_loss_pct=float(spec["stop_loss_pct"]),
  284. entry_offsets=tuple(float(value) for value in spec["entry_offsets"]),
  285. entry_valid_bars=int(spec["entry_valid_bars"]),
  286. fill_buffer=float(spec["fill_buffer"]),
  287. ),
  288. True,
  289. spec,
  290. )
  291. for spec in specs
  292. ]
  293. baselines = [
  294. Strategy(
  295. "baseline_price_twap_mid",
  296. explore.build_rsi2_long_guarded_price_twap_candidate(50, 3.0, 55.0, 0.008, MAX_HOLD_BARS, (0.001, 0.003, 0.005), 2),
  297. False,
  298. {
  299. "eth_trend_sma": 50,
  300. "eth_rsi_threshold": 3.0,
  301. "eth_exit_rsi": 55.0,
  302. "stop_loss_pct": 0.008,
  303. "entry_offsets": "0.0010-0.0030-0.0050",
  304. "entry_valid_bars": 2,
  305. "fill_buffer": 0.0,
  306. "btc_trend_sma": "",
  307. "btc_momentum_lookback": "",
  308. "btc_min_momentum": "",
  309. "max_hold_bars": MAX_HOLD_BARS,
  310. },
  311. ),
  312. Strategy(
  313. "baseline_price_twap_deep",
  314. explore.build_rsi2_long_guarded_price_twap_candidate(50, 3.0, 55.0, 0.008, MAX_HOLD_BARS, (0.002, 0.005, 0.008), 3),
  315. False,
  316. {
  317. "eth_trend_sma": 50,
  318. "eth_rsi_threshold": 3.0,
  319. "eth_exit_rsi": 55.0,
  320. "stop_loss_pct": 0.008,
  321. "entry_offsets": "0.0020-0.0050-0.0080",
  322. "entry_valid_bars": 3,
  323. "fill_buffer": 0.0,
  324. "btc_trend_sma": "",
  325. "btc_momentum_lookback": "",
  326. "btc_min_momentum": "",
  327. "max_hold_bars": MAX_HOLD_BARS,
  328. },
  329. ),
  330. Strategy(
  331. "baseline_eth_btc_rsi_filter",
  332. explore.build_eth_btc_rsi_filter_candidate(50, 3.0, 55.0, BTC_TREND_SMA, BTC_MOMENTUM_LOOKBACK, BTC_MIN_MOMENTUM),
  333. True,
  334. {
  335. "eth_trend_sma": 50,
  336. "eth_rsi_threshold": 3.0,
  337. "eth_exit_rsi": 55.0,
  338. "stop_loss_pct": "",
  339. "entry_offsets": "",
  340. "entry_valid_bars": "",
  341. "fill_buffer": "",
  342. "btc_trend_sma": BTC_TREND_SMA,
  343. "btc_momentum_lookback": BTC_MOMENTUM_LOOKBACK,
  344. "btc_min_momentum": BTC_MIN_MOMENTUM,
  345. "max_hold_bars": "",
  346. },
  347. ),
  348. ]
  349. return strategies + baselines
  350. def window_rows(strategy: Strategy, eth: list[explore.Candle], btc: list[explore.Candle], window_size: int) -> list[dict[str, object]]:
  351. if strategy.pair:
  352. return explore.evaluate_pair_candidate_window_rows(
  353. candidate=strategy.candidate,
  354. eth_candles=eth,
  355. btc_candles=btc,
  356. window_size=window_size,
  357. leverage=explore.LEVERAGE,
  358. )
  359. return explore.evaluate_candidate_window_rows(
  360. candidate=strategy.candidate,
  361. candles=eth,
  362. window_size=window_size,
  363. leverage=explore.LEVERAGE,
  364. )
  365. def full_result(strategy: Strategy, eth: list[explore.Candle], btc: list[explore.Candle]) -> explore.SegmentResult:
  366. if strategy.pair:
  367. return strategy.candidate.run(
  368. eth_candles=eth,
  369. btc_candles=btc,
  370. leverage=explore.LEVERAGE,
  371. warmup_bars=strategy.candidate.warmup_bars,
  372. )
  373. return strategy.candidate.run(
  374. candles=eth,
  375. leverage=explore.LEVERAGE,
  376. warmup_bars=strategy.candidate.warmup_bars,
  377. )
  378. def append_cost_rows(
  379. *,
  380. strategy: Strategy,
  381. bar: str,
  382. eth: list[explore.Candle],
  383. rows: list[dict[str, object]],
  384. result: explore.SegmentResult,
  385. summary_rows: list[dict[str, object]],
  386. total_rows: list[dict[str, object]],
  387. horizon_rows: list[dict[str, object]],
  388. ) -> None:
  389. spec = strategy.spec.copy()
  390. if isinstance(spec.get("entry_offsets"), tuple):
  391. spec["entry_offsets"] = "-".join(f"{value:.4f}" for value in tuple(spec["entry_offsets"]))
  392. for cost_name, cost_value in COST_SCENARIOS:
  393. summary = explore.add_cost_metrics(
  394. pd.DataFrame([explore.summarize_window_rows(rows, strategy.candidate.name)]),
  395. cost_value,
  396. ).iloc[0].to_dict()
  397. summary_rows.append(
  398. {
  399. "family": strategy.family,
  400. "cost": cost_name,
  401. "symbol": ETH_SYMBOL,
  402. "signal_symbol": BTC_SYMBOL if strategy.pair else "",
  403. "bar": bar,
  404. "actual_bars": len(eth),
  405. "first_candle": explore._format_ts(eth[0].ts),
  406. "last_candle": explore._format_ts(eth[-1].ts),
  407. **spec,
  408. **summary,
  409. }
  410. )
  411. net_equity = explore.cost_adjusted_trade_equity_frame(result, cost_value)
  412. metrics = explore.annualized_metrics_from_equity(net_equity, eth[0].ts, eth[-1].ts)
  413. years_actual = (eth[-1].ts - eth[0].ts) / 86_400_000 / 365
  414. gross_annualized = (1.0 + result.total_return) ** (1.0 / years_actual) - 1.0 if result.total_return > -1.0 else 0.0
  415. total_rows.append(
  416. {
  417. "family": strategy.family,
  418. "cost": cost_name,
  419. "symbol": ETH_SYMBOL,
  420. "signal_symbol": BTC_SYMBOL if strategy.pair else "",
  421. "bar": bar,
  422. "name": strategy.candidate.name,
  423. "first_candle": explore._format_ts(eth[0].ts),
  424. "last_candle": explore._format_ts(eth[-1].ts),
  425. "years": years_actual,
  426. "trades": result.trade_count,
  427. "gross_total_return": result.total_return,
  428. "gross_annualized_return": gross_annualized,
  429. "gross_max_drawdown_mark_to_market": result.max_drawdown,
  430. **spec,
  431. **metrics,
  432. }
  433. )
  434. horizon = explore.recent_horizon_metrics_from_equity(net_equity, eth[-1].ts, HORIZONS)
  435. for horizon_row in horizon.to_dict("records"):
  436. horizon_rows.append(
  437. {
  438. "family": strategy.family,
  439. "cost": cost_name,
  440. "symbol": ETH_SYMBOL,
  441. "signal_symbol": BTC_SYMBOL if strategy.pair else "",
  442. "bar": bar,
  443. "name": strategy.candidate.name,
  444. "trades": result.trade_count,
  445. **spec,
  446. **horizon_row,
  447. }
  448. )
  449. def markdown_table(frame: pd.DataFrame) -> str:
  450. columns = list(frame.columns)
  451. rows = [columns, ["---" for _ in columns]]
  452. for record in frame.to_dict("records"):
  453. rows.append([record[column] for column in columns])
  454. return "\n".join("| " + " | ".join(format_markdown_cell(value) for value in row) + " |" for row in rows)
  455. def format_markdown_cell(value: object) -> str:
  456. if isinstance(value, float):
  457. return f"{value:.6g}"
  458. return str(value).replace("|", "\\|")
  459. def markdown_report(
  460. *,
  461. summary: pd.DataFrame,
  462. total: pd.DataFrame,
  463. horizon: pd.DataFrame,
  464. output_files: list[Path],
  465. command: str,
  466. ) -> str:
  467. primary_summary = summary[summary["cost"] == PRIMARY_COST].copy()
  468. primary_total = total[total["cost"] == PRIMARY_COST].copy()
  469. top = primary_summary[primary_summary["family"] == "regime_price_twap"].head(10)
  470. baseline_summary = primary_summary[primary_summary["family"] != "regime_price_twap"].sort_values(
  471. ["net_ci95_low", "net_avg_return"], ascending=False
  472. )
  473. baseline_total = primary_total[primary_total["family"] != "regime_price_twap"].sort_values(
  474. ["net_calmar", "net_annualized_return"], ascending=False
  475. )
  476. best = top.iloc[0] if len(top) else pd.Series(dtype=object)
  477. baseline_best_summary = baseline_summary.iloc[0] if len(baseline_summary) else pd.Series(dtype=object)
  478. baseline_best_total = baseline_total.iloc[0] if len(baseline_total) else pd.Series(dtype=object)
  479. total_top = primary_total[primary_total["family"] == "regime_price_twap"].sort_values(
  480. ["net_calmar", "net_annualized_return"], ascending=False
  481. ).head(10)
  482. top_names = set(top["name"])
  483. horizon_top = horizon[
  484. (horizon["cost"] == PRIMARY_COST)
  485. & (horizon["name"].isin(top_names))
  486. ].sort_values(["name", "horizon"])
  487. horizon_leaders = (
  488. horizon[horizon["cost"] == PRIMARY_COST]
  489. .sort_values(["horizon", "net_annualized_return"], ascending=[True, False])
  490. .groupby("horizon", observed=True)
  491. .head(3)
  492. )
  493. better_window = bool(len(top) and len(baseline_summary) and float(best["net_ci95_low"]) > float(baseline_best_summary["net_ci95_low"]))
  494. better_total = bool(len(total_top) and len(baseline_total) and float(total_top.iloc[0]["net_calmar"]) > float(baseline_best_total["net_calmar"]))
  495. lines = [
  496. "# ETH regime price-TWAP variants",
  497. "",
  498. f"Run command: `{command}`",
  499. "",
  500. "Output files:",
  501. *[f"- `{path}`" for path in output_files],
  502. "",
  503. "Primary sort: maker_taker cost, by net_ci95_low then net_avg_return.",
  504. "",
  505. "Tested entry rule: ETH price-TWAP entry requires BTC close above SMA480 and BTC momentum240 >= 0. Exit remains ETH RSI/hold/stop based.",
  506. "",
  507. "Top 10 maker_taker candidates:",
  508. markdown_table(top[
  509. [
  510. "family",
  511. "name",
  512. "net_avg_return",
  513. "net_ci95_low",
  514. "positive_window_rate",
  515. "trades",
  516. "avg_trades_per_window",
  517. "max_drawdown",
  518. ]
  519. ]),
  520. "",
  521. "Top 10 by full-period maker_taker net Calmar:",
  522. markdown_table(total_top[
  523. [
  524. "family",
  525. "name",
  526. "trades",
  527. "net_total_return",
  528. "net_annualized_return",
  529. "net_max_drawdown",
  530. "net_calmar",
  531. ]
  532. ]),
  533. "",
  534. "Baselines:",
  535. markdown_table(baseline_summary[
  536. [
  537. "family",
  538. "name",
  539. "net_avg_return",
  540. "net_ci95_low",
  541. "positive_window_rate",
  542. "trades",
  543. "avg_trades_per_window",
  544. "max_drawdown",
  545. ]
  546. ]),
  547. "",
  548. "Recent horizons for top 10:",
  549. markdown_table(horizon_top[
  550. [
  551. "name",
  552. "horizon",
  553. "horizon_start",
  554. "horizon_end",
  555. "net_total_return",
  556. "net_annualized_return",
  557. "net_max_drawdown",
  558. "net_calmar",
  559. ]
  560. ]),
  561. "",
  562. "Recent horizon leaders:",
  563. markdown_table(horizon_leaders[
  564. [
  565. "horizon",
  566. "family",
  567. "name",
  568. "net_total_return",
  569. "net_annualized_return",
  570. "net_max_drawdown",
  571. "net_calmar",
  572. ]
  573. ]),
  574. "",
  575. "Comparison:",
  576. f"- Window robustness better than the best standalone baseline: {better_window}.",
  577. f"- Full-period Calmar better than the best standalone baseline: {better_total}.",
  578. ]
  579. if len(top) and len(baseline_summary):
  580. lines.append(
  581. f"- Best regime price-TWAP net_ci95_low={float(best['net_ci95_low']):.6g}; best baseline net_ci95_low={float(baseline_best_summary['net_ci95_low']):.6g} ({baseline_best_summary['family']})."
  582. )
  583. if len(total_top) and len(baseline_total):
  584. lines.append(
  585. f"- Best regime price-TWAP net_calmar={float(total_top.iloc[0]['net_calmar']):.6g}; best baseline net_calmar={float(baseline_best_total['net_calmar']):.6g} ({baseline_best_total['family']})."
  586. )
  587. return "\n".join(lines) + "\n"
  588. def main() -> int:
  589. parser = argparse.ArgumentParser()
  590. parser.add_argument("--bar", default="15m")
  591. parser.add_argument("--years", type=float, default=3.25)
  592. parser.add_argument("--window-size", type=int, default=explore.WINDOW_SIZE)
  593. parser.add_argument("--max-candidates", type=int, default=None)
  594. parser.add_argument("--output-dir", type=Path, default=Path("reports/eth-exploration"))
  595. args = parser.parse_args()
  596. requested_bars = explore.history_bars_for_years(args.bar, args.years)
  597. client = explore.OkxClient()
  598. eth = explore.get_candles_cached(client, ETH_SYMBOL, args.bar, requested_bars)
  599. btc = explore.get_candles_cached(client, BTC_SYMBOL, args.bar, requested_bars)
  600. eth, btc = explore.align_pair_candles(eth, btc)
  601. strategies = build_strategies(args.max_candidates)
  602. summary_rows: list[dict[str, object]] = []
  603. total_rows: list[dict[str, object]] = []
  604. horizon_rows: list[dict[str, object]] = []
  605. for index, strategy in enumerate(strategies, start=1):
  606. rows = window_rows(strategy, eth, btc, args.window_size)
  607. result = full_result(strategy, eth, btc)
  608. append_cost_rows(
  609. strategy=strategy,
  610. bar=args.bar,
  611. eth=eth,
  612. rows=rows,
  613. result=result,
  614. summary_rows=summary_rows,
  615. total_rows=total_rows,
  616. horizon_rows=horizon_rows,
  617. )
  618. print(f"done {index}/{len(strategies)} {strategy.family} {strategy.candidate.name}")
  619. summary = pd.DataFrame(summary_rows).sort_values(
  620. ["cost", "net_ci95_low", "net_avg_return"],
  621. ascending=[True, False, False],
  622. )
  623. primary = summary[summary["cost"] == PRIMARY_COST]
  624. others = summary[summary["cost"] != PRIMARY_COST]
  625. summary = pd.concat([primary, others], ignore_index=True)
  626. total = pd.DataFrame(total_rows).sort_values(
  627. ["cost", "net_calmar", "net_annualized_return"],
  628. ascending=[True, False, False],
  629. )
  630. horizon = pd.DataFrame(horizon_rows)
  631. horizon["horizon"] = pd.Categorical(horizon["horizon"], categories=["3y", "1y", "6m", "3m"], ordered=True)
  632. horizon = horizon.sort_values(["cost", "horizon", "net_annualized_return"], ascending=[True, True, False])
  633. args.output_dir.mkdir(parents=True, exist_ok=True)
  634. summary_path = args.output_dir / "eth-regime-price-twap-summary.csv"
  635. total_path = args.output_dir / "eth-regime-price-twap-total.csv"
  636. horizon_path = args.output_dir / "eth-regime-price-twap-horizon.csv"
  637. top10_path = args.output_dir / "eth-regime-price-twap-top10.csv"
  638. report_path = args.output_dir / "eth-regime-price-twap-report.md"
  639. output_files = [summary_path, total_path, horizon_path, top10_path, report_path]
  640. summary.to_csv(summary_path, index=False)
  641. total.to_csv(total_path, index=False)
  642. horizon.to_csv(horizon_path, index=False)
  643. summary[(summary["cost"] == PRIMARY_COST) & (summary["family"] == "regime_price_twap")].head(10).to_csv(top10_path, index=False)
  644. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --bar {args.bar} --years {args.years} --window-size {args.window_size}"
  645. report_path.write_text(
  646. markdown_report(
  647. summary=summary,
  648. total=total,
  649. horizon=horizon,
  650. output_files=output_files,
  651. command=command,
  652. ),
  653. encoding="utf-8",
  654. )
  655. print(summary[(summary["cost"] == PRIMARY_COST) & (summary["family"] == "regime_price_twap")].head(10).to_string(index=False))
  656. return 0
  657. if __name__ == "__main__":
  658. raise SystemExit(main())