stress_eth_bearish_price_proxy_candidate.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. from __future__ import annotations
  2. import argparse
  3. import sys
  4. from pathlib import Path
  5. import pandas as pd
  6. sys.path.append(str(Path(__file__).resolve().parent))
  7. from search_eth_bearish_price_proxy import ( # noqa: E402
  8. BTC_SYMBOL,
  9. DATA_DIR,
  10. HORIZONS,
  11. SYMBOL,
  12. Spec,
  13. joined_frames,
  14. load_frame,
  15. markdown_table,
  16. period_metrics,
  17. resample,
  18. row_for_spec,
  19. run_spec,
  20. )
  21. OUTPUT_DIR = Path("reports/eth-exploration")
  22. BASE_SPEC = Spec("crash_follow", "1H", 20, 120, 8, 0.035, 0.02, 0.06, 96, "btc_riskoff")
  23. def pct(value: object) -> object:
  24. return value if not isinstance(value, float) else f"{value:.2%}"
  25. def horizon_table(equity: pd.Series, trades: list[dict[str, object]]) -> pd.DataFrame:
  26. return pd.DataFrame(
  27. [
  28. {"period": label, **period_metrics(equity, trades, offset)}
  29. for label, offset in HORIZONS
  30. ]
  31. )
  32. def scoped_metrics(equity: pd.Series, trades: list[dict[str, object]], start: pd.Timestamp, end: pd.Timestamp) -> dict[str, object]:
  33. scoped = equity[(equity.index >= start) & (equity.index <= end)]
  34. scoped_trades = [
  35. trade
  36. for trade in trades
  37. if start <= pd.Timestamp(trade["entry_time"]).normalize() <= end
  38. ]
  39. if len(scoped) < 2:
  40. return {
  41. "total_return": 0.0,
  42. "max_drawdown": 0.0,
  43. "win_rate": 0.0,
  44. "profit_factor": 0.0,
  45. "trades": 0,
  46. }
  47. returns = [float(trade["return"]) for trade in scoped_trades]
  48. wins = [value for value in returns if value > 0]
  49. losses = [value for value in returns if value < 0]
  50. return {
  51. "total_return": float(scoped.iloc[-1] / scoped.iloc[0] - 1),
  52. "max_drawdown": float(((scoped.cummax() - scoped) / scoped.cummax()).max()),
  53. "win_rate": len(wins) / len(returns) if returns else 0.0,
  54. "profit_factor": sum(wins) / abs(sum(losses)) if losses else (999.0 if wins else 0.0),
  55. "trades": len(returns),
  56. }
  57. def yearly_table(equity: pd.Series, trades: list[dict[str, object]]) -> pd.DataFrame:
  58. rows = []
  59. for year, scoped in equity.groupby(equity.index.year):
  60. rows.append({"year": int(year), **scoped_metrics(equity, trades, scoped.index[0], scoped.index[-1])})
  61. return pd.DataFrame(rows)
  62. def monthly_table(equity: pd.Series, trades: list[dict[str, object]]) -> pd.DataFrame:
  63. rows = []
  64. months = equity.index.tz_convert(None).to_period("M")
  65. for month, scoped in equity.groupby(months):
  66. rows.append({"month": str(month), **scoped_metrics(equity, trades, scoped.index[0], scoped.index[-1])})
  67. return pd.DataFrame(rows)
  68. def regime_labels(frame: pd.DataFrame) -> pd.DataFrame:
  69. daily_close = frame["close"].resample("1D").last().ffill()
  70. daily_return = daily_close.pct_change()
  71. trend_90d = daily_close / daily_close.shift(90) - 1
  72. realized_vol_30d = daily_return.rolling(30).std() * (365 ** 0.5)
  73. vol_bucket = pd.qcut(realized_vol_30d.dropna(), 3, labels=["low_vol", "mid_vol", "high_vol"])
  74. labels = pd.DataFrame(index=daily_close.index)
  75. labels["trend"] = "unclassified"
  76. labels.loc[trend_90d > 0, "trend"] = "bull"
  77. labels.loc[trend_90d <= 0, "trend"] = "bear"
  78. labels["volatility"] = "unclassified"
  79. labels.loc[vol_bucket.index, "volatility"] = vol_bucket.astype(str)
  80. return labels
  81. def trade_segment_table(trades: list[dict[str, object]], labels: pd.DataFrame, column: str) -> pd.DataFrame:
  82. rows = []
  83. for name in sorted(labels[column].unique()):
  84. returns = []
  85. for trade in trades:
  86. day = pd.Timestamp(trade["entry_time"]).normalize()
  87. if day in labels.index and labels.at[day, column] == name:
  88. returns.append(float(trade["return"]))
  89. wins = [value for value in returns if value > 0]
  90. losses = [value for value in returns if value < 0]
  91. rows.append(
  92. {
  93. "segment": name,
  94. "total_return": float(pd.Series([1 + value for value in returns]).prod() - 1) if returns else 0.0,
  95. "win_rate": len(wins) / len(returns) if returns else 0.0,
  96. "profit_factor": sum(wins) / abs(sum(losses)) if losses else (999.0 if wins else 0.0),
  97. "trades": len(returns),
  98. }
  99. )
  100. return pd.DataFrame(rows)
  101. def worst_contiguous_months(months: pd.DataFrame) -> pd.DataFrame:
  102. returns = months[["month", "total_return"]].reset_index(drop=True)
  103. worst = None
  104. for start in range(len(returns)):
  105. compounded = 1.0
  106. for end in range(start, len(returns)):
  107. compounded *= 1 + float(returns.at[end, "total_return"])
  108. row = {
  109. "start_month": returns.at[start, "month"],
  110. "end_month": returns.at[end, "month"],
  111. "months": end - start + 1,
  112. "total_return": compounded - 1,
  113. }
  114. if worst is None or row["total_return"] < worst["total_return"]:
  115. worst = row
  116. streaks = []
  117. start = None
  118. compounded = 1.0
  119. for index, row in returns.iterrows():
  120. value = float(row["total_return"])
  121. if value < 0:
  122. start = index if start is None else start
  123. compounded *= 1 + value
  124. elif start is not None:
  125. streaks.append((start, index - 1, compounded - 1))
  126. start = None
  127. compounded = 1.0
  128. if start is not None:
  129. streaks.append((start, len(returns) - 1, compounded - 1))
  130. longest = max(streaks, key=lambda item: (item[1] - item[0] + 1, -item[2])) if streaks else None
  131. rows = [{"type": "worst_any_span", **worst}] if worst else []
  132. if longest:
  133. rows.append(
  134. {
  135. "type": "longest_losing_streak",
  136. "start_month": returns.at[longest[0], "month"],
  137. "end_month": returns.at[longest[1], "month"],
  138. "months": longest[1] - longest[0] + 1,
  139. "total_return": longest[2],
  140. }
  141. )
  142. return pd.DataFrame(rows)
  143. def neighbor_specs() -> list[Spec]:
  144. specs = []
  145. for threshold in (0.03, 0.035, 0.04):
  146. for stop in (0.015, 0.02, 0.025):
  147. for take in (0.05, 0.06, 0.07):
  148. for hold in (72, 96, 120):
  149. for gate in ("btc_riskoff", "eth_riskoff", "none"):
  150. specs.append(
  151. Spec(
  152. BASE_SPEC.family,
  153. BASE_SPEC.bar,
  154. BASE_SPEC.fast,
  155. BASE_SPEC.slow,
  156. BASE_SPEC.lookback,
  157. threshold,
  158. stop,
  159. take,
  160. hold,
  161. gate,
  162. )
  163. )
  164. return specs
  165. def stability_table(frame: pd.DataFrame) -> pd.DataFrame:
  166. rows = []
  167. for spec in neighbor_specs():
  168. equity, trades = run_spec(spec, frame)
  169. rows.append(row_for_spec(spec, equity, trades))
  170. return pd.DataFrame(rows).sort_values(
  171. ["full_total_return", "full_profit_factor", "3m_total_return"],
  172. ascending=[False, False, False],
  173. )
  174. def summarize_stability(stability: pd.DataFrame) -> pd.DataFrame:
  175. return pd.DataFrame(
  176. [
  177. {
  178. "group": "all_neighbors",
  179. "count": len(stability),
  180. "positive_full": int((stability["full_total_return"] > 0).sum()),
  181. "positive_all_windows": int(
  182. (
  183. (stability["full_total_return"] > 0)
  184. & (stability["3y_total_return"] > 0)
  185. & (stability["1y_total_return"] > 0)
  186. & (stability["6m_total_return"] > 0)
  187. & (stability["3m_total_return"] > 0)
  188. ).sum()
  189. ),
  190. "min_full_return": float(stability["full_total_return"].min()),
  191. "p10_full_return": float(stability["full_total_return"].quantile(0.10)),
  192. "median_full_return": float(stability["full_total_return"].median()),
  193. "min_full_pf": float(stability["full_profit_factor"].min()),
  194. "p10_full_pf": float(stability["full_profit_factor"].quantile(0.10)),
  195. "max_full_dd": float(stability["full_max_drawdown"].max()),
  196. "p90_full_dd": float(stability["full_max_drawdown"].quantile(0.90)),
  197. }
  198. ]
  199. )
  200. def report(
  201. csv_path: Path,
  202. horizon: pd.DataFrame,
  203. yearly: pd.DataFrame,
  204. monthly: pd.DataFrame,
  205. trend: pd.DataFrame,
  206. volatility: pd.DataFrame,
  207. worst_months: pd.DataFrame,
  208. stability: pd.DataFrame,
  209. stability_summary: pd.DataFrame,
  210. ) -> str:
  211. keep = [
  212. "name",
  213. "full_total_return",
  214. "full_max_drawdown",
  215. "full_profit_factor",
  216. "full_trades",
  217. "3y_total_return",
  218. "1y_total_return",
  219. "6m_total_return",
  220. "3m_total_return",
  221. ]
  222. base = stability[stability["name"] == BASE_SPEC.name].iloc[0]
  223. verdict = "reject as an independent candidate"
  224. reason = "full-sample max drawdown is 44.90% and the neighborhood left tail is structurally weak"
  225. return (
  226. "# ETH Bearish Price-Proxy Candidate Stress\n\n"
  227. f"Candidate: `{BASE_SPEC.name}`\n\n"
  228. f"Scope: existing local OKX candles only under `{DATA_DIR}`; no live API and no order path.\n\n"
  229. f"Parameter-neighborhood CSV: `{csv_path}`\n\n"
  230. f"Conclusion: {verdict}; {reason}.\n\n"
  231. "## Full / Recent Windows\n\n"
  232. f"{markdown_table(horizon)}\n\n"
  233. "## Years\n\n"
  234. f"{markdown_table(yearly)}\n\n"
  235. "## Months\n\n"
  236. f"{markdown_table(monthly)}\n\n"
  237. "## Bull / Bear Segments\n\n"
  238. f"{markdown_table(trend)}\n\n"
  239. "## Volatility Segments\n\n"
  240. f"{markdown_table(volatility)}\n\n"
  241. "## Worst Consecutive Months\n\n"
  242. f"{markdown_table(worst_months)}\n\n"
  243. "## Parameter Neighborhood Stability\n\n"
  244. f"Base row: full return {base['full_total_return']:.4f}, full DD {base['full_max_drawdown']:.4f}, full PF {base['full_profit_factor']:.4f}.\n\n"
  245. f"{markdown_table(stability_summary)}\n\n"
  246. "Top neighbors:\n\n"
  247. f"{markdown_table(stability[keep].head(12))}\n\n"
  248. "Left-tail neighbors:\n\n"
  249. f"{markdown_table(stability[keep].tail(12))}\n"
  250. )
  251. def main() -> int:
  252. parser = argparse.ArgumentParser()
  253. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  254. args = parser.parse_args()
  255. eth_15m = load_frame(SYMBOL)
  256. btc_15m = load_frame(BTC_SYMBOL)
  257. frame = joined_frames(resample(eth_15m, BASE_SPEC.bar), resample(btc_15m, BASE_SPEC.bar))
  258. equity, trades = run_spec(BASE_SPEC, frame)
  259. horizon = horizon_table(equity, trades)
  260. yearly = yearly_table(equity, trades)
  261. monthly = monthly_table(equity, trades)
  262. labels = regime_labels(frame)
  263. trend = trade_segment_table(trades, labels, "trend")
  264. volatility = trade_segment_table(trades, labels, "volatility")
  265. worst_months = worst_contiguous_months(monthly)
  266. stability = stability_table(frame)
  267. stability_summary = summarize_stability(stability)
  268. args.output_dir.mkdir(parents=True, exist_ok=True)
  269. csv_path = args.output_dir / "eth-bearish-price-proxy-candidate-stability.csv"
  270. report_path = args.output_dir / "eth-bearish-price-proxy-candidate-stress.md"
  271. stability.to_csv(csv_path, index=False)
  272. report_path.write_text(
  273. report(csv_path, horizon, yearly, monthly, trend, volatility, worst_months, stability, stability_summary),
  274. encoding="utf-8",
  275. )
  276. print(f"wrote {csv_path} and {report_path}")
  277. return 0
  278. if __name__ == "__main__":
  279. raise SystemExit(main())