"""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 # --------------------------------------------------------------------------- # cluster_stocks # --------------------------------------------------------------------------- def _make_prices(symbols: list[str], n_days: int = 60): """Build a deterministic price DataFrame with enough rows for t-SNE.""" import numpy as np import pandas as pd rng = np.random.default_rng(42) data = {} for sym in symbols: prices = 100.0 + np.cumsum(rng.normal(0, 1, n_days)) data[sym] = prices return pd.DataFrame(data) @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_cluster_stocks_happy_path(mock_fetch): """cluster_stocks returns valid structure for 6 symbols.""" import portfolio_service symbols = ["AAPL", "MSFT", "GOOGL", "AMZN", "JPM", "BAC"] mock_fetch.return_value = _make_prices(symbols) result = await portfolio_service.cluster_stocks(symbols, days=180) assert result["method"] == "t-SNE + KMeans" assert result["days"] == 180 assert set(result["symbols"]) == set(symbols) coords = result["coordinates"] assert len(coords) == len(symbols) for c in coords: assert "symbol" in c assert "x" in c assert "y" in c assert "cluster" in c assert isinstance(c["x"], float) assert isinstance(c["y"], float) assert isinstance(c["cluster"], int) clusters = result["clusters"] assert isinstance(clusters, dict) all_in_clusters = [] for members in clusters.values(): all_in_clusters.extend(members) assert set(all_in_clusters) == set(symbols) assert "n_clusters" in result assert result["n_clusters"] >= 2 @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_cluster_stocks_custom_n_clusters(mock_fetch): """Custom n_clusters is respected in the output.""" import portfolio_service symbols = ["AAPL", "MSFT", "GOOGL", "AMZN", "JPM", "BAC"] mock_fetch.return_value = _make_prices(symbols) result = await portfolio_service.cluster_stocks(symbols, days=180, n_clusters=3) assert result["n_clusters"] == 3 assert len(result["clusters"]) == 3 @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_cluster_stocks_minimum_three_symbols(mock_fetch): """cluster_stocks works correctly with exactly 3 symbols (minimum).""" import portfolio_service symbols = ["AAPL", "MSFT", "GOOGL"] mock_fetch.return_value = _make_prices(symbols) result = await portfolio_service.cluster_stocks(symbols, days=180) assert len(result["coordinates"]) == 3 assert set(result["symbols"]) == set(symbols) @pytest.mark.asyncio async def test_cluster_stocks_too_few_symbols_raises(): """cluster_stocks raises ValueError when fewer than 3 symbols are provided.""" import portfolio_service with pytest.raises(ValueError, match="at least 3"): await portfolio_service.cluster_stocks(["AAPL", "MSFT"], days=180) @pytest.mark.asyncio async def test_cluster_stocks_empty_symbols_raises(): """cluster_stocks raises ValueError for empty symbol list.""" import portfolio_service with pytest.raises(ValueError, match="at least 3"): await portfolio_service.cluster_stocks([], days=180) @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_cluster_stocks_no_data_raises(mock_fetch): """cluster_stocks raises ValueError when fetch returns empty DataFrame.""" import pandas as pd import portfolio_service mock_fetch.return_value = pd.DataFrame() with pytest.raises(ValueError, match="No price data"): await portfolio_service.cluster_stocks(["AAPL", "MSFT", "GOOGL"], days=180) @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_cluster_stocks_identical_returns_still_works(mock_fetch): """t-SNE should not raise even when all symbols have identical returns.""" import pandas as pd import portfolio_service # All columns identical — edge case for t-SNE flat = pd.DataFrame( { "AAPL": [100.0, 101.0, 102.0, 103.0, 104.0] * 12, "MSFT": [100.0, 101.0, 102.0, 103.0, 104.0] * 12, "GOOGL": [100.0, 101.0, 102.0, 103.0, 104.0] * 12, } ) mock_fetch.return_value = flat result = await portfolio_service.cluster_stocks( ["AAPL", "MSFT", "GOOGL"], days=180 ) assert len(result["coordinates"]) == 3 @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_cluster_stocks_coordinates_are_floats(mock_fetch): """x and y coordinates must be Python floats (JSON-serializable).""" import portfolio_service symbols = ["AAPL", "MSFT", "GOOGL", "AMZN"] mock_fetch.return_value = _make_prices(symbols) result = await portfolio_service.cluster_stocks(symbols, days=180) for c in result["coordinates"]: assert type(c["x"]) is float assert type(c["y"]) is float @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_cluster_stocks_clusters_key_is_str(mock_fetch): """clusters dict keys must be strings (JSON object keys).""" import portfolio_service symbols = ["AAPL", "MSFT", "GOOGL", "AMZN", "JPM", "BAC"] mock_fetch.return_value = _make_prices(symbols) result = await portfolio_service.cluster_stocks(symbols, days=180) for key in result["clusters"]: assert isinstance(key, str), f"Expected str key, got {type(key)}" # --------------------------------------------------------------------------- # find_similar_stocks # --------------------------------------------------------------------------- @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_find_similar_stocks_happy_path(mock_fetch): """most_similar is sorted descending by correlation; least_similar ascending.""" import portfolio_service symbols = ["AAPL", "MSFT", "GOOGL", "AMZN", "JPM", "BAC"] mock_fetch.return_value = _make_prices(symbols) result = await portfolio_service.find_similar_stocks( "AAPL", ["MSFT", "GOOGL", "AMZN", "JPM", "BAC"], days=180, top_n=3 ) assert result["symbol"] == "AAPL" most = result["most_similar"] least = result["least_similar"] assert len(most) <= 3 assert len(least) <= 3 # most_similar sorted descending corrs_most = [e["correlation"] for e in most] assert corrs_most == sorted(corrs_most, reverse=True) # least_similar sorted ascending corrs_least = [e["correlation"] for e in least] assert corrs_least == sorted(corrs_least) # Each entry has symbol and correlation for entry in most + least: assert "symbol" in entry assert "correlation" in entry assert isinstance(entry["correlation"], float) @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_find_similar_stocks_top_n_larger_than_universe(mock_fetch): """top_n larger than universe size is handled gracefully (returns all).""" import portfolio_service symbols = ["AAPL", "MSFT", "GOOGL"] mock_fetch.return_value = _make_prices(symbols) result = await portfolio_service.find_similar_stocks( "AAPL", ["MSFT", "GOOGL"], days=180, top_n=10 ) # Should return at most len(universe) entries, not crash assert len(result["most_similar"]) <= 2 assert len(result["least_similar"]) <= 2 @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_find_similar_stocks_no_overlap_with_most_and_least(mock_fetch): """most_similar and least_similar should not contain the target symbol.""" import portfolio_service symbols = ["AAPL", "MSFT", "GOOGL", "AMZN", "JPM"] mock_fetch.return_value = _make_prices(symbols) result = await portfolio_service.find_similar_stocks( "AAPL", ["MSFT", "GOOGL", "AMZN", "JPM"], days=180, top_n=2 ) all_symbols = [e["symbol"] for e in result["most_similar"] + result["least_similar"]] assert "AAPL" not in all_symbols @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_find_similar_stocks_no_data_raises(mock_fetch): """find_similar_stocks raises ValueError when no price data is returned.""" import pandas as pd import portfolio_service mock_fetch.return_value = pd.DataFrame() with pytest.raises(ValueError, match="No price data"): await portfolio_service.find_similar_stocks( "AAPL", ["MSFT", "GOOGL"], days=180, top_n=5 ) @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_find_similar_stocks_target_not_in_data_raises(mock_fetch): """find_similar_stocks raises ValueError when target symbol has no data.""" import portfolio_service # Only universe symbols have data, not the target mock_fetch.return_value = _make_prices(["MSFT", "GOOGL"]) with pytest.raises(ValueError, match="AAPL"): await portfolio_service.find_similar_stocks( "AAPL", ["MSFT", "GOOGL"], days=180, top_n=5 ) @pytest.mark.asyncio @patch("portfolio_service.fetch_historical_prices", new_callable=AsyncMock) async def test_find_similar_stocks_default_top_n(mock_fetch): """Default top_n=5 returns at most 5 entries in most_similar.""" import portfolio_service symbols = ["AAPL", "MSFT", "GOOGL", "AMZN", "JPM", "BAC", "WFC", "GS"] mock_fetch.return_value = _make_prices(symbols) result = await portfolio_service.find_similar_stocks( "AAPL", ["MSFT", "GOOGL", "AMZN", "JPM", "BAC", "WFC", "GS"], days=180, ) assert len(result["most_similar"]) <= 5 assert len(result["least_similar"]) <= 5