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:
225
tests/test_routes_portfolio.py
Normal file
225
tests/test_routes_portfolio.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user