"""Tests for portfolio optimization routes (TDD - RED phase first).""" from unittest.mock import patch, AsyncMock import pytest from httpx import AsyncClient, ASGITransport 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 # --- POST /api/v1/portfolio/optimize --- @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.optimize_hrp", new_callable=AsyncMock) async def test_portfolio_optimize_happy_path(mock_fn, client): mock_fn.return_value = { "weights": {"AAPL": 0.35, "MSFT": 0.32, "GOOGL": 0.33}, "method": "hrp", } resp = await client.post( "/api/v1/portfolio/optimize", json={"symbols": ["AAPL", "MSFT", "GOOGL"], "days": 365}, ) assert resp.status_code == 200 data = resp.json() assert data["success"] is True assert data["data"]["method"] == "hrp" assert "AAPL" in data["data"]["weights"] mock_fn.assert_called_once_with(["AAPL", "MSFT", "GOOGL"], days=365) @pytest.mark.asyncio async def test_portfolio_optimize_missing_symbols(client): resp = await client.post("/api/v1/portfolio/optimize", json={"days": 365}) assert resp.status_code == 422 @pytest.mark.asyncio async def test_portfolio_optimize_empty_symbols(client): resp = await client.post( "/api/v1/portfolio/optimize", json={"symbols": [], "days": 365} ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_portfolio_optimize_too_many_symbols(client): symbols = [f"SYM{i}" for i in range(51)] resp = await client.post( "/api/v1/portfolio/optimize", json={"symbols": symbols, "days": 365} ) assert resp.status_code == 422 @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.optimize_hrp", new_callable=AsyncMock) async def test_portfolio_optimize_service_error_returns_502(mock_fn, client): mock_fn.side_effect = RuntimeError("Computation failed") resp = await client.post( "/api/v1/portfolio/optimize", json={"symbols": ["AAPL", "MSFT"], "days": 365}, ) assert resp.status_code == 502 @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.optimize_hrp", new_callable=AsyncMock) async def test_portfolio_optimize_value_error_returns_400(mock_fn, client): mock_fn.side_effect = ValueError("No price data available") resp = await client.post( "/api/v1/portfolio/optimize", json={"symbols": ["AAPL", "MSFT"], "days": 365}, ) assert resp.status_code == 400 @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.optimize_hrp", new_callable=AsyncMock) async def test_portfolio_optimize_default_days(mock_fn, client): mock_fn.return_value = {"weights": {"AAPL": 1.0}, "method": "hrp"} resp = await client.post( "/api/v1/portfolio/optimize", json={"symbols": ["AAPL"]} ) assert resp.status_code == 200 mock_fn.assert_called_once_with(["AAPL"], days=365) # --- POST /api/v1/portfolio/correlation --- @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.compute_correlation", new_callable=AsyncMock) async def test_portfolio_correlation_happy_path(mock_fn, client): mock_fn.return_value = { "symbols": ["AAPL", "MSFT"], "matrix": [[1.0, 0.85], [0.85, 1.0]], } resp = await client.post( "/api/v1/portfolio/correlation", json={"symbols": ["AAPL", "MSFT"], "days": 365}, ) assert resp.status_code == 200 data = resp.json() assert data["success"] is True assert data["data"]["symbols"] == ["AAPL", "MSFT"] assert data["data"]["matrix"][0][0] == pytest.approx(1.0) mock_fn.assert_called_once_with(["AAPL", "MSFT"], days=365) @pytest.mark.asyncio async def test_portfolio_correlation_missing_symbols(client): resp = await client.post("/api/v1/portfolio/correlation", json={"days": 365}) assert resp.status_code == 422 @pytest.mark.asyncio async def test_portfolio_correlation_empty_symbols(client): resp = await client.post( "/api/v1/portfolio/correlation", json={"symbols": [], "days": 365} ) assert resp.status_code == 422 @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.compute_correlation", new_callable=AsyncMock) async def test_portfolio_correlation_service_error_returns_502(mock_fn, client): mock_fn.side_effect = RuntimeError("Failed") resp = await client.post( "/api/v1/portfolio/correlation", json={"symbols": ["AAPL", "MSFT"], "days": 365}, ) assert resp.status_code == 502 @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.compute_correlation", new_callable=AsyncMock) async def test_portfolio_correlation_value_error_returns_400(mock_fn, client): mock_fn.side_effect = ValueError("No price data available") resp = await client.post( "/api/v1/portfolio/correlation", json={"symbols": ["AAPL", "MSFT"], "days": 365}, ) assert resp.status_code == 400 # --- POST /api/v1/portfolio/risk-parity --- @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.compute_risk_parity", new_callable=AsyncMock) async def test_portfolio_risk_parity_happy_path(mock_fn, client): mock_fn.return_value = { "weights": {"AAPL": 0.35, "MSFT": 0.33, "GOOGL": 0.32}, "risk_contributions": {"AAPL": 0.34, "MSFT": 0.33, "GOOGL": 0.33}, "method": "risk_parity", } resp = await client.post( "/api/v1/portfolio/risk-parity", json={"symbols": ["AAPL", "MSFT", "GOOGL"], "days": 365}, ) assert resp.status_code == 200 data = resp.json() assert data["success"] is True assert data["data"]["method"] == "risk_parity" assert "risk_contributions" in data["data"] mock_fn.assert_called_once_with(["AAPL", "MSFT", "GOOGL"], days=365) @pytest.mark.asyncio async def test_portfolio_risk_parity_missing_symbols(client): resp = await client.post("/api/v1/portfolio/risk-parity", json={"days": 365}) assert resp.status_code == 422 @pytest.mark.asyncio async def test_portfolio_risk_parity_empty_symbols(client): resp = await client.post( "/api/v1/portfolio/risk-parity", json={"symbols": [], "days": 365} ) assert resp.status_code == 422 @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.compute_risk_parity", new_callable=AsyncMock) async def test_portfolio_risk_parity_service_error_returns_502(mock_fn, client): mock_fn.side_effect = RuntimeError("Failed") resp = await client.post( "/api/v1/portfolio/risk-parity", json={"symbols": ["AAPL", "MSFT"], "days": 365}, ) assert resp.status_code == 502 @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.compute_risk_parity", new_callable=AsyncMock) async def test_portfolio_risk_parity_value_error_returns_400(mock_fn, client): mock_fn.side_effect = ValueError("No price data available") resp = await client.post( "/api/v1/portfolio/risk-parity", json={"symbols": ["AAPL", "MSFT"], "days": 365}, ) assert resp.status_code == 400 @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.compute_risk_parity", new_callable=AsyncMock) async def test_portfolio_risk_parity_default_days(mock_fn, client): mock_fn.return_value = { "weights": {"AAPL": 1.0}, "risk_contributions": {"AAPL": 1.0}, "method": "risk_parity", } resp = await client.post( "/api/v1/portfolio/risk-parity", json={"symbols": ["AAPL"]} ) assert resp.status_code == 200 mock_fn.assert_called_once_with(["AAPL"], days=365)