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