Pārlūkot izejas kodu

Add reporting tools and Docker packaging

Lukas Goldschmidt 1 mēnesi atpakaļ
vecāks
revīzija
9a6258b273
9 mainītis faili ar 330 papildinājumiem un 1 dzēšanām
  1. 7 0
      .dockerignore
  2. 49 0
      AGENTS.md
  3. 19 0
      Dockerfile
  4. 3 0
      PROJECT.md
  5. 13 0
      README.md
  6. 15 0
      docker-compose.yml
  7. 40 1
      src/exec_mcp/repo.py
  8. 61 0
      src/exec_mcp/server.py
  9. 123 0
      tests/test_reporting_tools.py

+ 7 - 0
.dockerignore

@@ -0,0 +1,7 @@
+.git
+.venv
+__pycache__
+*.pyc
+.pytest_cache
+logs
+data

+ 49 - 0
AGENTS.md

@@ -0,0 +1,49 @@
+# AGENTS.md
+
+## Scope
+
+`exec-mcp` is the execution layer for Trader27. It owns exchange access, account metadata, balances, fees, market metadata, order placement, order lookup, and cancel flows.
+
+## How To Run
+
+- Start the service with `./run.sh`
+- Restart with `./restart.sh`
+- Stop listeners with `./killserver.sh`
+- Run tests with `./tests.sh`
+- `GET /health` is the runtime health probe used by Docker
+
+`run.sh` uses `uvicorn app:app` and will source `.venv/bin/activate` if present.
+
+## Code Boundaries
+
+- Keep secrets in `account_secrets`; never expose them through dashboard or MCP responses.
+- Keep account metadata and order lifecycle logic in `repo.py`, `services_bitstamp.py`, and `services_orders.py`.
+- Keep Bitstamp-specific client behavior in `bitstamp.py` and websocket handling in `bitstamp_private_ws.py`.
+- Keep schema changes compatible with the non-destructive bootstrap in `storage.py`.
+
+## Runtime Contracts
+
+- `venue_account_ref` is the exchange-side account reference shown in the dashboard.
+- `client_id` is optional on orders, stored locally, and used to filter open orders and recover tracked state.
+- Bitstamp client instances are cached per account, and nonce generation must remain monotonic per credential scope.
+- Background tasks refresh Bitstamp metadata and EUR/USD data, and the private websocket updates local order records.
+- Public discovery tools should stay read-only; order placement and cancel paths are the execution surface.
+- Reporting tools are snapshot-only: recent trades default to `filled`, support `cancelled` and `all`, and balance reporting reads stored `balance_snapshots`.
+
+## Editing Rules
+
+- Prefer small, targeted changes.
+- Do not invent new architecture or strategy semantics.
+- When changing runtime behavior, update the relevant tests and docs in the same patch.
+- If a test or cleanup path depends on the local SQLite DB, use a temp DB in tests instead of mutating shared state.
+- For Docker runs, keep `/app/data` mounted so the SQLite file survives restarts.
+
+## Files To Read First
+
+- `README.md`
+- `PROJECT.md`
+- `src/exec_mcp/server.py`
+- `src/exec_mcp/services_orders.py`
+- `src/exec_mcp/services_bitstamp.py`
+- `src/exec_mcp/bitstamp.py`
+- `src/exec_mcp/storage.py`

+ 19 - 0
Dockerfile

@@ -0,0 +1,19 @@
+FROM python:3.11-slim
+
+ENV PYTHONUNBUFFERED=1 \
+    PYTHONDONTWRITEBYTECODE=1
+
+WORKDIR /app
+
+COPY requirements.txt pyproject.toml README.md ./
+COPY app.py ./
+COPY src ./src
+
+RUN pip install --no-cache-dir -r requirements.txt
+
+EXPOSE 8560
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
+  CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8560/health', timeout=3).read()"]
+
+CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8560"]

+ 3 - 0
PROJECT.md

@@ -34,6 +34,8 @@ It sits between strategy logic and real exchange APIs, and is responsible for au
 - support multiple exchanges
 - support multiple accounts and subaccounts per exchange
 - keep secrets out of the strategy/UI layers
+- expose compact reporting tools for recent trades and stored balance snapshots
+- provide a `/health` endpoint suitable for container health checks
 
 ## Desired properties
 
@@ -44,6 +46,7 @@ It sits between strategy logic and real exchange APIs, and is responsible for au
 - paper-trading capable
 - restart-safe state handling
 - easy to extend with new exchanges
+- Docker-ready single-container deployment with a persistent SQLite volume
 
 ## So far
 

+ 13 - 0
README.md

@@ -51,6 +51,19 @@ This service is the order-and-account layer of the stack. It owns exchange acces
 - order placement, query, and cancel
 - client-scoped open-order recovery via `client_id`
 - bulk cancel that skips stale exchange-missing rows and marks them locally
+- reporting tools for recent trades and stored balance snapshots
+- `GET /health` for runtime checks and Docker health probes
+
+## Docker
+
+The server is packaged for a single-container deployment.
+
+- container name: `exec-mcp`
+- service port: `8560`
+- persistent SQLite path: `/app/data/exec_mcp.sqlite3`
+- health endpoint: `GET /health`
+
+The included `docker-compose.yml` mounts `./data` into `/app/data` so the database survives restarts.
 
 ## Dashboard shape
 

+ 15 - 0
docker-compose.yml

@@ -0,0 +1,15 @@
+services:
+  exec-mcp:
+    container_name: exec-mcp
+    build: .
+    ports:
+      - "8560:8560"
+    volumes:
+      - ./data:/app/data
+    restart: unless-stopped
+    healthcheck:
+      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8560/health', timeout=3).read()"]
+      interval: 30s
+      timeout: 5s
+      start_period: 20s
+      retries: 3

+ 40 - 1
src/exec_mcp/repo.py

@@ -202,6 +202,45 @@ def save_balance_snapshot(*, account_id: str, asset_code: str, balance: float, b
             VALUES (?, ?, ?, ?, ?, ?, ?)
             """,
             (snapshot_id, account_id, asset_code, balance, balance_value, value_currency, captured_at),
-        )
+            )
         conn.commit()
     return snapshot_id
+
+
+def list_recent_orders(*, account_id: str, client_id: str | None = None, status: str = "filled", limit: int = 50) -> list[dict]:
+    query = """
+        SELECT id, account_id, market, side, order_type, amount, price, expire_time, status,
+               bitstamp_order_id, client_id, client_order_id, raw_json, created_at, updated_at
+        FROM order_records
+        WHERE account_id = ?
+    """
+    params: list[object] = [account_id]
+    if client_id is not None:
+        query += " AND client_id = ?"
+        params.append(client_id)
+    if status != "all":
+        query += " AND lower(status) = ?"
+        params.append(status)
+    query += " ORDER BY created_at DESC, id DESC LIMIT ?"
+    params.append(limit)
+
+    with get_connection() as conn:
+        rows = conn.execute(query, tuple(params)).fetchall()
+
+    return [dict(row) for row in rows]
+
+
+def list_balance_snapshots(*, account_id: str, limit: int = 50) -> list[dict]:
+    with get_connection() as conn:
+        rows = conn.execute(
+            """
+            SELECT id, account_id, asset_code, balance, balance_value, value_currency, captured_at
+            FROM balance_snapshots
+            WHERE account_id = ?
+            ORDER BY captured_at DESC, id DESC
+            LIMIT ?
+            """,
+            (account_id, limit),
+        ).fetchall()
+
+    return [dict(row) for row in rows]

+ 61 - 0
src/exec_mcp/server.py

@@ -60,6 +60,26 @@ async def lifespan(_: FastAPI):
 app = FastAPI(title="exec-mcp", lifespan=lifespan)
 app.mount("/mcp", mcp.http_app(path="/sse", transport="sse"))
 SUPPORTED_VENUES = {"bitstamp"}
+REPORT_ORDER_STATUSES = {"filled", "cancelled", "all"}
+
+
+def _normalize_report_status(status: str) -> str:
+    value = str(status or "").strip().lower()
+    if value not in REPORT_ORDER_STATUSES:
+        raise HTTPException(status_code=400, detail="status must be one of filled, cancelled, all")
+    return value
+
+
+def _normalize_report_limit(limit: int) -> int:
+    try:
+        value = int(limit)
+    except Exception as exc:
+        raise HTTPException(status_code=400, detail="limit must be an integer") from exc
+    if value < 1:
+        raise HTTPException(status_code=400, detail="limit must be at least 1")
+    if value > 500:
+        raise HTTPException(status_code=400, detail="limit must be at most 500")
+    return value
 
 
 @app.get("/", response_class=HTMLResponse)
@@ -336,5 +356,46 @@ def cancel_all_orders(account_id: str, client_id: str | None = None) -> dict:
     return service_cancel_all_orders(account_id=account_id, client_id=client_id)
 
 
+@mcp.tool()
+def report_recent_trades(account_id: str, status: str = "filled", client_id: str | None = None, limit: int = 50) -> dict:
+    """report_recent_trades(account_id, status="filled", client_id=None, limit=50)
+
+    Return recent order snapshots for reporting, ordered newest first. Status
+    may be `filled`, `cancelled`, or `all`. `client_id` is optional and filters
+    to one reporting stream when provided.
+    """
+    repo.get_account(account_id)
+    status = _normalize_report_status(status)
+    limit = _normalize_report_limit(limit)
+    orders = repo.list_recent_orders(account_id=account_id, client_id=client_id, status=status, limit=limit)
+    return {
+        "ok": True,
+        "account_id": account_id,
+        "client_id": client_id,
+        "status": status,
+        "limit": limit,
+        "count": len(orders),
+        "orders": orders,
+    }
+
+
+@mcp.tool()
+def report_balance_snapshots(account_id: str, limit: int = 50) -> dict:
+    """report_balance_snapshots(account_id, limit=50)
+
+    Return stored wallet balance snapshots for reporting, ordered newest first.
+    """
+    repo.get_account(account_id)
+    limit = _normalize_report_limit(limit)
+    snapshots = repo.list_balance_snapshots(account_id=account_id, limit=limit)
+    return {
+        "ok": True,
+        "account_id": account_id,
+        "limit": limit,
+        "count": len(snapshots),
+        "snapshots": snapshots,
+    }
+
+
 def main() -> None:
     init_db()

+ 123 - 0
tests/test_reporting_tools.py

@@ -0,0 +1,123 @@
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from exec_mcp import repo, storage
+from exec_mcp.server import report_balance_snapshots, report_recent_trades
+
+
+@pytest.fixture()
+def temp_db(tmp_path, monkeypatch):
+    db_path = tmp_path / "exec.sqlite3"
+    monkeypatch.setattr(storage, "DB_PATH", db_path)
+    storage.init_db()
+    return db_path
+
+
+def _create_account() -> str:
+    repo.create_account(display_name="strategy", venue="bitstamp", venue_account_ref="ref-1", api_key="k", api_secret="s")
+    return repo.list_accounts(enabled_only=False)[0]["id"]
+
+
+def test_report_recent_trades_defaults_to_filled_and_sorts_newest_first(temp_db):
+    account_id = _create_account()
+
+    with storage.get_connection() as conn:
+        rows = [
+            ("rec-1", "filled", "client-a", "1994292738961401", "2026-04-09T08:20:50+00:00"),
+            ("rec-2", "cancelled", "client-a", "1994292738961402", "2026-04-09T09:20:50+00:00"),
+            ("rec-3", "filled", "client-b", "1994292738961403", "2026-04-09T10:20:50+00:00"),
+            ("rec-4", "open", "client-a", "1994292738961404", "2026-04-09T11:20:50+00:00"),
+        ]
+        for record_id, status, client_id, order_id, stamp in rows:
+            conn.execute(
+                """
+                INSERT INTO order_records
+                (id, account_id, market, side, order_type, amount, price, expire_time, status, bitstamp_order_id, client_id, client_order_id, raw_json, created_at, updated_at)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+                """,
+                (
+                    record_id,
+                    account_id,
+                    "xrpusd",
+                    "buy",
+                    "limit",
+                    "10",
+                    "2.00",
+                    None,
+                    status,
+                    order_id,
+                    client_id,
+                    None,
+                    json.dumps({"id": order_id, "status": status}),
+                    stamp,
+                    stamp,
+                ),
+            )
+        conn.commit()
+
+    result = report_recent_trades(account_id=account_id)
+    assert result["ok"] is True
+    assert result["status"] == "filled"
+    assert result["count"] == 2
+    assert [row["bitstamp_order_id"] for row in result["orders"]] == ["1994292738961403", "1994292738961401"]
+
+
+def test_report_recent_trades_supports_all_statuses_and_client_filter(temp_db):
+    account_id = _create_account()
+
+    with storage.get_connection() as conn:
+        for idx, (status, client_id, stamp) in enumerate(
+            [
+                ("filled", "client-a", "2026-04-09T08:20:50+00:00"),
+                ("cancelled", "client-a", "2026-04-09T09:20:50+00:00"),
+                ("filled", "client-b", "2026-04-09T10:20:50+00:00"),
+            ],
+            start=1,
+        ):
+            order_id = f"19942927389614{idx}"
+            conn.execute(
+                """
+                INSERT INTO order_records
+                (id, account_id, market, side, order_type, amount, price, expire_time, status, bitstamp_order_id, client_id, client_order_id, raw_json, created_at, updated_at)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+                """,
+                (
+                    f"rec-{idx}",
+                    account_id,
+                    "xrpusd",
+                    "sell",
+                    "limit",
+                    "10",
+                    "2.00",
+                    None,
+                    status,
+                    order_id,
+                    client_id,
+                    None,
+                    json.dumps({"id": order_id, "status": status}),
+                    stamp,
+                    stamp,
+                ),
+            )
+        conn.commit()
+
+    result = report_recent_trades(account_id=account_id, status="all", client_id="client-a", limit=10)
+    assert result["ok"] is True
+    assert result["status"] == "all"
+    assert result["client_id"] == "client-a"
+    assert [row["status"] for row in result["orders"]] == ["cancelled", "filled"]
+
+
+def test_report_balance_snapshots_returns_history_newest_first(temp_db):
+    account_id = _create_account()
+
+    repo.save_balance_snapshot(account_id=account_id, asset_code="BTC", balance=1.25, balance_value=11250.0, value_currency="USD")
+    repo.save_balance_snapshot(account_id=account_id, asset_code="USD", balance=500.0, balance_value=500.0, value_currency="USD")
+
+    result = report_balance_snapshots(account_id=account_id)
+    assert result["ok"] is True
+    assert result["count"] == 2
+    assert [row["asset_code"] for row in result["snapshots"]] == ["USD", "BTC"]