Ver código fonte

feat: add eth nextgen micro signal intent

lxy 1 mês atrás
pai
commit
f100b3a3b3

+ 215 - 0
reports/eth-exploration/eth-nextgen-micro-signal-intent.json

@@ -0,0 +1,215 @@
+{
+  "created_at": "2026-04-30T04:05:08Z",
+  "decision": {
+    "active_engine": "nextgen",
+    "intent": "observe_nextgen_no_signal",
+    "needs_cancel": false,
+    "needs_order": false,
+    "selected_signal": "no_signal"
+  },
+  "micro": {
+    "bar": "15m",
+    "candidate": "atr-compress-expand-r96-q0.15-sl0.008-tp0.016-mf0.25-us",
+    "decision_candle_time": "2026-04-29T16:45:00Z",
+    "decision_candle_ts": 1777481100000,
+    "engine": "micro",
+    "indicators": {
+      "atr_limit_previous": 0.0014534667021488998,
+      "atr_previous": 0.003739568398220663,
+      "eth_close": 2266.59,
+      "range_high": 2346.27,
+      "range_low": 2261.45
+    },
+    "latest_local_candle_time": "2026-04-29T17:00:00Z",
+    "latest_local_candle_ts": 1777482000000,
+    "params": {
+      "atr_quantile": 0.15,
+      "atr_quantile_window": 480,
+      "atr_window": 48,
+      "margin_fraction": 0.25,
+      "max_hold_bars": 32,
+      "range_window": 96,
+      "session": "us",
+      "stop_loss_pct": 0.008,
+      "take_profit_pct": 0.016
+    },
+    "raw_long_signal": false,
+    "raw_short_signal": false,
+    "session_ok": true,
+    "signal": "no_signal",
+    "symbol": "ETH-USDT-SWAP"
+  },
+  "mode": "readonly_signal_intent",
+  "nextgen": {
+    "decision": {
+      "active_signal_count": 0,
+      "active_suggested_weight": 0,
+      "intent": "observe_no_signal",
+      "needs_cancel": false,
+      "needs_order": false,
+      "signal": "no_signal"
+    },
+    "legs": [
+      {
+        "bar": "15m",
+        "conditions": {
+          "btc_close_above_sma480": {
+            "distance_to_pass": 1475.0999999999185,
+            "passes": false,
+            "threshold": 77331.09999999992,
+            "value": 75856.0
+          },
+          "btc_momentum_at_or_above_min": {
+            "distance_to_pass": 0.040101233786776325,
+            "passes": false,
+            "threshold": 0.0,
+            "value": -0.040101233786776325
+          },
+          "eth_close_above_sma50": {
+            "distance_to_pass": 48.86540000000059,
+            "passes": false,
+            "threshold": 2315.6154000000006,
+            "value": 2266.75
+          },
+          "eth_rsi2_at_or_below_3": {
+            "distance_to_pass": 7.743438715792621,
+            "passes": false,
+            "threshold": 3.0,
+            "value": 10.743438715792621
+          }
+        },
+        "direction": "long",
+        "dry_run_action": "observe_no_signal",
+        "entry_rule": "eth_close > eth_sma50 and eth_rsi2 <= 3 and btc_close > btc_sma480 and btc_momentum_240 >= minimum",
+        "exit_rule": "eth_rsi2 >= exit_rsi or btc_close < btc_sma480; shock leg also exits when shock guard fails",
+        "exit_signal": true,
+        "family": "btc_trend_eth_rsi",
+        "indicators": {
+          "btc_close": 75856.0,
+          "btc_momentum_240": -0.040101233786776325,
+          "btc_sma480": 77331.09999999992,
+          "eth_close": 2266.75,
+          "eth_rsi2": 10.743438715792621,
+          "eth_sma50": 2315.6154000000006
+        },
+        "intent": "no_signal",
+        "leg_id": "btc_trend_eth_rsi",
+        "params": {
+          "btc_min_momentum": 0.0,
+          "btc_momentum_lookback": 240,
+          "btc_trend_sma": 480,
+          "eth_exit_rsi": 55.0,
+          "eth_rsi_threshold": 3.0,
+          "eth_trend_sma": 50
+        },
+        "signal": false,
+        "suggested_weight": 0.5,
+        "symbol": "ETH-USDT-SWAP"
+      },
+      {
+        "bar": "15m",
+        "conditions": {
+          "btc_close_above_sma480": {
+            "distance_to_pass": 1475.0999999999185,
+            "passes": false,
+            "threshold": 77331.09999999992,
+            "value": 75856.0
+          },
+          "btc_drawdown_at_or_above_floor": {
+            "distance_to_pass": 0.0,
+            "passes": true,
+            "threshold": -0.05,
+            "value": -0.025520730294209204
+          },
+          "btc_momentum_at_or_above_min": {
+            "distance_to_pass": 0.05010123378677633,
+            "passes": false,
+            "threshold": 0.01,
+            "value": -0.040101233786776325
+          },
+          "btc_realized_vol_at_or_below_max": {
+            "distance_to_pass": 0.0,
+            "passes": true,
+            "threshold": 0.01,
+            "value": 0.001651657896372991
+          },
+          "eth_close_above_sma50": {
+            "distance_to_pass": 48.86540000000059,
+            "passes": false,
+            "threshold": 2315.6154000000006,
+            "value": 2266.75
+          },
+          "eth_rsi2_at_or_below_3": {
+            "distance_to_pass": 7.743438715792621,
+            "passes": false,
+            "threshold": 3.0,
+            "value": 10.743438715792621
+          }
+        },
+        "direction": "long",
+        "dry_run_action": "observe_no_signal",
+        "entry_rule": "eth_close > eth_sma50 and eth_rsi2 <= 3 and btc_close > btc_sma480 and btc_momentum_240 >= minimum and btc_realized_vol_96 <= 0.01 and btc_drawdown_96 >= -0.05",
+        "exit_rule": "eth_rsi2 >= exit_rsi or btc_close < btc_sma480; shock leg also exits when shock guard fails",
+        "exit_signal": true,
+        "family": "btc_shock_guard_eth_rsi",
+        "indicators": {
+          "btc_close": 75856.0,
+          "btc_drawdown_96": -0.025520730294209204,
+          "btc_momentum_240": -0.040101233786776325,
+          "btc_realized_vol_96": 0.001651657896372991,
+          "btc_recent_high_96": 77842.6,
+          "btc_sma480": 77331.09999999992,
+          "eth_close": 2266.75,
+          "eth_rsi2": 10.743438715792621,
+          "eth_sma50": 2315.6154000000006
+        },
+        "intent": "no_signal",
+        "leg_id": "btc_shock_guard_eth_rsi",
+        "params": {
+          "btc_max_drawdown": 0.05,
+          "btc_max_realized_vol": 0.01,
+          "btc_min_momentum": 0.01,
+          "btc_momentum_lookback": 240,
+          "btc_shock_lookback": 96,
+          "btc_trend_sma": 480,
+          "eth_exit_rsi": 55.0,
+          "eth_rsi_threshold": 3.0,
+          "eth_trend_sma": 50
+        },
+        "signal": false,
+        "suggested_weight": 0.5,
+        "symbol": "ETH-USDT-SWAP"
+      }
+    ]
+  },
+  "order_client": null,
+  "private_key_required": false,
+  "risk_limits": {
+    "blocked_for_live_trading": true,
+    "blocker": "persistent virtual position state is not maintained by this read-only script",
+    "execution": "intent_only",
+    "no_cancel_submission": true,
+    "no_order_submission": true,
+    "no_position_state_assumed": true
+  },
+  "strategy": {
+    "bar": "15m",
+    "cost_model": "maker_taker",
+    "direction": "nextgen_long_only_or_micro_observation",
+    "name": "switch-l30-r96_q0.15_mf0.25_us",
+    "roundtrip_cost_on_margin": 0.0021,
+    "source_report": "reports/eth-exploration/eth-nextgen-micro-portfolio-report.md",
+    "symbol": "ETH-USDT-SWAP"
+  },
+  "submitted_orders": 0,
+  "switch_state": {
+    "active_engine": "nextgen",
+    "decision_date": "2026-04-29",
+    "lookback_days": 30,
+    "micro_30d_return": -0.0081957115778859,
+    "micro_equity": 10864.981539168395,
+    "nextgen_30d_return": 0.016854077119548894,
+    "nextgen_equity": 25219.39752888712,
+    "switch_rule": "prior completed daily nextgen 30d return < 0 and micro 30d return > 0"
+  }
+}

+ 233 - 0
reports/eth-exploration/eth-nextgen-micro-signal-intent.md

@@ -0,0 +1,233 @@
+# ETH nextgen + micro signal intent
+
+Read-only signal intent. No order or cancel request was submitted.
+
+## Decision
+
+- Created at: `2026-04-30T04:05:08Z`
+- Strategy: `switch-l30-r96_q0.15_mf0.25_us`
+- Active engine: `nextgen`
+- Selected signal: `no_signal`
+- Needs order: `False`
+- Blocked for live trading: `True`
+- Blocker: `persistent virtual position state is not maintained by this read-only script`
+
+## Intent JSON
+
+```json
+{
+  "created_at": "2026-04-30T04:05:08Z",
+  "decision": {
+    "active_engine": "nextgen",
+    "intent": "observe_nextgen_no_signal",
+    "needs_cancel": false,
+    "needs_order": false,
+    "selected_signal": "no_signal"
+  },
+  "micro": {
+    "bar": "15m",
+    "candidate": "atr-compress-expand-r96-q0.15-sl0.008-tp0.016-mf0.25-us",
+    "decision_candle_time": "2026-04-29T16:45:00Z",
+    "decision_candle_ts": 1777481100000,
+    "engine": "micro",
+    "indicators": {
+      "atr_limit_previous": 0.0014534667021488998,
+      "atr_previous": 0.003739568398220663,
+      "eth_close": 2266.59,
+      "range_high": 2346.27,
+      "range_low": 2261.45
+    },
+    "latest_local_candle_time": "2026-04-29T17:00:00Z",
+    "latest_local_candle_ts": 1777482000000,
+    "params": {
+      "atr_quantile": 0.15,
+      "atr_quantile_window": 480,
+      "atr_window": 48,
+      "margin_fraction": 0.25,
+      "max_hold_bars": 32,
+      "range_window": 96,
+      "session": "us",
+      "stop_loss_pct": 0.008,
+      "take_profit_pct": 0.016
+    },
+    "raw_long_signal": false,
+    "raw_short_signal": false,
+    "session_ok": true,
+    "signal": "no_signal",
+    "symbol": "ETH-USDT-SWAP"
+  },
+  "mode": "readonly_signal_intent",
+  "nextgen": {
+    "decision": {
+      "active_signal_count": 0,
+      "active_suggested_weight": 0,
+      "intent": "observe_no_signal",
+      "needs_cancel": false,
+      "needs_order": false,
+      "signal": "no_signal"
+    },
+    "legs": [
+      {
+        "bar": "15m",
+        "conditions": {
+          "btc_close_above_sma480": {
+            "distance_to_pass": 1475.0999999999185,
+            "passes": false,
+            "threshold": 77331.09999999992,
+            "value": 75856.0
+          },
+          "btc_momentum_at_or_above_min": {
+            "distance_to_pass": 0.040101233786776325,
+            "passes": false,
+            "threshold": 0.0,
+            "value": -0.040101233786776325
+          },
+          "eth_close_above_sma50": {
+            "distance_to_pass": 48.86540000000059,
+            "passes": false,
+            "threshold": 2315.6154000000006,
+            "value": 2266.75
+          },
+          "eth_rsi2_at_or_below_3": {
+            "distance_to_pass": 7.743438715792621,
+            "passes": false,
+            "threshold": 3.0,
+            "value": 10.743438715792621
+          }
+        },
+        "direction": "long",
+        "dry_run_action": "observe_no_signal",
+        "entry_rule": "eth_close > eth_sma50 and eth_rsi2 <= 3 and btc_close > btc_sma480 and btc_momentum_240 >= minimum",
+        "exit_rule": "eth_rsi2 >= exit_rsi or btc_close < btc_sma480; shock leg also exits when shock guard fails",
+        "exit_signal": true,
+        "family": "btc_trend_eth_rsi",
+        "indicators": {
+          "btc_close": 75856.0,
+          "btc_momentum_240": -0.040101233786776325,
+          "btc_sma480": 77331.09999999992,
+          "eth_close": 2266.75,
+          "eth_rsi2": 10.743438715792621,
+          "eth_sma50": 2315.6154000000006
+        },
+        "intent": "no_signal",
+        "leg_id": "btc_trend_eth_rsi",
+        "params": {
+          "btc_min_momentum": 0.0,
+          "btc_momentum_lookback": 240,
+          "btc_trend_sma": 480,
+          "eth_exit_rsi": 55.0,
+          "eth_rsi_threshold": 3.0,
+          "eth_trend_sma": 50
+        },
+        "signal": false,
+        "suggested_weight": 0.5,
+        "symbol": "ETH-USDT-SWAP"
+      },
+      {
+        "bar": "15m",
+        "conditions": {
+          "btc_close_above_sma480": {
+            "distance_to_pass": 1475.0999999999185,
+            "passes": false,
+            "threshold": 77331.09999999992,
+            "value": 75856.0
+          },
+          "btc_drawdown_at_or_above_floor": {
+            "distance_to_pass": 0.0,
+            "passes": true,
+            "threshold": -0.05,
+            "value": -0.025520730294209204
+          },
+          "btc_momentum_at_or_above_min": {
+            "distance_to_pass": 0.05010123378677633,
+            "passes": false,
+            "threshold": 0.01,
+            "value": -0.040101233786776325
+          },
+          "btc_realized_vol_at_or_below_max": {
+            "distance_to_pass": 0.0,
+            "passes": true,
+            "threshold": 0.01,
+            "value": 0.001651657896372991
+          },
+          "eth_close_above_sma50": {
+            "distance_to_pass": 48.86540000000059,
+            "passes": false,
+            "threshold": 2315.6154000000006,
+            "value": 2266.75
+          },
+          "eth_rsi2_at_or_below_3": {
+            "distance_to_pass": 7.743438715792621,
+            "passes": false,
+            "threshold": 3.0,
+            "value": 10.743438715792621
+          }
+        },
+        "direction": "long",
+        "dry_run_action": "observe_no_signal",
+        "entry_rule": "eth_close > eth_sma50 and eth_rsi2 <= 3 and btc_close > btc_sma480 and btc_momentum_240 >= minimum and btc_realized_vol_96 <= 0.01 and btc_drawdown_96 >= -0.05",
+        "exit_rule": "eth_rsi2 >= exit_rsi or btc_close < btc_sma480; shock leg also exits when shock guard fails",
+        "exit_signal": true,
+        "family": "btc_shock_guard_eth_rsi",
+        "indicators": {
+          "btc_close": 75856.0,
+          "btc_drawdown_96": -0.025520730294209204,
+          "btc_momentum_240": -0.040101233786776325,
+          "btc_realized_vol_96": 0.001651657896372991,
+          "btc_recent_high_96": 77842.6,
+          "btc_sma480": 77331.09999999992,
+          "eth_close": 2266.75,
+          "eth_rsi2": 10.743438715792621,
+          "eth_sma50": 2315.6154000000006
+        },
+        "intent": "no_signal",
+        "leg_id": "btc_shock_guard_eth_rsi",
+        "params": {
+          "btc_max_drawdown": 0.05,
+          "btc_max_realized_vol": 0.01,
+          "btc_min_momentum": 0.01,
+          "btc_momentum_lookback": 240,
+          "btc_shock_lookback": 96,
+          "btc_trend_sma": 480,
+          "eth_exit_rsi": 55.0,
+          "eth_rsi_threshold": 3.0,
+          "eth_trend_sma": 50
+        },
+        "signal": false,
+        "suggested_weight": 0.5,
+        "symbol": "ETH-USDT-SWAP"
+      }
+    ]
+  },
+  "order_client": null,
+  "private_key_required": false,
+  "risk_limits": {
+    "blocked_for_live_trading": true,
+    "blocker": "persistent virtual position state is not maintained by this read-only script",
+    "execution": "intent_only",
+    "no_cancel_submission": true,
+    "no_order_submission": true,
+    "no_position_state_assumed": true
+  },
+  "strategy": {
+    "bar": "15m",
+    "cost_model": "maker_taker",
+    "direction": "nextgen_long_only_or_micro_observation",
+    "name": "switch-l30-r96_q0.15_mf0.25_us",
+    "roundtrip_cost_on_margin": 0.0021,
+    "source_report": "reports/eth-exploration/eth-nextgen-micro-portfolio-report.md",
+    "symbol": "ETH-USDT-SWAP"
+  },
+  "submitted_orders": 0,
+  "switch_state": {
+    "active_engine": "nextgen",
+    "decision_date": "2026-04-29",
+    "lookback_days": 30,
+    "micro_30d_return": -0.0081957115778859,
+    "micro_equity": 10864.981539168395,
+    "nextgen_30d_return": 0.016854077119548894,
+    "nextgen_equity": 25219.39752888712,
+    "switch_rule": "prior completed daily nextgen 30d return < 0 and micro 30d return > 0"
+  }
+}
+```

+ 201 - 0
scripts/build_eth_nextgen_micro_signal_intent.py

@@ -0,0 +1,201 @@
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from datetime import UTC, datetime
+from pathlib import Path
+
+import pandas as pd
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from scripts import build_eth_btc_nextgen_signal_intent as nextgen_intent
+from scripts import search_eth_microstructure_variants as micro
+from scripts import search_eth_nextgen_micro_portfolio as portfolio
+
+
+ROOT = Path(__file__).resolve().parents[1]
+REPORT_DIR = ROOT / "reports" / "eth-exploration"
+JSON_REPORT = REPORT_DIR / "eth-nextgen-micro-signal-intent.json"
+MARKDOWN_REPORT = REPORT_DIR / "eth-nextgen-micro-signal-intent.md"
+ETH = "ETH-USDT-SWAP"
+BAR = "15m"
+TARGET_NAME = "switch-l30-r96_q0.15_mf0.25_us"
+MICRO_NAME = "atr-compress-expand-r96-q0.15-sl0.008-tp0.016-mf0.25-us"
+LOOKBACK_DAYS = 30
+ROUNDTRIP_COST_ON_MARGIN = 0.0021
+MICRO_PARAMS = {
+    "range_window": 96,
+    "atr_window": 48,
+    "atr_quantile_window": 480,
+    "atr_quantile": 0.15,
+    "stop_loss_pct": 0.008,
+    "take_profit_pct": 0.016,
+    "max_hold_bars": 32,
+    "margin_fraction": 0.25,
+    "session": "us",
+}
+
+
+def iso_text(ts: int) -> str:
+    return datetime.fromtimestamp(ts / 1000, UTC).isoformat().replace("+00:00", "Z")
+
+
+def latest_active_engine() -> dict[str, object]:
+    existing_equity = pd.read_csv(REPORT_DIR / "eth-btc-nextgen-equity.csv")
+    base = existing_equity[
+        (existing_equity["cost_model"] == portfolio.PRIMARY_COST) & (existing_equity["name"] == portfolio.NEXTGEN_BASELINE)
+    ].copy()
+    if base.empty:
+        raise KeyError(f"missing existing nextgen equity for {portfolio.NEXTGEN_BASELINE}")
+    index = pd.DatetimeIndex(pd.to_datetime(base["date"], utc=True))
+    nextgen_series, _ = portfolio.load_nextgen(index, ROUNDTRIP_COST_ON_MARGIN)
+    micro_series = portfolio.load_micro_candidates(index, ROUNDTRIP_COST_ON_MARGIN)[MICRO_NAME][0]
+    nextgen_regime = nextgen_series / nextgen_series.shift(LOOKBACK_DAYS) - 1.0
+    micro_regime = micro_series / micro_series.shift(LOOKBACK_DAYS) - 1.0
+    active = ((nextgen_regime < 0.0) & (micro_regime > 0.0)).shift(1).fillna(False).astype(bool)
+    decision_date = active.index[-1]
+    micro_active = bool(active.iloc[-1])
+    return {
+        "active_engine": "micro" if micro_active else "nextgen",
+        "decision_date": decision_date.strftime("%Y-%m-%d"),
+        "switch_rule": "prior completed daily nextgen 30d return < 0 and micro 30d return > 0",
+        "lookback_days": LOOKBACK_DAYS,
+        "nextgen_30d_return": float(nextgen_regime.iloc[-2]) if len(nextgen_regime) >= 2 else None,
+        "micro_30d_return": float(micro_regime.iloc[-2]) if len(micro_regime) >= 2 else None,
+        "nextgen_equity": float(nextgen_series.iloc[-1]),
+        "micro_equity": float(micro_series.iloc[-1]),
+    }
+
+
+def micro_signal() -> dict[str, object]:
+    candles = micro._load_candles(ETH, BAR)
+    decision_index = len(candles) - 2
+    if decision_index < max(int(MICRO_PARAMS["range_window"]), int(MICRO_PARAMS["atr_window"]), int(MICRO_PARAMS["atr_quantile_window"])):
+        raise ValueError("not enough ETH candles for micro signal")
+
+    highs = pd.Series([c.high for c in candles], dtype=float)
+    lows = pd.Series([c.low for c in candles], dtype=float)
+    closes = pd.Series([c.close for c in candles], dtype=float)
+    prev_close = closes.shift(1)
+    true_range = pd.concat([(highs - lows), (highs - prev_close).abs(), (lows - prev_close).abs()], axis=1).max(axis=1)
+    atr = true_range.rolling(int(MICRO_PARAMS["atr_window"])).mean() / closes
+    atr_limit = atr.rolling(int(MICRO_PARAMS["atr_quantile_window"])).quantile(float(MICRO_PARAMS["atr_quantile"]))
+    range_high = highs.shift(1).rolling(int(MICRO_PARAMS["range_window"])).max()
+    range_low = lows.shift(1).rolling(int(MICRO_PARAMS["range_window"])).min()
+    candle = candles[decision_index]
+    compressed = bool(float(atr.iloc[decision_index - 1]) <= float(atr_limit.iloc[decision_index - 1]))
+    long_signal = compressed and candle.close > float(range_high.iloc[decision_index])
+    short_signal = compressed and candle.close < float(range_low.iloc[decision_index])
+    session_ok = micro._session_ok(candle, str(MICRO_PARAMS["session"]))
+    signal = "long" if session_ok and long_signal else "short" if session_ok and short_signal else "no_signal"
+    return {
+        "engine": "micro",
+        "candidate": MICRO_NAME,
+        "symbol": ETH,
+        "bar": BAR,
+        "decision_candle_ts": candle.ts,
+        "decision_candle_time": iso_text(candle.ts),
+        "latest_local_candle_ts": candles[-1].ts,
+        "latest_local_candle_time": iso_text(candles[-1].ts),
+        "signal": signal,
+        "raw_long_signal": bool(long_signal),
+        "raw_short_signal": bool(short_signal),
+        "session_ok": session_ok,
+        "indicators": {
+            "eth_close": candle.close,
+            "atr_previous": float(atr.iloc[decision_index - 1]),
+            "atr_limit_previous": float(atr_limit.iloc[decision_index - 1]),
+            "range_high": float(range_high.iloc[decision_index]),
+            "range_low": float(range_low.iloc[decision_index]),
+        },
+        "params": MICRO_PARAMS,
+    }
+
+
+def build_payload() -> dict[str, object]:
+    switch_state = latest_active_engine()
+    nextgen_payload = nextgen_intent.build_payload()
+    micro_payload = micro_signal()
+    active_engine = str(switch_state["active_engine"])
+    selected_signal = str(nextgen_payload["decision"]["signal"]) if active_engine == "nextgen" else str(micro_payload["signal"])
+    return {
+        "mode": "readonly_signal_intent",
+        "strategy": {
+            "name": TARGET_NAME,
+            "symbol": ETH,
+            "bar": BAR,
+            "direction": "nextgen_long_only_or_micro_observation",
+            "source_report": "reports/eth-exploration/eth-nextgen-micro-portfolio-report.md",
+            "cost_model": "maker_taker",
+            "roundtrip_cost_on_margin": ROUNDTRIP_COST_ON_MARGIN,
+        },
+        "created_at": datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"),
+        "submitted_orders": 0,
+        "private_key_required": False,
+        "order_client": None,
+        "switch_state": switch_state,
+        "decision": {
+            "active_engine": active_engine,
+            "selected_signal": selected_signal,
+            "needs_order": False,
+            "needs_cancel": False,
+            "intent": f"observe_{active_engine}_{selected_signal}",
+        },
+        "risk_limits": {
+            "no_order_submission": True,
+            "no_cancel_submission": True,
+            "no_position_state_assumed": True,
+            "execution": "intent_only",
+            "blocked_for_live_trading": True,
+            "blocker": "persistent virtual position state is not maintained by this read-only script",
+        },
+        "nextgen": {
+            "decision": nextgen_payload["decision"],
+            "legs": nextgen_payload["legs"],
+        },
+        "micro": micro_payload,
+    }
+
+
+def markdown_report(payload: dict[str, object]) -> str:
+    lines = [
+        "# ETH nextgen + micro signal intent",
+        "",
+        "Read-only signal intent. No order or cancel request was submitted.",
+        "",
+        "## Decision",
+        "",
+        f"- Created at: `{payload['created_at']}`",
+        f"- Strategy: `{payload['strategy']['name']}`",
+        f"- Active engine: `{payload['decision']['active_engine']}`",
+        f"- Selected signal: `{payload['decision']['selected_signal']}`",
+        f"- Needs order: `{payload['decision']['needs_order']}`",
+        f"- Blocked for live trading: `{payload['risk_limits']['blocked_for_live_trading']}`",
+        f"- Blocker: `{payload['risk_limits']['blocker']}`",
+        "",
+        "## Intent JSON",
+        "",
+        "```json",
+        json.dumps(payload, indent=2, sort_keys=True),
+        "```",
+    ]
+    return "\n".join(lines) + "\n"
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Build a read-only ETH nextgen + micro signal intent payload.")
+    parser.add_argument("--no-write", action="store_true")
+    args = parser.parse_args()
+    payload = build_payload()
+    if not args.no_write:
+        REPORT_DIR.mkdir(parents=True, exist_ok=True)
+        JSON_REPORT.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+        MARKDOWN_REPORT.write_text(markdown_report(payload), encoding="utf-8")
+    print(json.dumps(payload, indent=2, sort_keys=True))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 68 - 0
tests/test_build_eth_nextgen_micro_signal_intent.py

@@ -0,0 +1,68 @@
+import importlib.util
+import sys
+from pathlib import Path
+
+import pandas as pd
+import pytest
+
+
+def load_module():
+    path = Path(__file__).resolve().parents[1] / "scripts" / "build_eth_nextgen_micro_signal_intent.py"
+    spec = importlib.util.spec_from_file_location("build_eth_nextgen_micro_signal_intent", path)
+    assert spec is not None
+    module = importlib.util.module_from_spec(spec)
+    assert spec.loader is not None
+    sys.modules[spec.name] = module
+    spec.loader.exec_module(module)
+    return module
+
+
+def test_latest_active_engine_uses_shifted_prior_day_regime(monkeypatch):
+    module = load_module()
+    index = pd.date_range("2026-01-01", periods=32, freq="D", tz="UTC")
+    existing = pd.DataFrame({"cost_model": ["maker_taker"], "name": ["equal-2-c0003"], "date": [index[0].strftime("%Y-%m-%d")]})
+    nextgen = pd.Series([100.0] * 31 + [80.0], index=index)
+    micro = pd.Series([100.0] * 31 + [120.0], index=index)
+
+    monkeypatch.setattr(module.pd, "read_csv", lambda _: existing)
+    monkeypatch.setattr(module.portfolio, "load_nextgen", lambda _, __: (nextgen, []))
+    monkeypatch.setattr(module.portfolio, "load_micro_candidates", lambda _, __: {module.MICRO_NAME: (micro, [])})
+
+    state = module.latest_active_engine()
+
+    assert state["active_engine"] == "nextgen"
+
+
+def test_latest_active_engine_switches_on_prior_completed_day(monkeypatch):
+    module = load_module()
+    index = pd.date_range("2026-01-01", periods=33, freq="D", tz="UTC")
+    existing = pd.DataFrame({"cost_model": ["maker_taker"], "name": ["equal-2-c0003"], "date": [index[0].strftime("%Y-%m-%d")]})
+    nextgen = pd.Series([100.0] * 31 + [80.0, 80.0], index=index)
+    micro = pd.Series([100.0] * 31 + [120.0, 120.0], index=index)
+
+    monkeypatch.setattr(module.pd, "read_csv", lambda _: existing)
+    monkeypatch.setattr(module.portfolio, "load_nextgen", lambda _, __: (nextgen, []))
+    monkeypatch.setattr(module.portfolio, "load_micro_candidates", lambda _, __: {module.MICRO_NAME: (micro, [])})
+
+    state = module.latest_active_engine()
+
+    assert state["active_engine"] == "micro"
+
+
+def test_payload_is_readonly(monkeypatch):
+    module = load_module()
+    monkeypatch.setattr(module, "latest_active_engine", lambda: {"active_engine": "nextgen", "decision_date": "2026-01-01"})
+    monkeypatch.setattr(
+        module.nextgen_intent,
+        "build_payload",
+        lambda: {"decision": {"signal": "no_signal"}, "legs": []},
+    )
+    monkeypatch.setattr(module, "micro_signal", lambda: {"signal": "short"})
+
+    payload = module.build_payload()
+
+    assert payload["submitted_orders"] == 0
+    assert payload["private_key_required"] is False
+    assert payload["risk_limits"]["no_order_submission"] is True
+    assert payload["risk_limits"]["blocked_for_live_trading"] is True
+    assert payload["decision"]["selected_signal"] == "no_signal"