search_eth_partial_take_runner.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711
  1. from __future__ import annotations
  2. import argparse
  3. import sys
  4. from dataclasses import dataclass
  5. from pathlib import Path
  6. import pandas as pd
  7. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  8. from okx_codex_trader.models import Candle
  9. from okx_codex_trader.sampled_report import trade_equity
  10. ETH_SYMBOL = "ETH-USDT-SWAP"
  11. BTC_SYMBOL = "BTC-USDT-SWAP"
  12. BAR = "15m"
  13. YEARS = 10.0
  14. LEVERAGE = 3
  15. INITIAL_EQUITY = 10_000.0
  16. ROUNDTRIP_COST = 0.0021
  17. DATA_DIR = Path("data/okx-candles")
  18. OUTPUT_DIR = Path("reports/eth-exploration")
  19. PREFIX = "eth-partial-take-runner"
  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. ("30d", pd.DateOffset(days=30)),
  27. )
  28. @dataclass(frozen=True)
  29. class Variant:
  30. name: str
  31. band_length: int
  32. bandwidth_lookback: int
  33. bandwidth_quantile: float
  34. side_mode: str
  35. btc_filter: str
  36. eth_vol_cap: float
  37. stop_loss_pct: float
  38. cooldown_bars: int
  39. take_trigger_pct: float
  40. take_fraction: float
  41. trail_trigger_pct: float
  42. trail_giveback_pct: float
  43. readd_drawdown_pct: float | None
  44. reentry_after_exit: bool
  45. def _format_ts(ts: int) -> str:
  46. return pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
  47. def load_candles(symbol: str, bar: str) -> list[Candle]:
  48. frame = pd.read_csv(DATA_DIR / symbol / f"{bar}.csv")
  49. return [
  50. Candle(
  51. symbol=symbol,
  52. ts=int(row.ts),
  53. open=float(row.open),
  54. high=float(row.high),
  55. low=float(row.low),
  56. close=float(row.close),
  57. volume=float(row.volume),
  58. )
  59. for row in frame.itertuples(index=False)
  60. ]
  61. def align_pair(eth: list[Candle], btc: list[Candle]) -> tuple[list[Candle], list[Candle]]:
  62. btc_by_ts = {candle.ts: candle for candle in btc}
  63. eth_out: list[Candle] = []
  64. btc_out: list[Candle] = []
  65. for candle in eth:
  66. other = btc_by_ts.get(candle.ts)
  67. if other is not None:
  68. eth_out.append(candle)
  69. btc_out.append(other)
  70. return eth_out, btc_out
  71. def build_frame(eth: list[Candle], btc: list[Candle], variant: Variant) -> pd.DataFrame:
  72. frame = pd.DataFrame(
  73. {
  74. "ts": [candle.ts for candle in eth],
  75. "open": [candle.open for candle in eth],
  76. "high": [candle.high for candle in eth],
  77. "low": [candle.low for candle in eth],
  78. "close": [candle.close for candle in eth],
  79. "btc_close": [candle.close for candle in btc],
  80. }
  81. )
  82. close = frame["close"].astype(float)
  83. btc_close = frame["btc_close"].astype(float)
  84. middle = close.rolling(variant.band_length).mean()
  85. stdev = close.rolling(variant.band_length).std(ddof=0)
  86. upper = middle + 2.0 * stdev
  87. lower = middle - 2.0 * stdev
  88. bandwidth = (upper - lower) / middle
  89. frame["middle"] = middle
  90. frame["upper"] = upper
  91. frame["lower"] = lower
  92. frame["bandwidth"] = bandwidth
  93. frame["bandwidth_threshold"] = bandwidth.rolling(variant.bandwidth_lookback).quantile(variant.bandwidth_quantile)
  94. frame["btc_sma"] = btc_close.rolling(480).mean()
  95. frame["btc_momentum"] = btc_close / btc_close.shift(96) - 1.0
  96. frame["eth_vol_96"] = close.pct_change().rolling(96).std(ddof=0)
  97. return frame
  98. def position_margin(position: dict[str, object] | None) -> float:
  99. if position is None:
  100. return 0.0
  101. return sum(float(leg["margin"]) for leg in position["legs"]) # type: ignore[index]
  102. def weighted_entry(position: dict[str, object]) -> float:
  103. margin = position_margin(position)
  104. if margin <= 0.0:
  105. return 0.0
  106. return sum(float(leg["margin"]) * float(leg["entry_price"]) for leg in position["legs"]) / margin # type: ignore[index]
  107. def position_value(position: dict[str, object] | None, price: float) -> float:
  108. if position is None:
  109. return 0.0
  110. side = str(position["side"])
  111. return sum(
  112. trade_equity(
  113. side=side,
  114. margin_used=float(leg["margin"]),
  115. entry_price=float(leg["entry_price"]),
  116. exit_price=price,
  117. leverage=LEVERAGE,
  118. )
  119. for leg in position["legs"] # type: ignore[index]
  120. )
  121. def account_equity(cash: float, position: dict[str, object] | None, price: float) -> float:
  122. return cash + position_value(position, price)
  123. def close_fraction(
  124. *,
  125. cash: float,
  126. position: dict[str, object],
  127. fraction: float,
  128. exit_price: float,
  129. ts: int,
  130. reason: str,
  131. trades: list[dict[str, object]],
  132. ) -> tuple[float, dict[str, object] | None]:
  133. side = str(position["side"])
  134. entry_price = weighted_entry(position)
  135. closed_margin = 0.0
  136. gross_exit = 0.0
  137. remaining: list[dict[str, float]] = []
  138. for leg in position["legs"]: # type: ignore[index]
  139. margin = float(leg["margin"])
  140. close_margin = margin * fraction
  141. keep_margin = margin - close_margin
  142. if close_margin > 0.0:
  143. closed_margin += close_margin
  144. gross_exit += trade_equity(
  145. side=side,
  146. margin_used=close_margin,
  147. entry_price=float(leg["entry_price"]),
  148. exit_price=exit_price,
  149. leverage=LEVERAGE,
  150. )
  151. if keep_margin > 1e-9:
  152. remaining.append({"margin": keep_margin, "entry_price": float(leg["entry_price"])})
  153. if closed_margin <= 0.0:
  154. return cash, position
  155. net_exit = gross_exit - closed_margin * ROUNDTRIP_COST
  156. trades.append(
  157. {
  158. "side": "Long" if side == "long" else "Short",
  159. "entry_time": _format_ts(int(position["entry_ts"])),
  160. "exit_time": _format_ts(ts),
  161. "exit_ts": ts,
  162. "entry_price": round(entry_price, 4),
  163. "exit_price": round(exit_price, 4),
  164. "margin": round(closed_margin, 4),
  165. "pnl": round(net_exit - closed_margin, 4),
  166. "return_pct": (net_exit / closed_margin - 1.0) * 100.0,
  167. "exit_reason": reason,
  168. }
  169. )
  170. cash += net_exit
  171. if not remaining:
  172. return cash, None
  173. updated = dict(position)
  174. updated["legs"] = remaining
  175. return cash, updated
  176. def add_leg(position: dict[str, object], margin: float, price: float) -> dict[str, object]:
  177. updated = dict(position)
  178. legs = list(updated["legs"]) # type: ignore[arg-type]
  179. legs.append({"margin": margin, "entry_price": price})
  180. updated["legs"] = legs
  181. updated["partial_taken"] = False
  182. updated["peak_profit_pct"] = 0.0
  183. return updated
  184. def profit_pct(position: dict[str, object], price: float) -> float:
  185. margin = position_margin(position)
  186. if margin <= 0.0:
  187. return 0.0
  188. return position_value(position, price) / margin - 1.0
  189. def intrabar_profit_pct(position: dict[str, object], row: pd.Series) -> float:
  190. side = str(position["side"])
  191. price = float(row["high"]) if side == "long" else float(row["low"])
  192. return profit_pct(position, price)
  193. def stop_hit(position: dict[str, object], row: pd.Series) -> tuple[bool, float]:
  194. side = str(position["side"])
  195. stop = float(position["stop_price"])
  196. if side == "long":
  197. if float(row["open"]) <= stop:
  198. return True, float(row["open"])
  199. return float(row["low"]) <= stop, stop
  200. if float(row["open"]) >= stop:
  201. return True, float(row["open"])
  202. return float(row["high"]) >= stop, stop
  203. def trail_hit(position: dict[str, object], row: pd.Series, variant: Variant) -> tuple[bool, float]:
  204. peak = float(position["peak_profit_pct"])
  205. if peak < variant.trail_trigger_pct:
  206. return False, 0.0
  207. side = str(position["side"])
  208. entry = weighted_entry(position)
  209. if side == "long":
  210. trail_price = entry * (1.0 + (peak - variant.trail_giveback_pct) / LEVERAGE)
  211. if float(row["open"]) <= trail_price:
  212. return True, float(row["open"])
  213. return float(row["low"]) <= trail_price, trail_price
  214. trail_price = entry * (1.0 - (peak - variant.trail_giveback_pct) / LEVERAGE)
  215. if float(row["open"]) >= trail_price:
  216. return True, float(row["open"])
  217. return float(row["high"]) >= trail_price, trail_price
  218. def pass_btc_filter(row: pd.Series, variant: Variant) -> bool:
  219. if variant.btc_filter == "none":
  220. return True
  221. if variant.btc_filter == "btc-up":
  222. return float(row["btc_close"]) > float(row["btc_sma"])
  223. if variant.btc_filter == "btc-up-momo":
  224. return float(row["btc_close"]) > float(row["btc_sma"]) and float(row["btc_momentum"]) > 0.0
  225. raise ValueError(f"unknown btc_filter: {variant.btc_filter}")
  226. def run_variant(frame: pd.DataFrame, variant: Variant) -> dict[str, object]:
  227. warmup = max(variant.band_length, variant.bandwidth_lookback, 480, 96)
  228. cash = INITIAL_EQUITY
  229. position: dict[str, object] | None = None
  230. pending_entry_side: str | None = None
  231. pending_partial_take = False
  232. pending_readd = False
  233. pending_reentry_side: str | None = None
  234. cooldown_until = -1
  235. trades: list[dict[str, object]] = []
  236. events: list[dict[str, object]] = []
  237. equity_rows: list[dict[str, object]] = []
  238. for index in range(warmup, len(frame)):
  239. row = frame.iloc[index]
  240. ts = int(row["ts"])
  241. open_price = float(row["open"])
  242. close_price = float(row["close"])
  243. acted = False
  244. if pending_reentry_side is not None and position is None and index >= cooldown_until and cash > 1.0:
  245. margin = cash
  246. cash -= margin
  247. position = {
  248. "side": pending_reentry_side,
  249. "entry_ts": ts,
  250. "legs": [{"margin": margin, "entry_price": open_price}],
  251. "stop_price": open_price * (1.0 - variant.stop_loss_pct if pending_reentry_side == "long" else 1.0 + variant.stop_loss_pct),
  252. "partial_taken": False,
  253. "peak_profit_pct": 0.0,
  254. }
  255. events.append({"ts": ts, "event": f"reentry_{pending_reentry_side}", "margin": margin})
  256. pending_reentry_side = None
  257. acted = True
  258. if not acted and pending_partial_take and position is not None:
  259. before = position_margin(position)
  260. cash, position = close_fraction(
  261. cash=cash,
  262. position=position,
  263. fraction=variant.take_fraction,
  264. exit_price=open_price,
  265. ts=ts,
  266. reason="partial_take",
  267. trades=trades,
  268. )
  269. if position is not None:
  270. position["partial_taken"] = True
  271. events.append({"ts": ts, "event": "partial_take", "margin": before * variant.take_fraction})
  272. pending_partial_take = False
  273. acted = True
  274. if not acted and pending_readd and position is not None:
  275. equity = account_equity(cash, position, open_price)
  276. missing = max(0.0, equity - position_margin(position))
  277. add = min(cash, missing)
  278. if add > 1.0:
  279. cash -= add
  280. position = add_leg(position, add, open_price)
  281. events.append({"ts": ts, "event": "t_readd", "margin": add})
  282. pending_readd = False
  283. acted = True
  284. if not acted and pending_entry_side is not None and position is None and cash > 1.0:
  285. margin = cash
  286. cash -= margin
  287. position = {
  288. "side": pending_entry_side,
  289. "entry_ts": ts,
  290. "legs": [{"margin": margin, "entry_price": open_price}],
  291. "stop_price": open_price * (1.0 - variant.stop_loss_pct if pending_entry_side == "long" else 1.0 + variant.stop_loss_pct),
  292. "partial_taken": False,
  293. "peak_profit_pct": 0.0,
  294. }
  295. events.append({"ts": ts, "event": f"entry_{pending_entry_side}", "margin": margin})
  296. pending_entry_side = None
  297. acted = True
  298. if not acted and position is not None:
  299. hit, exit_price = stop_hit(position, row)
  300. if hit:
  301. side = str(position["side"])
  302. before = position_margin(position)
  303. cash, position = close_fraction(
  304. cash=cash,
  305. position=position,
  306. fraction=1.0,
  307. exit_price=exit_price,
  308. ts=ts,
  309. reason="stop",
  310. trades=trades,
  311. )
  312. events.append({"ts": ts, "event": "stop", "margin": before})
  313. cooldown_until = index + variant.cooldown_bars
  314. if variant.reentry_after_exit:
  315. pending_reentry_side = side
  316. acted = True
  317. if not acted and position is not None:
  318. position["peak_profit_pct"] = max(float(position["peak_profit_pct"]), intrabar_profit_pct(position, row))
  319. hit, exit_price = trail_hit(position, row, variant)
  320. if hit:
  321. side = str(position["side"])
  322. before = position_margin(position)
  323. cash, position = close_fraction(
  324. cash=cash,
  325. position=position,
  326. fraction=1.0,
  327. exit_price=exit_price,
  328. ts=ts,
  329. reason="trail",
  330. trades=trades,
  331. )
  332. events.append({"ts": ts, "event": "trail", "margin": before})
  333. cooldown_until = index + variant.cooldown_bars
  334. if variant.reentry_after_exit:
  335. pending_reentry_side = side
  336. acted = True
  337. equity = account_equity(cash, position, close_price)
  338. equity_rows.append(
  339. {
  340. "ts": ts,
  341. "time": pd.to_datetime(ts, unit="ms", utc=True),
  342. "equity": equity,
  343. "cash": cash,
  344. "position_margin": position_margin(position),
  345. "position_side": "flat" if position is None else str(position["side"]),
  346. }
  347. )
  348. if index == len(frame) - 1 or equity <= 0.0:
  349. continue
  350. needed = ("middle", "upper", "lower", "bandwidth", "bandwidth_threshold", "btc_sma", "btc_momentum", "eth_vol_96")
  351. if any(float(row[key]) != float(row[key]) for key in needed):
  352. continue
  353. if acted:
  354. continue
  355. if position is not None:
  356. if not bool(position["partial_taken"]) and profit_pct(position, close_price) >= variant.take_trigger_pct:
  357. pending_partial_take = True
  358. continue
  359. if (
  360. variant.readd_drawdown_pct is not None
  361. and bool(position["partial_taken"])
  362. and profit_pct(position, close_price) <= float(position["peak_profit_pct"]) - variant.readd_drawdown_pct
  363. ):
  364. pending_readd = True
  365. continue
  366. if index < cooldown_until or float(row["eth_vol_96"]) > variant.eth_vol_cap or not pass_btc_filter(row, variant):
  367. continue
  368. if float(row["bandwidth"]) > float(row["bandwidth_threshold"]):
  369. continue
  370. if close_price > float(row["upper"]):
  371. pending_entry_side = "long"
  372. elif variant.side_mode == "both" and close_price < float(row["lower"]):
  373. pending_entry_side = "short"
  374. return {
  375. "variant": variant,
  376. "equity": pd.DataFrame(equity_rows),
  377. "trades": pd.DataFrame(trades),
  378. "events": pd.DataFrame(events),
  379. }
  380. def max_drawdown(equity: pd.Series) -> float:
  381. if equity.empty:
  382. return 0.0
  383. peak = equity.cummax()
  384. return float(((peak - equity) / peak).max())
  385. def scoped_metrics(equity: pd.DataFrame, trades: pd.DataFrame, label: str, offset: pd.DateOffset | None) -> dict[str, object]:
  386. end = pd.Timestamp(equity["time"].iloc[-1])
  387. if offset is None:
  388. scoped = equity[["time", "equity"]].copy()
  389. else:
  390. cutoff = end - offset
  391. before = equity[equity["time"] <= cutoff]
  392. start_equity = float(before["equity"].iloc[-1]) if len(before) else float(equity["equity"].iloc[0])
  393. after = equity[equity["time"] > cutoff]
  394. scoped = pd.concat([pd.DataFrame([{"time": cutoff, "equity": start_equity}]), after[["time", "equity"]]], ignore_index=True)
  395. start = pd.Timestamp(scoped["time"].iloc[0])
  396. years = max((end - start).total_seconds() / 86_400 / 365, 1e-9)
  397. total_return = float(scoped["equity"].iloc[-1] / scoped["equity"].iloc[0] - 1.0)
  398. annualized = (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 else -1.0
  399. scoped_trades = trades.iloc[0:0]
  400. if not trades.empty:
  401. exit_times = pd.to_datetime(trades["exit_ts"], unit="ms", utc=True)
  402. scoped_trades = trades[(exit_times > start) & (exit_times <= end)]
  403. returns = scoped_trades["return_pct"].astype(float) / 100.0 if len(scoped_trades) else pd.Series(dtype=float)
  404. wins = returns[returns > 0.0]
  405. losses = returns[returns < 0.0]
  406. gains = float(wins.sum())
  407. loss_sum = abs(float(losses.sum()))
  408. avg_win = float(wins.mean()) if len(wins) else 0.0
  409. avg_loss = abs(float(losses.mean())) if len(losses) else 0.0
  410. dd = max_drawdown(scoped["equity"].astype(float))
  411. reasons = scoped_trades["exit_reason"].value_counts().to_dict() if len(scoped_trades) else {}
  412. return {
  413. "horizon": label,
  414. "start": start.strftime("%Y-%m-%d %H:%M"),
  415. "end": end.strftime("%Y-%m-%d %H:%M"),
  416. "total_return": total_return,
  417. "annualized_return": annualized,
  418. "max_drawdown": dd,
  419. "calmar": annualized / dd if dd else 0.0,
  420. "trades": int(len(scoped_trades)),
  421. "win_rate": float((returns > 0.0).mean()) if len(returns) else 0.0,
  422. "profit_factor": gains / loss_sum if loss_sum > 0.0 else 0.0,
  423. "payoff_ratio": avg_win / avg_loss if avg_loss > 0.0 else 0.0,
  424. "partial_take_exits": int(reasons.get("partial_take", 0)),
  425. "trail_exits": int(reasons.get("trail", 0)),
  426. "stop_exits": int(reasons.get("stop", 0)),
  427. }
  428. def build_variants() -> list[Variant]:
  429. variants: list[Variant] = []
  430. entries = (
  431. (48, 960, 0.25, "both", "none", 0.006, 0.010),
  432. (48, 960, 0.20, "both", "none", 0.006, 0.010),
  433. (96, 960, 0.25, "both", "btc-up", 0.006, 0.012),
  434. (96, 960, 0.25, "both", "btc-up-momo", 0.006, 0.012),
  435. )
  436. overlays = (
  437. (0.012, 0.30, 0.018, 0.010, None, False),
  438. (0.012, 0.30, 0.018, 0.010, 0.006, False),
  439. (0.012, 0.30, 0.018, 0.010, 0.006, True),
  440. (0.018, 0.25, 0.024, 0.012, None, False),
  441. (0.018, 0.25, 0.024, 0.012, 0.008, False),
  442. (0.018, 0.25, 0.024, 0.012, 0.008, True),
  443. (0.024, 0.20, 0.030, 0.015, None, False),
  444. (0.024, 0.20, 0.030, 0.015, 0.010, True),
  445. )
  446. for band, lookback, quantile, side_mode, btc_filter, vol_cap, stop in entries:
  447. for take, fraction, trail, giveback, readd, reentry in overlays:
  448. name = (
  449. f"bb-l{band}-bw{lookback}-q{quantile:g}-{side_mode}-{btc_filter}"
  450. f"-sl{stop:g}-pt{take:g}-f{fraction:g}-tr{trail:g}-{giveback:g}"
  451. f"-readd{'none' if readd is None else f'{readd:g}'}-reentry{int(reentry)}"
  452. )
  453. variants.append(
  454. Variant(
  455. name=name,
  456. band_length=band,
  457. bandwidth_lookback=lookback,
  458. bandwidth_quantile=quantile,
  459. side_mode=side_mode,
  460. btc_filter=btc_filter,
  461. eth_vol_cap=vol_cap,
  462. stop_loss_pct=stop,
  463. cooldown_bars=24,
  464. take_trigger_pct=take,
  465. take_fraction=fraction,
  466. trail_trigger_pct=trail,
  467. trail_giveback_pct=giveback,
  468. readd_drawdown_pct=readd,
  469. reentry_after_exit=reentry,
  470. )
  471. )
  472. return variants
  473. def markdown_table(frame: pd.DataFrame) -> str:
  474. def cell(value: object) -> str:
  475. if isinstance(value, float):
  476. return f"{value:.6g}"
  477. return str(value).replace("|", "\\|")
  478. rows = [list(frame.columns), ["---" for _ in frame.columns]]
  479. rows.extend(frame.astype(object).where(pd.notna(frame), "").values.tolist())
  480. return "\n".join("| " + " | ".join(cell(value) for value in row) + " |" for row in rows)
  481. def write_report(summary: pd.DataFrame, horizons: pd.DataFrame, events: pd.DataFrame, paths: list[Path], command: str) -> str:
  482. ranked = summary.sort_values(["recent_min_return", "recent_min_calmar", "full_calmar"], ascending=[False, False, False])
  483. best_name = str(ranked.iloc[0]["variant"])
  484. best_horizons = horizons[horizons["variant"] == best_name]
  485. return (
  486. "# ETH partial-take runner exploration\n\n"
  487. "Scope: offline local OKX 15m candle research only. No live executor, private API, env, or service file was touched.\n\n"
  488. f"Run command: `{command}`\n\n"
  489. "Model: BB squeeze entry, single-position contract-equity approximation, partial profit take, retained runner with trailing exit, optional drawdown re-add, optional post-exit re-entry. Each candle can execute at most one pending or risk action.\n\n"
  490. "Output files:\n"
  491. + "\n".join(f"- `{path}`" for path in paths)
  492. + "\n\n"
  493. "## Ranking\n\n"
  494. + markdown_table(
  495. ranked[
  496. [
  497. "variant",
  498. "full_total_return",
  499. "full_annualized_return",
  500. "full_max_drawdown",
  501. "full_calmar",
  502. "recent_min_return",
  503. "recent_min_calmar",
  504. "trades",
  505. "win_rate",
  506. "profit_factor",
  507. "payoff_ratio",
  508. "partial_take_events",
  509. "t_readd_events",
  510. "reentry_events",
  511. ]
  512. ].head(12)
  513. )
  514. + "\n\n"
  515. "## Best Variant Horizons\n\n"
  516. + markdown_table(
  517. best_horizons[
  518. [
  519. "horizon",
  520. "total_return",
  521. "annualized_return",
  522. "max_drawdown",
  523. "calmar",
  524. "trades",
  525. "win_rate",
  526. "profit_factor",
  527. "payoff_ratio",
  528. "partial_take_exits",
  529. "trail_exits",
  530. "stop_exits",
  531. ]
  532. ]
  533. )
  534. + "\n\n"
  535. "## Event Counts\n\n"
  536. + markdown_table(events.sort_values(["partial_take_events", "t_readd_events"], ascending=[False, False]).head(12))
  537. + "\n"
  538. )
  539. def main() -> int:
  540. parser = argparse.ArgumentParser()
  541. parser.add_argument("--bar", default=BAR)
  542. parser.add_argument("--years", type=float, default=YEARS)
  543. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  544. args = parser.parse_args()
  545. eth, btc = align_pair(load_candles(ETH_SYMBOL, args.bar), load_candles(BTC_SYMBOL, args.bar))
  546. requested_bars = int(args.years * 365 * 24 * 60 / 15)
  547. eth = eth[-requested_bars:]
  548. btc = btc[-requested_bars:]
  549. summary_rows: list[dict[str, object]] = []
  550. horizon_rows: list[dict[str, object]] = []
  551. event_rows: list[dict[str, object]] = []
  552. trade_frames: list[pd.DataFrame] = []
  553. variants = build_variants()
  554. for index, variant in enumerate(variants, start=1):
  555. frame = build_frame(eth, btc, variant)
  556. result = run_variant(frame, variant)
  557. equity = result["equity"]
  558. trades = result["trades"]
  559. events = result["events"]
  560. if equity.empty:
  561. continue
  562. if not trades.empty:
  563. trades = trades.copy()
  564. trades["variant"] = variant.name
  565. trade_frames.append(trades)
  566. horizon_metrics = [scoped_metrics(equity, trades, label, offset) for label, offset in HORIZONS]
  567. for row in horizon_metrics:
  568. horizon_rows.append({"variant": variant.name, **row})
  569. by_horizon = {row["horizon"]: row for row in horizon_metrics}
  570. event_counts = events["event"].value_counts().to_dict() if not events.empty else {}
  571. event_row = {
  572. "variant": variant.name,
  573. "entry_events": int(sum(value for key, value in event_counts.items() if str(key).startswith("entry_"))),
  574. "partial_take_events": int(event_counts.get("partial_take", 0)),
  575. "t_readd_events": int(event_counts.get("t_readd", 0)),
  576. "reentry_events": int(sum(value for key, value in event_counts.items() if str(key).startswith("reentry_"))),
  577. "trail_events": int(event_counts.get("trail", 0)),
  578. "stop_events": int(event_counts.get("stop", 0)),
  579. }
  580. event_rows.append(event_row)
  581. recent_returns = [float(by_horizon[label]["total_return"]) for label in ("1y", "6m", "3m", "30d")]
  582. recent_calmars = [float(by_horizon[label]["calmar"]) for label in ("1y", "6m", "3m", "30d")]
  583. summary_rows.append(
  584. {
  585. "variant": variant.name,
  586. "bar": args.bar,
  587. "first_candle": _format_ts(eth[0].ts),
  588. "last_candle": _format_ts(eth[-1].ts),
  589. "band_length": variant.band_length,
  590. "bandwidth_lookback": variant.bandwidth_lookback,
  591. "bandwidth_quantile": variant.bandwidth_quantile,
  592. "side_mode": variant.side_mode,
  593. "btc_filter": variant.btc_filter,
  594. "eth_vol_cap": variant.eth_vol_cap,
  595. "stop_loss_pct": variant.stop_loss_pct,
  596. "take_trigger_pct": variant.take_trigger_pct,
  597. "take_fraction": variant.take_fraction,
  598. "trail_trigger_pct": variant.trail_trigger_pct,
  599. "trail_giveback_pct": variant.trail_giveback_pct,
  600. "readd_drawdown_pct": variant.readd_drawdown_pct,
  601. "reentry_after_exit": variant.reentry_after_exit,
  602. "full_total_return": by_horizon["full"]["total_return"],
  603. "full_annualized_return": by_horizon["full"]["annualized_return"],
  604. "full_max_drawdown": by_horizon["full"]["max_drawdown"],
  605. "full_calmar": by_horizon["full"]["calmar"],
  606. "trades": by_horizon["full"]["trades"],
  607. "win_rate": by_horizon["full"]["win_rate"],
  608. "profit_factor": by_horizon["full"]["profit_factor"],
  609. "payoff_ratio": by_horizon["full"]["payoff_ratio"],
  610. "recent_min_return": min(recent_returns),
  611. "recent_min_calmar": min(recent_calmars),
  612. **event_row,
  613. }
  614. )
  615. print(f"done {index}/{len(variants)} {variant.name}", flush=True)
  616. summary = pd.DataFrame(summary_rows).sort_values(["recent_min_return", "recent_min_calmar", "full_calmar"], ascending=[False, False, False])
  617. horizons = pd.DataFrame(horizon_rows)
  618. horizons["horizon"] = pd.Categorical(horizons["horizon"], categories=[label for label, _ in HORIZONS], ordered=True)
  619. horizons = horizons.sort_values(["variant", "horizon"])
  620. events = pd.DataFrame(event_rows)
  621. trades_out = pd.concat(trade_frames, ignore_index=True) if trade_frames else pd.DataFrame()
  622. args.output_dir.mkdir(parents=True, exist_ok=True)
  623. summary_path = args.output_dir / f"{PREFIX}-summary.csv"
  624. horizons_path = args.output_dir / f"{PREFIX}-horizons.csv"
  625. events_path = args.output_dir / f"{PREFIX}-events.csv"
  626. trades_path = args.output_dir / f"{PREFIX}-trades.csv"
  627. report_path = args.output_dir / f"{PREFIX}-report.md"
  628. paths = [summary_path, horizons_path, events_path, trades_path, report_path]
  629. summary.to_csv(summary_path, index=False)
  630. horizons.to_csv(horizons_path, index=False)
  631. events.to_csv(events_path, index=False)
  632. trades_out.to_csv(trades_path, index=False)
  633. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --bar {args.bar} --years {args.years} --output-dir {args.output_dir.as_posix()}"
  634. report_path.write_text(write_report(summary, horizons, events, paths, command), encoding="utf-8")
  635. print(summary.head(10).to_string(index=False))
  636. return 0
  637. if __name__ == "__main__":
  638. raise SystemExit(main())