Browse Source

Refine Hermes grid-first decision flow

Lukas Goldschmidt 3 weeks ago
parent
commit
620462c4f4
2 changed files with 371 additions and 136 deletions
  1. 244 120
      src/hermes_mcp/decision_engine.py
  2. 127 16
      tests/test_decision_engine.py

+ 244 - 120
src/hermes_mcp/decision_engine.py

@@ -446,6 +446,222 @@ def _grid_fill_proximity(strategy: dict[str, Any], narrative_payload: dict[str,
     }
 
 
+def _grid_trend_pressure(strategy: dict[str, Any], narrative_payload: dict[str, Any]) -> dict[str, Any]:
+    state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
+    config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
+    features = narrative_payload.get("features_by_timeframe") if isinstance(narrative_payload.get("features_by_timeframe"), dict) else {}
+    micro_raw = features.get("1m", {}).get("raw", {}) if isinstance(features.get("1m"), dict) else {}
+
+    current_price = _safe_float(micro_raw.get("price") or state.get("last_price") or state.get("center_price"))
+    center_price = _safe_float(state.get("center_price") or state.get("last_price"))
+    step_pct = _safe_float(config.get("grid_step_pct") or state.get("grid_step_pct") or state.get("recenter_pct_live")) or 0.0
+    if not current_price or not center_price or current_price <= 0 or center_price <= 0 or step_pct <= 0:
+        return {"levels": 0.0, "rounded_levels": 0, "direction": "unknown", "current_price": current_price, "center_price": center_price, "step_pct": step_pct}
+
+    distance_pct = abs(current_price - center_price) / center_price
+    levels = distance_pct / step_pct
+    direction = "bullish" if current_price > center_price else "bearish" if current_price < center_price else "flat"
+    return {
+        "levels": round(levels, 4),
+        "rounded_levels": int(levels),
+        "direction": direction,
+        "current_price": current_price,
+        "center_price": center_price,
+        "step_pct": step_pct,
+        "distance_pct": round(distance_pct, 4),
+    }
+
+
+def _grid_can_still_work(strategy: dict[str, Any], wallet_state: dict[str, Any], grid_fill: dict[str, Any]) -> bool:
+    supervision = strategy.get("supervision") if isinstance(strategy.get("supervision"), dict) else {}
+    side_capacity = supervision.get("side_capacity") if isinstance(supervision.get("side_capacity"), dict) else {}
+    buy_capacity = bool(side_capacity.get("buy", False))
+    sell_capacity = bool(side_capacity.get("sell", False))
+    open_order_count = int(strategy.get("open_order_count") or 0)
+    degraded = bool(supervision.get("degraded"))
+    inventory_state = str(wallet_state.get("inventory_state") or "unknown")
+
+    if degraded:
+        return False
+    if buy_capacity or sell_capacity:
+        return True
+    if open_order_count > 0:
+        return True
+    if grid_fill.get("near_fill"):
+        return True
+    return inventory_state not in {"depleted_base_side", "depleted_quote_side"}
+
+
+def _grid_is_truly_stuck_for_recovery(strategy: dict[str, Any], wallet_state: dict[str, Any], grid_fill: dict[str, Any]) -> bool:
+    if _grid_can_still_work(strategy, wallet_state, grid_fill):
+        return False
+    inventory_state = str(wallet_state.get("inventory_state") or "unknown")
+    return wallet_state.get("rebalance_needed") and inventory_state in {"depleted_base_side", "depleted_quote_side", "critically_unbalanced"}
+
+
+def _decide_for_grid(*,
+    current_primary: dict[str, Any],
+    stance: str,
+    inventory_state: str,
+    wallet_state: dict[str, Any],
+    breakout: dict[str, Any],
+    grid_fill: dict[str, Any],
+    grid_pressure: dict[str, Any],
+    directional_micro_clear: bool,
+    severe_imbalance: bool,
+    trend: dict[str, Any] | None,
+    rebalance: dict[str, Any] | None,
+) -> tuple[str, str, str | None, list[str], list[str]]:
+    action = "keep_grid"
+    mode = "observe"
+    target_strategy = current_primary["id"]
+    reasons: list[str] = []
+    blocks: list[str] = []
+
+    # 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.
+    grid_friendly_stance = stance in {"neutral_rotational", "breakout_watch", "cautious_bullish", "cautious_bearish", "fragile_bullish", "fragile_bearish"}
+    grid_can_work = _grid_can_still_work(current_primary, wallet_state, grid_fill)
+    grid_stuck_for_recovery = _grid_is_truly_stuck_for_recovery(current_primary, wallet_state, grid_fill)
+    persistent_breakout = bool(breakout["persistent"])
+
+    if severe_imbalance and persistent_breakout:
+        reasons.append("grid imbalance now coincides with persistent breakout pressure")
+        directional_inventory = _inventory_breakout_is_directionally_compatible(inventory_state, breakout)
+        if trend and trend["score"] > 0.45 and directional_micro_clear and grid_pressure.get("levels", 0.0) >= 2.75 and (
+            not wallet_state.get("rebalance_needed")
+            or directional_inventory
+            or not rebalance
+            or trend["score"] >= rebalance["score"]
+        ):
+            action = "replace_with_trend_follower"
+            target_strategy = trend["strategy_id"]
+            mode = "act"
+            if directional_inventory:
+                reasons.append("inventory posture can be absorbed by the directional handoff")
+        elif wallet_state.get("rebalance_needed") and rebalance and rebalance["score"] > 0.35:
+            action = "replace_with_exposure_protector"
+            target_strategy = rebalance["strategy_id"]
+            mode = "act"
+        else:
+            action = "suspend_grid"
+            mode = "warn"
+    elif severe_imbalance and grid_stuck_for_recovery and not persistent_breakout and rebalance and rebalance["score"] > 0.6:
+        action = "replace_with_exposure_protector"
+        target_strategy = rebalance["strategy_id"]
+        mode = "act"
+        reasons.append("grid has lost practical recovery capacity, so inventory repair should take over")
+    elif not persistent_breakout and grid_can_work:
+        reasons.append("grid can still operate and self-heal, so inventory skew alone should not force a rebalance handoff")
+    elif persistent_breakout and grid_fill.get("near_fill") and inventory_state in {"balanced", "base_heavy", "quote_heavy"}:
+        reasons.append("grid is still close to a working fill, so immediate handoff would be premature")
+    elif not grid_friendly_stance and persistent_breakout:
+        reasons.append("grid should yield because directional pressure is persistent across scopes")
+        if trend and trend["score"] > 0.45 and directional_micro_clear and grid_pressure.get("levels", 0.0) >= 2.75:
+            action = "replace_with_trend_follower"
+            target_strategy = trend["strategy_id"]
+            mode = "act"
+        else:
+            mode = "warn"
+            if grid_pressure.get("levels", 0.0) < 2.75:
+                blocks.append("grid has not yet been eaten by enough levels to justify leaving it")
+            else:
+                blocks.append("directional pressure is rising but the micro layer is not clear enough for a trend handoff")
+    else:
+        reasons.append("grid can likely self-heal because breakout pressure is not yet persistent")
+
+    return action, mode, target_strategy, reasons, blocks
+
+
+def _decide_for_trend(*,
+    current_primary: dict[str, Any],
+    stance: str,
+    narrative_payload: dict[str, Any],
+    wallet_state: dict[str, Any],
+    grid: dict[str, Any] | None,
+) -> tuple[str, str, str | None, list[str], list[str]]:
+    action = "keep_trend"
+    mode = "observe"
+    target_strategy = current_primary["id"]
+    reasons: list[str] = []
+    blocks: list[str] = []
+
+    # Trend is allowed to cool back into grid, but it should not bounce into
+    # the rebalancer as an intermediate hop.
+    if _trend_cooling_edge(narrative_payload, wallet_state):
+        if grid and wallet_state.get("grid_ready"):
+            action = "replace_with_grid"
+            target_strategy = grid["strategy_id"]
+            mode = "act"
+            reasons.append("trend has cooled and grid can resume instead of ping-ponging into rebalancing")
+        else:
+            mode = "warn"
+            blocks.append("edge cooling is visible but the wallet should not bounce straight into rebalancing")
+    elif stance == "neutral_rotational" and wallet_state.get("grid_ready"):
+        if grid and grid["score"] >= 0.5:
+            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"
+            blocks.append("grid candidate not strong enough yet")
+    elif stance == "neutral_rotational" and wallet_state.get("rebalance_needed"):
+        if grid and wallet_state.get("grid_ready"):
+            action = "replace_with_grid"
+            target_strategy = grid["strategy_id"]
+            mode = "act"
+            reasons.append("trend has cooled and grid should recover the wallet instead of bouncing through rebalancing")
+        else:
+            mode = "warn"
+            blocks.append("trend has cooled but rebalancing should not be the immediate next hop")
+    else:
+        reasons.append("trend strategy still fits the directional narrative")
+
+    return action, mode, target_strategy, reasons, blocks
+
+
+def _decide_for_rebalancer(*,
+    current_primary: dict[str, Any],
+    stance: str,
+    wallet_state: dict[str, Any],
+    grid: dict[str, Any] | None,
+) -> tuple[str, str, str | None, list[str], list[str]]:
+    action = "keep_rebalancer"
+    mode = "observe"
+    target_strategy = current_primary["id"]
+    reasons: list[str] = []
+    blocks: list[str] = []
+
+    # Rebalancing is a repair phase. Once the wallet is usable again, Hermes
+    # should prefer handing back to grid, not directly to trend.
+    if str(wallet_state.get("inventory_state") or "").lower() == "balanced":
+        if grid:
+            action = "replace_with_grid"
+            target_strategy = grid["strategy_id"]
+            mode = "act"
+            reasons.append("wallet is balanced, so grid should run until a strong trend is detected")
+        else:
+            blocks.append("wallet is balanced but no grid candidate is available")
+    elif wallet_state.get("grid_ready") and stance == "neutral_rotational":
+        if grid and grid["score"] >= 0.5:
+            action = "replace_with_grid"
+            target_strategy = grid["strategy_id"]
+            mode = "act"
+            reasons.append("rebalance is complete and rotational conditions support grid again")
+        else:
+            blocks.append("wallet is ready but grid fit is still too weak")
+    elif grid and grid["score"] >= 0.5:
+        action = "replace_with_grid"
+        target_strategy = grid["strategy_id"]
+        mode = "act"
+        reasons.append("trend is directional but not yet sustained, so grid can resume first")
+    else:
+        blocks.append("trend candidate is not strong enough yet and grid fit is not ready, so rebalancer should not hand directly back to trend")
+
+    return action, mode, target_strategy, reasons, blocks
+
+
 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]
@@ -466,6 +682,8 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
     stance_is_bearish = "bearish" in stance
     directional_micro_clear = bullish_micro_clear if stance_is_bullish else bearish_micro_clear if stance_is_bearish else False
     grid_fill = _grid_fill_proximity(current_primary, narrative_payload) if current_primary and current_primary["strategy_type"] == "grid_trader" else {"near_fill": False}
+    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"}
 
     action = "hold"
     mode = "observe"
@@ -477,128 +695,34 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
     grid = next((r for r in ranked if r["strategy_type"] == "grid_trader"), None)
 
     if current_primary and current_primary["strategy_type"] == "grid_trader":
-        severe_imbalance = inventory_state in {"depleted_base_side", "depleted_quote_side", "critically_unbalanced"}
-        grid_friendly_stance = stance in {"neutral_rotational", "breakout_watch", "cautious_bullish", "cautious_bearish", "fragile_bullish", "fragile_bearish"}
-        if severe_imbalance and breakout["persistent"]:
-            reasons.append("grid imbalance now coincides with persistent breakout pressure")
-            directional_inventory = _inventory_breakout_is_directionally_compatible(inventory_state, breakout)
-            if trend and trend["score"] > 0.45 and directional_micro_clear and (
-                not wallet_state.get("rebalance_needed")
-                or directional_inventory
-                or not rebalance
-                or trend["score"] >= rebalance["score"]
-            ):
-                action = "replace_with_trend_follower"
-                target_strategy = trend["strategy_id"]
-                mode = "act"
-                if directional_inventory:
-                    reasons.append("inventory posture can be absorbed by the directional handoff")
-            elif wallet_state.get("rebalance_needed") and rebalance and rebalance["score"] > 0.35:
-                action = "replace_with_exposure_protector"
-                target_strategy = rebalance["strategy_id"]
-                mode = "act"
-            else:
-                action = "suspend_grid"
-                target_strategy = current_primary["id"]
-                mode = "warn"
-        elif wallet_state.get("rebalance_needed") and rebalance and rebalance["score"] > 0.6 and not grid_fill.get("near_fill") and severe_imbalance:
-            action = "replace_with_exposure_protector"
-            target_strategy = rebalance["strategy_id"]
-            mode = "act"
-            reasons.append("grid is no longer safe to nurse because inventory repair now matters more than waiting for self-heal")
-        elif breakout["persistent"] and grid_fill.get("near_fill") and inventory_state in {"balanced", "base_heavy", "quote_heavy"}:
-            action = "keep_grid"
-            target_strategy = current_primary["id"]
-            mode = "observe"
-            reasons.append("grid is still close to a working fill, so immediate handoff would be premature")
-        elif not grid_friendly_stance and breakout["persistent"]:
-            reasons.append("grid should yield because directional pressure is persistent across scopes")
-            trend = next((r for r in ranked if r["strategy_type"] == "trend_follower"), None)
-            if trend and trend["score"] > 0.45 and directional_micro_clear:
-                action = "replace_with_trend_follower"
-                target_strategy = trend["strategy_id"]
-                mode = "act"
-            else:
-                action = "keep_grid"
-                target_strategy = current_primary["id"]
-                mode = "warn"
-                blocks.append("directional pressure is rising but the micro layer is not clear enough for a trend handoff")
-        else:
-            action = "keep_grid"
-            mode = "observe"
-            reasons.append("grid can likely self-heal because breakout pressure is not yet persistent")
+        action, mode, target_strategy, reasons, blocks = _decide_for_grid(
+            current_primary=current_primary,
+            stance=stance,
+            inventory_state=inventory_state,
+            wallet_state=wallet_state,
+            breakout=breakout,
+            grid_fill=grid_fill,
+            grid_pressure=grid_pressure,
+            directional_micro_clear=directional_micro_clear,
+            severe_imbalance=severe_imbalance,
+            trend=trend,
+            rebalance=rebalance,
+        )
     elif current_primary and current_primary["strategy_type"] == "trend_follower":
-        if _trend_cooling_edge(narrative_payload, wallet_state):
-            if rebalance and rebalance["score"] > 0.35:
-                action = "replace_with_exposure_protector"
-                target_strategy = rebalance["strategy_id"]
-                mode = "act"
-                reasons.append("micro trend has cooled at the edge while inventory is still skewed, so repair should start before the move fully mean-reverts")
-            else:
-                action = "keep_trend"
-                mode = "warn"
-                blocks.append("edge cooling is visible but no rebalancer is ready")
-        elif stance == "neutral_rotational" and wallet_state.get("grid_ready"):
-            if grid and grid["score"] >= 0.5:
-                action = "replace_with_grid"
-                target_strategy = grid["strategy_id"]
-                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 stance == "neutral_rotational" and wallet_state.get("rebalance_needed"):
-            if rebalance and rebalance["score"] > 0.35:
-                action = "replace_with_exposure_protector"
-                target_strategy = rebalance["strategy_id"]
-                mode = "act"
-                reasons.append("trend has cooled and inventory should be normalized before grid resumes")
-            else:
-                action = "keep_trend"
-                mode = "warn"
-                blocks.append("trend has cooled but no rebalancer is ready")
-        else:
-            action = "keep_trend"
-            mode = "observe"
-            reasons.append("trend strategy still fits the directional narrative")
+        action, mode, target_strategy, reasons, blocks = _decide_for_trend(
+            current_primary=current_primary,
+            stance=stance,
+            narrative_payload=narrative_payload,
+            wallet_state=wallet_state,
+            grid=grid,
+        )
     elif current_primary and current_primary["strategy_type"] == "exposure_protector":
-        trend = next((r for r in ranked if r["strategy_type"] == "trend_follower"), None)
-        trend_is_sustained = bool(trend and trend["score"] > 0.85 and breakout["persistent"])
-        if trend_is_sustained and stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
-            action = "replace_with_trend_follower"
-            target_strategy = trend["strategy_id"]
-            mode = "act"
-            reasons.append("trend is sustained strongly enough that the rebalancer should hand back to trend immediately")
-        elif str(wallet_state.get("inventory_state") or "").lower() == "balanced":
-            if grid:
-                action = "replace_with_grid"
-                target_strategy = grid["strategy_id"]
-                mode = "act"
-                reasons.append("wallet is balanced, so grid should run until a strong trend is detected")
-            else:
-                action = "keep_rebalancer"
-                mode = "observe"
-                blocks.append("wallet is balanced but no grid candidate is available")
-        elif wallet_state.get("grid_ready") and stance == "neutral_rotational":
-            if grid and grid["score"] >= 0.5:
-                action = "replace_with_grid"
-                target_strategy = grid["strategy_id"]
-                mode = "act"
-                reasons.append("rebalance is complete and rotational conditions support grid again")
-            else:
-                action = "keep_rebalancer"
-                mode = "observe"
-                blocks.append("wallet is ready but grid fit is still too weak")
-        elif grid and grid["score"] >= 0.5:
-            action = "replace_with_grid"
-            target_strategy = grid["strategy_id"]
-            mode = "act"
-            reasons.append("trend is directional but not yet sustained, so grid can resume first")
-        else:
-            action = "keep_rebalancer"
-            mode = "observe"
-            blocks.append("trend candidate is not strong enough yet and grid fit is not ready")
+        action, mode, target_strategy, reasons, blocks = _decide_for_rebalancer(
+            current_primary=current_primary,
+            stance=stance,
+            wallet_state=wallet_state,
+            grid=grid,
+        )
     else:
         if best and best["score"] >= 0.55:
             action = f"enable_{best['strategy_type']}"

+ 127 - 16
tests/test_decision_engine.py

@@ -161,6 +161,117 @@ def test_make_decision_replaces_grid_when_breakout_pressure_is_persistent():
     assert decision.target_strategy == "protect-1"
 
 
+def test_make_decision_keeps_grid_when_critically_unbalanced_but_grid_still_has_working_side_capacity():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "cautious_bullish",
+        "confidence": 0.7,
+        "opportunity_map": {"continuation": 0.55, "mean_reversion": 0.2, "reversal": 0.05, "wait": 0.2},
+        "scoped_state": {
+            "micro": {"impulse": "up", "trend_bias": "bullish", "location": "upper_half", "reversal_risk": "low"},
+            "meso": {"structure": "range", "momentum_bias": "bullish"},
+            "macro": {"bias": "bullish"},
+        },
+        "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
+        "features_by_timeframe": {"1m": {"raw": {"price": 1.4374}}},
+    }
+    wallet_state = {
+        "inventory_state": "critically_unbalanced",
+        "rebalance_needed": True,
+        "grid_ready": False,
+        "base_ratio": 0.86,
+        "quote_ratio": 0.14,
+    }
+    strategies = [
+        {
+            "id": "grid-1",
+            "strategy_type": "grid_trader",
+            "mode": "active",
+            "account_id": "a1",
+            "state": {
+                "last_price": 1.4374,
+                "open_order_count": 4,
+                "orders": [
+                    {"side": "sell", "status": "open", "price": "1.43956", "amount": "7"},
+                    {"side": "sell", "status": "open", "price": "1.44500", "amount": "7"},
+                ],
+            },
+            "config": {},
+            "report": {"supervision": {"capacity_available": False, "side_capacity": {"buy": False, "sell": True}, "inventory_pressure": "critical", "degraded": False}},
+        },
+        {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
+        {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
+    ]
+    decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
+    assert decision.mode == "observe"
+    assert decision.action == "keep_grid"
+    assert decision.target_strategy == "grid-1"
+
+
+def test_make_decision_keeps_grid_when_trend_has_only_eaten_two_levels():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.78,
+        "opportunity_map": {"continuation": 0.72, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.15},
+        "scoped_state": {
+            "micro": {"impulse": "up", "trend_bias": "bullish", "location": "upper_half", "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": 110.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-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 == "warn"
+    assert decision.action == "keep_grid"
+    assert decision.target_strategy == "grid-1"
+
+
+def test_make_decision_replaces_grid_when_third_level_is_sustained():
+    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-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_trend_follower"
+    assert decision.target_strategy == "trend-1"
+
+
 def test_make_decision_replaces_grid_with_trend_when_breakout_is_persistent_but_inventory_is_only_base_heavy():
     concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     narrative = {
@@ -187,8 +298,8 @@ def test_make_decision_replaces_grid_with_trend_when_breakout_is_persistent_but_
         {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.action == "replace_with_trend_follower"
-    assert decision.target_strategy == "trend-1"
+    assert decision.action == "keep_grid"
+    assert decision.target_strategy == "grid-1"
 
 
 def test_make_decision_prefers_active_grid_over_observe_trend_as_current_primary():
@@ -241,8 +352,8 @@ def test_make_decision_prefers_trend_over_rebalancer_on_bullish_breakout_with_de
         {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.action == "replace_with_trend_follower"
-    assert decision.target_strategy == "trend-1"
+    assert decision.action == "replace_with_exposure_protector"
+    assert decision.target_strategy == "protect-1"
 
 
 def test_make_decision_keeps_grid_when_next_sell_is_close_despite_persistent_breakout():
@@ -357,9 +468,9 @@ def test_make_decision_replaces_trend_with_rebalancer_after_trend_cools_and_wall
         {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "act"
-    assert decision.action == "replace_with_exposure_protector"
-    assert decision.target_strategy == "protect-1"
+    assert decision.mode == "warn"
+    assert decision.action == "keep_trend"
+    assert decision.target_strategy == "trend-1"
 
 
 def test_make_decision_replaces_trend_with_rebalancer_on_edge_cooling_even_before_full_rotational_stance():
@@ -387,9 +498,9 @@ def test_make_decision_replaces_trend_with_rebalancer_on_edge_cooling_even_befor
         {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "act"
-    assert decision.action == "replace_with_exposure_protector"
-    assert decision.target_strategy == "protect-1"
+    assert decision.mode == "warn"
+    assert decision.action == "keep_trend"
+    assert decision.target_strategy == "trend-1"
 
 
 def test_make_decision_replaces_trend_with_rebalancer_when_micro_reversal_risk_spikes():
@@ -417,9 +528,9 @@ def test_make_decision_replaces_trend_with_rebalancer_when_micro_reversal_risk_s
         {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "act"
-    assert decision.action == "replace_with_exposure_protector"
-    assert decision.target_strategy == "protect-1"
+    assert decision.mode == "warn"
+    assert decision.action == "keep_trend"
+    assert decision.target_strategy == "trend-1"
 
 
 def test_make_decision_replaces_rebalancer_with_grid_when_balanced_and_rotational():
@@ -503,6 +614,6 @@ def test_make_decision_replaces_rebalancer_with_trend_when_breakout_is_still_str
         {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": True, "trend_strength": 0.92, "inventory_pressure": "balanced", "degraded": False}}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "act"
-    assert decision.action == "replace_with_trend_follower"
-    assert decision.target_strategy == "trend-1"
+    assert decision.mode == "observe"
+    assert decision.action == "keep_rebalancer"
+    assert decision.target_strategy == "protect-1"