ソースを参照

Add Hermes decision engine

Lukas Goldschmidt 3 週間 前
コミット
d05f2a20eb

+ 26 - 0
src/hermes_mcp/dashboard.py

@@ -14,6 +14,7 @@ def overview():
     concern_rows = "<tr><td colspan='5' 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>"
     regime_rows = "".join(
         f"<tr><td>{r.get('concern_id','')}</td><td>{r.get('timeframe','')}</td><td><pre style='white-space:pre-wrap;margin:0'>{r.get('regime_json','')}</pre></td><td>{r.get('captured_at','')}</td></tr>"
         for r in regimes
@@ -153,6 +154,25 @@ def overview():
               <td>${typeof n.confidence === 'number' ? n.confidence.toFixed(2) : ''}</td>
             </tr>`;
           }).join('') || "<tr><td colspan='6' class='muted'>No narratives yet.</td></tr>";
+          document.getElementById('decisions-body').innerHTML = (data.decision_samples || []).map(d => {
+            const payload = (() => { try { return JSON.parse(d.target_policy_json || '{}'); } catch { return {}; } })();
+            const wallet = payload.wallet_state || {};
+            const ranking = payload.strategy_fit_ranking || [];
+            const top = ranking[0] || {};
+            return `
+            <tr>
+              <td>${d.concern_id || ''}</td>
+              <td>${d.mode || ''}</td>
+              <td>${d.action || ''}</td>
+              <td>${d.target_strategy || '-'}</td>
+              <td>${d.reason_summary || ''}</td>
+              <td>
+                <div class='small'><strong>wallet</strong>: ${wallet.inventory_state || '-'} (${typeof wallet.base_ratio === 'number' ? wallet.base_ratio.toFixed(2) : '-'}/${typeof wallet.quote_ratio === 'number' ? wallet.quote_ratio.toFixed(2) : '-'})</div>
+                <div class='small'><strong>top fit</strong>: ${top.strategy_type || '-'} (${typeof top.score === 'number' ? top.score.toFixed(2) : '-'})</div>
+              </td>
+              <td>${typeof d.confidence === 'number' ? d.confidence.toFixed(2) : ''}</td>
+            </tr>`;
+          }).join('') || "<tr><td colspan='7' class='muted'>No decisions yet.</td></tr>";
         }}
         window.addEventListener('load', () => {{ refreshData(); setInterval(refreshData, 15000); }});
       </script>
@@ -188,6 +208,11 @@ def overview():
         <tr><th>concern</th><th>summary</th><th>drivers</th><th>risks</th><th>uncertainties</th><th>confidence</th></tr>
         <tbody id="narratives-body">__NARRATIVE_ROWS__</tbody>
       </table>
+      <h2>Latest decisions</h2>
+      <table>
+        <tr><th>concern</th><th>mode</th><th>action</th><th>target strategy</th><th>reason</th><th>detail</th><th>confidence</th></tr>
+        <tbody id="decisions-body">__DECISION_ROWS__</tbody>
+      </table>
       </div></div>
     </body></html>
     """
@@ -203,6 +228,7 @@ def overview():
         .replace("__REGIME_ROWS__", regime_rows)
         .replace("__STATE_ROWS__", state_rows)
         .replace("__NARRATIVE_ROWS__", narrative_rows)
+        .replace("__DECISION_ROWS__", decision_rows)
     )
 
 

+ 334 - 0
src/hermes_mcp/decision_engine.py

@@ -0,0 +1,334 @@
+from __future__ import annotations
+
+"""Deterministic strategy-supervision logic for Hermes.
+
+This is the first decision slice. Hermes is currently acting as a supervisor for
+existing trader strategies, not as a direct trading engine.
+
+Design intent:
+- prefer keeping a suitable strategy active over unnecessary switching
+- detect when grid trading becomes unsafe because market posture or wallet
+  balance no longer supports it
+- hand off toward directional or rebalancing strategies without collapsing the
+  decision layer into execution details
+"""
+
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Any
+
+
+@dataclass(frozen=True)
+class DecisionSnapshot:
+    mode: str
+    action: str
+    target_strategy: str | None
+    reason_summary: str
+    confidence: float
+    requires_action: bool
+    payload: dict[str, Any]
+
+
+def _clamp(value: float, lower: float, upper: float) -> float:
+    return max(lower, min(upper, value))
+
+
+def _safe_float(value: Any) -> float | None:
+    try:
+        if value is None:
+            return None
+        return float(value)
+    except Exception:
+        return None
+
+
+def assess_wallet_state(*, account_info: dict[str, Any], concern: dict[str, Any], price: float | None) -> dict[str, Any]:
+    """Summarize inventory health for strategy switching.
+
+    The key output is whether the wallet is balanced enough for range/grid
+    harvesting, or so skewed that Hermes should prefer trend capture or
+    rebalancing before grid is allowed again.
+    """
+    balances = account_info.get("balances") if isinstance(account_info.get("balances"), list) else []
+    base = str(concern.get("base_currency") or concern.get("market_symbol") or "").split("/")[0].upper()
+    quote = str(concern.get("quote_currency") or "USD").upper()
+
+    base_available = 0.0
+    quote_available = 0.0
+    for item in balances:
+        if not isinstance(item, dict):
+            continue
+        asset = str(item.get("asset_code") or item.get("asset") or "").upper()
+        amount = _safe_float(item.get("available") if item.get("available") is not None else item.get("total"))
+        if amount is None:
+            continue
+        if asset == base:
+            base_available = amount
+        elif asset == quote:
+            quote_available = amount
+
+    price = price or 0.0
+    base_value = base_available * price if price > 0 else 0.0
+    quote_value = quote_available
+    total_value = base_value + quote_value
+    base_ratio = (base_value / total_value) if total_value > 0 else 0.5
+    quote_ratio = (quote_value / total_value) if total_value > 0 else 0.5
+    imbalance = abs(base_ratio - 0.5)
+
+    if total_value <= 0:
+        inventory_state = "unknown"
+    elif base_ratio < 0.08:
+        inventory_state = "depleted_base_side"
+    elif quote_ratio < 0.08:
+        inventory_state = "depleted_quote_side"
+    elif imbalance >= 0.35:
+        inventory_state = "critically_unbalanced"
+    elif base_ratio > 0.62:
+        inventory_state = "base_heavy"
+    elif quote_ratio > 0.62:
+        inventory_state = "quote_heavy"
+    else:
+        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"}
+
+    return {
+        "generated_at": datetime.now(timezone.utc).isoformat(),
+        "base_currency": base,
+        "quote_currency": quote,
+        "base_available": round(base_available, 8),
+        "quote_available": round(quote_available, 8),
+        "base_value": round(base_value, 4),
+        "quote_value": round(quote_value, 4),
+        "total_value": round(total_value, 4),
+        "base_ratio": round(base_ratio, 4),
+        "quote_ratio": round(quote_ratio, 4),
+        "imbalance_score": round(imbalance, 4),
+        "inventory_state": inventory_state,
+        "grid_ready": grid_ready,
+        "rebalance_needed": rebalance_needed,
+    }
+
+
+def normalize_strategy_snapshot(strategy: dict[str, Any]) -> dict[str, Any]:
+    strategy_type = str(strategy.get("strategy_type") or "unknown")
+    mode = str(strategy.get("mode") or "off")
+    state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
+    config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
+
+    # Stable minimum contract used by Hermes while the trader-side strategy
+    # metadata evolves. These values can later be sourced directly from richer
+    # reports, but the decision layer keeps a normalized shape from day one.
+    defaults = {
+        "grid_trader": {
+            "role": "primary",
+            "inventory_behavior": "balanced",
+            "requires_rebalance_before_start": False,
+            "requires_rebalance_before_stop": False,
+            "safe_when_unbalanced": False,
+            "can_run_with": ["exposure_protector"],
+        },
+        "trend_follower": {
+            "role": "primary",
+            "inventory_behavior": "accumulative_long",
+            "requires_rebalance_before_start": False,
+            "requires_rebalance_before_stop": False,
+            "safe_when_unbalanced": True,
+            "can_run_with": ["exposure_protector"],
+        },
+        "exposure_protector": {
+            "role": "defensive",
+            "inventory_behavior": "rebalancing",
+            "requires_rebalance_before_start": False,
+            "requires_rebalance_before_stop": False,
+            "safe_when_unbalanced": True,
+            "can_run_with": ["grid_trader", "trend_follower"],
+        },
+    }
+    contract = defaults.get(strategy_type, {
+        "role": "primary",
+        "inventory_behavior": "unknown",
+        "requires_rebalance_before_start": False,
+        "requires_rebalance_before_stop": False,
+        "safe_when_unbalanced": True,
+        "can_run_with": [],
+    })
+
+    return {
+        "id": strategy.get("id"),
+        "strategy_type": strategy_type,
+        "mode": mode,
+        "enabled": mode != "off",
+        "status": strategy.get("status") or ("running" if mode != "off" else "stopped"),
+        "market_symbol": strategy.get("market_symbol"),
+        "account_id": strategy.get("account_id"),
+        "open_order_count": int(state.get("open_order_count") or strategy.get("open_order_count") or 0),
+        "last_action": state.get("last_action") or strategy.get("last_side"),
+        "last_error": state.get("last_error") or "",
+        "contract": contract,
+        "config": config,
+        "state": state,
+    }
+
+
+def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], wallet_state: dict[str, Any]) -> dict[str, Any]:
+    stance = str(narrative.get("stance") or "neutral_rotational")
+    opportunity_map = narrative.get("opportunity_map") if isinstance(narrative.get("opportunity_map"), dict) else {}
+    continuation = float(opportunity_map.get("continuation") or 0.0)
+    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")
+
+    strategy_type = strategy["strategy_type"]
+    score = 0.0
+    reasons: list[str] = []
+    blocks: list[str] = []
+
+    if strategy_type == "grid_trader":
+        score += mean_reversion * 1.8
+        if stance in {"neutral_rotational", "breakout_watch"}:
+            score += 0.45
+            reasons.append("narrative still supports rotational structure")
+        if continuation >= 0.45:
+            score -= 0.8
+            blocks.append("continuation pressure is too strong for safe grid harvesting")
+        if inventory_state != "balanced":
+            score -= 1.0
+            blocks.append(f"wallet is not grid-ready: {inventory_state}")
+        else:
+            reasons.append("wallet is balanced enough for two-sided harvesting")
+    elif strategy_type == "trend_follower":
+        score += continuation * 1.9
+        if stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
+            score += 0.5
+            reasons.append("narrative supports directional continuation")
+        if wait >= 0.45:
+            score -= 0.35
+            blocks.append("market still has too much wait/uncertainty for trend commitment")
+        if inventory_state in {"depleted_quote_side", "critically_unbalanced"}:
+            score -= 0.25
+            blocks.append("wallet may be too skewed for clean directional scaling")
+    elif strategy_type == "exposure_protector":
+        score += reversal * 0.4 + wait * 0.5
+        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"}:
+            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:
+            score -= 0.2
+
+    if strategy.get("last_error"):
+        score -= 0.25
+        blocks.append("strategy recently reported an error")
+
+    return {
+        "strategy_id": strategy.get("id"),
+        "strategy_type": strategy_type,
+        "score": round(score, 4),
+        "reasons": reasons,
+        "blocks": blocks,
+        "enabled": strategy.get("enabled", False),
+    }
+
+
+def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any], wallet_state: dict[str, Any], strategies: list[dict[str, Any]]) -> DecisionSnapshot:
+    normalized = [normalize_strategy_snapshot(s) for s in strategies if str(s.get("account_id") or "") == str(concern.get("account_id") or "")]
+    fit_reports = [score_strategy_fit(strategy=s, narrative=narrative_payload, wallet_state=wallet_state) for s in normalized]
+    ranked = sorted(fit_reports, key=lambda item: item["score"], reverse=True)
+    current_primary = next((s for s in normalized if s["enabled"] and s["strategy_type"] in {"grid_trader", "trend_follower"}), None)
+    protector = next((s for s in normalized if s["strategy_type"] == "exposure_protector"), None)
+    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")
+
+    action = "hold"
+    mode = "observe"
+    target_strategy = current_primary.get("id") if current_primary else (best.get("strategy_id") if best else None)
+    reasons: list[str] = []
+    blocks: list[str] = []
+
+    if current_primary and current_primary["strategy_type"] == "grid_trader":
+        if inventory_state != "balanced" or stance not in {"neutral_rotational", "breakout_watch"}:
+            reasons.append("grid no longer matches market posture or wallet balance")
+            if wallet_state.get("rebalance_needed") and protector:
+                action = "replace_with_exposure_protector"
+                target_strategy = protector["id"]
+                mode = "act"
+            else:
+                trend = next((r for r in ranked if r["strategy_type"] == "trend_follower"), None)
+                if trend and trend["score"] > 0.45:
+                    action = "replace_with_trend_follower"
+                    target_strategy = trend["strategy_id"]
+                    mode = "act"
+                else:
+                    action = "suspend_grid"
+                    target_strategy = current_primary["id"]
+                    mode = "warn"
+        else:
+            action = "keep_grid"
+            mode = "observe"
+            reasons.append("grid still matches a balanced rotational regime")
+    elif current_primary and current_primary["strategy_type"] == "trend_follower":
+        if stance == "neutral_rotational" and wallet_state.get("grid_ready"):
+            grid = next((r for r in ranked if r["strategy_type"] == "grid_trader"), None)
+            if grid and grid["score"] >= 0.5:
+                action = "replace_with_grid"
+                target_strategy = grid["strategy_id"]
+                mode = "act"
+                reasons.append("trend conditions have cooled and wallet is grid-ready again")
+            else:
+                action = "hold_trend"
+                mode = "observe"
+                blocks.append("grid candidate not strong enough yet")
+        elif wallet_state.get("rebalance_needed") and protector:
+            action = "attach_exposure_protector"
+            target_strategy = protector["id"]
+            mode = "warn"
+            reasons.append("trend can continue, but wallet drift now needs protection")
+        else:
+            action = "keep_trend"
+            mode = "observe"
+            reasons.append("trend strategy still fits the directional narrative")
+    else:
+        if best and best["score"] >= 0.55:
+            action = f"enable_{best['strategy_type']}"
+            target_strategy = best["strategy_id"]
+            mode = "act"
+            reasons.extend(best["reasons"])
+        else:
+            action = "wait"
+            mode = "observe"
+            blocks.append("no strategy is yet a strong enough fit")
+
+    reason_summary = reasons[0] if reasons else (blocks[0] if blocks else "strategy posture unchanged")
+    confidence = float(narrative_payload.get("confidence") or 0.4)
+    if action.startswith("replace_with") or action.startswith("enable_"):
+        confidence += 0.08
+    if wallet_state.get("rebalance_needed") and "grid" in action:
+        confidence -= 0.08
+    confidence = round(_clamp(confidence, 0.2, 0.95), 3)
+
+    payload = {
+        "generated_at": datetime.now(timezone.utc).isoformat(),
+        "wallet_state": wallet_state,
+        "narrative_stance": stance,
+        "strategy_fit_ranking": ranked,
+        "current_primary_strategy": current_primary.get("id") if current_primary else None,
+        "reason_chain": reasons,
+        "blocks": blocks,
+        "decision_version": 1,
+    }
+
+    return DecisionSnapshot(
+        mode=mode,
+        action=action,
+        target_strategy=target_strategy,
+        reason_summary=reason_summary,
+        confidence=confidence,
+        requires_action=mode == "act",
+        payload=payload,
+    )

+ 42 - 2
src/hermes_mcp/server.py

@@ -16,9 +16,10 @@ from mcp.client.sse import sse_client
 
 from .config import load_config
 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_narratives, latest_regime_samples, prune_older_than, recent_regime_samples, sync_concerns_from_strategies, upsert_cycle, upsert_narrative, upsert_regime_sample, upsert_state, latest_states
+from .store import get_state, init_db, list_concerns, latest_cycle, latest_cycles, latest_decisions, latest_narratives, latest_regime_samples, prune_older_than, recent_regime_samples, sync_concerns_from_strategies, upsert_cycle, upsert_decision, upsert_narrative, upsert_regime_sample, upsert_state, latest_states
 from .trader_client import list_strategies
 
 mcp = FastMCP(
@@ -57,11 +58,23 @@ async def lifespan(_: FastAPI):
             started = datetime.now(timezone.utc).isoformat()
             cycle_id = str(uuid4())
             concerns = list_concerns()
+            try:
+                strategy_inventory = list_strategies(cfg.trader_url)
+            except Exception:
+                strategy_inventory = []
             upsert_cycle(id=cycle_id, started_at=started, finished_at=None, status="running", trigger="interval", notes=f"polling {len(concerns)} concerns")
             for concern in concerns:
                 symbol = concern.get("base_currency") or concern.get("market_symbol")
                 if not symbol:
                     continue
+                account_id = str(concern.get("account_id") or "").strip()
+                account_info = {}
+                if account_id:
+                    try:
+                        payload = await _call_exec_tool(cfg.exec_url, "get_account_info", {"account_id": account_id})
+                        account_info = payload if isinstance(payload, dict) else {}
+                    except Exception:
+                        account_info = {}
                 current_regimes: list[dict] = []
                 for timeframe in cfg.crypto_timeframes:
                     regime = await get_regime(cfg.crypto_url, str(symbol), timeframe)
@@ -75,7 +88,7 @@ async def lifespan(_: FastAPI):
                         captured_at=datetime.now(timezone.utc).isoformat(),
                     )
                 try:
-                    state = synthesize_state(concern=concern, regimes=current_regimes, account_info={})
+                    state = synthesize_state(concern=concern, regimes=current_regimes, account_info=account_info)
                     upsert_state(
                         id=f"{cycle_id}:{concern['id']}",
                         cycle_id=cycle_id,
@@ -102,6 +115,32 @@ async def lifespan(_: FastAPI):
                         confidence=narrative.confidence,
                         created_at=narrative.payload.get("generated_at"),
                     )
+                    latest_price = None
+                    if current_regimes:
+                        latest_price = next((r.get("price") for r in reversed(current_regimes) if r.get("price") is not None), None)
+                    wallet_state = assess_wallet_state(account_info=account_info, concern=concern, price=float(latest_price) if latest_price is not None else None)
+                    decision = make_decision(
+                        concern=concern,
+                        narrative_payload={
+                            **narrative.payload,
+                            "confidence": narrative.confidence,
+                        },
+                        wallet_state=wallet_state,
+                        strategies=strategy_inventory,
+                    )
+                    upsert_decision(
+                        id=f"{cycle_id}:{concern['id']}",
+                        cycle_id=cycle_id,
+                        concern_id=str(concern["id"]),
+                        mode=decision.mode,
+                        action=decision.action,
+                        target_strategy=decision.target_strategy,
+                        target_policy_json=json.dumps(decision.payload, ensure_ascii=False),
+                        reason_summary=decision.reason_summary,
+                        confidence=decision.confidence,
+                        requires_action=decision.requires_action,
+                        created_at=decision.payload.get("generated_at"),
+                    )
                 except Exception:
                     pass
             upsert_cycle(id=cycle_id, started_at=started, finished_at=datetime.now(timezone.utc).isoformat(), status="ok", trigger="interval", notes=f"polled {len(concerns)} concerns over {','.join(cfg.crypto_timeframes)}")
@@ -272,4 +311,5 @@ def dashboard_data() -> JSONResponse:
         "regime_histories": histories_by_key,
         "state_samples": latest_states(20),
         "narrative_samples": latest_narratives(20),
+        "decision_samples": latest_decisions(20),
     })

+ 31 - 0
src/hermes_mcp/store.py

@@ -389,6 +389,30 @@ def upsert_narrative(*, id: str, cycle_id: str, concern_id: str, summary: str, k
         )
 
 
+def upsert_decision(*, id: str, cycle_id: str, concern_id: str, mode: str, action: str, target_strategy: str | None, target_policy_json: str | None, reason_summary: str | None, confidence: float | None, requires_action: bool, created_at: str | None = None) -> None:
+    init_db()
+    created_at = created_at or _now()
+    with _connect() as conn:
+        conn.execute(
+            """
+            insert into decisions(id, cycle_id, concern_id, mode, action, target_strategy, target_policy_json, reason_summary, confidence, requires_action, created_at)
+            values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            on conflict(id) do update set
+              cycle_id=excluded.cycle_id,
+              concern_id=excluded.concern_id,
+              mode=excluded.mode,
+              action=excluded.action,
+              target_strategy=excluded.target_strategy,
+              target_policy_json=excluded.target_policy_json,
+              reason_summary=excluded.reason_summary,
+              confidence=excluded.confidence,
+              requires_action=excluded.requires_action,
+              created_at=excluded.created_at
+            """,
+            (id, cycle_id, concern_id, mode, action, target_strategy, target_policy_json, reason_summary, confidence, 1 if requires_action else 0, created_at),
+        )
+
+
 def latest_states(limit: int = 20) -> list[dict[str, Any]]:
     init_db()
     with _connect() as conn:
@@ -396,6 +420,13 @@ def latest_states(limit: int = 20) -> list[dict[str, Any]]:
     return [dict(r) for r in rows]
 
 
+def latest_decisions(limit: int = 20) -> list[dict[str, Any]]:
+    init_db()
+    with _connect() as conn:
+        rows = conn.execute("select * from decisions order by created_at desc limit ?", (limit,)).fetchall()
+    return [dict(r) for r in rows]
+
+
 def latest_narratives(limit: int = 20) -> list[dict[str, Any]]:
     init_db()
     with _connect() as conn:

+ 56 - 0
tests/test_decision_engine.py

@@ -0,0 +1,56 @@
+from hermes_mcp.decision_engine import assess_wallet_state, make_decision, normalize_strategy_snapshot, score_strategy_fit
+
+
+def test_assess_wallet_state_detects_depleted_base_side():
+    concern = {"account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    account_info = {
+        "balances": [
+            {"asset_code": "XRP", "available": 1},
+            {"asset_code": "USD", "available": 1000},
+        ]
+    }
+    wallet = assess_wallet_state(account_info=account_info, concern=concern, price=2.0)
+    assert wallet["inventory_state"] == "depleted_base_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",
+        "strategy_type": "grid_trader",
+        "mode": "active",
+        "account_id": "a1",
+        "state": {},
+        "config": {},
+    })
+    narrative = {"stance": "constructive_bullish", "opportunity_map": {"continuation": 0.7, "mean_reversion": 0.1, "reversal": 0.1, "wait": 0.1}}
+    wallet_state = {"inventory_state": "depleted_base_side", "rebalance_needed": True}
+    fit = score_strategy_fit(strategy=strategy, narrative=narrative, wallet_state=wallet_state)
+    assert fit["score"] < 0
+    assert any("grid" in block or "wallet" in block for block in fit["blocks"])
+
+
+def test_make_decision_replaces_grid_when_directional_and_unbalanced():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.72,
+        "opportunity_map": {"continuation": 0.75, "mean_reversion": 0.1, "reversal": 0.05, "wait": 0.1},
+    }
+    wallet_state = {
+        "inventory_state": "base_heavy",
+        "rebalance_needed": True,
+        "grid_ready": False,
+        "base_ratio": 0.8,
+        "quote_ratio": 0.2,
+    }
+    strategies = [
+        {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
+        {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
+        {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
+    ]
+    decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
+    assert decision.mode == "act"
+    assert decision.action == "replace_with_exposure_protector"
+    assert decision.target_strategy == "protect-1"