Bläddra i källkod

chore(release): v0.1.1

Lukas Goldschmidt 3 veckor sedan
förälder
incheckning
f129a77d0c

+ 1 - 0
.env.example

@@ -8,4 +8,5 @@ HERMES_CRYPTO_TIMEFRAMES=1m,5m,15m,1h,4h,1d
 HERMES_RETENTION_DAYS=7
 HERMES_RETENTION_DAYS=7
 HERMES_PRUNE_INTERVAL_HOURS=6
 HERMES_PRUNE_INTERVAL_HOURS=6
 HERMES_CYCLE_SECONDS=60
 HERMES_CYCLE_SECONDS=60
+HERMES_BREAKOUT_MEMORY_WINDOW_SECONDS=900
 HERMES_ALLOW_AUTO_ACTIONS=false
 HERMES_ALLOW_AUTO_ACTIONS=false

+ 6 - 0
RELEASE_NOTES.md

@@ -0,0 +1,6 @@
+# Release notes
+
+## 0.1.1
+- Argus context now flows into Hermes state, narrative, and decision logic.
+- Breakout confirmation is now time-window aware and less conservative once sustained.
+- Grid-to-trend handoff now yields faster when a confirmed trend is real and nearby grid fills fight it.

+ 1 - 1
pyproject.toml

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 
 [project]
 [project]
 name = "hermes-mcp"
 name = "hermes-mcp"
-version = "0.1.0"
+version = "0.1.1"
 description = "Hermes MCP server"
 description = "Hermes MCP server"
 requires-python = ">=3.11"
 requires-python = ">=3.11"
 dependencies = [
 dependencies = [

+ 3 - 0
src/hermes_mcp/__init__.py

@@ -1 +1,4 @@
 """Hermes MCP package."""
 """Hermes MCP package."""
+
+__all__ = ["__version__"]
+__version__ = "0.1.1"

+ 54 - 0
src/hermes_mcp/argus_client.py

@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+from datetime import timedelta
+
+from mcp import ClientSession
+from mcp.client.sse import sse_client
+
+
+def _payload_from_result(result: Any) -> dict[str, Any]:
+    payload = getattr(result, "structuredContent", None)
+    if isinstance(payload, dict):
+        return payload
+
+    for item in getattr(result, "content", []) or []:
+        text = getattr(item, "text", None)
+        if not isinstance(text, str) or not text.strip():
+            continue
+        try:
+            decoded = json.loads(text)
+        except Exception:
+            continue
+        if isinstance(decoded, dict):
+            return decoded
+    return {}
+
+
+async def get_snapshot(base_url: str) -> dict[str, Any]:
+    url = (base_url or "").strip()
+    if not url:
+        return {}
+    if not url.endswith("/mcp/sse"):
+        url = url.rstrip("/") + "/mcp/sse"
+
+    async with sse_client(url, timeout=8.0, sse_read_timeout=8.0) as streams:
+        async with ClientSession(*streams, read_timeout_seconds=timedelta(seconds=8)) as session:
+            await session.initialize()
+            result = await session.call_tool("get_snapshot", {})
+            return _payload_from_result(result)
+
+
+async def get_regime(base_url: str) -> dict[str, Any]:
+    url = (base_url or "").strip()
+    if not url:
+        return {}
+    if not url.endswith("/mcp/sse"):
+        url = url.rstrip("/") + "/mcp/sse"
+
+    async with sse_client(url, timeout=8.0, sse_read_timeout=8.0) as streams:
+        async with ClientSession(*streams, read_timeout_seconds=timedelta(seconds=8)) as session:
+            await session.initialize()
+            result = await session.call_tool("get_regime", {})
+            return _payload_from_result(result)

+ 4 - 0
src/hermes_mcp/config.py

@@ -52,6 +52,7 @@ def _env_int(name: str, default: int) -> int:
 @dataclass(frozen=True)
 @dataclass(frozen=True)
 class HermesConfig:
 class HermesConfig:
     trader_url: str
     trader_url: str
+    argus_url: str
     crypto_url: str
     crypto_url: str
     metals_url: str
     metals_url: str
     news_url: str
     news_url: str
@@ -61,6 +62,7 @@ class HermesConfig:
     retention_days: int
     retention_days: int
     prune_interval_hours: int
     prune_interval_hours: int
     cycle_seconds: int
     cycle_seconds: int
+    breakout_memory_window_seconds: int
     hermes_allow_actions: bool
     hermes_allow_actions: bool
 
 
 
 
@@ -72,6 +74,7 @@ def load_config() -> HermesConfig:
     )
     )
     return HermesConfig(
     return HermesConfig(
         trader_url=_env("HERMES_TRADER_URL", "http://127.0.0.1:8570"),
         trader_url=_env("HERMES_TRADER_URL", "http://127.0.0.1:8570"),
+        argus_url=_env("HERMES_ARGUS_URL", "http://127.0.0.1:8520"),
         crypto_url=_env("HERMES_CRYPTO_URL", "http://127.0.0.1:8580"),
         crypto_url=_env("HERMES_CRYPTO_URL", "http://127.0.0.1:8580"),
         metals_url=_env("HERMES_METALS_URL", "http://127.0.0.1:8591"),
         metals_url=_env("HERMES_METALS_URL", "http://127.0.0.1:8591"),
         news_url=_env("HERMES_NEWS_URL", "http://127.0.0.1:8600"),
         news_url=_env("HERMES_NEWS_URL", "http://127.0.0.1:8600"),
@@ -81,5 +84,6 @@ def load_config() -> HermesConfig:
         retention_days=_env_int("HERMES_RETENTION_DAYS", 7),
         retention_days=_env_int("HERMES_RETENTION_DAYS", 7),
         prune_interval_hours=_env_int("HERMES_PRUNE_INTERVAL_HOURS", 6),
         prune_interval_hours=_env_int("HERMES_PRUNE_INTERVAL_HOURS", 6),
         cycle_seconds=_env_int("HERMES_CYCLE_SECONDS", 60),
         cycle_seconds=_env_int("HERMES_CYCLE_SECONDS", 60),
+        breakout_memory_window_seconds=_env_int("HERMES_BREAKOUT_MEMORY_WINDOW_SECONDS", 900),
         hermes_allow_actions=_env_bool_any("HERMES_ALLOW_ACTIONS", "HERMES_ALLOW_AUTO_ACTIONS", default=False),
         hermes_allow_actions=_env_bool_any("HERMES_ALLOW_ACTIONS", "HERMES_ALLOW_AUTO_ACTIONS", default=False),
     )
     )

+ 46 - 5
src/hermes_mcp/dashboard.py

@@ -115,6 +115,28 @@ def overview():
             return String(value || '');
             return String(value || '');
           }}
           }}
         }}
         }}
+        function formatAge(value) {{
+          try {{
+            const ts = new Date(value).getTime();
+            if (!Number.isFinite(ts)) return '-';
+            const deltaSec = Math.max(0, Math.round((Date.now() - ts) / 1000));
+            if (deltaSec < 60) return `${{deltaSec}}s ago`;
+            const deltaMin = Math.round(deltaSec / 60);
+            if (deltaMin < 60) return `${{deltaMin}}m ago`;
+            const deltaHr = Math.round(deltaMin / 60);
+            if (deltaHr < 24) return `${{deltaHr}}h ago`;
+            return `${{Math.round(deltaHr / 24)}}d ago`;
+          }} catch {{
+            return '-';
+          }}
+        }}
+        function parseJson(value, fallback) {{
+          try {{
+            return JSON.parse(value || 'null') ?? fallback;
+          }} catch {{
+            return fallback;
+          }}
+        }}
         function decisionChanges(rows) {{
         function decisionChanges(rows) {{
           const grouped = new Map();
           const grouped = new Map();
           for (const row of rows || []) {{
           for (const row of rows || []) {{
@@ -197,9 +219,12 @@ def overview():
           document.getElementById('regimes-body').innerHTML = `<div class='regime-grid'>${{cards}}</div>`;
           document.getElementById('regimes-body').innerHTML = `<div class='regime-grid'>${{cards}}</div>`;
           const latestStates = latestByConcern(data.state_samples || []);
           const latestStates = latestByConcern(data.state_samples || []);
           const prevStates = previousByConcern(data.state_samples || []);
           const prevStates = previousByConcern(data.state_samples || []);
+          const latestStateByConcern = new Map(latestStates.map(s => [String(s.concern_id || ''), s]));
+          const latestArgusObservation = (data.argus_observations || [])[0] || null;
+          const latestArgusPayload = parseJson(latestArgusObservation?.payload_json, {{}});
           document.getElementById('states-body').innerHTML = latestStates.map(s => {
           document.getElementById('states-body').innerHTML = latestStates.map(s => {
             const hasChanged = changed(s, prevStates.get(String(s.concern_id || '')), ['market_regime','volatility_state','sentiment_pressure','event_risk','execution_quality']);
             const hasChanged = changed(s, prevStates.get(String(s.concern_id || '')), ['market_regime','volatility_state','sentiment_pressure','event_risk','execution_quality']);
-            const payload = (() => { try { return JSON.parse(s.payload_json || '{}'); } catch { return {}; } })();
+            const payload = parseJson(s.payload_json, {});
             const micro = payload.scoped_state?.micro || {};
             const micro = payload.scoped_state?.micro || {};
             const meso = payload.scoped_state?.meso || {};
             const meso = payload.scoped_state?.meso || {};
             const macro = payload.scoped_state?.macro || {};
             const macro = payload.scoped_state?.macro || {};
@@ -226,13 +251,29 @@ def overview():
           const prevNarratives = previousByConcern(data.narrative_samples || []);
           const prevNarratives = previousByConcern(data.narrative_samples || []);
           document.getElementById('narratives-body').innerHTML = latestNarratives.map(n => {
           document.getElementById('narratives-body').innerHTML = latestNarratives.map(n => {
             const hasChanged = changed(n, prevNarratives.get(String(n.concern_id || '')), ['summary','confidence']);
             const hasChanged = changed(n, prevNarratives.get(String(n.concern_id || '')), ['summary','confidence']);
-            const drivers = (() => { try { return JSON.parse(n.key_drivers_json || '[]'); } catch { return []; } })();
-            const risks = (() => { try { return JSON.parse(n.risk_flags_json || '[]'); } catch { return []; } })();
-            const uncertainties = (() => { try { return JSON.parse(n.uncertainties_json || '[]'); } catch { return []; } })();
+            const drivers = parseJson(n.key_drivers_json, []);
+            const risks = parseJson(n.risk_flags_json, []);
+            const uncertainties = parseJson(n.uncertainties_json, []);
+            const stateRow = latestStateByConcern.get(String(n.concern_id || '')) || null;
+            const statePayload = parseJson(stateRow?.payload_json, {});
+            const argus = statePayload.argus_context || {};
+            const snapshot = latestArgusPayload.snapshot || {};
+            const regime = latestArgusPayload.regime || {};
+            const argusRegime = argus.regime || argus.snapshot_regime || regime.regime || snapshot.regime || '-';
+            const argusSummary = argus.regime_summary || argus.snapshot_summary || regime.summary || snapshot.summary || '';
+            const argusConfidence = argus.regime_confidence ?? argus.snapshot_confidence ?? regime.confidence ?? snapshot.confidence;
+            const argusFreshAt = latestArgusObservation?.observed_at || '';
             return `
             return `
             <tr class='${hasChanged ? 'recent-change' : ''}'>
             <tr class='${hasChanged ? 'recent-change' : ''}'>
               <td class='focus-cell'>${n.concern_id || ''}</td>
               <td class='focus-cell'>${n.concern_id || ''}</td>
-              <td>${n.summary || ''}</td>
+              <td>
+                <div>${n.summary || ''}</div>
+                <div class='small' style='margin-top:6px;padding:6px 8px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;'>
+                  <strong>Argus</strong>: ${argusRegime}${typeof argusConfidence === 'number' ? ` (${argusConfidence.toFixed(2)})` : ''}
+                  <div class='small'>freshness: ${argusFreshAt ? `${formatAge(argusFreshAt)}, ${formatLocalTime(argusFreshAt)}` : 'no Argus sample yet'}</div>
+                  ${argusSummary ? `<div class='small'>${argusSummary}</div>` : ''}
+                </div>
+              </td>
               <td>${drivers.join('<br>') || '-'}</td>
               <td>${drivers.join('<br>') || '-'}</td>
               <td>${risks.join('<br>') || '-'}</td>
               <td>${risks.join('<br>') || '-'}</td>
               <td>${uncertainties.join('<br>') || '-'}</td>
               <td>${uncertainties.join('<br>') || '-'}</td>

+ 235 - 18
src/hermes_mcp/decision_engine.py

@@ -12,6 +12,7 @@ Design intent:
 - switch cleanly between directional, range, and rebalancing phases
 - switch cleanly between directional, range, and rebalancing phases
 """
 """
 
 
+import json
 from dataclasses import dataclass
 from dataclasses import dataclass
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 from typing import Any
 from typing import Any
@@ -224,14 +225,51 @@ def normalize_strategy_snapshot(strategy: dict[str, Any]) -> dict[str, Any]:
     }
     }
 
 
 
 
+def _argus_decision_context(narrative_payload: dict[str, Any]) -> dict[str, Any]:
+    argus = narrative_payload.get("argus_context") if isinstance(narrative_payload.get("argus_context"), dict) else {}
+    regime = str(argus.get("regime") or argus.get("snapshot_regime") or "").strip()
+    confidence_raw = argus.get("regime_confidence") if argus.get("regime_confidence") is not None else argus.get("snapshot_confidence")
+    confidence = float(confidence_raw) if isinstance(confidence_raw, (int, float)) else 0.0
+    components = argus.get("regime_components") if isinstance(argus.get("regime_components"), dict) else {}
+    if not components:
+        components = argus.get("snapshot_components") if isinstance(argus.get("snapshot_components"), dict) else {}
+    compression = float(components.get("compression") or 0.0)
+    compression_active = regime == "compression" and confidence >= 0.55 and compression >= 0.65
+    return {
+        "regime": regime,
+        "confidence": round(confidence, 4),
+        "components": components,
+        "compression": round(compression, 4),
+        "compression_active": compression_active,
+    }
+
+
+def _parse_timestamp(value: Any) -> datetime | None:
+    text = str(value or "").strip()
+    if not text:
+        return None
+    if text.endswith("Z"):
+        text = text[:-1] + "+00:00"
+    try:
+        parsed = datetime.fromisoformat(text)
+    except Exception:
+        return None
+    if parsed.tzinfo is None:
+        return parsed.replace(tzinfo=timezone.utc)
+    return parsed.astimezone(timezone.utc)
+
+
 def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], wallet_state: dict[str, Any]) -> dict[str, Any]:
 def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], wallet_state: dict[str, Any]) -> dict[str, Any]:
     stance = str(narrative.get("stance") or "neutral_rotational")
     stance = str(narrative.get("stance") or "neutral_rotational")
     opportunity_map = narrative.get("opportunity_map") if isinstance(narrative.get("opportunity_map"), dict) else {}
     opportunity_map = narrative.get("opportunity_map") if isinstance(narrative.get("opportunity_map"), dict) else {}
+    breakout_pressure = narrative.get("grid_breakout_pressure") if isinstance(narrative.get("grid_breakout_pressure"), dict) else {}
+    breakout_phase = str(breakout_pressure.get("phase") or "none")
     continuation = float(opportunity_map.get("continuation") or 0.0)
     continuation = float(opportunity_map.get("continuation") or 0.0)
     mean_reversion = float(opportunity_map.get("mean_reversion") or 0.0)
     mean_reversion = float(opportunity_map.get("mean_reversion") or 0.0)
     reversal = float(opportunity_map.get("reversal") or 0.0)
     reversal = float(opportunity_map.get("reversal") or 0.0)
     wait = float(opportunity_map.get("wait") or 0.0)
     wait = float(opportunity_map.get("wait") or 0.0)
     inventory_state = str(wallet_state.get("inventory_state") or "unknown")
     inventory_state = str(wallet_state.get("inventory_state") or "unknown")
+    argus_context = _argus_decision_context(narrative)
 
 
     strategy_type = strategy["strategy_type"]
     strategy_type = strategy["strategy_type"]
     supervision = strategy.get("supervision") if isinstance(strategy.get("supervision"), dict) else {}
     supervision = strategy.get("supervision") if isinstance(strategy.get("supervision"), dict) else {}
@@ -261,12 +299,21 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
         if side_capacity and not (bool(side_capacity.get("buy", True)) and bool(side_capacity.get("sell", True))):
         if side_capacity and not (bool(side_capacity.get("buy", True)) and bool(side_capacity.get("sell", True))):
             score -= 0.25
             score -= 0.25
             blocks.append("grid side capacity is asymmetric")
             blocks.append("grid side capacity is asymmetric")
+        if argus_context["compression_active"]:
+            score += 0.2
+            reasons.append("Argus compression supports staying selective with grid")
     elif strategy_type == "trend_follower":
     elif strategy_type == "trend_follower":
         score += continuation * 1.9
         score += continuation * 1.9
         if stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
         if stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
             score += 0.5
             score += 0.5
             reasons.append("narrative supports directional continuation")
             reasons.append("narrative supports directional continuation")
-        if wait >= 0.45:
+        if breakout_phase == "confirmed":
+            score += 0.45
+            reasons.append("confirmed breakout pressure supports directional continuation")
+        elif breakout_phase == "developing":
+            score += 0.2
+            reasons.append("breakout pressure is developing in trend's favor")
+        if wait >= 0.45 and breakout_phase != "confirmed":
             score -= 0.35
             score -= 0.35
             blocks.append("market still has too much wait/uncertainty for trend commitment")
             blocks.append("market still has too much wait/uncertainty for trend commitment")
         if inventory_state in {"depleted_quote_side", "critically_unbalanced"}:
         if inventory_state in {"depleted_quote_side", "critically_unbalanced"}:
@@ -278,6 +325,9 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
         if not capacity_available:
         if not capacity_available:
             score -= 0.1
             score -= 0.1
             blocks.append("trend strength is below its own capacity threshold")
             blocks.append("trend strength is below its own capacity threshold")
+        if argus_context["compression_active"] and breakout_phase != "confirmed":
+            score -= 0.15
+            blocks.append("Argus compression says the broader tape is still range-like")
     elif strategy_type == "exposure_protector":
     elif strategy_type == "exposure_protector":
         score += reversal * 0.4 + wait * 0.5
         score += reversal * 0.4 + wait * 0.5
         if wallet_state.get("rebalance_needed"):
         if wallet_state.get("rebalance_needed"):
@@ -309,7 +359,17 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
     }
     }
 
 
 
 
-def _grid_breakout_pressure(narrative_payload: dict[str, Any]) -> dict[str, Any]:
+def _breakout_phase_from_score(score: float) -> str:
+    if score >= 3.45:
+        return "confirmed"
+    if score >= 2.45:
+        return "developing"
+    if score >= 1.4:
+        return "probing"
+    return "none"
+
+
+def _local_breakout_snapshot(narrative_payload: dict[str, Any]) -> dict[str, Any]:
     scoped = narrative_payload.get("scoped_state") if isinstance(narrative_payload.get("scoped_state"), dict) else {}
     scoped = narrative_payload.get("scoped_state") if isinstance(narrative_payload.get("scoped_state"), dict) else {}
     cross = narrative_payload.get("cross_scope_summary") if isinstance(narrative_payload.get("cross_scope_summary"), dict) else {}
     cross = narrative_payload.get("cross_scope_summary") if isinstance(narrative_payload.get("cross_scope_summary"), dict) else {}
     micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
     micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
@@ -327,10 +387,26 @@ def _grid_breakout_pressure(narrative_payload: dict[str, Any]) -> dict[str, Any]
     micro_directional = micro_impulse in {"up", "down"} and micro_bias in {"bullish", "bearish"}
     micro_directional = micro_impulse in {"up", "down"} and micro_bias in {"bullish", "bearish"}
     meso_directional = meso_structure == "trend_continuation" and meso_bias in {"bullish", "bearish"}
     meso_directional = meso_structure == "trend_continuation" and meso_bias in {"bullish", "bearish"}
     macro_supportive = macro_bias in {"bullish", "bearish"}
     macro_supportive = macro_bias in {"bullish", "bearish"}
-    persistent = micro_directional and meso_directional and macro_supportive and alignment == "micro_meso_macro_aligned" and friction == "low"
+
+    score = 0.0
+    if micro_directional:
+        score += 1.0
+    if meso_directional:
+        score += 1.1
+    if macro_supportive:
+        score += 0.55
+    if alignment == "micro_meso_macro_aligned":
+        score += 0.8
+    elif alignment == "partial_alignment":
+        score += 0.35
+    if friction == "low":
+        score += 0.45
+    elif friction == "medium":
+        score += 0.15
 
 
     return {
     return {
-        "persistent": persistent,
+        "score": round(score, 4),
+        "phase": _breakout_phase_from_score(score),
         "micro_impulse": micro_impulse,
         "micro_impulse": micro_impulse,
         "micro_bias": micro_bias,
         "micro_bias": micro_bias,
         "meso_structure": meso_structure,
         "meso_structure": meso_structure,
@@ -341,6 +417,76 @@ def _grid_breakout_pressure(narrative_payload: dict[str, Any]) -> dict[str, Any]
     }
     }
 
 
 
 
+def _breakout_memory(narrative_payload: dict[str, Any], history_window: dict[str, Any] | None, current_breakout: dict[str, Any]) -> dict[str, Any]:
+    recent_states = history_window.get("recent_states") if isinstance(history_window, dict) and isinstance(history_window.get("recent_states"), list) else []
+    window_seconds = int(history_window.get("window_seconds") or 0) if isinstance(history_window, dict) else 0
+    current_ts = _parse_timestamp(narrative_payload.get("generated_at")) or datetime.now(timezone.utc)
+    current_direction = str(current_breakout.get("meso_bias") or "neutral")
+    directional = current_direction in {"bullish", "bearish"} and current_breakout.get("meso_structure") == "trend_continuation"
+    if not directional:
+        return {"window_seconds": window_seconds, "samples_considered": 0, "qualifying_samples": 0, "same_direction_seconds": 0, "promoted_to_confirmed": False}
+
+    qualifying_samples = 0
+    oldest_match: datetime | None = None
+    for row in recent_states:
+        if not isinstance(row, dict):
+            continue
+        try:
+            payload = json.loads(row.get("payload_json") or "{}")
+        except Exception:
+            continue
+        snapshot = _local_breakout_snapshot(payload)
+        sample_ts = _parse_timestamp(row.get("created_at") or payload.get("generated_at"))
+        if sample_ts is None:
+            continue
+        if snapshot.get("phase") not in {"developing", "confirmed"}:
+            continue
+        if str(snapshot.get("meso_bias") or "neutral") != current_direction:
+            continue
+        if str(snapshot.get("macro_bias") or "mixed") != str(current_breakout.get("macro_bias") or "mixed"):
+            continue
+        qualifying_samples += 1
+        if oldest_match is None:
+            oldest_match = sample_ts
+
+    same_direction_seconds = int((current_ts - oldest_match).total_seconds()) if oldest_match else 0
+    promoted = current_breakout.get("phase") == "developing" and qualifying_samples >= 2 and same_direction_seconds >= min(window_seconds, 8 * 60)
+    return {
+        "window_seconds": window_seconds,
+        "samples_considered": len(recent_states),
+        "qualifying_samples": qualifying_samples,
+        "same_direction_seconds": max(0, same_direction_seconds),
+        "promoted_to_confirmed": promoted,
+    }
+
+
+def _grid_breakout_pressure(narrative_payload: dict[str, Any], history_window: dict[str, Any] | None = None) -> dict[str, Any]:
+    argus_context = _argus_decision_context(narrative_payload)
+    breakout = _local_breakout_snapshot(narrative_payload)
+    memory = _breakout_memory(narrative_payload, history_window, breakout)
+    phase = str(breakout.get("phase") or "none")
+    if memory["promoted_to_confirmed"]:
+        phase = "confirmed"
+    persistent = phase == "confirmed"
+
+    return {
+        "persistent": persistent,
+        "phase": phase,
+        "score": breakout["score"],
+        "micro_impulse": breakout["micro_impulse"],
+        "micro_bias": breakout["micro_bias"],
+        "meso_structure": breakout["meso_structure"],
+        "meso_bias": breakout["meso_bias"],
+        "macro_bias": breakout["macro_bias"],
+        "alignment": breakout["alignment"],
+        "friction": breakout["friction"],
+        "time_window_memory": memory,
+        "argus_regime": argus_context["regime"],
+        "argus_confidence": argus_context["confidence"],
+        "argus_compression_active": argus_context["compression_active"],
+    }
+
+
 def _select_current_primary(strategies: list[dict[str, Any]]) -> dict[str, Any] | None:
 def _select_current_primary(strategies: list[dict[str, Any]]) -> dict[str, Any] | None:
     primaries = [s for s in strategies if s["strategy_type"] in {"grid_trader", "trend_follower", "exposure_protector"} and s.get("mode") != "off"]
     primaries = [s for s in strategies if s["strategy_type"] in {"grid_trader", "trend_follower", "exposure_protector"} and s.get("mode") != "off"]
     if not primaries:
     if not primaries:
@@ -429,14 +575,30 @@ def _grid_fill_proximity(strategy: dict[str, Any], narrative_payload: dict[str,
     next_sell_distance_pct = (((next_sell - current_price) / current_price) * 100.0) if next_sell else None
     next_sell_distance_pct = (((next_sell - current_price) / current_price) * 100.0) if next_sell else None
     next_buy_distance_pct = (((current_price - next_buy) / current_price) * 100.0) if next_buy else None
     next_buy_distance_pct = (((current_price - next_buy) / current_price) * 100.0) if next_buy else None
     threshold_pct = max(0.25, atr_percent * 1.5)
     threshold_pct = max(0.25, atr_percent * 1.5)
-    near_fill = bool(
+    near_sell_fill = bool(
         next_sell_distance_pct is not None
         next_sell_distance_pct is not None
         and next_sell_distance_pct >= 0
         and next_sell_distance_pct >= 0
         and next_sell_distance_pct <= threshold_pct
         and next_sell_distance_pct <= threshold_pct
         and next_buy is not None
         and next_buy is not None
     )
     )
+    near_buy_fill = bool(
+        next_buy_distance_pct is not None
+        and next_buy_distance_pct >= 0
+        and next_buy_distance_pct <= threshold_pct
+        and next_sell is not None
+    )
+    near_fill_side: str | None = None
+    if near_sell_fill and near_buy_fill:
+        near_fill_side = "sell" if (next_sell_distance_pct or 0.0) <= (next_buy_distance_pct or 0.0) else "buy"
+    elif near_sell_fill:
+        near_fill_side = "sell"
+    elif near_buy_fill:
+        near_fill_side = "buy"
     return {
     return {
-        "near_fill": near_fill,
+        "near_fill": bool(near_sell_fill or near_buy_fill),
+        "near_fill_side": near_fill_side,
+        "near_sell_fill": near_sell_fill,
+        "near_buy_fill": near_buy_fill,
         "current_price": current_price,
         "current_price": current_price,
         "next_sell": next_sell,
         "next_sell": next_sell,
         "next_buy": next_buy,
         "next_buy": next_buy,
@@ -446,6 +608,38 @@ def _grid_fill_proximity(strategy: dict[str, Any], narrative_payload: dict[str,
     }
     }
 
 
 
 
+def _grid_fill_fights_breakout(grid_fill: dict[str, Any], breakout: dict[str, Any]) -> bool:
+    side = str(grid_fill.get("near_fill_side") or "")
+    bias = str(breakout.get("meso_bias") or breakout.get("micro_bias") or "")
+    if bias == "bullish":
+        return side == "sell"
+    if bias == "bearish":
+        return side == "buy"
+    return False
+
+
+def _breakout_direction(breakout: dict[str, Any], stance: str | None = None) -> str | None:
+    meso_bias = str(breakout.get("meso_bias") or "")
+    micro_bias = str(breakout.get("micro_bias") or "")
+    if meso_bias in {"bullish", "bearish"}:
+        return meso_bias
+    if micro_bias in {"bullish", "bearish"}:
+        return micro_bias
+    stance_text = str(stance or "")
+    if "bullish" in stance_text:
+        return "bullish"
+    if "bearish" in stance_text:
+        return "bearish"
+    return None
+
+
+def _trend_handoff_level_threshold(breakout: dict[str, Any]) -> float:
+    memory = breakout.get("time_window_memory") if isinstance(breakout.get("time_window_memory"), dict) else {}
+    if bool(memory.get("promoted_to_confirmed")):
+        return 2.0
+    return 2.75
+
+
 def _grid_trend_pressure(strategy: dict[str, Any], narrative_payload: dict[str, Any]) -> dict[str, Any]:
 def _grid_trend_pressure(strategy: dict[str, Any], narrative_payload: dict[str, Any]) -> dict[str, Any]:
     state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
     state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
     config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
     config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
@@ -524,11 +718,19 @@ def _decide_for_grid(*,
     grid_can_work = _grid_can_still_work(current_primary, wallet_state, grid_fill)
     grid_can_work = _grid_can_still_work(current_primary, wallet_state, grid_fill)
     grid_stuck_for_recovery = _grid_is_truly_stuck_for_recovery(current_primary, wallet_state, grid_fill)
     grid_stuck_for_recovery = _grid_is_truly_stuck_for_recovery(current_primary, wallet_state, grid_fill)
     persistent_breakout = bool(breakout["persistent"])
     persistent_breakout = bool(breakout["persistent"])
+    breakout_phase = str(breakout.get("phase") or "none")
+    trend_handoff_ready = bool(
+        trend
+        and trend["score"] > 0.45
+        and directional_micro_clear
+        and grid_pressure.get("levels", 0.0) >= _trend_handoff_level_threshold(breakout)
+    )
+    fill_fights_breakout = _grid_fill_fights_breakout(grid_fill, breakout)
 
 
     if severe_imbalance and persistent_breakout:
     if severe_imbalance and persistent_breakout:
         reasons.append("grid imbalance now coincides with persistent breakout pressure")
         reasons.append("grid imbalance now coincides with persistent breakout pressure")
         directional_inventory = _inventory_breakout_is_directionally_compatible(inventory_state, breakout)
         directional_inventory = _inventory_breakout_is_directionally_compatible(inventory_state, breakout)
-        if trend and trend["score"] > 0.45 and directional_micro_clear and grid_pressure.get("levels", 0.0) >= 2.75 and (
+        if trend_handoff_ready and (
             not wallet_state.get("rebalance_needed")
             not wallet_state.get("rebalance_needed")
             or directional_inventory
             or directional_inventory
             or not rebalance
             or not rebalance
@@ -551,19 +753,32 @@ def _decide_for_grid(*,
         target_strategy = rebalance["strategy_id"]
         target_strategy = rebalance["strategy_id"]
         mode = "act"
         mode = "act"
         reasons.append("grid has lost practical recovery capacity, so inventory repair should take over")
         reasons.append("grid has lost practical recovery capacity, so inventory repair should take over")
+    elif persistent_breakout and trend_handoff_ready and inventory_state in {"balanced", "base_heavy", "quote_heavy"}:
+        action = "replace_with_trend_follower"
+        target_strategy = trend["strategy_id"] if trend else target_strategy
+        mode = "act"
+        if grid_fill.get("near_fill") and fill_fights_breakout:
+            reasons.append("confirmed trend should not be delayed by a nearby grid fill that trades against the move")
+        elif grid_fill.get("near_fill"):
+            reasons.append("confirmed directional pressure is strong enough that nearby grid fills should not delay the trend handoff")
+        else:
+            reasons.append("grid should yield because directional pressure is confirmed and the trend handoff is ready")
     elif not persistent_breakout and grid_can_work:
     elif not persistent_breakout and grid_can_work:
-        reasons.append("grid can still operate and self-heal, so inventory skew alone should not force a rebalance handoff")
+        if breakout_phase == "developing":
+            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")
     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, so immediate handoff would be premature")
+        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:
         reasons.append("grid should yield because directional pressure is persistent across scopes")
         reasons.append("grid should yield because directional pressure is persistent across scopes")
-        if trend and trend["score"] > 0.45 and directional_micro_clear and grid_pressure.get("levels", 0.0) >= 2.75:
+        if trend_handoff_ready:
             action = "replace_with_trend_follower"
             action = "replace_with_trend_follower"
             target_strategy = trend["strategy_id"]
             target_strategy = trend["strategy_id"]
             mode = "act"
             mode = "act"
         else:
         else:
             mode = "warn"
             mode = "warn"
-            if grid_pressure.get("levels", 0.0) < 2.75:
+            if grid_pressure.get("levels", 0.0) < _trend_handoff_level_threshold(breakout):
                 blocks.append("grid has not yet been eaten by enough levels to justify leaving it")
                 blocks.append("grid has not yet been eaten by enough levels to justify leaving it")
             else:
             else:
                 blocks.append("directional pressure is rising but the micro layer is not clear enough for a trend handoff")
                 blocks.append("directional pressure is rising but the micro layer is not clear enough for a trend handoff")
@@ -662,15 +877,16 @@ def _decide_for_rebalancer(*,
     return action, mode, target_strategy, reasons, blocks
     return action, mode, target_strategy, reasons, blocks
 
 
 
 
-def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any], wallet_state: dict[str, Any], strategies: list[dict[str, Any]]) -> DecisionSnapshot:
+def make_decision(*, 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) -> DecisionSnapshot:
     normalized = [normalize_strategy_snapshot(s) for s in strategies if str(s.get("account_id") or "") == str(concern.get("account_id") or "")]
     normalized = [normalize_strategy_snapshot(s) for s in strategies if str(s.get("account_id") or "") == str(concern.get("account_id") or "")]
-    fit_reports = [score_strategy_fit(strategy=s, narrative=narrative_payload, wallet_state=wallet_state) for s in normalized]
+    breakout = _grid_breakout_pressure(narrative_payload, history_window=history_window)
+    narrative_for_scoring = {**narrative_payload, "grid_breakout_pressure": breakout}
+    fit_reports = [score_strategy_fit(strategy=s, narrative=narrative_for_scoring, wallet_state=wallet_state) for s in normalized]
     ranked = sorted(fit_reports, key=lambda item: item["score"], reverse=True)
     ranked = sorted(fit_reports, key=lambda item: item["score"], reverse=True)
     current_primary = _select_current_primary(normalized)
     current_primary = _select_current_primary(normalized)
     best = ranked[0] if ranked else None
     best = ranked[0] if ranked else None
     stance = str(narrative_payload.get("stance") or "neutral_rotational")
     stance = str(narrative_payload.get("stance") or "neutral_rotational")
     inventory_state = str(wallet_state.get("inventory_state") or "unknown")
     inventory_state = str(wallet_state.get("inventory_state") or "unknown")
-    breakout = _grid_breakout_pressure(narrative_payload)
     scoped = narrative_payload.get("scoped_state") if isinstance(narrative_payload.get("scoped_state"), dict) else {}
     scoped = narrative_payload.get("scoped_state") if isinstance(narrative_payload.get("scoped_state"), dict) else {}
     micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
     micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
     micro_impulse = str(micro.get("impulse") or "mixed")
     micro_impulse = str(micro.get("impulse") or "mixed")
@@ -678,9 +894,8 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
     micro_reversal_risk = str(micro.get("reversal_risk") or "low")
     micro_reversal_risk = str(micro.get("reversal_risk") or "low")
     bullish_micro_clear = micro_impulse == "up" and micro_bias == "bullish" and micro_reversal_risk != "high"
     bullish_micro_clear = micro_impulse == "up" and micro_bias == "bullish" and micro_reversal_risk != "high"
     bearish_micro_clear = micro_impulse == "down" and micro_bias == "bearish" and micro_reversal_risk != "high"
     bearish_micro_clear = micro_impulse == "down" and micro_bias == "bearish" and micro_reversal_risk != "high"
-    stance_is_bullish = "bullish" in stance
-    stance_is_bearish = "bearish" in stance
-    directional_micro_clear = bullish_micro_clear if stance_is_bullish else bearish_micro_clear if stance_is_bearish else False
+    breakout_direction = _breakout_direction(breakout, stance)
+    directional_micro_clear = bullish_micro_clear if breakout_direction == "bullish" else bearish_micro_clear if breakout_direction == "bearish" else False
     grid_fill = _grid_fill_proximity(current_primary, narrative_payload) if current_primary and current_primary["strategy_type"] == "grid_trader" else {"near_fill": False}
     grid_fill = _grid_fill_proximity(current_primary, narrative_payload) if current_primary and current_primary["strategy_type"] == "grid_trader" else {"near_fill": False}
     grid_pressure = _grid_trend_pressure(current_primary, narrative_payload) if current_primary and current_primary["strategy_type"] == "grid_trader" else {"levels": 0.0, "rounded_levels": 0, "direction": "unknown"}
     grid_pressure = _grid_trend_pressure(current_primary, narrative_payload) if current_primary and current_primary["strategy_type"] == "grid_trader" else {"levels": 0.0, "rounded_levels": 0, "direction": "unknown"}
     severe_imbalance = inventory_state in {"depleted_base_side", "depleted_quote_side", "critically_unbalanced"}
     severe_imbalance = inventory_state in {"depleted_base_side", "depleted_quote_side", "critically_unbalanced"}
@@ -748,11 +963,13 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
         "narrative_stance": stance,
         "narrative_stance": stance,
         "strategy_fit_ranking": ranked,
         "strategy_fit_ranking": ranked,
         "current_primary_strategy": current_primary.get("id") if current_primary else None,
         "current_primary_strategy": current_primary.get("id") if current_primary else None,
+        "argus_decision_context": _argus_decision_context(narrative_payload),
+        "history_window": history_window or {},
         "grid_breakout_pressure": breakout,
         "grid_breakout_pressure": breakout,
         "grid_fill_context": grid_fill,
         "grid_fill_context": grid_fill,
         "reason_chain": reasons,
         "reason_chain": reasons,
         "blocks": blocks,
         "blocks": blocks,
-        "decision_version": 1,
+        "decision_version": 2,
     }
     }
 
 
     return DecisionSnapshot(
     return DecisionSnapshot(

+ 15 - 0
src/hermes_mcp/narrative_engine.py

@@ -71,6 +71,7 @@ def build_narrative(*, concern: dict[str, Any], state_payload: dict[str, Any]) -
     meso = scoped.get("meso") or {}
     meso = scoped.get("meso") or {}
     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 {}
 
 
     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] = []
@@ -88,6 +89,11 @@ def build_narrative(*, concern: dict[str, Any], state_payload: dict[str, Any]) -
     friction = str(cross.get("friction") or "medium")
     friction = str(cross.get("friction") or "medium")
     alignment = str(cross.get("alignment") or "partial_alignment")
     alignment = str(cross.get("alignment") or "partial_alignment")
     source_confidence = float(cross.get("confidence") or 0.4)
     source_confidence = float(cross.get("confidence") or 0.4)
+    argus_regime = str(argus.get("regime") or argus.get("snapshot_regime") or "").strip()
+    argus_summary = str(argus.get("regime_summary") or argus.get("snapshot_summary") or "").strip()
+    argus_confidence = argus.get("regime_confidence")
+    if argus_confidence is None:
+        argus_confidence = argus.get("snapshot_confidence")
 
 
     if macro_bias == "bullish":
     if macro_bias == "bullish":
         drivers.append("macro bias remains bullish")
         drivers.append("macro bias remains bullish")
@@ -106,6 +112,14 @@ def build_narrative(*, concern: dict[str, Any], state_payload: dict[str, Any]) -
     else:
     else:
         uncertainties.append("micro impulse is mixed")
         uncertainties.append("micro impulse is mixed")
 
 
+    if argus_regime:
+        if isinstance(argus_confidence, (int, float)) and float(argus_confidence) >= 0.6:
+            drivers.append(f"argus context reads {argus_regime}")
+        else:
+            uncertainties.append(f"argus context reads {argus_regime} with limited confidence")
+    if argus_summary:
+        drivers.append(f"argus summary: {argus_summary}")
+
     if friction == "high":
     if friction == "high":
         risks.append("cross-scope friction is high")
         risks.append("cross-scope friction is high")
     elif friction == "medium":
     elif friction == "medium":
@@ -200,6 +214,7 @@ def build_narrative(*, concern: dict[str, Any], state_payload: dict[str, Any]) -
         "invalidators": invalidators,
         "invalidators": invalidators,
         "opportunity_map": opportunity_map,
         "opportunity_map": opportunity_map,
         "cross_scope_alignment": alignment,
         "cross_scope_alignment": alignment,
+        "argus_context": argus,
         "source_state_confidence": source_confidence,
         "source_state_confidence": source_confidence,
         "summary_version": 1,
         "summary_version": 1,
     }
     }

+ 36 - 2
src/hermes_mcp/server.py

@@ -15,11 +15,12 @@ from mcp import ClientSession
 from mcp.client.sse import sse_client
 from mcp.client.sse import sse_client
 
 
 from .config import load_config
 from .config import load_config
+from .argus_client import get_regime as argus_get_regime, get_snapshot as argus_get_snapshot
 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 .state_engine import synthesize_state
 from .state_engine import synthesize_state
-from .store import get_state, init_db, list_concerns, latest_cycle, latest_cycles, latest_decisions, latest_narratives, latest_regime_samples, prune_older_than, recent_regime_samples, sync_concerns_from_strategies, upsert_cycle, upsert_decision, upsert_narrative, upsert_regime_sample, upsert_state, latest_states
+from .store import 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
 
 
 mcp = FastMCP(
 mcp = FastMCP(
@@ -152,6 +153,26 @@ async def lifespan(_: FastAPI):
             except Exception:
             except Exception:
                 strategy_inventory = []
                 strategy_inventory = []
             upsert_cycle(id=cycle_id, started_at=started, finished_at=None, status="running", trigger="interval", notes=f"polling {len(concerns)} concerns")
             upsert_cycle(id=cycle_id, started_at=started, finished_at=None, status="running", trigger="interval", notes=f"polling {len(concerns)} concerns")
+            argus_snapshot: dict = {}
+            argus_regime: dict = {}
+            try:
+                argus_snapshot = await argus_get_snapshot(cfg.argus_url)
+            except Exception:
+                argus_snapshot = {}
+            try:
+                argus_regime = await argus_get_regime(cfg.argus_url)
+            except Exception:
+                argus_regime = {}
+            if argus_snapshot or argus_regime:
+                upsert_observation(
+                    id=f"{cycle_id}:argus",
+                    cycle_id=cycle_id,
+                    concern_id=None,
+                    source="argus-mcp",
+                    kind="macro_snapshot",
+                    payload_json=json.dumps({"snapshot": argus_snapshot, "regime": argus_regime}, ensure_ascii=False),
+                    observed_at=datetime.now(timezone.utc).isoformat(),
+                )
             for concern in concerns:
             for concern in concerns:
                 symbol = _resolve_regime_symbol(concern)
                 symbol = _resolve_regime_symbol(concern)
                 if not symbol:
                 if not symbol:
@@ -177,7 +198,13 @@ async def lifespan(_: FastAPI):
                         captured_at=datetime.now(timezone.utc).isoformat(),
                         captured_at=datetime.now(timezone.utc).isoformat(),
                     )
                     )
                 try:
                 try:
-                    state = synthesize_state(concern=concern, regimes=current_regimes, account_info=account_info)
+                    state = synthesize_state(
+                        concern=concern,
+                        regimes=current_regimes,
+                        account_info=account_info,
+                        argus_snapshot=argus_snapshot,
+                        argus_regime=argus_regime,
+                    )
                     upsert_state(
                     upsert_state(
                         id=f"{cycle_id}:{concern['id']}",
                         id=f"{cycle_id}:{concern['id']}",
                         cycle_id=cycle_id,
                         cycle_id=cycle_id,
@@ -213,6 +240,8 @@ async def lifespan(_: FastAPI):
                         price=float(latest_price) if latest_price is not None else None,
                         price=float(latest_price) if latest_price is not None else None,
                         strategies=strategy_inventory,
                         strategies=strategy_inventory,
                     )
                     )
+                    breakout_window_seconds = max(300, int(getattr(cfg, "breakout_memory_window_seconds", 900) or 900))
+                    recent_state_rows = recent_states_for_concern(concern_id=str(concern["id"]), since_seconds=breakout_window_seconds, limit=12)
                     decision = make_decision(
                     decision = make_decision(
                         concern=concern,
                         concern=concern,
                         narrative_payload={
                         narrative_payload={
@@ -222,6 +251,10 @@ async def lifespan(_: FastAPI):
                         },
                         },
                         wallet_state=wallet_state,
                         wallet_state=wallet_state,
                         strategies=strategy_inventory,
                         strategies=strategy_inventory,
+                        history_window={
+                            "window_seconds": breakout_window_seconds,
+                            "recent_states": recent_state_rows,
+                        },
                     )
                     )
                     decision_id = f"{cycle_id}:{concern['id']}"
                     decision_id = f"{cycle_id}:{concern['id']}"
                     dispatch_record = await _maybe_dispatch_trader_action(
                     dispatch_record = await _maybe_dispatch_trader_action(
@@ -423,6 +456,7 @@ def dashboard_data() -> JSONResponse:
     return JSONResponse({
     return JSONResponse({
         "latest_cycle": latest_cycle(),
         "latest_cycle": latest_cycle(),
         "cycles": latest_cycles(10),
         "cycles": latest_cycles(10),
+        "argus_observations": latest_observations(20, source="argus-mcp"),
         "concerns": enriched,
         "concerns": enriched,
         "regime_samples": regimes,
         "regime_samples": regimes,
         "regime_histories": histories_by_key,
         "regime_histories": histories_by_key,

+ 21 - 1
src/hermes_mcp/state_engine.py

@@ -423,7 +423,25 @@ def _derive_top_level(scoped_state: dict[str, Any], cross_scope: dict[str, Any],
     }
     }
 
 
 
 
-def synthesize_state(*, concern: dict[str, Any], regimes: list[dict[str, Any]], account_info: dict[str, Any] | None = None) -> StateSnapshot:
+def _argus_context(snapshot: dict[str, Any] | None, regime: dict[str, Any] | None) -> dict[str, Any] | None:
+    snapshot = snapshot or {}
+    regime = regime or {}
+    if not snapshot and not regime:
+        return None
+    return {
+        "snapshot_regime": snapshot.get("regime"),
+        "snapshot_confidence": snapshot.get("confidence"),
+        "snapshot_summary": snapshot.get("summary"),
+        "snapshot_components": snapshot.get("components") if isinstance(snapshot.get("components"), dict) else None,
+        "regime": regime.get("regime"),
+        "regime_confidence": regime.get("confidence"),
+        "regime_summary": regime.get("summary"),
+        "regime_components": regime.get("components") if isinstance(regime.get("components"), dict) else None,
+        "status": snapshot.get("status") or regime.get("status"),
+    }
+
+
+def synthesize_state(*, concern: dict[str, Any], regimes: list[dict[str, Any]], account_info: dict[str, Any] | None = None, argus_snapshot: dict[str, Any] | None = None, argus_regime: dict[str, Any] | None = None) -> StateSnapshot:
     account_info = account_info or {}
     account_info = account_info or {}
     features_by_timeframe = {
     features_by_timeframe = {
         tf: extract_regime_features(regime)
         tf: extract_regime_features(regime)
@@ -438,6 +456,7 @@ def synthesize_state(*, concern: dict[str, Any], regimes: list[dict[str, Any]],
     }
     }
     cross_scope = _cross_scope_summary(scoped_state["micro"], scoped_state["meso"], scoped_state["macro"], features_by_timeframe)
     cross_scope = _cross_scope_summary(scoped_state["micro"], scoped_state["meso"], scoped_state["macro"], features_by_timeframe)
     top_level = _derive_top_level(scoped_state, cross_scope, account_info, concern)
     top_level = _derive_top_level(scoped_state, cross_scope, account_info, concern)
+    argus_context = _argus_context(argus_snapshot, argus_regime)
 
 
     payload = {
     payload = {
         "concern_id": concern.get("id"),
         "concern_id": concern.get("id"),
@@ -448,6 +467,7 @@ def synthesize_state(*, concern: dict[str, Any], regimes: list[dict[str, Any]],
         "features_by_timeframe": features_by_timeframe,
         "features_by_timeframe": features_by_timeframe,
         "scoped_state": scoped_state,
         "scoped_state": scoped_state,
         "cross_scope_summary": cross_scope,
         "cross_scope_summary": cross_scope,
+        "argus_context": argus_context,
     }
     }
 
 
     return StateSnapshot(
     return StateSnapshot(

+ 45 - 0
src/hermes_mcp/store.py

@@ -342,6 +342,26 @@ def upsert_regime_sample(*, id: str, cycle_id: str, concern_id: str, timeframe:
         )
         )
 
 
 
 
+def upsert_observation(*, id: str, cycle_id: str, concern_id: str | None, source: str, kind: str, payload_json: str, observed_at: str | None = None) -> None:
+    init_db()
+    observed_at = observed_at or _now()
+    with _connect() as conn:
+        conn.execute(
+            """
+            insert into observations(id, cycle_id, concern_id, source, kind, payload_json, observed_at)
+            values(?, ?, ?, ?, ?, ?, ?)
+            on conflict(id) do update set
+              cycle_id=excluded.cycle_id,
+              concern_id=excluded.concern_id,
+              source=excluded.source,
+              kind=excluded.kind,
+              payload_json=excluded.payload_json,
+              observed_at=excluded.observed_at
+            """,
+            (id, cycle_id, concern_id, source, kind, payload_json, observed_at),
+        )
+
+
 def upsert_state(*, id: str, cycle_id: str, concern_id: str, market_regime: str | None, volatility_state: str | None, liquidity_state: str | None, sentiment_pressure: str | None, event_risk: str | None, execution_quality: str | None, confidence: float | None, payload_json: str, created_at: str | None = None) -> None:
 def upsert_state(*, id: str, cycle_id: str, concern_id: str, market_regime: str | None, volatility_state: str | None, liquidity_state: str | None, sentiment_pressure: str | None, event_risk: str | None, execution_quality: str | None, confidence: float | None, payload_json: str, created_at: str | None = None) -> None:
     init_db()
     init_db()
     created_at = created_at or _now()
     created_at = created_at or _now()
@@ -420,6 +440,18 @@ def latest_states(limit: int = 20) -> list[dict[str, Any]]:
     return [dict(r) for r in rows]
     return [dict(r) for r in rows]
 
 
 
 
+def recent_states_for_concern(*, concern_id: str, since_seconds: int, limit: int = 24) -> list[dict[str, Any]]:
+    init_db()
+    cutoff = datetime.now(timezone.utc).timestamp() - max(1, since_seconds)
+    cutoff_iso = datetime.fromtimestamp(cutoff, tz=timezone.utc).isoformat()
+    with _connect() as conn:
+        rows = conn.execute(
+            "select * from states where concern_id = ? and created_at >= ? order by created_at asc limit ?",
+            (concern_id, cutoff_iso, limit),
+        ).fetchall()
+    return [dict(r) for r in rows]
+
+
 def latest_decisions(limit: int = 20) -> list[dict[str, Any]]:
 def latest_decisions(limit: int = 20) -> list[dict[str, Any]]:
     init_db()
     init_db()
     with _connect() as conn:
     with _connect() as conn:
@@ -441,6 +473,19 @@ def latest_regime_samples(limit: int = 20) -> list[dict[str, Any]]:
     return [dict(r) for r in rows]
     return [dict(r) for r in rows]
 
 
 
 
+def latest_observations(limit: int = 20, source: str | None = None) -> list[dict[str, Any]]:
+    init_db()
+    with _connect() as conn:
+        if source:
+            rows = conn.execute(
+                "select * from observations where source = ? order by observed_at desc limit ?",
+                (source, limit),
+            ).fetchall()
+        else:
+            rows = conn.execute("select * from observations order by observed_at desc limit ?", (limit,)).fetchall()
+    return [dict(r) for r in rows]
+
+
 def recent_regime_samples(limit: int = 200) -> list[dict[str, Any]]:
 def recent_regime_samples(limit: int = 200) -> list[dict[str, Any]]:
     init_db()
     init_db()
     with _connect() as conn:
     with _connect() as conn:

+ 24 - 0
tests/test_argus_client.py

@@ -0,0 +1,24 @@
+from hermes_mcp.argus_client import _payload_from_result
+
+
+class _Text:
+    def __init__(self, text: str):
+        self.text = text
+
+
+class _Result:
+    def __init__(self, structured=None, content=None):
+        self.structuredContent = structured
+        self.content = content or []
+
+
+def test_payload_from_result_prefers_structured_content():
+    result = _Result(structured={"regime": "compression", "confidence": 0.6})
+    assert _payload_from_result(result)["regime"] == "compression"
+
+
+def test_payload_from_result_parses_json_text_content():
+    result = _Result(content=[_Text('{"regime":"compression","confidence":0.6,"summary":"range-like"}')])
+    payload = _payload_from_result(result)
+    assert payload["regime"] == "compression"
+    assert payload["summary"] == "range-like"

+ 9 - 0
tests/test_config.py

@@ -0,0 +1,9 @@
+from pathlib import Path
+
+from hermes_mcp.config import _load_env_file
+
+
+def test_env_example_includes_breakout_memory_window_setting():
+    env_example = Path(__file__).resolve().parents[1] / ".env.example"
+    values = _load_env_file(env_example)
+    assert values["HERMES_BREAKOUT_MEMORY_WINDOW_SECONDS"] == "900"

+ 214 - 4
tests/test_decision_engine.py

@@ -46,6 +46,31 @@ def test_score_strategy_fit_penalizes_grid_when_wallet_unbalanced():
     assert any("grid" in block or "wallet" in block for block in fit["blocks"])
     assert any("grid" in block or "wallet" in block for block in fit["blocks"])
 
 
 
 
+def test_score_strategy_fit_rewards_trend_when_breakout_is_confirmed():
+    strategy = normalize_strategy_snapshot({
+        "id": "trend-1",
+        "strategy_type": "trend_follower",
+        "mode": "off",
+        "account_id": "a1",
+        "state": {},
+        "config": {},
+    })
+    base_narrative = {
+        "stance": "constructive_bullish",
+        "opportunity_map": {"continuation": 0.72, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.15},
+        "grid_breakout_pressure": {"phase": "developing"},
+    }
+    confirmed_narrative = {
+        **base_narrative,
+        "grid_breakout_pressure": {"phase": "confirmed", "persistent": True},
+    }
+    wallet_state = {"inventory_state": "balanced", "rebalance_needed": False}
+    base_fit = score_strategy_fit(strategy=strategy, narrative=base_narrative, wallet_state=wallet_state)
+    confirmed_fit = score_strategy_fit(strategy=strategy, narrative=confirmed_narrative, wallet_state=wallet_state)
+    assert confirmed_fit["score"] > base_fit["score"]
+    assert any("confirmed breakout" in reason for reason in confirmed_fit["reasons"])
+
+
 def test_assess_wallet_state_counts_reserved_orders_in_effective_inventory():
 def test_assess_wallet_state_counts_reserved_orders_in_effective_inventory():
     concern = {"account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     concern = {"account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     account_info = {
     account_info = {
@@ -270,6 +295,127 @@ def test_make_decision_replaces_grid_when_third_level_is_sustained():
     assert decision.mode == "act"
     assert decision.mode == "act"
     assert decision.action == "replace_with_trend_follower"
     assert decision.action == "replace_with_trend_follower"
     assert decision.target_strategy == "trend-1"
     assert decision.target_strategy == "trend-1"
+    assert decision.payload["grid_breakout_pressure"]["phase"] == "confirmed"
+
+
+def test_make_decision_marks_breakout_as_developing_under_partial_alignment():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "cautious_bullish",
+        "confidence": 0.76,
+        "opportunity_map": {"continuation": 0.62, "mean_reversion": 0.12, "reversal": 0.06, "wait": 0.2},
+        "scoped_state": {
+            "micro": {"impulse": "up", "trend_bias": "bullish", "reversal_risk": "low"},
+            "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
+            "macro": {"bias": "bullish"},
+        },
+        "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
+    }
+    wallet_state = {
+        "inventory_state": "base_heavy",
+        "rebalance_needed": True,
+        "grid_ready": False,
+        "base_ratio": 0.74,
+        "quote_ratio": 0.26,
+    }
+    strategies = [
+        {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": False, "side_capacity": {"buy": True, "sell": False}}}},
+        {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
+        {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
+    ]
+    decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
+    assert decision.action == "keep_grid"
+    assert decision.payload["grid_breakout_pressure"]["phase"] == "developing"
+    assert decision.reason_summary == "breakout pressure is developing, but grid can still work and should not be abandoned yet"
+
+
+def test_make_decision_argus_compression_stays_context_only():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.82,
+        "opportunity_map": {"continuation": 0.82, "mean_reversion": 0.05, "reversal": 0.03, "wait": 0.1},
+        "scoped_state": {
+            "micro": {"impulse": "up", "trend_bias": "bullish", "reversal_risk": "low"},
+            "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
+            "macro": {"bias": "bullish"},
+        },
+        "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
+        "argus_context": {
+            "regime": "compression",
+            "regime_confidence": 0.72,
+            "regime_components": {"compression": 0.81},
+        },
+        "features_by_timeframe": {"1m": {"raw": {"price": 112.0}}},
+    }
+    wallet_state = {
+        "inventory_state": "balanced",
+        "rebalance_needed": False,
+        "grid_ready": True,
+        "base_ratio": 0.52,
+        "quote_ratio": 0.48,
+    }
+    strategies = [
+        {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {"center_price": 100.0}, "config": {"grid_step_pct": 0.05}},
+        {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
+        {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
+    ]
+    decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
+    assert decision.action == "keep_grid"
+    assert decision.payload["grid_breakout_pressure"]["argus_compression_active"] is True
+    assert decision.payload["grid_breakout_pressure"]["phase"] == "confirmed"
+    assert decision.payload["argus_decision_context"]["compression_active"] is True
+
+
+def test_make_decision_promotes_developing_breakout_from_time_window_memory():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "generated_at": "2026-04-18T20:15:00+00:00",
+        "stance": "cautious_bullish",
+        "confidence": 0.76,
+        "opportunity_map": {"continuation": 0.62, "mean_reversion": 0.12, "reversal": 0.06, "wait": 0.2},
+        "scoped_state": {
+            "micro": {"impulse": "up", "trend_bias": "bullish", "reversal_risk": "low"},
+            "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
+            "macro": {"bias": "bullish"},
+        },
+        "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
+    }
+    wallet_state = {
+        "inventory_state": "base_heavy",
+        "rebalance_needed": True,
+        "grid_ready": False,
+        "base_ratio": 0.74,
+        "quote_ratio": 0.26,
+    }
+    strategies = [
+        {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": False, "side_capacity": {"buy": True, "sell": False}}}},
+        {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
+        {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
+    ]
+    history_window = {
+        "window_seconds": 15 * 60,
+        "recent_states": [
+            {
+                "created_at": "2026-04-18T20:06: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":"partial_alignment","friction":"medium"}}',
+            },
+            {
+                "created_at": "2026-04-18T20:10: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":"partial_alignment","friction":"medium"}}',
+            },
+        ],
+    }
+    decision = make_decision(
+        concern=concern,
+        narrative_payload=narrative,
+        wallet_state=wallet_state,
+        strategies=strategies,
+        history_window=history_window,
+    )
+    assert decision.payload["grid_breakout_pressure"]["phase"] == "confirmed"
+    assert decision.payload["grid_breakout_pressure"]["time_window_memory"]["promoted_to_confirmed"] is True
+    assert decision.payload["grid_breakout_pressure"]["time_window_memory"]["same_direction_seconds"] >= 540
 
 
 
 
 def test_make_decision_replaces_grid_with_trend_when_breakout_is_persistent_but_inventory_is_only_base_heavy():
 def test_make_decision_replaces_grid_with_trend_when_breakout_is_persistent_but_inventory_is_only_base_heavy():
@@ -356,7 +502,7 @@ def test_make_decision_prefers_trend_over_rebalancer_on_bullish_breakout_with_de
     assert decision.target_strategy == "protect-1"
     assert decision.target_strategy == "protect-1"
 
 
 
 
-def test_make_decision_keeps_grid_when_next_sell_is_close_despite_persistent_breakout():
+def test_make_decision_replaces_grid_when_next_sell_is_close_but_confirmed_trend_handoff_is_ready():
     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 = {
         "stance": "constructive_bullish",
         "stance": "constructive_bullish",
@@ -388,19 +534,83 @@ def test_make_decision_keeps_grid_when_next_sell_is_close_despite_persistent_bre
             "market_symbol": "xrpusd",
             "market_symbol": "xrpusd",
             "state": {
             "state": {
                 "last_price": 1.4374,
                 "last_price": 1.4374,
+                "center_price": 1.24,
                 "orders": [
                 "orders": [
                     {"side": "sell", "status": "open", "price": "1.43956", "amount": "7"},
                     {"side": "sell", "status": "open", "price": "1.43956", "amount": "7"},
                     {"side": "buy", "status": "open", "price": "1.42523", "amount": "7"},
                     {"side": "buy", "status": "open", "price": "1.42523", "amount": "7"},
                 ],
                 ],
             },
             },
-            "config": {},
+            "config": {"grid_step_pct": 0.05},
         },
         },
         {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "account_id": "a1", "state": {}, "config": {}},
         {"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": {}},
         {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.action == "keep_grid"
-    assert decision.target_strategy == "grid-1"
+    assert decision.action == "replace_with_trend_follower"
+    assert decision.target_strategy == "trend-1"
+    assert decision.payload["grid_fill_context"]["near_fill_side"] == "sell"
+
+
+def test_make_decision_replaces_grid_when_time_promoted_confirmation_clears_lower_handoff_threshold():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
+    narrative = {
+        "generated_at": "2026-04-18T20:15:00+00:00",
+        "stance": "breakout_watch",
+        "confidence": 0.78,
+        "opportunity_map": {"continuation": 0.7, "mean_reversion": 0.1, "reversal": 0.04, "wait": 0.16},
+        "features_by_timeframe": {
+            "1m": {"raw": {"price": 111.0, "atr_percent": 0.35}},
+        },
+        "scoped_state": {
+            "micro": {"impulse": "up", "trend_bias": "bullish", "reversal_risk": "low"},
+            "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
+            "macro": {"bias": "bullish"},
+        },
+        "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
+    }
+    wallet_state = {
+        "inventory_state": "base_heavy",
+        "rebalance_needed": True,
+        "grid_ready": False,
+        "base_ratio": 0.66,
+        "quote_ratio": 0.34,
+    }
+    strategies = [
+        {
+            "id": "grid-1",
+            "strategy_type": "grid_trader",
+            "mode": "active",
+            "account_id": "a1",
+            "market_symbol": "xrpusd",
+            "state": {"last_price": 111.0, "center_price": 100.0},
+            "config": {"grid_step_pct": 0.05},
+        },
+        {"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-18T20:06: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":"partial_alignment","friction":"medium"}}',
+            },
+            {
+                "created_at": "2026-04-18T20:10: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":"partial_alignment","friction":"medium"}}',
+            },
+        ],
+    }
+    decision = make_decision(
+        concern=concern,
+        narrative_payload=narrative,
+        wallet_state=wallet_state,
+        strategies=strategies,
+        history_window=history_window,
+    )
+    assert decision.action == "replace_with_trend_follower"
+    assert decision.target_strategy == "trend-1"
+    assert decision.payload["grid_breakout_pressure"]["time_window_memory"]["promoted_to_confirmed"] is True
 
 
 
 
 def test_normalize_strategy_snapshot_uses_live_report_contract_and_supervision():
 def test_normalize_strategy_snapshot_uses_live_report_contract_and_supervision():

+ 17 - 0
tests/test_narrative_engine.py

@@ -34,3 +34,20 @@ def test_build_narrative_describes_opportunity_without_deciding():
     assert max(opportunity_map, key=opportunity_map.get) in {"continuation", "wait", "mean_reversion", "reversal"}
     assert max(opportunity_map, key=opportunity_map.get) in {"continuation", "wait", "mean_reversion", "reversal"}
     assert "buy now" not in narrative.summary.lower()
     assert "buy now" not in narrative.summary.lower()
     assert "sell now" not in narrative.summary.lower()
     assert "sell now" not in narrative.summary.lower()
+
+
+def test_build_narrative_preserves_argus_context_as_context_only():
+    concern, payload = _bullish_state_payload()
+    payload["argus_context"] = {
+        "snapshot_regime": "risk_off",
+        "snapshot_confidence": 0.72,
+        "snapshot_summary": "macro stress remains elevated",
+        "regime": "real_asset_pressure",
+        "regime_confidence": 0.83,
+        "regime_summary": "gold and silver confirm defensive cross-market pressure",
+    }
+    narrative = build_narrative(concern=concern, state_payload=payload)
+    assert narrative.payload["argus_context"]["regime"] == "real_asset_pressure"
+    assert any("argus context reads real_asset_pressure" in item for item in narrative.key_drivers)
+    assert "buy now" not in narrative.summary.lower()
+    assert "sell now" not in narrative.summary.lower()

+ 33 - 0
tests/test_state_engine.py

@@ -37,3 +37,36 @@ def test_synthesize_state_builds_scoped_state():
     assert state.payload["scoped_state"]["micro"]["impulse"] == "up"
     assert state.payload["scoped_state"]["micro"]["impulse"] == "up"
     assert state.payload["cross_scope_summary"]["alignment"] in {"micro_meso_macro_aligned", "partial_alignment"}
     assert state.payload["cross_scope_summary"]["alignment"] in {"micro_meso_macro_aligned", "partial_alignment"}
     assert state.confidence > 0.5
     assert state.confidence > 0.5
+
+
+def test_synthesize_state_carries_argus_context_without_rewriting_market_state():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "BTCUSD", "status": "active"}
+    regimes = [
+        {"timeframe": "1m", "price": 101, "trend": {"ema_fast": 100, "ema_slow": 99, "sma_long": 98, "state": "bull"}, "momentum": {"rsi": 62, "macd_histogram": 0.02, "state": "bull"}, "volatility": {"atr_percent": 0.5}, "bands": {"bollinger": {"middle": 100, "upper": 102, "lower": 98}}, "vwap": 100.2, "reversal": {"direction": "none", "score": 0}},
+        {"timeframe": "5m", "price": 102, "trend": {"ema_fast": 101, "ema_slow": 100, "sma_long": 99, "state": "bull"}, "momentum": {"rsi": 63, "macd_histogram": 0.018, "state": "bull"}, "volatility": {"atr_percent": 0.6}, "bands": {"bollinger": {"middle": 101, "upper": 103, "lower": 99}}, "vwap": 101.1, "reversal": {"direction": "none", "score": 0}},
+        {"timeframe": "15m", "price": 103, "trend": {"ema_fast": 102, "ema_slow": 101, "sma_long": 99.5, "state": "bull"}, "momentum": {"rsi": 60, "macd_histogram": 0.012, "state": "neutral"}, "volatility": {"atr_percent": 0.8}, "bands": {"bollinger": {"middle": 102, "upper": 104, "lower": 100}}, "vwap": 102.0, "reversal": {"direction": "none", "score": 0}},
+        {"timeframe": "1h", "price": 104, "trend": {"ema_fast": 103, "ema_slow": 102, "sma_long": 100, "state": "bull"}, "momentum": {"rsi": 58, "macd_histogram": 0.01, "state": "neutral"}, "volatility": {"atr_percent": 0.9}, "bands": {"bollinger": {"middle": 103, "upper": 105, "lower": 101}}, "vwap": 103.0, "reversal": {"direction": "none", "score": 0}},
+        {"timeframe": "4h", "price": 106, "trend": {"ema_fast": 104, "ema_slow": 103, "sma_long": 101, "state": "bull"}, "momentum": {"rsi": 57, "macd_histogram": 0.009, "state": "neutral"}, "volatility": {"atr_percent": 1.1}, "bands": {"bollinger": {"middle": 104, "upper": 107, "lower": 101}}, "vwap": 104.5, "reversal": {"direction": "none", "score": 0}},
+        {"timeframe": "1d", "price": 110, "trend": {"ema_fast": 108, "ema_slow": 106, "sma_long": 103, "state": "bull"}, "momentum": {"rsi": 61, "macd_histogram": 0.008, "state": "bull"}, "volatility": {"atr_percent": 1.4}, "bands": {"bollinger": {"middle": 107, "upper": 112, "lower": 102}}, "vwap": 108.0, "reversal": {"direction": "none", "score": 0}},
+    ]
+    state = synthesize_state(
+        concern=concern,
+        regimes=regimes,
+        argus_snapshot={
+            "regime": "risk_off",
+            "confidence": 0.74,
+            "summary": "precious metals firm while crypto beta is selective",
+            "components": {"stress": 0.42},
+        },
+        argus_regime={
+            "regime": "real_asset_pressure",
+            "confidence": 0.81,
+            "summary": "gold and silver continue to confirm macro stress",
+            "components": {"compression": 0.18, "real_asset_pressure": 0.67},
+        },
+    )
+    assert state.market_regime == "bull"
+    assert state.payload["argus_context"]["regime"] == "real_asset_pressure"
+    assert state.payload["argus_context"]["snapshot_regime"] == "risk_off"
+    assert state.payload["argus_context"]["snapshot_components"]["stress"] == 0.42
+    assert state.payload["argus_context"]["regime_components"]["real_asset_pressure"] == 0.67