"""Meet Kevin backtest API — run, list, get, latest.""" from __future__ import annotations import asyncio import logging import os import uuid from datetime import datetime, timezone from decimal import Decimal from typing import Any import pandas as pd from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from pydantic import BaseModel, Field from sqlalchemy import select from services.api_gateway.auth.middleware import get_current_user from shared.models.meet_kevin import KevinStockMention from shared.models.meet_kevin_trading import ( KevinBacktestRun, KevinBacktestRunStatus, KevinBacktestTrade, TriggerSource, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/meet-kevin/backtest", tags=["meet-kevin-backtest"]) _background_tasks: set[asyncio.Task[Any]] = set() class _AlpacaHistoricalFetcher: """Adapter that satisfies KevinPriceLoader's alpaca_fetcher protocol using Alpaca's market data API. Reads credentials from env vars (TRADING_ALPACA_API_KEY / TRADING_ALPACA_SECRET_KEY). """ def __init__(self) -> None: self._api_key = os.environ.get("TRADING_ALPACA_API_KEY", "") self._secret_key = os.environ.get("TRADING_ALPACA_SECRET_KEY", "") self._data_client: Any = None self._trading_client: Any = None def _ensure_data_client(self) -> Any: if self._data_client is None: from alpaca.data.historical import StockHistoricalDataClient self._data_client = StockHistoricalDataClient( api_key=self._api_key, secret_key=self._secret_key, ) return self._data_client def _ensure_trading_client(self) -> Any: if self._trading_client is None: from alpaca.trading.client import TradingClient self._trading_client = TradingClient( api_key=self._api_key, secret_key=self._secret_key, paper=True, ) return self._trading_client async def fetch_daily_bars( self, symbol: str, start: datetime, end: datetime ) -> pd.DataFrame: from alpaca.data.enums import DataFeed from alpaca.data.requests import StockBarsRequest from alpaca.data.timeframe import TimeFrame client = self._ensure_data_client() request = StockBarsRequest( symbol_or_symbols=[symbol], timeframe=TimeFrame.Day, start=start, end=end, feed=DataFeed.IEX, ) try: bars = await asyncio.to_thread(client.get_stock_bars, request) except Exception as exc: logger.warning("Alpaca bars fetch failed for %s: %s", symbol, exc) return pd.DataFrame() try: rows = bars[symbol] except (KeyError, IndexError): return pd.DataFrame() if not rows: return pd.DataFrame() df = pd.DataFrame( [ { "timestamp": b.timestamp, "open": float(b.open), "high": float(b.high), "low": float(b.low), "close": float(b.close), "volume": float(b.volume), } for b in rows ] ) return df.set_index("timestamp") async def is_asset_tradable(self, symbol: str) -> bool: try: client = self._ensure_trading_client() asset = await asyncio.to_thread(client.get_asset, symbol) return bool(getattr(asset, "tradable", False)) except Exception: return False class KevinBacktestRunRequest(BaseModel): """Request body for /api/meet-kevin/backtest/run.""" initial_capital: float = Field(default=100_000.0, gt=0) slippage_pct: float = Field(default=0.0005, ge=0) commission_per_trade: float = Field(default=0.0, ge=0) dedupe_policy: str = Field(default="roll", pattern="^(roll|ignore)$") class KevinBacktestRunSummary(BaseModel): run_uuid: str status: str started_at: datetime | None = None finished_at: datetime | None = None total_return_pct: float | None = None trade_count: int | None = None def _summary_from_row(row: KevinBacktestRun) -> KevinBacktestRunSummary: metrics = row.metrics_json or {} return KevinBacktestRunSummary( run_uuid=str(row.run_uuid), status=row.status.value if hasattr(row.status, "value") else str(row.status), started_at=row.started_at, finished_at=row.finished_at, total_return_pct=metrics.get("total_return_pct"), trade_count=metrics.get("trade_count"), ) @router.post("/run", status_code=status.HTTP_202_ACCEPTED) async def run_backtest( body: KevinBacktestRunRequest, request: Request, _user: dict = Depends(get_current_user), ) -> dict[str, str]: """Kick off a Kevin backtest in the background. Returns run_uuid.""" run_uuid = uuid.uuid4() session_factory = request.app.state.db_session_factory async with session_factory() as session: row = KevinBacktestRun( run_uuid=run_uuid, status=KevinBacktestRunStatus.RUNNING, trigger_source=TriggerSource.MANUAL, params_json=body.model_dump(), ) session.add(row) await session.commit() task = asyncio.create_task( _execute_backtest(run_uuid, body, session_factory) ) _background_tasks.add(task) task.add_done_callback(_background_tasks.discard) return {"run_uuid": str(run_uuid), "status": "running"} async def _execute_backtest( run_uuid: uuid.UUID, params: KevinBacktestRunRequest, session_factory: Any, ) -> None: """Background task that loads mentions, runs the engine, persists results.""" from backtester.kevin_backtest import KevinBacktestParams, KevinBacktestRunner from backtester.kevin_price_loader import KevinPriceLoader from shared.strategies.kevin import KevinStrategy, KevinStrategyConfig try: async with session_factory() as session: mentions = ( ( await session.execute( select(KevinStockMention).order_by( KevinStockMention.created_at.asc() ) ) ) .scalars() .all() ) strategy_cfg = KevinStrategyConfig( min_conviction=Decimal("0.6"), max_mention_age_hours=48 * 365, base_position_pct=Decimal("0.04"), min_trade_usd=Decimal("500"), max_trade_usd=Decimal("5000"), max_per_ticker_usd=Decimal("7500"), hold_days_by_horizon={ "days": 3, "weeks": 10, "months": 45, "long_term": 90, "unspecified": 10, }, avoid_closes_longs=True, avoid_blocks_days=7, ) strategy = KevinStrategy(strategy_cfg) price_loader = KevinPriceLoader( session_factory=session_factory, alpaca_fetcher=_AlpacaHistoricalFetcher(), ) runner = KevinBacktestRunner(strategy, price_loader) result = await runner.run( mentions, KevinBacktestParams( initial_capital=Decimal(str(params.initial_capital)), slippage_pct=Decimal(str(params.slippage_pct)), commission_per_trade=Decimal(str(params.commission_per_trade)), dedupe_policy=params.dedupe_policy, ), ) metrics = { "total_return_pct": float(result.total_return_pct), "annualized_return_pct": result.annualized_return, "sharpe_ratio": result.sharpe_ratio, "max_drawdown_pct": result.max_drawdown_pct, "win_rate": result.win_rate, "trade_count": result.trade_count, "alpha_vs_spy_pct": ( float(result.alpha_vs_spy_pct) if result.alpha_vs_spy_pct is not None else None ), "beta_vs_spy": ( float(result.beta_vs_spy) if result.beta_vs_spy is not None else None ), "avg_winner_pct": ( float(result.avg_winner_pct) if result.avg_winner_pct is not None else None ), "avg_loser_pct": ( float(result.avg_loser_pct) if result.avg_loser_pct is not None else None ), } equity_curve_serialised: list[list[Any]] = [ [ts.isoformat(), float(eq)] for ts, eq in result.equity_curve ] async with session_factory() as session: row = ( await session.execute( select(KevinBacktestRun).where( KevinBacktestRun.run_uuid == run_uuid ) ) ).scalar_one() row.status = KevinBacktestRunStatus.COMPLETED row.finished_at = datetime.now(timezone.utc) row.metrics_json = metrics row.equity_curve_json = equity_curve_serialised for t in result.trades: session.add( KevinBacktestTrade( run_id=row.id, symbol=t["symbol"], source_mention_id=t.get("source_mention_id"), entry_at=t["entry_at"], entry_price=t["entry_price"], exit_at=t.get("exit_at"), exit_price=t.get("exit_price"), qty=t["qty"], pnl_usd=t.get("pnl_usd"), pnl_pct=t.get("pnl_pct"), holding_days_actual=t.get("holding_days_actual"), ) ) await session.commit() except Exception as exc: logger.exception("Kevin backtest %s failed", run_uuid) async with session_factory() as session: row = ( await session.execute( select(KevinBacktestRun).where( KevinBacktestRun.run_uuid == run_uuid ) ) ).scalar_one() row.status = KevinBacktestRunStatus.FAILED row.finished_at = datetime.now(timezone.utc) row.error_message = str(exc) await session.commit() @router.get("/runs") async def list_runs( request: Request, limit: int = Query(20, ge=1, le=200), _user: dict = Depends(get_current_user), ) -> list[KevinBacktestRunSummary]: session_factory = request.app.state.db_session_factory async with session_factory() as session: rows = ( ( await session.execute( select(KevinBacktestRun) .order_by(KevinBacktestRun.started_at.desc()) .limit(limit) ) ) .scalars() .all() ) return [_summary_from_row(r) for r in rows] @router.get("/runs/{run_uuid}") async def get_run( run_uuid: uuid.UUID, request: Request, _user: dict = Depends(get_current_user), ) -> dict[str, Any]: session_factory = request.app.state.db_session_factory async with session_factory() as session: row = ( await session.execute( select(KevinBacktestRun).where(KevinBacktestRun.run_uuid == run_uuid) ) ).scalar_one_or_none() if row is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="run not found" ) trades = ( ( await session.execute( select(KevinBacktestTrade).where( KevinBacktestTrade.run_id == row.id ) ) ) .scalars() .all() ) return { "run_uuid": str(row.run_uuid), "status": row.status.value if hasattr(row.status, "value") else str(row.status), "params": row.params_json, "metrics": row.metrics_json, "equity_curve": row.equity_curve_json, "benchmark_curve": row.benchmark_curve_json, "error_message": row.error_message, "trades": [ { "symbol": t.symbol, "entry_at": t.entry_at.isoformat() if t.entry_at else None, "entry_price": float(t.entry_price) if t.entry_price else None, "exit_at": t.exit_at.isoformat() if t.exit_at else None, "exit_price": float(t.exit_price) if t.exit_price else None, "qty": float(t.qty), "pnl_pct": float(t.pnl_pct) if t.pnl_pct is not None else None, "pnl_usd": float(t.pnl_usd) if t.pnl_usd is not None else None, "holding_days_actual": t.holding_days_actual, } for t in trades ], } @router.get("/latest") async def latest_run( request: Request, _user: dict = Depends(get_current_user), ) -> dict[str, Any]: session_factory = request.app.state.db_session_factory async with session_factory() as session: row = ( await session.execute( select(KevinBacktestRun) .where(KevinBacktestRun.status == KevinBacktestRunStatus.COMPLETED) .order_by(KevinBacktestRun.started_at.desc()) .limit(1) ) ).scalar_one_or_none() if row is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="no completed runs yet", ) return await get_run(row.run_uuid, request, _user)