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:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
1
tests/conftest.py
Normal file
1
tests/conftest.py
Normal file
@@ -0,0 +1 @@
|
||||
# pythonpath configured in pyproject.toml [tool.pytest.ini_options]
|
||||
193
tests/test_alphavantage_service.py
Normal file
193
tests/test_alphavantage_service.py
Normal 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"]
|
||||
132
tests/test_analysis_service.py
Normal file
132
tests/test_analysis_service.py
Normal 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
|
||||
94
tests/test_finnhub_service.py
Normal file
94
tests/test_finnhub_service.py
Normal 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
68
tests/test_mappers.py
Normal 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
149
tests/test_models.py
Normal 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
|
||||
47
tests/test_openbb_service.py
Normal file
47
tests/test_openbb_service.py
Normal 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
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
|
||||
39
tests/test_routes_macro.py
Normal file
39
tests/test_routes_macro.py
Normal 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
|
||||
104
tests/test_routes_sentiment.py
Normal file
104
tests/test_routes_sentiment.py
Normal 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
|
||||
42
tests/test_routes_technical.py
Normal file
42
tests/test_routes_technical.py
Normal 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
|
||||
Reference in New Issue
Block a user