refine_expansion_rotation_risk.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  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 search_expansion_rotation as rotation
  10. OUTPUT_DIR = Path("reports/strategy-expansion")
  11. PREFIX = "rotation-risk"
  12. INITIAL_EQUITY = rotation.INITIAL_EQUITY
  13. TAKER_FEE = rotation.TAKER_FEE
  14. rotation.SYMBOLS = ("BTC-USDT-SWAP", "ETH-USDT-SWAP")
  15. @dataclass(frozen=True)
  16. class RiskParams:
  17. base: rotation.Params
  18. leverage: float
  19. exposure: float
  20. vol_target: float
  21. @property
  22. def name(self) -> str:
  23. return (
  24. f"{self.base.name}-lev{self.leverage:.2f}"
  25. f"-exp{self.exposure:.2f}-vt{self.vol_target:.4f}"
  26. )
  27. def params_from_row(row: pd.Series) -> rotation.Params:
  28. return rotation.Params(
  29. family=str(row["family"]),
  30. bar=str(row["bar"]),
  31. lookback=int(row["lookback"]),
  32. trend=int(row["trend"]),
  33. btc_trend=int(row["btc_trend"]),
  34. rebalance=int(row["rebalance"]),
  35. top_n=int(row["top_n"]),
  36. min_momentum=float(row["min_momentum"]),
  37. btc_min_momentum=float(row["btc_min_momentum"]),
  38. vol_lookback=int(row["vol_lookback"]),
  39. max_vol=float(row["max_vol"]),
  40. )
  41. def bar_scale(bar: str) -> float:
  42. return {"1h": 24.0 * 365.0, "4h": 6.0 * 365.0, "1d": 365.0}[bar] ** 0.5
  43. def source_bases(limit: int) -> list[rotation.Params]:
  44. report_path = OUTPUT_DIR / "rotation-top.csv"
  45. frame = pd.read_csv(report_path).head(limit)
  46. return [params_from_row(row) for _, row in frame.iterrows()]
  47. def build_signal_params(limit: int) -> list[rotation.Params]:
  48. params: dict[str, rotation.Params] = {}
  49. for base in source_bases(limit):
  50. if base.bar == "1h":
  51. rebalances = (base.rebalance,)
  52. btc_trends = tuple(sorted({base.btc_trend, 24 * 180}))
  53. max_vols = (0.030,)
  54. elif base.bar == "4h":
  55. rebalances = (base.rebalance,)
  56. btc_trends = tuple(sorted({base.btc_trend, 6 * 180}))
  57. max_vols = (0.055,)
  58. else:
  59. rebalances = (base.rebalance,)
  60. btc_trends = tuple(sorted({base.btc_trend, 240}))
  61. max_vols = (0.090,)
  62. top_ns = (1,) if base.family == "dual_momentum" else (1, 2)
  63. for rebalance, top_n, btc_trend, btc_min_momentum, max_vol in product(
  64. rebalances,
  65. top_ns,
  66. btc_trends,
  67. (0.00, 0.05),
  68. max_vols,
  69. ):
  70. tuned = replace(
  71. base,
  72. rebalance=rebalance,
  73. top_n=top_n,
  74. btc_trend=btc_trend,
  75. btc_min_momentum=btc_min_momentum,
  76. max_vol=max_vol,
  77. )
  78. params[tuned.name] = tuned
  79. return list(params.values())
  80. def risk_variants(base: rotation.Params) -> list[RiskParams]:
  81. return [
  82. RiskParams(base, leverage, exposure, vol_target)
  83. for leverage, exposure, vol_target in product(
  84. (0.75, 1.0, 1.25, 1.5, 2.0),
  85. (0.50, 0.65, 0.80),
  86. (0.0, 0.20, 0.30),
  87. )
  88. ]
  89. def apply_risk_controls(closes: pd.DataFrame, weights: pd.DataFrame, params: RiskParams) -> pd.DataFrame:
  90. controlled = weights * params.exposure
  91. if params.vol_target <= 0.0:
  92. return controlled
  93. returns = closes.pct_change().fillna(0.0)
  94. portfolio_vol = (controlled.shift(1).fillna(0.0) * returns).sum(axis=1).rolling(params.base.vol_lookback).std(ddof=1)
  95. target_bar_vol = params.vol_target / bar_scale(params.base.bar)
  96. scale = (target_bar_vol / portfolio_vol).clip(upper=1.0).fillna(1.0)
  97. return controlled.mul(scale, axis=0)
  98. def equity_curve(closes: pd.DataFrame, weights: pd.DataFrame, params: RiskParams) -> pd.Series:
  99. returns = closes.pct_change().fillna(0.0)
  100. executed = weights.shift(1).fillna(0.0)
  101. turnover = executed.diff().abs().sum(axis=1).fillna(executed.abs().sum(axis=1))
  102. net_returns = (executed * returns * params.leverage).sum(axis=1) - turnover * TAKER_FEE * params.leverage
  103. equity = INITIAL_EQUITY * (1.0 + net_returns).cumprod()
  104. equity.name = "equity"
  105. return equity
  106. def trade_stats(weights: pd.DataFrame, closes: pd.DataFrame, leverage: float) -> dict[str, float | int]:
  107. returns = closes.pct_change().fillna(0.0)
  108. executed = weights.shift(1).fillna(0.0)
  109. turnover = executed.diff().abs().fillna(executed.abs())
  110. wins = 0
  111. gross_profit = 0.0
  112. gross_loss = 0.0
  113. trades = 0
  114. for symbol in closes.columns:
  115. active = executed[symbol] > 0.0
  116. group = (active != active.shift(1)).cumsum()
  117. for _, mask in active.groupby(group):
  118. if not bool(mask.iloc[0]):
  119. continue
  120. segment_index = mask.index
  121. net_returns = (
  122. returns.loc[segment_index, symbol] * executed.loc[segment_index, symbol] * leverage
  123. - turnover.loc[segment_index, symbol] * TAKER_FEE * leverage
  124. )
  125. trade_equity = float((1.0 + net_returns).prod())
  126. last_position = executed.index.get_loc(segment_index[-1])
  127. if last_position + 1 < len(executed.index):
  128. close_index = executed.index[last_position + 1]
  129. if executed.loc[close_index, symbol] == 0.0:
  130. trade_equity *= 1.0 - turnover.loc[close_index, symbol] * TAKER_FEE * leverage
  131. trade_return = trade_equity - 1.0
  132. trades += 1
  133. if trade_return > 0.0:
  134. wins += 1
  135. gross_profit += trade_return
  136. else:
  137. gross_loss += abs(trade_return)
  138. return {
  139. "trades": trades,
  140. "win_rate": wins / trades if trades else 0.0,
  141. "profit_factor": gross_profit / gross_loss if gross_loss else 0.0,
  142. }
  143. def markdown_table(frame: pd.DataFrame) -> str:
  144. rows = [list(frame.columns), ["---" for _ in frame.columns]]
  145. rows.extend(frame.astype(object).where(pd.notna(frame), "").values.tolist())
  146. return "\n".join("| " + " | ".join(rotation.format_cell(value) for value in row) + " |" for row in rows)
  147. def report_text(command: str, paths: list[Path], top: pd.DataFrame, horizons: pd.DataFrame, monthly: pd.DataFrame) -> str:
  148. best_names = set(top.head(3)["strategy"])
  149. recent = horizons[horizons["strategy"].isin(best_names)]
  150. best_monthly = monthly[monthly["strategy"].isin(best_names)]
  151. next_step = top.head(3)[
  152. [
  153. "strategy",
  154. "total_return",
  155. "annualized_return",
  156. "max_drawdown",
  157. "calmar",
  158. "h3y_return",
  159. "h1y_return",
  160. "h6m_return",
  161. "h3m_return",
  162. "next_validation",
  163. ]
  164. ]
  165. return "\n".join(
  166. [
  167. "# Rotation risk refinement",
  168. "",
  169. f"Run command: `{command}`",
  170. "",
  171. "Output files:",
  172. *[f"- `{path}`" for path in paths],
  173. "",
  174. "Objective: reduce maximum drawdown to <=50%, preferably <=35%, while keeping annualized return positive and recent 3y/1y/6m/3m returns from deteriorating excessively.",
  175. "Cost model: 0.04% one-way taker fee, charged on leveraged notional at each portfolio weight change.",
  176. "",
  177. "## Top 3 low-drawdown candidates",
  178. "",
  179. markdown_table(next_step),
  180. "",
  181. "## Ranked candidates",
  182. "",
  183. markdown_table(
  184. top.head(20)[
  185. [
  186. "strategy",
  187. "family",
  188. "bar",
  189. "universe",
  190. "leverage",
  191. "exposure",
  192. "vol_target",
  193. "total_return",
  194. "annualized_return",
  195. "max_drawdown",
  196. "calmar",
  197. "trades",
  198. "win_rate",
  199. "profit_factor",
  200. ]
  201. ]
  202. ),
  203. "",
  204. "## Recent horizons for top 3",
  205. "",
  206. markdown_table(recent),
  207. "",
  208. "## Monthly returns for top 3",
  209. "",
  210. markdown_table(best_monthly),
  211. "",
  212. ]
  213. )
  214. def main() -> int:
  215. parser = argparse.ArgumentParser()
  216. parser.add_argument("--years", type=float, default=8.0)
  217. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  218. parser.add_argument("--top", type=int, default=30)
  219. parser.add_argument("--base-limit", type=int, default=6)
  220. args = parser.parse_args()
  221. frames = rotation.load_symbol_bar_frames(args.years)
  222. totals: list[dict[str, object]] = []
  223. horizon_output: list[dict[str, object]] = []
  224. monthly_output: list[pd.DataFrame] = []
  225. signal_params = build_signal_params(args.base_limit)
  226. total_runs = sum(len(risk_variants(base)) for base in signal_params)
  227. run_index = 0
  228. for base in signal_params:
  229. closes = rotation.aligned_closes(frames, base)
  230. signal_weights = rotation.target_weights(closes, base)
  231. for param in risk_variants(base):
  232. run_index += 1
  233. weights = apply_risk_controls(closes, signal_weights, param)
  234. equity = equity_curve(closes, weights, param)
  235. stat = trade_stats(weights, closes, param.leverage)
  236. years = (equity.index[-1] - equity.index[0]).total_seconds() / 86_400 / 365
  237. turnover_per_year = float(weights.shift(1).fillna(0.0).diff().abs().sum(axis=1).sum() / years)
  238. horizon_rows = rotation.horizon_rows(param.name, equity)
  239. horizons_by_label = {row["horizon"]: row for row in horizon_rows}
  240. row = {
  241. "strategy": param.name,
  242. "family": base.family,
  243. "bar": base.bar,
  244. "universe": ",".join(closes.columns),
  245. "lookback": base.lookback,
  246. "trend": base.trend,
  247. "btc_trend": base.btc_trend,
  248. "rebalance": base.rebalance,
  249. "top_n": base.top_n,
  250. "min_momentum": base.min_momentum,
  251. "btc_min_momentum": base.btc_min_momentum,
  252. "vol_lookback": base.vol_lookback,
  253. "max_vol": base.max_vol,
  254. "leverage": param.leverage,
  255. "exposure": param.exposure,
  256. "vol_target": param.vol_target,
  257. "first_candle": equity.index[0].strftime("%Y-%m-%d %H:%M"),
  258. "last_candle": equity.index[-1].strftime("%Y-%m-%d %H:%M"),
  259. "years": years,
  260. "turnover_per_year": turnover_per_year,
  261. "h3y_return": horizons_by_label["3y"]["total_return"],
  262. "h1y_return": horizons_by_label["1y"]["total_return"],
  263. "h6m_return": horizons_by_label["6m"]["total_return"],
  264. "h3m_return": horizons_by_label["3m"]["total_return"],
  265. **rotation.metrics(equity),
  266. **stat,
  267. }
  268. totals.append(row)
  269. horizon_output.extend(horizon_rows)
  270. monthly_output.append(rotation.monthly_rows(param.name, equity))
  271. if run_index % 1000 == 0:
  272. print(f"done {run_index}/{total_runs}")
  273. total = pd.DataFrame(totals)
  274. viable = total[
  275. (total["annualized_return"] > 0.0)
  276. & (total["max_drawdown"] <= 0.50)
  277. & (total["h3y_return"] > 0.0)
  278. & (total["h1y_return"] > 0.0)
  279. & (total["h6m_return"] > -0.10)
  280. & (total["h3m_return"] > -0.10)
  281. ].copy()
  282. if viable.empty:
  283. viable = total[(total["annualized_return"] > 0.0) & (total["max_drawdown"] <= 0.50)].copy()
  284. if viable.empty:
  285. viable = total.copy()
  286. viable["next_validation"] = viable.apply(
  287. lambda row: "yes" if row["max_drawdown"] <= 0.50 and row["annualized_return"] > 0.0 else "no",
  288. axis=1,
  289. )
  290. ranked = viable.sort_values(
  291. ["max_drawdown", "calmar", "annualized_return", "h1y_return"],
  292. ascending=[True, False, False, False],
  293. )
  294. top = ranked.head(args.top)
  295. top_names = set(top["strategy"])
  296. horizons = pd.DataFrame(horizon_output)
  297. horizons = horizons[horizons["strategy"].isin(top_names)]
  298. monthly = pd.concat(monthly_output, ignore_index=True)
  299. monthly = monthly[monthly["strategy"].isin(top_names)]
  300. args.output_dir.mkdir(parents=True, exist_ok=True)
  301. total_path = args.output_dir / f"{PREFIX}-total.csv"
  302. top_path = args.output_dir / f"{PREFIX}-top.csv"
  303. horizon_path = args.output_dir / f"{PREFIX}-horizons.csv"
  304. monthly_path = args.output_dir / f"{PREFIX}-monthly.csv"
  305. report_path = args.output_dir / f"{PREFIX}-report.md"
  306. paths = [total_path, top_path, horizon_path, monthly_path, report_path]
  307. total.to_csv(total_path, index=False)
  308. top.to_csv(top_path, index=False)
  309. horizons.to_csv(horizon_path, index=False)
  310. monthly.to_csv(monthly_path, index=False)
  311. command = (
  312. "rtk .venv/bin/python scripts/refine_expansion_rotation_risk.py"
  313. f" --years {args.years} --top {args.top} --base-limit {args.base_limit}"
  314. )
  315. report_path.write_text(report_text(command, paths, top, horizons, monthly), encoding="utf-8")
  316. print(top.head(10).to_string(index=False))
  317. return 0
  318. if __name__ == "__main__":
  319. raise SystemExit(main())