search_eth_btc_nextgen_variants.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887
  1. from __future__ import annotations
  2. import argparse
  3. import sys
  4. from dataclasses import dataclass
  5. from itertools import combinations
  6. from pathlib import Path
  7. import pandas as pd
  8. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  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. OUTPUT_DIR = Path("reports/eth-exploration")
  13. PREFIX = "eth-btc-nextgen"
  14. YEARS = 10.0
  15. COSTS = {
  16. "maker_taker": 0.0021,
  17. "taker_taker": 0.0030,
  18. }
  19. PRIMARY_COST = "maker_taker"
  20. HORIZONS = (
  21. ("full", None),
  22. ("3y", pd.DateOffset(years=3)),
  23. ("1y", pd.DateOffset(years=1)),
  24. ("6m", pd.DateOffset(months=6)),
  25. ("3m", pd.DateOffset(months=3)),
  26. )
  27. @dataclass(frozen=True)
  28. class Strategy:
  29. family: str
  30. bar: str
  31. candidate: explore.PairCandidate
  32. def close_trade(
  33. *,
  34. trades: list[dict[str, object]],
  35. exits: list[dict[str, object]],
  36. position: dict[str, object],
  37. candle: Candle,
  38. exit_price: float,
  39. leverage: int,
  40. ) -> tuple[float, bool]:
  41. exit_equity = trade_equity(
  42. side=str(position["side"]),
  43. margin_used=float(position["margin_used"]),
  44. entry_price=float(position["entry_price"]),
  45. exit_price=exit_price,
  46. leverage=leverage,
  47. )
  48. pnl = exit_equity - float(position["margin_used"])
  49. trades.append(
  50. {
  51. "side": "Long" if position["side"] == "long" else "Short",
  52. "entry_time": explore._format_ts(int(position["entry_time"])),
  53. "exit_time": explore._format_ts(candle.ts),
  54. "entry_price": round(float(position["entry_price"]), 4),
  55. "exit_price": round(exit_price, 4),
  56. "pnl": round(pnl, 4),
  57. "return_pct": round(pnl / float(position["margin_used"]) * 100, 4),
  58. }
  59. )
  60. exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
  61. return exit_equity, pnl > 0.0
  62. def run_btc_impulse_eth_follow_segment(
  63. *,
  64. eth_candles: list[Candle],
  65. btc_candles: list[Candle],
  66. leverage: int,
  67. warmup_bars: int,
  68. lookback: int,
  69. btc_threshold: float,
  70. eth_min_follow: float,
  71. stop_loss_pct: float,
  72. take_profit_pct: float,
  73. max_hold_bars: int,
  74. ) -> SegmentResult:
  75. equity = explore.INITIAL_EQUITY
  76. ending_equity = equity
  77. peak_equity = equity
  78. max_drawdown = 0.0
  79. wins = 0
  80. trades: list[dict[str, object]] = []
  81. entries: list[dict[str, object]] = []
  82. exits: list[dict[str, object]] = []
  83. equity_curve: list[dict[str, float | int]] = []
  84. position: dict[str, object] | None = None
  85. pending_side: str | None = None
  86. pending_exit = False
  87. for index in range(warmup_bars, len(eth_candles)):
  88. candle = eth_candles[index]
  89. if pending_exit and position is not None:
  90. equity, won = close_trade(
  91. trades=trades,
  92. exits=exits,
  93. position=position,
  94. candle=candle,
  95. exit_price=candle.open,
  96. leverage=leverage,
  97. )
  98. wins += 1 if won else 0
  99. position = None
  100. pending_exit = False
  101. if pending_side is not None and position is None and equity > 0.0:
  102. position = {
  103. "side": pending_side,
  104. "entry_time": candle.ts,
  105. "entry_price": candle.open,
  106. "entry_index": index,
  107. "margin_used": equity,
  108. "stop_price": candle.open * (1.0 - stop_loss_pct if pending_side == "long" else 1.0 + stop_loss_pct),
  109. "take_profit_price": candle.open * (1.0 + take_profit_pct if pending_side == "long" else 1.0 - take_profit_pct),
  110. }
  111. entries.append({"ts": candle.ts, "price": candle.open, "side": pending_side})
  112. pending_side = None
  113. current_equity = equity
  114. if position is not None:
  115. side = str(position["side"])
  116. stop_hit = (side == "long" and candle.low <= float(position["stop_price"])) or (
  117. side == "short" and candle.high >= float(position["stop_price"])
  118. )
  119. take_hit = (side == "long" and candle.high >= float(position["take_profit_price"])) or (
  120. side == "short" and candle.low <= float(position["take_profit_price"])
  121. )
  122. if stop_hit or take_hit:
  123. exit_price = float(position["stop_price"] if stop_hit else position["take_profit_price"])
  124. equity, won = close_trade(
  125. trades=trades,
  126. exits=exits,
  127. position=position,
  128. candle=candle,
  129. exit_price=exit_price,
  130. leverage=leverage,
  131. )
  132. wins += 1 if won else 0
  133. current_equity = equity
  134. position = None
  135. if position is not None:
  136. current_equity = mark_to_market(
  137. side=str(position["side"]),
  138. margin_used=float(position["margin_used"]),
  139. entry_price=float(position["entry_price"]),
  140. mark_price=candle.close,
  141. leverage=leverage,
  142. )
  143. peak_equity = max(peak_equity, current_equity)
  144. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  145. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  146. ending_equity = current_equity
  147. if index == len(eth_candles) - 1 or equity <= 0.0:
  148. continue
  149. if position is not None:
  150. if index - int(position["entry_index"]) >= max_hold_bars:
  151. pending_exit = True
  152. continue
  153. btc_return = btc_candles[index].close / btc_candles[index - lookback].close - 1.0
  154. eth_return = candle.close / eth_candles[index - lookback].close - 1.0
  155. if btc_return >= btc_threshold and eth_return >= eth_min_follow:
  156. pending_side = "long"
  157. elif btc_return <= -btc_threshold and eth_return <= -eth_min_follow:
  158. pending_side = "short"
  159. trade_count = len(trades)
  160. return SegmentResult(
  161. trade_count=trade_count,
  162. total_return=(ending_equity - explore.INITIAL_EQUITY) / explore.INITIAL_EQUITY,
  163. win_rate=wins / trade_count if trade_count else 0.0,
  164. max_drawdown=max_drawdown,
  165. trades=trades,
  166. open_position=position,
  167. candles=eth_candles[warmup_bars:],
  168. equity_curve=equity_curve,
  169. entries=entries,
  170. exits=exits,
  171. )
  172. def run_btc_regime_ratio_revert_segment(
  173. *,
  174. eth_candles: list[Candle],
  175. btc_candles: list[Candle],
  176. leverage: int,
  177. warmup_bars: int,
  178. btc_trend_sma: int,
  179. ratio_length: int,
  180. ratio_z: float,
  181. stop_loss_pct: float,
  182. max_hold_bars: int,
  183. ) -> SegmentResult:
  184. eth_close = pd.Series([candle.close for candle in eth_candles], dtype=float)
  185. btc_close = pd.Series([candle.close for candle in btc_candles], dtype=float)
  186. btc_trend = btc_close.rolling(btc_trend_sma).mean().tolist()
  187. ratio = eth_close / btc_close
  188. ratio_mean = ratio.rolling(ratio_length).mean()
  189. ratio_std = ratio.rolling(ratio_length).std(ddof=0)
  190. zscore = ((ratio - ratio_mean) / ratio_std).tolist()
  191. equity = explore.INITIAL_EQUITY
  192. ending_equity = equity
  193. peak_equity = equity
  194. max_drawdown = 0.0
  195. wins = 0
  196. trades: list[dict[str, object]] = []
  197. entries: list[dict[str, object]] = []
  198. exits: list[dict[str, object]] = []
  199. equity_curve: list[dict[str, float | int]] = []
  200. position: dict[str, object] | None = None
  201. pending_side: str | None = None
  202. pending_exit = False
  203. for index in range(warmup_bars, len(eth_candles)):
  204. candle = eth_candles[index]
  205. if pending_exit and position is not None:
  206. equity, won = close_trade(
  207. trades=trades,
  208. exits=exits,
  209. position=position,
  210. candle=candle,
  211. exit_price=candle.open,
  212. leverage=leverage,
  213. )
  214. wins += 1 if won else 0
  215. position = None
  216. pending_exit = False
  217. if pending_side is not None and position is None and equity > 0.0:
  218. position = {
  219. "side": pending_side,
  220. "entry_time": candle.ts,
  221. "entry_price": candle.open,
  222. "entry_index": index,
  223. "margin_used": equity,
  224. "stop_price": candle.open * (1.0 - stop_loss_pct if pending_side == "long" else 1.0 + stop_loss_pct),
  225. }
  226. entries.append({"ts": candle.ts, "price": candle.open, "side": pending_side})
  227. pending_side = None
  228. current_equity = equity
  229. if position is not None:
  230. side = str(position["side"])
  231. stop_hit = (side == "long" and candle.low <= float(position["stop_price"])) or (
  232. side == "short" and candle.high >= float(position["stop_price"])
  233. )
  234. if stop_hit:
  235. equity, won = close_trade(
  236. trades=trades,
  237. exits=exits,
  238. position=position,
  239. candle=candle,
  240. exit_price=float(position["stop_price"]),
  241. leverage=leverage,
  242. )
  243. wins += 1 if won else 0
  244. current_equity = equity
  245. position = None
  246. if position is not None:
  247. current_equity = mark_to_market(
  248. side=str(position["side"]),
  249. margin_used=float(position["margin_used"]),
  250. entry_price=float(position["entry_price"]),
  251. mark_price=candle.close,
  252. leverage=leverage,
  253. )
  254. peak_equity = max(peak_equity, current_equity)
  255. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  256. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  257. ending_equity = current_equity
  258. if index == len(eth_candles) - 1 or equity <= 0.0:
  259. continue
  260. current_trend = btc_trend[index]
  261. current_z = zscore[index]
  262. if current_trend != current_trend or current_z != current_z:
  263. continue
  264. btc_up = btc_candles[index].close > float(current_trend)
  265. if position is not None:
  266. side = str(position["side"])
  267. held = index - int(position["entry_index"])
  268. if (side == "long" and current_z >= 0.0) or (side == "short" and current_z <= 0.0) or held >= max_hold_bars:
  269. pending_exit = True
  270. continue
  271. if btc_up and current_z <= -ratio_z:
  272. pending_side = "long"
  273. elif not btc_up and current_z >= ratio_z:
  274. pending_side = "short"
  275. trade_count = len(trades)
  276. return SegmentResult(
  277. trade_count=trade_count,
  278. total_return=(ending_equity - explore.INITIAL_EQUITY) / explore.INITIAL_EQUITY,
  279. win_rate=wins / trade_count if trade_count else 0.0,
  280. max_drawdown=max_drawdown,
  281. trades=trades,
  282. open_position=position,
  283. candles=eth_candles[warmup_bars:],
  284. equity_curve=equity_curve,
  285. entries=entries,
  286. exits=exits,
  287. )
  288. def build_btc_impulse_eth_follow_candidate(
  289. lookback: int,
  290. btc_threshold: float,
  291. eth_min_follow: float,
  292. stop_loss_pct: float,
  293. take_profit_pct: float,
  294. max_hold_bars: int,
  295. ) -> explore.PairCandidate:
  296. return explore.PairCandidate(
  297. f"btc-impulse-eth-follow-l{lookback}-b{btc_threshold}-e{eth_min_follow}-sl{stop_loss_pct}-tp{take_profit_pct}-mh{max_hold_bars}",
  298. lookback,
  299. lambda eth_candles, btc_candles, leverage, warmup_bars: run_btc_impulse_eth_follow_segment(
  300. eth_candles=eth_candles,
  301. btc_candles=btc_candles,
  302. leverage=leverage,
  303. warmup_bars=warmup_bars,
  304. lookback=lookback,
  305. btc_threshold=btc_threshold,
  306. eth_min_follow=eth_min_follow,
  307. stop_loss_pct=stop_loss_pct,
  308. take_profit_pct=take_profit_pct,
  309. max_hold_bars=max_hold_bars,
  310. ),
  311. )
  312. def build_btc_regime_ratio_revert_candidate(
  313. btc_trend_sma: int,
  314. ratio_length: int,
  315. ratio_z: float,
  316. stop_loss_pct: float,
  317. max_hold_bars: int,
  318. ) -> explore.PairCandidate:
  319. return explore.PairCandidate(
  320. f"btc-regime-ratio-revert-t{btc_trend_sma}-r{ratio_length}-z{ratio_z}-sl{stop_loss_pct}-mh{max_hold_bars}",
  321. max(btc_trend_sma, ratio_length),
  322. lambda eth_candles, btc_candles, leverage, warmup_bars: run_btc_regime_ratio_revert_segment(
  323. eth_candles=eth_candles,
  324. btc_candles=btc_candles,
  325. leverage=leverage,
  326. warmup_bars=warmup_bars,
  327. btc_trend_sma=btc_trend_sma,
  328. ratio_length=ratio_length,
  329. ratio_z=ratio_z,
  330. stop_loss_pct=stop_loss_pct,
  331. max_hold_bars=max_hold_bars,
  332. ),
  333. )
  334. def build_strategies() -> list[Strategy]:
  335. strategies: list[Strategy] = []
  336. strategies.extend(
  337. Strategy(
  338. "btc_trend_eth_rsi",
  339. "15m",
  340. explore.build_eth_btc_rsi_filter_candidate(eth_trend, eth_rsi, exit_rsi, btc_trend, btc_momentum, btc_min_momentum),
  341. )
  342. for eth_trend in (50, 120)
  343. for eth_rsi in (3.0, 5.0)
  344. for exit_rsi in (55.0,)
  345. for btc_trend in (240, 480)
  346. for btc_momentum in (96, 240)
  347. for btc_min_momentum in (0.0, 0.01)
  348. )
  349. strategies.extend(
  350. Strategy(
  351. "btc_shock_guard_eth_rsi",
  352. "15m",
  353. explore.build_eth_btc_shock_filter_candidate(
  354. 50,
  355. 3.0,
  356. 55.0,
  357. btc_trend,
  358. btc_momentum,
  359. btc_min_momentum,
  360. shock_lookback,
  361. max_vol,
  362. max_dd,
  363. ),
  364. )
  365. for btc_trend in (480,)
  366. for btc_momentum in (96, 240)
  367. for btc_min_momentum in (0.0, 0.01)
  368. for shock_lookback in (96, 240)
  369. for max_vol in (0.006, 0.010)
  370. for max_dd in (0.03, 0.05)
  371. )
  372. strategies.extend(
  373. Strategy(
  374. "btc_lead_eth_lag",
  375. bar,
  376. explore.build_btc_lead_eth_lag_candidate(lookback, btc_threshold, lag_gap, max_hold, stop_loss, take_profit),
  377. )
  378. for bar in ("5m", "15m")
  379. for lookback in (8, 16)
  380. for btc_threshold in ((0.010, 0.014) if bar == "5m" else (0.018, 0.024))
  381. for lag_gap in (0.006, 0.010)
  382. for max_hold in (8, 32)
  383. for stop_loss in (0.006,)
  384. for take_profit in (0.012, 0.018)
  385. )
  386. strategies.extend(
  387. Strategy(
  388. "btc_impulse_eth_follow",
  389. bar,
  390. build_btc_impulse_eth_follow_candidate(lookback, btc_threshold, eth_min_follow, stop_loss, take_profit, max_hold),
  391. )
  392. for bar in ("5m", "15m")
  393. for lookback in (8, 16)
  394. for btc_threshold in ((0.008, 0.012) if bar == "5m" else (0.015, 0.020))
  395. for eth_min_follow in (0.002, 0.006)
  396. for stop_loss in (0.006,)
  397. for take_profit in (0.012, 0.018)
  398. for max_hold in (8, 16, 32)
  399. )
  400. strategies.extend(
  401. Strategy(
  402. "ethbtc_ratio_pullback",
  403. "15m",
  404. explore.build_eth_btc_ratio_pullback_candidate(480, btc_momentum, btc_min_momentum, ratio_length, ratio_std, ratio_rsi, stop_loss),
  405. )
  406. for btc_momentum in (96, 240)
  407. for btc_min_momentum in (0.0, 0.01)
  408. for ratio_length in (48, 96)
  409. for ratio_std in (1.5, 2.0)
  410. for ratio_rsi in (5.0,)
  411. for stop_loss in (0.008,)
  412. )
  413. strategies.extend(
  414. Strategy(
  415. "btc_regime_ratio_revert",
  416. "15m",
  417. build_btc_regime_ratio_revert_candidate(btc_trend, ratio_length, ratio_z, stop_loss, max_hold),
  418. )
  419. for btc_trend in (240, 480)
  420. for ratio_length in (48, 96)
  421. for ratio_z in (1.5, 2.0)
  422. for stop_loss in (0.008,)
  423. for max_hold in (16, 32)
  424. )
  425. return strategies
  426. def load_candles(symbol: str, bar: str, years: float) -> list[Candle]:
  427. candles, _ = explore.load_cached_candles(explore.CANDLE_CACHE_DIR, symbol, bar)
  428. if not candles:
  429. raise FileNotFoundError(f"missing cached candles for {symbol} {bar}")
  430. requested = explore.history_bars_for_years(bar, years)
  431. return candles[-requested:] if len(candles) > requested else candles
  432. def run_strategy(strategy: Strategy, data: dict[tuple[str, str], list[Candle]]) -> SegmentResult:
  433. eth, btc = explore.align_pair_candles(
  434. data[("ETH-USDT-SWAP", strategy.bar)],
  435. data[("BTC-USDT-SWAP", strategy.bar)],
  436. )
  437. return strategy.candidate.run(
  438. eth_candles=eth,
  439. btc_candles=btc,
  440. leverage=explore.LEVERAGE,
  441. warmup_bars=strategy.candidate.warmup_bars,
  442. )
  443. def daily_equity(frame: pd.DataFrame, start: pd.Timestamp, end: pd.Timestamp) -> pd.Series:
  444. series = frame.set_index("ts")["equity"].sort_index()
  445. index = pd.date_range(start.normalize(), end.normalize(), freq="1D", tz="UTC")
  446. return series.reindex(index.union(series.index)).sort_index().ffill().reindex(index).ffill()
  447. def trade_stats(result: SegmentResult, roundtrip_cost_on_margin: float) -> dict[str, float]:
  448. returns = [
  449. float(trade["return_pct"]) / 100.0 - roundtrip_cost_on_margin * float(trade.get("cost_weight", 1.0))
  450. for trade in result.trades
  451. ]
  452. wins = [value for value in returns if value > 0.0]
  453. losses = [value for value in returns if value < 0.0]
  454. avg_win = sum(wins) / len(wins) if wins else 0.0
  455. avg_loss_abs = abs(sum(losses) / len(losses)) if losses else 0.0
  456. gross_profit = sum(wins)
  457. gross_loss_abs = abs(sum(losses))
  458. return {
  459. "win_rate": len(wins) / len(returns) if returns else 0.0,
  460. "payoff_ratio": avg_win / avg_loss_abs if avg_loss_abs else 0.0,
  461. "profit_factor": gross_profit / gross_loss_abs if gross_loss_abs else 0.0,
  462. "expectancy_per_trade": sum(returns) / len(returns) if returns else 0.0,
  463. }
  464. def metrics_from_daily_equity(series: pd.Series) -> dict[str, float]:
  465. years = (series.index[-1] - series.index[0]).total_seconds() / 86_400 / 365
  466. total_return = float(series.iloc[-1] / series.iloc[0] - 1.0)
  467. annualized_return = (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 and years > 0.0 else 0.0
  468. max_drawdown = explore.max_drawdown_from_equity([float(value) for value in series])
  469. returns = series.pct_change().dropna()
  470. daily_std = float(returns.std(ddof=1)) if len(returns) > 1 else 0.0
  471. risk_reward = float(returns.mean()) / daily_std * (365**0.5) if daily_std else 0.0
  472. return {
  473. "net_total_return": total_return,
  474. "net_annualized_return": annualized_return,
  475. "net_max_drawdown": max_drawdown,
  476. "net_calmar": annualized_return / max_drawdown if max_drawdown else 0.0,
  477. "risk_reward_ratio": risk_reward,
  478. }
  479. def horizon_rows(name: str, series: pd.Series) -> list[dict[str, object]]:
  480. rows: list[dict[str, object]] = []
  481. end_time = series.index[-1]
  482. for label, offset in HORIZONS:
  483. horizon = series if offset is None else series[series.index >= end_time - offset]
  484. if len(horizon) < 2:
  485. horizon = series
  486. rows.append(
  487. {
  488. "name": name,
  489. "horizon": label,
  490. "horizon_start": horizon.index[0].strftime("%Y-%m-%d"),
  491. "horizon_end": horizon.index[-1].strftime("%Y-%m-%d"),
  492. **metrics_from_daily_equity(horizon),
  493. }
  494. )
  495. return rows
  496. def monthly_rows(name: str, series: pd.Series) -> pd.DataFrame:
  497. monthly = series.resample("ME").last()
  498. frame = pd.DataFrame(
  499. {
  500. "name": name,
  501. "month": monthly.index.strftime("%Y-%m"),
  502. "start_equity": monthly.shift(1).fillna(series.iloc[0]).to_numpy(),
  503. "end_equity": monthly.to_numpy(),
  504. }
  505. )
  506. frame["return"] = frame["end_equity"] / frame["start_equity"] - 1.0
  507. return frame
  508. def portfolio_equity(
  509. *,
  510. name: str,
  511. legs: tuple[str, ...],
  512. mode: str,
  513. daily: dict[str, pd.Series],
  514. strategy_metrics: dict[str, dict[str, float]],
  515. ) -> tuple[pd.Series, pd.Series]:
  516. returns = pd.DataFrame({leg: daily[leg].pct_change().fillna(0.0) for leg in legs}).dropna()
  517. if mode == "equal":
  518. weights = pd.Series(1.0 / len(legs), index=legs)
  519. else:
  520. raw = pd.Series({leg: 1.0 / max(strategy_metrics[leg]["net_max_drawdown"], 0.01) for leg in legs})
  521. weights = raw / raw.sum()
  522. equity = explore.INITIAL_EQUITY * (1.0 + returns.mul(weights, axis=1).sum(axis=1)).cumprod()
  523. equity.name = name
  524. return equity, weights
  525. def markdown_table(frame: pd.DataFrame) -> str:
  526. rows = [list(frame.columns), ["---" for _ in frame.columns]]
  527. rows.extend(frame.astype(object).where(pd.notna(frame), "").values.tolist())
  528. return "\n".join("| " + " | ".join(format_cell(value) for value in row) + " |" for row in rows)
  529. def format_cell(value: object) -> str:
  530. if isinstance(value, float):
  531. return f"{value:.6g}"
  532. return str(value).replace("|", "\\|")
  533. def markdown_report(
  534. *,
  535. command: str,
  536. paths: list[Path],
  537. strategy_total: pd.DataFrame,
  538. portfolio_total: pd.DataFrame,
  539. horizon: pd.DataFrame,
  540. monthly_summary: pd.DataFrame,
  541. worst_months: pd.DataFrame,
  542. ) -> str:
  543. primary_strategies = strategy_total[strategy_total["cost_model"] == PRIMARY_COST].head(10)
  544. primary_portfolios = portfolio_total[portfolio_total["cost_model"] == PRIMARY_COST].head(10)
  545. top_name = str(primary_portfolios.iloc[0]["name"]) if len(primary_portfolios) else ""
  546. top_horizon = horizon[(horizon["cost_model"] == PRIMARY_COST) & (horizon["name"] == top_name)]
  547. lines = [
  548. "# ETH BTC nextgen non-maker exploration",
  549. "",
  550. f"Run command: `{command}`",
  551. "",
  552. "Output files:",
  553. *[f"- `{path}`" for path in paths],
  554. "",
  555. "Scope: ETH-only execution, BTC-driven signals, market/taker style fills. No maker-dependent TWAP legs are included.",
  556. "Costs: maker_taker=0.0021 and taker_taker=0.0030 roundtrip on margin at 3x.",
  557. "",
  558. "## Top maker_taker strategies",
  559. "",
  560. markdown_table(
  561. primary_strategies[
  562. [
  563. "strategy_key",
  564. "family",
  565. "bar",
  566. "trades",
  567. "net_total_return",
  568. "net_annualized_return",
  569. "net_max_drawdown",
  570. "net_calmar",
  571. "win_rate",
  572. "payoff_ratio",
  573. "profit_factor",
  574. "risk_reward_ratio",
  575. "worst_month_return",
  576. ]
  577. ]
  578. ),
  579. "",
  580. "## Top maker_taker portfolios",
  581. "",
  582. markdown_table(
  583. primary_portfolios[
  584. [
  585. "name",
  586. "mode",
  587. "leg_count",
  588. "net_total_return",
  589. "net_annualized_return",
  590. "net_max_drawdown",
  591. "net_calmar",
  592. "risk_reward_ratio",
  593. "worst_month_return",
  594. "min_horizon_total_return",
  595. "legs",
  596. ]
  597. ]
  598. ),
  599. "",
  600. "## Horizon metrics for top portfolio",
  601. "",
  602. markdown_table(
  603. top_horizon[
  604. [
  605. "horizon",
  606. "horizon_start",
  607. "horizon_end",
  608. "net_total_return",
  609. "net_annualized_return",
  610. "net_max_drawdown",
  611. "net_calmar",
  612. "risk_reward_ratio",
  613. ]
  614. ]
  615. ),
  616. "",
  617. "## Monthly summary for top portfolios",
  618. "",
  619. markdown_table(monthly_summary[monthly_summary["cost_model"] == PRIMARY_COST].head(20)),
  620. "",
  621. "## Worst months",
  622. "",
  623. markdown_table(worst_months.head(20)),
  624. ]
  625. return "\n".join(lines) + "\n"
  626. def main() -> int:
  627. parser = argparse.ArgumentParser()
  628. parser.add_argument("--years", type=float, default=YEARS)
  629. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  630. parser.add_argument("--top-strategy-count", type=int, default=24)
  631. parser.add_argument("--max-leg-count", type=int, default=4)
  632. args = parser.parse_args()
  633. strategies = build_strategies()
  634. bars = sorted({strategy.bar for strategy in strategies})
  635. data = {
  636. (symbol, bar): load_candles(symbol, bar, args.years)
  637. for bar in bars
  638. for symbol in ("ETH-USDT-SWAP", "BTC-USDT-SWAP")
  639. }
  640. results: dict[str, tuple[Strategy, SegmentResult]] = {}
  641. for index, strategy in enumerate(strategies, start=1):
  642. key = f"{strategy.family}:{strategy.bar}:{strategy.candidate.name}"
  643. results[key] = (strategy, run_strategy(strategy, data))
  644. print(f"done {index}/{len(strategies)} {key}", flush=True)
  645. start = max(pd.to_datetime(result.equity_curve[0]["ts"], unit="ms", utc=True) for _, result in results.values())
  646. end = min(pd.to_datetime(result.equity_curve[-1]["ts"], unit="ms", utc=True) for _, result in results.values())
  647. strategy_rows: list[dict[str, object]] = []
  648. horizon_output: list[dict[str, object]] = []
  649. monthly_frames: list[pd.DataFrame] = []
  650. daily_by_cost: dict[str, dict[str, pd.Series]] = {cost: {} for cost in COSTS}
  651. metrics_by_cost: dict[str, dict[str, dict[str, float]]] = {cost: {} for cost in COSTS}
  652. for key, (strategy, result) in results.items():
  653. for cost_model, cost_value in COSTS.items():
  654. frame = explore.cost_adjusted_trade_equity_frame(result, cost_value)
  655. daily = daily_equity(frame, start, end)
  656. metrics = metrics_from_daily_equity(daily)
  657. monthly = monthly_rows(key, daily)
  658. stats = trade_stats(result, cost_value)
  659. daily_by_cost[cost_model][key] = daily
  660. metrics_by_cost[cost_model][key] = metrics
  661. strategy_rows.append(
  662. {
  663. "strategy_key": key,
  664. "cost_model": cost_model,
  665. "roundtrip_cost_on_margin": cost_value,
  666. "family": strategy.family,
  667. "bar": strategy.bar,
  668. "name": strategy.candidate.name,
  669. "first_candle": start.strftime("%Y-%m-%d %H:%M"),
  670. "last_candle": end.strftime("%Y-%m-%d %H:%M"),
  671. "years": (end - start).total_seconds() / 86_400 / 365,
  672. "trades": result.trade_count,
  673. "gross_total_return": result.total_return,
  674. "gross_max_drawdown_mark_to_market": result.max_drawdown,
  675. "worst_month_return": float(monthly["return"].min()),
  676. **stats,
  677. **metrics,
  678. }
  679. )
  680. for row in horizon_rows(key, daily):
  681. horizon_output.append({"kind": "strategy", "cost_model": cost_model, **row})
  682. monthly_frames.append(monthly.assign(kind="strategy", cost_model=cost_model))
  683. strategy_total = pd.DataFrame(strategy_rows).sort_values(
  684. ["cost_model", "net_calmar", "net_annualized_return", "net_max_drawdown"],
  685. ascending=[True, False, False, True],
  686. )
  687. primary_strategy_keys = list(strategy_total[strategy_total["cost_model"] == PRIMARY_COST].head(args.top_strategy_count)["strategy_key"])
  688. keys_by_family: dict[str, list[str]] = {}
  689. for key in primary_strategy_keys:
  690. keys_by_family.setdefault(results[key][0].family, []).append(key)
  691. selected_keys = [keys[0] for keys in keys_by_family.values()]
  692. for key in primary_strategy_keys:
  693. if key not in selected_keys:
  694. selected_keys.append(key)
  695. selected_keys = selected_keys[: args.top_strategy_count]
  696. portfolio_rows: list[dict[str, object]] = []
  697. equity_frames: list[pd.DataFrame] = []
  698. combo_index = 0
  699. for cost_model, daily in daily_by_cost.items():
  700. for leg_count in range(2, min(args.max_leg_count, len(selected_keys)) + 1):
  701. for legs in combinations(selected_keys, leg_count):
  702. if len({results[leg][0].family for leg in legs}) != leg_count:
  703. continue
  704. for mode in ("equal", "risk"):
  705. combo_index += 1
  706. name = f"{mode}-{leg_count}-c{combo_index:04d}"
  707. series, weights = portfolio_equity(
  708. name=name,
  709. legs=legs,
  710. mode=mode,
  711. daily=daily,
  712. strategy_metrics=metrics_by_cost[cost_model],
  713. )
  714. metrics = metrics_from_daily_equity(series)
  715. monthly = monthly_rows(name, series)
  716. current_horizons = horizon_rows(name, series)
  717. min_horizon_return = min(float(row["net_total_return"]) for row in current_horizons)
  718. portfolio_rows.append(
  719. {
  720. "name": name,
  721. "cost_model": cost_model,
  722. "roundtrip_cost_on_margin": COSTS[cost_model],
  723. "mode": mode,
  724. "leg_count": leg_count,
  725. "legs": ";".join(legs),
  726. "weights": ";".join(f"{leg}={weights[leg]:.8f}" for leg in legs),
  727. "first_candle": start.strftime("%Y-%m-%d %H:%M"),
  728. "last_candle": end.strftime("%Y-%m-%d %H:%M"),
  729. "years": (end - start).total_seconds() / 86_400 / 365,
  730. "trades": sum(results[leg][1].trade_count for leg in legs),
  731. "win_rate": float(pd.Series([trade_stats(results[leg][1], COSTS[cost_model])["win_rate"] for leg in legs]).mean()),
  732. "payoff_ratio": float(pd.Series([trade_stats(results[leg][1], COSTS[cost_model])["payoff_ratio"] for leg in legs]).mean()),
  733. "profit_factor": float(pd.Series([trade_stats(results[leg][1], COSTS[cost_model])["profit_factor"] for leg in legs]).mean()),
  734. "worst_month_return": float(monthly["return"].min()),
  735. "min_horizon_total_return": min_horizon_return,
  736. **metrics,
  737. }
  738. )
  739. for row in current_horizons:
  740. horizon_output.append({"kind": "portfolio", "cost_model": cost_model, **row})
  741. monthly_frames.append(monthly.assign(kind="portfolio", cost_model=cost_model))
  742. equity_frames.append(
  743. pd.DataFrame(
  744. {
  745. "name": name,
  746. "cost_model": cost_model,
  747. "date": series.index.strftime("%Y-%m-%d"),
  748. "equity": series.to_numpy(),
  749. }
  750. )
  751. )
  752. portfolio_total = pd.DataFrame(portfolio_rows).sort_values(
  753. ["cost_model", "net_calmar", "net_annualized_return", "min_horizon_total_return", "net_max_drawdown"],
  754. ascending=[True, False, False, False, True],
  755. )
  756. primary = portfolio_total[portfolio_total["cost_model"] == PRIMARY_COST]
  757. others = portfolio_total[portfolio_total["cost_model"] != PRIMARY_COST]
  758. portfolio_total = pd.concat([primary, others], ignore_index=True)
  759. top_names = set(primary.head(25)["name"])
  760. horizon = pd.DataFrame(horizon_output)
  761. horizon["horizon"] = pd.Categorical(horizon["horizon"], categories=["full", "3y", "1y", "6m", "3m"], ordered=True)
  762. horizon = horizon[(horizon["kind"] == "strategy") | (horizon["name"].isin(top_names))].sort_values(["cost_model", "kind", "name", "horizon"])
  763. monthly = pd.concat(monthly_frames, ignore_index=True)
  764. monthly_summary = (
  765. monthly[monthly["name"].isin(top_names)]
  766. .groupby(["kind", "cost_model", "name"], as_index=False)
  767. .agg(
  768. months=("return", "count"),
  769. positive_month_rate=("return", lambda values: float((values > 0.0).mean())),
  770. avg_month_return=("return", "mean"),
  771. median_month_return=("return", "median"),
  772. worst_month_return=("return", "min"),
  773. best_month_return=("return", "max"),
  774. )
  775. .sort_values(["cost_model", "kind", "worst_month_return"], ascending=[True, True, False])
  776. )
  777. worst_months = monthly[monthly["name"].isin(top_names)].sort_values("return").head(100)
  778. equity = pd.concat(equity_frames, ignore_index=True)
  779. equity = equity[equity["name"].isin(top_names)]
  780. args.output_dir.mkdir(parents=True, exist_ok=True)
  781. strategy_path = args.output_dir / f"{PREFIX}-strategies.csv"
  782. portfolio_path = args.output_dir / f"{PREFIX}-portfolios.csv"
  783. top_path = args.output_dir / f"{PREFIX}-top10.csv"
  784. horizon_path = args.output_dir / f"{PREFIX}-horizon.csv"
  785. monthly_path = args.output_dir / f"{PREFIX}-monthly-summary.csv"
  786. worst_path = args.output_dir / f"{PREFIX}-worst-months.csv"
  787. equity_path = args.output_dir / f"{PREFIX}-equity.csv"
  788. report_path = args.output_dir / f"{PREFIX}-report.md"
  789. strategy_total.to_csv(strategy_path, index=False)
  790. portfolio_total.to_csv(portfolio_path, index=False)
  791. primary.head(10).to_csv(top_path, index=False)
  792. horizon.to_csv(horizon_path, index=False)
  793. monthly_summary.to_csv(monthly_path, index=False)
  794. worst_months.to_csv(worst_path, index=False)
  795. equity.to_csv(equity_path, index=False)
  796. command = (
  797. f"rtk .venv/bin/python {Path(__file__).as_posix()} --years {args.years} "
  798. f"--top-strategy-count {args.top_strategy_count} --max-leg-count {args.max_leg_count}"
  799. )
  800. report_path.write_text(
  801. markdown_report(
  802. command=command,
  803. paths=[strategy_path, portfolio_path, top_path, horizon_path, monthly_path, worst_path, equity_path, report_path],
  804. strategy_total=strategy_total,
  805. portfolio_total=portfolio_total,
  806. horizon=horizon,
  807. monthly_summary=monthly_summary,
  808. worst_months=worst_months,
  809. ),
  810. encoding="utf-8",
  811. )
  812. print(primary.head(10).to_string(index=False))
  813. return 0
  814. if __name__ == "__main__":
  815. raise SystemExit(main())