search_executable_bidir_variants.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  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. from typing import Callable
  8. import pandas as pd
  9. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  10. from okx_codex_trader.models import Candle
  11. DATA_DIR = Path("data/okx-candles")
  12. OUTPUT_DIR = Path("reports/ultrashort")
  13. SYMBOLS = ("ETH-USDT-SWAP", "BTC-USDT-SWAP")
  14. BARS = ("15m",)
  15. INITIAL_EQUITY = 10_000.0
  16. LEVERAGE = 3
  17. ROUNDTRIP_COST = 0.0021
  18. HORIZONS = (
  19. ("full", None),
  20. ("3y", pd.DateOffset(years=3)),
  21. ("1y", pd.DateOffset(years=1)),
  22. ("6m", pd.DateOffset(months=6)),
  23. ("3m", pd.DateOffset(months=3)),
  24. )
  25. @dataclass(frozen=True)
  26. class Strategy:
  27. family: str
  28. name: str
  29. warmup_bars: int
  30. params: dict[str, object]
  31. build_signals: Callable[[list[Candle]], tuple[list[str | None], list[str | None], list[float | None], list[float | None]]]
  32. @dataclass(frozen=True)
  33. class BacktestResult:
  34. trades: list[dict[str, object]]
  35. equity: pd.DataFrame
  36. long_entries: int
  37. short_entries: int
  38. def load_candles(symbol: str, bar: str) -> list[Candle]:
  39. frame = pd.read_csv(DATA_DIR / symbol / f"{bar}.csv")
  40. return [
  41. Candle(
  42. symbol=symbol,
  43. ts=int(row.ts),
  44. open=float(row.open),
  45. high=float(row.high),
  46. low=float(row.low),
  47. close=float(row.close),
  48. volume=float(row.volume),
  49. )
  50. for row in frame.itertuples(index=False)
  51. ]
  52. def format_ts(ts: int) -> str:
  53. return pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
  54. def rsi(values: pd.Series, length: int) -> pd.Series:
  55. delta = values.diff()
  56. gain = delta.clip(lower=0).rolling(length).mean()
  57. loss = (-delta.clip(upper=0)).rolling(length).mean()
  58. rs = gain / loss
  59. return 100.0 - (100.0 / (1.0 + rs))
  60. def crossing_up(left: pd.Series, right: pd.Series) -> pd.Series:
  61. return (left.shift(1) <= right.shift(1)) & (left > right)
  62. def crossing_down(left: pd.Series, right: pd.Series) -> pd.Series:
  63. return (left.shift(1) >= right.shift(1)) & (left < right)
  64. def build_rsi2_signals(
  65. candles: list[Candle],
  66. *,
  67. trend_sma: int,
  68. entry_rsi: float,
  69. exit_rsi: float,
  70. stop_loss_pct: float,
  71. max_hold_bars: int,
  72. ) -> tuple[list[str | None], list[str | None], list[float | None], list[float | None]]:
  73. close = pd.Series([candle.close for candle in candles], dtype=float)
  74. trend = close.rolling(trend_sma).mean()
  75. rsi2 = rsi(close, 2)
  76. entries: list[str | None] = [None] * len(candles)
  77. exits: list[str | None] = [None] * len(candles)
  78. stops: list[float | None] = [stop_loss_pct] * len(candles)
  79. takes: list[float | None] = [None] * len(candles)
  80. for index, candle in enumerate(candles):
  81. if trend.iloc[index] != trend.iloc[index] or rsi2.iloc[index] != rsi2.iloc[index]:
  82. continue
  83. if candle.close > float(trend.iloc[index]) and float(rsi2.iloc[index]) <= entry_rsi:
  84. entries[index] = "long"
  85. elif candle.close < float(trend.iloc[index]) and float(rsi2.iloc[index]) >= 100.0 - entry_rsi:
  86. entries[index] = "short"
  87. if float(rsi2.iloc[index]) >= exit_rsi:
  88. exits[index] = "long"
  89. elif float(rsi2.iloc[index]) <= 100.0 - exit_rsi:
  90. exits[index] = "short"
  91. return with_max_hold(entries, exits, stops, takes, max_hold_bars)
  92. def build_ma_cross_signals(
  93. candles: list[Candle],
  94. *,
  95. fast: int,
  96. slow: int,
  97. stop_loss_pct: float,
  98. ) -> tuple[list[str | None], list[str | None], list[float | None], list[float | None]]:
  99. close = pd.Series([candle.close for candle in candles], dtype=float)
  100. fast_ma = close.rolling(fast).mean()
  101. slow_ma = close.rolling(slow).mean()
  102. entries: list[str | None] = [None] * len(candles)
  103. exits: list[str | None] = [None] * len(candles)
  104. stops: list[float | None] = [stop_loss_pct] * len(candles)
  105. takes: list[float | None] = [None] * len(candles)
  106. up = crossing_up(fast_ma, slow_ma)
  107. down = crossing_down(fast_ma, slow_ma)
  108. for index in range(len(candles)):
  109. if up.iloc[index]:
  110. entries[index] = "long"
  111. exits[index] = "both"
  112. elif down.iloc[index]:
  113. entries[index] = "short"
  114. exits[index] = "both"
  115. return entries, exits, stops, takes
  116. def build_vwap_reversion_signals(
  117. candles: list[Candle],
  118. *,
  119. window: int,
  120. entry_z: float,
  121. exit_z: float,
  122. stop_loss_pct: float,
  123. max_hold_bars: int,
  124. ) -> tuple[list[str | None], list[str | None], list[float | None], list[float | None]]:
  125. close = pd.Series([candle.close for candle in candles], dtype=float)
  126. volume = pd.Series([candle.volume for candle in candles], dtype=float)
  127. vwap = (close * volume).rolling(window).sum() / volume.rolling(window).sum()
  128. stdev = close.rolling(window).std(ddof=0)
  129. z = (close - vwap) / stdev
  130. entries: list[str | None] = [None] * len(candles)
  131. exits: list[str | None] = [None] * len(candles)
  132. stops: list[float | None] = [stop_loss_pct] * len(candles)
  133. takes: list[float | None] = [None] * len(candles)
  134. for index in range(len(candles)):
  135. if z.iloc[index] != z.iloc[index]:
  136. continue
  137. if float(z.iloc[index]) <= -entry_z:
  138. entries[index] = "long"
  139. elif float(z.iloc[index]) >= entry_z:
  140. entries[index] = "short"
  141. if abs(float(z.iloc[index])) <= exit_z:
  142. exits[index] = "both"
  143. return with_max_hold(entries, exits, stops, takes, max_hold_bars)
  144. def build_bbmr_signals(
  145. candles: list[Candle],
  146. *,
  147. trend_sma: int,
  148. band_length: int,
  149. stdev_mult: float,
  150. atr_length: int,
  151. max_atr_pct: float,
  152. stop_loss_pct: float,
  153. take_profit_pct: float,
  154. max_hold_bars: int,
  155. ) -> tuple[list[str | None], list[str | None], list[float | None], list[float | None]]:
  156. close = pd.Series([candle.close for candle in candles], dtype=float)
  157. high = pd.Series([candle.high for candle in candles], dtype=float)
  158. low = pd.Series([candle.low for candle in candles], dtype=float)
  159. trend = close.rolling(trend_sma).mean()
  160. middle = close.rolling(band_length).mean()
  161. stdev = close.rolling(band_length).std(ddof=0)
  162. upper = middle + stdev_mult * stdev
  163. lower = middle - stdev_mult * stdev
  164. true_range = pd.concat([(high - low), (high - close.shift(1)).abs(), (low - close.shift(1)).abs()], axis=1).max(axis=1)
  165. atr_pct = true_range.rolling(atr_length).mean() / close
  166. entries: list[str | None] = [None] * len(candles)
  167. exits: list[str | None] = [None] * len(candles)
  168. stops: list[float | None] = [stop_loss_pct] * len(candles)
  169. takes: list[float | None] = [take_profit_pct] * len(candles)
  170. for index, candle in enumerate(candles):
  171. values = (trend.iloc[index], middle.iloc[index], upper.iloc[index], lower.iloc[index], atr_pct.iloc[index])
  172. if any(value != value for value in values):
  173. continue
  174. if float(atr_pct.iloc[index]) > max_atr_pct:
  175. continue
  176. if candle.close > float(trend.iloc[index]) and candle.close <= float(lower.iloc[index]):
  177. entries[index] = "long"
  178. elif candle.close < float(trend.iloc[index]) and candle.close >= float(upper.iloc[index]):
  179. entries[index] = "short"
  180. if candle.close >= float(middle.iloc[index]):
  181. exits[index] = "long"
  182. elif candle.close <= float(middle.iloc[index]):
  183. exits[index] = "short"
  184. return with_max_hold(entries, exits, stops, takes, max_hold_bars)
  185. def build_donchian_false_breakout_signals(
  186. candles: list[Candle],
  187. *,
  188. lookback: int,
  189. reclaim_bars: int,
  190. stop_loss_pct: float,
  191. take_profit_pct: float,
  192. max_hold_bars: int,
  193. ) -> tuple[list[str | None], list[str | None], list[float | None], list[float | None]]:
  194. close = pd.Series([candle.close for candle in candles], dtype=float)
  195. high = pd.Series([candle.high for candle in candles], dtype=float)
  196. low = pd.Series([candle.low for candle in candles], dtype=float)
  197. prior_high = high.shift(1).rolling(lookback).max()
  198. prior_low = low.shift(1).rolling(lookback).min()
  199. entries: list[str | None] = [None] * len(candles)
  200. exits: list[str | None] = [None] * len(candles)
  201. stops: list[float | None] = [stop_loss_pct] * len(candles)
  202. takes: list[float | None] = [take_profit_pct] * len(candles)
  203. broke_high_at: int | None = None
  204. broke_low_at: int | None = None
  205. for index, candle in enumerate(candles):
  206. if prior_high.iloc[index] != prior_high.iloc[index] or prior_low.iloc[index] != prior_low.iloc[index]:
  207. continue
  208. if candle.high > float(prior_high.iloc[index]):
  209. broke_high_at = index
  210. if candle.low < float(prior_low.iloc[index]):
  211. broke_low_at = index
  212. if broke_high_at is not None and index - broke_high_at <= reclaim_bars and candle.close < float(prior_high.iloc[index]):
  213. entries[index] = "short"
  214. broke_high_at = None
  215. if broke_low_at is not None and index - broke_low_at <= reclaim_bars and candle.close > float(prior_low.iloc[index]):
  216. entries[index] = "long"
  217. broke_low_at = None
  218. if broke_high_at is not None and index - broke_high_at > reclaim_bars:
  219. broke_high_at = None
  220. if broke_low_at is not None and index - broke_low_at > reclaim_bars:
  221. broke_low_at = None
  222. return with_max_hold(entries, exits, stops, takes, max_hold_bars)
  223. def with_max_hold(
  224. entries: list[str | None],
  225. exits: list[str | None],
  226. stops: list[float | None],
  227. takes: list[float | None],
  228. max_hold_bars: int,
  229. ) -> tuple[list[str | None], list[str | None], list[float | None], list[float | None]]:
  230. return entries, exits, stops, takes
  231. def exit_equity(side: str, margin: float, entry_price: float, exit_price: float) -> float:
  232. if side == "long":
  233. price_return = exit_price / entry_price - 1.0
  234. else:
  235. price_return = (entry_price - exit_price) / entry_price
  236. return margin * (1.0 + LEVERAGE * price_return)
  237. def run_strategy(candles: list[Candle], strategy: Strategy) -> BacktestResult:
  238. entries, exits, stops, takes = strategy.build_signals(candles)
  239. trades: list[dict[str, object]] = []
  240. equity_points: list[dict[str, object]] = [{"ts": pd.to_datetime(candles[0].ts, unit="ms", utc=True), "equity": INITIAL_EQUITY}]
  241. equity = INITIAL_EQUITY
  242. position: dict[str, object] | None = None
  243. pending_entry: str | None = None
  244. pending_exit = False
  245. long_entries = 0
  246. short_entries = 0
  247. for index in range(strategy.warmup_bars, len(candles)):
  248. candle = candles[index]
  249. if pending_exit and position is not None:
  250. equity = close_position(trades, position, candle.ts, candle.open, "signal")
  251. position = None
  252. pending_exit = False
  253. equity_points.append({"ts": pd.to_datetime(candle.ts, unit="ms", utc=True), "equity": equity})
  254. if pending_entry is not None and position is None and equity > 0.0:
  255. position = {
  256. "side": pending_entry,
  257. "entry_index": index,
  258. "entry_ts": candle.ts,
  259. "entry_price": candle.open,
  260. "margin": equity,
  261. "stop_price": candle.open * (1.0 - float(stops[index]) if pending_entry == "long" else 1.0 + float(stops[index]))
  262. if stops[index] is not None
  263. else None,
  264. "take_price": candle.open * (1.0 + float(takes[index]) if pending_entry == "long" else 1.0 - float(takes[index]))
  265. if takes[index] is not None
  266. else None,
  267. }
  268. long_entries += 1 if pending_entry == "long" else 0
  269. short_entries += 1 if pending_entry == "short" else 0
  270. pending_entry = None
  271. if position is not None:
  272. side = str(position["side"])
  273. stop_price = position["stop_price"]
  274. take_price = position["take_price"]
  275. stop_hit = stop_price is not None and (
  276. (side == "long" and candle.low <= float(stop_price)) or (side == "short" and candle.high >= float(stop_price))
  277. )
  278. take_hit = take_price is not None and (
  279. (side == "long" and candle.high >= float(take_price)) or (side == "short" and candle.low <= float(take_price))
  280. )
  281. if stop_hit or take_hit:
  282. exit_price = float(stop_price if stop_hit else take_price)
  283. equity = close_position(trades, position, candle.ts, exit_price, "stop" if stop_hit else "take_profit")
  284. position = None
  285. equity_points.append({"ts": pd.to_datetime(candle.ts, unit="ms", utc=True), "equity": equity})
  286. if index == len(candles) - 1 or equity <= 0.0:
  287. continue
  288. entry_side = entries[index]
  289. if position is not None:
  290. max_hold = int(strategy.params.get("max_hold_bars", 0) or 0)
  291. reverse = entry_side is not None and entry_side != position["side"]
  292. stale = max_hold > 0 and index - int(position["entry_index"]) >= max_hold
  293. exit_signal = exits[index] in ("both", position["side"])
  294. if exit_signal or reverse or stale:
  295. pending_exit = True
  296. pending_entry = entry_side if reverse else None
  297. continue
  298. if entry_side is not None:
  299. pending_entry = entry_side
  300. if position is not None:
  301. equity = close_position(trades, position, candles[-1].ts, candles[-1].close, "final")
  302. equity_points.append({"ts": pd.to_datetime(candles[-1].ts, unit="ms", utc=True), "equity": equity})
  303. return BacktestResult(trades=trades, equity=pd.DataFrame(equity_points), long_entries=long_entries, short_entries=short_entries)
  304. def close_position(
  305. trades: list[dict[str, object]],
  306. position: dict[str, object],
  307. exit_ts: int,
  308. exit_price: float,
  309. reason: str,
  310. ) -> float:
  311. margin = float(position["margin"])
  312. gross = exit_equity(str(position["side"]), margin, float(position["entry_price"]), exit_price)
  313. net = gross - margin * ROUNDTRIP_COST
  314. pnl = net - margin
  315. trades.append(
  316. {
  317. "side": str(position["side"]),
  318. "entry_time": format_ts(int(position["entry_ts"])),
  319. "exit_time": format_ts(exit_ts),
  320. "entry_ts": int(position["entry_ts"]),
  321. "exit_ts": exit_ts,
  322. "entry_price": float(position["entry_price"]),
  323. "exit_price": exit_price,
  324. "return": pnl / margin,
  325. "pnl": pnl,
  326. "exit_reason": reason,
  327. }
  328. )
  329. return net
  330. def max_drawdown(values: list[float]) -> float:
  331. peak = values[0]
  332. drawdown = 0.0
  333. for value in values:
  334. peak = max(peak, value)
  335. drawdown = max(drawdown, (peak - value) / peak if peak > 0.0 else 0.0)
  336. return drawdown
  337. def metrics_for(equity: pd.DataFrame, trades: list[dict[str, object]], first_ts: int, last_ts: int) -> dict[str, object]:
  338. years = (last_ts - first_ts) / 86_400_000 / 365
  339. total_return = float(equity["equity"].iloc[-1] / equity["equity"].iloc[0] - 1.0)
  340. annualized = (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 and years > 0.0 else -1.0
  341. dd = max_drawdown([float(value) for value in equity["equity"]])
  342. wins = [float(trade["return"]) for trade in trades if float(trade["return"]) > 0.0]
  343. losses = [float(trade["return"]) for trade in trades if float(trade["return"]) < 0.0]
  344. avg_win = sum(wins) / len(wins) if wins else 0.0
  345. avg_loss = abs(sum(losses) / len(losses)) if losses else 0.0
  346. months = max(years * 12.0, 1.0 / 30.0)
  347. worst_month_label, worst_month_return = worst_month(equity)
  348. return {
  349. "net_total_return": total_return,
  350. "net_annualized_return": annualized,
  351. "net_max_drawdown": dd,
  352. "net_calmar": annualized / dd if dd else 0.0,
  353. "win_rate": len(wins) / len(trades) if trades else 0.0,
  354. "payoff_ratio": avg_win / avg_loss if avg_loss else 0.0,
  355. "profit_factor": sum(wins) / abs(sum(losses)) if losses else 0.0,
  356. "risk_reward_ratio": total_return / dd if dd else 0.0,
  357. "trades": len(trades),
  358. "trades_per_month": len(trades) / months,
  359. "worst_month": worst_month_label,
  360. "worst_month_return": worst_month_return,
  361. }
  362. def worst_month(equity: pd.DataFrame) -> tuple[str, float]:
  363. monthly = equity.set_index("ts")["equity"].resample("ME").last().ffill().pct_change().dropna()
  364. if not len(monthly):
  365. return "", 0.0
  366. index = monthly.idxmin()
  367. return index.strftime("%Y-%m"), float(monthly.loc[index])
  368. def horizon_slice(equity: pd.DataFrame, trades: list[dict[str, object]], last_ts: int, offset: pd.DateOffset | None) -> tuple[pd.DataFrame, list[dict[str, object]], int]:
  369. if offset is None:
  370. return equity.copy(), trades, int(equity["ts"].iloc[0].timestamp() * 1000)
  371. cutoff = pd.to_datetime(last_ts, unit="ms", utc=True) - offset
  372. before = equity[equity["ts"] <= cutoff]
  373. start_equity = float(before["equity"].iloc[-1]) if len(before) else float(equity["equity"].iloc[0])
  374. frame = pd.concat(
  375. [
  376. pd.DataFrame([{"ts": cutoff, "equity": start_equity}]),
  377. equity[equity["ts"] > cutoff][["ts", "equity"]],
  378. ],
  379. ignore_index=True,
  380. )
  381. return frame, [trade for trade in trades if int(trade["exit_ts"]) >= int(cutoff.timestamp() * 1000)], int(cutoff.timestamp() * 1000)
  382. def build_strategies() -> list[Strategy]:
  383. strategies: list[Strategy] = []
  384. for trend in (96, 192):
  385. for entry in (5.0, 10.0):
  386. for exit_level in (55.0,):
  387. for stop in (0.006, 0.01):
  388. params = {"trend_sma": trend, "entry_rsi": entry, "exit_rsi": exit_level, "stop_loss_pct": stop, "max_hold_bars": 96}
  389. strategies.append(Strategy("RSI2 both", f"rsi2-both-t{trend}-e{entry:g}-x{exit_level:g}-sl{stop:g}", trend, params, lambda c, p=params: build_rsi2_signals(c, **p)))
  390. for fast, slow in ((8, 34), (13, 55), (21, 89)):
  391. for stop in (0.01,):
  392. params = {"fast": fast, "slow": slow, "stop_loss_pct": stop}
  393. strategies.append(Strategy("MA cross both", f"ma-cross-both-f{fast}-s{slow}-sl{stop:g}", slow, params, lambda c, p=params: build_ma_cross_signals(c, **p)))
  394. for window in (48, 96):
  395. for entry_z in (1.5, 2.0):
  396. for stop in (0.006,):
  397. params = {"window": window, "entry_z": entry_z, "exit_z": 0.25, "stop_loss_pct": stop, "max_hold_bars": window}
  398. strategies.append(Strategy("VWAP reversion", f"vwap-reversion-w{window}-z{entry_z:g}-sl{stop:g}", window, params, lambda c, p=params: build_vwap_reversion_signals(c, **p)))
  399. for trend in (192,):
  400. for length in (48, 96):
  401. for max_atr in (0.01,):
  402. for stop, take in ((0.006, 0.009), (0.01, 0.015)):
  403. params = {
  404. "trend_sma": trend,
  405. "band_length": length,
  406. "stdev_mult": 2.0,
  407. "atr_length": length,
  408. "max_atr_pct": max_atr,
  409. "stop_loss_pct": stop,
  410. "take_profit_pct": take,
  411. "max_hold_bars": length,
  412. }
  413. strategies.append(Strategy("BBMR risk-filtered", f"bbmr-risk-t{trend}-l{length}-atr{max_atr:g}-sl{stop:g}-tp{take:g}", max(trend, length), params, lambda c, p=params: build_bbmr_signals(c, **p)))
  414. for lookback in (48, 96):
  415. for reclaim in (2, 4):
  416. for stop, take in ((0.006, 0.009),):
  417. params = {"lookback": lookback, "reclaim_bars": reclaim, "stop_loss_pct": stop, "take_profit_pct": take, "max_hold_bars": lookback}
  418. strategies.append(Strategy("Donchian false breakout", f"donchian-false-l{lookback}-r{reclaim}-sl{stop:g}-tp{take:g}", lookback, params, lambda c, p=params: build_donchian_false_breakout_signals(c, **p)))
  419. return strategies
  420. def markdown_table(frame: pd.DataFrame) -> str:
  421. columns = list(frame.columns)
  422. rows = [columns, ["---" for _ in columns]]
  423. for record in frame.to_dict("records"):
  424. rows.append([record[column] for column in columns])
  425. return "\n".join("| " + " | ".join(format_cell(value) for value in row) + " |" for row in rows)
  426. def format_cell(value: object) -> str:
  427. if isinstance(value, float):
  428. return f"{value:.6g}"
  429. return str(value).replace("|", "\\|")
  430. def write_report(total: pd.DataFrame, horizons: pd.DataFrame, report_files: list[Path], command: str) -> str:
  431. executable = total[total["directly_liveable"] & (total["supports_short"]) & (total["trades_per_month"] >= 10.0)]
  432. positive_fast = executable[executable["net_total_return"] > 0.0]
  433. positive_slow = total[
  434. (total["directly_liveable"])
  435. & (total["supports_short"])
  436. & (~total["needs_synthetic_bookkeeping"])
  437. & (total["net_total_return"] > 0.0)
  438. & (total["trades_per_month"] < 10.0)
  439. ].head(10)
  440. top = executable.head(15)
  441. family_best = executable.sort_values(["family", "net_calmar", "net_annualized_return"], ascending=[True, False, False]).groupby("family", as_index=False).head(1)
  442. lines = [
  443. "# Executable bidirectional strategy search",
  444. "",
  445. f"Run command: `{command}`",
  446. "Execution safety: local CSV backtest only; no private API, no order submission, no commit.",
  447. f"Cost model: fixed roundtrip cost on margin `{ROUNDTRIP_COST}`; leverage `{LEVERAGE}x`.",
  448. "",
  449. "Output files:",
  450. *[f"- `{path}`" for path in report_files],
  451. "",
  452. "Eligibility: directly liveable, supports short, no synthetic portfolio bookkeeping, at least 10 trades/month.",
  453. f"Decision: positive >=10 trades/month candidates found: `{len(positive_fast)}`.",
  454. "",
  455. "Top executable candidates:",
  456. markdown_table(top[["family", "symbol", "bar", "name", "trades_per_month", "net_total_return", "net_annualized_return", "net_max_drawdown", "net_calmar", "win_rate", "payoff_ratio", "profit_factor", "risk_reward_ratio", "worst_month_return"]]),
  457. "",
  458. "Family leaders:",
  459. markdown_table(family_best[["family", "symbol", "bar", "name", "trades_per_month", "net_calmar", "net_annualized_return", "net_max_drawdown", "profit_factor"]]),
  460. "",
  461. "Positive but below 10 trades/month:",
  462. markdown_table(positive_slow[["family", "symbol", "bar", "name", "trades_per_month", "net_total_return", "net_annualized_return", "net_max_drawdown", "net_calmar", "profit_factor", "worst_month_return"]]),
  463. "",
  464. "Recent horizon leaders:",
  465. markdown_table(
  466. horizons[horizons["name"].isin(set(top.head(8)["name"]))]
  467. .sort_values(["horizon", "net_calmar", "net_annualized_return"], ascending=[True, False, False])
  468. .groupby("horizon", observed=True)
  469. .head(5)[["horizon", "family", "symbol", "bar", "name", "trades_per_month", "net_total_return", "net_annualized_return", "net_max_drawdown", "net_calmar", "worst_month_return"]]
  470. ),
  471. ]
  472. return "\n".join(lines) + "\n"
  473. def main() -> int:
  474. parser = argparse.ArgumentParser()
  475. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  476. parser.add_argument("--symbols", nargs="+", default=list(SYMBOLS))
  477. parser.add_argument("--bars", nargs="+", default=list(BARS))
  478. args = parser.parse_args()
  479. strategies = build_strategies()
  480. total_rows: list[dict[str, object]] = []
  481. horizon_rows: list[dict[str, object]] = []
  482. for symbol in args.symbols:
  483. for bar in args.bars:
  484. candles = load_candles(symbol, bar)
  485. for index, strategy in enumerate(strategies, start=1):
  486. result = run_strategy(candles, strategy)
  487. if not len(result.equity):
  488. continue
  489. full_metrics = metrics_for(result.equity, result.trades, candles[0].ts, candles[-1].ts)
  490. row_base = {
  491. "family": strategy.family,
  492. "symbol": symbol,
  493. "bar": bar,
  494. "name": strategy.name,
  495. "first_candle": format_ts(candles[0].ts),
  496. "last_candle": format_ts(candles[-1].ts),
  497. "years": (candles[-1].ts - candles[0].ts) / 86_400_000 / 365,
  498. "directly_liveable": True,
  499. "needs_synthetic_bookkeeping": False,
  500. "supports_short": result.short_entries > 0,
  501. "long_entries": result.long_entries,
  502. "short_entries": result.short_entries,
  503. "order_intent": "single_symbol_long_short_entry_exit",
  504. "params_json": json.dumps(strategy.params, separators=(",", ":")),
  505. }
  506. total_rows.append({**row_base, **full_metrics})
  507. for horizon, offset in HORIZONS:
  508. frame, trades, start_ts = horizon_slice(result.equity, result.trades, candles[-1].ts, offset)
  509. horizon_rows.append(
  510. {
  511. **row_base,
  512. "horizon": horizon,
  513. "horizon_start": format_ts(start_ts),
  514. "horizon_end": format_ts(candles[-1].ts),
  515. **metrics_for(frame, trades, start_ts, candles[-1].ts),
  516. }
  517. )
  518. print(f"done {symbol} {bar} {index}/{len(strategies)} {strategy.family} {strategy.name}")
  519. total = pd.DataFrame(total_rows).sort_values(
  520. ["directly_liveable", "supports_short", "trades_per_month", "net_calmar", "net_annualized_return", "profit_factor"],
  521. ascending=[False, False, False, False, False, False],
  522. )
  523. total["candidate_tier"] = "other"
  524. total.loc[
  525. (total["directly_liveable"]) & (total["supports_short"]) & (total["trades_per_month"] >= 10.0) & (total["net_total_return"] > 0.0),
  526. "candidate_tier",
  527. ] = "positive_10pm"
  528. total.loc[
  529. (total["directly_liveable"]) & (total["supports_short"]) & (total["trades_per_month"] < 10.0) & (total["net_total_return"] > 0.0),
  530. "candidate_tier",
  531. ] = "positive_sub10pm"
  532. total.loc[
  533. (total["directly_liveable"]) & (total["supports_short"]) & (total["trades_per_month"] >= 10.0) & (total["net_total_return"] <= 0.0),
  534. "candidate_tier",
  535. ] = "nonpositive_10pm"
  536. total["candidate_tier"] = pd.Categorical(
  537. total["candidate_tier"],
  538. categories=["positive_10pm", "positive_sub10pm", "nonpositive_10pm", "other"],
  539. ordered=True,
  540. )
  541. executable = total[(total["directly_liveable"]) & (total["supports_short"]) & (total["trades_per_month"] >= 10.0)]
  542. executable = executable.sort_values(["net_calmar", "net_annualized_return", "profit_factor"], ascending=[False, False, False])
  543. total = total.sort_values(
  544. ["candidate_tier", "net_calmar", "net_annualized_return", "trades_per_month", "profit_factor"],
  545. ascending=[True, False, False, False, False],
  546. ).reset_index(drop=True)
  547. horizons = pd.DataFrame(horizon_rows)
  548. horizons["horizon"] = pd.Categorical(horizons["horizon"], categories=[label for label, _ in HORIZONS], ordered=True)
  549. horizons = horizons.sort_values(["horizon", "net_calmar", "net_annualized_return"], ascending=[True, False, False])
  550. args.output_dir.mkdir(parents=True, exist_ok=True)
  551. total_path = args.output_dir / "executable-bidir-total.csv"
  552. horizon_path = args.output_dir / "executable-bidir-horizons.csv"
  553. top_path = args.output_dir / "executable-bidir-top.csv"
  554. json_path = args.output_dir / "executable-bidir-top.json"
  555. report_path = args.output_dir / "executable-bidir-report.md"
  556. output_files = [total_path, horizon_path, top_path, json_path, report_path]
  557. total.to_csv(total_path, index=False)
  558. horizons.to_csv(horizon_path, index=False)
  559. total.head(50).to_csv(top_path, index=False)
  560. json_path.write_text(json.dumps(total.head(20).to_dict("records"), indent=2), encoding="utf-8")
  561. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --symbols {' '.join(args.symbols)} --bars {' '.join(args.bars)}"
  562. report_path.write_text(write_report(total, horizons, output_files, command), encoding="utf-8")
  563. print(total.head(20).to_string(index=False))
  564. return 0
  565. if __name__ == "__main__":
  566. raise SystemExit(main())