validate_eth_bb_squeeze_t_gate_robustness.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. from __future__ import annotations
  2. import argparse
  3. import sys
  4. from pathlib import Path
  5. import pandas as pd
  6. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  7. from scripts.search_eth_bb_squeeze_t_gates import (
  8. BAR,
  9. BTC_SYMBOL,
  10. COSTS,
  11. ETH_SYMBOL,
  12. OUTPUT_DIR,
  13. PRIMARY_COST,
  14. Variant,
  15. _align_pair,
  16. _format_ts,
  17. _load_candles,
  18. cost_equity_frame,
  19. equity_metrics,
  20. markdown_table,
  21. run_variant,
  22. worst_month,
  23. )
  24. HORIZONS = (
  25. ("10y", 10.0 * 365),
  26. ("180d", 180.0),
  27. ("90d", 90.0),
  28. )
  29. def build_candidates() -> list[Variant]:
  30. return [
  31. Variant(96, 960, 0.25, 0.01, 0.035, "both", "btc-up", 0.006, 0.25, 24, 48, "btc_against", 0.0, 1, 0.006, 0.25, 0.008),
  32. Variant(96, 960, 0.25, 0.01, 0.035, "both", "btc-up", 0.006, 0.25, 24, 96, "btc_against", 0.0, 1, 0.006, 0.25, 0.008),
  33. Variant(96, 960, 0.25, 0.01, 0.035, "both", "btc-up", 0.006, 0.25, 24, 192, "btc_against", 0.0, 1, 0.006, 0.25, 0.008),
  34. Variant(96, 960, 0.25, 0.01, 0.035, "both", "btc-up", 0.006, 0.25, 24, 48, "btc_against", 0.001, 1, 0.006, 0.25, 0.008),
  35. ]
  36. def slice_days(eth: list, btc: list, days: float) -> tuple[list, list]:
  37. cutoff = eth[-1].ts - int(days * 86_400_000)
  38. start = next(index for index, candle in enumerate(eth) if candle.ts >= cutoff)
  39. return eth[start:], btc[start:]
  40. def evaluate_candidates(eth: list, btc: list) -> pd.DataFrame:
  41. primary_cost = dict(COSTS)[PRIMARY_COST]
  42. rows: list[dict[str, object]] = []
  43. for variant in build_candidates():
  44. for horizon, days in HORIZONS:
  45. horizon_eth, horizon_btc = slice_days(eth, btc, days)
  46. result, gate_stats = run_variant(horizon_eth, horizon_btc, variant)
  47. if not result.equity_curve:
  48. continue
  49. frame = cost_equity_frame(result, primary_cost)
  50. metrics = equity_metrics(frame, horizon_eth[0].ts, horizon_eth[-1].ts)
  51. month, month_return = worst_month(frame)
  52. rows.append(
  53. {
  54. "horizon": horizon,
  55. "name": variant.name,
  56. "reentry_bars": variant.reentry_bars,
  57. "middle_exit_buffer_pct": variant.middle_exit_buffer_pct,
  58. "middle_exit_confirm_bars": variant.middle_exit_confirm_bars,
  59. "first_candle": _format_ts(horizon_eth[0].ts),
  60. "last_candle": _format_ts(horizon_eth[-1].ts),
  61. "trades": result.trade_count,
  62. "reentry_entries": gate_stats["reentry_entries"],
  63. "net_total_return": metrics["net_total_return"],
  64. "net_annualized_return": metrics["net_annualized_return"],
  65. "net_max_drawdown": metrics["net_max_drawdown"],
  66. "net_calmar": metrics["net_calmar"],
  67. "worst_month": month,
  68. "worst_month_return": month_return,
  69. }
  70. )
  71. return pd.DataFrame(rows)
  72. def candidate_score(frame: pd.DataFrame) -> pd.DataFrame:
  73. pivot = frame.pivot(index="name", columns="horizon", values=["net_total_return", "net_max_drawdown", "net_calmar", "trades"])
  74. rows: list[dict[str, object]] = []
  75. for name in pivot.index:
  76. row = {"name": name}
  77. source = frame[frame["name"] == name].iloc[0]
  78. row["reentry_bars"] = int(source["reentry_bars"])
  79. row["middle_exit_buffer_pct"] = float(source["middle_exit_buffer_pct"])
  80. row["middle_exit_confirm_bars"] = int(source["middle_exit_confirm_bars"])
  81. for horizon, _days in HORIZONS:
  82. row[f"{horizon}_return"] = float(pivot.loc[name, ("net_total_return", horizon)])
  83. row[f"{horizon}_mdd"] = float(pivot.loc[name, ("net_max_drawdown", horizon)])
  84. row[f"{horizon}_calmar"] = float(pivot.loc[name, ("net_calmar", horizon)])
  85. row[f"{horizon}_trades"] = int(pivot.loc[name, ("trades", horizon)])
  86. row["positive_recent_windows"] = int(row["180d_return"] > 0.0) + int(row["90d_return"] > 0.0)
  87. row["min_recent_return"] = min(row["180d_return"], row["90d_return"])
  88. row["max_recent_mdd"] = max(row["180d_mdd"], row["90d_mdd"])
  89. rows.append(row)
  90. return pd.DataFrame(rows).sort_values(
  91. ["positive_recent_windows", "min_recent_return", "max_recent_mdd", "10y_calmar"],
  92. ascending=[False, False, True, False],
  93. )
  94. def write_report(*, detail: pd.DataFrame, score: pd.DataFrame, output_dir: Path) -> str:
  95. best = score.iloc[0]
  96. recent = detail[detail["horizon"].isin(["180d", "90d"])].copy()
  97. recent = recent.sort_values(["horizon", "net_total_return"], ascending=[True, False])
  98. lines = [
  99. "# ETH BB squeeze T-gated robustness validation",
  100. "",
  101. "Scope: adjacent variants around the current best T-gated BB squeeze candidate. No live executor changes.",
  102. f"Cost model: `{PRIMARY_COST}`.",
  103. "",
  104. "Output files:",
  105. f"- `{output_dir / 'eth-bb-squeeze-t-gate-robustness-detail.csv'}`",
  106. f"- `{output_dir / 'eth-bb-squeeze-t-gate-robustness-score.csv'}`",
  107. f"- `{output_dir / 'eth-bb-squeeze-t-gate-robustness-report.md'}`",
  108. "",
  109. "Candidate score:",
  110. markdown_table(
  111. score[
  112. [
  113. "reentry_bars",
  114. "middle_exit_buffer_pct",
  115. "middle_exit_confirm_bars",
  116. "10y_return",
  117. "10y_calmar",
  118. "180d_return",
  119. "180d_mdd",
  120. "90d_return",
  121. "90d_mdd",
  122. "positive_recent_windows",
  123. ]
  124. ]
  125. ),
  126. "",
  127. "Recent horizon detail:",
  128. markdown_table(
  129. recent[
  130. [
  131. "horizon",
  132. "reentry_bars",
  133. "middle_exit_buffer_pct",
  134. "trades",
  135. "reentry_entries",
  136. "net_total_return",
  137. "net_max_drawdown",
  138. "net_calmar",
  139. "worst_month",
  140. "worst_month_return",
  141. ]
  142. ]
  143. ),
  144. "",
  145. "Verdict:",
  146. (
  147. f"- Most stable adjacent candidate: reentry_bars={int(best['reentry_bars'])}, "
  148. f"middle_exit_buffer_pct={best['middle_exit_buffer_pct']:.3g}, "
  149. f"middle_exit_confirm_bars={int(best['middle_exit_confirm_bars'])}."
  150. ),
  151. (
  152. f"- Recent performance check: 180d {best['180d_return']:.2%}, "
  153. f"90d {best['90d_return']:.2%}; max recent MDD {best['max_recent_mdd']:.2%}."
  154. ),
  155. "- Small live executor readiness should require positive 90d and 180d behavior across adjacent variants, not only the 10y top row.",
  156. ]
  157. return "\n".join(lines) + "\n"
  158. def main() -> int:
  159. parser = argparse.ArgumentParser()
  160. parser.add_argument("--bar", default=BAR)
  161. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  162. args = parser.parse_args()
  163. eth = _load_candles(ETH_SYMBOL, args.bar)
  164. btc = _load_candles(BTC_SYMBOL, args.bar)
  165. eth, btc = _align_pair(eth, btc)
  166. eth, btc = slice_days(eth, btc, HORIZONS[0][1])
  167. detail = evaluate_candidates(eth, btc)
  168. score = candidate_score(detail)
  169. args.output_dir.mkdir(parents=True, exist_ok=True)
  170. detail_path = args.output_dir / "eth-bb-squeeze-t-gate-robustness-detail.csv"
  171. score_path = args.output_dir / "eth-bb-squeeze-t-gate-robustness-score.csv"
  172. report_path = args.output_dir / "eth-bb-squeeze-t-gate-robustness-report.md"
  173. detail.to_csv(detail_path, index=False)
  174. score.to_csv(score_path, index=False)
  175. report_path.write_text(write_report(detail=detail, score=score, output_dir=args.output_dir), encoding="utf-8")
  176. print(score.to_string(index=False))
  177. return 0
  178. if __name__ == "__main__":
  179. raise SystemExit(main())