search_btc_eth_bbmr_risk_variants.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796
  1. from __future__ import annotations
  2. import argparse
  3. import importlib.util
  4. import json
  5. import sys
  6. from dataclasses import asdict, dataclass
  7. from pathlib import Path
  8. from statistics import median
  9. import pandas as pd
  10. from okx_codex_trader.models import Candle
  11. from okx_codex_trader.sampled_report import SegmentResult, mark_to_market, trade_equity
  12. INITIAL_EQUITY = 10_000.0
  13. LEVERAGE = 3
  14. BAR = "15m"
  15. REPORT_DIR = Path("reports/ultrashort")
  16. OUTPUT_PREFIX = "bbmr-risk"
  17. HORIZONS = (
  18. ("full", None),
  19. ("3y", pd.DateOffset(years=3)),
  20. ("1y", pd.DateOffset(years=1)),
  21. ("6m", pd.DateOffset(months=6)),
  22. ("3m", pd.DateOffset(months=3)),
  23. )
  24. COST_MODELS = {
  25. "gross": 0.0,
  26. "maker_taker": 0.0021,
  27. "taker_taker": 0.0030,
  28. }
  29. PRIMARY_COST_MODEL = "maker_taker"
  30. @dataclass(frozen=True)
  31. class RiskConfig:
  32. band_length: int
  33. std_multiplier: float
  34. bandwidth_lookback: int
  35. stop_loss_pct: float
  36. take_profit_pct: float | None
  37. max_hold_bars: int | None
  38. cooldown_bars: int
  39. vol_lookback: int
  40. vol_cap: float | None
  41. trend_sma: int
  42. long_regime: str
  43. short_regime: str
  44. neutral_distance: float | None
  45. session: str
  46. corr_lookback: int
  47. corr_min: float | None
  48. corr_max: float | None
  49. side_mode: str
  50. @property
  51. def name(self) -> str:
  52. fields = [
  53. f"l{self.band_length}",
  54. f"m{self.std_multiplier:g}",
  55. f"sl{self.stop_loss_pct:g}",
  56. ]
  57. if self.take_profit_pct is not None:
  58. fields.append(f"tp{self.take_profit_pct:g}")
  59. if self.max_hold_bars is not None:
  60. fields.append(f"mh{self.max_hold_bars}")
  61. if self.cooldown_bars:
  62. fields.append(f"cd{self.cooldown_bars}")
  63. if self.vol_cap is not None:
  64. fields.append(f"vc{self.vol_cap:g}")
  65. if self.long_regime != "any" or self.short_regime != "any":
  66. fields.append(f"lr{self.long_regime}-sr{self.short_regime}")
  67. if self.neutral_distance is not None:
  68. fields.append(f"nd{self.neutral_distance:g}")
  69. if self.session != "all":
  70. fields.append(f"s{self.session}")
  71. if self.corr_min is not None or self.corr_max is not None:
  72. fields.append(f"corr{self.corr_min}_{self.corr_max}")
  73. if self.side_mode != "both":
  74. fields.append(self.side_mode)
  75. return "bbmr-" + "-".join(fields)
  76. def load_explore_module():
  77. path = Path(__file__).resolve().with_name("explore_ultrashort.py")
  78. spec = importlib.util.spec_from_file_location("explore_ultrashort", path)
  79. if spec is None or spec.loader is None:
  80. raise RuntimeError("cannot load explore_ultrashort.py")
  81. module = importlib.util.module_from_spec(spec)
  82. sys.modules[spec.name] = module
  83. spec.loader.exec_module(module)
  84. return module
  85. def load_cached_history(explore, symbol: str, bar: str, years: float) -> list[Candle]:
  86. requested = explore.history_bars_for_years(bar, years)
  87. candles, _ = explore.load_cached_candles(explore.CANDLE_CACHE_DIR, symbol, bar)
  88. if not candles:
  89. raise RuntimeError(f"missing cached candles: {symbol} {bar}")
  90. return candles[-requested:] if len(candles) > requested else candles
  91. def align_pair(left: list[Candle], right: list[Candle]) -> tuple[list[Candle], list[Candle]]:
  92. right_by_ts = {candle.ts: candle for candle in right}
  93. left_out: list[Candle] = []
  94. right_out: list[Candle] = []
  95. for candle in left:
  96. other = right_by_ts.get(candle.ts)
  97. if other is None:
  98. continue
  99. left_out.append(candle)
  100. right_out.append(other)
  101. return left_out, right_out
  102. def format_ts(ts: int) -> str:
  103. return pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
  104. def close_trade(
  105. *,
  106. trades: list[dict[str, object]],
  107. exits: list[dict[str, object]],
  108. position: dict[str, object],
  109. candle: Candle,
  110. exit_price: float,
  111. roundtrip_cost: float,
  112. ) -> tuple[float, bool]:
  113. gross_exit_equity = trade_equity(
  114. side=str(position["side"]),
  115. margin_used=float(position["margin_used"]),
  116. entry_price=float(position["entry_price"]),
  117. exit_price=exit_price,
  118. leverage=LEVERAGE,
  119. )
  120. cost = float(position["margin_used"]) * roundtrip_cost
  121. exit_equity = gross_exit_equity - cost
  122. pnl = exit_equity - float(position["margin_used"])
  123. trades.append(
  124. {
  125. "side": "Long" if position["side"] == "long" else "Short",
  126. "entry_time": format_ts(int(position["entry_time"])),
  127. "exit_time": format_ts(candle.ts),
  128. "entry_price": round(float(position["entry_price"]), 4),
  129. "exit_price": round(exit_price, 4),
  130. "pnl": round(pnl, 4),
  131. "return_pct": round(pnl / float(position["margin_used"]) * 100.0, 4),
  132. "cost": round(cost, 4),
  133. }
  134. )
  135. exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
  136. return exit_equity, exit_equity > float(position["margin_used"])
  137. def in_regime(regime: str, close: float, trend: float, neutral_distance: float | None) -> bool:
  138. if regime == "any":
  139. return True
  140. if regime == "above":
  141. return close > trend
  142. if regime == "below":
  143. return close < trend
  144. if regime == "neutral":
  145. if neutral_distance is None:
  146. return True
  147. return abs(close / trend - 1.0) <= neutral_distance
  148. raise ValueError(f"unknown regime: {regime}")
  149. def in_session(session: str, ts: int) -> bool:
  150. if session == "all":
  151. return True
  152. hour = pd.to_datetime(ts, unit="ms", utc=True).hour
  153. if session == "asia":
  154. return 0 <= hour < 8
  155. if session == "eu_us":
  156. return 8 <= hour < 24
  157. if session == "us":
  158. return 13 <= hour < 22
  159. raise ValueError(f"unknown session: {session}")
  160. def exit_price_for_risk_hit(position: dict[str, object], candle: Candle, take_profit_price: float | None) -> float | None:
  161. side = str(position["side"])
  162. stop_price = float(position["stop_price"])
  163. if side == "long":
  164. if candle.open <= stop_price:
  165. return candle.open
  166. if take_profit_price is not None and candle.open >= take_profit_price:
  167. return candle.open
  168. stop_hit = candle.low <= stop_price
  169. take_profit_hit = take_profit_price is not None and candle.high >= take_profit_price
  170. else:
  171. if candle.open >= stop_price:
  172. return candle.open
  173. if take_profit_price is not None and candle.open <= take_profit_price:
  174. return candle.open
  175. stop_hit = candle.high >= stop_price
  176. take_profit_hit = take_profit_price is not None and candle.low <= take_profit_price
  177. if stop_hit:
  178. return stop_price
  179. if take_profit_hit:
  180. return take_profit_price
  181. return None
  182. def run_bbmr_risk_segment(
  183. *,
  184. candles: list[Candle],
  185. peer_candles: list[Candle],
  186. config: RiskConfig,
  187. roundtrip_cost: float = 0.0,
  188. ) -> SegmentResult:
  189. closes = pd.Series([candle.close for candle in candles], dtype=float)
  190. peer_closes = pd.Series([candle.close for candle in peer_candles], dtype=float)
  191. middle = closes.rolling(config.band_length).mean().tolist()
  192. stdev = closes.rolling(config.band_length).std(ddof=0).tolist()
  193. upper = [
  194. float("nan") if avg != avg or std != std else avg + config.std_multiplier * std
  195. for avg, std in zip(middle, stdev)
  196. ]
  197. lower = [
  198. float("nan") if avg != avg or std != std else avg - config.std_multiplier * std
  199. for avg, std in zip(middle, stdev)
  200. ]
  201. bandwidth = [
  202. float("nan") if up != up or lo != lo or avg != avg or avg == 0.0 else (up - lo) / avg
  203. for up, lo, avg in zip(upper, lower, middle)
  204. ]
  205. returns = closes.pct_change()
  206. realized_vol = returns.rolling(config.vol_lookback).std(ddof=1).tolist()
  207. trend = closes.rolling(config.trend_sma).mean().tolist()
  208. corr = returns.rolling(config.corr_lookback).corr(peer_closes.pct_change()).tolist()
  209. warmup_bars = max(
  210. config.band_length + config.bandwidth_lookback,
  211. config.vol_lookback,
  212. config.trend_sma,
  213. config.corr_lookback if config.corr_min is not None or config.corr_max is not None else 0,
  214. )
  215. equity = INITIAL_EQUITY
  216. ending_equity = equity
  217. peak_equity = equity
  218. max_drawdown = 0.0
  219. wins = 0
  220. cooldown_until = -1
  221. pending_entry_side: str | None = None
  222. pending_exit = False
  223. position: dict[str, object] | None = None
  224. trades: list[dict[str, object]] = []
  225. entries: list[dict[str, object]] = []
  226. exits: list[dict[str, object]] = []
  227. equity_curve: list[dict[str, float | int]] = []
  228. for index in range(warmup_bars, len(candles)):
  229. candle = candles[index]
  230. if pending_exit and position is not None:
  231. equity, won = close_trade(
  232. trades=trades,
  233. exits=exits,
  234. position=position,
  235. candle=candle,
  236. exit_price=candle.open,
  237. roundtrip_cost=roundtrip_cost,
  238. )
  239. wins += 1 if won else 0
  240. position = None
  241. pending_exit = False
  242. cooldown_until = index + config.cooldown_bars
  243. if pending_entry_side is not None and position is None and equity > 0.0:
  244. entry_price = candle.open
  245. position = {
  246. "side": pending_entry_side,
  247. "entry_time": candle.ts,
  248. "entry_price": entry_price,
  249. "entry_index": index,
  250. "margin_used": equity,
  251. "stop_price": entry_price * (1.0 - config.stop_loss_pct if pending_entry_side == "long" else 1.0 + config.stop_loss_pct),
  252. }
  253. entries.append({"ts": candle.ts, "price": entry_price, "side": pending_entry_side})
  254. pending_entry_side = None
  255. current_equity = equity
  256. if position is not None:
  257. take_profit_price = None
  258. if config.take_profit_pct is not None:
  259. take_profit_price = float(position["entry_price"]) * (
  260. 1.0 + config.take_profit_pct if position["side"] == "long" else 1.0 - config.take_profit_pct
  261. )
  262. exit_price = exit_price_for_risk_hit(position, candle, take_profit_price)
  263. if exit_price is not None:
  264. equity, won = close_trade(
  265. trades=trades,
  266. exits=exits,
  267. position=position,
  268. candle=candle,
  269. exit_price=exit_price,
  270. roundtrip_cost=roundtrip_cost,
  271. )
  272. wins += 1 if won else 0
  273. current_equity = equity
  274. position = None
  275. cooldown_until = index + config.cooldown_bars
  276. if position is not None:
  277. current_equity = mark_to_market(
  278. side=str(position["side"]),
  279. margin_used=float(position["margin_used"]),
  280. entry_price=float(position["entry_price"]),
  281. mark_price=candle.close,
  282. leverage=LEVERAGE,
  283. )
  284. peak_equity = max(peak_equity, current_equity)
  285. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  286. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  287. ending_equity = current_equity
  288. if index == len(candles) - 1 or equity <= 0.0:
  289. continue
  290. current_middle = middle[index]
  291. if position is not None:
  292. middle_exit = (
  293. position["side"] == "long" and current_middle == current_middle and candle.close >= float(current_middle)
  294. ) or (
  295. position["side"] == "short" and current_middle == current_middle and candle.close <= float(current_middle)
  296. )
  297. max_hold_exit = config.max_hold_bars is not None and index - int(position["entry_index"]) >= config.max_hold_bars
  298. if middle_exit or max_hold_exit:
  299. pending_exit = True
  300. continue
  301. if index < cooldown_until or not in_session(config.session, candle.ts):
  302. continue
  303. current_vol = realized_vol[index]
  304. if config.vol_cap is not None and (current_vol != current_vol or current_vol > config.vol_cap):
  305. continue
  306. current_corr = corr[index]
  307. if config.corr_min is not None and (current_corr != current_corr or current_corr < config.corr_min):
  308. continue
  309. if config.corr_max is not None and (current_corr != current_corr or current_corr > config.corr_max):
  310. continue
  311. current_trend = trend[index]
  312. if current_trend != current_trend:
  313. continue
  314. previous_bandwidths = [value for value in bandwidth[max(0, index - config.bandwidth_lookback) : index] if value == value]
  315. current_bandwidth = bandwidth[index]
  316. if len(previous_bandwidths) < config.bandwidth_lookback or current_bandwidth != current_bandwidth:
  317. continue
  318. if current_bandwidth > median(previous_bandwidths):
  319. continue
  320. if (
  321. config.side_mode in ("both", "long")
  322. and lower[index] == lower[index]
  323. and candle.close < float(lower[index])
  324. and in_regime(config.long_regime, candle.close, float(current_trend), config.neutral_distance)
  325. ):
  326. pending_entry_side = "long"
  327. elif (
  328. config.side_mode in ("both", "short")
  329. and upper[index] == upper[index]
  330. and candle.close > float(upper[index])
  331. and in_regime(config.short_regime, candle.close, float(current_trend), config.neutral_distance)
  332. ):
  333. pending_entry_side = "short"
  334. trade_count = len(trades)
  335. return SegmentResult(
  336. trade_count=trade_count,
  337. total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
  338. win_rate=wins / trade_count if trade_count else 0.0,
  339. max_drawdown=max_drawdown,
  340. trades=trades,
  341. open_position=position,
  342. candles=candles[warmup_bars:],
  343. equity_curve=equity_curve,
  344. entries=entries,
  345. exits=exits,
  346. )
  347. def equity_frame(result: SegmentResult) -> pd.DataFrame:
  348. frame = pd.DataFrame(result.equity_curve)
  349. frame["ts"] = pd.to_datetime(frame["ts"], unit="ms", utc=True)
  350. return frame[["ts", "equity"]]
  351. def drawdown(values: pd.Series) -> float:
  352. peak = values.cummax()
  353. return float(((peak - values) / peak).max()) if len(values) else 0.0
  354. def monthly_returns(frame: pd.DataFrame) -> pd.DataFrame:
  355. month_end = frame.set_index("ts")["equity"].resample("ME").last().ffill()
  356. month_start = month_end.shift(1)
  357. if len(month_end):
  358. month_start.iloc[0] = float(frame["equity"].iloc[0])
  359. return pd.DataFrame(
  360. {
  361. "period": month_end.index.tz_localize(None).to_period("M").astype(str),
  362. "return": month_end.to_numpy() / month_start.to_numpy() - 1.0,
  363. "end_equity": month_end.to_numpy(),
  364. }
  365. )
  366. def horizon_frame(frame: pd.DataFrame, offset: pd.DateOffset | None) -> pd.DataFrame:
  367. if offset is None:
  368. out = frame.copy()
  369. else:
  370. cutoff = frame["ts"].iloc[-1] - offset
  371. out = frame[frame["ts"] >= cutoff].copy()
  372. if out.empty:
  373. out = frame.copy()
  374. out["equity"] = out["equity"] / float(out["equity"].iloc[0]) * INITIAL_EQUITY
  375. return out
  376. def parse_exit_time(trade: dict[str, object]) -> pd.Timestamp:
  377. return pd.Timestamp(str(trade["exit_time"]), tz="UTC")
  378. def parse_entry_time(trade: dict[str, object]) -> pd.Timestamp:
  379. return pd.Timestamp(str(trade["entry_time"]), tz="UTC")
  380. def summarize(label: str, symbol: str, frame: pd.DataFrame, trades: list[dict[str, object]], horizon: str) -> dict[str, object]:
  381. start = frame["ts"].iloc[0]
  382. end = frame["ts"].iloc[-1]
  383. years = max((end - start).total_seconds() / (365.0 * 24 * 60 * 60), 1e-9)
  384. total_return = float(frame["equity"].iloc[-1] / frame["equity"].iloc[0] - 1.0)
  385. annualized = (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 else -1.0
  386. max_dd = drawdown(frame["equity"])
  387. scoped_trades = [trade for trade in trades if parse_entry_time(trade) >= start and parse_exit_time(trade) >= start]
  388. wins = [float(trade["pnl"]) for trade in scoped_trades if float(trade["pnl"]) > 0.0]
  389. losses = [float(trade["pnl"]) for trade in scoped_trades if float(trade["pnl"]) < 0.0]
  390. gross_profit = sum(wins)
  391. gross_loss_abs = abs(sum(losses))
  392. months = max((end - start).total_seconds() / (30.4375 * 24 * 60 * 60), 1e-9)
  393. monthly = monthly_returns(frame)
  394. worst = monthly.loc[monthly["return"].idxmin()] if len(monthly) else None
  395. positive_months = int((monthly["return"] > 0.0).sum()) if len(monthly) else 0
  396. negative_months = int((monthly["return"] < 0.0).sum()) if len(monthly) else 0
  397. return {
  398. "label": label,
  399. "symbol": symbol,
  400. "horizon": horizon,
  401. "start": start.strftime("%Y-%m-%d"),
  402. "end": end.strftime("%Y-%m-%d"),
  403. "total_return": total_return,
  404. "annualized_return": annualized,
  405. "max_drawdown": max_dd,
  406. "calmar": annualized / max_dd if max_dd else 0.0,
  407. "win_rate": len(wins) / len(scoped_trades) if scoped_trades else 0.0,
  408. "payoff_ratio": (sum(wins) / len(wins)) / (gross_loss_abs / len(losses)) if wins and losses else 0.0,
  409. "profit_factor": gross_profit / gross_loss_abs if gross_loss_abs else 0.0,
  410. "return_drawdown_ratio": total_return / max_dd if max_dd else 0.0,
  411. "trades": len(scoped_trades),
  412. "trades_per_month": len(scoped_trades) / months,
  413. "worst_month": str(worst["period"]) if worst is not None else "",
  414. "worst_month_return": float(worst["return"]) if worst is not None else 0.0,
  415. "months": len(monthly),
  416. "positive_month_rate": positive_months / len(monthly) if len(monthly) else 0.0,
  417. "negative_months": negative_months,
  418. }
  419. def summarize_all(label: str, symbol: str, frame: pd.DataFrame, trades: list[dict[str, object]]) -> list[dict[str, object]]:
  420. return [
  421. summarize(label, symbol, horizon_frame(frame, offset), trades, horizon)
  422. for horizon, offset in HORIZONS
  423. ]
  424. def add_cost_columns(row: dict[str, object], cost_model: str, roundtrip_cost: float) -> None:
  425. row["cost_model"] = cost_model
  426. row["roundtrip_cost_on_margin"] = roundtrip_cost
  427. row["net_total_return"] = row["total_return"]
  428. row["net_annualized_return"] = row["annualized_return"]
  429. row["net_max_drawdown"] = row["max_drawdown"]
  430. row["net_calmar"] = row["calmar"]
  431. def combine_equity_frames(frames: list[pd.DataFrame]) -> pd.DataFrame:
  432. combined = pd.concat(
  433. [frame.set_index("ts")["equity"].rename(str(index)) for index, frame in enumerate(frames)],
  434. axis=1,
  435. sort=True,
  436. ).sort_index().ffill().dropna()
  437. return pd.DataFrame({"ts": combined.index, "equity": combined.sum(axis=1).to_numpy()})
  438. def build_configs() -> list[RiskConfig]:
  439. base_params = [
  440. (20, 2.0, 0.005),
  441. (20, 2.5, 0.005),
  442. (20, 2.5, 0.008),
  443. (30, 2.0, 0.005),
  444. (30, 2.5, 0.005),
  445. ]
  446. recipes = [
  447. {},
  448. {"vol_cap": 0.006},
  449. {"vol_cap": 0.009},
  450. {"cooldown_bars": 8},
  451. {"cooldown_bars": 32},
  452. {"max_hold_bars": 96},
  453. {"max_hold_bars": 288},
  454. {"take_profit_pct": 0.006},
  455. {"take_profit_pct": 0.012},
  456. {"session": "asia"},
  457. {"session": "eu_us"},
  458. {"long_regime": "neutral", "short_regime": "neutral", "neutral_distance": 0.03},
  459. {"long_regime": "neutral", "short_regime": "neutral", "neutral_distance": 0.06},
  460. {"long_regime": "below", "short_regime": "above"},
  461. {"long_regime": "above", "short_regime": "below"},
  462. {"side_mode": "long", "long_regime": "below"},
  463. {"side_mode": "short", "short_regime": "above"},
  464. {"vol_cap": 0.009, "cooldown_bars": 8, "max_hold_bars": 96},
  465. {"vol_cap": 0.009, "take_profit_pct": 0.012, "max_hold_bars": 96},
  466. {"vol_cap": 0.006, "long_regime": "neutral", "short_regime": "neutral", "neutral_distance": 0.06},
  467. {"cooldown_bars": 8, "long_regime": "below", "short_regime": "above", "max_hold_bars": 288},
  468. {"corr_min": 0.35},
  469. {"corr_max": 0.75},
  470. {"corr_min": -0.20, "corr_max": 0.75, "vol_cap": 0.009},
  471. ]
  472. configs: list[RiskConfig] = []
  473. for band_length, multiplier, stop_loss in base_params:
  474. for recipe in recipes:
  475. configs.append(
  476. RiskConfig(
  477. band_length=band_length,
  478. std_multiplier=multiplier,
  479. bandwidth_lookback=50,
  480. stop_loss_pct=stop_loss,
  481. take_profit_pct=recipe.get("take_profit_pct"),
  482. max_hold_bars=recipe.get("max_hold_bars"),
  483. cooldown_bars=int(recipe.get("cooldown_bars", 0)),
  484. vol_lookback=96,
  485. vol_cap=recipe.get("vol_cap"),
  486. trend_sma=480,
  487. long_regime=str(recipe.get("long_regime", "any")),
  488. short_regime=str(recipe.get("short_regime", "any")),
  489. neutral_distance=recipe.get("neutral_distance"),
  490. session=str(recipe.get("session", "all")),
  491. corr_lookback=192,
  492. corr_min=recipe.get("corr_min"),
  493. corr_max=recipe.get("corr_max"),
  494. side_mode=str(recipe.get("side_mode", "both")),
  495. )
  496. )
  497. return configs
  498. def score(full: dict[str, object], recent: dict[str, object]) -> float:
  499. trades_per_month = float(full["trades_per_month"])
  500. if trades_per_month < 10.0:
  501. return -999.0
  502. if float(recent["total_return"]) < -0.05:
  503. return -999.0
  504. return (
  505. float(full["calmar"])
  506. + float(recent["calmar"]) * 0.5
  507. + min(trades_per_month, 60.0) / 100.0
  508. - float(full["max_drawdown"]) * 1.5
  509. )
  510. def pct(value: float) -> str:
  511. return f"{value * 100:.2f}%"
  512. def write_report(summary: pd.DataFrame, portfolio: pd.DataFrame, selected: list[dict[str, object]]) -> None:
  513. primary_summary = summary[summary["cost_model"] == PRIMARY_COST_MODEL]
  514. stress_summary = summary[summary["cost_model"] == "taker_taker"]
  515. primary_portfolio = portfolio[portfolio["cost_model"] == PRIMARY_COST_MODEL]
  516. stress_portfolio = portfolio[portfolio["cost_model"] == "taker_taker"]
  517. robust_single = robust_survivors(summary[summary["cost_model"].isin(("maker_taker", "taker_taker"))])
  518. robust_combo = robust_survivors(portfolio[portfolio["cost_model"].isin(("maker_taker", "taker_taker"))])
  519. lines = [
  520. "# BTC/ETH BBMR Risk Variant Search",
  521. "",
  522. "Primary ranking uses maker/taker roundtrip margin cost 0.21%. Taker/taker stress uses 0.30%. Funding and slippage remain excluded.",
  523. "",
  524. f"Strict robust survivors with positive full/3y/1y/6m/3m net return and Calmar: single-symbol {len(robust_single)}, portfolio {len(robust_combo)}.",
  525. "",
  526. "Selection rule: rank candidates with at least 10 trades/month, lower full-period drawdown, and non-collapsing recent 1y results.",
  527. "",
  528. "## Risk-Qualified Single-Symbol Rows: maker/taker",
  529. "",
  530. ]
  531. for row in risk_qualified(primary_summary).head(12).to_dict("records"):
  532. lines.append(
  533. f"- {row['symbol']} {row['label']}: total {pct(float(row['total_return']))}, annualized {pct(float(row['annualized_return']))}, "
  534. f"DD {pct(float(row['max_drawdown']))}, Calmar {float(row['calmar']):.2f}, trades/month {float(row['trades_per_month']):.1f}, "
  535. f"3y {pct(float(row['ret_3y']))}, 1y {pct(float(row['ret_1y']))}, 6m {pct(float(row['ret_6m']))}, 3m {pct(float(row['ret_3m']))}, "
  536. f"worst month {row['worst_month']} {pct(float(row['worst_month_return']))}, positive months {pct(float(row['positive_month_rate']))}"
  537. )
  538. lines.extend(["", "## Risk-Qualified Portfolio Rows: maker/taker", ""])
  539. for row in risk_qualified(primary_portfolio).head(10).to_dict("records"):
  540. lines.append(
  541. f"- {row['label']}: total {pct(float(row['total_return']))}, annualized {pct(float(row['annualized_return']))}, "
  542. f"DD {pct(float(row['max_drawdown']))}, Calmar {float(row['calmar']):.2f}, trades/month {float(row['trades_per_month']):.1f}, "
  543. f"3y {pct(float(row['ret_3y']))}, 1y {pct(float(row['ret_1y']))}, 6m {pct(float(row['ret_6m']))}, 3m {pct(float(row['ret_3m']))}, "
  544. f"worst month {row['worst_month']} {pct(float(row['worst_month_return']))}, positive months {pct(float(row['positive_month_rate']))}"
  545. )
  546. lines.extend(["", "## Taker/taker stress survivors", ""])
  547. for row in risk_qualified(stress_summary).head(8).to_dict("records"):
  548. lines.append(
  549. f"- {row['symbol']} {row['label']}: total {pct(float(row['total_return']))}, annualized {pct(float(row['annualized_return']))}, "
  550. f"DD {pct(float(row['max_drawdown']))}, Calmar {float(row['calmar']):.2f}, trades/month {float(row['trades_per_month']):.1f}, "
  551. f"3y {pct(float(row['ret_3y']))}, 1y {pct(float(row['ret_1y']))}, 6m {pct(float(row['ret_6m']))}, 3m {pct(float(row['ret_3m']))}"
  552. )
  553. for row in risk_qualified(stress_portfolio).head(5).to_dict("records"):
  554. lines.append(
  555. f"- portfolio {row['label']}: total {pct(float(row['total_return']))}, annualized {pct(float(row['annualized_return']))}, "
  556. f"DD {pct(float(row['max_drawdown']))}, Calmar {float(row['calmar']):.2f}, trades/month {float(row['trades_per_month']):.1f}, "
  557. f"3y {pct(float(row['ret_3y']))}, 1y {pct(float(row['ret_1y']))}, 6m {pct(float(row['ret_6m']))}, 3m {pct(float(row['ret_3m']))}"
  558. )
  559. lines.extend(["", "## Maker/taker near misses", ""])
  560. near_miss_cols = ["symbol", "label", "total_return", "annualized_return", "max_drawdown", "calmar", "trades_per_month", "positive_month_rate", "worst_month_return"]
  561. for row in primary_summary[primary_summary["horizon"] == "full"].sort_values("score", ascending=False).head(5)[near_miss_cols].to_dict("records"):
  562. lines.append(
  563. f"- {row['symbol']} {row['label']}: total {pct(float(row['total_return']))}, annualized {pct(float(row['annualized_return']))}, "
  564. f"DD {pct(float(row['max_drawdown']))}, Calmar {float(row['calmar']):.2f}, trades/month {float(row['trades_per_month']):.1f}, "
  565. f"positive months {pct(float(row['positive_month_rate']))}, worst month {pct(float(row['worst_month_return']))}"
  566. )
  567. for row in primary_portfolio[primary_portfolio["horizon"] == "full"].sort_values("score", ascending=False).head(5)[near_miss_cols].to_dict("records"):
  568. lines.append(
  569. f"- portfolio {row['label']}: total {pct(float(row['total_return']))}, annualized {pct(float(row['annualized_return']))}, "
  570. f"DD {pct(float(row['max_drawdown']))}, Calmar {float(row['calmar']):.2f}, trades/month {float(row['trades_per_month']):.1f}, "
  571. f"positive months {pct(float(row['positive_month_rate']))}, worst month {pct(float(row['worst_month_return']))}"
  572. )
  573. lines.extend(["", "## Selected Configs", ""])
  574. for item in selected:
  575. lines.append(f"- {item['symbol']} {item['label']} ({item['cost_model']}): `{json.dumps(item['config'], separators=(',', ':'))}`")
  576. (REPORT_DIR / f"{OUTPUT_PREFIX}-summary.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
  577. def risk_qualified(frame: pd.DataFrame) -> pd.DataFrame:
  578. full = frame[frame["horizon"] == "full"].copy()
  579. recent = full
  580. for horizon in ("3y", "1y", "6m", "3m"):
  581. part = frame[frame["horizon"] == horizon][["cost_model", "symbol", "label", "total_return", "max_drawdown", "calmar"]].rename(
  582. columns={"total_return": f"ret_{horizon}", "max_drawdown": f"dd_{horizon}", "calmar": f"calmar_{horizon}"}
  583. )
  584. recent = recent.merge(part, on=["cost_model", "symbol", "label"], how="inner")
  585. dd_limit = recent["symbol"].map({"BTC": 0.5788, "ETH": 0.7199, "BTC+ETH": 0.5788}).fillna(0.5788)
  586. qualified = recent[
  587. (recent["trades_per_month"] >= 10.0)
  588. & (recent["max_drawdown"] < dd_limit)
  589. & (recent["ret_1y"] > -0.05)
  590. & (recent["ret_6m"] > -0.05)
  591. & (recent["ret_3m"] > -0.05)
  592. ].copy()
  593. qualified["risk_rank"] = (
  594. qualified["calmar"] * 2.0
  595. + qualified["annualized_return"]
  596. + qualified["ret_1y"] * 0.5
  597. - qualified["max_drawdown"]
  598. )
  599. return qualified.sort_values(["symbol", "risk_rank"], ascending=[True, False])
  600. def robust_survivors(frame: pd.DataFrame) -> pd.DataFrame:
  601. full = frame[frame["horizon"] == "full"].copy()
  602. recent = full
  603. for horizon in ("3y", "1y", "6m", "3m"):
  604. part = frame[frame["horizon"] == horizon][["cost_model", "symbol", "label", "total_return", "calmar"]].rename(
  605. columns={"total_return": f"ret_{horizon}", "calmar": f"calmar_{horizon}"}
  606. )
  607. recent = recent.merge(part, on=["cost_model", "symbol", "label"], how="inner")
  608. return recent[
  609. (recent["trades_per_month"] >= 10.0)
  610. & (recent["total_return"] > 0.0)
  611. & (recent["calmar"] > 0.0)
  612. & (recent["ret_3y"] > 0.0)
  613. & (recent["ret_1y"] > 0.0)
  614. & (recent["ret_6m"] > 0.0)
  615. & (recent["ret_3m"] > 0.0)
  616. & (recent["calmar_3y"] > 0.0)
  617. & (recent["calmar_1y"] > 0.0)
  618. & (recent["calmar_6m"] > 0.0)
  619. & (recent["calmar_3m"] > 0.0)
  620. ].copy()
  621. def main() -> int:
  622. parser = argparse.ArgumentParser()
  623. parser.add_argument("--years", type=float, default=10.0)
  624. parser.add_argument("--top-per-symbol", type=int, default=12)
  625. args = parser.parse_args()
  626. REPORT_DIR.mkdir(parents=True, exist_ok=True)
  627. explore = load_explore_module()
  628. btc_raw = load_cached_history(explore, "BTC-USDT-SWAP", BAR, args.years)
  629. eth_raw = load_cached_history(explore, "ETH-USDT-SWAP", BAR, args.years)
  630. btc, eth = align_pair(btc_raw, eth_raw)
  631. symbols = {
  632. "BTC": (btc, eth),
  633. "ETH": (eth, btc),
  634. }
  635. result_rows: list[dict[str, object]] = []
  636. selected_rows: list[dict[str, object]] = []
  637. selected_configs: dict[tuple[str, str], RiskConfig] = {}
  638. result_cache: dict[tuple[str, str, str], tuple[pd.DataFrame, list[dict[str, object]]]] = {}
  639. configs = build_configs()
  640. for symbol, (candles, peer) in symbols.items():
  641. ranked: list[dict[str, object]] = []
  642. for config in configs:
  643. for cost_model, roundtrip_cost in COST_MODELS.items():
  644. result = run_bbmr_risk_segment(candles=candles, peer_candles=peer, config=config, roundtrip_cost=roundtrip_cost)
  645. frame = equity_frame(result)
  646. result_cache[(symbol, config.name, cost_model)] = (frame, result.trades)
  647. rows = summarize_all(config.name, symbol, frame, result.trades)
  648. full = next(row for row in rows if row["horizon"] == "full")
  649. one_year = next(row for row in rows if row["horizon"] == "1y")
  650. candidate_score = score(full, one_year)
  651. for row in rows:
  652. add_cost_columns(row, cost_model, roundtrip_cost)
  653. row["score"] = candidate_score
  654. row.update(asdict(config))
  655. result_rows.append(row)
  656. if cost_model == PRIMARY_COST_MODEL:
  657. ranked.append({"score": candidate_score, "config": config, "result": result, "frame": frame, "rows": rows})
  658. ranked.sort(key=lambda item: float(item["score"]), reverse=True)
  659. for item in ranked[: args.top_per_symbol]:
  660. config = item["config"]
  661. key = (symbol, config.name)
  662. selected_configs[key] = config
  663. selected_rows.append({"symbol": symbol, "label": config.name, "cost_model": PRIMARY_COST_MODEL, "score": item["score"], "config": asdict(config)})
  664. summary = pd.DataFrame(result_rows)
  665. summary["horizon"] = pd.Categorical(summary["horizon"], categories=[name for name, _ in HORIZONS], ordered=True)
  666. summary = summary.sort_values(["symbol", "score", "horizon"], ascending=[True, False, True])
  667. summary.to_csv(REPORT_DIR / f"{OUTPUT_PREFIX}-all.csv", index=False)
  668. summary[summary["horizon"] == "full"].sort_values("score", ascending=False).head(40).to_csv(
  669. REPORT_DIR / f"{OUTPUT_PREFIX}-top.csv",
  670. index=False,
  671. )
  672. risk_qualified(summary).to_csv(REPORT_DIR / f"{OUTPUT_PREFIX}-qualified.csv", index=False)
  673. net_summary = summary[summary["cost_model"].isin(("maker_taker", "taker_taker"))]
  674. robust_survivors(net_summary).to_csv(REPORT_DIR / f"{OUTPUT_PREFIX}-robust-survivors.csv", index=False)
  675. portfolio_rows: list[dict[str, object]] = []
  676. btc_keys = [key for key in selected_configs if key[0] == "BTC"][: args.top_per_symbol]
  677. eth_keys = [key for key in selected_configs if key[0] == "ETH"][: args.top_per_symbol]
  678. for btc_key in btc_keys:
  679. for eth_key in eth_keys:
  680. label = f"{btc_key[1]} + {eth_key[1]}"
  681. for cost_model, roundtrip_cost in COST_MODELS.items():
  682. btc_frame, btc_trades = result_cache[(btc_key[0], btc_key[1], cost_model)]
  683. eth_frame, eth_trades = result_cache[(eth_key[0], eth_key[1], cost_model)]
  684. frame = combine_equity_frames([btc_frame, eth_frame])
  685. trades = btc_trades + eth_trades
  686. rows = summarize_all(label, "BTC+ETH", frame, trades)
  687. full = next(row for row in rows if row["horizon"] == "full")
  688. one_year = next(row for row in rows if row["horizon"] == "1y")
  689. portfolio_score = score(full, one_year)
  690. for row in rows:
  691. add_cost_columns(row, cost_model, roundtrip_cost)
  692. row["score"] = portfolio_score
  693. portfolio_rows.append(row)
  694. portfolio = pd.DataFrame(portfolio_rows)
  695. portfolio["horizon"] = pd.Categorical(portfolio["horizon"], categories=[name for name, _ in HORIZONS], ordered=True)
  696. portfolio = portfolio.sort_values(["score", "horizon"], ascending=[False, True])
  697. portfolio.to_csv(REPORT_DIR / f"{OUTPUT_PREFIX}-portfolio.csv", index=False)
  698. risk_qualified(portfolio).to_csv(REPORT_DIR / f"{OUTPUT_PREFIX}-portfolio-qualified.csv", index=False)
  699. net_portfolio = portfolio[portfolio["cost_model"].isin(("maker_taker", "taker_taker"))]
  700. robust_survivors(net_portfolio).to_csv(REPORT_DIR / f"{OUTPUT_PREFIX}-portfolio-robust-survivors.csv", index=False)
  701. Path(REPORT_DIR / f"{OUTPUT_PREFIX}-selected.json").write_text(
  702. json.dumps(selected_rows, indent=2),
  703. encoding="utf-8",
  704. )
  705. write_report(summary, portfolio, selected_rows)
  706. print(f"wrote {REPORT_DIR / f'{OUTPUT_PREFIX}-all.csv'}")
  707. print(f"wrote {REPORT_DIR / f'{OUTPUT_PREFIX}-top.csv'}")
  708. print(f"wrote {REPORT_DIR / f'{OUTPUT_PREFIX}-portfolio.csv'}")
  709. print(f"wrote {REPORT_DIR / f'{OUTPUT_PREFIX}-summary.md'}")
  710. return 0
  711. if __name__ == "__main__":
  712. raise SystemExit(main())