feat(api): /api/meet-kevin/strategy/* routes
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
This commit is contained in:
parent
886dbaec86
commit
06ede26e78
3 changed files with 396 additions and 0 deletions
|
|
@ -96,6 +96,9 @@ def create_app(config: ApiGatewayConfig | None = None) -> FastAPI:
|
||||||
from services.api_gateway.routes.meet_kevin_backtest import (
|
from services.api_gateway.routes.meet_kevin_backtest import (
|
||||||
router as meet_kevin_backtest_router,
|
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(portfolio_router)
|
||||||
app.include_router(trades_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(backtest_router)
|
||||||
app.include_router(meet_kevin_router)
|
app.include_router(meet_kevin_router)
|
||||||
app.include_router(meet_kevin_backtest_router)
|
app.include_router(meet_kevin_backtest_router)
|
||||||
|
app.include_router(meet_kevin_strategy_router)
|
||||||
|
|
||||||
# WebSocket
|
# WebSocket
|
||||||
from services.api_gateway.ws import router as ws_router
|
from services.api_gateway.ws import router as ws_router
|
||||||
|
|
|
||||||
239
services/api_gateway/routes/meet_kevin_strategy.py
Normal file
239
services/api_gateway/routes/meet_kevin_strategy.py
Normal 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()}
|
||||||
153
tests/api_gateway/routes/test_meet_kevin_strategy.py
Normal file
153
tests/api_gateway/routes/test_meet_kevin_strategy.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue