Strategies: - POST /backtest/sma-crossover - SMA crossover (short/long window) - POST /backtest/rsi - RSI oversold/overbought signals - POST /backtest/buy-and-hold - passive benchmark - POST /backtest/momentum - multi-symbol momentum rotation Returns: total_return, annualized_return, sharpe_ratio, max_drawdown, win_rate, total_trades, equity_curve (last 20 points) Implementation: pure pandas/numpy, no external backtesting libs. Shared _compute_metrics helper across all strategies. 79 new tests (46 service unit + 33 route integration). All 391 tests passing.
142 lines
5.0 KiB
Python
142 lines
5.0 KiB
Python
"""Routes for backtesting strategies."""
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
import backtest_service
|
|
from models import ApiResponse
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/v1/backtest", tags=["backtest"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Request models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class BacktestRequest(BaseModel):
|
|
symbol: str = Field(..., min_length=1, max_length=20)
|
|
days: int = Field(default=365, ge=30, le=3650)
|
|
initial_capital: float = Field(default=10000.0, gt=0, le=1_000_000_000)
|
|
|
|
|
|
class SMARequest(BacktestRequest):
|
|
short_window: int = Field(default=20, ge=5, le=100)
|
|
long_window: int = Field(default=50, ge=10, le=400)
|
|
|
|
|
|
class RSIRequest(BacktestRequest):
|
|
period: int = Field(default=14, ge=2, le=50)
|
|
oversold: float = Field(default=30.0, ge=1, le=49)
|
|
overbought: float = Field(default=70.0, ge=51, le=99)
|
|
|
|
|
|
class BuyAndHoldRequest(BacktestRequest):
|
|
pass
|
|
|
|
|
|
class MomentumRequest(BaseModel):
|
|
symbols: list[str] = Field(..., min_length=2, max_length=20)
|
|
lookback: int = Field(default=60, ge=5, le=252)
|
|
top_n: int = Field(default=2, ge=1)
|
|
rebalance_days: int = Field(default=30, ge=5, le=252)
|
|
days: int = Field(default=365, ge=60, le=3650)
|
|
initial_capital: float = Field(default=10000.0, gt=0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Route handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/sma-crossover", response_model=ApiResponse)
|
|
async def sma_crossover(req: SMARequest) -> ApiResponse:
|
|
"""SMA crossover strategy: buy when short SMA crosses above long SMA."""
|
|
try:
|
|
result = await backtest_service.backtest_sma_crossover(
|
|
req.symbol,
|
|
short_window=req.short_window,
|
|
long_window=req.long_window,
|
|
days=req.days,
|
|
initial_capital=req.initial_capital,
|
|
)
|
|
return ApiResponse(data=result)
|
|
except ValueError as exc:
|
|
logger.warning("SMA crossover backtest validation error: %s", exc)
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
except Exception as exc:
|
|
logger.exception("SMA crossover backtest failed")
|
|
raise HTTPException(
|
|
status_code=502, detail="Data provider error. Check server logs."
|
|
) from exc
|
|
|
|
|
|
@router.post("/rsi", response_model=ApiResponse)
|
|
async def rsi_strategy(req: RSIRequest) -> ApiResponse:
|
|
"""RSI strategy: buy when RSI < oversold, sell when RSI > overbought."""
|
|
try:
|
|
result = await backtest_service.backtest_rsi(
|
|
req.symbol,
|
|
period=req.period,
|
|
oversold=req.oversold,
|
|
overbought=req.overbought,
|
|
days=req.days,
|
|
initial_capital=req.initial_capital,
|
|
)
|
|
return ApiResponse(data=result)
|
|
except ValueError as exc:
|
|
logger.warning("RSI backtest validation error: %s", exc)
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
except Exception as exc:
|
|
logger.exception("RSI backtest failed")
|
|
raise HTTPException(
|
|
status_code=502, detail="Data provider error. Check server logs."
|
|
) from exc
|
|
|
|
|
|
@router.post("/buy-and-hold", response_model=ApiResponse)
|
|
async def buy_and_hold(req: BuyAndHoldRequest) -> ApiResponse:
|
|
"""Buy-and-hold benchmark: buy on day 1, hold through end of period."""
|
|
try:
|
|
result = await backtest_service.backtest_buy_and_hold(
|
|
req.symbol,
|
|
days=req.days,
|
|
initial_capital=req.initial_capital,
|
|
)
|
|
return ApiResponse(data=result)
|
|
except ValueError as exc:
|
|
logger.warning("Buy-and-hold backtest validation error: %s", exc)
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
except Exception as exc:
|
|
logger.exception("Buy-and-hold backtest failed")
|
|
raise HTTPException(
|
|
status_code=502, detail="Data provider error. Check server logs."
|
|
) from exc
|
|
|
|
|
|
@router.post("/momentum", response_model=ApiResponse)
|
|
async def momentum_strategy(req: MomentumRequest) -> ApiResponse:
|
|
"""Momentum strategy: every rebalance_days pick top_n symbols by lookback return."""
|
|
try:
|
|
result = await backtest_service.backtest_momentum(
|
|
symbols=req.symbols,
|
|
lookback=req.lookback,
|
|
top_n=req.top_n,
|
|
rebalance_days=req.rebalance_days,
|
|
days=req.days,
|
|
initial_capital=req.initial_capital,
|
|
)
|
|
return ApiResponse(data=result)
|
|
except ValueError as exc:
|
|
logger.warning("Momentum backtest validation error: %s", exc)
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
except Exception as exc:
|
|
logger.exception("Momentum backtest failed")
|
|
raise HTTPException(
|
|
status_code=502, detail="Data provider error. Check server logs."
|
|
) from exc
|