diff options
| -rw-r--r-- | docker-compose.yml | 14 | ||||
| -rw-r--r-- | services/api/Dockerfile | 11 | ||||
| -rw-r--r-- | services/api/pyproject.toml | 20 | ||||
| -rw-r--r-- | services/api/src/trading_api/__init__.py | 0 | ||||
| -rw-r--r-- | services/api/src/trading_api/main.py | 34 | ||||
| -rw-r--r-- | services/api/src/trading_api/routers/__init__.py | 0 | ||||
| -rw-r--r-- | services/api/src/trading_api/routers/orders.py | 54 | ||||
| -rw-r--r-- | services/api/src/trading_api/routers/portfolio.py | 41 | ||||
| -rw-r--r-- | services/api/src/trading_api/routers/strategies.py | 28 | ||||
| -rw-r--r-- | services/api/tests/__init__.py | 0 | ||||
| -rw-r--r-- | services/api/tests/test_api.py | 17 |
11 files changed, 219 insertions, 0 deletions
diff --git a/docker-compose.yml b/docker-compose.yml index 5c0827d..1b72e8d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -103,6 +103,20 @@ services: retries: 3 restart: unless-stopped + api: + build: + context: . + dockerfile: services/api/Dockerfile + env_file: .env + ports: + - "8000:8000" + depends_on: + redis: + condition: service_healthy + postgres: + condition: service_healthy + restart: unless-stopped + loki: image: grafana/loki:latest profiles: ["monitoring"] diff --git a/services/api/Dockerfile b/services/api/Dockerfile new file mode 100644 index 0000000..b942075 --- /dev/null +++ b/services/api/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim +WORKDIR /app +COPY shared/ shared/ +RUN pip install --no-cache-dir ./shared +COPY services/api/ services/api/ +RUN pip install --no-cache-dir ./services/api +COPY services/strategy-engine/strategies/ /app/strategies/ +COPY services/strategy-engine/ services/strategy-engine/ +RUN pip install --no-cache-dir ./services/strategy-engine +ENV PYTHONPATH=/app +CMD ["uvicorn", "trading_api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/services/api/pyproject.toml b/services/api/pyproject.toml new file mode 100644 index 0000000..fd2598d --- /dev/null +++ b/services/api/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "trading-api" +version = "0.1.0" +description = "REST API for the trading platform" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.110", + "uvicorn>=0.27", + "trading-shared", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "httpx>=0.27"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/trading_api"] diff --git a/services/api/src/trading_api/__init__.py b/services/api/src/trading_api/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/services/api/src/trading_api/__init__.py diff --git a/services/api/src/trading_api/main.py b/services/api/src/trading_api/main.py new file mode 100644 index 0000000..61cfe36 --- /dev/null +++ b/services/api/src/trading_api/main.py @@ -0,0 +1,34 @@ +"""Trading Platform REST API.""" +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from shared.config import Settings +from shared.db import Database + +from trading_api.routers import portfolio, orders, strategies + + +@asynccontextmanager +async def lifespan(app: FastAPI): + settings = Settings() + app.state.db = Database(settings.database_url) + await app.state.db.connect() + yield + await app.state.db.close() + + +app = FastAPI( + title="Trading Platform API", + version="0.1.0", + lifespan=lifespan, +) + +app.include_router(portfolio.router, prefix="/api/v1/portfolio", tags=["portfolio"]) +app.include_router(orders.router, prefix="/api/v1/orders", tags=["orders"]) +app.include_router(strategies.router, prefix="/api/v1/strategies", tags=["strategies"]) + + +@app.get("/health") +async def health(): + return {"status": "ok"} diff --git a/services/api/src/trading_api/routers/__init__.py b/services/api/src/trading_api/routers/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/services/api/src/trading_api/routers/__init__.py diff --git a/services/api/src/trading_api/routers/orders.py b/services/api/src/trading_api/routers/orders.py new file mode 100644 index 0000000..989694f --- /dev/null +++ b/services/api/src/trading_api/routers/orders.py @@ -0,0 +1,54 @@ +"""Order endpoints.""" +from fastapi import APIRouter, Request +from shared.sa_models import OrderRow, SignalRow +from sqlalchemy import select + +router = APIRouter() + + +@router.get("/") +async def get_orders(request: Request, limit: int = 50): + """Get recent orders.""" + db = request.app.state.db + async with db.get_session() as session: + stmt = select(OrderRow).order_by(OrderRow.created_at.desc()).limit(limit) + result = await session.execute(stmt) + rows = result.scalars().all() + return [ + { + "id": r.id, + "signal_id": r.signal_id, + "symbol": r.symbol, + "side": r.side, + "type": r.type, + "price": float(r.price), + "quantity": float(r.quantity), + "status": r.status, + "created_at": r.created_at.isoformat() if r.created_at else None, + "filled_at": r.filled_at.isoformat() if r.filled_at else None, + } + for r in rows + ] + + +@router.get("/signals") +async def get_signals(request: Request, limit: int = 50): + """Get recent signals.""" + db = request.app.state.db + async with db.get_session() as session: + stmt = select(SignalRow).order_by(SignalRow.created_at.desc()).limit(limit) + result = await session.execute(stmt) + rows = result.scalars().all() + return [ + { + "id": r.id, + "strategy": r.strategy, + "symbol": r.symbol, + "side": r.side, + "price": float(r.price), + "quantity": float(r.quantity), + "reason": r.reason, + "created_at": r.created_at.isoformat() if r.created_at else None, + } + for r in rows + ] diff --git a/services/api/src/trading_api/routers/portfolio.py b/services/api/src/trading_api/routers/portfolio.py new file mode 100644 index 0000000..f4169cb --- /dev/null +++ b/services/api/src/trading_api/routers/portfolio.py @@ -0,0 +1,41 @@ +"""Portfolio endpoints.""" +from fastapi import APIRouter, Request +from shared.sa_models import PositionRow, PortfolioSnapshotRow +from sqlalchemy import select + +router = APIRouter() + + +@router.get("/positions") +async def get_positions(request: Request): + """Get all current positions.""" + db = request.app.state.db + async with db.get_session() as session: + result = await session.execute(select(PositionRow)) + rows = result.scalars().all() + return [ + { + "symbol": r.symbol, + "quantity": float(r.quantity), + "avg_entry_price": float(r.avg_entry_price), + "current_price": float(r.current_price), + "unrealized_pnl": float(r.quantity * (r.current_price - r.avg_entry_price)), + } + for r in rows + ] + + +@router.get("/snapshots") +async def get_snapshots(request: Request, days: int = 30): + """Get portfolio snapshots for the last N days.""" + db = request.app.state.db + snapshots = await db.get_portfolio_snapshots(days=days) + return [ + { + "total_value": float(s["total_value"]), + "realized_pnl": float(s["realized_pnl"]), + "unrealized_pnl": float(s["unrealized_pnl"]), + "snapshot_at": s["snapshot_at"].isoformat(), + } + for s in snapshots + ] diff --git a/services/api/src/trading_api/routers/strategies.py b/services/api/src/trading_api/routers/strategies.py new file mode 100644 index 0000000..a8d778d --- /dev/null +++ b/services/api/src/trading_api/routers/strategies.py @@ -0,0 +1,28 @@ +"""Strategy endpoints.""" +import sys +from pathlib import Path + +from fastapi import APIRouter + +# Add strategy-engine to path for plugin loading +_STRATEGY_DIR = Path(__file__).resolve().parents[5] / "strategy-engine" +if str(_STRATEGY_DIR) not in sys.path: + sys.path.insert(0, str(_STRATEGY_DIR)) + +router = APIRouter() + + +@router.get("/") +async def list_strategies(): + """List available strategies.""" + from strategy_engine.plugin_loader import load_strategies + strategies_dir = _STRATEGY_DIR / "strategies" + strategies = load_strategies(strategies_dir) + return [ + { + "name": s.name, + "warmup_period": s.warmup_period, + "class": type(s).__name__, + } + for s in strategies + ] diff --git a/services/api/tests/__init__.py b/services/api/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/services/api/tests/__init__.py diff --git a/services/api/tests/test_api.py b/services/api/tests/test_api.py new file mode 100644 index 0000000..99cf9e3 --- /dev/null +++ b/services/api/tests/test_api.py @@ -0,0 +1,17 @@ +"""Tests for the REST API.""" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from fastapi.testclient import TestClient + + +def test_health_endpoint(): + """Health endpoint returns ok.""" + from trading_api.main import app + # Override lifespan to skip DB + with patch("trading_api.main.lifespan") as mock_lifespan: + mock_lifespan.return_value.__aenter__ = AsyncMock() + mock_lifespan.return_value.__aexit__ = AsyncMock() + client = TestClient(app) + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["status"] == "ok" |
