evaluate_layered_bb_squeeze_position.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669
  1. from __future__ import annotations
  2. import argparse
  3. import sys
  4. from dataclasses import dataclass
  5. from pathlib import Path
  6. import pandas as pd
  7. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  8. from okx_codex_trader.models import Candle
  9. from okx_codex_trader.sampled_report import trade_equity
  10. from scripts.search_live_bb_squeeze_exit_variants import DATA_DIR, INITIAL_EQUITY, LEVERAGE, OUTPUT_DIR, _format_ts
  11. ETH_SYMBOL = "ETH-USDT-SWAP"
  12. BTC_SYMBOL = "BTC-USDT-SWAP"
  13. BAR = "15m"
  14. ROUNDTRIP_COST = 0.0021
  15. WARMUP_BARS = 960
  16. HORIZONS = (
  17. ("full", None),
  18. ("3y", pd.DateOffset(years=3)),
  19. ("1y", pd.DateOffset(years=1)),
  20. ("6m", pd.DateOffset(months=6)),
  21. ("3m", pd.DateOffset(months=3)),
  22. ("30d", pd.DateOffset(days=30)),
  23. ("14d", pd.DateOffset(days=14)),
  24. )
  25. @dataclass(frozen=True)
  26. class Variant:
  27. name: str
  28. regime: str
  29. dynamic_exposure: bool
  30. t_overlay: bool
  31. t_profit_pct: float
  32. t_fraction: float
  33. readd_buffer_pct: float
  34. readd_mode: str
  35. dynamic_mode: str
  36. def load_candles(symbol: str, bar: str) -> list[Candle]:
  37. frame = pd.read_csv(DATA_DIR / symbol / f"{bar}.csv")
  38. return [
  39. Candle(
  40. symbol=symbol,
  41. ts=int(row.ts),
  42. open=float(row.open),
  43. high=float(row.high),
  44. low=float(row.low),
  45. close=float(row.close),
  46. volume=float(row.volume),
  47. )
  48. for row in frame.itertuples(index=False)
  49. ]
  50. def align_pair(eth: list[Candle], btc: list[Candle]) -> tuple[list[Candle], list[Candle]]:
  51. btc_by_ts = {candle.ts: candle for candle in btc}
  52. eth_out: list[Candle] = []
  53. btc_out: list[Candle] = []
  54. for candle in eth:
  55. other = btc_by_ts.get(candle.ts)
  56. if other is not None:
  57. eth_out.append(candle)
  58. btc_out.append(other)
  59. return eth_out, btc_out
  60. def indicators(eth: list[Candle], btc: list[Candle]) -> pd.DataFrame:
  61. frame = pd.DataFrame(
  62. {
  63. "ts": [candle.ts for candle in eth],
  64. "open": [candle.open for candle in eth],
  65. "high": [candle.high for candle in eth],
  66. "low": [candle.low for candle in eth],
  67. "close": [candle.close for candle in eth],
  68. "btc_close": [candle.close for candle in btc],
  69. }
  70. )
  71. close = frame["close"].astype(float)
  72. btc_close = frame["btc_close"].astype(float)
  73. middle = close.rolling(48).mean()
  74. stdev = close.rolling(48).std(ddof=0)
  75. upper = middle + 2.0 * stdev
  76. lower = middle - 2.0 * stdev
  77. bandwidth = (upper - lower) / middle
  78. frame["middle"] = middle
  79. frame["upper"] = upper
  80. frame["lower"] = lower
  81. frame["bandwidth"] = bandwidth
  82. frame["bandwidth_threshold"] = bandwidth.rolling(960).quantile(0.25)
  83. frame["eth_vol_96"] = close.pct_change().rolling(96).std(ddof=0)
  84. frame["eth_ret_90d"] = close / close.shift(96 * 90) - 1.0
  85. frame["btc_ret_90d"] = btc_close / btc_close.shift(96 * 90) - 1.0
  86. frame["ratio_ret_30d"] = (close / btc_close) / (close / btc_close).shift(96 * 30) - 1.0
  87. frame["eth_vol_30d"] = close.pct_change().rolling(96 * 30).std(ddof=0)
  88. frame["eth_vol_365d_median"] = frame["eth_vol_30d"].rolling(365).median()
  89. return frame
  90. def position_equity(position: dict[str, object] | None, mark_price: float) -> float:
  91. if position is None:
  92. return 0.0
  93. side = str(position["side"])
  94. total = 0.0
  95. for leg in position["legs"]: # type: ignore[union-attr]
  96. total += trade_equity(
  97. side=side,
  98. margin_used=float(leg["margin"]),
  99. entry_price=float(leg["entry_price"]),
  100. exit_price=mark_price,
  101. leverage=LEVERAGE,
  102. )
  103. return total
  104. def position_margin(position: dict[str, object] | None) -> float:
  105. if position is None:
  106. return 0.0
  107. return sum(float(leg["margin"]) for leg in position["legs"]) # type: ignore[union-attr]
  108. def position_return(position: dict[str, object], mark_price: float) -> float:
  109. margin = position_margin(position)
  110. if margin <= 0.0:
  111. return 0.0
  112. return position_equity(position, mark_price) / margin - 1.0
  113. def account_equity(cash: float, position: dict[str, object] | None, mark_price: float) -> float:
  114. return cash + position_equity(position, mark_price)
  115. def close_fraction(
  116. *,
  117. cash: float,
  118. position: dict[str, object],
  119. fraction: float,
  120. exit_price: float,
  121. ts: int,
  122. reason: str,
  123. trades: list[dict[str, object]],
  124. ) -> tuple[float, dict[str, object] | None, float]:
  125. side = str(position["side"])
  126. closed_margin = 0.0
  127. exit_value = 0.0
  128. remaining_legs: list[dict[str, float]] = []
  129. for leg in position["legs"]: # type: ignore[union-attr]
  130. margin = float(leg["margin"])
  131. close_margin = margin * fraction
  132. keep_margin = margin - close_margin
  133. if close_margin > 0.0:
  134. closed_margin += close_margin
  135. exit_value += trade_equity(
  136. side=side,
  137. margin_used=close_margin,
  138. entry_price=float(leg["entry_price"]),
  139. exit_price=exit_price,
  140. leverage=LEVERAGE,
  141. )
  142. if keep_margin > 1e-9:
  143. remaining_legs.append({"margin": keep_margin, "entry_price": float(leg["entry_price"])})
  144. if closed_margin <= 0.0:
  145. return cash, position, 0.0
  146. net_exit_value = exit_value - closed_margin * ROUNDTRIP_COST
  147. trades.append(
  148. {
  149. "side": "Long" if side == "long" else "Short",
  150. "entry_time": _format_ts(int(position["entry_ts"])),
  151. "exit_time": _format_ts(ts),
  152. "exit_ts": ts,
  153. "entry_price": weighted_entry_price(position),
  154. "exit_price": exit_price,
  155. "margin": closed_margin,
  156. "return_pct": (net_exit_value / closed_margin - 1.0) * 100.0,
  157. "exit_reason": reason,
  158. }
  159. )
  160. cash += net_exit_value
  161. if not remaining_legs:
  162. return cash, None, closed_margin
  163. updated = dict(position)
  164. updated["legs"] = remaining_legs
  165. updated["t_reduced"] = bool(updated.get("t_reduced")) or reason == "t_reduce"
  166. return cash, updated, closed_margin
  167. def weighted_entry_price(position: dict[str, object]) -> float:
  168. total_margin = position_margin(position)
  169. if total_margin <= 0.0:
  170. return 0.0
  171. return sum(float(leg["margin"]) * float(leg["entry_price"]) for leg in position["legs"]) / total_margin # type: ignore[union-attr]
  172. def add_margin(position: dict[str, object], margin: float, entry_price: float) -> dict[str, object]:
  173. updated = dict(position)
  174. legs = list(updated["legs"]) # type: ignore[arg-type]
  175. legs.append({"margin": margin, "entry_price": entry_price})
  176. updated["legs"] = legs
  177. updated["t_reduced"] = False
  178. return updated
  179. def regime_cap(row: pd.Series, side: str, mode: str) -> float:
  180. if mode == "none":
  181. return 1.0
  182. eth_ret = float(row["eth_ret_90d"])
  183. btc_ret = float(row["btc_ret_90d"])
  184. ratio_ret = float(row["ratio_ret_30d"])
  185. if any(value != value for value in (eth_ret, btc_ret, ratio_ret)):
  186. return 1.0
  187. if mode == "directional":
  188. if side == "long":
  189. if eth_ret > 0.0 and btc_ret > 0.0 and ratio_ret > -0.05:
  190. return 1.0
  191. if btc_ret > -0.05 and eth_ret > -0.12:
  192. return 0.65
  193. return 0.0
  194. if eth_ret < 0.0 or ratio_ret < -0.08:
  195. return 1.0
  196. if ratio_ret < 0.0 or btc_ret < -0.05:
  197. return 0.65
  198. return 0.0
  199. if mode == "vol_scaled":
  200. cap = 1.0
  201. if float(row["eth_vol_30d"]) > float(row["eth_vol_365d_median"]):
  202. cap *= 0.7
  203. if side == "long" and ratio_ret < -0.05:
  204. cap *= 0.5
  205. if side == "short" and ratio_ret > 0.05:
  206. cap *= 0.5
  207. return cap
  208. raise ValueError(f"unknown regime mode: {mode}")
  209. def dynamic_multiplier(row: pd.Series, closed_returns: list[float], variant: Variant) -> float:
  210. if variant.dynamic_mode == "none":
  211. return 1.0
  212. if variant.dynamic_mode == "vol_only":
  213. vol = float(row["eth_vol_96"])
  214. if vol == vol and vol > 0.005:
  215. return 0.8
  216. return 1.0
  217. if variant.dynamic_mode == "ratio_soft":
  218. ratio = float(row["ratio_ret_30d"])
  219. if ratio != ratio:
  220. return 1.0
  221. if ratio < -0.08:
  222. return 0.75
  223. if ratio > 0.08:
  224. return 1.05
  225. return 1.0
  226. if variant.dynamic_mode != "closed_trade":
  227. raise ValueError(f"unknown dynamic mode: {variant.dynamic_mode}")
  228. mult = 1.0
  229. vol = float(row["eth_vol_96"])
  230. if vol == vol and vol > 0.005:
  231. mult *= 0.7
  232. recent = closed_returns[-20:]
  233. if len(recent) >= 8:
  234. avg = sum(recent) / len(recent)
  235. if avg < -0.01:
  236. mult *= 0.5
  237. elif avg > 0.015:
  238. mult *= 1.15
  239. return min(1.0, max(0.25, mult))
  240. def should_readd(position: dict[str, object], row: pd.Series, variant: Variant) -> bool:
  241. if not variant.t_overlay or not bool(position.get("t_reduced")):
  242. return False
  243. side = str(position["side"])
  244. middle = float(row["middle"])
  245. close = float(row["close"])
  246. if variant.readd_mode == "never":
  247. return False
  248. if variant.readd_mode == "middle_trend":
  249. if side == "long":
  250. return close <= middle * (1.0 + variant.readd_buffer_pct) and float(row["ratio_ret_30d"]) >= -0.03
  251. return close >= middle * (1.0 - variant.readd_buffer_pct) and float(row["ratio_ret_30d"]) <= 0.03
  252. if variant.readd_mode != "middle":
  253. raise ValueError(f"unknown readd mode: {variant.readd_mode}")
  254. if side == "long":
  255. return close <= middle * (1.0 + variant.readd_buffer_pct)
  256. return close >= middle * (1.0 - variant.readd_buffer_pct)
  257. def target_unit(row: pd.Series, side: str, variant: Variant, closed_returns: list[float]) -> float:
  258. return regime_cap(row, side, variant.regime) * dynamic_multiplier(row, closed_returns, variant)
  259. def run_variant(frame: pd.DataFrame, variant: Variant) -> dict[str, object]:
  260. cash = INITIAL_EQUITY
  261. position: dict[str, object] | None = None
  262. pending_entry_side: str | None = None
  263. pending_full_exit: str | None = None
  264. pending_t_reduce = False
  265. pending_readd = False
  266. cooldown_until = -1
  267. middle_exit_streak = 0
  268. trades: list[dict[str, object]] = []
  269. equity_rows: list[dict[str, object]] = []
  270. event_rows: list[dict[str, object]] = []
  271. closed_returns: list[float] = []
  272. for index in range(WARMUP_BARS, len(frame)):
  273. row = frame.iloc[index]
  274. ts = int(row["ts"])
  275. open_price = float(row["open"])
  276. close_price = float(row["close"])
  277. if pending_full_exit and position is not None:
  278. margin_before = position_margin(position)
  279. cash, position, _ = close_fraction(
  280. cash=cash,
  281. position=position,
  282. fraction=1.0,
  283. exit_price=open_price,
  284. ts=ts,
  285. reason=pending_full_exit,
  286. trades=trades,
  287. )
  288. if trades:
  289. closed_returns.append(float(trades[-1]["return_pct"]) / 100.0)
  290. event_rows.append({"ts": ts, "event": pending_full_exit, "margin": margin_before})
  291. pending_full_exit = None
  292. pending_t_reduce = False
  293. pending_readd = False
  294. middle_exit_streak = 0
  295. cooldown_until = index + 24
  296. if pending_t_reduce and position is not None:
  297. cash, position, closed_margin = close_fraction(
  298. cash=cash,
  299. position=position,
  300. fraction=variant.t_fraction,
  301. exit_price=open_price,
  302. ts=ts,
  303. reason="t_reduce",
  304. trades=trades,
  305. )
  306. if closed_margin > 0.0 and trades:
  307. closed_returns.append(float(trades[-1]["return_pct"]) / 100.0)
  308. event_rows.append({"ts": ts, "event": "t_reduce", "margin": closed_margin})
  309. pending_t_reduce = False
  310. if pending_readd and position is not None:
  311. unit = target_unit(row, str(position["side"]), variant, closed_returns)
  312. equity = account_equity(cash, position, open_price)
  313. desired_margin = equity * unit
  314. missing = max(0.0, desired_margin - position_margin(position))
  315. add = min(cash, missing)
  316. if add > 1.0:
  317. cash -= add
  318. position = add_margin(position, add, open_price)
  319. event_rows.append({"ts": ts, "event": "t_readd", "margin": add})
  320. pending_readd = False
  321. if pending_entry_side is not None and position is None and cash > 1.0:
  322. unit = target_unit(row, pending_entry_side, variant, closed_returns)
  323. margin = cash * unit
  324. if margin > 1.0:
  325. cash -= margin
  326. position = {
  327. "side": pending_entry_side,
  328. "entry_ts": ts,
  329. "entry_index": index,
  330. "legs": [{"margin": margin, "entry_price": open_price}],
  331. "stop_price": open_price * (0.99 if pending_entry_side == "long" else 1.01),
  332. "t_reduced": False,
  333. }
  334. event_rows.append({"ts": ts, "event": f"entry_{pending_entry_side}", "margin": margin})
  335. pending_entry_side = None
  336. if position is not None:
  337. side = str(position["side"])
  338. stop_hit = (side == "long" and float(row["low"]) <= float(position["stop_price"])) or (
  339. side == "short" and float(row["high"]) >= float(position["stop_price"])
  340. )
  341. if stop_hit:
  342. margin_before = position_margin(position)
  343. cash, position, _ = close_fraction(
  344. cash=cash,
  345. position=position,
  346. fraction=1.0,
  347. exit_price=float(position["stop_price"]),
  348. ts=ts,
  349. reason="stop",
  350. trades=trades,
  351. )
  352. if trades:
  353. closed_returns.append(float(trades[-1]["return_pct"]) / 100.0)
  354. event_rows.append({"ts": ts, "event": "stop", "margin": margin_before})
  355. middle_exit_streak = 0
  356. cooldown_until = index + 24
  357. equity = account_equity(cash, position, close_price)
  358. equity_rows.append(
  359. {
  360. "ts": ts,
  361. "time": pd.to_datetime(ts, unit="ms", utc=True),
  362. "equity": equity,
  363. "cash": cash,
  364. "position_margin": position_margin(position),
  365. "position_side": "flat" if position is None else str(position["side"]),
  366. }
  367. )
  368. if index == len(frame) - 1 or equity <= 0.0:
  369. continue
  370. needed = ("middle", "upper", "lower", "bandwidth", "bandwidth_threshold", "eth_vol_96")
  371. if any(float(row[key]) != float(row[key]) for key in needed):
  372. continue
  373. if position is not None:
  374. side = str(position["side"])
  375. middle_exit = (side == "long" and close_price < float(row["middle"]) * (1.0 - 0.0005)) or (
  376. side == "short" and close_price > float(row["middle"]) * (1.0 + 0.0005)
  377. )
  378. middle_exit_streak = middle_exit_streak + 1 if middle_exit else 0
  379. if middle_exit_streak >= 1:
  380. pending_full_exit = "middle_exit"
  381. continue
  382. if variant.t_overlay and not bool(position.get("t_reduced")):
  383. band_half = float(row["upper"]) - float(row["middle"])
  384. far = (side == "long" and close_price >= float(row["upper"]) + 0.2 * band_half) or (
  385. side == "short" and close_price <= float(row["lower"]) - 0.2 * band_half
  386. )
  387. if far and position_return(position, close_price) >= variant.t_profit_pct:
  388. pending_t_reduce = True
  389. continue
  390. if should_readd(position, row, variant):
  391. pending_readd = True
  392. continue
  393. if index < cooldown_until or float(row["eth_vol_96"]) > 0.006:
  394. continue
  395. compressed = float(row["bandwidth"]) <= float(row["bandwidth_threshold"])
  396. if not compressed:
  397. continue
  398. if close_price > float(row["upper"]):
  399. if target_unit(row, "long", variant, closed_returns) > 0.0:
  400. pending_entry_side = "long"
  401. elif close_price < float(row["lower"]):
  402. if target_unit(row, "short", variant, closed_returns) > 0.0:
  403. pending_entry_side = "short"
  404. return {
  405. "variant": variant,
  406. "equity": pd.DataFrame(equity_rows),
  407. "trades": pd.DataFrame(trades),
  408. "events": pd.DataFrame(event_rows),
  409. }
  410. def max_drawdown(values: pd.Series) -> float:
  411. peak = values.cummax()
  412. return float(((peak - values) / peak).max()) if len(values) else 0.0
  413. def metrics(equity: pd.DataFrame, trades: pd.DataFrame, label: str, offset: pd.DateOffset | None) -> dict[str, object]:
  414. end = pd.Timestamp(equity["time"].iloc[-1])
  415. if offset is None:
  416. scoped = equity.copy()
  417. else:
  418. cutoff = end - offset
  419. before = equity[equity["time"] <= cutoff]
  420. start_equity = float(before["equity"].iloc[-1]) if len(before) else float(equity["equity"].iloc[0])
  421. after = equity[equity["time"] > cutoff]
  422. scoped = pd.concat([pd.DataFrame([{"time": cutoff, "equity": start_equity}]), after[["time", "equity"]]], ignore_index=True)
  423. start = pd.Timestamp(scoped["time"].iloc[0])
  424. years = max((end - start).total_seconds() / 86_400 / 365, 1e-9)
  425. total_return = float(scoped["equity"].iloc[-1] / scoped["equity"].iloc[0] - 1.0)
  426. annualized = (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 else -1.0
  427. if trades.empty:
  428. trade_count = 0
  429. win_rate = 0.0
  430. profit_factor = 0.0
  431. else:
  432. trade_times = pd.to_datetime(trades["exit_ts"], unit="ms", utc=True)
  433. scoped_trades = trades[(trade_times > start) & (trade_times <= end)]
  434. trade_count = len(scoped_trades)
  435. returns = scoped_trades["return_pct"].astype(float) / 100.0 if trade_count else pd.Series(dtype=float)
  436. win_rate = float((returns > 0.0).mean()) if trade_count else 0.0
  437. gains = float(returns[returns > 0.0].sum())
  438. losses = abs(float(returns[returns < 0.0].sum()))
  439. profit_factor = gains / losses if losses > 0.0 else 0.0
  440. dd = max_drawdown(scoped["equity"].astype(float))
  441. return {
  442. "horizon": label,
  443. "start": start.strftime("%Y-%m-%d %H:%M"),
  444. "end": end.strftime("%Y-%m-%d %H:%M"),
  445. "total_return": total_return,
  446. "annualized_return": annualized,
  447. "max_drawdown": dd,
  448. "calmar": annualized / dd if dd else 0.0,
  449. "trades": trade_count,
  450. "trades_per_30d": trade_count / years / 365 * 30,
  451. "win_rate": win_rate,
  452. "profit_factor": profit_factor,
  453. }
  454. def build_variants() -> list[Variant]:
  455. return [
  456. Variant("baseline", "none", False, False, 0.0, 0.0, 0.0, "middle", "none"),
  457. Variant("dynamic_exposure", "none", True, False, 0.0, 0.0, 0.0, "middle", "closed_trade"),
  458. Variant("dynamic_vol_only", "none", True, False, 0.0, 0.0, 0.0, "middle", "vol_only"),
  459. Variant("dynamic_ratio_soft", "none", True, False, 0.0, 0.0, 0.0, "middle", "ratio_soft"),
  460. Variant("regime_directional", "directional", False, False, 0.0, 0.0, 0.0, "middle", "none"),
  461. Variant("regime_vol_scaled", "vol_scaled", False, False, 0.0, 0.0, 0.0, "middle", "none"),
  462. Variant("t_overlay_p012_f20_never", "none", False, True, 0.012, 0.20, 0.001, "never", "none"),
  463. Variant("t_overlay_p012_f30", "none", False, True, 0.012, 0.30, 0.001, "middle", "none"),
  464. Variant("t_overlay_p012_f30_never", "none", False, True, 0.012, 0.30, 0.001, "never", "none"),
  465. Variant("t_overlay_p018_f20_middle_trend", "none", False, True, 0.018, 0.20, 0.001, "middle_trend", "none"),
  466. Variant("t_overlay_p018_f30", "none", False, True, 0.018, 0.30, 0.001, "middle", "none"),
  467. Variant("t_overlay_p024_f20_never", "none", False, True, 0.024, 0.20, 0.001, "never", "none"),
  468. Variant("dynamic_t_p012_f30", "none", True, True, 0.012, 0.30, 0.001, "middle", "closed_trade"),
  469. Variant("dynamic_t_p018_f20_middle_trend", "none", True, True, 0.018, 0.20, 0.001, "middle_trend", "closed_trade"),
  470. Variant("vol_dynamic_t_p024_f20_never", "none", True, True, 0.024, 0.20, 0.001, "never", "vol_only"),
  471. Variant("ratio_dynamic_t_p024_f20_never", "none", True, True, 0.024, 0.20, 0.001, "never", "ratio_soft"),
  472. Variant("regime_directional_dynamic", "directional", True, False, 0.0, 0.0, 0.0, "middle", "closed_trade"),
  473. Variant("regime_directional_t_p012_f30", "directional", False, True, 0.012, 0.30, 0.001, "middle", "none"),
  474. Variant("layered_directional_dynamic_t_p012_f30", "directional", True, True, 0.012, 0.30, 0.001, "middle", "closed_trade"),
  475. Variant("layered_vol_dynamic_t_p012_f30", "vol_scaled", True, True, 0.012, 0.30, 0.001, "middle", "closed_trade"),
  476. Variant("layered_vol_dynamic_t_p018_f20_middle_trend", "vol_scaled", True, True, 0.018, 0.20, 0.001, "middle_trend", "closed_trade"),
  477. Variant("layered_vol_t_p024_f20_never", "vol_scaled", False, True, 0.024, 0.20, 0.001, "never", "none"),
  478. Variant("layered_vol_ratio_t_p024_f20_never", "vol_scaled", True, True, 0.024, 0.20, 0.001, "never", "ratio_soft"),
  479. ]
  480. def markdown_table(frame: pd.DataFrame) -> str:
  481. def cell(value: object) -> str:
  482. if isinstance(value, float):
  483. return f"{value:.6g}"
  484. return str(value).replace("|", "\\|")
  485. rows = [list(frame.columns), ["---" for _ in frame.columns]]
  486. rows.extend(frame.astype(object).where(pd.notna(frame), "").values.tolist())
  487. return "\n".join("| " + " | ".join(cell(value) for value in row) + " |" for row in rows)
  488. def write_report(summary: pd.DataFrame, horizons: pd.DataFrame, events: pd.DataFrame, paths: list[Path], command: str) -> str:
  489. ranking = summary.sort_values(["recent_min_return", "recent_min_calmar", "full_max_drawdown"], ascending=[False, False, True])
  490. baseline = horizons[horizons["variant"] == "baseline"]
  491. best = ranking.iloc[0]
  492. decision = "No"
  493. baseline_h = horizons[horizons["variant"] == "baseline"].set_index("horizon")
  494. best_h = horizons[horizons["variant"] == best["variant"]].set_index("horizon")
  495. medium_not_worse = all(float(best_h.loc[horizon, "total_return"]) >= float(baseline_h.loc[horizon, "total_return"]) for horizon in ("1y", "6m", "3m"))
  496. short_not_worse = all(float(best_h.loc[horizon, "total_return"]) >= float(baseline_h.loc[horizon, "total_return"]) for horizon in ("30d", "14d"))
  497. if best["variant"] != "baseline" and medium_not_worse and short_not_worse and float(best["full_max_drawdown"]) <= float(summary.loc[summary["variant"] == "baseline", "full_max_drawdown"].iloc[0]):
  498. decision = "Paper-observe only"
  499. return (
  500. "# Layered BB squeeze position exploration\n\n"
  501. "Scope: offline local-candle research only. No live executor, deployment, credentials, or order path was changed.\n\n"
  502. f"Run command: `{command}`\n\n"
  503. "Layer model: long-term regime cap + medium-term live BB squeeze signal + optional dynamic exposure + optional in-position T overlay.\n\n"
  504. "Output files:\n"
  505. + "\n".join(f"- `{path}`" for path in paths)
  506. + "\n\n"
  507. "## Summary Ranking\n\n"
  508. + markdown_table(ranking[["variant", "full_total_return", "full_max_drawdown", "full_calmar", "recent_min_return", "recent_min_calmar", "trades", "t_reduce_events", "t_readd_events"]])
  509. + "\n\n"
  510. "## Baseline Horizons\n\n"
  511. + markdown_table(baseline[["horizon", "total_return", "annualized_return", "max_drawdown", "calmar", "trades", "trades_per_30d", "win_rate", "profit_factor"]])
  512. + "\n\n"
  513. "## Best Variant Horizons\n\n"
  514. + markdown_table(horizons[horizons["variant"] == best["variant"]][["horizon", "total_return", "annualized_return", "max_drawdown", "calmar", "trades", "trades_per_30d", "win_rate", "profit_factor"]])
  515. + "\n\n"
  516. "## Event Counts\n\n"
  517. + markdown_table(events)
  518. + "\n\n"
  519. "## Decision\n\n"
  520. f"- Best ranked variant: `{best['variant']}`.\n"
  521. f"- Replace live strategy now: {decision}.\n"
  522. )
  523. def main() -> int:
  524. parser = argparse.ArgumentParser()
  525. parser.add_argument("--bar", default=BAR)
  526. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  527. args = parser.parse_args()
  528. eth, btc = align_pair(load_candles(ETH_SYMBOL, args.bar), load_candles(BTC_SYMBOL, args.bar))
  529. frame = indicators(eth, btc)
  530. summary_rows: list[dict[str, object]] = []
  531. horizon_rows: list[dict[str, object]] = []
  532. event_rows: list[dict[str, object]] = []
  533. equity_frames: list[pd.DataFrame] = []
  534. for variant in build_variants():
  535. result = run_variant(frame, variant)
  536. equity = result["equity"].copy()
  537. trades = result["trades"]
  538. events = result["events"]
  539. equity["variant"] = variant.name
  540. equity_frames.append(equity)
  541. horizon_metrics = [metrics(equity, trades, label, offset) for label, offset in HORIZONS]
  542. for row in horizon_metrics:
  543. horizon_rows.append({"variant": variant.name, **row})
  544. by_horizon = {row["horizon"]: row for row in horizon_metrics}
  545. event_counts = events["event"].value_counts().to_dict() if not events.empty else {}
  546. event_rows.append(
  547. {
  548. "variant": variant.name,
  549. "entry_events": int(sum(value for key, value in event_counts.items() if str(key).startswith("entry_"))),
  550. "middle_exit_events": int(event_counts.get("middle_exit", 0)),
  551. "stop_events": int(event_counts.get("stop", 0)),
  552. "t_reduce_events": int(event_counts.get("t_reduce", 0)),
  553. "t_readd_events": int(event_counts.get("t_readd", 0)),
  554. }
  555. )
  556. recent_returns = [float(by_horizon[label]["total_return"]) for label in ("1y", "6m", "3m", "30d", "14d")]
  557. recent_calmars = [float(by_horizon[label]["calmar"]) for label in ("1y", "6m", "3m", "30d", "14d")]
  558. summary_rows.append(
  559. {
  560. "variant": variant.name,
  561. "regime": variant.regime,
  562. "dynamic_exposure": variant.dynamic_exposure,
  563. "dynamic_mode": variant.dynamic_mode,
  564. "t_overlay": variant.t_overlay,
  565. "t_profit_pct": variant.t_profit_pct,
  566. "t_fraction": variant.t_fraction,
  567. "readd_mode": variant.readd_mode,
  568. "trades": int(by_horizon["full"]["trades"]),
  569. "full_total_return": by_horizon["full"]["total_return"],
  570. "full_annualized_return": by_horizon["full"]["annualized_return"],
  571. "full_max_drawdown": by_horizon["full"]["max_drawdown"],
  572. "full_calmar": by_horizon["full"]["calmar"],
  573. "recent_min_return": min(recent_returns),
  574. "recent_min_calmar": min(recent_calmars),
  575. "t_reduce_events": int(event_counts.get("t_reduce", 0)),
  576. "t_readd_events": int(event_counts.get("t_readd", 0)),
  577. }
  578. )
  579. print(f"done {variant.name}")
  580. summary = pd.DataFrame(summary_rows)
  581. horizons = pd.DataFrame(horizon_rows)
  582. events = pd.DataFrame(event_rows)
  583. equity_out = pd.concat(equity_frames, ignore_index=True)
  584. args.output_dir.mkdir(parents=True, exist_ok=True)
  585. prefix = "layered-bb-squeeze-position"
  586. summary_path = args.output_dir / f"{prefix}-summary.csv"
  587. horizons_path = args.output_dir / f"{prefix}-horizons.csv"
  588. events_path = args.output_dir / f"{prefix}-events.csv"
  589. equity_path = args.output_dir / f"{prefix}-equity.csv"
  590. report_path = args.output_dir / f"{prefix}-report.md"
  591. paths = [summary_path, horizons_path, events_path, equity_path, report_path]
  592. summary.to_csv(summary_path, index=False)
  593. horizons.to_csv(horizons_path, index=False)
  594. events.to_csv(events_path, index=False)
  595. equity_out.to_csv(equity_path, index=False)
  596. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --bar {args.bar} --output-dir {args.output_dir.as_posix()}"
  597. report_path.write_text(write_report(summary, horizons, events, paths, command), encoding="utf-8")
  598. print(summary.sort_values(["recent_min_return", "recent_min_calmar"], ascending=[False, False]).head(10).to_string(index=False))
  599. return 0
  600. if __name__ == "__main__":
  601. raise SystemExit(main())