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:
@@ -2,11 +2,13 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any
|
||||
|
||||
from openbb import obb
|
||||
|
||||
from obb_utils import extract_single, safe_last
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROVIDER = "yfinance"
|
||||
@@ -20,7 +22,7 @@ async def get_performance_metrics(symbol: str, days: int = 365) -> dict[str, Any
|
||||
"""Calculate Sharpe ratio, summary stats, and volatility for a symbol."""
|
||||
# Need at least 252 trading days for Sharpe window
|
||||
fetch_days = max(days, PERF_DAYS)
|
||||
start = (datetime.now() - timedelta(days=fetch_days)).strftime("%Y-%m-%d")
|
||||
start = (datetime.now(tz=timezone.utc) - timedelta(days=fetch_days)).strftime("%Y-%m-%d")
|
||||
|
||||
try:
|
||||
hist = await asyncio.to_thread(
|
||||
@@ -29,7 +31,7 @@ async def get_performance_metrics(symbol: str, days: int = 365) -> dict[str, Any
|
||||
if not hist or not hist.results:
|
||||
return {"symbol": symbol, "error": "No historical data"}
|
||||
|
||||
sharpe_result, summary_result, stdev_result = await asyncio.gather(
|
||||
results: tuple[Any, ...] = await asyncio.gather(
|
||||
asyncio.to_thread(
|
||||
obb.quantitative.performance.sharpe_ratio,
|
||||
data=hist.results, target=TARGET,
|
||||
@@ -42,10 +44,11 @@ async def get_performance_metrics(symbol: str, days: int = 365) -> dict[str, Any
|
||||
),
|
||||
return_exceptions=True,
|
||||
)
|
||||
sharpe_result, summary_result, stdev_result = results
|
||||
|
||||
sharpe = _safe_last(sharpe_result) if not isinstance(sharpe_result, BaseException) else None
|
||||
summary = _extract_single(summary_result) if not isinstance(summary_result, BaseException) else {}
|
||||
stdev = _safe_last(stdev_result) if not isinstance(stdev_result, BaseException) else None
|
||||
sharpe = safe_last(sharpe_result) if not isinstance(sharpe_result, BaseException) else None
|
||||
summary = extract_single(summary_result) if not isinstance(summary_result, BaseException) else {}
|
||||
stdev = safe_last(stdev_result) if not isinstance(stdev_result, BaseException) else None
|
||||
|
||||
return {
|
||||
"symbol": symbol,
|
||||
@@ -61,7 +64,7 @@ async def get_performance_metrics(symbol: str, days: int = 365) -> dict[str, Any
|
||||
|
||||
async def get_capm(symbol: str) -> dict[str, Any]:
|
||||
"""Calculate CAPM metrics: beta, alpha, systematic/idiosyncratic risk."""
|
||||
start = (datetime.now() - timedelta(days=PERF_DAYS)).strftime("%Y-%m-%d")
|
||||
start = (datetime.now(tz=timezone.utc) - timedelta(days=PERF_DAYS)).strftime("%Y-%m-%d")
|
||||
|
||||
try:
|
||||
hist = await asyncio.to_thread(
|
||||
@@ -73,7 +76,7 @@ async def get_capm(symbol: str) -> dict[str, Any]:
|
||||
capm = await asyncio.to_thread(
|
||||
obb.quantitative.capm, data=hist.results, target=TARGET
|
||||
)
|
||||
return {"symbol": symbol, **_extract_single(capm)}
|
||||
return {"symbol": symbol, **extract_single(capm)}
|
||||
except Exception:
|
||||
logger.warning("CAPM failed for %s", symbol, exc_info=True)
|
||||
return {"symbol": symbol, "error": "Failed to compute CAPM"}
|
||||
@@ -82,7 +85,7 @@ async def get_capm(symbol: str) -> 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."""
|
||||
fetch_days = max(days, PERF_DAYS)
|
||||
start = (datetime.now() - timedelta(days=fetch_days)).strftime("%Y-%m-%d")
|
||||
start = (datetime.now(tz=timezone.utc) - timedelta(days=fetch_days)).strftime("%Y-%m-%d")
|
||||
|
||||
try:
|
||||
hist = await asyncio.to_thread(
|
||||
@@ -94,7 +97,7 @@ async def get_normality_test(symbol: str, days: int = 365) -> dict[str, Any]:
|
||||
norm = await asyncio.to_thread(
|
||||
obb.quantitative.normality, data=hist.results, target=TARGET
|
||||
)
|
||||
return {"symbol": symbol, **_extract_single(norm)}
|
||||
return {"symbol": symbol, **extract_single(norm)}
|
||||
except Exception:
|
||||
logger.warning("Normality test failed for %s", symbol, exc_info=True)
|
||||
return {"symbol": symbol, "error": "Failed to compute normality tests"}
|
||||
@@ -103,7 +106,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]:
|
||||
"""Run unit root tests (ADF, KPSS) for stationarity."""
|
||||
fetch_days = max(days, PERF_DAYS)
|
||||
start = (datetime.now() - timedelta(days=fetch_days)).strftime("%Y-%m-%d")
|
||||
start = (datetime.now(tz=timezone.utc) - timedelta(days=fetch_days)).strftime("%Y-%m-%d")
|
||||
|
||||
try:
|
||||
hist = await asyncio.to_thread(
|
||||
@@ -115,33 +118,7 @@ async def get_unitroot_test(symbol: str, days: int = 365) -> dict[str, Any]:
|
||||
ur = await asyncio.to_thread(
|
||||
obb.quantitative.unitroot_test, data=hist.results, target=TARGET
|
||||
)
|
||||
return {"symbol": symbol, **_extract_single(ur)}
|
||||
return {"symbol": symbol, **extract_single(ur)}
|
||||
except Exception:
|
||||
logger.warning("Unit root test failed for %s", symbol, exc_info=True)
|
||||
return {"symbol": symbol, "error": "Failed to compute unit root test"}
|
||||
|
||||
|
||||
def _extract_single(result: Any) -> dict[str, Any]:
|
||||
"""Extract data from an OBBject result (single model or list)."""
|
||||
if result is None:
|
||||
return {}
|
||||
items = getattr(result, "results", None)
|
||||
if items is None:
|
||||
return {}
|
||||
if hasattr(items, "model_dump"):
|
||||
return items.model_dump()
|
||||
if isinstance(items, list) and items:
|
||||
last = items[-1]
|
||||
return last.model_dump() if hasattr(last, "model_dump") else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _safe_last(result: Any) -> dict[str, Any] | None:
|
||||
"""Get the last item from a list result, or None."""
|
||||
if result is None:
|
||||
return None
|
||||
items = getattr(result, "results", None)
|
||||
if items is None or not isinstance(items, list) or not items:
|
||||
return None
|
||||
last = items[-1]
|
||||
return last.model_dump() if hasattr(last, "model_dump") else None
|
||||
|
||||
Reference in New Issue
Block a user