evaluate_eth_crash_follow_overlay.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  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_bearish_price_proxy import Spec, joined_frames, load_frame, resample, run_spec
  8. from scripts.search_long_short_fusion import INITIAL_EQUITY, component_returns, markdown_table, metrics
  9. OUTPUT_DIR = Path("reports/long-short-fusion")
  10. PREFIX = "eth-crash-follow-overlay"
  11. BASELINE_EQUITY = Path("reports/eth-exploration/eth-focused-portfolio-conservative-equity.csv")
  12. BASELINE_PORTFOLIO = "all_legs-risk-3-c0124-eth_btc_rsi_filter+btc_lead_eth_lag_15m+eth_robust_twap"
  13. HORIZONS = (
  14. ("full", None),
  15. ("3y", pd.DateOffset(years=3)),
  16. ("1y", pd.DateOffset(years=1)),
  17. ("6m", pd.DateOffset(months=6)),
  18. ("3m", pd.DateOffset(months=3)),
  19. )
  20. CRASH_FOLLOW = Spec("crash_follow", "1H", 20, 120, 8, 0.035, 0.02, 0.06, 96, "btc_riskoff")
  21. WEIGHTS = (0.05, 0.10, 0.15, 0.20, 0.25, 0.30)
  22. def baseline_equity(path: Path, portfolio: str) -> pd.Series:
  23. frame = pd.read_csv(path)
  24. selected = frame[
  25. (frame["portfolio"] == portfolio)
  26. & (frame["cost_model"] == "maker_taker")
  27. & (frame["scope"] == "all_legs")
  28. ].copy()
  29. selected["date"] = pd.to_datetime(selected["date"], utc=True)
  30. series = selected.sort_values("date").set_index("date")["equity"].astype(float)
  31. series.name = portfolio
  32. return series
  33. def crash_follow_returns(spec: Spec) -> pd.Series:
  34. eth = load_frame("ETH-USDT-SWAP")
  35. btc = load_frame("BTC-USDT-SWAP")
  36. frame = joined_frames(resample(eth, spec.bar), resample(btc, spec.bar))
  37. equity, _ = run_spec(spec, frame)
  38. returns = component_returns(equity)
  39. returns.name = spec.name
  40. return returns
  41. def horizon_metrics(series: pd.Series) -> dict[str, dict[str, object]]:
  42. end = series.index[-1]
  43. rows = {}
  44. for label, offset in HORIZONS:
  45. scoped = series if offset is None else series[series.index >= end - offset]
  46. if len(scoped) < 2:
  47. scoped = series
  48. rows[label] = {
  49. "start": scoped.index[0].strftime("%Y-%m-%d"),
  50. "end": scoped.index[-1].strftime("%Y-%m-%d"),
  51. **metrics(scoped),
  52. }
  53. return rows
  54. def overlay_equity(base: pd.Series, proxy_returns: pd.Series, weight: float) -> pd.Series:
  55. aligned = pd.DataFrame({"base": component_returns(base), "proxy": proxy_returns}).dropna()
  56. combined = aligned["base"] + aligned["proxy"] * weight
  57. equity = INITIAL_EQUITY * (1.0 + combined).cumprod()
  58. equity.name = f"{base.name}+{CRASH_FOLLOW.name}@{weight:.2f}"
  59. return equity
  60. def result_rows(base: pd.Series, proxy_returns: pd.Series) -> pd.DataFrame:
  61. base_rows = horizon_metrics(base)
  62. rows = []
  63. for weight in WEIGHTS:
  64. overlaid = overlay_equity(base, proxy_returns, weight)
  65. overlaid_rows = horizon_metrics(overlaid)
  66. for horizon, row in overlaid_rows.items():
  67. baseline = base_rows[horizon]
  68. rows.append(
  69. {
  70. "overlay_weight": weight,
  71. "horizon": horizon,
  72. "start": row["start"],
  73. "end": row["end"],
  74. "total_return": row["total_return"],
  75. "annualized_return": row["annualized_return"],
  76. "max_drawdown": row["max_drawdown"],
  77. "calmar": row["calmar"],
  78. "baseline_total_return": baseline["total_return"],
  79. "baseline_max_drawdown": baseline["max_drawdown"],
  80. "delta_total_return": row["total_return"] - baseline["total_return"],
  81. "delta_max_drawdown": row["max_drawdown"] - baseline["max_drawdown"],
  82. }
  83. )
  84. return pd.DataFrame(rows)
  85. def report_text(command: str, baseline_name: str, proxy_name: str, paths: list[Path], results: pd.DataFrame) -> str:
  86. full = results[results["horizon"] == "full"].copy()
  87. best = full.sort_values(["calmar", "delta_max_drawdown", "total_return"], ascending=[False, True, False]).iloc[0]
  88. recent = results[results["horizon"].isin(["1y", "6m", "3m"])]
  89. recent_ok = recent.groupby("overlay_weight")["total_return"].apply(lambda values: bool((values > 0.0).all()))
  90. include = bool(
  91. recent_ok.get(float(best["overlay_weight"]), False)
  92. and best["delta_total_return"] > 0.0
  93. and best["delta_max_drawdown"] <= 0.005
  94. )
  95. verdict = (
  96. f"Include crash_follow as a small overlay dimension in fusion search, capped at {best['overlay_weight']:.2f} in the first pass. Higher tested weights add return but expand full/3y drawdown too much for this conservative baseline."
  97. if include
  98. else "Do not add crash_follow to fusion search now; the overlay does not improve the baseline cleanly across recent windows."
  99. )
  100. keep = [
  101. "overlay_weight",
  102. "horizon",
  103. "total_return",
  104. "annualized_return",
  105. "max_drawdown",
  106. "calmar",
  107. "delta_total_return",
  108. "delta_max_drawdown",
  109. ]
  110. return "\n".join(
  111. [
  112. "# ETH Crash-Follow Overlay Evaluation",
  113. "",
  114. f"Run command: `{command}`",
  115. "",
  116. "Output files:",
  117. *[f"- `{path}`" for path in paths],
  118. "",
  119. f"Baseline: `{baseline_name}` from `reports/eth-exploration/eth-focused-portfolio-conservative-equity.csv`.",
  120. f"Overlay proxy: `{proxy_name}` from the ETH bearish price-proxy all-window-positive candidate.",
  121. "",
  122. "Method: daily baseline return plus `overlay_weight * crash_follow_daily_return`; local candle data only; no live path touched.",
  123. "",
  124. "## Results",
  125. "",
  126. markdown_table(results[keep]),
  127. "",
  128. "## Verdict",
  129. "",
  130. verdict,
  131. "",
  132. ]
  133. )
  134. def main() -> int:
  135. parser = argparse.ArgumentParser()
  136. parser.add_argument("--baseline-equity", type=Path, default=BASELINE_EQUITY)
  137. parser.add_argument("--baseline-portfolio", default=BASELINE_PORTFOLIO)
  138. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  139. args = parser.parse_args()
  140. args.output_dir.mkdir(parents=True, exist_ok=True)
  141. base_equity = baseline_equity(args.baseline_equity, args.baseline_portfolio)
  142. proxy_returns = crash_follow_returns(CRASH_FOLLOW)
  143. results = result_rows(base_equity, proxy_returns)
  144. results_path = args.output_dir / f"{PREFIX}.csv"
  145. report_path = args.output_dir / f"{PREFIX}.md"
  146. results.to_csv(results_path, index=False)
  147. report_path.write_text(
  148. report_text(
  149. "rtk .venv/bin/python scripts/evaluate_eth_crash_follow_overlay.py",
  150. args.baseline_portfolio,
  151. CRASH_FOLLOW.name,
  152. [results_path, report_path],
  153. results,
  154. ),
  155. encoding="utf-8",
  156. )
  157. print(report_path)
  158. print(results.to_string(index=False))
  159. return 0
  160. if __name__ == "__main__":
  161. raise SystemExit(main())