Parcourir la source

Add Hermes control contract and strategy supervision signals

Lukas Goldschmidt il y a 3 semaines
Parent
commit
e11caa0f6e

+ 26 - 40
Hermes_Compatibility_Plan.md

@@ -30,25 +30,17 @@ Hermes should remain:
 
 ## Canonical strategy set
 
-Start with a small orthogonal registry:
-
-- `idle`
-- `defensive`
-- `trend_following`
-- `mean_reversion`
-- `grid`
-- `breakout`
-- `event_driven`
+The current Hermes-compatible primary set is:
+
+- `grid_trader`
+- `trend_follower`
+- `exposure_protector`
 
 ### Strategy ordering priority
 
-1. `idle`
-2. `defensive`
-3. `trend_following`
-4. `mean_reversion`
-5. `grid`
-6. `breakout`
-7. `event_driven`
+1. `grid_trader`
+2. `trend_follower`
+3. `exposure_protector`
 
 This is not a quality ranking. It is a sequencing plan for implementation and testing.
 
@@ -107,33 +99,29 @@ Acceptance criteria:
 - no micro-strategy sprawl
 - old names can be redirected or deprecated cleanly
 
-### Phase 2, expose Hermes-facing inspection tools
+### Phase 2, harden the Hermes read path
 Deliverables:
 - `list_strategies`
 - `get_strategy`
-- `get_runtime_state`
-- `get_recent_changes`
-- `get_decision_history`
-- `get_execution_state`
+- stable `report()` shape
 
 Acceptance criteria:
 - Hermes can inspect state without knowing internals
 - dashboard and API show the same truth
 - every response is structured and auditable
+- extra read tools are not added unless the canonical read path proves insufficient
 
-### Phase 3, add control tools for Hermes
+### Phase 3, add the Hermes action layer
 Deliverables:
 - `apply_control_decision`
-- `switch_strategy`
-- `set_risk_mode`
-- `pause_strategy`
-- `resume_strategy`
-- `reconcile_state`
+- internal `switch_strategy`
+- audit persistence for applied and rejected actions
 
 Acceptance criteria:
-- Hermes can control Trader through explicit verbs
+- Hermes controls Trader through one canonical write entry point
 - invalid or unsafe requests are rejected cleanly
 - every transition records a reason and decision id
+- `keep` remains Hermes-local and is not sent as a Trader write action
 
 ### Phase 4, implement switching safety
 Deliverables:
@@ -150,10 +138,9 @@ Acceptance criteria:
 
 ### Phase 5, implement initial strategies
 Deliverables:
-- `idle`
-- `defensive`
-- `trend_following`
-- `mean_reversion`
+- `grid_trader`
+- `trend_follower`
+- `exposure_protector`
 
 Acceptance criteria:
 - each one has metadata and tests
@@ -162,12 +149,13 @@ Acceptance criteria:
 
 ### Phase 6, add structure strategies
 Deliverables:
-- `grid`
-- `breakout`
+- tighten switching semantics around real inventory pressure
+- separate trend handoff from defensive rebalance handoff
 
 Acceptance criteria:
 - grid is clearly structure-based, not signal-based
-- breakout is separated from trend-following
+- moderate imbalance reports `watch_handoff`, not immediate `ready_for_handoff`
+- true depletion is required before grid self-reports `ready_for_handoff`
 - tests cover the distinction
 
 ### Phase 7, add event-aware behavior
@@ -241,11 +229,9 @@ Acceptance criteria:
 5. implement the inspection tools
 6. implement the control tools
 7. add switching safety
-8. implement `idle` and `defensive`
-9. implement `trend_following` and `mean_reversion`
-10. add `grid`, `breakout`, and `event_driven`
-11. wire execution feedback into selection
-12. polish dashboard and traceability
+8. tighten supervision semantics on current strategies
+9. wire execution feedback into selection
+10. polish dashboard and traceability
 
 ## Notes on ownership and scope
 

+ 248 - 0
Hermes_Trader_Action_Contract_v0.1.md

@@ -0,0 +1,248 @@
+# Hermes → Trader Action Contract (v0.1)
+
+This document defines the narrow write contract from `hermes-mcp` to `trader-mcp`.
+
+## Goal
+
+Keep Hermes in charge of decisions and keep Trader in charge of safe application.
+
+Hermes should send one explicit control decision.
+Trader should validate it, apply it if safe, and return a structured result.
+
+## Design rules
+
+1. One Hermes write entry point.
+2. Small explicit schema.
+3. No duplicate control surfaces.
+4. Safe rejection is a normal outcome.
+5. Every applied or rejected action is auditable.
+6. `keep` is not a Trader write action. Hermes may record `keep` on its own side.
+
+## Canonical Hermes write tool
+
+Trader exposes one canonical Hermes write tool:
+
+- `apply_control_decision(payload)`
+
+Hermes should not need to combine multiple Trader tools to express one decision.
+
+## Non-canonical helpers
+
+These may still exist internally or for operator use, but they are not the Hermes contract:
+
+- `control_strategy(...)`
+- `set_strategy_policy(...)`
+- `switch_strategy(...)`
+- `pause_strategy(...)`
+- `resume_strategy(...)`
+- `reconcile_instance(...)`
+
+They are runtime helpers, not the cross-system write boundary.
+
+## Supported actions
+
+The Hermes write contract supports only these actions:
+
+- `switch`
+- `pause`
+- `resume`
+- `set_risk_mode`
+
+Not supported here:
+
+- `keep`
+- free-form policy mutation
+- multi-step orchestration batches
+- broad config editing
+
+## Request shape
+
+```json
+{
+  "decision_id": "dec_2026_04_16_001",
+  "concern_id": "qndd8o9ppop6:xrpusd",
+  "account_id": "qndd8o9ppop6",
+  "market_symbol": "xrpusd",
+  "action": "switch",
+  "target_strategy_id": "0c555fee-e4c8-4543-ae70-c132517017e1",
+  "expected_active_strategy_id": "9cf29124-65a7-4950-ac18-65f938e0239b",
+  "risk_mode": "normal",
+  "reason": "trend strategy still fits the directional narrative",
+  "confidence": 0.83,
+  "dry_run": false,
+  "override": false,
+  "requested_at": "2026-04-16T20:15:00Z"
+}
+```
+
+## Required fields
+
+- `decision_id`
+- `concern_id`
+- `account_id`
+- `market_symbol`
+- `action`
+- `reason`
+- `confidence`
+
+## Conditionally required fields
+
+### For `switch`
+- `target_strategy_id`
+
+### For `set_risk_mode`
+- `risk_mode`
+
+### For guarded transitions
+- `expected_active_strategy_id` is strongly recommended
+
+## Field meanings
+
+### `decision_id`
+Stable id from Hermes. Trader stores it and uses it for audit linkage and idempotency checks.
+
+### `concern_id`
+The account-market concern Hermes is acting on.
+
+### `expected_active_strategy_id`
+What Hermes believes is active right now.
+If provided and Trader sees something else, Trader should reject unless `override=true`.
+
+### `reason`
+Human-readable explanation.
+Short, concrete, stable enough for audit logs.
+
+### `confidence`
+Normalized float in `[0, 1]`.
+Trader does not have to trust it blindly, but may use it in guardrails.
+
+### `dry_run`
+Validate and simulate only.
+No state-changing action should occur.
+
+### `override`
+Explicit emergency bypass for guarded rejection paths.
+Use sparingly and always record it.
+
+## Response shape
+
+```json
+{
+  "ok": true,
+  "status": "applied",
+  "decision_id": "dec_2026_04_16_001",
+  "concern_id": "qndd8o9ppop6:xrpusd",
+  "action": "switch",
+  "from_strategy_id": "9cf29124-65a7-4950-ac18-65f938e0239b",
+  "to_strategy_id": "0c555fee-e4c8-4543-ae70-c132517017e1",
+  "risk_mode": "normal",
+  "dry_run": false,
+  "validation": {
+    "concern_match": true,
+    "account_match": true,
+    "market_match": true,
+    "expected_active_match": true,
+    "target_exists": true,
+    "target_runnable": true
+  },
+  "warnings": [],
+  "errors": [],
+  "result": {
+    "mode_change": "applied",
+    "reconciled": true
+  },
+  "applied_at": "2026-04-16T20:15:01Z"
+}
+```
+
+## Status values
+
+- `applied`
+- `rejected`
+- `noop`
+- `failed`
+
+### `applied`
+Validation passed and Trader changed state.
+
+### `rejected`
+Trader refused the request due to validation or safety rules.
+This is a valid control outcome.
+
+### `noop`
+The requested state already exists.
+Example: Hermes asks to resume a strategy that is already active.
+
+### `failed`
+Trader attempted the action but could not complete it due to runtime or persistence failure.
+
+## Validation rules
+
+Trader should validate at least the following:
+
+1. `account_id` matches the target strategy or concern.
+2. `market_symbol` matches the target strategy or concern.
+3. `target_strategy_id` exists for `switch`.
+4. the target strategy belongs to the same account-market pair.
+5. if `expected_active_strategy_id` is provided, it matches reality unless `override=true`.
+6. the action is currently safe under runtime guardrails.
+7. `confidence` is a valid numeric value in range.
+
+## Initial safety gates
+
+Version `0.1` implements a small set of safety gates:
+
+- expected active strategy check
+- target existence check
+- account-market match check
+- degraded execution rejection unless `override=true`
+- idempotent duplicate decision rejection or noop handling
+
+Current implementation also records each attempt in Trader audit storage and reconciles the affected strategy runtime after an applied state change.
+
+## Idempotency
+
+Trader should treat `decision_id` as an idempotency key.
+Repeated submissions of the same already-applied decision should not reapply the state transition.
+
+## Audit persistence
+
+Trader should persist every Hermes action attempt, including:
+
+- request payload
+- validation result
+- final status
+- result payload
+- timestamps
+
+A rejected action is still important and should be stored.
+
+## Read/write separation
+
+Hermes read path should stay separate from Hermes write path.
+
+### Read
+- `list_strategies()`
+- `get_strategy()`
+
+### Write
+- `apply_control_decision(payload)`
+
+Avoid proliferating extra Hermes-facing tools unless a real gap appears.
+
+## Suggested implementation mapping inside Trader
+
+`apply_control_decision(payload)` may internally call existing runtime helpers:
+
+- `control_strategy(...)`
+- `set_strategy_policy(...)`
+- `reconcile_instance(...)`
+- a new internal `switch_strategy(...)`
+
+But Hermes should only see one write entry point.
+
+## Versioning rule
+
+Keep this contract small.
+Future versions should extend it only when a real decision or execution need appears.
+If a new field does not improve safety, auditability, or decision fidelity, it probably does not belong here.

+ 4 - 0
README.md

@@ -14,6 +14,7 @@ Trader MCP runs strategies, persists their state, and exposes a compact MCP surf
 - strategy lifecycle control (`start`, `pause`, `resume`, `stop`, `reconcile`)
 - strategy snapshots with `report()` and `fit`
 - Hermes policy storage via `set_strategy_policy`
+- Hermes action dispatch via `apply_control_decision(payload)`
 - grid trading
 - exposure protection
 - trend following
@@ -25,6 +26,7 @@ Trader MCP runs strategies, persists their state, and exposes a compact MCP surf
 - `update_strategy`
 - `control_strategy`
 - `set_strategy_policy`
+- `apply_control_decision`
 - `get_capabilities`
 
 ## `get_strategy()` defaults
@@ -56,7 +58,9 @@ Default port: `8570`
 ## Notes
 - `control_strategy()` handles lifecycle verbs.
 - `set_strategy_policy()` stores Hermes intent as `risk_posture` and `priority`.
+- `apply_control_decision()` is the canonical Hermes write tool for `switch`, `pause`, `resume`, and `set_risk_mode`.
 - `report().fit` is the main Hermes-facing strategy fit block.
+- `report().supervision` carries the strategy self-report used by Hermes for switching safety and ranking.
 - policy is applied again on reconcile.
 
 ## Development

+ 19 - 13
TODO.md

@@ -16,11 +16,13 @@ Trader-MCP concerns strategy behavior and the surfaces strategies must expose.
 Hermes decides the market story and the active control stance.
 
 ## 1. Contract first
-- Freeze the Hermes ⇄ Trader contract before adding new strategy behavior.
-- Make all control verbs explicit and schema-validated.
+- Freeze the Hermes ⇄ Trader read contract before adding new strategy behavior.
+- Freeze the Hermes → Trader action contract before adding new control paths.
 - Keep every request and response machine readable.
 - Require `decision_id`, `reason`, and `confidence` for Hermes control decisions.
 - Require Trader to return a structured applied-result record.
+- Keep one canonical Hermes write tool: `apply_control_decision`.
+- Do not send `keep` actions to Trader; Hermes records `keep` on its own side.
 
 ## 2. Canonical strategy taxonomy
 Reduce the strategy set to a small, orthogonal registry.
@@ -91,24 +93,26 @@ Preferred additions:
 - `last_error`
 
 ## 5. Trader surface for Hermes
-Refactor the MCP tools toward Hermes usefulness.
+Refactor the MCP tools toward Hermes usefulness, but keep the surface narrow.
 
-Priority read tools:
+Canonical read tools:
 - `list_strategies`
 - `get_strategy`
-- `get_runtime_state`
-- `get_recent_changes`
-- `get_decision_history`
-- `get_execution_state`
 
-Priority control tools:
+Canonical write tool:
 - `apply_control_decision`
+
+Internal or operator helpers may still exist:
 - `switch_strategy`
 - `set_risk_mode`
 - `pause_strategy`
 - `resume_strategy`
 - `reconcile_state`
 
+Rules:
+- Hermes should not need multiple Trader tool calls to express one decision.
+- Avoid adding extra Hermes-facing read tools unless `get_strategy().report` proves insufficient.
+
 ## 6. Switching policy
 Prevent flip-flopping.
 
@@ -177,14 +181,16 @@ Notes:
 - mark parameter variants as modes
 - add metadata to current strategies
 
-### Phase 2, expose Hermes-facing inspection tools
-- add the read tools listed above
+### Phase 2, harden the Hermes read path
+- make `get_strategy().report` the canonical structured read shape
+- ensure `list_strategies()` and `get_strategy()` cover Hermes inspection needs
 - ensure dashboard and API agree
 
-### Phase 3, add control tools
-- add the control tools listed above
+### Phase 3, add the action layer
+- add `apply_control_decision`
 - validate every transition
 - log the reason for every decision
+- keep legacy helper verbs behind the single write entry point where possible
 
 ### Phase 4, implement switching safety
 - confidence threshold

+ 283 - 1
src/trader_mcp/server.py

@@ -1,12 +1,13 @@
 from contextlib import asynccontextmanager
 import asyncio
+from datetime import datetime, timezone
 
 from fastapi import FastAPI
 
 from .dashboard import router as dashboard_router
 from .strategy_engine import reconcile_all, reconcile_instance, run_due_ticks, get_running_strategy, pause_strategy, resume_strategy, tick_strategy
 from .strategy_registry import list_available_strategy_modules
-from .strategy_store import add_strategy_instance, delete_strategy_instance, list_strategy_instances, update_strategy_config, update_strategy_mode, update_strategy_state
+from .strategy_store import add_control_action, add_strategy_instance, delete_strategy_instance, get_control_action_by_decision_id, get_strategy_instance, list_strategy_instances, update_strategy_config, update_strategy_mode, update_strategy_state
 
 try:
     from mcp.server.fastmcp import FastMCP
@@ -123,10 +124,16 @@ def list_strategies() -> dict:
                 "mode": record.mode,
                 "status": "running" if record.mode != "off" else "stopped",
                 "account_id": record.account_id,
+                "client_id": record.client_id,
                 "market_symbol": record.market_symbol,
+                "base_currency": record.base_currency,
+                "counter_currency": record.counter_currency,
+                "config": record.config or {},
+                "state": state,
                 "last_price": state.get("last_price"),
                 "last_side": state.get("last_side") or state.get("last_action"),
                 "open_order_count": state.get("open_order_count", 0),
+                "last_error": state.get("last_error", ""),
             }
         )
     return {"strategies": strategies}
@@ -253,6 +260,275 @@ def set_strategy_policy(instance_id: str, policy: dict) -> dict:
     return {"ok": False, "id": instance_id, "error": "failed to persist policy"}
 
 
+def _utc_now() -> str:
+    return datetime.now(timezone.utc).isoformat()
+
+
+def _active_strategy_for_scope(account_id: str, market_symbol: str | None) -> object | None:
+    wanted_market = str(market_symbol or "").strip().lower() or None
+    for record in list_strategy_instances():
+        if record.account_id != account_id:
+            continue
+        record_market = str(record.market_symbol or "").strip().lower() or None
+        if wanted_market != record_market:
+            continue
+        if str(record.mode or "").lower() == "active":
+            return record
+    return None
+
+
+def _is_degraded(instance_id: str) -> bool:
+    snapshot = get_strategy(instance_id, include_report=True)
+    report = snapshot.get("report") if isinstance(snapshot, dict) else {}
+    if not isinstance(report, dict):
+        return False
+    supervision = report.get("supervision") if isinstance(report.get("supervision"), dict) else {}
+    if bool(supervision.get("degraded")):
+        return True
+    execution = report.get("execution") if isinstance(report.get("execution"), dict) else {}
+    quality = str(execution.get("execution_quality") or "").strip().lower()
+    return quality in {"degraded", "poor", "bad", "failed"}
+
+
+def switch_strategy(*, account_id: str, market_symbol: str | None, target_strategy_id: str, expected_active_strategy_id: str | None = None, override: bool = False) -> dict:
+    target = get_strategy_instance(target_strategy_id)
+    if target is None:
+        return {"ok": False, "status": "rejected", "error": "target strategy not found"}
+    wanted_market = str(market_symbol or target.market_symbol or "").strip().lower() or None
+    if target.account_id != account_id:
+        return {"ok": False, "status": "rejected", "error": "target strategy account mismatch"}
+    if (str(target.market_symbol or "").strip().lower() or None) != wanted_market:
+        return {"ok": False, "status": "rejected", "error": "target strategy market mismatch"}
+
+    current = _active_strategy_for_scope(account_id, wanted_market)
+    current_id = getattr(current, "id", None)
+    if expected_active_strategy_id and current_id != expected_active_strategy_id and not override:
+        return {"ok": False, "status": "rejected", "error": "expected active strategy mismatch", "from_strategy_id": current_id}
+    if current_id == target_strategy_id:
+        return {"ok": True, "status": "noop", "from_strategy_id": current_id, "to_strategy_id": target_strategy_id, "reconciled": False}
+    if current_id and _is_degraded(current_id) and not override:
+        return {"ok": False, "status": "rejected", "error": "current strategy is execution-degraded", "from_strategy_id": current_id}
+
+    if current_id:
+        update_strategy_mode(current_id, "off")
+        reconcile_instance(current_id)
+    activated_at = _utc_now()
+    ok = update_strategy_mode(target_strategy_id, "active", activated_at=activated_at, started_at=activated_at)
+    if not ok:
+        return {"ok": False, "status": "failed", "error": "failed to activate target strategy", "from_strategy_id": current_id, "to_strategy_id": target_strategy_id}
+    reconcile_instance(target_strategy_id)
+    return {"ok": True, "status": "applied", "from_strategy_id": current_id, "to_strategy_id": target_strategy_id, "reconciled": True}
+
+
+def apply_control_decision(payload: dict) -> dict:
+    """apply_control_decision(payload)
+
+    Apply one Hermes control decision through a single validated write entry point.
+    Supported actions: switch, pause, resume, set_risk_mode.
+    """
+    if not isinstance(payload, dict):
+        return {"ok": False, "status": "rejected", "error": "payload must be an object"}
+
+    decision_id = str(payload.get("decision_id") or "").strip()
+    concern_id = str(payload.get("concern_id") or "").strip() or None
+    account_id = str(payload.get("account_id") or "").strip()
+    market_symbol = str(payload.get("market_symbol") or "").strip() or None
+    action = str(payload.get("action") or "").strip().lower()
+    target_strategy_id = str(payload.get("target_strategy_id") or "").strip() or None
+    expected_active_strategy_id = str(payload.get("expected_active_strategy_id") or "").strip() or None
+    risk_mode = str(payload.get("risk_mode") or "").strip() or None
+    reason = str(payload.get("reason") or "").strip()
+    dry_run = bool(payload.get("dry_run"))
+    override = bool(payload.get("override"))
+
+    existing = get_control_action_by_decision_id(decision_id) if decision_id else None
+    if existing is not None:
+        return dict(existing.result or {"ok": False, "status": existing.status, "decision_id": decision_id})
+
+    validation = {
+        "decision_id_present": bool(decision_id),
+        "account_id_present": bool(account_id),
+        "market_symbol_present": bool(market_symbol),
+        "reason_present": bool(reason),
+        "target_exists": None,
+        "target_scope_match": None,
+        "expected_active_match": None,
+    }
+    errors: list[str] = []
+
+    try:
+        confidence = float(payload.get("confidence"))
+        validation["confidence_valid"] = 0.0 <= confidence <= 1.0
+    except Exception:
+        confidence = None
+        validation["confidence_valid"] = False
+
+    if not decision_id:
+        errors.append("decision_id is required")
+    if not account_id:
+        errors.append("account_id is required")
+    if not market_symbol:
+        errors.append("market_symbol is required")
+    if not reason:
+        errors.append("reason is required")
+    if action not in {"switch", "pause", "resume", "set_risk_mode"}:
+        errors.append(f"unsupported action: {action}")
+    if confidence is None or not validation["confidence_valid"]:
+        errors.append("confidence must be a number between 0 and 1")
+    if action == "switch" and not target_strategy_id:
+        errors.append("target_strategy_id is required for switch")
+    if action == "set_risk_mode" and not risk_mode:
+        errors.append("risk_mode is required for set_risk_mode")
+
+    target = get_strategy_instance(target_strategy_id) if target_strategy_id else None
+    if target_strategy_id:
+        validation["target_exists"] = target is not None
+        validation["target_scope_match"] = bool(target is not None and target.account_id == account_id and str(target.market_symbol or "").lower() == str(market_symbol or "").lower())
+        if target is None:
+            errors.append("target strategy not found")
+        elif not validation["target_scope_match"]:
+            errors.append("target strategy does not belong to the requested account-market scope")
+
+    current = _active_strategy_for_scope(account_id, market_symbol)
+    current_id = getattr(current, "id", None)
+    validation["expected_active_match"] = (current_id == expected_active_strategy_id) if expected_active_strategy_id else None
+    if expected_active_strategy_id and current_id != expected_active_strategy_id and not override:
+        errors.append("expected active strategy mismatch")
+
+    if errors:
+        response = {
+            "ok": False,
+            "status": "rejected",
+            "decision_id": decision_id,
+            "concern_id": concern_id,
+            "action": action,
+            "from_strategy_id": current_id,
+            "to_strategy_id": target_strategy_id,
+            "risk_mode": risk_mode,
+            "dry_run": dry_run,
+            "validation": validation,
+            "warnings": [],
+            "errors": errors,
+            "result": {},
+            "applied_at": _utc_now(),
+        }
+        if decision_id:
+            add_control_action(
+                decision_id=decision_id,
+                concern_id=concern_id,
+                account_id=account_id,
+                market_symbol=market_symbol,
+                action=action,
+                target_strategy_id=target_strategy_id,
+                expected_active_strategy_id=expected_active_strategy_id,
+                status="rejected",
+                payload=payload,
+                validation=validation,
+                result=response,
+            )
+        return response
+
+    if dry_run:
+        response = {
+            "ok": True,
+            "status": "noop",
+            "decision_id": decision_id,
+            "concern_id": concern_id,
+            "action": action,
+            "from_strategy_id": current_id,
+            "to_strategy_id": target_strategy_id,
+            "risk_mode": risk_mode,
+            "dry_run": True,
+            "validation": validation,
+            "warnings": [],
+            "errors": [],
+            "result": {"dry_run": True},
+            "applied_at": _utc_now(),
+        }
+        add_control_action(
+            decision_id=decision_id,
+            concern_id=concern_id,
+            account_id=account_id,
+            market_symbol=market_symbol,
+            action=action,
+            target_strategy_id=target_strategy_id,
+            expected_active_strategy_id=expected_active_strategy_id,
+            status="noop",
+            payload=payload,
+            validation=validation,
+            result=response,
+        )
+        return response
+
+    result: dict
+    if action == "switch":
+        result = switch_strategy(
+            account_id=account_id,
+            market_symbol=market_symbol,
+            target_strategy_id=target_strategy_id or "",
+            expected_active_strategy_id=expected_active_strategy_id,
+            override=override,
+        )
+    elif action == "pause":
+        if not current_id:
+            result = {"ok": True, "status": "noop", "from_strategy_id": None, "reconciled": False}
+        else:
+            pause_result = pause_strategy(current_id)
+            result = {"ok": bool(pause_result.get("ok")), "status": "applied" if pause_result.get("ok") else "failed", "from_strategy_id": current_id, "to_strategy_id": current_id, "reconciled": False}
+    elif action == "resume":
+        if not current_id:
+            result = {"ok": True, "status": "noop", "from_strategy_id": None, "reconciled": False}
+        else:
+            resume_result = resume_strategy(current_id)
+            result = {"ok": bool(resume_result.get("ok")), "status": "applied" if resume_result.get("ok") else "failed", "from_strategy_id": current_id, "to_strategy_id": current_id, "reconciled": False}
+    else:  # set_risk_mode
+        target_id = target_strategy_id or current_id
+        if not target_id:
+            result = {"ok": True, "status": "noop", "from_strategy_id": None, "reconciled": False}
+        else:
+            policy_result = set_strategy_policy(
+                target_id,
+                {
+                    "risk_posture": risk_mode,
+                    "reason": reason,
+                    "decision_id": decision_id,
+                    "priority": (target.config.get("policy", {}) if target else {}).get("priority", "normal") if target else "normal",
+                },
+            )
+            result = {"ok": bool(policy_result.get("loaded", policy_result.get("ok", False)) or policy_result.get("running") is not None), "status": "applied" if (policy_result.get("loaded", policy_result.get("ok", False)) or policy_result.get("running") is not None) else "failed", "from_strategy_id": target_id, "to_strategy_id": target_id, "reconciled": True}
+
+    response = {
+        "ok": bool(result.get("ok")),
+        "status": str(result.get("status") or ("applied" if result.get("ok") else "failed")),
+        "decision_id": decision_id,
+        "concern_id": concern_id,
+        "action": action,
+        "from_strategy_id": result.get("from_strategy_id", current_id),
+        "to_strategy_id": result.get("to_strategy_id", target_strategy_id),
+        "risk_mode": risk_mode,
+        "dry_run": False,
+        "validation": validation,
+        "warnings": [],
+        "errors": [str(result.get("error"))] if result.get("error") else [],
+        "result": result,
+        "applied_at": _utc_now(),
+    }
+    add_control_action(
+        decision_id=decision_id,
+        concern_id=concern_id,
+        account_id=account_id,
+        market_symbol=market_symbol,
+        action=action,
+        target_strategy_id=target_strategy_id,
+        expected_active_strategy_id=expected_active_strategy_id,
+        status=str(response["status"]),
+        payload=payload,
+        validation=validation,
+        result=response,
+    )
+    return response
+
+
 def control_strategy(instance_id: str, action: str) -> dict:
     """control_strategy(instance_id, action)
 
@@ -306,6 +582,11 @@ def get_capabilities() -> dict:
                 "description": "Store a high-level Hermes policy on a strategy.",
                 "params": {"policy": "{risk_posture, priority, reason?, decision_id?}"},
             },
+            {
+                "name": "apply_control_decision",
+                "description": "Apply one validated Hermes control decision with audit persistence and idempotency.",
+                "params": {"payload": "{decision_id, concern_id?, account_id, market_symbol, action, target_strategy_id?, expected_active_strategy_id?, risk_mode?, reason, confidence, dry_run?, override?}"},
+            },
         ],
         "strategy_summary_fields": [
             "id",
@@ -343,6 +624,7 @@ if FastMCP is not None:
     mcp.tool()(update_strategy)
     mcp.tool()(control_strategy)
     mcp.tool()(set_strategy_policy)
+    mcp.tool()(apply_control_decision)
     mcp.tool()(get_capabilities)
 
     app.mount("/mcp", mcp.sse_app())

+ 99 - 0
src/trader_mcp/strategy_store.py

@@ -35,6 +35,24 @@ class StrategyRecord:
     updated_at: str
 
 
+@dataclass(frozen=True)
+class ControlActionRecord:
+    id: int
+    decision_id: str
+    concern_id: str | None
+    account_id: str
+    market_symbol: str | None
+    action: str
+    target_strategy_id: str | None
+    expected_active_strategy_id: str | None
+    status: str
+    payload: dict[str, Any]
+    validation: dict[str, Any]
+    result: dict[str, Any]
+    created_at: str
+    updated_at: str
+
+
 def _utc_now() -> str:
     return datetime.now(timezone.utc).isoformat()
 
@@ -72,6 +90,26 @@ def init_db() -> None:
             )
             """
         )
+        conn.execute(
+            """
+            CREATE TABLE IF NOT EXISTS control_actions (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                decision_id TEXT NOT NULL UNIQUE,
+                concern_id TEXT,
+                account_id TEXT NOT NULL,
+                market_symbol TEXT,
+                action TEXT NOT NULL,
+                target_strategy_id TEXT,
+                expected_active_strategy_id TEXT,
+                status TEXT NOT NULL,
+                payload_json TEXT NOT NULL DEFAULT '{}',
+                validation_json TEXT NOT NULL DEFAULT '{}',
+                result_json TEXT NOT NULL DEFAULT '{}',
+                created_at TEXT NOT NULL,
+                updated_at TEXT NOT NULL
+            )
+            """
+        )
         columns = {row[1] for row in conn.execute("PRAGMA table_info(strategy_instances)").fetchall()}
         if "name" not in columns:
             conn.execute("ALTER TABLE strategy_instances ADD COLUMN name TEXT NOT NULL DEFAULT ''")
@@ -214,3 +252,64 @@ def _row_to_record(row: sqlite3.Row | None) -> StrategyRecord | None:
         created_at=row["created_at"],
         updated_at=row["updated_at"],
     )
+
+
+def get_control_action_by_decision_id(decision_id: str) -> ControlActionRecord | None:
+    init_db()
+    with get_connection() as conn:
+        row = conn.execute("SELECT * FROM control_actions WHERE decision_id = ?", (decision_id,)).fetchone()
+    return _row_to_control_action(row) if row else None
+
+
+def add_control_action(*, decision_id: str, concern_id: str | None, account_id: str, market_symbol: str | None, action: str, target_strategy_id: str | None, expected_active_strategy_id: str | None, status: str, payload: dict[str, Any] | None = None, validation: dict[str, Any] | None = None, result: dict[str, Any] | None = None) -> ControlActionRecord:
+    init_db()
+    now = _utc_now()
+    with get_connection() as conn:
+        conn.execute(
+            """
+            INSERT INTO control_actions
+            (decision_id, concern_id, account_id, market_symbol, action, target_strategy_id, expected_active_strategy_id, status, payload_json, validation_json, result_json, created_at, updated_at)
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            """,
+            (
+                decision_id,
+                concern_id,
+                account_id,
+                market_symbol,
+                action,
+                target_strategy_id,
+                expected_active_strategy_id,
+                status,
+                json.dumps(payload or {}),
+                json.dumps(validation or {}),
+                json.dumps(result or {}),
+                now,
+                now,
+            ),
+        )
+        conn.commit()
+    record = get_control_action_by_decision_id(decision_id)
+    if record is None:
+        raise RuntimeError(f"failed to persist control action {decision_id}")
+    return record
+
+
+def _row_to_control_action(row: sqlite3.Row | None) -> ControlActionRecord | None:
+    if row is None:
+        return None
+    return ControlActionRecord(
+        id=int(row["id"]),
+        decision_id=row["decision_id"],
+        concern_id=row["concern_id"],
+        account_id=row["account_id"],
+        market_symbol=row["market_symbol"],
+        action=row["action"],
+        target_strategy_id=row["target_strategy_id"],
+        expected_active_strategy_id=row["expected_active_strategy_id"],
+        status=row["status"],
+        payload=json.loads(row["payload_json"] or "{}"),
+        validation=json.loads(row["validation_json"] or "{}"),
+        result=json.loads(row["result_json"] or "{}"),
+        created_at=row["created_at"],
+        updated_at=row["updated_at"],
+    )

+ 1 - 0
strategies/exposure_protector.md

@@ -29,3 +29,4 @@ Defensive rebalancer that trims skew and protects exposure.
 - This strategy is passive until Hermes enables it.
 - It should protect and rebalance, not decide regime.
 - Trader derives concrete execution values from policy.
+- `report().supervision` should be interpreted as a defensive attachment signal, not as a preferred replacement for a healthy grid during persistent trend continuation unless imbalance is genuinely severe.

+ 30 - 7
strategies/exposure_protector.py

@@ -10,19 +10,18 @@ class Strategy(Strategy):
     LABEL = "Exposure Protector"
     STRATEGY_PROFILE = {
         "expects": {
-            "trend": "strong",
+            "trend": "mixed",
             "volatility": "moderate",
             "event_risk": "low",
             "liquidity": "normal",
         },
         "avoids": {
-            "trend": "range",
             "volatility": "chaotic",
             "event_risk": "high",
             "liquidity": "thin",
         },
         "risk_profile": "defensive",
-        "capabilities": ["exposure_trim", "trail_protection", "balance_recovery"],
+        "capabilities": ["inventory_rebalancing", "exposure_trim", "companion_defense"],
         "role": "defensive",
         "inventory_behavior": "rebalancing",
         "requires_rebalance_before_start": False,
@@ -39,8 +38,8 @@ class Strategy(Strategy):
         "max_order_size": {"type": "float", "default": 0.0, "min": 0.0},
         "order_spacing_ticks": {"type": "int", "default": 1, "min": 0, "max": 1000},
         "cooldown_ticks": {"type": "int", "default": 2, "min": 0, "max": 1000},
-        "min_rebalance_seconds": {"type": "int", "default": 300, "min": 0, "max": 86400},
-        "min_price_move_pct": {"type": "float", "default": 0.01, "min": 0.0, "max": 1.0},
+        "min_rebalance_seconds": {"type": "int", "default": 180, "min": 0, "max": 86400},
+        "min_price_move_pct": {"type": "float", "default": 0.005, "min": 0.0, "max": 1.0},
         "balance_tolerance": {"type": "float", "default": 0.05, "min": 0.0, "max": 1.0},
         "fee_rate": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.05},
         "debug_orders": {"type": "bool", "default": True},
@@ -97,8 +96,8 @@ class Strategy(Strategy):
 
         trail_map = {"cautious": 0.02, "normal": 0.03, "assertive": 0.04}
         step_map = {"cautious": 0.08, "normal": 0.15, "assertive": 0.25}
-        wait_map = {"cautious": 600, "normal": 300, "assertive": 120}
-        move_map = {"cautious": 0.02, "normal": 0.01, "assertive": 0.005}
+        wait_map = {"cautious": 420, "normal": 180, "assertive": 90}
+        move_map = {"cautious": 0.01, "normal": 0.005, "assertive": 0.003}
 
         if priority in {"low", "background"}:
             trail = trail_map.get("cautious", 0.02)
@@ -179,6 +178,29 @@ class Strategy(Strategy):
             return 0.5
         return base_value / total
 
+    def _supervision(self) -> dict:
+        price = float(self.state.get("last_price") or 0.0)
+        ratio = self._account_value_ratio(price if price > 0 else 1.0)
+        target = float(self.config.get("rebalance_target_ratio", 0.5) or 0.5)
+        tolerance = float(self.config.get("balance_tolerance", 0.05) or 0.05)
+        drift = abs(ratio - target)
+        last_error = str(self.state.get("last_error") or "")
+        if drift >= 0.35:
+            pressure = "critical"
+        elif drift > tolerance:
+            pressure = "elevated"
+        else:
+            pressure = "contained"
+        return {
+            "health": "degraded" if last_error else "healthy",
+            "degraded": bool(last_error),
+            "inventory_pressure": pressure,
+            "capacity_available": drift > tolerance,
+            "switch_readiness": "handoff_complete" if drift <= tolerance else "stay_attached",
+            "last_reason": last_error or f"base_ratio={ratio:.3f}, target={target:.3f}, drift={drift:.3f}",
+            "desired_companion": None,
+        }
+
     def _desired_side(self, price: float) -> str:
         # If base dominates, sell some into strength, otherwise buy some back.
         ratio = self._account_value_ratio(price)
@@ -311,6 +333,7 @@ class Strategy(Strategy):
                 "policy": dict(self.config.get("policy") or {}),
             },
             "execution": snapshot.get("execution", {}),
+            "supervision": self._supervision(),
         }
 
     def render(self):

+ 4 - 0
strategies/grid_trader.md

@@ -30,3 +30,7 @@ Passive, structure-based liquidity strategy.
 - The strategy does not decide regime fit itself.
 - Hermes decides activation.
 - Trader applies policy on reconcile.
+- `report().supervision` is a hint layer for Hermes, not an autonomous switch order.
+- `ready_for_handoff` means real one-sided depletion.
+- `watch_handoff` means directional pressure plus moderate inventory skew.
+- ordinary directional conditions alone should not mark the grid as handoff-ready.

+ 43 - 1
strategies/grid_trader.py

@@ -23,7 +23,7 @@ class Strategy(Strategy):
             "liquidity": "thin",
         },
         "risk_profile": "medium",
-        "capabilities": ["structure_harvesting", "range_making", "liquidity_harvesting"],
+        "capabilities": ["mean_reversion", "range_harvesting", "two_sided_inventory"],
         "role": "primary",
         "inventory_behavior": "balanced",
         "requires_rebalance_before_start": False,
@@ -288,6 +288,47 @@ class Strategy(Strategy):
             return f"warning: recenter threshold ({recenter_pct:.4f}) is only {ratio:.2f}x the grid step ({grid_step_pct:.4f}), consider widening it"
         return None
 
+    def _inventory_ratio(self, price: float) -> float:
+        base_value = float(self.state.get("base_available") or 0.0) * price
+        counter_value = float(self.state.get("counter_available") or 0.0)
+        total = base_value + counter_value
+        if total <= 0:
+            return 0.5
+        return base_value / total
+
+    def _supervision(self) -> dict:
+        price = float(self.state.get("last_price") or 0.0)
+        ratio = self._inventory_ratio(price if price > 0 else 1.0)
+        last_error = str(self.state.get("last_error") or "")
+        config_warning = self._config_warning()
+        regime_1h = (((self.state.get("regimes") or {}).get("1h") or {}).get("trend") or {}).get("state")
+        if ratio >= 0.88:
+            pressure = "base_side_depleted"
+        elif ratio <= 0.12:
+            pressure = "quote_side_depleted"
+        elif ratio >= 0.65:
+            pressure = "base_heavy"
+        elif ratio <= 0.35:
+            pressure = "quote_heavy"
+        else:
+            pressure = "balanced"
+        directional_1h = regime_1h in {"bull", "bear", "up", "down", "strong_up", "strong_down"}
+        if pressure in {"base_side_depleted", "quote_side_depleted"}:
+            switch_readiness = "ready_for_handoff"
+        elif pressure in {"base_heavy", "quote_heavy"} and directional_1h:
+            switch_readiness = "watch_handoff"
+        else:
+            switch_readiness = "prefer_hold"
+        return {
+            "health": "degraded" if last_error or config_warning else "healthy",
+            "degraded": bool(last_error or config_warning),
+            "inventory_pressure": pressure,
+            "capacity_available": pressure == "balanced",
+            "switch_readiness": switch_readiness,
+            "last_reason": last_error or config_warning or f"base_ratio={ratio:.3f}, trend_1h={regime_1h or 'unknown'}",
+            "desired_companion": "exposure_protector" if pressure in {"base_side_depleted", "quote_side_depleted"} else None,
+        }
+
     def _available_balance(self, asset_code: str) -> float:
         try:
             info = self.context.get_account_info()
@@ -939,6 +980,7 @@ class Strategy(Strategy):
                 "policy": dict(self.config.get("policy") or {}),
             },
             "execution": snapshot.get("execution", {}),
+            "supervision": self._supervision(),
         }
 
     def render(self):

+ 6 - 0
strategies/hello_world.py

@@ -10,6 +10,12 @@ class Strategy(Strategy):
         "avoids": {},
         "risk_profile": "demo",
         "capabilities": ["demo"],
+        "role": "companion",
+        "inventory_behavior": "none",
+        "requires_rebalance_before_start": False,
+        "requires_rebalance_before_stop": False,
+        "safe_when_unbalanced": True,
+        "can_run_with": [],
     }
     TICK_MINUTES = 0.2
     CONFIG_SCHEMA = {

+ 1 - 0
strategies/trend_follower.md

@@ -30,3 +30,4 @@ Directional strategy for confirmed momentum.
 - Hermes decides when trend following is allowed.
 - Trader maps policy to concrete order behavior.
 - The strategy reports signal, strength, and policy-derived settings.
+- `report().supervision` may signal `ready_to_yield_to_grid` only when trend pressure has cooled and inventory pressure is balanced.

+ 159 - 10
strategies/trend_follower.py

@@ -22,7 +22,7 @@ class Strategy(Strategy):
             "liquidity": "thin",
         },
         "risk_profile": "growth",
-        "capabilities": ["trend_capture", "momentum_following", "position_persistence"],
+        "capabilities": ["directional_continuation", "momentum_following", "inventory_accumulation"],
         "role": "primary",
         "inventory_behavior": "accumulative_long",
         "requires_rebalance_before_start": False,
@@ -38,6 +38,7 @@ class Strategy(Strategy):
         "exit_offset_pct": {"type": "float", "default": 0.002, "min": 0.0, "max": 1.0},
         "order_size": {"type": "float", "default": 0.0, "min": 0.0},
         "max_order_size": {"type": "float", "default": 0.0, "min": 0.0},
+        "fee_rate": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.05},
         "cooldown_ticks": {"type": "int", "default": 2, "min": 0, "max": 1000},
         "debug_orders": {"type": "bool", "default": True},
     }
@@ -51,6 +52,8 @@ class Strategy(Strategy):
         "cooldown_remaining": {"type": "int", "default": 0},
         "last_order_at": {"type": "float", "default": 0.0},
         "last_order_price": {"type": "float", "default": 0.0},
+        "base_available": {"type": "float", "default": 0.0},
+        "counter_available": {"type": "float", "default": 0.0},
     }
 
     def init(self):
@@ -64,6 +67,8 @@ class Strategy(Strategy):
             "cooldown_remaining": 0,
             "last_order_at": 0.0,
             "last_order_price": 0.0,
+            "base_available": 0.0,
+            "counter_available": 0.0,
         }
 
     def _log(self, message: str) -> None:
@@ -84,6 +89,72 @@ class Strategy(Strategy):
         payload = self.context.get_price(self._base_symbol())
         return float(payload.get("price") or 0.0)
 
+    def _live_fee_rate(self) -> float:
+        try:
+            payload = self.context.get_fee_rates(self._market_symbol())
+            return float(payload.get("maker") or payload.get("taker") or 0.0)
+        except Exception as exc:
+            self._log(f"fee lookup failed: {exc}")
+            return float(self.config.get("fee_rate", 0.0025) or 0.0)
+
+    def _refresh_balance_snapshot(self) -> None:
+        try:
+            info = self.context.get_account_info()
+        except Exception as exc:
+            self._log(f"balance refresh failed: {exc}")
+            return
+        balances = info.get("balances") if isinstance(info, dict) else []
+        if not isinstance(balances, list):
+            return
+        base = self._base_symbol()
+        quote = str(self.context.counter_currency or "USD").upper()
+        for balance in balances:
+            if not isinstance(balance, dict):
+                continue
+            asset = str(balance.get("asset_code") or "").upper()
+            try:
+                available = float(balance.get("available") if balance.get("available") is not None else balance.get("total") or 0.0)
+            except Exception:
+                continue
+            if asset == base:
+                self.state["base_available"] = available
+            if asset == quote:
+                self.state["counter_available"] = available
+
+    def _inventory_ratio(self, price: float) -> float:
+        base_value = float(self.state.get("base_available") or 0.0) * price
+        counter_value = float(self.state.get("counter_available") or 0.0)
+        total = base_value + counter_value
+        if total <= 0:
+            return 0.5
+        return base_value / total
+
+    def _supervision(self) -> dict:
+        price = float(self.state.get("last_price") or 0.0)
+        ratio = self._inventory_ratio(price if price > 0 else 1.0)
+        last_error = str(self.state.get("last_error") or "")
+        strength = float(self.state.get("last_strength") or 0.0)
+        signal = str(self.state.get("last_signal") or "neutral")
+        if ratio >= 0.88:
+            pressure = "base_heavy"
+        elif ratio <= 0.12:
+            pressure = "quote_heavy"
+        elif ratio >= 0.68:
+            pressure = "base_biased"
+        elif ratio <= 0.32:
+            pressure = "quote_biased"
+        else:
+            pressure = "balanced"
+        return {
+            "health": "degraded" if last_error else "healthy",
+            "degraded": bool(last_error),
+            "inventory_pressure": pressure,
+            "capacity_available": strength >= float(self.config.get("trend_strength_min", 0.65) or 0.65),
+            "switch_readiness": "ready_to_yield_to_grid" if pressure == "balanced" and strength < float(self.config.get("trend_strength_min", 0.65) or 0.65) else "prefer_hold",
+            "last_reason": last_error or f"signal={signal}, strength={strength:.3f}, base_ratio={ratio:.3f}",
+            "desired_companion": "exposure_protector" if pressure != "balanced" else None,
+        }
+
     def _trend_snapshot(self) -> dict:
         tf = str(self.config.get("trend_timeframe", "1h") or "1h")
         try:
@@ -108,11 +179,11 @@ class Strategy(Strategy):
         elif priority in {"high", "urgent"}:
             risk = "assertive"
 
-        self.config["trend_strength_min"] = strength_map.get(risk, 0.65)
-        self.config["entry_offset_pct"] = entry_map.get(risk, 0.003)
-        self.config["exit_offset_pct"] = exit_map.get(risk, 0.002)
-        self.config["cooldown_ticks"] = cooldown_map.get(risk, 2)
-        self.config["order_size"] = size_map.get(risk, 1.0)
+        self.config["trend_strength_min"] = float(self.config.get("trend_strength_min") or strength_map.get(risk, 0.65))
+        self.config["entry_offset_pct"] = float(self.config.get("entry_offset_pct") or entry_map.get(risk, 0.003))
+        self.config["exit_offset_pct"] = float(self.config.get("exit_offset_pct") or exit_map.get(risk, 0.002))
+        self.config["cooldown_ticks"] = int(self.config.get("cooldown_ticks") or cooldown_map.get(risk, 2))
+        self.config["order_size"] = float(self.config.get("order_size") or size_map.get(risk, 1.0))
         self.state["policy_derived"] = {
             "trend_strength_min": self.config["trend_strength_min"],
             "entry_offset_pct": self.config["entry_offset_pct"],
@@ -125,16 +196,90 @@ class Strategy(Strategy):
     def _trend_strength(self) -> tuple[str, float]:
         regime = self._trend_snapshot()
         trend = regime.get("trend") or {}
+        momentum = regime.get("momentum") or {}
         direction = str(trend.get("state") or trend.get("direction") or "unknown")
+        strength = self._coerce_strength(trend.get("strength"))
+        if strength is None:
+            strength = self._derive_strength_from_regime(direction=direction, trend=trend, momentum=momentum, regime=regime)
+        return direction, strength
+
+    def _coerce_strength(self, value) -> float | None:
         try:
-            strength = float(trend.get("strength") or 0.0)
+            if value is None:
+                return None
+            return max(0.0, min(1.0, float(value)))
         except Exception:
-            strength = 0.0
-        return direction, strength
+            return None
+
+    def _derive_strength_from_regime(self, *, direction: str, trend: dict, momentum: dict, regime: dict) -> float:
+        direction = str(direction or "unknown").lower()
+        score = 0.0
+
+        if direction in {"bull", "up", "long"}:
+            score += 0.45
+        elif direction in {"bear", "down", "short"}:
+            score += 0.45
+        else:
+            return 0.0
+
+        momentum_state = str(momentum.get("state") or "").lower()
+        if direction in {"bull", "up", "long"} and momentum_state == "bull":
+            score += 0.2
+        elif direction in {"bear", "down", "short"} and momentum_state == "bear":
+            score += 0.2
+
+        try:
+            rsi = float(momentum.get("rsi") or 0.0)
+        except Exception:
+            rsi = 0.0
+        if direction in {"bull", "up", "long"}:
+            if rsi >= 60:
+                score += 0.2
+            elif rsi >= 52:
+                score += 0.1
+        else:
+            if 0 < rsi <= 40:
+                score += 0.2
+            elif 0 < rsi <= 48:
+                score += 0.1
+
+        try:
+            macd_hist = float(momentum.get("macd_histogram") or 0.0)
+        except Exception:
+            macd_hist = 0.0
+        if direction in {"bull", "up", "long"} and macd_hist > 0:
+            score += 0.1
+        elif direction in {"bear", "down", "short"} and macd_hist < 0:
+            score += 0.1
+
+        try:
+            ema_fast = float(trend.get("ema_fast") or 0.0)
+            ema_slow = float(trend.get("ema_slow") or 0.0)
+        except Exception:
+            ema_fast = 0.0
+            ema_slow = 0.0
+        if direction in {"bull", "up", "long"} and ema_fast > ema_slow > 0:
+            score += 0.05
+        elif direction in {"bear", "down", "short"} and 0 < ema_fast < ema_slow:
+            score += 0.05
+
+        return max(0.0, min(1.0, round(score, 4)))
 
     def _suggest_amount(self, price: float) -> float:
-        amount = float(self.config.get("order_size", 0.0) or 0.0)
+        min_notional = float(self.context.minimum_order_value or 0.0)
         max_order = float(self.config.get("max_order_size", 0.0) or 0.0)
+        if hasattr(self.context, "suggest_order_amount"):
+            fee_rate = self._live_fee_rate()
+            return float(self.context.suggest_order_amount(
+                side="buy" if str(self.state.get("last_signal") or "").lower() in {"bull", "up", "long"} else "sell",
+                price=price,
+                levels=1,
+                min_notional=min_notional,
+                fee_rate=fee_rate,
+                max_notional_per_order=(max_order * price) if max_order > 0 else 0.0,
+                order_size=float(self.config.get("order_size", 0.0) or 0.0),
+            ) or 0.0)
+        amount = float(self.config.get("order_size", 0.0) or 0.0)
         if max_order > 0:
             amount = min(amount, max_order)
         return max(amount, 0.0)
@@ -142,6 +287,7 @@ class Strategy(Strategy):
     def on_tick(self, tick):
         self.state["last_error"] = ""
         self._log(f"tick alive price={self.state.get('last_price') or 0.0}")
+        self._refresh_balance_snapshot()
         price = self._price()
         self.state["last_price"] = price
 
@@ -204,6 +350,8 @@ class Strategy(Strategy):
                 "last_signal": self.state.get("last_signal", "neutral"),
                 "last_strength": self.state.get("last_strength", 0.0),
                 "cooldown_remaining": self.state.get("cooldown_remaining", 0),
+                "base_available": self.state.get("base_available", 0.0),
+                "counter_available": self.state.get("counter_available", 0.0),
             },
             "assessment": {
                 "confidence": None,
@@ -213,6 +361,7 @@ class Strategy(Strategy):
                 "policy": dict(self.config.get("policy") or {}),
             },
             "execution": snapshot.get("execution", {}),
+            "supervision": self._supervision(),
         }
 
     def render(self):

+ 112 - 1
tests/test_engine.py

@@ -6,7 +6,7 @@ from tempfile import TemporaryDirectory
 from fastapi.testclient import TestClient
 
 from src.trader_mcp import strategy_registry, strategy_store, strategy_engine
-from src.trader_mcp.server import app
+from src.trader_mcp.server import app, apply_control_decision
 
 
 STRATEGY_CODE = '''
@@ -251,3 +251,114 @@ def test_dashboard_detail_panel_shows_render_and_saves_config(tmp_path):
         strategy_store.DB_PATH = original_db
         strategy_registry.STRATEGIES_DIR = original_dir
         strategy_engine._running.clear()
+
+
+def test_apply_control_decision_switches_active_strategy_and_records_audit(tmp_path):
+    original_db = strategy_store.DB_PATH
+    original_dir = strategy_registry.STRATEGIES_DIR
+    try:
+        strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
+        strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
+        strategy_registry.STRATEGIES_DIR.mkdir()
+        (strategy_registry.STRATEGIES_DIR / "hello_world.py").write_text(STRATEGY_CODE)
+        (strategy_registry.STRATEGIES_DIR / "demo.py").write_text(STRATEGY_CODE)
+
+        strategy_store.add_strategy_instance(id="old", strategy_type="hello_world", account_id="acct-1", client_id="cid-old", mode="active", market_symbol="xrpusd", base_currency="XRP", counter_currency="USD", config={})
+        strategy_store.add_strategy_instance(id="new", strategy_type="demo", account_id="acct-1", client_id="cid-new", mode="off", market_symbol="xrpusd", base_currency="XRP", counter_currency="USD", config={})
+        strategy_engine.reconcile_all()
+
+        result = apply_control_decision(
+            {
+                "decision_id": "dec-switch-1",
+                "concern_id": "acct-1:xrpusd",
+                "account_id": "acct-1",
+                "market_symbol": "xrpusd",
+                "action": "switch",
+                "target_strategy_id": "new",
+                "expected_active_strategy_id": "old",
+                "reason": "trend fits better",
+                "confidence": 0.9,
+            }
+        )
+        assert result["ok"] is True
+        assert result["status"] == "applied"
+        assert strategy_store.get_strategy_instance("old").mode == "off"
+        assert strategy_store.get_strategy_instance("new").mode == "active"
+        audit = strategy_store.get_control_action_by_decision_id("dec-switch-1")
+        assert audit is not None
+        assert audit.status == "applied"
+        assert audit.result["to_strategy_id"] == "new"
+    finally:
+        strategy_store.DB_PATH = original_db
+        strategy_registry.STRATEGIES_DIR = original_dir
+        strategy_engine._running.clear()
+
+
+def test_apply_control_decision_is_idempotent_by_decision_id(tmp_path):
+    original_db = strategy_store.DB_PATH
+    original_dir = strategy_registry.STRATEGIES_DIR
+    try:
+        strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
+        strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
+        strategy_registry.STRATEGIES_DIR.mkdir()
+        (strategy_registry.STRATEGIES_DIR / "hello_world.py").write_text(STRATEGY_CODE)
+        (strategy_registry.STRATEGIES_DIR / "demo.py").write_text(STRATEGY_CODE)
+
+        strategy_store.add_strategy_instance(id="old", strategy_type="hello_world", account_id="acct-1", client_id="cid-old", mode="active", market_symbol="xrpusd", base_currency="XRP", counter_currency="USD", config={})
+        strategy_store.add_strategy_instance(id="new", strategy_type="demo", account_id="acct-1", client_id="cid-new", mode="off", market_symbol="xrpusd", base_currency="XRP", counter_currency="USD", config={})
+        strategy_engine.reconcile_all()
+
+        payload = {
+            "decision_id": "dec-switch-2",
+            "concern_id": "acct-1:xrpusd",
+            "account_id": "acct-1",
+            "market_symbol": "xrpusd",
+            "action": "switch",
+            "target_strategy_id": "new",
+            "expected_active_strategy_id": "old",
+            "reason": "trend fits better",
+            "confidence": 0.9,
+        }
+        first = apply_control_decision(payload)
+        second = apply_control_decision(payload)
+        assert first == second
+    finally:
+        strategy_store.DB_PATH = original_db
+        strategy_registry.STRATEGIES_DIR = original_dir
+        strategy_engine._running.clear()
+
+
+def test_apply_control_decision_rejects_expected_active_mismatch(tmp_path):
+    original_db = strategy_store.DB_PATH
+    original_dir = strategy_registry.STRATEGIES_DIR
+    try:
+        strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
+        strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
+        strategy_registry.STRATEGIES_DIR.mkdir()
+        (strategy_registry.STRATEGIES_DIR / "hello_world.py").write_text(STRATEGY_CODE)
+        (strategy_registry.STRATEGIES_DIR / "demo.py").write_text(STRATEGY_CODE)
+
+        strategy_store.add_strategy_instance(id="old", strategy_type="hello_world", account_id="acct-1", client_id="cid-old", mode="active", market_symbol="xrpusd", base_currency="XRP", counter_currency="USD", config={})
+        strategy_store.add_strategy_instance(id="new", strategy_type="demo", account_id="acct-1", client_id="cid-new", mode="off", market_symbol="xrpusd", base_currency="XRP", counter_currency="USD", config={})
+        strategy_engine.reconcile_all()
+
+        result = apply_control_decision(
+            {
+                "decision_id": "dec-switch-3",
+                "concern_id": "acct-1:xrpusd",
+                "account_id": "acct-1",
+                "market_symbol": "xrpusd",
+                "action": "switch",
+                "target_strategy_id": "new",
+                "expected_active_strategy_id": "something-else",
+                "reason": "trend fits better",
+                "confidence": 0.9,
+            }
+        )
+        assert result["ok"] is False
+        assert result["status"] == "rejected"
+        assert "expected active strategy mismatch" in result["errors"]
+    finally:
+        strategy_store.DB_PATH = original_db
+        strategy_registry.STRATEGIES_DIR = original_dir
+        strategy_engine._running.clear()

+ 196 - 1
tests/test_strategies.py

@@ -108,12 +108,43 @@ def test_stop_loss_strategy_loads_with_aligned_regime_config(tmp_path):
         assert grid_defaults["grid_step_pct"] == 0.012
         assert stop_defaults["trail_distance_pct"] == 0.03
         assert stop_defaults["rebalance_target_ratio"] == 0.5
-        assert stop_defaults["min_rebalance_seconds"] == 300
+        assert stop_defaults["min_rebalance_seconds"] == 180
+        assert stop_defaults["min_price_move_pct"] == 0.005
     finally:
         strategy_store.DB_PATH = original_db
         strategy_registry.STRATEGIES_DIR = original_dir
 
 
+def test_grid_supervision_only_reports_ready_for_handoff_on_true_depletion():
+    class FakeContext:
+        account_id = "acct-1"
+        market_symbol = "xrpusd"
+        base_currency = "XRP"
+        counter_currency = "USD"
+        mode = "active"
+
+    strategy = GridStrategy(FakeContext(), {})
+    strategy.state.update({
+        "last_price": 1.45,
+        "base_available": 50.0,
+        "counter_available": 38.7,
+        "regimes": {"1h": {"trend": {"state": "bull"}}},
+    })
+    supervision = strategy._supervision()
+    assert supervision["inventory_pressure"] == "base_heavy"
+    assert supervision["switch_readiness"] == "watch_handoff"
+    assert supervision["desired_companion"] is None
+
+    strategy.state.update({
+        "base_available": 88.0,
+        "counter_available": 4.0,
+    })
+    supervision = strategy._supervision()
+    assert supervision["inventory_pressure"] == "base_side_depleted"
+    assert supervision["switch_readiness"] == "ready_for_handoff"
+    assert supervision["desired_companion"] == "exposure_protector"
+
+
 def test_grid_missing_order_triggers_full_rebuild(monkeypatch):
     class FakeContext:
         base_currency = "XRP"
@@ -385,3 +416,167 @@ def test_trend_follower_uses_policy_and_reports_fit():
     report = strat.report()
     assert report["fit"]["risk_profile"] == "growth"
     assert strat.state["policy_derived"]["order_size"] > 0
+
+
+def test_trend_follower_buys_from_bull_regime_without_explicit_strength():
+    class FakeContext:
+        id = "s-bull"
+        account_id = "acct-1"
+        client_id = "cid-1"
+        mode = "active"
+        market_symbol = "xrpusd"
+        base_currency = "XRP"
+        counter_currency = "USD"
+
+        def __init__(self):
+            self.orders = []
+
+        def get_price(self, symbol):
+            return {"price": 1.2}
+
+        def get_regime(self, symbol, timeframe="1h"):
+            return {
+                "trend": {"state": "bull", "ema_fast": 1.21, "ema_slow": 1.18},
+                "momentum": {"state": "bull", "rsi": 64, "macd_histogram": 0.002},
+            }
+
+        def place_order(self, **kwargs):
+            self.orders.append(kwargs)
+            return {"ok": True, "order": kwargs}
+
+        def get_account_info(self):
+            return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
+
+        minimum_order_value = 10.0
+
+        def suggest_order_amount(self, **kwargs):
+            return 10.0
+
+        def get_strategy_snapshot(self):
+            return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
+
+    ctx = FakeContext()
+    strat = TrendStrategy(ctx, {"order_size": 2.0, "trend_timeframe": "15m"})
+    result = strat.on_tick({})
+    assert result["action"] == "buy"
+    assert ctx.orders[-1]["side"] == "buy"
+    assert ctx.orders[-1]["amount"] == 10.0
+    assert strat.state["last_action"] == "buy_trend"
+    assert strat.state["last_strength"] >= 0.65
+
+
+def test_trend_follower_sells_from_bear_regime_without_explicit_strength():
+    class FakeContext:
+        id = "s-bear"
+        account_id = "acct-1"
+        client_id = "cid-1"
+        mode = "active"
+        market_symbol = "xrpusd"
+        base_currency = "XRP"
+        counter_currency = "USD"
+
+        def __init__(self):
+            self.orders = []
+
+        def get_price(self, symbol):
+            return {"price": 1.2}
+
+        def get_regime(self, symbol, timeframe="1h"):
+            return {
+                "trend": {"state": "bear", "ema_fast": 1.17, "ema_slow": 1.2},
+                "momentum": {"state": "bear", "rsi": 36, "macd_histogram": -0.002},
+            }
+
+        def place_order(self, **kwargs):
+            self.orders.append(kwargs)
+            return {"ok": True, "order": kwargs}
+
+        def get_account_info(self):
+            return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 10}]}
+
+        minimum_order_value = 10.0
+
+        def suggest_order_amount(self, **kwargs):
+            return 6.0
+
+        def get_strategy_snapshot(self):
+            return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
+
+    ctx = FakeContext()
+    strat = TrendStrategy(ctx, {"order_size": 2.0, "trend_timeframe": "15m"})
+    result = strat.on_tick({})
+    assert result["action"] == "sell"
+    assert ctx.orders[-1]["side"] == "sell"
+    assert ctx.orders[-1]["amount"] == 6.0
+    assert strat.state["last_action"] == "sell_trend"
+    assert strat.state["last_strength"] >= 0.65
+
+
+def test_trend_follower_policy_does_not_override_explicit_order_size():
+    class FakeContext:
+        id = "s-explicit"
+        account_id = "acct-1"
+        client_id = "cid-1"
+        mode = "active"
+        market_symbol = "xrpusd"
+        base_currency = "XRP"
+        counter_currency = "USD"
+
+        def get_price(self, symbol):
+            return {"price": 1.2}
+
+        def get_regime(self, symbol, timeframe="1h"):
+            return {"trend": {"state": "bull", "strength": 0.9}}
+
+        def get_strategy_snapshot(self):
+            return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
+
+    strat = TrendStrategy(FakeContext(), {"order_size": 10.5})
+    strat.apply_policy()
+    assert strat.config["order_size"] == 10.5
+    assert strat.state["policy_derived"]["order_size"] == 10.5
+
+
+def test_trend_follower_passes_live_fee_rate_into_sizing_helper():
+    class FakeContext:
+        id = "s-fee"
+        account_id = "acct-1"
+        client_id = "cid-1"
+        mode = "active"
+        market_symbol = "xrpusd"
+        base_currency = "XRP"
+        counter_currency = "USD"
+        minimum_order_value = 10.0
+
+        def __init__(self):
+            self.fee_calls = []
+            self.suggest_calls = []
+
+        def get_price(self, symbol):
+            return {"price": 1.2}
+
+        def get_regime(self, symbol, timeframe="1h"):
+            return {"trend": {"state": "bull", "strength": 0.9}}
+
+        def get_fee_rates(self, market_symbol=None):
+            self.fee_calls.append(market_symbol)
+            return {"maker": 0.0025, "taker": 0.004}
+
+        def suggest_order_amount(self, **kwargs):
+            self.suggest_calls.append(kwargs)
+            return 8.0
+
+        def place_order(self, **kwargs):
+            return {"ok": True, "order": kwargs}
+
+        def get_account_info(self):
+            return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
+
+        def get_strategy_snapshot(self):
+            return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
+
+    ctx = FakeContext()
+    strat = TrendStrategy(ctx, {"order_size": 10.5, "trend_timeframe": "15m"})
+    strat.on_tick({})
+    assert ctx.fee_calls == ["xrpusd"]
+    assert ctx.suggest_calls[-1]["fee_rate"] == 0.0025