Lukas Goldschmidt пре 3 недеља
родитељ
комит
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("--account-id", default="sim-account")
     parser.add_argument("--fee-rate", type=float, default=0.004)
     parser.add_argument("--fee-rate", type=float, default=0.004)
     parser.add_argument("--horizon-bars", type=int, default=30)
     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("--base-balance", type=float, default=500.0)
     parser.add_argument("--quote-balance", type=float, default=500.0)
     parser.add_argument("--quote-balance", type=float, default=500.0)
     parser.add_argument("--inventory-state", default="balanced")
     parser.add_argument("--inventory-state", default="balanced")
@@ -45,7 +47,7 @@ def main() -> int:
         inventory_state=args.inventory_state,
         inventory_state=args.inventory_state,
         rebalance_needed=args.rebalance_needed,
         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)
     output = rows_to_jsonl(rows)
 
 
     if args.out:
     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 pathlib import Path
 from typing import Any
 from typing import Any
 import json
 import json
+import sys
 
 
 from .candles import Candle, load_candles_csv, resample_candles, slice_through, timeframe_seconds
 from .candles import Candle, load_candles_csv, resample_candles, slice_through, timeframe_seconds
 from .indicators import atr, bollinger, ema, macd_histogram, rsi, sma, vwap
 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
     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:
     if len(candles) < 50:
         return []
         return []
 
 
     rows: list[ReplayRow] = []
     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]
         current = candles[index]
         regimes = build_regimes(window, config.timeframes)
         regimes = build_regimes(window, config.timeframes)
         concern = {
         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)
     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"}
 SEVERE_INVENTORY_STATES = {"critically_unbalanced", "depleted_base_side", "depleted_quote_side"}
 REBALANCE_INVENTORY_STATES = {"base_heavy", "quote_heavy", *SEVERE_INVENTORY_STATES}
 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"):
     if not wallet_state.get("rebalance_needed"):
         return False
         return False
     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 {}
+    short_term_dislocated = _short_term_trend_dislocated(narrative_payload)
     micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
     micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
     meso = scoped.get("meso") if isinstance(scoped.get("meso"), 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")
     meso_structure = str(meso.get("structure") or "rotation")
     inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
     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"}
+    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 = (
     bullish_cooling = (
-        inventory_state in {"base_heavy", "critically_unbalanced"}
+        bullish_inventory_pressure
         and meso_structure == "trend_continuation"
         and meso_structure == "trend_continuation"
         and meso_bias == "bullish"
         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_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 = (
     bearish_cooling = (
-        inventory_state in {"quote_heavy", "critically_unbalanced"}
+        bearish_inventory_pressure
         and meso_structure == "trend_continuation"
         and meso_structure == "trend_continuation"
         and meso_bias == "bearish"
         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_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
     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")
     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
     harvestability_score = tactical_range_quality * 0.45
     if pullback_to_grid_ratio is not None:
     if pullback_to_grid_ratio is not None:
         harvestability_score += min(pullback_to_grid_ratio, 2.0) * 0.22
         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_strength >= 0.35
         and tactical_direction == structural_direction
         and tactical_direction == structural_direction
         and not tactical_easing
         and not tactical_easing
+        and short_term_trend_score >= 0.32
     )
     )
     grid_harvestable_now = bool(
     grid_harvestable_now = bool(
         harvestability_score >= 0.48
         harvestability_score >= 0.48
@@ -954,6 +1067,7 @@ def _extract_decision_signals(*,
         "rapid_downside_pressure": rapid_downside_pressure,
         "rapid_downside_pressure": rapid_downside_pressure,
         "recent_move_pct": round(recent_move_pct, 4),
         "recent_move_pct": round(recent_move_pct, 4),
         "recent_move_window_minutes": recent_move_window_minutes,
         "recent_move_window_minutes": recent_move_window_minutes,
+        "short_term_trend_score": short_term_trend_score,
         "grid_harvestable_now": grid_harvestable_now,
         "grid_harvestable_now": grid_harvestable_now,
         "rebalancer_release_ready": rebalancer_release_ready,
         "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)
     tactical_strength = float(decision_signals.get("tactical_trend_strength") or 0.0)
     harvestability_score = float(decision_signals.get("grid_harvestability_score") or 0.0)
     harvestability_score = float(decision_signals.get("grid_harvestability_score") or 0.0)
     breakout_score = float(breakout.get("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)
     levels = float(grid_pressure.get("levels") or 0.0)
     near_fill = bool(grid_fill.get("near_fill"))
     near_fill = bool(grid_fill.get("near_fill"))
     fill_fights = _grid_fill_fights_breakout(grid_fill, breakout)
     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 += tactical_strength * 0.16
     switch_benefit += min(trend_score, 2.0) * 0.04
     switch_benefit += min(trend_score, 2.0) * 0.04
     switch_benefit += min(breakout_score, 5.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:
     if adverse_side in {"buy", "sell"} and adverse_count > 0:
         adverse_notional_ratio = adverse_notional / max(base_order_notional, 1.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),
         "structural_trend_strength": round(structural_strength, 4),
         "tactical_trend_strength": round(tactical_strength, 4),
         "tactical_trend_strength": round(tactical_strength, 4),
         "grid_harvestability_score": round(harvestability_score, 4),
         "grid_harvestability_score": round(harvestability_score, 4),
+        "short_term_trend_score": round(short_term_trend_score, 4),
         "breakout_score": round(breakout_score, 4),
         "breakout_score": round(breakout_score, 4),
         "switch_benefit": round(switch_benefit, 4),
         "switch_benefit": round(switch_benefit, 4),
         "stay_cost": round(stay_cost, 4),
         "stay_cost": round(stay_cost, 4),
@@ -1402,7 +1522,12 @@ def _decide_for_rebalancer(*,
     trend_pressure = bool(decision_signals.get("trend_following_pressure"))
     trend_pressure = bool(decision_signals.get("trend_following_pressure"))
     grid_harvestable_now = bool(decision_signals.get("grid_harvestable_now"))
     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")
         blocks.append("trend is still strong enough that rebalancer should keep repairing instead of resetting to grid")
     elif release_ready:
     elif release_ready:
         if grid:
         if grid:

+ 52 - 17
src/hermes_mcp/server.py

@@ -3,6 +3,7 @@ from __future__ import annotations
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
 import asyncio
 import asyncio
 import json
 import json
+import time
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 from uuid import uuid4
 from uuid import uuid4
 
 
@@ -74,7 +75,7 @@ def _build_trader_control_payload(*, decision_id: str, concern: dict, decision:
     return payload
     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)):
     if not bool(getattr(decision, "requires_action", False)):
         return {"dispatch": "not_required"}
         return {"dispatch": "not_required"}
 
 
@@ -92,6 +93,14 @@ async def _maybe_dispatch_trader_action(*, cfg: object, decision_id: str, concer
             "payload": payload,
             "payload": payload,
         }
         }
 
 
+    if not trader_available:
+        return {
+            "dispatch": "deferred",
+            "reason": "trader unavailable",
+            "retry_after_seconds": retry_after_seconds,
+            "payload": payload,
+        }
+
     try:
     try:
         result = await trader_apply_control_decision(getattr(cfg, "trader_url"), payload)
         result = await trader_apply_control_decision(getattr(cfg, "trader_url"), payload)
         return {
         return {
@@ -123,6 +132,24 @@ def report() -> dict:
 async def lifespan(_: FastAPI):
 async def lifespan(_: FastAPI):
     cfg = load_config()
     cfg = load_config()
     init_db()
     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:
     try:
         sync_concerns_from_strategies(await list_strategies(cfg.trader_url))
         sync_concerns_from_strategies(await list_strategies(cfg.trader_url))
     except Exception:
     except Exception:
@@ -133,26 +160,32 @@ async def lifespan(_: FastAPI):
         pass
         pass
 
 
     async def _poll_loop() -> None:
     async def _poll_loop() -> None:
+        nonlocal cached_strategy_inventory
         while True:
         while True:
             started = datetime.now(timezone.utc).isoformat()
             started = datetime.now(timezone.utc).isoformat()
             cycle_id = str(uuid4())
             cycle_id = str(uuid4())
             concerns = list_concerns()
             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")
             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_snapshot: dict = {}
             argus_regime: dict = {}
             argus_regime: dict = {}
@@ -263,6 +296,8 @@ async def lifespan(_: FastAPI):
                         decision_id=decision_id,
                         decision_id=decision_id,
                         concern=concern,
                         concern=concern,
                         decision=decision,
                         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 = {
                         **decision.payload,
                         **decision.payload,

+ 16 - 2
src/hermes_mcp/trader_client.py

@@ -1,14 +1,28 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 from typing import Any
 from typing import Any
+from datetime import timedelta
 import json
 import json
+
 from mcp import ClientSession
 from mcp import ClientSession
 from mcp.client.sse import sse_client
 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 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()
             await session.initialize()
             result = await session.call_tool(tool, arguments)
             result = await session.call_tool(tool, arguments)
             content = getattr(result, "content", None) or []
             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
     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():
 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"}
     concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     narrative = {
     narrative = {
@@ -934,6 +1010,78 @@ def test_make_decision_replaces_trend_with_rebalancer_on_edge_cooling_even_befor
     assert decision.target_strategy == "protect-1"
     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():
 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"}
     concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     narrative = {
     narrative = {
@@ -988,6 +1136,40 @@ 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_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():
 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"}
     concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     narrative = {
     narrative = {