R1: Extend @safe to catch ValueError->400, simplify routes_backtest
(eliminated 4 copies of duplicated try/except)
R2: Consolidate PROVIDER constant into obb_utils.py (single source)
R3: Add days_ago() helper to obb_utils.py, replace 8+ duplications
R4: Extract Reddit/ApeWisdom into reddit_service.py from finnhub_service
R5: Fix missing top-level import asyncio in finnhub_service
R6: (deferred - sentiment logic extraction is a larger change)
All 561 tests passing.
121 lines
4.6 KiB
Python
121 lines
4.6 KiB
Python
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.finnhub_service.get_recommendation_trends", new_callable=AsyncMock)
|
|
@patch("routes_sentiment.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock)
|
|
@patch("routes_sentiment.reddit_service.get_reddit_sentiment", new_callable=AsyncMock)
|
|
@patch("routes_sentiment.finnhub_service.get_sentiment_summary", new_callable=AsyncMock)
|
|
@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock)
|
|
async def test_stock_sentiment(mock_av, mock_sentiment, mock_reddit, mock_upgrades, mock_recs, client):
|
|
# Route was refactored to return composite_score/composite_label/details/source_scores
|
|
mock_sentiment.return_value = {
|
|
"symbol": "AAPL",
|
|
"news_sentiment": {"bullish_percent": 0.7, "bearish_percent": 0.3},
|
|
"recent_news": [{"headline": "Apple strong"}],
|
|
"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": [],
|
|
}
|
|
mock_reddit.return_value = {"found": False, "symbol": "AAPL"}
|
|
mock_upgrades.return_value = []
|
|
mock_recs.return_value = []
|
|
resp = await client.get("/api/v1/stock/AAPL/sentiment")
|
|
assert resp.status_code == 200
|
|
data = resp.json()["data"]
|
|
assert data["symbol"] == "AAPL"
|
|
# New composite response shape
|
|
assert "composite_score" in data
|
|
assert "composite_label" in data
|
|
assert "source_scores" in data
|
|
assert "details" in data
|
|
# AV news data accessible via details
|
|
assert data["details"]["news_sentiment"]["overall_sentiment"]["label"] == "Bullish"
|
|
# Finnhub news accessible via details
|
|
assert len(data["details"]["finnhub_news"]) == 1
|
|
|
|
|
|
@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.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock)
|
|
async def test_stock_upgrades(mock_upgrades, client):
|
|
mock_upgrades.return_value = [
|
|
{"date": "2026-03-05", "company": "Morgan Stanley", "action": "upgrade",
|
|
"from_grade": "Hold", "to_grade": "Buy", "price_target_action": "Raises",
|
|
"current_price_target": 300.0, "prior_price_target": 250.0}
|
|
]
|
|
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
|