search_expansion_mean_reversion.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597
  1. from __future__ import annotations
  2. import argparse
  3. import json
  4. import sys
  5. from dataclasses import dataclass
  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. from scripts.search_eth_btc_nextgen_variants import markdown_table, metrics_from_daily_equity, monthly_rows
  13. OUTPUT_DIR = Path("reports/strategy-expansion")
  14. PREFIX = "mean-reversion"
  15. BASE_BAR = "15m"
  16. YEARS = 10.0
  17. LEVERAGE = 3
  18. ROUNDTRIP_TAKER_COST_ON_MARGIN = 0.0004 * 2 * LEVERAGE
  19. HORIZONS = (
  20. ("full", None),
  21. ("3y", pd.DateOffset(years=3)),
  22. ("1y", pd.DateOffset(years=1)),
  23. ("6m", pd.DateOffset(months=6)),
  24. ("3m", pd.DateOffset(months=3)),
  25. )
  26. @dataclass(frozen=True)
  27. class Params:
  28. family: str
  29. symbol: str
  30. bar: str
  31. side_mode: str
  32. rsi_length: int
  33. rsi_entry: float
  34. rsi_exit: float
  35. bb_length: int
  36. bb_mult: float
  37. kc_mult: float
  38. squeeze_lookback: int
  39. vol_lookback: int
  40. vol_quantile_lookback: int
  41. vol_quantile: float
  42. btc_trend_sma: int
  43. btc_momentum_lookback: int
  44. btc_min_momentum: float
  45. exit_midline: bool
  46. max_hold_bars: int
  47. stop_loss_pct: float
  48. cooldown_bars: int
  49. @property
  50. def name(self) -> str:
  51. return (
  52. f"{self.symbol}-{self.bar}-{self.family}-{self.side_mode}"
  53. f"-r{self.rsi_length}-{self.rsi_entry:g}-{self.rsi_exit:g}"
  54. f"-bb{self.bb_length}-{self.bb_mult:g}-kc{self.kc_mult:g}-sq{self.squeeze_lookback}"
  55. f"-vw{self.vol_lookback}-vq{self.vol_quantile_lookback}-{self.vol_quantile:g}"
  56. f"-bt{self.btc_trend_sma}-bm{self.btc_momentum_lookback}-{self.btc_min_momentum:g}"
  57. f"-mid{int(self.exit_midline)}-mh{self.max_hold_bars}-sl{self.stop_loss_pct:g}-cd{self.cooldown_bars}"
  58. )
  59. def load_base_candles(symbol: str, years: float) -> list[Candle]:
  60. candles, _ = explore.load_cached_candles(explore.CANDLE_CACHE_DIR, symbol, BASE_BAR)
  61. if not candles:
  62. raise FileNotFoundError(f"missing cached candles for {symbol} {BASE_BAR}")
  63. requested = explore.history_bars_for_years(BASE_BAR, years)
  64. return candles[-requested:] if len(candles) > requested else candles
  65. def resample_candles(candles: list[Candle], bar: str) -> list[Candle]:
  66. if bar == BASE_BAR:
  67. return candles
  68. rule_by_bar = {"1H": "1h", "4H": "4h", "1D": "1D"}
  69. frame = pd.DataFrame(
  70. {
  71. "ts": pd.to_datetime([candle.ts for candle in candles], unit="ms", utc=True),
  72. "open": [candle.open for candle in candles],
  73. "high": [candle.high for candle in candles],
  74. "low": [candle.low for candle in candles],
  75. "close": [candle.close for candle in candles],
  76. "volume": [candle.volume for candle in candles],
  77. }
  78. ).set_index("ts")
  79. sampled = frame.resample(rule_by_bar[bar], label="left", closed="left").agg(
  80. {"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
  81. )
  82. sampled = sampled.dropna(subset=["open", "high", "low", "close"])
  83. symbol = candles[0].symbol
  84. return [
  85. Candle(
  86. symbol=symbol,
  87. ts=int(index.timestamp() * 1000),
  88. open=float(row.open),
  89. high=float(row.high),
  90. low=float(row.low),
  91. close=float(row.close),
  92. volume=float(row.volume),
  93. )
  94. for index, row in sampled.iterrows()
  95. ]
  96. def true_range(highs: pd.Series, lows: pd.Series, closes: pd.Series) -> pd.Series:
  97. prev_close = closes.shift(1)
  98. return pd.concat([(highs - lows), (highs - prev_close).abs(), (lows - prev_close).abs()], axis=1).max(axis=1)
  99. def close_position(
  100. *,
  101. trades: list[dict[str, object]],
  102. exits: list[dict[str, object]],
  103. position: dict[str, object],
  104. candle: Candle,
  105. exit_price: float,
  106. ) -> tuple[float, bool]:
  107. exit_equity = trade_equity(
  108. side=str(position["side"]),
  109. margin_used=float(position["margin_used"]),
  110. entry_price=float(position["entry_price"]),
  111. exit_price=exit_price,
  112. leverage=LEVERAGE,
  113. )
  114. pnl = exit_equity - float(position["margin_used"])
  115. trades.append(
  116. {
  117. "side": "Long" if position["side"] == "long" else "Short",
  118. "entry_time": explore._format_ts(int(position["entry_time"])),
  119. "exit_time": explore._format_ts(candle.ts),
  120. "entry_price": round(float(position["entry_price"]), 4),
  121. "exit_price": round(exit_price, 4),
  122. "pnl": round(pnl, 4),
  123. "return_pct": round(pnl / float(position["margin_used"]) * 100.0, 4),
  124. }
  125. )
  126. exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
  127. return exit_equity, pnl > 0.0
  128. def risk_allows(side: str, btc_close: float, btc_trend: float, btc_momentum: float, btc_vol: float, vol_cap: float, params: Params) -> bool:
  129. if btc_vol > vol_cap:
  130. return False
  131. if side == "long":
  132. return btc_close > btc_trend and btc_momentum >= params.btc_min_momentum
  133. return btc_close < btc_trend and btc_momentum <= -params.btc_min_momentum
  134. def run_segment(candles: list[Candle], btc_candles: list[Candle], params: Params) -> SegmentResult:
  135. closes = pd.Series([candle.close for candle in candles], dtype=float)
  136. highs = pd.Series([candle.high for candle in candles], dtype=float)
  137. lows = pd.Series([candle.low for candle in candles], dtype=float)
  138. btc_closes = pd.Series([candle.close for candle in btc_candles], dtype=float)
  139. returns = closes.pct_change()
  140. btc_returns = btc_closes.pct_change()
  141. rsi = explore._compute_rsi(closes, params.rsi_length)
  142. middle = closes.rolling(params.bb_length).mean()
  143. stdev = closes.rolling(params.bb_length).std(ddof=0)
  144. upper = middle + params.bb_mult * stdev
  145. lower = middle - params.bb_mult * stdev
  146. atr = true_range(highs, lows, closes).rolling(params.bb_length).mean()
  147. keltner_upper = middle + params.kc_mult * atr
  148. keltner_lower = middle - params.kc_mult * atr
  149. squeeze = ((upper < keltner_upper) & (lower > keltner_lower)).rolling(params.squeeze_lookback).max()
  150. realized_vol = returns.rolling(params.vol_lookback).std(ddof=1)
  151. vol_cap = realized_vol.rolling(params.vol_quantile_lookback).quantile(params.vol_quantile)
  152. btc_trend = btc_closes.rolling(params.btc_trend_sma).mean()
  153. btc_vol = btc_returns.rolling(params.vol_lookback).std(ddof=1)
  154. btc_vol_cap = btc_vol.rolling(params.vol_quantile_lookback).quantile(params.vol_quantile)
  155. warmup_bars = max(
  156. params.bb_length + params.squeeze_lookback,
  157. params.vol_lookback + params.vol_quantile_lookback,
  158. params.btc_trend_sma,
  159. params.btc_momentum_lookback,
  160. params.rsi_length + 2,
  161. )
  162. equity = explore.INITIAL_EQUITY
  163. ending_equity = equity
  164. peak_equity = equity
  165. max_drawdown = 0.0
  166. wins = 0
  167. last_exit_index = -10**9
  168. pending_side: str | None = None
  169. pending_exit = False
  170. position: dict[str, object] | None = None
  171. trades: list[dict[str, object]] = []
  172. entries: list[dict[str, object]] = []
  173. exits: list[dict[str, object]] = []
  174. equity_curve: list[dict[str, float | int]] = []
  175. for index in range(warmup_bars, len(candles)):
  176. candle = candles[index]
  177. if pending_exit and position is not None:
  178. equity, won = close_position(trades=trades, exits=exits, position=position, candle=candle, exit_price=candle.open)
  179. wins += 1 if won else 0
  180. position = None
  181. pending_exit = False
  182. last_exit_index = index
  183. if pending_side is not None and position is None and equity > 0.0:
  184. position = {
  185. "side": pending_side,
  186. "entry_time": candle.ts,
  187. "entry_price": candle.open,
  188. "entry_index": index,
  189. "margin_used": equity,
  190. "stop_price": candle.open * (1.0 - params.stop_loss_pct if pending_side == "long" else 1.0 + params.stop_loss_pct),
  191. }
  192. entries.append({"ts": candle.ts, "price": candle.open, "side": pending_side})
  193. pending_side = None
  194. current_equity = equity
  195. if position is not None:
  196. side = str(position["side"])
  197. stop_hit = (side == "long" and candle.low <= float(position["stop_price"])) or (
  198. side == "short" and candle.high >= float(position["stop_price"])
  199. )
  200. if stop_hit:
  201. equity, won = close_position(
  202. trades=trades,
  203. exits=exits,
  204. position=position,
  205. candle=candle,
  206. exit_price=float(position["stop_price"]),
  207. )
  208. wins += 1 if won else 0
  209. current_equity = equity
  210. position = None
  211. last_exit_index = index
  212. if position is not None:
  213. current_equity = mark_to_market(
  214. side=str(position["side"]),
  215. margin_used=float(position["margin_used"]),
  216. entry_price=float(position["entry_price"]),
  217. mark_price=candle.close,
  218. leverage=LEVERAGE,
  219. )
  220. peak_equity = max(peak_equity, current_equity)
  221. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  222. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  223. ending_equity = current_equity
  224. if index == len(candles) - 1 or equity <= 0.0:
  225. continue
  226. values = [
  227. rsi[index],
  228. middle.iloc[index],
  229. upper.iloc[index],
  230. lower.iloc[index],
  231. squeeze.iloc[index],
  232. realized_vol.iloc[index],
  233. vol_cap.iloc[index],
  234. btc_trend.iloc[index],
  235. btc_vol.iloc[index],
  236. btc_vol_cap.iloc[index],
  237. ]
  238. if any(value != value for value in values):
  239. continue
  240. current_rsi = float(rsi[index])
  241. current_middle = float(middle.iloc[index])
  242. current_vol = float(realized_vol.iloc[index])
  243. current_vol_cap = float(vol_cap.iloc[index])
  244. btc_momentum = btc_candles[index].close / btc_candles[index - params.btc_momentum_lookback].close - 1.0
  245. if position is not None:
  246. side = str(position["side"])
  247. held_bars = index - int(position["entry_index"])
  248. if side == "long":
  249. exit_signal = current_rsi >= params.rsi_exit or (params.exit_midline and candle.close >= current_middle)
  250. else:
  251. exit_signal = current_rsi <= 100.0 - params.rsi_exit or (params.exit_midline and candle.close <= current_middle)
  252. if exit_signal or held_bars >= params.max_hold_bars:
  253. pending_exit = True
  254. continue
  255. if index - last_exit_index < params.cooldown_bars or current_vol > current_vol_cap:
  256. continue
  257. btc_vol_limit = float(btc_vol_cap.iloc[index])
  258. long_allowed = params.side_mode in ("long", "both") and risk_allows(
  259. "long",
  260. btc_candles[index].close,
  261. float(btc_trend.iloc[index]),
  262. btc_momentum,
  263. float(btc_vol.iloc[index]),
  264. btc_vol_limit,
  265. params,
  266. )
  267. short_allowed = params.side_mode in ("short", "both") and risk_allows(
  268. "short",
  269. btc_candles[index].close,
  270. float(btc_trend.iloc[index]),
  271. btc_momentum,
  272. float(btc_vol.iloc[index]),
  273. btc_vol_limit,
  274. params,
  275. )
  276. if params.family == "rsi":
  277. if long_allowed and current_rsi <= params.rsi_entry:
  278. pending_side = "long"
  279. elif short_allowed and current_rsi >= 100.0 - params.rsi_entry:
  280. pending_side = "short"
  281. elif params.family == "bb_squeeze":
  282. if long_allowed and bool(squeeze.iloc[index]) and candle.close <= float(lower.iloc[index]) and current_rsi <= params.rsi_exit:
  283. pending_side = "long"
  284. elif short_allowed and bool(squeeze.iloc[index]) and candle.close >= float(upper.iloc[index]) and current_rsi >= 100.0 - params.rsi_exit:
  285. pending_side = "short"
  286. elif params.family == "range_band":
  287. if long_allowed and candle.close <= float(lower.iloc[index]) and current_rsi <= params.rsi_exit:
  288. pending_side = "long"
  289. elif short_allowed and candle.close >= float(upper.iloc[index]) and current_rsi >= 100.0 - params.rsi_exit:
  290. pending_side = "short"
  291. else:
  292. raise ValueError(f"unknown family {params.family}")
  293. trade_count = len(trades)
  294. return SegmentResult(
  295. trade_count=trade_count,
  296. total_return=(ending_equity - explore.INITIAL_EQUITY) / explore.INITIAL_EQUITY,
  297. win_rate=wins / trade_count if trade_count else 0.0,
  298. max_drawdown=max_drawdown,
  299. trades=trades,
  300. open_position=position,
  301. candles=candles[warmup_bars:],
  302. equity_curve=equity_curve,
  303. entries=entries,
  304. exits=exits,
  305. )
  306. def daily_equity(frame: pd.DataFrame, start: pd.Timestamp, end: pd.Timestamp) -> pd.Series:
  307. series = frame.set_index("ts")["equity"].sort_index()
  308. start_day = start.normalize()
  309. end_day = end.normalize()
  310. series = pd.concat([pd.Series([explore.INITIAL_EQUITY], index=[start_day]), series]).sort_index()
  311. series = series.groupby(level=0).last()
  312. index = pd.date_range(start_day, end_day, freq="1D", tz="UTC")
  313. return series.reindex(index.union(series.index)).sort_index().ffill().reindex(index)
  314. def trade_stats_for_window(result: SegmentResult, cost: float, start: pd.Timestamp, end: pd.Timestamp) -> dict[str, float | int]:
  315. returns: list[float] = []
  316. for trade in result.trades:
  317. exit_time = pd.to_datetime(str(trade["exit_time"]), utc=True)
  318. if start <= exit_time <= end:
  319. returns.append(float(trade["return_pct"]) / 100.0 - cost)
  320. wins = [value for value in returns if value > 0.0]
  321. losses = [value for value in returns if value < 0.0]
  322. avg_win = sum(wins) / len(wins) if wins else 0.0
  323. avg_loss_abs = abs(sum(losses) / len(losses)) if losses else 0.0
  324. return {
  325. "trades": len(returns),
  326. "win_rate": len(wins) / len(returns) if returns else 0.0,
  327. "payoff_ratio": avg_win / avg_loss_abs if avg_loss_abs else 0.0,
  328. }
  329. def horizon_rows(name: str, result: SegmentResult, cost: float, series: pd.Series) -> list[dict[str, object]]:
  330. rows: list[dict[str, object]] = []
  331. end_time = series.index[-1]
  332. for label, offset in HORIZONS:
  333. horizon = series if offset is None else series[series.index >= end_time - offset]
  334. if len(horizon) < 2:
  335. horizon = series
  336. rows.append(
  337. {
  338. "name": name,
  339. "horizon": label,
  340. "horizon_start": horizon.index[0].strftime("%Y-%m-%d"),
  341. "horizon_end": horizon.index[-1].strftime("%Y-%m-%d"),
  342. **metrics_from_daily_equity(horizon),
  343. **trade_stats_for_window(result, cost, horizon.index[0], horizon.index[-1]),
  344. }
  345. )
  346. return rows
  347. def build_params() -> list[Params]:
  348. params: list[Params] = []
  349. for symbol in ("ETH-USDT-SWAP", "BTC-USDT-SWAP"):
  350. for bar in ("1H", "4H", "1D"):
  351. hold_by_bar = {"1H": (48,), "4H": (24,), "1D": (10,)}[bar]
  352. trend_by_bar = {"1H": (200, 400), "4H": (100, 200), "1D": (80, 160)}[bar]
  353. momentum_by_bar = {"1H": (24,), "4H": (12,), "1D": (10,)}[bar]
  354. for family in ("rsi", "bb_squeeze", "range_band"):
  355. for rsi_length in ((2, 4) if family == "rsi" else (4,)):
  356. for rsi_entry in ((5.0, 8.0, 12.0) if family == "rsi" else (20.0,)):
  357. for bb_length in ((20, 40) if family != "rsi" else (20,)):
  358. for bb_mult in ((2.0,) if family != "range_band" else (1.8, 2.2)):
  359. for kc_mult in ((1.5,) if family == "bb_squeeze" else (1.5,)):
  360. for btc_trend in trend_by_bar:
  361. for btc_momentum in momentum_by_bar:
  362. for max_hold in hold_by_bar:
  363. params.append(
  364. Params(
  365. family=family,
  366. symbol=symbol,
  367. bar=bar,
  368. side_mode="long",
  369. rsi_length=rsi_length,
  370. rsi_entry=rsi_entry,
  371. rsi_exit=55.0,
  372. bb_length=bb_length,
  373. bb_mult=bb_mult,
  374. kc_mult=kc_mult,
  375. squeeze_lookback=8,
  376. vol_lookback=24,
  377. vol_quantile_lookback=240,
  378. vol_quantile=0.75,
  379. btc_trend_sma=btc_trend,
  380. btc_momentum_lookback=btc_momentum,
  381. btc_min_momentum=0.0,
  382. exit_midline=family != "rsi",
  383. max_hold_bars=max_hold,
  384. stop_loss_pct=0.08 if bar == "1D" else 0.045,
  385. cooldown_bars=max(2, max_hold // 4),
  386. )
  387. )
  388. return params
  389. def markdown_report(paths: list[Path], totals: pd.DataFrame, horizon: pd.DataFrame, monthly: pd.DataFrame, command: str) -> str:
  390. top = totals.head(20)
  391. top_names = set(top.head(5)["name"])
  392. selected_horizons = horizon[horizon["name"].isin(top_names)]
  393. selected_monthly = monthly[monthly["name"].isin(top_names)]
  394. lines = [
  395. "# Mean reversion strategy expansion",
  396. "",
  397. f"Run command: `{command}`",
  398. "",
  399. "Output files:",
  400. *[f"- `{path}`" for path in paths],
  401. "",
  402. "Scope: ETH/BTC 1H/4H/1D mean-reversion search from 15m OKX cache.",
  403. "Families: RSI2/RSI4 pullback, Bollinger/Keltner squeeze reversal, and range-band reversal.",
  404. f"Cost: 0.04% single-side taker, roundtrip cost on margin at {LEVERAGE}x = {ROUNDTRIP_TAKER_COST_ON_MARGIN:.6f}.",
  405. "",
  406. "## Top candidates",
  407. "",
  408. markdown_table(
  409. top[
  410. [
  411. "name",
  412. "symbol",
  413. "bar",
  414. "family",
  415. "trades",
  416. "net_total_return",
  417. "net_annualized_return",
  418. "net_max_drawdown",
  419. "net_calmar",
  420. "win_rate",
  421. "payoff_ratio",
  422. "return_3y",
  423. "return_1y",
  424. "return_6m",
  425. "return_3m",
  426. "worst_month_return",
  427. ]
  428. ]
  429. ),
  430. "",
  431. "## Horizon metrics for top five",
  432. "",
  433. markdown_table(
  434. selected_horizons[
  435. [
  436. "name",
  437. "horizon",
  438. "horizon_start",
  439. "horizon_end",
  440. "net_total_return",
  441. "net_annualized_return",
  442. "net_max_drawdown",
  443. "net_calmar",
  444. "trades",
  445. "win_rate",
  446. "payoff_ratio",
  447. ]
  448. ]
  449. ),
  450. "",
  451. "## Monthly returns for top five",
  452. "",
  453. markdown_table(selected_monthly[["name", "month", "return", "start_equity", "end_equity"]].tail(120)),
  454. ]
  455. return "\n".join(lines) + "\n"
  456. def main() -> int:
  457. parser = argparse.ArgumentParser()
  458. parser.add_argument("--years", type=float, default=YEARS)
  459. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  460. parser.add_argument("--max-candidates", type=int, default=0)
  461. args = parser.parse_args()
  462. base_data = {
  463. symbol: load_base_candles(symbol, args.years)
  464. for symbol in ("ETH-USDT-SWAP", "BTC-USDT-SWAP")
  465. }
  466. data = {
  467. (symbol, bar): resample_candles(base_data[symbol], bar)
  468. for symbol in ("ETH-USDT-SWAP", "BTC-USDT-SWAP")
  469. for bar in ("1H", "4H", "1D")
  470. }
  471. params_grid = build_params()
  472. if args.max_candidates:
  473. params_grid = params_grid[: args.max_candidates]
  474. total_rows: list[dict[str, object]] = []
  475. horizon_output: list[dict[str, object]] = []
  476. monthly_frames: list[pd.DataFrame] = []
  477. for index, params in enumerate(params_grid, start=1):
  478. candles, btc_candles = explore.align_pair_candles(data[(params.symbol, params.bar)], data[("BTC-USDT-SWAP", params.bar)])
  479. result = run_segment(candles, btc_candles, params)
  480. if not result.equity_curve:
  481. continue
  482. frame = explore.cost_adjusted_trade_equity_frame(result, ROUNDTRIP_TAKER_COST_ON_MARGIN)
  483. start = pd.to_datetime(result.equity_curve[0]["ts"], unit="ms", utc=True)
  484. end = pd.to_datetime(result.equity_curve[-1]["ts"], unit="ms", utc=True)
  485. daily = daily_equity(frame, start, end)
  486. metrics = metrics_from_daily_equity(daily)
  487. stats = trade_stats_for_window(result, ROUNDTRIP_TAKER_COST_ON_MARGIN, daily.index[0], daily.index[-1])
  488. monthly = monthly_rows(params.name, daily)
  489. current_horizons = horizon_rows(params.name, result, ROUNDTRIP_TAKER_COST_ON_MARGIN, daily)
  490. horizon_by_label = {str(row["horizon"]): float(row["net_total_return"]) for row in current_horizons}
  491. row = {
  492. "name": params.name,
  493. "symbol": params.symbol,
  494. "bar": params.bar,
  495. "family": params.family,
  496. "eligible_sample": stats["trades"] >= 20,
  497. "roundtrip_cost_on_margin": ROUNDTRIP_TAKER_COST_ON_MARGIN,
  498. "first_candle": start.strftime("%Y-%m-%d %H:%M"),
  499. "last_candle": end.strftime("%Y-%m-%d %H:%M"),
  500. "years": (end - start).total_seconds() / 86_400 / 365,
  501. "gross_total_return": result.total_return,
  502. "gross_max_drawdown_mark_to_market": result.max_drawdown,
  503. "worst_month_return": float(monthly["return"].min()),
  504. "return_3y": horizon_by_label.get("3y", 0.0),
  505. "return_1y": horizon_by_label.get("1y", 0.0),
  506. "return_6m": horizon_by_label.get("6m", 0.0),
  507. "return_3m": horizon_by_label.get("3m", 0.0),
  508. **params.__dict__,
  509. **stats,
  510. **metrics,
  511. }
  512. total_rows.append(row)
  513. horizon_output.extend(current_horizons)
  514. monthly_frames.append(monthly)
  515. print(f"done {index}/{len(params_grid)} {params.name}", flush=True)
  516. totals = pd.DataFrame(total_rows)
  517. totals["eligible_candidate"] = totals["eligible_sample"] & (totals["net_total_return"] > 0.0)
  518. totals = totals.sort_values(
  519. ["eligible_candidate", "net_calmar", "net_annualized_return", "net_max_drawdown", "trades"],
  520. ascending=[False, False, False, True, True],
  521. )
  522. horizon = pd.DataFrame(horizon_output)
  523. horizon["horizon"] = pd.Categorical(horizon["horizon"], categories=["full", "3y", "1y", "6m", "3m"], ordered=True)
  524. horizon = horizon.sort_values(["name", "horizon"])
  525. monthly = pd.concat(monthly_frames, ignore_index=True)
  526. top_names = set(totals.head(25)["name"])
  527. monthly_top = monthly[monthly["name"].isin(top_names)].sort_values(["name", "month"])
  528. args.output_dir.mkdir(parents=True, exist_ok=True)
  529. totals_path = args.output_dir / f"{PREFIX}-totals.csv"
  530. horizon_path = args.output_dir / f"{PREFIX}-horizons.csv"
  531. monthly_path = args.output_dir / f"{PREFIX}-monthly-returns.csv"
  532. best_path = args.output_dir / f"{PREFIX}-best.json"
  533. report_path = args.output_dir / f"{PREFIX}-report.md"
  534. totals.to_csv(totals_path, index=False)
  535. horizon.to_csv(horizon_path, index=False)
  536. monthly_top.to_csv(monthly_path, index=False)
  537. best_path.write_text(json.dumps(totals.head(3).to_dict(orient="records"), indent=2), encoding="utf-8")
  538. paths = [totals_path, horizon_path, monthly_path, best_path, report_path]
  539. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --years {args.years}"
  540. report_path.write_text(markdown_report(paths, totals, horizon, monthly_top, command), encoding="utf-8")
  541. print(totals.head(10).to_string(index=False))
  542. return 0
  543. if __name__ == "__main__":
  544. raise SystemExit(main())