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.
444 lines
14 KiB
Python
444 lines
14 KiB
Python
"""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
|