"""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) # --------------------------------------------------------------------------- # POST /api/v1/portfolio/cluster # --------------------------------------------------------------------------- _CLUSTER_RESULT = { "symbols": ["AAPL", "MSFT", "GOOGL", "AMZN", "JPM", "BAC"], "coordinates": [ {"symbol": "AAPL", "x": 12.5, "y": -3.2, "cluster": 0}, {"symbol": "MSFT", "x": 11.8, "y": -2.9, "cluster": 0}, {"symbol": "GOOGL", "x": 10.1, "y": -1.5, "cluster": 0}, {"symbol": "AMZN", "x": 9.5, "y": -0.8, "cluster": 0}, {"symbol": "JPM", "x": -5.1, "y": 8.3, "cluster": 1}, {"symbol": "BAC", "x": -4.9, "y": 7.9, "cluster": 1}, ], "clusters": {"0": ["AAPL", "MSFT", "GOOGL", "AMZN"], "1": ["JPM", "BAC"]}, "method": "t-SNE + KMeans", "n_clusters": 2, "days": 180, } @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.cluster_stocks", new_callable=AsyncMock) async def test_portfolio_cluster_happy_path(mock_fn, client): """POST /cluster returns 200 with valid cluster result.""" mock_fn.return_value = _CLUSTER_RESULT resp = await client.post( "/api/v1/portfolio/cluster", json={"symbols": ["AAPL", "MSFT", "GOOGL", "AMZN", "JPM", "BAC"], "days": 180}, ) assert resp.status_code == 200 data = resp.json() assert data["success"] is True assert data["data"]["method"] == "t-SNE + KMeans" assert "coordinates" in data["data"] assert "clusters" in data["data"] mock_fn.assert_called_once_with( ["AAPL", "MSFT", "GOOGL", "AMZN", "JPM", "BAC"], days=180, n_clusters=None ) @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.cluster_stocks", new_callable=AsyncMock) async def test_portfolio_cluster_with_custom_n_clusters(mock_fn, client): """n_clusters is forwarded to service when provided.""" mock_fn.return_value = _CLUSTER_RESULT resp = await client.post( "/api/v1/portfolio/cluster", json={ "symbols": ["AAPL", "MSFT", "GOOGL", "AMZN", "JPM", "BAC"], "days": 180, "n_clusters": 3, }, ) assert resp.status_code == 200 mock_fn.assert_called_once_with( ["AAPL", "MSFT", "GOOGL", "AMZN", "JPM", "BAC"], days=180, n_clusters=3 ) @pytest.mark.asyncio async def test_portfolio_cluster_too_few_symbols_returns_422(client): """Fewer than 3 symbols triggers Pydantic validation error (422).""" resp = await client.post( "/api/v1/portfolio/cluster", json={"symbols": ["AAPL", "MSFT"], "days": 180}, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_portfolio_cluster_missing_symbols_returns_422(client): """Missing symbols field returns 422.""" resp = await client.post("/api/v1/portfolio/cluster", json={"days": 180}) assert resp.status_code == 422 @pytest.mark.asyncio async def test_portfolio_cluster_too_many_symbols_returns_422(client): """More than 50 symbols returns 422.""" symbols = [f"SYM{i}" for i in range(51)] resp = await client.post( "/api/v1/portfolio/cluster", json={"symbols": symbols, "days": 180} ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_portfolio_cluster_days_below_minimum_returns_422(client): """days < 30 returns 422.""" resp = await client.post( "/api/v1/portfolio/cluster", json={"symbols": ["AAPL", "MSFT", "GOOGL"], "days": 10}, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_portfolio_cluster_n_clusters_below_minimum_returns_422(client): """n_clusters < 2 returns 422.""" resp = await client.post( "/api/v1/portfolio/cluster", json={"symbols": ["AAPL", "MSFT", "GOOGL"], "days": 180, "n_clusters": 1}, ) assert resp.status_code == 422 @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.cluster_stocks", new_callable=AsyncMock) async def test_portfolio_cluster_value_error_returns_400(mock_fn, client): """ValueError from service returns 400.""" mock_fn.side_effect = ValueError("at least 3 symbols required") resp = await client.post( "/api/v1/portfolio/cluster", json={"symbols": ["AAPL", "MSFT", "GOOGL"], "days": 180}, ) assert resp.status_code == 400 @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.cluster_stocks", new_callable=AsyncMock) async def test_portfolio_cluster_upstream_error_returns_502(mock_fn, client): """Unexpected exception from service returns 502.""" mock_fn.side_effect = RuntimeError("upstream failure") resp = await client.post( "/api/v1/portfolio/cluster", json={"symbols": ["AAPL", "MSFT", "GOOGL"], "days": 180}, ) assert resp.status_code == 502 @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.cluster_stocks", new_callable=AsyncMock) async def test_portfolio_cluster_default_days(mock_fn, client): """Default days=180 is used when not provided.""" mock_fn.return_value = _CLUSTER_RESULT resp = await client.post( "/api/v1/portfolio/cluster", json={"symbols": ["AAPL", "MSFT", "GOOGL"]}, ) assert resp.status_code == 200 mock_fn.assert_called_once_with( ["AAPL", "MSFT", "GOOGL"], days=180, n_clusters=None ) # --------------------------------------------------------------------------- # POST /api/v1/portfolio/similar # --------------------------------------------------------------------------- _SIMILAR_RESULT = { "symbol": "AAPL", "most_similar": [ {"symbol": "MSFT", "correlation": 0.85}, {"symbol": "GOOGL", "correlation": 0.78}, ], "least_similar": [ {"symbol": "JPM", "correlation": 0.32}, {"symbol": "BAC", "correlation": 0.28}, ], } @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.find_similar_stocks", new_callable=AsyncMock) async def test_portfolio_similar_happy_path(mock_fn, client): """POST /similar returns 200 with most_similar and least_similar.""" mock_fn.return_value = _SIMILAR_RESULT resp = await client.post( "/api/v1/portfolio/similar", json={ "symbol": "AAPL", "universe": ["MSFT", "GOOGL", "AMZN", "JPM", "BAC"], "days": 180, "top_n": 2, }, ) assert resp.status_code == 200 data = resp.json() assert data["success"] is True assert data["data"]["symbol"] == "AAPL" assert "most_similar" in data["data"] assert "least_similar" in data["data"] mock_fn.assert_called_once_with( "AAPL", ["MSFT", "GOOGL", "AMZN", "JPM", "BAC"], days=180, top_n=2, ) @pytest.mark.asyncio async def test_portfolio_similar_missing_symbol_returns_422(client): """Missing symbol field returns 422.""" resp = await client.post( "/api/v1/portfolio/similar", json={"universe": ["MSFT", "GOOGL"], "days": 180}, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_portfolio_similar_missing_universe_returns_422(client): """Missing universe field returns 422.""" resp = await client.post( "/api/v1/portfolio/similar", json={"symbol": "AAPL", "days": 180}, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_portfolio_similar_universe_too_small_returns_422(client): """universe with fewer than 2 entries returns 422.""" resp = await client.post( "/api/v1/portfolio/similar", json={"symbol": "AAPL", "universe": ["MSFT"], "days": 180}, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_portfolio_similar_top_n_below_minimum_returns_422(client): """top_n < 1 returns 422.""" resp = await client.post( "/api/v1/portfolio/similar", json={"symbol": "AAPL", "universe": ["MSFT", "GOOGL"], "days": 180, "top_n": 0}, ) assert resp.status_code == 422 @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.find_similar_stocks", new_callable=AsyncMock) async def test_portfolio_similar_value_error_returns_400(mock_fn, client): """ValueError from service returns 400.""" mock_fn.side_effect = ValueError("AAPL not found in price data") resp = await client.post( "/api/v1/portfolio/similar", json={"symbol": "AAPL", "universe": ["MSFT", "GOOGL"], "days": 180}, ) assert resp.status_code == 400 @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.find_similar_stocks", new_callable=AsyncMock) async def test_portfolio_similar_upstream_error_returns_502(mock_fn, client): """Unexpected exception from service returns 502.""" mock_fn.side_effect = RuntimeError("upstream failure") resp = await client.post( "/api/v1/portfolio/similar", json={"symbol": "AAPL", "universe": ["MSFT", "GOOGL"], "days": 180}, ) assert resp.status_code == 502 @pytest.mark.asyncio @patch("routes_portfolio.portfolio_service.find_similar_stocks", new_callable=AsyncMock) async def test_portfolio_similar_default_top_n(mock_fn, client): """Default top_n=5 is passed to service when not specified.""" mock_fn.return_value = _SIMILAR_RESULT resp = await client.post( "/api/v1/portfolio/similar", json={"symbol": "AAPL", "universe": ["MSFT", "GOOGL", "AMZN"]}, ) assert resp.status_code == 200 mock_fn.assert_called_once_with("AAPL", ["MSFT", "GOOGL", "AMZN"], days=180, top_n=5)