瀏覽代碼

Add replay harness and decision tuning docs

Lukas Goldschmidt 3 周之前
父節點
當前提交
8157c558d4

+ 30 - 0
README.md

@@ -39,3 +39,33 @@ When the gate is false, Hermes still makes and records decisions, but returns a
 ```bash
 ```bash
 ./tests.sh
 ./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
     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:
 def _strategy_trade_side(strategy: dict[str, Any]) -> str:
     config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
     config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
     state = strategy.get("state") if isinstance(strategy.get("state"), 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_fill: dict[str, Any],
     grid_pressure: dict[str, Any],
     grid_pressure: dict[str, Any],
     directional_micro_clear: bool,
     directional_micro_clear: bool,
+    decision_signals: dict[str, Any],
     trend: dict[str, Any] | None,
     trend: dict[str, Any] | None,
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
     inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
@@ -730,12 +936,15 @@ def _grid_switch_tradeoff(*,
             base_order_notional = candidate_value
             base_order_notional = candidate_value
 
 
     trend_score = float(trend.get("score") or 0.0) if trend else 0.0
     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)
     breakout_score = float(breakout.get("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)
     persistent = bool(breakout.get("persistent"))
     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
     stay_cost = 0.0
     switch_benefit = 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.
     # Requirement: ignore nearby fill timing/side when estimating the stay-vs-switch tradeoff.
     if levels >= _trend_handoff_level_threshold(breakout):
     if levels >= _trend_handoff_level_threshold(breakout):
         switch_benefit += 0.18
         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
     switch_benefit += min(breakout_score, 5.0) * 0.04
 
 
     if adverse_side in {"buy", "sell"} and adverse_count > 0:
     if adverse_side in {"buy", "sell"} and adverse_count > 0:
@@ -773,11 +984,15 @@ def _grid_switch_tradeoff(*,
         stay_cost += 0.12
         stay_cost += 0.12
     if adverse_notional_ratio >= 1.0:
     if adverse_notional_ratio >= 1.0:
         stay_cost += 0.08
         stay_cost += 0.08
+    stay_cost += harvestability_score * 0.18
 
 
     margin = round(switch_benefit - stay_cost, 4)
     margin = round(switch_benefit - stay_cost, 4)
     should_switch = persistent and trend_ready and margin > 0.0
     should_switch = persistent and trend_ready and margin > 0.0
     return {
     return {
         "trend_score": round(trend_score, 4),
         "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),
         "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),
@@ -871,6 +1086,7 @@ def _decide_for_grid(*,
     grid_pressure: dict[str, Any],
     grid_pressure: dict[str, Any],
     directional_micro_clear: bool,
     directional_micro_clear: bool,
     severe_imbalance: bool,
     severe_imbalance: bool,
+    decision_signals: dict[str, Any],
     trend: dict[str, Any] | None,
     trend: dict[str, Any] | None,
     rebalance: dict[str, Any] | None,
     rebalance: dict[str, Any] | None,
 ) -> tuple[str, str, str | None, list[str], list[str]]:
 ) -> 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")
     breakout_phase = str(breakout.get("phase") or "none")
     trend_handoff_ready = bool(
     trend_handoff_ready = bool(
         trend
         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)
         and grid_pressure.get("levels", 0.0) >= _trend_handoff_level_threshold(breakout)
     )
     )
     fill_fights_breakout = _grid_fill_fights_breakout(grid_fill, breakout)
     fill_fights_breakout = _grid_fill_fights_breakout(grid_fill, breakout)
@@ -902,6 +1117,7 @@ def _decide_for_grid(*,
         grid_fill=grid_fill,
         grid_fill=grid_fill,
         grid_pressure=grid_pressure,
         grid_pressure=grid_pressure,
         directional_micro_clear=directional_micro_clear,
         directional_micro_clear=directional_micro_clear,
+        decision_signals=decision_signals,
         trend=trend,
         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")
             reasons.append("breakout pressure is developing, but grid can still work and should not be abandoned yet")
         else:
         else:
             reasons.append("grid can still operate and self-heal, so inventory skew alone should not force a rebalance handoff")
             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"}:
     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")
         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:
     elif not grid_friendly_stance and persistent_breakout:
@@ -1043,6 +1261,7 @@ def _decide_for_rebalancer(*,
     stance: str,
     stance: str,
     wallet_state: dict[str, Any],
     wallet_state: dict[str, Any],
     grid: dict[str, Any] | None,
     grid: dict[str, Any] | None,
+    decision_signals: dict[str, Any],
     trend: dict[str, Any] | None = None,
     trend: dict[str, Any] | None = None,
 ) -> tuple[str, str, str | None, list[str], list[str]]:
 ) -> tuple[str, str, str | None, list[str], list[str]]:
     action = "keep_rebalancer"
     action = "keep_rebalancer"
@@ -1054,16 +1273,23 @@ def _decide_for_rebalancer(*,
     # Rebalancing is a repair phase. Once the wallet is usable again, Hermes
     # Rebalancing is a repair phase. Once the wallet is usable again, Hermes
     # should prefer handing back to grid, not directly to trend.
     # 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
     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")
         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:
         if grid:
             action = "replace_with_grid"
             action = "replace_with_grid"
             target_strategy = grid["strategy_id"]
             target_strategy = grid["strategy_id"]
             mode = "act"
             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:
         else:
             blocks.append("wallet is within the rebalance tolerance but no grid candidate is available")
             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":
     elif wallet_state.get("grid_ready") and stance == "neutral_rotational":
         if grid and grid["score"] >= 0.5:
         if grid and grid["score"] >= 0.5:
             action = "replace_with_grid"
             action = "replace_with_grid"
@@ -1072,11 +1298,11 @@ def _decide_for_rebalancer(*,
             reasons.append("rebalance is complete and rotational conditions support grid again")
             reasons.append("rebalance is complete and rotational conditions support grid again")
         else:
         else:
             blocks.append("wallet is ready but grid fit is still too weak")
             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"
         action = "replace_with_grid"
         target_strategy = grid["strategy_id"]
         target_strategy = grid["strategy_id"]
         mode = "act"
         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:
     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")
         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)
     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)
     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 = 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] = {}
     switch_tradeoff: dict[str, Any] = {}
 
 
     if current_primary and current_primary["strategy_type"] == "grid_trader":
     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,
             grid_pressure=grid_pressure,
             directional_micro_clear=directional_micro_clear,
             directional_micro_clear=directional_micro_clear,
             severe_imbalance=severe_imbalance,
             severe_imbalance=severe_imbalance,
+            decision_signals=decision_signals,
             trend=trend,
             trend=trend,
             rebalance=rebalance,
             rebalance=rebalance,
         )
         )
@@ -1137,6 +1371,7 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
             grid_fill=grid_fill,
             grid_fill=grid_fill,
             grid_pressure=grid_pressure,
             grid_pressure=grid_pressure,
             directional_micro_clear=directional_micro_clear,
             directional_micro_clear=directional_micro_clear,
+            decision_signals=decision_signals,
             trend=trend,
             trend=trend,
         )
         )
     elif current_primary and current_primary["strategy_type"] == "trend_follower":
     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,
             stance=stance,
             wallet_state=wallet_state,
             wallet_state=wallet_state,
             grid=grid,
             grid=grid,
+            decision_signals=decision_signals,
             trend=trend,
             trend=trend,
         )
         )
     else:
     else:
@@ -1186,9 +1422,10 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
         "grid_breakout_pressure": breakout,
         "grid_breakout_pressure": breakout,
         "grid_fill_context": grid_fill,
         "grid_fill_context": grid_fill,
         "grid_switch_tradeoff": switch_tradeoff if current_primary and current_primary["strategy_type"] == "grid_trader" else {},
         "grid_switch_tradeoff": switch_tradeoff if current_primary and current_primary["strategy_type"] == "grid_trader" else {},
+        "decision_audit": decision_signals,
         "reason_chain": reasons,
         "reason_chain": reasons,
         "blocks": blocks,
         "blocks": blocks,
-        "decision_version": 2,
+        "decision_version": 3,
     }
     }
 
 
     return DecisionSnapshot(
     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
     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:
 def build_narrative(*, concern: dict[str, Any], state_payload: dict[str, Any]) -> NarrativeSnapshot:
     scoped = state_payload.get("scoped_state") or {}
     scoped = state_payload.get("scoped_state") or {}
     micro = scoped.get("micro") 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 {}
     macro = scoped.get("macro") or {}
     cross = state_payload.get("cross_scope_summary") or {}
     cross = state_payload.get("cross_scope_summary") or {}
     argus = state_payload.get("argus_context") 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")
     market_symbol = str(concern.get("market_symbol") or state_payload.get("market_symbol") or "market")
     drivers: list[str] = []
     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:
     if stance not in STANCE_TAXONOMY:
         stance = "neutral_rotational"
         stance = "neutral_rotational"
 
 
+    decision_inputs = _derive_decision_inputs(scoped=scoped, cross=cross, features_by_timeframe=features_by_timeframe)
+
     opportunity_raw = {
     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"}:
     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"}:
     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["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":
     if stance == "reversal_watch":
-        opportunity_raw["reversal"] += 0.45
-        opportunity_raw["wait"] += 0.2
-    if micro_reversal_risk == "high":
         opportunity_raw["reversal"] += 0.2
         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)
     opportunity_map = _rounded_weights(opportunity_raw)
 
 
@@ -212,6 +311,7 @@ def build_narrative(*, concern: dict[str, Any], state_payload: dict[str, Any]) -
         "stance": stance,
         "stance": stance,
         "market_story": market_story,
         "market_story": market_story,
         "invalidators": invalidators,
         "invalidators": invalidators,
+        "decision_inputs": decision_inputs,
         "opportunity_map": opportunity_map,
         "opportunity_map": opportunity_map,
         "cross_scope_alignment": alignment,
         "cross_scope_alignment": alignment,
         "argus_context": argus,
         "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 .crypto_client import get_price, get_regime
 from .decision_engine import assess_wallet_state, make_decision
 from .decision_engine import assess_wallet_state, make_decision
 from .narrative_engine import build_narrative
 from .narrative_engine import build_narrative
+from .replay import build_replay_input
 from .state_engine import synthesize_state
 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 .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
 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 = {
                         **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,
                         "dispatch": dispatch_record,
                     }
                     }
                     upsert_decision(
                     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"
     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():
 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"}
     concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     narrative = {
     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