Selaa lähdekoodia

Document strategy persistence and grid architecture

Lukas Goldschmidt 1 kuukausi sitten
vanhempi
commit
92679277ad

+ 123 - 0
Grid_Bot_Architecture.md

@@ -0,0 +1,123 @@
+# Grid Bot Architecture
+
+This note describes the intended grid-bot design.
+
+## Core idea
+
+A survivable grid bot has three layers:
+
+1. **Micro layer** , places trades and captures oscillation.
+2. **Meso layer** , adapts the grid structure.
+3. **Macro layer** , protects capital in bad regimes.
+
+If one layer is missing:
+- micro only, the bot can blow up
+- macro only, it barely trades
+- meso only, the behavior is unstable
+
+## Operational model
+
+The bot should not be static. It should:
+- scale spacing with volatility
+- slide with the market
+- recenter only occasionally
+- stop or reduce activity in strong trends
+
+## Pseudocode outline
+
+```text
+INIT:
+  center_price = current_price()
+  grid_levels = 12
+  recenter_threshold = 0.05
+  max_inventory_pct = 0.7
+  trend_filter_enabled = true
+
+  build_grid(center_price)
+
+LOOP:
+  price = current_price()
+  volatility = ATR(lookback=50)
+
+  grid_step = clamp(k * volatility, min=0.008, max=0.025)
+
+  handle fills
+  slide grid when price leaves the active band
+  recenter only when deviation is large
+  pause new orders in strong trends
+```
+
+## Key mechanisms
+
+### 1. Sliding grid
+
+This is the default adjustment mode.
+
+Effect:
+- keeps the bot active
+- avoids full reset shock
+- behaves more like a market maker
+
+Use this as the main structural adjustment.
+
+### 2. Re-centering
+
+Use only when price drifts too far.
+
+Rule of thumb:
+- keep it rare
+- use only for larger deviations, e.g. 5 to 8 percent
+
+Too much recentering kills the edge.
+
+### 3. Volatility-based spacing
+
+Fixed spacing is fragile.
+
+Better:
+- high volatility, wider grid
+- low volatility, tighter grid
+
+This reduces overtrading and avoids getting wrecked in spikes.
+
+### 4. Trend filter
+
+This is the survival layer.
+
+A simple version can use:
+- price vs MA200
+- slope of MA50
+
+When a strong trend is detected:
+- pause new orders
+- optionally allow only one-sided behavior
+
+## Tunable parameters
+
+Suggested starting range:
+- grid spacing: ~1 to 1.5 percent for XRP
+- grid width: ~±10 to 20 percent
+- recenter threshold: 4 to 8 percent
+- inventory cap: never above ~70 percent of capital
+- volatility multiplier `k`: ~0.8 to 1.5
+
+## Failure modes
+
+Common ways grid bots die:
+- always-on grid with no trend filter
+- over-adjustment and too many recentering events
+- ignoring inventory drift
+- spacing too tight, so fees eat the edge
+- static behavior while market regime changes
+
+## Mental model
+
+Think of the layers like this:
+- **Grid** , engine
+- **Sliding** , steering
+- **Volatility scaling** , suspension
+- **Trend filter** , brakes
+
+## Summary
+
+A good grid bot sells local volatility, respects regime shifts, and stays bounded in inventory. The goal is not constant activity, it is controlled adaptation.

+ 104 - 0
Strategy_Contract.md

@@ -0,0 +1,104 @@
+# Strategy Contract
+
+This is the canonical contract for `trader-mcp` strategies.
+
+## Purpose
+
+A strategy defines behavior. It does not own persistence, lifecycle, or UI rendering.
+
+## Strategy class
+
+```python
+class Strategy:
+    LABEL = "Human readable name"
+    CONFIG_SCHEMA = {}
+    STATE_SCHEMA = {}
+    TICK_MINUTES = 1.0
+
+    def __init__(self, context, config):
+        self.context = context
+        self.config = config
+        self.state = self.init()
+```
+
+## Responsibilities
+
+### Strategy
+- decides what to do
+- reads config
+- mutates `self.state`
+- returns structured render data
+
+### Not strategy responsibilities
+- persistence
+- lifecycle control
+- config storage
+- identity ownership
+- direct access to engine internals
+
+## Configuration
+
+- `CONFIG_SCHEMA` is declarative metadata
+- config is supplied by the engine
+- config changes are handled by reload
+- config should stay read-only from the strategy perspective
+
+## State
+
+- `self.state` belongs to the instance
+- `STATE_SCHEMA` declares what state is durable / persisted
+- keep state serializable where practical
+- do not depend on hidden global state
+- the engine snapshots and restores state, not the strategy
+
+## Lifecycle
+
+```text
+init() -> state created
+on_tick() -> state updated
+reload -> state restored from engine snapshot if available
+```
+
+## Context
+
+The context is capability-only.
+
+Allowed:
+- market/data access
+- order placement
+- logging
+- engine-mediated actions
+- binding `account_id` and `client_id` into exec calls
+
+Not allowed:
+- persistence
+- config storage
+- lifecycle decisions
+- strategy identity ownership
+
+## UI output
+
+Strategies return structured widgets, not HTML.
+
+```python
+{
+  "widgets": [
+    {"type": "metric", "label": "PnL", "value": 123.45},
+    {"type": "line_chart", "data": [...]}
+  ]
+}
+```
+
+## Example
+
+```python
+from src.trader_mcp.strategy_sdk import Strategy
+
+class Strategy(Strategy):
+    LABEL = "Hello World"
+    CONFIG_SCHEMA = {"label": {"type": "string", "default": "hello world"}}
+    STATE_SCHEMA = {"counter": {"type": "int", "default": 0}}
+
+    def init(self):
+        return {"counter": 0}
+```

+ 80 - 0
Strategy_Runtime.md

@@ -0,0 +1,80 @@
+# Strategy Runtime
+
+This is the canonical runtime / engine document for `trader-mcp`.
+
+## Overview
+
+The system is split into:
+- strategy definition
+- persisted instance record
+- runtime execution
+- dashboard rendering
+
+## Instance identity
+
+Immutable identity fields:
+- `id`
+- `strategy_type`
+- `account_id`
+- `market_symbol`
+- `base_currency`
+- `counter_currency`
+
+These should stay stable and audit-friendly.
+
+## Mutable fields
+
+- `mode` (`off`, `observe`, `active`)
+- `config`
+- runtime state snapshots
+
+## State persistence
+
+- the engine owns snapshotting and restore
+- strategies only define state shape (`STATE_SCHEMA`)
+- persisted state is stored separately from config
+- state should be saved at lifecycle boundaries:
+  - tick
+  - pause
+  - unload
+  - reload/config change
+  - clean shutdown
+
+## Mode semantics
+
+- `off`: not instantiated
+- `observe`: instantiated, ticks enabled, trading disabled
+- `active`: instantiated, ticks enabled, trading enabled
+- `paused`: runtime freeze, not persisted as a mode
+
+## Reconciliation
+
+The engine reconciles DB records to runtime:
+
+```python
+if record.mode != "off" and record.id not in running:
+    load_instance(record)
+
+if record.mode == "off" and record.id in running:
+    unload_instance(record.id)
+```
+
+## Reload semantics
+
+Default behavior:
+- unload old runtime
+- restore state snapshot when available
+- load new runtime
+
+Selective state carryover is acceptable only through engine-managed persistence.
+
+## Dashboard responsibilities
+
+The dashboard shows:
+- identity
+- config
+- mode
+- runtime status
+- rendered widgets
+
+It does not own trading logic.

+ 5 - 203
Strategy_concepts_0.md

@@ -1,205 +1,7 @@
-# Strategy SDK Specification
+# Deprecated
 
-## 1. Purpose
+This document has been superseded by:
+- `Strategy_Contract.md`
+- `Strategy_Runtime.md`
 
-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):
-    TICK_MINUTES = 1.0
-    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
-- runtime defines tick cadence, in minutes
-- context defines permissions
-- config defines initial conditions
-- state belongs to the instance
+Keep these newer files as the canonical source.

+ 5 - 162
Strategy_concepts_1.md

@@ -1,164 +1,7 @@
-# Strategy Engine Architecture
+# Deprecated
 
-## 1. Overview
+This document has been superseded by:
+- `Strategy_Contract.md`
+- `Strategy_Runtime.md`
 
-This document describes how strategy definitions become running instances in `trader-mcp`.
-The design separates:
-
-- strategy definition
-- persisted instance record
-- runtime execution
-- dashboard rendering
-
-## 2. Core Concepts
-
-### 2.1 Strategy definition
-
-A strategy definition is reusable code, usually a Python module, that exposes the strategy class and behavior.
-
-It typically defines:
-
-- `init()`
-- `on_tick()`
-- `render()`
-- `CONFIG_SCHEMA`
-
-### 2.2 Strategy instance
-
-A strategy instance is a configured, uniquely identifiable deployment of a strategy definition.
-
-It has:
-
-- immutable identity
-- mutable config
-- runtime state
-- execution mode
-
-#### Immutable identity
-
-Examples:
-
-- `id`
-- `strategy_type`
-- `account`
-- `market`
-
-These fields should be stable and audit-friendly.
-
-#### Mutable config
-
-Config is user-editable and persistent.
-It is stored as JSON in the database and supplied to the strategy on load.
-
-#### Runtime state
-
-State is owned by the strategy instance and is not part of persistent config.
-
-## 3. System Model
-
-```text
-Strategy Definition -> Persisted Instance Record -> Running Instance -> UI View
-```
-
-The database stores desired state.
-The engine reconciles runtime from that desired state.
-
-## 4. Modes
-
-Instances should use a small mode set:
-
-```text
-off, observe, active
-```
-
-### Meaning
-
-- `off`, not instantiated, no ticks, no trading
-- `observe`, instantiated, ticks enabled, trading disabled
-- `active`, instantiated, ticks enabled, trading enabled
-
-### Mode transitions
-
-- `off` <-> `observe` is the power toggle
-- `observe` <-> `active` is the activation toggle
-- activation should be disabled while mode is `off`
-
-### Paused runtime state
-
-`paused` is not a persisted mode.
-It is a runtime freeze state controlled by the engine.
-
-- the instance stays loaded
-- ticks are skipped
-- trading is skipped
-- render can be skipped while paused
-
-This keeps the stored mode model small while still allowing a temporary freeze.
-
-## 5. Tick cadence
-
-Strategies declare cadence with `TICK_MINUTES`.
-The runtime heartbeats every 6 seconds and schedules strategies from that value.
-
-- `TICK_MINUTES = 1.0` means about 10 heartbeat steps
-- decimals are allowed, so `2.1` is valid
-
-## 5. Reconciliation
-
-The engine reconciles persisted records against runtime instances when records change and at startup.
-
-```python
-def reconcile():
-    for record in db.instances:
-        if record.mode != "off" and record.id not in running:
-            load_instance(record)
-
-        if record.mode == "off" and record.id in running:
-            unload_instance(record.id)
-```
-
-## 6. Config Reload Semantics
-
-Config changes should normally trigger a reload.
-
-```text
-unload_instance(id)
-load_instance(updated_record)
-```
-
-This is the clean default because it is deterministic and easy to debug.
-
-Selective state carryover is not part of the current model, reload is the default.
-
-## 7. Engine Responsibilities
-
-The engine should:
-
-- 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
-
-## 8. Dashboard Responsibilities
-
-The dashboard shows:
-
-- identity
-- config
-- current mode
-- runtime status
-- rendered widgets
-
-It does not own trading logic.
-
-## 9. Recommended Direction
-
-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
+Keep these newer files as the canonical source.

+ 11 - 2
Strategy_concepts_examples.md

@@ -6,11 +6,16 @@
 from strategy_sdk import Strategy
 
 class MyStrategy(Strategy):
+    LABEL = "My Strategy"
     TICK_MINUTES = 1.0
     CONFIG_SCHEMA = {
         "risk": {"type": "float", "default": 0.01},
         "window": {"type": "int", "default": 20}
     }
+    STATE_SCHEMA = {
+        "prices": {"type": "list", "default": []},
+        "position": {"type": "int", "default": 0}
+    }
 
     def init(self):
         return {
@@ -33,7 +38,9 @@ class MyStrategy(Strategy):
 
 ```python
 class Strategy:
+    LABEL = "Strategy"
     CONFIG_SCHEMA = {}
+    STATE_SCHEMA = {}
 
     def __init__(self, context, config):
         self.context = context
@@ -71,7 +78,7 @@ CONFIG_SCHEMA = {
 
 ```python
 class StrategyContext:
-    def get_price(self):
+    def get_price(self, symbol):
         raise NotImplementedError
 
     def place_order(self, side, amount):
@@ -80,7 +87,7 @@ class StrategyContext:
     def get_orders(self):
         raise NotImplementedError
 
-    def place_order(self, side, amount, client_id=None):
+    def cancel_all_orders(self):
         raise NotImplementedError
 
     def log(self, message):
@@ -93,6 +100,8 @@ class StrategyContext:
 Instantiate -> init() -> on_tick() -> render()
 ```
 
+The engine owns persistence and restores `state` around reload / unload / pause boundaries.
+
 ## 6. File structure
 
 ```text

+ 6 - 0
grid_bot_concept.md

@@ -0,0 +1,6 @@
+# Deprecated
+
+This note has been superseded by:
+- `Grid_Bot_Architecture.md`
+
+Keep the newer file as the canonical version.

+ 10 - 6
src/trader_mcp/dashboard.py

@@ -8,7 +8,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
 
 from .exec_client import list_accounts, list_markets
 from .strategy_engine import get_running_strategy, pause_strategy, reconcile_instance, render_strategy, resume_strategy
-from .strategy_registry import get_strategy_default_config, list_available_strategy_modules
+from .strategy_registry import get_strategy_default_config, get_strategy_label, list_available_strategy_modules
 from .strategy_store import add_strategy_instance, delete_strategy_instance, list_strategy_instances, synthesize_client_id, update_strategy_config, update_strategy_mode, update_strategy_name
 from .crypto_client import call_crypto_tool
 
@@ -82,7 +82,7 @@ def dashboard_home():
         <tr>
           <td>{indicator}</td>
           <td>{name}</td>
-          <td>{strategy_type}</td>
+            <td>{strategy_label}<div class="muted" style="font-size: 12px;">{strategy_type}</div></td>
           <td>{account_name}</td>
           <td>{market_label}</td>
           <td class="actions">
@@ -109,10 +109,11 @@ def dashboard_home():
           </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"))),
+            indicator=("🔵" if (get_running_strategy(s.id).paused if get_running_strategy(s.id) else False) else ("" if (s.mode or "off") == "off" else ("🟡" if s.mode == "observe" else "✅"))),
             id=s.id,
             name=s.name or s.id,
             strategy_type=s.strategy_type,
+            strategy_label=get_strategy_label(s.strategy_type),
             account_name=account_lookup.get(s.account_id, s.account_id),
             mode=s.mode,
             config=s.config,
@@ -130,7 +131,10 @@ def dashboard_home():
         for s in strategies
     )
 
-    module_options = "".join(f'<option value="{m.module_name}">{m.module_name}</option>' for m in available_modules)
+    module_options = "".join(
+        f'<option value="{m.module_name}">{get_strategy_label(m.module_name)}</option>'
+        for m in available_modules
+    )
     module_options = '<option value="" disabled selected>Select strategy blueprint</option>' + (module_options or "")
 
     account_options = '<option value="" disabled selected>Select account</option>' + (account_options or "")
@@ -149,8 +153,8 @@ def dashboard_home():
       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; }}
-      .actions {{ display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }}
-      .actions form {{ display: inline; }}
+      .actions {{ display: flex; gap: 8px; flex-wrap: nowrap; align-items: center; white-space: nowrap; }}
+      .actions form {{ display: inline-flex; }}
       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; }}

+ 14 - 2
src/trader_mcp/strategy_engine.py

@@ -7,7 +7,7 @@ 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
+from .strategy_store import StrategyRecord, list_strategy_instances, update_strategy_state
 
 
 @dataclass
@@ -35,6 +35,7 @@ def pause_strategy(instance_id: str) -> dict[str, Any]:
     if runtime is None:
         return {"ok": False, "error": "strategy not running", "id": instance_id}
     runtime.paused = True
+    update_strategy_state(instance_id, runtime.instance.state)
     return {"ok": True, "id": instance_id, "paused": True}
 
 
@@ -53,6 +54,7 @@ def tick_strategy(instance_id: str, tick: dict[str, Any]) -> dict[str, Any]:
     if runtime.paused:
         return {"ok": True, "id": instance_id, "paused": True, "skipped": True}
     result = runtime.instance.on_tick(tick)
+    update_strategy_state(instance_id, runtime.instance.state)
     runtime.next_tick_at = time.time() + (runtime.tick_minutes * 60.0)
     return {"ok": True, "id": instance_id, "result": result}
 
@@ -74,6 +76,7 @@ def reconcile_all() -> dict[str, Any]:
     for instance_id, runtime in list(_running.items()):
         record = records.get(instance_id)
         if record is None or record.mode == "off":
+            update_strategy_state(instance_id, runtime.instance.state)
             _running.pop(instance_id, None)
             unloaded.append(instance_id)
 
@@ -83,6 +86,8 @@ def reconcile_all() -> dict[str, Any]:
         existing = _running.get(instance_id)
         if existing is not None and existing.record.updated_at == record.updated_at:
             continue
+        if existing is not None:
+            update_strategy_state(instance_id, existing.instance.state)
         _running[instance_id] = _make_runtime(record)
         loaded.append(instance_id)
 
@@ -93,8 +98,13 @@ 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)
+        if removed is not None:
+            update_strategy_state(instance_id, removed.instance.state)
         return {"loaded": False, "unloaded": removed is not None, "running": running_strategy_ids()}
 
+    existing = _running.get(instance_id)
+    if existing is not None:
+        update_strategy_state(instance_id, existing.instance.state)
     _running[instance_id] = _make_runtime(record)
     return {"loaded": True, "unloaded": False, "running": running_strategy_ids()}
 
@@ -117,7 +127,9 @@ def _instantiate(record: StrategyRecord) -> Strategy:
         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)
+    instance = strategy_cls(context=context, config=record.config)
+    instance.state = record.state or instance.init()
+    return instance
 
 
 def _make_runtime(record: StrategyRecord) -> RuntimeStrategy:

+ 8 - 0
src/trader_mcp/strategy_registry.py

@@ -29,6 +29,14 @@ def get_strategy_default_config(module_name: str) -> dict[str, Any]:
     return defaults
 
 
+def get_strategy_label(module_name: str) -> str:
+    module = load_strategy_module(module_name)
+    strategy_cls = getattr(module, "Strategy", None)
+    if strategy_cls is None:
+        return module_name
+    return str(getattr(strategy_cls, "LABEL", None) or module_name)
+
+
 def list_available_strategy_modules() -> list[StrategyModuleInfo]:
     if not STRATEGIES_DIR.exists():
         return []

+ 3 - 0
src/trader_mcp/strategy_sdk.py

@@ -4,7 +4,10 @@ from typing import Any
 
 
 class Strategy:
+    # CONFIG_SCHEMA is declarative metadata for the engine and dashboard.
     CONFIG_SCHEMA: dict[str, Any] = {}
+    # STATE_SCHEMA declares which instance-local state should be persisted by the engine.
+    STATE_SCHEMA: dict[str, Any] = {}
     TICK_MINUTES: float = 1.0
 
     def __init__(self, context, config):

+ 20 - 3
src/trader_mcp/strategy_store.py

@@ -27,6 +27,7 @@ class StrategyRecord:
     market_symbol: str | None
     base_currency: str | None
     counter_currency: str | None
+    state: dict[str, Any]
     config: dict[str, Any]
     started_at: str | None
     activated_at: str | None
@@ -60,6 +61,7 @@ def init_db() -> None:
                 market_symbol TEXT,
                 base_currency TEXT,
                 counter_currency TEXT,
+                state_json TEXT NOT NULL DEFAULT '{}',
                 config_json TEXT NOT NULL DEFAULT '{}',
                 started_at TEXT,
                 activated_at TEXT,
@@ -77,6 +79,8 @@ def init_db() -> None:
             conn.execute("ALTER TABLE strategy_instances ADD COLUMN base_currency TEXT")
         if "counter_currency" not in columns:
             conn.execute("ALTER TABLE strategy_instances ADD COLUMN counter_currency TEXT")
+        if "state_json" not in columns:
+            conn.execute("ALTER TABLE strategy_instances ADD COLUMN state_json TEXT NOT NULL DEFAULT '{}' ")
 
         # Backfill missing market identity for existing rows.
         # You can treat this as a lightweight bootstrap; it won’t override rows that already have market set.
@@ -114,10 +118,10 @@ def add_strategy_instance(*, id: str, strategy_type: str, account_id: str, clien
         conn.execute(
             """
             INSERT INTO strategy_instances
-            (id, name, strategy_type, account_id, client_id, mode, market_symbol, base_currency, counter_currency, config_json, started_at, activated_at, created_at, updated_at)
-            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            (id, name, strategy_type, account_id, client_id, mode, market_symbol, base_currency, counter_currency, state_json, config_json, started_at, activated_at, created_at, updated_at)
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
             """,
-            (id, "", strategy_type, account_id, client_id, mode, market_symbol, base_currency, counter_currency, json.dumps(config), started_at, activated_at, now, now),
+            (id, "", strategy_type, account_id, client_id, mode, market_symbol, base_currency, counter_currency, json.dumps({}), json.dumps(config), started_at, activated_at, now, now),
         )
         conn.commit()
     return get_strategy_instance(id)  # type: ignore[return-value]
@@ -159,6 +163,18 @@ def update_strategy_config(instance_id: str, config: dict[str, Any]) -> bool:
     return cur.rowcount > 0
 
 
+def update_strategy_state(instance_id: str, state: dict[str, Any]) -> bool:
+    init_db()
+    now = _utc_now()
+    with get_connection() as conn:
+        cur = conn.execute(
+            "UPDATE strategy_instances SET state_json = ?, updated_at = ? WHERE id = ?",
+            (json.dumps(state), now, instance_id),
+        )
+        conn.commit()
+    return cur.rowcount > 0
+
+
 def update_strategy_name(instance_id: str, name: str) -> bool:
     init_db()
     now = _utc_now()
@@ -184,6 +200,7 @@ def _row_to_record(row: sqlite3.Row | None) -> StrategyRecord | None:
         market_symbol=row["market_symbol"] if "market_symbol" in row.keys() else None,
         base_currency=row["base_currency"] if "base_currency" in row.keys() else None,
         counter_currency=row["counter_currency"] if "counter_currency" in row.keys() else None,
+        state=json.loads(row["state_json"] or "{}") if "state_json" in row.keys() else {},
         config=json.loads(row["config_json"] or "{}"),
         started_at=row["started_at"],
         activated_at=row["activated_at"],

+ 5 - 0
strategies/hello_world.py

@@ -4,12 +4,17 @@ from src.trader_mcp.strategy_sdk import Strategy
 
 
 class Strategy(Strategy):
+    LABEL = "Hello World"
     TICK_MINUTES = 0.2
     CONFIG_SCHEMA = {
         "label": {"type": "string", "default": "hello world"},
     }
+    STATE_SCHEMA = {
+        "counter": {"type": "int", "default": 0},
+    }
 
     def init(self):
+        # Tiny demo state, useful for persistence smoke tests.
         return {"counter": 0}
 
     def on_tick(self, tick):