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:
@@ -6,6 +6,8 @@ from typing import Any
|
|||||||
|
|
||||||
from openbb import obb
|
from openbb import obb
|
||||||
|
|
||||||
|
from obb_utils import to_list
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -20,7 +22,7 @@ async def get_earnings_calendar(
|
|||||||
if end_date:
|
if end_date:
|
||||||
kwargs["end_date"] = end_date
|
kwargs["end_date"] = end_date
|
||||||
result = await asyncio.to_thread(obb.equity.calendar.earnings, **kwargs)
|
result = await asyncio.to_thread(obb.equity.calendar.earnings, **kwargs)
|
||||||
return _to_list(result)
|
return to_list(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Earnings calendar failed", exc_info=True)
|
logger.warning("Earnings calendar failed", exc_info=True)
|
||||||
return []
|
return []
|
||||||
@@ -37,7 +39,7 @@ async def get_dividend_calendar(
|
|||||||
if end_date:
|
if end_date:
|
||||||
kwargs["end_date"] = end_date
|
kwargs["end_date"] = end_date
|
||||||
result = await asyncio.to_thread(obb.equity.calendar.dividend, **kwargs)
|
result = await asyncio.to_thread(obb.equity.calendar.dividend, **kwargs)
|
||||||
return _to_list(result)
|
return to_list(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Dividend calendar failed", exc_info=True)
|
logger.warning("Dividend calendar failed", exc_info=True)
|
||||||
return []
|
return []
|
||||||
@@ -54,7 +56,7 @@ async def get_ipo_calendar(
|
|||||||
if end_date:
|
if end_date:
|
||||||
kwargs["end_date"] = end_date
|
kwargs["end_date"] = end_date
|
||||||
result = await asyncio.to_thread(obb.equity.calendar.ipo, **kwargs)
|
result = await asyncio.to_thread(obb.equity.calendar.ipo, **kwargs)
|
||||||
return _to_list(result)
|
return to_list(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("IPO calendar failed", exc_info=True)
|
logger.warning("IPO calendar failed", exc_info=True)
|
||||||
return []
|
return []
|
||||||
@@ -71,7 +73,7 @@ async def get_splits_calendar(
|
|||||||
if end_date:
|
if end_date:
|
||||||
kwargs["end_date"] = end_date
|
kwargs["end_date"] = end_date
|
||||||
result = await asyncio.to_thread(obb.equity.calendar.splits, **kwargs)
|
result = await asyncio.to_thread(obb.equity.calendar.splits, **kwargs)
|
||||||
return _to_list(result)
|
return to_list(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Splits calendar failed", exc_info=True)
|
logger.warning("Splits calendar failed", exc_info=True)
|
||||||
return []
|
return []
|
||||||
@@ -83,7 +85,7 @@ async def get_analyst_estimates(symbol: str) -> dict[str, Any]:
|
|||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
obb.equity.estimates.consensus, symbol, provider="yfinance"
|
obb.equity.estimates.consensus, symbol, provider="yfinance"
|
||||||
)
|
)
|
||||||
items = _to_list(result)
|
items = to_list(result)
|
||||||
return {"symbol": symbol, "estimates": items}
|
return {"symbol": symbol, "estimates": items}
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Analyst estimates failed for %s", symbol, exc_info=True)
|
logger.warning("Analyst estimates failed for %s", symbol, exc_info=True)
|
||||||
@@ -96,7 +98,7 @@ async def get_share_statistics(symbol: str) -> dict[str, Any]:
|
|||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
obb.equity.ownership.share_statistics, symbol, provider="yfinance"
|
obb.equity.ownership.share_statistics, symbol, provider="yfinance"
|
||||||
)
|
)
|
||||||
items = _to_list(result)
|
items = to_list(result)
|
||||||
return items[0] if items else {}
|
return items[0] if items else {}
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Share statistics failed for %s", symbol, exc_info=True)
|
logger.warning("Share statistics failed for %s", symbol, exc_info=True)
|
||||||
@@ -109,7 +111,7 @@ async def get_insider_trading(symbol: str) -> list[dict[str, Any]]:
|
|||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
obb.equity.ownership.insider_trading, symbol, provider="sec"
|
obb.equity.ownership.insider_trading, symbol, provider="sec"
|
||||||
)
|
)
|
||||||
return _to_list(result)
|
return to_list(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("SEC insider trading failed for %s", symbol, exc_info=True)
|
logger.warning("SEC insider trading failed for %s", symbol, exc_info=True)
|
||||||
return []
|
return []
|
||||||
@@ -121,7 +123,7 @@ async def get_institutional_holders(symbol: str) -> list[dict[str, Any]]:
|
|||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
obb.equity.ownership.form_13f, symbol, provider="sec"
|
obb.equity.ownership.form_13f, symbol, provider="sec"
|
||||||
)
|
)
|
||||||
return _to_list(result)
|
return to_list(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("13F data failed for %s", symbol, exc_info=True)
|
logger.warning("13F data failed for %s", symbol, exc_info=True)
|
||||||
return []
|
return []
|
||||||
@@ -133,27 +135,7 @@ async def screen_stocks() -> list[dict[str, Any]]:
|
|||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
obb.equity.screener, provider="yfinance"
|
obb.equity.screener, provider="yfinance"
|
||||||
)
|
)
|
||||||
return _to_list(result)
|
return to_list(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Stock screener failed", exc_info=True)
|
logger.warning("Stock screener failed", exc_info=True)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _to_list(result: Any) -> list[dict[str, Any]]:
|
|
||||||
"""Convert OBBject result to list of dicts."""
|
|
||||||
if result is None or result.results is None:
|
|
||||||
return []
|
|
||||||
items = result.results
|
|
||||||
if not isinstance(items, list):
|
|
||||||
items = [items]
|
|
||||||
out = []
|
|
||||||
for item in items:
|
|
||||||
if hasattr(item, "model_dump"):
|
|
||||||
d = item.model_dump()
|
|
||||||
else:
|
|
||||||
d = vars(item) if vars(item) else {}
|
|
||||||
for k, v in d.items():
|
|
||||||
if hasattr(v, "isoformat"):
|
|
||||||
d[k] = v.isoformat()
|
|
||||||
out.append(d)
|
|
||||||
return out
|
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from openbb import obb
|
from openbb import obb
|
||||||
|
|
||||||
|
from obb_utils import to_list
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PROVIDER = "yfinance"
|
PROVIDER = "yfinance"
|
||||||
@@ -19,7 +21,7 @@ async def get_etf_info(symbol: str) -> dict[str, Any]:
|
|||||||
"""Get ETF profile/info."""
|
"""Get ETF profile/info."""
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(obb.etf.info, symbol, provider=PROVIDER)
|
result = await asyncio.to_thread(obb.etf.info, symbol, provider=PROVIDER)
|
||||||
items = _to_list(result)
|
items = to_list(result)
|
||||||
return items[0] if items else {}
|
return items[0] if items else {}
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("ETF info failed for %s", symbol, exc_info=True)
|
logger.warning("ETF info failed for %s", symbol, exc_info=True)
|
||||||
@@ -28,12 +30,12 @@ async def get_etf_info(symbol: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
async def get_etf_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]:
|
async def get_etf_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]:
|
||||||
"""Get ETF price history."""
|
"""Get ETF price history."""
|
||||||
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
obb.etf.historical, symbol, start_date=start, provider=PROVIDER
|
obb.etf.historical, symbol, start_date=start, provider=PROVIDER
|
||||||
)
|
)
|
||||||
return _to_list(result)
|
return to_list(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("ETF historical failed for %s", symbol, exc_info=True)
|
logger.warning("ETF historical failed for %s", symbol, exc_info=True)
|
||||||
return []
|
return []
|
||||||
@@ -43,7 +45,7 @@ async def search_etf(query: str) -> list[dict[str, Any]]:
|
|||||||
"""Search for ETFs by name or keyword."""
|
"""Search for ETFs by name or keyword."""
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(obb.etf.search, query)
|
result = await asyncio.to_thread(obb.etf.search, query)
|
||||||
return _to_list(result)
|
return to_list(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("ETF search failed for %s", query, exc_info=True)
|
logger.warning("ETF search failed for %s", query, exc_info=True)
|
||||||
return []
|
return []
|
||||||
@@ -56,7 +58,7 @@ async def get_available_indices() -> list[dict[str, Any]]:
|
|||||||
"""List available market indices."""
|
"""List available market indices."""
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(obb.index.available, provider=PROVIDER)
|
result = await asyncio.to_thread(obb.index.available, provider=PROVIDER)
|
||||||
return _to_list(result)
|
return to_list(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Available indices failed", exc_info=True)
|
logger.warning("Available indices failed", exc_info=True)
|
||||||
return []
|
return []
|
||||||
@@ -64,12 +66,12 @@ async def get_available_indices() -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
async def get_index_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]:
|
async def get_index_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]:
|
||||||
"""Get index price history."""
|
"""Get index price history."""
|
||||||
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
obb.index.price.historical, symbol, start_date=start, provider=PROVIDER
|
obb.index.price.historical, symbol, start_date=start, provider=PROVIDER
|
||||||
)
|
)
|
||||||
return _to_list(result)
|
return to_list(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Index historical failed for %s", symbol, exc_info=True)
|
logger.warning("Index historical failed for %s", symbol, exc_info=True)
|
||||||
return []
|
return []
|
||||||
@@ -80,12 +82,12 @@ async def get_index_historical(symbol: str, days: int = 365) -> list[dict[str, A
|
|||||||
|
|
||||||
async def get_crypto_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]:
|
async def get_crypto_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]:
|
||||||
"""Get cryptocurrency price history."""
|
"""Get cryptocurrency price history."""
|
||||||
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
obb.crypto.price.historical, symbol, start_date=start, provider=PROVIDER
|
obb.crypto.price.historical, symbol, start_date=start, provider=PROVIDER
|
||||||
)
|
)
|
||||||
return _to_list(result)
|
return to_list(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Crypto historical failed for %s", symbol, exc_info=True)
|
logger.warning("Crypto historical failed for %s", symbol, exc_info=True)
|
||||||
return []
|
return []
|
||||||
@@ -95,7 +97,7 @@ async def search_crypto(query: str) -> list[dict[str, Any]]:
|
|||||||
"""Search for cryptocurrencies."""
|
"""Search for cryptocurrencies."""
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(obb.crypto.search, query)
|
result = await asyncio.to_thread(obb.crypto.search, query)
|
||||||
return _to_list(result)
|
return to_list(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Crypto search failed for %s", query, exc_info=True)
|
logger.warning("Crypto search failed for %s", query, exc_info=True)
|
||||||
return []
|
return []
|
||||||
@@ -108,12 +110,12 @@ async def get_currency_historical(
|
|||||||
symbol: str, days: int = 365
|
symbol: str, days: int = 365
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Get forex price history (e.g., EURUSD)."""
|
"""Get forex price history (e.g., EURUSD)."""
|
||||||
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
obb.currency.price.historical, symbol, start_date=start, provider=PROVIDER
|
obb.currency.price.historical, symbol, start_date=start, provider=PROVIDER
|
||||||
)
|
)
|
||||||
return _to_list(result)
|
return to_list(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Currency historical failed for %s", symbol, exc_info=True)
|
logger.warning("Currency historical failed for %s", symbol, exc_info=True)
|
||||||
return []
|
return []
|
||||||
@@ -128,7 +130,7 @@ async def get_options_chains(symbol: str) -> list[dict[str, Any]]:
|
|||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
obb.derivatives.options.chains, symbol, provider=PROVIDER
|
obb.derivatives.options.chains, symbol, provider=PROVIDER
|
||||||
)
|
)
|
||||||
return _to_list(result)
|
return to_list(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Options chains failed for %s", symbol, exc_info=True)
|
logger.warning("Options chains failed for %s", symbol, exc_info=True)
|
||||||
return []
|
return []
|
||||||
@@ -138,12 +140,12 @@ async def get_futures_historical(
|
|||||||
symbol: str, days: int = 365
|
symbol: str, days: int = 365
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Get futures price history."""
|
"""Get futures price history."""
|
||||||
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
obb.derivatives.futures.historical, symbol, start_date=start, provider=PROVIDER
|
obb.derivatives.futures.historical, symbol, start_date=start, provider=PROVIDER
|
||||||
)
|
)
|
||||||
return _to_list(result)
|
return to_list(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Futures historical failed for %s", symbol, exc_info=True)
|
logger.warning("Futures historical failed for %s", symbol, exc_info=True)
|
||||||
return []
|
return []
|
||||||
@@ -155,27 +157,7 @@ async def get_futures_curve(symbol: str) -> list[dict[str, Any]]:
|
|||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
obb.derivatives.futures.curve, symbol, provider=PROVIDER
|
obb.derivatives.futures.curve, symbol, provider=PROVIDER
|
||||||
)
|
)
|
||||||
return _to_list(result)
|
return to_list(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Futures curve failed for %s", symbol, exc_info=True)
|
logger.warning("Futures curve failed for %s", symbol, exc_info=True)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _to_list(result: Any) -> list[dict[str, Any]]:
|
|
||||||
"""Convert OBBject result to list of dicts."""
|
|
||||||
if result is None or result.results is None:
|
|
||||||
return []
|
|
||||||
items = result.results
|
|
||||||
if not isinstance(items, list):
|
|
||||||
items = [items]
|
|
||||||
out = []
|
|
||||||
for item in items:
|
|
||||||
if hasattr(item, "model_dump"):
|
|
||||||
d = item.model_dump()
|
|
||||||
else:
|
|
||||||
d = vars(item) if vars(item) else {}
|
|
||||||
for k, v in d.items():
|
|
||||||
if hasattr(v, "isoformat"):
|
|
||||||
d[k] = v.isoformat()
|
|
||||||
out.append(d)
|
|
||||||
return out
|
|
||||||
|
|||||||
51
obb_utils.py
Normal file
51
obb_utils.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Shared OpenBB result conversion utilities."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def to_list(result: Any) -> list[dict[str, Any]]:
|
||||||
|
"""Convert OBBject result to list of dicts with serialized dates."""
|
||||||
|
if result is None or result.results is None:
|
||||||
|
return []
|
||||||
|
items = result.results
|
||||||
|
if not isinstance(items, list):
|
||||||
|
items = [items]
|
||||||
|
out = []
|
||||||
|
for item in items:
|
||||||
|
if hasattr(item, "model_dump"):
|
||||||
|
d = item.model_dump()
|
||||||
|
else:
|
||||||
|
raw = vars(item)
|
||||||
|
d = dict(raw) if raw else {}
|
||||||
|
d = {
|
||||||
|
k: v.isoformat() if hasattr(v, "isoformat") else v
|
||||||
|
for k, v in d.items()
|
||||||
|
}
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from openbb import obb
|
from openbb import obb
|
||||||
|
|
||||||
|
from obb_utils import extract_single, safe_last
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PROVIDER = "yfinance"
|
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."""
|
"""Calculate Sharpe ratio, summary stats, and volatility for a symbol."""
|
||||||
# Need at least 252 trading days for Sharpe window
|
# Need at least 252 trading days for Sharpe window
|
||||||
fetch_days = max(days, PERF_DAYS)
|
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:
|
try:
|
||||||
hist = await asyncio.to_thread(
|
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:
|
if not hist or not hist.results:
|
||||||
return {"symbol": symbol, "error": "No historical data"}
|
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(
|
asyncio.to_thread(
|
||||||
obb.quantitative.performance.sharpe_ratio,
|
obb.quantitative.performance.sharpe_ratio,
|
||||||
data=hist.results, target=TARGET,
|
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,
|
return_exceptions=True,
|
||||||
)
|
)
|
||||||
|
sharpe_result, summary_result, stdev_result = results
|
||||||
|
|
||||||
sharpe = _safe_last(sharpe_result) if not isinstance(sharpe_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 {}
|
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
|
stdev = safe_last(stdev_result) if not isinstance(stdev_result, BaseException) else None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"symbol": symbol,
|
"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]:
|
async def get_capm(symbol: str) -> dict[str, Any]:
|
||||||
"""Calculate CAPM metrics: beta, alpha, systematic/idiosyncratic risk."""
|
"""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:
|
try:
|
||||||
hist = await asyncio.to_thread(
|
hist = await asyncio.to_thread(
|
||||||
@@ -73,7 +76,7 @@ async def get_capm(symbol: str) -> dict[str, Any]:
|
|||||||
capm = await asyncio.to_thread(
|
capm = await asyncio.to_thread(
|
||||||
obb.quantitative.capm, data=hist.results, target=TARGET
|
obb.quantitative.capm, data=hist.results, target=TARGET
|
||||||
)
|
)
|
||||||
return {"symbol": symbol, **_extract_single(capm)}
|
return {"symbol": symbol, **extract_single(capm)}
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("CAPM failed for %s", symbol, exc_info=True)
|
logger.warning("CAPM failed for %s", symbol, exc_info=True)
|
||||||
return {"symbol": symbol, "error": "Failed to compute CAPM"}
|
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]:
|
async def get_normality_test(symbol: str, days: int = 365) -> dict[str, Any]:
|
||||||
"""Run normality tests (Jarque-Bera, Shapiro-Wilk, etc.) on returns."""
|
"""Run normality tests (Jarque-Bera, Shapiro-Wilk, etc.) on returns."""
|
||||||
fetch_days = max(days, PERF_DAYS)
|
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:
|
try:
|
||||||
hist = await asyncio.to_thread(
|
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(
|
norm = await asyncio.to_thread(
|
||||||
obb.quantitative.normality, data=hist.results, target=TARGET
|
obb.quantitative.normality, data=hist.results, target=TARGET
|
||||||
)
|
)
|
||||||
return {"symbol": symbol, **_extract_single(norm)}
|
return {"symbol": symbol, **extract_single(norm)}
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Normality test failed for %s", symbol, exc_info=True)
|
logger.warning("Normality test failed for %s", symbol, exc_info=True)
|
||||||
return {"symbol": symbol, "error": "Failed to compute normality tests"}
|
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]:
|
async def get_unitroot_test(symbol: str, days: int = 365) -> dict[str, Any]:
|
||||||
"""Run unit root tests (ADF, KPSS) for stationarity."""
|
"""Run unit root tests (ADF, KPSS) for stationarity."""
|
||||||
fetch_days = max(days, PERF_DAYS)
|
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:
|
try:
|
||||||
hist = await asyncio.to_thread(
|
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(
|
ur = await asyncio.to_thread(
|
||||||
obb.quantitative.unitroot_test, data=hist.results, target=TARGET
|
obb.quantitative.unitroot_test, data=hist.results, target=TARGET
|
||||||
)
|
)
|
||||||
return {"symbol": symbol, **_extract_single(ur)}
|
return {"symbol": symbol, **extract_single(ur)}
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Unit root test failed for %s", symbol, exc_info=True)
|
logger.warning("Unit root test failed for %s", symbol, exc_info=True)
|
||||||
return {"symbol": symbol, "error": "Failed to compute unit root test"}
|
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
|
|
||||||
|
|||||||
39
route_utils.py
Normal file
39
route_utils.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Shared route utilities: symbol validation and error handling decorator."""
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import ParamSpec, TypeVar
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from models import SYMBOL_PATTERN
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
P = ParamSpec("P")
|
||||||
|
R = TypeVar("R")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_symbol(symbol: str) -> str:
|
||||||
|
"""Validate and normalize a stock symbol."""
|
||||||
|
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]]:
|
||||||
|
"""Decorator to catch upstream errors and return 502."""
|
||||||
|
@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]
|
||||||
77
routes.py
77
routes.py
@@ -1,9 +1,4 @@
|
|||||||
import functools
|
from fastapi import APIRouter, Path, Query
|
||||||
import logging
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import ParamSpec, TypeVar
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Path, Query
|
|
||||||
|
|
||||||
from mappers import (
|
from mappers import (
|
||||||
discover_items_from_list,
|
discover_items_from_list,
|
||||||
@@ -12,7 +7,6 @@ from mappers import (
|
|||||||
quote_from_dict,
|
quote_from_dict,
|
||||||
)
|
)
|
||||||
from models import (
|
from models import (
|
||||||
SYMBOL_PATTERN,
|
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
FinancialsResponse,
|
FinancialsResponse,
|
||||||
HistoricalBar,
|
HistoricalBar,
|
||||||
@@ -21,87 +15,60 @@ from models import (
|
|||||||
PortfolioResponse,
|
PortfolioResponse,
|
||||||
SummaryResponse,
|
SummaryResponse,
|
||||||
)
|
)
|
||||||
|
from route_utils import safe, validate_symbol
|
||||||
import openbb_service
|
import openbb_service
|
||||||
import analysis_service
|
import analysis_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
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]]:
|
|
||||||
"""Decorator to catch OpenBB errors and return 502."""
|
|
||||||
@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]
|
|
||||||
|
|
||||||
|
|
||||||
# --- Stock Data ---
|
# --- Stock Data ---
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/quote", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/quote", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_quote(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_quote(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get current quote for a stock."""
|
"""Get current quote for a stock."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await openbb_service.get_quote(symbol)
|
data = await openbb_service.get_quote(symbol)
|
||||||
return ApiResponse(data=quote_from_dict(symbol, data).model_dump())
|
return ApiResponse(data=quote_from_dict(symbol, data).model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/profile", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/profile", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_profile(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_profile(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get company profile."""
|
"""Get company profile."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await openbb_service.get_profile(symbol)
|
data = await openbb_service.get_profile(symbol)
|
||||||
return ApiResponse(data=profile_from_dict(symbol, data).model_dump())
|
return ApiResponse(data=profile_from_dict(symbol, data).model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/metrics", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/metrics", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_metrics(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_metrics(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get key financial metrics (PE, PB, ROE, etc.)."""
|
"""Get key financial metrics (PE, PB, ROE, etc.)."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await openbb_service.get_metrics(symbol)
|
data = await openbb_service.get_metrics(symbol)
|
||||||
return ApiResponse(data=metrics_from_dict(symbol, data).model_dump())
|
return ApiResponse(data=metrics_from_dict(symbol, data).model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/financials", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/financials", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_financials(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_financials(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get income statement, balance sheet, and cash flow."""
|
"""Get income statement, balance sheet, and cash flow."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await openbb_service.get_financials(symbol)
|
data = await openbb_service.get_financials(symbol)
|
||||||
return ApiResponse(data=FinancialsResponse(**data).model_dump())
|
return ApiResponse(data=FinancialsResponse(**data).model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/historical", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/historical", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_historical(
|
async def stock_historical(
|
||||||
symbol: str = Path(..., min_length=1, max_length=20),
|
symbol: str = Path(..., min_length=1, max_length=20),
|
||||||
days: int = Query(default=365, ge=1, le=3650),
|
days: int = Query(default=365, ge=1, le=3650),
|
||||||
):
|
):
|
||||||
"""Get historical price data."""
|
"""Get historical price data."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await openbb_service.get_historical(symbol, days=days)
|
data = await openbb_service.get_historical(symbol, days=days)
|
||||||
bars = [
|
bars = [
|
||||||
HistoricalBar(
|
HistoricalBar(
|
||||||
@@ -118,10 +85,10 @@ async def stock_historical(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/news", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/news", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_news(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_news(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get recent company news."""
|
"""Get recent company news."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await openbb_service.get_news(symbol)
|
data = await openbb_service.get_news(symbol)
|
||||||
news = [
|
news = [
|
||||||
NewsItem(
|
NewsItem(
|
||||||
@@ -136,10 +103,10 @@ async def stock_news(symbol: str = Path(..., min_length=1, max_length=20)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/summary", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/summary", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_summary(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_summary(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get aggregated stock data: quote + profile + metrics + financials."""
|
"""Get aggregated stock data: quote + profile + metrics + financials."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await openbb_service.get_summary(symbol)
|
data = await openbb_service.get_summary(symbol)
|
||||||
summary = SummaryResponse(
|
summary = SummaryResponse(
|
||||||
quote=quote_from_dict(symbol, data.get("quote", {})),
|
quote=quote_from_dict(symbol, data.get("quote", {})),
|
||||||
@@ -156,7 +123,7 @@ async def stock_summary(symbol: str = Path(..., min_length=1, max_length=20)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/portfolio/analyze", response_model=ApiResponse)
|
@router.post("/portfolio/analyze", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def portfolio_analyze(request: PortfolioRequest):
|
async def portfolio_analyze(request: PortfolioRequest):
|
||||||
"""Analyze portfolio holdings with rule-based engine."""
|
"""Analyze portfolio holdings with rule-based engine."""
|
||||||
result: PortfolioResponse = await analysis_service.analyze_portfolio(
|
result: PortfolioResponse = await analysis_service.analyze_portfolio(
|
||||||
@@ -169,7 +136,7 @@ async def portfolio_analyze(request: PortfolioRequest):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/discover/gainers", response_model=ApiResponse)
|
@router.get("/discover/gainers", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def discover_gainers():
|
async def discover_gainers():
|
||||||
"""Get top gainers (US market)."""
|
"""Get top gainers (US market)."""
|
||||||
data = await openbb_service.get_gainers()
|
data = await openbb_service.get_gainers()
|
||||||
@@ -177,7 +144,7 @@ async def discover_gainers():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/discover/losers", response_model=ApiResponse)
|
@router.get("/discover/losers", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def discover_losers():
|
async def discover_losers():
|
||||||
"""Get top losers (US market)."""
|
"""Get top losers (US market)."""
|
||||||
data = await openbb_service.get_losers()
|
data = await openbb_service.get_losers()
|
||||||
@@ -185,7 +152,7 @@ async def discover_losers():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/discover/active", response_model=ApiResponse)
|
@router.get("/discover/active", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def discover_active():
|
async def discover_active():
|
||||||
"""Get most active stocks (US market)."""
|
"""Get most active stocks (US market)."""
|
||||||
data = await openbb_service.get_active()
|
data = await openbb_service.get_active()
|
||||||
@@ -193,7 +160,7 @@ async def discover_active():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/discover/undervalued", response_model=ApiResponse)
|
@router.get("/discover/undervalued", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def discover_undervalued():
|
async def discover_undervalued():
|
||||||
"""Get undervalued large cap stocks."""
|
"""Get undervalued large cap stocks."""
|
||||||
data = await openbb_service.get_undervalued()
|
data = await openbb_service.get_undervalued()
|
||||||
@@ -201,7 +168,7 @@ async def discover_undervalued():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/discover/growth", response_model=ApiResponse)
|
@router.get("/discover/growth", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def discover_growth():
|
async def discover_growth():
|
||||||
"""Get growth tech stocks."""
|
"""Get growth tech stocks."""
|
||||||
data = await openbb_service.get_growth()
|
data = await openbb_service.get_growth()
|
||||||
|
|||||||
@@ -1,53 +1,28 @@
|
|||||||
"""Routes for calendar events, screening, ownership, and estimates."""
|
"""Routes for calendar events, screening, ownership, and estimates."""
|
||||||
|
|
||||||
import functools
|
from fastapi import APIRouter, Path, Query
|
||||||
import logging
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import ParamSpec, TypeVar
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Path, Query
|
from models import ApiResponse
|
||||||
|
from route_utils import safe, validate_symbol
|
||||||
from models import SYMBOL_PATTERN, ApiResponse
|
|
||||||
import calendar_service
|
import calendar_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
P = ParamSpec("P")
|
DATE_PATTERN = r"^\d{4}-\d{2}-\d{2}$"
|
||||||
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]
|
|
||||||
|
|
||||||
|
|
||||||
# --- Calendar Events ---
|
# --- Calendar Events ---
|
||||||
|
|
||||||
|
|
||||||
@router.get("/calendar/earnings", response_model=ApiResponse)
|
@router.get("/calendar/earnings", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def earnings_calendar(
|
async def earnings_calendar(
|
||||||
start_date: str | None = Query(default=None, description="YYYY-MM-DD"),
|
start_date: str | None = Query(
|
||||||
end_date: str | None = Query(default=None, description="YYYY-MM-DD"),
|
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."""
|
"""Get upcoming earnings announcements."""
|
||||||
data = await calendar_service.get_earnings_calendar(start_date, end_date)
|
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)
|
@router.get("/calendar/dividends", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def dividend_calendar(
|
async def dividend_calendar(
|
||||||
start_date: str | None = Query(default=None, description="YYYY-MM-DD"),
|
start_date: str | None = Query(
|
||||||
end_date: str | None = Query(default=None, description="YYYY-MM-DD"),
|
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."""
|
"""Get upcoming dividend dates."""
|
||||||
data = await calendar_service.get_dividend_calendar(start_date, end_date)
|
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)
|
@router.get("/calendar/ipo", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def ipo_calendar(
|
async def ipo_calendar(
|
||||||
start_date: str | None = Query(default=None, description="YYYY-MM-DD"),
|
start_date: str | None = Query(
|
||||||
end_date: str | None = Query(default=None, description="YYYY-MM-DD"),
|
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."""
|
"""Get upcoming IPOs."""
|
||||||
data = await calendar_service.get_ipo_calendar(start_date, end_date)
|
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)
|
@router.get("/calendar/splits", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def splits_calendar(
|
async def splits_calendar(
|
||||||
start_date: str | None = Query(default=None, description="YYYY-MM-DD"),
|
start_date: str | None = Query(
|
||||||
end_date: str | None = Query(default=None, description="YYYY-MM-DD"),
|
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."""
|
"""Get upcoming stock splits."""
|
||||||
data = await calendar_service.get_splits_calendar(start_date, end_date)
|
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)
|
@router.get("/stock/{symbol}/estimates", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_estimates(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_estimates(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get analyst consensus estimates."""
|
"""Get analyst consensus estimates."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await calendar_service.get_analyst_estimates(symbol)
|
data = await calendar_service.get_analyst_estimates(symbol)
|
||||||
return ApiResponse(data=data)
|
return ApiResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/share-statistics", response_model=ApiResponse)
|
@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)):
|
async def stock_share_stats(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get share statistics: float, outstanding, short interest."""
|
"""Get share statistics: float, outstanding, short interest."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await calendar_service.get_share_statistics(symbol)
|
data = await calendar_service.get_share_statistics(symbol)
|
||||||
return ApiResponse(data=data)
|
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)
|
@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)):
|
async def stock_sec_insider(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get insider trading data from SEC (Form 4)."""
|
"""Get insider trading data from SEC (Form 4)."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await calendar_service.get_insider_trading(symbol)
|
data = await calendar_service.get_insider_trading(symbol)
|
||||||
return ApiResponse(data=data)
|
return ApiResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/institutional", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/institutional", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_institutional(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_institutional(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get institutional holders from SEC 13F filings."""
|
"""Get institutional holders from SEC 13F filings."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await calendar_service.get_institutional_holders(symbol)
|
data = await calendar_service.get_institutional_holders(symbol)
|
||||||
return ApiResponse(data=data)
|
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)
|
@router.get("/screener", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_screener():
|
async def stock_screener():
|
||||||
"""Screen stocks using available filters."""
|
"""Screen stocks using available filters."""
|
||||||
data = await calendar_service.screen_stocks()
|
data = await calendar_service.screen_stocks()
|
||||||
|
|||||||
@@ -1,41 +1,16 @@
|
|||||||
"""Routes for macroeconomic data (FRED-powered)."""
|
"""Routes for macroeconomic data (FRED-powered)."""
|
||||||
|
|
||||||
import functools
|
from fastapi import APIRouter, Query
|
||||||
import logging
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import ParamSpec, TypeVar
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
|
||||||
|
|
||||||
from models import ApiResponse
|
from models import ApiResponse
|
||||||
|
from route_utils import safe
|
||||||
import macro_service
|
import macro_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
P = ParamSpec("P")
|
|
||||||
R = TypeVar("R")
|
|
||||||
|
|
||||||
|
|
||||||
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("/macro/overview", response_model=ApiResponse)
|
@router.get("/macro/overview", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def macro_overview():
|
async def macro_overview():
|
||||||
"""Get key macro indicators: Fed rate, treasury yields, CPI, unemployment, GDP, VIX."""
|
"""Get key macro indicators: Fed rate, treasury yields, CPI, unemployment, GDP, VIX."""
|
||||||
data = await macro_service.get_macro_overview()
|
data = await macro_service.get_macro_overview()
|
||||||
@@ -43,7 +18,7 @@ async def macro_overview():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/macro/series/{series_id}", response_model=ApiResponse)
|
@router.get("/macro/series/{series_id}", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def macro_series(
|
async def macro_series(
|
||||||
series_id: str,
|
series_id: str,
|
||||||
limit: int = Query(default=30, ge=1, le=1000),
|
limit: int = Query(default=30, ge=1, le=1000),
|
||||||
|
|||||||
105
routes_market.py
105
routes_market.py
@@ -1,82 +1,52 @@
|
|||||||
"""Routes for ETF, index, crypto, currency, and derivatives data."""
|
"""Routes for ETF, index, crypto, currency, and derivatives data."""
|
||||||
|
|
||||||
import functools
|
from fastapi import APIRouter, Path, Query
|
||||||
import logging
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import ParamSpec, TypeVar
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Path, Query
|
from models import ApiResponse
|
||||||
|
from route_utils import safe, validate_symbol
|
||||||
from models import SYMBOL_PATTERN, ApiResponse
|
|
||||||
import market_service
|
import market_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
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 ---
|
# --- 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)
|
@router.get("/etf/{symbol}/info", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def etf_info(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def etf_info(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get ETF profile and info."""
|
"""Get ETF profile and info."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await market_service.get_etf_info(symbol)
|
data = await market_service.get_etf_info(symbol)
|
||||||
return ApiResponse(data=data)
|
return ApiResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/etf/{symbol}/historical", response_model=ApiResponse)
|
@router.get("/etf/{symbol}/historical", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def etf_historical(
|
async def etf_historical(
|
||||||
symbol: str = Path(..., min_length=1, max_length=20),
|
symbol: str = Path(..., min_length=1, max_length=20),
|
||||||
days: int = Query(default=365, ge=1, le=3650),
|
days: int = Query(default=365, ge=1, le=3650),
|
||||||
):
|
):
|
||||||
"""Get ETF price history."""
|
"""Get ETF price history."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await market_service.get_etf_historical(symbol, days=days)
|
data = await market_service.get_etf_historical(symbol, days=days)
|
||||||
return ApiResponse(data=data)
|
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 ---
|
# --- Index ---
|
||||||
|
|
||||||
|
|
||||||
@router.get("/index/available", response_model=ApiResponse)
|
@router.get("/index/available", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def index_available():
|
async def index_available():
|
||||||
"""List available market indices."""
|
"""List available market indices."""
|
||||||
data = await market_service.get_available_indices()
|
data = await market_service.get_available_indices()
|
||||||
@@ -84,51 +54,52 @@ async def index_available():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/index/{symbol}/historical", response_model=ApiResponse)
|
@router.get("/index/{symbol}/historical", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def index_historical(
|
async def index_historical(
|
||||||
symbol: str = Path(..., min_length=1, max_length=20),
|
symbol: str = Path(..., min_length=1, max_length=20),
|
||||||
days: int = Query(default=365, ge=1, le=3650),
|
days: int = Query(default=365, ge=1, le=3650),
|
||||||
):
|
):
|
||||||
"""Get index price history (e.g., ^GSPC, ^DJI, ^IXIC)."""
|
"""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)
|
data = await market_service.get_index_historical(symbol, days=days)
|
||||||
return ApiResponse(data=data)
|
return ApiResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
# --- Crypto ---
|
# --- 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)
|
@router.get("/crypto/{symbol}/historical", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def crypto_historical(
|
async def crypto_historical(
|
||||||
symbol: str = Path(..., min_length=1, max_length=20),
|
symbol: str = Path(..., min_length=1, max_length=20),
|
||||||
days: int = Query(default=365, ge=1, le=3650),
|
days: int = Query(default=365, ge=1, le=3650),
|
||||||
):
|
):
|
||||||
"""Get cryptocurrency price history (e.g., BTC-USD)."""
|
"""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)
|
data = await market_service.get_crypto_historical(symbol, days=days)
|
||||||
return ApiResponse(data=data)
|
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 ---
|
# --- Currency ---
|
||||||
|
|
||||||
|
|
||||||
@router.get("/currency/{symbol}/historical", response_model=ApiResponse)
|
@router.get("/currency/{symbol}/historical", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def currency_historical(
|
async def currency_historical(
|
||||||
symbol: str = Path(..., min_length=1, max_length=20),
|
symbol: str = Path(..., min_length=1, max_length=20),
|
||||||
days: int = Query(default=365, ge=1, le=3650),
|
days: int = Query(default=365, ge=1, le=3650),
|
||||||
):
|
):
|
||||||
"""Get forex price history (e.g., EURUSD, USDSEK)."""
|
"""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)
|
data = await market_service.get_currency_historical(symbol, days=days)
|
||||||
return ApiResponse(data=data)
|
return ApiResponse(data=data)
|
||||||
|
|
||||||
@@ -137,30 +108,30 @@ async def currency_historical(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/options/{symbol}/chains", response_model=ApiResponse)
|
@router.get("/options/{symbol}/chains", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def options_chains(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def options_chains(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get options chain data."""
|
"""Get options chain data."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await market_service.get_options_chains(symbol)
|
data = await market_service.get_options_chains(symbol)
|
||||||
return ApiResponse(data=data)
|
return ApiResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/futures/{symbol}/historical", response_model=ApiResponse)
|
@router.get("/futures/{symbol}/historical", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def futures_historical(
|
async def futures_historical(
|
||||||
symbol: str = Path(..., min_length=1, max_length=20),
|
symbol: str = Path(..., min_length=1, max_length=20),
|
||||||
days: int = Query(default=365, ge=1, le=3650),
|
days: int = Query(default=365, ge=1, le=3650),
|
||||||
):
|
):
|
||||||
"""Get futures price history."""
|
"""Get futures price history."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await market_service.get_futures_historical(symbol, days=days)
|
data = await market_service.get_futures_historical(symbol, days=days)
|
||||||
return ApiResponse(data=data)
|
return ApiResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/futures/{symbol}/curve", response_model=ApiResponse)
|
@router.get("/futures/{symbol}/curve", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def futures_curve(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def futures_curve(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get futures term structure/curve."""
|
"""Get futures term structure/curve."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await market_service.get_futures_curve(symbol)
|
data = await market_service.get_futures_curve(symbol)
|
||||||
return ApiResponse(data=data)
|
return ApiResponse(data=data)
|
||||||
|
|||||||
@@ -1,85 +1,54 @@
|
|||||||
"""Routes for quantitative analysis: risk metrics, CAPM, normality, unit root."""
|
"""Routes for quantitative analysis: risk metrics, CAPM, normality, unit root."""
|
||||||
|
|
||||||
import functools
|
from fastapi import APIRouter, Path, Query
|
||||||
import logging
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import ParamSpec, TypeVar
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Path, Query
|
from models import ApiResponse
|
||||||
|
from route_utils import safe, validate_symbol
|
||||||
from models import SYMBOL_PATTERN, ApiResponse
|
|
||||||
import quantitative_service
|
import quantitative_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
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)
|
@router.get("/stock/{symbol}/performance", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_performance(
|
async def stock_performance(
|
||||||
symbol: str = Path(..., min_length=1, max_length=20),
|
symbol: str = Path(..., min_length=1, max_length=20),
|
||||||
days: int = Query(default=365, ge=30, le=3650),
|
days: int = Query(default=365, ge=30, le=3650),
|
||||||
):
|
):
|
||||||
"""Performance metrics: Sharpe, Sortino, max drawdown, volatility."""
|
"""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)
|
data = await quantitative_service.get_performance_metrics(symbol, days=days)
|
||||||
return ApiResponse(data=data)
|
return ApiResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/capm", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/capm", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_capm(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_capm(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""CAPM: beta, alpha, systematic and idiosyncratic risk."""
|
"""CAPM: beta, alpha, systematic and idiosyncratic risk."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await quantitative_service.get_capm(symbol)
|
data = await quantitative_service.get_capm(symbol)
|
||||||
return ApiResponse(data=data)
|
return ApiResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/normality", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/normality", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_normality(
|
async def stock_normality(
|
||||||
symbol: str = Path(..., min_length=1, max_length=20),
|
symbol: str = Path(..., min_length=1, max_length=20),
|
||||||
days: int = Query(default=365, ge=30, le=3650),
|
days: int = Query(default=365, ge=30, le=3650),
|
||||||
):
|
):
|
||||||
"""Normality tests: Jarque-Bera, Shapiro-Wilk on returns."""
|
"""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)
|
data = await quantitative_service.get_normality_test(symbol, days=days)
|
||||||
return ApiResponse(data=data)
|
return ApiResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/unitroot", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/unitroot", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_unitroot(
|
async def stock_unitroot(
|
||||||
symbol: str = Path(..., min_length=1, max_length=20),
|
symbol: str = Path(..., min_length=1, max_length=20),
|
||||||
days: int = Query(default=365, ge=30, le=3650),
|
days: int = Query(default=365, ge=30, le=3650),
|
||||||
):
|
):
|
||||||
"""Unit root tests: ADF, KPSS for stationarity."""
|
"""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)
|
data = await quantitative_service.get_unitroot_test(symbol, days=days)
|
||||||
return ApiResponse(data=data)
|
return ApiResponse(data=data)
|
||||||
|
|||||||
@@ -1,55 +1,29 @@
|
|||||||
"""Routes for sentiment, insider trades, and analyst data (Finnhub + Alpha Vantage)."""
|
"""Routes for sentiment, insider trades, and analyst data (Finnhub + Alpha Vantage)."""
|
||||||
|
|
||||||
import asyncio
|
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 alphavantage_service
|
||||||
import finnhub_service
|
import finnhub_service
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
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 ---
|
# --- Sentiment & News ---
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/sentiment", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/sentiment", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_sentiment(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_sentiment(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get aggregated sentiment: Alpha Vantage news sentiment + Finnhub analyst data."""
|
"""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_data, av_data = await asyncio.gather(
|
||||||
finnhub_service.get_sentiment_summary(symbol),
|
finnhub_service.get_sentiment_summary(symbol),
|
||||||
alphavantage_service.get_news_sentiment(symbol, limit=20),
|
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)
|
@router.get("/stock/{symbol}/news-sentiment", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_news_sentiment(
|
async def stock_news_sentiment(
|
||||||
symbol: str = Path(..., min_length=1, max_length=20),
|
symbol: str = Path(..., min_length=1, max_length=20),
|
||||||
limit: int = Query(default=30, ge=1, le=200),
|
limit: int = Query(default=30, ge=1, le=200),
|
||||||
):
|
):
|
||||||
"""Get news articles with per-ticker sentiment scores (Alpha Vantage)."""
|
"""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)
|
data = await alphavantage_service.get_news_sentiment(symbol, limit=limit)
|
||||||
return ApiResponse(data=data)
|
return ApiResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/insider-trades", response_model=ApiResponse)
|
@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)):
|
async def stock_insider_trades(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get insider transactions (CEO/CFO buys and sells)."""
|
"""Get insider transactions (CEO/CFO buys and sells)."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
raw = await finnhub_service.get_insider_transactions(symbol)
|
raw = await finnhub_service.get_insider_transactions(symbol)
|
||||||
trades = [
|
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)
|
@router.get("/stock/{symbol}/recommendations", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_recommendations(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_recommendations(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get analyst recommendation trends (monthly buy/hold/sell counts)."""
|
"""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)
|
raw = await finnhub_service.get_recommendation_trends(symbol)
|
||||||
recs = [
|
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)
|
@router.get("/stock/{symbol}/upgrades", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_upgrades(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_upgrades(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get recent analyst upgrades and downgrades."""
|
"""Get recent analyst upgrades and downgrades."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
raw = await finnhub_service.get_upgrade_downgrade(symbol)
|
raw = await finnhub_service.get_upgrade_downgrade(symbol)
|
||||||
upgrades = [
|
upgrades = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,49 +1,18 @@
|
|||||||
"""Routes for technical analysis indicators."""
|
"""Routes for technical analysis indicators."""
|
||||||
|
|
||||||
import functools
|
from fastapi import APIRouter, Path
|
||||||
import logging
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import ParamSpec, TypeVar
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Path
|
from models import ApiResponse
|
||||||
|
from route_utils import safe, validate_symbol
|
||||||
from models import SYMBOL_PATTERN, ApiResponse
|
|
||||||
import technical_service
|
import technical_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
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}/technical", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/technical", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_technical(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_technical(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get technical indicators: RSI, MACD, SMA, EMA, Bollinger Bands + signal interpretation."""
|
"""Get technical indicators: RSI, MACD, SMA, EMA, Bollinger Bands + signal interpretation."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await technical_service.get_technical_indicators(symbol)
|
data = await technical_service.get_technical_indicators(symbol)
|
||||||
return ApiResponse(data=data)
|
return ApiResponse(data=data)
|
||||||
|
|||||||
Reference in New Issue
Block a user