From 507194397e76ebdb20d6d6d9ba626a640087bca8 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Mon, 9 Mar 2026 10:28:33 +0100 Subject: [PATCH] feat: integrate quantitative, calendar, market data endpoints Add 3 new service layers and route modules: - quantitative_service: Sharpe ratio, CAPM, normality tests, unit root tests - calendar_service: earnings/dividends/IPO/splits calendars, estimates, SEC ownership - market_service: ETF, index, crypto, forex, options, futures data Total endpoints: 50. All use free providers (yfinance, SEC). Update README with comprehensive endpoint documentation. --- README.md | 114 +++++++++++++++++++++---- calendar_service.py | 159 +++++++++++++++++++++++++++++++++++ main.py | 6 ++ market_service.py | 181 ++++++++++++++++++++++++++++++++++++++++ quantitative_service.py | 147 ++++++++++++++++++++++++++++++++ routes_calendar.py | 140 +++++++++++++++++++++++++++++++ routes_market.py | 166 ++++++++++++++++++++++++++++++++++++ routes_quantitative.py | 85 +++++++++++++++++++ 8 files changed, 980 insertions(+), 18 deletions(-) create mode 100644 calendar_service.py create mode 100644 market_service.py create mode 100644 quantitative_service.py create mode 100644 routes_calendar.py create mode 100644 routes_market.py create mode 100644 routes_quantitative.py diff --git a/README.md b/README.md index 9e81742..d4dde46 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenBB Investment Analysis API -REST API wrapping OpenBB SDK, providing stock data query, sentiment analysis, technical indicators, macro data, and rule-based investment analysis for US and Swedish markets. Designed to be called by OpenClaw (or any AI assistant) — the API returns structured data, all LLM reasoning happens on the caller side. +REST API wrapping OpenBB SDK, providing stock data, sentiment analysis, technical indicators, quantitative risk metrics, macro data, market data (ETF/index/crypto/forex/options), and rule-based investment analysis for US and Swedish markets. Designed to be called by OpenClaw (or any AI assistant) -- the API returns structured data, all LLM reasoning happens on the caller side. ## API Keys @@ -42,6 +42,7 @@ export INVEST_API_ALPHAVANTAGE_API_KEY=your_alphavantage_key ```bash conda env create -f environment.yml conda activate openbb-invest-api +pip install openbb-quantitative openbb-econometrics ``` ### 2. Start the server @@ -64,15 +65,28 @@ curl http://localhost:8000/api/v1/stock/AAPL/quote # Swedish stock quote curl http://localhost:8000/api/v1/stock/VOLV-B.ST/quote -# Sentiment analysis (requires Finnhub + Alpha Vantage keys) +# Sentiment analysis (Finnhub + Alpha Vantage) curl http://localhost:8000/api/v1/stock/AAPL/sentiment -# News sentiment with per-article scores (requires Alpha Vantage key) +# News sentiment with per-article scores (Alpha Vantage) curl http://localhost:8000/api/v1/stock/AAPL/news-sentiment # Technical indicators curl http://localhost:8000/api/v1/stock/AAPL/technical +# Quantitative risk metrics +curl http://localhost:8000/api/v1/stock/AAPL/performance +curl http://localhost:8000/api/v1/stock/AAPL/capm + +# SEC insider trading +curl http://localhost:8000/api/v1/stock/AAPL/sec-insider + +# ETF info +curl http://localhost:8000/api/v1/etf/SPY/info + +# Crypto price history +curl http://localhost:8000/api/v1/crypto/BTC-USD/historical?days=30 + # Macro overview (requires FRED key) curl http://localhost:8000/api/v1/macro/overview @@ -108,7 +122,7 @@ curl -X POST http://localhost:8000/api/v1/portfolio/analyze \ |--------|------|-------------| | GET | `/api/v1/stock/{symbol}/sentiment` | Aggregated: news sentiment + recommendations + upgrades | | GET | `/api/v1/stock/{symbol}/news-sentiment?limit=30` | News articles with per-ticker sentiment scores (Alpha Vantage) | -| GET | `/api/v1/stock/{symbol}/insider-trades` | Insider transactions (CEO/CFO buys and sells) | +| GET | `/api/v1/stock/{symbol}/insider-trades` | Insider transactions via Finnhub | | GET | `/api/v1/stock/{symbol}/recommendations` | Monthly analyst buy/hold/sell counts | | GET | `/api/v1/stock/{symbol}/upgrades` | Recent analyst upgrades and downgrades | @@ -118,6 +132,70 @@ curl -X POST http://localhost:8000/api/v1/portfolio/analyze \ |--------|------|-------------| | GET | `/api/v1/stock/{symbol}/technical` | RSI, MACD, SMA, EMA, Bollinger Bands + signal interpretation | +### Quantitative Analysis (openbb-quantitative, no key needed) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/stock/{symbol}/performance?days=365` | Sharpe ratio, summary statistics, volatility | +| GET | `/api/v1/stock/{symbol}/capm` | CAPM: market risk, systematic risk, idiosyncratic risk | +| GET | `/api/v1/stock/{symbol}/normality?days=365` | Normality tests: Jarque-Bera, Shapiro-Wilk, Kolmogorov-Smirnov | +| GET | `/api/v1/stock/{symbol}/unitroot?days=365` | Unit root tests: ADF, KPSS for stationarity | + +### Calendar Events (no key needed) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/calendar/earnings?start_date=&end_date=` | Upcoming earnings announcements | +| GET | `/api/v1/calendar/dividends?start_date=&end_date=` | Upcoming dividend dates | +| GET | `/api/v1/calendar/ipo?start_date=&end_date=` | Upcoming IPOs | +| GET | `/api/v1/calendar/splits?start_date=&end_date=` | Upcoming stock splits | + +### Estimates & Ownership (yfinance + SEC, no key needed) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/stock/{symbol}/estimates` | Analyst consensus estimates | +| GET | `/api/v1/stock/{symbol}/share-statistics` | Float, shares outstanding, short interest | +| GET | `/api/v1/stock/{symbol}/sec-insider` | Insider trading from SEC (Form 4) | +| GET | `/api/v1/stock/{symbol}/institutional` | Institutional holders from SEC 13F filings | +| GET | `/api/v1/screener` | Stock screener | + +### ETF Data (yfinance, no key needed) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/etf/{symbol}/info` | ETF profile, issuer, holdings | +| GET | `/api/v1/etf/{symbol}/historical?days=365` | ETF price history | +| GET | `/api/v1/etf/search?query=` | Search ETFs by name | + +### Index Data (yfinance, no key needed) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/index/available` | List available indices | +| GET | `/api/v1/index/{symbol}/historical?days=365` | Index price history (^GSPC, ^DJI, ^IXIC) | + +### Crypto Data (yfinance, no key needed) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/crypto/{symbol}/historical?days=365` | Crypto price history (BTC-USD, ETH-USD) | +| GET | `/api/v1/crypto/search?query=` | Search cryptocurrencies | + +### Currency / Forex (yfinance, no key needed) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/currency/{symbol}/historical?days=365` | Forex price history (EURUSD, USDSEK) | + +### Derivatives (yfinance, no key needed) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/options/{symbol}/chains` | Options chain data | +| GET | `/api/v1/futures/{symbol}/historical?days=365` | Futures price history | +| GET | `/api/v1/futures/{symbol}/curve` | Futures term structure/curve | + ### Macro Economics (FRED, free key) | Method | Path | Description | @@ -192,26 +270,22 @@ openbb-invest-api/ ├── openbb_service.py # OpenBB SDK wrapper (async) ├── finnhub_service.py # Finnhub REST client (insider, analyst data) ├── alphavantage_service.py # Alpha Vantage REST client (news sentiment) +├── quantitative_service.py # Risk metrics, CAPM, normality tests +├── calendar_service.py # Calendar events, screening, ownership +├── market_service.py # ETF, index, crypto, currency, derivatives ├── macro_service.py # FRED macro data via OpenBB ├── technical_service.py # Technical indicators via openbb-technical ├── analysis_service.py # Rule engine for portfolio analysis ├── routes.py # Core stock data + portfolio + discovery routes ├── routes_sentiment.py # Sentiment & analyst routes (Finnhub + Alpha Vantage) +├── routes_quantitative.py # Quantitative analysis routes +├── routes_calendar.py # Calendar, estimates, ownership routes +├── routes_market.py # ETF, index, crypto, currency, derivatives routes ├── routes_macro.py # Macro economics routes (FRED) ├── routes_technical.py # Technical analysis routes ├── environment.yml # Conda environment ├── pyproject.toml # Project metadata └── tests/ # 102 tests - ├── test_models.py - ├── test_mappers.py - ├── test_openbb_service.py - ├── test_finnhub_service.py - ├── test_analysis_service.py - ├── test_routes.py - ├── test_routes_sentiment.py - ├── test_alphavantage_service.py - ├── test_routes_macro.py - └── test_routes_technical.py ``` ## Running Tests @@ -239,16 +313,20 @@ Example OpenClaw workflow: 2. OpenClaw calls `GET /api/v1/stock/AAPL/summary` for fundamental data 3. OpenClaw calls `GET /api/v1/stock/AAPL/sentiment` for news/analyst sentiment 4. OpenClaw calls `GET /api/v1/stock/AAPL/technical` for technical signals -5. OpenClaw calls `GET /api/v1/macro/overview` for market context -6. OpenClaw calls `POST /api/v1/portfolio/analyze` with user's holdings -7. OpenClaw's LLM synthesizes all structured data into a personalized recommendation +5. OpenClaw calls `GET /api/v1/stock/AAPL/performance` for risk metrics (Sharpe, volatility) +6. OpenClaw calls `GET /api/v1/stock/AAPL/sec-insider` for insider trading activity +7. OpenClaw calls `GET /api/v1/macro/overview` for market context +8. OpenClaw calls `POST /api/v1/portfolio/analyze` with user's holdings +9. OpenClaw's LLM synthesizes all structured data into a personalized recommendation ## Data Sources | Source | Cost | Key Required | Data Provided | |--------|------|-------------|---------------| -| **yfinance** | Free | No | Quotes, fundamentals, financials, historical prices, news, discovery | +| **yfinance** | Free | No | Quotes, fundamentals, financials, historical prices, news, discovery, ETF, index, crypto, forex, options, futures | +| **SEC** | Free | No | Insider trading (Form 4), institutional holdings (13F), company filings | | **Finnhub** | Free | Yes (free registration) | Insider trades, analyst recommendations, upgrades/downgrades | | **Alpha Vantage** | Free | Yes (free registration) | News sentiment scores (bullish/bearish per ticker per article), 25 req/day | | **FRED** | Free | Yes (free registration) | Fed rate, treasury yields, CPI, unemployment, GDP, VIX, 800K+ economic series | | **openbb-technical** | Free | No (local computation) | RSI, MACD, SMA, EMA, Bollinger Bands | +| **openbb-quantitative** | Free | No (local computation) | Sharpe ratio, CAPM, normality tests, unit root tests, summary statistics | diff --git a/calendar_service.py b/calendar_service.py new file mode 100644 index 0000000..26aee26 --- /dev/null +++ b/calendar_service.py @@ -0,0 +1,159 @@ +"""Calendar events (earnings, dividends, IPOs, splits), screening, and ownership.""" + +import asyncio +import logging +from typing import Any + +from openbb import obb + +logger = logging.getLogger(__name__) + + +async def get_earnings_calendar( + start_date: str | None = None, end_date: str | None = None +) -> list[dict[str, Any]]: + """Get upcoming earnings announcements.""" + try: + kwargs: dict[str, Any] = {} + if start_date: + kwargs["start_date"] = start_date + if end_date: + kwargs["end_date"] = end_date + result = await asyncio.to_thread(obb.equity.calendar.earnings, **kwargs) + return _to_list(result) + except Exception: + logger.warning("Earnings calendar failed", exc_info=True) + return [] + + +async def get_dividend_calendar( + start_date: str | None = None, end_date: str | None = None +) -> list[dict[str, Any]]: + """Get upcoming dividend dates.""" + try: + kwargs: dict[str, Any] = {} + if start_date: + kwargs["start_date"] = start_date + if end_date: + kwargs["end_date"] = end_date + result = await asyncio.to_thread(obb.equity.calendar.dividend, **kwargs) + return _to_list(result) + except Exception: + logger.warning("Dividend calendar failed", exc_info=True) + return [] + + +async def get_ipo_calendar( + start_date: str | None = None, end_date: str | None = None +) -> list[dict[str, Any]]: + """Get upcoming IPO dates.""" + try: + kwargs: dict[str, Any] = {} + if start_date: + kwargs["start_date"] = start_date + if end_date: + kwargs["end_date"] = end_date + result = await asyncio.to_thread(obb.equity.calendar.ipo, **kwargs) + return _to_list(result) + except Exception: + logger.warning("IPO calendar failed", exc_info=True) + return [] + + +async def get_splits_calendar( + start_date: str | None = None, end_date: str | None = None +) -> list[dict[str, Any]]: + """Get upcoming stock split dates.""" + try: + kwargs: dict[str, Any] = {} + if start_date: + kwargs["start_date"] = start_date + if end_date: + kwargs["end_date"] = end_date + result = await asyncio.to_thread(obb.equity.calendar.splits, **kwargs) + return _to_list(result) + except Exception: + logger.warning("Splits calendar failed", exc_info=True) + return [] + + +async def get_analyst_estimates(symbol: str) -> dict[str, Any]: + """Get analyst consensus estimates for a symbol.""" + try: + result = await asyncio.to_thread( + obb.equity.estimates.consensus, symbol, provider="yfinance" + ) + items = _to_list(result) + return {"symbol": symbol, "estimates": items} + except Exception: + logger.warning("Analyst estimates failed for %s", symbol, exc_info=True) + return {"symbol": symbol, "estimates": []} + + +async def get_share_statistics(symbol: str) -> dict[str, Any]: + """Get share statistics (float, shares outstanding, etc.).""" + try: + result = await asyncio.to_thread( + obb.equity.ownership.share_statistics, symbol, provider="yfinance" + ) + items = _to_list(result) + return items[0] if items else {} + except Exception: + logger.warning("Share statistics failed for %s", symbol, exc_info=True) + return {} + + +async def get_insider_trading(symbol: str) -> list[dict[str, Any]]: + """Get insider trading data from SEC (free).""" + try: + result = await asyncio.to_thread( + obb.equity.ownership.insider_trading, symbol, provider="sec" + ) + return _to_list(result) + except Exception: + logger.warning("SEC insider trading failed for %s", symbol, exc_info=True) + return [] + + +async def get_institutional_holders(symbol: str) -> list[dict[str, Any]]: + """Get institutional holders from SEC 13F filings.""" + try: + result = await asyncio.to_thread( + obb.equity.ownership.form_13f, symbol, provider="sec" + ) + return _to_list(result) + except Exception: + logger.warning("13F data failed for %s", symbol, exc_info=True) + return [] + + +async def screen_stocks() -> list[dict[str, Any]]: + """Screen stocks using available screener.""" + try: + result = await asyncio.to_thread( + obb.equity.screener, provider="yfinance" + ) + return _to_list(result) + except Exception: + logger.warning("Stock screener failed", exc_info=True) + 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 diff --git a/main.py b/main.py index 5d16ba9..b2549bb 100644 --- a/main.py +++ b/main.py @@ -9,6 +9,9 @@ from routes import router from routes_sentiment import router as sentiment_router from routes_macro import router as macro_router from routes_technical import router as technical_router +from routes_quantitative import router as quantitative_router +from routes_calendar import router as calendar_router +from routes_market import router as market_router logging.basicConfig( level=settings.log_level.upper(), @@ -33,6 +36,9 @@ app.include_router(router) app.include_router(sentiment_router) app.include_router(macro_router) app.include_router(technical_router) +app.include_router(quantitative_router) +app.include_router(calendar_router) +app.include_router(market_router) @app.get("/health", response_model=dict[str, str]) diff --git a/market_service.py b/market_service.py new file mode 100644 index 0000000..53b0d5d --- /dev/null +++ b/market_service.py @@ -0,0 +1,181 @@ +"""Market data: ETFs, indices, crypto, currencies, and derivatives.""" + +import asyncio +import logging +from datetime import datetime, timedelta +from typing import Any + +from openbb import obb + +logger = logging.getLogger(__name__) + +PROVIDER = "yfinance" + + +# --- ETF --- + + +async def get_etf_info(symbol: str) -> dict[str, Any]: + """Get ETF profile/info.""" + try: + result = await asyncio.to_thread(obb.etf.info, symbol, provider=PROVIDER) + items = _to_list(result) + return items[0] if items else {} + except Exception: + logger.warning("ETF info failed for %s", symbol, exc_info=True) + return {} + + +async def get_etf_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]: + """Get ETF price history.""" + start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + try: + result = await asyncio.to_thread( + obb.etf.historical, symbol, start_date=start, provider=PROVIDER + ) + return _to_list(result) + except Exception: + logger.warning("ETF historical failed for %s", symbol, exc_info=True) + return [] + + +async def search_etf(query: str) -> list[dict[str, Any]]: + """Search for ETFs by name or keyword.""" + try: + result = await asyncio.to_thread(obb.etf.search, query) + return _to_list(result) + except Exception: + logger.warning("ETF search failed for %s", query, exc_info=True) + return [] + + +# --- Index --- + + +async def get_available_indices() -> list[dict[str, Any]]: + """List available market indices.""" + try: + result = await asyncio.to_thread(obb.index.available, provider=PROVIDER) + return _to_list(result) + except Exception: + logger.warning("Available indices failed", exc_info=True) + return [] + + +async def get_index_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]: + """Get index price history.""" + start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + try: + result = await asyncio.to_thread( + obb.index.price.historical, symbol, start_date=start, provider=PROVIDER + ) + return _to_list(result) + except Exception: + logger.warning("Index historical failed for %s", symbol, exc_info=True) + return [] + + +# --- Crypto --- + + +async def get_crypto_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]: + """Get cryptocurrency price history.""" + start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + try: + result = await asyncio.to_thread( + obb.crypto.price.historical, symbol, start_date=start, provider=PROVIDER + ) + return _to_list(result) + except Exception: + logger.warning("Crypto historical failed for %s", symbol, exc_info=True) + return [] + + +async def search_crypto(query: str) -> list[dict[str, Any]]: + """Search for cryptocurrencies.""" + try: + result = await asyncio.to_thread(obb.crypto.search, query) + return _to_list(result) + except Exception: + logger.warning("Crypto search failed for %s", query, exc_info=True) + return [] + + +# --- Currency --- + + +async def get_currency_historical( + symbol: str, days: int = 365 +) -> list[dict[str, Any]]: + """Get forex price history (e.g., EURUSD).""" + start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + try: + result = await asyncio.to_thread( + obb.currency.price.historical, symbol, start_date=start, provider=PROVIDER + ) + return _to_list(result) + except Exception: + logger.warning("Currency historical failed for %s", symbol, exc_info=True) + return [] + + +# --- Derivatives --- + + +async def get_options_chains(symbol: str) -> list[dict[str, Any]]: + """Get options chain data for a symbol.""" + try: + result = await asyncio.to_thread( + obb.derivatives.options.chains, symbol, provider=PROVIDER + ) + return _to_list(result) + except Exception: + logger.warning("Options chains failed for %s", symbol, exc_info=True) + return [] + + +async def get_futures_historical( + symbol: str, days: int = 365 +) -> list[dict[str, Any]]: + """Get futures price history.""" + start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + try: + result = await asyncio.to_thread( + obb.derivatives.futures.historical, symbol, start_date=start, provider=PROVIDER + ) + return _to_list(result) + except Exception: + logger.warning("Futures historical failed for %s", symbol, exc_info=True) + return [] + + +async def get_futures_curve(symbol: str) -> list[dict[str, Any]]: + """Get futures term structure/curve.""" + try: + result = await asyncio.to_thread( + obb.derivatives.futures.curve, symbol, provider=PROVIDER + ) + return _to_list(result) + except Exception: + logger.warning("Futures curve failed for %s", symbol, exc_info=True) + 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 diff --git a/quantitative_service.py b/quantitative_service.py new file mode 100644 index 0000000..2d2f302 --- /dev/null +++ b/quantitative_service.py @@ -0,0 +1,147 @@ +"""Quantitative analysis: risk metrics, performance, CAPM, normality tests.""" + +import asyncio +import logging +from datetime import datetime, timedelta +from typing import Any + +from openbb import obb + +logger = logging.getLogger(__name__) + +PROVIDER = "yfinance" + +# Need 252+ trading days for default window; 730 calendar days is safe +PERF_DAYS = 730 +TARGET = "close" + + +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") + + try: + hist = await asyncio.to_thread( + obb.equity.price.historical, symbol, start_date=start, provider=PROVIDER + ) + if not hist or not hist.results: + return {"symbol": symbol, "error": "No historical data"} + + sharpe_result, summary_result, stdev_result = await asyncio.gather( + asyncio.to_thread( + obb.quantitative.performance.sharpe_ratio, + data=hist.results, target=TARGET, + ), + asyncio.to_thread( + obb.quantitative.summary, data=hist.results, target=TARGET + ), + asyncio.to_thread( + obb.quantitative.stats.stdev, data=hist.results, target=TARGET + ), + return_exceptions=True, + ) + + 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, + "period_days": days, + "sharpe_ratio": sharpe, + "summary": summary, + "stdev": stdev, + } + except Exception: + logger.warning("Performance metrics failed for %s", symbol, exc_info=True) + return {"symbol": symbol, "error": "Failed to compute performance metrics"} + + +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") + + try: + hist = await asyncio.to_thread( + obb.equity.price.historical, symbol, start_date=start, provider=PROVIDER + ) + if not hist or not hist.results: + return {"symbol": symbol, "error": "No historical data"} + + capm = await asyncio.to_thread( + obb.quantitative.capm, data=hist.results, target=TARGET + ) + 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"} + + +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") + + try: + hist = await asyncio.to_thread( + obb.equity.price.historical, symbol, start_date=start, provider=PROVIDER + ) + if not hist or not hist.results: + return {"symbol": symbol, "error": "No historical data"} + + norm = await asyncio.to_thread( + obb.quantitative.normality, data=hist.results, target=TARGET + ) + 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"} + + +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") + + try: + hist = await asyncio.to_thread( + obb.equity.price.historical, symbol, start_date=start, provider=PROVIDER + ) + if not hist or not hist.results: + return {"symbol": symbol, "error": "No historical data"} + + ur = await asyncio.to_thread( + obb.quantitative.unitroot_test, data=hist.results, target=TARGET + ) + 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 diff --git a/routes_calendar.py b/routes_calendar.py new file mode 100644 index 0000000..cf7310a --- /dev/null +++ b/routes_calendar.py @@ -0,0 +1,140 @@ +"""Routes for calendar events, screening, ownership, and estimates.""" + +import functools +import logging +from collections.abc import Awaitable, Callable +from typing import ParamSpec, TypeVar + +from fastapi import APIRouter, HTTPException, Path, Query + +from models import SYMBOL_PATTERN, ApiResponse +import calendar_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1") + +P = ParamSpec("P") +R = TypeVar("R") + + +def _validate_symbol(symbol: str) -> str: + if not SYMBOL_PATTERN.match(symbol): + raise HTTPException(status_code=400, detail="Invalid symbol format") + return symbol.upper() + + +def _safe(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: + @functools.wraps(fn) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + try: + return await fn(*args, **kwargs) + except HTTPException: + raise + except Exception: + logger.exception("Upstream data error") + raise HTTPException( + status_code=502, + detail="Data provider error. Check server logs.", + ) + return wrapper # type: ignore[return-value] + + +# --- Calendar Events --- + + +@router.get("/calendar/earnings", response_model=ApiResponse) +@_safe +async def earnings_calendar( + start_date: str | None = Query(default=None, description="YYYY-MM-DD"), + end_date: str | None = Query(default=None, description="YYYY-MM-DD"), +): + """Get upcoming earnings announcements.""" + data = await calendar_service.get_earnings_calendar(start_date, end_date) + return ApiResponse(data=data) + + +@router.get("/calendar/dividends", response_model=ApiResponse) +@_safe +async def dividend_calendar( + start_date: str | None = Query(default=None, description="YYYY-MM-DD"), + end_date: str | None = Query(default=None, description="YYYY-MM-DD"), +): + """Get upcoming dividend dates.""" + data = await calendar_service.get_dividend_calendar(start_date, end_date) + return ApiResponse(data=data) + + +@router.get("/calendar/ipo", response_model=ApiResponse) +@_safe +async def ipo_calendar( + start_date: str | None = Query(default=None, description="YYYY-MM-DD"), + end_date: str | None = Query(default=None, description="YYYY-MM-DD"), +): + """Get upcoming IPOs.""" + data = await calendar_service.get_ipo_calendar(start_date, end_date) + return ApiResponse(data=data) + + +@router.get("/calendar/splits", response_model=ApiResponse) +@_safe +async def splits_calendar( + start_date: str | None = Query(default=None, description="YYYY-MM-DD"), + end_date: str | None = Query(default=None, description="YYYY-MM-DD"), +): + """Get upcoming stock splits.""" + data = await calendar_service.get_splits_calendar(start_date, end_date) + return ApiResponse(data=data) + + +# --- Analyst Estimates --- + + +@router.get("/stock/{symbol}/estimates", response_model=ApiResponse) +@_safe +async def stock_estimates(symbol: str = Path(..., min_length=1, max_length=20)): + """Get analyst consensus estimates.""" + symbol = _validate_symbol(symbol) + data = await calendar_service.get_analyst_estimates(symbol) + return ApiResponse(data=data) + + +@router.get("/stock/{symbol}/share-statistics", response_model=ApiResponse) +@_safe +async def stock_share_stats(symbol: str = Path(..., min_length=1, max_length=20)): + """Get share statistics: float, outstanding, short interest.""" + symbol = _validate_symbol(symbol) + data = await calendar_service.get_share_statistics(symbol) + return ApiResponse(data=data) + + +# --- Ownership (SEC, free) --- + + +@router.get("/stock/{symbol}/sec-insider", response_model=ApiResponse) +@_safe +async def stock_sec_insider(symbol: str = Path(..., min_length=1, max_length=20)): + """Get insider trading data from SEC (Form 4).""" + symbol = _validate_symbol(symbol) + data = await calendar_service.get_insider_trading(symbol) + return ApiResponse(data=data) + + +@router.get("/stock/{symbol}/institutional", response_model=ApiResponse) +@_safe +async def stock_institutional(symbol: str = Path(..., min_length=1, max_length=20)): + """Get institutional holders from SEC 13F filings.""" + symbol = _validate_symbol(symbol) + data = await calendar_service.get_institutional_holders(symbol) + return ApiResponse(data=data) + + +# --- Screener --- + + +@router.get("/screener", response_model=ApiResponse) +@_safe +async def stock_screener(): + """Screen stocks using available filters.""" + data = await calendar_service.screen_stocks() + return ApiResponse(data=data) diff --git a/routes_market.py b/routes_market.py new file mode 100644 index 0000000..b0c6a94 --- /dev/null +++ b/routes_market.py @@ -0,0 +1,166 @@ +"""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, HTTPException, Path, Query + +from models import SYMBOL_PATTERN, ApiResponse +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 --- + + +@router.get("/etf/{symbol}/info", response_model=ApiResponse) +@_safe +async def etf_info(symbol: str = Path(..., min_length=1, max_length=20)): + """Get ETF profile and info.""" + 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 +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) + 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 +async def index_available(): + """List available market indices.""" + data = await market_service.get_available_indices() + return ApiResponse(data=data) + + +@router.get("/index/{symbol}/historical", response_model=ApiResponse) +@_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) + data = await market_service.get_index_historical(symbol, days=days) + return ApiResponse(data=data) + + +# --- Crypto --- + + +@router.get("/crypto/{symbol}/historical", response_model=ApiResponse) +@_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) + 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 +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) + data = await market_service.get_currency_historical(symbol, days=days) + return ApiResponse(data=data) + + +# --- Derivatives --- + + +@router.get("/options/{symbol}/chains", response_model=ApiResponse) +@_safe +async def options_chains(symbol: str = Path(..., min_length=1, max_length=20)): + """Get options chain data.""" + 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 +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) + data = await market_service.get_futures_historical(symbol, days=days) + return ApiResponse(data=data) + + +@router.get("/futures/{symbol}/curve", response_model=ApiResponse) +@_safe +async def futures_curve(symbol: str = Path(..., min_length=1, max_length=20)): + """Get futures term structure/curve.""" + symbol = _validate_symbol(symbol) + data = await market_service.get_futures_curve(symbol) + return ApiResponse(data=data) diff --git a/routes_quantitative.py b/routes_quantitative.py new file mode 100644 index 0000000..1b5f2ac --- /dev/null +++ b/routes_quantitative.py @@ -0,0 +1,85 @@ +"""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, HTTPException, Path, Query + +from models import SYMBOL_PATTERN, ApiResponse +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 +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) + data = await quantitative_service.get_performance_metrics(symbol, days=days) + return ApiResponse(data=data) + + +@router.get("/stock/{symbol}/capm", response_model=ApiResponse) +@_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) + data = await quantitative_service.get_capm(symbol) + return ApiResponse(data=data) + + +@router.get("/stock/{symbol}/normality", response_model=ApiResponse) +@_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) + data = await quantitative_service.get_normality_test(symbol, days=days) + return ApiResponse(data=data) + + +@router.get("/stock/{symbol}/unitroot", response_model=ApiResponse) +@_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) + data = await quantitative_service.get_unitroot_test(symbol, days=days) + return ApiResponse(data=data)