refine_expansion_mean_reversion_regime.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  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.insert(0, str(Path(__file__).resolve().parents[1]))
  9. from okx_codex_trader.models import Candle
  10. from okx_codex_trader.sampled_report import SegmentResult, mark_to_market, trade_equity
  11. from scripts import explore_ultrashort as explore
  12. from scripts.search_eth_btc_nextgen_variants import markdown_table, metrics_from_daily_equity, monthly_rows
  13. from scripts.search_expansion_mean_reversion import (
  14. ROUNDTRIP_TAKER_COST_ON_MARGIN,
  15. daily_equity,
  16. horizon_rows,
  17. load_base_candles,
  18. resample_candles,
  19. trade_stats_for_window,
  20. true_range,
  21. )
  22. OUTPUT_DIR = Path("reports/strategy-expansion")
  23. PREFIX = "mean-reversion-regime"
  24. YEARS = 10.0
  25. LEVERAGE = 3
  26. @dataclass(frozen=True)
  27. class BaseParams:
  28. rsi_length: int = 2
  29. rsi_entry: float = 8.0
  30. rsi_exit: float = 55.0
  31. bb_length: int = 20
  32. vol_lookback: int = 24
  33. vol_quantile_lookback: int = 240
  34. vol_quantile: float = 0.75
  35. btc_trend_sma: int = 200
  36. btc_momentum_lookback: int = 12
  37. max_hold_bars: int = 24
  38. stop_loss_pct: float = 0.045
  39. cooldown_bars: int = 6
  40. @dataclass(frozen=True)
  41. class Regime:
  42. name: str
  43. category: str
  44. rule: str
  45. def close_position(
  46. *,
  47. trades: list[dict[str, object]],
  48. exits: list[dict[str, object]],
  49. position: dict[str, object],
  50. candle: Candle,
  51. exit_price: float,
  52. ) -> tuple[float, bool, float]:
  53. exit_equity = trade_equity(
  54. side=str(position["side"]),
  55. margin_used=float(position["margin_used"]),
  56. entry_price=float(position["entry_price"]),
  57. exit_price=exit_price,
  58. leverage=LEVERAGE,
  59. )
  60. pnl = exit_equity - float(position["margin_used"])
  61. return_pct = pnl / float(position["margin_used"])
  62. trades.append(
  63. {
  64. "side": "Long",
  65. "entry_time": explore._format_ts(int(position["entry_time"])),
  66. "exit_time": explore._format_ts(candle.ts),
  67. "entry_price": round(float(position["entry_price"]), 4),
  68. "exit_price": round(exit_price, 4),
  69. "pnl": round(pnl, 4),
  70. "return_pct": round(return_pct * 100.0, 4),
  71. }
  72. )
  73. exits.append({"ts": candle.ts, "price": exit_price, "side": "long"})
  74. return exit_equity, pnl > 0.0, return_pct - ROUNDTRIP_TAKER_COST_ON_MARGIN
  75. def adx(highs: pd.Series, lows: pd.Series, closes: pd.Series, length: int) -> pd.Series:
  76. up_move = highs.diff()
  77. down_move = -lows.diff()
  78. plus_dm = up_move.where((up_move > down_move) & (up_move > 0.0), 0.0)
  79. minus_dm = down_move.where((down_move > up_move) & (down_move > 0.0), 0.0)
  80. atr = true_range(highs, lows, closes).rolling(length).mean()
  81. plus_di = 100.0 * plus_dm.rolling(length).mean() / atr
  82. minus_di = 100.0 * minus_dm.rolling(length).mean() / atr
  83. dx = 100.0 * (plus_di - minus_di).abs() / (plus_di + minus_di)
  84. return dx.rolling(length).mean()
  85. def daily_regime_to_4h(candles: list[Candle], daily_candles: list[Candle], daily_values: pd.Series) -> pd.Series:
  86. daily_frame = pd.DataFrame(
  87. {
  88. "day": [pd.to_datetime(candle.ts, unit="ms", utc=True).normalize() for candle in daily_candles],
  89. "value": daily_values.shift(1).to_numpy(),
  90. }
  91. ).dropna()
  92. by_day = daily_frame.set_index("day")["value"].sort_index()
  93. days = pd.Series([pd.to_datetime(candle.ts, unit="ms", utc=True).normalize() for candle in candles])
  94. return days.map(by_day).astype("boolean")
  95. def static_regime_series(regime: Regime, candles: list[Candle], btc_candles: list[Candle], eth_daily: list[Candle], btc_daily: list[Candle]) -> pd.Series:
  96. closes = pd.Series([candle.close for candle in candles], dtype=float)
  97. highs = pd.Series([candle.high for candle in candles], dtype=float)
  98. lows = pd.Series([candle.low for candle in candles], dtype=float)
  99. returns = closes.pct_change()
  100. btc_closes = pd.Series([candle.close for candle in btc_candles], dtype=float)
  101. if regime.name == "baseline":
  102. return pd.Series([True] * len(candles))
  103. if regime.name.startswith("btc_4h_sma"):
  104. length = int(regime.name.removeprefix("btc_4h_sma"))
  105. return btc_closes > btc_closes.rolling(length).mean()
  106. if regime.name.startswith("btc_4h_mom"):
  107. bars = int(regime.name.removeprefix("btc_4h_mom"))
  108. return btc_closes / btc_closes.shift(bars) - 1.0 > 0.0
  109. if regime.name.startswith("eth_daily_sma"):
  110. length = int(regime.name.removeprefix("eth_daily_sma"))
  111. daily_close = pd.Series([candle.close for candle in eth_daily], dtype=float)
  112. return daily_regime_to_4h(candles, eth_daily, daily_close > daily_close.rolling(length).mean())
  113. if regime.name.startswith("eth_daily_mom"):
  114. days = int(regime.name.removeprefix("eth_daily_mom"))
  115. daily_close = pd.Series([candle.close for candle in eth_daily], dtype=float)
  116. return daily_regime_to_4h(candles, eth_daily, daily_close / daily_close.shift(days) - 1.0 > 0.0)
  117. if regime.name.startswith("btc_daily_sma"):
  118. length = int(regime.name.removeprefix("btc_daily_sma"))
  119. daily_close = pd.Series([candle.close for candle in btc_daily], dtype=float)
  120. return daily_regime_to_4h(candles, btc_daily, daily_close > daily_close.rolling(length).mean())
  121. if regime.name.startswith("vol_q"):
  122. quantile = float(regime.name.removeprefix("vol_q")) / 100.0
  123. realized_vol = returns.rolling(24).std(ddof=1)
  124. return realized_vol <= realized_vol.rolling(240).quantile(quantile)
  125. if regime.name.startswith("adx_lt"):
  126. limit = float(regime.name.removeprefix("adx_lt"))
  127. return adx(highs, lows, closes, 14) < limit
  128. if regime.name.startswith("adx_gt"):
  129. limit = float(regime.name.removeprefix("adx_gt"))
  130. return adx(highs, lows, closes, 14) > limit
  131. raise ValueError(f"unknown static regime {regime.name}")
  132. def baseline_equity_regime_series(regime: Regime, candles: list[Candle], baseline_result: SegmentResult, baseline_daily: pd.Series) -> pd.Series:
  133. if regime.name.startswith("eq_trades"):
  134. count = int(regime.name.removeprefix("eq_trades"))
  135. closed: list[float] = []
  136. trades = sorted(baseline_result.trades, key=lambda trade: pd.to_datetime(str(trade["exit_time"]), utc=True))
  137. trade_index = 0
  138. allowed: list[bool] = []
  139. for candle in candles:
  140. current_time = pd.to_datetime(candle.ts, unit="ms", utc=True)
  141. while trade_index < len(trades) and pd.to_datetime(str(trades[trade_index]["exit_time"]), utc=True) < current_time:
  142. closed.append(float(trades[trade_index]["return_pct"]) / 100.0 - ROUNDTRIP_TAKER_COST_ON_MARGIN)
  143. trade_index += 1
  144. allowed.append(len(closed) >= count and sum(closed[-count:]) > 0.0)
  145. return pd.Series(allowed)
  146. if regime.name.startswith("eq_days"):
  147. days = int(regime.name.removeprefix("eq_days"))
  148. allowed = []
  149. for candle in candles:
  150. current_day = pd.to_datetime(candle.ts, unit="ms", utc=True).normalize()
  151. end_day = current_day - pd.Timedelta(days=1)
  152. start_day = end_day - pd.Timedelta(days=days)
  153. allowed.append(
  154. start_day in baseline_daily.index
  155. and end_day in baseline_daily.index
  156. and float(baseline_daily.loc[end_day] / baseline_daily.loc[start_day] - 1.0) > 0.0
  157. )
  158. return pd.Series(allowed)
  159. raise ValueError(f"unknown equity regime {regime.name}")
  160. def run_segment(
  161. candles: list[Candle],
  162. btc_candles: list[Candle],
  163. eth_daily: list[Candle],
  164. btc_daily: list[Candle],
  165. params: BaseParams,
  166. regime: Regime,
  167. regime_allowed: pd.Series | None = None,
  168. ) -> SegmentResult:
  169. closes = pd.Series([candle.close for candle in candles], dtype=float)
  170. highs = pd.Series([candle.high for candle in candles], dtype=float)
  171. lows = pd.Series([candle.low for candle in candles], dtype=float)
  172. returns = closes.pct_change()
  173. btc_closes = pd.Series([candle.close for candle in btc_candles], dtype=float)
  174. btc_returns = btc_closes.pct_change()
  175. rsi = explore._compute_rsi(closes, params.rsi_length)
  176. realized_vol = returns.rolling(params.vol_lookback).std(ddof=1)
  177. vol_cap = realized_vol.rolling(params.vol_quantile_lookback).quantile(params.vol_quantile)
  178. btc_trend = btc_closes.rolling(params.btc_trend_sma).mean()
  179. btc_vol = btc_returns.rolling(params.vol_lookback).std(ddof=1)
  180. btc_vol_cap = btc_vol.rolling(params.vol_quantile_lookback).quantile(params.vol_quantile)
  181. static_allowed = regime_allowed if regime_allowed is not None else static_regime_series(regime, candles, btc_candles, eth_daily, btc_daily)
  182. warmup_bars = max(
  183. params.vol_lookback + params.vol_quantile_lookback,
  184. params.btc_trend_sma,
  185. params.btc_momentum_lookback,
  186. params.rsi_length + 2,
  187. )
  188. equity = explore.INITIAL_EQUITY
  189. ending_equity = equity
  190. peak_equity = equity
  191. max_drawdown = 0.0
  192. wins = 0
  193. last_exit_index = -10**9
  194. pending_side = False
  195. pending_exit = False
  196. position: dict[str, object] | None = None
  197. trades: list[dict[str, object]] = []
  198. entries: list[dict[str, object]] = []
  199. exits: list[dict[str, object]] = []
  200. equity_curve: list[dict[str, float | int]] = []
  201. for index in range(warmup_bars, len(candles)):
  202. candle = candles[index]
  203. if pending_exit and position is not None:
  204. equity, won, net_return = close_position(trades=trades, exits=exits, position=position, candle=candle, exit_price=candle.open)
  205. wins += 1 if won else 0
  206. position = None
  207. pending_exit = False
  208. last_exit_index = index
  209. if pending_side and position is None and equity > 0.0:
  210. position = {
  211. "side": "long",
  212. "entry_time": candle.ts,
  213. "entry_price": candle.open,
  214. "entry_index": index,
  215. "margin_used": equity,
  216. "stop_price": candle.open * (1.0 - params.stop_loss_pct),
  217. }
  218. entries.append({"ts": candle.ts, "price": candle.open, "side": "long"})
  219. pending_side = False
  220. current_equity = equity
  221. if position is not None:
  222. if candle.low <= float(position["stop_price"]):
  223. equity, won, net_return = close_position(
  224. trades=trades,
  225. exits=exits,
  226. position=position,
  227. candle=candle,
  228. exit_price=float(position["stop_price"]),
  229. )
  230. wins += 1 if won else 0
  231. current_equity = equity
  232. position = None
  233. last_exit_index = index
  234. if position is not None:
  235. current_equity = mark_to_market(
  236. side="long",
  237. margin_used=float(position["margin_used"]),
  238. entry_price=float(position["entry_price"]),
  239. mark_price=candle.close,
  240. leverage=LEVERAGE,
  241. )
  242. peak_equity = max(peak_equity, current_equity)
  243. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  244. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  245. ending_equity = current_equity
  246. if index == len(candles) - 1 or equity <= 0.0:
  247. continue
  248. values = [
  249. rsi[index],
  250. realized_vol.iloc[index],
  251. vol_cap.iloc[index],
  252. btc_trend.iloc[index],
  253. btc_vol.iloc[index],
  254. btc_vol_cap.iloc[index],
  255. ]
  256. if any(value != value for value in values):
  257. continue
  258. current_rsi = float(rsi[index])
  259. if position is not None:
  260. held_bars = index - int(position["entry_index"])
  261. if current_rsi >= params.rsi_exit or held_bars >= params.max_hold_bars:
  262. pending_exit = True
  263. continue
  264. if index - last_exit_index < params.cooldown_bars or float(realized_vol.iloc[index]) > float(vol_cap.iloc[index]):
  265. continue
  266. btc_momentum = btc_candles[index].close / btc_candles[index - params.btc_momentum_lookback].close - 1.0
  267. if btc_candles[index].close <= float(btc_trend.iloc[index]) or btc_momentum < 0.0 or float(btc_vol.iloc[index]) > float(btc_vol_cap.iloc[index]):
  268. continue
  269. if not bool(static_allowed.iloc[index]):
  270. continue
  271. if current_rsi <= params.rsi_entry:
  272. pending_side = True
  273. trade_count = len(trades)
  274. return SegmentResult(
  275. trade_count=trade_count,
  276. total_return=(ending_equity - explore.INITIAL_EQUITY) / explore.INITIAL_EQUITY,
  277. win_rate=wins / trade_count if trade_count else 0.0,
  278. max_drawdown=max_drawdown,
  279. trades=trades,
  280. open_position=position,
  281. candles=candles[warmup_bars:],
  282. equity_curve=equity_curve,
  283. entries=entries,
  284. exits=exits,
  285. )
  286. def regimes() -> list[Regime]:
  287. return [
  288. Regime("baseline", "baseline", "existing ETH 4H RSI2 entry<=8; BTC 4H close>SMA200 and 12-bar momentum>=0"),
  289. Regime("btc_4h_sma400", "btc_trend", "baseline plus BTC 4H close>SMA400"),
  290. Regime("btc_4h_mom24", "btc_trend", "baseline plus BTC 4H 24-bar return>0"),
  291. Regime("btc_daily_sma100", "btc_trend", "baseline plus BTC daily close>SMA100"),
  292. Regime("btc_daily_sma200", "btc_trend", "baseline plus BTC daily close>SMA200"),
  293. Regime("eth_daily_sma50", "eth_daily_trend", "baseline plus ETH daily close>SMA50"),
  294. Regime("eth_daily_sma100", "eth_daily_trend", "baseline plus ETH daily close>SMA100"),
  295. Regime("eth_daily_sma200", "eth_daily_trend", "baseline plus ETH daily close>SMA200"),
  296. Regime("eth_daily_mom20", "eth_daily_trend", "baseline plus ETH daily 20-day return>0"),
  297. Regime("vol_q50", "volatility", "baseline with ETH 4H realized vol <= rolling 50th percentile"),
  298. Regime("vol_q60", "volatility", "baseline with ETH 4H realized vol <= rolling 60th percentile"),
  299. Regime("adx_lt20", "trend_strength", "baseline plus ETH 4H ADX14<20"),
  300. Regime("adx_lt25", "trend_strength", "baseline plus ETH 4H ADX14<25"),
  301. Regime("adx_gt25", "trend_strength", "baseline plus ETH 4H ADX14>25"),
  302. Regime("eq_trades3", "equity_momentum", "baseline only after last 3 closed net trades have positive summed return"),
  303. Regime("eq_trades5", "equity_momentum", "baseline only after last 5 closed net trades have positive summed return"),
  304. Regime("eq_days30", "equity_momentum", "baseline only after previous 30 closed-trade net equity days are positive"),
  305. Regime("eq_days60", "equity_momentum", "baseline only after previous 60 closed-trade net equity days are positive"),
  306. ]
  307. def build_row(regime: Regime, result: SegmentResult, daily: pd.Series) -> tuple[dict[str, object], list[dict[str, object]], pd.DataFrame]:
  308. monthly = monthly_rows(regime.name, daily)
  309. current_horizons = horizon_rows(regime.name, result, ROUNDTRIP_TAKER_COST_ON_MARGIN, daily)
  310. horizon_by_label = {str(row["horizon"]): float(row["net_total_return"]) for row in current_horizons}
  311. stats = trade_stats_for_window(result, ROUNDTRIP_TAKER_COST_ON_MARGIN, daily.index[0], daily.index[-1])
  312. row = {
  313. "name": regime.name,
  314. "category": regime.category,
  315. "rule": regime.rule,
  316. "first_candle": daily.index[0].strftime("%Y-%m-%d"),
  317. "last_candle": daily.index[-1].strftime("%Y-%m-%d"),
  318. "years": (daily.index[-1] - daily.index[0]).total_seconds() / 86_400 / 365,
  319. "gross_total_return": result.total_return,
  320. "gross_max_drawdown_mark_to_market": result.max_drawdown,
  321. "worst_month_return": float(monthly["return"].min()),
  322. "return_3y": horizon_by_label.get("3y", 0.0),
  323. "return_1y": horizon_by_label.get("1y", 0.0),
  324. "return_6m": horizon_by_label.get("6m", 0.0),
  325. "return_3m": horizon_by_label.get("3m", 0.0),
  326. **stats,
  327. **metrics_from_daily_equity(daily),
  328. }
  329. return row, current_horizons, monthly
  330. def markdown_report(paths: list[Path], totals: pd.DataFrame, horizon: pd.DataFrame, monthly: pd.DataFrame, command: str) -> str:
  331. baseline = totals[totals["name"] == "baseline"].iloc[0]
  332. improved = totals[
  333. (totals["name"] != "baseline")
  334. & (totals["return_6m"] > float(baseline["return_6m"]) + 1e-9)
  335. & (totals["return_3m"] > float(baseline["return_3m"]) + 1e-9)
  336. ].sort_values(["return_3m", "return_6m", "net_calmar"], ascending=[False, False, False])
  337. top_names = set(totals.head(10)["name"]) | {"baseline"}
  338. lines = [
  339. "# Mean reversion regime review",
  340. "",
  341. f"Run command: `{command}`",
  342. "",
  343. "Output files:",
  344. *[f"- `{path}`" for path in paths],
  345. "",
  346. "Scope: ETH-USDT-SWAP 4H RSI2 long mean reversion from existing expansion best candidate.",
  347. f"Cost: roundtrip taker cost on margin at {LEVERAGE}x = {ROUNDTRIP_TAKER_COST_ON_MARGIN:.6f}.",
  348. "",
  349. "## Baseline",
  350. "",
  351. markdown_table(pd.DataFrame([baseline])[["name", "trades", "net_total_return", "net_annualized_return", "net_max_drawdown", "net_calmar", "win_rate", "payoff_ratio", "return_3y", "return_1y", "return_6m", "return_3m"]]),
  352. "",
  353. "## Conditions improving both 6m and 3m",
  354. "",
  355. markdown_table(improved[["name", "category", "trades", "net_total_return", "net_annualized_return", "net_max_drawdown", "net_calmar", "win_rate", "payoff_ratio", "return_3y", "return_1y", "return_6m", "return_3m", "rule"]]),
  356. "",
  357. "## All regime tests",
  358. "",
  359. markdown_table(totals[["name", "category", "trades", "net_total_return", "net_annualized_return", "net_max_drawdown", "net_calmar", "win_rate", "payoff_ratio", "return_3y", "return_1y", "return_6m", "return_3m", "worst_month_return"]]),
  360. "",
  361. "## Horizon metrics",
  362. "",
  363. markdown_table(horizon[horizon["name"].isin(top_names)][["name", "horizon", "horizon_start", "horizon_end", "net_total_return", "net_annualized_return", "net_max_drawdown", "net_calmar", "trades", "win_rate", "payoff_ratio"]]),
  364. "",
  365. "## Monthly returns",
  366. "",
  367. markdown_table(monthly[monthly["name"].isin(top_names)][["name", "month", "return", "start_equity", "end_equity"]].tail(180)),
  368. ]
  369. return "\n".join(lines) + "\n"
  370. def main() -> int:
  371. parser = argparse.ArgumentParser()
  372. parser.add_argument("--years", type=float, default=YEARS)
  373. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  374. args = parser.parse_args()
  375. eth_15m = load_base_candles("ETH-USDT-SWAP", args.years)
  376. btc_15m = load_base_candles("BTC-USDT-SWAP", args.years)
  377. eth_4h = resample_candles(eth_15m, "4H")
  378. btc_4h = resample_candles(btc_15m, "4H")
  379. eth_daily = resample_candles(eth_15m, "1D")
  380. btc_daily = resample_candles(btc_15m, "1D")
  381. candles, btc_candles = explore.align_pair_candles(eth_4h, btc_4h)
  382. params = BaseParams()
  383. total_rows: list[dict[str, object]] = []
  384. horizon_output: list[dict[str, object]] = []
  385. monthly_frames: list[pd.DataFrame] = []
  386. tested = regimes()
  387. baseline_regime = tested[0]
  388. baseline_result = run_segment(candles, btc_candles, eth_daily, btc_daily, params, baseline_regime)
  389. baseline_frame = explore.cost_adjusted_trade_equity_frame(baseline_result, ROUNDTRIP_TAKER_COST_ON_MARGIN)
  390. baseline_start = pd.to_datetime(baseline_result.equity_curve[0]["ts"], unit="ms", utc=True)
  391. baseline_end = pd.to_datetime(baseline_result.equity_curve[-1]["ts"], unit="ms", utc=True)
  392. baseline_daily = daily_equity(baseline_frame, baseline_start, baseline_end)
  393. for index, regime in enumerate(tested, start=1):
  394. allowed = baseline_equity_regime_series(regime, candles, baseline_result, baseline_daily) if regime.category == "equity_momentum" else None
  395. result = baseline_result if regime.name == "baseline" else run_segment(candles, btc_candles, eth_daily, btc_daily, params, regime, allowed)
  396. frame = explore.cost_adjusted_trade_equity_frame(result, ROUNDTRIP_TAKER_COST_ON_MARGIN)
  397. start = pd.to_datetime(result.equity_curve[0]["ts"], unit="ms", utc=True)
  398. end = pd.to_datetime(result.equity_curve[-1]["ts"], unit="ms", utc=True)
  399. daily = daily_equity(frame, start, end)
  400. row, current_horizons, monthly = build_row(regime, result, daily)
  401. total_rows.append(row)
  402. horizon_output.extend(current_horizons)
  403. monthly_frames.append(monthly)
  404. print(f"done {index}/{len(tested)} {regime.name}", flush=True)
  405. totals = pd.DataFrame(total_rows).sort_values(
  406. ["return_3m", "return_6m", "net_calmar", "net_total_return"],
  407. ascending=[False, False, False, False],
  408. )
  409. horizon = pd.DataFrame(horizon_output)
  410. horizon["horizon"] = pd.Categorical(horizon["horizon"], categories=["full", "3y", "1y", "6m", "3m"], ordered=True)
  411. horizon = horizon.sort_values(["name", "horizon"])
  412. monthly = pd.concat(monthly_frames, ignore_index=True).sort_values(["name", "month"])
  413. args.output_dir.mkdir(parents=True, exist_ok=True)
  414. totals_path = args.output_dir / f"{PREFIX}-totals.csv"
  415. horizon_path = args.output_dir / f"{PREFIX}-horizons.csv"
  416. monthly_path = args.output_dir / f"{PREFIX}-monthly-returns.csv"
  417. best_path = args.output_dir / f"{PREFIX}-best.json"
  418. report_path = args.output_dir / f"{PREFIX}-report.md"
  419. totals.to_csv(totals_path, index=False)
  420. horizon.to_csv(horizon_path, index=False)
  421. monthly.to_csv(monthly_path, index=False)
  422. best_path.write_text(json.dumps(totals.head(5).to_dict(orient="records"), indent=2), encoding="utf-8")
  423. paths = [totals_path, horizon_path, monthly_path, best_path, report_path]
  424. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --years {args.years}"
  425. report_path.write_text(markdown_report(paths, totals, horizon, monthly, command), encoding="utf-8")
  426. print(totals.head(10).to_string(index=False))
  427. return 0
  428. if __name__ == "__main__":
  429. raise SystemExit(main())