Compare commits

...

4 Commits

Author SHA1 Message Date
Yaojia Wang
d05cb55cb0 feat: add Docker, Drone CI, and k8s deployment manifests
- Dockerfile for Python 3.12 FastAPI app
- Drone CI pipeline to build and push to internal registry
- Kubernetes manifests (Deployment, Service, Secret, Namespace)
- ArgoCD Application for GitOps deployment
- Kustomize base configuration
2026-03-09 23:28:31 +01:00
Yaojia Wang
e3e9c1986c docs: add route_utils and obb_utils to project structure in README 2026-03-09 14:50:54 +01:00
Yaojia Wang
003c1d6ffc 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
2026-03-09 10:56:21 +01:00
Yaojia Wang
507194397e 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.
2026-03-09 10:28:33 +01:00
23 changed files with 1156 additions and 179 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
__pycache__/
*.pyc
.env
.pytest_cache/
.coverage
.claude/
tests/
*.md
environment.yml
test_*.py

22
.drone.yml Normal file
View File

@@ -0,0 +1,22 @@
kind: pipeline
type: docker
name: build-and-push
trigger:
branch:
- main
- develop
event:
- push
steps:
- name: build-and-push
image: plugins/docker
settings:
repo: 192.168.68.11:30500/invest-api
registry: 192.168.68.11:30500
insecure: true
tags:
- ${DRONE_COMMIT_SHA:0:8}
- latest
dockerfile: Dockerfile

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM python:3.12-slim AS base
WORKDIR /app
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc g++ && \
rm -rf /var/lib/apt/lists/*
COPY pyproject.toml ./
RUN pip install --no-cache-dir . && \
pip install --no-cache-dir openbb-quantitative openbb-econometrics openbb-technical && \
apt-get purge -y gcc g++ && \
apt-get autoremove -y
COPY *.py ./
EXPOSE 8000
USER nobody
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

116
README.md
View File

@@ -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 |
@@ -189,29 +267,27 @@ openbb-invest-api/
├── config.py # Settings (env-based)
├── models.py # Pydantic request/response models
├── mappers.py # Dict-to-model mapping functions
├── route_utils.py # Shared route utilities (validation, error handling)
├── obb_utils.py # Shared OpenBB result conversion utilities
├── 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 +315,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 |

141
calendar_service.py Normal file
View File

@@ -0,0 +1,141 @@
"""Calendar events (earnings, dividends, IPOs, splits), screening, and ownership."""
import asyncio
import logging
from typing import Any
from openbb import obb
from obb_utils import to_list
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 []

20
k8s/argocd-app.yaml Normal file
View File

@@ -0,0 +1,20 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: invest-api
namespace: argocd
spec:
project: default
source:
repoURL: https://git.colacoder.com/kai/openbb-invest-api.git
targetRevision: main
path: k8s/base
destination:
server: https://kubernetes.default.svc
namespace: invest-api
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true

56
k8s/base/deployment.yaml Normal file
View File

@@ -0,0 +1,56 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: invest-api
namespace: invest-api
labels:
app: invest-api
spec:
replicas: 1
selector:
matchLabels:
app: invest-api
template:
metadata:
labels:
app: invest-api
spec:
containers:
- name: invest-api
image: 192.168.68.11:30500/invest-api:latest
imagePullPolicy: Always
ports:
- containerPort: 8000
protocol: TCP
env:
- name: INVEST_API_HOST
value: "0.0.0.0"
- name: INVEST_API_PORT
value: "8000"
- name: INVEST_API_LOG_LEVEL
value: "info"
- name: INVEST_API_CORS_ORIGINS
value: '["*"]'
envFrom:
- secretRef:
name: invest-api-secrets
optional: true
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 15
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 5
periodSeconds: 10

View File

@@ -0,0 +1,10 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: invest-api
resources:
- namespace.yaml
- secret.yaml
- deployment.yaml
- service.yaml

4
k8s/base/namespace.yaml Normal file
View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: invest-api

11
k8s/base/secret.yaml Normal file
View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: Secret
metadata:
name: invest-api-secrets
namespace: invest-api
type: Opaque
stringData:
# Replace with your actual keys before applying, or use sealed-secrets / external-secrets
INVEST_API_FINNHUB_API_KEY: ""
INVEST_API_FRED_API_KEY: ""
INVEST_API_ALPHAVANTAGE_API_KEY: ""

15
k8s/base/service.yaml Normal file
View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: invest-api
namespace: invest-api
labels:
app: invest-api
spec:
type: ClusterIP
selector:
app: invest-api
ports:
- port: 8000
targetPort: 8000
protocol: TCP

View File

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

163
market_service.py Normal file
View File

@@ -0,0 +1,163 @@
"""Market data: ETFs, indices, crypto, currencies, and derivatives."""
import asyncio
import logging
from datetime import datetime, timezone, timedelta
from typing import Any
from openbb import obb
from obb_utils import to_list
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(tz=timezone.utc) - 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(tz=timezone.utc) - 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(tz=timezone.utc) - 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(tz=timezone.utc) - 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(tz=timezone.utc) - 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 []

51
obb_utils.py Normal file
View 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

124
quantitative_service.py Normal file
View File

@@ -0,0 +1,124 @@
"""Quantitative analysis: risk metrics, performance, CAPM, normality tests."""
import asyncio
import logging
from datetime import datetime, timezone, timedelta
from typing import Any
from openbb import obb
from obb_utils import extract_single, safe_last
logger = logging.getLogger(__name__)
PROVIDER = "yfinance"
# 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(tz=timezone.utc) - 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"}
results: tuple[Any, ...] = 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_result, summary_result, stdev_result = results
sharpe = safe_last(sharpe_result) if not isinstance(sharpe_result, BaseException) else None
summary = extract_single(summary_result) if not isinstance(summary_result, BaseException) else {}
stdev = safe_last(stdev_result) if not isinstance(stdev_result, BaseException) else None
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(tz=timezone.utc) - 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(tz=timezone.utc) - 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(tz=timezone.utc) - 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"}

39
route_utils.py Normal file
View 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]

View File

@@ -1,9 +1,4 @@
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 mappers import (
discover_items_from_list,
@@ -12,7 +7,6 @@ from mappers import (
quote_from_dict,
)
from models import (
SYMBOL_PATTERN,
ApiResponse,
FinancialsResponse,
HistoricalBar,
@@ -21,87 +15,60 @@ from models import (
PortfolioResponse,
SummaryResponse,
)
from route_utils import safe, validate_symbol
import openbb_service
import analysis_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]]:
"""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 ---
@router.get("/stock/{symbol}/quote", response_model=ApiResponse)
@_safe
@safe
async def stock_quote(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get current quote for a stock."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await openbb_service.get_quote(symbol)
return ApiResponse(data=quote_from_dict(symbol, data).model_dump())
@router.get("/stock/{symbol}/profile", response_model=ApiResponse)
@_safe
@safe
async def stock_profile(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get company profile."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await openbb_service.get_profile(symbol)
return ApiResponse(data=profile_from_dict(symbol, data).model_dump())
@router.get("/stock/{symbol}/metrics", response_model=ApiResponse)
@_safe
@safe
async def stock_metrics(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get key financial metrics (PE, PB, ROE, etc.)."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await openbb_service.get_metrics(symbol)
return ApiResponse(data=metrics_from_dict(symbol, data).model_dump())
@router.get("/stock/{symbol}/financials", response_model=ApiResponse)
@_safe
@safe
async def stock_financials(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get income statement, balance sheet, and cash flow."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await openbb_service.get_financials(symbol)
return ApiResponse(data=FinancialsResponse(**data).model_dump())
@router.get("/stock/{symbol}/historical", response_model=ApiResponse)
@_safe
@safe
async def stock_historical(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=1, le=3650),
):
"""Get historical price data."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await openbb_service.get_historical(symbol, days=days)
bars = [
HistoricalBar(
@@ -118,10 +85,10 @@ async def stock_historical(
@router.get("/stock/{symbol}/news", response_model=ApiResponse)
@_safe
@safe
async def stock_news(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get recent company news."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await openbb_service.get_news(symbol)
news = [
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)
@_safe
@safe
async def stock_summary(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get aggregated stock data: quote + profile + metrics + financials."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await openbb_service.get_summary(symbol)
summary = SummaryResponse(
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)
@_safe
@safe
async def portfolio_analyze(request: PortfolioRequest):
"""Analyze portfolio holdings with rule-based engine."""
result: PortfolioResponse = await analysis_service.analyze_portfolio(
@@ -169,7 +136,7 @@ async def portfolio_analyze(request: PortfolioRequest):
@router.get("/discover/gainers", response_model=ApiResponse)
@_safe
@safe
async def discover_gainers():
"""Get top gainers (US market)."""
data = await openbb_service.get_gainers()
@@ -177,7 +144,7 @@ async def discover_gainers():
@router.get("/discover/losers", response_model=ApiResponse)
@_safe
@safe
async def discover_losers():
"""Get top losers (US market)."""
data = await openbb_service.get_losers()
@@ -185,7 +152,7 @@ async def discover_losers():
@router.get("/discover/active", response_model=ApiResponse)
@_safe
@safe
async def discover_active():
"""Get most active stocks (US market)."""
data = await openbb_service.get_active()
@@ -193,7 +160,7 @@ async def discover_active():
@router.get("/discover/undervalued", response_model=ApiResponse)
@_safe
@safe
async def discover_undervalued():
"""Get undervalued large cap stocks."""
data = await openbb_service.get_undervalued()
@@ -201,7 +168,7 @@ async def discover_undervalued():
@router.get("/discover/growth", response_model=ApiResponse)
@_safe
@safe
async def discover_growth():
"""Get growth tech stocks."""
data = await openbb_service.get_growth()

127
routes_calendar.py Normal file
View File

@@ -0,0 +1,127 @@
"""Routes for calendar events, screening, ownership, and estimates."""
from fastapi import APIRouter, Path, Query
from models import ApiResponse
from route_utils import safe, validate_symbol
import calendar_service
router = APIRouter(prefix="/api/v1")
DATE_PATTERN = r"^\d{4}-\d{2}-\d{2}$"
# --- Calendar Events ---
@router.get("/calendar/earnings", response_model=ApiResponse)
@safe
async def earnings_calendar(
start_date: str | None = Query(
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."""
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, 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."""
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, pattern=DATE_PATTERN, description="YYYY-MM-DD"
),
end_date: str | None = Query(
default=None, pattern=DATE_PATTERN, 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, 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."""
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)

View File

@@ -1,41 +1,16 @@
"""Routes for macroeconomic data (FRED-powered)."""
import functools
import logging
from collections.abc import Awaitable, Callable
from typing import ParamSpec, TypeVar
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, Query
from models import ApiResponse
from route_utils import safe
import macro_service
logger = logging.getLogger(__name__)
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)
@_safe
@safe
async def macro_overview():
"""Get key macro indicators: Fed rate, treasury yields, CPI, unemployment, GDP, VIX."""
data = await macro_service.get_macro_overview()
@@ -43,7 +18,7 @@ async def macro_overview():
@router.get("/macro/series/{series_id}", response_model=ApiResponse)
@_safe
@safe
async def macro_series(
series_id: str,
limit: int = Query(default=30, ge=1, le=1000),

137
routes_market.py Normal file
View File

@@ -0,0 +1,137 @@
"""Routes for ETF, index, crypto, currency, and derivatives data."""
from fastapi import APIRouter, Path, Query
from models import ApiResponse
from route_utils import safe, validate_symbol
import market_service
router = APIRouter(prefix="/api/v1")
# --- ETF ---
# NOTE: /etf/search MUST be registered before /etf/{symbol} to avoid shadowing.
@router.get("/etf/search", response_model=ApiResponse)
@safe
async def etf_search(query: str = Query(..., min_length=1, max_length=100)):
"""Search for ETFs by name or keyword."""
data = await market_service.search_etf(query)
return ApiResponse(data=data)
@router.get("/etf/{symbol}/info", response_model=ApiResponse)
@safe
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)
# --- 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 ---
# NOTE: /crypto/search MUST be registered before /crypto/{symbol} to avoid shadowing.
@router.get("/crypto/search", response_model=ApiResponse)
@safe
async def crypto_search(query: str = Query(..., min_length=1, max_length=100)):
"""Search for cryptocurrencies."""
data = await market_service.search_crypto(query)
return ApiResponse(data=data)
@router.get("/crypto/{symbol}/historical", response_model=ApiResponse)
@safe
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)
# --- 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)

54
routes_quantitative.py Normal file
View File

@@ -0,0 +1,54 @@
"""Routes for quantitative analysis: risk metrics, CAPM, normality, unit root."""
from fastapi import APIRouter, Path, Query
from models import ApiResponse
from route_utils import safe, validate_symbol
import quantitative_service
router = APIRouter(prefix="/api/v1")
@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)

View File

@@ -1,55 +1,29 @@
"""Routes for sentiment, insider trades, and analyst data (Finnhub + Alpha Vantage)."""
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 finnhub_service
import logging
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]
# --- Sentiment & News ---
@router.get("/stock/{symbol}/sentiment", response_model=ApiResponse)
@_safe
@safe
async def stock_sentiment(symbol: str = Path(..., min_length=1, max_length=20)):
"""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_service.get_sentiment_summary(symbol),
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)
@_safe
@safe
async def stock_news_sentiment(
symbol: str = Path(..., min_length=1, max_length=20),
limit: int = Query(default=30, ge=1, le=200),
):
"""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)
return ApiResponse(data=data)
@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)):
"""Get insider transactions (CEO/CFO buys and sells)."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
raw = await finnhub_service.get_insider_transactions(symbol)
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)
@_safe
@safe
async def stock_recommendations(symbol: str = Path(..., min_length=1, max_length=20)):
"""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)
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)
@_safe
@safe
async def stock_upgrades(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get recent analyst upgrades and downgrades."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
raw = await finnhub_service.get_upgrade_downgrade(symbol)
upgrades = [
{

View File

@@ -1,49 +1,18 @@
"""Routes for technical analysis indicators."""
import functools
import logging
from collections.abc import Awaitable, Callable
from typing import ParamSpec, TypeVar
from fastapi import APIRouter, Path
from fastapi import APIRouter, HTTPException, Path
from models import SYMBOL_PATTERN, ApiResponse
from models import ApiResponse
from route_utils import safe, validate_symbol
import technical_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}/technical", response_model=ApiResponse)
@_safe
@safe
async def stock_technical(symbol: str = Path(..., min_length=1, max_length=20)):
"""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)
return ApiResponse(data=data)