feat(backtest): wire real Alpaca historical fetcher
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

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.
This commit is contained in:
Viktor Barzin 2026-05-26 21:09:40 +00:00
parent 1a95bfc06a
commit bcd0857729

View file

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