search_long_short_fusion.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  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_short_bias_swing as swing
  8. from scripts import search_short_overlay_mix as overlay
  9. from scripts.search_short_bias_overlay import markdown_table
  10. OUTPUT_DIR = Path("reports/long-short-fusion")
  11. PREFIX = "fusion"
  12. INITIAL_EQUITY = 10_000.0
  13. HORIZONS = (
  14. ("full", None),
  15. ("3y", pd.DateOffset(years=3)),
  16. ("1y", pd.DateOffset(years=1)),
  17. ("6m", pd.DateOffset(months=6)),
  18. ("3m", pd.DateOffset(months=3)),
  19. )
  20. def metrics(series: pd.Series) -> dict[str, float]:
  21. years = (series.index[-1] - series.index[0]).total_seconds() / 86_400 / 365
  22. total = float(series.iloc[-1] / series.iloc[0] - 1.0)
  23. annualized = (1.0 + total) ** (1.0 / years) - 1.0 if total > -1.0 and years > 0.0 else 0.0
  24. drawdown = float((series.cummax() - series).div(series.cummax()).max())
  25. return {
  26. "total_return": total,
  27. "annualized_return": annualized,
  28. "max_drawdown": drawdown,
  29. "calmar": annualized / drawdown if drawdown else 0.0,
  30. }
  31. def horizon_rows(name: str, kind: str, series: pd.Series) -> list[dict[str, object]]:
  32. rows = []
  33. end = series.index[-1]
  34. for label, offset in HORIZONS:
  35. horizon = series if offset is None else series[series.index >= end - offset]
  36. if len(horizon) < 2:
  37. horizon = series
  38. rows.append(
  39. {
  40. "name": name,
  41. "kind": kind,
  42. "horizon": label,
  43. "start": horizon.index[0].strftime("%Y-%m-%d"),
  44. "end": horizon.index[-1].strftime("%Y-%m-%d"),
  45. **metrics(horizon),
  46. }
  47. )
  48. return rows
  49. def horizon_return_fields(series: pd.Series) -> dict[str, float]:
  50. rows = {row["horizon"]: row for row in horizon_rows("", "", series)}
  51. return {
  52. "h3y_return": float(rows["3y"]["total_return"]),
  53. "h1y_return": float(rows["1y"]["total_return"]),
  54. "h6m_return": float(rows["6m"]["total_return"]),
  55. "h3m_return": float(rows["3m"]["total_return"]),
  56. }
  57. def daily(series: pd.Series) -> pd.Series:
  58. index = pd.date_range(series.index[0].normalize(), series.index[-1].normalize(), freq="1D", tz="UTC")
  59. return series.resample("1D").last().reindex(index).ffill()
  60. def component_returns(series: pd.Series) -> pd.Series:
  61. return series.pct_change().fillna(0.0)
  62. def combine_components(components: dict[str, pd.Series], weights: dict[str, float]) -> pd.Series:
  63. frame = pd.DataFrame({name: component_returns(series) for name, series in components.items()}).dropna()
  64. combined = sum(frame[name] * weights.get(name, 0.0) for name in frame.columns)
  65. equity = INITIAL_EQUITY * (1.0 + combined).cumprod()
  66. equity.name = "equity"
  67. return equity
  68. def riskoff_long_equity(series: pd.Series, gate: pd.Series, multiplier: float) -> pd.Series:
  69. aligned = pd.DataFrame({"returns": component_returns(series), "gate": gate.reindex(series.index).ffill().fillna(0.0)}).dropna()
  70. scale = aligned["gate"].where(aligned["gate"] <= 0.0, multiplier).where(aligned["gate"] > 0.0, 1.0)
  71. equity = INITIAL_EQUITY * (1.0 + aligned["returns"] * scale).cumprod()
  72. equity.name = "equity"
  73. return equity
  74. def btc_risk_short(years: float) -> tuple[str, pd.Series, dict[str, float | int], pd.Series]:
  75. params = overlay.BtcRiskShort(
  76. family="btc_risk_pair",
  77. bar="1h",
  78. btc_trend=1440,
  79. btc_lookback=336,
  80. symbol_trend=720,
  81. vol_lookback=336,
  82. btc_max_momentum=-0.005,
  83. btc_min_drop=0.025,
  84. min_btc_vol=0.012,
  85. symbol_max_momentum=-0.010,
  86. short_symbols=("ETH-USDT-SWAP",),
  87. )
  88. frames = overlay.load_frames(years)
  89. closes = pd.DataFrame({symbol: frames[(symbol, params.bar)]["close"] for symbol in ("BTC-USDT-SWAP", "ETH-USDT-SWAP")}).dropna()
  90. weights = overlay.short_weights(closes, params)
  91. equity = daily(overlay.equity_from_weights(closes, weights))
  92. risk_state = daily((weights["ETH-USDT-SWAP"].abs() > 0.0).astype(float))
  93. return params.name, equity, overlay.trade_stats(weights, closes), risk_state
  94. def swing_short(strategy: swing.Strategy, years: float) -> tuple[str, pd.Series, dict[str, float | int]]:
  95. frame = swing.resample_frame(swing.load_15m_frame(strategy.symbol, years), strategy.bar)
  96. btc_frame = swing.resample_frame(swing.load_15m_frame("BTC-USDT-SWAP", years), strategy.bar) if strategy.symbol == "ETH-USDT-SWAP" else None
  97. result = swing.run_strategy(strategy, frame, btc_frame)
  98. equity = swing.daily_equity(result)
  99. return strategy.name, equity, swing.trade_metrics(result["trades"])
  100. def build_components(years: float) -> dict[str, tuple[str, pd.Series, dict[str, float | int]]]:
  101. base_name, base_equity, base_stats = overlay.rotation_base(years)
  102. btc_risk_name, btc_risk_equity, btc_risk_stats, risk_state = btc_risk_short(years)
  103. eth_name, eth_equity, eth_stats = swing_short(
  104. swing.Strategy(
  105. family="vol_expansion_short",
  106. symbol="ETH-USDT-SWAP",
  107. bar="4H",
  108. params={
  109. "fast": 20,
  110. "slow": 80,
  111. "entry": 20,
  112. "exit": 10,
  113. "atr": 14,
  114. "stop_atr": 3.0,
  115. "take_atr": 6.0,
  116. "max_hold": 120,
  117. "vol_window": 120,
  118. "vol_quantile": 0.8,
  119. },
  120. ),
  121. years,
  122. )
  123. btc_name, btc_equity, btc_stats = swing_short(
  124. swing.Strategy(
  125. family="vol_expansion_short",
  126. symbol="BTC-USDT-SWAP",
  127. bar="4H",
  128. params={
  129. "fast": 30,
  130. "slow": 120,
  131. "entry": 20,
  132. "exit": 10,
  133. "atr": 14,
  134. "stop_atr": 3.0,
  135. "take_atr": 6.0,
  136. "max_hold": 120,
  137. "vol_window": 120,
  138. "vol_quantile": 0.8,
  139. },
  140. ),
  141. years,
  142. )
  143. components = {
  144. "long_rotation": (base_name, daily(base_equity), base_stats),
  145. "long_rotation_riskoff70": (f"{base_name}-riskoff70", riskoff_long_equity(daily(base_equity), risk_state, 0.70), base_stats),
  146. "long_rotation_riskoff50": (f"{base_name}-riskoff50", riskoff_long_equity(daily(base_equity), risk_state, 0.50), base_stats),
  147. "long_rotation_riskoff25": (f"{base_name}-riskoff25", riskoff_long_equity(daily(base_equity), risk_state, 0.25), base_stats),
  148. "long_rotation_riskoff00": (f"{base_name}-riskoff00", riskoff_long_equity(daily(base_equity), risk_state, 0.0), base_stats),
  149. "btc_risk_short": (btc_risk_name, btc_risk_equity, btc_risk_stats),
  150. "eth_4h_vol_short": (eth_name, eth_equity, eth_stats),
  151. "btc_4h_vol_short": (btc_name, btc_equity, btc_stats),
  152. "eth_4h_vol_short_gated": (f"{eth_name}-btc_risk_gated", gated_equity(eth_equity, risk_state), eth_stats),
  153. "btc_4h_vol_short_gated": (f"{btc_name}-btc_risk_gated", gated_equity(btc_equity, risk_state), btc_stats),
  154. }
  155. return components
  156. def gated_equity(series: pd.Series, gate: pd.Series) -> pd.Series:
  157. aligned = pd.DataFrame({"returns": component_returns(series), "gate": gate.reindex(series.index).ffill().fillna(0.0)}).dropna()
  158. equity = INITIAL_EQUITY * (1.0 + aligned["returns"] * aligned["gate"]).cumprod()
  159. equity.name = "equity"
  160. return equity
  161. def monthly_rows(name: str, kind: str, series: pd.Series) -> pd.DataFrame:
  162. monthly = series.resample("ME").last()
  163. frame = pd.DataFrame(
  164. {
  165. "name": name,
  166. "kind": kind,
  167. "month": monthly.index.strftime("%Y-%m"),
  168. "start_equity": monthly.shift(1).fillna(series.iloc[0]).to_numpy(),
  169. "end_equity": monthly.to_numpy(),
  170. }
  171. )
  172. frame["return"] = frame["end_equity"] / frame["start_equity"] - 1.0
  173. return frame
  174. def score(row: dict[str, object]) -> float:
  175. return float(row["annualized_return"]) - float(row["max_drawdown"]) + 0.5 * float(row["h1y_return"]) + 0.25 * float(row["h6m_return"])
  176. def report_text(command: str, paths: list[Path], selected: pd.DataFrame, components: pd.DataFrame, horizons: pd.DataFrame, monthly: pd.DataFrame) -> str:
  177. return "\n".join(
  178. [
  179. "# Long Short Fusion Search",
  180. "",
  181. f"Run command: `{command}`",
  182. "",
  183. "Output files:",
  184. *[f"- `{path}`" for path in paths],
  185. "",
  186. "Objective: combine existing long rotation and short-biased legs into bidirectional portfolios. This is research only; no live service was changed.",
  187. "",
  188. "## Selected Fusion Candidates",
  189. "",
  190. markdown_table(selected),
  191. "",
  192. "## Component Curves",
  193. "",
  194. markdown_table(components),
  195. "",
  196. "## Horizons",
  197. "",
  198. markdown_table(horizons[horizons["name"].isin(set(selected["name"]))]),
  199. "",
  200. "## Recent Monthly Returns",
  201. "",
  202. markdown_table(monthly[monthly["name"].isin(set(selected["name"]))].tail(96)),
  203. "",
  204. ]
  205. )
  206. def main() -> int:
  207. parser = argparse.ArgumentParser()
  208. parser.add_argument("--years", type=float, default=8.0)
  209. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  210. parser.add_argument("--focused", action="store_true")
  211. args = parser.parse_args()
  212. args.output_dir.mkdir(parents=True, exist_ok=True)
  213. components = build_components(args.years)
  214. component_series = {key: value[1] for key, value in components.items()}
  215. rows: list[dict[str, object]] = []
  216. horizon_output: list[dict[str, object]] = []
  217. monthly_output: list[pd.DataFrame] = []
  218. component_rows = []
  219. for key, (name, series, stats) in components.items():
  220. row = {
  221. "name": key,
  222. "source": name,
  223. "kind": "component",
  224. "long_weight": 1.0 if key == "long_rotation" else 0.0,
  225. "long_variant": key if key.startswith("long_rotation") else "",
  226. "btc_risk_short_weight": 1.0 if key == "btc_risk_short" else 0.0,
  227. "eth_4h_vol_short_weight": 1.0 if key == "eth_4h_vol_short" else 0.0,
  228. "btc_4h_vol_short_weight": 1.0 if key == "btc_4h_vol_short" else 0.0,
  229. "eth_4h_vol_short_gated_weight": 1.0 if key == "eth_4h_vol_short_gated" else 0.0,
  230. "btc_4h_vol_short_gated_weight": 1.0 if key == "btc_4h_vol_short_gated" else 0.0,
  231. **horizon_return_fields(series),
  232. **metrics(series),
  233. **stats,
  234. }
  235. component_rows.append(row)
  236. horizon_output.extend(horizon_rows(key, "component", series))
  237. monthly_output.append(monthly_rows(key, "component", series))
  238. if args.focused:
  239. long_keys = ("long_rotation_riskoff00",)
  240. long_weights = (1.08, 1.10, 1.12, 1.15, 1.18, 1.20)
  241. btc_risk_weights = (0.06, 0.08, 0.10, 0.12)
  242. eth_swing_weights = (0.00, 0.04, 0.06, 0.08, 0.10, 0.12)
  243. btc_swing_weights = (0.00, 0.02, 0.04, 0.06)
  244. eth_gated_weights = (0.00, 0.06, 0.08, 0.10, 0.12)
  245. btc_gated_weights = (0.00, 0.02, 0.03, 0.04, 0.06)
  246. max_short_weight = 0.30
  247. max_gross_exposure = 1.45
  248. else:
  249. long_keys = ("long_rotation", "long_rotation_riskoff70", "long_rotation_riskoff50", "long_rotation_riskoff25", "long_rotation_riskoff00")
  250. long_weights = (0.70, 0.85, 1.00, 1.10, 1.20)
  251. btc_risk_weights = (0.00, 0.02, 0.04, 0.06, 0.08, 0.10, 0.12)
  252. eth_swing_weights = (0.00, 0.02, 0.04, 0.06, 0.08, 0.10, 0.12)
  253. btc_swing_weights = (0.00, 0.02, 0.04, 0.06)
  254. eth_gated_weights = (0.00, 0.04, 0.08, 0.12)
  255. btc_gated_weights = (0.00, 0.03, 0.06)
  256. max_short_weight = 0.40
  257. max_gross_exposure = 1.45
  258. for long_key in long_keys:
  259. for long_weight in long_weights:
  260. for btc_risk_weight in btc_risk_weights:
  261. for eth_swing_weight in eth_swing_weights:
  262. for btc_swing_weight in btc_swing_weights:
  263. for eth_gated_weight in eth_gated_weights:
  264. for btc_gated_weight in btc_gated_weights:
  265. if eth_swing_weight > 0.0 and eth_gated_weight > 0.0:
  266. continue
  267. if btc_swing_weight > 0.0 and btc_gated_weight > 0.0:
  268. continue
  269. short_weight = btc_risk_weight + eth_swing_weight + btc_swing_weight + eth_gated_weight + btc_gated_weight
  270. if short_weight <= 0.0 or short_weight > max_short_weight:
  271. continue
  272. if long_weight + short_weight > max_gross_exposure:
  273. continue
  274. weights = {
  275. long_key: long_weight,
  276. "btc_risk_short": btc_risk_weight,
  277. "eth_4h_vol_short": eth_swing_weight,
  278. "btc_4h_vol_short": btc_swing_weight,
  279. "eth_4h_vol_short_gated": eth_gated_weight,
  280. "btc_4h_vol_short_gated": btc_gated_weight,
  281. }
  282. equity = combine_components(component_series, weights)
  283. name = (
  284. f"fusion-{long_key.replace('long_rotation', 'lr')}-l{long_weight:.2f}"
  285. f"-brs{btc_risk_weight:.2f}"
  286. f"-eth4hs{eth_swing_weight:.2f}"
  287. f"-btc4hs{btc_swing_weight:.2f}"
  288. f"-eg{eth_gated_weight:.2f}"
  289. f"-bg{btc_gated_weight:.2f}"
  290. )
  291. row = {
  292. "name": name,
  293. "kind": "fusion",
  294. "long_variant": long_key,
  295. "long_weight": long_weight,
  296. "btc_risk_short_weight": btc_risk_weight,
  297. "eth_4h_vol_short_weight": eth_swing_weight,
  298. "btc_4h_vol_short_weight": btc_swing_weight,
  299. "eth_4h_vol_short_gated_weight": eth_gated_weight,
  300. "btc_4h_vol_short_gated_weight": btc_gated_weight,
  301. "gross_exposure": long_weight + short_weight,
  302. "short_exposure": short_weight,
  303. **horizon_return_fields(equity),
  304. **metrics(equity),
  305. "trades": int(sum(int(components[key][2].get("trades", 0)) for key, weight in weights.items() if weight > 0.0)),
  306. "win_rate": 0.0,
  307. "profit_factor": 0.0,
  308. }
  309. row["score"] = score(row)
  310. row["selected"] = "no"
  311. rows.append(row)
  312. horizon_output.extend(horizon_rows(name, "fusion", equity))
  313. monthly_output.append(monthly_rows(name, "fusion", equity))
  314. total = pd.DataFrame(rows).sort_values(
  315. ["h1y_return", "h6m_return", "h3m_return", "max_drawdown", "annualized_return"],
  316. ascending=[False, False, False, True, False],
  317. )
  318. recent_ok = total[(total["h1y_return"] > 0.0) & (total["h6m_return"] > 0.0) & (total["h3m_return"] > 0.0)].copy()
  319. low_drawdown = recent_ok[recent_ok["max_drawdown"] <= 0.10].sort_values("score", ascending=False).head(10)
  320. high_return = recent_ok.sort_values(["annualized_return", "max_drawdown"], ascending=[False, True]).head(10)
  321. selected = pd.concat([low_drawdown, high_return]).drop_duplicates("name").head(12).copy()
  322. selected["selected"] = "yes"
  323. total.loc[total["name"].isin(set(selected["name"])), "selected"] = "yes"
  324. component_frame = pd.DataFrame(component_rows)
  325. horizons = pd.DataFrame(horizon_output)
  326. monthly = pd.concat(monthly_output, ignore_index=True)
  327. total_path = args.output_dir / f"{PREFIX}-total.csv"
  328. selected_path = args.output_dir / f"{PREFIX}-selected.csv"
  329. component_path = args.output_dir / f"{PREFIX}-components.csv"
  330. horizon_path = args.output_dir / f"{PREFIX}-horizons.csv"
  331. monthly_path = args.output_dir / f"{PREFIX}-monthly.csv"
  332. report_path = args.output_dir / f"{PREFIX}-report.md"
  333. total.to_csv(total_path, index=False)
  334. selected.to_csv(selected_path, index=False)
  335. component_frame.to_csv(component_path, index=False)
  336. horizons.to_csv(horizon_path, index=False)
  337. monthly.to_csv(monthly_path, index=False)
  338. report_path.write_text(
  339. report_text(
  340. f"rtk .venv/bin/python scripts/search_long_short_fusion.py --years {args.years}",
  341. [total_path, selected_path, component_path, horizon_path, monthly_path, report_path],
  342. selected,
  343. component_frame,
  344. horizons,
  345. monthly,
  346. ),
  347. encoding="utf-8",
  348. )
  349. print(report_path)
  350. print(selected.head(8).to_string(index=False))
  351. return 0
  352. if __name__ == "__main__":
  353. raise SystemExit(main())