search_eth_risk_exit_variants.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
  1. from __future__ import annotations
  2. import argparse
  3. from dataclasses import dataclass
  4. from math import sqrt
  5. from pathlib import Path
  6. import pandas as pd
  7. from okx_codex_trader.models import Candle
  8. from okx_codex_trader.rsi2_report import _compute_rsi
  9. from okx_codex_trader.sampled_report import SegmentResult, mark_to_market, trade_equity
  10. SYMBOL = "ETH-USDT-SWAP"
  11. BAR = "15m"
  12. LEVERAGE = 3
  13. INITIAL_EQUITY = 10_000.0
  14. DATA_DIR = Path("data/okx-candles")
  15. OUTPUT_DIR = Path("reports/eth-exploration")
  16. COSTS = {
  17. "maker_maker": 0.0012,
  18. "maker_taker": 0.0021,
  19. "taker_taker": 0.0030,
  20. }
  21. HORIZONS = (
  22. ("3y", pd.DateOffset(years=3)),
  23. ("1y", pd.DateOffset(years=1)),
  24. ("6m", pd.DateOffset(months=6)),
  25. ("3m", pd.DateOffset(months=3)),
  26. )
  27. @dataclass(frozen=True)
  28. class RiskConfig:
  29. entry_mode: str
  30. trend_sma: int
  31. rsi_threshold: float
  32. exit_rsi: float
  33. stop_mode: str
  34. stop_loss_pct: float
  35. atr_length: int
  36. atr_multiple: float
  37. atr_floor_pct: float
  38. atr_ceiling_pct: float
  39. first_bar_stop_pct: float
  40. max_hold_bars: int
  41. take_profit_pct: float
  42. take_profit_fraction: float
  43. max_gap_pct: float
  44. max_range_pct: float
  45. entry_offsets: tuple[float, ...]
  46. entry_valid_bars: int
  47. def _format_ts(ts: int) -> str:
  48. return pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
  49. def _load_candles(symbol: str, bar: str) -> list[Candle]:
  50. path = DATA_DIR / symbol / f"{bar}.csv"
  51. frame = pd.read_csv(path)
  52. return [
  53. Candle(
  54. symbol=symbol,
  55. ts=int(row.ts),
  56. open=float(row.open),
  57. high=float(row.high),
  58. low=float(row.low),
  59. close=float(row.close),
  60. volume=float(row.volume),
  61. )
  62. for row in frame.itertuples(index=False)
  63. ]
  64. def _atr(candles: list[Candle], length: int) -> list[float]:
  65. rows = []
  66. previous_close = None
  67. for candle in candles:
  68. if previous_close is None:
  69. rows.append(candle.high - candle.low)
  70. else:
  71. rows.append(max(candle.high - candle.low, abs(candle.high - previous_close), abs(candle.low - previous_close)))
  72. previous_close = candle.close
  73. return pd.Series(rows, dtype=float).rolling(length).mean().tolist()
  74. def _stop_pct(config: RiskConfig, atr_value: float, entry_price: float) -> float:
  75. if config.stop_mode == "none":
  76. return 0.0
  77. if config.stop_mode == "fixed":
  78. return config.stop_loss_pct
  79. raw = config.atr_multiple * atr_value / entry_price
  80. return min(max(raw, config.atr_floor_pct), config.atr_ceiling_pct)
  81. def _append_trade(
  82. *,
  83. trades: list[dict[str, object]],
  84. exits: list[dict[str, object]],
  85. position: dict[str, object],
  86. candle: Candle,
  87. exit_price: float,
  88. margin_used: float,
  89. account_equity: float,
  90. ) -> tuple[float, bool]:
  91. exit_equity = trade_equity(
  92. side="long",
  93. margin_used=margin_used,
  94. entry_price=float(position["entry_price"]),
  95. exit_price=exit_price,
  96. leverage=LEVERAGE,
  97. )
  98. pnl = exit_equity - margin_used
  99. trades.append(
  100. {
  101. "side": "Long",
  102. "entry_time": _format_ts(int(position["entry_time"])),
  103. "exit_time": _format_ts(candle.ts),
  104. "entry_price": round(float(position["entry_price"]), 4),
  105. "exit_price": round(exit_price, 4),
  106. "pnl": round(pnl, 4),
  107. "return_pct": round(pnl / margin_used * 100.0, 4),
  108. "cost_weight": round(margin_used / account_equity, 8),
  109. }
  110. )
  111. exits.append({"ts": candle.ts, "price": exit_price, "side": "long"})
  112. return account_equity + pnl, pnl > 0.0
  113. def run_risk_exit_segment(candles: list[Candle], config: RiskConfig) -> SegmentResult:
  114. closes = pd.Series([candle.close for candle in candles], dtype=float)
  115. trend = closes.rolling(config.trend_sma).mean().tolist()
  116. rsi_values = _compute_rsi(closes, 2)
  117. atr_values = _atr(candles, config.atr_length)
  118. warmup_bars = max(config.trend_sma, config.atr_length, 3)
  119. equity = INITIAL_EQUITY
  120. ending_equity = equity
  121. peak_equity = equity
  122. max_drawdown = 0.0
  123. wins = 0
  124. trades: list[dict[str, object]] = []
  125. entries: list[dict[str, object]] = []
  126. exits: list[dict[str, object]] = []
  127. equity_curve: list[dict[str, float | int]] = []
  128. position: dict[str, object] | None = None
  129. pending_market_entry = False
  130. pending_limits: list[dict[str, float | int]] = []
  131. pending_exit = False
  132. for index in range(warmup_bars, len(candles)):
  133. candle = candles[index]
  134. if pending_exit and position is not None:
  135. equity, won = _append_trade(
  136. trades=trades,
  137. exits=exits,
  138. position=position,
  139. candle=candle,
  140. exit_price=candle.open,
  141. margin_used=float(position["margin_used"]),
  142. account_equity=equity,
  143. )
  144. wins += int(won)
  145. position = None
  146. pending_exit = False
  147. pending_limits = []
  148. if pending_market_entry and position is None and equity > 0.0:
  149. stop_pct = _stop_pct(config, atr_values[index], candle.open)
  150. position = {
  151. "entry_time": candle.ts,
  152. "entry_price": candle.open,
  153. "entry_index": index,
  154. "margin_used": equity,
  155. "stop_pct": stop_pct,
  156. "stop_price": candle.open * (1.0 - stop_pct) if stop_pct else 0.0,
  157. "first_bar_stop_price": candle.open * (1.0 - config.first_bar_stop_pct) if config.first_bar_stop_pct else 0.0,
  158. "took_profit": False,
  159. }
  160. entries.append({"ts": candle.ts, "price": candle.open, "side": "long"})
  161. pending_market_entry = False
  162. active_limits: list[dict[str, float | int]] = []
  163. for limit in pending_limits:
  164. if index > int(limit["expires_index"]):
  165. continue
  166. limit_price = float(limit["price"])
  167. if candle.low <= limit_price and equity > 0.0:
  168. slice_margin = equity / len(config.entry_offsets)
  169. if position is None:
  170. stop_pct = _stop_pct(config, atr_values[index], limit_price)
  171. position = {
  172. "entry_time": candle.ts,
  173. "entry_price": limit_price,
  174. "entry_index": index,
  175. "margin_used": slice_margin,
  176. "stop_pct": stop_pct,
  177. "stop_price": limit_price * (1.0 - stop_pct) if stop_pct else 0.0,
  178. "first_bar_stop_price": limit_price * (1.0 - config.first_bar_stop_pct) if config.first_bar_stop_pct else 0.0,
  179. "took_profit": False,
  180. }
  181. else:
  182. old_margin = float(position["margin_used"])
  183. new_margin = old_margin + slice_margin
  184. entry_price = (float(position["entry_price"]) * old_margin + limit_price * slice_margin) / new_margin
  185. stop_pct = _stop_pct(config, atr_values[index], entry_price)
  186. position["entry_price"] = entry_price
  187. position["margin_used"] = new_margin
  188. position["stop_pct"] = stop_pct
  189. position["stop_price"] = entry_price * (1.0 - stop_pct) if stop_pct else 0.0
  190. position["first_bar_stop_price"] = entry_price * (1.0 - config.first_bar_stop_pct) if config.first_bar_stop_pct else 0.0
  191. entries.append({"ts": candle.ts, "price": limit_price, "side": "long"})
  192. else:
  193. active_limits.append(limit)
  194. pending_limits = active_limits
  195. current_equity = equity
  196. if position is not None:
  197. stop_price = float(position["stop_price"])
  198. first_bar_stop = float(position["first_bar_stop_price"])
  199. if first_bar_stop and index == int(position["entry_index"]):
  200. stop_price = max(stop_price, first_bar_stop) if stop_price else first_bar_stop
  201. take_profit_price = float(position["entry_price"]) * (1.0 + config.take_profit_pct) if config.take_profit_pct else 0.0
  202. if stop_price and candle.low <= stop_price:
  203. equity, won = _append_trade(
  204. trades=trades,
  205. exits=exits,
  206. position=position,
  207. candle=candle,
  208. exit_price=stop_price,
  209. margin_used=float(position["margin_used"]),
  210. account_equity=equity,
  211. )
  212. wins += int(won)
  213. current_equity = equity
  214. position = None
  215. pending_limits = []
  216. elif take_profit_price and not bool(position["took_profit"]) and candle.high >= take_profit_price:
  217. close_margin = float(position["margin_used"]) * config.take_profit_fraction
  218. equity, won = _append_trade(
  219. trades=trades,
  220. exits=exits,
  221. position=position,
  222. candle=candle,
  223. exit_price=take_profit_price,
  224. margin_used=close_margin,
  225. account_equity=equity,
  226. )
  227. wins += int(won)
  228. position["margin_used"] = float(position["margin_used"]) - close_margin
  229. position["took_profit"] = True
  230. current_equity = equity
  231. if float(position["margin_used"]) <= 0.0:
  232. position = None
  233. pending_limits = []
  234. if position is not None:
  235. position_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. current_equity = equity - float(position["margin_used"]) + position_equity
  243. peak_equity = max(peak_equity, current_equity)
  244. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  245. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  246. ending_equity = current_equity
  247. if index == len(candles) - 1 or equity <= 0.0:
  248. continue
  249. current_rsi = rsi_values[index]
  250. current_trend = trend[index]
  251. current_atr = atr_values[index]
  252. if current_rsi != current_rsi or current_trend != current_trend or current_atr != current_atr:
  253. continue
  254. if position is not None:
  255. held_bars = index - int(position["entry_index"])
  256. if current_rsi >= config.exit_rsi or held_bars >= config.max_hold_bars:
  257. pending_exit = True
  258. pending_limits = []
  259. continue
  260. if pending_limits:
  261. continue
  262. previous_close = candles[index - 1].close
  263. gap_pct = abs(candle.open / previous_close - 1.0)
  264. range_pct = (candle.high - candle.low) / candle.close
  265. filter_passed = (not config.max_gap_pct or gap_pct <= config.max_gap_pct) and (
  266. not config.max_range_pct or range_pct <= config.max_range_pct
  267. )
  268. if filter_passed and candle.close > float(current_trend) and current_rsi <= config.rsi_threshold:
  269. if config.entry_mode == "market":
  270. pending_market_entry = True
  271. else:
  272. pending_limits = [
  273. {"price": candle.close * (1.0 - offset), "expires_index": index + config.entry_valid_bars}
  274. for offset in config.entry_offsets
  275. ]
  276. return SegmentResult(
  277. trade_count=len(trades),
  278. total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
  279. win_rate=wins / len(trades) if trades else 0.0,
  280. max_drawdown=max_drawdown,
  281. trades=trades,
  282. open_position=position,
  283. candles=candles[warmup_bars:],
  284. equity_curve=equity_curve,
  285. entries=entries,
  286. exits=exits,
  287. )
  288. def cost_adjusted_equity_frame(result: SegmentResult, roundtrip_cost: float) -> pd.DataFrame:
  289. frame = pd.DataFrame(result.equity_curve)
  290. frame["ts"] = pd.to_datetime(frame["ts"], unit="ms", utc=True)
  291. frame["gross_equity"] = frame["equity"].astype(float)
  292. frame["cost_factor"] = 1.0
  293. if result.trades:
  294. trade_frame = pd.DataFrame(result.trades)
  295. trade_frame["ts"] = pd.to_datetime(trade_frame["exit_time"], utc=True)
  296. trade_frame["factor"] = 1.0 - roundtrip_cost * trade_frame["cost_weight"].astype(float)
  297. cost_by_ts = trade_frame.groupby("ts")["factor"].prod()
  298. frame["cost_factor"] = frame["ts"].map(cost_by_ts).fillna(1.0)
  299. frame["cost_factor"] = frame["cost_factor"].cumprod()
  300. frame["equity"] = frame["gross_equity"] * frame["cost_factor"]
  301. return frame[["ts", "equity"]]
  302. def max_drawdown(values: list[float]) -> float:
  303. peak = values[0]
  304. drawdown = 0.0
  305. for value in values:
  306. peak = max(peak, value)
  307. drawdown = max(drawdown, (peak - value) / peak)
  308. return drawdown
  309. def annualized_metrics(frame: pd.DataFrame) -> dict[str, float]:
  310. years = (frame["ts"].iloc[-1] - frame["ts"].iloc[0]).total_seconds() / 86_400 / 365
  311. total_return = float(frame["equity"].iloc[-1] / frame["equity"].iloc[0] - 1.0)
  312. annualized_return = (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 and years > 0.0 else 0.0
  313. daily = frame.set_index("ts")["equity"].resample("1D").last().ffill()
  314. daily_returns = daily.pct_change().dropna()
  315. daily_std = float(daily_returns.std(ddof=1)) if len(daily_returns) > 1 else 0.0
  316. sharpe = float(daily_returns.mean()) / daily_std * sqrt(365) if daily_std else 0.0
  317. drawdown = max_drawdown([float(value) for value in frame["equity"]])
  318. return {
  319. "net_total_return": total_return,
  320. "net_annualized_return": annualized_return,
  321. "net_max_drawdown": drawdown,
  322. "net_calmar": annualized_return / drawdown if drawdown else 0.0,
  323. "net_sharpe_daily": sharpe,
  324. }
  325. def horizon_metrics(frame: pd.DataFrame) -> list[dict[str, object]]:
  326. rows = []
  327. end_time = frame["ts"].iloc[-1]
  328. for label, offset in HORIZONS:
  329. cutoff = end_time - offset
  330. before = frame[frame["ts"] <= cutoff]
  331. if len(before):
  332. start_equity = float(before["equity"].iloc[-1])
  333. segment = pd.concat(
  334. [
  335. pd.DataFrame([{"ts": cutoff, "equity": start_equity}]),
  336. frame[frame["ts"] > cutoff][["ts", "equity"]],
  337. ],
  338. ignore_index=True,
  339. )
  340. else:
  341. segment = frame[["ts", "equity"]].copy()
  342. cutoff = segment["ts"].iloc[0]
  343. rows.append(
  344. {
  345. "horizon": label,
  346. "horizon_start": cutoff.strftime("%Y-%m-%d %H:%M"),
  347. "horizon_end": end_time.strftime("%Y-%m-%d %H:%M"),
  348. "horizon_days": (end_time - cutoff).total_seconds() / 86_400,
  349. **annualized_metrics(segment),
  350. }
  351. )
  352. return rows
  353. def build_configs() -> list[RiskConfig]:
  354. base = {
  355. "trend_sma": 50,
  356. "rsi_threshold": 3.0,
  357. "atr_length": 14,
  358. "entry_offsets": (0.001, 0.002, 0.003),
  359. "entry_valid_bars": 4,
  360. }
  361. configs: list[RiskConfig] = []
  362. for entry_mode in ("market", "price_twap"):
  363. configs.append(
  364. RiskConfig(
  365. entry_mode=entry_mode,
  366. exit_rsi=45.0,
  367. stop_mode="none",
  368. stop_loss_pct=0.0,
  369. atr_multiple=0.0,
  370. atr_floor_pct=0.0,
  371. atr_ceiling_pct=0.0,
  372. first_bar_stop_pct=0.0,
  373. max_hold_bars=10_000,
  374. take_profit_pct=0.0,
  375. take_profit_fraction=0.0,
  376. max_gap_pct=0.0,
  377. max_range_pct=0.0,
  378. **base,
  379. )
  380. )
  381. for exit_rsi in (35.0, 45.0):
  382. for max_hold in (24, 48, 96):
  383. for stop_mode, stop, atr_mult, floor, ceiling in (
  384. ("fixed", 0.006, 0.0, 0.0, 0.0),
  385. ("atr", 0.0, 1.2, 0.004, 0.012),
  386. ("atr", 0.0, 1.6, 0.005, 0.016),
  387. ):
  388. configs.append(
  389. RiskConfig(
  390. entry_mode=entry_mode,
  391. exit_rsi=exit_rsi,
  392. stop_mode=stop_mode,
  393. stop_loss_pct=stop,
  394. atr_multiple=atr_mult,
  395. atr_floor_pct=floor,
  396. atr_ceiling_pct=ceiling,
  397. first_bar_stop_pct=0.0,
  398. max_hold_bars=max_hold,
  399. take_profit_pct=0.0,
  400. take_profit_fraction=0.0,
  401. max_gap_pct=0.0,
  402. max_range_pct=0.0,
  403. **base,
  404. )
  405. )
  406. for first_bar_stop in (0.004, 0.006):
  407. configs.append(
  408. RiskConfig(
  409. entry_mode=entry_mode,
  410. exit_rsi=45.0,
  411. stop_mode="atr",
  412. stop_loss_pct=0.0,
  413. atr_multiple=1.2,
  414. atr_floor_pct=0.004,
  415. atr_ceiling_pct=0.012,
  416. first_bar_stop_pct=first_bar_stop,
  417. max_hold_bars=48,
  418. take_profit_pct=0.0,
  419. take_profit_fraction=0.0,
  420. max_gap_pct=0.0,
  421. max_range_pct=0.0,
  422. **base,
  423. )
  424. )
  425. for take_profit in (0.008, 0.012):
  426. configs.append(
  427. RiskConfig(
  428. entry_mode=entry_mode,
  429. exit_rsi=45.0,
  430. stop_mode="atr",
  431. stop_loss_pct=0.0,
  432. atr_multiple=1.2,
  433. atr_floor_pct=0.004,
  434. atr_ceiling_pct=0.012,
  435. first_bar_stop_pct=0.004,
  436. max_hold_bars=48,
  437. take_profit_pct=take_profit,
  438. take_profit_fraction=0.5,
  439. max_gap_pct=0.0,
  440. max_range_pct=0.0,
  441. **base,
  442. )
  443. )
  444. for max_gap, max_range in ((0.006, 0.018), (0.010, 0.024)):
  445. configs.append(
  446. RiskConfig(
  447. entry_mode=entry_mode,
  448. exit_rsi=45.0,
  449. stop_mode="atr",
  450. stop_loss_pct=0.0,
  451. atr_multiple=1.2,
  452. atr_floor_pct=0.004,
  453. atr_ceiling_pct=0.012,
  454. first_bar_stop_pct=0.004,
  455. max_hold_bars=48,
  456. take_profit_pct=0.0,
  457. take_profit_fraction=0.0,
  458. max_gap_pct=max_gap,
  459. max_range_pct=max_range,
  460. **base,
  461. )
  462. )
  463. return configs
  464. def config_name(config: RiskConfig) -> str:
  465. stop = config.stop_mode
  466. if config.stop_mode == "fixed":
  467. stop += f"{config.stop_loss_pct:.4f}"
  468. if config.stop_mode == "atr":
  469. stop += f"{config.atr_multiple:.1f}-{config.atr_floor_pct:.4f}-{config.atr_ceiling_pct:.4f}"
  470. parts = [
  471. config.entry_mode,
  472. f"t{config.trend_sma}",
  473. f"r{config.rsi_threshold:.1f}",
  474. f"x{config.exit_rsi:.1f}",
  475. f"stop-{stop}",
  476. f"mh{config.max_hold_bars}",
  477. ]
  478. if config.first_bar_stop_pct:
  479. parts.append(f"fb{config.first_bar_stop_pct:.4f}")
  480. if config.take_profit_pct:
  481. parts.append(f"tp{config.take_profit_pct:.4f}p{config.take_profit_fraction:.2f}")
  482. if config.max_gap_pct or config.max_range_pct:
  483. parts.append(f"gap{config.max_gap_pct:.4f}range{config.max_range_pct:.4f}")
  484. return "-".join(parts)
  485. def run_search() -> tuple[pd.DataFrame, pd.DataFrame]:
  486. candles = _load_candles(SYMBOL, BAR)
  487. total_rows: list[dict[str, object]] = []
  488. horizon_rows: list[dict[str, object]] = []
  489. configs = build_configs()
  490. for index, config in enumerate(configs, start=1):
  491. name = config_name(config)
  492. result = run_risk_exit_segment(candles, config)
  493. for cost_name, cost in COSTS.items():
  494. equity = cost_adjusted_equity_frame(result, cost)
  495. metrics = annualized_metrics(equity)
  496. total_rows.append(
  497. {
  498. "symbol": SYMBOL,
  499. "bar": BAR,
  500. "name": name,
  501. "cost_scenario": cost_name,
  502. "roundtrip_cost_on_margin": cost,
  503. "first_candle": _format_ts(candles[0].ts),
  504. "last_candle": _format_ts(candles[-1].ts),
  505. "trades": result.trade_count,
  506. "gross_total_return": result.total_return,
  507. "gross_max_drawdown": result.max_drawdown,
  508. "win_rate": result.win_rate,
  509. **metrics,
  510. }
  511. )
  512. for row in horizon_metrics(equity):
  513. horizon_rows.append(
  514. {
  515. "symbol": SYMBOL,
  516. "bar": BAR,
  517. "name": name,
  518. "cost_scenario": cost_name,
  519. "roundtrip_cost_on_margin": cost,
  520. "trades": result.trade_count,
  521. **row,
  522. }
  523. )
  524. print(f"done {index}/{len(configs)} {name}")
  525. totals = pd.DataFrame(total_rows).sort_values(
  526. ["cost_scenario", "net_calmar", "net_annualized_return"],
  527. ascending=[True, False, False],
  528. )
  529. horizons = pd.DataFrame(horizon_rows)
  530. horizons["horizon"] = pd.Categorical(horizons["horizon"], ["3y", "1y", "6m", "3m"], ordered=True)
  531. horizons = horizons.sort_values(
  532. ["cost_scenario", "horizon", "net_annualized_return"],
  533. ascending=[True, True, False],
  534. )
  535. return totals, horizons
  536. def _markdown_table(frame: pd.DataFrame) -> str:
  537. columns = [str(column) for column in frame.columns]
  538. rows = [columns, ["---"] * len(columns)]
  539. for row in frame.itertuples(index=False):
  540. rows.append([str(value) for value in row])
  541. return "\n".join("| " + " | ".join(row) + " |" for row in rows)
  542. def write_summary(totals: pd.DataFrame, horizons: pd.DataFrame, output_dir: Path) -> None:
  543. main = totals[totals["cost_scenario"] == "maker_taker"].copy()
  544. top = main.sort_values(["net_calmar", "net_annualized_return"], ascending=False).head(10)
  545. market_baseline = main[main["name"] == "market-t50-r3.0-x45.0-stop-none-mh10000"].iloc[0]
  546. twap_baseline = main[main["name"] == "price_twap-t50-r3.0-x45.0-stop-none-mh10000"].iloc[0]
  547. lines = [
  548. "# ETH risk/exit exploration",
  549. "",
  550. "Fixed entry baseline: ETH 15m RSI2 long, trend SMA 50, RSI <= 3, exit RSI baseline 45. Variants only change risk/exit rules or optional gap/range risk filters. Main ranking uses maker_taker cost 0.0021.",
  551. "",
  552. "## Baselines",
  553. "",
  554. f"- market: net_annualized_return={market_baseline['net_annualized_return']:.6f}, net_max_drawdown={market_baseline['net_max_drawdown']:.6f}, net_calmar={market_baseline['net_calmar']:.6f}",
  555. f"- price_twap: net_annualized_return={twap_baseline['net_annualized_return']:.6f}, net_max_drawdown={twap_baseline['net_max_drawdown']:.6f}, net_calmar={twap_baseline['net_calmar']:.6f}",
  556. "",
  557. "## Top 10 maker_taker candidates",
  558. "",
  559. _markdown_table(top[
  560. [
  561. "name",
  562. "trades",
  563. "net_total_return",
  564. "net_annualized_return",
  565. "net_max_drawdown",
  566. "net_calmar",
  567. "gross_max_drawdown",
  568. "win_rate",
  569. ]
  570. ]),
  571. "",
  572. "## Drawdown/recent-return read",
  573. "",
  574. ]
  575. best = top.iloc[0]
  576. baseline = twap_baseline if str(best["name"]).startswith("price_twap") else market_baseline
  577. drawdown_lower = float(best["net_max_drawdown"]) < float(baseline["net_max_drawdown"])
  578. recent = horizons[
  579. (horizons["cost_scenario"] == "maker_taker")
  580. & (horizons["name"].isin([best["name"], baseline["name"]]))
  581. & (horizons["horizon"].isin(["1y", "6m", "3m"]))
  582. ][["name", "horizon", "net_total_return", "net_annualized_return", "net_max_drawdown"]]
  583. lines.append(
  584. f"Best candidate versus its entry-mode baseline: drawdown_lower={drawdown_lower}; "
  585. f"annualized_return_delta={float(best['net_annualized_return']) - float(baseline['net_annualized_return']):.6f}."
  586. )
  587. lines.extend(["", _markdown_table(recent), ""])
  588. output_dir.mkdir(parents=True, exist_ok=True)
  589. (output_dir / "eth-risk-exit-summary.md").write_text("\n".join(lines), encoding="utf-8")
  590. def main() -> int:
  591. parser = argparse.ArgumentParser()
  592. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  593. args = parser.parse_args()
  594. output_dir = args.output_dir
  595. output_dir.mkdir(parents=True, exist_ok=True)
  596. totals, horizons = run_search()
  597. totals.to_csv(output_dir / "eth-risk-exit-total.csv", index=False)
  598. horizons.to_csv(output_dir / "eth-risk-exit-horizon.csv", index=False)
  599. top = totals[totals["cost_scenario"] == "maker_taker"].sort_values(
  600. ["net_calmar", "net_annualized_return"], ascending=False
  601. )
  602. top.head(10).to_csv(output_dir / "eth-risk-exit-top10.csv", index=False)
  603. write_summary(totals, horizons, output_dir)
  604. print(top.head(10)[["name", "trades", "net_annualized_return", "net_max_drawdown", "net_calmar"]].to_string(index=False))
  605. return 0
  606. if __name__ == "__main__":
  607. raise SystemExit(main())