feat: OpenBB Investment Analysis API
REST API wrapping OpenBB SDK for stock data, sentiment analysis, technical indicators, macro data, and rule-based portfolio analysis. - Stock data via yfinance (quote, profile, metrics, financials, historical, news) - News sentiment via Alpha Vantage (per-article, per-ticker scores) - Analyst data via Finnhub (recommendations, insider trades, upgrades) - Macro data via FRED (Fed rate, CPI, GDP, unemployment, treasury yields) - Technical indicators via openbb-technical (RSI, MACD, SMA, EMA, Bollinger) - Rule-based portfolio analysis engine (BUY_MORE/HOLD/SELL) - Stock discovery (gainers, losers, active, undervalued, growth) - 102 tests, all passing
This commit is contained in:
242
tests/test_routes.py
Normal file
242
tests/test_routes.py
Normal file
@@ -0,0 +1,242 @@
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch, AsyncMock
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health(client):
|
||||
resp = await client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
|
||||
# --- Symbol Validation ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_symbol_returns_400(client):
|
||||
resp = await client.get("/api/v1/stock/AAPL;DROP/quote")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_symbol_too_long_returns_422(client):
|
||||
resp = await client.get(f"/api/v1/stock/{'A' * 25}/quote")
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# --- Stock Endpoints ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes.openbb_service.get_quote", new_callable=AsyncMock)
|
||||
async def test_stock_quote(mock_quote, client):
|
||||
mock_quote.return_value = {
|
||||
"name": "Apple Inc.",
|
||||
"last_price": 180.0,
|
||||
"change": 2.5,
|
||||
"change_percent": 1.4,
|
||||
"volume": 50000000,
|
||||
"market_cap": 2800000000000,
|
||||
"currency": "USD",
|
||||
}
|
||||
resp = await client.get("/api/v1/stock/AAPL/quote")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["price"] == 180.0
|
||||
assert data["data"]["symbol"] == "AAPL"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes.openbb_service.get_profile", new_callable=AsyncMock)
|
||||
async def test_stock_profile(mock_profile, client):
|
||||
mock_profile.return_value = {
|
||||
"name": "Apple Inc.",
|
||||
"sector": "Technology",
|
||||
"industry": "Consumer Electronics",
|
||||
"country": "US",
|
||||
}
|
||||
resp = await client.get("/api/v1/stock/AAPL/profile")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["data"]["sector"] == "Technology"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes.openbb_service.get_metrics", new_callable=AsyncMock)
|
||||
async def test_stock_metrics(mock_metrics, client):
|
||||
mock_metrics.return_value = {"pe_ratio": 28.5, "roe": 0.15}
|
||||
resp = await client.get("/api/v1/stock/AAPL/metrics")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"]["pe_ratio"] == 28.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes.openbb_service.get_historical", new_callable=AsyncMock)
|
||||
async def test_stock_historical(mock_hist, client):
|
||||
mock_hist.return_value = [
|
||||
{"date": "2025-01-01", "open": 150, "close": 155, "volume": 1000000}
|
||||
]
|
||||
resp = await client.get("/api/v1/stock/AAPL/historical?days=30")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert len(data) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes.openbb_service.get_news", new_callable=AsyncMock)
|
||||
async def test_stock_news(mock_news, client):
|
||||
mock_news.return_value = [
|
||||
{"title": "Apple reports earnings", "url": "https://example.com", "date": "2025-01-01"}
|
||||
]
|
||||
resp = await client.get("/api/v1/stock/AAPL/news")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["data"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes.openbb_service.get_summary", new_callable=AsyncMock)
|
||||
async def test_stock_summary(mock_summary, client):
|
||||
mock_summary.return_value = {
|
||||
"quote": {"name": "Apple", "last_price": 180.0},
|
||||
"profile": {"name": "Apple", "sector": "Tech"},
|
||||
"metrics": {"pe_ratio": 28.0},
|
||||
"financials": {"symbol": "AAPL", "income": [], "balance": [], "cash_flow": []},
|
||||
}
|
||||
resp = await client.get("/api/v1/stock/AAPL/summary")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert data["quote"]["price"] == 180.0
|
||||
assert data["profile"]["sector"] == "Tech"
|
||||
|
||||
|
||||
# --- Error Handling ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes.openbb_service.get_quote", new_callable=AsyncMock)
|
||||
async def test_upstream_error_returns_502(mock_quote, client):
|
||||
mock_quote.side_effect = RuntimeError("Connection failed")
|
||||
resp = await client.get("/api/v1/stock/AAPL/quote")
|
||||
assert resp.status_code == 502
|
||||
assert "Data provider error" in resp.json()["detail"]
|
||||
# Verify raw exception is NOT leaked
|
||||
assert "Connection failed" not in resp.json()["detail"]
|
||||
|
||||
|
||||
# --- Portfolio Analysis ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes.analysis_service.analyze_portfolio", new_callable=AsyncMock)
|
||||
async def test_portfolio_analyze(mock_analyze, client):
|
||||
from models import (
|
||||
ActionEnum,
|
||||
AnalysisResult,
|
||||
ConfidenceEnum,
|
||||
HoldingAnalysis,
|
||||
PortfolioResponse,
|
||||
)
|
||||
|
||||
mock_analyze.return_value = PortfolioResponse(
|
||||
holdings=[
|
||||
HoldingAnalysis(
|
||||
symbol="AAPL",
|
||||
current_price=180.0,
|
||||
buy_in_price=150.0,
|
||||
shares=100,
|
||||
pnl=3000.0,
|
||||
pnl_percent=0.20,
|
||||
analysis=AnalysisResult(
|
||||
action=ActionEnum.HOLD,
|
||||
confidence=ConfidenceEnum.MEDIUM,
|
||||
reasons=["Within hold range"],
|
||||
),
|
||||
)
|
||||
],
|
||||
analyzed_at=datetime.now(timezone.utc),
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/v1/portfolio/analyze",
|
||||
json={
|
||||
"holdings": [
|
||||
{"symbol": "AAPL", "shares": 100, "buy_in_price": 150.0}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert len(data["holdings"]) == 1
|
||||
assert data["holdings"][0]["analysis"]["action"] == "HOLD"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_portfolio_analyze_empty_body(client):
|
||||
resp = await client.post(
|
||||
"/api/v1/portfolio/analyze",
|
||||
json={"holdings": []},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# --- Discovery Endpoints ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes.openbb_service.get_gainers", new_callable=AsyncMock)
|
||||
async def test_discover_gainers(mock_gainers, client):
|
||||
mock_gainers.return_value = [
|
||||
{"symbol": "TSLA", "name": "Tesla", "price": 250.0, "change_percent": 5.0}
|
||||
]
|
||||
resp = await client.get("/api/v1/discover/gainers")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["data"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes.openbb_service.get_losers", new_callable=AsyncMock)
|
||||
async def test_discover_losers(mock_losers, client):
|
||||
mock_losers.return_value = [
|
||||
{"symbol": "XYZ", "name": "XYZ Corp", "price": 10.0, "change_percent": -8.0}
|
||||
]
|
||||
resp = await client.get("/api/v1/discover/losers")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["data"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes.openbb_service.get_active", new_callable=AsyncMock)
|
||||
async def test_discover_active(mock_active, client):
|
||||
mock_active.return_value = [
|
||||
{"symbol": "NVDA", "name": "NVIDIA", "volume": 99000000}
|
||||
]
|
||||
resp = await client.get("/api/v1/discover/active")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["data"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes.openbb_service.get_undervalued", new_callable=AsyncMock)
|
||||
async def test_discover_undervalued(mock_uv, client):
|
||||
mock_uv.return_value = [{"symbol": "IBM", "name": "IBM"}]
|
||||
resp = await client.get("/api/v1/discover/undervalued")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes.openbb_service.get_growth", new_callable=AsyncMock)
|
||||
async def test_discover_growth(mock_growth, client):
|
||||
mock_growth.return_value = [{"symbol": "PLTR", "name": "Palantir"}]
|
||||
resp = await client.get("/api/v1/discover/growth")
|
||||
assert resp.status_code == 200
|
||||
Reference in New Issue
Block a user