From 0f7341b15853908257649972ca2e7daa1fb9d093 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 19 Mar 2026 23:15:00 +0100 Subject: [PATCH] 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. --- finnhub_service.py | 76 +---------------- market_service.py | 14 ++-- obb_utils.py | 7 +- openbb_service.py | 6 +- quantitative_service.py | 12 ++- reddit_service.py | 80 ++++++++++++++++++ route_utils.py | 8 +- routes_backtest.py | 113 +++++++++----------------- routes_sentiment.py | 7 +- tests/test_finnhub_service_social.py | 37 +++++---- tests/test_routes_sentiment.py | 2 +- tests/test_routes_sentiment_social.py | 26 +++--- 12 files changed, 184 insertions(+), 204 deletions(-) create mode 100644 reddit_service.py diff --git a/finnhub_service.py b/finnhub_service.py index 8c93789..205f0a5 100644 --- a/finnhub_service.py +++ b/finnhub_service.py @@ -1,5 +1,6 @@ """Finnhub API client for sentiment, insider trades, and analyst data.""" +import asyncio import logging from datetime import datetime, timedelta 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]: - """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 [] +# Reddit sentiment moved to reddit_service.py async def get_sentiment_summary(symbol: str) -> dict[str, Any]: """Aggregate all sentiment data for a symbol into one response.""" if not _is_configured(): 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( get_news_sentiment(symbol), get_company_news(symbol, days=7), diff --git a/market_service.py b/market_service.py index 7cff854..b11b96a 100644 --- a/market_service.py +++ b/market_service.py @@ -7,12 +7,10 @@ from typing import Any from openbb import obb -from obb_utils import to_list +from obb_utils import to_list, days_ago, PROVIDER logger = logging.getLogger(__name__) -PROVIDER = "yfinance" - # --- 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]]: """Get ETF price history.""" - start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d") + start = days_ago(days) try: result = await asyncio.to_thread( 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]]: """Get index price history.""" - start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d") + start = days_ago(days) try: result = await asyncio.to_thread( 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]]: """Get cryptocurrency price history.""" - start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d") + start = days_ago(days) try: result = await asyncio.to_thread( obb.crypto.price.historical, symbol, start_date=start, provider=PROVIDER @@ -110,7 +108,7 @@ async def get_currency_historical( symbol: str, days: int = 365 ) -> list[dict[str, Any]]: """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: result = await asyncio.to_thread( obb.currency.price.historical, symbol, start_date=start, provider=PROVIDER @@ -140,7 +138,7 @@ async def get_futures_historical( symbol: str, days: int = 365 ) -> list[dict[str, Any]]: """Get futures price history.""" - start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d") + start = days_ago(days) try: result = await asyncio.to_thread( obb.derivatives.futures.historical, symbol, start_date=start, provider=PROVIDER diff --git a/obb_utils.py b/obb_utils.py index e0ed3ee..b4d4ce0 100644 --- a/obb_utils.py +++ b/obb_utils.py @@ -66,11 +66,16 @@ def first_or_empty(result: Any) -> dict[str, Any]: 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( symbol: str, days: int = 365, provider: str = PROVIDER, ) -> Any | 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: result = await asyncio.to_thread( obb.equity.price.historical, symbol, start_date=start, provider=provider, diff --git a/openbb_service.py b/openbb_service.py index 8337640..af2ebc4 100644 --- a/openbb_service.py +++ b/openbb_service.py @@ -6,12 +6,10 @@ from typing import Any import yfinance as yf 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__) -PROVIDER = "yfinance" - async def get_quote(symbol: str) -> dict[str, Any]: 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]]: - start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d") + start = days_ago(days) result = await asyncio.to_thread( obb.equity.price.historical, symbol, diff --git a/quantitative_service.py b/quantitative_service.py index fe5db59..11845fc 100644 --- a/quantitative_service.py +++ b/quantitative_service.py @@ -7,12 +7,10 @@ from typing import Any 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__) -PROVIDER = "yfinance" - # Need 252+ trading days for default window; 730 calendar days is safe PERF_DAYS = 730 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.""" # Need at least 252 trading days for Sharpe window 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: 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]: """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: 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]: """Run normality tests (Jarque-Bera, Shapiro-Wilk, etc.) on returns.""" 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: 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]: """Run unit root tests (ADF, KPSS) for stationarity.""" 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: hist = await asyncio.to_thread( diff --git a/reddit_service.py b/reddit_service.py new file mode 100644 index 0000000..c4a5f92 --- /dev/null +++ b/reddit_service.py @@ -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 [] diff --git a/route_utils.py b/route_utils.py index 966451d..bb21c7d 100644 --- a/route_utils.py +++ b/route_utils.py @@ -23,13 +23,19 @@ def validate_symbol(symbol: str) -> str: 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) async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: try: return await fn(*args, **kwargs) except HTTPException: raise + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) except Exception: logger.exception("Upstream data error") raise HTTPException( diff --git a/routes_backtest.py b/routes_backtest.py index 6b0e7df..16f8449 100644 --- a/routes_backtest.py +++ b/routes_backtest.py @@ -1,14 +1,11 @@ """Routes for backtesting strategies.""" -import logging - -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter from pydantic import BaseModel, Field import backtest_service from models import ApiResponse - -logger = logging.getLogger(__name__) +from route_utils import safe router = APIRouter(prefix="/api/v1/backtest", tags=["backtest"]) @@ -54,88 +51,56 @@ class MomentumRequest(BaseModel): @router.post("/sma-crossover", response_model=ApiResponse) +@safe async def sma_crossover(req: SMARequest) -> ApiResponse: """SMA crossover strategy: buy when short SMA crosses above long SMA.""" - try: - result = await backtest_service.backtest_sma_crossover( - req.symbol, - short_window=req.short_window, - long_window=req.long_window, - days=req.days, - initial_capital=req.initial_capital, - ) - 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 + result = await backtest_service.backtest_sma_crossover( + req.symbol, + short_window=req.short_window, + long_window=req.long_window, + days=req.days, + initial_capital=req.initial_capital, + ) + return ApiResponse(data=result) @router.post("/rsi", response_model=ApiResponse) +@safe async def rsi_strategy(req: RSIRequest) -> ApiResponse: """RSI strategy: buy when RSI < oversold, sell when RSI > overbought.""" - try: - result = await backtest_service.backtest_rsi( - req.symbol, - period=req.period, - oversold=req.oversold, - overbought=req.overbought, - days=req.days, - initial_capital=req.initial_capital, - ) - 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 + result = await backtest_service.backtest_rsi( + req.symbol, + period=req.period, + oversold=req.oversold, + overbought=req.overbought, + days=req.days, + initial_capital=req.initial_capital, + ) + return ApiResponse(data=result) @router.post("/buy-and-hold", response_model=ApiResponse) +@safe async def buy_and_hold(req: BuyAndHoldRequest) -> ApiResponse: """Buy-and-hold benchmark: buy on day 1, hold through end of period.""" - try: - result = await backtest_service.backtest_buy_and_hold( - req.symbol, - days=req.days, - initial_capital=req.initial_capital, - ) - 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 + result = await backtest_service.backtest_buy_and_hold( + req.symbol, + days=req.days, + initial_capital=req.initial_capital, + ) + return ApiResponse(data=result) @router.post("/momentum", response_model=ApiResponse) +@safe async def momentum_strategy(req: MomentumRequest) -> ApiResponse: - """Momentum strategy: every rebalance_days pick top_n symbols by lookback return.""" - try: - result = await backtest_service.backtest_momentum( - symbols=req.symbols, - lookback=req.lookback, - top_n=req.top_n, - rebalance_days=req.rebalance_days, - days=req.days, - initial_capital=req.initial_capital, - ) - 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 + """Momentum strategy: every rebalance_days pick top_n by lookback return.""" + result = await backtest_service.backtest_momentum( + symbols=req.symbols, + lookback=req.lookback, + top_n=req.top_n, + rebalance_days=req.rebalance_days, + days=req.days, + initial_capital=req.initial_capital, + ) + return ApiResponse(data=result) diff --git a/routes_sentiment.py b/routes_sentiment.py index a0f616b..4f75061 100644 --- a/routes_sentiment.py +++ b/routes_sentiment.py @@ -9,6 +9,7 @@ from route_utils import safe, validate_symbol import alphavantage_service import finnhub_service import openbb_service +import reddit_service 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( alphavantage_service.get_news_sentiment(symbol, limit=20), 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), finnhub_service.get_recommendation_trends(symbol), return_exceptions=True, @@ -211,7 +212,7 @@ async def stock_reddit_sentiment( ): """Reddit sentiment: mentions, upvotes, rank on WSB/stocks/investing (free, no key).""" symbol = validate_symbol(symbol) - data = await finnhub_service.get_reddit_sentiment(symbol) + data = await reddit_service.get_reddit_sentiment(symbol) return ApiResponse(data=data) @@ -219,5 +220,5 @@ async def stock_reddit_sentiment( @safe async def reddit_trending(): """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) diff --git a/tests/test_finnhub_service_social.py b/tests/test_finnhub_service_social.py index 615f15e..4278f23 100644 --- a/tests/test_finnhub_service_social.py +++ b/tests/test_finnhub_service_social.py @@ -5,6 +5,7 @@ from unittest.mock import patch, AsyncMock, MagicMock import pytest import finnhub_service +import reddit_service # --- get_social_sentiment --- @@ -178,7 +179,7 @@ def test_summarize_social_missing_fields(): @pytest.mark.asyncio -@patch("finnhub_service.httpx.AsyncClient") +@patch("reddit_service.httpx.AsyncClient") async def test_reddit_sentiment_symbol_found(mock_client_cls): mock_resp = 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_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["symbol"] == "AAPL" assert result["rank"] == 3 @@ -205,7 +206,7 @@ async def test_reddit_sentiment_symbol_found(mock_client_cls): @pytest.mark.asyncio -@patch("finnhub_service.httpx.AsyncClient") +@patch("reddit_service.httpx.AsyncClient") async def test_reddit_sentiment_symbol_not_found(mock_client_cls): mock_resp = 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_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["symbol"] == "AAPL" assert "not in Reddit" in result["message"] @pytest.mark.asyncio -@patch("finnhub_service.httpx.AsyncClient") +@patch("reddit_service.httpx.AsyncClient") async def test_reddit_sentiment_zero_mentions_prev(mock_client_cls): mock_resp = 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_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["mentions_change_pct"] is None # division by zero handled @pytest.mark.asyncio -@patch("finnhub_service.httpx.AsyncClient") +@patch("reddit_service.httpx.AsyncClient") async def test_reddit_sentiment_api_failure(mock_client_cls): mock_client = AsyncMock() 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_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 "error" in result @pytest.mark.asyncio -@patch("finnhub_service.httpx.AsyncClient") +@patch("reddit_service.httpx.AsyncClient") async def test_reddit_sentiment_case_insensitive(mock_client_cls): mock_resp = 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_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 @@ -288,7 +289,7 @@ async def test_reddit_sentiment_case_insensitive(mock_client_cls): @pytest.mark.asyncio -@patch("finnhub_service.httpx.AsyncClient") +@patch("reddit_service.httpx.AsyncClient") async def test_reddit_trending_happy_path(mock_client_cls): mock_resp = 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_cls.return_value = mock_client - result = await finnhub_service.get_reddit_trending() + result = await reddit_service.get_reddit_trending() assert len(result) == 3 assert result[0]["symbol"] == "TSLA" assert result[0]["rank"] == 1 @@ -316,7 +317,7 @@ async def test_reddit_trending_happy_path(mock_client_cls): @pytest.mark.asyncio -@patch("finnhub_service.httpx.AsyncClient") +@patch("reddit_service.httpx.AsyncClient") async def test_reddit_trending_limits_to_25(mock_client_cls): mock_resp = 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_cls.return_value = mock_client - result = await finnhub_service.get_reddit_trending() + result = await reddit_service.get_reddit_trending() assert len(result) == 25 @pytest.mark.asyncio -@patch("finnhub_service.httpx.AsyncClient") +@patch("reddit_service.httpx.AsyncClient") async def test_reddit_trending_empty_results(mock_client_cls): mock_resp = 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_cls.return_value = mock_client - result = await finnhub_service.get_reddit_trending() + result = await reddit_service.get_reddit_trending() assert result == [] @pytest.mark.asyncio -@patch("finnhub_service.httpx.AsyncClient") +@patch("reddit_service.httpx.AsyncClient") async def test_reddit_trending_api_failure(mock_client_cls): mock_client = AsyncMock() 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_cls.return_value = mock_client - result = await finnhub_service.get_reddit_trending() + result = await reddit_service.get_reddit_trending() assert result == [] diff --git a/tests/test_routes_sentiment.py b/tests/test_routes_sentiment.py index 83be103..7f6a4e6 100644 --- a/tests/test_routes_sentiment.py +++ b/tests/test_routes_sentiment.py @@ -16,7 +16,7 @@ async def client(): @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.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.alphavantage_service.get_news_sentiment", new_callable=AsyncMock) async def test_stock_sentiment(mock_av, mock_sentiment, mock_reddit, mock_upgrades, mock_recs, client): diff --git a/tests/test_routes_sentiment_social.py b/tests/test_routes_sentiment_social.py index 8d9d4b9..92079eb 100644 --- a/tests/test_routes_sentiment_social.py +++ b/tests/test_routes_sentiment_social.py @@ -81,7 +81,7 @@ async def test_stock_social_sentiment_invalid_symbol(client): @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): mock_fn.return_value = { "symbol": "AAPL", @@ -104,7 +104,7 @@ async def test_stock_reddit_sentiment_found(mock_fn, client): @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): mock_fn.return_value = { "symbol": "OBSCURE", @@ -119,7 +119,7 @@ async def test_stock_reddit_sentiment_not_found(mock_fn, client): @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): mock_fn.side_effect = RuntimeError("ApeWisdom down") 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 -@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): mock_fn.return_value = [ {"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 -@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): mock_fn.return_value = [] 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 -@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): mock_fn.side_effect = RuntimeError("ApeWisdom unavailable") 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 @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.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.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): @@ -229,7 +229,7 @@ async def test_composite_sentiment_all_sources(mock_av, mock_fh, mock_reddit, mo @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.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.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): @@ -250,7 +250,7 @@ async def test_composite_sentiment_no_data_returns_unknown(mock_av, mock_fh, moc @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.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.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): @@ -271,7 +271,7 @@ async def test_composite_sentiment_strong_bullish_label(mock_av, mock_fh, mock_r @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.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.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): @@ -291,7 +291,7 @@ async def test_composite_sentiment_bearish_label(mock_av, mock_fh, mock_reddit, @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.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.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): @@ -318,7 +318,7 @@ async def test_composite_sentiment_invalid_symbol(client): @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.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.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): @@ -338,7 +338,7 @@ async def test_composite_sentiment_reddit_low_mentions_excluded(mock_av, mock_fh @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.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.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):