search_btc_eth_short_overlay_variants.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728
  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. OUTPUT_DIR = Path("reports/ultrashort")
  13. PREFIX = "short-overlay"
  14. YEARS = 10.0
  15. INITIAL_EQUITY = explore.INITIAL_EQUITY
  16. LEVERAGE = explore.LEVERAGE
  17. PRIMARY_COST = "maker_taker"
  18. COSTS = {
  19. "maker_taker": 0.0021,
  20. "taker_taker": 0.0030,
  21. }
  22. HORIZONS = (
  23. ("full", None),
  24. ("3y", pd.DateOffset(years=3)),
  25. ("1y", pd.DateOffset(years=1)),
  26. ("6m", pd.DateOffset(months=6)),
  27. ("3m", pd.DateOffset(months=3)),
  28. )
  29. @dataclass(frozen=True)
  30. class Strategy:
  31. family: str
  32. symbol: str
  33. signal_symbol: str
  34. bar: str
  35. name: str
  36. warmup_bars: int
  37. params: dict[str, float | int | str]
  38. def load_candles(symbol: str, bar: str, years: float) -> list[Candle]:
  39. candles, _ = explore.load_cached_candles(explore.CANDLE_CACHE_DIR, symbol, bar)
  40. if not candles:
  41. raise FileNotFoundError(f"missing cached candles for {symbol} {bar}")
  42. requested = explore.history_bars_for_years(bar, years)
  43. return candles[-requested:] if len(candles) > requested else candles
  44. def frame_from_candles(candles: list[Candle], prefix: str = "") -> pd.DataFrame:
  45. return pd.DataFrame(
  46. {
  47. f"{prefix}ts": [candle.ts for candle in candles],
  48. f"{prefix}open": [candle.open for candle in candles],
  49. f"{prefix}high": [candle.high for candle in candles],
  50. f"{prefix}low": [candle.low for candle in candles],
  51. f"{prefix}close": [candle.close for candle in candles],
  52. f"{prefix}volume": [candle.volume for candle in candles],
  53. }
  54. )
  55. def aligned_frame(symbol_candles: list[Candle], signal_candles: list[Candle] | None = None) -> pd.DataFrame:
  56. base = frame_from_candles(symbol_candles)
  57. if signal_candles is None:
  58. return base
  59. signal = frame_from_candles(signal_candles, "sig_").rename(columns={"sig_ts": "ts"})
  60. return base.merge(signal, on="ts", how="inner")
  61. def rsi(series: pd.Series, length: int) -> pd.Series:
  62. diff = series.diff()
  63. gain = diff.clip(lower=0.0).rolling(length).mean()
  64. loss = (-diff.clip(upper=0.0)).rolling(length).mean()
  65. rs = gain / loss
  66. return 100.0 - 100.0 / (1.0 + rs)
  67. def true_range(frame: pd.DataFrame) -> pd.Series:
  68. prev_close = frame["close"].shift(1)
  69. return pd.concat(
  70. [
  71. frame["high"] - frame["low"],
  72. (frame["high"] - prev_close).abs(),
  73. (frame["low"] - prev_close).abs(),
  74. ],
  75. axis=1,
  76. ).max(axis=1)
  77. def close_trade(
  78. *,
  79. trades: list[dict[str, object]],
  80. exits: list[dict[str, object]],
  81. position: dict[str, object],
  82. candle: Candle,
  83. exit_price: float,
  84. ) -> tuple[float, bool]:
  85. exit_equity = trade_equity(
  86. side="short",
  87. margin_used=float(position["margin_used"]),
  88. entry_price=float(position["entry_price"]),
  89. exit_price=exit_price,
  90. leverage=LEVERAGE,
  91. )
  92. pnl = exit_equity - float(position["margin_used"])
  93. trades.append(
  94. {
  95. "side": "Short",
  96. "entry_time": explore._format_ts(int(position["entry_time"])),
  97. "exit_time": explore._format_ts(candle.ts),
  98. "entry_price": round(float(position["entry_price"]), 4),
  99. "exit_price": round(exit_price, 4),
  100. "pnl": round(pnl, 4),
  101. "return_pct": round(pnl / float(position["margin_used"]) * 100.0, 4),
  102. }
  103. )
  104. exits.append({"ts": candle.ts, "price": exit_price, "side": "short"})
  105. return exit_equity, pnl > 0.0
  106. def run_short_segment(candles: list[Candle], entry_signal: pd.Series, exit_signal: pd.Series, max_hold_bars: int, stop_loss_pct: float, take_profit_pct: float) -> SegmentResult:
  107. equity = INITIAL_EQUITY
  108. ending_equity = equity
  109. peak_equity = equity
  110. max_drawdown = 0.0
  111. wins = 0
  112. trades: list[dict[str, object]] = []
  113. entries: list[dict[str, object]] = []
  114. exits: list[dict[str, object]] = []
  115. equity_curve: list[dict[str, float | int]] = []
  116. position: dict[str, object] | None = None
  117. pending_entry = False
  118. pending_exit = False
  119. warmup_bars = max(int(entry_signal.first_valid_index() or 0), 1)
  120. for index in range(warmup_bars, len(candles)):
  121. candle = candles[index]
  122. if pending_exit and position is not None:
  123. equity, won = close_trade(trades=trades, exits=exits, position=position, candle=candle, exit_price=candle.open)
  124. wins += 1 if won else 0
  125. position = None
  126. pending_exit = False
  127. if pending_entry and position is None and equity > 0.0:
  128. position = {
  129. "entry_time": candle.ts,
  130. "entry_price": candle.open,
  131. "entry_index": index,
  132. "margin_used": equity,
  133. "stop_price": candle.open * (1.0 + stop_loss_pct),
  134. "take_profit_price": candle.open * (1.0 - take_profit_pct),
  135. }
  136. entries.append({"ts": candle.ts, "price": candle.open, "side": "short"})
  137. pending_entry = False
  138. current_equity = equity
  139. if position is not None:
  140. stop_hit = candle.high >= float(position["stop_price"])
  141. take_hit = candle.low <= float(position["take_profit_price"])
  142. if stop_hit or take_hit:
  143. exit_price = float(position["stop_price"] if stop_hit else position["take_profit_price"])
  144. equity, won = close_trade(trades=trades, exits=exits, position=position, candle=candle, exit_price=exit_price)
  145. wins += 1 if won else 0
  146. current_equity = equity
  147. position = None
  148. if position is not None:
  149. current_equity = mark_to_market(
  150. side="short",
  151. margin_used=float(position["margin_used"]),
  152. entry_price=float(position["entry_price"]),
  153. mark_price=candle.close,
  154. leverage=LEVERAGE,
  155. )
  156. current_equity = max(0.0, current_equity)
  157. peak_equity = max(peak_equity, current_equity)
  158. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  159. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  160. ending_equity = current_equity
  161. if index == len(candles) - 1 or equity <= 0.0:
  162. continue
  163. if position is not None:
  164. held = index - int(position["entry_index"])
  165. if bool(exit_signal.iloc[index]) or held >= max_hold_bars:
  166. pending_exit = True
  167. continue
  168. if bool(entry_signal.iloc[index]):
  169. pending_entry = True
  170. trade_count = len(trades)
  171. return SegmentResult(
  172. trade_count=trade_count,
  173. total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
  174. win_rate=wins / trade_count if trade_count else 0.0,
  175. max_drawdown=max_drawdown,
  176. trades=trades,
  177. open_position=position,
  178. candles=candles[warmup_bars:],
  179. equity_curve=equity_curve,
  180. entries=entries,
  181. exits=exits,
  182. )
  183. def strategy_signals(strategy: Strategy, frame: pd.DataFrame) -> tuple[pd.Series, pd.Series]:
  184. close = frame["close"]
  185. open_ = frame["open"]
  186. high = frame["high"]
  187. low = frame["low"]
  188. p = strategy.params
  189. trend = close.rolling(int(p["trend"])).mean()
  190. fast = close.rolling(int(p.get("fast", max(2, int(p["trend"]) // 4)))).mean()
  191. atr = true_range(frame).rolling(int(p.get("atr", 48))).mean()
  192. below_trend = close < trend
  193. if strategy.family == "trend_bounce_fail":
  194. pullback = high >= fast * (1.0 + float(p["pullback"])) if float(p["pullback"]) >= 0.0 else high >= fast * (1.0 + float(p["pullback"]))
  195. reject = close < open_
  196. entry = below_trend & pullback & reject & (close < fast)
  197. exit_ = (close > fast) | (close > trend)
  198. elif strategy.family == "crash_continuation":
  199. ret = close / close.shift(int(p["lookback"])) - 1.0
  200. prior_low = low.shift(1).rolling(int(p["break_lookback"])).min()
  201. entry = below_trend & (ret <= -float(p["drop"])) & (close <= prior_low * (1.0 + float(p["break_buffer"])))
  202. exit_ = (close > close.rolling(int(p["exit_fast"])).mean()) | (ret > 0.0)
  203. elif strategy.family == "vwap_deviation_fade":
  204. typical = (high + low + close) / 3.0
  205. vwap = (typical * frame["volume"]).rolling(int(p["vwap"])).sum() / frame["volume"].rolling(int(p["vwap"])).sum()
  206. entry = below_trend & (high >= vwap * (1.0 + float(p["deviation"]))) & (close < vwap)
  207. exit_ = (close <= vwap * (1.0 - float(p["exit_deviation"]))) | (close > trend)
  208. elif strategy.family == "rsi_overbought_downtrend":
  209. value = rsi(close, int(p["rsi"]))
  210. entry = below_trend & (value >= float(p["entry_rsi"])) & (close < open_)
  211. exit_ = (value <= float(p["exit_rsi"])) | (close > trend)
  212. elif strategy.family == "bb_upper_rejection":
  213. mid = close.rolling(int(p["bb"])).mean()
  214. std = close.rolling(int(p["bb"])).std(ddof=0)
  215. upper = mid + std * float(p["std"])
  216. entry = below_trend & (high >= upper) & (close < upper) & (close < open_)
  217. exit_ = (close <= mid) | (close > trend)
  218. elif strategy.family == "eth_by_btc_down":
  219. sig_close = frame["sig_close"]
  220. sig_trend = sig_close.rolling(int(p["btc_trend"])).mean()
  221. sig_ret = sig_close / sig_close.shift(int(p["btc_lookback"])) - 1.0
  222. eth_ret = close / close.shift(int(p["eth_lookback"])) - 1.0
  223. entry = (sig_close < sig_trend) & (sig_ret <= -float(p["btc_drop"])) & (eth_ret <= float(p["eth_max_rebound"]))
  224. exit_ = (sig_ret >= 0.0) | (close > fast)
  225. else:
  226. raise ValueError(f"unknown family {strategy.family}")
  227. enough_range = atr.notna() & (atr > 0.0)
  228. return (entry & enough_range).fillna(False), exit_.fillna(False)
  229. def make_strategy(family: str, symbol: str, signal_symbol: str, bar: str, params: dict[str, float | int | str]) -> Strategy:
  230. parts = [family, symbol.split("-")[0].lower(), bar]
  231. parts.extend(f"{key}{value}" for key, value in params.items())
  232. warmup = max(int(value) for key, value in params.items() if key.endswith(("trend", "lookback", "fast", "atr", "vwap", "rsi", "bb")) and isinstance(value, int))
  233. return Strategy(family, symbol, signal_symbol, bar, "-".join(parts), warmup, params)
  234. def build_strategies() -> list[Strategy]:
  235. strategies: list[Strategy] = []
  236. for symbol in ("BTC-USDT-SWAP", "ETH-USDT-SWAP"):
  237. for bar in ("15m",):
  238. strategies.extend(
  239. make_strategy(
  240. "trend_bounce_fail",
  241. symbol,
  242. symbol,
  243. bar,
  244. {"trend": trend, "fast": fast, "atr": 48, "pullback": pullback, "stop": stop, "take": take, "hold": hold},
  245. )
  246. for trend in (96,)
  247. for fast in (16,)
  248. for pullback in (0.003,)
  249. for stop in (0.006, 0.009)
  250. for take in (0.010,)
  251. for hold in (12,)
  252. )
  253. strategies.extend(
  254. make_strategy(
  255. "crash_continuation",
  256. symbol,
  257. symbol,
  258. bar,
  259. {
  260. "trend": trend,
  261. "lookback": lookback,
  262. "break_lookback": break_lookback,
  263. "break_buffer": 0.002,
  264. "exit_fast": 8,
  265. "atr": 48,
  266. "drop": drop,
  267. "stop": stop,
  268. "take": take,
  269. "hold": hold,
  270. },
  271. )
  272. for trend in (96, 192) if bar == "5m" or trend == 96
  273. for lookback in (3, 6)
  274. for break_lookback in (12,)
  275. for drop in (0.010, 0.016)
  276. for stop in (0.006, 0.010)
  277. for take in (0.010,)
  278. for hold in (8,)
  279. )
  280. strategies.extend(
  281. make_strategy(
  282. "vwap_deviation_fade",
  283. symbol,
  284. symbol,
  285. bar,
  286. {"trend": trend, "vwap": vwap, "atr": 48, "deviation": dev, "exit_deviation": 0.001, "stop": stop, "take": take, "hold": hold},
  287. )
  288. for trend in (96,)
  289. for vwap in (48,)
  290. for dev in (0.004, 0.007)
  291. for stop in (0.006,)
  292. for take in (0.010,)
  293. for hold in (12,)
  294. )
  295. strategies.extend(
  296. make_strategy(
  297. "rsi_overbought_downtrend",
  298. symbol,
  299. symbol,
  300. bar,
  301. {"trend": trend, "fast": 16, "atr": 48, "rsi": rsi_length, "entry_rsi": entry_rsi, "exit_rsi": exit_rsi, "stop": stop, "take": take, "hold": hold},
  302. )
  303. for trend in (96,)
  304. for rsi_length in (14,)
  305. for entry_rsi in (60.0, 70.0)
  306. for exit_rsi in (45.0,)
  307. for stop in (0.006,)
  308. for take in (0.010,)
  309. for hold in (12,)
  310. )
  311. strategies.extend(
  312. make_strategy(
  313. "bb_upper_rejection",
  314. symbol,
  315. symbol,
  316. bar,
  317. {"trend": trend, "bb": bb, "atr": 48, "std": std, "stop": stop, "take": take, "hold": hold},
  318. )
  319. for trend in (96,)
  320. for bb in (20, 48)
  321. for std in (1.5,)
  322. for stop in (0.006,)
  323. for take in (0.010,)
  324. for hold in (12,)
  325. )
  326. for bar in ("15m",):
  327. strategies.extend(
  328. make_strategy(
  329. "eth_by_btc_down",
  330. "ETH-USDT-SWAP",
  331. "BTC-USDT-SWAP",
  332. bar,
  333. {
  334. "trend": 96,
  335. "fast": 16,
  336. "atr": 48,
  337. "btc_trend": btc_trend,
  338. "btc_lookback": btc_lookback,
  339. "eth_lookback": eth_lookback,
  340. "btc_drop": btc_drop,
  341. "eth_max_rebound": eth_max_rebound,
  342. "stop": stop,
  343. "take": take,
  344. "hold": hold,
  345. },
  346. )
  347. for btc_trend in (96,)
  348. for btc_lookback in (3, 6, 12)
  349. for eth_lookback in (3,)
  350. for btc_drop in (0.010, 0.015)
  351. for eth_max_rebound in (-0.002,)
  352. for stop in (0.006, 0.010)
  353. for take in (0.010,)
  354. for hold in (8,)
  355. )
  356. return strategies
  357. def run_strategy(strategy: Strategy, data: dict[tuple[str, str], list[Candle]]) -> SegmentResult:
  358. symbol_candles = data[(strategy.symbol, strategy.bar)]
  359. signal_candles = None if strategy.signal_symbol == strategy.symbol else data[(strategy.signal_symbol, strategy.bar)]
  360. frame = aligned_frame(symbol_candles, signal_candles)
  361. candles = [
  362. Candle(strategy.symbol, int(row.ts), float(row.open), float(row.high), float(row.low), float(row.close), float(row.volume))
  363. for row in frame.itertuples(index=False)
  364. ]
  365. entry, exit_ = strategy_signals(strategy, frame)
  366. return run_short_segment(candles, entry, exit_, int(strategy.params["hold"]), float(strategy.params["stop"]), float(strategy.params["take"]))
  367. def daily_equity(frame: pd.DataFrame, start: pd.Timestamp, end: pd.Timestamp) -> pd.Series:
  368. series = frame.set_index("ts")["equity"].sort_index()
  369. series = pd.concat([pd.Series([INITIAL_EQUITY], index=[start.normalize()]), series]).sort_index()
  370. series = series.groupby(level=0).last()
  371. index = pd.date_range(start.normalize(), end.normalize(), freq="1D", tz="UTC")
  372. return series.reindex(index.union(series.index)).sort_index().ffill().reindex(index).ffill()
  373. def cost_adjusted_equity_frame(result: SegmentResult, cost: float) -> pd.DataFrame:
  374. frame = pd.DataFrame(result.equity_curve)
  375. if frame.empty:
  376. return pd.DataFrame({"ts": pd.Series(dtype="datetime64[ns, UTC]"), "equity": pd.Series(dtype=float)})
  377. frame["ts"] = pd.to_datetime(frame["ts"], unit="ms", utc=True)
  378. adjustments: dict[pd.Timestamp, float] = {}
  379. for trade in result.trades:
  380. exit_time = pd.to_datetime(str(trade["exit_time"]), utc=True)
  381. gross_factor = 1.0 + float(trade["return_pct"]) / 100.0
  382. net_factor = gross_factor - cost
  383. if gross_factor <= 0.0 or net_factor <= 0.0:
  384. factor = 0.0
  385. else:
  386. factor = net_factor / gross_factor
  387. adjustments[exit_time] = adjustments.get(exit_time, 1.0) * factor
  388. if adjustments:
  389. factor_frame = pd.DataFrame({"ts": list(adjustments.keys()), "factor": list(adjustments.values())}).sort_values("ts")
  390. frame = frame.merge(factor_frame, on="ts", how="left")
  391. frame["factor"] = frame["factor"].fillna(1.0).cumprod()
  392. frame["equity"] = frame["equity"] * frame["factor"]
  393. return frame[["ts", "equity"]]
  394. def metrics_from_daily_equity(series: pd.Series) -> dict[str, float]:
  395. series = series.clip(lower=0.0)
  396. years = (series.index[-1] - series.index[0]).total_seconds() / 86_400 / 365
  397. if float(series.iloc[0]) <= 0.0:
  398. total_return = -1.0
  399. else:
  400. total_return = float(series.iloc[-1] / series.iloc[0] - 1.0)
  401. annualized_return = (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 and years > 0.0 else 0.0
  402. max_drawdown = explore.max_drawdown_from_equity([float(value) for value in series])
  403. returns = series.where(series > 0.0).pct_change(fill_method=None).dropna()
  404. daily_std = float(returns.std(ddof=1)) if len(returns) > 1 else 0.0
  405. risk_reward = float(returns.mean()) / daily_std * (365**0.5) if daily_std else 0.0
  406. return {
  407. "net_total_return": total_return,
  408. "net_annualized_return": annualized_return,
  409. "net_max_drawdown": max_drawdown,
  410. "net_calmar": annualized_return / max_drawdown if max_drawdown else 0.0,
  411. "risk_reward_ratio": risk_reward,
  412. }
  413. def monthly_rows(name: str, series: pd.Series) -> pd.DataFrame:
  414. monthly = series.resample("ME").last()
  415. frame = pd.DataFrame(
  416. {
  417. "name": name,
  418. "month": monthly.index.strftime("%Y-%m"),
  419. "start_equity": monthly.shift(1).fillna(series.iloc[0]).to_numpy(),
  420. "end_equity": monthly.to_numpy(),
  421. }
  422. )
  423. frame["return"] = frame["end_equity"] / frame["start_equity"] - 1.0
  424. return frame
  425. def trade_stats_for_window(result: SegmentResult, cost: float, start: pd.Timestamp, end: pd.Timestamp) -> dict[str, float | int]:
  426. returns: list[float] = []
  427. for trade in result.trades:
  428. exit_time = pd.to_datetime(str(trade["exit_time"]), utc=True)
  429. if start <= exit_time <= end:
  430. returns.append(float(trade["return_pct"]) / 100.0 - cost)
  431. wins = [value for value in returns if value > 0.0]
  432. losses = [value for value in returns if value < 0.0]
  433. avg_win = sum(wins) / len(wins) if wins else 0.0
  434. avg_loss_abs = abs(sum(losses) / len(losses)) if losses else 0.0
  435. gross_profit = sum(wins)
  436. gross_loss_abs = abs(sum(losses))
  437. months = max((end - start).days / 30.4375, 1.0)
  438. return {
  439. "trades": len(returns),
  440. "trades_per_month": len(returns) / months,
  441. "win_rate": len(wins) / len(returns) if returns else 0.0,
  442. "payoff_ratio": avg_win / avg_loss_abs if avg_loss_abs else 0.0,
  443. "profit_factor": gross_profit / gross_loss_abs if gross_loss_abs else 0.0,
  444. }
  445. def horizon_rows(name: str, result: SegmentResult, cost: float, series: pd.Series) -> list[dict[str, object]]:
  446. rows: list[dict[str, object]] = []
  447. end_time = series.index[-1]
  448. for label, offset in HORIZONS:
  449. horizon = series if offset is None else series[series.index >= end_time - offset]
  450. if len(horizon) < 2:
  451. horizon = series
  452. rows.append(
  453. {
  454. "name": name,
  455. "horizon": label,
  456. "horizon_start": horizon.index[0].strftime("%Y-%m-%d"),
  457. "horizon_end": horizon.index[-1].strftime("%Y-%m-%d"),
  458. **metrics_from_daily_equity(horizon),
  459. **trade_stats_for_window(result, cost, horizon.index[0], horizon.index[-1]),
  460. }
  461. )
  462. return rows
  463. def format_cell(value: object) -> str:
  464. if isinstance(value, float):
  465. return f"{value:.6g}"
  466. return str(value).replace("|", "\\|")
  467. def markdown_table(frame: pd.DataFrame) -> str:
  468. rows = [list(frame.columns), ["---" for _ in frame.columns]]
  469. rows.extend(frame.astype(object).where(pd.notna(frame), "").values.tolist())
  470. return "\n".join("| " + " | ".join(format_cell(value) for value in row) + " |" for row in rows)
  471. def markdown_report(command: str, paths: list[Path], totals: pd.DataFrame, horizons: pd.DataFrame, monthly_summary: pd.DataFrame, worst_months: pd.DataFrame, qualified: pd.DataFrame) -> str:
  472. primary = totals[totals["cost_model"] == PRIMARY_COST]
  473. top = qualified.head(10) if len(qualified) else primary.head(10)
  474. top_name = str(top.iloc[0]["name"]) if len(top) else ""
  475. top_horizon = horizons[(horizons["cost_model"] == PRIMARY_COST) & (horizons["name"] == top_name)]
  476. lines = [
  477. "# BTC/ETH Short Overlay Search",
  478. "",
  479. f"Run command: `{command}`",
  480. "",
  481. "Output files:",
  482. *[f"- `{path}`" for path in paths],
  483. "",
  484. "Scope: cached BTC/ETH perpetual candles only. All candidates are short-only overlays; no live OKX calls and no order submission.",
  485. "Costs: maker_taker=0.0021 and taker_taker=0.0030 roundtrip on margin at 3x.",
  486. "Candidate families: trend bounce failure, crash continuation, VWAP deviation fade, RSI overbought under downtrend, BB upper rejection, and ETH short by BTC downside.",
  487. "",
  488. f"Qualified maker_taker candidates with >=8 trades/month and positive 1y/6m/3m: {len(qualified)}.",
  489. "",
  490. "## Top qualified or fallback candidates",
  491. "",
  492. markdown_table(
  493. top[
  494. [
  495. "name",
  496. "family",
  497. "symbol",
  498. "signal_symbol",
  499. "bar",
  500. "trades",
  501. "trades_per_month",
  502. "net_total_return",
  503. "net_annualized_return",
  504. "net_max_drawdown",
  505. "net_calmar",
  506. "win_rate",
  507. "payoff_ratio",
  508. "profit_factor",
  509. "risk_reward_ratio",
  510. "worst_month_return",
  511. ]
  512. ]
  513. ),
  514. "",
  515. "## Horizon metrics for top candidate",
  516. "",
  517. markdown_table(
  518. top_horizon[
  519. [
  520. "horizon",
  521. "horizon_start",
  522. "horizon_end",
  523. "net_total_return",
  524. "net_annualized_return",
  525. "net_max_drawdown",
  526. "net_calmar",
  527. "trades_per_month",
  528. "profit_factor",
  529. "risk_reward_ratio",
  530. ]
  531. ]
  532. ),
  533. "",
  534. "## Monthly summary",
  535. "",
  536. markdown_table(monthly_summary.head(20)),
  537. "",
  538. "## Worst months",
  539. "",
  540. markdown_table(worst_months.head(20)),
  541. ]
  542. return "\n".join(lines) + "\n"
  543. def main() -> int:
  544. parser = argparse.ArgumentParser()
  545. parser.add_argument("--years", type=float, default=YEARS)
  546. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  547. parser.add_argument("--prefix", default=PREFIX)
  548. args = parser.parse_args()
  549. strategies = build_strategies()
  550. bars = sorted({strategy.bar for strategy in strategies})
  551. data = {
  552. (symbol, bar): load_candles(symbol, bar, args.years)
  553. for bar in bars
  554. for symbol in ("BTC-USDT-SWAP", "ETH-USDT-SWAP")
  555. }
  556. results: dict[str, tuple[Strategy, SegmentResult]] = {}
  557. for index, strategy in enumerate(strategies, start=1):
  558. results[strategy.name] = (strategy, run_strategy(strategy, data))
  559. print(f"done {index}/{len(strategies)} {strategy.name}", flush=True)
  560. start = max(pd.to_datetime(result.equity_curve[0]["ts"], unit="ms", utc=True) for _, result in results.values())
  561. end = min(pd.to_datetime(result.equity_curve[-1]["ts"], unit="ms", utc=True) for _, result in results.values())
  562. total_rows: list[dict[str, object]] = []
  563. horizon_output: list[dict[str, object]] = []
  564. monthly_frames: list[pd.DataFrame] = []
  565. for name, (strategy, result) in results.items():
  566. for cost_model, cost_value in COSTS.items():
  567. frame = cost_adjusted_equity_frame(result, cost_value)
  568. daily = daily_equity(frame, start, end)
  569. monthly = monthly_rows(name, daily)
  570. worst = monthly.loc[monthly["return"].idxmin()]
  571. stats = trade_stats_for_window(result, cost_value, start, end)
  572. total_rows.append(
  573. {
  574. "name": name,
  575. "cost_model": cost_model,
  576. "roundtrip_cost_on_margin": cost_value,
  577. "family": strategy.family,
  578. "symbol": strategy.symbol,
  579. "signal_symbol": strategy.signal_symbol,
  580. "bar": strategy.bar,
  581. "first_candle": start.strftime("%Y-%m-%d %H:%M"),
  582. "last_candle": end.strftime("%Y-%m-%d %H:%M"),
  583. "years": (end - start).total_seconds() / 86_400 / 365,
  584. "gross_total_return": result.total_return,
  585. "gross_max_drawdown_mark_to_market": result.max_drawdown,
  586. "worst_month": str(worst["month"]),
  587. "worst_month_return": float(worst["return"]),
  588. **stats,
  589. **metrics_from_daily_equity(daily),
  590. **strategy.params,
  591. }
  592. )
  593. for row in horizon_rows(name, result, cost_value, daily):
  594. horizon_output.append({"cost_model": cost_model, "family": strategy.family, "symbol": strategy.symbol, "signal_symbol": strategy.signal_symbol, "bar": strategy.bar, **row})
  595. monthly_frames.append(monthly.assign(cost_model=cost_model, family=strategy.family, symbol=strategy.symbol, signal_symbol=strategy.signal_symbol, bar=strategy.bar))
  596. totals = pd.DataFrame(total_rows).sort_values(
  597. ["cost_model", "net_calmar", "net_annualized_return", "trades_per_month"],
  598. ascending=[True, False, False, False],
  599. )
  600. horizons = pd.DataFrame(horizon_output).sort_values(["cost_model", "name", "horizon"])
  601. monthly_all = pd.concat(monthly_frames, ignore_index=True)
  602. monthly_summary = (
  603. monthly_all[monthly_all["cost_model"] == PRIMARY_COST]
  604. .groupby(["name", "family", "symbol", "signal_symbol", "bar"], as_index=False)
  605. .agg(
  606. positive_months=("return", lambda values: int((values > 0.0).sum())),
  607. negative_months=("return", lambda values: int((values < 0.0).sum())),
  608. avg_month_return=("return", "mean"),
  609. worst_month_return=("return", "min"),
  610. )
  611. .sort_values(["avg_month_return", "worst_month_return"], ascending=False)
  612. )
  613. worst_months = monthly_all.sort_values("return").head(50)
  614. primary_horizon = horizons[horizons["cost_model"] == PRIMARY_COST]
  615. recent = primary_horizon[primary_horizon["horizon"].isin(("1y", "6m", "3m"))].pivot_table(index="name", columns="horizon", values="net_total_return", aggfunc="min")
  616. recent_positive = set(recent[(recent.reindex(columns=["1y", "6m", "3m"]) > 0.0).all(axis=1)].index)
  617. qualified = totals[
  618. (totals["cost_model"] == PRIMARY_COST)
  619. & (totals["trades_per_month"] >= 8.0)
  620. & (totals["name"].isin(recent_positive))
  621. & (totals["net_total_return"] > -1.0)
  622. & totals["net_total_return"].notna()
  623. & totals["net_calmar"].notna()
  624. & (totals["net_max_drawdown"] <= 0.65)
  625. ].sort_values(["net_calmar", "net_annualized_return", "trades_per_month"], ascending=[False, False, False])
  626. args.output_dir.mkdir(parents=True, exist_ok=True)
  627. total_path = args.output_dir / f"{args.prefix}-totals.csv"
  628. horizon_path = args.output_dir / f"{args.prefix}-horizons.csv"
  629. monthly_path = args.output_dir / f"{args.prefix}-monthly.csv"
  630. qualified_path = args.output_dir / f"{args.prefix}-qualified.csv"
  631. summary_path = args.output_dir / f"{args.prefix}-summary.json"
  632. report_path = args.output_dir / f"{args.prefix}-report.md"
  633. totals.to_csv(total_path, index=False)
  634. horizons.to_csv(horizon_path, index=False)
  635. monthly_all.to_csv(monthly_path, index=False)
  636. qualified.to_csv(qualified_path, index=False)
  637. summary = {
  638. "years_requested": args.years,
  639. "strategy_count": len(strategies),
  640. "qualified_count": int(len(qualified)),
  641. "top_name": str((qualified if len(qualified) else totals[totals["cost_model"] == PRIMARY_COST]).iloc[0]["name"]),
  642. "output_files": [str(path) for path in (total_path, horizon_path, monthly_path, qualified_path, summary_path, report_path)],
  643. }
  644. summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
  645. report_path.write_text(
  646. markdown_report(
  647. " ".join(sys.argv),
  648. [total_path, horizon_path, monthly_path, qualified_path, summary_path, report_path],
  649. totals,
  650. horizons,
  651. monthly_summary,
  652. worst_months,
  653. qualified,
  654. ),
  655. encoding="utf-8",
  656. )
  657. print(f"wrote {report_path}")
  658. return 0
  659. if __name__ == "__main__":
  660. raise SystemExit(main())