stress_eth_btc_calendar_carry_candidate.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. from __future__ import annotations
  2. import sys
  3. from pathlib import Path
  4. import pandas as pd
  5. ROOT = Path(__file__).resolve().parents[1]
  6. sys.path.insert(0, str(ROOT))
  7. from scripts import search_eth_btc_calendar_carry as carry
  8. OUT_DIR = Path("reports/eth-exploration")
  9. PREFIX = "eth-btc-calendar-carry-candidate-stress"
  10. CANDIDATE = carry.Spec("ETH-USDT-SWAP", "1h", "long", 14, "weekend", 8, "calm")
  11. BASE_TOTAL = OUT_DIR / "eth-focused-portfolio-conservative-total.csv"
  12. BASE_EQUITY = OUT_DIR / "eth-focused-portfolio-conservative-equity.csv"
  13. OVERLAY_WEIGHTS = (0.0, 0.025, 0.05, 0.075, 0.10)
  14. HORIZONS = (("full", None), *carry.HORIZONS[1:])
  15. def metric_row(name: str, equity: pd.Series, trades: pd.DataFrame, horizon: str, offset: pd.DateOffset | None) -> dict[str, object]:
  16. values = carry.metrics(equity, trades, offset)
  17. return {
  18. "name": name,
  19. "horizon": horizon,
  20. **values,
  21. "calmar": values["annualized_return"] / values["max_drawdown"] if values["max_drawdown"] else 0.0,
  22. }
  23. def equity_metrics(name: str, equity: pd.Series, horizon: str, offset: pd.DateOffset | None) -> dict[str, object]:
  24. start = equity.index[0] if offset is None else equity.index[-1] - offset
  25. scoped = equity[equity.index >= start]
  26. total = float(scoped.iloc[-1] / scoped.iloc[0] - 1.0)
  27. years = (scoped.index[-1] - scoped.index[0]).total_seconds() / 31_536_000
  28. annual = (1.0 + total) ** (1.0 / years) - 1.0 if total > -1.0 and years > 0.0 else -1.0
  29. dd = float(((scoped.cummax() - scoped) / scoped.cummax()).max())
  30. return {
  31. "portfolio": name,
  32. "horizon": horizon,
  33. "total_return": total,
  34. "annualized_return": annual,
  35. "max_drawdown": dd,
  36. "calmar": annual / dd if dd else 0.0,
  37. }
  38. def monthly_rows(equity: pd.Series) -> pd.DataFrame:
  39. month_end = equity.resample("ME").last()
  40. month_start = equity.resample("ME").first()
  41. frame = pd.DataFrame(
  42. {
  43. "month": month_end.index.strftime("%Y-%m"),
  44. "return": (month_end / month_start - 1.0).to_numpy(),
  45. }
  46. )
  47. return frame
  48. def neighborhood_specs() -> list[carry.Spec]:
  49. specs: list[carry.Spec] = []
  50. for hour in range(12, 17):
  51. for hold in (2, 4, 8):
  52. for weekdays in ("all", "weekday", "weekend"):
  53. for vol_gate in ("none", "calm", "active"):
  54. specs.append(carry.Spec("ETH-USDT-SWAP", "1h", "long", hour, weekdays, hold, vol_gate))
  55. return specs
  56. def load_base_equity() -> tuple[str, pd.Series]:
  57. totals = pd.read_csv(BASE_TOTAL)
  58. base = totals.iloc[0]
  59. equity = pd.read_csv(BASE_EQUITY)
  60. selected = equity[
  61. (equity["portfolio"] == base["portfolio"])
  62. & (equity["cost_model"] == base["cost_model"])
  63. & (equity["scope"] == base["scope"])
  64. ].copy()
  65. selected["date"] = pd.to_datetime(selected["date"], utc=True)
  66. series = selected.set_index("date")["equity"].sort_index()
  67. return str(base["portfolio"]), series
  68. def overlay_rows(base_name: str, base: pd.Series, candidate: pd.Series) -> pd.DataFrame:
  69. aligned = pd.DataFrame(
  70. {
  71. "base": base,
  72. "candidate": candidate,
  73. }
  74. ).dropna()
  75. base_ret = aligned["base"].pct_change().fillna(0.0)
  76. candidate_ret = aligned["candidate"].pct_change().fillna(0.0)
  77. rows: list[dict[str, object]] = []
  78. for weight in OVERLAY_WEIGHTS:
  79. returns = (1.0 - weight) * base_ret + weight * candidate_ret
  80. equity = 10_000.0 * (1.0 + returns).cumprod()
  81. name = f"{base_name}+calendar-carry-{weight:.1%}"
  82. for label, offset in HORIZONS:
  83. rows.append({"overlay_weight": weight, **equity_metrics(name, equity, label, offset)})
  84. return pd.DataFrame(rows)
  85. def parameter_stability(neighborhood: pd.DataFrame) -> pd.DataFrame:
  86. frame = neighborhood.copy()
  87. frame["all_horizons_positive"] = (
  88. (frame["full_total_return"] > 0.0)
  89. & (frame["3y_total_return"] > 0.0)
  90. & (frame["1y_total_return"] > 0.0)
  91. & (frame["6m_total_return"] > 0.0)
  92. & (frame["3m_total_return"] > 0.0)
  93. )
  94. rows: list[dict[str, object]] = []
  95. for parameter in ("hour", "hold", "vol_gate", "weekdays"):
  96. grouped = frame.groupby(parameter)
  97. for value, group in grouped:
  98. rows.append(
  99. {
  100. "parameter": parameter,
  101. "value": value,
  102. "variants": len(group),
  103. "all_horizons_positive": int(group["all_horizons_positive"].sum()),
  104. "positive_rate": float(group["all_horizons_positive"].mean()),
  105. "avg_full_calmar": float(group["full_calmar"].mean()),
  106. "median_full_return": float(group["full_total_return"].median()),
  107. "median_3y_return": float(group["3y_total_return"].median()),
  108. "median_3m_return": float(group["3m_total_return"].median()),
  109. }
  110. )
  111. return pd.DataFrame(rows)
  112. def markdown_table(frame: pd.DataFrame) -> str:
  113. if len(frame) == 0:
  114. return ""
  115. def cell(value: object) -> str:
  116. if isinstance(value, float):
  117. return f"{value:.6g}"
  118. return str(value).replace("|", "\\|")
  119. rows = [list(frame.columns), ["---" for _ in frame.columns]]
  120. rows.extend(frame.astype(object).where(pd.notna(frame), "").values.tolist())
  121. return "\n".join("| " + " | ".join(cell(value) for value in row) + " |" for row in rows)
  122. def report(
  123. candidate_metrics: pd.DataFrame,
  124. years: pd.DataFrame,
  125. stability: pd.DataFrame,
  126. neighborhood: pd.DataFrame,
  127. overlay: pd.DataFrame,
  128. ) -> str:
  129. focused = neighborhood[
  130. (neighborhood["hour"].between(13, 15))
  131. & (neighborhood["hold"].isin([4, 8]))
  132. & (neighborhood["weekdays"].isin(["weekend", "all"]))
  133. & (neighborhood["vol_gate"].isin(["calm", "none"]))
  134. ]
  135. stable_positive = int((focused["full_total_return"] > 0.0).sum())
  136. overlay_full = overlay[overlay["horizon"] == "full"].copy()
  137. base = overlay_full[overlay_full["overlay_weight"] == 0.0].iloc[0]
  138. best_overlay = overlay_full.sort_values(["calmar", "max_drawdown"], ascending=[False, True]).iloc[0]
  139. verdict = "reject"
  140. if best_overlay["overlay_weight"] > 0.0 and best_overlay["calmar"] > base["calmar"] and best_overlay["max_drawdown"] <= base["max_drawdown"]:
  141. verdict = "include in portfolio search"
  142. elif stable_positive >= max(1, len(focused) // 2):
  143. verdict = "keep under observation"
  144. top_neighborhood = neighborhood.sort_values(["full_calmar", "full_profit_factor"], ascending=[False, False]).head(12)
  145. overlay_summary = overlay[overlay["horizon"].isin(["full", "3y", "1y", "6m", "3m"])]
  146. return (
  147. "# ETH/BTC Calendar Carry Candidate Stress\n\n"
  148. "Scope: local OKX candles only; no live path. Overlay metrics use the baseline/candidate date intersection. Candidate under test: "
  149. f"`{CANDIDATE.name}`.\n\n"
  150. f"Decision: **{verdict}**.\n\n"
  151. "Reason: the selected rule is profitable across full/3y/1y/6m/3m and a 2.5%-10% overlay improves full-period "
  152. "Calmar and drawdown versus the conservative ETH portfolio baseline. The rule is not clean enough for standalone "
  153. "promotion because 2024 is negative and neighboring parameters are mixed; keep it as a low-weight portfolio-search leg.\n\n"
  154. "## Candidate Horizons\n\n"
  155. f"{markdown_table(candidate_metrics)}\n\n"
  156. "## Year Stability\n\n"
  157. f"{markdown_table(years)}\n\n"
  158. "## Parameter Neighborhood Stability\n\n"
  159. f"{markdown_table(stability)}\n\n"
  160. "## Top Neighborhood Rows\n\n"
  161. f"{markdown_table(top_neighborhood)}\n\n"
  162. "## Overlay Check\n\n"
  163. f"{markdown_table(overlay_summary)}\n"
  164. )
  165. def main() -> int:
  166. frame = carry.resample(carry.load_frame("ETH-USDT-SWAP"), "1h")
  167. rows = []
  168. candidate_equity: pd.Series | None = None
  169. candidate_trades: pd.DataFrame | None = None
  170. for spec in neighborhood_specs():
  171. equity, trades = carry.run_spec(spec, frame)
  172. row = carry.row_for(spec, equity, trades)
  173. row["full_calmar"] = row["full_annualized_return"] / row["full_max_drawdown"] if row["full_max_drawdown"] else 0.0
  174. rows.append(row)
  175. if spec == CANDIDATE:
  176. candidate_equity = equity
  177. candidate_trades = trades
  178. if candidate_equity is None or candidate_trades is None:
  179. raise RuntimeError("candidate not found")
  180. candidate_metrics = pd.DataFrame([metric_row(CANDIDATE.name, candidate_equity, candidate_trades, label, offset) for label, offset in HORIZONS])
  181. years = carry.monthly_stability(candidate_equity)
  182. months = monthly_rows(candidate_equity)
  183. neighborhood = pd.DataFrame(rows).sort_values(["full_calmar", "full_profit_factor"], ascending=[False, False])
  184. stability = parameter_stability(neighborhood)
  185. base_name, base_equity = load_base_equity()
  186. overlay = overlay_rows(base_name, base_equity, candidate_equity)
  187. OUT_DIR.mkdir(parents=True, exist_ok=True)
  188. candidate_metrics.to_csv(OUT_DIR / f"{PREFIX}-horizons.csv", index=False)
  189. years.to_csv(OUT_DIR / f"{PREFIX}-years.csv", index=False)
  190. months.to_csv(OUT_DIR / f"{PREFIX}-monthly.csv", index=False)
  191. stability.to_csv(OUT_DIR / f"{PREFIX}-parameter-stability.csv", index=False)
  192. neighborhood.to_csv(OUT_DIR / f"{PREFIX}-neighborhood.csv", index=False)
  193. overlay.to_csv(OUT_DIR / f"{PREFIX}-overlay.csv", index=False)
  194. (OUT_DIR / f"{PREFIX}.md").write_text(report(candidate_metrics, years, stability, neighborhood, overlay), encoding="utf-8")
  195. print(f"wrote {OUT_DIR / f'{PREFIX}.md'}")
  196. return 0
  197. if __name__ == "__main__":
  198. raise SystemExit(main())