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,82 +1,52 @@
"""Routes for ETF, index, crypto, currency, and derivatives data."""
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 market_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]
# --- ETF ---
# NOTE: /etf/search MUST be registered before /etf/{symbol} to avoid shadowing.
@router.get("/etf/search", response_model=ApiResponse)
@safe
async def etf_search(query: str = Query(..., min_length=1, max_length=100)):
"""Search for ETFs by name or keyword."""
data = await market_service.search_etf(query)
return ApiResponse(data=data)
@router.get("/etf/{symbol}/info", response_model=ApiResponse)
@_safe
@safe
async def etf_info(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get ETF profile and info."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await market_service.get_etf_info(symbol)
return ApiResponse(data=data)
@router.get("/etf/{symbol}/historical", response_model=ApiResponse)
@_safe
@safe
async def etf_historical(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=1, le=3650),
):
"""Get ETF price history."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await market_service.get_etf_historical(symbol, days=days)
return ApiResponse(data=data)
@router.get("/etf/search", response_model=ApiResponse)
@_safe
async def etf_search(query: str = Query(..., min_length=1, max_length=100)):
"""Search for ETFs by name or keyword."""
data = await market_service.search_etf(query)
return ApiResponse(data=data)
# --- Index ---
@router.get("/index/available", response_model=ApiResponse)
@_safe
@safe
async def index_available():
"""List available market indices."""
data = await market_service.get_available_indices()
@@ -84,51 +54,52 @@ async def index_available():
@router.get("/index/{symbol}/historical", response_model=ApiResponse)
@_safe
@safe
async def index_historical(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=1, le=3650),
):
"""Get index price history (e.g., ^GSPC, ^DJI, ^IXIC)."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await market_service.get_index_historical(symbol, days=days)
return ApiResponse(data=data)
# --- Crypto ---
# NOTE: /crypto/search MUST be registered before /crypto/{symbol} to avoid shadowing.
@router.get("/crypto/search", response_model=ApiResponse)
@safe
async def crypto_search(query: str = Query(..., min_length=1, max_length=100)):
"""Search for cryptocurrencies."""
data = await market_service.search_crypto(query)
return ApiResponse(data=data)
@router.get("/crypto/{symbol}/historical", response_model=ApiResponse)
@_safe
@safe
async def crypto_historical(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=1, le=3650),
):
"""Get cryptocurrency price history (e.g., BTC-USD)."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await market_service.get_crypto_historical(symbol, days=days)
return ApiResponse(data=data)
@router.get("/crypto/search", response_model=ApiResponse)
@_safe
async def crypto_search(query: str = Query(..., min_length=1, max_length=100)):
"""Search for cryptocurrencies."""
data = await market_service.search_crypto(query)
return ApiResponse(data=data)
# --- Currency ---
@router.get("/currency/{symbol}/historical", response_model=ApiResponse)
@_safe
@safe
async def currency_historical(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=1, le=3650),
):
"""Get forex price history (e.g., EURUSD, USDSEK)."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await market_service.get_currency_historical(symbol, days=days)
return ApiResponse(data=data)
@@ -137,30 +108,30 @@ async def currency_historical(
@router.get("/options/{symbol}/chains", response_model=ApiResponse)
@_safe
@safe
async def options_chains(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get options chain data."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await market_service.get_options_chains(symbol)
return ApiResponse(data=data)
@router.get("/futures/{symbol}/historical", response_model=ApiResponse)
@_safe
@safe
async def futures_historical(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=1, le=3650),
):
"""Get futures price history."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await market_service.get_futures_historical(symbol, days=days)
return ApiResponse(data=data)
@router.get("/futures/{symbol}/curve", response_model=ApiResponse)
@_safe
@safe
async def futures_curve(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get futures term structure/curve."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await market_service.get_futures_curve(symbol)
return ApiResponse(data=data)