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:
Yaojia Wang
2026-03-09 00:20:10 +01:00
commit ad45cb429c
30 changed files with 3107 additions and 0 deletions

0
tests/__init__.py Normal file
View File

1
tests/conftest.py Normal file
View File

@@ -0,0 +1 @@
# pythonpath configured in pyproject.toml [tool.pytest.ini_options]

View File

@@ -0,0 +1,193 @@
from unittest.mock import AsyncMock, patch, MagicMock
import pytest
from alphavantage_service import (
_compute_overall_sentiment,
_find_ticker_sentiment,
_parse_article,
_safe_float,
get_news_sentiment,
)
# --- Unit tests for helpers ---
def test_safe_float_valid():
assert _safe_float("0.35") == 0.35
assert _safe_float(1) == 1.0
assert _safe_float(0.0) == 0.0
def test_safe_float_invalid():
assert _safe_float(None) is None
assert _safe_float("abc") is None
assert _safe_float({}) is None
def test_find_ticker_sentiment_found():
sentiments = [
{"ticker": "MSFT", "ticker_sentiment_score": "0.2", "ticker_sentiment_label": "Bullish", "relevance_score": "0.5"},
{"ticker": "AAPL", "ticker_sentiment_score": "0.45", "ticker_sentiment_label": "Bullish", "relevance_score": "0.9"},
]
result = _find_ticker_sentiment(sentiments, "AAPL")
assert result["score"] == 0.45
assert result["label"] == "Bullish"
assert result["relevance"] == 0.9
def test_find_ticker_sentiment_not_found():
result = _find_ticker_sentiment([], "AAPL")
assert result["score"] is None
assert result["label"] is None
def test_find_ticker_sentiment_case_insensitive():
sentiments = [{"ticker": "aapl", "ticker_sentiment_score": "0.1", "ticker_sentiment_label": "Neutral", "relevance_score": "0.5"}]
result = _find_ticker_sentiment(sentiments, "AAPL")
assert result["score"] == 0.1
def test_parse_article():
article = {
"title": "Apple rises",
"url": "https://example.com",
"source": "Reuters",
"time_published": "20260308T120000",
"summary": "Apple stock rose today.",
"overall_sentiment_score": "0.3",
"overall_sentiment_label": "Somewhat-Bullish",
"ticker_sentiment": [
{"ticker": "AAPL", "ticker_sentiment_score": "0.5", "ticker_sentiment_label": "Bullish", "relevance_score": "0.95"},
],
"topics": [{"topic": "Technology"}, {"topic": "Earnings"}],
}
result = _parse_article(article, "AAPL")
assert result["title"] == "Apple rises"
assert result["ticker_sentiment_score"] == 0.5
assert result["ticker_sentiment_label"] == "Bullish"
assert result["topics"] == ["Technology", "Earnings"]
def test_compute_overall_sentiment_bullish():
articles = [
{"ticker_sentiment_score": 0.5},
{"ticker_sentiment_score": 0.4},
{"ticker_sentiment_score": 0.3},
]
result = _compute_overall_sentiment(articles)
assert result["label"] == "Bullish"
assert result["bullish_count"] == 3
assert result["bearish_count"] == 0
assert result["total_scored"] == 3
def test_compute_overall_sentiment_bearish():
articles = [
{"ticker_sentiment_score": -0.5},
{"ticker_sentiment_score": -0.4},
]
result = _compute_overall_sentiment(articles)
assert result["label"] == "Bearish"
assert result["bearish_count"] == 2
def test_compute_overall_sentiment_neutral():
articles = [
{"ticker_sentiment_score": 0.05},
{"ticker_sentiment_score": -0.05},
]
result = _compute_overall_sentiment(articles)
assert result["label"] == "Neutral"
def test_compute_overall_sentiment_empty():
result = _compute_overall_sentiment([])
assert result["label"] == "Unknown"
assert result["avg_score"] is None
assert result["total_scored"] == 0
def test_compute_overall_sentiment_skips_none():
articles = [
{"ticker_sentiment_score": 0.5},
{"ticker_sentiment_score": None},
]
result = _compute_overall_sentiment(articles)
assert result["total_scored"] == 1
# --- Integration tests for get_news_sentiment ---
@pytest.mark.asyncio
@patch("alphavantage_service.settings")
async def test_get_news_sentiment_not_configured(mock_settings):
mock_settings.alphavantage_api_key = ""
result = await get_news_sentiment("AAPL")
assert result["configured"] is False
@pytest.mark.asyncio
@patch("alphavantage_service.httpx.AsyncClient")
@patch("alphavantage_service.settings")
async def test_get_news_sentiment_success(mock_settings, mock_client_cls):
mock_settings.alphavantage_api_key = "test-key"
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {
"feed": [
{
"title": "AAPL up",
"url": "https://example.com",
"source": "Reuters",
"time_published": "20260308T120000",
"summary": "Good news",
"overall_sentiment_score": "0.4",
"overall_sentiment_label": "Bullish",
"ticker_sentiment": [
{"ticker": "AAPL", "ticker_sentiment_score": "0.5", "ticker_sentiment_label": "Bullish", "relevance_score": "0.9"},
],
"topics": [{"topic": "Tech"}],
}
],
}
mock_client = AsyncMock()
mock_client.get.return_value = mock_resp
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = mock_client
result = await get_news_sentiment("AAPL")
assert result["configured"] is True
assert result["symbol"] == "AAPL"
assert result["article_count"] == 1
assert result["overall_sentiment"]["label"] == "Bullish"
assert result["articles"][0]["ticker_sentiment_score"] == 0.5
@pytest.mark.asyncio
@patch("alphavantage_service.httpx.AsyncClient")
@patch("alphavantage_service.settings")
async def test_get_news_sentiment_api_error(mock_settings, mock_client_cls):
mock_settings.alphavantage_api_key = "test-key"
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"Error Message": "Invalid API call"}
mock_client = AsyncMock()
mock_client.get.return_value = mock_resp
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = mock_client
result = await get_news_sentiment("AAPL")
assert result["configured"] is True
assert result["articles"] == []
assert "Invalid API call" in result["error"]

View File

@@ -0,0 +1,132 @@
from models import ActionEnum, ConfidenceEnum
from analysis_service import (
compute_analysis,
_score_pnl,
_score_pe,
_score_revenue_growth,
_score_target_price,
)
class TestScorePnl:
def test_large_loss_suggests_buy(self):
score, reason = _score_pnl(-0.25)
assert score == 1
assert "averaging down" in reason
def test_large_profit_suggests_sell(self):
score, reason = _score_pnl(0.60)
assert score == -1
assert "taking profit" in reason
def test_moderate_suggests_hold(self):
score, reason = _score_pnl(0.10)
assert score == 0
class TestScorePe:
def test_low_pe(self):
score, _ = _score_pe(10.0)
assert score == 1
def test_high_pe(self):
score, _ = _score_pe(50.0)
assert score == -1
def test_normal_pe(self):
score, _ = _score_pe(20.0)
assert score == 0
def test_negative_pe(self):
score, _ = _score_pe(-5.0)
assert score == -1
def test_none_pe(self):
score, reason = _score_pe(None)
assert score == 0
assert reason is None
class TestScoreRevenueGrowth:
def test_strong_growth(self):
score, _ = _score_revenue_growth(0.20)
assert score == 1
def test_negative_growth(self):
score, _ = _score_revenue_growth(-0.05)
assert score == -1
def test_moderate_growth(self):
score, _ = _score_revenue_growth(0.05)
assert score == 0
def test_none(self):
score, reason = _score_revenue_growth(None)
assert score == 0
class TestScoreTargetPrice:
def test_big_upside(self):
score, _ = _score_target_price(100.0, 120.0)
assert score == 1
def test_big_downside(self):
score, _ = _score_target_price(100.0, 85.0)
assert score == -1
def test_near_target(self):
score, _ = _score_target_price(100.0, 105.0)
assert score == 0
def test_none_price(self):
score, _ = _score_target_price(None, 120.0)
assert score == 0
class TestComputeAnalysis:
def test_strong_buy_signals(self):
result = compute_analysis(
current_price=100.0,
buy_in_price=130.0, # loss > 20%
target_price=120.0, # upside > 15%
metrics={"pe_ratio": 10.0, "revenue_growth": 0.20},
)
assert result.action == ActionEnum.BUY_MORE
assert result.confidence == ConfidenceEnum.HIGH
def test_strong_sell_signals(self):
result = compute_analysis(
current_price=200.0,
buy_in_price=100.0, # profit > 50%
target_price=170.0, # downside
metrics={"pe_ratio": 50.0, "revenue_growth": -0.10},
)
assert result.action == ActionEnum.SELL
def test_mixed_signals_hold(self):
result = compute_analysis(
current_price=100.0,
buy_in_price=95.0,
target_price=105.0,
metrics={"pe_ratio": 20.0, "revenue_growth": 0.05},
)
assert result.action == ActionEnum.HOLD
def test_no_data(self):
result = compute_analysis(
current_price=None,
buy_in_price=100.0,
target_price=None,
metrics={},
)
assert result.action == ActionEnum.HOLD
assert result.confidence == ConfidenceEnum.LOW
def test_reasons_populated(self):
result = compute_analysis(
current_price=100.0,
buy_in_price=90.0,
target_price=110.0,
metrics={"pe_ratio": 25.0},
)
assert len(result.reasons) > 0

View File

@@ -0,0 +1,94 @@
from unittest.mock import patch, AsyncMock, MagicMock
import pytest
import finnhub_service
@pytest.mark.asyncio
@patch("finnhub_service.settings")
async def test_not_configured_returns_empty(mock_settings):
mock_settings.finnhub_api_key = ""
result = await finnhub_service.get_news_sentiment("AAPL")
assert result == {}
@pytest.mark.asyncio
@patch("finnhub_service.settings")
async def test_not_configured_returns_message(mock_settings):
mock_settings.finnhub_api_key = ""
result = await finnhub_service.get_sentiment_summary("AAPL")
assert result["configured"] is False
@pytest.mark.asyncio
@patch("finnhub_service.settings")
async def test_insider_not_configured(mock_settings):
mock_settings.finnhub_api_key = ""
result = await finnhub_service.get_insider_transactions("AAPL")
assert result == []
@pytest.mark.asyncio
@patch("finnhub_service.settings")
async def test_recommendations_not_configured(mock_settings):
mock_settings.finnhub_api_key = ""
result = await finnhub_service.get_recommendation_trends("AAPL")
assert result == []
@pytest.mark.asyncio
@patch("finnhub_service.settings")
async def test_upgrade_downgrade_not_configured(mock_settings):
mock_settings.finnhub_api_key = ""
result = await finnhub_service.get_upgrade_downgrade("AAPL")
assert result == []
@pytest.mark.asyncio
@patch("finnhub_service.settings")
@patch("finnhub_service.httpx.AsyncClient")
async def test_news_sentiment_with_key(mock_client_cls, mock_settings):
mock_settings.finnhub_api_key = "test_key"
mock_resp = MagicMock()
mock_resp.json.return_value = {
"buzz": {"articlesInLastWeek": 10},
"sentiment": {"bullishPercent": 0.7, "bearishPercent": 0.3},
"companyNewsScore": 0.65,
"symbol": "AAPL",
}
mock_resp.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_resp
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = mock_client
result = await finnhub_service.get_news_sentiment("AAPL")
assert result["symbol"] == "AAPL"
assert result["sentiment"]["bullishPercent"] == 0.7
@pytest.mark.asyncio
@patch("finnhub_service.settings")
@patch("finnhub_service.httpx.AsyncClient")
async def test_insider_transactions_with_key(mock_client_cls, mock_settings):
mock_settings.finnhub_api_key = "test_key"
mock_resp = MagicMock()
mock_resp.json.return_value = {
"data": [
{"name": "Tim Cook", "share": 100000, "change": -50000, "transactionCode": "S"}
]
}
mock_resp.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_resp
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = mock_client
result = await finnhub_service.get_insider_transactions("AAPL")
assert len(result) == 1
assert result[0]["name"] == "Tim Cook"

68
tests/test_mappers.py Normal file
View File

@@ -0,0 +1,68 @@
from mappers import (
discover_items_from_list,
metrics_from_dict,
profile_from_dict,
quote_from_dict,
)
class TestQuoteFromDict:
def test_basic(self):
q = quote_from_dict("AAPL", {"name": "Apple", "last_price": 180.0})
assert q.symbol == "AAPL"
assert q.price == 180.0
def test_fallback_to_close(self):
q = quote_from_dict("AAPL", {"close": 175.0})
assert q.price == 175.0
def test_empty_dict(self):
q = quote_from_dict("AAPL", {})
assert q.price is None
class TestProfileFromDict:
def test_basic(self):
p = profile_from_dict("AAPL", {"name": "Apple", "sector": "Tech"})
assert p.sector == "Tech"
def test_description_fallback(self):
p = profile_from_dict("AAPL", {"long_description": "A company"})
assert p.description == "A company"
def test_employees_fallback(self):
p = profile_from_dict("AAPL", {"full_time_employees": 150000})
assert p.employees == 150000
class TestMetricsFromDict:
def test_basic(self):
m = metrics_from_dict("AAPL", {"pe_ratio": 28.0, "roe": 0.15})
assert m.pe_ratio == 28.0
assert m.roe == 0.15
def test_roe_fallback(self):
m = metrics_from_dict("AAPL", {"return_on_equity": 0.20})
assert m.roe == 0.20
def test_eps_fallback(self):
m = metrics_from_dict("AAPL", {"eps_ttm": 6.5})
assert m.eps == 6.5
def test_empty_dict(self):
m = metrics_from_dict("AAPL", {})
assert m.pe_ratio is None
class TestDiscoverItemsFromList:
def test_basic(self):
items = discover_items_from_list([
{"symbol": "TSLA", "price": 250.0},
{"symbol": "AAPL", "last_price": 180.0},
])
assert len(items) == 2
assert items[0]["symbol"] == "TSLA"
assert items[1]["price"] == 180.0
def test_empty_list(self):
assert discover_items_from_list([]) == []

149
tests/test_models.py Normal file
View File

@@ -0,0 +1,149 @@
from datetime import datetime, timezone
from pydantic import ValidationError
from models import (
ActionEnum,
AnalysisResult,
ConfidenceEnum,
Holding,
HoldingAnalysis,
MetricsResponse,
PortfolioRequest,
PortfolioResponse,
QuoteResponse,
)
def test_holding_valid():
h = Holding(symbol="AAPL", shares=100, buy_in_price=150.0)
assert h.symbol == "AAPL"
assert h.shares == 100
assert h.buy_in_price == 150.0
def test_holding_swedish_symbol():
h = Holding(symbol="VOLV-B.ST", shares=50, buy_in_price=250.0)
assert h.symbol == "VOLV-B.ST"
def test_holding_symbol_uppercased():
h = Holding(symbol="aapl", shares=10, buy_in_price=150.0)
assert h.symbol == "AAPL"
def test_holding_invalid_symbol_format():
try:
Holding(symbol="AAPL;DROP TABLE", shares=10, buy_in_price=150.0)
assert False, "Should have raised"
except ValidationError:
pass
def test_holding_symbol_too_long():
try:
Holding(symbol="A" * 21, shares=10, buy_in_price=150.0)
assert False, "Should have raised"
except ValidationError:
pass
def test_holding_invalid_shares():
try:
Holding(symbol="AAPL", shares=0, buy_in_price=150.0)
assert False, "Should have raised"
except ValidationError:
pass
def test_holding_invalid_price():
try:
Holding(symbol="AAPL", shares=10, buy_in_price=-5.0)
assert False, "Should have raised"
except ValidationError:
pass
def test_portfolio_request_empty():
try:
PortfolioRequest(holdings=[])
assert False, "Should have raised"
except ValidationError:
pass
def test_portfolio_request_too_many():
holdings = [
Holding(symbol="AAPL", shares=1, buy_in_price=100)
for _ in range(51)
]
try:
PortfolioRequest(holdings=holdings)
assert False, "Should have raised"
except ValidationError:
pass
def test_portfolio_request_valid():
req = PortfolioRequest(
holdings=[Holding(symbol="AAPL", shares=10, buy_in_price=150)]
)
assert len(req.holdings) == 1
def test_quote_response_defaults():
q = QuoteResponse(symbol="AAPL")
assert q.price is None
assert q.change is None
def test_metrics_response():
m = MetricsResponse(symbol="AAPL", pe_ratio=25.0, roe=0.15)
assert m.pe_ratio == 25.0
assert m.pb_ratio is None
def test_analysis_result():
a = AnalysisResult(
action=ActionEnum.BUY_MORE,
confidence=ConfidenceEnum.HIGH,
reasons=["Low PE", "Strong growth"],
)
assert a.action == ActionEnum.BUY_MORE
assert len(a.reasons) == 2
def test_holding_analysis():
ha = HoldingAnalysis(
symbol="AAPL",
current_price=180.0,
buy_in_price=150.0,
shares=100,
pnl=3000.0,
pnl_percent=0.2,
analysis=AnalysisResult(
action=ActionEnum.HOLD,
confidence=ConfidenceEnum.MEDIUM,
reasons=["Within hold range"],
),
)
assert ha.pnl == 3000.0
def test_portfolio_response():
pr = PortfolioResponse(
holdings=[
HoldingAnalysis(
symbol="AAPL",
buy_in_price=150.0,
shares=100,
analysis=AnalysisResult(
action=ActionEnum.HOLD,
confidence=ConfidenceEnum.LOW,
reasons=["No data"],
),
)
],
analyzed_at=datetime.now(timezone.utc),
)
assert len(pr.holdings) == 1

View File

@@ -0,0 +1,47 @@
from openbb_service import _to_dicts, _first_or_empty
class MockModel:
def __init__(self, data: dict):
self._data = data
def model_dump(self):
return self._data
class MockOBBject:
def __init__(self, results):
self.results = results
class TestToDicts:
def test_none_result(self):
assert _to_dicts(None) == []
def test_none_results(self):
obj = MockOBBject(results=None)
assert _to_dicts(obj) == []
def test_list_results(self):
obj = MockOBBject(results=[
MockModel({"a": 1}),
MockModel({"b": 2}),
])
result = _to_dicts(obj)
assert len(result) == 2
assert result[0] == {"a": 1}
def test_single_result(self):
obj = MockOBBject(results=MockModel({"x": 42}))
result = _to_dicts(obj)
assert result == [{"x": 42}]
class TestFirstOrEmpty:
def test_empty(self):
assert _first_or_empty(None) == {}
def test_with_data(self):
obj = MockOBBject(results=[MockModel({"price": 150.0})])
result = _first_or_empty(obj)
assert result == {"price": 150.0}

242
tests/test_routes.py Normal file
View 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

View File

@@ -0,0 +1,39 @@
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
@pytest.mark.asyncio
@patch("routes_macro.macro_service.get_macro_overview", new_callable=AsyncMock)
async def test_macro_overview(mock_overview, client):
mock_overview.return_value = {
"fed_funds_rate": {"value": 5.33, "date": "2026-01-01"},
"us_10y_treasury": {"value": 4.25, "date": "2026-01-01"},
"unemployment_rate": {"value": 3.7, "date": "2026-01-01"},
}
resp = await client.get("/api/v1/macro/overview")
assert resp.status_code == 200
data = resp.json()["data"]
assert data["fed_funds_rate"]["value"] == 5.33
@pytest.mark.asyncio
@patch("routes_macro.macro_service.get_series", new_callable=AsyncMock)
async def test_macro_series(mock_series, client):
mock_series.return_value = [
{"date": "2026-01-01", "value": 5.33}
]
resp = await client.get("/api/v1/macro/series/FEDFUNDS?limit=5")
assert resp.status_code == 200
data = resp.json()["data"]
assert len(data) == 1

View File

@@ -0,0 +1,104 @@
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
@pytest.mark.asyncio
@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock)
@patch("routes_sentiment.finnhub_service.get_sentiment_summary", new_callable=AsyncMock)
async def test_stock_sentiment(mock_sentiment, mock_av, client):
mock_sentiment.return_value = {
"symbol": "AAPL",
"news_sentiment": {"bullish_percent": 0.7, "bearish_percent": 0.3},
"recent_news": [],
"analyst_recommendations": [],
"recent_upgrades_downgrades": [],
}
mock_av.return_value = {
"configured": True,
"symbol": "AAPL",
"article_count": 1,
"overall_sentiment": {"avg_score": 0.4, "label": "Bullish"},
"articles": [],
}
resp = await client.get("/api/v1/stock/AAPL/sentiment")
assert resp.status_code == 200
data = resp.json()["data"]
assert data["symbol"] == "AAPL"
assert data["news_sentiment"]["bullish_percent"] == 0.7
assert data["alpha_vantage_sentiment"]["overall_sentiment"]["label"] == "Bullish"
@pytest.mark.asyncio
@patch("routes_sentiment.finnhub_service.get_insider_transactions", new_callable=AsyncMock)
async def test_stock_insider_trades(mock_insider, client):
mock_insider.return_value = [
{"name": "Tim Cook", "share": 100000, "change": -50000, "transactionCode": "S"}
]
resp = await client.get("/api/v1/stock/AAPL/insider-trades")
assert resp.status_code == 200
data = resp.json()["data"]
assert len(data) == 1
assert data[0]["name"] == "Tim Cook"
@pytest.mark.asyncio
@patch("routes_sentiment.finnhub_service.get_recommendation_trends", new_callable=AsyncMock)
async def test_stock_recommendations(mock_recs, client):
mock_recs.return_value = [
{"period": "2026-01-01", "strongBuy": 10, "buy": 15, "hold": 5, "sell": 1, "strongSell": 0}
]
resp = await client.get("/api/v1/stock/AAPL/recommendations")
assert resp.status_code == 200
data = resp.json()["data"]
assert len(data) == 1
assert data[0]["strong_buy"] == 10
@pytest.mark.asyncio
@patch("routes_sentiment.finnhub_service.get_upgrade_downgrade", new_callable=AsyncMock)
async def test_stock_upgrades(mock_upgrades, client):
mock_upgrades.return_value = [
{"company": "Morgan Stanley", "action": "upgrade", "fromGrade": "Hold", "toGrade": "Buy"}
]
resp = await client.get("/api/v1/stock/AAPL/upgrades")
assert resp.status_code == 200
data = resp.json()["data"]
assert len(data) == 1
assert data[0]["action"] == "upgrade"
@pytest.mark.asyncio
@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock)
async def test_stock_news_sentiment(mock_av, client):
mock_av.return_value = {
"configured": True,
"symbol": "AAPL",
"article_count": 2,
"overall_sentiment": {"avg_score": 0.3, "label": "Somewhat-Bullish"},
"articles": [
{"title": "Apple up", "ticker_sentiment_score": 0.5},
{"title": "Apple flat", "ticker_sentiment_score": 0.1},
],
}
resp = await client.get("/api/v1/stock/AAPL/news-sentiment")
assert resp.status_code == 200
data = resp.json()["data"]
assert data["article_count"] == 2
assert data["overall_sentiment"]["label"] == "Somewhat-Bullish"
@pytest.mark.asyncio
async def test_invalid_symbol_sentiment(client):
resp = await client.get("/api/v1/stock/AAPL;DROP/sentiment")
assert resp.status_code == 400

View File

@@ -0,0 +1,42 @@
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
@pytest.mark.asyncio
@patch("routes_technical.technical_service.get_technical_indicators", new_callable=AsyncMock)
async def test_stock_technical(mock_tech, client):
mock_tech.return_value = {
"symbol": "AAPL",
"rsi_14": 55.3,
"macd": {"macd": 1.5, "signal": 1.2, "histogram": 0.3},
"sma_20": 180.0,
"sma_50": 175.0,
"sma_200": 170.0,
"ema_12": 179.0,
"ema_26": 176.0,
"bollinger_bands": {"upper": 190.0, "middle": 180.0, "lower": 170.0},
"signals": ["RSI 55.3: Neutral", "MACD histogram positive (bullish momentum)"],
}
resp = await client.get("/api/v1/stock/AAPL/technical")
assert resp.status_code == 200
data = resp.json()["data"]
assert data["symbol"] == "AAPL"
assert data["rsi_14"] == 55.3
assert len(data["signals"]) == 2
@pytest.mark.asyncio
async def test_invalid_symbol_technical(client):
resp = await client.get("/api/v1/stock/INVALID!!!/technical")
assert resp.status_code == 400