search_short_bias_overlay.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. from __future__ import annotations
  2. import argparse
  3. import sys
  4. from dataclasses import dataclass
  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 rotation_risk
  10. from scripts import search_expansion_rotation as rotation
  11. OUTPUT_DIR = Path("reports/short-bias")
  12. PREFIX = "overlay"
  13. SYMBOLS = ("BTC-USDT-SWAP", "ETH-USDT-SWAP")
  14. INITIAL_EQUITY = 10_000.0
  15. TAKER_FEE = 0.0004
  16. HORIZONS = (
  17. ("full", None),
  18. ("3y", pd.DateOffset(years=3)),
  19. ("1y", pd.DateOffset(years=1)),
  20. ("6m", pd.DateOffset(months=6)),
  21. ("3m", pd.DateOffset(months=3)),
  22. )
  23. @dataclass(frozen=True)
  24. class ShortParams:
  25. family: str
  26. bar: str
  27. trend: int
  28. lookback: int
  29. vol_lookback: int
  30. min_vol: float
  31. max_momentum: float
  32. drop: float
  33. @property
  34. def name(self) -> str:
  35. return (
  36. f"{self.family}-{self.bar}-tr{self.trend}-lb{self.lookback}"
  37. f"-vw{self.vol_lookback}-mv{self.min_vol:.4f}"
  38. f"-mm{self.max_momentum:.3f}-dp{self.drop:.3f}"
  39. )
  40. def build_short_params() -> list[ShortParams]:
  41. params: list[ShortParams] = []
  42. for bar in ("1h", "4h"):
  43. if bar == "1h":
  44. trends = (24 * 30, 24 * 60)
  45. lookbacks = (24 * 7, 24 * 14)
  46. vols = (24 * 14,)
  47. min_vols = (0.010, 0.014, 0.018)
  48. drops = (0.025, 0.040)
  49. else:
  50. trends = (6 * 30, 6 * 60)
  51. lookbacks = (6 * 7, 6 * 14)
  52. vols = (6 * 14,)
  53. min_vols = (0.020, 0.028, 0.036)
  54. drops = (0.030, 0.050)
  55. for family, trend, lookback, vol_lookback, min_vol, max_momentum, drop in product(
  56. ("risk_off_pair", "trend_breakdown", "high_vol_breakdown"),
  57. trends,
  58. lookbacks,
  59. vols,
  60. min_vols,
  61. (-0.005, 0.000),
  62. drops,
  63. ):
  64. params.append(ShortParams(family, bar, trend, lookback, vol_lookback, min_vol, max_momentum, drop))
  65. return params
  66. def short_weights(closes: pd.DataFrame, params: ShortParams) -> pd.DataFrame:
  67. returns = closes.pct_change()
  68. momentum = closes / closes.shift(params.lookback) - 1.0
  69. trend = closes.rolling(params.trend).mean()
  70. vol = returns.rolling(params.vol_lookback).std(ddof=1) * (365 * {"1h": 24, "4h": 6}[params.bar]) ** 0.5
  71. btc = "BTC-USDT-SWAP"
  72. weights = pd.DataFrame(0.0, index=closes.index, columns=closes.columns)
  73. if params.family == "risk_off_pair":
  74. state = (
  75. (closes[btc] < trend[btc])
  76. & (momentum[btc] <= params.max_momentum)
  77. & (vol[btc] >= params.min_vol)
  78. )
  79. weights.loc[state, list(SYMBOLS)] = -0.5
  80. elif params.family == "trend_breakdown":
  81. eligible = (closes < trend) & (momentum <= params.max_momentum) & (vol >= params.min_vol)
  82. counts = eligible[list(SYMBOLS)].sum(axis=1)
  83. for symbol in SYMBOLS:
  84. weights.loc[eligible[symbol] & (counts > 0), symbol] = -1.0 / counts[eligible[symbol] & (counts > 0)]
  85. elif params.family == "high_vol_breakdown":
  86. drawdown = closes / closes.shift(params.lookback) - 1.0
  87. state = (
  88. (closes[btc] < trend[btc])
  89. & (drawdown[btc] <= -params.drop)
  90. & (vol[btc] >= params.min_vol)
  91. )
  92. eligible = (closes < trend) & (drawdown <= 0.0)
  93. counts = eligible[list(SYMBOLS)].sum(axis=1)
  94. for symbol in SYMBOLS:
  95. mask = state & eligible[symbol] & (counts > 0)
  96. weights.loc[mask, symbol] = -1.0 / counts[mask]
  97. else:
  98. raise ValueError(f"unknown family {params.family}")
  99. return weights.fillna(0.0)
  100. def equity_from_weights(closes: pd.DataFrame, weights: pd.DataFrame) -> pd.Series:
  101. returns = closes.pct_change().fillna(0.0)
  102. executed = weights.shift(1).fillna(0.0)
  103. turnover = executed.diff().abs().sum(axis=1).fillna(executed.abs().sum(axis=1))
  104. net_returns = (executed * returns).sum(axis=1) - turnover * TAKER_FEE
  105. equity = INITIAL_EQUITY * (1.0 + net_returns).cumprod()
  106. equity.name = "equity"
  107. return equity
  108. def metrics(series: pd.Series) -> dict[str, float]:
  109. years = (series.index[-1] - series.index[0]).total_seconds() / 86_400 / 365
  110. total = float(series.iloc[-1] / series.iloc[0] - 1.0)
  111. annualized = (1.0 + total) ** (1.0 / years) - 1.0 if total > -1.0 and years > 0.0 else 0.0
  112. drawdown = float((series.cummax() - series).div(series.cummax()).max())
  113. return {
  114. "total_return": total,
  115. "annualized_return": annualized,
  116. "max_drawdown": drawdown,
  117. "calmar": annualized / drawdown if drawdown else 0.0,
  118. }
  119. def horizon_rows(name: str, kind: str, series: pd.Series) -> list[dict[str, object]]:
  120. rows: list[dict[str, object]] = []
  121. end = series.index[-1]
  122. for label, offset in HORIZONS:
  123. horizon = series if offset is None else series[series.index >= end - offset]
  124. if len(horizon) < 2:
  125. horizon = series
  126. rows.append(
  127. {
  128. "name": name,
  129. "kind": kind,
  130. "horizon": label,
  131. "start": horizon.index[0].strftime("%Y-%m-%d"),
  132. "end": horizon.index[-1].strftime("%Y-%m-%d"),
  133. **metrics(horizon),
  134. }
  135. )
  136. return rows
  137. def horizon_return_fields(series: pd.Series) -> dict[str, float]:
  138. rows = {row["horizon"]: row for row in horizon_rows("", "", series)}
  139. return {
  140. "h3y_return": float(rows["3y"]["total_return"]),
  141. "h1y_return": float(rows["1y"]["total_return"]),
  142. "h6m_return": float(rows["6m"]["total_return"]),
  143. "h3m_return": float(rows["3m"]["total_return"]),
  144. }
  145. def monthly_rows(name: str, kind: str, series: pd.Series) -> pd.DataFrame:
  146. monthly = series.resample("ME").last()
  147. frame = pd.DataFrame(
  148. {
  149. "name": name,
  150. "kind": kind,
  151. "month": monthly.index.strftime("%Y-%m"),
  152. "start_equity": monthly.shift(1).fillna(series.iloc[0]).to_numpy(),
  153. "end_equity": monthly.to_numpy(),
  154. }
  155. )
  156. frame["return"] = frame["end_equity"] / frame["start_equity"] - 1.0
  157. return frame
  158. def worst_rolling_rows(name: str, kind: str, monthly: pd.DataFrame) -> list[dict[str, object]]:
  159. rows: list[dict[str, object]] = []
  160. returns = monthly.set_index("month")["return"]
  161. for window in (6, 12):
  162. rolling = (1.0 + returns).rolling(window).apply(lambda values: float(values.prod() - 1.0), raw=True)
  163. worst_end = rolling.idxmin()
  164. rows.append(
  165. {
  166. "name": name,
  167. "kind": kind,
  168. "window_months": window,
  169. "end_month": worst_end,
  170. "return": float(rolling.loc[worst_end]),
  171. }
  172. )
  173. return rows
  174. def trade_stats(weights: pd.DataFrame, closes: pd.DataFrame) -> dict[str, float | int]:
  175. executed = weights.shift(1).fillna(0.0)
  176. returns = closes.pct_change().fillna(0.0)
  177. turnover = executed.diff().abs().fillna(executed.abs())
  178. trades = 0
  179. wins = 0
  180. gross_profit = 0.0
  181. gross_loss = 0.0
  182. for symbol in SYMBOLS:
  183. active = executed[symbol] < 0.0
  184. groups = (active != active.shift(1)).cumsum()
  185. for _, mask in active.groupby(groups):
  186. if not bool(mask.iloc[0]):
  187. continue
  188. index = mask.index
  189. net = returns.loc[index, symbol] * executed.loc[index, symbol] - turnover.loc[index, symbol] * TAKER_FEE
  190. value = float((1.0 + net).prod() - 1.0)
  191. trades += 1
  192. if value > 0.0:
  193. wins += 1
  194. gross_profit += value
  195. else:
  196. gross_loss += abs(value)
  197. return {
  198. "trades": trades,
  199. "win_rate": wins / trades if trades else 0.0,
  200. "profit_factor": gross_profit / gross_loss if gross_loss else 0.0,
  201. }
  202. def rotation_risk_equity(years: float) -> tuple[str, pd.Series]:
  203. top = pd.read_csv(rotation_risk.OUTPUT_DIR / "rotation-risk-top.csv")
  204. row = top.iloc[0]
  205. base = rotation_risk.params_from_row(row)
  206. params = rotation_risk.RiskParams(
  207. base=base,
  208. leverage=float(row["leverage"]),
  209. exposure=float(row["exposure"]),
  210. vol_target=float(row["vol_target"]),
  211. )
  212. frames = rotation.load_symbol_bar_frames(years)
  213. closes = rotation.aligned_closes(frames, base)
  214. weights = rotation_risk.apply_risk_controls(closes, rotation.target_weights(closes, base), params)
  215. return str(row["strategy"]), rotation_risk.equity_curve(closes, weights, params)
  216. def combined_equity(rotation_equity: pd.Series, short_equity: pd.Series, short_alloc: float) -> pd.Series:
  217. returns = pd.DataFrame(
  218. {
  219. "rotation": rotation_equity.pct_change().fillna(0.0),
  220. "short": short_equity.pct_change().fillna(0.0),
  221. }
  222. ).dropna()
  223. combo_returns = (1.0 - short_alloc) * returns["rotation"] + short_alloc * returns["short"]
  224. equity = INITIAL_EQUITY * (1.0 + combo_returns).cumprod()
  225. equity.name = "equity"
  226. return equity
  227. def format_cell(value: object) -> str:
  228. if isinstance(value, float):
  229. return f"{value:.6g}"
  230. return str(value).replace("|", "\\|")
  231. def markdown_table(frame: pd.DataFrame) -> str:
  232. rows = [list(frame.columns), ["---" for _ in frame.columns]]
  233. rows.extend(frame.astype(object).where(pd.notna(frame), "").values.tolist())
  234. return "\n".join("| " + " | ".join(format_cell(value) for value in row) + " |" for row in rows)
  235. def report_text(
  236. command: str,
  237. paths: list[Path],
  238. totals: pd.DataFrame,
  239. horizons: pd.DataFrame,
  240. monthly: pd.DataFrame,
  241. correlations: pd.DataFrame,
  242. worst: pd.DataFrame,
  243. ) -> str:
  244. selected = totals[totals["selected"] == "yes"].copy()
  245. best_short = selected[selected["kind"] == "short_leg"].head(1)
  246. best_combo = selected[selected["kind"] == "rotation_plus_short"].head(4)
  247. conclusion = "Not worth adding."
  248. if len(best_combo):
  249. row = best_combo.sort_values(["max_drawdown", "calmar"], ascending=[True, False]).iloc[0]
  250. conclusion = (
  251. "Worth only as a small protection leg"
  252. if float(row["max_drawdown_delta_vs_rotation"]) < 0.0 and float(row["annualized_return_delta_vs_rotation"]) > -0.03
  253. else "Not worth adding at the tested small weights"
  254. )
  255. focus_names = set(selected["name"])
  256. return "\n".join(
  257. [
  258. "# Short Bias Overlay",
  259. "",
  260. f"Run command: `{command}`",
  261. "",
  262. "Output files:",
  263. *[f"- `{path}`" for path in paths],
  264. "",
  265. "Scope: BTC/ETH short-only risk-state overlays from cached candles. Cost model is 0.04% one-way taker fee on absolute notional turnover.",
  266. "Combination test uses rotation-risk as the base and allocates 5%, 10%, or 15% of capital to the short leg.",
  267. "",
  268. f"Conclusion: {conclusion}.",
  269. "",
  270. "## Selected short leg",
  271. "",
  272. markdown_table(best_short),
  273. "",
  274. "## Selected rotation-risk combinations",
  275. "",
  276. markdown_table(best_combo),
  277. "",
  278. "## Horizons",
  279. "",
  280. markdown_table(horizons[horizons["name"].isin(focus_names)]),
  281. "",
  282. "## Monthly returns",
  283. "",
  284. markdown_table(monthly[monthly["name"].isin(focus_names)].tail(80)),
  285. "",
  286. "## Correlation",
  287. "",
  288. markdown_table(correlations),
  289. "",
  290. "## Worst rolling 6/12m",
  291. "",
  292. markdown_table(worst[worst["name"].isin(focus_names)]),
  293. "",
  294. ]
  295. )
  296. def main() -> int:
  297. parser = argparse.ArgumentParser()
  298. parser.add_argument("--years", type=float, default=8.0)
  299. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  300. parser.add_argument("--top", type=int, default=20)
  301. args = parser.parse_args()
  302. rotation_name, rotation_equity_raw = rotation_risk_equity(args.years)
  303. frames = rotation.load_symbol_bar_frames(args.years)
  304. short_results: list[dict[str, object]] = []
  305. short_equities: dict[str, pd.Series] = {}
  306. short_weights_by_name: dict[str, pd.DataFrame] = {}
  307. for param in build_short_params():
  308. closes = pd.DataFrame({symbol: frames[(symbol, param.bar)]["close"] for symbol in SYMBOLS}).dropna()
  309. weights = short_weights(closes, param)
  310. equity = equity_from_weights(closes, weights)
  311. short_equities[param.name] = equity
  312. short_weights_by_name[param.name] = weights
  313. horizon = {row["horizon"]: row for row in horizon_rows(param.name, "short_leg", equity)}
  314. years = (equity.index[-1] - equity.index[0]).total_seconds() / 86_400 / 365
  315. short_results.append(
  316. {
  317. "name": param.name,
  318. "kind": "short_leg",
  319. "family": param.family,
  320. "bar": param.bar,
  321. "allocation": 1.0,
  322. "years": years,
  323. "h3y_return": horizon["3y"]["total_return"],
  324. "h1y_return": horizon["1y"]["total_return"],
  325. "h6m_return": horizon["6m"]["total_return"],
  326. "h3m_return": horizon["3m"]["total_return"],
  327. "turnover_per_year": float(weights.diff().abs().sum(axis=1).sum() / years),
  328. **metrics(equity),
  329. **trade_stats(weights, closes),
  330. **param.__dict__,
  331. }
  332. )
  333. rotation_monthly = monthly_rows(rotation_name, "rotation_risk", rotation_equity_raw)
  334. rotation_total = {
  335. "name": rotation_name,
  336. "kind": "rotation_risk",
  337. "family": "rotation_risk",
  338. "bar": "",
  339. "allocation": 1.0,
  340. "years": (rotation_equity_raw.index[-1] - rotation_equity_raw.index[0]).total_seconds() / 86_400 / 365,
  341. **horizon_return_fields(rotation_equity_raw),
  342. **metrics(rotation_equity_raw),
  343. }
  344. rotation_metrics = metrics(rotation_equity_raw)
  345. ranked_short = pd.DataFrame(short_results).sort_values(
  346. ["h1y_return", "h6m_return", "h3m_return", "calmar", "total_return"],
  347. ascending=[False, False, False, False, False],
  348. )
  349. ranked_unique = ranked_short.drop_duplicates(
  350. subset=["total_return", "h3y_return", "h1y_return", "h6m_return", "h3m_return", "max_drawdown"]
  351. )
  352. candidates = ranked_unique.head(args.top)
  353. totals: list[dict[str, object]] = [rotation_total]
  354. totals.extend(short_results)
  355. horizon_output = horizon_rows(rotation_name, "rotation_risk", rotation_equity_raw)
  356. monthly_frames = [rotation_monthly]
  357. worst_rows = worst_rolling_rows(rotation_name, "rotation_risk", rotation_monthly)
  358. correlations: list[dict[str, object]] = []
  359. rotation_daily = rotation_equity_raw.resample("1D").last().ffill()
  360. for _, short_row in candidates.iterrows():
  361. short_name = str(short_row["name"])
  362. short_daily = short_equities[short_name].resample("1D").last().ffill()
  363. aligned = pd.concat([rotation_daily, short_daily], axis=1, join="inner")
  364. aligned.columns = ["rotation", "short"]
  365. correlations.append(
  366. {
  367. "name": short_name,
  368. "kind": "short_leg",
  369. "correlation_to_rotation_risk": float(aligned.pct_change().dropna().corr().loc["rotation", "short"]),
  370. }
  371. )
  372. horizon_output.extend(horizon_rows(short_name, "short_leg", short_daily))
  373. short_monthly = monthly_rows(short_name, "short_leg", short_daily)
  374. monthly_frames.append(short_monthly)
  375. worst_rows.extend(worst_rolling_rows(short_name, "short_leg", short_monthly))
  376. for allocation in (0.05, 0.10, 0.15):
  377. combo_name = f"{rotation_name}+{allocation:.0%}-{short_name}"
  378. combo = combined_equity(rotation_daily, short_daily, allocation)
  379. combo_metrics = metrics(combo)
  380. combo_monthly = monthly_rows(combo_name, "rotation_plus_short", combo)
  381. totals.append(
  382. {
  383. "name": combo_name,
  384. "kind": "rotation_plus_short",
  385. "family": str(short_row["family"]),
  386. "bar": str(short_row["bar"]),
  387. "allocation": allocation,
  388. "years": (combo.index[-1] - combo.index[0]).total_seconds() / 86_400 / 365,
  389. "short_leg": short_name,
  390. "annualized_return_delta_vs_rotation": combo_metrics["annualized_return"] - rotation_metrics["annualized_return"],
  391. "max_drawdown_delta_vs_rotation": combo_metrics["max_drawdown"] - rotation_metrics["max_drawdown"],
  392. **horizon_return_fields(combo),
  393. **combo_metrics,
  394. }
  395. )
  396. horizon_output.extend(horizon_rows(combo_name, "rotation_plus_short", combo))
  397. monthly_frames.append(combo_monthly)
  398. worst_rows.extend(worst_rolling_rows(combo_name, "rotation_plus_short", combo_monthly))
  399. aligned_combo = pd.concat([rotation_daily, combo], axis=1, join="inner")
  400. aligned_combo.columns = ["rotation", "combo"]
  401. correlations.append(
  402. {
  403. "name": combo_name,
  404. "kind": "rotation_plus_short",
  405. "correlation_to_rotation_risk": float(aligned_combo.pct_change().dropna().corr().loc["rotation", "combo"]),
  406. }
  407. )
  408. total = pd.DataFrame(totals)
  409. combos = total[total["kind"] == "rotation_plus_short"].copy()
  410. selected_combo = combos[
  411. (combos["max_drawdown_delta_vs_rotation"] < 0.0)
  412. & (combos["annualized_return_delta_vs_rotation"] > -0.03)
  413. ].sort_values(["max_drawdown", "calmar"], ascending=[True, False]).head(4)
  414. selected_short_names = set(selected_combo["short_leg"]) if len(selected_combo) else set(candidates.head(1)["name"])
  415. total["selected"] = "no"
  416. total.loc[total["name"].isin(selected_short_names), "selected"] = "yes"
  417. total.loc[total["name"].isin(set(selected_combo["name"])), "selected"] = "yes"
  418. total.loc[total["name"] == rotation_name, "selected"] = "yes"
  419. horizons = pd.DataFrame(horizon_output)
  420. monthly = pd.concat(monthly_frames, ignore_index=True)
  421. correlation_frame = pd.DataFrame(correlations)
  422. worst = pd.DataFrame(worst_rows)
  423. args.output_dir.mkdir(parents=True, exist_ok=True)
  424. total_path = args.output_dir / f"{PREFIX}-total.csv"
  425. short_path = args.output_dir / f"{PREFIX}-short-leg.csv"
  426. combo_path = args.output_dir / f"{PREFIX}-rotation-combo.csv"
  427. horizon_path = args.output_dir / f"{PREFIX}-horizons.csv"
  428. monthly_path = args.output_dir / f"{PREFIX}-monthly.csv"
  429. correlation_path = args.output_dir / f"{PREFIX}-correlation.csv"
  430. worst_path = args.output_dir / f"{PREFIX}-worst-rolling.csv"
  431. report_path = args.output_dir / f"{PREFIX}-report.md"
  432. paths = [total_path, short_path, combo_path, horizon_path, monthly_path, correlation_path, worst_path, report_path]
  433. total.to_csv(total_path, index=False)
  434. ranked_short.to_csv(short_path, index=False)
  435. combos.sort_values(["max_drawdown", "calmar"], ascending=[True, False]).to_csv(combo_path, index=False)
  436. horizons.to_csv(horizon_path, index=False)
  437. monthly.to_csv(monthly_path, index=False)
  438. correlation_frame.to_csv(correlation_path, index=False)
  439. worst.to_csv(worst_path, index=False)
  440. command = "rtk .venv/bin/python " + " ".join(sys.argv)
  441. report_path.write_text(report_text(command, paths, total, horizons, monthly, correlation_frame, worst), encoding="utf-8")
  442. print(total[total["selected"] == "yes"].to_string(index=False))
  443. return 0
  444. if __name__ == "__main__":
  445. raise SystemExit(main())