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:
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
80
reddit_service.py
Normal 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 []
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,9 +51,9 @@ 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,
|
||||||
@@ -65,20 +62,12 @@ async def sma_crossover(req: SMARequest) -> ApiResponse:
|
|||||||
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,
|
||||||
@@ -88,40 +77,24 @@ async def rsi_strategy(req: RSIRequest) -> ApiResponse:
|
|||||||
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,
|
||||||
@@ -131,11 +104,3 @@ async def momentum_strategy(req: MomentumRequest) -> ApiResponse:
|
|||||||
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
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 == []
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user