Procházet zdrojové kódy

Add replay harness and decision tuning docs

Lukas Goldschmidt před 3 týdny
rodič
revize
8157c558d4

+ 30 - 0
README.md

@@ -39,3 +39,33 @@ When the gate is false, Hermes still makes and records decisions, but returns a
 ```bash
 ./tests.sh
 ```
+
+## Replay decisions
+
+Hermes now stores a full `replay_input` bundle with each new decision, so stored decisions can be replayed against the current decision engine.
+
+Replay recent decisions:
+
+```bash
+python3 scripts/replay_decisions.py --limit 20
+```
+
+Only show changed outcomes:
+
+```bash
+python3 scripts/replay_decisions.py --limit 50 --only-changed
+```
+
+JSON output:
+
+```bash
+python3 scripts/replay_decisions.py --limit 50 --json
+```
+
+Optional filter by concern:
+
+```bash
+python3 scripts/replay_decisions.py --concern-id <concern-id>
+```
+
+Note: decisions created before this replay capture existed will not have `replay_input` yet.

+ 250 - 0
UPGRADE_PLAN.md

@@ -0,0 +1,250 @@
+# Hermes upgrade plan
+
+Status: working design document
+Date: 2026-04-19
+
+This document captures the intended operating philosophy for Hermes, the upgrade phases, and the concrete control points we want to improve. It is meant to be a stable reference while tuning and refactoring the current decision logic.
+
+## Core operating philosophy
+
+### Strategy roles
+
+#### Grid trader
+- Grid is the default operating mode when it makes sense.
+- Grid should stay active when the market offers harvestable noise.
+- Grid should not stay active in strong persistent trends that can run through the ladder and damage the wallet.
+- Breakouts are not automatically bad for grid. Grid can still profit during breakout conditions if pullbacks remain larger than the effective grid size.
+- The key question is not "is there movement?" but "is there enough two-way movement relative to grid spacing to harvest safely?"
+
+#### Trend follower
+- Trend following should take over as early as reasonably possible when a real emerging trend is likely to persist and pullbacks are too small for grid harvesting.
+- Earlier trend handoff is better when the move is directional, persistent, and not offering sufficient pullback depth for the grid.
+- Trend detection should begin on the smallest timeframe first and then be confirmed upward across timeframes.
+- Time hierarchy matters: 1m first, then 5m, then 15m, then higher.
+
+#### Rebalancer
+- Rebalancer has one purpose: restore the wallet into a state where grid can operate again.
+- In practical terms, that means bringing the wallet back toward a composition that supports orders on both sides.
+- Rebalancer is not an independent profit engine and should not linger longer than necessary.
+- Rebalancer should become active when the trend has eased enough that inventory repair is preferable to continued trend following.
+- Rebalancer should hand back to grid once the wallet is sufficiently usable and the market is again harvestable.
+
+## Key market distinctions Hermes must learn to make
+
+### 1. Harvestable noise vs destructive trend
+Hermes must distinguish between:
+- noise large enough for grid harvesting
+- emerging trend that may still be noisy enough for grid to survive
+- sustained persistent trend that will damage grid if not handed off early
+
+This distinction should explicitly depend on:
+- pullback depth relative to grid size
+- persistence across timeframes
+- local auction behavior and easing
+- wallet composition and side capacity
+
+### 2. Structural trend vs tactical behavior
+Hermes should separate:
+- structural regime, what the market broadly is doing
+- tactical state, what price is doing right now
+- execution feasibility, what the wallet and ladder can safely support
+
+Example:
+- Meso may still be bearish.
+- But 1m can already be ranging or easing.
+- That should not necessarily flip the structural view.
+- It can still justify releasing from trend or rebalancer into grid if local noise is again harvestable.
+
+### 3. Timescale sequencing
+Trend emergence should be treated as a sequence:
+- first visible on 1m
+- then seen on 5m
+- then 15m
+- then higher scopes
+
+Trend easing should also be treated as a sequence:
+- first the 1m loses directional drive
+- then 5m shows reduced continuation quality or increased overlap
+- then broader scopes cool
+
+Hermes should use that sequencing explicitly, not just indirectly through blended scores.
+
+### 4. Harvestability depends on wallet state
+Market structure alone is not enough.
+Harvestability also depends on:
+- wallet composition
+- whether both trade sides can be quoted
+- whether remaining ladder geometry is usable
+- whether current noise exceeds effective grid size
+- whether inventory skew makes one-sided fills too risky
+
+## Current upgrade phases
+
+## Phase 1, stabilize intent and document expected behavior
+Purpose: define the intended behavior clearly enough to tune against it.
+
+### Phase 1 deliverables
+- Written operating philosophy for grid, trend, and rebalancer.
+- Clear description of harvestable noise vs destructive trend.
+- Clear statement that rebalancer exists only to repair wallet usability for grid.
+- Explicit acknowledgement that trend detection and trend easing begin on lower timeframes first.
+- Scenario list for later tests.
+
+### Phase 1 status
+Mostly captured. The core philosophy is now documented in this file.
+
+Open questions still worth formalizing:
+- What exact pullback-to-grid-size ratio should count as harvestable?
+- How much weight should 1m have relative to 5m for early trend detection?
+- What exact wallet state is "good enough" for grid resumption?
+- What should count as sufficient easing for trend -> rebalancer and rebalancer -> grid handoff?
+
+## Phase 2, instrument the current system
+Purpose: expose why Hermes is making each decision before deeper refactoring.
+
+### Goals
+- Emit compact decision diagnostics for every cycle.
+- Show structural trend state, tactical state, harvestability estimate, wallet usability, and final transition reason.
+- Make it easy to compare what Hermes saw versus what Lukas expected.
+
+### Proposed diagnostics
+- structural_directionality
+- tactical_directionality
+- tactical_easing_state
+- breakout_persistence
+- pullback_vs_grid_size
+- grid_harvestability
+- wallet_grid_usability
+- rebalance_urgency
+- chosen_transition
+- blocked_transitions
+
+## Phase 3, separate the market model into explicit layers
+Purpose: stop using blended strategy preference scores as if they were pure market truth.
+
+### Target layers
+
+#### Structural layer
+Answers:
+- Is the market directionally bullish, bearish, or rotational overall?
+- Is the regime persistent or fragile?
+
+Inputs should mostly come from:
+- 15m+
+- 1h+
+- higher-level snapshot persistence
+
+#### Tactical layer
+Answers:
+- Is price currently impulsing, overlapping, easing, stretching, or reverting?
+- Is a breakout accelerating or fading?
+- Is local movement range-like enough to harvest?
+
+Inputs should mostly come from:
+- 1m
+- 5m
+- internal short-horizon snapshots
+
+#### Execution layer
+Answers:
+- Can grid operate safely now?
+- Is the wallet usable on both sides?
+- Is trend continuation still operationally better than releasing?
+
+Inputs should include:
+- wallet composition
+- side capacity
+- open order geometry
+- grid step size
+- pullback depth relative to step size
+
+## Phase 4, rewrite the transition logic as an explicit state machine
+Purpose: make handoffs interpretable and tunable.
+
+### Desired transition model
+- GRID -> TREND
+- GRID -> REBALANCER
+- TREND -> REBALANCER
+- REBALANCER -> GRID
+
+Optional but stricter:
+- avoid direct REBALANCER -> TREND unless there is a very specific reason
+- prefer TREND -> REBALANCER -> GRID as the normal unwind path
+
+### Transition design principle
+Each transition should have explicit guards based on:
+- structural regime
+- tactical state
+- wallet usability
+- harvestability
+
+Not just a single blended strategy score.
+
+## Phase 5, expose tuning screws explicitly
+Purpose: make behavior adjustable without rewriting logic.
+
+### Example tuning screws
+- micro_early_trend_weight
+- micro_easing_weight
+- five_min_confirmation_weight
+- meso_persistence_weight
+- breakout_confirmation_threshold
+- breakout_release_threshold
+- grid_harvestability_threshold
+- pullback_to_grid_ratio_threshold
+- wallet_grid_resume_tolerance
+- rebalance_release_threshold
+
+## Phase 6, scenario-based test suite and replay tuning
+Purpose: verify Hermes against real trading situations instead of only abstract scores.
+
+### Scenario families to test
+- clean trend emergence from 1m to 5m to 15m
+- false breakout that remains grid-harvestable
+- persistent trend with too-shallow pullbacks for grid
+- trend easing after asymmetric wallet buildup
+- rebalancer handing back too early
+- rebalancer handing back too late
+- local bottom or top where grid can resume harvesting after shift
+- one-sided wallet where harvestability is limited despite attractive price action
+
+## Architectural observations from the current code
+
+### Current opportunity_map origin
+Today `opportunity_map` is generated in `src/hermes_mcp/narrative_engine.py` from coarse semantic labels such as:
+- stance
+- meso_structure
+- friction
+- micro_reversal_risk
+
+This means it is already a compressed interpretation, not a direct market measure.
+
+### Current issue
+Hermes currently risks confusing:
+- market truth
+with
+- strategy preference score
+
+That makes tuning harder and can cause delayed or sticky handoffs.
+
+## Immediate next recommended actions
+1. Repair the currently failing Hermes tests.
+2. Add decision diagnostics so current behavior is visible.
+3. Introduce explicit metrics for:
+   - structural trend strength
+   - tactical easing
+   - pullback depth vs grid size
+   - grid harvestability
+   - wallet grid usability
+4. Rewrite rebalancer release logic to depend on those explicit metrics.
+5. Then rewrite grid-to-trend handoff using the same model.
+
+## Notes for future tuning sessions
+When evaluating a bad Hermes decision, ask these questions in order:
+1. Did Hermes correctly read the structural regime?
+2. Did Hermes correctly read the tactical short-term behavior?
+3. Did Hermes correctly estimate whether noise was harvestable relative to grid size?
+4. Did Hermes correctly assess wallet usability?
+5. Did Hermes choose the right transition for that combination?
+
+If the answer fails earlier in the chain, later threshold tuning will not solve it.

+ 96 - 0
scripts/replay_decisions.py

@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+import sys
+
+ROOT = Path(__file__).resolve().parents[1]
+SRC = ROOT / "src"
+if str(SRC) not in sys.path:
+    sys.path.insert(0, str(SRC))
+
+from hermes_mcp.replay import compare_to_baseline  # noqa: E402
+from hermes_mcp.store import init_db  # noqa: E402
+import sqlite3  # noqa: E402
+
+
+def _load_rows(limit: int, concern_id: str | None) -> list[dict]:
+    init_db()
+    db_path = ROOT / "data" / "hermes_mcp.sqlite3"
+    with sqlite3.connect(db_path) as conn:
+        conn.row_factory = sqlite3.Row
+        if concern_id:
+            rows = conn.execute(
+                "select * from decisions where concern_id = ? order by created_at desc limit ?",
+                (concern_id, limit),
+            ).fetchall()
+        else:
+            rows = conn.execute(
+                "select * from decisions order by created_at desc limit ?",
+                (limit,),
+            ).fetchall()
+    return [dict(r) for r in rows]
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Replay stored Hermes decisions against the current decision engine.")
+    parser.add_argument("--limit", type=int, default=20, help="How many stored decisions to replay")
+    parser.add_argument("--concern-id", help="Optional concern id filter")
+    parser.add_argument("--only-changed", action="store_true", help="Print only changed decisions")
+    parser.add_argument("--json", action="store_true", help="Emit JSON lines instead of plain text")
+    args = parser.parse_args()
+
+    rows = _load_rows(limit=max(1, args.limit), concern_id=args.concern_id)
+    checked = 0
+    changed = 0
+    skipped = 0
+
+    for row in rows:
+        payload = json.loads(row.get("target_policy_json") or "{}")
+        replay_input = payload.get("replay_input") if isinstance(payload.get("replay_input"), dict) else None
+        if not replay_input:
+            skipped += 1
+            continue
+
+        result = compare_to_baseline(
+            replay_input=replay_input,
+            baseline={
+                "mode": row.get("mode"),
+                "action": row.get("action"),
+                "target_strategy": row.get("target_strategy"),
+            },
+        )
+        checked += 1
+        changed += 1 if result["changed"] else 0
+        if args.only_changed and not result["changed"]:
+            continue
+
+        output = {
+            "decision_id": row.get("id"),
+            "concern_id": row.get("concern_id"),
+            "created_at": row.get("created_at"),
+            **result,
+        }
+        if args.json:
+            print(json.dumps(output, ensure_ascii=False))
+        else:
+            marker = "CHANGED" if result["changed"] else "same"
+            print(f"[{marker}] {row.get('created_at')} concern={row.get('concern_id')} baseline={result['baseline']} replayed={result['replayed']}")
+
+    summary = {
+        "rows_loaded": len(rows),
+        "checked": checked,
+        "changed": changed,
+        "skipped_without_replay_input": skipped,
+    }
+    if args.json:
+        print(json.dumps({"summary": summary}, ensure_ascii=False))
+    else:
+        print(f"summary: {summary}")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 247 - 10
src/hermes_mcp/decision_engine.py

@@ -688,6 +688,211 @@ def _narrative_direction(narrative: dict[str, Any]) -> str | None:
     return None
 
 
+def _direction_label_from_score(score: float, bullish_threshold: float = 0.18) -> str:
+    if score >= bullish_threshold:
+        return "bullish"
+    if score <= -bullish_threshold:
+        return "bearish"
+    return "mixed"
+
+
+def _extract_decision_signals(*,
+    narrative_payload: dict[str, Any],
+    wallet_state: dict[str, Any],
+    grid_strategy: dict[str, Any] | None = None,
+    breakout: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    scoped = narrative_payload.get("scoped_state") if isinstance(narrative_payload.get("scoped_state"), dict) else {}
+    cross = narrative_payload.get("cross_scope_summary") if isinstance(narrative_payload.get("cross_scope_summary"), dict) else {}
+    features = narrative_payload.get("features_by_timeframe") if isinstance(narrative_payload.get("features_by_timeframe"), dict) else {}
+    embedded = narrative_payload.get("decision_inputs") if isinstance(narrative_payload.get("decision_inputs"), dict) else {}
+
+    micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
+    meso = scoped.get("meso") if isinstance(scoped.get("meso"), dict) else {}
+    macro = scoped.get("macro") if isinstance(scoped.get("macro"), dict) else {}
+    micro_features = features.get("1m") if isinstance(features.get("1m"), dict) else {}
+    micro_vol = micro_features.get("volatility") if isinstance(micro_features.get("volatility"), dict) else {}
+    micro_raw = micro_features.get("raw") if isinstance(micro_features.get("raw"), dict) else {}
+
+    alignment = str(cross.get("alignment") or "partial_alignment")
+    friction = str(cross.get("friction") or "medium")
+    micro_impulse = str(micro.get("impulse") or "mixed")
+    micro_bias = str(micro.get("trend_bias") or "mixed")
+    micro_location = str(micro.get("location") or embedded.get("micro_location") or "unknown")
+    micro_reversal_risk = str(micro.get("reversal_risk") or "low")
+    meso_structure = str(meso.get("structure") or "rotation")
+    meso_bias = str(meso.get("momentum_bias") or "neutral")
+    macro_bias = str(macro.get("bias") or "mixed")
+
+    structural_direction = str(embedded.get("structural_direction") or "")
+    if structural_direction not in {"bullish", "bearish"}:
+        structural_direction = meso_bias if meso_bias in {"bullish", "bearish"} else macro_bias if macro_bias in {"bullish", "bearish"} else "mixed"
+
+    structural_strength = _safe_float(embedded.get("structural_trend_strength"))
+    if structural_strength is None:
+        structural_strength = 0.0
+        if meso_structure == "trend_continuation" and meso_bias in {"bullish", "bearish"}:
+            structural_strength += 0.45
+        elif meso_structure in {"bullish_pullback", "bearish_pullback"} and meso_bias in {"bullish", "bearish"}:
+            structural_strength += 0.25
+        if macro_bias in {"bullish", "bearish"} and macro_bias == structural_direction:
+            structural_strength += 0.25
+        if alignment == "micro_meso_macro_aligned":
+            structural_strength += 0.2
+        elif alignment == "partial_alignment":
+            structural_strength += 0.1
+        if friction == "high":
+            structural_strength -= 0.18
+    structural_strength = round(_clamp(structural_strength, 0.0, 1.0), 4)
+
+    tactical_direction = str(embedded.get("tactical_direction") or "")
+    if tactical_direction not in {"bullish", "bearish", "mixed"}:
+        micro_score = 0.0
+        if micro_impulse == "up":
+            micro_score += 0.35
+        elif micro_impulse == "down":
+            micro_score -= 0.35
+        if micro_bias == "bullish":
+            micro_score += 0.45
+        elif micro_bias == "bearish":
+            micro_score -= 0.45
+        tactical_direction = _direction_label_from_score(micro_score)
+
+    tactical_strength = _safe_float(embedded.get("tactical_trend_strength"))
+    if tactical_strength is None:
+        tactical_strength = 0.0
+        if micro_impulse in {"up", "down"} and micro_bias in {"bullish", "bearish"}:
+            tactical_strength += 0.45
+        elif micro_impulse in {"up", "down"}:
+            tactical_strength += 0.2
+        if micro_location in {"near_upper_band", "near_lower_band"}:
+            tactical_strength += 0.1
+        if micro_reversal_risk == "medium":
+            tactical_strength -= 0.12
+        elif micro_reversal_risk == "high":
+            tactical_strength -= 0.25
+    tactical_strength = round(_clamp(tactical_strength, 0.0, 1.0), 4)
+
+    tactical_range_quality = _safe_float(embedded.get("tactical_range_quality"))
+    if tactical_range_quality is None:
+        tactical_range_quality = 0.0
+        if micro_impulse == "mixed":
+            tactical_range_quality += 0.35
+        if micro_bias == "mixed":
+            tactical_range_quality += 0.2
+        if micro_location in {"centered", "lower_half", "upper_half"}:
+            tactical_range_quality += 0.18
+        if friction == "high":
+            tactical_range_quality += 0.08
+        if micro_reversal_risk == "high":
+            tactical_range_quality -= 0.08
+    tactical_range_quality = round(_clamp(tactical_range_quality, 0.0, 1.0), 4)
+
+    tactical_easing = bool(embedded.get("tactical_easing"))
+    if not tactical_easing:
+        tactical_easing = bool(
+            meso_structure == "trend_continuation"
+            and meso_bias in {"bullish", "bearish"}
+            and (
+                micro_impulse == "mixed"
+                or micro_bias == "mixed"
+                or micro_reversal_risk in {"medium", "high"}
+                or micro_location == "centered"
+            )
+        )
+
+    breakout = breakout or {}
+    breakout_phase = str(breakout.get("phase") or "none")
+    breakout_persistence = 1.0 if bool(breakout.get("persistent")) else 0.65 if breakout_phase == "developing" else 0.35 if breakout_phase == "probing" else 0.0
+
+    grid_step_pct = None
+    if grid_strategy:
+        state = grid_strategy.get("state") if isinstance(grid_strategy.get("state"), dict) else {}
+        config = grid_strategy.get("config") if isinstance(grid_strategy.get("config"), dict) else {}
+        grid_step_pct = _safe_float(config.get("grid_step_pct") or state.get("grid_step_pct") or state.get("recenter_pct_live"))
+
+    atr_percent = _safe_float(embedded.get("micro_atr_percent"))
+    if atr_percent is None:
+        atr_percent = _safe_float(micro_raw.get("atr_percent"))
+    band_width_pct = _safe_float(embedded.get("micro_bollinger_width_pct"))
+    if band_width_pct is None:
+        band_width_pct = _safe_float(micro_vol.get("bollinger_width_pct"))
+    noise_pct = max(band_width_pct or 0.0, (atr_percent or 0.0) * 2.0)
+    pullback_to_grid_ratio = None
+    if grid_step_pct and grid_step_pct > 0:
+        pullback_to_grid_ratio = noise_pct / max(grid_step_pct * 100.0, 0.0001)
+
+    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
+    elif atr_percent is not None:
+        harvestability_score += min((atr_percent or 0.0) / 0.5, 1.0) * 0.18
+    if tactical_easing:
+        harvestability_score += 0.18
+    if micro_location in {"centered", "lower_half", "upper_half"}:
+        harvestability_score += 0.08
+    if breakout_persistence >= 1.0 and not tactical_easing and tactical_strength >= 0.5:
+        harvestability_score -= 0.3
+    harvestability_score = round(_clamp(harvestability_score, 0.0, 1.0), 4)
+
+    inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
+    within_rebalance_tolerance = _wallet_within_rebalance_tolerance(wallet_state, 0.3)
+    if wallet_state.get("grid_ready"):
+        wallet_grid_usability = 1.0
+    elif within_rebalance_tolerance:
+        wallet_grid_usability = 0.78
+    elif inventory_state in {"base_heavy", "quote_heavy"}:
+        wallet_grid_usability = 0.42
+    elif inventory_state in SEVERE_INVENTORY_STATES:
+        wallet_grid_usability = 0.12
+    else:
+        wallet_grid_usability = 0.3
+
+    trend_following_pressure = bool(
+        structural_strength >= 0.58
+        and breakout_persistence >= 0.65
+        and tactical_strength >= 0.35
+        and tactical_direction == structural_direction
+        and not tactical_easing
+    )
+    grid_harvestable_now = bool(
+        harvestability_score >= 0.52
+        and wallet_grid_usability >= 0.42
+    )
+    rebalancer_release_ready = bool(
+        within_rebalance_tolerance
+        and (
+            (
+                harvestability_score >= 0.45
+                and (tactical_easing or breakout_persistence < 1.0 or tactical_range_quality >= 0.45)
+            )
+            or (wallet_state.get("grid_ready") and breakout_persistence < 1.0)
+            or (tactical_range_quality >= 0.5 and breakout_persistence < 0.65)
+        )
+    )
+
+    return {
+        "structural_direction": structural_direction,
+        "structural_trend_strength": structural_strength,
+        "tactical_direction": tactical_direction,
+        "tactical_trend_strength": tactical_strength,
+        "tactical_range_quality": tactical_range_quality,
+        "tactical_easing": tactical_easing,
+        "breakout_persistence_score": round(breakout_persistence, 4),
+        "micro_location": micro_location,
+        "micro_atr_percent": atr_percent,
+        "micro_bollinger_width_pct": band_width_pct,
+        "grid_step_pct": round(grid_step_pct, 6) if grid_step_pct is not None else None,
+        "pullback_to_grid_ratio": round(pullback_to_grid_ratio, 4) if pullback_to_grid_ratio is not None else None,
+        "grid_harvestability_score": harvestability_score,
+        "wallet_grid_usability": round(wallet_grid_usability, 4),
+        "within_rebalance_tolerance": within_rebalance_tolerance,
+        "trend_following_pressure": trend_following_pressure,
+        "grid_harvestable_now": grid_harvestable_now,
+        "rebalancer_release_ready": rebalancer_release_ready,
+    }
+
+
 def _strategy_trade_side(strategy: dict[str, Any]) -> str:
     config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
     state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
@@ -709,6 +914,7 @@ def _grid_switch_tradeoff(*,
     grid_fill: dict[str, Any],
     grid_pressure: dict[str, Any],
     directional_micro_clear: bool,
+    decision_signals: dict[str, Any],
     trend: dict[str, Any] | None,
 ) -> dict[str, Any]:
     inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
@@ -730,12 +936,15 @@ def _grid_switch_tradeoff(*,
             base_order_notional = candidate_value
 
     trend_score = float(trend.get("score") or 0.0) if trend else 0.0
+    structural_strength = float(decision_signals.get("structural_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)
     breakout_score = float(breakout.get("score") or 0.0)
     levels = float(grid_pressure.get("levels") or 0.0)
     near_fill = bool(grid_fill.get("near_fill"))
     fill_fights = _grid_fill_fights_breakout(grid_fill, breakout)
     persistent = bool(breakout.get("persistent"))
-    trend_ready = trend_score > 0.45 and directional_micro_clear
+    trend_ready = bool(decision_signals.get("trend_following_pressure")) and directional_micro_clear
 
     stay_cost = 0.0
     switch_benefit = 0.0
@@ -746,7 +955,9 @@ def _grid_switch_tradeoff(*,
     # Requirement: ignore nearby fill timing/side when estimating the stay-vs-switch tradeoff.
     if levels >= _trend_handoff_level_threshold(breakout):
         switch_benefit += 0.18
-    switch_benefit += min(trend_score, 2.5) * 0.18
+    switch_benefit += structural_strength * 0.26
+    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 adverse_side in {"buy", "sell"} and adverse_count > 0:
@@ -773,11 +984,15 @@ def _grid_switch_tradeoff(*,
         stay_cost += 0.12
     if adverse_notional_ratio >= 1.0:
         stay_cost += 0.08
+    stay_cost += harvestability_score * 0.18
 
     margin = round(switch_benefit - stay_cost, 4)
     should_switch = persistent and trend_ready and margin > 0.0
     return {
         "trend_score": round(trend_score, 4),
+        "structural_trend_strength": round(structural_strength, 4),
+        "tactical_trend_strength": round(tactical_strength, 4),
+        "grid_harvestability_score": round(harvestability_score, 4),
         "breakout_score": round(breakout_score, 4),
         "switch_benefit": round(switch_benefit, 4),
         "stay_cost": round(stay_cost, 4),
@@ -871,6 +1086,7 @@ def _decide_for_grid(*,
     grid_pressure: dict[str, Any],
     directional_micro_clear: bool,
     severe_imbalance: bool,
+    decision_signals: dict[str, Any],
     trend: dict[str, Any] | None,
     rebalance: dict[str, Any] | None,
 ) -> tuple[str, str, str | None, list[str], list[str]]:
@@ -890,8 +1106,7 @@ def _decide_for_grid(*,
     breakout_phase = str(breakout.get("phase") or "none")
     trend_handoff_ready = bool(
         trend
-        and trend["score"] > 0.45
-        and directional_micro_clear
+        and bool(decision_signals.get("trend_following_pressure"))
         and grid_pressure.get("levels", 0.0) >= _trend_handoff_level_threshold(breakout)
     )
     fill_fights_breakout = _grid_fill_fights_breakout(grid_fill, breakout)
@@ -902,6 +1117,7 @@ def _decide_for_grid(*,
         grid_fill=grid_fill,
         grid_pressure=grid_pressure,
         directional_micro_clear=directional_micro_clear,
+        decision_signals=decision_signals,
         trend=trend,
     )
 
@@ -964,6 +1180,8 @@ def _decide_for_grid(*,
             reasons.append("breakout pressure is developing, but grid can still work and should not be abandoned yet")
         else:
             reasons.append("grid can still operate and self-heal, so inventory skew alone should not force a rebalance handoff")
+        if decision_signals.get("grid_harvestable_now"):
+            reasons.append("tactical range quality still looks harvestable for the grid")
     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, but trend handoff is not ready enough yet")
     elif not grid_friendly_stance and persistent_breakout:
@@ -1043,6 +1261,7 @@ def _decide_for_rebalancer(*,
     stance: str,
     wallet_state: dict[str, Any],
     grid: dict[str, Any] | None,
+    decision_signals: dict[str, Any],
     trend: dict[str, Any] | None = None,
 ) -> tuple[str, str, str | None, list[str], list[str]]:
     action = "keep_rebalancer"
@@ -1054,16 +1273,23 @@ def _decide_for_rebalancer(*,
     # Rebalancing is a repair phase. Once the wallet is usable again, Hermes
     # should prefer handing back to grid, not directly to trend.
     trend_strength = float(trend["score"]) if trend and isinstance(trend.get("score"), (int, float)) else 0.0
-    if trend and trend_strength >= 1.5:
+    within_tolerance = bool(decision_signals.get("within_rebalance_tolerance"))
+    release_ready = bool(decision_signals.get("rebalancer_release_ready"))
+    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:
         blocks.append("trend is still strong enough that rebalancer should keep repairing instead of resetting to grid")
-    elif _wallet_within_rebalance_tolerance(wallet_state, 0.3):
+    elif release_ready:
         if grid:
             action = "replace_with_grid"
             target_strategy = grid["strategy_id"]
             mode = "act"
-            reasons.append("wallet is within the 0.3 rebalance tolerance, so grid can resume before perfect balance")
+            reasons.append("wallet is usable enough and micro conditions are easing, so grid can resume harvesting")
         else:
             blocks.append("wallet is within the rebalance tolerance but no grid candidate is available")
+    elif within_tolerance and not grid_harvestable_now:
+        blocks.append("wallet is close enough, but the local tape is still not harvestable enough for grid release")
     elif wallet_state.get("grid_ready") and stance == "neutral_rotational":
         if grid and grid["score"] >= 0.5:
             action = "replace_with_grid"
@@ -1072,11 +1298,11 @@ def _decide_for_rebalancer(*,
             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:
+    elif grid and grid_harvestable_now:
         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")
+        reasons.append("local price action looks harvestable enough that grid can resume before perfect balance")
     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")
 
@@ -1114,6 +1340,13 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
     trend = next((r for r in ranked if r["strategy_type"] == "trend_follower"), None)
     rebalance = next((r for r in ranked if r["strategy_type"] == "exposure_protector"), None)
     grid = next((r for r in ranked if r["strategy_type"] == "grid_trader"), None)
+    grid_strategy = next((s for s in normalized if s["strategy_type"] == "grid_trader"), None)
+    decision_signals = _extract_decision_signals(
+        narrative_payload=narrative_payload,
+        wallet_state=wallet_state,
+        grid_strategy=grid_strategy,
+        breakout=breakout,
+    )
     switch_tradeoff: dict[str, Any] = {}
 
     if current_primary and current_primary["strategy_type"] == "grid_trader":
@@ -1127,6 +1360,7 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
             grid_pressure=grid_pressure,
             directional_micro_clear=directional_micro_clear,
             severe_imbalance=severe_imbalance,
+            decision_signals=decision_signals,
             trend=trend,
             rebalance=rebalance,
         )
@@ -1137,6 +1371,7 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
             grid_fill=grid_fill,
             grid_pressure=grid_pressure,
             directional_micro_clear=directional_micro_clear,
+            decision_signals=decision_signals,
             trend=trend,
         )
     elif current_primary and current_primary["strategy_type"] == "trend_follower":
@@ -1154,6 +1389,7 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
             stance=stance,
             wallet_state=wallet_state,
             grid=grid,
+            decision_signals=decision_signals,
             trend=trend,
         )
     else:
@@ -1186,9 +1422,10 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
         "grid_breakout_pressure": breakout,
         "grid_fill_context": grid_fill,
         "grid_switch_tradeoff": switch_tradeoff if current_primary and current_primary["strategy_type"] == "grid_trader" else {},
+        "decision_audit": decision_signals,
         "reason_chain": reasons,
         "blocks": blocks,
-        "decision_version": 2,
+        "decision_version": 3,
     }
 
     return DecisionSnapshot(

+ 118 - 18
src/hermes_mcp/narrative_engine.py

@@ -65,6 +65,107 @@ def _first_nonempty(values: list[str | None], fallback: str) -> str:
     return fallback
 
 
+def _derive_decision_inputs(*,
+    scoped: dict[str, Any],
+    cross: dict[str, Any],
+    features_by_timeframe: dict[str, Any],
+) -> dict[str, Any]:
+    micro = scoped.get("micro") or {}
+    meso = scoped.get("meso") or {}
+    macro = scoped.get("macro") or {}
+    micro_features = features_by_timeframe.get("1m") if isinstance(features_by_timeframe.get("1m"), dict) else {}
+    micro_vol = micro_features.get("volatility") if isinstance(micro_features.get("volatility"), dict) else {}
+    micro_raw = micro_features.get("raw") if isinstance(micro_features.get("raw"), dict) else {}
+
+    alignment = str(cross.get("alignment") or "partial_alignment")
+    friction = str(cross.get("friction") or "medium")
+    micro_impulse = str(micro.get("impulse") or "mixed")
+    micro_bias = str(micro.get("trend_bias") or "mixed")
+    micro_location = str(micro.get("location") or "unknown")
+    micro_reversal_risk = str(micro.get("reversal_risk") or "low")
+    meso_structure = str(meso.get("structure") or "rotation")
+    meso_bias = str(meso.get("momentum_bias") or "neutral")
+    macro_bias = str(macro.get("bias") or "mixed")
+
+    structural_direction = meso_bias if meso_bias in {"bullish", "bearish"} else macro_bias if macro_bias in {"bullish", "bearish"} else "mixed"
+    structural_trend_strength = 0.0
+    if meso_structure == "trend_continuation" and meso_bias in {"bullish", "bearish"}:
+        structural_trend_strength += 0.45
+    elif meso_structure in {"bullish_pullback", "bearish_pullback"} and meso_bias in {"bullish", "bearish"}:
+        structural_trend_strength += 0.25
+    if macro_bias in {"bullish", "bearish"} and macro_bias == structural_direction:
+        structural_trend_strength += 0.25
+    if alignment == "micro_meso_macro_aligned":
+        structural_trend_strength += 0.2
+    elif alignment == "partial_alignment":
+        structural_trend_strength += 0.1
+    if friction == "high":
+        structural_trend_strength -= 0.18
+    structural_trend_strength = round(_clamp(structural_trend_strength, 0.0, 1.0), 3)
+
+    tactical_direction = micro_bias if micro_bias in {"bullish", "bearish"} else "mixed"
+    tactical_trend_strength = 0.0
+    if micro_impulse in {"up", "down"} and micro_bias in {"bullish", "bearish"}:
+        tactical_trend_strength += 0.45
+    elif micro_impulse in {"up", "down"}:
+        tactical_trend_strength += 0.2
+    if micro_location in {"near_upper_band", "near_lower_band"}:
+        tactical_trend_strength += 0.1
+    if micro_reversal_risk == "medium":
+        tactical_trend_strength -= 0.12
+    elif micro_reversal_risk == "high":
+        tactical_trend_strength -= 0.25
+    tactical_trend_strength = round(_clamp(tactical_trend_strength, 0.0, 1.0), 3)
+
+    tactical_range_quality = 0.0
+    if micro_impulse == "mixed":
+        tactical_range_quality += 0.35
+    if micro_bias == "mixed":
+        tactical_range_quality += 0.2
+    if micro_location in {"centered", "lower_half", "upper_half"}:
+        tactical_range_quality += 0.18
+    if friction == "high":
+        tactical_range_quality += 0.08
+    if micro_reversal_risk == "high":
+        tactical_range_quality -= 0.08
+    tactical_range_quality = round(_clamp(tactical_range_quality, 0.0, 1.0), 3)
+
+    tactical_easing = bool(
+        meso_structure == "trend_continuation"
+        and meso_bias in {"bullish", "bearish"}
+        and (
+            micro_impulse == "mixed"
+            or micro_bias == "mixed"
+            or micro_reversal_risk in {"medium", "high"}
+            or micro_location == "centered"
+        )
+    )
+
+    breakout_readiness = 0.0
+    if micro_impulse in {"up", "down"} and micro_bias in {"bullish", "bearish"}:
+        breakout_readiness += 0.28
+    if meso_structure == "trend_continuation":
+        breakout_readiness += 0.32
+    if alignment == "micro_meso_macro_aligned":
+        breakout_readiness += 0.2
+    if friction == "low":
+        breakout_readiness += 0.12
+    breakout_readiness = round(_clamp(breakout_readiness, 0.0, 1.0), 3)
+
+    return {
+        "structural_direction": structural_direction,
+        "structural_trend_strength": structural_trend_strength,
+        "tactical_direction": tactical_direction,
+        "tactical_trend_strength": tactical_trend_strength,
+        "tactical_range_quality": tactical_range_quality,
+        "tactical_easing": tactical_easing,
+        "breakout_readiness": breakout_readiness,
+        "micro_location": micro_location,
+        "micro_atr_percent": micro_raw.get("atr_percent"),
+        "micro_bollinger_width_pct": micro_vol.get("bollinger_width_pct"),
+    }
+
+
 def build_narrative(*, concern: dict[str, Any], state_payload: dict[str, Any]) -> NarrativeSnapshot:
     scoped = state_payload.get("scoped_state") or {}
     micro = scoped.get("micro") or {}
@@ -72,6 +173,7 @@ def build_narrative(*, concern: dict[str, Any], state_payload: dict[str, Any]) -
     macro = scoped.get("macro") or {}
     cross = state_payload.get("cross_scope_summary") or {}
     argus = state_payload.get("argus_context") or {}
+    features_by_timeframe = state_payload.get("features_by_timeframe") if isinstance(state_payload.get("features_by_timeframe"), dict) else {}
 
     market_symbol = str(concern.get("market_symbol") or state_payload.get("market_symbol") or "market")
     drivers: list[str] = []
@@ -161,31 +263,28 @@ def build_narrative(*, concern: dict[str, Any], state_payload: dict[str, Any]) -
     if stance not in STANCE_TAXONOMY:
         stance = "neutral_rotational"
 
+    decision_inputs = _derive_decision_inputs(scoped=scoped, cross=cross, features_by_timeframe=features_by_timeframe)
+
     opportunity_raw = {
-        "continuation": 0.0,
-        "mean_reversion": 0.0,
-        "reversal": 0.0,
-        "wait": 0.0,
+        "continuation": decision_inputs["structural_trend_strength"] * 0.55 + decision_inputs["breakout_readiness"] * 0.45,
+        "mean_reversion": decision_inputs["tactical_range_quality"] * 0.75 + (0.2 if meso_structure == "rotation" else 0.0),
+        "reversal": (0.4 if micro_reversal_risk == "high" else 0.18 if micro_reversal_risk == "medium" else 0.0),
+        "wait": (0.2 if friction == "high" else 0.1 if friction == "medium" else 0.0),
     }
     if stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
-        opportunity_raw["continuation"] += 0.55
+        opportunity_raw["continuation"] += 0.2
     if stance in {"neutral_rotational", "transition_risk"}:
-        opportunity_raw["wait"] += 0.45
-        opportunity_raw["mean_reversion"] += 0.3
-    if stance == "breakout_watch":
-        opportunity_raw["continuation"] += 0.3
         opportunity_raw["wait"] += 0.25
+        opportunity_raw["mean_reversion"] += 0.15
+    if stance == "breakout_watch":
+        opportunity_raw["continuation"] += 0.12
+        opportunity_raw["wait"] += 0.12
     if stance == "reversal_watch":
-        opportunity_raw["reversal"] += 0.45
-        opportunity_raw["wait"] += 0.2
-    if micro_reversal_risk == "high":
         opportunity_raw["reversal"] += 0.2
-    if friction == "high":
-        opportunity_raw["wait"] += 0.25
-    if meso_structure == "rotation":
-        opportunity_raw["mean_reversion"] += 0.2
-    if meso_structure in {"bullish_pullback", "bearish_pullback", "trend_continuation"}:
-        opportunity_raw["continuation"] += 0.15
+        opportunity_raw["wait"] += 0.08
+    if decision_inputs["tactical_easing"]:
+        opportunity_raw["mean_reversion"] += 0.12
+        opportunity_raw["continuation"] -= 0.08
 
     opportunity_map = _rounded_weights(opportunity_raw)
 
@@ -212,6 +311,7 @@ def build_narrative(*, concern: dict[str, Any], state_payload: dict[str, Any]) -
         "stance": stance,
         "market_story": market_story,
         "invalidators": invalidators,
+        "decision_inputs": decision_inputs,
         "opportunity_map": opportunity_map,
         "cross_scope_alignment": alignment,
         "argus_context": argus,

+ 60 - 0
src/hermes_mcp/replay.py

@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+import copy
+from typing import Any
+
+from .decision_engine import DecisionSnapshot, make_decision
+
+
+def build_replay_input(*,
+    concern: dict[str, Any],
+    narrative_payload: dict[str, Any],
+    wallet_state: dict[str, Any],
+    strategies: list[dict[str, Any]],
+    history_window: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    """Capture the minimum full-fidelity input needed to replay a Hermes decision."""
+    return {
+        "concern": copy.deepcopy(concern),
+        "narrative_payload": copy.deepcopy(narrative_payload),
+        "wallet_state": copy.deepcopy(wallet_state),
+        "strategies": copy.deepcopy(strategies),
+        "history_window": copy.deepcopy(history_window or {}),
+    }
+
+
+def replay_decision(replay_input: dict[str, Any]) -> DecisionSnapshot:
+    return make_decision(
+        concern=copy.deepcopy(replay_input.get("concern") or {}),
+        narrative_payload=copy.deepcopy(replay_input.get("narrative_payload") or {}),
+        wallet_state=copy.deepcopy(replay_input.get("wallet_state") or {}),
+        strategies=copy.deepcopy(replay_input.get("strategies") or []),
+        history_window=copy.deepcopy(replay_input.get("history_window") or {}),
+    )
+
+
+def compare_to_baseline(*, replay_input: dict[str, Any], baseline: dict[str, Any]) -> dict[str, Any]:
+    replayed = replay_decision(replay_input)
+    changed = any(
+        [
+            replayed.mode != baseline.get("mode"),
+            replayed.action != baseline.get("action"),
+            replayed.target_strategy != baseline.get("target_strategy"),
+        ]
+    )
+    return {
+        "changed": changed,
+        "baseline": {
+            "mode": baseline.get("mode"),
+            "action": baseline.get("action"),
+            "target_strategy": baseline.get("target_strategy"),
+        },
+        "replayed": {
+            "mode": replayed.mode,
+            "action": replayed.action,
+            "target_strategy": replayed.target_strategy,
+            "reason_summary": replayed.reason_summary,
+            "confidence": replayed.confidence,
+        },
+        "decision_audit": replayed.payload.get("decision_audit") if isinstance(replayed.payload, dict) else {},
+    }

+ 15 - 0
src/hermes_mcp/server.py

@@ -19,6 +19,7 @@ from .argus_client import get_regime as argus_get_regime, get_snapshot as argus_
 from .crypto_client import get_price, get_regime
 from .decision_engine import assess_wallet_state, make_decision
 from .narrative_engine import build_narrative
+from .replay import build_replay_input
 from .state_engine import synthesize_state
 from .store import delete_concern, get_state, init_db, list_concerns, latest_cycle, latest_cycles, latest_decisions, latest_narratives, latest_observations, latest_regime_samples, prune_older_than, recent_regime_samples, recent_states_for_concern, sync_concerns_from_strategies, upsert_cycle, upsert_decision, upsert_narrative, upsert_observation, upsert_regime_sample, upsert_state, latest_states
 from .trader_client import apply_control_decision as trader_apply_control_decision, get_strategy as trader_get_strategy, list_strategies
@@ -265,6 +266,20 @@ async def lifespan(_: FastAPI):
                     )
                     decision_payload = {
                         **decision.payload,
+                        "replay_input": build_replay_input(
+                            concern=concern,
+                            narrative_payload={
+                                **state.payload,
+                                **narrative.payload,
+                                "confidence": narrative.confidence,
+                            },
+                            wallet_state=wallet_state,
+                            strategies=strategy_inventory,
+                            history_window={
+                                "window_seconds": breakout_window_seconds,
+                                "recent_states": recent_state_rows,
+                            },
+                        ),
                         "dispatch": dispatch_record,
                     }
                     upsert_decision(

+ 152 - 0
tests/test_decision_engine.py

@@ -886,6 +886,158 @@ def test_make_decision_replaces_rebalancer_with_grid_when_trend_is_directional_b
     assert decision.target_strategy == "grid-1"
 
 
+def test_make_decision_replaces_rebalancer_with_grid_when_micro_easing_restores_harvestability():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "constructive_bearish",
+        "confidence": 0.73,
+        "opportunity_map": {"continuation": 0.42, "mean_reversion": 0.38, "reversal": 0.08, "wait": 0.12},
+        "scoped_state": {
+            "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "centered", "reversal_risk": "low"},
+            "meso": {"structure": "trend_continuation", "momentum_bias": "bearish"},
+            "macro": {"bias": "bearish"},
+        },
+        "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
+        "features_by_timeframe": {
+            "1m": {
+                "raw": {"price": 1.01, "atr_percent": 0.42},
+                "volatility": {"bollinger_width_pct": 1.35},
+            },
+        },
+    }
+    wallet_state = {
+        "inventory_state": "quote_heavy",
+        "rebalance_needed": True,
+        "grid_ready": False,
+        "base_ratio": 0.29,
+        "quote_ratio": 0.71,
+    }
+    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": {"grid_step_pct": 0.005}},
+        {"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 == "act"
+    assert decision.action == "replace_with_grid"
+    assert decision.target_strategy == "grid-1"
+    assert decision.payload["decision_audit"]["tactical_easing"] is True
+    assert decision.payload["decision_audit"]["grid_harvestable_now"] is True
+    assert decision.payload["decision_audit"]["rebalancer_release_ready"] is True
+
+
+def test_make_decision_replaces_rebalancer_with_grid_near_local_bottom_when_noise_exceeds_grid_size():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "cautious_bearish",
+        "confidence": 0.69,
+        "opportunity_map": {"continuation": 0.34, "mean_reversion": 0.44, "reversal": 0.1, "wait": 0.12},
+        "scoped_state": {
+            "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "near_lower_band", "reversal_risk": "medium"},
+            "meso": {"structure": "trend_continuation", "momentum_bias": "bearish"},
+            "macro": {"bias": "bearish"},
+        },
+        "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
+        "features_by_timeframe": {
+            "1m": {
+                "raw": {"price": 0.992, "atr_percent": 0.48},
+                "volatility": {"bollinger_width_pct": 1.6},
+            },
+        },
+    }
+    wallet_state = {
+        "inventory_state": "quote_heavy",
+        "rebalance_needed": True,
+        "grid_ready": False,
+        "base_ratio": 0.31,
+        "quote_ratio": 0.69,
+    }
+    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": {"grid_step_pct": 0.005}},
+    ]
+    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 decision.payload["decision_audit"]["pullback_to_grid_ratio"] is not None
+    assert decision.payload["decision_audit"]["pullback_to_grid_ratio"] > 2.0
+
+
+def test_make_decision_replaces_grid_with_trend_when_pullbacks_are_too_shallow_for_grid_step():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "generated_at": "2026-04-19T19:05:00+00:00",
+        "stance": "constructive_bullish",
+        "confidence": 0.88,
+        "opportunity_map": {"continuation": 0.84, "mean_reversion": 0.05, "reversal": 0.03, "wait": 0.08},
+        "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": 104.2, "atr_percent": 0.11},
+                "volatility": {"bollinger_width_pct": 0.24},
+            },
+        },
+    }
+    wallet_state = {
+        "inventory_state": "base_heavy",
+        "rebalance_needed": True,
+        "grid_ready": False,
+        "base_ratio": 0.67,
+        "quote_ratio": 0.33,
+    }
+    strategies = [
+        {
+            "id": "grid-1",
+            "strategy_type": "grid_trader",
+            "mode": "active",
+            "account_id": "a1",
+            "market_symbol": "xrpusd",
+            "state": {
+                "last_price": 104.2,
+                "center_price": 100.0,
+                "orders": [
+                    {"side": "sell", "status": "open", "price": "104.7", "amount": "5"},
+                    {"side": "buy", "status": "open", "price": "103.7", "amount": "5"},
+                ],
+            },
+            "config": {"grid_step_pct": 0.005},
+        },
+        {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "account_id": "a1", "state": {}, "config": {}},
+        {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
+    ]
+    history_window = {
+        "window_seconds": 15 * 60,
+        "recent_states": [
+            {
+                "created_at": "2026-04-19T18:55:00+00:00",
+                "payload_json": '{"scoped_state":{"micro":{"impulse":"up","trend_bias":"bullish"},"meso":{"structure":"trend_continuation","momentum_bias":"bullish"},"macro":{"bias":"bullish"}},"cross_scope_summary":{"alignment":"micro_meso_macro_aligned","friction":"low"}}',
+            },
+            {
+                "created_at": "2026-04-19T19:00:30+00:00",
+                "payload_json": '{"scoped_state":{"micro":{"impulse":"up","trend_bias":"bullish"},"meso":{"structure":"trend_continuation","momentum_bias":"bullish"},"macro":{"bias":"bullish"}},"cross_scope_summary":{"alignment":"micro_meso_macro_aligned","friction":"low"}}',
+            },
+        ],
+    }
+    decision = make_decision(
+        concern=concern,
+        narrative_payload=narrative,
+        wallet_state=wallet_state,
+        strategies=strategies,
+        history_window=history_window,
+    )
+    assert decision.mode == "act"
+    assert decision.action == "replace_with_trend_follower"
+    assert decision.target_strategy == "trend-1"
+    assert decision.payload["decision_audit"]["pullback_to_grid_ratio"] < 1.0
+    assert decision.payload["decision_audit"]["trend_following_pressure"] is True
+
+
 def test_make_decision_replaces_rebalancer_with_trend_when_breakout_is_still_strong():
     concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     narrative = {

+ 37 - 0
tests/test_replay.py

@@ -0,0 +1,37 @@
+from hermes_mcp.replay import build_replay_input, compare_to_baseline
+
+
+def test_compare_to_baseline_replays_decision_and_exposes_audit():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "neutral_rotational",
+        "confidence": 0.7,
+        "opportunity_map": {"continuation": 0.18, "mean_reversion": 0.68, "reversal": 0.05, "wait": 0.09},
+    }
+    wallet_state = {
+        "inventory_state": "balanced",
+        "rebalance_needed": False,
+        "grid_ready": True,
+        "base_ratio": 0.5,
+        "quote_ratio": 0.5,
+    }
+    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": {}},
+    ]
+    replay_input = build_replay_input(
+        concern=concern,
+        narrative_payload=narrative,
+        wallet_state=wallet_state,
+        strategies=strategies,
+        history_window={"window_seconds": 900, "recent_states": []},
+    )
+
+    result = compare_to_baseline(
+        replay_input=replay_input,
+        baseline={"mode": "act", "action": "replace_with_grid", "target_strategy": "grid-1"},
+    )
+
+    assert result["changed"] is False
+    assert result["replayed"]["action"] == "replace_with_grid"
+    assert result["decision_audit"]["within_rebalance_tolerance"] is True