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,53 +1,28 @@
"""Routes for calendar events, screening, ownership, and estimates."""
import functools
import logging
from collections.abc import Awaitable, Callable
from typing import ParamSpec, TypeVar
from fastapi import APIRouter, Path, Query
from fastapi import APIRouter, HTTPException, Path, Query
from models import SYMBOL_PATTERN, ApiResponse
from models import ApiResponse
from route_utils import safe, validate_symbol
import calendar_service
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]
DATE_PATTERN = r"^\d{4}-\d{2}-\d{2}$"
# --- Calendar Events ---
@router.get("/calendar/earnings", response_model=ApiResponse)
@_safe
@safe
async def earnings_calendar(
start_date: str | None = Query(default=None, description="YYYY-MM-DD"),
end_date: str | None = Query(default=None, description="YYYY-MM-DD"),
start_date: str | None = Query(
default=None, pattern=DATE_PATTERN, description="YYYY-MM-DD"
),
end_date: str | None = Query(
default=None, pattern=DATE_PATTERN, description="YYYY-MM-DD"
),
):
"""Get upcoming earnings announcements."""
data = await calendar_service.get_earnings_calendar(start_date, end_date)
@@ -55,10 +30,14 @@ async def earnings_calendar(
@router.get("/calendar/dividends", response_model=ApiResponse)
@_safe
@safe
async def dividend_calendar(
start_date: str | None = Query(default=None, description="YYYY-MM-DD"),
end_date: str | None = Query(default=None, description="YYYY-MM-DD"),
start_date: str | None = Query(
default=None, pattern=DATE_PATTERN, description="YYYY-MM-DD"
),
end_date: str | None = Query(
default=None, pattern=DATE_PATTERN, description="YYYY-MM-DD"
),
):
"""Get upcoming dividend dates."""
data = await calendar_service.get_dividend_calendar(start_date, end_date)
@@ -66,10 +45,14 @@ async def dividend_calendar(
@router.get("/calendar/ipo", response_model=ApiResponse)
@_safe
@safe
async def ipo_calendar(
start_date: str | None = Query(default=None, description="YYYY-MM-DD"),
end_date: str | None = Query(default=None, description="YYYY-MM-DD"),
start_date: str | None = Query(
default=None, pattern=DATE_PATTERN, description="YYYY-MM-DD"
),
end_date: str | None = Query(
default=None, pattern=DATE_PATTERN, description="YYYY-MM-DD"
),
):
"""Get upcoming IPOs."""
data = await calendar_service.get_ipo_calendar(start_date, end_date)
@@ -77,10 +60,14 @@ async def ipo_calendar(
@router.get("/calendar/splits", response_model=ApiResponse)
@_safe
@safe
async def splits_calendar(
start_date: str | None = Query(default=None, description="YYYY-MM-DD"),
end_date: str | None = Query(default=None, description="YYYY-MM-DD"),
start_date: str | None = Query(
default=None, pattern=DATE_PATTERN, description="YYYY-MM-DD"
),
end_date: str | None = Query(
default=None, pattern=DATE_PATTERN, description="YYYY-MM-DD"
),
):
"""Get upcoming stock splits."""
data = await calendar_service.get_splits_calendar(start_date, end_date)
@@ -91,19 +78,19 @@ async def splits_calendar(
@router.get("/stock/{symbol}/estimates", response_model=ApiResponse)
@_safe
@safe
async def stock_estimates(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get analyst consensus estimates."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await calendar_service.get_analyst_estimates(symbol)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/share-statistics", response_model=ApiResponse)
@_safe
@safe
async def stock_share_stats(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get share statistics: float, outstanding, short interest."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await calendar_service.get_share_statistics(symbol)
return ApiResponse(data=data)
@@ -112,19 +99,19 @@ async def stock_share_stats(symbol: str = Path(..., min_length=1, max_length=20)
@router.get("/stock/{symbol}/sec-insider", response_model=ApiResponse)
@_safe
@safe
async def stock_sec_insider(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get insider trading data from SEC (Form 4)."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await calendar_service.get_insider_trading(symbol)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/institutional", response_model=ApiResponse)
@_safe
@safe
async def stock_institutional(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get institutional holders from SEC 13F filings."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await calendar_service.get_institutional_holders(symbol)
return ApiResponse(data=data)
@@ -133,7 +120,7 @@ async def stock_institutional(symbol: str = Path(..., min_length=1, max_length=2
@router.get("/screener", response_model=ApiResponse)
@_safe
@safe
async def stock_screener():
"""Screen stocks using available filters."""
data = await calendar_service.screen_stocks()