build_eth_nextgen_micro_signal_intent.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. from __future__ import annotations
  2. import argparse
  3. import json
  4. import sys
  5. from datetime import UTC, datetime
  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 build_eth_btc_nextgen_signal_intent as nextgen_intent
  10. from scripts import search_eth_microstructure_variants as micro
  11. from scripts import search_eth_nextgen_micro_portfolio as portfolio
  12. ROOT = Path(__file__).resolve().parents[1]
  13. REPORT_DIR = ROOT / "reports" / "eth-exploration"
  14. JSON_REPORT = REPORT_DIR / "eth-nextgen-micro-signal-intent.json"
  15. MARKDOWN_REPORT = REPORT_DIR / "eth-nextgen-micro-signal-intent.md"
  16. ETH = "ETH-USDT-SWAP"
  17. BAR = "15m"
  18. TARGET_NAME = "switch-l30-r96_q0.15_mf0.25_us"
  19. MICRO_NAME = "atr-compress-expand-r96-q0.15-sl0.008-tp0.016-mf0.25-us"
  20. LOOKBACK_DAYS = 30
  21. ROUNDTRIP_COST_ON_MARGIN = 0.0021
  22. MICRO_PARAMS = {
  23. "range_window": 96,
  24. "atr_window": 48,
  25. "atr_quantile_window": 480,
  26. "atr_quantile": 0.15,
  27. "stop_loss_pct": 0.008,
  28. "take_profit_pct": 0.016,
  29. "max_hold_bars": 32,
  30. "margin_fraction": 0.25,
  31. "session": "us",
  32. }
  33. def iso_text(ts: int) -> str:
  34. return datetime.fromtimestamp(ts / 1000, UTC).isoformat().replace("+00:00", "Z")
  35. def latest_active_engine() -> dict[str, object]:
  36. existing_equity = pd.read_csv(REPORT_DIR / "eth-btc-nextgen-equity.csv")
  37. base = existing_equity[
  38. (existing_equity["cost_model"] == portfolio.PRIMARY_COST) & (existing_equity["name"] == portfolio.NEXTGEN_BASELINE)
  39. ].copy()
  40. if base.empty:
  41. raise KeyError(f"missing existing nextgen equity for {portfolio.NEXTGEN_BASELINE}")
  42. index = pd.DatetimeIndex(pd.to_datetime(base["date"], utc=True))
  43. nextgen_series, _ = portfolio.load_nextgen(index, ROUNDTRIP_COST_ON_MARGIN)
  44. micro_series = portfolio.load_micro_candidates(index, ROUNDTRIP_COST_ON_MARGIN)[MICRO_NAME][0]
  45. nextgen_regime = nextgen_series / nextgen_series.shift(LOOKBACK_DAYS) - 1.0
  46. micro_regime = micro_series / micro_series.shift(LOOKBACK_DAYS) - 1.0
  47. active = ((nextgen_regime < 0.0) & (micro_regime > 0.0)).shift(1).fillna(False).astype(bool)
  48. decision_date = active.index[-1]
  49. micro_active = bool(active.iloc[-1])
  50. return {
  51. "active_engine": "micro" if micro_active else "nextgen",
  52. "decision_date": decision_date.strftime("%Y-%m-%d"),
  53. "switch_rule": "prior completed daily nextgen 30d return < 0 and micro 30d return > 0",
  54. "lookback_days": LOOKBACK_DAYS,
  55. "nextgen_30d_return": float(nextgen_regime.iloc[-2]) if len(nextgen_regime) >= 2 else None,
  56. "micro_30d_return": float(micro_regime.iloc[-2]) if len(micro_regime) >= 2 else None,
  57. "nextgen_equity": float(nextgen_series.iloc[-1]),
  58. "micro_equity": float(micro_series.iloc[-1]),
  59. }
  60. def micro_signal() -> dict[str, object]:
  61. candles = micro._load_candles(ETH, BAR)
  62. decision_index = len(candles) - 2
  63. if decision_index < max(int(MICRO_PARAMS["range_window"]), int(MICRO_PARAMS["atr_window"]), int(MICRO_PARAMS["atr_quantile_window"])):
  64. raise ValueError("not enough ETH candles for micro signal")
  65. highs = pd.Series([c.high for c in candles], dtype=float)
  66. lows = pd.Series([c.low for c in candles], dtype=float)
  67. closes = pd.Series([c.close for c in candles], dtype=float)
  68. prev_close = closes.shift(1)
  69. true_range = pd.concat([(highs - lows), (highs - prev_close).abs(), (lows - prev_close).abs()], axis=1).max(axis=1)
  70. atr = true_range.rolling(int(MICRO_PARAMS["atr_window"])).mean() / closes
  71. atr_limit = atr.rolling(int(MICRO_PARAMS["atr_quantile_window"])).quantile(float(MICRO_PARAMS["atr_quantile"]))
  72. range_high = highs.shift(1).rolling(int(MICRO_PARAMS["range_window"])).max()
  73. range_low = lows.shift(1).rolling(int(MICRO_PARAMS["range_window"])).min()
  74. candle = candles[decision_index]
  75. compressed = bool(float(atr.iloc[decision_index - 1]) <= float(atr_limit.iloc[decision_index - 1]))
  76. long_signal = compressed and candle.close > float(range_high.iloc[decision_index])
  77. short_signal = compressed and candle.close < float(range_low.iloc[decision_index])
  78. session_ok = micro._session_ok(candle, str(MICRO_PARAMS["session"]))
  79. signal = "long" if session_ok and long_signal else "short" if session_ok and short_signal else "no_signal"
  80. return {
  81. "engine": "micro",
  82. "candidate": MICRO_NAME,
  83. "symbol": ETH,
  84. "bar": BAR,
  85. "decision_candle_ts": candle.ts,
  86. "decision_candle_time": iso_text(candle.ts),
  87. "latest_local_candle_ts": candles[-1].ts,
  88. "latest_local_candle_time": iso_text(candles[-1].ts),
  89. "signal": signal,
  90. "raw_long_signal": bool(long_signal),
  91. "raw_short_signal": bool(short_signal),
  92. "session_ok": session_ok,
  93. "indicators": {
  94. "eth_close": candle.close,
  95. "atr_previous": float(atr.iloc[decision_index - 1]),
  96. "atr_limit_previous": float(atr_limit.iloc[decision_index - 1]),
  97. "range_high": float(range_high.iloc[decision_index]),
  98. "range_low": float(range_low.iloc[decision_index]),
  99. },
  100. "params": MICRO_PARAMS,
  101. }
  102. def build_payload() -> dict[str, object]:
  103. switch_state = latest_active_engine()
  104. nextgen_payload = nextgen_intent.build_payload()
  105. micro_payload = micro_signal()
  106. active_engine = str(switch_state["active_engine"])
  107. selected_signal = str(nextgen_payload["decision"]["signal"]) if active_engine == "nextgen" else str(micro_payload["signal"])
  108. return {
  109. "mode": "readonly_signal_intent",
  110. "strategy": {
  111. "name": TARGET_NAME,
  112. "symbol": ETH,
  113. "bar": BAR,
  114. "direction": "nextgen_long_only_or_micro_observation",
  115. "source_report": "reports/eth-exploration/eth-nextgen-micro-portfolio-report.md",
  116. "cost_model": "maker_taker",
  117. "roundtrip_cost_on_margin": ROUNDTRIP_COST_ON_MARGIN,
  118. },
  119. "created_at": datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"),
  120. "submitted_orders": 0,
  121. "private_key_required": False,
  122. "order_client": None,
  123. "switch_state": switch_state,
  124. "decision": {
  125. "active_engine": active_engine,
  126. "selected_signal": selected_signal,
  127. "needs_order": False,
  128. "needs_cancel": False,
  129. "intent": f"observe_{active_engine}_{selected_signal}",
  130. },
  131. "risk_limits": {
  132. "no_order_submission": True,
  133. "no_cancel_submission": True,
  134. "no_position_state_assumed": True,
  135. "execution": "intent_only",
  136. "blocked_for_live_trading": True,
  137. "blocker": "persistent virtual position state is not maintained by this read-only script",
  138. },
  139. "nextgen": {
  140. "decision": nextgen_payload["decision"],
  141. "legs": nextgen_payload["legs"],
  142. },
  143. "micro": micro_payload,
  144. }
  145. def markdown_report(payload: dict[str, object]) -> str:
  146. lines = [
  147. "# ETH nextgen + micro signal intent",
  148. "",
  149. "Read-only signal intent. No order or cancel request was submitted.",
  150. "",
  151. "## Decision",
  152. "",
  153. f"- Created at: `{payload['created_at']}`",
  154. f"- Strategy: `{payload['strategy']['name']}`",
  155. f"- Active engine: `{payload['decision']['active_engine']}`",
  156. f"- Selected signal: `{payload['decision']['selected_signal']}`",
  157. f"- Needs order: `{payload['decision']['needs_order']}`",
  158. f"- Blocked for live trading: `{payload['risk_limits']['blocked_for_live_trading']}`",
  159. f"- Blocker: `{payload['risk_limits']['blocker']}`",
  160. "",
  161. "## Intent JSON",
  162. "",
  163. "```json",
  164. json.dumps(payload, indent=2, sort_keys=True),
  165. "```",
  166. ]
  167. return "\n".join(lines) + "\n"
  168. def main() -> int:
  169. parser = argparse.ArgumentParser(description="Build a read-only ETH nextgen + micro signal intent payload.")
  170. parser.add_argument("--no-write", action="store_true")
  171. args = parser.parse_args()
  172. payload = build_payload()
  173. if not args.no_write:
  174. REPORT_DIR.mkdir(parents=True, exist_ok=True)
  175. JSON_REPORT.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
  176. MARKDOWN_REPORT.write_text(markdown_report(payload), encoding="utf-8")
  177. print(json.dumps(payload, indent=2, sort_keys=True))
  178. return 0
  179. if __name__ == "__main__":
  180. raise SystemExit(main())