Prechádzať zdrojové kódy

Improve Hermes grid-trend switching

Lukas Goldschmidt 3 týždňov pred
rodič
commit
f56fa028ad

+ 2 - 0
TRADER_COMPATIBILITY_NOTE.md

@@ -49,6 +49,8 @@ The current supervision facts used by Hermes include:
 - `rebalance_needed`
 - `signal`
 
+Directional trend instances should be distinguished by `trade_side` in Trader config, with quote-currency notionals as the canonical sizing unit.
+
 The reports should stay descriptive, not imperative.
 Hermes infers switches from the factual report plus narrative and wallet state.
 

+ 18 - 4
src/hermes_mcp/dashboard.py

@@ -11,7 +11,7 @@ def overview():
     cycle = latest_cycle() or {}
     concerns = []
     regimes = latest_regime_samples(10)
-    concern_rows = "<tr><td colspan='5' class='muted'>Loading live data…</td></tr>"
+    concern_rows = "<tr><td colspan='6' class='muted'>Loading live data…</td></tr>"
     state_rows = "<tr><td colspan='9' class='muted'>No state snapshots yet.</td></tr>"
     narrative_rows = "<tr><td colspan='6' class='muted'>No narratives yet.</td></tr>"
     decision_rows = "<tr><td colspan='7' class='muted'>No decisions yet.</td></tr>"
@@ -42,6 +42,7 @@ def overview():
         .regime-card {{ border:1px solid #e5e7eb; border-radius: 14px; padding: 10px; background: linear-gradient(180deg, #fff, #fafafa); min-width: 0; }}
         .chips {{ display:flex; gap:6px; flex-wrap:wrap; margin: 8px 0; }}
         .chip {{ display:inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }}
+        .danger {{ border: 1px solid #dc2626; background: #fee2e2; color: #991b1b; border-radius: 8px; padding: 6px 10px; cursor: pointer; }}
         .good {{ background:#dcfce7; color:#166534; }}
         .warn {{ background:#fef3c7; color:#92400e; }}
         .bad {{ background:#fee2e2; color:#991b1b; }}
@@ -159,6 +160,18 @@ def overview():
           }}
           return out.sort((a, b) => String(b.cur.created_at || '').localeCompare(String(a.cur.created_at || '')));
         }}
+        async function deleteConcern(concernId) {{
+          if (!concernId) return;
+          const ok = confirm(`Delete orphaned concern ${{concernId}}? This will purge its Hermes traces.`);
+          if (!ok) return;
+          const res = await fetch(`/concerns/${{encodeURIComponent(concernId)}}`, {{ method: 'DELETE' }});
+          if (!res.ok) {{
+            const body = await res.json().catch(() => ({{}}));
+            alert(body.error || 'Delete failed');
+            return;
+          }}
+          await refreshData();
+        }}
         async function refreshData() {{
           const res = await fetch('/dashboard/data', {{ cache: 'no-store' }});
           const data = await res.json();
@@ -173,8 +186,9 @@ def overview():
               <td>${{c.market_display || c.market_symbol || ''}}<div class='small'>${{c.market_description || ''}}</div></td>
               <td>${{c.balance_summary || '-'}}<div class='small'>Total value: ${{typeof c.total_value_usd === 'number' ? c.total_value_usd.toFixed(2) : '-'}}</div></td>
               <td>${{c.source || ''}}</td>
-              <td>${{c.status || ''}}</td>
-            </tr>`).join('') || "<tr><td colspan='5' class='muted'>No concerns yet.</td></tr>";
+              <td>${{c.status || ''}}${{c.orphaned ? "<div><span class='chip warn'>orphaned</span></div>" : ''}}</td>
+              <td>${{c.orphaned ? `<button type='button' class='danger' data-concern-id='${{c.id || ''}}' onclick="deleteConcern(this.dataset.concernId)">Delete</button>` : '<span class="small">linked</span>'}}</td>
+            </tr>`).join('') || "<tr><td colspan='6' class='muted'>No concerns yet.</td></tr>";
           const histories = data.regime_histories || {};
           const desiredOrder = ['1d', '4h', '1h', '15m', '5m', '1m'];
           const samples = data.regime_samples || [];
@@ -329,7 +343,7 @@ def overview():
       <p class="small"><span id="concern-count">__CONCERN_COUNT__</span> concerns</p>
       <h2>Concerns</h2>
       <table>
-        <tr><th>account</th><th>market</th><th>balances</th><th>source</th><th>status</th></tr>
+        <tr><th>account</th><th>market</th><th>balances</th><th>source</th><th>status</th><th>action</th></tr>
         <tbody id="concerns-body">__CONCERN_ROWS__</tbody>
       </table>
       <h2>Latest regime samples</h2>

+ 229 - 40
src/hermes_mcp/decision_engine.py

@@ -42,6 +42,23 @@ def _safe_float(value: Any) -> float | None:
         return None
 
 
+def _inventory_state_label(value: Any) -> str:
+    state = str(value or "unknown").strip().lower()
+    aliases = {
+        "critical": "critically_unbalanced",
+        "critically_imbalanced": "critically_unbalanced",
+        "depleted_base": "depleted_base_side",
+        "depleted_quote": "depleted_quote_side",
+        "one_sided_base": "depleted_base_side",
+        "one_sided_quote": "depleted_quote_side",
+    }
+    return aliases.get(state, state)
+
+
+SEVERE_INVENTORY_STATES = {"critically_unbalanced", "depleted_base_side", "depleted_quote_side"}
+REBALANCE_INVENTORY_STATES = {"base_heavy", "quote_heavy", *SEVERE_INVENTORY_STATES}
+
+
 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()
@@ -120,6 +137,10 @@ def assess_wallet_state(*, account_info: dict[str, Any], concern: dict[str, Any]
 
     if total_value <= 0:
         inventory_state = "unknown"
+    elif base_ratio <= 0.02:
+        inventory_state = "depleted_base_side"
+    elif quote_ratio <= 0.02:
+        inventory_state = "depleted_quote_side"
     elif base_ratio < 0.08:
         inventory_state = "critically_unbalanced"
     elif quote_ratio < 0.08:
@@ -134,7 +155,7 @@ def assess_wallet_state(*, account_info: dict[str, Any], concern: dict[str, Any]
         inventory_state = "balanced"
 
     grid_ready = inventory_state == "balanced"
-    rebalance_needed = inventory_state in {"base_heavy", "quote_heavy", "critically_unbalanced", "depleted_base_side", "depleted_quote_side"}
+    rebalance_needed = inventory_state in REBALANCE_INVENTORY_STATES
 
     return {
         "generated_at": datetime.now(timezone.utc).isoformat(),
@@ -187,6 +208,7 @@ def normalize_strategy_snapshot(strategy: dict[str, Any]) -> dict[str, Any]:
             "requires_rebalance_before_stop": False,
             "safe_when_unbalanced": True,
             "can_run_with": [],
+            "trade_side": "both",
         },
         "exposure_protector": {
             "role": "rebalancing",
@@ -219,6 +241,7 @@ def normalize_strategy_snapshot(strategy: dict[str, Any]) -> dict[str, Any]:
         "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,
+        "trade_side": str(config.get("trade_side") or contract.get("trade_side") or "both"),
         "supervision": report_supervision,
         "config": config,
         "state": {**report_state, **state},
@@ -268,7 +291,7 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
     mean_reversion = float(opportunity_map.get("mean_reversion") or 0.0)
     reversal = float(opportunity_map.get("reversal") or 0.0)
     wait = float(opportunity_map.get("wait") or 0.0)
-    inventory_state = str(wallet_state.get("inventory_state") or "unknown")
+    inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
     argus_context = _argus_decision_context(narrative)
 
     strategy_type = strategy["strategy_type"]
@@ -304,9 +327,25 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
             reasons.append("Argus compression supports staying selective with grid")
     elif strategy_type == "trend_follower":
         score += continuation * 1.9
+        trade_side = _strategy_trade_side(strategy)
+        narrative_direction = _narrative_direction(narrative)
         if stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
             score += 0.5
             reasons.append("narrative supports directional continuation")
+        if trade_side == "buy":
+            if narrative_direction == "bullish":
+                score += 0.6
+                reasons.append("buy-side trend instance matches bullish direction")
+            elif narrative_direction == "bearish":
+                score -= 0.9
+                blocks.append("buy-side trend instance conflicts with bearish direction")
+        elif trade_side == "sell":
+            if narrative_direction == "bearish":
+                score += 0.6
+                reasons.append("sell-side trend instance matches bearish direction")
+            elif narrative_direction == "bullish":
+                score -= 0.9
+                blocks.append("sell-side trend instance conflicts with bullish direction")
         if breakout_phase == "confirmed":
             score += 0.45
             reasons.append("confirmed breakout pressure supports directional continuation")
@@ -316,7 +355,7 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
         if wait >= 0.45 and breakout_phase != "confirmed":
             score -= 0.35
             blocks.append("market still has too much wait/uncertainty for trend commitment")
-        if inventory_state in {"depleted_quote_side", "critically_unbalanced"}:
+        if inventory_state in SEVERE_INVENTORY_STATES:
             score -= 0.25
             blocks.append("wallet may be too skewed for clean directional scaling")
         if inventory_pressure in {"base_heavy", "quote_heavy"}:
@@ -325,6 +364,9 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
         if not capacity_available:
             score -= 0.1
             blocks.append("trend strength is below its own capacity threshold")
+        if trade_side == "both" and narrative_direction in {"bullish", "bearish"}:
+            score += 0.15
+            reasons.append("generic trend instance can follow either side")
         if argus_context["compression_active"] and breakout_phase != "confirmed":
             score -= 0.15
             blocks.append("Argus compression says the broader tape is still range-like")
@@ -333,7 +375,7 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
         if wallet_state.get("rebalance_needed"):
             score += 1.1
             reasons.append("wallet imbalance calls for rebalancing protection")
-        if inventory_state in {"depleted_base_side", "depleted_quote_side", "critically_unbalanced"}:
+        if inventory_state in SEVERE_INVENTORY_STATES:
             score += 0.45
             reasons.append("inventory drift is high enough to justify defensive action")
         if stance in {"constructive_bullish", "constructive_bearish"} and continuation > 0.65:
@@ -498,6 +540,7 @@ def _select_current_primary(strategies: list[dict[str, Any]]) -> dict[str, Any]
 
 
 def _inventory_breakout_is_directionally_compatible(inventory_state: str, breakout: dict[str, Any]) -> bool:
+    inventory_state = _inventory_state_label(inventory_state)
     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"
@@ -522,7 +565,7 @@ def _trend_cooling_edge(narrative_payload: dict[str, Any], wallet_state: dict[st
     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")
+    inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
     early_reversal_warning = micro_reversal_risk in {"medium", "high"}
 
     bullish_cooling = (
@@ -633,6 +676,26 @@ def _breakout_direction(breakout: dict[str, Any], stance: str | None = None) ->
     return None
 
 
+def _narrative_direction(narrative: dict[str, Any]) -> str | None:
+    stance = str(narrative.get("stance") or "")
+    breakout = narrative.get("grid_breakout_pressure") if isinstance(narrative.get("grid_breakout_pressure"), dict) else {}
+    direction = _breakout_direction(breakout, stance)
+    if direction:
+        return direction
+    if stance in {"constructive_bullish", "cautious_bullish", "fragile_bullish"}:
+        return "bullish"
+    if stance in {"constructive_bearish", "cautious_bearish", "fragile_bearish"}:
+        return "bearish"
+    return None
+
+
+def _strategy_trade_side(strategy: dict[str, Any]) -> str:
+    config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
+    state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
+    side = str(config.get("trade_side") or state.get("trade_side") or strategy.get("trade_side") or "both").strip().lower()
+    return side if side in {"buy", "sell", "both"} else "both"
+
+
 def _trend_handoff_level_threshold(breakout: dict[str, Any]) -> float:
     memory = breakout.get("time_window_memory") if isinstance(breakout.get("time_window_memory"), dict) else {}
     if bool(memory.get("promoted_to_confirmed")):
@@ -640,6 +703,78 @@ def _trend_handoff_level_threshold(breakout: dict[str, Any]) -> float:
     return 2.75
 
 
+def _grid_switch_tradeoff(*,
+    current_primary: dict[str, Any],
+    wallet_state: dict[str, Any],
+    breakout: dict[str, Any],
+    grid_fill: dict[str, Any],
+    grid_pressure: dict[str, Any],
+    directional_micro_clear: bool,
+    trend: dict[str, Any] | None,
+) -> dict[str, Any]:
+    inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
+    open_order_count = int(current_primary.get("open_order_count") or 0)
+    if not open_order_count:
+        state = current_primary.get("state") if isinstance(current_primary.get("state"), dict) else {}
+        open_order_count = int(state.get("open_order_count") or len(state.get("orders") or []) or 0)
+
+    trend_score = float(trend.get("score") or 0.0) if trend else 0.0
+    breakout_score = float(breakout.get("score") or 0.0)
+    levels = float(grid_pressure.get("levels") or 0.0)
+    near_fill = bool(grid_fill.get("near_fill"))
+    fill_fights = _grid_fill_fights_breakout(grid_fill, breakout)
+    persistent = bool(breakout.get("persistent"))
+    trend_ready = trend_score > 0.45 and directional_micro_clear
+
+    switch_benefit = 0.0
+    if persistent:
+        switch_benefit += 0.28
+    if trend_ready:
+        switch_benefit += 0.34
+    if fill_fights:
+        switch_benefit += 0.12
+    if levels >= _trend_handoff_level_threshold(breakout):
+        switch_benefit += 0.18
+    switch_benefit += min(trend_score, 2.5) * 0.18
+    switch_benefit += min(breakout_score, 5.0) * 0.04
+
+    stay_cost = 0.0
+    if inventory_state == "balanced":
+        stay_cost += 0.06
+    elif inventory_state in {"base_heavy", "quote_heavy"}:
+        stay_cost += 0.16
+    elif inventory_state in SEVERE_INVENTORY_STATES:
+        stay_cost += 0.28
+    else:
+        stay_cost += 0.1
+    stay_cost += min(levels, 6.0) * 0.06
+    stay_cost += min(open_order_count, 8) * 0.025
+    if near_fill:
+        stay_cost += 0.06
+    if fill_fights:
+        stay_cost += 0.18
+    if not persistent:
+        stay_cost += 0.12
+
+    margin = round(switch_benefit - stay_cost, 4)
+    should_switch = persistent and trend_ready and margin > 0.0
+    return {
+        "trend_score": round(trend_score, 4),
+        "breakout_score": round(breakout_score, 4),
+        "switch_benefit": round(switch_benefit, 4),
+        "stay_cost": round(stay_cost, 4),
+        "margin": margin,
+        "should_switch": should_switch,
+        "trend_ready": trend_ready,
+        "persistent": persistent,
+        "levels": round(levels, 4),
+        "open_order_count": open_order_count,
+        "near_fill": near_fill,
+        "fill_fights": fill_fights,
+        "inventory_state": inventory_state,
+    }
+
+
 def _grid_trend_pressure(strategy: dict[str, Any], narrative_payload: dict[str, Any]) -> dict[str, Any]:
     state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
     config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
@@ -673,7 +808,7 @@ def _grid_can_still_work(strategy: dict[str, Any], wallet_state: dict[str, Any],
     sell_capacity = bool(side_capacity.get("sell", False))
     open_order_count = int(strategy.get("open_order_count") or 0)
     degraded = bool(supervision.get("degraded"))
-    inventory_state = str(wallet_state.get("inventory_state") or "unknown")
+    inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
 
     if degraded:
         return False
@@ -683,14 +818,25 @@ def _grid_can_still_work(strategy: dict[str, Any], wallet_state: dict[str, Any],
         return True
     if grid_fill.get("near_fill"):
         return True
-    return inventory_state not in {"depleted_base_side", "depleted_quote_side"}
+    return inventory_state not in SEVERE_INVENTORY_STATES
 
 
 def _grid_is_truly_stuck_for_recovery(strategy: dict[str, Any], wallet_state: dict[str, Any], grid_fill: dict[str, Any]) -> bool:
     if _grid_can_still_work(strategy, wallet_state, grid_fill):
         return False
-    inventory_state = str(wallet_state.get("inventory_state") or "unknown")
-    return wallet_state.get("rebalance_needed") and inventory_state in {"depleted_base_side", "depleted_quote_side", "critically_unbalanced"}
+    inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
+    return wallet_state.get("rebalance_needed") and inventory_state in SEVERE_INVENTORY_STATES
+
+
+def _wallet_within_rebalance_tolerance(wallet_state: dict[str, Any], tolerance: float = 0.3) -> bool:
+    imbalance = _safe_float(wallet_state.get("imbalance_score"))
+    if imbalance is None:
+        base_ratio = _safe_float(wallet_state.get("base_ratio"))
+        if base_ratio is not None:
+            imbalance = abs(base_ratio - 0.5)
+    if imbalance is None:
+        return str(wallet_state.get("inventory_state") or "").lower() == "balanced"
+    return imbalance <= tolerance
 
 
 def _decide_for_grid(*,
@@ -711,6 +857,7 @@ def _decide_for_grid(*,
     target_strategy = current_primary["id"]
     reasons: list[str] = []
     blocks: list[str] = []
+    inventory_state = _inventory_state_label(inventory_state)
 
     # Grid is the base mode. Leave it only for a persistent breakout or when
     # the grid has genuinely lost its ability to recover on its own.
@@ -726,11 +873,20 @@ def _decide_for_grid(*,
         and grid_pressure.get("levels", 0.0) >= _trend_handoff_level_threshold(breakout)
     )
     fill_fights_breakout = _grid_fill_fights_breakout(grid_fill, breakout)
+    switch_tradeoff = _grid_switch_tradeoff(
+        current_primary=current_primary,
+        wallet_state=wallet_state,
+        breakout=breakout,
+        grid_fill=grid_fill,
+        grid_pressure=grid_pressure,
+        directional_micro_clear=directional_micro_clear,
+        trend=trend,
+    )
 
     if severe_imbalance and persistent_breakout:
         reasons.append("grid imbalance now coincides with persistent breakout pressure")
         directional_inventory = _inventory_breakout_is_directionally_compatible(inventory_state, breakout)
-        if trend_handoff_ready and (
+        if switch_tradeoff["should_switch"] and trend_handoff_ready and (
             not wallet_state.get("rebalance_needed")
             or directional_inventory
             or not rebalance
@@ -741,6 +897,9 @@ def _decide_for_grid(*,
             mode = "act"
             if directional_inventory:
                 reasons.append("inventory posture can be absorbed by the directional handoff")
+            reasons.append(
+                f"switch benefit ({switch_tradeoff['switch_benefit']:.2f}) exceeds stay cost ({switch_tradeoff['stay_cost']:.2f})"
+            )
         elif wallet_state.get("rebalance_needed") and rebalance and rebalance["score"] > 0.35:
             action = "replace_with_exposure_protector"
             target_strategy = rebalance["strategy_id"]
@@ -754,15 +913,22 @@ def _decide_for_grid(*,
         mode = "act"
         reasons.append("grid has lost practical recovery capacity, so inventory repair should take over")
     elif persistent_breakout and trend_handoff_ready and inventory_state in {"balanced", "base_heavy", "quote_heavy"}:
-        action = "replace_with_trend_follower"
-        target_strategy = trend["strategy_id"] if trend else target_strategy
-        mode = "act"
-        if grid_fill.get("near_fill") and fill_fights_breakout:
-            reasons.append("confirmed trend should not be delayed by a nearby grid fill that trades against the move")
-        elif grid_fill.get("near_fill"):
-            reasons.append("confirmed directional pressure is strong enough that nearby grid fills should not delay the trend handoff")
+        if not switch_tradeoff["should_switch"]:
+            reasons.append(
+                f"breakout is persistent, but staying in grid still looks cheaper than switching (benefit {switch_tradeoff['switch_benefit']:.2f} vs cost {switch_tradeoff['stay_cost']:.2f})"
+            )
+            if grid_fill.get("near_fill") and fill_fights_breakout:
+                reasons.append("nearby opposing fill is only a warning here, not enough on its own to justify the handoff")
         else:
-            reasons.append("grid should yield because directional pressure is confirmed and the trend handoff is ready")
+            action = "replace_with_trend_follower"
+            target_strategy = trend["strategy_id"] if trend else target_strategy
+            mode = "act"
+            if grid_fill.get("near_fill") and fill_fights_breakout:
+                reasons.append("confirmed trend should not be delayed by a nearby grid fill that trades against the move")
+            elif grid_fill.get("near_fill"):
+                reasons.append("confirmed directional pressure is strong enough that nearby grid fills should not delay the trend handoff")
+            else:
+                reasons.append("grid should yield because directional pressure is confirmed and the trend handoff is ready")
     elif not persistent_breakout and grid_can_work:
         if breakout_phase == "developing":
             reasons.append("breakout pressure is developing, but grid can still work and should not be abandoned yet")
@@ -794,6 +960,7 @@ def _decide_for_trend(*,
     narrative_payload: dict[str, Any],
     wallet_state: dict[str, Any],
     grid: dict[str, Any] | None,
+    rebalance: dict[str, Any] | None = None,
 ) -> tuple[str, str, str | None, list[str], list[str]]:
     action = "keep_trend"
     mode = "observe"
@@ -801,35 +968,40 @@ def _decide_for_trend(*,
     reasons: list[str] = []
     blocks: list[str] = []
 
-    # Trend is allowed to cool back into grid, but it should not bounce into
-    # the rebalancer as an intermediate hop.
-    if _trend_cooling_edge(narrative_payload, wallet_state):
-        if grid and wallet_state.get("grid_ready"):
+    # Trend should cool into rebalancing first when the wallet is skewed, then
+    # let rebalancer hand back to grid once the inventory is healthy again.
+    cooling = _trend_cooling_edge(narrative_payload, wallet_state)
+    if cooling:
+        if wallet_state.get("rebalance_needed") and rebalance:
+            action = "replace_with_exposure_protector"
+            target_strategy = rebalance["strategy_id"]
+            mode = "act"
+            reasons.append("trend has cooled and rebalancing should repair the wallet before grid resumes")
+        elif grid and wallet_state.get("grid_ready"):
             action = "replace_with_grid"
             target_strategy = grid["strategy_id"]
             mode = "act"
-            reasons.append("trend has cooled and grid can resume instead of ping-ponging into rebalancing")
+            reasons.append("trend has cooled and grid can resume because no rebalancer is available")
         else:
             mode = "warn"
-            blocks.append("edge cooling is visible but the wallet should not bounce straight into rebalancing")
-    elif stance == "neutral_rotational" and wallet_state.get("grid_ready"):
-        if grid and grid["score"] >= 0.5:
+            blocks.append("edge cooling is visible but the wallet is not yet ready for grid")
+    elif stance == "neutral_rotational":
+        if wallet_state.get("rebalance_needed") and rebalance:
+            action = "replace_with_exposure_protector"
+            target_strategy = rebalance["strategy_id"]
+            mode = "act"
+            reasons.append("trend conditions have cooled and rebalancing should repair the wallet before grid resumes")
+        elif grid and wallet_state.get("grid_ready"):
             action = "replace_with_grid"
             target_strategy = grid["strategy_id"]
             mode = "act"
             reasons.append("trend conditions have cooled and wallet is grid-ready again")
+        elif wallet_state.get("rebalance_needed"):
+            mode = "warn"
+            blocks.append("trend has cooled but rebalancing should be the next hop")
         else:
             action = "hold_trend"
             blocks.append("grid candidate not strong enough yet")
-    elif stance == "neutral_rotational" and wallet_state.get("rebalance_needed"):
-        if grid and wallet_state.get("grid_ready"):
-            action = "replace_with_grid"
-            target_strategy = grid["strategy_id"]
-            mode = "act"
-            reasons.append("trend has cooled and grid should recover the wallet instead of bouncing through rebalancing")
-        else:
-            mode = "warn"
-            blocks.append("trend has cooled but rebalancing should not be the immediate next hop")
     else:
         reasons.append("trend strategy still fits the directional narrative")
 
@@ -841,6 +1013,7 @@ def _decide_for_rebalancer(*,
     stance: str,
     wallet_state: dict[str, Any],
     grid: dict[str, Any] | None,
+    trend: dict[str, Any] | None = None,
 ) -> tuple[str, str, str | None, list[str], list[str]]:
     action = "keep_rebalancer"
     mode = "observe"
@@ -850,14 +1023,17 @@ def _decide_for_rebalancer(*,
 
     # Rebalancing is a repair phase. Once the wallet is usable again, Hermes
     # should prefer handing back to grid, not directly to trend.
-    if str(wallet_state.get("inventory_state") or "").lower() == "balanced":
+    trend_strength = float(trend["score"]) if trend and isinstance(trend.get("score"), (int, float)) else 0.0
+    if trend and trend_strength >= 1.5:
+        blocks.append("trend is still strong enough that rebalancer should keep repairing instead of resetting to grid")
+    elif _wallet_within_rebalance_tolerance(wallet_state, 0.3):
         if grid:
             action = "replace_with_grid"
             target_strategy = grid["strategy_id"]
             mode = "act"
-            reasons.append("wallet is balanced, so grid should run until a strong trend is detected")
+            reasons.append("wallet is within the 0.3 rebalance tolerance, so grid can resume before perfect balance")
         else:
-            blocks.append("wallet is balanced but no grid candidate is available")
+            blocks.append("wallet is within the rebalance tolerance 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"
@@ -886,7 +1062,7 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
     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")
+    inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
     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")
@@ -898,7 +1074,7 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
     directional_micro_clear = bullish_micro_clear if breakout_direction == "bullish" else bearish_micro_clear if breakout_direction == "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}
     grid_pressure = _grid_trend_pressure(current_primary, narrative_payload) if current_primary and current_primary["strategy_type"] == "grid_trader" else {"levels": 0.0, "rounded_levels": 0, "direction": "unknown"}
-    severe_imbalance = inventory_state in {"depleted_base_side", "depleted_quote_side", "critically_unbalanced"}
+    severe_imbalance = inventory_state in SEVERE_INVENTORY_STATES
 
     action = "hold"
     mode = "observe"
@@ -908,6 +1084,7 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
     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)
+    switch_tradeoff: dict[str, Any] = {}
 
     if current_primary and current_primary["strategy_type"] == "grid_trader":
         action, mode, target_strategy, reasons, blocks = _decide_for_grid(
@@ -923,6 +1100,15 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
             trend=trend,
             rebalance=rebalance,
         )
+        switch_tradeoff = _grid_switch_tradeoff(
+            current_primary=current_primary,
+            wallet_state=wallet_state,
+            breakout=breakout,
+            grid_fill=grid_fill,
+            grid_pressure=grid_pressure,
+            directional_micro_clear=directional_micro_clear,
+            trend=trend,
+        )
     elif current_primary and current_primary["strategy_type"] == "trend_follower":
         action, mode, target_strategy, reasons, blocks = _decide_for_trend(
             current_primary=current_primary,
@@ -930,6 +1116,7 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
             narrative_payload=narrative_payload,
             wallet_state=wallet_state,
             grid=grid,
+            rebalance=rebalance,
         )
     elif current_primary and current_primary["strategy_type"] == "exposure_protector":
         action, mode, target_strategy, reasons, blocks = _decide_for_rebalancer(
@@ -937,6 +1124,7 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
             stance=stance,
             wallet_state=wallet_state,
             grid=grid,
+            trend=trend,
         )
     else:
         if best and best["score"] >= 0.55:
@@ -967,6 +1155,7 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
         "history_window": history_window or {},
         "grid_breakout_pressure": breakout,
         "grid_fill_context": grid_fill,
+        "grid_switch_tradeoff": switch_tradeoff if current_primary and current_primary["strategy_type"] == "grid_trader" else {},
         "reason_chain": reasons,
         "blocks": blocks,
         "decision_version": 2,

+ 22 - 1
src/hermes_mcp/server.py

@@ -20,7 +20,7 @@ from .crypto_client import get_price, get_regime
 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_observations, latest_regime_samples, prune_older_than, recent_regime_samples, recent_states_for_concern, sync_concerns_from_strategies, upsert_cycle, upsert_decision, upsert_narrative, upsert_observation, upsert_regime_sample, upsert_state, latest_states
+from .store import delete_concern, get_state, init_db, list_concerns, latest_cycle, latest_cycles, latest_decisions, latest_narratives, latest_observations, latest_regime_samples, prune_older_than, recent_regime_samples, recent_states_for_concern, sync_concerns_from_strategies, upsert_cycle, upsert_decision, upsert_narrative, upsert_observation, upsert_regime_sample, upsert_state, latest_states
 from .trader_client import apply_control_decision as trader_apply_control_decision, get_strategy as trader_get_strategy, list_strategies
 
 mcp = FastMCP(
@@ -303,6 +303,14 @@ def health() -> dict:
     return {"status": "ok", "db": "sqlite", "tool": "report"}
 
 
+@app.delete("/concerns/{concern_id}")
+def remove_concern(concern_id: str) -> JSONResponse:
+    deleted = delete_concern(concern_id=concern_id)
+    if not deleted.get("concerns"):
+        return JSONResponse({"ok": False, "error": "concern not found", "deleted": deleted}, status_code=404)
+    return JSONResponse({"ok": True, "deleted": deleted})
+
+
 def _strip_sse(url: str) -> str:
     root = url.rstrip("/")
     return root[:-8] if root.endswith("/mcp/sse") else root
@@ -415,11 +423,23 @@ def dashboard_data() -> JSONResponse:
     concerns = list_concerns()
     accounts_by_id: dict[str, dict] = {}
     markets_by_symbol: dict[str, dict] = {}
+    strategy_inventory: list[dict] = []
+    strategy_inventory_available = True
     try:
         accounts_by_id, markets_by_symbol, total_values = anyio.run(_load_exec_enrichment, cfg.exec_url, cfg.crypto_url, concerns)
     except Exception:
         total_values = {}
         pass
+    try:
+        strategy_inventory = anyio.run(list_strategies, cfg.trader_url)
+    except Exception:
+        strategy_inventory = []
+        strategy_inventory_available = False
+    live_scopes = {
+        (str(strategy.get("account_id") or "").strip(), str(strategy.get("market_symbol") or "").strip().lower())
+        for strategy in strategy_inventory
+        if str(strategy.get("account_id") or "").strip() and str(strategy.get("market_symbol") or "").strip()
+    }
     enriched = []
     concern_lookup: dict[str, dict] = {}
     for concern in concerns:
@@ -435,6 +455,7 @@ def dashboard_data() -> JSONResponse:
             "total_value_usd": total_values.get(account_id) if total_values.get(account_id) is not None else account_info.get("total_value_usd"),
             "market_display": market_info.get("name") or concern.get("market_symbol") or "",
             "market_description": market_info.get("description") or "",
+            "orphaned": strategy_inventory_available and (account_id, market_symbol) not in live_scopes,
         })
         concern_lookup[str(concern.get("id") or "")] = enriched[-1]
     regimes = []

+ 23 - 0
src/hermes_mcp/store.py

@@ -234,6 +234,29 @@ def list_concerns() -> list[dict[str, Any]]:
     return [dict(r) for r in rows]
 
 
+def delete_concern(*, concern_id: str) -> dict[str, int]:
+    init_db()
+    concern_id = str(concern_id or "").strip()
+    if not concern_id:
+        return {"concerns": 0, "observations": 0, "states": 0, "narratives": 0, "decisions": 0, "actions": 0, "coverage_gaps": 0, "regime_samples": 0}
+
+    deleted = {"concerns": 0, "observations": 0, "states": 0, "narratives": 0, "decisions": 0, "actions": 0, "coverage_gaps": 0, "regime_samples": 0}
+    with _connect() as conn:
+        decision_ids = [row[0] for row in conn.execute("select id from decisions where concern_id = ?", (concern_id,)).fetchall()]
+        deleted["actions"] = conn.execute(
+            f"delete from actions where decision_id in ({','.join('?' for _ in decision_ids)})",
+            decision_ids,
+        ).rowcount if decision_ids else 0
+        deleted["observations"] = conn.execute("delete from observations where concern_id = ?", (concern_id,)).rowcount or 0
+        deleted["states"] = conn.execute("delete from states where concern_id = ?", (concern_id,)).rowcount or 0
+        deleted["narratives"] = conn.execute("delete from narratives where concern_id = ?", (concern_id,)).rowcount or 0
+        deleted["coverage_gaps"] = conn.execute("delete from coverage_gaps where concern_id = ?", (concern_id,)).rowcount or 0
+        deleted["regime_samples"] = conn.execute("delete from regime_samples where concern_id = ?", (concern_id,)).rowcount or 0
+        deleted["decisions"] = conn.execute("delete from decisions where concern_id = ?", (concern_id,)).rowcount or 0
+        deleted["concerns"] = conn.execute("delete from concerns where id = ?", (concern_id,)).rowcount or 0
+    return deleted
+
+
 def prune_older_than(days: int) -> dict[str, int]:
     init_db()
     cutoff = datetime.now(timezone.utc).timestamp() - (days * 86400)

+ 102 - 0
tests/test_concern_cleanup.py

@@ -0,0 +1,102 @@
+from __future__ import annotations
+
+import sqlite3
+from uuid import uuid4
+
+from hermes_mcp.store import (
+    DB_PATH,
+    delete_concern,
+    init_db,
+    upsert_concern,
+    upsert_cycle,
+    upsert_decision,
+    upsert_narrative,
+    upsert_observation,
+    upsert_regime_sample,
+    upsert_state,
+)
+
+
+def _count(table: str, value: str, column: str = "concern_id") -> int:
+    with sqlite3.connect(DB_PATH) as conn:
+        conn.row_factory = sqlite3.Row
+        row = conn.execute(f"select count(*) as n from {table} where {column} = ?", (value,)).fetchone()
+        return int(row["n"] if row else 0)
+
+
+def test_delete_concern_purges_related_rows():
+    init_db()
+    concern_id = f"test:{uuid4().hex}"
+    cycle_id = f"cycle:{uuid4().hex}"
+    decision_id = f"decision:{uuid4().hex}"
+    action_id = f"action:{uuid4().hex}"
+
+    upsert_concern(
+        id=concern_id,
+        account_id="acct-1",
+        market_symbol="xrpusd",
+        base_currency="XRP",
+        quote_currency="USD",
+        strategy_id="trend-1",
+        source="test",
+        status="active",
+        notes="cleanup target",
+    )
+    upsert_cycle(id=cycle_id, started_at="2026-04-19T00:00:00+00:00", finished_at=None, status="ok", trigger="test")
+    upsert_observation(id=f"obs:{uuid4().hex}", cycle_id=cycle_id, concern_id=concern_id, source="test", kind="snapshot", payload_json="{}")
+    upsert_state(
+        id=f"state:{uuid4().hex}",
+        cycle_id=cycle_id,
+        concern_id=concern_id,
+        market_regime="bull",
+        volatility_state="normal",
+        liquidity_state="good",
+        sentiment_pressure="neutral",
+        event_risk="low",
+        execution_quality="good",
+        confidence=0.9,
+        payload_json="{}",
+    )
+    upsert_narrative(
+        id=f"narr:{uuid4().hex}",
+        cycle_id=cycle_id,
+        concern_id=concern_id,
+        summary="cleanup target",
+        key_drivers_json="[]",
+        risk_flags_json="[]",
+        uncertainties_json="[]",
+        confidence=0.8,
+    )
+    upsert_decision(
+        id=decision_id,
+        cycle_id=cycle_id,
+        concern_id=concern_id,
+        mode="act",
+        action="replace_with_grid",
+        target_strategy="grid-1",
+        target_policy_json="{}",
+        reason_summary="cleanup target",
+        confidence=0.7,
+        requires_action=True,
+    )
+    upsert_regime_sample(id=f"regime:{uuid4().hex}", cycle_id=cycle_id, concern_id=concern_id, timeframe="1h", regime_json="{}", captured_at="2026-04-19T00:00:00+00:00")
+    with sqlite3.connect(DB_PATH) as conn:
+        conn.execute(
+            "insert into actions(id, decision_id, target, command, request_json, response_json, status, executed_at) values(?, ?, ?, ?, ?, ?, ?, ?)",
+            (action_id, decision_id, "trader", "switch", "{}", None, "pending", None),
+        )
+        conn.commit()
+
+    deleted = delete_concern(concern_id=concern_id)
+    assert deleted["concerns"] == 1
+    assert deleted["decisions"] == 1
+    assert deleted["actions"] == 1
+    assert deleted["observations"] == 1
+    assert deleted["states"] == 1
+    assert deleted["narratives"] == 1
+    assert deleted["regime_samples"] == 1
+
+    assert _count("concerns", concern_id, "id") == 0
+    for table in ("observations", "states", "narratives", "decisions", "coverage_gaps", "regime_samples"):
+        assert _count(table, concern_id) == 0
+    assert _count("actions", decision_id, "decision_id") == 0

+ 99 - 11
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_marks_one_sided_wallet_as_critically_unbalanced():
+def test_assess_wallet_state_marks_one_sided_wallet_as_depleted_base_side():
     concern = {"account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     account_info = {
         "balances": [
@@ -10,7 +10,7 @@ def test_assess_wallet_state_marks_one_sided_wallet_as_critically_unbalanced():
         ]
     }
     wallet = assess_wallet_state(account_info=account_info, concern=concern, price=2.0)
-    assert wallet["inventory_state"] == "critically_unbalanced"
+    assert wallet["inventory_state"] == "depleted_base_side"
     assert wallet["rebalance_needed"] is True
     assert wallet["grid_ready"] is False
 
@@ -30,6 +30,20 @@ def test_assess_wallet_state_infers_base_and_quote_from_market_symbol_when_missi
     assert wallet["quote_available"] == 12.64
 
 
+def test_assess_wallet_state_marks_one_sided_wallet_as_depleted_quote_side():
+    concern = {"account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    account_info = {
+        "balances": [
+            {"asset_code": "XRP", "available": 1000},
+            {"asset_code": "USD", "available": 1},
+        ]
+    }
+    wallet = assess_wallet_state(account_info=account_info, concern=concern, price=2.0)
+    assert wallet["inventory_state"] == "depleted_quote_side"
+    assert wallet["rebalance_needed"] is True
+    assert wallet["grid_ready"] is False
+
+
 def test_score_strategy_fit_penalizes_grid_when_wallet_unbalanced():
     strategy = normalize_strategy_snapshot({
         "id": "grid-1",
@@ -71,6 +85,30 @@ def test_score_strategy_fit_rewards_trend_when_breakout_is_confirmed():
     assert any("confirmed breakout" in reason for reason in confirmed_fit["reasons"])
 
 
+def test_score_strategy_fit_prefers_matching_trade_side():
+    bullish_narrative = {
+        "stance": "constructive_bullish",
+        "opportunity_map": {"continuation": 0.75, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.12},
+        "grid_breakout_pressure": {"phase": "confirmed", "persistent": True, "micro_bias": "bullish", "meso_bias": "bullish"},
+    }
+    bearish_narrative = {
+        "stance": "constructive_bearish",
+        "opportunity_map": {"continuation": 0.75, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.12},
+        "grid_breakout_pressure": {"phase": "confirmed", "persistent": True, "micro_bias": "bearish", "meso_bias": "bearish"},
+    }
+    wallet_state = {"inventory_state": "balanced", "rebalance_needed": False}
+    buy_strategy = normalize_strategy_snapshot({"id": "trend-long", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {"trade_side": "buy"}})
+    sell_strategy = normalize_strategy_snapshot({"id": "trend-short", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {"trade_side": "sell"}})
+
+    buy_fit = score_strategy_fit(strategy=buy_strategy, narrative=bullish_narrative, wallet_state=wallet_state)
+    sell_fit = score_strategy_fit(strategy=sell_strategy, narrative=bullish_narrative, wallet_state=wallet_state)
+    assert buy_fit["score"] > sell_fit["score"]
+
+    buy_fit_bear = score_strategy_fit(strategy=buy_strategy, narrative=bearish_narrative, wallet_state=wallet_state)
+    sell_fit_bear = score_strategy_fit(strategy=sell_strategy, narrative=bearish_narrative, wallet_state=wallet_state)
+    assert sell_fit_bear["score"] > buy_fit_bear["score"]
+
+
 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 = {
@@ -298,6 +336,32 @@ def test_make_decision_replaces_grid_when_third_level_is_sustained():
     assert decision.payload["grid_breakout_pressure"]["phase"] == "confirmed"
 
 
+def test_make_decision_targets_the_trade_side_that_matches_direction():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.82,
+        "opportunity_map": {"continuation": 0.82, "mean_reversion": 0.05, "reversal": 0.03, "wait": 0.1},
+        "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"},
+        "features_by_timeframe": {"1m": {"raw": {"price": 116.0}}},
+    }
+    wallet_state = {"inventory_state": "balanced", "rebalance_needed": False, "grid_ready": True, "base_ratio": 0.52, "quote_ratio": 0.48}
+    strategies = [
+        {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {"center_price": 100.0}, "config": {"grid_step_pct": 0.05}},
+        {"id": "trend-long", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {"trade_side": "buy"}},
+        {"id": "trend-short", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {"trade_side": "sell"}},
+    ]
+    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-long"
+
+
 def test_make_decision_marks_breakout_as_developing_under_partial_alignment():
     concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     narrative = {
@@ -678,9 +742,9 @@ def test_make_decision_replaces_trend_with_rebalancer_after_trend_cools_and_wall
         {"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 == "warn"
-    assert decision.action == "keep_trend"
-    assert decision.target_strategy == "trend-1"
+    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():
@@ -708,9 +772,9 @@ def test_make_decision_replaces_trend_with_rebalancer_on_edge_cooling_even_befor
         {"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 == "warn"
-    assert decision.action == "keep_trend"
-    assert decision.target_strategy == "trend-1"
+    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_when_micro_reversal_risk_spikes():
@@ -738,9 +802,9 @@ def test_make_decision_replaces_trend_with_rebalancer_when_micro_reversal_risk_s
         {"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 == "warn"
-    assert decision.action == "keep_trend"
-    assert decision.target_strategy == "trend-1"
+    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():
@@ -767,6 +831,30 @@ def test_make_decision_replaces_rebalancer_with_grid_when_balanced_and_rotationa
     assert decision.target_strategy == "grid-1"
 
 
+def test_make_decision_replaces_rebalancer_with_grid_when_within_tolerance_even_before_perfect_balance():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "neutral_rotational",
+        "confidence": 0.7,
+        "opportunity_map": {"continuation": 0.18, "mean_reversion": 0.68, "reversal": 0.05, "wait": 0.09},
+    }
+    wallet_state = {
+        "inventory_state": "base_heavy",
+        "rebalance_needed": True,
+        "grid_ready": True,
+        "base_ratio": 0.71,
+        "quote_ratio": 0.29,
+    }
+    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"
+
+
 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 = {