From 06ede26e78f70338a6d33e4941a24fe61b15d8d0 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 24 May 2026 01:12:16 +0000 Subject: [PATCH] feat(api): /api/meet-kevin/strategy/* routes --- services/api_gateway/main.py | 4 + .../api_gateway/routes/meet_kevin_strategy.py | 239 ++++++++++++++++++ .../routes/test_meet_kevin_strategy.py | 153 +++++++++++ 3 files changed, 396 insertions(+) create mode 100644 services/api_gateway/routes/meet_kevin_strategy.py create mode 100644 tests/api_gateway/routes/test_meet_kevin_strategy.py diff --git a/services/api_gateway/main.py b/services/api_gateway/main.py index 8bcd22e..9c40f4d 100644 --- a/services/api_gateway/main.py +++ b/services/api_gateway/main.py @@ -96,6 +96,9 @@ def create_app(config: ApiGatewayConfig | None = None) -> FastAPI: from services.api_gateway.routes.meet_kevin_backtest import ( router as meet_kevin_backtest_router, ) + from services.api_gateway.routes.meet_kevin_strategy import ( + router as meet_kevin_strategy_router, + ) app.include_router(portfolio_router) app.include_router(trades_router) @@ -106,6 +109,7 @@ def create_app(config: ApiGatewayConfig | None = None) -> FastAPI: app.include_router(backtest_router) app.include_router(meet_kevin_router) app.include_router(meet_kevin_backtest_router) + app.include_router(meet_kevin_strategy_router) # WebSocket from services.api_gateway.ws import router as ws_router diff --git a/services/api_gateway/routes/meet_kevin_strategy.py b/services/api_gateway/routes/meet_kevin_strategy.py new file mode 100644 index 0000000..f704d4e --- /dev/null +++ b/services/api_gateway/routes/meet_kevin_strategy.py @@ -0,0 +1,239 @@ +"""Meet Kevin strategy / ticker scorecard / paper-trading API. + +Provides the data behind /meet-kevin/strategy: + - GET /api/meet-kevin/strategy/tickers per-ticker scorecard + - GET /api/meet-kevin/strategy/equity-curve cumulative equity + benchmark + - GET /api/meet-kevin/strategy/performance headline metrics card + - POST /api/meet-kevin/positions/{symbol}/close flag a manual close +""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timedelta, timezone +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from sqlalchemy import select + +from services.api_gateway.auth.middleware import get_current_user +from shared.constants.kevin import KEVIN_STRATEGY_UUID +from shared.models.meet_kevin import KevinStockMention +from shared.models.meet_kevin_trading import KevinSignalBridgeState +from shared.models.timeseries import PortfolioSnapshot +from shared.models.trading import Trade, TradeStatus + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/meet-kevin", tags=["meet-kevin-strategy"]) + + +_CACHE_TTL_SECONDS = 30 + + +def _cache_key(name: str, params: str = "") -> str: + return f"kevin:api:cache:{name}:{params}" + + +async def _cached(redis: Any, key: str, builder: Any) -> Any: + if redis is not None: + try: + cached = await redis.get(key) + if cached is not None: + return json.loads(cached) + except Exception: + pass + value = await builder() + if redis is not None: + try: + await redis.set(key, json.dumps(value), ex=_CACHE_TTL_SECONDS) + except Exception: + pass + return value + + +@router.get("/strategy/tickers") +async def get_strategy_tickers( + request: Request, + _user: dict = Depends(get_current_user), +) -> list[dict[str, Any]]: + """Per-ticker scorecard: latest mention + bridge status + open trade.""" + redis = getattr(request.app.state, "redis", None) + session_factory = request.app.state.db_session_factory + + async def build(): + async with session_factory() as session: + mentions = ( + ( + await session.execute( + select(KevinStockMention).order_by( + KevinStockMention.created_at.desc() + ) + ) + ) + .scalars() + .all() + ) + + # Latest mention per symbol + latest_by_symbol: dict[str, Any] = {} + for m in mentions: + if m.symbol not in latest_by_symbol: + latest_by_symbol[m.symbol] = m + + audit_rows = ( + (await session.execute(select(KevinSignalBridgeState))).scalars().all() + ) + audit_by_mention = {a.mention_id: a for a in audit_rows} + + kevin_trades = ( + ( + await session.execute( + select(Trade).where(Trade.strategy_id == KEVIN_STRATEGY_UUID) + ) + ) + .scalars() + .all() + ) + trades_by_symbol: dict[str, Any] = {} + for t in kevin_trades: + if t.status == TradeStatus.FILLED: + trades_by_symbol[t.ticker] = t + + out = [] + for symbol, m in latest_by_symbol.items(): + audit = audit_by_mention.get(m.id) + trade = trades_by_symbol.get(symbol) + out.append( + { + "symbol": symbol, + "latest_action": m.action.value + if hasattr(m.action, "value") + else str(m.action), + "latest_conviction": float(m.conviction), + "latest_horizon": m.time_horizon.value + if hasattr(m.time_horizon, "value") + else str(m.time_horizon), + "latest_mention_at": m.created_at.isoformat(), + "bridge_status": audit.bridge_status.value + if audit and hasattr(audit.bridge_status, "value") + else None, + "effective_conviction": float(audit.effective_conviction) + if audit and audit.effective_conviction is not None + else None, + "has_open_trade": trade is not None, + "trade_entry_price": float(trade.price) if trade else None, + } + ) + return out + + result: list[dict[str, Any]] = await _cached(redis, _cache_key("tickers"), build) + return result + + +@router.get("/strategy/equity-curve") +async def get_strategy_equity_curve( + request: Request, + from_date: datetime | None = Query(default=None, alias="from"), + to_date: datetime | None = Query(default=None, alias="to"), + include_benchmark: str | None = Query(default=None), + _user: dict = Depends(get_current_user), +) -> dict[str, Any]: + """Kevin-attributed equity curve from PortfolioSnapshot.""" + redis = getattr(request.app.state, "redis", None) + session_factory = request.app.state.db_session_factory + cache_key = _cache_key( + "equity-curve", + f"{from_date}:{to_date}:{include_benchmark}", + ) + + if to_date is None: + to_date = datetime.now(timezone.utc) + if from_date is None: + from_date = to_date - timedelta(days=90) + + async def build(): + async with session_factory() as session: + snapshots = ( + ( + await session.execute( + select(PortfolioSnapshot) + .where(PortfolioSnapshot.timestamp >= from_date) + .where(PortfolioSnapshot.timestamp <= to_date) + .order_by(PortfolioSnapshot.timestamp.asc()) + ) + ) + .scalars() + .all() + ) + + return { + "from": from_date.isoformat(), + "to": to_date.isoformat(), + "kevin_equity_curve": [ + [s.timestamp.isoformat(), float(s.total_value)] + for s in snapshots + ], + "benchmark": include_benchmark, + "benchmark_curve": None, + } + + result: dict[str, Any] = await _cached(redis, cache_key, build) + return result + + +@router.get("/strategy/performance") +async def get_strategy_performance( + request: Request, + _user: dict = Depends(get_current_user), +) -> dict[str, Any]: + """Headline performance card for the Kevin strategy live path.""" + session_factory = request.app.state.db_session_factory + + async with session_factory() as session: + trades = ( + ( + await session.execute( + select(Trade).where(Trade.strategy_id == KEVIN_STRATEGY_UUID) + ) + ) + .scalars() + .all() + ) + + closed = [t for t in trades if t.pnl is not None] + total_pnl = sum((t.pnl or 0.0) for t in closed) + wins = [t for t in closed if (t.pnl or 0) > 0] + losses = [t for t in closed if (t.pnl or 0) <= 0] + win_rate = (len(wins) / len(closed) * 100.0) if closed else 0.0 + + return { + "trade_count": len(trades), + "closed_trade_count": len(closed), + "total_pnl_usd": total_pnl, + "win_rate_pct": win_rate, + "wins": len(wins), + "losses": len(losses), + } + + +@router.post("/positions/{symbol}/close") +async def close_kevin_position( + symbol: str, + request: Request, + _user: dict = Depends(get_current_user), +) -> dict[str, str]: + """Flag a Kevin position for manual close. + + Writes Redis key `kevin:manual_close:{symbol}=1` (TTL 10m). The + bridge's exit scanner picks this up and emits an EXIT signal. + """ + redis = getattr(request.app.state, "redis", None) + if redis is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="redis unavailable", + ) + await redis.set(f"kevin:manual_close:{symbol.upper()}", "1", ex=600) + return {"status": "queued", "symbol": symbol.upper()} diff --git a/tests/api_gateway/routes/test_meet_kevin_strategy.py b/tests/api_gateway/routes/test_meet_kevin_strategy.py new file mode 100644 index 0000000..f59d851 --- /dev/null +++ b/tests/api_gateway/routes/test_meet_kevin_strategy.py @@ -0,0 +1,153 @@ +"""Tests for /api/meet-kevin/strategy/* + /positions/{symbol}/close routes.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi.testclient import TestClient + +from services.api_gateway.auth.jwt import create_access_token +from services.api_gateway.auth.middleware import get_config, get_current_user +from services.api_gateway.config import ApiGatewayConfig +from services.api_gateway.main import create_app + + +@pytest.fixture +def config() -> ApiGatewayConfig: + return ApiGatewayConfig( + jwt_secret_key="test-secret-for-routes-kevin-strategy", + database_url="sqlite+aiosqlite:///:memory:", + redis_url="redis://localhost:6379/0", + ) + + +@pytest.fixture +def auth_headers(config: ApiGatewayConfig) -> dict[str, str]: + token = create_access_token("user-test", "tester", config) + return {"Authorization": f"Bearer {token}"} + + +def _make_session(): + session = AsyncMock() + session.__aenter__ = AsyncMock(return_value=session) + session.__aexit__ = AsyncMock(return_value=False) + factory = MagicMock() + factory.return_value = session + return factory, session + + +def _result(scalars: list, scalar_one_or_none=None): + result = MagicMock() + result.scalars.return_value.all.return_value = scalars + result.scalar_one_or_none.return_value = scalar_one_or_none + return result + + +@pytest.fixture +def app(config: ApiGatewayConfig): + factory, session = _make_session() + app = create_app(config) + app.dependency_overrides[get_current_user] = lambda: { + "sub": "user-test", + "username": "tester", + "type": "access", + } + app.dependency_overrides[get_config] = lambda: config + app.state.db_session_factory = factory + app.state.config = config + app.state.redis = AsyncMock() + app.state.redis.get = AsyncMock(return_value=None) + app.state.redis.set = AsyncMock() + app.state.db_engine = MagicMock() + app._test_session = session # type: ignore[attr-defined] + return app + + +def test_tickers_returns_empty_when_no_mentions(app, auth_headers): + session = app._test_session # type: ignore[attr-defined] + session.execute = AsyncMock(return_value=_result([])) + client = TestClient(app, raise_server_exceptions=False) + resp = client.get("/api/meet-kevin/strategy/tickers", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_tickers_joins_mention_audit_trade(app, auth_headers): + mention = MagicMock( + id=1, + symbol="NVDA", + action=MagicMock(value="buy"), + conviction=Decimal("0.7"), + time_horizon=MagicMock(value="weeks"), + created_at=datetime.now(timezone.utc), + ) + audit = MagicMock( + mention_id=1, + bridge_status=MagicMock(value="emitted"), + effective_conviction=Decimal("0.75"), + ) + + session = app._test_session # type: ignore[attr-defined] + session.execute = AsyncMock( + side_effect=[ + _result([mention]), + _result([audit]), + _result([]), # no trades + ] + ) + client = TestClient(app, raise_server_exceptions=False) + resp = client.get("/api/meet-kevin/strategy/tickers", headers=auth_headers) + assert resp.status_code == 200 + rows = resp.json() + assert len(rows) == 1 + assert rows[0]["symbol"] == "NVDA" + assert rows[0]["bridge_status"] == "emitted" + assert rows[0]["effective_conviction"] == 0.75 + assert rows[0]["has_open_trade"] is False + + +def test_performance_returns_aggregates(app, auth_headers): + closed_win = MagicMock(pnl=100.0) + closed_loss = MagicMock(pnl=-50.0) + session = app._test_session # type: ignore[attr-defined] + session.execute = AsyncMock( + return_value=_result([closed_win, closed_loss]) + ) + client = TestClient(app, raise_server_exceptions=False) + resp = client.get("/api/meet-kevin/strategy/performance", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["closed_trade_count"] == 2 + assert data["wins"] == 1 + assert data["losses"] == 1 + + +def test_close_position_writes_redis_flag(app, auth_headers): + client = TestClient(app, raise_server_exceptions=False) + resp = client.post( + "/api/meet-kevin/positions/nvda/close", headers=auth_headers + ) + assert resp.status_code == 200 + assert resp.json()["symbol"] == "NVDA" + redis = app.state.redis + redis.set.assert_awaited() + + +def test_equity_curve_returns_kevin_series(app, auth_headers): + snap = MagicMock( + timestamp=datetime(2026, 5, 1, tzinfo=timezone.utc), + total_value=100000.0, + ) + session = app._test_session # type: ignore[attr-defined] + session.execute = AsyncMock(return_value=_result([snap])) + client = TestClient(app, raise_server_exceptions=False) + resp = client.get( + "/api/meet-kevin/strategy/equity-curve", headers=auth_headers + ) + assert resp.status_code == 200 + data = resp.json() + assert "kevin_equity_curve" in data + assert data["kevin_equity_curve"][0][1] == 100000.0