|
|
@@ -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())
|