feat: make backtest work end-to-end with Alpaca bars, ticker selection, all 9 strategies

- Change BacktestRequest from strategy_weights dict to strategies list to match frontend
- Add tickers field so users can select which stocks to backtest
- Fetch historical bars from Alpaca StockHistoricalDataClient instead of empty data loader
- Register all 9 strategies (momentum, mean_reversion, news_driven, value, macd_crossover,
  bollinger_breakout, vwap, liquidity, ma_stack) filtered by user selection
- Fix response format: use frontend field names (max_drawdown, total_trades, win_rate as
  0-1 decimal), include equity_curve and run_id in response
- Add ticker selector with checkboxes and custom ticker input to dashboard
- Add alpaca-py to api dependency group in pyproject.toml
This commit is contained in:
Viktor Barzin 2026-02-23 22:25:41 +00:00
parent 82d30bde80
commit a2c08743ac
No known key found for this signature in database
GPG key ID: 0EB088298288D958
3 changed files with 236 additions and 31 deletions

View file

@ -20,6 +20,39 @@ router = APIRouter(prefix="/api/backtest", tags=["backtest"])
# Store references to background tasks to prevent garbage collection
_background_tasks: set[asyncio.Task] = set()
# All available strategy classes keyed by name
_STRATEGY_REGISTRY: dict[str, type] | None = None
def _get_strategy_registry() -> dict[str, type]:
"""Lazy-load strategy classes to avoid import-time side effects."""
global _STRATEGY_REGISTRY
if _STRATEGY_REGISTRY is None:
from shared.strategies import (
MomentumStrategy,
MeanReversionStrategy,
NewsDrivenStrategy,
ValueStrategy,
MACDCrossoverStrategy,
BollingerBreakoutStrategy,
VWAPStrategy,
LiquidityStrategy,
MAStackStrategy,
)
_STRATEGY_REGISTRY = {
"momentum": MomentumStrategy,
"mean_reversion": MeanReversionStrategy,
"news_driven": NewsDrivenStrategy,
"value": ValueStrategy,
"macd_crossover": MACDCrossoverStrategy,
"bollinger_breakout": BollingerBreakoutStrategy,
"vwap": VWAPStrategy,
"liquidity": LiquidityStrategy,
"ma_stack": MAStackStrategy,
}
return _STRATEGY_REGISTRY
class BacktestRequest(BaseModel):
"""Request body for starting a new backtest."""
@ -29,7 +62,8 @@ class BacktestRequest(BaseModel):
initial_capital: float = Field(default=100_000.0, gt=0)
commission_per_trade: float = Field(default=0.0, ge=0)
slippage_pct: float = Field(default=0.001, ge=0)
strategy_weights: dict[str, float] = Field(default_factory=dict)
strategies: list[str] = Field(default_factory=list)
tickers: list[str] = Field(default_factory=lambda: ["AAPL", "TSLA", "NVDA", "MSFT", "GOOGL"])
max_position_pct: float = Field(default=0.05, gt=0, le=1.0)
signal_threshold: float = Field(default=0.3, ge=0, le=1.0)
@ -47,12 +81,14 @@ async def run_backtest(
"""
run_id = str(uuid.uuid4())
redis = request.app.state.redis
config = request.app.state.config
# Store initial status
await redis.setex(
f"backtest:{run_id}",
86400, # 24h TTL
json.dumps({
"run_id": run_id,
"status": "running",
"config": body.model_dump(mode="json"),
"started_at": datetime.now(tz=timezone.utc).isoformat(),
@ -60,7 +96,7 @@ async def run_backtest(
)
# Launch background task (stored in set to prevent GC)
task = asyncio.create_task(_run_backtest_task(run_id, body, redis))
task = asyncio.create_task(_run_backtest_task(run_id, body, redis, config))
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
@ -71,14 +107,57 @@ async def _run_backtest_task(
run_id: str,
config: BacktestRequest,
redis,
app_config,
) -> None:
"""Execute the backtest in the background and store results in Redis."""
try:
from backtester.config import BacktestConfig
from backtester.data_loader import BacktestDataLoader
from backtester.engine import BacktestEngine
from shared.strategies.momentum import MomentumStrategy
from shared.strategies.mean_reversion import MeanReversionStrategy
from shared.strategies.news_driven import NewsDrivenStrategy
# ---- Fetch historical bars from Alpaca ----
bars = await _fetch_alpaca_bars(
tickers=config.tickers,
start=config.start_date,
end=config.end_date,
api_key=app_config.alpaca_api_key,
secret_key=app_config.alpaca_secret_key,
)
if not bars:
await redis.setex(
f"backtest:{run_id}",
86400,
json.dumps({
"run_id": run_id,
"status": "failed",
"error": "No historical bar data returned from Alpaca. Check tickers and date range.",
}),
)
return
data_loader = BacktestDataLoader(bars=bars)
# ---- Build strategy list ----
registry = _get_strategy_registry()
strategy_names = config.strategies or list(registry.keys())
strategies = [
registry[name]()
for name in strategy_names
if name in registry
]
if not strategies:
await redis.setex(
f"backtest:{run_id}",
86400,
json.dumps({
"run_id": run_id,
"status": "failed",
"error": f"No valid strategies selected. Available: {list(registry.keys())}",
}),
)
return
bt_config = BacktestConfig(
start_date=config.start_date,
@ -86,44 +165,35 @@ async def _run_backtest_task(
initial_capital=config.initial_capital,
commission_per_trade=config.commission_per_trade,
slippage_pct=config.slippage_pct,
strategy_weights=config.strategy_weights,
strategy_weights={}, # equal weights
max_position_pct=config.max_position_pct,
signal_threshold=config.signal_threshold,
)
strategies = [
MomentumStrategy(),
MeanReversionStrategy(),
NewsDrivenStrategy(),
]
engine = BacktestEngine(config=bt_config, strategies=strategies)
# Use an empty data loader for now; a full implementation
# would load historical bars from TimescaleDB.
from backtester.data_loader import BacktestDataLoader
data_loader = BacktestDataLoader(bars=[], sentiments=[])
result = await engine.run(data_loader)
# ---- Build response matching frontend expectations ----
equity_curve = [
{"timestamp": ts.isoformat(), "value": eq}
for ts, eq in result.equity_curve
]
await redis.setex(
f"backtest:{run_id}",
86400,
json.dumps({
"run_id": run_id,
"status": "completed",
"config": config.model_dump(mode="json"),
"result": {
"equity_curve": equity_curve,
"metrics": {
"total_return": result.total_return,
"annualized_return": result.annualized_return,
"sharpe_ratio": result.sharpe_ratio,
"sortino_ratio": result.sortino_ratio,
"max_drawdown_pct": result.max_drawdown_pct,
"max_drawdown_duration_days": result.max_drawdown_duration_days,
"win_rate": result.win_rate,
"avg_win_loss_ratio": result.avg_win_loss_ratio,
"trade_count": result.trade_count,
"avg_hold_duration_seconds": result.avg_hold_duration.total_seconds(),
"max_drawdown": result.max_drawdown_pct,
"win_rate": result.win_rate / 100.0,
"total_trades": result.trade_count,
"avg_hold_duration": str(result.avg_hold_duration),
},
"completed_at": datetime.now(tz=timezone.utc).isoformat(),
}),
@ -134,12 +204,68 @@ async def _run_backtest_task(
f"backtest:{run_id}",
86400,
json.dumps({
"run_id": run_id,
"status": "failed",
"error": str(exc),
}),
)
async def _fetch_alpaca_bars(
tickers: list[str],
start: datetime,
end: datetime,
api_key: str,
secret_key: str,
) -> list[dict]:
"""Fetch historical bars from Alpaca's market data API.
Runs the synchronous Alpaca SDK call in a thread executor to avoid
blocking the event loop.
"""
if not api_key or not secret_key:
raise ValueError("Alpaca API credentials not configured (TRADING_ALPACA_API_KEY / TRADING_ALPACA_SECRET_KEY)")
def _fetch() -> list[dict]:
from alpaca.data.historical import StockHistoricalDataClient
from alpaca.data.requests import StockBarsRequest
from alpaca.data.timeframe import TimeFrame
client = StockHistoricalDataClient(api_key, secret_key)
# Ensure timezone-aware datetimes
start_dt = start if start.tzinfo else start.replace(tzinfo=timezone.utc)
end_dt = end if end.tzinfo else end.replace(tzinfo=timezone.utc)
req = StockBarsRequest(
symbol_or_symbols=tickers,
timeframe=TimeFrame.Day,
start=start_dt,
end=end_dt,
)
bars_response = client.get_stock_bars(req)
all_bars: list[dict] = []
for ticker in tickers:
ticker_bars = bars_response.get(ticker, []) if bars_response else []
for bar in ticker_bars:
all_bars.append({
"timestamp": bar.timestamp,
"ticker": ticker,
"open": float(bar.open),
"high": float(bar.high),
"low": float(bar.low),
"close": float(bar.close),
"volume": int(bar.volume),
})
return all_bars
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, _fetch)
@router.get("/{run_id}")
async def get_backtest(
run_id: str,