eth_nextgen_micro.py 8.6 KB

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