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.
414 lines
14 KiB
Python
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)
|