"""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