generate_ultrashort_report.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551
  1. from __future__ import annotations
  2. import importlib.util
  3. import sys
  4. from dataclasses import dataclass
  5. from html import escape
  6. from pathlib import Path
  7. import pandas as pd
  8. ROUNDTRIP_COST_ON_MARGIN = 0.0021
  9. COST_SCENARIOS = (
  10. ("maker_maker", 0.0012),
  11. ("maker_taker", 0.0021),
  12. ("taker_taker", 0.0030),
  13. )
  14. REPORT_FILE = Path("ultrashort-recent-report.html")
  15. REPORT_DIR = Path("reports/ultrashort")
  16. HORIZONS = (
  17. ("3y", pd.DateOffset(years=3)),
  18. ("1y", pd.DateOffset(years=1)),
  19. ("6m", pd.DateOffset(months=6)),
  20. ("3m", pd.DateOffset(months=3)),
  21. )
  22. @dataclass(frozen=True)
  23. class StrategySpec:
  24. label: str
  25. symbol: str
  26. bar: str
  27. candidate: object
  28. is_pair: bool = False
  29. def load_explore_module():
  30. path = Path(__file__).resolve().with_name("explore_ultrashort.py")
  31. spec = importlib.util.spec_from_file_location("explore_ultrashort", path)
  32. if spec is None or spec.loader is None:
  33. raise RuntimeError("cannot load explore_ultrashort.py")
  34. module = importlib.util.module_from_spec(spec)
  35. sys.modules[spec.name] = module
  36. spec.loader.exec_module(module)
  37. return module
  38. def load_cached_history(explore, symbol: str, bar: str, requested_bars: int):
  39. candles, _ = explore.load_cached_candles(explore.CANDLE_CACHE_DIR, symbol, bar)
  40. if not candles:
  41. raise RuntimeError(f"missing cached candles: {symbol} {bar}")
  42. return candles[-requested_bars:] if len(candles) > requested_bars else candles
  43. def pct(value: float) -> str:
  44. return f"{value * 100:.2f}%"
  45. def money(value: float) -> str:
  46. return f"{value:,.0f}"
  47. def normalize_equity(frame: pd.DataFrame, cutoff: pd.Timestamp) -> pd.DataFrame:
  48. recent = frame[frame["ts"] >= cutoff][["ts", "equity"]].copy()
  49. if recent.empty:
  50. recent = frame[["ts", "equity"]].copy()
  51. recent["equity"] = recent["equity"] / float(recent["equity"].iloc[0]) * 10_000.0
  52. return recent
  53. def monthly_from_equity(frame: pd.DataFrame) -> pd.DataFrame:
  54. month_end = frame.set_index("ts")["equity"].resample("ME").last().ffill()
  55. month_start = month_end.shift(1)
  56. if len(month_end):
  57. month_start.iloc[0] = float(frame["equity"].iloc[0])
  58. monthly = pd.DataFrame(
  59. {
  60. "period": month_end.index.tz_localize(None).to_period("M").astype(str),
  61. "return": month_end.to_numpy() / month_start.to_numpy() - 1.0,
  62. "end_equity": month_end.to_numpy(),
  63. }
  64. )
  65. return monthly
  66. def combine_equities(frames: list[pd.DataFrame]) -> pd.DataFrame:
  67. combined = pd.concat(
  68. [
  69. frame.set_index("ts")["equity"].rename(str(index))
  70. for index, frame in enumerate(frames)
  71. ],
  72. axis=1,
  73. sort=True,
  74. ).sort_index().ffill().dropna()
  75. return pd.DataFrame({"ts": combined.index, "equity": combined.sum(axis=1).to_numpy()})
  76. def daily_return_frame(equities: dict[str, pd.DataFrame]) -> pd.DataFrame:
  77. combined = pd.concat(
  78. [
  79. frame.set_index("ts")["equity"].rename(name)
  80. for name, frame in equities.items()
  81. ],
  82. axis=1,
  83. sort=True,
  84. ).sort_index().ffill().dropna()
  85. return combined.pct_change().dropna()
  86. def apply_drawdown_overlay(frame: pd.DataFrame, threshold: float, reduced_exposure: float) -> pd.DataFrame:
  87. raw = frame.sort_values("ts").reset_index(drop=True)
  88. values = [float(raw["equity"].iloc[0])]
  89. raw_peak = float(raw["equity"].iloc[0])
  90. for index in range(1, len(raw)):
  91. previous_raw_equity = float(raw["equity"].iloc[index - 1])
  92. raw_peak = max(raw_peak, previous_raw_equity)
  93. previous_drawdown = raw_peak / previous_raw_equity - 1.0 if previous_raw_equity else 0.0
  94. exposure = reduced_exposure if previous_drawdown >= threshold else 1.0
  95. raw_return = float(raw["equity"].iloc[index]) / previous_raw_equity - 1.0
  96. values.append(values[-1] * (1.0 + raw_return * exposure))
  97. return pd.DataFrame({"ts": raw["ts"], "equity": values})
  98. def yearly_from_equity(label: str, frame: pd.DataFrame) -> list[dict[str, object]]:
  99. year_end = frame.set_index("ts")["equity"].resample("YE").last().ffill()
  100. year_start = year_end.shift(1)
  101. if len(year_end):
  102. year_start.iloc[0] = float(frame["equity"].iloc[0])
  103. return [
  104. {
  105. "name": label,
  106. "year": str(index.year),
  107. "return": pct(float(end / start - 1.0)),
  108. "end_equity": money(float(end)),
  109. }
  110. for index, start, end in zip(year_end.index, year_start.to_numpy(), year_end.to_numpy())
  111. ]
  112. def horizon_rows(explore, label: str, kind: str, frame: pd.DataFrame) -> list[dict[str, object]]:
  113. last_ts = int(frame["ts"].iloc[-1].timestamp() * 1000)
  114. horizons = explore.recent_horizon_metrics_from_equity(frame, last_ts, HORIZONS)
  115. return [
  116. {
  117. kind: label,
  118. "horizon": row["horizon"],
  119. "return": pct(float(row["net_total_return"])),
  120. "annualized": pct(float(row["net_annualized_return"])),
  121. "max_dd": pct(float(row["net_max_drawdown"])),
  122. "calmar": f"{float(row['net_calmar']):.2f}",
  123. }
  124. for row in horizons.to_dict("records")
  125. ]
  126. def make_svg(curves: list[dict[str, object]]) -> str:
  127. width = 1200
  128. height = 460
  129. left = 58
  130. top = 28
  131. right = 24
  132. bottom = 44
  133. plot_width = width - left - right
  134. plot_height = height - top - bottom
  135. all_points = [point for curve in curves for point in curve["points"]]
  136. min_ts = min(ts for ts, _ in all_points)
  137. max_ts = max(ts for ts, _ in all_points)
  138. min_value = min(value for _, value in all_points)
  139. max_value = max(value for _, value in all_points)
  140. if max_value == min_value:
  141. max_value += 1.0
  142. def x(ts: float) -> float:
  143. return left + (ts - min_ts) / (max_ts - min_ts) * plot_width
  144. def y(value: float) -> float:
  145. return top + (max_value - value) / (max_value - min_value) * plot_height
  146. grid = []
  147. for i in range(5):
  148. yy = top + i * plot_height / 4
  149. value = max_value - i * (max_value - min_value) / 4
  150. grid.append(f'<line x1="{left}" y1="{yy:.1f}" x2="{width-right}" y2="{yy:.1f}" class="grid"/>')
  151. grid.append(f'<text x="8" y="{yy + 4:.1f}" class="axis">{money(value)}</text>')
  152. paths = []
  153. for curve in curves:
  154. points = " ".join(f"{x(ts):.1f},{y(value):.1f}" for ts, value in curve["points"])
  155. paths.append(f'<polyline points="{points}" fill="none" stroke="{curve["color"]}" stroke-width="2.4"/>')
  156. legend = []
  157. legend_x = left
  158. legend_y = height - 18
  159. for curve in curves:
  160. legend.append(f'<circle cx="{legend_x}" cy="{legend_y}" r="5" fill="{curve["color"]}"/>')
  161. legend.append(f'<text x="{legend_x + 10}" y="{legend_y + 4}" class="legend">{escape(str(curve["label"]))}</text>')
  162. legend_x += 170
  163. return f"""
  164. <svg viewBox="0 0 {width} {height}" role="img" aria-label="Recent equity curves">
  165. <style>
  166. .grid {{ stroke: #e5e7eb; stroke-width: 1; }}
  167. .axis {{ fill: #6b7280; font: 12px Inter, system-ui, sans-serif; }}
  168. .legend {{ fill: #111827; font: 12px Inter, system-ui, sans-serif; }}
  169. </style>
  170. <rect x="0" y="0" width="{width}" height="{height}" fill="#ffffff"/>
  171. {''.join(grid)}
  172. {''.join(paths)}
  173. {''.join(legend)}
  174. </svg>
  175. """
  176. def render_table(frame: pd.DataFrame, columns: list[str]) -> str:
  177. header = "".join(f"<th>{escape(column)}</th>" for column in columns)
  178. rows = []
  179. for row in frame.to_dict("records"):
  180. cells = "".join(f"<td>{escape(str(row[column]))}</td>" for column in columns)
  181. rows.append(f"<tr>{cells}</tr>")
  182. return f"<table><thead><tr>{header}</tr></thead><tbody>{''.join(rows)}</tbody></table>"
  183. def main() -> int:
  184. explore = load_explore_module()
  185. specs = [
  186. StrategySpec("BTC RSI2 Guarded 15m", "BTC-USDT-SWAP", "15m", explore.build_rsi2_long_guarded_candidate(240, 2.0, 55.0, 0.008, 48)),
  187. StrategySpec("BTC RSI2 TWAP2 15m", "BTC-USDT-SWAP", "15m", explore.build_rsi2_long_guarded_twap_candidate(240, 2.0, 55.0, 0.008, 48, 2)),
  188. StrategySpec("BTC RSI2 TWAP3 15m", "BTC-USDT-SWAP", "15m", explore.build_rsi2_long_guarded_twap_candidate(240, 2.0, 55.0, 0.008, 48, 3)),
  189. StrategySpec("BTC RSI2 Price TWAP shallow 15m", "BTC-USDT-SWAP", "15m", explore.build_rsi2_long_guarded_price_twap_candidate(240, 2.0, 55.0, 0.008, 48, (0.0005, 0.0015, 0.003), 2)),
  190. StrategySpec("BTC RSI2 Price TWAP mid 15m", "BTC-USDT-SWAP", "15m", explore.build_rsi2_long_guarded_price_twap_candidate(240, 2.0, 55.0, 0.008, 48, (0.001, 0.003, 0.005), 2)),
  191. StrategySpec("BTC RSI2 Price TWAP deep 15m", "BTC-USDT-SWAP", "15m", explore.build_rsi2_long_guarded_price_twap_candidate(240, 2.0, 55.0, 0.008, 48, (0.002, 0.005, 0.008), 3)),
  192. StrategySpec("BTC RSI2 Price TWAP 2slice 15m", "BTC-USDT-SWAP", "15m", explore.build_rsi2_long_guarded_price_twap_candidate(240, 2.0, 55.0, 0.008, 48, (0.001, 0.004), 2)),
  193. StrategySpec("BTC RSI2 Price TWAP mid fb2 15m", "BTC-USDT-SWAP", "15m", explore.build_rsi2_long_guarded_price_twap_candidate(240, 2.0, 55.0, 0.008, 48, (0.001, 0.003, 0.005), 2, 0.0002)),
  194. StrategySpec("BTC RSI2 Price TWAP mid fb5 15m", "BTC-USDT-SWAP", "15m", explore.build_rsi2_long_guarded_price_twap_candidate(240, 2.0, 55.0, 0.008, 48, (0.001, 0.003, 0.005), 2, 0.0005)),
  195. StrategySpec("BTC RSI2 Price TWAP deep fb2 15m", "BTC-USDT-SWAP", "15m", explore.build_rsi2_long_guarded_price_twap_candidate(240, 2.0, 55.0, 0.008, 48, (0.002, 0.005, 0.008), 3, 0.0002)),
  196. StrategySpec("BTC RSI2 Price TWAP deep fb5 15m", "BTC-USDT-SWAP", "15m", explore.build_rsi2_long_guarded_price_twap_candidate(240, 2.0, 55.0, 0.008, 48, (0.002, 0.005, 0.008), 3, 0.0005)),
  197. StrategySpec("BTC Trend RSI-BB 15m", "BTC-USDT-SWAP", "15m", explore.build_trend_rsi_bb_long_candidate(240, 20, 2.5, 5.0, 55.0, 0.008)),
  198. StrategySpec("ETH RSI2 15m", "ETH-USDT-SWAP", "15m", explore.build_rsi2_side_candidate(50, 3.0, 97.0, 55.0, "long")),
  199. StrategySpec("ETH RSI2 Price TWAP mid 15m", "ETH-USDT-SWAP", "15m", explore.build_rsi2_long_guarded_price_twap_candidate(50, 3.0, 55.0, 0.008, 48, (0.001, 0.003, 0.005), 2)),
  200. StrategySpec("ETH RSI2 Price TWAP deep 15m", "ETH-USDT-SWAP", "15m", explore.build_rsi2_long_guarded_price_twap_candidate(50, 3.0, 55.0, 0.008, 48, (0.002, 0.005, 0.008), 3)),
  201. StrategySpec("ETH RSI2 Price TWAP mid fb2 15m", "ETH-USDT-SWAP", "15m", explore.build_rsi2_long_guarded_price_twap_candidate(50, 3.0, 55.0, 0.008, 48, (0.001, 0.003, 0.005), 2, 0.0002)),
  202. StrategySpec("ETH Trend RSI-BB 15m", "ETH-USDT-SWAP", "15m", explore.build_trend_rsi_bb_long_candidate(480, 20, 2.0, 5.0, 45.0, 0.005)),
  203. StrategySpec("ETH/BTC RSI Filter 15m", "ETH-USDT-SWAP", "15m", explore.build_eth_btc_rsi_filter_candidate(50, 3.0, 55.0, 480, 240, 0.0), True),
  204. StrategySpec("BTC Lead ETH Lag 15m", "ETH-USDT-SWAP", "15m", explore.build_btc_lead_eth_lag_candidate(16, 0.024, 0.006, 32, 0.008, 0.018), True),
  205. StrategySpec("BTC Lead ETH Lag 5m", "ETH-USDT-SWAP", "5m", explore.build_btc_lead_eth_lag_candidate(16, 0.018, 0.006, 32, 0.006, 0.018), True),
  206. StrategySpec("BTC Lead ETH Lag 3m", "ETH-USDT-SWAP", "3m", explore.build_btc_lead_eth_lag_candidate(8, 0.012, 0.006, 32, 0.006, 0.012), True),
  207. ]
  208. colors = ["#2563eb", "#16a34a", "#0f766e", "#65a30d", "#84cc16", "#ca8a04", "#f97316", "#a3e635", "#bef264", "#facc15", "#fde047", "#dc2626", "#059669", "#22c55e", "#10b981", "#14b8a6", "#7c3aed", "#ea580c", "#0891b2", "#be123c", "#4b5563"]
  209. result_rows: list[dict[str, object]] = []
  210. monthly_rows: list[dict[str, object]] = []
  211. monthly_raw_rows: list[dict[str, object]] = []
  212. curves: list[dict[str, object]] = []
  213. recent_equities: dict[str, pd.DataFrame] = {}
  214. yearly_rows: list[dict[str, object]] = []
  215. strategy_horizon_rows: list[dict[str, object]] = []
  216. cost_scenario_rows: list[dict[str, object]] = []
  217. for index, spec in enumerate(specs):
  218. requested_bars = explore.history_bars_for_years(spec.bar, 10.0)
  219. candles = load_cached_history(explore, spec.symbol, spec.bar, requested_bars)
  220. if spec.is_pair:
  221. btc = load_cached_history(explore, "BTC-USDT-SWAP", spec.bar, requested_bars)
  222. candles, btc = explore.align_pair_candles(candles, btc)
  223. result = spec.candidate.run(eth_candles=candles, btc_candles=btc, leverage=explore.LEVERAGE, warmup_bars=spec.candidate.warmup_bars)
  224. else:
  225. result = spec.candidate.run(candles=candles, leverage=explore.LEVERAGE, warmup_bars=spec.candidate.warmup_bars)
  226. for cost_name, cost_value in COST_SCENARIOS:
  227. scenario_equity = explore.cost_adjusted_trade_equity_frame(result, cost_value)
  228. scenario_metrics = explore.annualized_metrics_from_equity(scenario_equity, int(scenario_equity["ts"].iloc[0].timestamp() * 1000), candles[-1].ts)
  229. cost_scenario_rows.append(
  230. {
  231. "strategy": spec.label,
  232. "cost": cost_name,
  233. "return": pct(scenario_metrics["net_total_return"]),
  234. "annualized": pct(scenario_metrics["net_annualized_return"]),
  235. "max_dd": pct(scenario_metrics["net_max_drawdown"]),
  236. "calmar": f"{scenario_metrics['net_calmar']:.2f}",
  237. }
  238. )
  239. equity = explore.cost_adjusted_trade_equity_frame(result, ROUNDTRIP_COST_ON_MARGIN)
  240. strategy_horizon_rows.extend(horizon_rows(explore, spec.label, "strategy", equity))
  241. cutoff = pd.to_datetime(candles[-1].ts, unit="ms", utc=True) - pd.DateOffset(years=3)
  242. recent_equity = normalize_equity(equity, cutoff)
  243. metrics = explore.annualized_metrics_from_equity(recent_equity, int(recent_equity["ts"].iloc[0].timestamp() * 1000), candles[-1].ts)
  244. result_rows.append(
  245. {
  246. "strategy": spec.label,
  247. "bar": spec.bar,
  248. "trades": result.trade_count,
  249. "3y_return": pct(metrics["net_total_return"]),
  250. "3y_annualized": pct(metrics["net_annualized_return"]),
  251. "3y_max_dd": pct(metrics["net_max_drawdown"]),
  252. "3y_calmar": f"{metrics['net_calmar']:.2f}",
  253. }
  254. )
  255. monthly = monthly_from_equity(equity)
  256. monthly = monthly[monthly["period"].str.startswith(("2025-", "2026-"))].copy()
  257. monthly.insert(0, "strategy", spec.label)
  258. for row in monthly.to_dict("records"):
  259. monthly_raw_rows.append(
  260. {
  261. "strategy": row["strategy"],
  262. "period": row["period"],
  263. "return": float(row["return"]),
  264. "end_equity": float(row["end_equity"]),
  265. }
  266. )
  267. monthly_rows.append(
  268. {
  269. "strategy": row["strategy"],
  270. "period": row["period"],
  271. "return": pct(float(row["return"])),
  272. "end_equity": money(float(row["end_equity"])),
  273. }
  274. )
  275. daily = recent_equity.set_index("ts")["equity"].resample("1D").last().ffill().reset_index()
  276. recent_equities[spec.label] = daily
  277. yearly_rows.extend(yearly_from_equity(spec.label, daily))
  278. curves.append(
  279. {
  280. "label": spec.label,
  281. "color": colors[index],
  282. "points": [(float(row.ts.timestamp()), float(row.equity)) for row in daily.itertuples(index=False)],
  283. }
  284. )
  285. print(f"done {spec.label}")
  286. portfolio_specs = [
  287. (
  288. "Balanced 4",
  289. [
  290. "BTC RSI2 Guarded 15m",
  291. "BTC Trend RSI-BB 15m",
  292. "ETH/BTC RSI Filter 15m",
  293. "BTC Lead ETH Lag 5m",
  294. ],
  295. ),
  296. (
  297. "Aggressive 5",
  298. [
  299. "BTC RSI2 Guarded 15m",
  300. "ETH RSI2 15m",
  301. "ETH/BTC RSI Filter 15m",
  302. "BTC Lead ETH Lag 15m",
  303. "BTC Lead ETH Lag 5m",
  304. ],
  305. ),
  306. (
  307. "Lead Lag Basket",
  308. [
  309. "BTC Lead ETH Lag 15m",
  310. "BTC Lead ETH Lag 5m",
  311. "BTC Lead ETH Lag 3m",
  312. ],
  313. ),
  314. ]
  315. portfolio_rows: list[dict[str, object]] = []
  316. portfolio_monthly_rows: list[dict[str, object]] = []
  317. portfolio_monthly_raw_rows: list[dict[str, object]] = []
  318. portfolio_curves: list[dict[str, object]] = []
  319. portfolio_equities: dict[str, pd.DataFrame] = {}
  320. portfolio_horizon_rows: list[dict[str, object]] = []
  321. portfolio_colors = ["#111827", "#b45309", "#0f766e"]
  322. overlay_rows: list[dict[str, object]] = []
  323. overlay_horizon_rows: list[dict[str, object]] = []
  324. overlay_curves: list[dict[str, object]] = []
  325. for index, (portfolio_name, names) in enumerate(portfolio_specs):
  326. portfolio_equity = combine_equities([recent_equities[name] for name in names])
  327. metrics = explore.annualized_metrics_from_equity(
  328. portfolio_equity,
  329. int(portfolio_equity["ts"].iloc[0].timestamp() * 1000),
  330. int(portfolio_equity["ts"].iloc[-1].timestamp() * 1000),
  331. )
  332. portfolio_rows.append(
  333. {
  334. "portfolio": portfolio_name,
  335. "legs": len(names),
  336. "3y_return": pct(metrics["net_total_return"]),
  337. "3y_annualized": pct(metrics["net_annualized_return"]),
  338. "3y_max_dd": pct(metrics["net_max_drawdown"]),
  339. "3y_calmar": f"{metrics['net_calmar']:.2f}",
  340. }
  341. )
  342. portfolio_horizon_rows.extend(horizon_rows(explore, portfolio_name, "portfolio", portfolio_equity))
  343. monthly = monthly_from_equity(portfolio_equity)
  344. monthly = monthly[monthly["period"].str.startswith(("2025-", "2026-"))].copy()
  345. monthly.insert(0, "portfolio", portfolio_name)
  346. for row in monthly.to_dict("records"):
  347. portfolio_monthly_raw_rows.append(
  348. {
  349. "portfolio": row["portfolio"],
  350. "period": row["period"],
  351. "return": float(row["return"]),
  352. "end_equity": float(row["end_equity"]),
  353. }
  354. )
  355. portfolio_monthly_rows.append(
  356. {
  357. "portfolio": row["portfolio"],
  358. "period": row["period"],
  359. "return": pct(float(row["return"])),
  360. "end_equity": money(float(row["end_equity"])),
  361. }
  362. )
  363. portfolio_equities[portfolio_name] = portfolio_equity
  364. yearly_rows.extend(yearly_from_equity(portfolio_name, portfolio_equity))
  365. portfolio_curves.append(
  366. {
  367. "label": portfolio_name,
  368. "color": portfolio_colors[index],
  369. "points": [(float(row.ts.timestamp()), float(row.equity)) for row in portfolio_equity.itertuples(index=False)],
  370. }
  371. )
  372. if portfolio_name in {"Balanced 4", "Aggressive 5"}:
  373. for threshold in (0.03, 0.05, 0.07):
  374. for reduced_exposure in (0.0, 0.5):
  375. overlay_name = f"{portfolio_name} DD>{threshold:.0%} x{reduced_exposure:.1f}"
  376. overlay_equity = apply_drawdown_overlay(portfolio_equity, threshold, reduced_exposure)
  377. overlay_metrics = explore.annualized_metrics_from_equity(
  378. overlay_equity,
  379. int(overlay_equity["ts"].iloc[0].timestamp() * 1000),
  380. int(overlay_equity["ts"].iloc[-1].timestamp() * 1000),
  381. )
  382. overlay_rows.append(
  383. {
  384. "overlay": overlay_name,
  385. "base": portfolio_name,
  386. "threshold": pct(threshold),
  387. "reduced_exposure": f"{reduced_exposure:.1f}",
  388. "sort_calmar": overlay_metrics["net_calmar"],
  389. "sort_annualized": overlay_metrics["net_annualized_return"],
  390. "3y_return": pct(overlay_metrics["net_total_return"]),
  391. "3y_annualized": pct(overlay_metrics["net_annualized_return"]),
  392. "3y_max_dd": pct(overlay_metrics["net_max_drawdown"]),
  393. "3y_calmar": f"{overlay_metrics['net_calmar']:.2f}",
  394. }
  395. )
  396. overlay_horizon_rows.extend(horizon_rows(explore, overlay_name, "overlay", overlay_equity))
  397. if portfolio_name == "Balanced 4" and threshold == 0.05:
  398. overlay_curves.append(
  399. {
  400. "label": overlay_name,
  401. "color": "#2563eb" if reduced_exposure == 0.5 else "#dc2626",
  402. "points": [(float(row.ts.timestamp()), float(row.equity)) for row in overlay_equity.itertuples(index=False)],
  403. }
  404. )
  405. summary = pd.DataFrame(result_rows)
  406. monthly_frame = pd.DataFrame(monthly_rows)
  407. monthly_raw = pd.DataFrame(monthly_raw_rows)
  408. portfolio_summary = pd.DataFrame(portfolio_rows)
  409. portfolio_monthly = pd.DataFrame(portfolio_monthly_rows)
  410. strategy_horizon = pd.DataFrame(strategy_horizon_rows)
  411. cost_scenarios = pd.DataFrame(cost_scenario_rows)
  412. portfolio_horizon = pd.DataFrame(portfolio_horizon_rows)
  413. overlay_summary = (
  414. pd.DataFrame(overlay_rows)
  415. .sort_values(["sort_calmar", "sort_annualized"], ascending=False)
  416. .drop(columns=["sort_calmar", "sort_annualized"])
  417. )
  418. overlay_horizon = pd.DataFrame(overlay_horizon_rows)
  419. portfolio_monthly_raw = pd.DataFrame(portfolio_monthly_raw_rows)
  420. correlations = daily_return_frame({**recent_equities, **portfolio_equities}).corr()
  421. correlation_rows = correlations.reset_index().rename(columns={"index": "name"})
  422. worst_strategy_months = monthly_raw.sort_values("return").head(20).copy()
  423. worst_strategy_months["return"] = worst_strategy_months["return"].map(pct)
  424. worst_strategy_months["end_equity"] = worst_strategy_months["end_equity"].map(money)
  425. worst_portfolio_months = portfolio_monthly_raw.sort_values("return").head(12).copy()
  426. worst_portfolio_months["return"] = worst_portfolio_months["return"].map(pct)
  427. worst_portfolio_months["end_equity"] = worst_portfolio_months["end_equity"].map(money)
  428. yearly_frame = pd.DataFrame(yearly_rows)
  429. REPORT_DIR.mkdir(parents=True, exist_ok=True)
  430. monthly_frame.to_csv(REPORT_DIR / "ultrashort-recent-monthly.csv", index=False)
  431. summary.to_csv(REPORT_DIR / "ultrashort-recent-summary.csv", index=False)
  432. portfolio_summary.to_csv(REPORT_DIR / "ultrashort-portfolio-summary.csv", index=False)
  433. portfolio_monthly.to_csv(REPORT_DIR / "ultrashort-portfolio-monthly.csv", index=False)
  434. strategy_horizon.to_csv(REPORT_DIR / "ultrashort-strategy-horizons.csv", index=False)
  435. cost_scenarios.to_csv(REPORT_DIR / "ultrashort-cost-scenarios.csv", index=False)
  436. portfolio_horizon.to_csv(REPORT_DIR / "ultrashort-portfolio-horizons.csv", index=False)
  437. overlay_summary.to_csv(REPORT_DIR / "ultrashort-overlay-summary.csv", index=False)
  438. overlay_horizon.to_csv(REPORT_DIR / "ultrashort-overlay-horizons.csv", index=False)
  439. correlation_rows.to_csv(REPORT_DIR / "ultrashort-correlation.csv", index=False)
  440. worst_strategy_months.to_csv(REPORT_DIR / "ultrashort-worst-strategy-months.csv", index=False)
  441. worst_portfolio_months.to_csv(REPORT_DIR / "ultrashort-worst-portfolio-months.csv", index=False)
  442. yearly_frame.to_csv(REPORT_DIR / "ultrashort-yearly.csv", index=False)
  443. html = f"""<!doctype html>
  444. <html lang="zh-CN">
  445. <head>
  446. <meta charset="utf-8">
  447. <meta name="viewport" content="width=device-width, initial-scale=1">
  448. <title>Ultra Short Recent Strategy Report</title>
  449. <style>
  450. body {{ margin: 0; background: #f8fafc; color: #111827; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
  451. main {{ max-width: 1280px; margin: 0 auto; padding: 28px 24px 48px; }}
  452. h1 {{ margin: 0 0 8px; font-size: 30px; }}
  453. h2 {{ margin: 28px 0 12px; font-size: 20px; }}
  454. .meta {{ color: #4b5563; margin-bottom: 18px; }}
  455. .panel {{ background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; margin-bottom: 18px; overflow-x: auto; }}
  456. table {{ width: 100%; border-collapse: collapse; font-size: 13px; white-space: nowrap; }}
  457. th, td {{ text-align: right; padding: 8px 10px; border-bottom: 1px solid #e5e7eb; }}
  458. th:first-child, td:first-child {{ text-align: left; }}
  459. th {{ color: #374151; background: #f9fafb; font-weight: 700; }}
  460. .note {{ color: #6b7280; font-size: 13px; line-height: 1.6; }}
  461. </style>
  462. </head>
  463. <body>
  464. <main>
  465. <h1>近 3 年超短线策略报告</h1>
  466. <div class="meta">数据截至 {escape(str(pd.Timestamp.now("UTC").strftime("%Y-%m-%d %H:%M UTC")))},主结果成本按 maker+taker,每次完整交易扣除保证金 0.21%。曲线以近 3 年起点统一归一为 10,000。</div>
  467. <section class="panel">{make_svg(curves)}</section>
  468. <h2>近 3 年汇总</h2>
  469. <section class="panel">{render_table(summary, ["strategy", "bar", "trades", "3y_return", "3y_annualized", "3y_max_dd", "3y_calmar"])}</section>
  470. <h2>单策略分周期表现</h2>
  471. <section class="panel">{render_table(strategy_horizon, ["strategy", "horizon", "return", "annualized", "max_dd", "calmar"])}</section>
  472. <h2>手续费三档敏感性</h2>
  473. <section class="panel">{render_table(cost_scenarios, ["strategy", "cost", "return", "annualized", "max_dd", "calmar"])}</section>
  474. <h2>组合曲线</h2>
  475. <section class="panel">{make_svg(portfolio_curves)}</section>
  476. <h2>组合近 3 年汇总</h2>
  477. <section class="panel">{render_table(portfolio_summary, ["portfolio", "legs", "3y_return", "3y_annualized", "3y_max_dd", "3y_calmar"])}</section>
  478. <h2>组合分周期表现</h2>
  479. <section class="panel">{render_table(portfolio_horizon, ["portfolio", "horizon", "return", "annualized", "max_dd", "calmar"])}</section>
  480. <h2>回撤降仓 Overlay</h2>
  481. <section class="panel">{make_svg([portfolio_curves[0], *overlay_curves])}</section>
  482. <section class="panel">{render_table(overlay_summary, ["overlay", "base", "threshold", "reduced_exposure", "3y_return", "3y_annualized", "3y_max_dd", "3y_calmar"])}</section>
  483. <h2>Overlay 分周期表现</h2>
  484. <section class="panel">{render_table(overlay_horizon, ["overlay", "horizon", "return", "annualized", "max_dd", "calmar"])}</section>
  485. <h2>组合 2025-2026 月度收益</h2>
  486. <section class="panel">{render_table(portfolio_monthly, ["portfolio", "period", "return", "end_equity"])}</section>
  487. <h2>年度收益</h2>
  488. <section class="panel">{render_table(yearly_frame, ["name", "year", "return", "end_equity"])}</section>
  489. <h2>最差月份</h2>
  490. <section class="panel">{render_table(worst_portfolio_months, ["portfolio", "period", "return", "end_equity"])}</section>
  491. <section class="panel">{render_table(worst_strategy_months, ["strategy", "period", "return", "end_equity"])}</section>
  492. <h2>相关性矩阵</h2>
  493. <section class="panel">{correlation_rows.round(3).to_html(index=False, escape=True)}</section>
  494. <h2>2025-2026 月度收益</h2>
  495. <section class="panel">{render_table(monthly_frame, ["strategy", "period", "return", "end_equity"])}</section>
  496. <p class="note">组合按近 3 年起点等资金分配,不做月度再平衡。月度收益按成本调整后的闭合交易权益计算;若月内无平仓交易,收益可能显示为 0。</p>
  497. </main>
  498. </body>
  499. </html>
  500. """
  501. output_file = REPORT_DIR / REPORT_FILE
  502. output_file.write_text(html, encoding="utf-8")
  503. print(output_file)
  504. return 0
  505. if __name__ == "__main__":
  506. raise SystemExit(main())