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.
This commit is contained in:
Yaojia Wang
2026-03-09 10:28:33 +01:00
parent 00f2cb5e74
commit 507194397e
8 changed files with 980 additions and 18 deletions

114
README.md
View File

@@ -1,6 +1,6 @@
# OpenBB Investment Analysis API # 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 ## API Keys
@@ -42,6 +42,7 @@ export INVEST_API_ALPHAVANTAGE_API_KEY=your_alphavantage_key
```bash ```bash
conda env create -f environment.yml conda env create -f environment.yml
conda activate openbb-invest-api conda activate openbb-invest-api
pip install openbb-quantitative openbb-econometrics
``` ```
### 2. Start the server ### 2. Start the server
@@ -64,15 +65,28 @@ curl http://localhost:8000/api/v1/stock/AAPL/quote
# Swedish stock quote # Swedish stock quote
curl http://localhost:8000/api/v1/stock/VOLV-B.ST/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 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 curl http://localhost:8000/api/v1/stock/AAPL/news-sentiment
# Technical indicators # Technical indicators
curl http://localhost:8000/api/v1/stock/AAPL/technical 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) # Macro overview (requires FRED key)
curl http://localhost:8000/api/v1/macro/overview 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}/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}/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}/recommendations` | Monthly analyst buy/hold/sell counts |
| GET | `/api/v1/stock/{symbol}/upgrades` | Recent analyst upgrades and downgrades | | 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 | | 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) ### Macro Economics (FRED, free key)
| Method | Path | Description | | Method | Path | Description |
@@ -192,26 +270,22 @@ openbb-invest-api/
├── openbb_service.py # OpenBB SDK wrapper (async) ├── openbb_service.py # OpenBB SDK wrapper (async)
├── finnhub_service.py # Finnhub REST client (insider, analyst data) ├── finnhub_service.py # Finnhub REST client (insider, analyst data)
├── alphavantage_service.py # Alpha Vantage REST client (news sentiment) ├── 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 ├── macro_service.py # FRED macro data via OpenBB
├── technical_service.py # Technical indicators via openbb-technical ├── technical_service.py # Technical indicators via openbb-technical
├── analysis_service.py # Rule engine for portfolio analysis ├── analysis_service.py # Rule engine for portfolio analysis
├── routes.py # Core stock data + portfolio + discovery routes ├── routes.py # Core stock data + portfolio + discovery routes
├── routes_sentiment.py # Sentiment & analyst routes (Finnhub + Alpha Vantage) ├── 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_macro.py # Macro economics routes (FRED)
├── routes_technical.py # Technical analysis routes ├── routes_technical.py # Technical analysis routes
├── environment.yml # Conda environment ├── environment.yml # Conda environment
├── pyproject.toml # Project metadata ├── pyproject.toml # Project metadata
└── tests/ # 102 tests └── 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 ## Running Tests
@@ -239,16 +313,20 @@ Example OpenClaw workflow:
2. OpenClaw calls `GET /api/v1/stock/AAPL/summary` for fundamental data 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 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 4. OpenClaw calls `GET /api/v1/stock/AAPL/technical` for technical signals
5. OpenClaw calls `GET /api/v1/macro/overview` for market context 5. OpenClaw calls `GET /api/v1/stock/AAPL/performance` for risk metrics (Sharpe, volatility)
6. OpenClaw calls `POST /api/v1/portfolio/analyze` with user's holdings 6. OpenClaw calls `GET /api/v1/stock/AAPL/sec-insider` for insider trading activity
7. OpenClaw's LLM synthesizes all structured data into a personalized recommendation 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 ## Data Sources
| Source | Cost | Key Required | Data Provided | | 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 | | **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 | | **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 | | **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-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 |

159
calendar_service.py Normal file
View File

@@ -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

View File

@@ -9,6 +9,9 @@ from routes import router
from routes_sentiment import router as sentiment_router from routes_sentiment import router as sentiment_router
from routes_macro import router as macro_router from routes_macro import router as macro_router
from routes_technical import router as technical_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( logging.basicConfig(
level=settings.log_level.upper(), level=settings.log_level.upper(),
@@ -33,6 +36,9 @@ app.include_router(router)
app.include_router(sentiment_router) app.include_router(sentiment_router)
app.include_router(macro_router) app.include_router(macro_router)
app.include_router(technical_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]) @app.get("/health", response_model=dict[str, str])

181
market_service.py Normal file
View File

@@ -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

147
quantitative_service.py Normal file
View File

@@ -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

140
routes_calendar.py Normal file
View File

@@ -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)

166
routes_market.py Normal file
View File

@@ -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)

85
routes_quantitative.py Normal file
View File

@@ -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)