feat: add backtesting engine with 4 strategies (TDD)

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.
This commit is contained in:
Yaojia Wang
2026-03-19 22:35:00 +01:00
parent 42ba359c48
commit 5c7a0ee4c0
5 changed files with 1585 additions and 0 deletions

View File

@@ -0,0 +1,443 @@
"""Integration tests for backtest routes - written FIRST (TDD RED phase)."""
from unittest.mock import AsyncMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from main import app
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
# Shared mock response used across strategy tests
MOCK_BACKTEST_RESULT = {
"total_return": 0.15,
"annualized_return": 0.14,
"sharpe_ratio": 1.2,
"max_drawdown": -0.08,
"win_rate": 0.6,
"total_trades": 10,
"equity_curve": [10000 + i * 75 for i in range(20)],
}
MOCK_MOMENTUM_RESULT = {
**MOCK_BACKTEST_RESULT,
"allocation_history": [
{"date": "2024-01-01", "symbols": ["AAPL", "MSFT"], "weights": [0.5, 0.5]},
],
}
# ---------------------------------------------------------------------------
# POST /api/v1/backtest/sma-crossover
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("routes_backtest.backtest_service.backtest_sma_crossover", new_callable=AsyncMock)
async def test_sma_crossover_happy_path(mock_fn, client):
mock_fn.return_value = MOCK_BACKTEST_RESULT
resp = await client.post(
"/api/v1/backtest/sma-crossover",
json={
"symbol": "AAPL",
"short_window": 20,
"long_window": 50,
"days": 365,
"initial_capital": 10000,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["data"]["total_return"] == pytest.approx(0.15)
assert data["data"]["total_trades"] == 10
assert len(data["data"]["equity_curve"]) == 20
mock_fn.assert_called_once_with(
"AAPL",
short_window=20,
long_window=50,
days=365,
initial_capital=10000.0,
)
@pytest.mark.asyncio
@patch("routes_backtest.backtest_service.backtest_sma_crossover", new_callable=AsyncMock)
async def test_sma_crossover_default_values(mock_fn, client):
mock_fn.return_value = MOCK_BACKTEST_RESULT
resp = await client.post(
"/api/v1/backtest/sma-crossover",
json={"symbol": "MSFT"},
)
assert resp.status_code == 200
mock_fn.assert_called_once_with(
"MSFT",
short_window=20,
long_window=50,
days=365,
initial_capital=10000.0,
)
@pytest.mark.asyncio
async def test_sma_crossover_missing_symbol(client):
resp = await client.post(
"/api/v1/backtest/sma-crossover",
json={"short_window": 20, "long_window": 50},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_sma_crossover_short_window_too_small(client):
resp = await client.post(
"/api/v1/backtest/sma-crossover",
json={"symbol": "AAPL", "short_window": 2, "long_window": 50},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_sma_crossover_long_window_too_large(client):
resp = await client.post(
"/api/v1/backtest/sma-crossover",
json={"symbol": "AAPL", "short_window": 20, "long_window": 500},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_sma_crossover_days_too_few(client):
resp = await client.post(
"/api/v1/backtest/sma-crossover",
json={"symbol": "AAPL", "days": 5},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_sma_crossover_days_too_many(client):
resp = await client.post(
"/api/v1/backtest/sma-crossover",
json={"symbol": "AAPL", "days": 9999},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_sma_crossover_capital_zero(client):
resp = await client.post(
"/api/v1/backtest/sma-crossover",
json={"symbol": "AAPL", "initial_capital": 0},
)
assert resp.status_code == 422
@pytest.mark.asyncio
@patch("routes_backtest.backtest_service.backtest_sma_crossover", new_callable=AsyncMock)
async def test_sma_crossover_service_error_returns_502(mock_fn, client):
mock_fn.side_effect = RuntimeError("Data fetch failed")
resp = await client.post(
"/api/v1/backtest/sma-crossover",
json={"symbol": "AAPL", "short_window": 20, "long_window": 50},
)
assert resp.status_code == 502
@pytest.mark.asyncio
@patch("routes_backtest.backtest_service.backtest_sma_crossover", new_callable=AsyncMock)
async def test_sma_crossover_value_error_returns_400(mock_fn, client):
mock_fn.side_effect = ValueError("No historical data")
resp = await client.post(
"/api/v1/backtest/sma-crossover",
json={"symbol": "AAPL", "short_window": 20, "long_window": 50},
)
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# POST /api/v1/backtest/rsi
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("routes_backtest.backtest_service.backtest_rsi", new_callable=AsyncMock)
async def test_rsi_happy_path(mock_fn, client):
mock_fn.return_value = MOCK_BACKTEST_RESULT
resp = await client.post(
"/api/v1/backtest/rsi",
json={
"symbol": "AAPL",
"period": 14,
"oversold": 30,
"overbought": 70,
"days": 365,
"initial_capital": 10000,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["data"]["sharpe_ratio"] == pytest.approx(1.2)
mock_fn.assert_called_once_with(
"AAPL",
period=14,
oversold=30.0,
overbought=70.0,
days=365,
initial_capital=10000.0,
)
@pytest.mark.asyncio
@patch("routes_backtest.backtest_service.backtest_rsi", new_callable=AsyncMock)
async def test_rsi_default_values(mock_fn, client):
mock_fn.return_value = MOCK_BACKTEST_RESULT
resp = await client.post("/api/v1/backtest/rsi", json={"symbol": "AAPL"})
assert resp.status_code == 200
mock_fn.assert_called_once_with(
"AAPL",
period=14,
oversold=30.0,
overbought=70.0,
days=365,
initial_capital=10000.0,
)
@pytest.mark.asyncio
async def test_rsi_missing_symbol(client):
resp = await client.post("/api/v1/backtest/rsi", json={"period": 14})
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_rsi_period_too_small(client):
resp = await client.post(
"/api/v1/backtest/rsi",
json={"symbol": "AAPL", "period": 1},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_rsi_oversold_too_high(client):
# oversold must be < 50
resp = await client.post(
"/api/v1/backtest/rsi",
json={"symbol": "AAPL", "oversold": 55, "overbought": 70},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_rsi_overbought_too_low(client):
# overbought must be > 50
resp = await client.post(
"/api/v1/backtest/rsi",
json={"symbol": "AAPL", "oversold": 30, "overbought": 45},
)
assert resp.status_code == 422
@pytest.mark.asyncio
@patch("routes_backtest.backtest_service.backtest_rsi", new_callable=AsyncMock)
async def test_rsi_service_error_returns_502(mock_fn, client):
mock_fn.side_effect = RuntimeError("upstream error")
resp = await client.post("/api/v1/backtest/rsi", json={"symbol": "AAPL"})
assert resp.status_code == 502
@pytest.mark.asyncio
@patch("routes_backtest.backtest_service.backtest_rsi", new_callable=AsyncMock)
async def test_rsi_value_error_returns_400(mock_fn, client):
mock_fn.side_effect = ValueError("Insufficient data")
resp = await client.post("/api/v1/backtest/rsi", json={"symbol": "AAPL"})
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# POST /api/v1/backtest/buy-and-hold
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("routes_backtest.backtest_service.backtest_buy_and_hold", new_callable=AsyncMock)
async def test_buy_and_hold_happy_path(mock_fn, client):
mock_fn.return_value = MOCK_BACKTEST_RESULT
resp = await client.post(
"/api/v1/backtest/buy-and-hold",
json={"symbol": "AAPL", "days": 365, "initial_capital": 10000},
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["data"]["total_return"] == pytest.approx(0.15)
mock_fn.assert_called_once_with("AAPL", days=365, initial_capital=10000.0)
@pytest.mark.asyncio
@patch("routes_backtest.backtest_service.backtest_buy_and_hold", new_callable=AsyncMock)
async def test_buy_and_hold_default_values(mock_fn, client):
mock_fn.return_value = MOCK_BACKTEST_RESULT
resp = await client.post("/api/v1/backtest/buy-and-hold", json={"symbol": "AAPL"})
assert resp.status_code == 200
mock_fn.assert_called_once_with("AAPL", days=365, initial_capital=10000.0)
@pytest.mark.asyncio
async def test_buy_and_hold_missing_symbol(client):
resp = await client.post("/api/v1/backtest/buy-and-hold", json={"days": 365})
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_buy_and_hold_days_too_few(client):
resp = await client.post(
"/api/v1/backtest/buy-and-hold",
json={"symbol": "AAPL", "days": 10},
)
assert resp.status_code == 422
@pytest.mark.asyncio
@patch("routes_backtest.backtest_service.backtest_buy_and_hold", new_callable=AsyncMock)
async def test_buy_and_hold_service_error_returns_502(mock_fn, client):
mock_fn.side_effect = RuntimeError("provider down")
resp = await client.post("/api/v1/backtest/buy-and-hold", json={"symbol": "AAPL"})
assert resp.status_code == 502
@pytest.mark.asyncio
@patch("routes_backtest.backtest_service.backtest_buy_and_hold", new_callable=AsyncMock)
async def test_buy_and_hold_value_error_returns_400(mock_fn, client):
mock_fn.side_effect = ValueError("No historical data")
resp = await client.post("/api/v1/backtest/buy-and-hold", json={"symbol": "AAPL"})
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# POST /api/v1/backtest/momentum
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("routes_backtest.backtest_service.backtest_momentum", new_callable=AsyncMock)
async def test_momentum_happy_path(mock_fn, client):
mock_fn.return_value = MOCK_MOMENTUM_RESULT
resp = await client.post(
"/api/v1/backtest/momentum",
json={
"symbols": ["AAPL", "MSFT", "GOOGL"],
"lookback": 60,
"top_n": 2,
"rebalance_days": 30,
"days": 365,
"initial_capital": 10000,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert "allocation_history" in data["data"]
assert data["data"]["total_trades"] == 10
mock_fn.assert_called_once_with(
symbols=["AAPL", "MSFT", "GOOGL"],
lookback=60,
top_n=2,
rebalance_days=30,
days=365,
initial_capital=10000.0,
)
@pytest.mark.asyncio
@patch("routes_backtest.backtest_service.backtest_momentum", new_callable=AsyncMock)
async def test_momentum_default_values(mock_fn, client):
mock_fn.return_value = MOCK_MOMENTUM_RESULT
resp = await client.post(
"/api/v1/backtest/momentum",
json={"symbols": ["AAPL", "MSFT"]},
)
assert resp.status_code == 200
mock_fn.assert_called_once_with(
symbols=["AAPL", "MSFT"],
lookback=60,
top_n=2,
rebalance_days=30,
days=365,
initial_capital=10000.0,
)
@pytest.mark.asyncio
async def test_momentum_missing_symbols(client):
resp = await client.post("/api/v1/backtest/momentum", json={"lookback": 60})
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_momentum_too_few_symbols(client):
# min_length=2 on symbols list
resp = await client.post(
"/api/v1/backtest/momentum",
json={"symbols": ["AAPL"]},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_momentum_too_many_symbols(client):
symbols = [f"SYM{i}" for i in range(25)]
resp = await client.post(
"/api/v1/backtest/momentum",
json={"symbols": symbols},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_momentum_lookback_too_small(client):
resp = await client.post(
"/api/v1/backtest/momentum",
json={"symbols": ["AAPL", "MSFT"], "lookback": 2},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_momentum_days_too_few(client):
resp = await client.post(
"/api/v1/backtest/momentum",
json={"symbols": ["AAPL", "MSFT"], "days": 10},
)
assert resp.status_code == 422
@pytest.mark.asyncio
@patch("routes_backtest.backtest_service.backtest_momentum", new_callable=AsyncMock)
async def test_momentum_service_error_returns_502(mock_fn, client):
mock_fn.side_effect = RuntimeError("provider down")
resp = await client.post(
"/api/v1/backtest/momentum",
json={"symbols": ["AAPL", "MSFT"]},
)
assert resp.status_code == 502
@pytest.mark.asyncio
@patch("routes_backtest.backtest_service.backtest_momentum", new_callable=AsyncMock)
async def test_momentum_value_error_returns_400(mock_fn, client):
mock_fn.side_effect = ValueError("No price data available")
resp = await client.post(
"/api/v1/backtest/momentum",
json={"symbols": ["AAPL", "MSFT"]},
)
assert resp.status_code == 400