Explorar el Código

strategies table looking great

Lukas Goldschmidt hace 1 mes
padre
commit
c7dda72849

+ 203 - 0
Strategy_concepts_0.md

@@ -0,0 +1,203 @@
+# Strategy SDK Specification
+
+## 1. Purpose
+
+This document defines the core strategy contract for `trader-mcp`.
+It separates:
+
+- strategy logic
+- engine lifecycle control
+- capability access
+- configuration
+- runtime UI output
+
+The goal is simple, single-file strategies that stay easy to reason about, safe to run, and consistent across the app.
+
+## 2. Design Goals
+
+### 2.1 Keep the surface small
+
+Strategies should have a minimal API and minimal boilerplate.
+
+### 2.2 Separate responsibilities
+
+- strategy, decides what to do
+- engine, decides when to load, unload, and tick
+- context, enforces permissions and binds instance identity (`account_id`, `client_id`) to exec calls
+- database, owns persistent config and identity
+
+### 2.3 Prefer deterministic behavior
+
+A given strategy instance should behave predictably for the same config and input stream.
+
+### 2.4 Restrict capabilities explicitly
+
+Strategies must not touch engine internals or persistence directly.
+All external actions go through the injected context.
+
+## 3. Strategy Contract
+
+Each strategy is a Python class extending `Strategy`.
+
+```python
+from strategy_sdk import Strategy
+
+class MyStrategy(Strategy):
+    CONFIG_SCHEMA = {
+        "risk": {"type": "float", "default": 0.01},
+        "window": {"type": "int", "default": 20}
+    }
+
+    def init(self):
+        return {
+            "prices": [],
+            "position": 0
+        }
+
+    def on_tick(self, tick):
+        price = tick["price"]
+        self.state["prices"].append(price)
+
+    def render(self):
+        return {
+            "widgets": [
+                {"type": "line_chart", "data": self.state["prices"]}
+            ]
+        }
+```
+
+## 4. Base Class Shape
+
+```python
+class Strategy:
+    CONFIG_SCHEMA = {}
+
+    def __init__(self, context, config):
+        self.context = context
+        self.config = config
+        self.state = self.init()
+
+    def init(self):
+        return {}
+
+    def on_tick(self, tick):
+        pass
+
+    def render(self):
+        return {"widgets": []}
+```
+
+## 5. Configuration
+
+### 5.1 Purpose
+
+`CONFIG_SCHEMA` drives validation and UI generation.
+
+### 5.2 Rules
+
+- config is read-only from the strategy’s perspective
+- config is supplied by the engine
+- config changes are handled by reload, not mutation
+
+### 5.3 Recommended schema style
+
+```python
+CONFIG_SCHEMA = {
+    "risk": {
+        "type": "float",
+        "default": 0.01,
+        "min": 0.0,
+        "max": 1.0
+    },
+    "window": {
+        "type": "int",
+        "default": 20
+    }
+}
+```
+
+## 6. State
+
+### 6.1 Ownership
+
+`self.state` belongs to the strategy instance.
+
+### 6.2 Lifecycle
+
+```text
+init() -> state created
+on_tick() -> state updated
+reload -> state reset
+```
+
+### 6.3 Guidance
+
+- keep state serializable where practical
+- do not let state depend on hidden external globals
+
+## 7. Context
+
+The context is a capability boundary, not a config loader.
+
+### Allowed responsibilities
+
+- market/data access
+- order placement
+- logging
+- engine-mediated actions
+- strategy-scoped metadata, including attaching the instance `account_id` and `client_id` to execution calls
+
+### Not the responsibility of context
+
+- config storage
+- persistence
+- lifecycle control
+- strategy identity ownership
+- deciding strategy behavior
+
+Example:
+
+```python
+class StrategyContext:
+    def get_price(self):
+        raise NotImplementedError
+
+    def place_order(self, side, amount):
+        raise NotImplementedError
+
+    def get_orders(self):
+        raise NotImplementedError
+
+    def log(self, message):
+        raise NotImplementedError
+```
+
+## 8. UI Output
+
+Strategies should return structured UI data, not HTML.
+
+```python
+def render(self):
+    return {
+        "widgets": [
+            {"type": "line_chart", "data": [...]},
+            {"type": "metric", "label": "PnL", "value": 123.45}
+        ]
+    }
+```
+
+### Principles
+
+- no frontend code inside the strategy
+- no DOM or template output
+- dashboard owns rendering
+
+## 9. Summary
+
+The strategy SDK should make it obvious that:
+
+- strategy defines behavior
+- engine defines lifecycle
+- context defines permissions
+- config defines initial conditions
+- state belongs to the instance

+ 87 - 330
Strategy_concepts_1.md

@@ -1,170 +1,107 @@
-# Strategy Engine Architecture – Concept Paper
+# Strategy Engine Architecture
 
 ## 1. Overview
 
-This document defines a modular architecture for a strategy execution system consisting of:
+This document describes how strategy definitions become running instances in `trader-mcp`.
+The design separates:
 
-* A **host engine**
-* Pluggable **strategy modules**
-* Configurable **strategy instances**
-* A **dashboard UI**
-
-The system separates **definition**, **configuration**, **execution**, and **visualization** into clearly distinct layers.
-
----
+- strategy definition
+- persisted instance record
+- runtime execution
+- dashboard rendering
 
 ## 2. Core Concepts
 
-### 2.1 Strategy Definition
-
-A strategy is a Python module that defines:
-
-* Logic (`on_tick`)
-* State initialization (`init`)
-* Runtime visualization (`render`)
-* Configuration schema (`CONFIG_SCHEMA`)
+### 2.1 Strategy definition
 
-Characteristics:
+A strategy definition is reusable code, usually a Python module, that exposes the strategy class and behavior.
 
-* Stateless at definition level
-* Reusable across multiple instances
-* Stored as a single `.py` file (optionally + config)
+It typically defines:
 
----
+- `init()`
+- `on_tick()`
+- `render()`
+- `CONFIG_SCHEMA`
 
-### 2.2 Strategy Instance
+### 2.2 Strategy instance
 
-A strategy instance represents a **configured and uniquely identifiable deployment** of a strategy.
+A strategy instance is a configured, uniquely identifiable deployment of a strategy definition.
 
-#### Immutable Identity
+It has:
 
-Defined at creation and never changed:
+- immutable identity
+- mutable config
+- runtime state
+- execution mode
 
-* `id` (unique internal identifier)
-* `strategy_type`
-* `account`
-* `market` (e.g. BTC/USDT)
+#### Immutable identity
 
-Purpose:
+Examples:
 
-* Stable logging
-* Auditability
-* Reproducibility
+- `id`
+- `strategy_type`
+- `account`
+- `market`
 
----
+These fields should be stable and audit-friendly.
 
-#### Mutable Configuration
-
-User-editable and persisted:
-
-```json
-{
-  "risk": 0.01,
-  "window": 20,
-  "note": "test run"
-}
-```
+#### Mutable config
 
-Characteristics:
+Config is user-editable and persistent.
+It is stored as JSON in the database and supplied to the strategy on load.
 
-* Editable via UI
-* Stored in database
-* Survives restarts
-
----
-
-#### Runtime State
-
-* Managed internally by the strategy
-* Not user-editable
-* Typically not persisted (initially)
-
-Example:
-
-```json
-{
-  "prices": [],
-  "position": 0
-}
-```
+#### Runtime state
 
----
+State is owned by the strategy instance and is not part of persistent config.
 
-## 3. System Layers
+## 3. System Model
 
 ```text
-Strategy Definition → Strategy Instance → Runtime Execution → UI Rendering
+Strategy Definition -> Persisted Instance Record -> Running Instance -> UI View
 ```
 
----
+The database stores desired state.
+The engine reconciles runtime from that desired state.
 
-## 4. Modes of Operation
+## 4. Modes
 
-Each instance has exactly one mode:
+Instances should use a small mode set:
 
 ```text
-mode ∈ { off, observe, active }
+off, observe, active
 ```
 
-### Modes
+### Meaning
 
-| Mode    | Loaded | Receives Ticks | Can Trade |
-| ------- | ------ | -------------- | --------- |
-| off     | ❌      | ❌              | ❌         |
-| observe | ✅      | ✅              | ❌         |
-| active  | ✅      | ✅              | ✅         |
+- `off`, not instantiated, no ticks, no trading
+- `observe`, instantiated, ticks enabled, trading disabled
+- `active`, instantiated, ticks enabled, trading enabled
 
----
+### Mode transitions
 
-### Design Principle
+- `off` <-> `observe` is the power toggle
+- `observe` <-> `active` is the activation toggle
+- activation should be disabled while mode is `off`
 
-* Strategies always execute the same logic
-* Capabilities are restricted externally via context
-* No internal branching based on mode
+### Paused runtime state
 
----
+`paused` is not a persisted mode.
+It is a runtime freeze state controlled by the engine.
 
-## 5. Persistence vs Runtime
+- the instance stays loaded
+- ticks are skipped
+- trading is skipped
+- render can be skipped while paused
 
-### Persistent Layer (Database)
+This keeps the stored mode model small while still allowing a temporary freeze.
 
-Source of truth:
+## 5. Reconciliation
 
-```json
-{
-  "id": "...",
-  "strategy_type": "...",
-  "account": "...",
-  "market": "...",
-  "mode": "observe",
-  "config": {...}
-}
-```
-
----
-
-### Runtime Layer (Engine Memory)
-
-```python
-running_instances = {
-    "id": StrategyInstance(...)
-}
-```
-
-Derived from persistent state.
-
----
-
-## 6. Reconciliation Model
-
-The engine continuously aligns runtime with the database.
-
-### Pseudocode
+The engine should reconcile persisted records against runtime instances when records change or at startup.
 
 ```python
 def reconcile():
     for record in db.instances:
-
         if record.mode != "off" and record.id not in running:
             load_instance(record)
 
@@ -172,228 +109,48 @@ def reconcile():
             unload_instance(record.id)
 ```
 
----
-
-### Key Insight
-
-> The system is **declarative**:
-> The database defines the desired state, the engine enforces it.
-
----
-
-## 7. Strategy SDK Contract
-
-### Base Structure
-
-```python
-class Strategy:
-    CONFIG_SCHEMA = {}
-
-    def __init__(self, context, config):
-        self.context = context
-        self.config = config
-        self.state = self.init()
-
-    def init(self):
-        return {}
-
-    def on_tick(self, tick):
-        pass
-
-    def render(self):
-        return {"widgets": []}
-```
-
----
-
-### Context Interface
-
-Strategies interact only through context:
-
-```python
-context.get_price()
-context.place_order(...)
-context.get_orders()
-```
-
----
-
-### Mode Enforcement
-
-```python
-def place_order(...):
-    if mode != "active":
-        raise Exception("Not allowed")
-```
-
----
-
-## 8. UI Architecture
-
-### 8.1 Config UI
-
-Defined via `CONFIG_SCHEMA`
-
-Purpose:
-
-* Generate forms
-* Validate input
-
-Example:
-
-```json
-{
-  "risk": {
-    "type": "float",
-    "default": 0.01
-  }
-}
-```
-
----
-
-### 8.2 Runtime UI
+## 6. Config Reload Semantics
 
-Generated dynamically:
-
-```python
-def render(state):
-    return {
-        "widgets": [
-            {"type": "line_chart", "data": [...]},
-            {"type": "metric", "label": "PnL", "value": 123}
-        ]
-    }
-```
-
----
-
-### Principle
+Config changes should normally trigger a reload.
 
 ```text
-Config UI = user input
-Runtime UI = system output
-```
-
----
-
-## 9. Dashboard Responsibilities
-
-### Strategy List View
-
-Displays:
-
-* Instance ID
-* Strategy type
-* Market
-* Account
-* Mode
-* Status
-
----
-
-### Detail View
-
-#### Sections:
-
-1. **Identity (immutable)**
-2. **Config (editable)**
-3. **Runtime UI (widgets)**
-4. **Controls (mode switching)**
-
----
-
-## 10. Execution Model
-
-### Tick Loop
-
-```python
-for instance in running_instances:
-    instance.on_tick(tick)
+unload_instance(id)
+load_instance(updated_record)
 ```
 
----
-
-### Rendering
-
-```python
-def get_ui(instance_id):
-    return instance.render()
-```
-
----
-
-### Optimization
-
-* Rendering is **on-demand only**
-* No UI computation when not requested
-
----
-
-## 11. Design Principles
-
-### 1. Separation of Concerns
-
-* Strategy logic vs system control
-* Config vs runtime state
-* UI input vs UI output
-
----
+This is the clean default because it is deterministic and easy to debug.
 
-### 2. Declarative Control
+Selective state carryover can exist later, but reload is the default.
 
-* No direct lifecycle manipulation
-* DB defines desired state
+## 7. Engine Responsibilities
 
----
+The engine should:
 
-### 3. Minimal Control Surface
+- load strategy modules
+- instantiate strategies with context + config
+- manage lifecycle
+- tick loaded strategies
+- render on demand
+- enforce execution mode through the context, including binding the instance `account_id` and `client_id` when orders are sent to exec-mcp
 
-* Single `mode` field
-* Avoid multiple flags
+## 8. Dashboard Responsibilities
 
----
+The dashboard should show:
 
-### 4. Capability-Based Execution
+- identity
+- config
+- current mode
+- runtime status
+- rendered widgets
 
-* Strategies always run logic
-* Context restricts actions
+It should not own trading logic.
 
----
+## 9. Recommended Direction
 
-### 5. Instance-Centric Design
-
-* Everything revolves around instances
-* Not strategies as singletons
-
----
-
-## 12. Future Extensions
-
-* Backtesting (reuse instance config)
-* Simulation mode (observe + virtual trading)
-* Distributed execution
-* Strategy versioning
-* State persistence and replay
-* Advanced UI widgets (logs, trades, signals)
-
----
-
-## 13. Summary
-
-The system consists of:
-
-* **Strategies**: reusable logic modules
-* **Instances**: configured, persistent deployments
-* **Engine**: reconciles desired vs actual state
-* **UI**: renders config and runtime data separately
-
----
-
-### Core Insight
-
-```text
-Let strategies always think — but only sometimes act.
-```
+The architecture is strongest when:
 
----
+- config is declarative
+- state is instance-local
+- context is capability-only and may enrich calls with the instance `account_id` and `client_id`
+- reload is the default config update mechanism
+- the database is the source of truth

+ 114 - 0
Strategy_concepts_2.md

@@ -0,0 +1,114 @@
+# Strategy Configuration Ownership and Reload Semantics
+
+## 1. Responsibility Split
+
+Keep ownership clear:
+
+- database, stores config and identity
+- engine, loads config and creates strategy instances
+- strategy, reads config and maintains state
+- context, exposes capabilities only and binds instance identity (`account_id`, `client_id`) to execution calls
+
+## 2. Context Should Not Own Config
+
+The context is the strategy’s access boundary to the outside world.
+It should not become a secondary persistence layer.
+
+If context loads config, it becomes harder to reason about, more coupled to storage, and less reusable.
+
+**Context = capabilities only.**
+
+## 3. Config Flow
+
+### Step 1, persisted record
+
+```python
+record = {
+    "id": "...",
+    "strategy_type": "mean_rev",
+    "config": {
+        "risk": 0.01,
+        "window": 20
+    },
+    "mode": "observe"
+}
+```
+
+### Step 2, engine loads the record
+
+```python
+def load_instance(record):
+    module = load_strategy(record["strategy_type"])
+    context = StrategyContext(engine, record["id"], mode=record["mode"])
+
+    instance = module.StrategyClass(
+        context=context,
+        config=record["config"]
+    )
+
+    running_instances[record["id"]] = instance
+```
+
+### Step 3, strategy reads the config
+
+```python
+risk = self.config["risk"]
+```
+
+That read should be conceptually read-only from the strategy’s point of view.
+
+## 4. Reload Semantics
+
+Config changes should normally mean a clean reload.
+
+```text
+unload_instance(id)
+load_instance(updated_record)
+```
+
+Benefits:
+
+- deterministic
+- easy to debug
+- no partially updated live object
+
+Tradeoff:
+
+- runtime state resets
+
+That tradeoff is acceptable and probably desirable for the first version.
+
+## 5. Config vs State
+
+Config is:
+
+- persistent
+- external
+- owned by the system
+
+State is:
+
+- ephemeral
+- internal
+- owned by the strategy instance
+
+Keep them separate.
+
+## 6. Mode and Reload Safety
+
+If a strategy is active, a config change should move it to a safe mode before reload, then optionally reactivate it afterward.
+That avoids accidental trading during transition.
+
+## 7. Recommended Mental Model
+
+```text
+Engine constructs the world
+Strategy lives inside it
+Context is the interface to reality
+Config is part of the world, not the interface
+```
+
+## 8. Strong Default Recommendation
+
+Use JSON config blobs in SQLite and reload on config change.
+That fits the current app shape well.

+ 107 - 0
Strategy_concepts_examples.md

@@ -0,0 +1,107 @@
+# Strategy Concepts Examples
+
+## 1. Minimal strategy example
+
+```python
+from strategy_sdk import Strategy
+
+class MyStrategy(Strategy):
+    CONFIG_SCHEMA = {
+        "risk": {"type": "float", "default": 0.01},
+        "window": {"type": "int", "default": 20}
+    }
+
+    def init(self):
+        return {
+            "prices": [],
+            "position": 0
+        }
+
+    def on_tick(self, tick):
+        self.state["prices"].append(tick["price"])
+
+    def render(self):
+        return {
+            "widgets": [
+                {"type": "line_chart", "data": self.state["prices"]}
+            ]
+        }
+```
+
+## 2. Base class contract
+
+```python
+class Strategy:
+    CONFIG_SCHEMA = {}
+
+    def __init__(self, context, config):
+        self.context = context
+        self.config = config
+        self.state = self.init()
+
+    def init(self):
+        return {}
+
+    def on_tick(self, tick):
+        pass
+
+    def render(self):
+        return {"widgets": []}
+```
+
+## 3. Config schema example
+
+```python
+CONFIG_SCHEMA = {
+    "risk": {
+        "type": "float",
+        "default": 0.01,
+        "min": 0.0,
+        "max": 1.0
+    },
+    "window": {
+        "type": "int",
+        "default": 20
+    }
+}
+```
+
+## 4. Context sketch
+
+```python
+class StrategyContext:
+    def get_price(self):
+        raise NotImplementedError
+
+    def place_order(self, side, amount):
+        raise NotImplementedError
+
+    def get_orders(self):
+        raise NotImplementedError
+
+    def place_order(self, side, amount, client_id=None):
+        raise NotImplementedError
+
+    def log(self, message):
+        raise NotImplementedError
+```
+
+## 5. Lifecycle
+
+```text
+Instantiate -> init() -> on_tick() -> render()
+```
+
+## 6. File structure
+
+```text
+strategy_sdk/
+  __init__.py
+  base.py
+  context.py
+  config.py (optional)
+  ui.py (optional)
+
+strategies/
+  my_strategy.py
+```

BIN
data/trader_mcp.sqlite3


+ 2 - 0
run.sh

@@ -3,6 +3,8 @@ set -euo pipefail
 
 PORT="${1:-8570}"
 
+source .venv/bin/activate
+
 killserver.sh "$PORT" >/dev/null 2>&1 || true
 
 export PYTHONPATH="${PYTHONPATH:-}:$(pwd)"

+ 181 - 19
src/trader_mcp/dashboard.py

@@ -1,7 +1,15 @@
-from fastapi import APIRouter
-from fastapi.responses import HTMLResponse
+from __future__ import annotations
+
+import json
+from uuid import uuid4
+
+from fastapi import APIRouter, Form
+from fastapi.responses import HTMLResponse, RedirectResponse
 
 from .exec_client import list_accounts
+from .strategy_engine import pause_strategy, reconcile_instance, resume_strategy, get_running_strategy
+from .strategy_registry import get_strategy_default_config, list_available_strategy_modules
+from .strategy_store import add_strategy_instance, delete_strategy_instance, list_strategy_instances, synthesize_client_id, update_strategy_mode, update_strategy_name
 
 router = APIRouter(prefix="/dashboard", tags=["dashboard"])
 
@@ -9,8 +17,22 @@ router = APIRouter(prefix="/dashboard", tags=["dashboard"])
 @router.get("/", response_class=HTMLResponse)
 def dashboard_home():
     accounts = list_accounts()
+    strategies = list_strategy_instances()
+    available_modules = list_available_strategy_modules()
+
+    account_options = "".join(
+        f'<option value="{a.get("id")}">{a.get("display_name") or a.get("venue_account_ref") or a.get("id")}</option>'
+        for a in (accounts or [])
+        if isinstance(a, dict)
+    )
 
-    rows = "".join(
+    account_lookup = {
+        (a.get("id") if isinstance(a, dict) else None): (a.get("display_name") or a.get("venue_account_ref") or a.get("id") or "")
+        for a in (accounts or [])
+        if isinstance(a, dict)
+    }
+
+    account_rows = "".join(
         """
         <tr>
           <td>{display_name}</td>
@@ -29,39 +51,179 @@ def dashboard_home():
         for a in (accounts or [])
     )
 
+    strategy_rows = "".join(
+        """
+        <tr>
+          <td>{indicator}</td>
+          <td>{name}</td>
+          <td>{strategy_type}</td>
+          <td>{account_name}</td>
+          <td>{mode}</td>
+          <td>{config}</td>
+          <td class="actions">
+            <form method="post" action="/dashboard/strategies/{id}/power"><button type="submit" class="ghost">{power_label}</button></form>
+            <form method="post" action="/dashboard/strategies/{id}/activation"><button type="submit" {activation_disabled}>{activation_label}</button></form>
+            <form method="post" action="/dashboard/strategies/{id}/pause"><button type="submit" {pause_disabled}>{pause_label}</button></form>
+            <form method="post" action="/dashboard/strategies/{id}/delete"><button type="submit" class="danger">Delete</button></form>
+          </td>
+        </tr>
+        """.strip().format(
+            indicator=("🔵 paused" if (get_running_strategy(s.id).paused if get_running_strategy(s.id) else False) else ("" if (s.mode or "off") == "off" else ("🟡 observe" if s.mode == "observe" else "✅ active"))),
+            id=s.id,
+            name=s.name or s.id,
+            strategy_type=s.strategy_type,
+            account_name=account_lookup.get(s.account_id, s.account_id),
+            mode=s.mode,
+            config=s.config,
+            power_label="Turn on" if s.mode == "off" else "Turn off",
+            activation_label="Activate" if s.mode != "active" else "Deactivate",
+            activation_disabled="disabled" if s.mode == "off" or (get_running_strategy(s.id).paused if get_running_strategy(s.id) else False) else "",
+            pause_label=("Resume" if (get_running_strategy(s.id).paused if get_running_strategy(s.id) else False) else "Pause"),
+            pause_disabled="disabled" if s.mode == "off" else "",
+        )
+        for s in strategies
+    )
+
+    module_options = "".join(f'<option value="{m.module_name}">{m.module_name}</option>' for m in available_modules)
+
     return f"""<!doctype html>
 <html>
   <head>
-    <meta charset=\"utf-8\" />
-    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
     <title>Trader MCP Dashboard</title>
     <style>
       body {{ font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; margin: 2rem; color: #111827; }}
-      .card {{ max-width: 980px; padding: 1.25rem; border: 1px solid #e5e7eb; border-radius: 12px; }}
+      .card {{ max-width: 1100px; padding: 1.25rem; border: 1px solid #e5e7eb; border-radius: 12px; }}
       .muted {{ color: #6b7280; }}
       table {{ width: 100%; border-collapse: collapse; margin-top: 14px; }}
       th, td {{ border-bottom: 1px solid #e5e7eb; padding: 10px 8px; text-align: left; vertical-align: top; }}
       th {{ background: #f9fafb; }}
       .pill {{ display:inline-block; padding:2px 10px; border-radius:999px; background:#f3f4f6; font-size: 0.9em; }}
+      details {{ margin: 14px 0; }}
+      summary {{ cursor: pointer; font-weight: 600; }}
+      .actions {{ display: flex; gap: 8px; flex-wrap: wrap; }}
+      .actions form {{ display: inline; }}
+      button {{ border: 1px solid #d1d5db; background: white; border-radius: 8px; padding: 8px 10px; cursor: pointer; }}
+      button.danger {{ background: #fee2e2; border-color: #fecaca; }}
+      button.ghost {{ background: #f9fafb; }}
+      input, select {{ padding: 8px 10px; border-radius: 8px; border: 1px solid #d1d5db; }}
     </style>
   </head>
   <body>
-    <div class=\"card\">
+    <div class="card">
       <h1>Trader MCP Dashboard</h1>
-      <p class=\"muted\">exec-mcp accounts</p>
+      <p class="muted">Strategies and exec-mcp accounts</p>
 
-      <table>
-        <tr>
-          <th>name</th>
-          <th>venue</th>
-          <th>exchange account ref</th>
-          <th>description</th>
-          <th>enabled</th>
-        </tr>
-        {rows}
-      </table>
+      <section>
+        <h2>Strategies</h2>
+        <form method="post" action="/dashboard/strategies/add" style="display:grid; gap:10px; max-width: 720px; margin-top: 12px;">
+          <input name="name" placeholder="strategy name, e.g. My super Grid 0.5" required />
+          <select name="strategy_type" required>
+            {module_options}
+          </select>
+          <select name="account_id" required>
+            {account_options}
+          </select>
+          <button type="submit">Add strategy</button>
+        </form>
 
-      <p class=\"muted\" style=\"margin-top: 12px;\">Source: <span class=\"pill\">exec-mcp.list_accounts</span></p>
+        <table>
+          <tr>
+            <th>state</th>
+            <th>name</th>
+            <th>type</th>
+            <th>account</th>
+            <th>mode</th>
+            <th>config</th>
+            <th>actions</th>
+          </tr>
+          {strategy_rows}
+        </table>
+      </section>
+
+      <details>
+        <summary>Accounts</summary>
+        <p class="muted">exec-mcp accounts</p>
+
+        <table>
+          <tr>
+            <th>name</th>
+            <th>venue</th>
+            <th>exchange account ref</th>
+            <th>description</th>
+            <th>enabled</th>
+          </tr>
+          {account_rows}
+        </table>
+      </details>
+
+      <p class="muted" style="margin-top: 12px;">Source: <span class="pill">exec-mcp.list_accounts</span></p>
     </div>
   </body>
 </html>"""
+
+
+@router.post("/strategies/add")
+def dashboard_strategies_add(
+    name: str = Form(...),
+    strategy_type: str = Form(...),
+    account_id: str = Form(...),
+):
+    strategy_id = str(uuid4())
+    default_config = get_strategy_default_config(strategy_type.strip())
+    client_id = synthesize_client_id(strategy_type.strip(), strategy_id, name.strip())
+    record = add_strategy_instance(
+        id=strategy_id,
+        strategy_type=strategy_type.strip(),
+        account_id=account_id.strip(),
+        client_id=client_id,
+        mode="off",
+        config=default_config,
+    )
+    update_strategy_name(strategy_id, name.strip())
+    reconcile_instance(strategy_id)
+    return RedirectResponse(url="/dashboard", status_code=303)
+
+
+@router.post("/strategies/{strategy_id}/delete")
+def dashboard_strategies_delete(strategy_id: str):
+    delete_strategy_instance(strategy_id)
+    reconcile_instance(strategy_id)
+    return RedirectResponse(url="/dashboard", status_code=303)
+
+
+@router.post("/strategies/{strategy_id}/power")
+def dashboard_strategies_power(strategy_id: str):
+    from .strategy_store import get_strategy_instance
+
+    record = get_strategy_instance(strategy_id)
+    if record is None:
+        return RedirectResponse(url="/dashboard", status_code=303)
+    update_strategy_mode(strategy_id, "observe" if record.mode == "off" else "off")
+    reconcile_instance(strategy_id)
+    return RedirectResponse(url="/dashboard", status_code=303)
+
+
+@router.post("/strategies/{strategy_id}/activation")
+def dashboard_strategies_activation(strategy_id: str):
+    from .strategy_store import get_strategy_instance
+
+    record = get_strategy_instance(strategy_id)
+    if record is None or record.mode == "off":
+        return RedirectResponse(url="/dashboard", status_code=303)
+    update_strategy_mode(strategy_id, "active" if record.mode != "active" else "observe")
+    reconcile_instance(strategy_id)
+    return RedirectResponse(url="/dashboard", status_code=303)
+
+
+@router.post("/strategies/{strategy_id}/pause")
+def dashboard_strategies_pause(strategy_id: str):
+    runtime = get_running_strategy(strategy_id)
+    if runtime is None:
+        return RedirectResponse(url="/dashboard", status_code=303)
+    if runtime.paused:
+        resume_strategy(strategy_id)
+    else:
+        pause_strategy(strategy_id)
+    return RedirectResponse(url="/dashboard", status_code=303)

+ 18 - 0
src/trader_mcp/exec_client.py

@@ -25,3 +25,21 @@ def list_accounts() -> list[dict[str, Any]]:
     if payload is None:
         return []
     return [payload]
+
+
+def place_order(arguments: dict[str, Any]) -> Any:
+    return _mcp.call_tool("place_order", arguments)
+
+
+def list_open_orders(account_id: str, client_id: str | None = None) -> Any:
+    args: dict[str, Any] = {"account_id": account_id}
+    if client_id is not None:
+        args["client_id"] = client_id
+    return _mcp.call_tool("get_open_orders", args)
+
+
+def cancel_all_orders(account_id: str, client_id: str | None = None) -> Any:
+    args: dict[str, Any] = {"account_id": account_id}
+    if client_id is not None:
+        args["client_id"] = client_id
+    return _mcp.call_tool("cancel_all_orders", args)

+ 55 - 1
src/trader_mcp/server.py

@@ -1,6 +1,9 @@
 from fastapi import FastAPI
 
 from .dashboard import router as dashboard_router
+from .strategy_engine import reconcile_all, reconcile_instance
+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
 
 try:
     from fastmcp import FastMCP
@@ -21,6 +24,58 @@ def health():
     return {"status": "ok"}
 
 
+@app.get("/strategies")
+def strategies_list():
+    return {
+        "available": [s.__dict__ for s in list_available_strategy_modules()],
+        "configured": [s.__dict__ for s in list_strategy_instances()],
+    }
+
+
+@app.post("/strategies")
+def strategies_add(payload: dict):
+    record = add_strategy_instance(
+        id=payload["id"],
+        strategy_type=payload["strategy_type"],
+        account_id=payload["account_id"],
+        client_id=payload.get("client_id"),
+        mode=payload.get("mode", "off"),
+        config=payload.get("config") or {},
+        started_at=payload.get("started_at"),
+        activated_at=payload.get("activated_at"),
+    )
+    reconcile_instance(record.id)
+    return record.__dict__
+
+
+@app.delete("/strategies/{instance_id}")
+def strategies_delete(instance_id: str):
+    result = delete_strategy_instance(instance_id)
+    reconcile_instance(instance_id)
+    return {"ok": result, "id": instance_id}
+
+
+@app.post("/strategies/{instance_id}/mode")
+def strategies_mode(instance_id: str, payload: dict):
+    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)
+    return {"ok": False, "id": instance_id}
+
+
+@app.post("/strategies/{instance_id}/config")
+def strategies_config(instance_id: str, payload: dict):
+    ok = update_strategy_config(instance_id, payload["config"])
+    if ok:
+        return reconcile_instance(instance_id)
+    return {"ok": False, "id": instance_id}
+
+
+@app.post("/strategies/reconcile")
+def strategies_reconcile():
+    return reconcile_all()
+
+
 # MCP (SSE)
 # FastMCP mounted at /mcp with SSE at /mcp/sse (when FastMCP is available)
 if FastMCP is not None:
@@ -36,4 +91,3 @@ if FastMCP is not None:
     app.mount("/mcp", mcp_asgi)
 
     # SSE endpoint is expected at /mcp/sse by the FastMCP integration.
-

+ 32 - 0
src/trader_mcp/strategy_context.py

@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from .exec_client import list_open_orders, cancel_all_orders, place_order
+from .news_client import call_news_tool
+from .crypto_client import call_crypto_tool
+
+
+@dataclass(frozen=True)
+class StrategyContext:
+    id: str
+    account_id: str
+    client_id: str | None = None
+
+    def get_open_orders(self) -> Any:
+        return list_open_orders(self.account_id, self.client_id)
+
+    def cancel_all_orders(self) -> Any:
+        return cancel_all_orders(self.account_id, self.client_id)
+
+    def place_order(self, **kwargs: Any) -> Any:
+        kwargs.setdefault("account_id", self.account_id)
+        kwargs.setdefault("client_id", self.client_id)
+        return place_order(**kwargs)
+
+    def get_price(self, symbol: str) -> Any:
+        return call_crypto_tool("get_price", {"symbol": symbol})
+
+    def get_news(self, **kwargs: Any) -> Any:
+        return call_news_tool("search", kwargs)

+ 105 - 0
src/trader_mcp/strategy_engine.py

@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from .strategy_context import StrategyContext
+from .strategy_registry import load_strategy_module
+from .strategy_sdk import Strategy
+from .strategy_store import StrategyRecord, list_strategy_instances
+
+
+@dataclass
+class RuntimeStrategy:
+    record: StrategyRecord
+    instance: Strategy
+    paused: bool = False
+
+
+_running: dict[str, RuntimeStrategy] = {}
+
+
+def running_strategy_ids() -> list[str]:
+    return sorted(_running.keys())
+
+
+def get_running_strategy(instance_id: str) -> RuntimeStrategy | None:
+    return _running.get(instance_id)
+
+
+def pause_strategy(instance_id: str) -> dict[str, Any]:
+    runtime = _running.get(instance_id)
+    if runtime is None:
+        return {"ok": False, "error": "strategy not running", "id": instance_id}
+    runtime.paused = True
+    return {"ok": True, "id": instance_id, "paused": True}
+
+
+def resume_strategy(instance_id: str) -> dict[str, Any]:
+    runtime = _running.get(instance_id)
+    if runtime is None:
+        return {"ok": False, "error": "strategy not running", "id": instance_id}
+    runtime.paused = False
+    return {"ok": True, "id": instance_id, "paused": False}
+
+
+def tick_strategy(instance_id: str, tick: dict[str, Any]) -> dict[str, Any]:
+    runtime = _running.get(instance_id)
+    if runtime is None:
+        return {"ok": False, "error": "strategy not running", "id": instance_id}
+    if runtime.paused:
+        return {"ok": True, "id": instance_id, "paused": True, "skipped": True}
+    result = runtime.instance.on_tick(tick)
+    return {"ok": True, "id": instance_id, "result": result}
+
+
+def render_strategy(instance_id: str) -> dict[str, Any]:
+    runtime = _running.get(instance_id)
+    if runtime is None:
+        return {"ok": False, "error": "strategy not running", "id": instance_id}
+    if runtime.paused:
+        return {"ok": True, "id": instance_id, "paused": True, "render": None}
+    return {"ok": True, "id": instance_id, "render": runtime.instance.render()}
+
+
+def reconcile_all() -> dict[str, Any]:
+    records = {record.id: record for record in list_strategy_instances()}
+    loaded: list[str] = []
+    unloaded: list[str] = []
+
+    for instance_id, runtime in list(_running.items()):
+        record = records.get(instance_id)
+        if record is None or record.mode == "off":
+            _running.pop(instance_id, None)
+            unloaded.append(instance_id)
+
+    for instance_id, record in records.items():
+        if record.mode == "off":
+            continue
+        existing = _running.get(instance_id)
+        if existing is not None and existing.record.updated_at == record.updated_at:
+            continue
+        _running[instance_id] = RuntimeStrategy(record=record, instance=_instantiate(record))
+        loaded.append(instance_id)
+
+    return {"loaded": loaded, "unloaded": unloaded, "running": running_strategy_ids()}
+
+
+def reconcile_instance(instance_id: str) -> dict[str, Any]:
+    record = next((r for r in list_strategy_instances() if r.id == instance_id), None)
+    if record is None or record.mode == "off":
+        removed = _running.pop(instance_id, None)
+        return {"loaded": False, "unloaded": removed is not None, "running": running_strategy_ids()}
+
+    _running[instance_id] = RuntimeStrategy(record=record, instance=_instantiate(record))
+    return {"loaded": True, "unloaded": False, "running": running_strategy_ids()}
+
+
+def _instantiate(record: StrategyRecord) -> Strategy:
+    module = load_strategy_module(record.strategy_type)
+    strategy_cls = getattr(module, "Strategy", None)
+    if strategy_cls is None:
+        raise AttributeError(f"strategy module {record.strategy_type!r} does not expose Strategy")
+
+    context = StrategyContext(id=record.id, account_id=record.account_id, client_id=record.client_id)
+    return strategy_cls(context=context, config=record.config)

+ 64 - 0
src/trader_mcp/strategy_registry.py

@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+import importlib.util
+
+ROOT = Path(__file__).resolve().parents[2]
+STRATEGIES_DIR = ROOT / "strategies"
+
+
+@dataclass(frozen=True)
+class StrategyModuleInfo:
+    module_name: str
+    file_path: str
+    class_name: str | None
+
+
+def get_strategy_default_config(module_name: str) -> dict[str, Any]:
+    module = load_strategy_module(module_name)
+    strategy_cls = getattr(module, "Strategy", None)
+    if strategy_cls is None:
+        return {}
+    schema = getattr(strategy_cls, "CONFIG_SCHEMA", {}) or {}
+    defaults: dict[str, Any] = {}
+    for key, spec in schema.items():
+        if isinstance(spec, dict) and "default" in spec:
+            defaults[key] = spec["default"]
+    return defaults
+
+
+def list_available_strategy_modules() -> list[StrategyModuleInfo]:
+    if not STRATEGIES_DIR.exists():
+        return []
+
+    modules: list[StrategyModuleInfo] = []
+    for path in sorted(STRATEGIES_DIR.glob("*.py")):
+        if path.name.startswith("_"):
+            continue
+        modules.append(
+            StrategyModuleInfo(
+                module_name=path.stem,
+                file_path=str(path),
+                class_name=_infer_strategy_class_name(path),
+            )
+        )
+    return modules
+
+
+def load_strategy_module(module_name: str) -> Any:
+    path = STRATEGIES_DIR / f"{module_name}.py"
+    if not path.exists():
+        raise FileNotFoundError(f"strategy module not found: {module_name}")
+
+    spec = importlib.util.spec_from_file_location(f"strategies.{module_name}", path)
+    if spec is None or spec.loader is None:
+        raise ImportError(f"unable to load strategy module: {module_name}")
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
+def _infer_strategy_class_name(path: Path) -> str | None:
+    return None

+ 21 - 0
src/trader_mcp/strategy_sdk.py

@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from typing import Any
+
+
+class Strategy:
+    CONFIG_SCHEMA: dict[str, Any] = {}
+
+    def __init__(self, context, config):
+        self.context = context
+        self.config = config
+        self.state = self.init()
+
+    def init(self):
+        return {}
+
+    def on_tick(self, tick):
+        return None
+
+    def render(self):
+        return {"widgets": []}

+ 165 - 0
src/trader_mcp/strategy_store.py

@@ -0,0 +1,165 @@
+from __future__ import annotations
+
+import json
+import sqlite3
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+import re
+
+DB_PATH = Path(__file__).resolve().parents[2] / "data" / "trader_mcp.sqlite3"
+
+
+def synthesize_client_id(strategy_type: str, instance_id: str, name: str) -> str:
+    slug = re.sub(r"[^a-zA-Z0-9]+", "-", name.strip().lower()).strip("-") or "strategy"
+    return f"{strategy_type}:{instance_id}:{slug}"
+
+
+@dataclass(frozen=True)
+class StrategyRecord:
+    id: str
+    name: str
+    strategy_type: str
+    account_id: str
+    client_id: str | None
+    mode: str
+    config: dict[str, Any]
+    started_at: str | None
+    activated_at: str | None
+    created_at: str
+    updated_at: str
+
+
+def _utc_now() -> str:
+    return datetime.now(timezone.utc).isoformat()
+
+
+def get_connection() -> sqlite3.Connection:
+    DB_PATH.parent.mkdir(parents=True, exist_ok=True)
+    conn = sqlite3.connect(DB_PATH)
+    conn.row_factory = sqlite3.Row
+    conn.execute("PRAGMA foreign_keys = ON")
+    return conn
+
+
+def init_db() -> None:
+    with get_connection() as conn:
+        conn.execute(
+            """
+            CREATE TABLE IF NOT EXISTS strategy_instances (
+                id TEXT PRIMARY KEY,
+                name TEXT NOT NULL DEFAULT '',
+                strategy_type TEXT NOT NULL,
+                account_id TEXT NOT NULL,
+                client_id TEXT,
+                mode TEXT NOT NULL DEFAULT 'off',
+                config_json TEXT NOT NULL DEFAULT '{}',
+                started_at TEXT,
+                activated_at TEXT,
+                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 ''")
+        conn.commit()
+
+
+def list_strategy_instances() -> list[StrategyRecord]:
+    init_db()
+    with get_connection() as conn:
+        rows = conn.execute("SELECT * FROM strategy_instances ORDER BY created_at DESC").fetchall()
+    return [_row_to_record(row) for row in rows]
+
+
+def get_strategy_instance(instance_id: str) -> StrategyRecord | None:
+    init_db()
+    with get_connection() as conn:
+        row = conn.execute("SELECT * FROM strategy_instances WHERE id = ?", (instance_id,)).fetchone()
+    return _row_to_record(row) if row else None
+
+
+def add_strategy_instance(*, id: str, strategy_type: str, account_id: str, client_id: str | None = None, mode: str = "off", config: dict[str, Any] | None = None, started_at: str | None = None, activated_at: str | None = None) -> StrategyRecord:
+    init_db()
+    now = _utc_now()
+    config = config or {}
+    with get_connection() as conn:
+        conn.execute(
+            """
+            INSERT INTO strategy_instances
+            (id, name, strategy_type, account_id, client_id, mode, config_json, started_at, activated_at, created_at, updated_at)
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            """,
+            (id, "", strategy_type, account_id, client_id, mode, json.dumps(config), started_at, activated_at, now, now),
+        )
+        conn.commit()
+    return get_strategy_instance(id)  # type: ignore[return-value]
+
+
+def delete_strategy_instance(instance_id: str) -> bool:
+    init_db()
+    with get_connection() as conn:
+        cur = conn.execute("DELETE FROM strategy_instances WHERE id = ?", (instance_id,))
+        conn.commit()
+    return cur.rowcount > 0
+
+
+def update_strategy_mode(instance_id: str, mode: str, *, started_at: str | None = None, activated_at: str | None = None) -> bool:
+    init_db()
+    now = _utc_now()
+    with get_connection() as conn:
+        cur = conn.execute(
+            """
+            UPDATE strategy_instances
+            SET mode = ?, started_at = COALESCE(?, started_at), activated_at = COALESCE(?, activated_at), updated_at = ?
+            WHERE id = ?
+            """,
+            (mode, started_at, activated_at, now, instance_id),
+        )
+        conn.commit()
+    return cur.rowcount > 0
+
+
+def update_strategy_config(instance_id: str, config: dict[str, Any]) -> bool:
+    init_db()
+    now = _utc_now()
+    with get_connection() as conn:
+        cur = conn.execute(
+            "UPDATE strategy_instances SET config_json = ?, updated_at = ? WHERE id = ?",
+            (json.dumps(config), now, instance_id),
+        )
+        conn.commit()
+    return cur.rowcount > 0
+
+
+def update_strategy_name(instance_id: str, name: str) -> bool:
+    init_db()
+    now = _utc_now()
+    with get_connection() as conn:
+        cur = conn.execute(
+            "UPDATE strategy_instances SET name = ?, updated_at = ? WHERE id = ?",
+            (name, now, instance_id),
+        )
+        conn.commit()
+    return cur.rowcount > 0
+
+
+def _row_to_record(row: sqlite3.Row | None) -> StrategyRecord | None:
+    if row is None:
+        return None
+    return StrategyRecord(
+        id=row["id"],
+        name=row["name"] if "name" in row.keys() else "",
+        strategy_type=row["strategy_type"],
+        account_id=row["account_id"],
+        client_id=row["client_id"],
+        mode=row["mode"],
+        config=json.loads(row["config_json"] or "{}"),
+        started_at=row["started_at"],
+        activated_at=row["activated_at"],
+        created_at=row["created_at"],
+        updated_at=row["updated_at"],
+    )

+ 24 - 0
strategies/hello_world.py

@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+from src.trader_mcp.strategy_sdk import Strategy
+
+
+class Strategy(Strategy):
+    CONFIG_SCHEMA = {
+        "label": {"type": "string", "default": "hello world"},
+    }
+
+    def init(self):
+        return {"counter": 0}
+
+    def on_tick(self, tick):
+        self.state["counter"] += 1
+        return self.state["counter"]
+
+    def render(self):
+        return {
+            "widgets": [
+                {"type": "text", "label": "message", "value": self.config.get("label", "hello world")},
+                {"type": "metric", "label": "ticks", "value": self.state["counter"]},
+            ]
+        }

+ 14 - 0
test_servers.sh

@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Live dependency tests for trader-mcp.
+
+export PYTHONPATH="${PYTHONPATH:-}:$(pwd)"
+
+if [ -f .venv/bin/activate ]; then
+  # shellcheck disable=SC1091
+  source .venv/bin/activate
+fi
+
+echo "Running server/integration pytest suite (trader-mcp)..."
+pytest -q tests/test_smoke.py tests/test_crypto_client.py tests/test_news_client.py

+ 3 - 4
tests.sh

@@ -1,8 +1,7 @@
 #!/usr/bin/env bash
 set -euo pipefail
 
-# Pytest-only smoke tests for trader-mcp.
-# Tests use FastAPI TestClient, so the server does not need to be running.
+# Fast unit tests for trader-mcp.
 
 # Ensure local package import works.
 export PYTHONPATH="${PYTHONPATH:-}:$(pwd)"
@@ -12,5 +11,5 @@ if [ -f .venv/bin/activate ]; then
   source .venv/bin/activate
 fi
 
-echo "Running pytest suite (trader-mcp)..."
-pytest -q
+echo "Running unit pytest suite (trader-mcp)..."
+pytest -q tests/test_strategies.py tests/test_engine.py

+ 175 - 0
tests/test_engine.py

@@ -0,0 +1,175 @@
+from __future__ import annotations
+
+from pathlib import Path
+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
+
+
+STRATEGY_CODE = '''
+from src.trader_mcp.strategy_sdk import Strategy
+
+class Strategy(Strategy):
+    CONFIG_SCHEMA = {"label": {"type": "string", "default": "hello world"}}
+
+    def init(self):
+        return {"started": True, "config_copy": dict(self.config)}
+
+    def on_tick(self, tick):
+        self.state["ticks"] = self.state.get("ticks", 0) + 1
+        return self.state["ticks"]
+
+    def render(self):
+        return {"widgets": [{"type": "metric", "label": "ticks", "value": self.state.get("ticks", 0)}]}
+'''
+
+
+def test_mode_off_does_not_instantiate_and_active_does(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 / "demo.py").write_text(STRATEGY_CODE)
+
+        strategy_store.add_strategy_instance(id="s1", strategy_type="demo", account_id="acct-1", client_id="cid-1", mode="off", config={"x": 1})
+        result = strategy_engine.reconcile_all()
+        assert result["running"] == []
+        assert strategy_engine.get_running_strategy("s1") is None
+
+        strategy_store.update_strategy_mode("s1", "active")
+        result = strategy_engine.reconcile_all()
+        assert "s1" in result["running"]
+        runtime = strategy_engine.get_running_strategy("s1")
+        assert runtime is not None
+        assert runtime.instance.state["started"] is True
+        assert runtime.instance.context.account_id == "acct-1"
+        assert runtime.instance.context.client_id == "cid-1"
+        tick_result = strategy_engine.tick_strategy("s1", {"price": 1})
+        assert tick_result["ok"] is True
+        assert tick_result["result"] == 1
+        render_result = strategy_engine.render_strategy("s1")
+        assert render_result["ok"] is True
+        assert render_result["render"]["widgets"][0]["value"] == 1
+    finally:
+        strategy_store.DB_PATH = original_db
+        strategy_registry.STRATEGIES_DIR = original_dir
+        strategy_engine._running.clear()
+
+
+def test_mode_change_route_reconciles(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 / "demo.py").write_text(STRATEGY_CODE)
+
+        client = TestClient(app)
+        client.post(
+            "/strategies",
+            json={"id": "s2", "strategy_type": "demo", "account_id": "acct-1", "client_id": "cid-1", "mode": "off", "config": {"x": 1}},
+        )
+        r = client.post("/strategies/s2/mode", json={"mode": "active"})
+        assert r.status_code == 200
+        assert "s2" in r.json()["running"]
+    finally:
+        strategy_store.DB_PATH = original_db
+        strategy_registry.STRATEGIES_DIR = original_dir
+        strategy_engine._running.clear()
+
+
+def test_dashboard_add_strategy_synthesizes_client_and_defaults(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)
+
+        client = TestClient(app)
+        response = client.post(
+            "/dashboard/strategies/add",
+            data={"name": "My super Grid 0.5", "strategy_type": "hello_world", "account_id": "acct-1"},
+            follow_redirects=False,
+        )
+        assert response.status_code in {302, 303}
+
+        record = strategy_store.list_strategy_instances()[0]
+        assert record.name == "My super Grid 0.5"
+        assert record.client_id.startswith("hello_world:")
+        assert record.config == {"label": "hello world"}
+        assert record.mode == "off"
+    finally:
+        strategy_store.DB_PATH = original_db
+        strategy_registry.STRATEGIES_DIR = original_dir
+        strategy_engine._running.clear()
+
+
+def test_runtime_pause_suppresses_tick_and_render(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_store.add_strategy_instance(id="s3", strategy_type="hello_world", account_id="acct-1", client_id="cid-1", mode="active", config={})
+        strategy_engine.reconcile_all()
+
+        assert strategy_engine.pause_strategy("s3")["ok"] is True
+        tick_result = strategy_engine.tick_strategy("s3", {"price": 1})
+        assert tick_result["paused"] is True
+        assert tick_result["skipped"] is True
+        render_result = strategy_engine.render_strategy("s3")
+        assert render_result["paused"] is True
+        assert render_result["render"] is None
+
+        assert strategy_engine.resume_strategy("s3")["ok"] is True
+        tick_result = strategy_engine.tick_strategy("s3", {"price": 1})
+        assert tick_result["result"] == 1
+        render_result = strategy_engine.render_strategy("s3")
+        assert render_result["render"]["widgets"][0]["value"] == 1
+    finally:
+        strategy_store.DB_PATH = original_db
+        strategy_registry.STRATEGIES_DIR = original_dir
+        strategy_engine._running.clear()
+
+
+def test_dashboard_pause_toggle(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)
+
+        client = TestClient(app)
+        client.post(
+            "/dashboard/strategies/add",
+            data={"name": "Pause test", "strategy_type": "hello_world", "account_id": "acct-1"},
+            follow_redirects=False,
+        )
+        strategy_id = strategy_store.list_strategy_instances()[0].id
+
+        strategy_store.update_strategy_mode(strategy_id, "active")
+        strategy_engine.reconcile_instance(strategy_id)
+        assert strategy_engine.get_running_strategy(strategy_id).paused is False
+
+        client.post(f"/dashboard/strategies/{strategy_id}/pause", follow_redirects=False)
+        assert strategy_engine.get_running_strategy(strategy_id).paused is True
+
+        client.post(f"/dashboard/strategies/{strategy_id}/pause", follow_redirects=False)
+        assert strategy_engine.get_running_strategy(strategy_id).paused is False
+    finally:
+        strategy_store.DB_PATH = original_db
+        strategy_registry.STRATEGIES_DIR = original_dir
+        strategy_engine._running.clear()

+ 88 - 0
tests/test_strategies.py

@@ -0,0 +1,88 @@
+from __future__ import annotations
+
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+from fastapi.testclient import TestClient
+
+from src.trader_mcp import strategy_store
+from src.trader_mcp.server import app
+from src.trader_mcp.strategy_context import StrategyContext
+
+
+STRATEGY_CODE = '''
+from src.trader_mcp.strategy_sdk import Strategy
+
+class Strategy(Strategy):
+    def init(self):
+        return {"started": True, "config_copy": dict(self.config)}
+'''
+
+
+def test_strategies_endpoints_roundtrip():
+    with TemporaryDirectory() as tmpdir:
+        strategy_store.DB_PATH = Path(tmpdir) / "trader_mcp.sqlite3"
+        from src.trader_mcp import strategy_registry
+
+        strategy_registry.STRATEGIES_DIR = Path(tmpdir) / "strategies"
+        strategy_registry.STRATEGIES_DIR.mkdir()
+        (strategy_registry.STRATEGIES_DIR / "demo.py").write_text(STRATEGY_CODE)
+        client = TestClient(app)
+
+        r = client.get("/strategies")
+        assert r.status_code == 200
+        body = r.json()
+        assert "available" in body
+        assert "configured" in body
+
+        r = client.post(
+            "/strategies",
+            json={
+                "id": "demo-1",
+                "strategy_type": "demo",
+                "account_id": "acct-1",
+                "client_id": "strategy:test",
+                "mode": "observe",
+                "config": {"risk": 0.01},
+            },
+        )
+        assert r.status_code == 200
+        assert r.json()["id"] == "demo-1"
+
+        r = client.get("/strategies")
+        assert any(item["id"] == "demo-1" for item in r.json()["configured"])
+
+        r = client.delete("/strategies/demo-1")
+        assert r.status_code == 200
+        assert r.json()["ok"] is True
+
+
+def test_strategy_context_binds_identity(monkeypatch):
+    calls = {}
+
+    def fake_place_order(**arguments):
+        calls["place_order"] = arguments
+        return {"ok": True}
+
+    def fake_open_orders(account_id, client_id=None):
+        calls["open_orders"] = {"account_id": account_id, "client_id": client_id}
+        return {"ok": True}
+
+    def fake_cancel_all(account_id, client_id=None):
+        calls["cancel_all"] = {"account_id": account_id, "client_id": client_id}
+        return {"ok": True}
+
+    monkeypatch.setattr("src.trader_mcp.strategy_context.place_order", fake_place_order)
+    monkeypatch.setattr("src.trader_mcp.strategy_context.list_open_orders", fake_open_orders)
+    monkeypatch.setattr("src.trader_mcp.strategy_context.cancel_all_orders", fake_cancel_all)
+
+    ctx = StrategyContext(id="inst-1", account_id="acct-1", client_id="client-1")
+
+    ctx.place_order(side="sell", market="xrpusd", order_type="limit", amount="10", price="2")
+    ctx.get_open_orders()
+    ctx.cancel_all_orders()
+
+    assert calls["place_order"]["account_id"] == "acct-1"
+    assert calls["place_order"]["client_id"] == "client-1"
+    assert calls["open_orders"] == {"account_id": "acct-1", "client_id": "client-1"}
+    assert calls["cancel_all"] == {"account_id": "acct-1", "client_id": "client-1"}