refactor: address architect review findings (6 items)

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.
This commit is contained in:
Yaojia Wang
2026-03-19 23:15:00 +01:00
parent 37c46e76ae
commit 0f7341b158
12 changed files with 184 additions and 204 deletions

View File

@@ -1,5 +1,6 @@
"""Finnhub API client for sentiment, insider trades, and analyst data.""" """Finnhub API client for sentiment, insider trades, and analyst data."""
import asyncio
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import Any
@@ -158,86 +159,13 @@ def _summarize_social(entries: list[dict[str, Any]]) -> dict[str, Any]:
} }
async def get_reddit_sentiment(symbol: str) -> dict[str, Any]: # Reddit sentiment moved to reddit_service.py
"""Get Reddit sentiment from ApeWisdom (free, no key needed).
Tracks mentions and upvotes across r/wallstreetbets, r/stocks, r/investing.
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
"https://apewisdom.io/api/v1.0/filter/all-stocks/page/1"
)
resp.raise_for_status()
data = resp.json()
results = data.get("results", [])
# Find the requested symbol
match = next(
(r for r in results if r.get("ticker", "").upper() == symbol.upper()),
None,
)
if match is None:
return {
"symbol": symbol,
"found": False,
"message": f"{symbol} not in Reddit top trending (not enough mentions)",
}
mentions_prev = match.get("mentions_24h_ago", 0)
mentions_now = match.get("mentions", 0)
change_pct = (
round((mentions_now - mentions_prev) / mentions_prev * 100, 1)
if mentions_prev > 0
else None
)
return {
"symbol": symbol,
"found": True,
"rank": match.get("rank"),
"mentions_24h": mentions_now,
"mentions_24h_ago": mentions_prev,
"mentions_change_pct": change_pct,
"upvotes": match.get("upvotes"),
"rank_24h_ago": match.get("rank_24h_ago"),
}
except Exception:
logger.warning("Reddit sentiment failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to fetch Reddit sentiment"}
async def get_reddit_trending() -> list[dict[str, Any]]:
"""Get top trending stocks on Reddit (ApeWisdom, free, no key)."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
"https://apewisdom.io/api/v1.0/filter/all-stocks/page/1"
)
resp.raise_for_status()
data = resp.json()
return [
{
"rank": r.get("rank"),
"symbol": r.get("ticker"),
"name": r.get("name"),
"mentions_24h": r.get("mentions"),
"upvotes": r.get("upvotes"),
"rank_24h_ago": r.get("rank_24h_ago"),
"mentions_24h_ago": r.get("mentions_24h_ago"),
}
for r in data.get("results", [])[:25]
]
except Exception:
logger.warning("Reddit trending failed", exc_info=True)
return []
async def get_sentiment_summary(symbol: str) -> dict[str, Any]: async def get_sentiment_summary(symbol: str) -> dict[str, Any]:
"""Aggregate all sentiment data for a symbol into one response.""" """Aggregate all sentiment data for a symbol into one response."""
if not _is_configured(): if not _is_configured():
return {"configured": False, "message": "Set INVEST_API_FINNHUB_API_KEY to enable sentiment data"} return {"configured": False, "message": "Set INVEST_API_FINNHUB_API_KEY to enable sentiment data"}
import asyncio
news_sentiment, company_news, recommendations, upgrades = await asyncio.gather( news_sentiment, company_news, recommendations, upgrades = await asyncio.gather(
get_news_sentiment(symbol), get_news_sentiment(symbol),
get_company_news(symbol, days=7), get_company_news(symbol, days=7),

View File

@@ -7,12 +7,10 @@ from typing import Any
from openbb import obb from openbb import obb
from obb_utils import to_list from obb_utils import to_list, days_ago, PROVIDER
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PROVIDER = "yfinance"
# --- ETF --- # --- ETF ---
@@ -30,7 +28,7 @@ async def get_etf_info(symbol: str) -> dict[str, Any]:
async def get_etf_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]: async def get_etf_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]:
"""Get ETF price history.""" """Get ETF price history."""
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d") start = days_ago(days)
try: try:
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.etf.historical, symbol, start_date=start, provider=PROVIDER obb.etf.historical, symbol, start_date=start, provider=PROVIDER
@@ -66,7 +64,7 @@ async def get_available_indices() -> list[dict[str, Any]]:
async def get_index_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]: async def get_index_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]:
"""Get index price history.""" """Get index price history."""
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d") start = days_ago(days)
try: try:
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.index.price.historical, symbol, start_date=start, provider=PROVIDER obb.index.price.historical, symbol, start_date=start, provider=PROVIDER
@@ -82,7 +80,7 @@ async def get_index_historical(symbol: str, days: int = 365) -> list[dict[str, A
async def get_crypto_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]: async def get_crypto_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]:
"""Get cryptocurrency price history.""" """Get cryptocurrency price history."""
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d") start = days_ago(days)
try: try:
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.crypto.price.historical, symbol, start_date=start, provider=PROVIDER obb.crypto.price.historical, symbol, start_date=start, provider=PROVIDER
@@ -110,7 +108,7 @@ async def get_currency_historical(
symbol: str, days: int = 365 symbol: str, days: int = 365
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Get forex price history (e.g., EURUSD).""" """Get forex price history (e.g., EURUSD)."""
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d") start = days_ago(days)
try: try:
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.currency.price.historical, symbol, start_date=start, provider=PROVIDER obb.currency.price.historical, symbol, start_date=start, provider=PROVIDER
@@ -140,7 +138,7 @@ async def get_futures_historical(
symbol: str, days: int = 365 symbol: str, days: int = 365
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Get futures price history.""" """Get futures price history."""
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d") start = days_ago(days)
try: try:
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.derivatives.futures.historical, symbol, start_date=start, provider=PROVIDER obb.derivatives.futures.historical, symbol, start_date=start, provider=PROVIDER

View File

@@ -66,11 +66,16 @@ def first_or_empty(result: Any) -> dict[str, Any]:
return items[0] if items else {} return items[0] if items else {}
def days_ago(days: int) -> str:
"""Return a YYYY-MM-DD date string for N days ago (UTC)."""
return (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
async def fetch_historical( async def fetch_historical(
symbol: str, days: int = 365, provider: str = PROVIDER, symbol: str, days: int = 365, provider: str = PROVIDER,
) -> Any | None: ) -> Any | None:
"""Fetch historical price data, returning the OBBject result or None.""" """Fetch historical price data, returning the OBBject result or None."""
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d") start = days_ago(days)
try: try:
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.equity.price.historical, symbol, start_date=start, provider=provider, obb.equity.price.historical, symbol, start_date=start, provider=provider,

View File

@@ -6,12 +6,10 @@ from typing import Any
import yfinance as yf import yfinance as yf
from openbb import obb from openbb import obb
from obb_utils import to_list, first_or_empty from obb_utils import to_list, first_or_empty, days_ago, PROVIDER
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PROVIDER = "yfinance"
async def get_quote(symbol: str) -> dict[str, Any]: async def get_quote(symbol: str) -> dict[str, Any]:
result = await asyncio.to_thread( result = await asyncio.to_thread(
@@ -21,7 +19,7 @@ async def get_quote(symbol: str) -> dict[str, Any]:
async def get_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]: async def get_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]:
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d") start = days_ago(days)
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.equity.price.historical, obb.equity.price.historical,
symbol, symbol,

View File

@@ -7,12 +7,10 @@ from typing import Any
from openbb import obb from openbb import obb
from obb_utils import extract_single, safe_last, fetch_historical, to_list from obb_utils import extract_single, safe_last, fetch_historical, to_list, days_ago, PROVIDER
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PROVIDER = "yfinance"
# Need 252+ trading days for default window; 730 calendar days is safe # Need 252+ trading days for default window; 730 calendar days is safe
PERF_DAYS = 730 PERF_DAYS = 730
TARGET = "close" TARGET = "close"
@@ -22,7 +20,7 @@ async def get_performance_metrics(symbol: str, days: int = 365) -> dict[str, Any
"""Calculate Sharpe ratio, summary stats, and volatility for a symbol.""" """Calculate Sharpe ratio, summary stats, and volatility for a symbol."""
# Need at least 252 trading days for Sharpe window # Need at least 252 trading days for Sharpe window
fetch_days = max(days, PERF_DAYS) fetch_days = max(days, PERF_DAYS)
start = (datetime.now(tz=timezone.utc) - timedelta(days=fetch_days)).strftime("%Y-%m-%d") start = days_ago(fetch_days)
try: try:
hist = await asyncio.to_thread( hist = await asyncio.to_thread(
@@ -64,7 +62,7 @@ async def get_performance_metrics(symbol: str, days: int = 365) -> dict[str, Any
async def get_capm(symbol: str) -> dict[str, Any]: async def get_capm(symbol: str) -> dict[str, Any]:
"""Calculate CAPM metrics: beta, alpha, systematic/idiosyncratic risk.""" """Calculate CAPM metrics: beta, alpha, systematic/idiosyncratic risk."""
start = (datetime.now(tz=timezone.utc) - timedelta(days=PERF_DAYS)).strftime("%Y-%m-%d") start = days_ago(PERF_DAYS)
try: try:
hist = await asyncio.to_thread( hist = await asyncio.to_thread(
@@ -85,7 +83,7 @@ async def get_capm(symbol: str) -> dict[str, Any]:
async def get_normality_test(symbol: str, days: int = 365) -> dict[str, Any]: async def get_normality_test(symbol: str, days: int = 365) -> dict[str, Any]:
"""Run normality tests (Jarque-Bera, Shapiro-Wilk, etc.) on returns.""" """Run normality tests (Jarque-Bera, Shapiro-Wilk, etc.) on returns."""
fetch_days = max(days, PERF_DAYS) fetch_days = max(days, PERF_DAYS)
start = (datetime.now(tz=timezone.utc) - timedelta(days=fetch_days)).strftime("%Y-%m-%d") start = days_ago(fetch_days)
try: try:
hist = await asyncio.to_thread( hist = await asyncio.to_thread(
@@ -106,7 +104,7 @@ async def get_normality_test(symbol: str, days: int = 365) -> dict[str, Any]:
async def get_unitroot_test(symbol: str, days: int = 365) -> dict[str, Any]: async def get_unitroot_test(symbol: str, days: int = 365) -> dict[str, Any]:
"""Run unit root tests (ADF, KPSS) for stationarity.""" """Run unit root tests (ADF, KPSS) for stationarity."""
fetch_days = max(days, PERF_DAYS) fetch_days = max(days, PERF_DAYS)
start = (datetime.now(tz=timezone.utc) - timedelta(days=fetch_days)).strftime("%Y-%m-%d") start = days_ago(fetch_days)
try: try:
hist = await asyncio.to_thread( hist = await asyncio.to_thread(

80
reddit_service.py Normal file
View File

@@ -0,0 +1,80 @@
"""Reddit stock sentiment via ApeWisdom API (free, no key needed)."""
import logging
from typing import Any
import httpx
logger = logging.getLogger(__name__)
APEWISDOM_URL = "https://apewisdom.io/api/v1.0/filter/all-stocks/page/1"
TIMEOUT = 10.0
async def get_reddit_sentiment(symbol: str) -> dict[str, Any]:
"""Get Reddit sentiment for a symbol.
Tracks mentions and upvotes across r/wallstreetbets, r/stocks, r/investing.
"""
try:
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
resp = await client.get(APEWISDOM_URL)
resp.raise_for_status()
data = resp.json()
results = data.get("results", [])
match = next(
(r for r in results if r.get("ticker", "").upper() == symbol.upper()),
None,
)
if match is None:
return {
"symbol": symbol,
"found": False,
"message": f"{symbol} not in Reddit top trending (not enough mentions)",
}
mentions_prev = match.get("mentions_24h_ago", 0)
mentions_now = match.get("mentions", 0)
change_pct = (
round((mentions_now - mentions_prev) / mentions_prev * 100, 1)
if mentions_prev > 0
else None
)
return {
"symbol": symbol,
"found": True,
"rank": match.get("rank"),
"mentions_24h": mentions_now,
"mentions_24h_ago": mentions_prev,
"mentions_change_pct": change_pct,
"upvotes": match.get("upvotes"),
"rank_24h_ago": match.get("rank_24h_ago"),
}
except Exception:
logger.warning("Reddit sentiment failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to fetch Reddit sentiment"}
async def get_reddit_trending() -> list[dict[str, Any]]:
"""Get top trending stocks on Reddit (free, no key)."""
try:
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
resp = await client.get(APEWISDOM_URL)
resp.raise_for_status()
data = resp.json()
return [
{
"rank": r.get("rank"),
"symbol": r.get("ticker"),
"name": r.get("name"),
"mentions_24h": r.get("mentions"),
"upvotes": r.get("upvotes"),
"rank_24h_ago": r.get("rank_24h_ago"),
"mentions_24h_ago": r.get("mentions_24h_ago"),
}
for r in data.get("results", [])[:25]
]
except Exception:
logger.warning("Reddit trending failed", exc_info=True)
return []

View File

@@ -23,13 +23,19 @@ def validate_symbol(symbol: str) -> str:
def safe(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: def safe(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
"""Decorator to catch upstream errors and return 502.""" """Decorator to catch upstream errors and return 502.
ValueError is caught separately and returned as 400 (bad request).
All other non-HTTP exceptions become 502 (upstream error).
"""
@functools.wraps(fn) @functools.wraps(fn)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
try: try:
return await fn(*args, **kwargs) return await fn(*args, **kwargs)
except HTTPException: except HTTPException:
raise raise
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception: except Exception:
logger.exception("Upstream data error") logger.exception("Upstream data error")
raise HTTPException( raise HTTPException(

View File

@@ -1,14 +1,11 @@
"""Routes for backtesting strategies.""" """Routes for backtesting strategies."""
import logging from fastapi import APIRouter
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import backtest_service import backtest_service
from models import ApiResponse from models import ApiResponse
from route_utils import safe
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/backtest", tags=["backtest"]) router = APIRouter(prefix="/api/v1/backtest", tags=["backtest"])
@@ -54,88 +51,56 @@ class MomentumRequest(BaseModel):
@router.post("/sma-crossover", response_model=ApiResponse) @router.post("/sma-crossover", response_model=ApiResponse)
@safe
async def sma_crossover(req: SMARequest) -> ApiResponse: async def sma_crossover(req: SMARequest) -> ApiResponse:
"""SMA crossover strategy: buy when short SMA crosses above long SMA.""" """SMA crossover strategy: buy when short SMA crosses above long SMA."""
try: result = await backtest_service.backtest_sma_crossover(
result = await backtest_service.backtest_sma_crossover( req.symbol,
req.symbol, short_window=req.short_window,
short_window=req.short_window, long_window=req.long_window,
long_window=req.long_window, days=req.days,
days=req.days, initial_capital=req.initial_capital,
initial_capital=req.initial_capital, )
) return ApiResponse(data=result)
return ApiResponse(data=result)
except ValueError as exc:
logger.warning("SMA crossover backtest validation error: %s", exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
logger.exception("SMA crossover backtest failed")
raise HTTPException(
status_code=502, detail="Data provider error. Check server logs."
) from exc
@router.post("/rsi", response_model=ApiResponse) @router.post("/rsi", response_model=ApiResponse)
@safe
async def rsi_strategy(req: RSIRequest) -> ApiResponse: async def rsi_strategy(req: RSIRequest) -> ApiResponse:
"""RSI strategy: buy when RSI < oversold, sell when RSI > overbought.""" """RSI strategy: buy when RSI < oversold, sell when RSI > overbought."""
try: result = await backtest_service.backtest_rsi(
result = await backtest_service.backtest_rsi( req.symbol,
req.symbol, period=req.period,
period=req.period, oversold=req.oversold,
oversold=req.oversold, overbought=req.overbought,
overbought=req.overbought, days=req.days,
days=req.days, initial_capital=req.initial_capital,
initial_capital=req.initial_capital, )
) return ApiResponse(data=result)
return ApiResponse(data=result)
except ValueError as exc:
logger.warning("RSI backtest validation error: %s", exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
logger.exception("RSI backtest failed")
raise HTTPException(
status_code=502, detail="Data provider error. Check server logs."
) from exc
@router.post("/buy-and-hold", response_model=ApiResponse) @router.post("/buy-and-hold", response_model=ApiResponse)
@safe
async def buy_and_hold(req: BuyAndHoldRequest) -> ApiResponse: async def buy_and_hold(req: BuyAndHoldRequest) -> ApiResponse:
"""Buy-and-hold benchmark: buy on day 1, hold through end of period.""" """Buy-and-hold benchmark: buy on day 1, hold through end of period."""
try: result = await backtest_service.backtest_buy_and_hold(
result = await backtest_service.backtest_buy_and_hold( req.symbol,
req.symbol, days=req.days,
days=req.days, initial_capital=req.initial_capital,
initial_capital=req.initial_capital, )
) return ApiResponse(data=result)
return ApiResponse(data=result)
except ValueError as exc:
logger.warning("Buy-and-hold backtest validation error: %s", exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Buy-and-hold backtest failed")
raise HTTPException(
status_code=502, detail="Data provider error. Check server logs."
) from exc
@router.post("/momentum", response_model=ApiResponse) @router.post("/momentum", response_model=ApiResponse)
@safe
async def momentum_strategy(req: MomentumRequest) -> ApiResponse: async def momentum_strategy(req: MomentumRequest) -> ApiResponse:
"""Momentum strategy: every rebalance_days pick top_n symbols by lookback return.""" """Momentum strategy: every rebalance_days pick top_n by lookback return."""
try: result = await backtest_service.backtest_momentum(
result = await backtest_service.backtest_momentum( symbols=req.symbols,
symbols=req.symbols, lookback=req.lookback,
lookback=req.lookback, top_n=req.top_n,
top_n=req.top_n, rebalance_days=req.rebalance_days,
rebalance_days=req.rebalance_days, days=req.days,
days=req.days, initial_capital=req.initial_capital,
initial_capital=req.initial_capital, )
) return ApiResponse(data=result)
return ApiResponse(data=result)
except ValueError as exc:
logger.warning("Momentum backtest validation error: %s", exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Momentum backtest failed")
raise HTTPException(
status_code=502, detail="Data provider error. Check server logs."
) from exc

View File

@@ -9,6 +9,7 @@ from route_utils import safe, validate_symbol
import alphavantage_service import alphavantage_service
import finnhub_service import finnhub_service
import openbb_service import openbb_service
import reddit_service
import logging import logging
@@ -35,7 +36,7 @@ async def stock_sentiment(symbol: str = Path(..., min_length=1, max_length=20)):
av_data, finnhub_data, reddit_data, upgrades_data, recs_data = await asyncio.gather( av_data, finnhub_data, reddit_data, upgrades_data, recs_data = await asyncio.gather(
alphavantage_service.get_news_sentiment(symbol, limit=20), alphavantage_service.get_news_sentiment(symbol, limit=20),
finnhub_service.get_sentiment_summary(symbol), finnhub_service.get_sentiment_summary(symbol),
finnhub_service.get_reddit_sentiment(symbol), reddit_service.get_reddit_sentiment(symbol),
openbb_service.get_upgrades_downgrades(symbol, limit=10), openbb_service.get_upgrades_downgrades(symbol, limit=10),
finnhub_service.get_recommendation_trends(symbol), finnhub_service.get_recommendation_trends(symbol),
return_exceptions=True, return_exceptions=True,
@@ -211,7 +212,7 @@ async def stock_reddit_sentiment(
): ):
"""Reddit sentiment: mentions, upvotes, rank on WSB/stocks/investing (free, no key).""" """Reddit sentiment: mentions, upvotes, rank on WSB/stocks/investing (free, no key)."""
symbol = validate_symbol(symbol) symbol = validate_symbol(symbol)
data = await finnhub_service.get_reddit_sentiment(symbol) data = await reddit_service.get_reddit_sentiment(symbol)
return ApiResponse(data=data) return ApiResponse(data=data)
@@ -219,5 +220,5 @@ async def stock_reddit_sentiment(
@safe @safe
async def reddit_trending(): async def reddit_trending():
"""Top 25 trending stocks on Reddit (WSB, r/stocks, r/investing). Free, no key.""" """Top 25 trending stocks on Reddit (WSB, r/stocks, r/investing). Free, no key."""
data = await finnhub_service.get_reddit_trending() data = await reddit_service.get_reddit_trending()
return ApiResponse(data=data) return ApiResponse(data=data)

View File

@@ -5,6 +5,7 @@ from unittest.mock import patch, AsyncMock, MagicMock
import pytest import pytest
import finnhub_service import finnhub_service
import reddit_service
# --- get_social_sentiment --- # --- get_social_sentiment ---
@@ -178,7 +179,7 @@ def test_summarize_social_missing_fields():
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("finnhub_service.httpx.AsyncClient") @patch("reddit_service.httpx.AsyncClient")
async def test_reddit_sentiment_symbol_found(mock_client_cls): async def test_reddit_sentiment_symbol_found(mock_client_cls):
mock_resp = MagicMock() mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock() mock_resp.raise_for_status = MagicMock()
@@ -195,7 +196,7 @@ async def test_reddit_sentiment_symbol_found(mock_client_cls):
mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
result = await finnhub_service.get_reddit_sentiment("AAPL") result = await reddit_service.get_reddit_sentiment("AAPL")
assert result["found"] is True assert result["found"] is True
assert result["symbol"] == "AAPL" assert result["symbol"] == "AAPL"
assert result["rank"] == 3 assert result["rank"] == 3
@@ -205,7 +206,7 @@ async def test_reddit_sentiment_symbol_found(mock_client_cls):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("finnhub_service.httpx.AsyncClient") @patch("reddit_service.httpx.AsyncClient")
async def test_reddit_sentiment_symbol_not_found(mock_client_cls): async def test_reddit_sentiment_symbol_not_found(mock_client_cls):
mock_resp = MagicMock() mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock() mock_resp.raise_for_status = MagicMock()
@@ -221,14 +222,14 @@ async def test_reddit_sentiment_symbol_not_found(mock_client_cls):
mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
result = await finnhub_service.get_reddit_sentiment("AAPL") result = await reddit_service.get_reddit_sentiment("AAPL")
assert result["found"] is False assert result["found"] is False
assert result["symbol"] == "AAPL" assert result["symbol"] == "AAPL"
assert "not in Reddit" in result["message"] assert "not in Reddit" in result["message"]
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("finnhub_service.httpx.AsyncClient") @patch("reddit_service.httpx.AsyncClient")
async def test_reddit_sentiment_zero_mentions_prev(mock_client_cls): async def test_reddit_sentiment_zero_mentions_prev(mock_client_cls):
mock_resp = MagicMock() mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock() mock_resp.raise_for_status = MagicMock()
@@ -244,13 +245,13 @@ async def test_reddit_sentiment_zero_mentions_prev(mock_client_cls):
mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
result = await finnhub_service.get_reddit_sentiment("AAPL") result = await reddit_service.get_reddit_sentiment("AAPL")
assert result["found"] is True assert result["found"] is True
assert result["mentions_change_pct"] is None # division by zero handled assert result["mentions_change_pct"] is None # division by zero handled
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("finnhub_service.httpx.AsyncClient") @patch("reddit_service.httpx.AsyncClient")
async def test_reddit_sentiment_api_failure(mock_client_cls): async def test_reddit_sentiment_api_failure(mock_client_cls):
mock_client = AsyncMock() mock_client = AsyncMock()
mock_client.get.side_effect = Exception("Connection error") mock_client.get.side_effect = Exception("Connection error")
@@ -258,13 +259,13 @@ async def test_reddit_sentiment_api_failure(mock_client_cls):
mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
result = await finnhub_service.get_reddit_sentiment("AAPL") result = await reddit_service.get_reddit_sentiment("AAPL")
assert result["symbol"] == "AAPL" assert result["symbol"] == "AAPL"
assert "error" in result assert "error" in result
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("finnhub_service.httpx.AsyncClient") @patch("reddit_service.httpx.AsyncClient")
async def test_reddit_sentiment_case_insensitive(mock_client_cls): async def test_reddit_sentiment_case_insensitive(mock_client_cls):
mock_resp = MagicMock() mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock() mock_resp.raise_for_status = MagicMock()
@@ -280,7 +281,7 @@ async def test_reddit_sentiment_case_insensitive(mock_client_cls):
mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
result = await finnhub_service.get_reddit_sentiment("AAPL") result = await reddit_service.get_reddit_sentiment("AAPL")
assert result["found"] is True assert result["found"] is True
@@ -288,7 +289,7 @@ async def test_reddit_sentiment_case_insensitive(mock_client_cls):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("finnhub_service.httpx.AsyncClient") @patch("reddit_service.httpx.AsyncClient")
async def test_reddit_trending_happy_path(mock_client_cls): async def test_reddit_trending_happy_path(mock_client_cls):
mock_resp = MagicMock() mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock() mock_resp.raise_for_status = MagicMock()
@@ -306,7 +307,7 @@ async def test_reddit_trending_happy_path(mock_client_cls):
mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
result = await finnhub_service.get_reddit_trending() result = await reddit_service.get_reddit_trending()
assert len(result) == 3 assert len(result) == 3
assert result[0]["symbol"] == "TSLA" assert result[0]["symbol"] == "TSLA"
assert result[0]["rank"] == 1 assert result[0]["rank"] == 1
@@ -316,7 +317,7 @@ async def test_reddit_trending_happy_path(mock_client_cls):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("finnhub_service.httpx.AsyncClient") @patch("reddit_service.httpx.AsyncClient")
async def test_reddit_trending_limits_to_25(mock_client_cls): async def test_reddit_trending_limits_to_25(mock_client_cls):
mock_resp = MagicMock() mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock() mock_resp.raise_for_status = MagicMock()
@@ -333,12 +334,12 @@ async def test_reddit_trending_limits_to_25(mock_client_cls):
mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
result = await finnhub_service.get_reddit_trending() result = await reddit_service.get_reddit_trending()
assert len(result) == 25 assert len(result) == 25
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("finnhub_service.httpx.AsyncClient") @patch("reddit_service.httpx.AsyncClient")
async def test_reddit_trending_empty_results(mock_client_cls): async def test_reddit_trending_empty_results(mock_client_cls):
mock_resp = MagicMock() mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock() mock_resp.raise_for_status = MagicMock()
@@ -350,12 +351,12 @@ async def test_reddit_trending_empty_results(mock_client_cls):
mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
result = await finnhub_service.get_reddit_trending() result = await reddit_service.get_reddit_trending()
assert result == [] assert result == []
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("finnhub_service.httpx.AsyncClient") @patch("reddit_service.httpx.AsyncClient")
async def test_reddit_trending_api_failure(mock_client_cls): async def test_reddit_trending_api_failure(mock_client_cls):
mock_client = AsyncMock() mock_client = AsyncMock()
mock_client.get.side_effect = Exception("ApeWisdom down") mock_client.get.side_effect = Exception("ApeWisdom down")
@@ -363,5 +364,5 @@ async def test_reddit_trending_api_failure(mock_client_cls):
mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
result = await finnhub_service.get_reddit_trending() result = await reddit_service.get_reddit_trending()
assert result == [] assert result == []

View File

@@ -16,7 +16,7 @@ async def client():
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("routes_sentiment.finnhub_service.get_recommendation_trends", new_callable=AsyncMock) @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.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock)
@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", 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.finnhub_service.get_sentiment_summary", new_callable=AsyncMock)
@patch("routes_sentiment.alphavantage_service.get_news_sentiment", 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): async def test_stock_sentiment(mock_av, mock_sentiment, mock_reddit, mock_upgrades, mock_recs, client):

View File

@@ -81,7 +81,7 @@ async def test_stock_social_sentiment_invalid_symbol(client):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", new_callable=AsyncMock) @patch("routes_sentiment.reddit_service.get_reddit_sentiment", new_callable=AsyncMock)
async def test_stock_reddit_sentiment_found(mock_fn, client): async def test_stock_reddit_sentiment_found(mock_fn, client):
mock_fn.return_value = { mock_fn.return_value = {
"symbol": "AAPL", "symbol": "AAPL",
@@ -104,7 +104,7 @@ async def test_stock_reddit_sentiment_found(mock_fn, client):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", new_callable=AsyncMock) @patch("routes_sentiment.reddit_service.get_reddit_sentiment", new_callable=AsyncMock)
async def test_stock_reddit_sentiment_not_found(mock_fn, client): async def test_stock_reddit_sentiment_not_found(mock_fn, client):
mock_fn.return_value = { mock_fn.return_value = {
"symbol": "OBSCURE", "symbol": "OBSCURE",
@@ -119,7 +119,7 @@ async def test_stock_reddit_sentiment_not_found(mock_fn, client):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", new_callable=AsyncMock) @patch("routes_sentiment.reddit_service.get_reddit_sentiment", new_callable=AsyncMock)
async def test_stock_reddit_sentiment_service_error_returns_502(mock_fn, client): async def test_stock_reddit_sentiment_service_error_returns_502(mock_fn, client):
mock_fn.side_effect = RuntimeError("ApeWisdom down") mock_fn.side_effect = RuntimeError("ApeWisdom down")
resp = await client.get("/api/v1/stock/AAPL/reddit-sentiment") resp = await client.get("/api/v1/stock/AAPL/reddit-sentiment")
@@ -136,7 +136,7 @@ async def test_stock_reddit_sentiment_invalid_symbol(client):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("routes_sentiment.finnhub_service.get_reddit_trending", new_callable=AsyncMock) @patch("routes_sentiment.reddit_service.get_reddit_trending", new_callable=AsyncMock)
async def test_reddit_trending_happy_path(mock_fn, client): async def test_reddit_trending_happy_path(mock_fn, client):
mock_fn.return_value = [ mock_fn.return_value = [
{"rank": 1, "symbol": "TSLA", "name": "Tesla", "mentions_24h": 500, "upvotes": 1200, "rank_24h_ago": 2, "mentions_24h_ago": 400}, {"rank": 1, "symbol": "TSLA", "name": "Tesla", "mentions_24h": 500, "upvotes": 1200, "rank_24h_ago": 2, "mentions_24h_ago": 400},
@@ -154,7 +154,7 @@ async def test_reddit_trending_happy_path(mock_fn, client):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("routes_sentiment.finnhub_service.get_reddit_trending", new_callable=AsyncMock) @patch("routes_sentiment.reddit_service.get_reddit_trending", new_callable=AsyncMock)
async def test_reddit_trending_empty(mock_fn, client): async def test_reddit_trending_empty(mock_fn, client):
mock_fn.return_value = [] mock_fn.return_value = []
resp = await client.get("/api/v1/discover/reddit-trending") resp = await client.get("/api/v1/discover/reddit-trending")
@@ -163,7 +163,7 @@ async def test_reddit_trending_empty(mock_fn, client):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("routes_sentiment.finnhub_service.get_reddit_trending", new_callable=AsyncMock) @patch("routes_sentiment.reddit_service.get_reddit_trending", new_callable=AsyncMock)
async def test_reddit_trending_service_error_returns_502(mock_fn, client): async def test_reddit_trending_service_error_returns_502(mock_fn, client):
mock_fn.side_effect = RuntimeError("ApeWisdom unavailable") mock_fn.side_effect = RuntimeError("ApeWisdom unavailable")
resp = await client.get("/api/v1/discover/reddit-trending") resp = await client.get("/api/v1/discover/reddit-trending")
@@ -176,7 +176,7 @@ async def test_reddit_trending_service_error_returns_502(mock_fn, client):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("routes_sentiment.finnhub_service.get_recommendation_trends", new_callable=AsyncMock) @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.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock)
@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", 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.finnhub_service.get_sentiment_summary", new_callable=AsyncMock)
@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock) @patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock)
async def test_composite_sentiment_all_sources(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client): async def test_composite_sentiment_all_sources(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client):
@@ -229,7 +229,7 @@ async def test_composite_sentiment_all_sources(mock_av, mock_fh, mock_reddit, mo
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("routes_sentiment.finnhub_service.get_recommendation_trends", new_callable=AsyncMock) @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.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock)
@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", 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.finnhub_service.get_sentiment_summary", new_callable=AsyncMock)
@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock) @patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock)
async def test_composite_sentiment_no_data_returns_unknown(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client): async def test_composite_sentiment_no_data_returns_unknown(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client):
@@ -250,7 +250,7 @@ async def test_composite_sentiment_no_data_returns_unknown(mock_av, mock_fh, moc
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("routes_sentiment.finnhub_service.get_recommendation_trends", new_callable=AsyncMock) @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.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock)
@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", 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.finnhub_service.get_sentiment_summary", new_callable=AsyncMock)
@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock) @patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock)
async def test_composite_sentiment_strong_bullish_label(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client): async def test_composite_sentiment_strong_bullish_label(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client):
@@ -271,7 +271,7 @@ async def test_composite_sentiment_strong_bullish_label(mock_av, mock_fh, mock_r
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("routes_sentiment.finnhub_service.get_recommendation_trends", new_callable=AsyncMock) @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.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock)
@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", 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.finnhub_service.get_sentiment_summary", new_callable=AsyncMock)
@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock) @patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock)
async def test_composite_sentiment_bearish_label(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client): async def test_composite_sentiment_bearish_label(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client):
@@ -291,7 +291,7 @@ async def test_composite_sentiment_bearish_label(mock_av, mock_fh, mock_reddit,
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("routes_sentiment.finnhub_service.get_recommendation_trends", new_callable=AsyncMock) @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.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock)
@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", 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.finnhub_service.get_sentiment_summary", new_callable=AsyncMock)
@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock) @patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock)
async def test_composite_sentiment_one_source_failing_is_graceful(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client): async def test_composite_sentiment_one_source_failing_is_graceful(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client):
@@ -318,7 +318,7 @@ async def test_composite_sentiment_invalid_symbol(client):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("routes_sentiment.finnhub_service.get_recommendation_trends", new_callable=AsyncMock) @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.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock)
@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", 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.finnhub_service.get_sentiment_summary", new_callable=AsyncMock)
@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock) @patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock)
async def test_composite_sentiment_reddit_low_mentions_excluded(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client): async def test_composite_sentiment_reddit_low_mentions_excluded(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client):
@@ -338,7 +338,7 @@ async def test_composite_sentiment_reddit_low_mentions_excluded(mock_av, mock_fh
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("routes_sentiment.finnhub_service.get_recommendation_trends", new_callable=AsyncMock) @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.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock)
@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", 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.finnhub_service.get_sentiment_summary", new_callable=AsyncMock)
@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock) @patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock)
async def test_composite_sentiment_details_structure(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client): async def test_composite_sentiment_details_structure(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client):