refine_expansion_trend_validation.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702
  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 format_cell, markdown_table
  13. from scripts.search_expansion_trend_swing import (
  14. Params,
  15. frame_to_candles,
  16. load_15m_frame,
  17. resample_frame,
  18. true_range,
  19. )
  20. OUTPUT_DIR = Path("reports/strategy-expansion")
  21. INPUT_TOTALS = OUTPUT_DIR / "trend-swing-totals.csv"
  22. PREFIX = "trend-validation"
  23. SYMBOLS = ("BTC-USDT-SWAP", "ETH-USDT-SWAP")
  24. BARS = ("1D", "4H")
  25. YEARS = 10.0
  26. FEE_SINGLE_SIDE = 0.0004
  27. HORIZONS = (
  28. ("3y", pd.DateOffset(years=3)),
  29. ("1y", pd.DateOffset(years=1)),
  30. ("6m", pd.DateOffset(months=6)),
  31. ("3m", pd.DateOffset(months=3)),
  32. )
  33. MARKET_PHASES = (
  34. ("2020_2021_bull", "2020-03-13", "2021-11-10"),
  35. ("2021_2022_bear", "2021-11-10", "2022-11-21"),
  36. ("2022_2024_bull", "2022-11-21", "2024-03-14"),
  37. ("2024_2026_late_cycle", "2024-03-14", "2026-05-31"),
  38. )
  39. @dataclass(frozen=True)
  40. class Risk:
  41. leverage: int
  42. position_fraction: float
  43. @property
  44. def label(self) -> str:
  45. return f"lev{self.leverage}-pos{self.position_fraction:g}"
  46. RISK_PROFILES = (
  47. Risk(leverage=1, position_fraction=1.0),
  48. Risk(leverage=2, position_fraction=0.5),
  49. Risk(leverage=1, position_fraction=0.5),
  50. )
  51. def close_trade(
  52. *,
  53. trades: list[dict[str, object]],
  54. exits: list[dict[str, object]],
  55. account_equity: float,
  56. position: dict[str, object],
  57. candle: Candle,
  58. exit_price: float,
  59. risk: Risk,
  60. ) -> tuple[float, bool]:
  61. margin_used = float(position["margin_used"])
  62. exit_equity = trade_equity(
  63. side=str(position["side"]),
  64. margin_used=margin_used,
  65. entry_price=float(position["entry_price"]),
  66. exit_price=exit_price,
  67. leverage=risk.leverage,
  68. )
  69. gross_pnl = exit_equity - margin_used
  70. fee = margin_used * FEE_SINGLE_SIDE * 2.0 * risk.leverage
  71. pnl = gross_pnl - fee
  72. next_equity = account_equity + pnl
  73. trades.append(
  74. {
  75. "side": "Long" if position["side"] == "long" else "Short",
  76. "entry_time": explore._format_ts(int(position["entry_time"])),
  77. "exit_time": explore._format_ts(candle.ts),
  78. "entry_price": round(float(position["entry_price"]), 4),
  79. "exit_price": round(exit_price, 4),
  80. "pnl": round(pnl, 4),
  81. "return_pct": round(pnl / account_equity * 100.0, 4),
  82. "return_on_margin_pct": round(pnl / margin_used * 100.0, 4),
  83. }
  84. )
  85. exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
  86. return next_equity, pnl > 0.0
  87. def run_segment(candles: list[Candle], params: Params, risk: Risk) -> SegmentResult:
  88. highs = pd.Series([c.high for c in candles], dtype=float)
  89. lows = pd.Series([c.low for c in candles], dtype=float)
  90. closes = pd.Series([c.close for c in candles], dtype=float)
  91. fast = closes.ewm(span=params.fast, adjust=False).mean()
  92. slow = closes.ewm(span=params.slow, adjust=False).mean()
  93. atr = true_range(highs, lows, closes).rolling(params.atr).mean()
  94. entry_high = highs.shift(1).rolling(params.entry).max()
  95. entry_low = lows.shift(1).rolling(params.entry).min()
  96. exit_high = highs.shift(1).rolling(params.exit).max()
  97. exit_low = lows.shift(1).rolling(params.exit).min()
  98. rsi = explore._compute_rsi(closes, 5)
  99. warmup = max(params.slow, params.entry, params.exit, params.atr, 8)
  100. account_equity = explore.INITIAL_EQUITY
  101. ending_equity = account_equity
  102. peak_equity = account_equity
  103. max_drawdown = 0.0
  104. wins = 0
  105. trades: list[dict[str, object]] = []
  106. entries: list[dict[str, object]] = []
  107. exits: list[dict[str, object]] = []
  108. equity_curve: list[dict[str, float | int]] = []
  109. position: dict[str, object] | None = None
  110. pending_side: str | None = None
  111. pending_exit = False
  112. for index in range(warmup, len(candles)):
  113. candle = candles[index]
  114. if pending_exit and position is not None:
  115. account_equity, won = close_trade(
  116. trades=trades,
  117. exits=exits,
  118. account_equity=account_equity,
  119. position=position,
  120. candle=candle,
  121. exit_price=candle.open,
  122. risk=risk,
  123. )
  124. wins += 1 if won else 0
  125. position = None
  126. pending_exit = False
  127. if pending_side is not None and position is None and account_equity > 0.0:
  128. side = pending_side
  129. current_atr = float(atr.iloc[index - 1])
  130. margin_used = account_equity * risk.position_fraction
  131. position = {
  132. "side": side,
  133. "entry_time": candle.ts,
  134. "entry_price": candle.open,
  135. "entry_index": index,
  136. "margin_used": margin_used,
  137. "stop_price": candle.open - params.stop_atr * current_atr if side == "long" else candle.open + params.stop_atr * current_atr,
  138. "take_price": candle.open + params.take_atr * current_atr if side == "long" else candle.open - params.take_atr * current_atr,
  139. }
  140. entries.append({"ts": candle.ts, "price": candle.open, "side": side})
  141. pending_side = None
  142. current_equity = account_equity
  143. if position is not None:
  144. side = str(position["side"])
  145. stop_hit = (side == "long" and candle.low <= float(position["stop_price"])) or (
  146. side == "short" and candle.high >= float(position["stop_price"])
  147. )
  148. take_hit = (side == "long" and candle.high >= float(position["take_price"])) or (
  149. side == "short" and candle.low <= float(position["take_price"])
  150. )
  151. if stop_hit or take_hit:
  152. exit_price = float(position["stop_price"] if stop_hit else position["take_price"])
  153. account_equity, won = close_trade(
  154. trades=trades,
  155. exits=exits,
  156. account_equity=account_equity,
  157. position=position,
  158. candle=candle,
  159. exit_price=exit_price,
  160. risk=risk,
  161. )
  162. wins += 1 if won else 0
  163. current_equity = account_equity
  164. position = None
  165. if position is not None:
  166. margin_used = float(position["margin_used"])
  167. position_equity = mark_to_market(
  168. side=str(position["side"]),
  169. margin_used=margin_used,
  170. entry_price=float(position["entry_price"]),
  171. mark_price=candle.close,
  172. leverage=risk.leverage,
  173. )
  174. current_equity = account_equity - margin_used + position_equity
  175. peak_equity = max(peak_equity, current_equity)
  176. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  177. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  178. ending_equity = current_equity
  179. if index == len(candles) - 1 or account_equity <= 0.0:
  180. continue
  181. if position is not None:
  182. held = index - int(position["entry_index"])
  183. side = str(position["side"])
  184. if side == "long":
  185. pending_exit = candle.close < float(exit_low.iloc[index]) or fast.iloc[index] < slow.iloc[index] or held >= params.max_hold
  186. else:
  187. pending_exit = candle.close > float(exit_high.iloc[index]) or fast.iloc[index] > slow.iloc[index] or held >= params.max_hold
  188. continue
  189. if params.family == "donchian":
  190. if candle.close > float(entry_high.iloc[index]) and fast.iloc[index] > slow.iloc[index]:
  191. pending_side = "long"
  192. elif candle.close < float(entry_low.iloc[index]) and fast.iloc[index] < slow.iloc[index]:
  193. pending_side = "short"
  194. elif params.family == "ema_cross":
  195. prev_fast = fast.iloc[index - 1]
  196. prev_slow = slow.iloc[index - 1]
  197. if prev_fast <= prev_slow and fast.iloc[index] > slow.iloc[index]:
  198. pending_side = "long"
  199. elif prev_fast >= prev_slow and fast.iloc[index] < slow.iloc[index]:
  200. pending_side = "short"
  201. elif params.family == "trend_pullback":
  202. if fast.iloc[index] > slow.iloc[index] and candle.close <= fast.iloc[index] and rsi[index] <= 45:
  203. pending_side = "long"
  204. elif fast.iloc[index] < slow.iloc[index] and candle.close >= fast.iloc[index] and rsi[index] >= 55:
  205. pending_side = "short"
  206. trade_count = len(trades)
  207. return SegmentResult(
  208. trade_count=trade_count,
  209. total_return=(ending_equity - explore.INITIAL_EQUITY) / explore.INITIAL_EQUITY,
  210. win_rate=wins / trade_count if trade_count else 0.0,
  211. max_drawdown=max_drawdown,
  212. trades=trades,
  213. open_position=position,
  214. candles=candles[warmup:],
  215. equity_curve=equity_curve,
  216. entries=entries,
  217. exits=exits,
  218. )
  219. def daily_equity(result: SegmentResult) -> pd.Series:
  220. frame = pd.DataFrame(
  221. {
  222. "ts": [pd.to_datetime(point["ts"], unit="ms", utc=True) for point in result.equity_curve],
  223. "equity": [float(point["equity"]) for point in result.equity_curve],
  224. }
  225. )
  226. series = frame.set_index("ts")["equity"].sort_index()
  227. index = pd.date_range(series.index[0].normalize(), series.index[-1].normalize(), freq="1D", tz="UTC")
  228. return series.reindex(index.union(series.index)).sort_index().ffill().reindex(index).fillna(explore.INITIAL_EQUITY)
  229. def metrics_from_series(series: pd.Series) -> dict[str, float]:
  230. if len(series) < 2:
  231. return {"total_return": 0.0, "annualized_return": 0.0, "max_drawdown": 0.0, "calmar": 0.0}
  232. years = (series.index[-1] - series.index[0]).total_seconds() / 86_400 / 365
  233. total = float(series.iloc[-1] / series.iloc[0] - 1.0)
  234. annual = (1.0 + total) ** (1.0 / years) - 1.0 if total > -1.0 and years > 0.0 else 0.0
  235. drawdown = explore.max_drawdown_from_equity([float(value) for value in series])
  236. return {
  237. "total_return": total,
  238. "annualized_return": annual,
  239. "max_drawdown": drawdown,
  240. "calmar": annual / drawdown if drawdown else 0.0,
  241. }
  242. def trade_stats(trades: list[dict[str, object]]) -> dict[str, float | int]:
  243. returns = [float(trade["return_pct"]) / 100.0 for trade in trades]
  244. wins = [value for value in returns if value > 0.0]
  245. losses = [value for value in returns if value < 0.0]
  246. avg_win = sum(wins) / len(wins) if wins else 0.0
  247. avg_loss = abs(sum(losses) / len(losses)) if losses else 0.0
  248. return {
  249. "trades": len(returns),
  250. "win_rate": len(wins) / len(returns) if returns else 0.0,
  251. "payoff_ratio": avg_win / avg_loss if avg_loss else 0.0,
  252. }
  253. def trades_between(trades: list[dict[str, object]], start: pd.Timestamp, end: pd.Timestamp) -> list[dict[str, object]]:
  254. rows = []
  255. for trade in trades:
  256. exit_time = pd.to_datetime(str(trade["exit_time"]), utc=True)
  257. if start <= exit_time <= end:
  258. rows.append(trade)
  259. return rows
  260. def horizon_returns(series: pd.Series) -> dict[str, float]:
  261. out: dict[str, float] = {}
  262. end = series.index[-1]
  263. for label, offset in HORIZONS:
  264. scoped = series[series.index >= end - offset]
  265. out[f"return_{label}"] = float(scoped.iloc[-1] / scoped.iloc[0] - 1.0) if len(scoped) >= 2 else 0.0
  266. return out
  267. def monthly_rows(name: str, params: Params, risk: Risk, series: pd.Series) -> pd.DataFrame:
  268. monthly = series.resample("ME").last()
  269. frame = pd.DataFrame(
  270. {
  271. "name": name,
  272. "symbol": params.symbol,
  273. "bar": params.bar,
  274. "family": params.family,
  275. "risk": risk.label,
  276. "month": monthly.index.strftime("%Y-%m"),
  277. "start_equity": monthly.shift(1).fillna(series.iloc[0]).to_numpy(),
  278. "end_equity": monthly.to_numpy(),
  279. }
  280. )
  281. frame["return"] = frame["end_equity"] / frame["start_equity"] - 1.0
  282. return frame
  283. def period_row(
  284. *,
  285. name: str,
  286. period_type: str,
  287. period: str,
  288. params: Params,
  289. risk: Risk,
  290. series: pd.Series,
  291. trades: list[dict[str, object]],
  292. ) -> dict[str, object]:
  293. metrics = metrics_from_series(series)
  294. stats = trade_stats(trades)
  295. return {
  296. "name": name,
  297. "period_type": period_type,
  298. "period": period,
  299. "symbol": params.symbol,
  300. "bar": params.bar,
  301. "family": params.family,
  302. "risk": risk.label,
  303. "start": series.index[0].strftime("%Y-%m-%d"),
  304. "end": series.index[-1].strftime("%Y-%m-%d"),
  305. **metrics,
  306. **stats,
  307. }
  308. def yearly_rows(name: str, params: Params, risk: Risk, series: pd.Series, trades: list[dict[str, object]]) -> list[dict[str, object]]:
  309. rows: list[dict[str, object]] = []
  310. for year, scoped in series.groupby(series.index.year):
  311. if len(scoped) < 2:
  312. continue
  313. start = scoped.index[0]
  314. end = scoped.index[-1]
  315. rows.append(
  316. period_row(
  317. name=name,
  318. period_type="year",
  319. period=str(year),
  320. params=params,
  321. risk=risk,
  322. series=scoped,
  323. trades=trades_between(trades, start, end),
  324. )
  325. )
  326. return rows
  327. def phase_rows(name: str, params: Params, risk: Risk, series: pd.Series, trades: list[dict[str, object]]) -> list[dict[str, object]]:
  328. rows: list[dict[str, object]] = []
  329. for label, raw_start, raw_end in MARKET_PHASES:
  330. start = pd.Timestamp(raw_start, tz="UTC")
  331. end = pd.Timestamp(raw_end, tz="UTC")
  332. scoped = series[(series.index >= start) & (series.index <= end)]
  333. if len(scoped) < 2:
  334. continue
  335. rows.append(
  336. period_row(
  337. name=name,
  338. period_type="market_phase",
  339. period=label,
  340. params=params,
  341. risk=risk,
  342. series=scoped,
  343. trades=trades_between(trades, scoped.index[0], scoped.index[-1]),
  344. )
  345. )
  346. return rows
  347. def rolling_3y_worst(series: pd.Series) -> dict[str, object]:
  348. monthly = series.resample("ME").last()
  349. rows: list[dict[str, object]] = []
  350. for start in monthly.index:
  351. end = start + pd.DateOffset(years=3)
  352. scoped = series[(series.index >= start) & (series.index <= end)]
  353. if len(scoped) < 365 * 2:
  354. continue
  355. metrics = metrics_from_series(scoped)
  356. rows.append(
  357. {
  358. "rolling_3y_start": scoped.index[0].strftime("%Y-%m-%d"),
  359. "rolling_3y_end": scoped.index[-1].strftime("%Y-%m-%d"),
  360. "rolling_3y_total_return": metrics["total_return"],
  361. "rolling_3y_annualized_return": metrics["annualized_return"],
  362. "rolling_3y_max_drawdown": metrics["max_drawdown"],
  363. "rolling_3y_calmar": metrics["calmar"],
  364. }
  365. )
  366. if not rows:
  367. return {
  368. "rolling_3y_start": "",
  369. "rolling_3y_end": "",
  370. "rolling_3y_total_return": 0.0,
  371. "rolling_3y_annualized_return": 0.0,
  372. "rolling_3y_max_drawdown": 0.0,
  373. "rolling_3y_calmar": 0.0,
  374. }
  375. return min(rows, key=lambda row: float(row["rolling_3y_total_return"]))
  376. def params_from_totals(path: Path) -> list[Params]:
  377. totals = pd.read_csv(path)
  378. scoped = totals[totals["bar"].isin(BARS)].copy()
  379. scoped = scoped.sort_values(["symbol", "bar", "family", "fast", "slow", "entry", "exit", "stop_atr", "take_atr"])
  380. rows: list[Params] = []
  381. for row in scoped.itertuples(index=False):
  382. rows.append(
  383. Params(
  384. symbol=str(row.symbol),
  385. bar=str(row.bar),
  386. family=str(row.family),
  387. fast=int(row.fast),
  388. slow=int(row.slow),
  389. entry=int(row.entry),
  390. exit=int(row.exit),
  391. atr=int(row.atr),
  392. stop_atr=float(row.stop_atr),
  393. take_atr=float(row.take_atr),
  394. max_hold=int(row.max_hold),
  395. )
  396. )
  397. return rows
  398. def candidate_name(params: Params, risk: Risk) -> str:
  399. return f"{params.name}-{risk.label}"
  400. def verdict(totals: pd.DataFrame) -> str:
  401. daily_ema = totals[(totals["bar"] == "1D") & (totals["family"] == "ema_cross")]
  402. daily_ema_pass = daily_ema[
  403. (daily_ema["calmar"] > 0.8)
  404. & (daily_ema["return_1y"] >= 0.0)
  405. & (daily_ema["return_6m"] >= 0.0)
  406. & (daily_ema["return_3m"] >= 0.0)
  407. ]
  408. liquid_pass = totals[
  409. (totals["trades"] >= 60)
  410. & (totals["calmar"] > 0.8)
  411. & (totals["return_1y"] >= 0.0)
  412. & (totals["return_6m"] >= 0.0)
  413. & (totals["return_3m"] >= 0.0)
  414. ]
  415. if daily_ema.empty:
  416. return "No 1D EMA branch was present in the validation input."
  417. max_daily_trades = int(daily_ema["trades"].max())
  418. if daily_ema_pass.empty:
  419. return (
  420. "Exclude the 1D EMA branch: it does not pass Calmar > 0.8 with non-negative 1y/6m/3m returns "
  421. f"under lower-risk validation. Its maximum trade count is {max_daily_trades}, so the sample is also too small."
  422. )
  423. if max_daily_trades < 30:
  424. return (
  425. "Exclude the 1D EMA branch despite headline metrics: every passing 1D EMA row has fewer than 30 trades, "
  426. "so the result is dominated by a small number of exits rather than repeatable evidence."
  427. )
  428. if liquid_pass.empty:
  429. return "No validated candidate has both adequate trade count and non-negative 1y/6m/3m returns."
  430. return "Keep only the liquid candidates listed in the validated table; 1D EMA is not the preferred branch."
  431. def markdown_report(
  432. command: str,
  433. paths: list[Path],
  434. totals: pd.DataFrame,
  435. periods: pd.DataFrame,
  436. monthly: pd.DataFrame,
  437. ) -> str:
  438. validated = totals[
  439. (totals["trades"] >= 60)
  440. & (totals["calmar"] > 0.8)
  441. & (totals["return_1y"] >= 0.0)
  442. & (totals["return_6m"] >= 0.0)
  443. & (totals["return_3m"] >= 0.0)
  444. ].sort_values(["calmar", "trades"], ascending=[False, False])
  445. daily_ema = totals[(totals["bar"] == "1D") & (totals["family"] == "ema_cross")].sort_values("calmar", ascending=False)
  446. top = totals.sort_values(["calmar", "trades"], ascending=[False, False]).head(12)
  447. lines = [
  448. "# Trend validation",
  449. "",
  450. f"Run command: `{command}`",
  451. "",
  452. "Output files:",
  453. *[f"- `{path}`" for path in paths],
  454. "",
  455. "Scope: existing trend-swing 1D and 4H parameter rows, rerun on BTC-USDT-SWAP and ETH-USDT-SWAP local 15m cache.",
  456. "Risk profiles: 1x full notional, 2x half notional, 1x half notional. Fee is 0.04% per side.",
  457. "Validation pass: trades >= 60, Calmar > 0.8, and non-negative 1y/6m/3m returns.",
  458. "",
  459. "## Verdict",
  460. "",
  461. verdict(totals),
  462. "",
  463. "## Validated candidates",
  464. "",
  465. markdown_table(
  466. validated[
  467. [
  468. "name",
  469. "symbol",
  470. "bar",
  471. "family",
  472. "risk",
  473. "trades",
  474. "total_return",
  475. "annualized_return",
  476. "max_drawdown",
  477. "calmar",
  478. "win_rate",
  479. "payoff_ratio",
  480. "return_3y",
  481. "return_1y",
  482. "return_6m",
  483. "return_3m",
  484. "rolling_3y_total_return",
  485. "rolling_3y_calmar",
  486. ]
  487. ].head(20)
  488. if not validated.empty
  489. else pd.DataFrame(columns=["result"])
  490. ),
  491. "",
  492. "## Headline ranking",
  493. "",
  494. markdown_table(
  495. top[
  496. [
  497. "name",
  498. "symbol",
  499. "bar",
  500. "family",
  501. "risk",
  502. "trades",
  503. "total_return",
  504. "annualized_return",
  505. "max_drawdown",
  506. "calmar",
  507. "return_1y",
  508. "return_6m",
  509. "return_3m",
  510. "rolling_3y_total_return",
  511. ]
  512. ]
  513. ),
  514. "",
  515. "## 1D EMA sample check",
  516. "",
  517. markdown_table(
  518. daily_ema[
  519. [
  520. "name",
  521. "symbol",
  522. "risk",
  523. "trades",
  524. "total_return",
  525. "annualized_return",
  526. "max_drawdown",
  527. "calmar",
  528. "return_1y",
  529. "return_6m",
  530. "return_3m",
  531. "rolling_3y_total_return",
  532. ]
  533. ].head(12)
  534. ),
  535. "",
  536. "## Worst rolling 3-year by symbol/bar",
  537. "",
  538. markdown_table(
  539. totals.sort_values("rolling_3y_total_return")[
  540. [
  541. "name",
  542. "symbol",
  543. "bar",
  544. "family",
  545. "risk",
  546. "trades",
  547. "rolling_3y_start",
  548. "rolling_3y_end",
  549. "rolling_3y_total_return",
  550. "rolling_3y_annualized_return",
  551. "rolling_3y_max_drawdown",
  552. "rolling_3y_calmar",
  553. ]
  554. ].head(16)
  555. ),
  556. "",
  557. "## Year and market phase files",
  558. "",
  559. "Yearly and bull/bear phase metrics are written to the periods CSV. Monthly returns are written to the monthly CSV.",
  560. ]
  561. return "\n".join(lines) + "\n"
  562. def main() -> int:
  563. parser = argparse.ArgumentParser()
  564. parser.add_argument("--years", type=float, default=YEARS)
  565. parser.add_argument("--input-totals", type=Path, default=INPUT_TOTALS)
  566. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  567. parser.add_argument("--max-candidates", type=int, default=0)
  568. args = parser.parse_args()
  569. params_grid = params_from_totals(args.input_totals)
  570. if args.max_candidates:
  571. params_grid = params_grid[: args.max_candidates]
  572. raw = {symbol: load_15m_frame(symbol, args.years) for symbol in SYMBOLS}
  573. candles = {
  574. (symbol, bar): frame_to_candles(symbol, resample_frame(raw[symbol], bar))
  575. for symbol in SYMBOLS
  576. for bar in BARS
  577. }
  578. total_rows: list[dict[str, object]] = []
  579. period_rows: list[dict[str, object]] = []
  580. monthly_frames: list[pd.DataFrame] = []
  581. tasks = [(params, risk) for params in params_grid for risk in RISK_PROFILES]
  582. for index, (params, risk) in enumerate(tasks, start=1):
  583. result = run_segment(candles[(params.symbol, params.bar)], params, risk)
  584. series = daily_equity(result)
  585. name = candidate_name(params, risk)
  586. monthly = monthly_rows(name, params, risk, series)
  587. row = {
  588. "name": name,
  589. **params.__dict__,
  590. "risk": risk.label,
  591. "leverage": risk.leverage,
  592. "position_fraction": risk.position_fraction,
  593. "first_candle": series.index[0].strftime("%Y-%m-%d"),
  594. "last_candle": series.index[-1].strftime("%Y-%m-%d"),
  595. "fee_single_side": FEE_SINGLE_SIDE,
  596. "worst_month_return": float(monthly["return"].min()),
  597. **metrics_from_series(series),
  598. **trade_stats(result.trades),
  599. **horizon_returns(series),
  600. **rolling_3y_worst(series),
  601. }
  602. total_rows.append(row)
  603. period_rows.extend(yearly_rows(name, params, risk, series, result.trades))
  604. period_rows.extend(phase_rows(name, params, risk, series, result.trades))
  605. monthly_frames.append(monthly)
  606. print(f"done {index}/{len(tasks)} {name}", flush=True)
  607. totals = pd.DataFrame(total_rows).sort_values(
  608. ["calmar", "annualized_return", "trades"],
  609. ascending=[False, False, False],
  610. )
  611. periods = pd.DataFrame(period_rows)
  612. monthly_all = pd.concat(monthly_frames, ignore_index=True)
  613. validated = totals[
  614. (totals["trades"] >= 60)
  615. & (totals["calmar"] > 0.8)
  616. & (totals["return_1y"] >= 0.0)
  617. & (totals["return_6m"] >= 0.0)
  618. & (totals["return_3m"] >= 0.0)
  619. ].sort_values(["calmar", "trades"], ascending=[False, False])
  620. args.output_dir.mkdir(parents=True, exist_ok=True)
  621. totals_path = args.output_dir / f"{PREFIX}-totals.csv"
  622. periods_path = args.output_dir / f"{PREFIX}-periods.csv"
  623. monthly_path = args.output_dir / f"{PREFIX}-monthly-returns.csv"
  624. validated_path = args.output_dir / f"{PREFIX}-validated.csv"
  625. best_path = args.output_dir / f"{PREFIX}-best.json"
  626. report_path = args.output_dir / f"{PREFIX}-report.md"
  627. totals.to_csv(totals_path, index=False)
  628. periods.to_csv(periods_path, index=False)
  629. monthly_all.to_csv(monthly_path, index=False)
  630. validated.to_csv(validated_path, index=False)
  631. best_path.write_text(json.dumps(validated.head(20).to_dict(orient="records"), indent=2), encoding="utf-8")
  632. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --years {args.years}"
  633. report_path.write_text(
  634. markdown_report(command, [totals_path, periods_path, monthly_path, validated_path, best_path, report_path], totals, periods, monthly_all),
  635. encoding="utf-8",
  636. )
  637. print(validated.head(10).to_string(index=False, formatters={col: format_cell for col in validated.columns}))
  638. print(verdict(totals))
  639. return 0
  640. if __name__ == "__main__":
  641. raise SystemExit(main())