"""Tests for portfolio optimization service (TDD - RED phase first).""" from unittest.mock import AsyncMock, patch import pytest # --- HRP Optimization --- @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_hrp_optimize_happy_path(mock_fetch): """HRP returns weights that sum to ~1.0 for valid symbols.""" import pandas as pd mock_fetch.return_value = pd.DataFrame( { "AAPL": [150.0, 151.0, 149.0, 152.0, 153.0], "MSFT": [300.0, 302.0, 298.0, 305.0, 307.0], "GOOGL": [2800.0, 2820.0, 2790.0, 2830.0, 2850.0], } ) import portfolio_service result = await portfolio_service.optimize_hrp( ["AAPL", "MSFT", "GOOGL"], days=365 ) assert result["method"] == "hrp" assert set(result["weights"].keys()) == {"AAPL", "MSFT", "GOOGL"} total = sum(result["weights"].values()) assert abs(total - 1.0) < 0.01 @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_hrp_optimize_single_symbol(mock_fetch): """Single symbol gets weight of 1.0.""" import pandas as pd mock_fetch.return_value = pd.DataFrame( {"AAPL": [150.0, 151.0, 149.0, 152.0, 153.0]} ) import portfolio_service result = await portfolio_service.optimize_hrp(["AAPL"], days=365) assert result["weights"]["AAPL"] == pytest.approx(1.0, abs=0.01) @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_hrp_optimize_no_data_raises(mock_fetch): """Raises ValueError when no price data is available.""" import pandas as pd mock_fetch.return_value = pd.DataFrame() import portfolio_service with pytest.raises(ValueError, match="No price data"): await portfolio_service.optimize_hrp(["AAPL", "MSFT"], days=365) @pytest.mark.asyncio async def test_hrp_optimize_empty_symbols_raises(): """Raises ValueError for empty symbol list.""" import portfolio_service with pytest.raises(ValueError, match="symbols"): await portfolio_service.optimize_hrp([], days=365) # --- Correlation Matrix --- @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_correlation_matrix_happy_path(mock_fetch): """Correlation matrix has 1.0 on diagonal and valid shape.""" import pandas as pd mock_fetch.return_value = pd.DataFrame( { "AAPL": [150.0, 151.0, 149.0, 152.0, 153.0], "MSFT": [300.0, 302.0, 298.0, 305.0, 307.0], "GOOGL": [2800.0, 2820.0, 2790.0, 2830.0, 2850.0], } ) import portfolio_service result = await portfolio_service.compute_correlation( ["AAPL", "MSFT", "GOOGL"], days=365 ) assert result["symbols"] == ["AAPL", "MSFT", "GOOGL"] matrix = result["matrix"] assert len(matrix) == 3 assert len(matrix[0]) == 3 # Diagonal should be 1.0 for i in range(3): assert abs(matrix[i][i] - 1.0) < 0.01 @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_correlation_matrix_two_symbols(mock_fetch): """Two-symbol correlation is symmetric.""" import pandas as pd mock_fetch.return_value = pd.DataFrame( { "AAPL": [150.0, 151.0, 149.0, 152.0, 153.0], "MSFT": [300.0, 302.0, 298.0, 305.0, 307.0], } ) import portfolio_service result = await portfolio_service.compute_correlation(["AAPL", "MSFT"], days=365) matrix = result["matrix"] # Symmetric: matrix[0][1] == matrix[1][0] assert abs(matrix[0][1] - matrix[1][0]) < 0.001 @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_correlation_no_data_raises(mock_fetch): """Raises ValueError when no data is returned.""" import pandas as pd mock_fetch.return_value = pd.DataFrame() import portfolio_service with pytest.raises(ValueError, match="No price data"): await portfolio_service.compute_correlation(["AAPL", "MSFT"], days=365) @pytest.mark.asyncio async def test_correlation_empty_symbols_raises(): """Raises ValueError for empty symbol list.""" import portfolio_service with pytest.raises(ValueError, match="symbols"): await portfolio_service.compute_correlation([], days=365) # --- Risk Parity --- @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_risk_parity_happy_path(mock_fetch): """Risk parity returns weights and risk_contributions summing to ~1.0.""" import pandas as pd mock_fetch.return_value = pd.DataFrame( { "AAPL": [150.0, 151.0, 149.0, 152.0, 153.0], "MSFT": [300.0, 302.0, 298.0, 305.0, 307.0], "GOOGL": [2800.0, 2820.0, 2790.0, 2830.0, 2850.0], } ) import portfolio_service result = await portfolio_service.compute_risk_parity( ["AAPL", "MSFT", "GOOGL"], days=365 ) assert result["method"] == "risk_parity" assert set(result["weights"].keys()) == {"AAPL", "MSFT", "GOOGL"} assert set(result["risk_contributions"].keys()) == {"AAPL", "MSFT", "GOOGL"} total_w = sum(result["weights"].values()) assert abs(total_w - 1.0) < 0.01 @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_risk_parity_single_symbol(mock_fetch): """Single symbol gets weight 1.0 and risk_contribution 1.0.""" import pandas as pd mock_fetch.return_value = pd.DataFrame( {"AAPL": [150.0, 151.0, 149.0, 152.0, 153.0]} ) import portfolio_service result = await portfolio_service.compute_risk_parity(["AAPL"], days=365) assert result["weights"]["AAPL"] == pytest.approx(1.0, abs=0.01) assert result["risk_contributions"]["AAPL"] == pytest.approx(1.0, abs=0.01) @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_risk_parity_no_data_raises(mock_fetch): """Raises ValueError when no price data is available.""" import pandas as pd mock_fetch.return_value = pd.DataFrame() import portfolio_service with pytest.raises(ValueError, match="No price data"): await portfolio_service.compute_risk_parity(["AAPL", "MSFT"], days=365) @pytest.mark.asyncio async def test_risk_parity_empty_symbols_raises(): """Raises ValueError for empty symbol list.""" import portfolio_service with pytest.raises(ValueError, match="symbols"): await portfolio_service.compute_risk_parity([], days=365) # --- fetch_historical_prices helper --- @pytest.mark.asyncio @patch("portfolio_service.fetch_historical") async def test_fetch_historical_prices_returns_dataframe(mock_fetch_hist): """fetch_historical_prices assembles a price DataFrame from OBBject results.""" import pandas as pd from unittest.mock import MagicMock mock_result = MagicMock() mock_result.results = [ MagicMock(date="2024-01-01", close=150.0), MagicMock(date="2024-01-02", close=151.0), ] mock_fetch_hist.return_value = mock_result import portfolio_service df = await portfolio_service.fetch_historical_prices(["AAPL"], days=30) assert isinstance(df, pd.DataFrame) assert "AAPL" in df.columns @pytest.mark.asyncio @patch("portfolio_service.fetch_historical") async def test_fetch_historical_prices_skips_none(mock_fetch_hist): """fetch_historical_prices returns empty DataFrame when all fetches fail.""" import pandas as pd mock_fetch_hist.return_value = None import portfolio_service df = await portfolio_service.fetch_historical_prices(["AAPL", "MSFT"], days=30) assert isinstance(df, pd.DataFrame) assert df.empty