search_btc_eth_bbmr_risk_variants.py 27 KB

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