trading/services/api_gateway/routes/meet_kevin_backtest.py
Viktor Barzin bcd0857729
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat(backtest): wire real Alpaca historical fetcher
Previously the backtest API instantiated a _NoAlpaca stub that
returned an empty DataFrame for every fetch_daily_bars call, so the
mention-driven engine had no price data to mark trades against and
every backtest reported total_return=0 / trade_count=0.

Replace with _AlpacaHistoricalFetcher which:
- Uses StockHistoricalDataClient (alpaca-py) with Day timeframe
- Reads creds from TRADING_ALPACA_API_KEY/SECRET_KEY env vars (already
  injected via trading-bot-secrets ESO)
- DataFeed.IEX (free tier — same as services/market_data uses)
- Lazy-instantiates the SDK clients on first use to avoid import cost
  in the api-gateway hot path
- Returns indexed DataFrame matching KevinPriceLoader's expected shape
  ([open, high, low, close, volume], timestamp index)
- Returns empty DataFrame on Alpaca failure (loader has its own
  cache-miss fallback that no-ops gracefully)

is_asset_tradable also wired to the real Alpaca TradingClient so the
backtest doesn't trade non-tradable tickers.
2026-05-26 21:09:40 +00:00

414 lines
14 KiB
Python

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