소스 검색

Sync trader-mcp docs and dashboard

Lukas Goldschmidt 1 개월 전
부모
커밋
6ed4438908

+ 8 - 1
README.md

@@ -1,6 +1,6 @@
 # Trader MCP
 
-MCP server for trading-related helper functions (read-only by default where possible).
+MCP server for trading-related helper functions, with a dashboard for accounts and strategies.
 
 ## Endpoints
 - `GET /` - landing page
@@ -10,5 +10,12 @@ MCP server for trading-related helper functions (read-only by default where poss
 ## MCP
 Implements a small, read-oriented MCP surface under `/mcp`.
 
+## Dashboard
+- accounts section is collapsed by default
+- strategies table stays visible
+- per-strategy details expand below the row
+- live render panels update automatically
+- config is editable inline in the detail row
+
 ## Development
 See `run.sh` / `tests.sh` in this folder.

+ 2 - 0
Strategy_concepts_0.md

@@ -43,6 +43,7 @@ Each strategy is a Python class extending `Strategy`.
 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}
@@ -198,6 +199,7 @@ 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

+ 12 - 4
Strategy_concepts_1.md

@@ -95,9 +95,17 @@ It is a runtime freeze state controlled by the engine.
 
 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 should reconcile persisted records against runtime instances when records change or at startup.
+The engine reconciles persisted records against runtime instances when records change and at startup.
 
 ```python
 def reconcile():
@@ -120,7 +128,7 @@ load_instance(updated_record)
 
 This is the clean default because it is deterministic and easy to debug.
 
-Selective state carryover can exist later, but reload is the default.
+Selective state carryover is not part of the current model, reload is the default.
 
 ## 7. Engine Responsibilities
 
@@ -135,7 +143,7 @@ The engine should:
 
 ## 8. Dashboard Responsibilities
 
-The dashboard should show:
+The dashboard shows:
 
 - identity
 - config
@@ -143,7 +151,7 @@ The dashboard should show:
 - runtime status
 - rendered widgets
 
-It should not own trading logic.
+It does not own trading logic.
 
 ## 9. Recommended Direction
 

+ 1 - 0
Strategy_concepts_examples.md

@@ -6,6 +6,7 @@
 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}

+ 118 - 8
src/trader_mcp/dashboard.py

@@ -7,9 +7,9 @@ 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_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_store import add_strategy_instance, delete_strategy_instance, list_strategy_instances, synthesize_client_id, 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
 
 router = APIRouter(prefix="/dashboard", tags=["dashboard"])
 
@@ -61,12 +61,28 @@ def dashboard_home():
           <td>{mode}</td>
           <td>{config}</td>
           <td class="actions">
+            <button type="button" class="ghost" onclick="toggleDetails('{id}')">Details</button>
             <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>
+        <tr id="details-{id}" class="detail-row" style="display:none;">
+          <td colspan="7">
+            <div style="margin-top:10px; display:grid; gap:12px;">
+              <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>
+              <form method="post" action="/dashboard/strategies/{id}/config" style="display:grid; gap:8px;">
+                <strong>Edit config</strong>
+                <textarea name="config_json" rows="6" style="width:100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;">{config_json}</textarea>
+                <button type="submit">Save config</button>
+              </form>
+            </div>
+          </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,
@@ -75,6 +91,7 @@ def dashboard_home():
             account_name=account_lookup.get(s.account_id, s.account_id),
             mode=s.mode,
             config=s.config,
+            config_json=json.dumps(s.config, indent=2, sort_keys=True),
             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 "",
@@ -100,14 +117,13 @@ 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; }}
-      details {{ margin: 14px 0; }}
-      summary {{ cursor: pointer; font-weight: 600; }}
-      .actions {{ display: flex; gap: 8px; flex-wrap: wrap; }}
+      .actions {{ display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }}
       .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; }}
+      input, select, textarea {{ padding: 8px 10px; border-radius: 8px; border: 1px solid #d1d5db; }}
+      .render-panel {{ min-height: 24px; }}
     </style>
   </head>
   <body>
@@ -142,7 +158,7 @@ def dashboard_home():
         </table>
       </section>
 
-      <details>
+      <details id="accounts-section">
         <summary>Accounts</summary>
         <p class="muted">exec-mcp accounts</p>
 
@@ -160,6 +176,87 @@ def dashboard_home():
 
       <p class="muted" style="margin-top: 12px;">Source: <span class="pill">exec-mcp.list_accounts</span></p>
     </div>
+
+    <script>
+      function renderWidget(widget) {{
+        if (!widget || !widget.type) return '';
+        if (widget.type === 'metric') {{
+          return `<div style="display:inline-block; min-width:140px; padding:10px 12px; margin:6px 8px 6px 0; background:#fff; border:1px solid #dbe1e8; border-radius:10px;"><div class="muted" style="font-size:12px; margin-bottom:4px;">${{widget.label || 'metric'}}</div><div style="font-size:20px; font-weight:700;">${{widget.value ?? ''}}</div></div>`;
+        }}
+        if (widget.type === 'text') {{
+          return `<div style="padding:8px 0;">${{widget.label ? `<strong>${{widget.label}}:</strong> ` : ''}}${{widget.value ?? ''}}</div>`;
+        }}
+        if (widget.type === 'line_chart') {{
+          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>`;
+        }}
+        return `<pre style="white-space:pre-wrap; margin:0;">${{JSON.stringify(widget, null, 2)}}</pre>`;
+      }}
+
+      async function refreshRender(panel) {{
+        const id = panel.dataset.strategyId;
+        try {{
+          const res = await fetch(`/dashboard/strategies/${{encodeURIComponent(id)}}/render`);
+          const data = await res.json();
+          if (data.paused) {{
+            panel.innerHTML = '<div class="muted">paused</div>';
+            return;
+          }}
+          const render = data.render;
+          if (!render || !render.widgets || !render.widgets.length) {{
+            panel.innerHTML = '<div class="muted">(no render)</div>';
+            return;
+          }}
+          panel.innerHTML = render.widgets.map(renderWidget).join('');
+        }} catch (err) {{
+          panel.innerHTML = '<div class="muted">(render unavailable)</div>';
+        }}
+      }}
+
+      function refreshAll() {{
+        document.querySelectorAll('.render-panel').forEach(refreshRender);
+      }}
+
+      function toggleDetails(id) {{
+        const row = document.getElementById(`details-${{id}}`);
+        if (!row) return;
+        row.style.display = row.style.display === 'none' ? 'table-row' : 'none';
+        localStorage.setItem(`trader-mcp:detail:${{id}}`, row.style.display === 'table-row' ? 'open' : 'closed');
+        if (row.style.display === 'table-row') {{
+          const panel = row.querySelector('.render-panel');
+          if (panel) refreshRender(panel);
+        }}
+      }}
+
+      function restoreDetails() {{
+        document.querySelectorAll('.detail-row').forEach((row) => {{
+          const id = row.id.replace(/^details-/, '');
+          const open = localStorage.getItem(`trader-mcp:detail:${{id}}`) === 'open';
+          row.style.display = open ? 'table-row' : 'none';
+          if (open) {{
+            const panel = row.querySelector('.render-panel');
+            if (panel) refreshRender(panel);
+          }}
+        }});
+
+        const accounts = document.getElementById('accounts-section');
+        if (accounts) {{
+          const open = localStorage.getItem('trader-mcp:accounts-section') === 'open';
+          accounts.open = open;
+        }}
+      }}
+
+      const accountsSection = document.getElementById('accounts-section');
+      if (accountsSection) {{
+        accountsSection.addEventListener('toggle', () => {{
+          localStorage.setItem('trader-mcp:accounts-section', accountsSection.open ? 'open' : 'closed');
+        }});
+      }}
+
+      restoreDetails();
+      refreshAll();
+      setInterval(refreshAll, 6000);
+    </script>
   </body>
 </html>"""
 
@@ -173,7 +270,7 @@ def dashboard_strategies_add(
     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(
+    add_strategy_instance(
         id=strategy_id,
         strategy_type=strategy_type.strip(),
         account_id=account_id.strip(),
@@ -227,3 +324,16 @@ def dashboard_strategies_pause(strategy_id: str):
     else:
         pause_strategy(strategy_id)
     return RedirectResponse(url="/dashboard", status_code=303)
+
+
+@router.post("/strategies/{strategy_id}/config")
+def dashboard_strategies_config(strategy_id: str, config_json: str = Form(...)):
+    config = json.loads(config_json) if config_json.strip() else {}
+    update_strategy_config(strategy_id, config)
+    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)

+ 25 - 3
src/trader_mcp/server.py

@@ -1,7 +1,10 @@
+from contextlib import asynccontextmanager
+import asyncio
+
 from fastapi import FastAPI
 
 from .dashboard import router as dashboard_router
-from .strategy_engine import reconcile_all, reconcile_instance
+from .strategy_engine import reconcile_all, reconcile_instance, run_due_ticks
 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
 
@@ -10,7 +13,26 @@ try:
 except ImportError:  # pragma: no cover
     FastMCP = None
 
-app = FastAPI(title="Trader MCP")
+
+async def _tick_loop(stop_event: asyncio.Event) -> None:
+    while not stop_event.is_set():
+        run_due_ticks()
+        await asyncio.sleep(1)
+
+
+@asynccontextmanager
+async def lifespan(_: FastAPI):
+    stop_event = asyncio.Event()
+    reconcile_all()
+    tick_task = asyncio.create_task(_tick_loop(stop_event))
+    try:
+        yield
+    finally:
+        stop_event.set()
+        tick_task.cancel()
+
+
+app = FastAPI(title="Trader MCP", lifespan=lifespan)
 app.include_router(dashboard_router)
 
 
@@ -84,7 +106,7 @@ if FastMCP is not None:
     # Minimal public surface for now; expand once requirements are clear.
     # Keep it read-oriented by default.
 
-    # FastMCP exposes an ASGI app via `http_app` (older/newer versions may differ).
+    # FastMCP exposes an ASGI app via `http_app` (older/newer versions may differ). 
     mcp_asgi = getattr(mcp, "http_app", None) or getattr(mcp, "app", None) or getattr(mcp, "asgi_app", None)
     if mcp_asgi is None:
         raise AttributeError("FastMCP ASGI app attribute not found (expected http_app/app/asgi_app).")

+ 23 - 2
src/trader_mcp/strategy_engine.py

@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 from dataclasses import dataclass
+import time
 from typing import Any
 
 from .strategy_context import StrategyContext
@@ -14,6 +15,8 @@ class RuntimeStrategy:
     record: StrategyRecord
     instance: Strategy
     paused: bool = False
+    tick_minutes: float = 1.0
+    next_tick_at: float = 0.0
 
 
 _running: dict[str, RuntimeStrategy] = {}
@@ -50,6 +53,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)
+    runtime.next_tick_at = time.time() + (runtime.tick_minutes * 60.0)
     return {"ok": True, "id": instance_id, "result": result}
 
 
@@ -79,7 +83,7 @@ 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
-        _running[instance_id] = RuntimeStrategy(record=record, instance=_instantiate(record))
+        _running[instance_id] = _make_runtime(record)
         loaded.append(instance_id)
 
     return {"loaded": loaded, "unloaded": unloaded, "running": running_strategy_ids()}
@@ -91,10 +95,21 @@ def reconcile_instance(instance_id: str) -> dict[str, Any]:
         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))
+    _running[instance_id] = _make_runtime(record)
     return {"loaded": True, "unloaded": False, "running": running_strategy_ids()}
 
 
+def run_due_ticks(now: float | None = None) -> dict[str, Any]:
+    now = time.time() if now is None else now
+    ticked: list[str] = []
+    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})
+        ticked.append(instance_id)
+    return {"ok": True, "ticked": ticked, "running": running_strategy_ids()}
+
+
 def _instantiate(record: StrategyRecord) -> Strategy:
     module = load_strategy_module(record.strategy_type)
     strategy_cls = getattr(module, "Strategy", None)
@@ -103,3 +118,9 @@ def _instantiate(record: StrategyRecord) -> Strategy:
 
     context = StrategyContext(id=record.id, account_id=record.account_id, client_id=record.client_id)
     return strategy_cls(context=context, config=record.config)
+
+
+def _make_runtime(record: StrategyRecord) -> RuntimeStrategy:
+    instance = _instantiate(record)
+    tick_minutes = float(getattr(instance, "TICK_MINUTES", 1.0) or 1.0)
+    return RuntimeStrategy(record=record, instance=instance, paused=False, tick_minutes=tick_minutes, next_tick_at=time.time() + (tick_minutes * 60.0))

+ 1 - 0
src/trader_mcp/strategy_sdk.py

@@ -5,6 +5,7 @@ from typing import Any
 
 class Strategy:
     CONFIG_SCHEMA: dict[str, Any] = {}
+    TICK_MINUTES: float = 1.0
 
     def __init__(self, context, config):
         self.context = context

+ 1 - 0
strategies/hello_world.py

@@ -4,6 +4,7 @@ from src.trader_mcp.strategy_sdk import Strategy
 
 
 class Strategy(Strategy):
+    TICK_MINUTES = 0.2
     CONFIG_SCHEMA = {
         "label": {"type": "string", "default": "hello world"},
     }

+ 78 - 0
tests/test_engine.py

@@ -26,6 +26,21 @@ class Strategy(Strategy):
         return {"widgets": [{"type": "metric", "label": "ticks", "value": self.state.get("ticks", 0)}]}
 '''
 
+COUNTER_STRATEGY_CODE = '''
+from src.trader_mcp.strategy_sdk import Strategy
+
+class Strategy(Strategy):
+    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": "metric", "label": "ticks", "value": self.state["counter"]}]}
+'''
+
 
 def test_mode_off_does_not_instantiate_and_active_does(tmp_path):
     original_db = strategy_store.DB_PATH
@@ -143,6 +158,30 @@ def test_runtime_pause_suppresses_tick_and_render(tmp_path):
         strategy_engine._running.clear()
 
 
+def test_run_due_ticks_triggers_tick_when_due(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(COUNTER_STRATEGY_CODE)
+
+        strategy_store.add_strategy_instance(id="s4", strategy_type="hello_world", account_id="acct-1", client_id="cid-1", mode="active", config={})
+        strategy_engine.reconcile_all()
+        runtime = strategy_engine.get_running_strategy("s4")
+        assert runtime is not None
+        runtime.next_tick_at = 0.0
+
+        result = strategy_engine.run_due_ticks(now=1.0)
+        assert "s4" in result["ticked"]
+        assert strategy_engine.get_running_strategy("s4").instance.state["counter"] == 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
@@ -173,3 +212,42 @@ def test_dashboard_pause_toggle(tmp_path):
         strategy_store.DB_PATH = original_db
         strategy_registry.STRATEGIES_DIR = original_dir
         strategy_engine._running.clear()
+
+
+def test_dashboard_detail_panel_shows_render_and_saves_config(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": "Render 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)
+
+        page = client.get("/dashboard/")
+        assert page.status_code == 200
+        render_response = client.get(f"/dashboard/strategies/{strategy_id}/render")
+        assert render_response.status_code == 200
+        assert render_response.json()["render"]["widgets"][0]["label"] == "ticks"
+
+        client.post(
+            f"/dashboard/strategies/{strategy_id}/config",
+            data={"config_json": '{"label": "changed label"}'},
+            follow_redirects=False,
+        )
+        record = strategy_store.get_strategy_instance(strategy_id)
+        assert record is not None
+        assert record.config == {"label": "changed label"}
+    finally:
+        strategy_store.DB_PATH = original_db
+        strategy_registry.STRATEGIES_DIR = original_dir
+        strategy_engine._running.clear()