refactor: fix code review issues across routes and services

- Extract shared route_utils.py (validate_symbol, safe decorator)
  removing duplication from 6 route files
- Extract shared obb_utils.py (to_list, extract_single, safe_last)
  removing duplication from calendar_service and market_service
- Fix _to_list dict mutation during iteration (use comprehension)
- Fix double vars() call and live __dict__ mutation risk
- Fix route ordering: /etf/search and /crypto/search now registered
  before /{symbol} path params to prevent shadowing
- Add date format validation (YYYY-MM-DD pattern) on calendar routes
- Use timezone-aware datetime.now(tz=timezone.utc) in all services
- Add explicit type annotation for asyncio.gather results
This commit is contained in:
Yaojia Wang
2026-03-09 10:56:21 +01:00
parent 507194397e
commit 003c1d6ffc
12 changed files with 271 additions and 428 deletions

View File

@@ -1,55 +1,29 @@
"""Routes for sentiment, insider trades, and analyst data (Finnhub + Alpha Vantage)."""
import asyncio
import functools
import logging
from collections.abc import Awaitable, Callable
from typing import ParamSpec, TypeVar
from fastapi import APIRouter, HTTPException, Path, Query
from fastapi import APIRouter, Path, Query
from models import SYMBOL_PATTERN, ApiResponse
from models import ApiResponse
from route_utils import safe, validate_symbol
import alphavantage_service
import finnhub_service
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1")
P = ParamSpec("P")
R = TypeVar("R")
def _validate_symbol(symbol: str) -> str:
if not SYMBOL_PATTERN.match(symbol):
raise HTTPException(status_code=400, detail="Invalid symbol format")
return symbol.upper()
def _safe(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
@functools.wraps(fn)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
try:
return await fn(*args, **kwargs)
except HTTPException:
raise
except Exception:
logger.exception("Upstream data error")
raise HTTPException(
status_code=502,
detail="Data provider error. Check server logs.",
)
return wrapper # type: ignore[return-value]
# --- Sentiment & News ---
@router.get("/stock/{symbol}/sentiment", response_model=ApiResponse)
@_safe
@safe
async def stock_sentiment(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get aggregated sentiment: Alpha Vantage news sentiment + Finnhub analyst data."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
finnhub_data, av_data = await asyncio.gather(
finnhub_service.get_sentiment_summary(symbol),
alphavantage_service.get_news_sentiment(symbol, limit=20),
@@ -67,22 +41,22 @@ async def stock_sentiment(symbol: str = Path(..., min_length=1, max_length=20)):
@router.get("/stock/{symbol}/news-sentiment", response_model=ApiResponse)
@_safe
@safe
async def stock_news_sentiment(
symbol: str = Path(..., min_length=1, max_length=20),
limit: int = Query(default=30, ge=1, le=200),
):
"""Get news articles with per-ticker sentiment scores (Alpha Vantage)."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await alphavantage_service.get_news_sentiment(symbol, limit=limit)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/insider-trades", response_model=ApiResponse)
@_safe
@safe
async def stock_insider_trades(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get insider transactions (CEO/CFO buys and sells)."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
raw = await finnhub_service.get_insider_transactions(symbol)
trades = [
{
@@ -100,10 +74,10 @@ async def stock_insider_trades(symbol: str = Path(..., min_length=1, max_length=
@router.get("/stock/{symbol}/recommendations", response_model=ApiResponse)
@_safe
@safe
async def stock_recommendations(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get analyst recommendation trends (monthly buy/hold/sell counts)."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
raw = await finnhub_service.get_recommendation_trends(symbol)
recs = [
{
@@ -120,10 +94,10 @@ async def stock_recommendations(symbol: str = Path(..., min_length=1, max_length
@router.get("/stock/{symbol}/upgrades", response_model=ApiResponse)
@_safe
@safe
async def stock_upgrades(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get recent analyst upgrades and downgrades."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
raw = await finnhub_service.get_upgrade_downgrade(symbol)
upgrades = [
{