Prechádzať zdrojové kódy

Initial exec-mcp scaffold

Lukas Goldschmidt 1 mesiac pred
commit
4819fc5395

+ 36 - 0
.gitignore

@@ -0,0 +1,36 @@
+# Python
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+*.so
+.venv/
+venv/
+.env
+.env.*
+
+# FastMCP / app runtime
+logs/
+data/
+run/
+.tmp/
+
+# OS / editor
+.DS_Store
+Thumbs.db
+.vscode/
+.idea/
+
+# Build / coverage
+build/
+dist/
+htmlcov/
+.coverage
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+
+# Secrets
+*.key
+*.pem
+*.crt

+ 68 - 0
PROJECT.md

@@ -0,0 +1,68 @@
+# exec-mcp project outline
+
+## Purpose
+
+`exec-mcp` is the execution layer for the Trader27 stack.
+It sits between strategy logic and real exchange APIs, and is responsible for authenticated trading actions and exchange/account metadata.
+
+## Proposed architecture
+
+### 1. Data layer
+- Crypto MCP provides technical market data
+- News MCP provides sentiment and event context
+
+### 2. Strategy layer
+- Trader client runs the strategy loop
+- Can be a UI-backed controller rather than a pure MCP server
+- Chooses market, strategy, behavior, and risk settings
+- Consumes data from Crypto MCP and News MCP
+- Produces order intents
+
+### 3. Execution layer
+- Exec MCP owns exchange access tokens
+- Handles account configuration, routing, and exchange-specific metadata
+- Executes orders and maintains order lifecycle state
+- Supports query and cancellation flows
+
+## Responsibilities of exec-mcp
+
+- `list_accounts`, optionally filtered by exchange
+- `get_account_info(account_id)`
+- expose commission / fee data through account info
+- expose precision, decimals, minimum size, step size, and other exchange rules through account info
+- keep the public MCP surface read-only for now
+- support multiple exchanges
+- support multiple accounts and subaccounts per exchange
+- keep secrets out of the strategy/UI layers
+
+## Desired properties
+
+- FastMCP-native
+- modular exchange adapters
+- audit-friendly
+- safe around retries and partial fills
+- paper-trading capable
+- restart-safe state handling
+- easy to extend with new exchanges
+
+## So far
+
+We have a clear separation of concerns:
+
+- Crypto MCP = market data and technical insight
+- News MCP = sentiment and context
+- Trader = strategy logic and UI/control surface
+- Exec MCP = account and order execution
+
+The open design question is mostly implementation detail, not architecture viability.
+This appears sane, feasible, and worth further analysis.
+
+## Suggested next build items
+
+1. define the FastMCP server skeleton
+2. define account metadata models
+3. define order request / response schemas
+4. define exchange adapter interface
+5. add paper-trading / dry-run mode
+6. add logging and audit trail
+7. add tests around routing and order state

+ 56 - 0
README.md

@@ -0,0 +1,56 @@
+# exec-mcp
+
+Execution MCP for Trader27.
+
+This service is the order-and-account layer of the stack. It owns exchange access, account metadata, balances, fee schedules, precision/step rules, and the actual order lifecycle.
+
+## Role in the stack
+
+- **Crypto MCP**: market data, indicators, technical context
+- **News MCP**: sentiment, events, background context
+- **Trader / Strategy client**: strategy logic, UI, internal loop, decision making
+- **Exec MCP**: authenticated execution, account routing, order placement, query, cancel, metadata
+
+## What this server should do
+
+- List configured accounts, optionally filtered by exchange
+- Expose account metadata, balances, commissions, and exchange-specific limits
+- Keep the public MCP surface read-only for now
+- Use the dashboard for adding, updating, enabling, disabling, and deleting accounts
+- Store credentials separately from account metadata
+- Record balance snapshots over time internally
+- Support multiple exchanges and subaccounts
+- Keep credentials isolated from strategy and UI layers
+- Provide a stable API for strategy engines and operator UIs
+
+## Project goals
+
+- FastMCP-based service
+- Clear separation from strategy logic
+- Safe credential handling
+- Exchange-agnostic account abstraction
+- Paper-trading friendly architecture
+- Observability, auditability, and restart safety
+
+## Current status
+
+This folder is the starting scaffold. The architecture and intent are captured in `trader27.md` and will be expanded into:
+
+- server contract
+- account/exchange adapters
+- execution state model
+- logging and audit trail
+- test harness / paper trading mode
+
+## Dashboard shape
+
+- `GET /` shows a minimal landing page with links
+- `GET /dashboard` shows the HTML dashboard
+- the dashboard creates, updates, and deletes accounts with forms
+- the visible account reference is the exchange-side identifier, for example the Bitstamp numeric user id
+- the internal database primary key is separate and hidden from the web UI
+- the `name` field is the arbitrary label you choose for that account
+
+## Notes
+
+This project is intended to stay lightweight at the FastMCP boundary and push exchange-specific details into adapter modules.

+ 9 - 0
app.py

@@ -0,0 +1,9 @@
+from pathlib import Path
+import sys
+
+ROOT = Path(__file__).resolve().parent
+SRC = ROOT / "src"
+if str(SRC) not in sys.path:
+    sys.path.insert(0, str(SRC))
+
+from exec_mcp.server import app

+ 34 - 0
killserver.sh

@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PORT="${PORT:-8560}"
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$ROOT_DIR"
+
+echo "Killing exec-mcp listeners on port ${PORT}..."
+
+if command -v ss >/dev/null 2>&1; then
+  PIDS=$(ss -ltnp 2>/dev/null | awk -v port=":${PORT}" '$4 ~ port {print $NF}' | sed -E 's/users:\(\("([^"]+)".*pid=([0-9]+).*/\2/' | tr -d '"' || true)
+elif command -v lsof >/dev/null 2>&1; then
+  PIDS=$(lsof -t -iTCP:"${PORT}" -sTCP:LISTEN 2>/dev/null || true)
+else
+  echo "Neither 'ss' nor 'lsof' found; cannot auto-kill by port." >&2
+  exit 1
+fi
+
+if [[ -z "${PIDS:-}" ]]; then
+  echo "No listeners found on port ${PORT}."
+  exit 0
+fi
+
+echo "Found PIDs: ${PIDS}"
+PIDS_UNIQ=$(echo "$PIDS" | tr ' ' '\n' | awk 'NF' | sort -u)
+
+for pid in $PIDS_UNIQ; do
+  if [[ "$pid" =~ ^[0-9]+$ ]]; then
+    echo "Killing PID ${pid}..."
+    kill -9 "$pid" 2>/dev/null || true
+  fi
+done
+
+echo "Done."

+ 19 - 0
pyproject.toml

@@ -0,0 +1,19 @@
+[build-system]
+requires = ["setuptools>=68", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "exec-mcp"
+version = "0.1.0"
+description = "Execution MCP for Trader27"
+readme = "README.md"
+requires-python = ">=3.11"
+
+[project.scripts]
+exec-mcp = "exec_mcp.server:main"
+
+[tool.setuptools]
+package-dir = {"" = "src"}
+
+[tool.setuptools.packages.find]
+where = ["src"]

+ 4 - 0
requirements.txt

@@ -0,0 +1,4 @@
+fastapi>=0.110.0
+uvicorn[standard]>=0.23.0
+fastmcp>=2.11.3
+pytest>=8.0.0

+ 8 - 0
restart.sh

@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$ROOT_DIR"
+
+./killserver.sh
+./run.sh

+ 27 - 0
run.sh

@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PORT="${PORT:-8560}"
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$ROOT_DIR"
+
+echo "Starting exec-mcp on port ${PORT}..."
+
+mkdir -p logs
+
+LOG_FILE="logs/server.log"
+PID_FILE="logs/server.pid"
+
+if [[ -f .venv/bin/activate ]]; then
+  # shellcheck disable=SC1091
+  source .venv/bin/activate
+fi
+
+nohup uvicorn app:app \
+  --host 0.0.0.0 \
+  --port "${PORT}" \
+  >"${LOG_FILE}" 2>&1 &
+
+echo $! >"${PID_FILE}"
+
+echo "exec-mcp started (pid $(cat "${PID_FILE}")), logging to ${LOG_FILE}."

+ 1 - 0
src/exec_mcp/__init__.py

@@ -0,0 +1 @@
+"""exec-mcp package."""

+ 16 - 0
src/exec_mcp/adapters.py

@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(slots=True)
+class ExchangeAdapter:
+    exchange: str
+
+    def supports(self, exchange: str) -> bool:
+        return exchange == self.exchange
+
+
+class BitstampAdapter(ExchangeAdapter):
+    def __init__(self) -> None:
+        super().__init__(exchange="bitstamp")

+ 24 - 0
src/exec_mcp/bitstamp.py

@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(slots=True)
+class AccountInfo:
+    account_id: str
+    exchange: str
+    balance: float | None = None
+    decimals: int | None = None
+    fee_rate: float | None = None
+    metadata: dict | None = None
+
+
+class BitstampAdapter:
+    exchange = "bitstamp"
+
+    def get_account_info(self, account_id: str) -> AccountInfo:
+        return AccountInfo(
+            account_id=account_id,
+            exchange=self.exchange,
+            metadata={"note": "bitstamp adapter stub"},
+        )

+ 394 - 0
src/exec_mcp/server.py

@@ -0,0 +1,394 @@
+from __future__ import annotations
+
+from contextlib import asynccontextmanager
+from datetime import datetime, timezone
+from uuid import uuid4
+
+from fastapi import FastAPI, Form, HTTPException
+from fastapi.responses import HTMLResponse, RedirectResponse
+from fastmcp import FastMCP
+from pydantic import BaseModel, Field
+
+from .storage import get_connection, init_db
+
+# Public MCP surface stays read-only.
+mcp = FastMCP("exec-mcp")
+
+
+@asynccontextmanager
+async def lifespan(_: FastAPI):
+    # Initialize the local SQLite scaffold on startup.
+    init_db()
+    yield
+
+
+app = FastAPI(title="exec-mcp", lifespan=lifespan)
+SUPPORTED_VENUES = {"bitstamp"}
+
+
+class AccountView(BaseModel):
+    display_name: str
+    venue: str
+    venue_account_ref: str
+    description: str | None = None
+    enabled: bool
+    metadata: str = Field(default="{}")
+    created_at: str | None = None
+    updated_at: str | None = None
+
+
+@app.get("/", response_class=HTMLResponse)
+def http_root() -> str:
+    return """
+    <html><body style="font-family: system-ui, sans-serif; max-width: 900px; margin: 32px auto; padding: 0 16px;">
+    <header style="margin-bottom: 24px;">
+      <h1>exec-mcp</h1>
+    </header>
+    <main>
+      <p>Execution layer for Trader27, with a dashboard for account management.</p>
+      <ul>
+        <li><a href="/dashboard">Dashboard</a></li>
+        <li><a href="/health">/health</a></li>
+      </ul>
+    </main>
+    <footer style="margin-top: 32px; padding-top: 12px; border-top: 1px solid #e5e7eb; color: #6b7280;">
+      exec-mcp
+    </footer>
+    </body></html>
+    """
+
+
+@app.get("/health")
+def http_health() -> dict:
+    return {"ok": True, "server": "exec-mcp"}
+
+
+@app.get("/dashboard", response_class=HTMLResponse)
+def http_dashboard() -> str:
+    rows = list_accounts()
+    options = "".join(f'<option value="{v}">{v}</option>' for v in sorted(SUPPORTED_VENUES))
+    table_rows = "".join(
+        f"""
+        <tr>
+          <td>{row['display_name']}</td>
+          <td>{row['venue']}</td>
+          <td>{row['venue_account_ref']}</td>
+          <td>{row['description'] or ''}</td>
+          <td>{'yes' if row['enabled'] else 'no'}</td>
+          <td class="actions">
+            <form method="get" action="/dashboard/accounts/{row['venue']}/{row['venue_account_ref']}/edit">
+              <button type="submit">Edit</button>
+            </form>
+            <form method="post" action="/dashboard/accounts/{row['venue']}/{row['venue_account_ref']}/delete">
+              <button type="submit" class="danger">Delete</button>
+            </form>
+          </td>
+        </tr>
+        """
+        for row in rows
+    )
+    return f"""
+    <html>
+      <head>
+        <style>
+          body {{ font-family: system-ui, sans-serif; max-width: 1100px; margin: 32px auto; padding: 0 16px; color: #1f2937; }}
+          header, footer {{ padding: 12px 0; }}
+          header {{ border-bottom: 1px solid #e5e7eb; margin-bottom: 20px; }}
+          footer {{ border-top: 1px solid #e5e7eb; margin-top: 32px; color: #6b7280; }}
+          .panel {{ background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; margin-bottom: 20px; }}
+          form {{ display: grid; gap: 10px; max-width: 520px; }}
+          input, select, button {{ padding: 10px 12px; border-radius: 8px; border: 1px solid #cbd5e1; }}
+          button {{ background: #111827; color: white; cursor: pointer; }}
+          button.danger {{ background: #991b1b; }}
+          table {{ width: 100%; border-collapse: collapse; margin-top: 12px; }}
+          th, td {{ border-bottom: 1px solid #e5e7eb; padding: 10px 8px; text-align: left; vertical-align: top; }}
+          .actions {{ display: flex; gap: 8px; align-items: center; }}
+          .actions form {{ display: inline; }}
+          .actions a {{ color: #1d4ed8; text-decoration: none; }}
+        </style>
+      </head>
+      <body>
+        <header>
+          <h1>exec-mcp dashboard</h1>
+        </header>
+
+        <div class="panel">
+          <h2>Create account</h2>
+          <form method="post" action="/dashboard/accounts/create">
+            <input name="display_name" placeholder="name" required />
+            <select name="venue">{options}</select>
+            <input name="venue_account_ref" placeholder="exchange account ref" required />
+            <input name="api_key" placeholder="api key" required />
+            <input name="api_secret" placeholder="api secret" required />
+            <input name="description" placeholder="description" />
+            <label><input type="checkbox" name="enabled" checked /> enabled</label>
+            <button type="submit">Create</button>
+          </form>
+        </div>
+
+        <div class="panel">
+          <h2>Accounts</h2>
+          <table>
+            <tr>
+              <th>name</th><th>venue</th><th>exchange account ref</th><th>description</th><th>enabled</th><th>actions</th>
+            </tr>
+            {table_rows}
+          </table>
+        </div>
+
+        <footer>
+          exec-mcp
+        </footer>
+      </body>
+    </html>
+    """
+
+
+@app.post("/dashboard/accounts/create")
+def http_dashboard_create_account(
+    display_name: str = Form(...),
+    venue: str = Form(...),
+    venue_account_ref: str = Form(...),
+    api_key: str = Form(...),
+    api_secret: str = Form(...),
+    description: str | None = Form(None),
+    enabled: bool = Form(False),
+) -> RedirectResponse:
+    create_account(
+        display_name=display_name,
+        venue=venue,
+        venue_account_ref=venue_account_ref,
+        api_key=api_key,
+        api_secret=api_secret,
+        description=description,
+        enabled=enabled,
+    )
+    return RedirectResponse(url="/dashboard", status_code=303)
+
+
+@app.get("/dashboard/accounts/{venue}/{venue_account_ref}/edit", response_class=HTMLResponse)
+def http_dashboard_edit_account(venue: str, venue_account_ref: str) -> str:
+    with get_connection() as conn:
+        row = conn.execute(
+            "SELECT display_name, venue, venue_account_ref, description, enabled FROM accounts WHERE venue = ? AND venue_account_ref = ?",
+            (venue, venue_account_ref),
+        ).fetchone()
+    if row is None:
+        raise HTTPException(status_code=404, detail="account not found")
+    checked = "checked" if row["enabled"] else ""
+    return f"""
+    <html>
+      <body style="font-family: system-ui, sans-serif; max-width: 700px; margin: 32px auto; padding: 0 16px;">
+        <h1>Edit account</h1>
+        <p><a href="/dashboard">Back to dashboard</a></p>
+        <form method="post" action="/dashboard/accounts/{venue}/{venue_account_ref}/update" style="display:grid; gap:10px; max-width: 520px;">
+          <input name="display_name" value="{row['display_name']}" placeholder="name" required />
+          <input value="{row['venue_account_ref']}" placeholder="exchange account ref" readonly />
+          <input name="description" value="{row['description'] or ''}" placeholder="description" />
+          <label><input type="checkbox" name="enabled" {checked} /> enabled</label>
+          <button type="submit">Save</button>
+        </form>
+      </body>
+    </html>
+    """
+
+
+@app.post("/dashboard/accounts/{venue}/{venue_account_ref}/update")
+def http_dashboard_update_account(
+    venue: str,
+    venue_account_ref: str,
+    display_name: str = Form(...),
+    description: str = Form(""),
+    enabled: bool = Form(False),
+) -> RedirectResponse:
+    update_account(venue=venue, venue_account_ref=venue_account_ref, display_name=display_name, description=description or None, enabled=enabled)
+    return RedirectResponse(url="/dashboard", status_code=303)
+
+
+@app.post("/dashboard/accounts/{venue}/{venue_account_ref}/delete")
+def http_dashboard_delete_account(venue: str, venue_account_ref: str) -> RedirectResponse:
+    delete_account(venue=venue, venue_account_ref=venue_account_ref)
+    return RedirectResponse(url="/dashboard", status_code=303)
+
+
+@mcp.tool()
+def list_accounts(venue: str | None = None) -> list[dict]:
+    query = "SELECT display_name, venue, venue_account_ref, description, enabled, metadata_json, created_at, updated_at FROM accounts"
+    params: tuple = ()
+    if venue:
+        query += " WHERE venue = ?"
+        params = (venue,)
+    query += " ORDER BY display_name ASC"
+
+    with get_connection() as conn:
+        rows = conn.execute(query, params).fetchall()
+
+    return [
+        {
+            "display_name": row["display_name"],
+            "venue": row["venue"],
+            "venue_account_ref": row["venue_account_ref"],
+            "description": row["description"],
+            "enabled": bool(row["enabled"]),
+            "metadata": row["metadata_json"],
+            "created_at": row["created_at"],
+            "updated_at": row["updated_at"],
+        }
+        for row in rows
+    ]
+
+
+@mcp.tool()
+def get_account_info(venue: str, venue_account_ref: str) -> dict:
+    with get_connection() as conn:
+        account = conn.execute(
+            "SELECT id, display_name, venue, venue_account_ref, description, enabled, metadata_json FROM accounts WHERE venue = ? AND venue_account_ref = ?",
+            (venue, venue_account_ref),
+        ).fetchone()
+        secrets = None
+        if account is not None:
+            secrets = conn.execute(
+                "SELECT api_key, api_secret FROM account_secrets WHERE account_id = ?",
+                (account["id"],),
+            ).fetchone()
+
+    if account is None:
+        raise HTTPException(status_code=404, detail="account not found")
+
+    # Do not expose the hidden internal primary key or raw secret material.
+    return {
+        "display_name": account["display_name"],
+        "venue": account["venue"],
+        "venue_account_ref": account["venue_account_ref"],
+        "description": account["description"],
+        "enabled": bool(account["enabled"]),
+        "metadata": account["metadata_json"],
+        "api_key_present": secrets is not None,
+        "api_secret_present": secrets is not None,
+    }
+
+
+def create_account(
+    display_name: str,
+    venue: str,
+    venue_account_ref: str,
+    api_key: str,
+    api_secret: str,
+    description: str | None = None,
+    enabled: bool = True,
+) -> dict:
+    if venue not in SUPPORTED_VENUES:
+        raise HTTPException(status_code=400, detail="unsupported venue")
+
+    account_pk = str(uuid4())
+    now = datetime.now(timezone.utc).isoformat()
+    with get_connection() as conn:
+        conn.execute(
+            """
+            INSERT INTO accounts (id, display_name, venue, venue_account_ref, description, enabled, created_at, updated_at)
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+            ON CONFLICT(venue, venue_account_ref) DO UPDATE SET
+                display_name=excluded.display_name,
+                description=excluded.description,
+                enabled=excluded.enabled,
+                updated_at=excluded.updated_at
+            """,
+            (account_pk, display_name, venue, venue_account_ref, description, int(enabled), now, now),
+        )
+        account = conn.execute(
+            "SELECT id FROM accounts WHERE venue = ? AND venue_account_ref = ?",
+            (venue, venue_account_ref),
+        ).fetchone()
+        conn.execute(
+            """
+            INSERT INTO account_secrets (account_id, api_key, api_secret, created_at, updated_at)
+            VALUES (?, ?, ?, ?, ?)
+            ON CONFLICT(account_id) DO UPDATE SET
+                api_key=excluded.api_key,
+                api_secret=excluded.api_secret,
+                updated_at=excluded.updated_at
+            """,
+            (account["id"], api_key, api_secret, now, now),
+        )
+        conn.commit()
+
+    return {
+        "display_name": display_name,
+        "venue": venue,
+        "venue_account_ref": venue_account_ref,
+        "enabled": enabled,
+    }
+
+
+def update_account(
+    venue: str,
+    venue_account_ref: str,
+    display_name: str | None = None,
+    description: str | None = None,
+    enabled: bool | None = None,
+) -> dict:
+    now = datetime.now(timezone.utc).isoformat()
+    with get_connection() as conn:
+        row = conn.execute(
+            "SELECT id FROM accounts WHERE venue = ? AND venue_account_ref = ?",
+            (venue, venue_account_ref),
+        ).fetchone()
+        if row is None:
+            raise HTTPException(status_code=404, detail="account not found")
+        if display_name is not None:
+            conn.execute(
+                "UPDATE accounts SET display_name = ?, updated_at = ? WHERE id = ?",
+                (display_name, now, row["id"]),
+            )
+        if description is not None:
+            conn.execute(
+                "UPDATE accounts SET description = ?, updated_at = ? WHERE id = ?",
+                (description, now, row["id"]),
+            )
+        if enabled is not None:
+            conn.execute(
+                "UPDATE accounts SET enabled = ?, updated_at = ? WHERE id = ?",
+                (int(enabled), now, row["id"]),
+            )
+        conn.commit()
+    return {"venue": venue, "venue_account_ref": venue_account_ref, "updated": True}
+
+
+def delete_account(venue: str, venue_account_ref: str) -> dict:
+    with get_connection() as conn:
+        deleted = conn.execute(
+            "DELETE FROM accounts WHERE venue = ? AND venue_account_ref = ?",
+            (venue, venue_account_ref),
+        ).rowcount
+        conn.commit()
+    if not deleted:
+        raise HTTPException(status_code=404, detail="account not found")
+    return {"venue": venue, "venue_account_ref": venue_account_ref, "deleted": True}
+
+
+def record_balance(venue: str, venue_account_ref: str, asset_code: str, balance_value: float) -> dict:
+    # Internal helper, called by execution sync logic when balances change.
+    now = datetime.now(timezone.utc).isoformat()
+    with get_connection() as conn:
+        account = conn.execute(
+            "SELECT id FROM accounts WHERE venue = ? AND venue_account_ref = ?",
+            (venue, venue_account_ref),
+        ).fetchone()
+        if account is None:
+            raise HTTPException(status_code=404, detail="account not found")
+        conn.execute(
+            "INSERT INTO balance_snapshots (account_id, asset_code, balance_value, captured_at) VALUES (?, ?, ?, ?)",
+            (account["id"], asset_code, balance_value, now),
+        )
+        conn.commit()
+    return {
+        "venue": venue,
+        "venue_account_ref": venue_account_ref,
+        "asset_code": asset_code,
+        "balance_value": balance_value,
+        "captured_at": now,
+    }
+
+
+def main() -> None:
+    init_db()

+ 69 - 0
src/exec_mcp/storage.py

@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+import sqlite3
+from pathlib import Path
+
+DB_PATH = Path(__file__).resolve().parents[3] / "data" / "exec_mcp.sqlite3"
+
+
+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:
+        # Stable final schema for the app foundation.
+        conn.executescript(
+            """
+            CREATE TABLE IF NOT EXISTS accounts (
+                id TEXT PRIMARY KEY,
+                display_name TEXT NOT NULL,
+                venue TEXT NOT NULL,
+                venue_account_ref TEXT NOT NULL,
+                description TEXT,
+                enabled INTEGER NOT NULL DEFAULT 1,
+                metadata_json TEXT NOT NULL DEFAULT '{}',
+                created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                UNIQUE(venue, venue_account_ref)
+            );
+
+            CREATE TABLE IF NOT EXISTS account_secrets (
+                account_id TEXT PRIMARY KEY,
+                api_key TEXT NOT NULL,
+                api_secret TEXT NOT NULL,
+                created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE
+            );
+
+            CREATE TABLE IF NOT EXISTS balance_snapshots (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                account_id TEXT NOT NULL,
+                asset_code TEXT NOT NULL,
+                balance_value REAL NOT NULL,
+                captured_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE
+            );
+
+            CREATE TABLE IF NOT EXISTS order_records (
+                id TEXT PRIMARY KEY,
+                account_id TEXT NOT NULL,
+                instrument TEXT NOT NULL,
+                side TEXT NOT NULL,
+                order_kind TEXT NOT NULL,
+                quantity REAL NOT NULL,
+                price REAL,
+                status TEXT NOT NULL,
+                created_at TEXT NOT NULL,
+                updated_at TEXT NOT NULL,
+                raw_json TEXT NOT NULL DEFAULT '{}',
+                FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE
+            );
+            """
+        )
+        conn.commit()

+ 16 - 0
tests.sh

@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$ROOT_DIR"
+
+echo "Running tests (pytest)..."
+
+if command -v pytest >/dev/null 2>&1; then
+  exec pytest -q
+elif command -v python >/dev/null 2>&1; then
+  exec python -m pytest -q
+else
+  echo "pytest/python not found in PATH. Install dependencies and retry." >&2
+  exit 1
+fi

+ 8 - 0
tests/conftest.py

@@ -0,0 +1,8 @@
+from pathlib import Path
+import sys
+
+# Ensure src/ layout imports work in local pytest runs.
+ROOT = Path(__file__).resolve().parents[1]
+SRC = ROOT / "src"
+if str(SRC) not in sys.path:
+    sys.path.insert(0, str(SRC))

+ 44 - 0
tests/test_dashboard.py

@@ -0,0 +1,44 @@
+from fastapi.testclient import TestClient
+
+from exec_mcp.server import app
+
+client = TestClient(app)
+
+
+def test_health():
+    resp = client.get('/health')
+    assert resp.status_code == 200
+    assert resp.json() == {'ok': True, 'server': 'exec-mcp'}
+
+
+def test_dashboard_account_crud_roundtrip():
+    payload = {
+        'display_name': 'Bitstamp Main',
+        'venue': 'bitstamp',
+        'venue_account_ref': '123456',
+        'api_key': 'key-123',
+        'api_secret': 'secret-123',
+        'description': 'primary account',
+        'enabled': 'on',
+    }
+
+    resp = client.post('/dashboard/accounts/create', data=payload, follow_redirects=True)
+    assert resp.status_code == 200, resp.text
+    assert 'Bitstamp Main' in resp.text
+    assert '123456' in resp.text
+
+    resp = client.get('/dashboard/accounts/bitstamp/123456/edit')
+    assert resp.status_code == 200
+    assert 'Edit account' in resp.text
+
+    resp = client.post(
+        '/dashboard/accounts/bitstamp/123456/update',
+        data={'display_name': 'Bitstamp Main', 'description': 'updated', 'enabled': 'on'},
+        follow_redirects=True,
+    )
+    assert resp.status_code == 200
+    assert 'updated' in resp.text
+
+    resp = client.post('/dashboard/accounts/bitstamp/123456/delete', follow_redirects=True)
+    assert resp.status_code == 200
+    assert '123456' not in resp.text