From bcd0857729e170c35fb76181462d3a71ef893f5e Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Tue, 26 May 2026 21:09:40 +0000 Subject: [PATCH] feat(backtest): wire real Alpaca historical fetcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../api_gateway/routes/meet_kevin_backtest.py | 103 +++++++++++++++--- 1 file changed, 90 insertions(+), 13 deletions(-) diff --git a/services/api_gateway/routes/meet_kevin_backtest.py b/services/api_gateway/routes/meet_kevin_backtest.py index 6f59cdf..d0aef4d 100644 --- a/services/api_gateway/routes/meet_kevin_backtest.py +++ b/services/api_gateway/routes/meet_kevin_backtest.py @@ -4,11 +4,13 @@ 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 @@ -29,6 +31,92 @@ 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.""" @@ -131,20 +219,9 @@ async def _execute_backtest( ) strategy = KevinStrategy(strategy_cfg) - # Use an Alpaca-less price loader for now — backtest will fall back - # to whatever is already in market_data. A real run would inject the - # AlpacaBroker here. - class _NoAlpaca: - async def fetch_daily_bars(self, *a: Any, **kw: Any) -> Any: - import pandas as pd - - return pd.DataFrame() - - async def is_asset_tradable(self, *a: Any, **kw: Any) -> bool: - return True - price_loader = KevinPriceLoader( - session_factory=session_factory, alpaca_fetcher=_NoAlpaca() + session_factory=session_factory, + alpaca_fetcher=_AlpacaHistoricalFetcher(), ) runner = KevinBacktestRunner(strategy, price_loader)