Răsfoiți Sursa

Add standardized strategy MCP tools

Lukas Goldschmidt 1 lună în urmă
părinte
comite
d2f540c1d0

+ 78 - 0
MCP_SURFACE_PROPOSAL.md

@@ -0,0 +1,78 @@
+# Trader MCP Surface Proposal
+
+Keep the public MCP surface small and standardized.
+
+## Proposed tools
+
+### 1. `list_strategies()`
+Returns a compact inventory of loaded strategies.
+
+```json
+{
+  "strategies": [
+    {
+      "id": "grid-1",
+      "name": "Grid Trader",
+      "mode": "active",
+      "status": "running"
+    }
+  ]
+}
+```
+
+### 2. `get_strategy(id)`
+Returns the full strategy record, including config, state, and key live metadata.
+
+Optional flags:
+- `include_render`
+- `include_debug`
+
+```json
+{
+  "id": "grid-1",
+  "name": "Grid Trader",
+  "mode": "active",
+  "config": {
+    "grid_levels": 6,
+    "fee_rate": 0.0025
+  },
+  "state": {
+    "center_price": 2375.2,
+    "last_price": 2374.8,
+    "last_side": "buy",
+    "open_order_count": 4,
+    "last_error": ""
+  },
+  "account_id": "qndd8o9ppop6",
+  "market_symbol": "xrpusd"
+}
+```
+
+### 3. `update_strategy(id, config?, state?)`
+Writes config/state changes for an existing strategy, then reconciles it.
+
+```json
+{
+  "id": "grid-1",
+  "updated": true
+}
+```
+
+### 4. `control_strategy(id, action)`
+Single control entry point for `start`, `pause`, `resume`, `stop`, and `reconcile`.
+
+```json
+{
+  "id": "grid-1",
+  "action": "pause",
+  "ok": true
+}
+```
+
+## Design notes
+
+- `get_strategy()` is the main read tool.
+- `update_strategy()` is the write tool for config/state.
+- `control_strategy()` handles lifecycle and reconcile.
+- Keep the surface compact and predictable.
+- Prefer returning real live metadata in `get_strategy()` instead of adding more specialized tools.

+ 12 - 1
PROJECT.md

@@ -1,7 +1,7 @@
 # Trader MCP - Project
 
 ## Purpose
-A minimal MCP server scaffold for trading helpers. Start with a small public surface and keep app logic isolated.
+Trading helpers, strategy control, and a small MCP surface. Keep app logic isolated and the public API compact.
 
 ## Architecture
 - FastAPI app
@@ -10,6 +10,17 @@ A minimal MCP server scaffold for trading helpers. Start with a small public sur
 - State persistence via SQLite (add only as needed)
 - Logs and pid files under `./logs/`
 
+## MCP surface
+- `list_strategies`
+- `get_strategy`
+- `update_strategy`
+- `control_strategy`
+
+## Notes
+- `get_strategy()` can optionally include render and debug data.
+- `update_strategy()` updates config/state and reconciles.
+- `control_strategy()` handles `start`, `pause`, `resume`, `stop`, and `reconcile`.
+
 ## Routes
 - `GET /` minimal landing page
 - `GET /health` liveness

+ 22 - 2
README.md

@@ -2,13 +2,33 @@
 
 MCP server for trading-related helper functions, with a dashboard for accounts and strategies.
 
+## Current MCP tools
+- `list_strategies`
+- `get_strategy`
+- `update_strategy`
+- `control_strategy`
+
 ## Endpoints
 - `GET /` - landing page
 - `GET /health` - lightweight health check
 - `GET /mcp/sse` - MCP SSE transport endpoint
 
+## Quick start
+```bash
+source .venv/bin/activate
+pip install -r requirements.txt
+./run.sh
+```
+
+Default port: `8570`
+
 ## MCP
-Implements a small, read-oriented MCP surface under `/mcp`.
+The public MCP surface is intentionally small:
+
+- `list_strategies()` returns a compact inventory
+- `get_strategy(id, include_render=False, include_debug=False)` returns one strategy with live metadata
+- `update_strategy(id, config=None, state=None)` updates config/state and reconciles
+- `control_strategy(id, action)` handles `start`, `pause`, `resume`, `stop`, `reconcile`
 
 ## Dashboard
 - accounts section is collapsed by default
@@ -18,4 +38,4 @@ Implements a small, read-oriented MCP surface under `/mcp`.
 - config is editable inline in the detail row
 
 ## Development
-See `run.sh` / `tests.sh` in this folder.
+See `run.sh`, `tests.sh`, `killserver.sh`, and `restart.sh` in this folder.

+ 186 - 13
src/trader_mcp/server.py

@@ -4,14 +4,16 @@ import asyncio
 from fastapi import FastAPI
 
 from .dashboard import router as dashboard_router
-from .strategy_engine import reconcile_all, reconcile_instance, run_due_ticks
+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
+from .strategy_store import add_strategy_instance, delete_strategy_instance, list_strategy_instances, update_strategy_config, update_strategy_mode, update_strategy_state
 
 try:
-    from fastmcp import FastMCP
+    from mcp.server.fastmcp import FastMCP
+    from mcp.server.transport_security import TransportSecuritySettings
 except ImportError:  # pragma: no cover
     FastMCP = None
+    TransportSecuritySettings = None
 
 
 async def _tick_loop(stop_event: asyncio.Event) -> None:
@@ -48,6 +50,7 @@ def health():
 
 @app.get("/strategies")
 def strategies_list():
+    """Return available strategy modules and configured strategy instances."""
     return {
         "available": [s.__dict__ for s in list_available_strategy_modules()],
         "configured": [s.__dict__ for s in list_strategy_instances()],
@@ -56,6 +59,7 @@ def strategies_list():
 
 @app.post("/strategies")
 def strategies_add(payload: dict):
+    """Create a new strategy instance from the supplied payload."""
     record = add_strategy_instance(
         id=payload["id"],
         strategy_type=payload["strategy_type"],
@@ -72,6 +76,7 @@ def strategies_add(payload: dict):
 
 @app.delete("/strategies/{instance_id}")
 def strategies_delete(instance_id: str):
+    """Delete a strategy instance and reconcile the runtime state."""
     result = delete_strategy_instance(instance_id)
     reconcile_instance(instance_id)
     return {"ok": result, "id": instance_id}
@@ -79,6 +84,7 @@ def strategies_delete(instance_id: str):
 
 @app.post("/strategies/{instance_id}/mode")
 def strategies_mode(instance_id: str, payload: dict):
+    """Update a strategy mode and reconcile it into the runtime."""
     ok = update_strategy_mode(instance_id, payload["mode"], started_at=payload.get("started_at"), activated_at=payload.get("activated_at"))
     if ok:
         return reconcile_instance(instance_id)
@@ -87,6 +93,7 @@ def strategies_mode(instance_id: str, payload: dict):
 
 @app.post("/strategies/{instance_id}/config")
 def strategies_config(instance_id: str, payload: dict):
+    """Replace a strategy config and reconcile it into the runtime."""
     ok = update_strategy_config(instance_id, payload["config"])
     if ok:
         return reconcile_instance(instance_id)
@@ -95,21 +102,187 @@ def strategies_config(instance_id: str, payload: dict):
 
 @app.post("/strategies/reconcile")
 def strategies_reconcile():
+    """Reconcile every configured strategy with the live runtime."""
     return reconcile_all()
 
 
+def list_strategies() -> dict:
+    """list_strategies()
+
+    Return the configured strategy instances in a compact, standardized form.
+    Each item includes the live runtime summary needed by humans and agents.
+    """
+    strategies = []
+    for record in list_strategy_instances():
+        state = record.state or {}
+        strategies.append(
+            {
+                "id": record.id,
+                "name": record.name or record.strategy_type,
+                "strategy_type": record.strategy_type,
+                "mode": record.mode,
+                "status": "running" if record.mode != "off" else "stopped",
+                "account_id": record.account_id,
+                "market_symbol": record.market_symbol,
+                "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),
+            }
+        )
+    return {"strategies": strategies}
+
+
+def get_strategy(instance_id: str, include_render: bool = False, include_debug: bool = False) -> dict:
+    """get_strategy(instance_id)
+
+    Return one strategy record with config, state, and live runtime metadata.
+    Optional render and debug data can be included on demand.
+    """
+    record = next((r for r in list_strategy_instances() if r.id == instance_id), None)
+    if record is None:
+        return {"ok": False, "error": "strategy not found", "id": instance_id}
+
+    runtime = get_running_strategy(instance_id)
+    state = dict(record.state or {})
+    if runtime is not None:
+        state = dict(runtime.instance.state or state)
+        state["paused"] = runtime.paused
+        state["next_tick_at"] = runtime.next_tick_at
+
+    render = None
+    if include_render and runtime is not None:
+        try:
+            render = runtime.instance.render()
+        except Exception as exc:
+            render = {"error": str(exc)}
+
+    debug = None
+    if include_debug:
+        debug = state.get("debug_log") or []
+
+    return {
+        "ok": True,
+        "id": record.id,
+        "name": record.name or record.strategy_type,
+        "strategy_type": record.strategy_type,
+        "mode": record.mode,
+        "status": "running" if runtime is not None and not runtime.paused and record.mode != "off" else "paused" if runtime is not None and runtime.paused 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,
+        "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", ""),
+        "render": render,
+        "debug_log": debug,
+    }
+
+
+def update_strategy(instance_id: str, config: dict | None = None, state: dict | None = None) -> dict:
+    """update_strategy(instance_id, config=None, state=None)
+
+    Update the stored config and/or state for a strategy, then reconcile it.
+    Use this for edits that should be persisted without changing lifecycle mode.
+    """
+    changed = False
+    if config is not None:
+        changed = update_strategy_config(instance_id, config) or changed
+    if state is not None:
+        changed = update_strategy_state(instance_id, state) or changed
+    if changed:
+        return reconcile_instance(instance_id)
+    return {"ok": False, "id": instance_id}
+
+
+def control_strategy(instance_id: str, action: str) -> dict:
+    """control_strategy(instance_id, action)
+
+    Control a strategy with one action: start, pause, resume, stop, reconcile.
+    This is the lifecycle entry point for operators and agents.
+    """
+    action = str(action or "").strip().lower()
+    if action == "pause":
+        return pause_strategy(instance_id)
+    if action == "resume":
+        return resume_strategy(instance_id)
+    if action == "reconcile":
+        return reconcile_instance(instance_id)
+    if action == "start":
+        ok = update_strategy_mode(instance_id, "active")
+        return reconcile_instance(instance_id) if ok else {"ok": False, "id": instance_id}
+    if action == "stop":
+        ok = update_strategy_mode(instance_id, "off")
+        return reconcile_instance(instance_id) if ok else {"ok": False, "id": instance_id}
+    return {"ok": False, "id": instance_id, "error": f"unsupported action: {action}"}
+
+
+def get_capabilities() -> dict:
+    """get_capabilities()
+
+    Describe the current public MCP surface and the strategy record shape.
+    """
+    return {
+        "name": "trader-mcp",
+        "tools": [
+            {
+                "name": "list_strategies",
+                "description": "List configured strategy instances with compact live metadata.",
+            },
+            {
+                "name": "get_strategy",
+                "description": "Return one strategy record with optional render and debug data.",
+                "params": {"include_render": "bool", "include_debug": "bool"},
+            },
+            {
+                "name": "update_strategy",
+                "description": "Update stored strategy config and/or state, then reconcile.",
+            },
+            {
+                "name": "control_strategy",
+                "description": "Control lifecycle or reconcile with a single action.",
+                "params": {"action": "start|pause|resume|stop|reconcile"},
+            },
+        ],
+        "strategy_summary_fields": [
+            "id",
+            "name",
+            "strategy_type",
+            "mode",
+            "status",
+            "account_id",
+            "client_id",
+            "market_symbol",
+            "base_currency",
+            "counter_currency",
+            "config",
+            "state",
+            "last_price",
+            "last_side",
+            "open_order_count",
+            "last_error",
+        ],
+    }
+
+
 # MCP (SSE)
 # FastMCP mounted at /mcp with SSE at /mcp/sse (when FastMCP is available)
 if FastMCP is not None:
-    mcp = FastMCP()
-
-    # Minimal public surface for now; expand once requirements are clear.
-    # Keep it read-oriented by default.
+    mcp = FastMCP(
+        "trader-mcp",
+        transport_security=TransportSecuritySettings(
+            enable_dns_rebinding_protection=False,
+        ),
+    )
 
-    # FastMCP exposes an ASGI app via `http_app` (older/newer versions may differ). 
-    mcp_asgi = getattr(mcp, "http_app", None) or getattr(mcp, "app", None) or getattr(mcp, "asgi_app", None)
-    if mcp_asgi is None:
-        raise AttributeError("FastMCP ASGI app attribute not found (expected http_app/app/asgi_app).")
-    app.mount("/mcp", mcp_asgi)
+    mcp.tool()(list_strategies)
+    mcp.tool()(get_strategy)
+    mcp.tool()(update_strategy)
+    mcp.tool()(control_strategy)
+    mcp.tool()(get_capabilities)
 
-    # SSE endpoint is expected at /mcp/sse by the FastMCP integration.
+    app.mount("/mcp", mcp.sse_app())

+ 19 - 1
src/trader_mcp/strategy_context.py

@@ -3,7 +3,7 @@ from __future__ import annotations
 from dataclasses import dataclass, field
 from typing import Any
 
-from .exec_client import list_open_orders, query_order, cancel_all_orders, cancel_order, place_order, get_account_info
+from .exec_client import list_open_orders, query_order, cancel_all_orders, cancel_order, place_order, get_account_info, get_account_fees
 from .news_client import call_news_tool
 from .crypto_client import call_crypto_tool
 
@@ -56,5 +56,23 @@ class StrategyContext:
     def get_account_info(self) -> Any:
         return get_account_info(self.account_id)
 
+    def get_account_fees(self, market_symbol: str | None = None) -> Any:
+        return get_account_fees(self.account_id, market_symbol)
+
+    def get_fee_rates(self, market_symbol: str | None = None) -> dict[str, float]:
+        payload = get_account_fees(self.account_id, market_symbol)
+        if not isinstance(payload, dict):
+            return {"maker": 0.0, "taker": 0.0}
+        fees = payload.get("fees") if isinstance(payload.get("fees"), dict) else {}
+        try:
+            maker = float(fees.get("maker") or 0.0)
+        except Exception:
+            maker = 0.0
+        try:
+            taker = float(fees.get("taker") or 0.0)
+        except Exception:
+            taker = 0.0
+        return {"maker": maker, "taker": taker}
+
     def get_news(self, **kwargs: Any) -> Any:
         return call_news_tool("search", kwargs)

+ 18 - 3
strategies/grid_trader.py

@@ -129,6 +129,21 @@ class Strategy(Strategy):
     def _market_symbol(self) -> str:
         return self.context.market_symbol or f"{self._base_symbol().lower()}usd"
 
+    def _live_fee_rates(self) -> tuple[float, float]:
+        try:
+            payload = self.context.get_fee_rates(self._market_symbol())
+            maker = float(payload.get("maker") or 0.0)
+            taker = float(payload.get("taker") or 0.0)
+            return maker, taker
+        except Exception as exc:
+            self._log(f"fee lookup failed: {exc}")
+            fallback = float(self.config.get("fee_rate", 0.0025) or 0.0)
+            return fallback, fallback
+
+    def _live_fee_rate(self) -> float:
+        maker, _taker = self._live_fee_rates()
+        return maker
+
     def _mode(self) -> str:
         return getattr(self.context, "mode", "active") or "active"
 
@@ -276,7 +291,7 @@ class Strategy(Strategy):
         if min_notional <= 0 or price <= 0:
             return 0
         safety = 0.995
-        fee_rate = float(self.config.get("fee_rate", 0.0025) or 0.0)
+        fee_rate = self._live_fee_rate()
         if side == "buy":
             quote = self.context.counter_currency or "USD"
             quote_available = self._available_balance(quote)
@@ -315,7 +330,7 @@ class Strategy(Strategy):
         if levels <= 0 or price <= 0:
             return 0.0
         safety = 0.995
-        fee_rate = float(self.config.get("fee_rate", 0.0025) or 0.0)
+        fee_rate = self._live_fee_rate()
         max_notional = float(self.config.get("max_notional_per_order", 0.0) or 0.0)
         manual = float(self.config.get("order_size", 0.0) or 0.0)
         min_amount = (min_notional / price) if min_notional > 0 else 0.0
@@ -419,7 +434,7 @@ class Strategy(Strategy):
         levels = int(self.config.get("grid_levels", 6) or 6)
         step = self._grid_step_pct()
         min_notional = float(self.context.minimum_order_value or 0.0)
-        fee_rate = float(self.config.get("fee_rate", 0.0025) or 0.0)
+        fee_rate = self._live_fee_rate()
         safety = 0.995
         market = self._market_symbol()
         orders = list(self.state.get("orders") or [])

+ 9 - 1
strategies/stop_loss_trader.py

@@ -65,6 +65,14 @@ class Strategy(Strategy):
     def _market_symbol(self) -> str:
         return self.context.market_symbol or f"{self._base_symbol().lower()}usd"
 
+    def _live_fee_rate(self) -> float:
+        try:
+            payload = self.context.get_fee_rates(self._market_symbol())
+            return float(payload.get("maker") 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 _price(self) -> float:
         payload = self.context.get_price(self._base_symbol())
         return float(payload.get("price") or 0.0)
@@ -131,7 +139,7 @@ class Strategy(Strategy):
         return "sell" if ratio > target else "buy"
 
     def _suggest_amount(self, side: str, price: float) -> float:
-        fee_rate = float(self.config.get("fee_rate", 0.0025) or 0.0)
+        fee_rate = self._live_fee_rate()
         step_ratio = float(self.config.get("rebalance_step_ratio", 0.15) or 0.0)
         target = float(self.config.get("rebalance_target_ratio", 0.5) or 0.5)
         min_order = float(self.config.get("min_order_size", 0.0) or 0.0)