search_eth_nextgen_micro_direction_b.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. from __future__ import annotations
  2. import argparse
  3. import json
  4. import sys
  5. from pathlib import Path
  6. import pandas as pd
  7. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  8. from scripts import search_eth_nextgen_micro_portfolio as base
  9. OUTPUT_DIR = Path("reports/eth-exploration")
  10. PREFIX = "eth-nextgen-micro-direction-b"
  11. PRIMARY_COST = "maker_taker"
  12. COST_MODELS = {
  13. "maker_taker": 0.0021,
  14. "taker_taker": 0.0030,
  15. }
  16. MICRO_NAMES = (
  17. "atr-compress-expand-r96-q0.15-sl0.008-tp0.016-mf0.25-us",
  18. "atr-compress-expand-r96-q0.15-sl0.008-tp0.016-mf0.4-us",
  19. "atr-compress-expand-r48-q0.15-sl0.008-tp0.016-mf0.25-us",
  20. "atr-compress-expand-r48-q0.15-sl0.008-tp0.016-mf0.4-us",
  21. )
  22. LOOKBACKS = (30, 60, 90)
  23. HORIZONS = (
  24. ("full", None),
  25. ("3y", pd.DateOffset(years=3)),
  26. ("1y", pd.DateOffset(years=1)),
  27. ("6m", pd.DateOffset(months=6)),
  28. ("3m", pd.DateOffset(months=3)),
  29. ("30d", pd.DateOffset(days=30)),
  30. ("21d", pd.DateOffset(days=21)),
  31. ("14d", pd.DateOffset(days=14)),
  32. )
  33. def short_micro_name(name: str) -> str:
  34. return base.short_micro_name(name)
  35. def metrics_from_daily(series: pd.Series) -> dict[str, float]:
  36. return base.metrics_from_daily(series)
  37. def trade_return_stats(trades: list[base.LegReturn]) -> dict[str, float]:
  38. values = [trade.value for trade in trades]
  39. wins = [value for value in values if value > 0.0]
  40. losses = [value for value in values if value < 0.0]
  41. gross_profit = sum(wins)
  42. gross_loss_abs = abs(sum(losses))
  43. avg_win = gross_profit / len(wins) if wins else 0.0
  44. avg_loss_abs = gross_loss_abs / len(losses) if losses else 0.0
  45. return {
  46. "trades": len(values),
  47. "win_rate": len(wins) / len(values) if values else 0.0,
  48. "avg_return": sum(values) / len(values) if values else 0.0,
  49. "payoff_ratio": avg_win / avg_loss_abs if avg_loss_abs else 0.0,
  50. "profit_factor": gross_profit / gross_loss_abs if gross_loss_abs else 0.0,
  51. }
  52. def horizon_rows(name: str, cost_model: str, series: pd.Series, trades: list[base.LegReturn]) -> list[dict[str, object]]:
  53. end = series.index[-1]
  54. rows: list[dict[str, object]] = []
  55. for label, offset in HORIZONS:
  56. horizon = series if offset is None else series[series.index >= end - offset]
  57. if len(horizon) < 2:
  58. horizon = series
  59. start = horizon.index[0]
  60. selected = [trade for trade in trades if trade.exit_date >= start]
  61. years = max((horizon.index[-1] - start).total_seconds() / 86_400 / 365, 1 / 365)
  62. rows.append(
  63. {
  64. "name": name,
  65. "cost_model": cost_model,
  66. "horizon": label,
  67. "horizon_start": start.strftime("%Y-%m-%d"),
  68. "horizon_end": horizon.index[-1].strftime("%Y-%m-%d"),
  69. "trades_per_year": len(selected) / years,
  70. **trade_return_stats(selected),
  71. **metrics_from_daily(horizon),
  72. }
  73. )
  74. return rows
  75. def evaluate(
  76. *,
  77. name: str,
  78. cost_model: str,
  79. kind: str,
  80. micro_name: str,
  81. lookback_days: int,
  82. micro_weight: float,
  83. series: pd.Series,
  84. trades: list[base.LegReturn],
  85. ) -> tuple[dict[str, object], list[dict[str, object]]]:
  86. horizons = horizon_rows(name, cost_model, series, trades)
  87. full = next(row for row in horizons if row["horizon"] == "full")
  88. recent = [row for row in horizons if row["horizon"] != "full"]
  89. return (
  90. {
  91. "name": name,
  92. "cost_model": cost_model,
  93. "kind": kind,
  94. "micro_name": micro_name,
  95. "lookback_days": lookback_days,
  96. "micro_weight": micro_weight,
  97. "min_recent_total_return": min(float(row["net_total_return"]) for row in recent),
  98. **{key: full[key] for key in (
  99. "trades",
  100. "trades_per_year",
  101. "win_rate",
  102. "avg_return",
  103. "payoff_ratio",
  104. "profit_factor",
  105. "net_total_return",
  106. "net_annualized_return",
  107. "net_max_drawdown",
  108. "net_calmar",
  109. )},
  110. },
  111. horizons,
  112. )
  113. def build_direction_b(
  114. *,
  115. cost_model: str,
  116. nextgen_series: pd.Series,
  117. nextgen_trades: list[base.LegReturn],
  118. micro_candidates: dict[str, tuple[pd.Series, list[base.LegReturn]]],
  119. ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
  120. nextgen_returns = nextgen_series.pct_change().fillna(0.0)
  121. rows: list[dict[str, object]] = []
  122. horizon_rows_out: list[dict[str, object]] = []
  123. equity_rows: list[pd.DataFrame] = []
  124. baseline_row, baseline_horizons = evaluate(
  125. name=base.NEXTGEN_BASELINE,
  126. cost_model=cost_model,
  127. kind="baseline",
  128. micro_name="",
  129. lookback_days=0,
  130. micro_weight=0.0,
  131. series=nextgen_series,
  132. trades=nextgen_trades,
  133. )
  134. rows.append(baseline_row)
  135. horizon_rows_out.extend(baseline_horizons)
  136. equity_rows.append(base.equity_frame(base.NEXTGEN_BASELINE, cost_model, nextgen_series))
  137. for micro_name, (micro_series, micro_trades) in micro_candidates.items():
  138. if micro_name not in MICRO_NAMES:
  139. continue
  140. micro_returns = micro_series.pct_change().fillna(0.0)
  141. for lookback in LOOKBACKS:
  142. nextgen_regime = nextgen_series / nextgen_series.shift(lookback) - 1.0
  143. micro_regime = micro_series / micro_series.shift(lookback) - 1.0
  144. active = ((nextgen_regime < 0.0) & (micro_regime > 0.0)).shift(1).fillna(False).astype(bool)
  145. switch_name = f"switch-l{lookback}-{short_micro_name(micro_name)}"
  146. switch_series = base.returns_to_equity(switch_name, nextgen_returns.where(~active, micro_returns))
  147. switch_trades = base.combine_trade_returns(
  148. nextgen_returns=[row for row in nextgen_trades if not bool(active.reindex([row.exit_date]).fillna(False).iloc[0])],
  149. micro_returns=micro_trades,
  150. nextgen_weight=1.0,
  151. micro_weight=1.0,
  152. micro_mask=active,
  153. )
  154. row, horizons = evaluate(
  155. name=switch_name,
  156. cost_model=cost_model,
  157. kind="recent_regime_switch",
  158. micro_name=micro_name,
  159. lookback_days=lookback,
  160. micro_weight=1.0,
  161. series=switch_series,
  162. trades=switch_trades,
  163. )
  164. rows.append(row)
  165. horizon_rows_out.extend(horizons)
  166. equity_rows.append(base.equity_frame(switch_name, cost_model, switch_series))
  167. for micro_weight in (0.25, 0.40):
  168. overlay_name = f"riskoff-overlay-l{lookback}-m{micro_weight:.2f}-{short_micro_name(micro_name)}"
  169. overlay_series = base.returns_to_equity(overlay_name, nextgen_returns + micro_returns.where(active, 0.0) * micro_weight)
  170. overlay_trades = base.combine_trade_returns(
  171. nextgen_returns=nextgen_trades,
  172. micro_returns=micro_trades,
  173. nextgen_weight=1.0,
  174. micro_weight=micro_weight,
  175. micro_mask=active,
  176. )
  177. row, horizons = evaluate(
  178. name=overlay_name,
  179. cost_model=cost_model,
  180. kind="riskoff_overlay",
  181. micro_name=micro_name,
  182. lookback_days=lookback,
  183. micro_weight=micro_weight,
  184. series=overlay_series,
  185. trades=overlay_trades,
  186. )
  187. rows.append(row)
  188. horizon_rows_out.extend(horizons)
  189. equity_rows.append(base.equity_frame(overlay_name, cost_model, overlay_series))
  190. summary = pd.DataFrame(rows).sort_values(
  191. ["cost_model", "kind", "net_calmar", "min_recent_total_return", "net_total_return"],
  192. ascending=[True, True, False, False, False],
  193. )
  194. horizons = pd.DataFrame(horizon_rows_out)
  195. horizons["horizon"] = pd.Categorical(horizons["horizon"], categories=[label for label, _ in HORIZONS], ordered=True)
  196. horizons = horizons.sort_values(["cost_model", "name", "horizon"])
  197. equity = pd.concat(equity_rows, ignore_index=True)
  198. return summary, horizons, equity
  199. def markdown_table(frame: pd.DataFrame) -> str:
  200. return base.markdown_table(frame)
  201. def pct_columns(frame: pd.DataFrame, columns: tuple[str, ...]) -> pd.DataFrame:
  202. output = frame.copy()
  203. for column in columns:
  204. output[column] = output[column].map(lambda value: f"{float(value) * 100:.2f}%")
  205. return output
  206. def write_report(summary: pd.DataFrame, horizons: pd.DataFrame, output_files: list[Path], command: str) -> str:
  207. primary = summary[(summary["cost_model"] == PRIMARY_COST) & (summary["kind"] != "baseline")].copy()
  208. stress = summary[(summary["cost_model"] == "taker_taker") & (summary["kind"] != "baseline")].copy()
  209. top_primary = primary.sort_values(["kind", "net_calmar", "min_recent_total_return"], ascending=[True, False, False]).head(12)
  210. top_switch = primary[primary["kind"] == "recent_regime_switch"].sort_values(["net_calmar", "min_recent_total_return"], ascending=[False, False]).head(5)
  211. top_overlay = primary[primary["kind"] == "riskoff_overlay"].sort_values(["net_calmar", "min_recent_total_return"], ascending=[False, False]).head(8)
  212. taker_positive = stress[stress["net_total_return"] > 0.0].sort_values(["kind", "net_calmar"], ascending=[True, False]).head(10)
  213. best = top_switch.iloc[0]
  214. best_horizons = horizons[(horizons["cost_model"] == PRIMARY_COST) & (horizons["name"] == best["name"])].copy()
  215. frequency_ok = bool((best_horizons[best_horizons["horizon"].isin(["30d", "21d", "14d"])]["trades"] >= 2).all())
  216. short_window_ok = bool((best_horizons[best_horizons["horizon"].isin(["30d", "21d", "14d"])]["net_total_return"] > 0.0).all())
  217. stress_match = stress[stress["name"] == best["name"]]
  218. taker_ok = bool(len(stress_match) and float(stress_match.iloc[0]["net_total_return"]) > 0.0)
  219. recommendation = (
  220. "建议作为当前 BB squeeze 的并行只读观察策略;不建议直接列为近期实盘候选。"
  221. if frequency_ok and taker_ok and not short_window_ok
  222. else "建议只读观察,不建议进入实盘候选。"
  223. )
  224. lines = [
  225. "# ETH nextgen + microstructure direction B",
  226. "",
  227. f"Run command: `{command}`",
  228. "",
  229. "Scope: lookback 30/60/90, selected ATR compression/expansion US-session micro legs, recent_regime_switch first and small riskoff_overlay checks second.",
  230. "Read-only local backtest only; no secrets, private exchange API, order path, executor, or remote service was touched.",
  231. "",
  232. "Output files:",
  233. *[f"- `{path}`" for path in output_files],
  234. "",
  235. "## Verdict",
  236. "",
  237. recommendation,
  238. f"Best switch candidate: `{best['name']}`. Recent 30d/21d/14d trade counts are {best_horizons[best_horizons['horizon'].isin(['30d', '21d', '14d'])]['trades'].astype(int).tolist()}; taker/taker full-sample positive: `{taker_ok}`; all short windows positive: `{short_window_ok}`.",
  239. "",
  240. "## Top maker_taker candidates",
  241. "",
  242. markdown_table(
  243. pct_columns(
  244. top_primary[
  245. [
  246. "name",
  247. "kind",
  248. "lookback_days",
  249. "micro_weight",
  250. "net_total_return",
  251. "net_annualized_return",
  252. "net_max_drawdown",
  253. "net_calmar",
  254. "trades",
  255. "trades_per_year",
  256. "win_rate",
  257. "avg_return",
  258. "payoff_ratio",
  259. "profit_factor",
  260. "min_recent_total_return",
  261. ]
  262. ],
  263. ("net_total_return", "net_annualized_return", "net_max_drawdown", "win_rate", "avg_return", "min_recent_total_return"),
  264. )
  265. ),
  266. "",
  267. "## Riskoff overlay checks",
  268. "",
  269. markdown_table(
  270. pct_columns(
  271. top_overlay[
  272. [
  273. "name",
  274. "lookback_days",
  275. "micro_weight",
  276. "net_total_return",
  277. "net_annualized_return",
  278. "net_max_drawdown",
  279. "net_calmar",
  280. "trades",
  281. "trades_per_year",
  282. "win_rate",
  283. "avg_return",
  284. "payoff_ratio",
  285. "profit_factor",
  286. "min_recent_total_return",
  287. ]
  288. ],
  289. ("net_total_return", "net_annualized_return", "net_max_drawdown", "win_rate", "avg_return", "min_recent_total_return"),
  290. )
  291. ),
  292. "",
  293. "## Taker/taker positive candidates",
  294. "",
  295. markdown_table(
  296. pct_columns(
  297. taker_positive[
  298. [
  299. "name",
  300. "kind",
  301. "lookback_days",
  302. "micro_weight",
  303. "net_total_return",
  304. "net_annualized_return",
  305. "net_max_drawdown",
  306. "net_calmar",
  307. "trades",
  308. "trades_per_year",
  309. ]
  310. ],
  311. ("net_total_return", "net_annualized_return", "net_max_drawdown"),
  312. )
  313. ),
  314. "",
  315. "## Best switch windows",
  316. "",
  317. markdown_table(
  318. pct_columns(
  319. best_horizons[
  320. [
  321. "horizon",
  322. "horizon_start",
  323. "horizon_end",
  324. "net_total_return",
  325. "net_annualized_return",
  326. "net_max_drawdown",
  327. "net_calmar",
  328. "trades",
  329. "trades_per_year",
  330. "win_rate",
  331. "avg_return",
  332. "payoff_ratio",
  333. "profit_factor",
  334. ]
  335. ],
  336. ("net_total_return", "net_annualized_return", "net_max_drawdown", "win_rate", "avg_return"),
  337. )
  338. ),
  339. ]
  340. return "\n".join(lines) + "\n"
  341. def main() -> int:
  342. parser = argparse.ArgumentParser()
  343. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  344. args = parser.parse_args()
  345. existing_equity = pd.read_csv(args.output_dir / "eth-btc-nextgen-equity.csv")
  346. base_equity = existing_equity[(existing_equity["cost_model"] == PRIMARY_COST) & (existing_equity["name"] == base.NEXTGEN_BASELINE)]
  347. if base_equity.empty:
  348. raise KeyError(f"missing existing nextgen equity for {base.NEXTGEN_BASELINE}")
  349. index = pd.DatetimeIndex(pd.to_datetime(base_equity["date"], utc=True))
  350. summary_frames: list[pd.DataFrame] = []
  351. horizon_frames: list[pd.DataFrame] = []
  352. equity_frames: list[pd.DataFrame] = []
  353. for cost_model, roundtrip_cost in COST_MODELS.items():
  354. nextgen_series, nextgen_trades = base.load_nextgen(index, roundtrip_cost)
  355. micro_candidates = base.load_micro_candidates(index, roundtrip_cost)
  356. summary, horizons, equity = build_direction_b(
  357. cost_model=cost_model,
  358. nextgen_series=nextgen_series,
  359. nextgen_trades=nextgen_trades,
  360. micro_candidates=micro_candidates,
  361. )
  362. summary_frames.append(summary)
  363. horizon_frames.append(horizons)
  364. equity_frames.append(equity)
  365. summary = pd.concat(summary_frames, ignore_index=True)
  366. horizons = pd.concat(horizon_frames, ignore_index=True)
  367. equity = pd.concat(equity_frames, ignore_index=True)
  368. args.output_dir.mkdir(parents=True, exist_ok=True)
  369. summary_path = args.output_dir / f"{PREFIX}-summary.csv"
  370. horizon_path = args.output_dir / f"{PREFIX}-horizons.csv"
  371. equity_path = args.output_dir / f"{PREFIX}-equity.csv"
  372. json_path = args.output_dir / f"{PREFIX}-top.json"
  373. report_path = args.output_dir / f"{PREFIX}-report.md"
  374. output_files = [summary_path, horizon_path, equity_path, json_path, report_path]
  375. summary.to_csv(summary_path, index=False)
  376. horizons.to_csv(horizon_path, index=False)
  377. equity.to_csv(equity_path, index=False)
  378. primary = summary[(summary["cost_model"] == PRIMARY_COST) & (summary["kind"] != "baseline")]
  379. json_path.write_text(json.dumps(primary.sort_values(["kind", "net_calmar"], ascending=[True, False]).head(20).to_dict("records"), indent=2), encoding="utf-8")
  380. command = f"rtk .venv/bin/python {Path(__file__).as_posix()}"
  381. report_path.write_text(write_report(summary, horizons, output_files, command), encoding="utf-8")
  382. print(primary.sort_values(["kind", "net_calmar"], ascending=[True, False]).head(12).to_string(index=False))
  383. return 0
  384. if __name__ == "__main__":
  385. raise SystemExit(main())