feat: add portfolio optimization and congress tracking (TDD)
Portfolio optimization (3 endpoints): - POST /portfolio/optimize - HRP optimal weights via scipy clustering - POST /portfolio/correlation - pairwise correlation matrix - POST /portfolio/risk-parity - inverse-volatility risk parity weights Congress tracking (2 endpoints): - GET /regulators/congress/trades - congress member stock trades - GET /regulators/congress/bills?query= - search congress bills Implementation: - portfolio_service.py: HRP with scipy fallback to inverse-vol - congress_service.py: multi-provider fallback pattern - 51 new tests (14 portfolio unit, 20 portfolio route, 12 congress unit, 7 congress route) - All 312 tests passing
This commit is contained in:
263
tests/test_portfolio_service.py
Normal file
263
tests/test_portfolio_service.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user