瀏覽代碼

Refine Hermes decision flow and dashboard change history

Lukas Goldschmidt 3 周之前
父節點
當前提交
32c33e460f
共有 5 個文件被更改,包括 348 次插入35 次删除
  1. 9 8
      TRADER_COMPATIBILITY_NOTE.md
  2. 188 0
      src/hermes_mcp/dashboard.py
  3. 53 23
      src/hermes_mcp/decision_engine.py
  4. 2 0
      src/hermes_mcp/server.py
  5. 96 4
      tests/test_decision_engine.py

+ 9 - 8
TRADER_COMPATIBILITY_NOTE.md

@@ -41,14 +41,15 @@ Trader may still use these internally or for operator workflows:
 - `control_strategy()`
 - `set_strategy_policy()`
 
-The current supervision hints used by Hermes include:
+The current supervision facts used by Hermes include:
 - `inventory_pressure`
-- `switch_readiness`
-- `desired_companion`
-
-`grid_trader` semantics are intentionally conservative now:
-- `ready_for_handoff` means true depletion, not merely directional conditions
-- `watch_handoff` means directional pressure plus moderate imbalance
-- `prefer_hold` means the grid does not self-report handoff pressure
+- `capacity_available`
+- `side_capacity`
+- `trend_strength`
+- `rebalance_needed`
+- `signal`
+
+The reports should stay descriptive, not imperative.
+Hermes infers switches from the factual report plus narrative and wallet state.
 
 Policies are still applied on reconcile and instance creation.

+ 188 - 0
src/hermes_mcp/dashboard.py

@@ -104,6 +104,39 @@ def overview():
           if (['suspend_grid', 'replace_with_exposure_protector', 'replace_with_trend_follower'].includes(v)) return 'warn';
           return 'neutral';
         }}
+        function formatLocalTime(value) {{
+          try {{
+            return new Intl.DateTimeFormat('de-AT', {{
+              timeZone: 'Europe/Vienna',
+              dateStyle: 'short',
+              timeStyle: 'medium',
+            }}).format(new Date(value));
+          }} catch {{
+            return String(value || '');
+          }}
+        }}
+        function decisionChanges(rows) {{
+          const grouped = new Map();
+          for (const row of rows || []) {{
+            const key = String(row.concern_id || '');
+            if (!key) continue;
+            if (!grouped.has(key)) grouped.set(key, []);
+            grouped.get(key).push(row);
+          }}
+          const out = [];
+          for (const list of grouped.values()) {{
+            const sorted = list.slice().sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || '')));
+            for (let i = 1; i < sorted.length; i++) {{
+              const prev = sorted[i - 1];
+              const cur = sorted[i];
+              const fields = ['mode', 'action', 'target_strategy', 'reason_summary'];
+              const diffs = fields.filter(f => String(cur?.[f] ?? '') !== String(prev?.[f] ?? ''));
+              if (!diffs.length) continue;
+              out.push({{ cur, prev, diffs }});
+            }}
+          }}
+          return out.sort((a, b) => String(b.cur.created_at || '').localeCompare(String(a.cur.created_at || '')));
+        }}
         async function refreshData() {{
           const res = await fetch('/dashboard/data', {{ cache: 'no-store' }});
           const data = await res.json();
@@ -244,6 +277,7 @@ def overview():
       <p class="muted">Overview, signals, features, narrative, decision, explanation.</p>
       <div class="nav">
         <a href="/dashboard/">Overview</a>
+        <a href="/dashboard/changes">Decision changes</a>
         <a href="/dashboard/tech">Tech monitor</a>
       </div>
       <h2>Last poll</h2>
@@ -293,6 +327,155 @@ def overview():
     )
 
 
+@router.get("/changes", response_class=HTMLResponse)
+def changes():
+    return """
+    <html>
+    <head>
+      <title>Hermes Decision Changes</title>
+      <meta name="viewport" content="width=device-width, initial-scale=1" />
+      <style>
+        body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; margin: 0; color: #111827; background: #fff; }
+        .page { width: 100%; display: flex; justify-content: center; }
+        .card { width: min(1600px, calc(100vw - 2rem)); margin: 1rem auto; padding: 1.25rem; border: 1px solid #e5e7eb; border-radius: 12px; }
+        .muted { color: #6b7280; }
+        table { width: 100%; border-collapse: collapse; margin-top: 14px; }
+        th, td { border-bottom: 1px solid #e5e7eb; padding: 10px 8px; text-align: left; vertical-align: top; }
+        th { background: #f9fafb; }
+        .pill { display:inline-block; padding:2px 10px; border-radius:999px; background:#f3f4f6; font-size: 0.9em; }
+        .nav { display:flex; gap:10px; flex-wrap:wrap; margin: 10px 0 18px; }
+        .nav a { text-decoration:none; border:1px solid #d1d5db; padding:8px 10px; border-radius:8px; color:#111827; background:#fff; }
+        .good { background:#dcfce7; color:#166534; }
+        .warn { background:#fef3c7; color:#92400e; }
+        .bad { background:#fee2e2; color:#991b1b; }
+        .info { background:#dbeafe; color:#1d4ed8; }
+        .neutral { background:#e5e7eb; color:#374151; }
+        .small { font-size: 0.92rem; color:#4b5563; }
+        .recent-change td { background:#eff6ff; transition: background 0.3s ease; }
+        .focus-cell { border-left: 4px solid #3b82f6; }
+      </style>
+      <script>
+        function modeChip(value) {
+          const v = String(value || '').toLowerCase();
+          if (['act', 'active', 'running', 'keep_grid', 'keep_trend', 'keep_rebalancer'].includes(v)) return 'good';
+          if (['observe', 'wait', 'warn'].includes(v)) return 'info';
+          if (['suspend_grid', 'replace_with_exposure_protector', 'replace_with_trend_follower'].includes(v)) return 'warn';
+          return 'neutral';
+        }
+        function formatLocalTime(value) {
+          try {
+            return new Intl.DateTimeFormat('de-AT', {
+              timeZone: 'Europe/Vienna',
+              dateStyle: 'short',
+              timeStyle: 'medium',
+            }).format(new Date(value));
+          } catch {
+            return String(value || '');
+          }
+        }
+        function decisionChanges(rows) {
+          const grouped = new Map();
+          for (const row of rows || []) {
+            const key = String(row.concern_id || '');
+            if (!key) continue;
+            if (!grouped.has(key)) grouped.set(key, []);
+            grouped.get(key).push(row);
+          }
+          const out = [];
+          for (const list of grouped.values()) {
+            const sorted = list.slice().sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || '')));
+            for (let i = 1; i < sorted.length; i++) {
+              const prev = sorted[i - 1];
+              const cur = sorted[i];
+              const fields = ['mode', 'action', 'target_strategy', 'reason_summary'];
+              const diffs = fields.filter(f => String(cur?.[f] ?? '') !== String(prev?.[f] ?? ''));
+              if (!diffs.length) continue;
+              out.push({ cur, prev, diffs });
+            }
+          }
+          return out.sort((a, b) => String(b.cur.created_at || '').localeCompare(String(a.cur.created_at || '')));
+        }
+        function parsePayload(row) {
+          try { return JSON.parse(row.target_policy_json || '{}'); } catch { return {}; }
+        }
+        function parseState(row) {
+          try { return JSON.parse(row.payload_json || '{}'); } catch { return {}; }
+        }
+        function strategyLabel(payload, strategyId) {
+          const ranking = payload.strategy_fit_ranking || [];
+          const match = ranking.find(x => String(x.strategy_id || '') === String(strategyId || ''));
+          return match?.strategy_type || (strategyId ? String(strategyId).slice(0, 8) : '-');
+        }
+        function priceFromState(statePayload) {
+          const raw = statePayload?.features_by_timeframe?.['1m']?.raw || {};
+          return Number.isFinite(Number(raw.price)) ? Number(raw.price) : null;
+        }
+        async function refreshData() {
+          const res = await fetch('/dashboard/data', { cache: 'no-store' });
+          const data = await res.json();
+          const concernsById = new Map((data.concerns || []).map(c => [String(c.id || ''), c]));
+          const stateByCycle = new Map((data.state_history || []).map(s => [String(s.cycle_id || ''), s]));
+          const rows = decisionChanges(data.decision_history || []);
+          document.getElementById('count').textContent = String(rows.length);
+          document.getElementById('changes-body').innerHTML = rows.map(({ cur, prev, diffs }) => {
+            const payload = parsePayload(cur);
+            const wallet = payload.wallet_state || {};
+            const ranking = payload.strategy_fit_ranking || [];
+            const top = ranking[0] || {};
+            const concern = concernsById.get(String(cur.concern_id || '')) || {};
+            const statePayload = parseState(stateByCycle.get(String(cur.cycle_id || '')) || {});
+            const price = priceFromState(statePayload);
+            const targetLabel = strategyLabel(payload, cur.target_strategy || '');
+            const breakout = payload.grid_breakout_pressure || {};
+            const changed = diffs.map(f => `${f}`).join(', ');
+            const baseAvailable = Number(wallet.base_available);
+            const baseReserved = Number(wallet.base_reserved);
+            const quoteAvailable = Number(wallet.quote_available);
+            const quoteReserved = Number(wallet.quote_reserved);
+            const baseTotal = (Number.isFinite(baseAvailable) ? baseAvailable : 0) + (Number.isFinite(baseReserved) ? baseReserved : 0);
+            const quoteTotal = (Number.isFinite(quoteAvailable) ? quoteAvailable : 0) + (Number.isFinite(quoteReserved) ? quoteReserved : 0);
+            const baseValue = Number.isFinite(baseTotal) && Number.isFinite(price) ? baseTotal * price : Number(wallet.base_value);
+            const quoteValue = Number.isFinite(quoteTotal) ? quoteTotal : Number(wallet.quote_value);
+            const totalValue = Number.isFinite(baseValue) && Number.isFinite(quoteValue) ? baseValue + quoteValue : Number(wallet.total_value);
+            return `
+            <tr class='recent-change'>
+              <td class='focus-cell'>${formatLocalTime(cur.created_at || cur.applied_at || '')}</td>
+              <td><strong>${concern.account_display || ''}</strong><div class='small'>${concern.id || cur.concern_id || ''}</div></td>
+              <td>${concern.market_display || concern.market_symbol || ''}<div class='small'>${concern.market_description || ''}</div></td>
+              <td>${wallet.base_currency || concern.base_currency || 'base'} total ${Number.isFinite(baseTotal) ? baseTotal.toFixed(4) : '-'} / ${wallet.quote_currency || concern.quote_currency || 'quote'} total ${Number.isFinite(quoteTotal) ? quoteTotal.toFixed(4) : '-'}<div class='small'>Base value: ${Number.isFinite(baseValue) ? baseValue.toFixed(4) : '-'} · Quote value: ${Number.isFinite(quoteValue) ? quoteValue.toFixed(4) : '-'} · Total: ${Number.isFinite(totalValue) ? totalValue.toFixed(4) : '-'}</div></td>
+              <td>${typeof price === 'number' ? price.toFixed(4) : '-'}</td>
+              <td>
+                <div><span class='pill ${modeChip(cur.action)}'>${cur.action || ''}</span></div>
+                <div class='small'>to ${targetLabel || cur.target_strategy || '-'}</div>
+              </td>
+              <td>${changed}</td>
+              <td>${cur.reason_summary || ''}</td>
+              <td>${typeof cur.confidence === 'number' ? cur.confidence.toFixed(2) : ''}</td>
+            </tr>`;
+          }).join('') || "<tr><td colspan='9' class='muted'>No decision changes yet.</td></tr>";
+        }
+        window.addEventListener('load', () => { refreshData(); setInterval(refreshData, 15000); });
+      </script>
+    </head>
+    <body>
+      <div class="page"><div class="card">
+      <h1>Hermes Decision Changes</h1>
+      <p class="muted">Only rows where the decision changed from the previous state for that concern.</p>
+      <div class="nav">
+        <a href="/dashboard/">Overview</a>
+        <a href="/dashboard/changes">Decision changes</a>
+        <a href="/dashboard/tech">Tech monitor</a>
+      </div>
+      <p class="small"><span id="count">0</span> changes</p>
+      <table>
+        <tr><th>time</th><th>account</th><th>market</th><th>balances</th><th>price</th><th>action</th><th>changed fields</th><th>reason</th><th>confidence</th></tr>
+        <tbody id="changes-body"><tr><td colspan='9' class='muted'>Loading live data…</td></tr></tbody>
+      </table>
+      </div></div>
+    </body></html>
+    """
+
+
 @router.get("/tech", response_class=HTMLResponse)
 def tech():
     return """
@@ -311,6 +494,11 @@ def tech():
       <div class="page"><div class="card">
       <h1>Tech Monitor</h1>
       <p class="muted">Signals, features, state, narrative, decision, explanation.</p>
+      <div class="nav">
+        <a href="/dashboard/">Overview</a>
+        <a href="/dashboard/changes">Decision changes</a>
+        <a href="/dashboard/tech">Tech monitor</a>
+      </div>
       </div></div>
     </body></html>
     """

+ 53 - 23
src/hermes_mcp/decision_engine.py

@@ -235,8 +235,9 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
 
     strategy_type = strategy["strategy_type"]
     supervision = strategy.get("supervision") if isinstance(strategy.get("supervision"), dict) else {}
-    switch_readiness = str(supervision.get("switch_readiness") or "")
     inventory_pressure = str(supervision.get("inventory_pressure") or "")
+    capacity_available = bool(supervision.get("capacity_available"))
+    side_capacity = supervision.get("side_capacity") if isinstance(supervision.get("side_capacity"), dict) else {}
     score = 0.0
     reasons: list[str] = []
     blocks: list[str] = []
@@ -254,9 +255,12 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
             blocks.append(f"wallet is not grid-ready: {inventory_state}")
         else:
             reasons.append("wallet is balanced enough for two-sided harvesting")
-        if switch_readiness == "ready_for_handoff":
-            score -= 0.35
-            blocks.append("grid reports handoff readiness")
+        if not capacity_available:
+            score -= 0.25
+            blocks.append("grid report shows one-sided capacity")
+        if side_capacity and not (bool(side_capacity.get("buy", True)) and bool(side_capacity.get("sell", True))):
+            score -= 0.25
+            blocks.append("grid side capacity is asymmetric")
     elif strategy_type == "trend_follower":
         score += continuation * 1.9
         if stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
@@ -271,6 +275,9 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
         if inventory_pressure in {"base_heavy", "quote_heavy"}:
             score -= 0.1
             blocks.append("trend report shows rising inventory pressure")
+        if not capacity_available:
+            score -= 0.1
+            blocks.append("trend strength is below its own capacity threshold")
     elif strategy_type == "exposure_protector":
         score += reversal * 0.4 + wait * 0.5
         if wallet_state.get("rebalance_needed"):
@@ -366,24 +373,26 @@ def _trend_cooling_edge(narrative_payload: dict[str, Any], wallet_state: dict[st
     micro_impulse = str(micro.get("impulse") or "mixed")
     micro_bias = str(micro.get("trend_bias") or "mixed")
     micro_location = str(micro.get("location") or "unknown")
+    micro_reversal_risk = str(micro.get("reversal_risk") or "low")
     meso_bias = str(meso.get("momentum_bias") or "neutral")
     meso_structure = str(meso.get("structure") or "rotation")
     inventory_state = str(wallet_state.get("inventory_state") or "unknown")
+    early_reversal_warning = micro_reversal_risk in {"medium", "high"}
 
     bullish_cooling = (
         inventory_state in {"base_heavy", "critically_unbalanced"}
         and meso_structure == "trend_continuation"
         and meso_bias == "bullish"
-        and micro_impulse == "mixed"
-        and micro_bias in {"mixed", "bearish"}
+        and (micro_impulse == "mixed" or early_reversal_warning)
+        and micro_bias in {"mixed", "bearish", "bullish"}
         and micro_location in {"near_upper_band", "upper_half", "centered"}
     )
     bearish_cooling = (
         inventory_state in {"quote_heavy", "critically_unbalanced"}
         and meso_structure == "trend_continuation"
         and meso_bias == "bearish"
-        and micro_impulse == "mixed"
-        and micro_bias in {"mixed", "bullish"}
+        and (micro_impulse == "mixed" or early_reversal_warning)
+        and micro_bias in {"mixed", "bullish", "bearish"}
         and micro_location in {"near_lower_band", "lower_half", "centered"}
     )
     return bullish_cooling or bearish_cooling
@@ -446,6 +455,16 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
     stance = str(narrative_payload.get("stance") or "neutral_rotational")
     inventory_state = str(wallet_state.get("inventory_state") or "unknown")
     breakout = _grid_breakout_pressure(narrative_payload)
+    scoped = narrative_payload.get("scoped_state") if isinstance(narrative_payload.get("scoped_state"), dict) else {}
+    micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
+    micro_impulse = str(micro.get("impulse") or "mixed")
+    micro_bias = str(micro.get("trend_bias") or "mixed")
+    micro_reversal_risk = str(micro.get("reversal_risk") or "low")
+    bullish_micro_clear = micro_impulse == "up" and micro_bias == "bullish" and micro_reversal_risk != "high"
+    bearish_micro_clear = micro_impulse == "down" and micro_bias == "bearish" and micro_reversal_risk != "high"
+    stance_is_bullish = "bullish" in stance
+    stance_is_bearish = "bearish" in stance
+    directional_micro_clear = bullish_micro_clear if stance_is_bullish else bearish_micro_clear if stance_is_bearish else False
     grid_fill = _grid_fill_proximity(current_primary, narrative_payload) if current_primary and current_primary["strategy_type"] == "grid_trader" else {"near_fill": False}
 
     action = "hold"
@@ -463,7 +482,7 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
         if severe_imbalance and breakout["persistent"]:
             reasons.append("grid imbalance now coincides with persistent breakout pressure")
             directional_inventory = _inventory_breakout_is_directionally_compatible(inventory_state, breakout)
-            if trend and trend["score"] > 0.45 and (
+            if trend and trend["score"] > 0.45 and directional_micro_clear and (
                 not wallet_state.get("rebalance_needed")
                 or directional_inventory
                 or not rebalance
@@ -495,7 +514,7 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
         elif not grid_friendly_stance and breakout["persistent"]:
             reasons.append("grid should yield because directional pressure is persistent across scopes")
             trend = next((r for r in ranked if r["strategy_type"] == "trend_follower"), None)
-            if trend and trend["score"] > 0.45:
+            if trend and trend["score"] > 0.45 and directional_micro_clear:
                 action = "replace_with_trend_follower"
                 target_strategy = trend["strategy_id"]
                 mode = "act"
@@ -503,7 +522,7 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
                 action = "keep_grid"
                 target_strategy = current_primary["id"]
                 mode = "warn"
-                blocks.append("directional pressure is rising but no strong trend handoff is ready")
+                blocks.append("directional pressure is rising but the micro layer is not clear enough for a trend handoff")
         else:
             action = "keep_grid"
             mode = "observe"
@@ -544,31 +563,42 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
             mode = "observe"
             reasons.append("trend strategy still fits the directional narrative")
     elif current_primary and current_primary["strategy_type"] == "exposure_protector":
-        if wallet_state.get("grid_ready") and stance == "neutral_rotational":
-            if grid and grid["score"] >= 0.5:
+        trend = next((r for r in ranked if r["strategy_type"] == "trend_follower"), None)
+        trend_is_sustained = bool(trend and trend["score"] > 0.85 and breakout["persistent"])
+        if trend_is_sustained and stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
+            action = "replace_with_trend_follower"
+            target_strategy = trend["strategy_id"]
+            mode = "act"
+            reasons.append("trend is sustained strongly enough that the rebalancer should hand back to trend immediately")
+        elif str(wallet_state.get("inventory_state") or "").lower() == "balanced":
+            if grid:
                 action = "replace_with_grid"
                 target_strategy = grid["strategy_id"]
                 mode = "act"
-                reasons.append("rebalance is complete and rotational conditions support grid again")
+                reasons.append("wallet is balanced, so grid should run until a strong trend is detected")
             else:
                 action = "keep_rebalancer"
                 mode = "observe"
-                blocks.append("wallet is ready but grid fit is still too weak")
-        elif not wallet_state.get("rebalance_needed") and stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
-            trend = next((r for r in ranked if r["strategy_type"] == "trend_follower"), None)
-            if trend and trend["score"] > 0.45:
-                action = "replace_with_trend_follower"
-                target_strategy = trend["strategy_id"]
+                blocks.append("wallet is balanced but no grid candidate is available")
+        elif wallet_state.get("grid_ready") and stance == "neutral_rotational":
+            if grid and grid["score"] >= 0.5:
+                action = "replace_with_grid"
+                target_strategy = grid["strategy_id"]
                 mode = "act"
-                reasons.append("rebalance is done and directional conditions favor trend capture")
+                reasons.append("rebalance is complete and rotational conditions support grid again")
             else:
                 action = "keep_rebalancer"
                 mode = "observe"
-                blocks.append("trend candidate is not strong enough yet")
+                blocks.append("wallet is ready but grid fit is still too weak")
+        elif grid and grid["score"] >= 0.5:
+            action = "replace_with_grid"
+            target_strategy = grid["strategy_id"]
+            mode = "act"
+            reasons.append("trend is directional but not yet sustained, so grid can resume first")
         else:
             action = "keep_rebalancer"
             mode = "observe"
-            reasons.append("rebalancing should continue until wallet posture improves")
+            blocks.append("trend candidate is not strong enough yet and grid fit is not ready")
     else:
         if best and best["score"] >= 0.55:
             action = f"enable_{best['strategy_type']}"

+ 2 - 0
src/hermes_mcp/server.py

@@ -427,6 +427,8 @@ def dashboard_data() -> JSONResponse:
         "regime_samples": regimes,
         "regime_histories": histories_by_key,
         "state_samples": latest_states(20),
+        "state_history": latest_states(100),
         "narrative_samples": latest_narratives(20),
         "decision_samples": latest_decisions(20),
+        "decision_history": latest_decisions(100),
     })

+ 96 - 4
tests/test_decision_engine.py

@@ -123,7 +123,7 @@ def test_make_decision_does_not_replace_grid_with_rebalancer_only_because_grid_m
         "quote_ratio": 0.36,
     }
     strategies = [
-        {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"switch_readiness": "ready_for_handoff"}}},
+        {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": False, "side_capacity": {"buy": False, "sell": True}}}},
         {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
         {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
@@ -182,7 +182,7 @@ def test_make_decision_replaces_grid_with_trend_when_breakout_is_persistent_but_
         "quote_ratio": 0.36,
     }
     strategies = [
-        {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"switch_readiness": "watch_handoff"}}},
+        {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": False, "side_capacity": {"buy": True, "sell": False}}}},
         {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
         {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
@@ -306,11 +306,11 @@ def test_normalize_strategy_snapshot_uses_live_report_contract_and_supervision()
                 "can_run_with": ["exposure_protector"],
             },
             "state": {"last_action": "hold", "open_order_count": 12},
-            "supervision": {"inventory_pressure": "base_heavy", "switch_readiness": "ready_for_handoff", "degraded": False},
+            "supervision": {"inventory_pressure": "base_heavy", "capacity_available": False, "side_capacity": {"buy": True, "sell": False}, "degraded": False},
         },
     })
     assert normalized["contract"]["inventory_behavior"] == "balanced"
-    assert normalized["supervision"]["switch_readiness"] == "ready_for_handoff"
+    assert normalized["supervision"]["capacity_available"] is False
     assert normalized["open_order_count"] == 12
 
 
@@ -392,6 +392,36 @@ def test_make_decision_replaces_trend_with_rebalancer_on_edge_cooling_even_befor
     assert decision.target_strategy == "protect-1"
 
 
+def test_make_decision_replaces_trend_with_rebalancer_when_micro_reversal_risk_spikes():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.76,
+        "opportunity_map": {"continuation": 0.62, "mean_reversion": 0.1, "reversal": 0.18, "wait": 0.1},
+        "scoped_state": {
+            "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "high"},
+            "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
+            "macro": {"bias": "bullish"},
+        },
+        "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
+    }
+    wallet_state = {
+        "inventory_state": "base_heavy",
+        "rebalance_needed": True,
+        "grid_ready": False,
+        "base_ratio": 0.72,
+        "quote_ratio": 0.28,
+    }
+    strategies = [
+        {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
+        {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
+    ]
+    decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
+    assert decision.mode == "act"
+    assert decision.action == "replace_with_exposure_protector"
+    assert decision.target_strategy == "protect-1"
+
+
 def test_make_decision_replaces_rebalancer_with_grid_when_balanced_and_rotational():
     concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     narrative = {
@@ -414,3 +444,65 @@ def test_make_decision_replaces_rebalancer_with_grid_when_balanced_and_rotationa
     assert decision.mode == "act"
     assert decision.action == "replace_with_grid"
     assert decision.target_strategy == "grid-1"
+
+
+def test_make_decision_replaces_rebalancer_with_grid_when_trend_is_directional_but_not_sustained():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.72,
+        "opportunity_map": {"continuation": 0.4, "mean_reversion": 0.4, "reversal": 0.08, "wait": 0.12},
+        "scoped_state": {
+            "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "centered", "reversal_risk": "low"},
+            "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
+            "macro": {"bias": "bullish"},
+        },
+        "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
+    }
+    wallet_state = {
+        "inventory_state": "balanced",
+        "rebalance_needed": False,
+        "grid_ready": True,
+        "base_ratio": 0.51,
+        "quote_ratio": 0.49,
+    }
+    strategies = [
+        {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
+        {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": True, "side_capacity": {"buy": True, "sell": True}, "inventory_pressure": "balanced", "degraded": False}}},
+        {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": True, "trend_strength": 0.58, "inventory_pressure": "balanced", "degraded": False}}},
+    ]
+    decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
+    assert decision.mode == "act"
+    assert decision.action == "replace_with_grid"
+    assert decision.target_strategy == "grid-1"
+
+
+def test_make_decision_replaces_rebalancer_with_trend_when_breakout_is_still_strong():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.84,
+        "opportunity_map": {"continuation": 0.82, "mean_reversion": 0.08, "reversal": 0.03, "wait": 0.07},
+        "scoped_state": {
+            "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "low"},
+            "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
+            "macro": {"bias": "bullish"},
+        },
+        "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
+    }
+    wallet_state = {
+        "inventory_state": "base_heavy",
+        "rebalance_needed": True,
+        "grid_ready": False,
+        "base_ratio": 0.74,
+        "quote_ratio": 0.26,
+    }
+    strategies = [
+        {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
+        {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": True, "side_capacity": {"buy": True, "sell": True}, "inventory_pressure": "balanced", "degraded": False}}},
+        {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": True, "trend_strength": 0.92, "inventory_pressure": "balanced", "degraded": False}}},
+    ]
+    decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
+    assert decision.mode == "act"
+    assert decision.action == "replace_with_trend_follower"
+    assert decision.target_strategy == "trend-1"