소스 검색

Checkpoint grid strategy and dashboard updates

Lukas Goldschmidt 1 개월 전
부모
커밋
bf00b9b288
6개의 변경된 파일501개의 추가작업 그리고 20개의 파일을 삭제
  1. 25 8
      src/trader_mcp/dashboard.py
  2. 7 0
      src/trader_mcp/exec_client.py
  3. 22 1
      src/trader_mcp/mcp_client.py
  4. 30 5
      src/trader_mcp/strategy_context.py
  5. 34 6
      src/trader_mcp/strategy_engine.py
  6. 383 0
      strategies/grid_trader.py

+ 25 - 8
src/trader_mcp/dashboard.py

@@ -9,7 +9,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, 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 .strategy_store import add_strategy_instance, delete_strategy_instance, list_strategy_instances, synthesize_client_id, update_strategy_config, update_strategy_mode, update_strategy_name, update_strategy_state
 from .crypto_client import call_crypto_tool
 
 router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@@ -94,17 +94,20 @@ def dashboard_home():
           </td>
         </tr>
         <tr id="details-{id}" class="detail-row" style="display:none;">
-          <td colspan="5">
-            <div style="margin-top:10px; display:grid; gap:12px;">
+          <td colspan="6">
+            <div style="margin-top:10px; display:grid; grid-template-columns: 1.2fr 0.8fr; gap:12px; align-items:start; width:100%;">
               <div>
                 <strong>Render</strong>
-                <div class="render-panel" data-strategy-id="{id}" style="background:#f9fafb; padding:10px; border-radius:8px; border:1px solid #e5e7eb;">(loading)</div>
+                <div class="render-panel" data-strategy-id="{id}" style="background:#f9fafb; padding:10px; border-radius:8px; border:1px solid #e5e7eb; width:100%; box-sizing:border-box;">(loading)</div>
               </div>
-              <form method="post" action="/dashboard/strategies/{id}/config" style="display:grid; gap:8px;">
+              <form method="post" action="/dashboard/strategies/{id}/config" style="display:grid; gap:8px; width:100%;">
                 <strong>Edit config</strong>
-                <textarea name="config_json" rows="6" style="width:100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;">{config_json}</textarea>
+                <textarea name="config_json" rows="12" style="width:100%; box-sizing:border-box; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;">{config_json}</textarea>
                 <button type="submit">Save config</button>
               </form>
+              <form method="post" action="/dashboard/strategies/{id}/reset" style="display:flex; justify-content:flex-end; width:100%;">
+                <button type="submit" class="ghost" style="font-size: 12px; padding: 6px 8px;">Reset state</button>
+              </form>
             </div>
           </td>
         </tr>
@@ -146,8 +149,9 @@ def dashboard_home():
     <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: 1100px; padding: 1.25rem; border: 1px solid #e5e7eb; border-radius: 12px; }}
+      body {{ font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; margin: 0; color: #111827; background: #fff; }}
+      .page {{ width: 100%; display: flex; justify-content: center; }}
+      .card {{ width: min(1600px, calc(100vw - 2rem)); margin: 1rem auto; 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; }}
@@ -163,6 +167,7 @@ def dashboard_home():
     </style>
   </head>
   <body>
+    <div class="page">
     <div class="card">
       <h1>Trader MCP Dashboard</h1>
       <p class="muted">Strategies and exec-mcp accounts</p>
@@ -215,6 +220,7 @@ def dashboard_home():
 
       <p class="muted" style="margin-top: 12px;">Source: <span class="pill">exec-mcp.list_accounts</span></p>
     </div>
+    </div>
 
     <script>
       function renderWidget(widget) {{
@@ -229,6 +235,10 @@ def dashboard_home():
           const data = Array.isArray(widget.data) ? widget.data : [];
           return `<div style="padding:8px 0;"><strong>${{widget.label || 'chart'}}</strong><pre style="white-space:pre-wrap; margin:6px 0 0;">${{JSON.stringify(data, null, 2)}}</pre></div>`;
         }}
+        if (widget.type === 'log') {{
+          const lines = Array.isArray(widget.lines) ? widget.lines : [widget.value ?? ''];
+          return `<div style="padding:8px 0;"><strong>${{widget.label || 'log'}}</strong><pre style="white-space:pre-wrap; height:220px; overflow-y:scroll; overflow-x:auto; margin:6px 0 0; padding:8px; background:#f9fafb; border:1px solid #e5e7eb; border-radius:8px;">${{lines.join('\\n')}}</pre></div>`;
+        }}
         return `<pre style="white-space:pre-wrap; margin:0;">${{JSON.stringify(widget, null, 2)}}</pre>`;
       }}
 
@@ -427,6 +437,13 @@ def dashboard_strategies_config(strategy_id: str, config_json: str = Form(...)):
     return RedirectResponse(url="/dashboard", status_code=303)
 
 
+@router.post("/strategies/{strategy_id}/reset")
+def dashboard_strategies_reset(strategy_id: str):
+    update_strategy_state(strategy_id, {})
+    reconcile_instance(strategy_id)
+    return RedirectResponse(url="/dashboard", status_code=303)
+
+
 @router.get("/strategies/{strategy_id}/render")
 def dashboard_strategies_render(strategy_id: str):
     return render_strategy(strategy_id)

+ 7 - 0
src/trader_mcp/exec_client.py

@@ -56,3 +56,10 @@ def cancel_all_orders(account_id: str, client_id: str | None = None) -> Any:
     if client_id is not None:
         args["client_id"] = client_id
     return _mcp.call_tool("cancel_all_orders", args)
+
+
+def cancel_order(account_id: str, order_id: str, client_id: str | None = None) -> Any:
+    args: dict[str, Any] = {"account_id": account_id, "order_id": order_id}
+    if client_id is not None:
+        args["client_id"] = client_id
+    return _mcp.call_tool("cancel_order", args)

+ 22 - 1
src/trader_mcp/mcp_client.py

@@ -2,6 +2,9 @@ from __future__ import annotations
 
 import json
 import os
+import asyncio
+import threading
+from queue import Queue
 from dataclasses import dataclass
 from typing import Any, Optional
 
@@ -39,7 +42,25 @@ class GenericMcpClient:
             return payload
 
     def call_tool(self, tool_name: str, arguments: Optional[dict[str, Any]] = None) -> Any:
-        return anyio.run(self._call_tool_async, tool_name, arguments)
+        try:
+            asyncio.get_running_loop()
+        except RuntimeError:
+            return anyio.run(self._call_tool_async, tool_name, arguments)
+
+        q: Queue[tuple[bool, Any]] = Queue(maxsize=1)
+
+        def _runner() -> None:
+            try:
+                q.put((True, anyio.run(self._call_tool_async, tool_name, arguments)))
+            except Exception as exc:
+                q.put((False, exc))
+
+        thread = threading.Thread(target=_runner, daemon=True)
+        thread.start()
+        ok, payload = q.get()
+        if ok:
+            return payload
+        raise payload
 
 
 def sse_url_from_env(var_name: str, default: str) -> str:

+ 30 - 5
src/trader_mcp/strategy_context.py

@@ -1,9 +1,9 @@
 from __future__ import annotations
 
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from typing import Any
 
-from .exec_client import list_open_orders, cancel_all_orders, place_order
+from .exec_client import list_open_orders, cancel_all_orders, cancel_order, place_order, get_account_info
 from .news_client import call_news_tool
 from .crypto_client import call_crypto_tool
 
@@ -12,21 +12,46 @@ from .crypto_client import call_crypto_tool
 class StrategyContext:
     id: str
     account_id: str
-    client_id: str | None = None
+    client_id: str | None = field(default=None, repr=False)
+    mode: str = "off"
+    market_symbol: str | None = None
+    base_currency: str | None = None
+    counter_currency: str | None = None
+    minimum_order_value: float | None = None
+
+    def __getattr__(self, name: str):
+        if name == "mode":
+            return "active"
+        raise AttributeError(name)
 
     def get_open_orders(self) -> Any:
-        return list_open_orders(self.account_id, self.client_id)
+        payload = list_open_orders(self.account_id, self.client_id)
+        if isinstance(payload, dict) and isinstance(payload.get("orders"), list):
+            return payload["orders"]
+        return payload
 
     def cancel_all_orders(self) -> Any:
         return cancel_all_orders(self.account_id, self.client_id)
 
+    def cancel_order(self, order_id: str) -> Any:
+        return cancel_order(self.account_id, order_id)
+
     def place_order(self, **kwargs: Any) -> Any:
+        mode = getattr(self, "mode", "active")
+        if mode != "active":
+            raise RuntimeError(f"place_order not allowed in {mode} mode")
         kwargs.setdefault("account_id", self.account_id)
         kwargs.setdefault("client_id", self.client_id)
-        return place_order(**kwargs)
+        return place_order(kwargs)
 
     def get_price(self, symbol: str) -> Any:
         return call_crypto_tool("get_price", {"symbol": symbol})
 
+    def get_regime(self, symbol: str, timeframe: str = "1h") -> Any:
+        return call_crypto_tool("get_regime", {"symbol": symbol, "timeframe": timeframe})
+
+    def get_account_info(self) -> Any:
+        return get_account_info(self.account_id)
+
     def get_news(self, **kwargs: Any) -> Any:
         return call_news_tool("search", kwargs)

+ 34 - 6
src/trader_mcp/strategy_engine.py

@@ -8,6 +8,7 @@ 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, update_strategy_state
+from .exec_client import list_markets
 
 
 @dataclass
@@ -53,10 +54,16 @@ def tick_strategy(instance_id: str, tick: dict[str, Any]) -> dict[str, Any]:
         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)
-    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}
+    try:
+        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}
+    except Exception as exc:
+        runtime.instance.state["last_error"] = str(exc)
+        update_strategy_state(instance_id, runtime.instance.state)
+        runtime.next_tick_at = time.time() + (runtime.tick_minutes * 60.0)
+        return {"ok": False, "id": instance_id, "error": str(exc)}
 
 
 def render_strategy(instance_id: str) -> dict[str, Any]:
@@ -115,7 +122,11 @@ def run_due_ticks(now: float | None = None) -> dict[str, Any]:
     for instance_id, runtime in list(_running.items()):
         if runtime.paused or runtime.next_tick_at > now:
             continue
-        tick_strategy(instance_id, {"ts": now, "strategy_id": instance_id})
+        try:
+            tick_strategy(instance_id, {"ts": now, "strategy_id": instance_id})
+        except Exception:
+            # keep the scheduler alive even if one strategy misbehaves
+            continue
         ticked.append(instance_id)
     return {"ok": True, "ticked": ticked, "running": running_strategy_ids()}
 
@@ -126,7 +137,24 @@ def _instantiate(record: StrategyRecord) -> Strategy:
     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)
+    market_meta = next((m for m in list_markets() if isinstance(m, dict) and m.get("market_symbol") == record.market_symbol), None)
+    minimum_order_value = None
+    if isinstance(market_meta, dict):
+        try:
+            minimum_order_value = float(market_meta.get("minimum_order_value") or 0) or None
+        except Exception:
+            minimum_order_value = None
+
+    context = StrategyContext(
+        id=record.id,
+        account_id=record.account_id,
+        client_id=record.client_id,
+        mode=record.mode,
+        market_symbol=record.market_symbol,
+        base_currency=record.base_currency,
+        counter_currency=record.counter_currency,
+        minimum_order_value=minimum_order_value,
+    )
     instance = strategy_cls(context=context, config=record.config)
     instance.state = record.state or instance.init()
     return instance

+ 383 - 0
strategies/grid_trader.py

@@ -0,0 +1,383 @@
+from __future__ import annotations
+
+import time
+
+from src.trader_mcp.strategy_sdk import Strategy
+
+
+class Strategy(Strategy):
+    LABEL = "Grid Trader"
+    TICK_MINUTES = 0.2
+    # NOTE:
+    # This strategy is currently using a protective workaround for stale order state,
+    # because exec-mcp can temporarily report order records that do not reflect the
+    # clean post-reset strategy state. The grid prefers its own fresh persisted state
+    # first, so the real exchange behavior stays testable while exec-mcp is improved.
+    # Expect the reconciliation behavior to change again once exec-mcp is fixed.
+    CONFIG_SCHEMA = {
+        "grid_levels": {"type": "int", "default": 6, "min": 1, "max": 20},
+        "grid_step_pct": {"type": "float", "default": 0.012, "min": 0.001, "max": 0.1},
+        "volatility_timeframe": {"type": "string", "default": "1h"},
+        "volatility_multiplier": {"type": "float", "default": 0.5, "min": 0.0, "max": 10.0},
+        "grid_step_min_pct": {"type": "float", "default": 0.005, "min": 0.0001, "max": 0.5},
+        "grid_step_max_pct": {"type": "float", "default": 0.03, "min": 0.0001, "max": 1.0},
+        "order_size": {"type": "float", "default": 0.0, "min": 0.0},
+        "inventory_cap_pct": {"type": "float", "default": 0.7, "min": 0.0, "max": 1.0},
+        "recenter_pct": {"type": "float", "default": 0.05, "min": 0.0, "max": 0.5},
+        "fee_rate": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.05},
+        "trade_sides": {"type": "string", "default": "both"},
+        "max_notional_per_order": {"type": "float", "default": 0.0, "min": 0.0},
+        "order_call_delay_ms": {"type": "int", "default": 250, "min": 0, "max": 10000},
+        "debug_orders": {"type": "bool", "default": True},
+        "use_all_available": {"type": "bool", "default": True},
+    }
+    STATE_SCHEMA = {
+        "center_price": {"type": "float", "default": 0.0},
+        "last_price": {"type": "float", "default": 0.0},
+        "seeded": {"type": "bool", "default": False},
+        "last_action": {"type": "string", "default": "idle"},
+        "last_error": {"type": "string", "default": ""},
+        "orders": {"type": "list", "default": []},
+        "order_ids": {"type": "list", "default": []},
+        "debug_log": {"type": "list", "default": []},
+        "base_available": {"type": "float", "default": 0.0},
+        "counter_available": {"type": "float", "default": 0.0},
+    }
+
+    def init(self):
+        return {
+            "center_price": 0.0,
+            "last_price": 0.0,
+            "seeded": False,
+            "last_action": "idle",
+            "last_error": "",
+            "orders": [],
+            "order_ids": [],
+            "debug_log": ["init cancel all orders"],
+            "base_available": 0.0,
+            "counter_available": 0.0,
+        }
+
+    def _log(self, message: str) -> None:
+        state = getattr(self, "state", {}) or {}
+        log = list(state.get("debug_log") or [])
+        log.append(message)
+        state["debug_log"] = log[-12:]
+        self.state = state
+
+    def _base_symbol(self) -> str:
+        return (self.context.base_currency or self.context.market_symbol or "XRP").split("/")[0].upper()
+
+    def _market_symbol(self) -> str:
+        return self.context.market_symbol or f"{self._base_symbol().lower()}usd"
+
+    def _mode(self) -> str:
+        return getattr(self.context, "mode", "active") or "active"
+
+    def _price(self) -> float:
+        payload = self.context.get_price(self._base_symbol())
+        return float(payload.get("price") or 0.0)
+
+    def _regime_snapshot(self) -> dict:
+        timeframes = ["1d", "4h", "1h", "15m"]
+        snapshot = {}
+        for tf in timeframes:
+            try:
+                snapshot[tf] = self.context.get_regime(self._base_symbol(), tf)
+            except Exception as exc:
+                snapshot[tf] = {"error": str(exc)}
+        return snapshot
+
+    def _grid_step_pct(self) -> float:
+        base_step = float(self.config.get("grid_step_pct", 0.012) or 0.012)
+        tf = str(self.config.get("volatility_timeframe", "1h") or "1h")
+        multiplier = float(self.config.get("volatility_multiplier", 0.5) or 0.0)
+        min_step = float(self.config.get("grid_step_min_pct", 0.005) or 0.0)
+        max_step = float(self.config.get("grid_step_max_pct", 0.03) or 1.0)
+
+        try:
+            regime = self.context.get_regime(self._base_symbol(), tf)
+            short_regime = self.context.get_regime(self._base_symbol(), "15m")
+            atr_pct = float((regime or {}).get("volatility", {}).get("atr_percent") or 0.0)
+            short_atr_pct = float((short_regime or {}).get("volatility", {}).get("atr_percent") or 0.0)
+            atr_pct = max(atr_pct, short_atr_pct)
+            self.state["regimes"] = self._regime_snapshot()
+        except Exception as exc:
+            self._log(f"regime fetch failed: {exc}")
+            atr_pct = 0.0
+
+        adaptive = (atr_pct / 100.0) * multiplier if atr_pct > 0 else base_step
+        step = adaptive if atr_pct > 0 else base_step
+        step = max(step, min_step)
+        step = min(step, max_step)
+        self.state["grid_step_pct"] = step
+        self.state["atr_percent"] = atr_pct
+        return step
+
+    def _available_balance(self, asset_code: str) -> float:
+        try:
+            info = self.context.get_account_info()
+        except Exception as exc:
+            self._log(f"account info failed: {exc}")
+            return 0.0
+
+        balances = info.get("balances") if isinstance(info, dict) else []
+        if not isinstance(balances, list):
+            return 0.0
+        wanted = str(asset_code or "").upper()
+        for balance in balances:
+            if not isinstance(balance, dict):
+                continue
+            if str(balance.get("asset_code") or "").upper() != wanted:
+                continue
+            try:
+                return float(balance.get("available") if balance.get("available") is not None else balance.get("total") or 0.0)
+            except Exception:
+                return 0.0
+        return 0.0
+
+    def _supported_levels(self, side: str, price: float, min_notional: float) -> int:
+        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)
+        if side == "buy":
+            quote = self.context.counter_currency or "USD"
+            quote_available = self._available_balance(quote)
+            self.state["counter_available"] = quote_available
+            usable_notional = quote_available * safety
+            return max(int(usable_notional / min_notional), 0)
+
+        base = self._base_symbol()
+        base_available = self._available_balance(base)
+        self.state["base_available"] = base_available
+        usable_notional = base_available * safety * price / (1 + fee_rate)
+        return max(int(usable_notional / min_notional), 0)
+
+    def _side_allowed(self, side: str) -> bool:
+        selected = str(self.config.get("trade_sides", "both") or "both").strip().lower()
+        if selected == "both":
+            return True
+        return selected == side
+
+    def _suggest_amount(self, side: str, price: float, levels: int, min_notional: float) -> float:
+        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)
+        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)
+        if side == "buy":
+            quote = self.context.counter_currency or "USD"
+            quote_available = self._available_balance(quote)
+            self.state["counter_available"] = quote_available
+            spendable_quote = quote_available * safety
+            amount = spendable_quote / (max(levels, 1) * price * (1 + fee_rate))
+        else:
+            base = self._base_symbol()
+            base_available = self._available_balance(base)
+            self.state["base_available"] = base_available
+            spendable_base = (base_available * safety) / (1 + fee_rate)
+            amount = spendable_base / max(levels, 1)
+
+        min_size = (min_notional / price) if price > 0 else 0.0
+        amount = max(amount, min_size * 1.05)
+        if max_notional > 0 and price > 0:
+            amount = min(amount, max_notional / (price * (1 + fee_rate)))
+        if manual > 0:
+            amount = min(amount, manual)
+        return max(amount, 0.0)
+
+    def _place_grid(self, center: float) -> None:
+        mode = self._mode()
+        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)
+        market = self._market_symbol()
+        orders = []
+        order_ids = []
+
+        def _capture_order_id(result):
+            if isinstance(result, dict):
+                return result.get("bitstamp_order_id") or result.get("order_id") or result.get("id") or result.get("client_order_id")
+            return None
+
+        buy_levels = min(levels, self._supported_levels("buy", center, min_notional)) if (mode == "active" and self._side_allowed("buy")) else (levels if self._side_allowed("buy") else 0)
+        sell_levels = min(levels, self._supported_levels("sell", center, min_notional)) if (mode == "active" and self._side_allowed("sell")) else (levels if self._side_allowed("sell") else 0)
+        buy_amount = self._suggest_amount("buy", center, max(buy_levels, 1), min_notional)
+        sell_amount = self._suggest_amount("sell", center, max(sell_levels, 1), min_notional)
+
+        for i in range(1, levels + 1):
+            buy_price = round(center * (1 - (step * i)), 8)
+            sell_price = round(center * (1 + (step * i)), 8)
+            if mode != "active":
+                orders.append({"side": "buy", "price": buy_price, "amount": buy_amount, "result": {"simulated": True}})
+                orders.append({"side": "sell", "price": sell_price, "amount": sell_amount, "result": {"simulated": True}})
+                self._log(f"plan level {i}: buy {buy_price} amount {buy_amount:.6g} / sell {sell_price} amount {sell_amount:.6g}")
+                continue
+
+            if i > buy_levels and i > sell_levels:
+                self._log(f"skip level {i}: no capacity on either side")
+                continue
+
+            min_size_buy = (min_notional / buy_price) if buy_price > 0 else 0.0
+            min_size_sell = (min_notional / sell_price) if sell_price > 0 else 0.0
+
+            try:
+                if i <= buy_levels and buy_amount >= min_size_buy:
+                    buy = self.context.place_order(side="buy", order_type="limit", amount=buy_amount, price=buy_price, market=market)
+                    orders.append({"side": "buy", "price": buy_price, "amount": buy_amount, "result": buy})
+                    buy_id = _capture_order_id(buy)
+                    if buy_id is not None:
+                        order_ids.append(str(buy_id))
+                if i <= sell_levels and sell_amount >= min_size_sell:
+                    sell = self.context.place_order(side="sell", order_type="limit", amount=sell_amount, price=sell_price, market=market)
+                    orders.append({"side": "sell", "price": sell_price, "amount": sell_amount, "result": sell})
+                    sell_id = _capture_order_id(sell)
+                    if sell_id is not None:
+                        order_ids.append(str(sell_id))
+                self._log(f"seed level {i}: buy {buy_price} amount {buy_amount:.6g} / sell {sell_price} amount {sell_amount:.6g}")
+            except Exception as exc:  # best effort for first draft
+                self.state["last_error"] = str(exc)
+                self._log(f"seed level {i} failed: {exc}")
+                continue
+
+            delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
+            if delay > 0:
+                time.sleep(delay)
+
+        self.state["orders"] = orders
+        self.state["order_ids"] = order_ids
+        self.state["last_action"] = "seeded grid"
+
+    def _cancel_orders(self, order_ids) -> None:
+        for order_id in order_ids or []:
+            self._log(f"dropping stale order {order_id} from state")
+
+    def on_tick(self, tick):
+        price = self._price()
+        self.state["last_price"] = price
+        self.state["last_error"] = ""
+
+        try:
+            open_orders = self.context.get_open_orders()
+            live_ids = []
+            if isinstance(open_orders, list):
+                for order in open_orders:
+                    if isinstance(order, dict):
+                        live_ids.append(str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or ""))
+            live_ids = [oid for oid in live_ids if oid]
+            open_order_count = len(live_ids)
+            expected_ids = [str(oid) for oid in (self.state.get("order_ids") or []) if oid]
+            stale_ids = [oid for oid in live_ids if oid not in expected_ids]
+            missing_ids = [oid for oid in expected_ids if oid not in live_ids]
+        except Exception as exc:
+            open_order_count = -1
+            live_ids = []
+            expected_ids = []
+            stale_ids = []
+            missing_ids = []
+            self.state["last_error"] = str(exc)
+            self._log(f"open orders check failed: {exc}")
+
+        # Workaround: after a reset, trust the fresh strategy state first.
+        # This prevents stale exec-mcp records from blocking the next clean test.
+        if not (self.state.get("order_ids") or []):
+            live_ids = []
+            open_order_count = 0
+            expected_ids = []
+            stale_ids = []
+            missing_ids = []
+
+        self.state["open_order_count"] = open_order_count
+
+        mode = self._mode()
+
+        if mode != "active":
+            if not self.state.get("seeded") or not self.state.get("center_price"):
+                self.state["center_price"] = price
+                self._place_grid(price)
+                self.state["seeded"] = True
+                self._log(f"planned grid at {price}")
+                return {"action": "plan", "price": price}
+
+            center = float(self.state.get("center_price") or price)
+            recenter_pct = float(self.config.get("recenter_pct", 0.05) or 0.05)
+            deviation = abs(price - center) / center if center else 0.0
+            if deviation >= recenter_pct:
+                self.state["center_price"] = price
+                self._place_grid(price)
+                self._log(f"planned recenter to {price}")
+                return {"action": "plan", "price": price, "deviation": deviation}
+
+            self.state["last_action"] = "observe monitor"
+            self._log(f"observe at {price} dev {deviation:.4f}")
+            return {"action": "observe", "price": price, "deviation": deviation}
+
+        if stale_ids:
+            self._log(f"stale live orders: {stale_ids}")
+            self._cancel_orders(stale_ids)
+            live_ids = [oid for oid in live_ids if oid not in stale_ids]
+
+        if missing_ids:
+            self._log(f"missing tracked orders: {missing_ids}")
+            self.state["order_ids"] = live_ids
+
+        if not self.state.get("seeded") or not self.state.get("center_price"):
+            self.state["center_price"] = price
+            self._place_grid(price)
+            self.state["seeded"] = True
+            mode = self._mode()
+            self._log(f"{'seeded' if mode == 'active' else 'planned'} grid at {price}")
+            return {"action": "seed" if mode == "active" else "plan", "price": price}
+
+        if open_order_count == 0 or (expected_ids and not set(expected_ids).intersection(set(live_ids))):
+            self._log("no open orders, reseeding grid")
+            self.state["center_price"] = price
+            self._place_grid(price)
+            mode = self._mode()
+            self.state["last_action"] = "reseeded" if mode == "active" else f"{mode} monitor"
+            return {"action": "reseed" if mode == "active" else "plan", "price": price}
+
+        center = float(self.state.get("center_price") or price)
+        recenter_pct = float(self.config.get("recenter_pct", 0.05) or 0.05)
+        deviation = abs(price - center) / center if center else 0.0
+
+        if deviation >= recenter_pct:
+            try:
+                self.context.cancel_all_orders()
+            except Exception as exc:
+                self.state["last_error"] = str(exc)
+            self.state["center_price"] = price
+            self._place_grid(price)
+            mode = self._mode()
+            self.state["last_action"] = "recentered" if mode == "active" else f"{mode} monitor"
+            self._log(f"recentered grid to {price}")
+            return {"action": "recenter" if mode == "active" else "plan", "price": price, "deviation": deviation}
+
+        mode = self._mode()
+        self.state["last_action"] = "hold" if mode == "active" else f"{mode} monitor"
+        self._log(f"hold at {price} dev {deviation:.4f}")
+        return {"action": "hold" if mode == "active" else "plan", "price": price, "deviation": deviation}
+
+    def render(self):
+        return {
+            "widgets": [
+                {"type": "metric", "label": "market", "value": self._market_symbol()},
+                {"type": "metric", "label": "center", "value": round(float(self.state.get("center_price") or 0.0), 6)},
+                {"type": "metric", "label": "last price", "value": round(float(self.state.get("last_price") or 0.0), 6)},
+                {"type": "metric", "label": "state", "value": self.state.get("last_action", "idle")},
+                {"type": "metric", "label": "orders", "value": len(self.state.get("orders") or [])},
+                {"type": "metric", "label": "open orders", "value": self.state.get("open_order_count", 0)},
+                {"type": "metric", "label": "ATR %", "value": round(float(self.state.get("atr_percent") or 0.0), 4)},
+                {"type": "metric", "label": "grid step %", "value": round(float(self.state.get("grid_step_pct") or 0.0) * 100.0, 4)},
+                {"type": "metric", "label": "1d", "value": ((self.state.get('regimes') or {}).get('1d') or {}).get('trend', {}).get('state', 'n/a')},
+                {"type": "metric", "label": "4h", "value": ((self.state.get('regimes') or {}).get('4h') or {}).get('trend', {}).get('state', 'n/a')},
+                {"type": "metric", "label": "1h", "value": ((self.state.get('regimes') or {}).get('1h') or {}).get('trend', {}).get('state', 'n/a')},
+                {"type": "metric", "label": "15m", "value": ((self.state.get('regimes') or {}).get('15m') or {}).get('trend', {}).get('state', 'n/a')},
+                {"type": "metric", "label": f"{self._base_symbol()} avail", "value": round(float(self.state.get("base_available") or 0.0), 8)},
+                {"type": "metric", "label": f"{self.context.counter_currency or 'USD'} avail", "value": round(float(self.state.get("counter_available") or 0.0), 8)},
+                {"type": "text", "label": "error", "value": self.state.get("last_error", "") or "none"},
+                {"type": "log", "label": "debug log", "lines": self.state.get("debug_log") or []},
+            ]
+        }