stress_long_short_fusion.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. from __future__ import annotations
  2. import argparse
  3. import sys
  4. from itertools import product
  5. from pathlib import Path
  6. import pandas as pd
  7. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  8. from scripts.search_long_short_fusion import (
  9. OUTPUT_DIR,
  10. PREFIX,
  11. INITIAL_EQUITY,
  12. build_components,
  13. component_returns,
  14. horizon_return_fields,
  15. markdown_table,
  16. metrics,
  17. )
  18. STRESS_PREFIX = "fusion-stress"
  19. def stressed_equity(
  20. components: dict[str, pd.Series],
  21. weights: dict[str, float],
  22. *,
  23. fee_single_side: float,
  24. slippage: float,
  25. funding_8h: float,
  26. ) -> pd.Series:
  27. returns = pd.DataFrame({name: component_returns(series) for name, series in components.items()}).dropna()
  28. combined = sum(returns[name] * weights.get(name, 0.0) for name in returns.columns)
  29. short_weight = sum(weight for name, weight in weights.items() if "short" in name)
  30. active_short = returns[[name for name in returns.columns if "short" in name]].abs().sum(axis=1) > 0.0
  31. turnover_proxy = sum(abs(weight) for weight in weights.values())
  32. base_fee = 0.0004
  33. extra_fee = max(0.0, fee_single_side - base_fee) * turnover_proxy / 30.0
  34. extra_slippage = slippage * turnover_proxy / 30.0
  35. funding_drag = active_short.astype(float) * short_weight * max(0.0, -funding_8h) * 3.0
  36. stressed = combined - extra_fee - extra_slippage - funding_drag
  37. equity = INITIAL_EQUITY * (1.0 + stressed).cumprod()
  38. equity.name = "equity"
  39. return equity
  40. def parse_weights(row: pd.Series) -> dict[str, float]:
  41. long_key = str(row["long_variant"])
  42. return {
  43. long_key: float(row["long_weight"]),
  44. "btc_risk_short": float(row["btc_risk_short_weight"]),
  45. "eth_4h_vol_short": float(row["eth_4h_vol_short_weight"]),
  46. "btc_4h_vol_short": float(row["btc_4h_vol_short_weight"]),
  47. "eth_4h_vol_short_gated": float(row["eth_4h_vol_short_gated_weight"]),
  48. "btc_4h_vol_short_gated": float(row["btc_4h_vol_short_gated_weight"]),
  49. }
  50. def report_text(command: str, paths: list[Path], robustness: pd.DataFrame, worst: pd.DataFrame) -> str:
  51. return "\n".join(
  52. [
  53. "# Long Short Fusion Stress Test",
  54. "",
  55. f"Run command: `{command}`",
  56. "",
  57. "Output files:",
  58. *[f"- `{path}`" for path in paths],
  59. "",
  60. "Stress grid: fee 0.04/0.08/0.10% single-side, slippage 0/0.05/0.10%, funding every 8h -0.005/0/+0.005%. Negative funding is short pay.",
  61. "",
  62. "## Robustness Summary",
  63. "",
  64. markdown_table(robustness),
  65. "",
  66. "## Worst Cases",
  67. "",
  68. markdown_table(worst),
  69. "",
  70. ]
  71. )
  72. def main() -> int:
  73. parser = argparse.ArgumentParser()
  74. parser.add_argument("--years", type=float, default=8.0)
  75. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  76. args = parser.parse_args()
  77. args.output_dir.mkdir(parents=True, exist_ok=True)
  78. selected = pd.read_csv(args.output_dir / f"{PREFIX}-selected.csv")
  79. components_full = build_components(args.years)
  80. components = {name: item[1] for name, item in components_full.items()}
  81. rows = []
  82. for _, selected_row in selected.iterrows():
  83. weights = parse_weights(selected_row)
  84. for fee, slippage, funding in product((0.0004, 0.0008, 0.0010), (0.0, 0.0005, 0.0010), (-0.00005, 0.0, 0.00005)):
  85. equity = stressed_equity(
  86. components,
  87. weights,
  88. fee_single_side=fee,
  89. slippage=slippage,
  90. funding_8h=funding,
  91. )
  92. rows.append(
  93. {
  94. "name": selected_row["name"],
  95. "fee_single_side": fee,
  96. "slippage": slippage,
  97. "funding_8h": funding,
  98. "long_variant": selected_row["long_variant"],
  99. "long_weight": selected_row["long_weight"],
  100. "short_exposure": selected_row["short_exposure"],
  101. **horizon_return_fields(equity),
  102. **metrics(equity),
  103. }
  104. )
  105. totals = pd.DataFrame(rows)
  106. robustness_rows = []
  107. for name, group in totals.groupby("name"):
  108. worst = group.sort_values(["total_return", "max_drawdown"], ascending=[True, False]).iloc[0]
  109. robustness_rows.append(
  110. {
  111. "name": name,
  112. "stress_cases": len(group),
  113. "positive_cases": int((group["total_return"] > 0.0).sum()),
  114. "worst_total_return": float(group["total_return"].min()),
  115. "worst_annualized_return": float(group["annualized_return"].min()),
  116. "worst_max_drawdown": float(group["max_drawdown"].max()),
  117. "worst_calmar": float(group["calmar"].min()),
  118. "worst_return_3y": float(group["h3y_return"].min()),
  119. "worst_return_1y": float(group["h1y_return"].min()),
  120. "worst_return_6m": float(group["h6m_return"].min()),
  121. "worst_return_3m": float(group["h3m_return"].min()),
  122. "worst_fee_single_side": float(worst["fee_single_side"]),
  123. "worst_slippage": float(worst["slippage"]),
  124. "worst_funding_8h": float(worst["funding_8h"]),
  125. }
  126. )
  127. robustness = pd.DataFrame(robustness_rows).sort_values(["worst_calmar", "worst_total_return"], ascending=[False, False])
  128. worst = totals.sort_values(["total_return", "max_drawdown"], ascending=[True, False]).head(20)
  129. totals_path = args.output_dir / f"{STRESS_PREFIX}-totals.csv"
  130. robustness_path = args.output_dir / f"{STRESS_PREFIX}-robustness.csv"
  131. worst_path = args.output_dir / f"{STRESS_PREFIX}-worst.csv"
  132. report_path = args.output_dir / f"{STRESS_PREFIX}-report.md"
  133. totals.to_csv(totals_path, index=False)
  134. robustness.to_csv(robustness_path, index=False)
  135. worst.to_csv(worst_path, index=False)
  136. report_path.write_text(
  137. report_text(
  138. f"rtk .venv/bin/python scripts/stress_long_short_fusion.py --years {args.years}",
  139. [totals_path, robustness_path, worst_path, report_path],
  140. robustness,
  141. worst,
  142. ),
  143. encoding="utf-8",
  144. )
  145. print(report_path)
  146. print(robustness.head(10).to_string(index=False))
  147. return 0
  148. if __name__ == "__main__":
  149. raise SystemExit(main())