Parcourir la source

Wire Trader action dispatch and tighten handoff decisions

Lukas Goldschmidt il y a 3 semaines
Parent
commit
ed65bc3dd2

+ 24 - 2
README.md

@@ -1,19 +1,41 @@
 # Hermes MCP
 
-Hermes MCP is a small FastAPI + MCP server for state, interpretation, and dashboarding.
+Hermes MCP is a FastAPI + MCP supervisor for market interpretation, strategy selection, and Trader control.
+
+## What Hermes does
+
+- builds a market narrative from regime data
+- scores strategy fit for each account/market concern
+- records decision snapshots for audit and dashboarding
+- optionally dispatches Trader control actions through a single guarded write path
 
 ## Surface
+
 - MCP transport: `/mcp/sse`
 - Health: `/health`
 - Dashboard: `/dashboard/`
-- Tool: `report()`
+- Primary tool: `report()`
+
+## Hermes to Trader control path
+
+Hermes reads Trader state via strategy snapshots and writes only through Trader's canonical action tool:
+
+- `apply_control_decision(payload)`
+
+Dispatch is gated locally by:
+
+- `HERMES_ALLOW_ACTIONS`
+
+When the gate is false, Hermes still makes and records decisions, but returns a blocked dispatch result instead of changing Trader state.
 
 ## Run
+
 ```bash
 ./run.sh 8590
 ```
 
 ## Test
+
 ```bash
 ./tests.sh
 ```

+ 22 - 12
TRADER_COMPATIBILITY_NOTE.md

@@ -4,20 +4,16 @@ This note is for Hermes-MCP developers.
 
 ## Trader taxonomy expected by Hermes
 
-Canonical strategy set currently being stabilized:
-- `grid`
-- `Exposure Protector` (defensive exposure rebalancer)
+Canonical strategy set currently used by Hermes supervision:
+- `grid_trader`
 - `trend_follower`
-- `idle`
-- `defensive`
-- `mean_reversion`
-- `breakout`
-- `event_driven`
+- `exposure_protector`
 
 ## Trader contract locations
 
 Primary contract and runtime docs live in `trader-mcp/`:
 - `trader-mcp/Hermes_Trader_Contract_v0.1.md`
+- `trader-mcp/Hermes_Trader_Action_Contract_v0.1.md`
 - `trader-mcp/Strategy_Contract.md`
 - `trader-mcp/Strategy_Runtime.md`
 - `trader-mcp/MCP_SURFACE_PROPOSAL.md`
@@ -30,6 +26,7 @@ Important fields:
 - `identity`
 - `control`
 - `fit`
+- `supervision`
 - `position`
 - `state`
 - `assessment`
@@ -37,8 +34,21 @@ Important fields:
 
 ## Hermes control path
 
-Hermes should use:
-- `set_strategy_policy()` for high-level policy (`risk_posture`, `priority`)
-- `control_strategy()` for lifecycle (`start`, `stop`, `pause`, `resume`, `reconcile`)
+Hermes should write through one canonical Trader tool:
+- `apply_control_decision(payload)`
 
-Policies are applied on reconcile and instance creation.
+Trader may still use these internally or for operator workflows:
+- `control_strategy()`
+- `set_strategy_policy()`
+
+The current supervision hints 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
+
+Policies are still applied on reconcile and instance creation.

+ 13 - 2
src/hermes_mcp/config.py

@@ -31,6 +31,17 @@ def _env_bool(name: str, default: bool = False) -> bool:
     return value in {"1", "true", "yes", "on"}
 
 
+def _env_bool_any(*names: str, default: bool = False) -> bool:
+    file_values = _load_env_file()
+    for name in names:
+        value = os.getenv(name)
+        if value is None:
+            value = file_values.get(name)
+        if value is not None:
+            return value.strip().lower() in {"1", "true", "yes", "on"}
+    return default
+
+
 def _env_int(name: str, default: int) -> int:
     try:
         return int(_env(name, str(default)))
@@ -50,7 +61,7 @@ class HermesConfig:
     retention_days: int
     prune_interval_hours: int
     cycle_seconds: int
-    allow_auto_actions: bool
+    hermes_allow_actions: bool
 
 
 def load_config() -> HermesConfig:
@@ -70,5 +81,5 @@ def load_config() -> HermesConfig:
         retention_days=_env_int("HERMES_RETENTION_DAYS", 7),
         prune_interval_hours=_env_int("HERMES_PRUNE_INTERVAL_HOURS", 6),
         cycle_seconds=_env_int("HERMES_CYCLE_SECONDS", 60),
-        allow_auto_actions=_env_bool("HERMES_ALLOW_AUTO_ACTIONS", False),
+        hermes_allow_actions=_env_bool_any("HERMES_ALLOW_ACTIONS", "HERMES_ALLOW_AUTO_ACTIONS", default=False),
     )

+ 78 - 17
src/hermes_mcp/dashboard.py

@@ -46,6 +46,9 @@ def overview():
         .warn {{ background:#fef3c7; color:#92400e; }}
         .bad {{ background:#fee2e2; color:#991b1b; }}
         .neutral {{ background:#e5e7eb; color:#374151; }}
+        .info {{ background:#dbeafe; color:#1d4ed8; }}
+        .recent-change td {{ background:#eff6ff; transition: background 0.3s ease; }}
+        .focus-cell {{ border-left: 4px solid #3b82f6; }}
         .spark {{ width:100%; height:40px; display:block; margin-top:8px; }}
         .spark-block {{ margin-top: 8px; }}
         .spark-label {{ display:flex; justify-content:space-between; gap:8px; font-size:12px; color:#6b7280; }}
@@ -65,6 +68,42 @@ def overview():
           const points = values.map((v, i) => `${{(i/(values.length-1||1))*100}},${{40 - ((v-min)/span)*40}}`).join(' ');
           return `<svg class='spark' viewBox='0 0 100 40' preserveAspectRatio='none'><polyline fill='none' stroke='${{stroke}}' stroke-width='2' points='${{points}}' /></svg>`;
         }}
+        function latestByConcern(rows) {{
+          const seen = new Set();
+          const out = [];
+          for (const row of rows || []) {{
+            const key = String(row.concern_id || '');
+            if (!key || seen.has(key)) continue;
+            seen.add(key);
+            out.push(row);
+          }}
+          return out;
+        }}
+        function previousByConcern(rows) {{
+          const seen = new Set();
+          const prev = new Map();
+          for (const row of rows || []) {{
+            const key = String(row.concern_id || '');
+            if (!key) continue;
+            if (!seen.has(key)) {{
+              seen.add(key);
+              continue;
+            }}
+            if (!prev.has(key)) prev.set(key, row);
+          }}
+          return prev;
+        }}
+        function changed(latest, previous, fields) {{
+          if (!previous) return false;
+          return fields.some(f => String(latest?.[f] ?? '') !== String(previous?.[f] ?? ''));
+        }}
+        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';
+        }}
         async function refreshData() {{
           const res = await fetch('/dashboard/data', {{ cache: 'no-store' }});
           const data = await res.json();
@@ -86,6 +125,7 @@ def overview():
           const samples = data.regime_samples || [];
           const cards = desiredOrder.map(tf => samples.find(r => String(r.timeframe || '').toLowerCase() === tf)).filter(Boolean).map(r => {{
             const parsed = (() => {{ try {{ return JSON.parse(r.regime_json); }} catch {{ return {{}}; }} }})();
+            const rawError = parsed.raw || parsed.error || '';
             const trend = parsed.trend?.state || 'neutral';
             const momentum = parsed.momentum?.state || 'neutral';
             const reversal = parsed.reversal?.direction || 'none';
@@ -97,7 +137,11 @@ def overview():
               : rawMarket;
             const title = tf ? `${{market}} · ${{tf}}` : market;
             const key = `${{r.concern_id}}::${{r.timeframe}}`;
-            const hist = (histories[key] || []).map(x => {{ try {{ return JSON.parse(x.regime_json); }} catch {{ return null; }} }}).filter(Boolean);
+            const hist = (histories[key] || [])
+              .slice()
+              .sort((a, b) => String(a.captured_at || '').localeCompare(String(b.captured_at || '')))
+              .map(x => {{ try {{ return JSON.parse(x.regime_json); }} catch {{ return null; }} }})
+              .filter(Boolean);
             const prices = hist.map(x => Number(x.price)).filter(Number.isFinite).slice(-24);
             const atrs = hist.map(x => Number(x.volatility?.atr_percent)).filter(Number.isFinite).slice(-24);
             const rsis = hist.map(x => Number(x.momentum?.rsi)).filter(Number.isFinite).slice(-24);
@@ -105,26 +149,31 @@ def overview():
               <div class='regime-card'>
                 <div><strong>${{title}}</strong></div>
                 <div class='chips'>
+                  ${rawError ? `<span class='chip bad'>regime unavailable</span><span class='chip warn'>symbol issue</span>` : `
                   <span class='chip ${{regimeColor(trend)}}'>trend: ${{trend}}</span>
                   <span class='chip ${{regimeColor(momentum)}}'>momentum: ${{momentum}}</span>
                   <span class='chip ${{regimeColor(reversal)}}'>reversal: ${{reversal}}</span>
-                  <span class='chip neutral'>strength: ${{strength}}</span>
+                  <span class='chip neutral'>strength: ${{strength}}</span>`}
                 </div>
+                ${rawError ? `<div class='small' style='color:#991b1b'><strong>error</strong>: ${rawError}</div>` : ''}
                 <div class='spark-block'><div class='spark-label'><span>Price: ${{parsed.price ?? '-'}}</span><span>${{prices.length}} / 24</span></div>${{sparkline(prices, '#2563eb')}}</div>
                 <div class='spark-block'><div class='spark-label'><span>ATR %: ${{parsed.volatility?.atr_percent ?? '-'}}</span><span>${{atrs.length}} / 24</span></div>${{sparkline(atrs, '#d97706')}}</div>
                 <div class='spark-block'><div class='spark-label'><span>Momentum RSI: ${{parsed.momentum?.rsi ?? '-'}}</span><span>${{rsis.length}} / 24</span></div>${{sparkline(rsis, '#16a34a')}}</div>
               </div>`;
           }}).join('') || "<div class='muted'>No regime samples yet.</div>";
           document.getElementById('regimes-body').innerHTML = `<div class='regime-grid'>${{cards}}</div>`;
-          document.getElementById('states-body').innerHTML = (data.state_samples || []).map(s => {
+          const latestStates = latestByConcern(data.state_samples || []);
+          const prevStates = previousByConcern(data.state_samples || []);
+          document.getElementById('states-body').innerHTML = latestStates.map(s => {
+            const hasChanged = changed(s, prevStates.get(String(s.concern_id || '')), ['market_regime','volatility_state','sentiment_pressure','event_risk','execution_quality']);
             const payload = (() => { try { return JSON.parse(s.payload_json || '{}'); } catch { return {}; } })();
             const micro = payload.scoped_state?.micro || {};
             const meso = payload.scoped_state?.meso || {};
             const macro = payload.scoped_state?.macro || {};
             const cross = payload.cross_scope_summary || {};
             return `
-            <tr>
-              <td>${{s.concern_id || ''}}</td>
+            <tr class='${{hasChanged ? 'recent-change' : ''}}'>
+              <td class='focus-cell'>${{s.concern_id || ''}}</td>
               <td>${{s.market_regime || ''}}</td>
               <td>${{s.volatility_state || ''}}</td>
               <td>${{s.liquidity_state || ''}}</td>
@@ -140,13 +189,16 @@ def overview():
               </td>
             </tr>`;
           }).join('') || "<tr><td colspan='9' class='muted'>No state snapshots yet.</td></tr>";
-          document.getElementById('narratives-body').innerHTML = (data.narrative_samples || []).map(n => {
+          const latestNarratives = latestByConcern(data.narrative_samples || []);
+          const prevNarratives = previousByConcern(data.narrative_samples || []);
+          document.getElementById('narratives-body').innerHTML = latestNarratives.map(n => {
+            const hasChanged = changed(n, prevNarratives.get(String(n.concern_id || '')), ['summary','confidence']);
             const drivers = (() => { try { return JSON.parse(n.key_drivers_json || '[]'); } catch { return []; } })();
             const risks = (() => { try { return JSON.parse(n.risk_flags_json || '[]'); } catch { return []; } })();
             const uncertainties = (() => { try { return JSON.parse(n.uncertainties_json || '[]'); } catch { return []; } })();
             return `
-            <tr>
-              <td>${n.concern_id || ''}</td>
+            <tr class='${hasChanged ? 'recent-change' : ''}'>
+              <td class='focus-cell'>${n.concern_id || ''}</td>
               <td>${n.summary || ''}</td>
               <td>${drivers.join('<br>') || '-'}</td>
               <td>${risks.join('<br>') || '-'}</td>
@@ -154,21 +206,30 @@ def overview():
               <td>${typeof n.confidence === 'number' ? n.confidence.toFixed(2) : ''}</td>
             </tr>`;
           }).join('') || "<tr><td colspan='6' class='muted'>No narratives yet.</td></tr>";
-          document.getElementById('decisions-body').innerHTML = (data.decision_samples || []).map(d => {
+          const latestDecisions = latestByConcern(data.decision_samples || []);
+          const prevDecisions = previousByConcern(data.decision_samples || []);
+          document.getElementById('decisions-body').innerHTML = latestDecisions.map(d => {
+            const hasChanged = changed(d, prevDecisions.get(String(d.concern_id || '')), ['mode','action','target_strategy','reason_summary']);
             const payload = (() => { try { return JSON.parse(d.target_policy_json || '{}'); } catch { return {}; } })();
             const wallet = payload.wallet_state || {};
             const ranking = payload.strategy_fit_ranking || [];
             const top = ranking[0] || {};
+            const current = payload.current_primary_strategy || d.target_strategy || '-';
+            const breakout = payload.grid_breakout_pressure || {};
             return `
-            <tr>
-              <td>${d.concern_id || ''}</td>
-              <td>${d.mode || ''}</td>
-              <td>${d.action || ''}</td>
+            <tr class='${hasChanged ? 'recent-change' : ''}'>
+              <td class='focus-cell'>${d.concern_id || ''}</td>
+              <td><span class='chip ${modeChip(d.mode)}'>${d.mode || ''}</span></td>
+              <td><span class='chip ${modeChip(d.action)}'>${d.action || ''}</span></td>
               <td>${d.target_strategy || '-'}</td>
               <td>${d.reason_summary || ''}</td>
               <td>
+                <div class='small'><strong>active now</strong>: ${current}</div>
                 <div class='small'><strong>wallet</strong>: ${wallet.inventory_state || '-'} (${typeof wallet.base_ratio === 'number' ? wallet.base_ratio.toFixed(2) : '-'}/${typeof wallet.quote_ratio === 'number' ? wallet.quote_ratio.toFixed(2) : '-'})</div>
+                <div class='small'><strong>effective</strong>: base ${typeof wallet.base_effective === 'number' ? wallet.base_effective.toFixed(4) : '-'} / quote ${typeof wallet.quote_effective === 'number' ? wallet.quote_effective.toFixed(4) : '-'}</div>
+                <div class='small'><strong>reserved</strong>: base ${typeof wallet.base_reserved === 'number' ? wallet.base_reserved.toFixed(4) : '-'} / quote ${typeof wallet.quote_reserved === 'number' ? wallet.quote_reserved.toFixed(4) : '-'}</div>
                 <div class='small'><strong>top fit</strong>: ${top.strategy_type || '-'} (${typeof top.score === 'number' ? top.score.toFixed(2) : '-'})</div>
+                <div class='small'><strong>breakout pressure</strong>: ${breakout.persistent ? 'persistent' : 'not persistent'}</div>
               </td>
               <td>${typeof d.confidence === 'number' ? d.confidence.toFixed(2) : ''}</td>
             </tr>`;
@@ -219,10 +280,10 @@ def overview():
     template = template.replace("{{", "{").replace("}}", "}")
     return HTMLResponse(
         template
-        .replace("__CYCLE_STATUS__", cycle.get("status", "none"))
-        .replace("__CYCLE_STARTED__", cycle.get("started_at", "-"))
-        .replace("__CYCLE_FINISHED__", cycle.get("finished_at", "-"))
-        .replace("__CYCLE_NOTES__", cycle.get("notes", "-"))
+        .replace("__CYCLE_STATUS__", str(cycle.get("status") or "none"))
+        .replace("__CYCLE_STARTED__", str(cycle.get("started_at") or "-"))
+        .replace("__CYCLE_FINISHED__", str(cycle.get("finished_at") or "-"))
+        .replace("__CYCLE_NOTES__", str(cycle.get("notes") or "-"))
         .replace("__CONCERN_COUNT__", str(len(concerns)))
         .replace("__CONCERN_ROWS__", concern_rows)
         .replace("__REGIME_ROWS__", regime_rows)

+ 318 - 40
src/hermes_mcp/decision_engine.py

@@ -6,11 +6,10 @@ This is the first decision slice. Hermes is currently acting as a supervisor for
 existing trader strategies, not as a direct trading engine.
 
 Design intent:
-- prefer keeping a suitable strategy active over unnecessary switching
+- prefer one active posture at a time over layered companions
 - detect when grid trading becomes unsafe because market posture or wallet
   balance no longer supports it
-- hand off toward directional or rebalancing strategies without collapsing the
-  decision layer into execution details
+- switch cleanly between directional, range, and rebalancing phases
 """
 
 from dataclasses import dataclass
@@ -42,7 +41,22 @@ def _safe_float(value: Any) -> float | None:
         return None
 
 
-def assess_wallet_state(*, account_info: dict[str, Any], concern: dict[str, Any], price: float | None) -> dict[str, Any]:
+def _infer_market_pair(concern: dict[str, Any]) -> tuple[str, str]:
+    base = str(concern.get("base_currency") or "").strip().upper()
+    quote = str(concern.get("quote_currency") or "").strip().upper()
+    if base and quote:
+        return base, quote
+
+    market = str(concern.get("market_symbol") or "").strip().upper().replace("/", "").replace("-", "")
+    for suffix in ("USDT", "USDC", "USD", "EUR", "BTC", "ETH"):
+        if market.endswith(suffix) and len(market) > len(suffix):
+            inferred_base = market[:-len(suffix)]
+            inferred_quote = suffix
+            return base or inferred_base, quote or inferred_quote
+    return base or market, quote or "USD"
+
+
+def assess_wallet_state(*, account_info: dict[str, Any], concern: dict[str, Any], price: float | None, strategies: list[dict[str, Any]] | None = None) -> dict[str, Any]:
     """Summarize inventory health for strategy switching.
 
     The key output is whether the wallet is balanced enough for range/grid
@@ -50,8 +64,7 @@ def assess_wallet_state(*, account_info: dict[str, Any], concern: dict[str, Any]
     rebalancing before grid is allowed again.
     """
     balances = account_info.get("balances") if isinstance(account_info.get("balances"), list) else []
-    base = str(concern.get("base_currency") or concern.get("market_symbol") or "").split("/")[0].upper()
-    quote = str(concern.get("quote_currency") or "USD").upper()
+    base, quote = _infer_market_pair(concern)
 
     base_available = 0.0
     quote_available = 0.0
@@ -67,9 +80,38 @@ def assess_wallet_state(*, account_info: dict[str, Any], concern: dict[str, Any]
         elif asset == quote:
             quote_available = amount
 
+    reserved_base = 0.0
+    reserved_quote = 0.0
+    for strategy in strategies or []:
+        if not isinstance(strategy, dict):
+            continue
+        if str(strategy.get("account_id") or "").strip() != str(concern.get("account_id") or "").strip():
+            continue
+        market_symbol = str(strategy.get("market_symbol") or "").strip().lower()
+        if market_symbol and market_symbol != str(concern.get("market_symbol") or "").strip().lower():
+            continue
+        if str(strategy.get("mode") or "off") == "off":
+            continue
+        state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
+        orders = state.get("orders") if isinstance(state.get("orders"), list) else []
+        for order in orders:
+            if not isinstance(order, dict):
+                continue
+            if str(order.get("status") or "open").lower() not in {"open", "live", "active"}:
+                continue
+            side = str(order.get("side") or "").lower()
+            amount = _safe_float(order.get("amount") or order.get("amount_remaining")) or 0.0
+            order_price = _safe_float(order.get("price")) or price or 0.0
+            if side == "sell":
+                reserved_base += amount
+            elif side == "buy":
+                reserved_quote += amount * order_price
+
     price = price or 0.0
-    base_value = base_available * price if price > 0 else 0.0
-    quote_value = quote_available
+    effective_base = base_available + reserved_base
+    effective_quote = quote_available + reserved_quote
+    base_value = effective_base * price if price > 0 else 0.0
+    quote_value = effective_quote
     total_value = base_value + quote_value
     base_ratio = (base_value / total_value) if total_value > 0 else 0.5
     quote_ratio = (quote_value / total_value) if total_value > 0 else 0.5
@@ -78,9 +120,9 @@ def assess_wallet_state(*, account_info: dict[str, Any], concern: dict[str, Any]
     if total_value <= 0:
         inventory_state = "unknown"
     elif base_ratio < 0.08:
-        inventory_state = "depleted_base_side"
+        inventory_state = "critically_unbalanced"
     elif quote_ratio < 0.08:
-        inventory_state = "depleted_quote_side"
+        inventory_state = "critically_unbalanced"
     elif imbalance >= 0.35:
         inventory_state = "critically_unbalanced"
     elif base_ratio > 0.62:
@@ -99,6 +141,10 @@ def assess_wallet_state(*, account_info: dict[str, Any], concern: dict[str, Any]
         "quote_currency": quote,
         "base_available": round(base_available, 8),
         "quote_available": round(quote_available, 8),
+        "base_reserved": round(reserved_base, 8),
+        "quote_reserved": round(reserved_quote, 8),
+        "base_effective": round(effective_base, 8),
+        "quote_effective": round(effective_quote, 8),
         "base_value": round(base_value, 4),
         "quote_value": round(quote_value, 4),
         "total_value": round(total_value, 4),
@@ -116,6 +162,10 @@ def normalize_strategy_snapshot(strategy: dict[str, Any]) -> dict[str, Any]:
     mode = str(strategy.get("mode") or "off")
     state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
     config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
+    report = strategy.get("report") if isinstance(strategy.get("report"), dict) else {}
+    report_fit = report.get("fit") if isinstance(report.get("fit"), dict) else {}
+    report_supervision = report.get("supervision") if isinstance(report.get("supervision"), dict) else {}
+    report_state = report.get("state") if isinstance(report.get("state"), dict) else {}
 
     # Stable minimum contract used by Hermes while the trader-side strategy
     # metadata evolves. These values can later be sourced directly from richer
@@ -135,15 +185,15 @@ def normalize_strategy_snapshot(strategy: dict[str, Any]) -> dict[str, Any]:
             "requires_rebalance_before_start": False,
             "requires_rebalance_before_stop": False,
             "safe_when_unbalanced": True,
-            "can_run_with": ["exposure_protector"],
+            "can_run_with": [],
         },
         "exposure_protector": {
-            "role": "defensive",
+            "role": "rebalancing",
             "inventory_behavior": "rebalancing",
             "requires_rebalance_before_start": False,
             "requires_rebalance_before_stop": False,
             "safe_when_unbalanced": True,
-            "can_run_with": ["grid_trader", "trend_follower"],
+            "can_run_with": [],
         },
     }
     contract = defaults.get(strategy_type, {
@@ -154,6 +204,7 @@ def normalize_strategy_snapshot(strategy: dict[str, Any]) -> dict[str, Any]:
         "safe_when_unbalanced": True,
         "can_run_with": [],
     })
+    contract = {**contract, **report_fit}
 
     return {
         "id": strategy.get("id"),
@@ -163,12 +214,13 @@ def normalize_strategy_snapshot(strategy: dict[str, Any]) -> dict[str, Any]:
         "status": strategy.get("status") or ("running" if mode != "off" else "stopped"),
         "market_symbol": strategy.get("market_symbol"),
         "account_id": strategy.get("account_id"),
-        "open_order_count": int(state.get("open_order_count") or strategy.get("open_order_count") or 0),
-        "last_action": state.get("last_action") or strategy.get("last_side"),
-        "last_error": state.get("last_error") or "",
+        "open_order_count": int(state.get("open_order_count") or report_state.get("open_order_count") or strategy.get("open_order_count") or 0),
+        "last_action": state.get("last_action") or report_state.get("last_action") or strategy.get("last_side"),
+        "last_error": state.get("last_error") or report_state.get("last_error") or "",
         "contract": contract,
+        "supervision": report_supervision,
         "config": config,
-        "state": state,
+        "state": {**report_state, **state},
     }
 
 
@@ -182,6 +234,9 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
     inventory_state = str(wallet_state.get("inventory_state") or "unknown")
 
     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 "")
     score = 0.0
     reasons: list[str] = []
     blocks: list[str] = []
@@ -199,6 +254,9 @@ 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")
     elif strategy_type == "trend_follower":
         score += continuation * 1.9
         if stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
@@ -210,6 +268,9 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
         if inventory_state in {"depleted_quote_side", "critically_unbalanced"}:
             score -= 0.25
             blocks.append("wallet may be too skewed for clean directional scaling")
+        if inventory_pressure in {"base_heavy", "quote_heavy"}:
+            score -= 0.1
+            blocks.append("trend report shows rising inventory pressure")
     elif strategy_type == "exposure_protector":
         score += reversal * 0.4 + wait * 0.5
         if wallet_state.get("rebalance_needed"):
@@ -220,10 +281,16 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
             reasons.append("inventory drift is high enough to justify defensive action")
         if stance in {"constructive_bullish", "constructive_bearish"} and continuation > 0.65:
             score -= 0.2
+        if inventory_pressure in {"critical", "elevated"}:
+            score += 0.25
+            reasons.append("protector reports active inventory pressure")
 
     if strategy.get("last_error"):
         score -= 0.25
         blocks.append("strategy recently reported an error")
+    if bool(supervision.get("degraded")):
+        score -= 0.15
+        blocks.append("strategy self-reports degraded supervision state")
 
     return {
         "strategy_id": strategy.get("id"),
@@ -235,46 +302,224 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
     }
 
 
+def _grid_breakout_pressure(narrative_payload: dict[str, Any]) -> dict[str, Any]:
+    scoped = narrative_payload.get("scoped_state") if isinstance(narrative_payload.get("scoped_state"), dict) else {}
+    cross = narrative_payload.get("cross_scope_summary") if isinstance(narrative_payload.get("cross_scope_summary"), dict) else {}
+    micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
+    meso = scoped.get("meso") if isinstance(scoped.get("meso"), dict) else {}
+    macro = scoped.get("macro") if isinstance(scoped.get("macro"), dict) else {}
+
+    micro_impulse = str(micro.get("impulse") or "mixed")
+    micro_bias = str(micro.get("trend_bias") or "mixed")
+    meso_structure = str(meso.get("structure") or "rotation")
+    meso_bias = str(meso.get("momentum_bias") or "neutral")
+    macro_bias = str(macro.get("bias") or "mixed")
+    alignment = str(cross.get("alignment") or "partial_alignment")
+    friction = str(cross.get("friction") or "medium")
+
+    micro_directional = micro_impulse in {"up", "down"} and micro_bias in {"bullish", "bearish"}
+    meso_directional = meso_structure == "trend_continuation" and meso_bias in {"bullish", "bearish"}
+    macro_supportive = macro_bias in {"bullish", "bearish"}
+    persistent = micro_directional and meso_directional and macro_supportive and alignment == "micro_meso_macro_aligned" and friction == "low"
+
+    return {
+        "persistent": persistent,
+        "micro_impulse": micro_impulse,
+        "micro_bias": micro_bias,
+        "meso_structure": meso_structure,
+        "meso_bias": meso_bias,
+        "macro_bias": macro_bias,
+        "alignment": alignment,
+        "friction": friction,
+    }
+
+
+def _select_current_primary(strategies: list[dict[str, Any]]) -> dict[str, Any] | None:
+    primaries = [s for s in strategies if s["strategy_type"] in {"grid_trader", "trend_follower", "exposure_protector"} and s.get("mode") != "off"]
+    if not primaries:
+        return None
+    active = next((s for s in primaries if s.get("mode") == "active"), None)
+    if active:
+        return active
+    return primaries[0]
+
+
+def _inventory_breakout_is_directionally_compatible(inventory_state: str, breakout: dict[str, Any]) -> bool:
+    macro_bias = str(breakout.get("macro_bias") or "mixed")
+    meso_bias = str(breakout.get("meso_bias") or "neutral")
+    bullish = macro_bias == "bullish" and meso_bias == "bullish"
+    bearish = macro_bias == "bearish" and meso_bias == "bearish"
+    if bullish and inventory_state in {"depleted_base_side", "quote_heavy"}:
+        return True
+    if bearish and inventory_state in {"depleted_quote_side", "base_heavy"}:
+        return True
+    return False
+
+
+def _trend_cooling_edge(narrative_payload: dict[str, Any], wallet_state: dict[str, Any]) -> bool:
+    if not wallet_state.get("rebalance_needed"):
+        return False
+    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 {}
+    meso = scoped.get("meso") if isinstance(scoped.get("meso"), dict) else {}
+
+    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")
+    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")
+
+    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_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_location in {"near_lower_band", "lower_half", "centered"}
+    )
+    return bullish_cooling or bearish_cooling
+
+
+def _grid_fill_proximity(strategy: dict[str, Any], narrative_payload: dict[str, Any]) -> dict[str, Any]:
+    state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
+    orders = state.get("orders") if isinstance(state.get("orders"), list) else []
+    features = narrative_payload.get("features_by_timeframe") if isinstance(narrative_payload.get("features_by_timeframe"), dict) else {}
+    micro_raw = features.get("1m", {}).get("raw", {}) if isinstance(features.get("1m"), dict) else {}
+    current_price = _safe_float(micro_raw.get("price") or state.get("last_price") or state.get("center_price"))
+    atr_percent = _safe_float(micro_raw.get("atr_percent")) or 0.0
+    if not current_price or current_price <= 0:
+        return {"near_fill": False}
+
+    sell_prices: list[float] = []
+    buy_prices: list[float] = []
+    for order in orders:
+        if not isinstance(order, dict):
+            continue
+        if str(order.get("status") or "open").lower() not in {"open", "live", "active"}:
+            continue
+        price = _safe_float(order.get("price"))
+        if price is None or price <= 0:
+            continue
+        side = str(order.get("side") or "").lower()
+        if side == "sell" and price >= current_price:
+            sell_prices.append(price)
+        elif side == "buy" and price <= current_price:
+            buy_prices.append(price)
+
+    next_sell = min(sell_prices) if sell_prices else None
+    next_buy = max(buy_prices) if buy_prices else None
+    next_sell_distance_pct = (((next_sell - current_price) / current_price) * 100.0) if next_sell else None
+    next_buy_distance_pct = (((current_price - next_buy) / current_price) * 100.0) if next_buy else None
+    threshold_pct = max(0.25, atr_percent * 1.5)
+    near_fill = bool(
+        next_sell_distance_pct is not None
+        and next_sell_distance_pct >= 0
+        and next_sell_distance_pct <= threshold_pct
+        and next_buy is not None
+    )
+    return {
+        "near_fill": near_fill,
+        "current_price": current_price,
+        "next_sell": next_sell,
+        "next_buy": next_buy,
+        "next_sell_distance_pct": round(next_sell_distance_pct, 4) if next_sell_distance_pct is not None else None,
+        "next_buy_distance_pct": round(next_buy_distance_pct, 4) if next_buy_distance_pct is not None else None,
+        "threshold_pct": round(threshold_pct, 4),
+    }
+
+
 def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any], wallet_state: dict[str, Any], strategies: list[dict[str, Any]]) -> DecisionSnapshot:
     normalized = [normalize_strategy_snapshot(s) for s in strategies if str(s.get("account_id") or "") == str(concern.get("account_id") or "")]
     fit_reports = [score_strategy_fit(strategy=s, narrative=narrative_payload, wallet_state=wallet_state) for s in normalized]
     ranked = sorted(fit_reports, key=lambda item: item["score"], reverse=True)
-    current_primary = next((s for s in normalized if s["enabled"] and s["strategy_type"] in {"grid_trader", "trend_follower"}), None)
-    protector = next((s for s in normalized if s["strategy_type"] == "exposure_protector"), None)
+    current_primary = _select_current_primary(normalized)
     best = ranked[0] if ranked else None
     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)
+    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"
     mode = "observe"
     target_strategy = current_primary.get("id") if current_primary else (best.get("strategy_id") if best else None)
     reasons: list[str] = []
     blocks: list[str] = []
+    trend = next((r for r in ranked if r["strategy_type"] == "trend_follower"), None)
+    rebalance = next((r for r in ranked if r["strategy_type"] == "exposure_protector"), None)
+    grid = next((r for r in ranked if r["strategy_type"] == "grid_trader"), None)
 
     if current_primary and current_primary["strategy_type"] == "grid_trader":
-        if inventory_state != "balanced" or stance not in {"neutral_rotational", "breakout_watch"}:
-            reasons.append("grid no longer matches market posture or wallet balance")
-            if wallet_state.get("rebalance_needed") and protector:
+        severe_imbalance = inventory_state in {"depleted_base_side", "depleted_quote_side", "critically_unbalanced"}
+        grid_friendly_stance = stance in {"neutral_rotational", "breakout_watch", "cautious_bullish", "cautious_bearish", "fragile_bullish", "fragile_bearish"}
+        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 (
+                not wallet_state.get("rebalance_needed")
+                or directional_inventory
+                or not rebalance
+                or trend["score"] >= rebalance["score"]
+            ):
+                action = "replace_with_trend_follower"
+                target_strategy = trend["strategy_id"]
+                mode = "act"
+                if directional_inventory:
+                    reasons.append("inventory posture can be absorbed by the directional handoff")
+            elif wallet_state.get("rebalance_needed") and rebalance and rebalance["score"] > 0.35:
                 action = "replace_with_exposure_protector"
-                target_strategy = protector["id"]
+                target_strategy = rebalance["strategy_id"]
                 mode = "act"
             else:
-                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"]
-                    mode = "act"
-                else:
-                    action = "suspend_grid"
-                    target_strategy = current_primary["id"]
-                    mode = "warn"
+                action = "suspend_grid"
+                target_strategy = current_primary["id"]
+                mode = "warn"
+        elif wallet_state.get("rebalance_needed") and rebalance and rebalance["score"] > 0.6 and not grid_fill.get("near_fill") and severe_imbalance:
+            action = "replace_with_exposure_protector"
+            target_strategy = rebalance["strategy_id"]
+            mode = "act"
+            reasons.append("grid is no longer safe to nurse because inventory repair now matters more than waiting for self-heal")
+        elif breakout["persistent"] and grid_fill.get("near_fill") and inventory_state in {"balanced", "base_heavy", "quote_heavy"}:
+            action = "keep_grid"
+            target_strategy = current_primary["id"]
+            mode = "observe"
+            reasons.append("grid is still close to a working fill, so immediate handoff would be premature")
+        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:
+                action = "replace_with_trend_follower"
+                target_strategy = trend["strategy_id"]
+                mode = "act"
+            else:
+                action = "keep_grid"
+                target_strategy = current_primary["id"]
+                mode = "warn"
+                blocks.append("directional pressure is rising but no strong trend handoff is ready")
         else:
             action = "keep_grid"
             mode = "observe"
-            reasons.append("grid still matches a balanced rotational regime")
+            reasons.append("grid can likely self-heal because breakout pressure is not yet persistent")
     elif current_primary and current_primary["strategy_type"] == "trend_follower":
-        if stance == "neutral_rotational" and wallet_state.get("grid_ready"):
-            grid = next((r for r in ranked if r["strategy_type"] == "grid_trader"), None)
+        if _trend_cooling_edge(narrative_payload, wallet_state):
+            if rebalance and rebalance["score"] > 0.35:
+                action = "replace_with_exposure_protector"
+                target_strategy = rebalance["strategy_id"]
+                mode = "act"
+                reasons.append("micro trend has cooled at the edge while inventory is still skewed, so repair should start before the move fully mean-reverts")
+            else:
+                action = "keep_trend"
+                mode = "warn"
+                blocks.append("edge cooling is visible but no rebalancer is ready")
+        elif stance == "neutral_rotational" and wallet_state.get("grid_ready"):
             if grid and grid["score"] >= 0.5:
                 action = "replace_with_grid"
                 target_strategy = grid["strategy_id"]
@@ -284,15 +529,46 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
                 action = "hold_trend"
                 mode = "observe"
                 blocks.append("grid candidate not strong enough yet")
-        elif wallet_state.get("rebalance_needed") and protector:
-            action = "attach_exposure_protector"
-            target_strategy = protector["id"]
-            mode = "warn"
-            reasons.append("trend can continue, but wallet drift now needs protection")
+        elif stance == "neutral_rotational" and wallet_state.get("rebalance_needed"):
+            if rebalance and rebalance["score"] > 0.35:
+                action = "replace_with_exposure_protector"
+                target_strategy = rebalance["strategy_id"]
+                mode = "act"
+                reasons.append("trend has cooled and inventory should be normalized before grid resumes")
+            else:
+                action = "keep_trend"
+                mode = "warn"
+                blocks.append("trend has cooled but no rebalancer is ready")
         else:
             action = "keep_trend"
             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:
+                action = "replace_with_grid"
+                target_strategy = grid["strategy_id"]
+                mode = "act"
+                reasons.append("rebalance is complete and rotational conditions support grid again")
+            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"]
+                mode = "act"
+                reasons.append("rebalance is done and directional conditions favor trend capture")
+            else:
+                action = "keep_rebalancer"
+                mode = "observe"
+                blocks.append("trend candidate is not strong enough yet")
+        else:
+            action = "keep_rebalancer"
+            mode = "observe"
+            reasons.append("rebalancing should continue until wallet posture improves")
     else:
         if best and best["score"] >= 0.55:
             action = f"enable_{best['strategy_type']}"
@@ -318,6 +594,8 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
         "narrative_stance": stance,
         "strategy_fit_ranking": ranked,
         "current_primary_strategy": current_primary.get("id") if current_primary else None,
+        "grid_breakout_pressure": breakout,
+        "grid_fill_context": grid_fill,
         "reason_chain": reasons,
         "blocks": blocks,
         "decision_version": 1,

+ 124 - 7
src/hermes_mcp/server.py

@@ -20,7 +20,7 @@ from .decision_engine import assess_wallet_state, make_decision
 from .narrative_engine import build_narrative
 from .state_engine import synthesize_state
 from .store import get_state, init_db, list_concerns, latest_cycle, latest_cycles, latest_decisions, latest_narratives, latest_regime_samples, prune_older_than, recent_regime_samples, sync_concerns_from_strategies, upsert_cycle, upsert_decision, upsert_narrative, upsert_regime_sample, upsert_state, latest_states
-from .trader_client import list_strategies
+from .trader_client import apply_control_decision as trader_apply_control_decision, get_strategy as trader_get_strategy, list_strategies
 
 mcp = FastMCP(
     "hermes-mcp",
@@ -28,6 +28,83 @@ mcp = FastMCP(
 )
 
 
+def _build_trader_control_payload(*, decision_id: str, concern: dict, decision: object) -> dict | None:
+    action = str(getattr(decision, "action", "") or "").strip()
+    target_strategy = str(getattr(decision, "target_strategy", "") or "").strip() or None
+    decision_payload = getattr(decision, "payload", {}) if isinstance(getattr(decision, "payload", {}), dict) else {}
+    current_primary = str(decision_payload.get("current_primary_strategy") or "").strip() or None
+
+    trader_action: str | None = None
+    risk_mode: str | None = None
+    if action.startswith("replace_with_") or action.startswith("enable_"):
+        trader_action = "switch"
+    elif action == "suspend_grid":
+        trader_action = "pause"
+        target_strategy = current_primary
+    elif action == "set_risk_mode":
+        trader_action = "set_risk_mode"
+        risk_mode = str(decision_payload.get("risk_mode") or "").strip() or None
+    else:
+        return None
+
+    account_id = str(concern.get("account_id") or "").strip()
+    market_symbol = str(concern.get("market_symbol") or "").strip().lower()
+    concern_id = str(concern.get("id") or "").strip() or None
+    reason = str(getattr(decision, "reason_summary", "") or "").strip()
+    confidence = float(getattr(decision, "confidence", 0.0) or 0.0)
+
+    payload = {
+        "decision_id": decision_id,
+        "concern_id": concern_id,
+        "account_id": account_id,
+        "market_symbol": market_symbol,
+        "action": trader_action,
+        "target_strategy_id": target_strategy,
+        "expected_active_strategy_id": current_primary,
+        "risk_mode": risk_mode,
+        "reason": reason,
+        "confidence": confidence,
+        "dry_run": False,
+        "override": False,
+        "source": "hermes-mcp",
+        "source_action": action,
+    }
+    return payload
+
+
+async def _maybe_dispatch_trader_action(*, cfg: object, decision_id: str, concern: dict, decision: object) -> dict:
+    if not bool(getattr(decision, "requires_action", False)):
+        return {"dispatch": "not_required"}
+
+    payload = _build_trader_control_payload(decision_id=decision_id, concern=concern, decision=decision)
+    if payload is None:
+        return {
+            "dispatch": "skipped",
+            "reason": f"no trader action mapping for {getattr(decision, 'action', 'unknown')}",
+        }
+
+    if not bool(getattr(cfg, "hermes_allow_actions", False)):
+        return {
+            "dispatch": "blocked",
+            "reason": "HERMES_ALLOW_ACTIONS is false",
+            "payload": payload,
+        }
+
+    try:
+        result = await trader_apply_control_decision(getattr(cfg, "trader_url"), payload)
+        return {
+            "dispatch": "sent",
+            "payload": payload,
+            "result": result,
+        }
+    except Exception as exc:
+        return {
+            "dispatch": "failed",
+            "payload": payload,
+            "error": str(exc),
+        }
+
+
 @mcp.tool(description="Return Hermes current state, narrative, uncertainty, and a short self-assessment report.")
 def report() -> dict:
     state = get_state()
@@ -45,7 +122,7 @@ async def lifespan(_: FastAPI):
     cfg = load_config()
     init_db()
     try:
-        sync_concerns_from_strategies(list_strategies(cfg.trader_url))
+        sync_concerns_from_strategies(await list_strategies(cfg.trader_url))
     except Exception:
         pass
     try:
@@ -59,12 +136,24 @@ async def lifespan(_: FastAPI):
             cycle_id = str(uuid4())
             concerns = list_concerns()
             try:
-                strategy_inventory = list_strategies(cfg.trader_url)
+                strategy_inventory = await list_strategies(cfg.trader_url)
+                enriched_inventory = []
+                for strategy in strategy_inventory:
+                    instance_id = str(strategy.get("id") or "").strip()
+                    if not instance_id:
+                        enriched_inventory.append(strategy)
+                        continue
+                    try:
+                        detail = await trader_get_strategy(cfg.trader_url, instance_id, include_state=True, include_report=True)
+                        enriched_inventory.append({**strategy, **detail})
+                    except Exception:
+                        enriched_inventory.append(strategy)
+                strategy_inventory = enriched_inventory
             except Exception:
                 strategy_inventory = []
             upsert_cycle(id=cycle_id, started_at=started, finished_at=None, status="running", trigger="interval", notes=f"polling {len(concerns)} concerns")
             for concern in concerns:
-                symbol = concern.get("base_currency") or concern.get("market_symbol")
+                symbol = _resolve_regime_symbol(concern)
                 if not symbol:
                     continue
                 account_id = str(concern.get("account_id") or "").strip()
@@ -118,24 +207,41 @@ async def lifespan(_: FastAPI):
                     latest_price = None
                     if current_regimes:
                         latest_price = next((r.get("price") for r in reversed(current_regimes) if r.get("price") is not None), None)
-                    wallet_state = assess_wallet_state(account_info=account_info, concern=concern, price=float(latest_price) if latest_price is not None else None)
+                    wallet_state = assess_wallet_state(
+                        account_info=account_info,
+                        concern=concern,
+                        price=float(latest_price) if latest_price is not None else None,
+                        strategies=strategy_inventory,
+                    )
                     decision = make_decision(
                         concern=concern,
                         narrative_payload={
+                            **state.payload,
                             **narrative.payload,
                             "confidence": narrative.confidence,
                         },
                         wallet_state=wallet_state,
                         strategies=strategy_inventory,
                     )
+                    decision_id = f"{cycle_id}:{concern['id']}"
+                    dispatch_record = await _maybe_dispatch_trader_action(
+                        cfg=cfg,
+                        decision_id=decision_id,
+                        concern=concern,
+                        decision=decision,
+                    )
+                    decision_payload = {
+                        **decision.payload,
+                        "dispatch": dispatch_record,
+                    }
                     upsert_decision(
-                        id=f"{cycle_id}:{concern['id']}",
+                        id=decision_id,
                         cycle_id=cycle_id,
                         concern_id=str(concern["id"]),
                         mode=decision.mode,
                         action=decision.action,
                         target_strategy=decision.target_strategy,
-                        target_policy_json=json.dumps(decision.payload, ensure_ascii=False),
+                        target_policy_json=json.dumps(decision_payload, ensure_ascii=False),
                         reason_summary=decision.reason_summary,
                         confidence=decision.confidence,
                         requires_action=decision.requires_action,
@@ -259,6 +365,17 @@ def _compact_balances(payload: object) -> str:
     return " | ".join(parts[:5]) or "-"
 
 
+def _resolve_regime_symbol(concern: dict) -> str | None:
+    base = str(concern.get("base_currency") or "").strip().upper()
+    if base:
+        return base
+    market = str(concern.get("market_symbol") or "").strip().upper().replace("/", "").replace("-", "")
+    for suffix in ("USDT", "USDC", "USD", "EUR", "BTC", "ETH"):
+        if market.endswith(suffix) and len(market) > len(suffix):
+            return market[:-len(suffix)]
+    return market or None
+
+
 @app.get("/dashboard/data")
 def dashboard_data() -> JSONResponse:
     cfg = load_config()

+ 46 - 14
src/hermes_mcp/trader_client.py

@@ -1,25 +1,57 @@
 from __future__ import annotations
 
 from typing import Any
-from urllib.request import urlopen
 import json
+from mcp import ClientSession
+from mcp.client.sse import sse_client
 
 
-def list_strategies(base_url: str) -> list[dict[str, Any]]:
-    root = base_url.rstrip('/')
-    if root.endswith('/mcp/sse'):
-        root = root[:-8]
-    with urlopen(f"{root}/strategies", timeout=10) as resp:
-        payload = json.loads(resp.read().decode("utf-8"))
-    strategies = payload.get("configured", []) or []
+async def _call_tool(base_url: str, tool: str, arguments: dict[str, Any]) -> dict[str, Any]:
+    async with sse_client(base_url) as (read_stream, write_stream):
+        async with ClientSession(read_stream, write_stream) as session:
+            await session.initialize()
+            result = await session.call_tool(tool, arguments)
+            content = getattr(result, "content", None) or []
+            if not content:
+                return {}
+            first = content[0]
+            text = getattr(first, "text", None)
+            if text is None and isinstance(first, dict):
+                text = first.get("text")
+            if text is None:
+                return {}
+            try:
+                payload = json.loads(text)
+                return payload if isinstance(payload, dict) else {}
+            except Exception:
+                return {"raw": text}
+
+
+async def list_strategies(base_url: str) -> list[dict[str, Any]]:
+    payload = await _call_tool(base_url, "list_strategies", {})
+    strategies = payload.get("strategies", payload.get("configured", [])) or []
     return [s for s in strategies if isinstance(s, dict)]
 
 
-def list_accounts(base_url: str) -> list[dict[str, Any]]:
-    root = base_url.rstrip('/')
-    if root.endswith('/mcp/sse'):
-        root = root[:-8]
-    with urlopen(f"{root}/accounts", timeout=10) as resp:
-        payload = json.loads(resp.read().decode("utf-8"))
+async def get_strategy(base_url: str, instance_id: str, *, include_state: bool = True, include_report: bool = True) -> dict[str, Any]:
+    payload = await _call_tool(
+        base_url,
+        "get_strategy",
+        {
+            "instance_id": instance_id,
+            "include_state": include_state,
+            "include_report": include_report,
+        },
+    )
+    return payload if isinstance(payload, dict) else {}
+
+
+async def list_accounts(base_url: str) -> list[dict[str, Any]]:
+    payload = await _call_tool(base_url, "list_accounts", {})
     accounts = payload.get("accounts", []) or []
     return [a for a in accounts if isinstance(a, dict)]
+
+
+async def apply_control_decision(base_url: str, payload: dict[str, Any]) -> dict[str, Any]:
+    response = await _call_tool(base_url, "apply_control_decision", {"payload": payload})
+    return response if isinstance(response, dict) else {}

+ 91 - 0
tests/test_action_dispatch.py

@@ -0,0 +1,91 @@
+from types import SimpleNamespace
+
+import pytest
+
+from hermes_mcp.decision_engine import DecisionSnapshot
+from hermes_mcp.server import _build_trader_control_payload, _maybe_dispatch_trader_action
+
+
+def test_build_trader_control_payload_maps_replace_to_switch():
+    concern = {"id": "c1", "account_id": "acct-1", "market_symbol": "xrpusd"}
+    decision = DecisionSnapshot(
+        mode="act",
+        action="replace_with_exposure_protector",
+        target_strategy="protect-1",
+        reason_summary="inventory repair should start",
+        confidence=0.81,
+        requires_action=True,
+        payload={"current_primary_strategy": "grid-1"},
+    )
+
+    payload = _build_trader_control_payload(decision_id="d1", concern=concern, decision=decision)
+
+    assert payload == {
+        "decision_id": "d1",
+        "concern_id": "c1",
+        "account_id": "acct-1",
+        "market_symbol": "xrpusd",
+        "action": "switch",
+        "target_strategy_id": "protect-1",
+        "expected_active_strategy_id": "grid-1",
+        "risk_mode": None,
+        "reason": "inventory repair should start",
+        "confidence": 0.81,
+        "dry_run": False,
+        "override": False,
+        "source": "hermes-mcp",
+        "source_action": "replace_with_exposure_protector",
+    }
+
+
+@pytest.mark.anyio
+async def test_dispatch_is_blocked_when_hermes_allow_actions_is_false():
+    concern = {"id": "c1", "account_id": "acct-1", "market_symbol": "xrpusd"}
+    decision = DecisionSnapshot(
+        mode="act",
+        action="replace_with_grid",
+        target_strategy="grid-1",
+        reason_summary="range conditions support grid again",
+        confidence=0.77,
+        requires_action=True,
+        payload={"current_primary_strategy": "protect-1"},
+    )
+    cfg = SimpleNamespace(hermes_allow_actions=False, trader_url="http://trader.test/mcp/sse")
+
+    result = await _maybe_dispatch_trader_action(cfg=cfg, decision_id="d2", concern=concern, decision=decision)
+
+    assert result["dispatch"] == "blocked"
+    assert result["reason"] == "HERMES_ALLOW_ACTIONS is false"
+    assert result["payload"]["action"] == "switch"
+    assert result["payload"]["target_strategy_id"] == "grid-1"
+
+
+@pytest.mark.anyio
+async def test_dispatch_calls_trader_when_gate_is_open(monkeypatch):
+    concern = {"id": "c1", "account_id": "acct-1", "market_symbol": "xrpusd"}
+    decision = DecisionSnapshot(
+        mode="act",
+        action="replace_with_trend_follower",
+        target_strategy="trend-1",
+        reason_summary="persistent breakout pressure favors trend capture",
+        confidence=0.84,
+        requires_action=True,
+        payload={"current_primary_strategy": "grid-1"},
+    )
+    cfg = SimpleNamespace(hermes_allow_actions=True, trader_url="http://trader.test/mcp/sse")
+    seen = {}
+
+    async def fake_apply(base_url: str, payload: dict):
+        seen["base_url"] = base_url
+        seen["payload"] = payload
+        return {"ok": True, "status": "applied", "decision_id": payload["decision_id"]}
+
+    monkeypatch.setattr("hermes_mcp.server.trader_apply_control_decision", fake_apply)
+
+    result = await _maybe_dispatch_trader_action(cfg=cfg, decision_id="d3", concern=concern, decision=decision)
+
+    assert result["dispatch"] == "sent"
+    assert result["result"]["ok"] is True
+    assert seen["base_url"] == "http://trader.test/mcp/sse"
+    assert seen["payload"]["action"] == "switch"
+    assert seen["payload"]["expected_active_strategy_id"] == "grid-1"

+ 364 - 4
tests/test_decision_engine.py

@@ -1,7 +1,7 @@
 from hermes_mcp.decision_engine import assess_wallet_state, make_decision, normalize_strategy_snapshot, score_strategy_fit
 
 
-def test_assess_wallet_state_detects_depleted_base_side():
+def test_assess_wallet_state_marks_one_sided_wallet_as_critically_unbalanced():
     concern = {"account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     account_info = {
         "balances": [
@@ -10,11 +10,26 @@ def test_assess_wallet_state_detects_depleted_base_side():
         ]
     }
     wallet = assess_wallet_state(account_info=account_info, concern=concern, price=2.0)
-    assert wallet["inventory_state"] == "depleted_base_side"
+    assert wallet["inventory_state"] == "critically_unbalanced"
     assert wallet["rebalance_needed"] is True
     assert wallet["grid_ready"] is False
 
 
+def test_assess_wallet_state_infers_base_and_quote_from_market_symbol_when_missing():
+    concern = {"account_id": "a1", "market_symbol": "xrpusd", "base_currency": None, "quote_currency": None}
+    account_info = {
+        "balances": [
+            {"asset_code": "XRP", "available": 64.68158},
+            {"asset_code": "USD", "available": 12.64},
+        ]
+    }
+    wallet = assess_wallet_state(account_info=account_info, concern=concern, price=1.318, strategies=[])
+    assert wallet["base_currency"] == "XRP"
+    assert wallet["quote_currency"] == "USD"
+    assert wallet["base_available"] == 64.68158
+    assert wallet["quote_available"] == 12.64
+
+
 def test_score_strategy_fit_penalizes_grid_when_wallet_unbalanced():
     strategy = normalize_strategy_snapshot({
         "id": "grid-1",
@@ -25,13 +40,44 @@ def test_score_strategy_fit_penalizes_grid_when_wallet_unbalanced():
         "config": {},
     })
     narrative = {"stance": "constructive_bullish", "opportunity_map": {"continuation": 0.7, "mean_reversion": 0.1, "reversal": 0.1, "wait": 0.1}}
-    wallet_state = {"inventory_state": "depleted_base_side", "rebalance_needed": True}
+    wallet_state = {"inventory_state": "critically_unbalanced", "rebalance_needed": True}
     fit = score_strategy_fit(strategy=strategy, narrative=narrative, wallet_state=wallet_state)
     assert fit["score"] < 0
     assert any("grid" in block or "wallet" in block for block in fit["blocks"])
 
 
-def test_make_decision_replaces_grid_when_directional_and_unbalanced():
+def test_assess_wallet_state_counts_reserved_orders_in_effective_inventory():
+    concern = {"account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    account_info = {
+        "balances": [
+            {"asset_code": "XRP", "available": 0},
+            {"asset_code": "USD", "available": 0},
+        ]
+    }
+    strategies = [
+        {
+            "id": "grid-1",
+            "strategy_type": "grid_trader",
+            "mode": "active",
+            "account_id": "a1",
+            "market_symbol": "xrpusd",
+            "state": {
+                "orders": [
+                    {"side": "sell", "status": "open", "amount": "10", "price": "1.50"},
+                    {"side": "buy", "status": "open", "amount": "10", "price": "1.40"},
+                ]
+            },
+        }
+    ]
+    wallet = assess_wallet_state(account_info=account_info, concern=concern, price=1.45, strategies=strategies)
+    assert wallet["inventory_state"] == "balanced"
+    assert wallet["base_reserved"] == 10.0
+    assert wallet["quote_reserved"] == 14.0
+    assert wallet["base_effective"] == 10.0
+    assert wallet["quote_effective"] == 14.0
+
+
+def test_make_decision_keeps_grid_when_imbalance_is_manageable():
     concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     narrative = {
         "stance": "constructive_bullish",
@@ -51,6 +97,320 @@ def test_make_decision_replaces_grid_when_directional_and_unbalanced():
         {"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 == "observe"
+    assert decision.action == "keep_grid"
+    assert decision.target_strategy == "grid-1"
+
+
+def test_make_decision_does_not_replace_grid_with_rebalancer_only_because_grid_mentions_handoff_readiness():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "cautious_bullish",
+        "confidence": 0.74,
+        "opportunity_map": {"continuation": 0.5, "mean_reversion": 0.25, "reversal": 0.05, "wait": 0.2},
+        "scoped_state": {
+            "micro": {"impulse": "mixed", "trend_bias": "mixed"},
+            "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.64,
+        "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": "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": {}},
+    ]
+    decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
+    assert decision.action == "keep_grid"
+    assert decision.target_strategy == "grid-1"
+
+
+def test_make_decision_replaces_grid_when_breakout_pressure_is_persistent():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.78,
+        "opportunity_map": {"continuation": 0.72, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.15},
+        "scoped_state": {
+            "micro": {"impulse": "up", "trend_bias": "bullish"},
+            "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
+            "macro": {"bias": "bullish"},
+        },
+        "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
+    }
+    wallet_state = {
+        "inventory_state": "critically_unbalanced",
+        "rebalance_needed": True,
+        "grid_ready": False,
+        "base_ratio": 0.88,
+        "quote_ratio": 0.12,
+    }
+    strategies = [
+        {"id": "grid-1", "strategy_type": "grid_trader", "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.action == "replace_with_exposure_protector"
+    assert decision.target_strategy == "protect-1"
+
+
+def test_make_decision_replaces_grid_with_trend_when_breakout_is_persistent_but_inventory_is_only_base_heavy():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.78,
+        "opportunity_map": {"continuation": 0.72, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.15},
+        "scoped_state": {
+            "micro": {"impulse": "up", "trend_bias": "bullish"},
+            "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.64,
+        "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": "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": {}},
+    ]
+    decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
+    assert decision.action == "replace_with_trend_follower"
+    assert decision.target_strategy == "trend-1"
+
+
+def test_make_decision_prefers_active_grid_over_observe_trend_as_current_primary():
+    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.75, "mean_reversion": 0.1, "reversal": 0.05, "wait": 0.1},
+    }
+    wallet_state = {
+        "inventory_state": "base_heavy",
+        "rebalance_needed": True,
+        "grid_ready": False,
+        "base_ratio": 0.81,
+        "quote_ratio": 0.19,
+    }
+    strategies = [
+        {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "account_id": "a1", "state": {}, "config": {}},
+        {"id": "grid-1", "strategy_type": "grid_trader", "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.action == "keep_grid"
+    assert decision.target_strategy == "grid-1"
+
+
+def test_make_decision_prefers_trend_over_rebalancer_on_bullish_breakout_with_depleted_base():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.9,
+        "opportunity_map": {"continuation": 0.85, "mean_reversion": 0.05, "reversal": 0.02, "wait": 0.08},
+        "scoped_state": {
+            "micro": {"impulse": "up", "trend_bias": "bullish"},
+            "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
+            "macro": {"bias": "bullish"},
+        },
+        "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
+    }
+    wallet_state = {
+        "inventory_state": "critically_unbalanced",
+        "rebalance_needed": True,
+        "grid_ready": False,
+        "base_ratio": 0.0,
+        "quote_ratio": 1.0,
+    }
+    strategies = [
+        {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
+        {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "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.action == "replace_with_trend_follower"
+    assert decision.target_strategy == "trend-1"
+
+
+def test_make_decision_keeps_grid_when_next_sell_is_close_despite_persistent_breakout():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.9,
+        "opportunity_map": {"continuation": 0.85, "mean_reversion": 0.05, "reversal": 0.02, "wait": 0.08},
+        "features_by_timeframe": {
+            "1m": {"raw": {"price": 1.4374, "atr_percent": 0.11}},
+        },
+        "scoped_state": {
+            "micro": {"impulse": "up", "trend_bias": "bullish"},
+            "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.65,
+        "quote_ratio": 0.35,
+    }
+    strategies = [
+        {
+            "id": "grid-1",
+            "strategy_type": "grid_trader",
+            "mode": "active",
+            "account_id": "a1",
+            "market_symbol": "xrpusd",
+            "state": {
+                "last_price": 1.4374,
+                "orders": [
+                    {"side": "sell", "status": "open", "price": "1.43956", "amount": "7"},
+                    {"side": "buy", "status": "open", "price": "1.42523", "amount": "7"},
+                ],
+            },
+            "config": {},
+        },
+        {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "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.action == "keep_grid"
+    assert decision.target_strategy == "grid-1"
+
+
+def test_normalize_strategy_snapshot_uses_live_report_contract_and_supervision():
+    normalized = normalize_strategy_snapshot({
+        "id": "grid-1",
+        "strategy_type": "grid_trader",
+        "mode": "active",
+        "account_id": "a1",
+        "report": {
+            "fit": {
+                "role": "primary",
+                "inventory_behavior": "balanced",
+                "safe_when_unbalanced": False,
+                "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},
+        },
+    })
+    assert normalized["contract"]["inventory_behavior"] == "balanced"
+    assert normalized["supervision"]["switch_readiness"] == "ready_for_handoff"
+    assert normalized["open_order_count"] == 12
+
+
+def test_make_decision_keeps_trend_during_directional_regime_even_if_wallet_is_skewed():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.7,
+        "opportunity_map": {"continuation": 0.8, "mean_reversion": 0.1, "reversal": 0.05, "wait": 0.05},
+    }
+    wallet_state = {
+        "inventory_state": "critically_unbalanced",
+        "rebalance_needed": True,
+        "grid_ready": False,
+        "base_ratio": 0.88,
+        "quote_ratio": 0.12,
+    }
+    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 == "observe"
+    assert decision.action == "keep_trend"
+    assert decision.target_strategy == "trend-1"
+
+
+def test_make_decision_replaces_trend_with_rebalancer_after_trend_cools_and_wallet_needs_repair():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "neutral_rotational",
+        "confidence": 0.65,
+        "opportunity_map": {"continuation": 0.15, "mean_reversion": 0.25, "reversal": 0.2, "wait": 0.4},
+    }
+    wallet_state = {
+        "inventory_state": "critically_unbalanced",
+        "rebalance_needed": True,
+        "grid_ready": False,
+        "base_ratio": 0.88,
+        "quote_ratio": 0.12,
+    }
+    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_trend_with_rebalancer_on_edge_cooling_even_before_full_rotational_stance():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.74,
+        "opportunity_map": {"continuation": 0.58, "mean_reversion": 0.12, "reversal": 0.08, "wait": 0.22},
+        "scoped_state": {
+            "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "near_upper_band"},
+            "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.74,
+        "quote_ratio": 0.26,
+    }
+    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 = {
+        "stance": "neutral_rotational",
+        "confidence": 0.68,
+        "opportunity_map": {"continuation": 0.15, "mean_reversion": 0.72, "reversal": 0.05, "wait": 0.08},
+    }
+    wallet_state = {
+        "inventory_state": "balanced",
+        "rebalance_needed": False,
+        "grid_ready": True,
+        "base_ratio": 0.49,
+        "quote_ratio": 0.51,
+    }
+    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": {}},
+    ]
+    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"