search_eth_nextgen_micro_portfolio.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713
  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 scripts import explore_ultrashort as explore
  10. from scripts import search_eth_btc_nextgen_variants as nextgen
  11. from scripts import search_eth_microstructure_variants as micro
  12. OUTPUT_DIR = Path("reports/eth-exploration")
  13. PREFIX = "eth-nextgen-micro-portfolio"
  14. PRIMARY_COST = "maker_taker"
  15. COST_MODELS = {
  16. "maker_taker": 0.0021,
  17. "taker_taker": 0.0030,
  18. }
  19. NEXTGEN_BASELINE = "equal-2-c0003"
  20. NEXTGEN_LEGS = (
  21. "btc_trend_eth_rsi:15m:eth-btc-rsi-filter-et50-l3.0-x55.0-bt480-bm240-br0.0",
  22. "btc_shock_guard_eth_rsi:15m:eth-btc-shock-filter-et50-l3.0-x55.0-bt480-bm240-br0.01-sw96-sv0.01-sd0.05",
  23. )
  24. MICRO_NAMES = (
  25. "atr-compress-expand-r48-q0.15-sl0.008-tp0.016-mf0.25-us",
  26. "atr-compress-expand-r48-q0.15-sl0.008-tp0.016-mf0.4-us",
  27. "atr-compress-expand-r96-q0.15-sl0.008-tp0.016-mf0.25-us",
  28. "atr-compress-expand-r96-q0.15-sl0.008-tp0.016-mf0.4-us",
  29. )
  30. HORIZONS = (
  31. ("full", None),
  32. ("3y", pd.DateOffset(years=3)),
  33. ("1y", pd.DateOffset(years=1)),
  34. ("6m", pd.DateOffset(months=6)),
  35. ("3m", pd.DateOffset(months=3)),
  36. )
  37. @dataclass(frozen=True)
  38. class LegReturn:
  39. source: str
  40. exit_date: pd.Timestamp
  41. value: float
  42. def daily_equity_from_frame(frame: pd.DataFrame, index: pd.DatetimeIndex) -> pd.Series:
  43. series = frame.set_index("ts")["equity"].sort_index()
  44. daily = series.reindex(index.union(series.index)).sort_index().ffill().reindex(index).ffill()
  45. daily.iloc[0] = explore.INITIAL_EQUITY
  46. return daily
  47. def metrics_from_daily(series: pd.Series) -> dict[str, float]:
  48. years = (series.index[-1] - series.index[0]).total_seconds() / 86_400 / 365
  49. total = float(series.iloc[-1] / series.iloc[0] - 1.0)
  50. annualized = (1.0 + total) ** (1.0 / years) - 1.0 if total > -1.0 and years > 0.0 else 0.0
  51. drawdown = explore.max_drawdown_from_equity([float(value) for value in series])
  52. returns = series.pct_change().dropna()
  53. daily_std = float(returns.std(ddof=1)) if len(returns) > 1 else 0.0
  54. risk_reward = float(returns.mean()) / daily_std * (365**0.5) if daily_std else 0.0
  55. return {
  56. "net_total_return": total,
  57. "net_annualized_return": annualized,
  58. "net_max_drawdown": drawdown,
  59. "net_calmar": annualized / drawdown if drawdown else 0.0,
  60. "risk_reward_ratio": risk_reward,
  61. }
  62. def monthly_rows(name: str, cost_model: str, series: pd.Series) -> pd.DataFrame:
  63. monthly = series.resample("ME").last()
  64. frame = pd.DataFrame(
  65. {
  66. "name": name,
  67. "cost_model": cost_model,
  68. "month": monthly.index.strftime("%Y-%m"),
  69. "start_equity": monthly.shift(1).fillna(series.iloc[0]).to_numpy(),
  70. "end_equity": monthly.to_numpy(),
  71. }
  72. )
  73. frame["return"] = frame["end_equity"] / frame["start_equity"] - 1.0
  74. return frame
  75. def horizon_rows(name: str, cost_model: str, series: pd.Series, trades: list[LegReturn], monthly: pd.DataFrame) -> list[dict[str, object]]:
  76. end = series.index[-1]
  77. rows: list[dict[str, object]] = []
  78. for label, offset in HORIZONS:
  79. horizon = series if offset is None else series[series.index >= end - offset]
  80. if len(horizon) < 2:
  81. horizon = series
  82. start = horizon.index[0]
  83. horizon_trades = [trade for trade in trades if trade.exit_date >= start]
  84. horizon_monthly = monthly[monthly["month"] >= start.strftime("%Y-%m")]
  85. worst = horizon_monthly.sort_values("return").iloc[0] if len(horizon_monthly) else None
  86. rows.append(
  87. {
  88. "name": name,
  89. "cost_model": cost_model,
  90. "horizon": label,
  91. "horizon_start": horizon.index[0].strftime("%Y-%m-%d"),
  92. "horizon_end": horizon.index[-1].strftime("%Y-%m-%d"),
  93. "worst_month": "" if worst is None else str(worst["month"]),
  94. "worst_month_return": 0.0 if worst is None else float(worst["return"]),
  95. **trade_return_stats(horizon_trades),
  96. **metrics_from_daily(horizon),
  97. }
  98. )
  99. return rows
  100. def returns_to_equity(name: str, returns: pd.Series) -> pd.Series:
  101. equity = explore.INITIAL_EQUITY * (1.0 + returns.fillna(0.0)).cumprod()
  102. equity.iloc[0] = explore.INITIAL_EQUITY
  103. equity.name = name
  104. return equity
  105. def trade_return_stats(returns: list[LegReturn]) -> dict[str, float]:
  106. values = [row.value for row in returns]
  107. wins = [value for value in values if value > 0.0]
  108. losses = [value for value in values if value < 0.0]
  109. avg_win = sum(wins) / len(wins) if wins else 0.0
  110. avg_loss_abs = abs(sum(losses) / len(losses)) if losses else 0.0
  111. gross_profit = sum(wins)
  112. gross_loss_abs = abs(sum(losses))
  113. return {
  114. "trades": len(values),
  115. "win_rate": len(wins) / len(values) if values else 0.0,
  116. "payoff_ratio": avg_win / avg_loss_abs if avg_loss_abs else 0.0,
  117. "profit_factor": gross_profit / gross_loss_abs if gross_loss_abs else 0.0,
  118. }
  119. def nextgen_trade_returns(result: object, weight: float, roundtrip_cost: float) -> list[LegReturn]:
  120. rows: list[LegReturn] = []
  121. for trade in result.trades:
  122. value = (float(trade["return_pct"]) / 100.0 - roundtrip_cost * float(trade.get("cost_weight", 1.0))) * weight
  123. rows.append(LegReturn("nextgen", pd.to_datetime(str(trade["exit_time"]), utc=True).normalize(), value))
  124. return rows
  125. def micro_trade_returns(result: micro.SegmentResult, weight: float, roundtrip_cost: float) -> list[LegReturn]:
  126. rows: list[LegReturn] = []
  127. for trade in result.trades:
  128. value = (float(trade["return_pct"]) / 100.0 - roundtrip_cost * float(trade["cost_weight"])) * weight
  129. rows.append(LegReturn("micro", pd.to_datetime(str(trade["exit_time"]), utc=True).normalize(), value))
  130. return rows
  131. def combine_trade_returns(
  132. *,
  133. nextgen_returns: list[LegReturn],
  134. micro_returns: list[LegReturn],
  135. nextgen_weight: float,
  136. micro_weight: float,
  137. micro_mask: pd.Series | None = None,
  138. ) -> list[LegReturn]:
  139. values = [LegReturn(row.source, row.exit_date, row.value * nextgen_weight) for row in nextgen_returns]
  140. for row in micro_returns:
  141. if micro_mask is None or bool(micro_mask.reindex([row.exit_date]).fillna(False).iloc[0]):
  142. values.append(LegReturn(row.source, row.exit_date, row.value * micro_weight))
  143. return values
  144. def load_nextgen(index: pd.DatetimeIndex, roundtrip_cost: float) -> tuple[pd.Series, list[LegReturn]]:
  145. strategies = {
  146. f"{strategy.family}:{strategy.bar}:{strategy.candidate.name}": strategy
  147. for strategy in nextgen.build_strategies()
  148. }
  149. missing = [key for key in NEXTGEN_LEGS if key not in strategies]
  150. if missing:
  151. raise KeyError(f"missing nextgen legs: {missing}")
  152. data = {
  153. (symbol, "15m"): nextgen.load_candles(symbol, "15m", 20.0)
  154. for symbol in ("ETH-USDT-SWAP", "BTC-USDT-SWAP")
  155. }
  156. leg_series: list[pd.Series] = []
  157. trade_returns: list[LegReturn] = []
  158. for key in NEXTGEN_LEGS:
  159. result = nextgen.run_strategy(strategies[key], data)
  160. frame = explore.cost_adjusted_trade_equity_frame(result, roundtrip_cost)
  161. leg_series.append(daily_equity_from_frame(frame, index))
  162. trade_returns.extend(nextgen_trade_returns(result, 0.5, roundtrip_cost))
  163. returns = pd.DataFrame({key: series.pct_change().fillna(0.0) for key, series in zip(NEXTGEN_LEGS, leg_series)}).mean(axis=1)
  164. return returns_to_equity(NEXTGEN_BASELINE, returns), trade_returns
  165. def load_micro_candidates(index: pd.DatetimeIndex, roundtrip_cost: float) -> dict[str, tuple[pd.Series, list[LegReturn]]]:
  166. candles = micro._load_candles(micro.SYMBOL, micro.BAR)
  167. requested = int(20.0 * 365 * 24 * 60 / 15)
  168. candles = candles[-requested:]
  169. variants = {variant.name: variant for variant in micro.build_variants()}
  170. missing = [name for name in MICRO_NAMES if name not in variants]
  171. if missing:
  172. raise KeyError(f"missing micro variants: {missing}")
  173. output: dict[str, tuple[pd.Series, list[LegReturn]]] = {}
  174. for name in MICRO_NAMES:
  175. result = variants[name].run(candles)
  176. frame = micro.cost_equity_frame(result, roundtrip_cost)
  177. output[name] = (daily_equity_from_frame(frame, index), micro_trade_returns(result, 1.0, roundtrip_cost))
  178. return output
  179. def evaluate_portfolio(
  180. *,
  181. name: str,
  182. cost_model: str,
  183. roundtrip_cost: float,
  184. kind: str,
  185. series: pd.Series,
  186. nextgen_weight: float,
  187. micro_weight: float,
  188. micro_name: str,
  189. trade_returns: list[LegReturn],
  190. ) -> tuple[dict[str, object], list[dict[str, object]], pd.DataFrame]:
  191. monthly = monthly_rows(name, cost_model, series)
  192. worst = monthly.sort_values("return").iloc[0]
  193. horizons = horizon_rows(name, cost_model, series, trade_returns, monthly)
  194. min_recent = min(float(row["net_total_return"]) for row in horizons if row["horizon"] != "full")
  195. row = {
  196. "name": name,
  197. "cost_model": cost_model,
  198. "roundtrip_cost_on_margin": roundtrip_cost,
  199. "kind": kind,
  200. "micro_name": micro_name,
  201. "nextgen_weight": nextgen_weight,
  202. "micro_weight": micro_weight,
  203. "worst_month": str(worst["month"]),
  204. "worst_month_return": float(worst["return"]),
  205. "min_recent_total_return": min_recent,
  206. **trade_return_stats(trade_returns),
  207. **metrics_from_daily(series),
  208. }
  209. return row, horizons, monthly
  210. def build_portfolios(
  211. *,
  212. cost_model: str,
  213. roundtrip_cost: float,
  214. nextgen_series: pd.Series,
  215. nextgen_trade_returns: list[LegReturn],
  216. micro_candidates: dict[str, tuple[pd.Series, list[LegReturn]]],
  217. ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame]:
  218. rows: list[dict[str, object]] = []
  219. horizon_output: list[dict[str, object]] = []
  220. monthly_frames: list[pd.DataFrame] = []
  221. equity_frames: list[pd.DataFrame] = []
  222. nextgen_returns = nextgen_series.pct_change().fillna(0.0)
  223. baseline_row, baseline_horizons, baseline_monthly = evaluate_portfolio(
  224. name=NEXTGEN_BASELINE,
  225. cost_model=cost_model,
  226. roundtrip_cost=roundtrip_cost,
  227. kind="baseline",
  228. series=nextgen_series,
  229. nextgen_weight=1.0,
  230. micro_weight=0.0,
  231. micro_name="",
  232. trade_returns=nextgen_trade_returns,
  233. )
  234. rows.append(baseline_row)
  235. horizon_output.extend(baseline_horizons)
  236. monthly_frames.append(baseline_monthly)
  237. equity_frames.append(equity_frame(NEXTGEN_BASELINE, cost_model, nextgen_series))
  238. for micro_name, (micro_series, micro_trade_rows) in micro_candidates.items():
  239. micro_returns = micro_series.pct_change().fillna(0.0)
  240. for micro_weight in (0.10, 0.15, 0.20, 0.25, 0.30):
  241. nextgen_weight = 1.0 - micro_weight
  242. returns = nextgen_returns * nextgen_weight + micro_returns * micro_weight
  243. name = f"blend-ng{nextgen_weight:.2f}-{short_micro_name(micro_name)}"
  244. series = returns_to_equity(name, returns)
  245. trades = combine_trade_returns(
  246. nextgen_returns=nextgen_trade_returns,
  247. micro_returns=micro_trade_rows,
  248. nextgen_weight=nextgen_weight,
  249. micro_weight=micro_weight,
  250. )
  251. add_result(rows, horizon_output, monthly_frames, equity_frames, name, cost_model, roundtrip_cost, "equity_blend", micro_name, nextgen_weight, micro_weight, series, trades)
  252. flat_mask = nextgen_returns.abs() < 1e-12
  253. for micro_weight in (0.25, 0.40):
  254. returns = nextgen_returns + micro_returns.where(flat_mask, 0.0) * micro_weight
  255. name = f"nonoverlap-m{micro_weight:.2f}-{short_micro_name(micro_name)}"
  256. series = returns_to_equity(name, returns)
  257. trades = combine_trade_returns(
  258. nextgen_returns=nextgen_trade_returns,
  259. micro_returns=micro_trade_rows,
  260. nextgen_weight=1.0,
  261. micro_weight=micro_weight,
  262. micro_mask=flat_mask,
  263. )
  264. add_result(rows, horizon_output, monthly_frames, equity_frames, name, cost_model, roundtrip_cost, "signal_non_overlap", micro_name, 1.0, micro_weight, series, trades)
  265. for lookback in (30, 60, 90, 120):
  266. nextgen_regime = nextgen_series / nextgen_series.shift(lookback) - 1.0
  267. micro_regime = micro_series / micro_series.shift(lookback) - 1.0
  268. active = ((nextgen_regime < 0.0) & (micro_regime > 0.0)).shift(1).fillna(False).astype(bool)
  269. switch_returns = nextgen_returns.where(~active, micro_returns)
  270. switch_name = f"switch-l{lookback}-{short_micro_name(micro_name)}"
  271. switch_series = returns_to_equity(switch_name, switch_returns)
  272. switch_trades = combine_trade_returns(
  273. nextgen_returns=[row for row in nextgen_trade_returns if not bool(active.reindex([row.exit_date]).fillna(False).iloc[0])],
  274. micro_returns=micro_trade_rows,
  275. nextgen_weight=1.0,
  276. micro_weight=1.0,
  277. micro_mask=active,
  278. )
  279. add_result(rows, horizon_output, monthly_frames, equity_frames, switch_name, cost_model, roundtrip_cost, "recent_regime_switch", micro_name, 1.0, 1.0, switch_series, switch_trades)
  280. for micro_weight in (0.25, 0.40):
  281. overlay_returns = nextgen_returns + micro_returns.where(active, 0.0) * micro_weight
  282. overlay_name = f"riskoff-overlay-l{lookback}-m{micro_weight:.2f}-{short_micro_name(micro_name)}"
  283. overlay_series = returns_to_equity(overlay_name, overlay_returns)
  284. overlay_trades = combine_trade_returns(
  285. nextgen_returns=nextgen_trade_returns,
  286. micro_returns=micro_trade_rows,
  287. nextgen_weight=1.0,
  288. micro_weight=micro_weight,
  289. micro_mask=active,
  290. )
  291. add_result(
  292. rows,
  293. horizon_output,
  294. monthly_frames,
  295. equity_frames,
  296. overlay_name,
  297. cost_model,
  298. roundtrip_cost,
  299. "riskoff_overlay",
  300. micro_name,
  301. 1.0,
  302. micro_weight,
  303. overlay_series,
  304. overlay_trades,
  305. )
  306. summary = pd.DataFrame(rows).sort_values(
  307. ["cost_model", "net_calmar", "net_annualized_return", "min_recent_total_return", "worst_month_return"],
  308. ascending=[True, False, False, False, False],
  309. )
  310. horizons = pd.DataFrame(horizon_output)
  311. horizons["horizon"] = pd.Categorical(horizons["horizon"], categories=[label for label, _ in HORIZONS], ordered=True)
  312. horizons = horizons.sort_values(["cost_model", "name", "horizon"])
  313. monthly = pd.concat(monthly_frames, ignore_index=True)
  314. equity = pd.concat(equity_frames, ignore_index=True)
  315. return summary, horizons, monthly, equity
  316. def add_result(
  317. rows: list[dict[str, object]],
  318. horizon_output: list[dict[str, object]],
  319. monthly_frames: list[pd.DataFrame],
  320. equity_frames: list[pd.DataFrame],
  321. name: str,
  322. cost_model: str,
  323. roundtrip_cost: float,
  324. kind: str,
  325. micro_name: str,
  326. nextgen_weight: float,
  327. micro_weight: float,
  328. series: pd.Series,
  329. trades: list[LegReturn],
  330. ) -> None:
  331. row, horizons, monthly = evaluate_portfolio(
  332. name=name,
  333. cost_model=cost_model,
  334. roundtrip_cost=roundtrip_cost,
  335. kind=kind,
  336. series=series,
  337. nextgen_weight=nextgen_weight,
  338. micro_weight=micro_weight,
  339. micro_name=micro_name,
  340. trade_returns=trades,
  341. )
  342. rows.append(row)
  343. horizon_output.extend(horizons)
  344. monthly_frames.append(monthly)
  345. equity_frames.append(equity_frame(name, cost_model, series))
  346. def equity_frame(name: str, cost_model: str, series: pd.Series) -> pd.DataFrame:
  347. return pd.DataFrame({"name": name, "cost_model": cost_model, "date": series.index.strftime("%Y-%m-%d"), "equity": series.to_numpy()})
  348. def short_micro_name(name: str) -> str:
  349. return name.replace("atr-compress-expand-", "").replace("sl0.008-tp0.016-", "").replace("-", "_")
  350. def markdown_table(frame: pd.DataFrame) -> str:
  351. rows = [list(frame.columns), ["---" for _ in frame.columns]]
  352. rows.extend(frame.astype(object).where(pd.notna(frame), "").values.tolist())
  353. return "\n".join("| " + " | ".join(format_cell(value) for value in row) + " |" for row in rows)
  354. def format_cell(value: object) -> str:
  355. if isinstance(value, float):
  356. return f"{value:.6g}"
  357. return str(value).replace("|", "\\|")
  358. def monthly_stability(monthly: pd.DataFrame) -> pd.DataFrame:
  359. return (
  360. monthly.groupby(["cost_model", "name"], as_index=False)
  361. .agg(
  362. months=("return", "count"),
  363. positive_month_rate=("return", lambda values: float((values > 0.0).mean())),
  364. avg_month_return=("return", "mean"),
  365. median_month_return=("return", "median"),
  366. worst_month_return=("return", "min"),
  367. best_month_return=("return", "max"),
  368. )
  369. .sort_values(["cost_model", "positive_month_rate", "worst_month_return"], ascending=[True, False, False])
  370. )
  371. def robust_survivors(horizons: pd.DataFrame) -> pd.DataFrame:
  372. full = horizons[horizons["horizon"] == "full"].copy()
  373. recent = full
  374. for horizon in ("3y", "1y", "6m", "3m"):
  375. part = horizons[horizons["horizon"] == horizon][["cost_model", "name", "net_total_return", "net_calmar"]].rename(
  376. columns={"net_total_return": f"ret_{horizon}", "net_calmar": f"calmar_{horizon}"}
  377. )
  378. recent = recent.merge(part, on=["cost_model", "name"], how="inner")
  379. return recent[
  380. (recent["net_total_return"] > 0.0)
  381. & (recent["net_calmar"] > 0.0)
  382. & (recent["ret_3y"] > 0.0)
  383. & (recent["ret_1y"] > 0.0)
  384. & (recent["ret_6m"] > 0.0)
  385. & (recent["ret_3m"] > 0.0)
  386. & (recent["calmar_3y"] > 0.0)
  387. & (recent["calmar_1y"] > 0.0)
  388. & (recent["calmar_6m"] > 0.0)
  389. & (recent["calmar_3m"] > 0.0)
  390. ].copy()
  391. def write_report(
  392. *,
  393. command: str,
  394. output_files: list[Path],
  395. summary: pd.DataFrame,
  396. horizons: pd.DataFrame,
  397. stability: pd.DataFrame,
  398. worst_months: pd.DataFrame,
  399. survivors: pd.DataFrame,
  400. ) -> str:
  401. primary_summary = summary[summary["cost_model"] == PRIMARY_COST]
  402. stress_summary = summary[summary["cost_model"] == "taker_taker"]
  403. baseline = primary_summary[primary_summary["name"] == NEXTGEN_BASELINE].iloc[0]
  404. best = primary_summary.iloc[0]
  405. best_horizons = horizons[(horizons["cost_model"] == PRIMARY_COST) & (horizons["name"] == best["name"])]
  406. baseline_horizons = horizons[(horizons["cost_model"] == PRIMARY_COST) & (horizons["name"] == NEXTGEN_BASELINE)]
  407. dilution = (
  408. "The best combination improves risk-adjusted return versus nextgen equal-2-c0003."
  409. if float(best["net_calmar"]) > float(baseline["net_calmar"]) and float(best["net_total_return"]) >= float(baseline["net_total_return"]) * 0.98
  410. else "The best combination is mainly return dilution unless its lower drawdown or better worst month is preferred."
  411. )
  412. lines = [
  413. "# ETH nextgen + microstructure portfolio exploration",
  414. "",
  415. f"Run command: `{command}`",
  416. "",
  417. "No order placement or exchange API path is used; this script reads local candle/report data only.",
  418. "",
  419. "Output files:",
  420. *[f"- `{path}`" for path in output_files],
  421. "",
  422. "Base nextgen: `equal-2-c0003` with the two documented maker_taker legs.",
  423. "Micro candidates: ATR compression/expansion US-session robust candidates only.",
  424. "Costs: maker_taker=0.0021 and taker_taker=0.0030 roundtrip on margin. Funding and slippage remain excluded.",
  425. "",
  426. "## Conclusion",
  427. "",
  428. dilution,
  429. f"Strict robust survivors with positive full/3y/1y/6m/3m net return and Calmar: {len(survivors)}.",
  430. "",
  431. "## Top maker_taker combinations",
  432. "",
  433. markdown_table(
  434. primary_summary.head(12)[
  435. [
  436. "name",
  437. "cost_model",
  438. "kind",
  439. "micro_name",
  440. "net_total_return",
  441. "net_annualized_return",
  442. "net_max_drawdown",
  443. "net_calmar",
  444. "risk_reward_ratio",
  445. "worst_month",
  446. "worst_month_return",
  447. "min_recent_total_return",
  448. "trades",
  449. "win_rate",
  450. "payoff_ratio",
  451. "profit_factor",
  452. ]
  453. ]
  454. ),
  455. "",
  456. "## Taker/taker stress combinations",
  457. "",
  458. markdown_table(
  459. stress_summary.head(12)[
  460. [
  461. "name",
  462. "cost_model",
  463. "kind",
  464. "micro_name",
  465. "net_total_return",
  466. "net_annualized_return",
  467. "net_max_drawdown",
  468. "net_calmar",
  469. "risk_reward_ratio",
  470. "worst_month",
  471. "worst_month_return",
  472. "min_recent_total_return",
  473. "trades",
  474. ]
  475. ]
  476. ),
  477. "",
  478. "## Strict robust survivors",
  479. "",
  480. markdown_table(
  481. survivors.head(20)[
  482. [
  483. "name",
  484. "cost_model",
  485. "net_total_return",
  486. "net_calmar",
  487. "ret_3y",
  488. "ret_1y",
  489. "ret_6m",
  490. "ret_3m",
  491. "calmar_3y",
  492. "calmar_1y",
  493. "calmar_6m",
  494. "calmar_3m",
  495. ]
  496. ]
  497. ),
  498. "",
  499. "## Best horizon metrics",
  500. "",
  501. markdown_table(
  502. best_horizons[
  503. [
  504. "horizon",
  505. "horizon_start",
  506. "horizon_end",
  507. "net_total_return",
  508. "net_annualized_return",
  509. "net_max_drawdown",
  510. "net_calmar",
  511. "trades",
  512. "win_rate",
  513. "payoff_ratio",
  514. "profit_factor",
  515. "risk_reward_ratio",
  516. "worst_month",
  517. "worst_month_return",
  518. ]
  519. ]
  520. ),
  521. "",
  522. "## Baseline horizon metrics",
  523. "",
  524. markdown_table(
  525. baseline_horizons[
  526. [
  527. "horizon",
  528. "horizon_start",
  529. "horizon_end",
  530. "net_total_return",
  531. "net_annualized_return",
  532. "net_max_drawdown",
  533. "net_calmar",
  534. "trades",
  535. "win_rate",
  536. "payoff_ratio",
  537. "profit_factor",
  538. "risk_reward_ratio",
  539. "worst_month",
  540. "worst_month_return",
  541. ]
  542. ]
  543. ),
  544. "",
  545. "## Monthly stability",
  546. "",
  547. markdown_table(stability[stability["cost_model"] == PRIMARY_COST].head(20)),
  548. "",
  549. "## Worst months",
  550. "",
  551. markdown_table(worst_months.head(20)[["name", "cost_model", "month", "return"]]),
  552. ]
  553. return "\n".join(lines) + "\n"
  554. def main() -> int:
  555. parser = argparse.ArgumentParser()
  556. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  557. args = parser.parse_args()
  558. existing_equity = pd.read_csv(args.output_dir / "eth-btc-nextgen-equity.csv")
  559. base = existing_equity[(existing_equity["cost_model"] == PRIMARY_COST) & (existing_equity["name"] == NEXTGEN_BASELINE)].copy()
  560. if base.empty:
  561. raise KeyError(f"missing existing nextgen equity for {NEXTGEN_BASELINE}")
  562. index = pd.DatetimeIndex(pd.to_datetime(base["date"], utc=True))
  563. summary_frames: list[pd.DataFrame] = []
  564. horizon_frames: list[pd.DataFrame] = []
  565. monthly_frames: list[pd.DataFrame] = []
  566. equity_frames: list[pd.DataFrame] = []
  567. for cost_model, roundtrip_cost in COST_MODELS.items():
  568. nextgen_series, nextgen_returns = load_nextgen(index, roundtrip_cost)
  569. micro_candidates = load_micro_candidates(index, roundtrip_cost)
  570. summary, horizons, monthly, equity = build_portfolios(
  571. cost_model=cost_model,
  572. roundtrip_cost=roundtrip_cost,
  573. nextgen_series=nextgen_series,
  574. nextgen_trade_returns=nextgen_returns,
  575. micro_candidates=micro_candidates,
  576. )
  577. summary_frames.append(summary)
  578. horizon_frames.append(horizons)
  579. monthly_frames.append(monthly)
  580. equity_frames.append(equity)
  581. summary = pd.concat(summary_frames, ignore_index=True)
  582. primary = summary[summary["cost_model"] == PRIMARY_COST]
  583. stress = summary[summary["cost_model"] != PRIMARY_COST]
  584. summary = pd.concat([primary, stress], ignore_index=True)
  585. top_pairs = set(zip(primary.head(25)["cost_model"], primary.head(25)["name"]))
  586. top_pairs.update(zip(stress.head(25)["cost_model"], stress.head(25)["name"]))
  587. top_pairs.add((PRIMARY_COST, NEXTGEN_BASELINE))
  588. for cost_model in COST_MODELS:
  589. top_pairs.add((cost_model, NEXTGEN_BASELINE))
  590. horizons = pd.concat(horizon_frames, ignore_index=True)
  591. monthly = pd.concat(monthly_frames, ignore_index=True)
  592. equity = pd.concat(equity_frames, ignore_index=True)
  593. survivors = robust_survivors(horizons).merge(
  594. summary[
  595. [
  596. "cost_model",
  597. "name",
  598. "kind",
  599. "micro_name",
  600. "nextgen_weight",
  601. "micro_weight",
  602. "roundtrip_cost_on_margin",
  603. ]
  604. ],
  605. on=["cost_model", "name"],
  606. how="left",
  607. ).sort_values(["cost_model", "net_calmar", "net_total_return"], ascending=[True, False, False])
  608. stability = monthly_stability(monthly)
  609. worst_months = monthly.sort_values("return").head(100)
  610. horizons = horizons[horizons.apply(lambda row: (row["cost_model"], row["name"]) in top_pairs, axis=1)]
  611. monthly = monthly[monthly.apply(lambda row: (row["cost_model"], row["name"]) in top_pairs, axis=1)]
  612. equity = equity[equity.apply(lambda row: (row["cost_model"], row["name"]) in top_pairs, axis=1)]
  613. args.output_dir.mkdir(parents=True, exist_ok=True)
  614. summary_path = args.output_dir / f"{PREFIX}-summary.csv"
  615. horizon_path = args.output_dir / f"{PREFIX}-horizons.csv"
  616. monthly_path = args.output_dir / f"{PREFIX}-monthly.csv"
  617. stability_path = args.output_dir / f"{PREFIX}-monthly-stability.csv"
  618. worst_path = args.output_dir / f"{PREFIX}-worst-months.csv"
  619. robust_path = args.output_dir / f"{PREFIX}-robust-survivors.csv"
  620. equity_path = args.output_dir / f"{PREFIX}-equity.csv"
  621. json_path = args.output_dir / f"{PREFIX}-top.json"
  622. report_path = args.output_dir / f"{PREFIX}-report.md"
  623. output_files = [summary_path, horizon_path, monthly_path, stability_path, worst_path, robust_path, equity_path, json_path, report_path]
  624. summary.to_csv(summary_path, index=False)
  625. horizons.to_csv(horizon_path, index=False)
  626. monthly.to_csv(monthly_path, index=False)
  627. stability.to_csv(stability_path, index=False)
  628. worst_months.to_csv(worst_path, index=False)
  629. survivors.to_csv(robust_path, index=False)
  630. equity.to_csv(equity_path, index=False)
  631. json_path.write_text(json.dumps(primary.head(20).to_dict("records"), indent=2), encoding="utf-8")
  632. command = f"rtk .venv/bin/python {Path(__file__).as_posix()}"
  633. report_path.write_text(
  634. write_report(
  635. command=command,
  636. output_files=output_files,
  637. summary=summary,
  638. horizons=horizons,
  639. stability=stability,
  640. worst_months=worst_months,
  641. survivors=survivors,
  642. ),
  643. encoding="utf-8",
  644. )
  645. print(primary.head(12).to_string(index=False))
  646. return 0
  647. if __name__ == "__main__":
  648. raise SystemExit(main())