build_eth_nextgen_micro_signal_stream.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. from __future__ import annotations
  2. import json
  3. import sys
  4. from dataclasses import dataclass
  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 explore_ultrashort as explore
  9. from scripts import search_eth_btc_nextgen_variants as nextgen
  10. from scripts import search_eth_microstructure_variants as micro
  11. REPORT_DIR = Path("reports/eth-exploration")
  12. TARGET_NAME = "switch-l30-r96_q0.15_mf0.25_us"
  13. COST_MODEL = "maker_taker"
  14. ROUNDTRIP_COST_ON_MARGIN = 0.0021
  15. NEXTGEN_LEGS = (
  16. "btc_trend_eth_rsi:15m:eth-btc-rsi-filter-et50-l3.0-x55.0-bt480-bm240-br0.0",
  17. "btc_shock_guard_eth_rsi:15m:eth-btc-shock-filter-et50-l3.0-x55.0-bt480-bm240-br0.01-sw96-sv0.01-sd0.05",
  18. )
  19. MICRO_NAME = "atr-compress-expand-r96-q0.15-sl0.008-tp0.016-mf0.25-us"
  20. LOOKBACK_DAYS = 30
  21. @dataclass(frozen=True)
  22. class TradeEvent:
  23. source: str
  24. leg: str
  25. side: str
  26. entry_time: pd.Timestamp
  27. exit_time: pd.Timestamp
  28. exit_date: pd.Timestamp
  29. entry_price: float
  30. exit_price: float
  31. net_return: float
  32. def daily_equity(frame: pd.DataFrame, index: pd.DatetimeIndex) -> pd.Series:
  33. series = frame.set_index("ts")["equity"].sort_index()
  34. daily = series.reindex(index.union(series.index)).sort_index().ffill().reindex(index).ffill()
  35. daily.iloc[0] = explore.INITIAL_EQUITY
  36. return daily
  37. def parse_time(value: object) -> pd.Timestamp:
  38. return pd.to_datetime(str(value), utc=True)
  39. def trade_from_nextgen(leg: str, trade: dict[str, object]) -> TradeEvent:
  40. net_return = (float(trade["return_pct"]) / 100.0 - ROUNDTRIP_COST_ON_MARGIN * float(trade.get("cost_weight", 1.0))) * 0.5
  41. exit_time = parse_time(trade["exit_time"])
  42. return TradeEvent(
  43. source="nextgen",
  44. leg=leg,
  45. side=str(trade["side"]).lower(),
  46. entry_time=parse_time(trade["entry_time"]),
  47. exit_time=exit_time,
  48. exit_date=exit_time.normalize(),
  49. entry_price=float(trade["entry_price"]),
  50. exit_price=float(trade["exit_price"]),
  51. net_return=net_return,
  52. )
  53. def trade_from_micro(trade: dict[str, object]) -> TradeEvent:
  54. net_return = float(trade["return_pct"]) / 100.0 - ROUNDTRIP_COST_ON_MARGIN * float(trade["cost_weight"])
  55. exit_time = parse_time(trade["exit_time"])
  56. return TradeEvent(
  57. source="micro",
  58. leg=MICRO_NAME,
  59. side=str(trade["side"]).lower(),
  60. entry_time=parse_time(trade["entry_time"]),
  61. exit_time=exit_time,
  62. exit_date=exit_time.normalize(),
  63. entry_price=float(trade["entry_price"]),
  64. exit_price=float(trade["exit_price"]),
  65. net_return=net_return,
  66. )
  67. def load_components() -> tuple[pd.Series, pd.Series, pd.Series, list[TradeEvent], list[TradeEvent]]:
  68. published_equity = pd.read_csv(REPORT_DIR / "eth-btc-nextgen-equity.csv")
  69. base = published_equity[(published_equity["name"] == "equal-2-c0003") & (published_equity["cost_model"] == COST_MODEL)]
  70. index = pd.DatetimeIndex(pd.to_datetime(base["date"], utc=True))
  71. strategies = {
  72. f"{strategy.family}:{strategy.bar}:{strategy.candidate.name}": strategy
  73. for strategy in nextgen.build_strategies()
  74. }
  75. data = {
  76. (symbol, "15m"): nextgen.load_candles(symbol, "15m", 10.0)
  77. for symbol in ("ETH-USDT-SWAP", "BTC-USDT-SWAP")
  78. }
  79. leg_series = []
  80. nextgen_trades = []
  81. for leg in NEXTGEN_LEGS:
  82. result = nextgen.run_strategy(strategies[leg], data)
  83. leg_series.append(daily_equity(explore.cost_adjusted_trade_equity_frame(result, ROUNDTRIP_COST_ON_MARGIN), index))
  84. nextgen_trades.extend(trade_from_nextgen(leg, trade) for trade in result.trades)
  85. nextgen_returns = pd.DataFrame([series.pct_change().fillna(0.0) for series in leg_series]).T.mean(axis=1)
  86. nextgen_series = explore.INITIAL_EQUITY * (1.0 + nextgen_returns).cumprod()
  87. nextgen_series.iloc[0] = explore.INITIAL_EQUITY
  88. candles = micro._load_candles(micro.SYMBOL, micro.BAR)
  89. requested = int(10.0 * 365 * 24 * 60 / 15)
  90. candles = candles[-requested:]
  91. variant = {variant.name: variant for variant in micro.build_variants()}[MICRO_NAME]
  92. micro_result = variant.run(candles)
  93. micro_series = daily_equity(micro.cost_equity_frame(micro_result, ROUNDTRIP_COST_ON_MARGIN), index)
  94. micro_trades = [trade_from_micro(trade) for trade in micro_result.trades]
  95. nextgen_regime = nextgen_series / nextgen_series.shift(LOOKBACK_DAYS) - 1.0
  96. micro_regime = micro_series / micro_series.shift(LOOKBACK_DAYS) - 1.0
  97. active = ((nextgen_regime < 0.0) & (micro_regime > 0.0)).shift(1).fillna(False).astype(bool)
  98. return active, nextgen_series, micro_series, nextgen_trades, micro_trades
  99. def selected_trades(active: pd.Series, nextgen_trades: list[TradeEvent], micro_trades: list[TradeEvent]) -> list[TradeEvent]:
  100. selected = [
  101. trade for trade in nextgen_trades if not bool(active.reindex([trade.exit_date]).fillna(False).iloc[0])
  102. ]
  103. selected.extend(
  104. trade for trade in micro_trades if bool(active.reindex([trade.exit_date]).fillna(False).iloc[0])
  105. )
  106. return sorted(selected, key=lambda trade: (trade.entry_time, trade.exit_time, trade.source, trade.leg))
  107. def trade_frame(trades: list[TradeEvent]) -> pd.DataFrame:
  108. return pd.DataFrame(
  109. [
  110. {
  111. "source": trade.source,
  112. "leg": trade.leg,
  113. "side": trade.side,
  114. "entry_time": trade.entry_time.strftime("%Y-%m-%d %H:%M:%S%z"),
  115. "exit_time": trade.exit_time.strftime("%Y-%m-%d %H:%M:%S%z"),
  116. "exit_date": trade.exit_date.strftime("%Y-%m-%d"),
  117. "entry_price": trade.entry_price,
  118. "exit_price": trade.exit_price,
  119. "net_return": trade.net_return,
  120. }
  121. for trade in trades
  122. ]
  123. )
  124. def event_maps(trades: list[TradeEvent]) -> tuple[dict[pd.Timestamp, list[TradeEvent]], dict[pd.Timestamp, list[TradeEvent]]]:
  125. entries: dict[pd.Timestamp, list[TradeEvent]] = {}
  126. exits: dict[pd.Timestamp, list[TradeEvent]] = {}
  127. for trade in trades:
  128. entries.setdefault(trade.entry_time, []).append(trade)
  129. exits.setdefault(trade.exit_time, []).append(trade)
  130. return entries, exits
  131. def source_count(trades: list[TradeEvent], source: str) -> int:
  132. return sum(1 for trade in trades if trade.source == source)
  133. def labels(trades: list[TradeEvent]) -> str:
  134. return ";".join(f"{trade.source}:{trade.side}" for trade in trades)
  135. def stream_frame(active: pd.Series, selected: list[TradeEvent], raw_trades: list[TradeEvent]) -> pd.DataFrame:
  136. candles = micro._load_candles(micro.SYMBOL, micro.BAR)
  137. first = active.index[0]
  138. last = active.index[-1] + pd.Timedelta(days=1)
  139. selected_entries, selected_exits = event_maps(selected)
  140. raw_entries, raw_exits = event_maps(raw_trades)
  141. rows = []
  142. for candle in candles:
  143. ts = pd.to_datetime(candle.ts, unit="ms", utc=True)
  144. if ts < first or ts >= last:
  145. continue
  146. date = ts.normalize()
  147. micro_active = bool(active.reindex([date]).fillna(False).iloc[0])
  148. selected_entry_trades = selected_entries.get(ts, [])
  149. selected_exit_trades = selected_exits.get(ts, [])
  150. raw_entry_trades = raw_entries.get(ts, [])
  151. raw_exit_trades = raw_exits.get(ts, [])
  152. rows.append(
  153. {
  154. "time": ts.strftime("%Y-%m-%d %H:%M:%S%z"),
  155. "date": date.strftime("%Y-%m-%d"),
  156. "active_engine": "micro" if micro_active else "nextgen",
  157. "open": candle.open,
  158. "high": candle.high,
  159. "low": candle.low,
  160. "close": candle.close,
  161. "selected_entry_count": len(selected_entry_trades),
  162. "selected_exit_count": len(selected_exit_trades),
  163. "selected_entry_labels": labels(selected_entry_trades),
  164. "selected_exit_labels": labels(selected_exit_trades),
  165. "raw_nextgen_entry_count": source_count(raw_entry_trades, "nextgen"),
  166. "raw_nextgen_exit_count": source_count(raw_exit_trades, "nextgen"),
  167. "raw_micro_entry_count": source_count(raw_entry_trades, "micro"),
  168. "raw_micro_exit_count": source_count(raw_exit_trades, "micro"),
  169. }
  170. )
  171. return pd.DataFrame(rows)
  172. def write_report(stream: pd.DataFrame, trades: pd.DataFrame, active: pd.Series) -> str:
  173. source_counts = trades["source"].value_counts().to_dict()
  174. side_counts = trades["side"].value_counts().to_dict()
  175. entry_rows = stream[stream["selected_entry_count"] > 0]
  176. exit_rows = stream[stream["selected_exit_count"] > 0]
  177. lines = [
  178. "# ETH nextgen micro signal stream",
  179. "",
  180. f"Target: `{TARGET_NAME}` / `{COST_MODEL}`",
  181. "",
  182. "This is a read-only signal stream for cross-checking strategy timing. It does not call OKX private APIs and does not place orders.",
  183. "",
  184. "## Outputs",
  185. "",
  186. "- `reports/eth-exploration/eth-nextgen-micro-signal-stream.csv`",
  187. "- `reports/eth-exploration/eth-nextgen-micro-selected-trades.csv`",
  188. "- `reports/eth-exploration/eth-nextgen-micro-signal-stream-summary.json`",
  189. "",
  190. "## Summary",
  191. "",
  192. f"- 15m rows: `{len(stream)}`",
  193. f"- Selected trades: `{len(trades)}`",
  194. f"- Active micro days: `{int(active.sum())}`",
  195. f"- Active nextgen days: `{int((~active).sum())}`",
  196. f"- Selected source counts: `{json.dumps(source_counts, sort_keys=True)}`",
  197. f"- Selected side counts: `{json.dumps(side_counts, sort_keys=True)}`",
  198. f"- Entry candles with selected trades: `{len(entry_rows)}`",
  199. f"- Exit candles with selected trades: `{len(exit_rows)}`",
  200. "",
  201. "## Interpretation",
  202. "",
  203. "The stream records which engine the switch rule selects on each 15m candle's UTC date. A trade is selected by the engine active on its exit date, matching the validated portfolio accounting path.",
  204. "",
  205. ]
  206. return "\n".join(lines)
  207. def main() -> int:
  208. active, _, _, nextgen_trades, micro_trades = load_components()
  209. raw_trades = sorted([*nextgen_trades, *micro_trades], key=lambda trade: (trade.entry_time, trade.exit_time))
  210. selected = selected_trades(active, nextgen_trades, micro_trades)
  211. trades = trade_frame(selected)
  212. stream = stream_frame(active, selected, raw_trades)
  213. stream_path = REPORT_DIR / "eth-nextgen-micro-signal-stream.csv"
  214. trades_path = REPORT_DIR / "eth-nextgen-micro-selected-trades.csv"
  215. summary_path = REPORT_DIR / "eth-nextgen-micro-signal-stream-summary.json"
  216. report_path = REPORT_DIR / "eth-nextgen-micro-signal-stream.md"
  217. summary = {
  218. "target": TARGET_NAME,
  219. "cost_model": COST_MODEL,
  220. "stream_rows": len(stream),
  221. "selected_trades": len(trades),
  222. "active_micro_days": int(active.sum()),
  223. "active_nextgen_days": int((~active).sum()),
  224. "source_counts": trades["source"].value_counts().to_dict(),
  225. "side_counts": trades["side"].value_counts().to_dict(),
  226. "selected_entry_candles": int((stream["selected_entry_count"] > 0).sum()),
  227. "selected_exit_candles": int((stream["selected_exit_count"] > 0).sum()),
  228. }
  229. stream.to_csv(stream_path, index=False)
  230. trades.to_csv(trades_path, index=False)
  231. summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True), encoding="utf-8")
  232. report_path.write_text(write_report(stream, trades, active), encoding="utf-8")
  233. print(report_path)
  234. print(json.dumps(summary, indent=2, sort_keys=True))
  235. return 0
  236. if __name__ == "__main__":
  237. raise SystemExit(main())