search_long_short_fusion_crash_follow_overlay.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  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 import search_short_bias_swing as swing
  9. from scripts import search_short_overlay_mix as overlay
  10. from scripts.search_long_short_fusion import (
  11. INITIAL_EQUITY,
  12. component_returns,
  13. daily,
  14. gated_equity,
  15. horizon_rows,
  16. markdown_table,
  17. metrics,
  18. riskoff_long_equity,
  19. swing_short,
  20. )
  21. OUTPUT_DIR = Path("reports/long-short-fusion")
  22. SELECTED_FUSION = OUTPUT_DIR / "fusion-selected.csv"
  23. PREFIX = "eth-crash-follow-fusion-overlay-search"
  24. CRASH_FOLLOW = Spec("crash_follow", "1H", 20, 120, 8, 0.035, 0.02, 0.06, 96, "btc_riskoff")
  25. OVERLAY_WEIGHTS = (0.00, 0.02, 0.04, 0.06, 0.08, 0.10, 0.12)
  26. COMPONENT_WEIGHT_COLUMNS = {
  27. "long_rotation": "long_weight",
  28. "long_rotation_riskoff70": "long_weight",
  29. "long_rotation_riskoff50": "long_weight",
  30. "long_rotation_riskoff25": "long_weight",
  31. "long_rotation_riskoff00": "long_weight",
  32. "btc_risk_short": "btc_risk_short_weight",
  33. "eth_4h_vol_short": "eth_4h_vol_short_weight",
  34. "btc_4h_vol_short": "btc_4h_vol_short_weight",
  35. "eth_4h_vol_short_gated": "eth_4h_vol_short_gated_weight",
  36. "btc_4h_vol_short_gated": "btc_4h_vol_short_gated_weight",
  37. }
  38. def crash_follow_equity(spec: Spec) -> pd.Series:
  39. eth = load_frame("ETH-USDT-SWAP")
  40. btc = load_frame("BTC-USDT-SWAP")
  41. frame = joined_frames(resample(eth, spec.bar), resample(btc, spec.bar))
  42. equity, _ = run_spec(spec, frame)
  43. equity.name = spec.name
  44. return equity
  45. def fast_btc_risk_short(years: float) -> tuple[pd.Series, pd.Series]:
  46. params = overlay.BtcRiskShort(
  47. family="btc_risk_pair",
  48. bar="1h",
  49. btc_trend=1440,
  50. btc_lookback=336,
  51. symbol_trend=720,
  52. vol_lookback=336,
  53. btc_max_momentum=-0.005,
  54. btc_min_drop=0.025,
  55. min_btc_vol=0.012,
  56. symbol_max_momentum=-0.010,
  57. short_symbols=("ETH-USDT-SWAP",),
  58. )
  59. eth = load_frame("ETH-USDT-SWAP")
  60. btc = load_frame("BTC-USDT-SWAP")
  61. full = pd.DataFrame(
  62. {
  63. "BTC-USDT-SWAP": resample(btc, "1H")["close"],
  64. "ETH-USDT-SWAP": resample(eth, "1H")["close"],
  65. }
  66. ).dropna()
  67. cutoff = full.index[-1] - pd.DateOffset(years=years)
  68. closes = full[full.index >= cutoff]
  69. weights = overlay.short_weights(closes, params)
  70. equity = daily(overlay.equity_from_weights(closes, weights))
  71. risk_state = daily((weights["ETH-USDT-SWAP"].abs() > 0.0).astype(float))
  72. return equity, risk_state
  73. def required_component_keys(selected: pd.DataFrame) -> set[str]:
  74. keys = set(selected["long_variant"].astype(str))
  75. for key, column in COMPONENT_WEIGHT_COLUMNS.items():
  76. if key.startswith("long_rotation"):
  77. continue
  78. if column in selected.columns and bool((selected[column].astype(float) > 0.0).any()):
  79. keys.add(key)
  80. return keys
  81. def build_required_components(years: float, selected: pd.DataFrame) -> dict[str, pd.Series]:
  82. required = required_component_keys(selected)
  83. base_name, base_equity, _ = overlay.rotation_base(years)
  84. base = daily(base_equity)
  85. btc_risk_equity, risk_state = fast_btc_risk_short(years)
  86. components: dict[str, pd.Series] = {}
  87. riskoff_multipliers = {
  88. "long_rotation": 1.0,
  89. "long_rotation_riskoff70": 0.70,
  90. "long_rotation_riskoff50": 0.50,
  91. "long_rotation_riskoff25": 0.25,
  92. "long_rotation_riskoff00": 0.0,
  93. }
  94. for key, multiplier in riskoff_multipliers.items():
  95. if key in required:
  96. components[key] = base if key == "long_rotation" else riskoff_long_equity(base, risk_state, multiplier)
  97. if "btc_risk_short" in required:
  98. components["btc_risk_short"] = btc_risk_equity
  99. if "eth_4h_vol_short" in required or "eth_4h_vol_short_gated" in required:
  100. _, eth_equity, _ = swing_short(
  101. swing.Strategy(
  102. family="vol_expansion_short",
  103. symbol="ETH-USDT-SWAP",
  104. bar="4H",
  105. params={
  106. "fast": 20,
  107. "slow": 80,
  108. "entry": 20,
  109. "exit": 10,
  110. "atr": 14,
  111. "stop_atr": 3.0,
  112. "take_atr": 6.0,
  113. "max_hold": 120,
  114. "vol_window": 120,
  115. "vol_quantile": 0.8,
  116. },
  117. ),
  118. years,
  119. )
  120. if "eth_4h_vol_short" in required:
  121. components["eth_4h_vol_short"] = eth_equity
  122. if "eth_4h_vol_short_gated" in required:
  123. components["eth_4h_vol_short_gated"] = gated_equity(eth_equity, risk_state)
  124. if "btc_4h_vol_short" in required or "btc_4h_vol_short_gated" in required:
  125. _, btc_equity, _ = swing_short(
  126. swing.Strategy(
  127. family="vol_expansion_short",
  128. symbol="BTC-USDT-SWAP",
  129. bar="4H",
  130. params={
  131. "fast": 30,
  132. "slow": 120,
  133. "entry": 20,
  134. "exit": 10,
  135. "atr": 14,
  136. "stop_atr": 3.0,
  137. "take_atr": 6.0,
  138. "max_hold": 120,
  139. "vol_window": 120,
  140. "vol_quantile": 0.8,
  141. },
  142. ),
  143. years,
  144. )
  145. if "btc_4h_vol_short" in required:
  146. components["btc_4h_vol_short"] = btc_equity
  147. if "btc_4h_vol_short_gated" in required:
  148. components["btc_4h_vol_short_gated"] = gated_equity(btc_equity, risk_state)
  149. missing = required - set(components)
  150. if missing:
  151. raise ValueError(f"missing required components: {sorted(missing)}; base={base_name}")
  152. return components
  153. def weights_from_row(row: pd.Series) -> dict[str, float]:
  154. weights: dict[str, float] = {}
  155. long_key = str(row["long_variant"])
  156. weights[long_key] = float(row["long_weight"])
  157. for key, column in COMPONENT_WEIGHT_COLUMNS.items():
  158. if key.startswith("long_rotation"):
  159. continue
  160. weight = float(row[column])
  161. if weight > 0.0:
  162. weights[key] = weight
  163. return weights
  164. def overlay_equity(component_series: dict[str, pd.Series], weights: dict[str, float], crash: pd.Series, overlay_weight: float) -> pd.Series:
  165. weighted = {
  166. name: component_returns(series) * weights[name]
  167. for name, series in component_series.items()
  168. if weights.get(name, 0.0) > 0.0
  169. }
  170. weighted["eth_crash_follow"] = component_returns(crash) * overlay_weight
  171. returns = pd.DataFrame(weighted).dropna().sum(axis=1)
  172. equity = INITIAL_EQUITY * (1.0 + returns).cumprod()
  173. equity.name = "equity"
  174. return equity
  175. def horizon_metrics_by_label(series: pd.Series) -> dict[str, dict[str, object]]:
  176. return {str(row["horizon"]): row for row in horizon_rows("", "", series)}
  177. def search_rows(selected: pd.DataFrame, component_series: dict[str, pd.Series], crash: pd.Series) -> pd.DataFrame:
  178. rows: list[dict[str, object]] = []
  179. for _, selected_row in selected.iterrows():
  180. base_name = str(selected_row["name"])
  181. weights = weights_from_row(selected_row)
  182. base_equity = overlay_equity(component_series, weights, crash, 0.0)
  183. base_horizons = horizon_metrics_by_label(base_equity)
  184. for overlay_weight in OVERLAY_WEIGHTS:
  185. equity = overlay_equity(component_series, weights, crash, overlay_weight)
  186. for horizon, row in horizon_metrics_by_label(equity).items():
  187. base = base_horizons[horizon]
  188. rows.append(
  189. {
  190. "base_name": base_name,
  191. "overlay_weight": overlay_weight,
  192. "horizon": horizon,
  193. "start": row["start"],
  194. "end": row["end"],
  195. "total_return": row["total_return"],
  196. "annualized_return": row["annualized_return"],
  197. "max_drawdown": row["max_drawdown"],
  198. "calmar": row["calmar"],
  199. "baseline_total_return": base["total_return"],
  200. "baseline_max_drawdown": base["max_drawdown"],
  201. "baseline_calmar": base["calmar"],
  202. "delta_total_return": float(row["total_return"]) - float(base["total_return"]),
  203. "delta_max_drawdown": float(row["max_drawdown"]) - float(base["max_drawdown"]),
  204. "delta_calmar": float(row["calmar"]) - float(base["calmar"]),
  205. }
  206. )
  207. return pd.DataFrame(rows)
  208. def summary_rows(results: pd.DataFrame) -> pd.DataFrame:
  209. rows: list[dict[str, object]] = []
  210. grouped = results[results["overlay_weight"] > 0.0].groupby(["base_name", "overlay_weight"])
  211. for (base_name, overlay_weight), group in grouped:
  212. full = group[group["horizon"] == "full"].iloc[0]
  213. rows.append(
  214. {
  215. "base_name": base_name,
  216. "overlay_weight": overlay_weight,
  217. "all_return_improved": bool((group["delta_total_return"] > 0.0).all()),
  218. "all_calmar_improved": bool((group["delta_calmar"] > 0.0).all()),
  219. "all_dd_not_worse": bool((group["delta_max_drawdown"] <= 0.0).all()),
  220. "stable_improvement": bool(
  221. (group["delta_total_return"] > 0.0).all()
  222. and (group["delta_calmar"] > 0.0).all()
  223. and (group["delta_max_drawdown"] <= 0.0).all()
  224. ),
  225. "full_delta_return": full["delta_total_return"],
  226. "full_delta_calmar": full["delta_calmar"],
  227. "full_delta_dd": full["delta_max_drawdown"],
  228. "worst_delta_return": group["delta_total_return"].min(),
  229. "worst_delta_calmar": group["delta_calmar"].min(),
  230. "worst_delta_dd": group["delta_max_drawdown"].max(),
  231. }
  232. )
  233. return pd.DataFrame(rows).sort_values(
  234. ["stable_improvement", "all_return_improved", "full_delta_calmar", "worst_delta_dd"],
  235. ascending=[False, False, False, True],
  236. )
  237. def component_summary(crash: pd.Series) -> pd.DataFrame:
  238. rows = []
  239. for row in horizon_rows(CRASH_FOLLOW.name, "component", crash):
  240. rows.append(
  241. {
  242. "name": row["name"],
  243. "horizon": row["horizon"],
  244. "total_return": row["total_return"],
  245. "max_drawdown": row["max_drawdown"],
  246. "calmar": row["calmar"],
  247. }
  248. )
  249. return pd.DataFrame(rows)
  250. def report_text(command: str, paths: list[Path], crash_component: pd.DataFrame, summary: pd.DataFrame, results: pd.DataFrame) -> str:
  251. stable = summary[summary["stable_improvement"]].copy()
  252. if len(stable):
  253. verdict = "Worth entering the next fusion main search only as a capped small overlay dimension, because at least one representative fusion combination improved return, Calmar, and drawdown across every tested horizon."
  254. else:
  255. verdict = "Not worth entering the next fusion main search now. No tested representative fusion combination improved return, Calmar, and drawdown across full/3y/1y/6m/3m simultaneously."
  256. keep = [
  257. "base_name",
  258. "overlay_weight",
  259. "horizon",
  260. "total_return",
  261. "max_drawdown",
  262. "calmar",
  263. "delta_total_return",
  264. "delta_max_drawdown",
  265. "delta_calmar",
  266. ]
  267. return "\n".join(
  268. [
  269. "# ETH Crash-Follow Long-Short Fusion Overlay Search",
  270. "",
  271. f"Run command: `{command}`",
  272. "",
  273. "Output files:",
  274. *[f"- `{path}`" for path in paths],
  275. "",
  276. f"Overlay proxy: `{CRASH_FOLLOW.name}`.",
  277. f"Representative fusion source: `{SELECTED_FUSION}`.",
  278. "Method: rebuild each selected fusion candidate from existing component curves, then add `overlay_weight * crash_follow_daily_return` for weights 0.00 through 0.12. This is research only; no live path was changed.",
  279. "",
  280. "Stable improvement filter: every tested horizon must have `delta_total_return > 0`, `delta_calmar > 0`, and `delta_max_drawdown <= 0` versus the same fusion candidate with overlay weight 0.00.",
  281. "",
  282. "## Crash-Follow Component",
  283. "",
  284. markdown_table(crash_component),
  285. "",
  286. "## Stable Improvement Candidates",
  287. "",
  288. markdown_table(stable),
  289. "",
  290. "## Overlay Summary",
  291. "",
  292. markdown_table(summary.head(30)),
  293. "",
  294. "## Horizon Results",
  295. "",
  296. markdown_table(results[keep]),
  297. "",
  298. "## Conclusion",
  299. "",
  300. verdict,
  301. "",
  302. ]
  303. )
  304. def main() -> int:
  305. parser = argparse.ArgumentParser()
  306. parser.add_argument("--years", type=float, default=8.0)
  307. parser.add_argument("--selected-fusion", type=Path, default=SELECTED_FUSION)
  308. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  309. args = parser.parse_args()
  310. args.output_dir.mkdir(parents=True, exist_ok=True)
  311. selected = pd.read_csv(args.selected_fusion)
  312. component_series = build_required_components(args.years, selected)
  313. crash = crash_follow_equity(CRASH_FOLLOW)
  314. results = search_rows(selected, component_series, crash)
  315. summary = summary_rows(results)
  316. crash_component = component_summary(crash)
  317. results_path = args.output_dir / f"{PREFIX}.csv"
  318. summary_path = args.output_dir / f"{PREFIX}-summary.csv"
  319. report_path = args.output_dir / f"{PREFIX}.md"
  320. results.to_csv(results_path, index=False)
  321. summary.to_csv(summary_path, index=False)
  322. report_path.write_text(
  323. report_text(
  324. f"rtk .venv/bin/python scripts/search_long_short_fusion_crash_follow_overlay.py --years {args.years}",
  325. [results_path, summary_path, report_path],
  326. crash_component,
  327. summary,
  328. results,
  329. ),
  330. encoding="utf-8",
  331. )
  332. print(report_path)
  333. print(summary.head(20).to_string(index=False))
  334. return 0
  335. if __name__ == "__main__":
  336. raise SystemExit(main())