stress_short_bias_swing.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. from __future__ import annotations
  2. import argparse
  3. import json
  4. import sys
  5. from dataclasses import dataclass
  6. from pathlib import Path
  7. import pandas as pd
  8. sys.path.append(str(Path(__file__).resolve().parent))
  9. import search_short_bias_swing as base
  10. PREFIX = "swing-stress"
  11. FEES = (0.0004, 0.0008, 0.0010)
  12. SLIPPAGES = (0.0, 0.0005, 0.0010)
  13. FUNDING_8H = (-0.00005, 0.0, 0.00005)
  14. FOCUS_FAMILY = "vol_expansion_short"
  15. FOCUS_BAR = "4H"
  16. @dataclass(frozen=True)
  17. class Stress:
  18. fee_single_side: float
  19. slippage: float
  20. funding_8h: float
  21. @property
  22. def label(self) -> str:
  23. return f"fee{self.fee_single_side:.4f}-slip{self.slippage:.4f}-funding{self.funding_8h:.5f}"
  24. def funding_events(entry_time: pd.Timestamp, exit_time: pd.Timestamp) -> int:
  25. hours = (exit_time - entry_time).total_seconds() / 3600
  26. return int(hours // 8)
  27. def close_short(entry_price: float, exit_price: float, entry_time: pd.Timestamp, exit_time: pd.Timestamp, equity: float, stress: Stress) -> tuple[float, float, float]:
  28. funding_return = funding_events(entry_time, exit_time) * stress.funding_8h
  29. net_return = entry_price / exit_price - 1.0 - stress.fee_single_side * 2 + funding_return
  30. return max(0.0, equity * (1.0 + net_return)), net_return, funding_return
  31. def run_strategy(strategy: base.Strategy, frame: pd.DataFrame, btc_frame: pd.DataFrame | None, stress: Stress) -> dict[str, object]:
  32. signals = base.signal_frame(strategy, frame, btc_frame)
  33. warmup = max(int(strategy.params.get(key, 0)) for key in ("slow", "entry", "exit", "atr", "vol_window", "btc_slow")) + 2
  34. equity = base.INITIAL_EQUITY
  35. position: dict[str, float | int | pd.Timestamp] | None = None
  36. pending_entry = False
  37. pending_exit = False
  38. trades: list[dict[str, object]] = []
  39. equity_curve: list[dict[str, object]] = []
  40. rows = list(frame.itertuples())
  41. for index in range(warmup, len(rows)):
  42. row = rows[index]
  43. ts = frame.index[index]
  44. if pending_exit and position is not None:
  45. exit_price = float(row.open) * (1.0 + stress.slippage)
  46. equity, net_return, funding_return = close_short(
  47. float(position["entry_price"]),
  48. exit_price,
  49. pd.Timestamp(position["entry_time"]),
  50. ts,
  51. equity,
  52. stress,
  53. )
  54. trades.append(
  55. {
  56. "side": "Short",
  57. "entry_time": pd.Timestamp(position["entry_time"]).strftime("%Y-%m-%d %H:%M"),
  58. "exit_time": ts.strftime("%Y-%m-%d %H:%M"),
  59. "entry_price": float(position["entry_price"]),
  60. "exit_price": exit_price,
  61. "return": net_return,
  62. "funding_return": funding_return,
  63. "hold_bars": index - int(position["entry_index"]),
  64. }
  65. )
  66. position = None
  67. pending_exit = False
  68. if pending_entry and position is None and equity > 0.0:
  69. atr = float(signals["atr"].iloc[index - 1])
  70. entry_price = float(row.open) * (1.0 - stress.slippage)
  71. position = {
  72. "entry_time": ts,
  73. "entry_index": index,
  74. "entry_price": entry_price,
  75. "stop_price": entry_price + atr * float(strategy.params["stop_atr"]),
  76. "take_price": max(0.01, entry_price - atr * float(strategy.params["take_atr"])),
  77. }
  78. pending_entry = False
  79. mark_equity = equity
  80. if position is not None:
  81. stop_hit = float(row.high) >= float(position["stop_price"])
  82. take_hit = float(row.low) <= float(position["take_price"])
  83. if stop_hit or take_hit:
  84. raw_exit = float(position["stop_price"] if stop_hit else position["take_price"])
  85. exit_price = raw_exit * (1.0 + stress.slippage)
  86. equity, net_return, funding_return = close_short(
  87. float(position["entry_price"]),
  88. exit_price,
  89. pd.Timestamp(position["entry_time"]),
  90. ts,
  91. equity,
  92. stress,
  93. )
  94. trades.append(
  95. {
  96. "side": "Short",
  97. "entry_time": pd.Timestamp(position["entry_time"]).strftime("%Y-%m-%d %H:%M"),
  98. "exit_time": ts.strftime("%Y-%m-%d %H:%M"),
  99. "entry_price": float(position["entry_price"]),
  100. "exit_price": exit_price,
  101. "return": net_return,
  102. "funding_return": funding_return,
  103. "hold_bars": index - int(position["entry_index"]),
  104. }
  105. )
  106. position = None
  107. mark_equity = equity
  108. if position is not None:
  109. funding_return = funding_events(pd.Timestamp(position["entry_time"]), ts) * stress.funding_8h
  110. gross_return = float(position["entry_price"]) / float(row.close) - 1.0
  111. mark_equity = max(0.0, equity * (1.0 + gross_return - stress.fee_single_side + funding_return))
  112. equity_curve.append({"ts": ts, "equity": mark_equity})
  113. if index == len(rows) - 1 or equity <= 0.0:
  114. continue
  115. if position is not None:
  116. held = index - int(position["entry_index"])
  117. if bool(signals["exit"].iloc[index]) or held >= int(strategy.params["max_hold"]):
  118. pending_exit = True
  119. elif bool(signals["entry"].iloc[index]):
  120. pending_entry = True
  121. if position is not None:
  122. last = rows[-1]
  123. ts = frame.index[-1]
  124. exit_price = float(last.close) * (1.0 + stress.slippage)
  125. equity, net_return, funding_return = close_short(
  126. float(position["entry_price"]),
  127. exit_price,
  128. pd.Timestamp(position["entry_time"]),
  129. ts,
  130. equity,
  131. stress,
  132. )
  133. trades.append(
  134. {
  135. "side": "Short",
  136. "entry_time": pd.Timestamp(position["entry_time"]).strftime("%Y-%m-%d %H:%M"),
  137. "exit_time": ts.strftime("%Y-%m-%d %H:%M"),
  138. "entry_price": float(position["entry_price"]),
  139. "exit_price": exit_price,
  140. "return": net_return,
  141. "funding_return": funding_return,
  142. "hold_bars": len(rows) - 1 - int(position["entry_index"]),
  143. }
  144. )
  145. equity_curve.append({"ts": ts, "equity": equity})
  146. return {"trades": trades, "equity_curve": equity_curve}
  147. def load_candidate_names(path: Path) -> set[str]:
  148. candidates = pd.read_csv(path)
  149. if candidates.empty:
  150. raise ValueError(f"no candidates in {path}")
  151. return set(candidates["name"].astype(str))
  152. def format_cell(value: object) -> str:
  153. if isinstance(value, float):
  154. return f"{value:.6g}"
  155. return str(value).replace("|", "\\|")
  156. def markdown_table(frame: pd.DataFrame) -> str:
  157. rows = [list(frame.columns), ["---" for _ in frame.columns]]
  158. rows.extend(frame.astype(object).where(pd.notna(frame), "").values.tolist())
  159. return "\n".join("| " + " | ".join(format_cell(value) for value in row) + " |" for row in rows)
  160. def focus_decision(focus: pd.DataFrame) -> str:
  161. if focus.empty:
  162. return "No. BTC/ETH 4H vol_expansion_short was not present in the stressed candidate set."
  163. worst = focus.sort_values("worst_stress_total_return").iloc[0]
  164. both_positive = bool((focus["worst_stress_total_return"] > 0.0).all() and (focus["worst_stress_return_1y"] > 0.0).all())
  165. if both_positive:
  166. return (
  167. "Yes, still worth continuing as a research candidate. "
  168. f"The weakest focused variant is {worst['name']} with worst stressed total return {worst['worst_stress_total_return']:.2%} "
  169. f"and worst stressed 1y return {worst['worst_stress_return_1y']:.2%}."
  170. )
  171. return (
  172. "No for live deployment; keep only as a research candidate. "
  173. f"The weakest focused variant is {worst['name']} with worst stressed total return {worst['worst_stress_total_return']:.2%} "
  174. f"and worst stressed 1y return {worst['worst_stress_return_1y']:.2%}."
  175. )
  176. def markdown_report(command: str, paths: list[Path], totals: pd.DataFrame, focus: pd.DataFrame, summary: dict[str, object]) -> str:
  177. worst = totals.sort_values(["total_return", "return_1y"]).head(10)
  178. focus_view = focus.sort_values(["symbol", "fast", "slow", "stop_atr", "take_atr"])
  179. lines = [
  180. "# Short-Bias Swing Stress Test",
  181. "",
  182. f"Run command: `{command}`",
  183. "",
  184. "Output files:",
  185. *[f"- `{path}`" for path in paths],
  186. "",
  187. "Scope: strategies from `reports/short-bias/swing-qualified.csv`; original search script was imported but not modified.",
  188. "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%. Positive funding is short receive; negative funding is short pay.",
  189. "",
  190. f"Conclusion: {summary['decision']}",
  191. "",
  192. "## BTC/ETH 4H vol_expansion_short robustness",
  193. "",
  194. markdown_table(
  195. focus_view[
  196. [
  197. "name",
  198. "symbol",
  199. "stress_cases",
  200. "positive_cases",
  201. "worst_stress_total_return",
  202. "worst_stress_max_drawdown",
  203. "worst_stress_calmar",
  204. "worst_stress_return_3y",
  205. "worst_stress_return_1y",
  206. "worst_stress_return_6m",
  207. "worst_stress_return_3m",
  208. "worst_stress_fee_single_side",
  209. "worst_stress_slippage",
  210. "worst_stress_funding_8h",
  211. "trades",
  212. ]
  213. ]
  214. ),
  215. "",
  216. "## Worst stressed cases",
  217. "",
  218. markdown_table(
  219. worst[
  220. [
  221. "name",
  222. "symbol",
  223. "bar",
  224. "family",
  225. "fee_single_side",
  226. "slippage",
  227. "funding_8h",
  228. "total_return",
  229. "annualized_return",
  230. "max_drawdown",
  231. "calmar",
  232. "trades",
  233. "profit_factor",
  234. "return_3y",
  235. "return_1y",
  236. "return_6m",
  237. "return_3m",
  238. "total_funding_return",
  239. ]
  240. ]
  241. ),
  242. ]
  243. return "\n".join(lines) + "\n"
  244. def main() -> int:
  245. parser = argparse.ArgumentParser()
  246. parser.add_argument("--years", type=float, default=base.YEARS)
  247. parser.add_argument("--candidates", type=Path, default=base.OUTPUT_DIR / "swing-qualified.csv")
  248. parser.add_argument("--output-dir", type=Path, default=base.OUTPUT_DIR)
  249. args = parser.parse_args()
  250. candidate_names = load_candidate_names(args.candidates)
  251. strategies = [strategy for strategy in base.build_strategies() if strategy.name in candidate_names]
  252. if not strategies:
  253. raise ValueError("no matching built-in strategies for candidate file")
  254. raw = {symbol: base.load_15m_frame(symbol, args.years) for symbol in base.SYMBOLS}
  255. data = {(symbol, bar): base.resample_frame(raw[symbol], bar) for symbol in base.SYMBOLS for bar in base.BARS}
  256. stresses = [Stress(fee, slippage, funding) for fee in FEES for slippage in SLIPPAGES for funding in FUNDING_8H]
  257. rows: list[dict[str, object]] = []
  258. for strategy_index, strategy in enumerate(strategies, start=1):
  259. btc_frame = data[("BTC-USDT-SWAP", strategy.bar)] if strategy.family == "btc_riskoff_eth" else None
  260. frame = data[(strategy.symbol, strategy.bar)]
  261. for stress in stresses:
  262. result = run_strategy(strategy, frame, btc_frame, stress)
  263. series = base.daily_equity(result)
  264. trades = list(result["trades"])
  265. rows.append(
  266. {
  267. "name": strategy.name,
  268. "symbol": strategy.symbol,
  269. "bar": strategy.bar,
  270. "family": strategy.family,
  271. "first_day": series.index[0].strftime("%Y-%m-%d"),
  272. "last_day": series.index[-1].strftime("%Y-%m-%d"),
  273. "fee_single_side": stress.fee_single_side,
  274. "slippage": stress.slippage,
  275. "funding_8h": stress.funding_8h,
  276. "total_funding_return": sum(float(trade["funding_return"]) for trade in trades),
  277. **base.equity_metrics(series),
  278. **base.trade_metrics(trades),
  279. **base.horizon_returns(series),
  280. **strategy.params,
  281. }
  282. )
  283. print(f"done {strategy_index}/{len(strategies)} {strategy.name}", flush=True)
  284. totals = pd.DataFrame(rows).sort_values(["name", "fee_single_side", "slippage", "funding_8h"])
  285. grouped = totals.groupby("name", as_index=False)
  286. robustness = grouped.agg(
  287. symbol=("symbol", "first"),
  288. bar=("bar", "first"),
  289. family=("family", "first"),
  290. stress_cases=("total_return", "size"),
  291. positive_cases=("total_return", lambda values: int((values > 0.0).sum())),
  292. worst_stress_total_return=("total_return", "min"),
  293. worst_stress_max_drawdown=("max_drawdown", "max"),
  294. worst_stress_calmar=("calmar", "min"),
  295. worst_stress_return_3y=("return_3y", "min"),
  296. worst_stress_return_1y=("return_1y", "min"),
  297. worst_stress_return_6m=("return_6m", "min"),
  298. worst_stress_return_3m=("return_3m", "min"),
  299. trades=("trades", "first"),
  300. )
  301. params = totals.drop_duplicates("name")[["name", "fast", "slow", "stop_atr", "take_atr"]]
  302. worst_cases = totals.loc[totals.groupby("name")["total_return"].idxmin()][["name", "fee_single_side", "slippage", "funding_8h"]]
  303. robustness = robustness.merge(params, on="name", how="left").merge(worst_cases, on="name", how="left", suffixes=("", "_worst"))
  304. robustness = robustness.rename(
  305. columns={
  306. "fee_single_side": "worst_stress_fee_single_side",
  307. "slippage": "worst_stress_slippage",
  308. "funding_8h": "worst_stress_funding_8h",
  309. }
  310. ).sort_values(["worst_stress_total_return", "worst_stress_return_1y"], ascending=[False, False])
  311. focus = robustness[
  312. (robustness["family"] == FOCUS_FAMILY)
  313. & (robustness["bar"] == FOCUS_BAR)
  314. & (robustness["symbol"].isin(["BTC-USDT-SWAP", "ETH-USDT-SWAP"]))
  315. ].copy()
  316. decision = focus_decision(focus)
  317. args.output_dir.mkdir(parents=True, exist_ok=True)
  318. totals_path = args.output_dir / f"{PREFIX}-totals.csv"
  319. robustness_path = args.output_dir / f"{PREFIX}-robustness.csv"
  320. focus_path = args.output_dir / f"{PREFIX}-vol-expansion-4h.csv"
  321. summary_path = args.output_dir / f"{PREFIX}-summary.json"
  322. report_path = args.output_dir / f"{PREFIX}-report.md"
  323. paths = [totals_path, robustness_path, focus_path, summary_path, report_path]
  324. totals.to_csv(totals_path, index=False)
  325. robustness.to_csv(robustness_path, index=False)
  326. focus.to_csv(focus_path, index=False)
  327. summary: dict[str, object] = {
  328. "years_requested": args.years,
  329. "candidate_file": str(args.candidates),
  330. "strategy_count": len(strategies),
  331. "stress_case_count": len(stresses),
  332. "row_count": len(totals),
  333. "focus_count": len(focus),
  334. "decision": decision,
  335. "output_files": [str(path) for path in paths],
  336. }
  337. summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
  338. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --years {args.years}"
  339. report_path.write_text(markdown_report(command, paths, totals, focus, summary), encoding="utf-8")
  340. print(focus.to_string(index=False, formatters={col: format_cell for col in focus.columns}))
  341. print(f"wrote={report_path}")
  342. return 0
  343. if __name__ == "__main__":
  344. raise SystemExit(main())