feat(api): /api/meet-kevin/strategy/* routes
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
Viktor Barzin 2026-05-24 01:12:16 +00:00
parent 886dbaec86
commit 06ede26e78
3 changed files with 396 additions and 0 deletions

View file

@ -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

View file

@ -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()}

View file

@ -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