浏览代码

Improve Hermes grid-trend switching

Lukas Goldschmidt 3 周之前
父节点
当前提交
f56fa028ad

+ 2 - 0
TRADER_COMPATIBILITY_NOTE.md

@@ -49,6 +49,8 @@ The current supervision facts used by Hermes include:
 - `rebalance_needed`
 - `rebalance_needed`
 - `signal`
 - `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.
 The reports should stay descriptive, not imperative.
 Hermes infers switches from the factual report plus narrative and wallet state.
 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 {}
     cycle = latest_cycle() or {}
     concerns = []
     concerns = []
     regimes = latest_regime_samples(10)
     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>"
     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>"
     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>"
     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; }}
         .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; }}
         .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; }}
         .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; }}
         .good {{ background:#dcfce7; color:#166534; }}
         .warn {{ background:#fef3c7; color:#92400e; }}
         .warn {{ background:#fef3c7; color:#92400e; }}
         .bad {{ background:#fee2e2; color:#991b1b; }}
         .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 || '')));
           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() {{
         async function refreshData() {{
           const res = await fetch('/dashboard/data', {{ cache: 'no-store' }});
           const res = await fetch('/dashboard/data', {{ cache: 'no-store' }});
           const data = await res.json();
           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.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.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.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 histories = data.regime_histories || {};
           const desiredOrder = ['1d', '4h', '1h', '15m', '5m', '1m'];
           const desiredOrder = ['1d', '4h', '1h', '15m', '5m', '1m'];
           const samples = data.regime_samples || [];
           const samples = data.regime_samples || [];
@@ -329,7 +343,7 @@ def overview():
       <p class="small"><span id="concern-count">__CONCERN_COUNT__</span> concerns</p>
       <p class="small"><span id="concern-count">__CONCERN_COUNT__</span> concerns</p>
       <h2>Concerns</h2>
       <h2>Concerns</h2>
       <table>
       <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>
         <tbody id="concerns-body">__CONCERN_ROWS__</tbody>
       </table>
       </table>
       <h2>Latest regime samples</h2>
       <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
         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]:
 def _infer_market_pair(concern: dict[str, Any]) -> tuple[str, str]:
     base = str(concern.get("base_currency") or "").strip().upper()
     base = str(concern.get("base_currency") or "").strip().upper()
     quote = str(concern.get("quote_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:
     if total_value <= 0:
         inventory_state = "unknown"
         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:
     elif base_ratio < 0.08:
         inventory_state = "critically_unbalanced"
         inventory_state = "critically_unbalanced"
     elif quote_ratio < 0.08:
     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"
         inventory_state = "balanced"
 
 
     grid_ready = 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 {
     return {
         "generated_at": datetime.now(timezone.utc).isoformat(),
         "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,
             "requires_rebalance_before_stop": False,
             "safe_when_unbalanced": True,
             "safe_when_unbalanced": True,
             "can_run_with": [],
             "can_run_with": [],
+            "trade_side": "both",
         },
         },
         "exposure_protector": {
         "exposure_protector": {
             "role": "rebalancing",
             "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_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 "",
         "last_error": state.get("last_error") or report_state.get("last_error") or "",
         "contract": contract,
         "contract": contract,
+        "trade_side": str(config.get("trade_side") or contract.get("trade_side") or "both"),
         "supervision": report_supervision,
         "supervision": report_supervision,
         "config": config,
         "config": config,
         "state": {**report_state, **state},
         "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)
     mean_reversion = float(opportunity_map.get("mean_reversion") or 0.0)
     reversal = float(opportunity_map.get("reversal") or 0.0)
     reversal = float(opportunity_map.get("reversal") or 0.0)
     wait = float(opportunity_map.get("wait") 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)
     argus_context = _argus_decision_context(narrative)
 
 
     strategy_type = strategy["strategy_type"]
     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")
             reasons.append("Argus compression supports staying selective with grid")
     elif strategy_type == "trend_follower":
     elif strategy_type == "trend_follower":
         score += continuation * 1.9
         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"}:
         if stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
             score += 0.5
             score += 0.5
             reasons.append("narrative supports directional continuation")
             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":
         if breakout_phase == "confirmed":
             score += 0.45
             score += 0.45
             reasons.append("confirmed breakout pressure supports directional continuation")
             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":
         if wait >= 0.45 and breakout_phase != "confirmed":
             score -= 0.35
             score -= 0.35
             blocks.append("market still has too much wait/uncertainty for trend commitment")
             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
             score -= 0.25
             blocks.append("wallet may be too skewed for clean directional scaling")
             blocks.append("wallet may be too skewed for clean directional scaling")
         if inventory_pressure in {"base_heavy", "quote_heavy"}:
         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:
         if not capacity_available:
             score -= 0.1
             score -= 0.1
             blocks.append("trend strength is below its own capacity threshold")
             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":
         if argus_context["compression_active"] and breakout_phase != "confirmed":
             score -= 0.15
             score -= 0.15
             blocks.append("Argus compression says the broader tape is still range-like")
             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"):
         if wallet_state.get("rebalance_needed"):
             score += 1.1
             score += 1.1
             reasons.append("wallet imbalance calls for rebalancing protection")
             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
             score += 0.45
             reasons.append("inventory drift is high enough to justify defensive action")
             reasons.append("inventory drift is high enough to justify defensive action")
         if stance in {"constructive_bullish", "constructive_bearish"} and continuation > 0.65:
         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:
 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")
     macro_bias = str(breakout.get("macro_bias") or "mixed")
     meso_bias = str(breakout.get("meso_bias") or "neutral")
     meso_bias = str(breakout.get("meso_bias") or "neutral")
     bullish = macro_bias == "bullish" and meso_bias == "bullish"
     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")
     micro_reversal_risk = str(micro.get("reversal_risk") or "low")
     meso_bias = str(meso.get("momentum_bias") or "neutral")
     meso_bias = str(meso.get("momentum_bias") or "neutral")
     meso_structure = str(meso.get("structure") or "rotation")
     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"}
     early_reversal_warning = micro_reversal_risk in {"medium", "high"}
 
 
     bullish_cooling = (
     bullish_cooling = (
@@ -633,6 +676,26 @@ def _breakout_direction(breakout: dict[str, Any], stance: str | None = None) ->
     return 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:
 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 {}
     memory = breakout.get("time_window_memory") if isinstance(breakout.get("time_window_memory"), dict) else {}
     if bool(memory.get("promoted_to_confirmed")):
     if bool(memory.get("promoted_to_confirmed")):
@@ -640,6 +703,78 @@ def _trend_handoff_level_threshold(breakout: dict[str, Any]) -> float:
     return 2.75
     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]:
 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 {}
     state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
     config = strategy.get("config") if isinstance(strategy.get("config"), 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))
     sell_capacity = bool(side_capacity.get("sell", False))
     open_order_count = int(strategy.get("open_order_count") or 0)
     open_order_count = int(strategy.get("open_order_count") or 0)
     degraded = bool(supervision.get("degraded"))
     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:
     if degraded:
         return False
         return False
@@ -683,14 +818,25 @@ def _grid_can_still_work(strategy: dict[str, Any], wallet_state: dict[str, Any],
         return True
         return True
     if grid_fill.get("near_fill"):
     if grid_fill.get("near_fill"):
         return True
         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:
 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):
     if _grid_can_still_work(strategy, wallet_state, grid_fill):
         return False
         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(*,
 def _decide_for_grid(*,
@@ -711,6 +857,7 @@ def _decide_for_grid(*,
     target_strategy = current_primary["id"]
     target_strategy = current_primary["id"]
     reasons: list[str] = []
     reasons: list[str] = []
     blocks: 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
     # 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.
     # 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)
         and grid_pressure.get("levels", 0.0) >= _trend_handoff_level_threshold(breakout)
     )
     )
     fill_fights_breakout = _grid_fill_fights_breakout(grid_fill, 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:
     if severe_imbalance and persistent_breakout:
         reasons.append("grid imbalance now coincides with persistent breakout pressure")
         reasons.append("grid imbalance now coincides with persistent breakout pressure")
         directional_inventory = _inventory_breakout_is_directionally_compatible(inventory_state, breakout)
         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")
             not wallet_state.get("rebalance_needed")
             or directional_inventory
             or directional_inventory
             or not rebalance
             or not rebalance
@@ -741,6 +897,9 @@ def _decide_for_grid(*,
             mode = "act"
             mode = "act"
             if directional_inventory:
             if directional_inventory:
                 reasons.append("inventory posture can be absorbed by the directional handoff")
                 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:
         elif wallet_state.get("rebalance_needed") and rebalance and rebalance["score"] > 0.35:
             action = "replace_with_exposure_protector"
             action = "replace_with_exposure_protector"
             target_strategy = rebalance["strategy_id"]
             target_strategy = rebalance["strategy_id"]
@@ -754,15 +913,22 @@ def _decide_for_grid(*,
         mode = "act"
         mode = "act"
         reasons.append("grid has lost practical recovery capacity, so inventory repair should take over")
         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"}:
     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:
         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:
     elif not persistent_breakout and grid_can_work:
         if breakout_phase == "developing":
         if breakout_phase == "developing":
             reasons.append("breakout pressure is developing, but grid can still work and should not be abandoned yet")
             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],
     narrative_payload: dict[str, Any],
     wallet_state: dict[str, Any],
     wallet_state: dict[str, Any],
     grid: dict[str, Any] | None,
     grid: dict[str, Any] | None,
+    rebalance: dict[str, Any] | None = None,
 ) -> tuple[str, str, str | None, list[str], list[str]]:
 ) -> tuple[str, str, str | None, list[str], list[str]]:
     action = "keep_trend"
     action = "keep_trend"
     mode = "observe"
     mode = "observe"
@@ -801,35 +968,40 @@ def _decide_for_trend(*,
     reasons: list[str] = []
     reasons: list[str] = []
     blocks: 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"
             action = "replace_with_grid"
             target_strategy = grid["strategy_id"]
             target_strategy = grid["strategy_id"]
             mode = "act"
             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:
         else:
             mode = "warn"
             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"
             action = "replace_with_grid"
             target_strategy = grid["strategy_id"]
             target_strategy = grid["strategy_id"]
             mode = "act"
             mode = "act"
             reasons.append("trend conditions have cooled and wallet is grid-ready again")
             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:
         else:
             action = "hold_trend"
             action = "hold_trend"
             blocks.append("grid candidate not strong enough yet")
             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:
     else:
         reasons.append("trend strategy still fits the directional narrative")
         reasons.append("trend strategy still fits the directional narrative")
 
 
@@ -841,6 +1013,7 @@ def _decide_for_rebalancer(*,
     stance: str,
     stance: str,
     wallet_state: dict[str, Any],
     wallet_state: dict[str, Any],
     grid: dict[str, Any] | None,
     grid: dict[str, Any] | None,
+    trend: dict[str, Any] | None = None,
 ) -> tuple[str, str, str | None, list[str], list[str]]:
 ) -> tuple[str, str, str | None, list[str], list[str]]:
     action = "keep_rebalancer"
     action = "keep_rebalancer"
     mode = "observe"
     mode = "observe"
@@ -850,14 +1023,17 @@ def _decide_for_rebalancer(*,
 
 
     # Rebalancing is a repair phase. Once the wallet is usable again, Hermes
     # Rebalancing is a repair phase. Once the wallet is usable again, Hermes
     # should prefer handing back to grid, not directly to trend.
     # 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:
         if grid:
             action = "replace_with_grid"
             action = "replace_with_grid"
             target_strategy = grid["strategy_id"]
             target_strategy = grid["strategy_id"]
             mode = "act"
             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:
         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":
     elif wallet_state.get("grid_ready") and stance == "neutral_rotational":
         if grid and grid["score"] >= 0.5:
         if grid and grid["score"] >= 0.5:
             action = "replace_with_grid"
             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)
     current_primary = _select_current_primary(normalized)
     best = ranked[0] if ranked else None
     best = ranked[0] if ranked else None
     stance = str(narrative_payload.get("stance") or "neutral_rotational")
     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 {}
     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 = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
     micro_impulse = str(micro.get("impulse") or "mixed")
     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
     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_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"}
     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"
     action = "hold"
     mode = "observe"
     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)
     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)
     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)
     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":
     if current_primary and current_primary["strategy_type"] == "grid_trader":
         action, mode, target_strategy, reasons, blocks = _decide_for_grid(
         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,
             trend=trend,
             rebalance=rebalance,
             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":
     elif current_primary and current_primary["strategy_type"] == "trend_follower":
         action, mode, target_strategy, reasons, blocks = _decide_for_trend(
         action, mode, target_strategy, reasons, blocks = _decide_for_trend(
             current_primary=current_primary,
             current_primary=current_primary,
@@ -930,6 +1116,7 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
             narrative_payload=narrative_payload,
             narrative_payload=narrative_payload,
             wallet_state=wallet_state,
             wallet_state=wallet_state,
             grid=grid,
             grid=grid,
+            rebalance=rebalance,
         )
         )
     elif current_primary and current_primary["strategy_type"] == "exposure_protector":
     elif current_primary and current_primary["strategy_type"] == "exposure_protector":
         action, mode, target_strategy, reasons, blocks = _decide_for_rebalancer(
         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,
             stance=stance,
             wallet_state=wallet_state,
             wallet_state=wallet_state,
             grid=grid,
             grid=grid,
+            trend=trend,
         )
         )
     else:
     else:
         if best and best["score"] >= 0.55:
         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 {},
         "history_window": history_window or {},
         "grid_breakout_pressure": breakout,
         "grid_breakout_pressure": breakout,
         "grid_fill_context": grid_fill,
         "grid_fill_context": grid_fill,
+        "grid_switch_tradeoff": switch_tradeoff if current_primary and current_primary["strategy_type"] == "grid_trader" else {},
         "reason_chain": reasons,
         "reason_chain": reasons,
         "blocks": blocks,
         "blocks": blocks,
         "decision_version": 2,
         "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 .decision_engine import assess_wallet_state, make_decision
 from .narrative_engine import build_narrative
 from .narrative_engine import build_narrative
 from .state_engine import synthesize_state
 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
 from .trader_client import apply_control_decision as trader_apply_control_decision, get_strategy as trader_get_strategy, list_strategies
 
 
 mcp = FastMCP(
 mcp = FastMCP(
@@ -303,6 +303,14 @@ def health() -> dict:
     return {"status": "ok", "db": "sqlite", "tool": "report"}
     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:
 def _strip_sse(url: str) -> str:
     root = url.rstrip("/")
     root = url.rstrip("/")
     return root[:-8] if root.endswith("/mcp/sse") else root
     return root[:-8] if root.endswith("/mcp/sse") else root
@@ -415,11 +423,23 @@ def dashboard_data() -> JSONResponse:
     concerns = list_concerns()
     concerns = list_concerns()
     accounts_by_id: dict[str, dict] = {}
     accounts_by_id: dict[str, dict] = {}
     markets_by_symbol: dict[str, dict] = {}
     markets_by_symbol: dict[str, dict] = {}
+    strategy_inventory: list[dict] = []
+    strategy_inventory_available = True
     try:
     try:
         accounts_by_id, markets_by_symbol, total_values = anyio.run(_load_exec_enrichment, cfg.exec_url, cfg.crypto_url, concerns)
         accounts_by_id, markets_by_symbol, total_values = anyio.run(_load_exec_enrichment, cfg.exec_url, cfg.crypto_url, concerns)
     except Exception:
     except Exception:
         total_values = {}
         total_values = {}
         pass
         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 = []
     enriched = []
     concern_lookup: dict[str, dict] = {}
     concern_lookup: dict[str, dict] = {}
     for concern in concerns:
     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"),
             "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_display": market_info.get("name") or concern.get("market_symbol") or "",
             "market_description": market_info.get("description") 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]
         concern_lookup[str(concern.get("id") or "")] = enriched[-1]
     regimes = []
     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]
     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]:
 def prune_older_than(days: int) -> dict[str, int]:
     init_db()
     init_db()
     cutoff = datetime.now(timezone.utc).timestamp() - (days * 86400)
     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
 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"}
     concern = {"account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     account_info = {
     account_info = {
         "balances": [
         "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)
     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["rebalance_needed"] is True
     assert wallet["grid_ready"] is False
     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
     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():
 def test_score_strategy_fit_penalizes_grid_when_wallet_unbalanced():
     strategy = normalize_strategy_snapshot({
     strategy = normalize_strategy_snapshot({
         "id": "grid-1",
         "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"])
     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():
 def test_assess_wallet_state_counts_reserved_orders_in_effective_inventory():
     concern = {"account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     concern = {"account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     account_info = {
     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"
     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():
 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"}
     concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     narrative = {
     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": {}},
         {"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)
     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():
 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": {}},
         {"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)
     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():
 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": {}},
         {"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)
     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():
 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"
     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():
 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"}
     concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     narrative = {
     narrative = {