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,85 +1,54 @@
"""Routes for quantitative analysis: risk metrics, CAPM, normality, unit root."""
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 quantitative_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]
@router.get("/stock/{symbol}/performance", response_model=ApiResponse)
@_safe
@safe
async def stock_performance(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=30, le=3650),
):
"""Performance metrics: Sharpe, Sortino, max drawdown, volatility."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await quantitative_service.get_performance_metrics(symbol, days=days)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/capm", response_model=ApiResponse)
@_safe
@safe
async def stock_capm(symbol: str = Path(..., min_length=1, max_length=20)):
"""CAPM: beta, alpha, systematic and idiosyncratic risk."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await quantitative_service.get_capm(symbol)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/normality", response_model=ApiResponse)
@_safe
@safe
async def stock_normality(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=30, le=3650),
):
"""Normality tests: Jarque-Bera, Shapiro-Wilk on returns."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await quantitative_service.get_normality_test(symbol, days=days)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/unitroot", response_model=ApiResponse)
@_safe
@safe
async def stock_unitroot(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=30, le=3650),
):
"""Unit root tests: ADF, KPSS for stationarity."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await quantitative_service.get_unitroot_test(symbol, days=days)
return ApiResponse(data=data)