stress_expansion_rotation_risk.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. from __future__ import annotations
  2. import argparse
  3. import sys
  4. from dataclasses import dataclass, replace
  5. from itertools import product
  6. from pathlib import Path
  7. import pandas as pd
  8. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  9. from scripts import refine_expansion_rotation_risk as refine
  10. from scripts import search_expansion_rotation as rotation
  11. OUTPUT_DIR = Path("reports/strategy-expansion")
  12. PREFIX = "rotation-risk-stress"
  13. FEE_RATES = (0.0004, 0.0006, 0.0008, 0.0010)
  14. SLIPPAGE_RATES = (0.0, 0.0002, 0.0005, 0.0010)
  15. FUNDING_RATES_8H = (-0.00005, 0.0, 0.00005)
  16. EXPOSURES = (0.50, 0.65, 0.80)
  17. VOL_TARGETS = (0.0, 0.20, 0.30)
  18. rotation.SYMBOLS = ("BTC-USDT-SWAP", "ETH-USDT-SWAP")
  19. @dataclass(frozen=True)
  20. class StressParams:
  21. risk: refine.RiskParams
  22. fee_rate: float
  23. slippage_rate: float
  24. funding_rate_8h: float
  25. @property
  26. def name(self) -> str:
  27. return (
  28. f"{self.risk.name}-fee{self.fee_rate:.4f}"
  29. f"-slip{self.slippage_rate:.4f}-fund{self.funding_rate_8h:.5f}"
  30. )
  31. def params_from_risk_row(row: pd.Series) -> refine.RiskParams:
  32. base = refine.params_from_row(row)
  33. return refine.RiskParams(
  34. base=base,
  35. leverage=float(row["leverage"]),
  36. exposure=float(row["exposure"]),
  37. vol_target=float(row["vol_target"]),
  38. )
  39. def bar_hours(bar: str) -> float:
  40. return {"1h": 1.0, "4h": 4.0, "1d": 24.0}[bar]
  41. def source_candidates(limit: int) -> list[refine.RiskParams]:
  42. frame = pd.read_csv(OUTPUT_DIR / "rotation-risk-top.csv").head(limit)
  43. candidates: dict[str, refine.RiskParams] = {}
  44. for _, row in frame.iterrows():
  45. risk = params_from_risk_row(row)
  46. candidates[risk.name] = risk
  47. return list(candidates.values())
  48. def robustness_variants(candidate: refine.RiskParams) -> list[refine.RiskParams]:
  49. variants: dict[str, refine.RiskParams] = {}
  50. for exposure, vol_target in product(EXPOSURES, VOL_TARGETS):
  51. risk = replace(candidate, exposure=exposure, vol_target=vol_target)
  52. variants[risk.name] = risk
  53. return list(variants.values())
  54. def equity_curve(closes: pd.DataFrame, weights: pd.DataFrame, params: StressParams) -> pd.Series:
  55. returns = closes.pct_change().fillna(0.0)
  56. executed = weights.shift(1).fillna(0.0)
  57. turnover = executed.diff().abs().sum(axis=1).fillna(executed.abs().sum(axis=1))
  58. gross_returns = (executed * returns * params.risk.leverage).sum(axis=1)
  59. trade_cost = turnover * (params.fee_rate + params.slippage_rate) * params.risk.leverage
  60. funding = executed.abs().sum(axis=1) * params.risk.leverage * params.funding_rate_8h * bar_hours(params.risk.base.bar) / 8.0
  61. net_returns = gross_returns - trade_cost - funding
  62. equity = refine.INITIAL_EQUITY * (1.0 + net_returns).cumprod()
  63. equity.name = "equity"
  64. return equity
  65. def row_for_result(
  66. candidate_name: str,
  67. closes: pd.DataFrame,
  68. weights: pd.DataFrame,
  69. params: StressParams,
  70. equity: pd.Series,
  71. ) -> dict[str, object]:
  72. years = (equity.index[-1] - equity.index[0]).total_seconds() / 86_400 / 365
  73. turnover_per_year = float(weights.shift(1).fillna(0.0).diff().abs().sum(axis=1).sum() / years)
  74. horizon_rows = rotation.horizon_rows(params.name, equity)
  75. horizons_by_label = {row["horizon"]: row for row in horizon_rows}
  76. return {
  77. "candidate": candidate_name,
  78. "strategy": params.name,
  79. "family": params.risk.base.family,
  80. "bar": params.risk.base.bar,
  81. "universe": ",".join(closes.columns),
  82. "lookback": params.risk.base.lookback,
  83. "trend": params.risk.base.trend,
  84. "btc_trend": params.risk.base.btc_trend,
  85. "rebalance": params.risk.base.rebalance,
  86. "top_n": params.risk.base.top_n,
  87. "min_momentum": params.risk.base.min_momentum,
  88. "btc_min_momentum": params.risk.base.btc_min_momentum,
  89. "vol_lookback": params.risk.base.vol_lookback,
  90. "max_vol": params.risk.base.max_vol,
  91. "leverage": params.risk.leverage,
  92. "exposure": params.risk.exposure,
  93. "vol_target": params.risk.vol_target,
  94. "fee_rate": params.fee_rate,
  95. "slippage_rate": params.slippage_rate,
  96. "funding_rate_8h": params.funding_rate_8h,
  97. "first_candle": equity.index[0].strftime("%Y-%m-%d %H:%M"),
  98. "last_candle": equity.index[-1].strftime("%Y-%m-%d %H:%M"),
  99. "years": years,
  100. "turnover_per_year": turnover_per_year,
  101. "h3y_return": horizons_by_label["3y"]["total_return"],
  102. "h1y_return": horizons_by_label["1y"]["total_return"],
  103. "h6m_return": horizons_by_label["6m"]["total_return"],
  104. "h3m_return": horizons_by_label["3m"]["total_return"],
  105. **rotation.metrics(equity),
  106. }
  107. def markdown_table(frame: pd.DataFrame) -> str:
  108. rows = [list(frame.columns), ["---" for _ in frame.columns]]
  109. rows.extend(frame.astype(object).where(pd.notna(frame), "").values.tolist())
  110. return "\n".join("| " + " | ".join(rotation.format_cell(value) for value in row) + " |" for row in rows)
  111. def conservative_summary(total: pd.DataFrame) -> pd.DataFrame:
  112. conservative = total[
  113. (total["fee_rate"] == 0.0010)
  114. & (total["slippage_rate"] == 0.0010)
  115. & (total["funding_rate_8h"] == 0.00005)
  116. ]
  117. return conservative.sort_values(
  118. ["calmar", "annualized_return", "max_drawdown"],
  119. ascending=[False, False, True],
  120. )
  121. def report_text(command: str, paths: list[Path], total: pd.DataFrame, best_candidate: str) -> str:
  122. conservative = conservative_summary(total)
  123. best_conservative = conservative[conservative["candidate"] == best_candidate].sort_values(
  124. ["calmar", "annualized_return", "max_drawdown"],
  125. ascending=[False, False, True],
  126. )
  127. verdict_row = best_conservative.iloc[0]
  128. worth = (
  129. verdict_row["annualized_return"] > 0.0
  130. and verdict_row["max_drawdown"] <= 0.35
  131. and verdict_row["calmar"] >= 1.0
  132. and verdict_row["h1y_return"] > 0.0
  133. )
  134. verdict = "worth continuing" if worth else "not worth continuing under conservative cost"
  135. columns = [
  136. "candidate",
  137. "strategy",
  138. "fee_rate",
  139. "slippage_rate",
  140. "funding_rate_8h",
  141. "total_return",
  142. "annualized_return",
  143. "max_drawdown",
  144. "calmar",
  145. "h3y_return",
  146. "h1y_return",
  147. "h6m_return",
  148. "h3m_return",
  149. ]
  150. return "\n".join(
  151. [
  152. "# Rotation risk stress test",
  153. "",
  154. f"Run command: `{command}`",
  155. "",
  156. "Output files:",
  157. *[f"- `{path}`" for path in paths],
  158. "",
  159. "Cost grid: one-way fee 0.04/0.06/0.08/0.10%, extra slippage 0/0.02/0.05/0.10%, funding per 8h -0.005/0/+0.005%.",
  160. "Funding model: positive funding is paid by long exposure; negative funding is received by long exposure.",
  161. "Robustness grid: exposure 0.50/0.65/0.80 and vol_target 0/0.20/0.30 on each front candidate signal.",
  162. "",
  163. f"Verdict: original best candidate `{best_candidate}` is `{verdict}`. Conservative case annualized return is {verdict_row['annualized_return']:.2%}, max drawdown is {verdict_row['max_drawdown']:.2%}, Calmar is {verdict_row['calmar']:.2f}.",
  164. "",
  165. "## Best Conservative Rows",
  166. "",
  167. markdown_table(conservative.head(10)[columns]),
  168. "",
  169. "## Best Candidate Conservative Robustness",
  170. "",
  171. markdown_table(best_conservative.head(10)[columns]),
  172. "",
  173. ]
  174. )
  175. def main() -> int:
  176. parser = argparse.ArgumentParser()
  177. parser.add_argument("--years", type=float, default=8.0)
  178. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  179. parser.add_argument("--candidate-limit", type=int, default=10)
  180. parser.add_argument("--top", type=int, default=30)
  181. args = parser.parse_args()
  182. frames = rotation.load_symbol_bar_frames(args.years)
  183. rows: list[dict[str, object]] = []
  184. horizon_rows: list[dict[str, object]] = []
  185. candidates = source_candidates(args.candidate_limit)
  186. total_runs = len(candidates) * len(EXPOSURES) * len(VOL_TARGETS) * len(FEE_RATES) * len(SLIPPAGE_RATES) * len(FUNDING_RATES_8H)
  187. run_index = 0
  188. for candidate in candidates:
  189. closes = rotation.aligned_closes(frames, candidate.base)
  190. signal_weights = rotation.target_weights(closes, candidate.base)
  191. for risk in robustness_variants(candidate):
  192. weights = refine.apply_risk_controls(closes, signal_weights, risk)
  193. for fee_rate, slippage_rate, funding_rate in product(FEE_RATES, SLIPPAGE_RATES, FUNDING_RATES_8H):
  194. run_index += 1
  195. stress = StressParams(risk=risk, fee_rate=fee_rate, slippage_rate=slippage_rate, funding_rate_8h=funding_rate)
  196. equity = equity_curve(closes, weights, stress)
  197. rows.append(row_for_result(candidate.name, closes, weights, stress, equity))
  198. horizon_rows.extend(rotation.horizon_rows(stress.name, equity))
  199. if run_index % 1000 == 0:
  200. print(f"done {run_index}/{total_runs}")
  201. total = pd.DataFrame(rows)
  202. ranked = total.sort_values(
  203. ["calmar", "annualized_return", "max_drawdown"],
  204. ascending=[False, False, True],
  205. )
  206. top = ranked.head(args.top)
  207. conservative = conservative_summary(total)
  208. best_candidate = candidates[0].name
  209. horizons = pd.DataFrame(horizon_rows)
  210. horizons = horizons[horizons["strategy"].isin(set(top["strategy"]))]
  211. args.output_dir.mkdir(parents=True, exist_ok=True)
  212. total_path = args.output_dir / f"{PREFIX}-total.csv"
  213. top_path = args.output_dir / f"{PREFIX}-top.csv"
  214. conservative_path = args.output_dir / f"{PREFIX}-conservative.csv"
  215. horizon_path = args.output_dir / f"{PREFIX}-horizons.csv"
  216. report_path = args.output_dir / f"{PREFIX}-report.md"
  217. paths = [total_path, top_path, conservative_path, horizon_path, report_path]
  218. total.to_csv(total_path, index=False)
  219. top.to_csv(top_path, index=False)
  220. conservative.to_csv(conservative_path, index=False)
  221. horizons.to_csv(horizon_path, index=False)
  222. command = (
  223. "rtk .venv/bin/python scripts/stress_expansion_rotation_risk.py"
  224. f" --years {args.years} --candidate-limit {args.candidate_limit} --top {args.top}"
  225. )
  226. report_path.write_text(report_text(command, paths, total, best_candidate), encoding="utf-8")
  227. print(conservative.head(10).to_string(index=False))
  228. return 0
  229. if __name__ == "__main__":
  230. raise SystemExit(main())