search_long_short_fusion_with_calendar.py 18 KB

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