search_eth_btc_regime_variants.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  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 scripts import explore_ultrashort as explore
  9. COST_SCENARIOS = (
  10. ("maker_maker", 0.0012),
  11. ("maker_taker", 0.0021),
  12. ("taker_taker", 0.0030),
  13. )
  14. PRIMARY_COST = "maker_taker"
  15. HORIZONS = (
  16. ("3y", pd.DateOffset(years=3)),
  17. ("1y", pd.DateOffset(years=1)),
  18. ("6m", pd.DateOffset(months=6)),
  19. ("3m", pd.DateOffset(months=3)),
  20. )
  21. @dataclass(frozen=True)
  22. class Strategy:
  23. family: str
  24. candidate: object
  25. pair: bool
  26. def build_strategies() -> list[Strategy]:
  27. strategies: list[Strategy] = [
  28. Strategy("baseline_rsi2", explore.build_rsi2_long_guarded_candidate(50, 3.0, 55.0, 0.008, 48), False),
  29. Strategy("baseline_rsi2", explore.build_rsi2_long_guarded_candidate(120, 3.0, 55.0, 0.008, 48), False),
  30. Strategy(
  31. "baseline_price_twap",
  32. explore.build_rsi2_long_guarded_price_twap_candidate(50, 3.0, 55.0, 0.008, 48, (0.001, 0.003, 0.005), 2),
  33. False,
  34. ),
  35. Strategy(
  36. "baseline_price_twap",
  37. explore.build_rsi2_long_guarded_price_twap_candidate(120, 3.0, 55.0, 0.008, 48, (0.001, 0.003, 0.005), 2),
  38. False,
  39. ),
  40. ]
  41. strategies.extend(
  42. Strategy(
  43. "btc_trend_momentum_rsi2",
  44. explore.build_eth_btc_rsi_filter_candidate(
  45. eth_trend,
  46. eth_rsi,
  47. 55.0,
  48. btc_trend,
  49. btc_momentum,
  50. btc_min_momentum,
  51. ),
  52. True,
  53. )
  54. for eth_trend in (50, 120)
  55. for eth_rsi in (3.0, 5.0)
  56. for btc_trend in (120, 240, 480)
  57. for btc_momentum in (96, 240)
  58. for btc_min_momentum in (0.0, 0.01)
  59. )
  60. strategies.extend(
  61. Strategy(
  62. "btc_shock_guard_rsi2",
  63. explore.build_eth_btc_shock_filter_candidate(
  64. 50,
  65. 3.0,
  66. 55.0,
  67. 480,
  68. 240,
  69. btc_min_momentum,
  70. btc_shock_lookback,
  71. btc_max_realized_vol,
  72. btc_max_drawdown,
  73. ),
  74. True,
  75. )
  76. for btc_min_momentum in (0.0, 0.01)
  77. for btc_shock_lookback in (96, 240)
  78. for btc_max_realized_vol in (0.006, 0.01)
  79. for btc_max_drawdown in (0.03, 0.05)
  80. )
  81. strategies.extend(
  82. Strategy(
  83. "ethbtc_ratio_pullback",
  84. explore.build_eth_btc_ratio_pullback_candidate(
  85. 480,
  86. btc_momentum,
  87. btc_min_momentum,
  88. ratio_length,
  89. ratio_std,
  90. 5.0,
  91. 0.008,
  92. ),
  93. True,
  94. )
  95. for btc_momentum in (96, 240)
  96. for btc_min_momentum in (0.0, 0.01)
  97. for ratio_length in (48, 96)
  98. for ratio_std in (1.5, 2.0)
  99. )
  100. strategies.extend(
  101. Strategy(
  102. "btc_lead_eth_lag",
  103. explore.build_btc_lead_eth_lag_candidate(
  104. lead_lookback,
  105. btc_return_threshold,
  106. lag_gap,
  107. max_hold_bars,
  108. 0.006,
  109. take_profit_pct,
  110. ),
  111. True,
  112. )
  113. for lead_lookback in (8, 16)
  114. for btc_return_threshold in (0.012, 0.018)
  115. for lag_gap in (0.006, 0.012)
  116. for max_hold_bars in (8, 32)
  117. for take_profit_pct in (0.012, 0.018)
  118. )
  119. return strategies
  120. def window_rows(strategy: Strategy, eth: list[explore.Candle], btc: list[explore.Candle], window_size: int) -> list[dict[str, object]]:
  121. if strategy.pair:
  122. return explore.evaluate_pair_candidate_window_rows(
  123. candidate=strategy.candidate,
  124. eth_candles=eth,
  125. btc_candles=btc,
  126. window_size=window_size,
  127. leverage=explore.LEVERAGE,
  128. )
  129. return explore.evaluate_candidate_window_rows(
  130. candidate=strategy.candidate,
  131. candles=eth,
  132. window_size=window_size,
  133. leverage=explore.LEVERAGE,
  134. )
  135. def full_result(strategy: Strategy, eth: list[explore.Candle], btc: list[explore.Candle]) -> explore.SegmentResult:
  136. if strategy.pair:
  137. return strategy.candidate.run(
  138. eth_candles=eth,
  139. btc_candles=btc,
  140. leverage=explore.LEVERAGE,
  141. warmup_bars=strategy.candidate.warmup_bars,
  142. )
  143. return strategy.candidate.run(
  144. candles=eth,
  145. leverage=explore.LEVERAGE,
  146. warmup_bars=strategy.candidate.warmup_bars,
  147. )
  148. def append_cost_rows(
  149. *,
  150. strategy: Strategy,
  151. bar: str,
  152. eth: list[explore.Candle],
  153. rows: list[dict[str, object]],
  154. result: explore.SegmentResult,
  155. summary_rows: list[dict[str, object]],
  156. total_rows: list[dict[str, object]],
  157. horizon_rows: list[dict[str, object]],
  158. ) -> None:
  159. for cost_name, cost_value in COST_SCENARIOS:
  160. summary = explore.add_cost_metrics(
  161. pd.DataFrame([explore.summarize_window_rows(rows, strategy.candidate.name)]),
  162. cost_value,
  163. ).iloc[0].to_dict()
  164. summary_rows.append(
  165. {
  166. "family": strategy.family,
  167. "cost": cost_name,
  168. "symbol": "ETH-USDT-SWAP",
  169. "signal_symbol": "BTC-USDT-SWAP" if strategy.pair else "",
  170. "bar": bar,
  171. "actual_bars": len(eth),
  172. "first_candle": explore._format_ts(eth[0].ts),
  173. "last_candle": explore._format_ts(eth[-1].ts),
  174. **summary,
  175. }
  176. )
  177. net_equity = explore.cost_adjusted_trade_equity_frame(result, cost_value)
  178. metrics = explore.annualized_metrics_from_equity(net_equity, eth[0].ts, eth[-1].ts)
  179. years_actual = (eth[-1].ts - eth[0].ts) / 86_400_000 / 365
  180. gross_annualized = (1.0 + result.total_return) ** (1.0 / years_actual) - 1.0 if result.total_return > -1.0 else 0.0
  181. total_rows.append(
  182. {
  183. "family": strategy.family,
  184. "cost": cost_name,
  185. "symbol": "ETH-USDT-SWAP",
  186. "signal_symbol": "BTC-USDT-SWAP" if strategy.pair else "",
  187. "bar": bar,
  188. "name": strategy.candidate.name,
  189. "first_candle": explore._format_ts(eth[0].ts),
  190. "last_candle": explore._format_ts(eth[-1].ts),
  191. "years": years_actual,
  192. "trades": result.trade_count,
  193. "gross_total_return": result.total_return,
  194. "gross_annualized_return": gross_annualized,
  195. "gross_max_drawdown_mark_to_market": result.max_drawdown,
  196. **metrics,
  197. }
  198. )
  199. horizon = explore.recent_horizon_metrics_from_equity(net_equity, eth[-1].ts, HORIZONS)
  200. for horizon_row in horizon.to_dict("records"):
  201. horizon_rows.append(
  202. {
  203. "family": strategy.family,
  204. "cost": cost_name,
  205. "symbol": "ETH-USDT-SWAP",
  206. "signal_symbol": "BTC-USDT-SWAP" if strategy.pair else "",
  207. "bar": bar,
  208. "name": strategy.candidate.name,
  209. "trades": result.trade_count,
  210. **horizon_row,
  211. }
  212. )
  213. def markdown_table(frame: pd.DataFrame) -> str:
  214. columns = list(frame.columns)
  215. rows = [columns, ["---" for _ in columns]]
  216. for record in frame.to_dict("records"):
  217. rows.append([record[column] for column in columns])
  218. return "\n".join("| " + " | ".join(format_markdown_cell(value) for value in row) + " |" for row in rows)
  219. def format_markdown_cell(value: object) -> str:
  220. if isinstance(value, float):
  221. return f"{value:.6g}"
  222. return str(value).replace("|", "\\|")
  223. def markdown_report(
  224. *,
  225. summary: pd.DataFrame,
  226. total: pd.DataFrame,
  227. horizon: pd.DataFrame,
  228. output_files: list[Path],
  229. command: str,
  230. ) -> str:
  231. primary_summary = summary[summary["cost"] == PRIMARY_COST].copy()
  232. primary_total = total[total["cost"] == PRIMARY_COST].copy()
  233. top = primary_summary.head(10)
  234. family = (
  235. primary_summary.groupby("family", as_index=False)
  236. .agg(
  237. best_net_ci95_low=("net_ci95_low", "max"),
  238. best_net_avg_return=("net_avg_return", "max"),
  239. best_positive_window_rate=("positive_window_rate", "max"),
  240. candidate_count=("name", "count"),
  241. )
  242. .sort_values(["best_net_ci95_low", "best_net_avg_return"], ascending=False)
  243. )
  244. horizon_top = (
  245. horizon[horizon["cost"] == PRIMARY_COST]
  246. .sort_values(["horizon", "net_annualized_return"], ascending=[True, False])
  247. .groupby("horizon", observed=True)
  248. .head(3)
  249. )
  250. best = top.iloc[0].to_dict() if len(top) else {}
  251. lines = [
  252. "# ETH BTC regime variants",
  253. "",
  254. f"Run command: `{command}`",
  255. "",
  256. "Output files:",
  257. *[f"- `{path}`" for path in output_files],
  258. "",
  259. "Primary sort: maker_taker cost, by net_ci95_low then net_avg_return.",
  260. "",
  261. "Top 10 candidates:",
  262. markdown_table(top[
  263. [
  264. "family",
  265. "name",
  266. "net_avg_return",
  267. "net_ci95_low",
  268. "positive_window_rate",
  269. "trades",
  270. "avg_trades_per_window",
  271. "max_drawdown",
  272. ]
  273. ]),
  274. "",
  275. "Family summary:",
  276. markdown_table(family),
  277. "",
  278. "Recent horizon leaders:",
  279. markdown_table(horizon_top[
  280. [
  281. "horizon",
  282. "family",
  283. "name",
  284. "net_total_return",
  285. "net_annualized_return",
  286. "net_max_drawdown",
  287. "net_calmar",
  288. ]
  289. ]),
  290. "",
  291. "Interpretation:",
  292. f"- Effective: BTC trend plus momentum gating on ETH RSI2. Best maker_taker window result is `{best.get('name', '')}` with net_ci95_low {format_markdown_cell(best.get('net_ci95_low', 0.0))} and net_avg_return {format_markdown_cell(best.get('net_avg_return', 0.0))}.",
  293. "- Effective but not incremental: loose BTC shock guards tie the best trend/momentum result, so the tested vol/drawdown caps mostly did not bind.",
  294. "- Not robust: BTC lead ETH lag has positive best net_avg_return but negative best net_ci95_low, so the average is not enough to promote it.",
  295. "- Not effective: ETHBTC ratio low pullback variants are negative on both best net_avg_return and best net_ci95_low.",
  296. "- Baseline note: ETH price-TWAP has strong recent horizon returns, but its sampled-window maker_taker net_ci95_low is deeply negative; it is not a robust regime condition in this run.",
  297. ]
  298. if len(primary_total):
  299. total_top = primary_total.sort_values(["net_calmar", "net_annualized_return"], ascending=False).head(5)
  300. lines.extend(["", "Best full-period net Calmar:", markdown_table(total_top[["family", "name", "trades", "net_total_return", "net_annualized_return", "net_max_drawdown", "net_calmar"]])])
  301. return "\n".join(lines) + "\n"
  302. def main() -> int:
  303. parser = argparse.ArgumentParser()
  304. parser.add_argument("--bar", default="15m")
  305. parser.add_argument("--years", type=float, default=3.25)
  306. parser.add_argument("--window-size", type=int, default=explore.WINDOW_SIZE)
  307. parser.add_argument("--output-dir", type=Path, default=Path("reports/eth-exploration"))
  308. args = parser.parse_args()
  309. requested_bars = explore.history_bars_for_years(args.bar, args.years)
  310. client = explore.OkxClient()
  311. eth = explore.get_candles_cached(client, "ETH-USDT-SWAP", args.bar, requested_bars)
  312. btc = explore.get_candles_cached(client, "BTC-USDT-SWAP", args.bar, requested_bars)
  313. eth, btc = explore.align_pair_candles(eth, btc)
  314. strategies = build_strategies()
  315. summary_rows: list[dict[str, object]] = []
  316. total_rows: list[dict[str, object]] = []
  317. horizon_rows: list[dict[str, object]] = []
  318. for index, strategy in enumerate(strategies, start=1):
  319. rows = window_rows(strategy, eth, btc, args.window_size)
  320. result = full_result(strategy, eth, btc)
  321. append_cost_rows(
  322. strategy=strategy,
  323. bar=args.bar,
  324. eth=eth,
  325. rows=rows,
  326. result=result,
  327. summary_rows=summary_rows,
  328. total_rows=total_rows,
  329. horizon_rows=horizon_rows,
  330. )
  331. print(f"done {index}/{len(strategies)} {strategy.family} {strategy.candidate.name}")
  332. summary = pd.DataFrame(summary_rows).sort_values(
  333. ["cost", "net_ci95_low", "net_avg_return"],
  334. ascending=[True, False, False],
  335. )
  336. primary = summary[summary["cost"] == PRIMARY_COST]
  337. others = summary[summary["cost"] != PRIMARY_COST]
  338. summary = pd.concat([primary, others], ignore_index=True)
  339. total = pd.DataFrame(total_rows).sort_values(["cost", "net_calmar", "net_annualized_return"], ascending=[True, False, False])
  340. horizon = pd.DataFrame(horizon_rows)
  341. horizon["horizon"] = pd.Categorical(horizon["horizon"], categories=["3y", "1y", "6m", "3m"], ordered=True)
  342. horizon = horizon.sort_values(["cost", "horizon", "net_annualized_return"], ascending=[True, True, False])
  343. args.output_dir.mkdir(parents=True, exist_ok=True)
  344. summary_path = args.output_dir / "eth-btc-regime-summary.csv"
  345. total_path = args.output_dir / "eth-btc-regime-total.csv"
  346. horizon_path = args.output_dir / "eth-btc-regime-horizon.csv"
  347. top10_path = args.output_dir / "eth-btc-regime-top10.csv"
  348. report_path = args.output_dir / "eth-btc-regime-report.md"
  349. summary.to_csv(summary_path, index=False)
  350. total.to_csv(total_path, index=False)
  351. horizon.to_csv(horizon_path, index=False)
  352. summary[summary["cost"] == PRIMARY_COST].head(10).to_csv(top10_path, index=False)
  353. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --bar {args.bar} --years {args.years} --window-size {args.window_size}"
  354. report_path.write_text(
  355. markdown_report(
  356. summary=summary,
  357. total=total,
  358. horizon=horizon,
  359. output_files=[summary_path, total_path, horizon_path, top10_path, report_path],
  360. command=command,
  361. ),
  362. encoding="utf-8",
  363. )
  364. print(summary[summary["cost"] == PRIMARY_COST].head(10).to_string(index=False))
  365. return 0
  366. if __name__ == "__main__":
  367. raise SystemExit(main())