Lukas Goldschmidt 3 hete
szülő
commit
fea9d91d0f

+ 3 - 1
simulation/run_replay.py

@@ -25,6 +25,8 @@ def main() -> int:
     parser.add_argument("--account-id", default="sim-account")
     parser.add_argument("--fee-rate", type=float, default=0.004)
     parser.add_argument("--horizon-bars", type=int, default=30)
+    parser.add_argument("--lookback-bars", type=int, default=2000, help="How many most-recent 1m candles to use for feature/regime computation")
+    parser.add_argument("--progress-every", type=int, default=2000, help="Print progress every N replay rows")
     parser.add_argument("--base-balance", type=float, default=500.0)
     parser.add_argument("--quote-balance", type=float, default=500.0)
     parser.add_argument("--inventory-state", default="balanced")
@@ -45,7 +47,7 @@ def main() -> int:
         inventory_state=args.inventory_state,
         rebalance_needed=args.rebalance_needed,
     )
-    rows = run_replay(candles=candles, config=config)
+    rows = run_replay(candles=candles, config=config, lookback_bars=args.lookback_bars, progress_every=args.progress_every)
     output = rows_to_jsonl(rows)
 
     if args.out:

+ 8 - 3
simulation/src/hermes_sim/harness.py

@@ -5,6 +5,7 @@ from datetime import datetime, timedelta, timezone
 from pathlib import Path
 from typing import Any
 import json
+import sys
 
 from .candles import Candle, load_candles_csv, resample_candles, slice_through, timeframe_seconds
 from .indicators import atr, bollinger, ema, macd_histogram, rsi, sma, vwap
@@ -264,13 +265,17 @@ def _score_row(decision_action: str, future_return_pct: float | None, fee_adjust
     return future_return_pct / 10.0
 
 
-def run_replay(*, candles: list[Candle], config: ReplayConfig) -> list[ReplayRow]:
+def run_replay(*, candles: list[Candle], config: ReplayConfig, lookback_bars: int = 2000, progress_every: int = 2000) -> list[ReplayRow]:
     if len(candles) < 50:
         return []
 
     rows: list[ReplayRow] = []
-    for index in range(50, len(candles) - config.horizon_bars):
-        window = candles[: index + 1]
+    start_index = max(50, lookback_bars)
+    total = max(0, (len(candles) - config.horizon_bars) - start_index)
+    for i, index in enumerate(range(start_index, len(candles) - config.horizon_bars)):
+        if progress_every and (i % progress_every == 0):
+            print(f"replay {i}/{total}", file=sys.stderr)
+        window = candles[max(0, index - lookback_bars + 1) : index + 1]
         current = candles[index]
         regimes = build_regimes(window, config.timeframes)
         concern = {

+ 132 - 7
src/hermes_mcp/decision_engine.py

@@ -55,6 +55,112 @@ def _inventory_state_label(value: Any) -> str:
     return aliases.get(state, state)
 
 
+def _timeframe_direction(feature: dict[str, Any] | None) -> str:
+    if not isinstance(feature, dict):
+        return "mixed"
+    trend = feature.get("trend") if isinstance(feature.get("trend"), dict) else {}
+    momentum = feature.get("momentum") if isinstance(feature.get("momentum"), dict) else {}
+
+    alignment = str(trend.get("alignment") or "")
+    if alignment in {"fully_bullish", "bullish_pullback"}:
+        return "bullish"
+    if alignment in {"fully_bearish", "bearish_pullback"}:
+        return "bearish"
+
+    bias_score = _safe_float(trend.get("bias_score"))
+    if bias_score is not None:
+        if bias_score >= 0.55:
+            return "bullish"
+        if bias_score <= -0.55:
+            return "bearish"
+
+    impulse = str(momentum.get("impulse") or "")
+    if impulse == "up":
+        return "bullish"
+    if impulse == "down":
+        return "bearish"
+
+    return "mixed"
+
+
+def _short_term_trend_dislocated(narrative_payload: dict[str, Any]) -> bool:
+    features = narrative_payload.get("features_by_timeframe") if isinstance(narrative_payload.get("features_by_timeframe"), dict) else {}
+    short_dirs = [_timeframe_direction(features.get(tf)) for tf in ("1m", "5m")]
+    higher_dirs = [_timeframe_direction(features.get(tf)) for tf in ("15m", "1h", "4h", "1d")]
+
+    short_clean = [d for d in short_dirs if d in {"bullish", "bearish"}]
+    higher_clean = [d for d in higher_dirs if d in {"bullish", "bearish"}]
+    if not short_clean:
+        return bool(higher_clean) and len(set(higher_clean)) == 1
+
+    if any(d == "mixed" for d in short_dirs):
+        return bool(higher_clean) and len(set(higher_clean)) == 1
+
+    short_direction = short_clean[0] if len(set(short_clean)) == 1 else "mixed"
+    if short_direction == "mixed":
+        return True
+
+    if not higher_clean:
+        return False
+
+    higher_direction = higher_clean[0] if len(set(higher_clean)) == 1 else "mixed"
+    return higher_direction in {"bullish", "bearish"} and short_direction != higher_direction
+
+
+def _short_term_trend_manifest_score(narrative_payload: dict[str, Any], direction: str) -> float:
+    features = narrative_payload.get("features_by_timeframe") if isinstance(narrative_payload.get("features_by_timeframe"), dict) else {}
+    if direction not in {"bullish", "bearish"}:
+        return 0.0
+
+    total = 0.0
+    seen = 0
+    for timeframe in ("1m", "5m"):
+        feature = features.get(timeframe) if isinstance(features.get(timeframe), dict) else None
+        if not feature:
+            continue
+        seen += 1
+        trend = feature.get("trend") if isinstance(feature.get("trend"), dict) else {}
+        if not trend:
+            local = 0.68
+        else:
+            alignment = str(trend.get("alignment") or "")
+            strength = _safe_float(trend.get("strength")) or 0.0
+            bias_score = abs(_safe_float(trend.get("bias_score")) or 0.0)
+            local = 0.0
+            if direction == "bullish":
+                if alignment == "fully_bullish":
+                    local = 1.0
+                elif alignment == "bullish_pullback":
+                    local = 0.62
+            else:
+                if alignment == "fully_bearish":
+                    local = 1.0
+                elif alignment == "bearish_pullback":
+                    local = 0.62
+
+            if local == 0.0:
+                short_direction = _timeframe_direction(feature)
+                if short_direction == direction:
+                    local = 0.34
+                elif short_direction == "mixed":
+                    local = 0.12
+
+            if strength >= 0.55:
+                local += 0.16
+            elif strength <= 0.2:
+                local -= 0.08
+            if bias_score >= 0.75:
+                local += 0.08
+            elif bias_score <= 0.2:
+                local -= 0.04
+
+        total += _clamp(local, 0.0, 1.0)
+
+    if not seen:
+        return 0.0
+    return round(total / seen, 4)
+
+
 SEVERE_INVENTORY_STATES = {"critically_unbalanced", "depleted_base_side", "depleted_quote_side"}
 REBALANCE_INVENTORY_STATES = {"base_heavy", "quote_heavy", *SEVERE_INVENTORY_STATES}
 
@@ -557,6 +663,7 @@ def _trend_cooling_edge(narrative_payload: dict[str, Any], wallet_state: dict[st
     if not wallet_state.get("rebalance_needed"):
         return False
     scoped = narrative_payload.get("scoped_state") if isinstance(narrative_payload.get("scoped_state"), dict) else {}
+    short_term_dislocated = _short_term_trend_dislocated(narrative_payload)
     micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
     meso = scoped.get("meso") if isinstance(scoped.get("meso"), dict) else {}
 
@@ -568,22 +675,25 @@ def _trend_cooling_edge(narrative_payload: dict[str, Any], wallet_state: dict[st
     meso_structure = str(meso.get("structure") or "rotation")
     inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
     early_reversal_warning = micro_reversal_risk in {"medium", "high"}
+    short_term_warning = short_term_dislocated and meso_structure == "trend_continuation"
+    bullish_inventory_pressure = inventory_state in {"base_heavy", "critically_unbalanced", "depleted_quote_side"}
+    bearish_inventory_pressure = inventory_state in {"quote_heavy", "critically_unbalanced", "depleted_base_side"}
 
     bullish_cooling = (
-        inventory_state in {"base_heavy", "critically_unbalanced"}
+        bullish_inventory_pressure
         and meso_structure == "trend_continuation"
         and meso_bias == "bullish"
-        and (micro_impulse == "mixed" or early_reversal_warning)
+        and (micro_impulse == "mixed" or early_reversal_warning or short_term_warning)
         and micro_bias in {"mixed", "bearish", "bullish"}
-        and micro_location in {"near_upper_band", "upper_half", "centered"}
+        and (short_term_warning or micro_location in {"near_upper_band", "upper_half", "centered"})
     )
     bearish_cooling = (
-        inventory_state in {"quote_heavy", "critically_unbalanced"}
+        bearish_inventory_pressure
         and meso_structure == "trend_continuation"
         and meso_bias == "bearish"
-        and (micro_impulse == "mixed" or early_reversal_warning)
+        and (micro_impulse == "mixed" or early_reversal_warning or short_term_warning)
         and micro_bias in {"mixed", "bullish", "bearish"}
-        and micro_location in {"near_lower_band", "lower_half", "centered"}
+        and (short_term_warning or micro_location in {"near_lower_band", "lower_half", "centered"})
     )
     return bullish_cooling or bearish_cooling
 
@@ -883,6 +993,8 @@ def _extract_decision_signals(*,
         )
     rapid_downside_pressure = bool(rapid_directional_pressure and recent_move_direction == "bearish")
 
+    short_term_trend_score = _short_term_trend_manifest_score(narrative_payload, structural_direction)
+
     harvestability_score = tactical_range_quality * 0.45
     if pullback_to_grid_ratio is not None:
         harvestability_score += min(pullback_to_grid_ratio, 2.0) * 0.22
@@ -915,6 +1027,7 @@ def _extract_decision_signals(*,
         and tactical_strength >= 0.35
         and tactical_direction == structural_direction
         and not tactical_easing
+        and short_term_trend_score >= 0.32
     )
     grid_harvestable_now = bool(
         harvestability_score >= 0.48
@@ -954,6 +1067,7 @@ def _extract_decision_signals(*,
         "rapid_downside_pressure": rapid_downside_pressure,
         "recent_move_pct": round(recent_move_pct, 4),
         "recent_move_window_minutes": recent_move_window_minutes,
+        "short_term_trend_score": short_term_trend_score,
         "grid_harvestable_now": grid_harvestable_now,
         "rebalancer_release_ready": rebalancer_release_ready,
     }
@@ -1006,6 +1120,7 @@ def _grid_switch_tradeoff(*,
     tactical_strength = float(decision_signals.get("tactical_trend_strength") or 0.0)
     harvestability_score = float(decision_signals.get("grid_harvestability_score") or 0.0)
     breakout_score = float(breakout.get("score") or 0.0)
+    short_term_trend_score = float(decision_signals.get("short_term_trend_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)
@@ -1025,6 +1140,10 @@ def _grid_switch_tradeoff(*,
     switch_benefit += tactical_strength * 0.16
     switch_benefit += min(trend_score, 2.0) * 0.04
     switch_benefit += min(breakout_score, 5.0) * 0.04
+    if short_term_trend_score < 0.68:
+        short_term_gap = 0.68 - short_term_trend_score
+        switch_benefit -= short_term_gap * 1.15
+        stay_cost += short_term_gap * 0.42
 
     if adverse_side in {"buy", "sell"} and adverse_count > 0:
         adverse_notional_ratio = adverse_notional / max(base_order_notional, 1.0)
@@ -1059,6 +1178,7 @@ def _grid_switch_tradeoff(*,
         "structural_trend_strength": round(structural_strength, 4),
         "tactical_trend_strength": round(tactical_strength, 4),
         "grid_harvestability_score": round(harvestability_score, 4),
+        "short_term_trend_score": round(short_term_trend_score, 4),
         "breakout_score": round(breakout_score, 4),
         "switch_benefit": round(switch_benefit, 4),
         "stay_cost": round(stay_cost, 4),
@@ -1402,7 +1522,12 @@ def _decide_for_rebalancer(*,
     trend_pressure = bool(decision_signals.get("trend_following_pressure"))
     grid_harvestable_now = bool(decision_signals.get("grid_harvestable_now"))
 
-    if trend_pressure and not release_ready:
+    if wallet_state.get("grid_ready") and grid:
+        action = "replace_with_grid"
+        target_strategy = grid["strategy_id"]
+        mode = "act"
+        reasons.append("wallet is rebalanced, so grid should resume first and let the tape prove itself again")
+    elif trend_pressure and not release_ready:
         blocks.append("trend is still strong enough that rebalancer should keep repairing instead of resetting to grid")
     elif release_ready:
         if grid:

+ 52 - 17
src/hermes_mcp/server.py

@@ -3,6 +3,7 @@ from __future__ import annotations
 from contextlib import asynccontextmanager
 import asyncio
 import json
+import time
 from datetime import datetime, timezone
 from uuid import uuid4
 
@@ -74,7 +75,7 @@ def _build_trader_control_payload(*, decision_id: str, concern: dict, decision:
     return payload
 
 
-async def _maybe_dispatch_trader_action(*, cfg: object, decision_id: str, concern: dict, decision: object) -> dict:
+async def _maybe_dispatch_trader_action(*, cfg: object, decision_id: str, concern: dict, decision: object, trader_available: bool = True, retry_after_seconds: int | None = None) -> dict:
     if not bool(getattr(decision, "requires_action", False)):
         return {"dispatch": "not_required"}
 
@@ -92,6 +93,14 @@ async def _maybe_dispatch_trader_action(*, cfg: object, decision_id: str, concer
             "payload": payload,
         }
 
+    if not trader_available:
+        return {
+            "dispatch": "deferred",
+            "reason": "trader unavailable",
+            "retry_after_seconds": retry_after_seconds,
+            "payload": payload,
+        }
+
     try:
         result = await trader_apply_control_decision(getattr(cfg, "trader_url"), payload)
         return {
@@ -123,6 +132,24 @@ def report() -> dict:
 async def lifespan(_: FastAPI):
     cfg = load_config()
     init_db()
+    trader_gate = {"failures": 0, "down_until": 0.0, "last_error": "", "last_ok": 0.0}
+    cached_strategy_inventory: list[dict] = []
+
+    def _trader_available() -> bool:
+        return time.monotonic() >= float(trader_gate["down_until"] or 0.0)
+
+    def _mark_trader_success() -> None:
+        trader_gate["failures"] = 0
+        trader_gate["down_until"] = 0.0
+        trader_gate["last_error"] = ""
+        trader_gate["last_ok"] = time.monotonic()
+
+    def _mark_trader_failure(error: Exception) -> None:
+        failures = int(trader_gate["failures"] or 0) + 1
+        trader_gate["failures"] = failures
+        trader_gate["last_error"] = str(error)
+        backoff = min(300, max(5, 5 * (2 ** min(failures - 1, 5))))
+        trader_gate["down_until"] = time.monotonic() + backoff
     try:
         sync_concerns_from_strategies(await list_strategies(cfg.trader_url))
     except Exception:
@@ -133,26 +160,32 @@ async def lifespan(_: FastAPI):
         pass
 
     async def _poll_loop() -> None:
+        nonlocal cached_strategy_inventory
         while True:
             started = datetime.now(timezone.utc).isoformat()
             cycle_id = str(uuid4())
             concerns = list_concerns()
-            try:
-                strategy_inventory = await list_strategies(cfg.trader_url)
-                enriched_inventory = []
-                for strategy in strategy_inventory:
-                    instance_id = str(strategy.get("id") or "").strip()
-                    if not instance_id:
-                        enriched_inventory.append(strategy)
-                        continue
-                    try:
-                        detail = await trader_get_strategy(cfg.trader_url, instance_id, include_state=True, include_report=True)
-                        enriched_inventory.append({**strategy, **detail})
-                    except Exception:
-                        enriched_inventory.append(strategy)
-                strategy_inventory = enriched_inventory
-            except Exception:
-                strategy_inventory = []
+            strategy_inventory = cached_strategy_inventory
+            if _trader_available():
+                try:
+                    strategy_inventory = await list_strategies(cfg.trader_url)
+                    enriched_inventory = []
+                    for strategy in strategy_inventory:
+                        instance_id = str(strategy.get("id") or "").strip()
+                        if not instance_id:
+                            enriched_inventory.append(strategy)
+                            continue
+                        try:
+                            detail = await trader_get_strategy(cfg.trader_url, instance_id, include_state=True, include_report=True)
+                            enriched_inventory.append({**strategy, **detail})
+                        except Exception:
+                            enriched_inventory.append(strategy)
+                    strategy_inventory = enriched_inventory
+                    cached_strategy_inventory = strategy_inventory
+                    _mark_trader_success()
+                except Exception as exc:
+                    _mark_trader_failure(exc)
+                    strategy_inventory = cached_strategy_inventory
             upsert_cycle(id=cycle_id, started_at=started, finished_at=None, status="running", trigger="interval", notes=f"polling {len(concerns)} concerns")
             argus_snapshot: dict = {}
             argus_regime: dict = {}
@@ -263,6 +296,8 @@ async def lifespan(_: FastAPI):
                         decision_id=decision_id,
                         concern=concern,
                         decision=decision,
+                        trader_available=_trader_available(),
+                        retry_after_seconds=max(0, int(trader_gate["down_until"] - time.monotonic())) if not _trader_available() else None,
                     )
                     decision_payload = {
                         **decision.payload,

+ 16 - 2
src/hermes_mcp/trader_client.py

@@ -1,14 +1,28 @@
 from __future__ import annotations
 
 from typing import Any
+from datetime import timedelta
 import json
+
 from mcp import ClientSession
 from mcp.client.sse import sse_client
 
 
+def _normalize_url(base_url: str) -> str:
+    url = (base_url or "").strip()
+    if not url:
+        return url
+    if not url.endswith("/mcp/sse"):
+        url = url.rstrip("/") + "/mcp/sse"
+    return url
+
+
 async def _call_tool(base_url: str, tool: str, arguments: dict[str, Any]) -> dict[str, Any]:
-    async with sse_client(base_url) as (read_stream, write_stream):
-        async with ClientSession(read_stream, write_stream) as session:
+    url = _normalize_url(base_url)
+    if not url:
+        return {}
+    async with sse_client(url, timeout=8.0, sse_read_timeout=8.0) as streams:
+        async with ClientSession(*streams, read_timeout_seconds=timedelta(seconds=8)) as session:
             await session.initialize()
             result = await session.call_tool(tool, arguments)
             content = getattr(result, "content", None) or []

+ 182 - 0
tests/test_decision_engine.py

@@ -588,6 +588,82 @@ def test_make_decision_argus_compression_stays_context_only():
     assert decision.payload["argus_decision_context"]["compression_active"] is True
 
 
+def test_make_decision_keeps_grid_when_1m_and_5m_trend_is_only_partial_confirmation():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.91,
+        "opportunity_map": {"continuation": 0.9, "mean_reversion": 0.03, "reversal": 0.02, "wait": 0.05},
+        "features_by_timeframe": {
+            "1m": {
+                "raw": {"price": 1.4334},
+                "trend": {"alignment": "mixed", "strength": 0.12, "bias_score": 0.49},
+                "momentum": {"impulse": "high"},
+            },
+            "5m": {
+                "raw": {"price": 1.4334},
+                "trend": {"alignment": "bullish_pullback", "strength": 0.32, "bias_score": 1.30},
+                "momentum": {"impulse": "medium"},
+            },
+            "15m": {
+                "raw": {"price": 1.4334},
+                "trend": {"alignment": "bullish_pullback", "strength": 0.38, "bias_score": 1.51},
+                "momentum": {"impulse": "medium"},
+            },
+            "1h": {
+                "raw": {"price": 1.4312},
+                "trend": {"alignment": "fully_bullish", "strength": 0.71, "bias_score": 2.83},
+                "momentum": {"impulse": "medium"},
+            },
+            "4h": {
+                "raw": {"price": 1.4308},
+                "trend": {"alignment": "fully_bullish", "strength": 1.0, "bias_score": 4.64},
+                "momentum": {"impulse": "low"},
+            },
+            "1d": {
+                "raw": {"price": 1.4311},
+                "trend": {"alignment": "bearish_pullback", "strength": 0.28, "bias_score": -1.13},
+                "momentum": {"impulse": "medium"},
+            },
+        },
+        "scoped_state": {
+            "micro": {"impulse": "mixed", "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"},
+        "decision_inputs": {
+            "structural_direction": "bullish",
+            "structural_trend_strength": 0.9,
+            "tactical_direction": "bullish",
+            "tactical_trend_strength": 0.55,
+            "tactical_range_quality": 0.0,
+            "tactical_easing": False,
+            "micro_location": "upper_half",
+            "micro_atr_percent": 0.0606,
+            "micro_bollinger_width_pct": 0.2976,
+        },
+    }
+    wallet_state = {
+        "inventory_state": "base_heavy",
+        "rebalance_needed": True,
+        "grid_ready": False,
+        "base_ratio": 0.7481,
+        "quote_ratio": 0.2519,
+    }
+    strategies = [
+        {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {"center_price": 1.4293}, "config": {"grid_step_pct": 0.0125}},
+        {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
+        {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
+    ]
+
+    decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
+
+    assert decision.action == "keep_grid"
+    assert decision.target_strategy == "grid-1"
+    assert decision.payload["grid_switch_tradeoff"]["short_term_trend_score"] < 0.68
+
+
 def test_make_decision_promotes_developing_breakout_from_time_window_memory():
     concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     narrative = {
@@ -934,6 +1010,78 @@ def test_make_decision_replaces_trend_with_rebalancer_on_edge_cooling_even_befor
     assert decision.target_strategy == "protect-1"
 
 
+def test_make_decision_replaces_trend_with_rebalancer_when_1m_and_5m_dislocate_from_higher_trends():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.76,
+        "opportunity_map": {"continuation": 0.6, "mean_reversion": 0.12, "reversal": 0.08, "wait": 0.2},
+        "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": "partial_alignment", "friction": "medium"},
+        "features_by_timeframe": {
+            "1m": {"trend": {"bias_score": 0.82, "alignment": "fully_bullish"}, "momentum": {"impulse": "up"}},
+            "5m": {"trend": {"bias_score": -0.74, "alignment": "fully_bearish"}, "momentum": {"impulse": "down"}},
+            "15m": {"trend": {"bias_score": 0.66, "alignment": "fully_bullish"}, "momentum": {"impulse": "up"}},
+            "1h": {"trend": {"bias_score": 0.7, "alignment": "fully_bullish"}, "momentum": {"impulse": "up"}},
+        },
+    }
+    wallet_state = {
+        "inventory_state": "base_heavy",
+        "rebalance_needed": True,
+        "grid_ready": False,
+        "base_ratio": 0.74,
+        "quote_ratio": 0.26,
+    }
+    strategies = [
+        {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
+        {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
+    ]
+    decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
+    assert decision.mode == "act"
+    assert decision.action == "replace_with_exposure_protector"
+    assert decision.target_strategy == "protect-1"
+
+
+def test_make_decision_replaces_trend_with_rebalancer_when_short_tape_is_mixed_and_inventory_is_base_heavy():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.77,
+        "opportunity_map": {"continuation": 0.61, "mean_reversion": 0.12, "reversal": 0.08, "wait": 0.19},
+        "scoped_state": {
+            "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "lower_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": {"trend": {"bias_score": -0.12, "alignment": "mixed"}, "momentum": {"impulse": "medium"}},
+            "5m": {"trend": {"bias_score": 0.88, "alignment": "bullish_pullback"}, "momentum": {"impulse": "low"}},
+            "15m": {"trend": {"bias_score": 0.95, "alignment": "bullish_pullback"}, "momentum": {"impulse": "low"}},
+            "1h": {"trend": {"bias_score": 2.6, "alignment": "fully_bullish"}, "momentum": {"impulse": "medium"}},
+        },
+    }
+    wallet_state = {
+        "inventory_state": "depleted_quote_side",
+        "rebalance_needed": True,
+        "grid_ready": False,
+        "base_ratio": 0.99,
+        "quote_ratio": 0.01,
+    }
+    strategies = [
+        {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
+        {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
+    ]
+    decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
+    assert decision.mode == "act"
+    assert decision.action == "replace_with_exposure_protector"
+    assert decision.target_strategy == "protect-1"
+
+
 def test_make_decision_replaces_trend_with_rebalancer_when_micro_reversal_risk_spikes():
     concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     narrative = {
@@ -988,6 +1136,40 @@ def test_make_decision_replaces_rebalancer_with_grid_when_balanced_and_rotationa
     assert decision.target_strategy == "grid-1"
 
 
+def test_make_decision_replaces_rebalancer_with_grid_when_wallet_is_rebalanced_even_if_trend_is_still_hot():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.84,
+        "opportunity_map": {"continuation": 0.82, "mean_reversion": 0.08, "reversal": 0.03, "wait": 0.07},
+        "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"},
+    }
+    wallet_state = {
+        "inventory_state": "balanced",
+        "rebalance_needed": False,
+        "grid_ready": True,
+        "base_ratio": 0.61,
+        "quote_ratio": 0.39,
+    }
+    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": {}, "report": {"supervision": {"capacity_available": True, "side_capacity": {"buy": True, "sell": True}, "inventory_pressure": "balanced", "degraded": False}}},
+        {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": True, "trend_strength": 0.94, "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_grid"
+    assert decision.target_strategy == "grid-1"
+    assert "rebalanced" in decision.reason_summary
+
+
 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 = {