search_long_short_fusion_with_calendar.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  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 import search_eth_btc_calendar_carry as carry
  8. from scripts import search_long_short_fusion as fusion
  9. from scripts.search_short_bias_overlay import markdown_table
  10. OUTPUT_DIR = Path("reports/long-short-fusion")
  11. PREFIX = "fusion-calendar"
  12. CANDIDATE = carry.Spec("ETH-USDT-SWAP", "1h", "long", 14, "weekend", 8, "calm")
  13. CALENDAR_WEIGHTS = (0.0, 0.025, 0.05, 0.075, 0.10, 0.125)
  14. HORIZONS = fusion.HORIZONS
  15. TOTAL_COLUMNS = [
  16. "name",
  17. "selected",
  18. "long_variant",
  19. "long_weight",
  20. "btc_risk_short_weight",
  21. "eth_4h_vol_short_weight",
  22. "btc_4h_vol_short_weight",
  23. "eth_4h_vol_short_gated_weight",
  24. "btc_4h_vol_short_gated_weight",
  25. "calendar_weight",
  26. "gross_exposure",
  27. "short_exposure",
  28. "total_return",
  29. "annualized_return",
  30. "max_drawdown",
  31. "calmar",
  32. "h3y_return",
  33. "h1y_return",
  34. "h6m_return",
  35. "h3m_return",
  36. "full_improved_vs_no_calendar",
  37. "3y_improved_vs_no_calendar",
  38. "1y_improved_vs_no_calendar",
  39. "6m_improved_vs_no_calendar",
  40. "3m_improved_vs_no_calendar",
  41. "score",
  42. ]
  43. def calendar_equity() -> pd.Series:
  44. frame = carry.resample(carry.load_frame("ETH-USDT-SWAP"), "1h")
  45. equity, _ = carry.run_spec(CANDIDATE, frame)
  46. return equity
  47. def horizon_slice(series: pd.Series, offset: pd.DateOffset | None) -> pd.Series:
  48. if offset is None:
  49. return series
  50. scoped = series[series.index >= series.index[-1] - offset]
  51. return scoped if len(scoped) >= 2 else series
  52. def horizon_metrics(series: pd.Series) -> dict[str, dict[str, float]]:
  53. return {label: fusion.metrics(horizon_slice(series, offset)) for label, offset in HORIZONS}
  54. def row_from_metrics(name: str, metrics_by_horizon: dict[str, dict[str, float]]) -> dict[str, object]:
  55. full = metrics_by_horizon["full"]
  56. return {
  57. "name": name,
  58. **full,
  59. "h3y_return": metrics_by_horizon["3y"]["total_return"],
  60. "h1y_return": metrics_by_horizon["1y"]["total_return"],
  61. "h6m_return": metrics_by_horizon["6m"]["total_return"],
  62. "h3m_return": metrics_by_horizon["3m"]["total_return"],
  63. "h3y_calmar": metrics_by_horizon["3y"]["calmar"],
  64. "h1y_calmar": metrics_by_horizon["1y"]["calmar"],
  65. "h6m_calmar": metrics_by_horizon["6m"]["calmar"],
  66. "h3m_calmar": metrics_by_horizon["3m"]["calmar"],
  67. "h3y_max_drawdown": metrics_by_horizon["3y"]["max_drawdown"],
  68. "h1y_max_drawdown": metrics_by_horizon["1y"]["max_drawdown"],
  69. "h6m_max_drawdown": metrics_by_horizon["6m"]["max_drawdown"],
  70. "h3m_max_drawdown": metrics_by_horizon["3m"]["max_drawdown"],
  71. }
  72. def improvement_fields(
  73. current: dict[str, dict[str, float]],
  74. baseline: dict[str, dict[str, float]] | None,
  75. ) -> dict[str, object]:
  76. fields: dict[str, object] = {}
  77. for label in ("full", "3y", "1y", "6m", "3m"):
  78. if baseline is None:
  79. fields[f"{label}_improved_vs_no_calendar"] = False
  80. fields[f"{label}_delta_calmar"] = 0.0
  81. fields[f"{label}_delta_max_drawdown"] = 0.0
  82. fields[f"{label}_delta_total_return"] = 0.0
  83. continue
  84. delta_calmar = current[label]["calmar"] - baseline[label]["calmar"]
  85. delta_drawdown = current[label]["max_drawdown"] - baseline[label]["max_drawdown"]
  86. delta_total = current[label]["total_return"] - baseline[label]["total_return"]
  87. fields[f"{label}_improved_vs_no_calendar"] = delta_calmar > 0.0 and delta_drawdown <= 0.0
  88. fields[f"{label}_delta_calmar"] = delta_calmar
  89. fields[f"{label}_delta_max_drawdown"] = delta_drawdown
  90. fields[f"{label}_delta_total_return"] = delta_total
  91. return fields
  92. def candidate_name(
  93. long_key: str,
  94. long_weight: float,
  95. btc_risk_weight: float,
  96. eth_swing_weight: float,
  97. btc_swing_weight: float,
  98. eth_gated_weight: float,
  99. btc_gated_weight: float,
  100. calendar_weight: float,
  101. ) -> str:
  102. return (
  103. f"fusion-cal-{long_key.replace('long_rotation', 'lr')}-l{long_weight:.2f}"
  104. f"-brs{btc_risk_weight:.2f}"
  105. f"-eth4hs{eth_swing_weight:.2f}"
  106. f"-btc4hs{btc_swing_weight:.2f}"
  107. f"-eg{eth_gated_weight:.2f}"
  108. f"-bg{btc_gated_weight:.2f}"
  109. f"-cal{calendar_weight:.3f}"
  110. )
  111. def build_equity(series: dict[str, pd.Series], row: pd.Series) -> pd.Series:
  112. weights = {
  113. str(row["long_variant"]): float(row["long_weight"]),
  114. "btc_risk_short": float(row["btc_risk_short_weight"]),
  115. "eth_4h_vol_short": float(row["eth_4h_vol_short_weight"]),
  116. "btc_4h_vol_short": float(row["btc_4h_vol_short_weight"]),
  117. "eth_4h_vol_short_gated": float(row["eth_4h_vol_short_gated_weight"]),
  118. "btc_4h_vol_short_gated": float(row["btc_4h_vol_short_gated_weight"]),
  119. "calendar_carry": float(row["calendar_weight"]),
  120. }
  121. return fusion.combine_components(series, weights)
  122. def horizon_rows(name: str, kind: str, series: pd.Series, baseline: pd.Series | None) -> list[dict[str, object]]:
  123. rows: list[dict[str, object]] = []
  124. baseline_metrics = horizon_metrics(baseline) if baseline is not None else None
  125. for label, offset in HORIZONS:
  126. scoped = horizon_slice(series, offset)
  127. values = fusion.metrics(scoped)
  128. base_values = baseline_metrics[label] if baseline_metrics is not None else None
  129. row = {
  130. "name": name,
  131. "kind": kind,
  132. "horizon": label,
  133. "start": scoped.index[0].strftime("%Y-%m-%d"),
  134. "end": scoped.index[-1].strftime("%Y-%m-%d"),
  135. **values,
  136. }
  137. if base_values is None:
  138. row.update(
  139. {
  140. "improved_vs_no_calendar": False,
  141. "delta_total_return": 0.0,
  142. "delta_calmar": 0.0,
  143. "delta_max_drawdown": 0.0,
  144. }
  145. )
  146. else:
  147. row.update(
  148. {
  149. "improved_vs_no_calendar": values["calmar"] > base_values["calmar"]
  150. and values["max_drawdown"] <= base_values["max_drawdown"],
  151. "delta_total_return": values["total_return"] - base_values["total_return"],
  152. "delta_calmar": values["calmar"] - base_values["calmar"],
  153. "delta_max_drawdown": values["max_drawdown"] - base_values["max_drawdown"],
  154. }
  155. )
  156. rows.append(row)
  157. return rows
  158. def report_text(command: str, paths: list[Path], selected: pd.DataFrame, horizons: pd.DataFrame, monthly: pd.DataFrame) -> str:
  159. top_horizons = horizons[horizons["name"].isin(set(selected["name"]))]
  160. summary_cols = [
  161. "name",
  162. "calendar_weight",
  163. "total_return",
  164. "annualized_return",
  165. "max_drawdown",
  166. "calmar",
  167. "full_improved_vs_no_calendar",
  168. "3y_improved_vs_no_calendar",
  169. "1y_improved_vs_no_calendar",
  170. "6m_improved_vs_no_calendar",
  171. "3m_improved_vs_no_calendar",
  172. ]
  173. return "\n".join(
  174. [
  175. "# Long Short Fusion Search With Calendar Carry",
  176. "",
  177. f"Run command: `{command}`",
  178. "",
  179. "Output files:",
  180. *[f"- `{path}`" for path in paths],
  181. "",
  182. "Scope: research-only search under `reports/long-short-fusion`; no live path changed.",
  183. f"Calendar carry leg: `{CANDIDATE.name}`.",
  184. f"Calendar weights searched: `{', '.join(f'{value:.3f}' for value in CALENDAR_WEIGHTS)}`.",
  185. "",
  186. "## Selected Candidates",
  187. "",
  188. markdown_table(selected[summary_cols]),
  189. "",
  190. "## Top Candidate Horizons",
  191. "",
  192. markdown_table(top_horizons),
  193. "",
  194. "## Recent Monthly Returns",
  195. "",
  196. markdown_table(monthly.tail(96)),
  197. "",
  198. ]
  199. )
  200. def main() -> int:
  201. parser = argparse.ArgumentParser()
  202. parser.add_argument("--years", type=float, default=8.0)
  203. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  204. parser.add_argument("--focused", action="store_true")
  205. args = parser.parse_args()
  206. args.output_dir.mkdir(parents=True, exist_ok=True)
  207. fusion.overlay.SYMBOLS = ("BTC-USDT-SWAP", "ETH-USDT-SWAP")
  208. components = fusion.build_components(args.years)
  209. component_series = {key: value[1] for key, value in components.items()}
  210. component_series["calendar_carry"] = calendar_equity()
  211. if args.focused:
  212. long_keys = ("long_rotation_riskoff00",)
  213. long_weights = (1.08, 1.10, 1.12, 1.15, 1.18, 1.20)
  214. btc_risk_weights = (0.06, 0.08, 0.10, 0.12)
  215. eth_swing_weights = (0.00, 0.04, 0.06, 0.08, 0.10, 0.12)
  216. btc_swing_weights = (0.00, 0.02, 0.04, 0.06)
  217. eth_gated_weights = (0.00, 0.06, 0.08, 0.10, 0.12)
  218. btc_gated_weights = (0.00, 0.02, 0.03, 0.04, 0.06)
  219. max_short_weight = 0.30
  220. max_gross_exposure = 1.45
  221. else:
  222. long_keys = ("long_rotation", "long_rotation_riskoff70", "long_rotation_riskoff50", "long_rotation_riskoff25", "long_rotation_riskoff00")
  223. long_weights = (0.70, 0.85, 1.00, 1.10, 1.20)
  224. btc_risk_weights = (0.00, 0.02, 0.04, 0.06, 0.08, 0.10, 0.12)
  225. eth_swing_weights = (0.00, 0.02, 0.04, 0.06, 0.08, 0.10, 0.12)
  226. btc_swing_weights = (0.00, 0.02, 0.04, 0.06)
  227. eth_gated_weights = (0.00, 0.04, 0.08, 0.12)
  228. btc_gated_weights = (0.00, 0.03, 0.06)
  229. max_short_weight = 0.40
  230. max_gross_exposure = 1.45
  231. rows: list[dict[str, object]] = []
  232. no_calendar_metrics: dict[tuple[object, ...], dict[str, dict[str, float]]] = {}
  233. for long_key in long_keys:
  234. for long_weight in long_weights:
  235. for btc_risk_weight in btc_risk_weights:
  236. for eth_swing_weight in eth_swing_weights:
  237. for btc_swing_weight in btc_swing_weights:
  238. for eth_gated_weight in eth_gated_weights:
  239. for btc_gated_weight in btc_gated_weights:
  240. if eth_swing_weight > 0.0 and eth_gated_weight > 0.0:
  241. continue
  242. if btc_swing_weight > 0.0 and btc_gated_weight > 0.0:
  243. continue
  244. short_weight = btc_risk_weight + eth_swing_weight + btc_swing_weight + eth_gated_weight + btc_gated_weight
  245. if short_weight <= 0.0 or short_weight > max_short_weight:
  246. continue
  247. if long_weight + short_weight > max_gross_exposure:
  248. continue
  249. base_key = (
  250. long_key,
  251. long_weight,
  252. btc_risk_weight,
  253. eth_swing_weight,
  254. btc_swing_weight,
  255. eth_gated_weight,
  256. btc_gated_weight,
  257. )
  258. for calendar_weight in CALENDAR_WEIGHTS:
  259. weights = {
  260. long_key: long_weight,
  261. "btc_risk_short": btc_risk_weight,
  262. "eth_4h_vol_short": eth_swing_weight,
  263. "btc_4h_vol_short": btc_swing_weight,
  264. "eth_4h_vol_short_gated": eth_gated_weight,
  265. "btc_4h_vol_short_gated": btc_gated_weight,
  266. "calendar_carry": calendar_weight,
  267. }
  268. equity = fusion.combine_components(component_series, weights)
  269. metrics_by_horizon = horizon_metrics(equity)
  270. if calendar_weight == 0.0:
  271. no_calendar_metrics[base_key] = metrics_by_horizon
  272. name = candidate_name(
  273. long_key,
  274. long_weight,
  275. btc_risk_weight,
  276. eth_swing_weight,
  277. btc_swing_weight,
  278. eth_gated_weight,
  279. btc_gated_weight,
  280. calendar_weight,
  281. )
  282. row = {
  283. **row_from_metrics(name, metrics_by_horizon),
  284. "kind": "fusion",
  285. "long_variant": long_key,
  286. "long_weight": long_weight,
  287. "btc_risk_short_weight": btc_risk_weight,
  288. "eth_4h_vol_short_weight": eth_swing_weight,
  289. "btc_4h_vol_short_weight": btc_swing_weight,
  290. "eth_4h_vol_short_gated_weight": eth_gated_weight,
  291. "btc_4h_vol_short_gated_weight": btc_gated_weight,
  292. "calendar_weight": calendar_weight,
  293. "gross_exposure": long_weight + short_weight + calendar_weight,
  294. "short_exposure": short_weight,
  295. "calendar_leg": CANDIDATE.name,
  296. "trades": int(
  297. sum(int(components[key][2].get("trades", 0)) for key, weight in weights.items() if key in components and weight > 0.0)
  298. ),
  299. "win_rate": 0.0,
  300. "profit_factor": 0.0,
  301. }
  302. row.update(improvement_fields(metrics_by_horizon, no_calendar_metrics.get(base_key)))
  303. row["score"] = fusion.score(row)
  304. row["selected"] = "no"
  305. rows.append(row)
  306. total = pd.DataFrame(rows).sort_values(
  307. ["h1y_return", "h6m_return", "h3m_return", "max_drawdown", "annualized_return"],
  308. ascending=[False, False, False, True, False],
  309. )
  310. recent_ok = total[(total["h1y_return"] > 0.0) & (total["h6m_return"] > 0.0) & (total["h3m_return"] > 0.0)].copy()
  311. low_drawdown = recent_ok[recent_ok["max_drawdown"] <= 0.10].sort_values("score", ascending=False).head(10)
  312. high_return = recent_ok.sort_values(["annualized_return", "max_drawdown"], ascending=[False, True]).head(10)
  313. improved = recent_ok[
  314. (recent_ok["calendar_weight"] > 0.0)
  315. & (recent_ok["full_improved_vs_no_calendar"])
  316. & (recent_ok["1y_improved_vs_no_calendar"])
  317. ].sort_values("score", ascending=False).head(10)
  318. selected = pd.concat([low_drawdown, high_return, improved]).drop_duplicates("name").head(12).copy()
  319. selected["selected"] = "yes"
  320. total.loc[total["name"].isin(set(selected["name"])), "selected"] = "yes"
  321. horizon_output: list[dict[str, object]] = []
  322. monthly_output: list[pd.DataFrame] = []
  323. for _, row in selected.iterrows():
  324. equity = build_equity(component_series, row)
  325. baseline_row = row.copy()
  326. baseline_row["calendar_weight"] = 0.0
  327. baseline = build_equity(component_series, baseline_row)
  328. horizon_output.extend(horizon_rows(str(row["name"]), "fusion", equity, baseline))
  329. monthly_output.append(fusion.monthly_rows(str(row["name"]), "fusion", equity))
  330. horizons = pd.DataFrame(horizon_output)
  331. monthly = pd.concat(monthly_output, ignore_index=True) if monthly_output else pd.DataFrame()
  332. total_path = args.output_dir / f"{PREFIX}-total.csv"
  333. selected_path = args.output_dir / f"{PREFIX}-selected.csv"
  334. horizon_path = args.output_dir / f"{PREFIX}-horizons.csv"
  335. monthly_path = args.output_dir / f"{PREFIX}-monthly.csv"
  336. report_path = args.output_dir / f"{PREFIX}-report.md"
  337. total[TOTAL_COLUMNS].to_csv(total_path, index=False)
  338. selected.to_csv(selected_path, index=False)
  339. horizons.to_csv(horizon_path, index=False)
  340. monthly.to_csv(monthly_path, index=False)
  341. report_path.write_text(
  342. report_text(
  343. f"rtk .venv/bin/python scripts/search_long_short_fusion_with_calendar.py --years {args.years}{' --focused' if args.focused else ''}",
  344. [total_path, selected_path, horizon_path, monthly_path, report_path],
  345. selected,
  346. horizons,
  347. monthly,
  348. ),
  349. encoding="utf-8",
  350. )
  351. print(report_path)
  352. print(selected.head(8).to_string(index=False))
  353. return 0
  354. if __name__ == "__main__":
  355. raise SystemExit(main())