Lukas Goldschmidt 1 hónapja
szülő
commit
13c3041b0a

+ 15 - 0
src/exec_mcp/models.py

@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+
+
+class AccountView(BaseModel):
+    id: str
+    display_name: str | None = None
+    venue: str | None = None
+    venue_account_ref: str | None = None
+    description: str | None = None
+    enabled: bool
+    metadata: str = Field(default="{}")
+    created_at: str | None = None
+    updated_at: str | None = None

+ 179 - 0
src/exec_mcp/repo.py

@@ -0,0 +1,179 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime, timezone
+from sqlite3 import IntegrityError
+from uuid import uuid4
+
+from fastapi import HTTPException
+
+from .storage import get_connection
+
+
+def utc_now_iso() -> str:
+    return datetime.now(timezone.utc).isoformat()
+
+
+def list_accounts(*, venue: str | None = None, enabled_only: bool = True) -> list[dict]:
+    query = "SELECT id, display_name, venue, venue_account_ref, description, enabled, metadata_json, created_at, updated_at FROM accounts"
+    clauses: list[str] = []
+    params: list[str] = []
+
+    if venue:
+        clauses.append("venue = ?")
+        params.append(venue)
+    if enabled_only:
+        clauses.append("enabled = 1")
+
+    if clauses:
+        query += " WHERE " + " AND ".join(clauses)
+    query += " ORDER BY created_at ASC"
+
+    with get_connection() as conn:
+        rows = conn.execute(query, tuple(params)).fetchall()
+
+    return [
+        {
+            "id": row["id"],
+            "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
+    ]
+
+
+def get_account(account_id: str) -> dict:
+    with get_connection() as conn:
+        row = conn.execute(
+            "SELECT id, display_name, venue, venue_account_ref, description, enabled, metadata_json, created_at, updated_at FROM accounts WHERE id = ?",
+            (account_id,),
+        ).fetchone()
+    if row is None:
+        raise HTTPException(status_code=404, detail="account not found")
+    return {
+        "id": row["id"],
+        "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"],
+    }
+
+
+def get_account_secrets(account_id: str) -> dict:
+    with get_connection() as conn:
+        row = conn.execute(
+            "SELECT api_key, api_secret FROM account_secrets WHERE account_id = ?",
+            (account_id,),
+        ).fetchone()
+    if row is None:
+        raise HTTPException(status_code=404, detail="account secrets not found")
+    return {"api_key": row["api_key"], "api_secret": row["api_secret"]}
+
+
+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:
+    account_id = str(uuid4())
+    now = utc_now_iso()
+    with get_connection() as conn:
+        try:
+            conn.execute(
+                """
+                INSERT INTO accounts (id, display_name, venue, venue_account_ref, description, enabled, created_at, updated_at)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+                """,
+                (account_id, display_name, venue, venue_account_ref, description, int(enabled), now, now),
+            )
+            conn.execute(
+                """
+                INSERT INTO account_secrets (account_id, api_key, api_secret, created_at, updated_at)
+                VALUES (?, ?, ?, ?, ?)
+                """,
+                (account_id, api_key, api_secret, now, now),
+            )
+            conn.commit()
+        except IntegrityError as exc:
+            conn.rollback()
+            if "api_key" in str(exc).lower():
+                raise HTTPException(status_code=409, detail="api key already exists") from exc
+            raise
+    return {"id": account_id, "display_name": display_name, "venue": venue}
+
+
+def update_account(*, account_id: str, display_name: str | None = None, description: str | None = None, enabled: bool | None = None) -> dict:
+    now = utc_now_iso()
+    with get_connection() as conn:
+        row = conn.execute("SELECT id FROM accounts WHERE id = ?", (account_id,)).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, account_id))
+        if description is not None:
+            conn.execute("UPDATE accounts SET description = ?, updated_at = ? WHERE id = ?", (description, now, account_id))
+        if enabled is not None:
+            conn.execute("UPDATE accounts SET enabled = ?, updated_at = ? WHERE id = ?", (int(enabled), now, account_id))
+        conn.commit()
+    return {"id": account_id, "updated": True}
+
+
+def delete_account(*, account_id: str) -> dict:
+    with get_connection() as conn:
+        deleted = conn.execute("DELETE FROM accounts WHERE id = ?", (account_id,)).rowcount
+        conn.commit()
+    if not deleted:
+        raise HTTPException(status_code=404, detail="account not found")
+    return {"id": account_id, "deleted": True}
+
+
+def cache_get(cache_key: str) -> dict | None:
+    now = utc_now_iso()
+    with get_connection() as conn:
+        row = conn.execute(
+            "SELECT payload_json FROM api_cache WHERE cache_key = ? AND expires_at > ?",
+            (cache_key, now),
+        ).fetchone()
+    if row is None:
+        return None
+    return json.loads(row["payload_json"])
+
+
+def cache_put(cache_key: str, payload: dict, ttl_seconds: int) -> None:
+    now_dt = datetime.now(timezone.utc)
+    fetched_at = now_dt.isoformat()
+    expires_at = datetime.fromtimestamp(now_dt.timestamp() + ttl_seconds, tz=timezone.utc).isoformat()
+    with get_connection() as conn:
+        conn.execute(
+            """
+            INSERT INTO api_cache (cache_key, payload_json, fetched_at, expires_at)
+            VALUES (?, ?, ?, ?)
+            ON CONFLICT(cache_key) DO UPDATE SET
+                payload_json=excluded.payload_json,
+                fetched_at=excluded.fetched_at,
+                expires_at=excluded.expires_at
+            """,
+            (cache_key, json.dumps(payload), fetched_at, expires_at),
+        )
+        conn.commit()
+
+
+def save_balance_snapshot(*, account_id: str, asset_code: str, balance: float, balance_value: float | None = None, value_currency: str | None = None) -> str:
+    snapshot_id = str(uuid4())
+    captured_at = utc_now_iso()
+    with get_connection() as conn:
+        conn.execute(
+            """
+            INSERT INTO balance_snapshots (id, account_id, asset_code, balance, balance_value, value_currency, captured_at)
+            VALUES (?, ?, ?, ?, ?, ?, ?)
+            """,
+            (snapshot_id, account_id, asset_code, balance, balance_value, value_currency, captured_at),
+        )
+        conn.commit()
+    return snapshot_id

+ 15 - 166
src/exec_mcp/server.py

@@ -1,24 +1,21 @@
 from __future__ import annotations
 
 from contextlib import asynccontextmanager
-from datetime import datetime, timezone
-from sqlite3 import IntegrityError
-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
+from .models import AccountView
+from . import repo
+from .services_bitstamp import fetch_account_info as fetch_remote_account_info
+from .storage import 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
 
@@ -27,18 +24,6 @@ app = FastAPI(title="exec-mcp", lifespan=lifespan)
 SUPPORTED_VENUES = {"bitstamp"}
 
 
-class AccountView(BaseModel):
-    id: str
-    display_name: str | None = None
-    venue: str | None = None
-    venue_account_ref: str | None = None
-    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 """
@@ -67,7 +52,7 @@ def http_health() -> dict:
 
 @app.get("/dashboard", response_class=HTMLResponse)
 def http_dashboard() -> str:
-    rows = list_accounts()
+    rows = list_accounts(enabled_only=False)
     options = "".join(f'<option value="{v}">{v}</option>' for v in sorted(SUPPORTED_VENUES))
     table_rows = "".join(
         f"""
@@ -157,7 +142,7 @@ def http_dashboard_create_account(
     description: str | None = Form(None),
     enabled: bool = Form(False),
 ) -> RedirectResponse:
-    create_account(
+    repo.create_account(
         display_name=display_name,
         venue=venue,
         venue_account_ref=venue_account_ref,
@@ -171,13 +156,7 @@ def http_dashboard_create_account(
 
 @app.get("/dashboard/accounts/{account_id}/edit", response_class=HTMLResponse)
 def http_dashboard_edit_account(account_id: str) -> str:
-    with get_connection() as conn:
-        row = conn.execute(
-            "SELECT id, display_name, venue, venue_account_ref, description, enabled FROM accounts WHERE id = ?",
-            (account_id,),
-        ).fetchone()
-    if row is None:
-        raise HTTPException(status_code=404, detail="account not found")
+    row = repo.get_account(account_id)
     checked = "checked" if row["enabled"] else ""
     return f"""
     <html>
@@ -205,157 +184,27 @@ def http_dashboard_update_account(
     description: str = Form(""),
     enabled: bool = Form(False),
 ) -> RedirectResponse:
-    update_account(account_id=account_id, display_name=display_name, description=description or None, enabled=enabled)
+    repo.update_account(account_id=account_id, display_name=display_name, description=description or None, enabled=enabled)
     return RedirectResponse(url="/dashboard", status_code=303)
 
 
 @app.post("/dashboard/accounts/{account_id}/delete")
 def http_dashboard_delete_account(account_id: str) -> RedirectResponse:
-    delete_account(account_id=account_id)
+    repo.delete_account(account_id=account_id)
     return RedirectResponse(url="/dashboard", status_code=303)
 
 
 @mcp.tool()
-def list_accounts(venue: str | None = None) -> list[dict]:
-    query = "SELECT id, 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 created_at ASC"
-
-    with get_connection() as conn:
-        rows = conn.execute(query, params).fetchall()
-
-    return [
-        {
-            "id": row["id"],
-            "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
-    ]
+def list_accounts(enabled_only: bool = True, venue: str | None = None) -> list[dict]:
+    return repo.list_accounts(venue=venue, enabled_only=enabled_only)
 
 
 @mcp.tool()
 def get_account_info(account_id: 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 id = ?",
-            (account_id,),
-        ).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")
-
-    return {
-        "id": account["id"],
-        "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:
-        try:
-            conn.execute(
-                """
-                INSERT INTO accounts (id, display_name, venue, venue_account_ref, description, enabled, created_at, updated_at)
-                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
-                """,
-                (account_pk, display_name, venue, venue_account_ref, description, int(enabled), now, now),
-            )
-            conn.execute(
-                """
-                INSERT INTO account_secrets (account_id, api_key, api_secret, created_at, updated_at)
-                VALUES (?, ?, ?, ?, ?)
-                """,
-                (account_pk, api_key, api_secret, now, now),
-            )
-            conn.commit()
-        except IntegrityError as exc:
-            conn.rollback()
-            if "api_key" in str(exc).lower():
-                raise HTTPException(status_code=409, detail="api key already exists") from exc
-            raise
-
-    return {"id": account_pk, "display_name": display_name, "venue": venue}
-
-
-def update_account(
-    account_id: 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 id = ?", (account_id,)).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, account_id))
-        if description is not None:
-            conn.execute("UPDATE accounts SET description = ?, updated_at = ? WHERE id = ?", (description, now, account_id))
-        if enabled is not None:
-            conn.execute("UPDATE accounts SET enabled = ?, updated_at = ? WHERE id = ?", (int(enabled), now, account_id))
-        conn.commit()
-    return {"id": account_id, "updated": True}
-
-
-def delete_account(account_id: str) -> dict:
-    with get_connection() as conn:
-        deleted = conn.execute("DELETE FROM accounts WHERE id = ?", (account_id,)).rowcount
-        conn.commit()
-    if not deleted:
-        raise HTTPException(status_code=404, detail="account not found")
-    return {"id": account_id, "deleted": True}
-
-
-def record_balance(account_id: 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 id = ?", (account_id,)).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 {"id": account_id, "asset_code": asset_code, "balance_value": balance_value, "captured_at": now}
+    account = repo.get_account(account_id)
+    if account["venue"] == "bitstamp":
+        return fetch_remote_account_info(account_id)
+    raise HTTPException(status_code=400, detail="unsupported venue")
 
 
 def main() -> None:

+ 91 - 0
src/exec_mcp/services_bitstamp.py

@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+try:
+    import bitstamp.client
+except ModuleNotFoundError:  # allows test runs without the optional dependency
+    bitstamp = None  # type: ignore
+
+from . import repo
+
+BALANCE_CACHE_TTL_SECONDS = 20
+ACCOUNT_INFO_CACHE_TTL_SECONDS = 30
+
+
+def _require_client() -> None:
+    if bitstamp is None:
+        raise RuntimeError("bitstamp-python-client dependency is not installed")
+
+
+def _build_trading_client(account_id: str):
+    _require_client()
+    account = repo.get_account(account_id)
+    secrets = repo.get_account_secrets(account_id)
+    return bitstamp.client.Trading(
+        username=account["venue_account_ref"],
+        key=secrets["api_key"],
+        secret=secrets["api_secret"],
+    )
+
+
+def _normalize_account_balance_payload(payload: dict, account_id: str) -> list[dict]:
+    snapshots: list[dict] = []
+    for key, value in payload.items():
+        if key.endswith("_balance") and isinstance(value, (int, float, str)):
+            asset_code = key.removesuffix("_balance").upper()
+            try:
+                balance = float(value)
+            except ValueError:
+                continue
+            snapshots.append(
+                {
+                    "account_id": account_id,
+                    "asset_code": asset_code,
+                    "balance": balance,
+                    "balance_value": None,
+                    "value_currency": None,
+                }
+            )
+    return snapshots
+
+
+def fetch_account_balance(account_id: str) -> dict:
+    cache_key = f"bitstamp:account_balance:{account_id}"
+    cached = repo.cache_get(cache_key)
+    if cached is not None:
+        return cached
+
+    client = _build_trading_client(account_id)
+    payload = client.account_balance()
+    normalized = _normalize_account_balance_payload(payload, account_id)
+
+    for snapshot in normalized:
+        repo.save_balance_snapshot(**snapshot)
+
+    result = {"source": "bitstamp", "cached": False, "payload": payload, "normalized": normalized}
+    repo.cache_put(cache_key, result, BALANCE_CACHE_TTL_SECONDS)
+    return result
+
+
+def fetch_account_info(account_id: str) -> dict:
+    cache_key = f"bitstamp:account_info:{account_id}"
+    cached = repo.cache_get(cache_key)
+    if cached is not None:
+        return cached
+
+    account = repo.get_account(account_id)
+    balance = fetch_account_balance(account_id)
+
+    result = {
+        "id": account["id"],
+        "display_name": account["display_name"],
+        "venue": account["venue"],
+        "venue_account_ref": account["venue_account_ref"],
+        "description": account["description"],
+        "enabled": account["enabled"],
+        "metadata": account["metadata"],
+        "balance": balance["payload"],
+        "balance_normalized": balance["normalized"],
+    }
+
+    repo.cache_put(cache_key, result, ACCOUNT_INFO_CACHE_TTL_SECONDS)
+    return result

+ 7 - 0
src/exec_mcp/storage.py

@@ -65,6 +65,13 @@ def init_db() -> None:
                 raw_json TEXT NOT NULL DEFAULT '{}',
                 FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE
             );
+
+            CREATE TABLE IF NOT EXISTS api_cache (
+                cache_key TEXT PRIMARY KEY,
+                payload_json TEXT NOT NULL,
+                fetched_at TEXT NOT NULL,
+                expires_at TEXT NOT NULL
+            );
             """
         )
         conn.commit()

+ 8 - 3
tests.sh

@@ -6,10 +6,15 @@ 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
+if [[ -f .venv/bin/activate ]]; then
+  # shellcheck disable=SC1091
+  source .venv/bin/activate
+fi
+
+if command -v python >/dev/null 2>&1; then
   exec python -m pytest -q
+elif command -v pytest >/dev/null 2>&1; then
+  exec pytest -q
 else
   echo "pytest/python not found in PATH. Install dependencies and retry." >&2
   exit 1

+ 22 - 13
tests/test_dashboard.py

@@ -1,6 +1,7 @@
 from fastapi.testclient import TestClient
 
 from exec_mcp.server import app
+from exec_mcp.repo import list_accounts
 
 client = TestClient(app)
 
@@ -13,32 +14,40 @@ def test_health():
 
 def test_dashboard_account_crud_roundtrip():
     payload = {
-        'display_name': 'Bitstamp Main',
+        'display_name': 'synthetic-test-account',
         'venue': 'bitstamp',
-        'venue_account_ref': '123456',
-        'api_key': 'key-123',
-        'api_secret': 'secret-123',
-        'description': 'primary account',
+        'venue_account_ref': 'synthetic-ref-001',
+        'api_key': 'synthetic-api-key-001',
+        'api_secret': 'synthetic-api-secret-001',
+        'description': 'test row',
         '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
+    assert 'synthetic-test-account' in resp.text
+    assert 'synthetic-ref-001' in resp.text
 
-    resp = client.get('/dashboard/accounts/bitstamp/123456/edit')
+    resp = client.get('/dashboard')
+    assert resp.status_code == 200
+    assert '/dashboard/accounts/' in resp.text
+
+    account_id = next(a['id'] for a in list_accounts(enabled_only=False) if a['venue_account_ref'] == 'synthetic-ref-001')
+
+    resp = client.get(f'/dashboard/accounts/{account_id}/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'},
+        f'/dashboard/accounts/{account_id}/update',
+        data={'display_name': 'synthetic-test-account-updated', 'description': 'updated', 'enabled': 'on'},
         follow_redirects=True,
     )
     assert resp.status_code == 200
-    assert 'updated' in resp.text
+    assert 'synthetic-test-account-updated' in resp.text
 
-    resp = client.post('/dashboard/accounts/bitstamp/123456/delete', follow_redirects=True)
+    resp = client.post(f'/dashboard/accounts/{account_id}/delete', follow_redirects=True)
     assert resp.status_code == 200
-    assert '123456' not in resp.text
+
+    remaining = list_accounts(enabled_only=False)
+    assert not any(a['id'] == account_id for a in remaining)