Compare commits
32 Commits
00f2cb5e74
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac101c663a | ||
|
|
f5b22deec3 | ||
|
|
b631c888a5 | ||
|
|
760b0a09ea | ||
|
|
16ad276146 | ||
|
|
e5820ebe4a | ||
|
|
cd6158b05c | ||
|
|
2446a2fde8 | ||
|
|
d3c919385f | ||
|
|
e797f8929d | ||
|
|
d46e8685d7 | ||
|
|
26cd716590 | ||
|
|
42f25426ac | ||
|
|
d4e06c71b7 | ||
|
|
eac9fe963e | ||
|
|
f4e98653d4 | ||
|
|
dd17c4f2ae | ||
|
|
82ac3deee4 | ||
|
|
f9f5d37b44 | ||
|
|
1a0c8ea675 | ||
|
|
8c682dc5dd | ||
|
|
e807af6395 | ||
|
|
4565edf432 | ||
|
|
79d9d5a012 | ||
|
|
64fb5fc43e | ||
|
|
faec132bbe | ||
|
|
e7c747d297 | ||
|
|
ec76e92f1e | ||
|
|
d05cb55cb0 | ||
|
|
e3e9c1986c | ||
|
|
003c1d6ffc | ||
|
|
507194397e |
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
.claude/
|
||||||
|
tests/
|
||||||
|
*.md
|
||||||
|
environment.yml
|
||||||
|
test_*.py
|
||||||
29
.drone.yml
Normal file
29
.drone.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
kind: pipeline
|
||||||
|
type: kubernetes
|
||||||
|
name: build-and-push
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
- custom
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build-and-push
|
||||||
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
|
commands:
|
||||||
|
- >
|
||||||
|
/kaniko/executor
|
||||||
|
--context=/drone/src
|
||||||
|
--dockerfile=Dockerfile
|
||||||
|
--destination=192.168.68.11:30500/invest-api:${DRONE_COMMIT_SHA:0:8}
|
||||||
|
--destination=192.168.68.11:30500/invest-api:latest
|
||||||
|
--insecure
|
||||||
|
--skip-tls-verify
|
||||||
|
|
||||||
|
- name: restart-deployment
|
||||||
|
image: bitnami/kubectl:latest
|
||||||
|
commands:
|
||||||
|
- kubectl rollout restart deploy/invest-api -n invest-api
|
||||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
FROM python:3.12-slim AS base
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
gcc g++ libssl-dev \
|
||||||
|
ca-certificates curl libnss3 libssl3 && \
|
||||||
|
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++ libssl-dev && \
|
||||||
|
apt-get autoremove -y
|
||||||
|
|
||||||
|
COPY *.py ./
|
||||||
|
|
||||||
|
RUN useradd -m -s /bin/bash appuser && \
|
||||||
|
mkdir -p /home/appuser/.openbb_platform && \
|
||||||
|
chown -R appuser:appuser /home/appuser && \
|
||||||
|
chown -R appuser:appuser /usr/local/lib/python3.12/site-packages/openbb
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
222
README.md
222
README.md
@@ -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 |
|
||||||
@@ -189,29 +267,27 @@ openbb-invest-api/
|
|||||||
├── config.py # Settings (env-based)
|
├── config.py # Settings (env-based)
|
||||||
├── models.py # Pydantic request/response models
|
├── models.py # Pydantic request/response models
|
||||||
├── mappers.py # Dict-to-model mapping functions
|
├── 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)
|
├── 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 +315,126 @@ 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
|
||||||
|
|
||||||
|
## Kubernetes Deployment
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Kubernetes cluster with ingress-nginx
|
||||||
|
- Docker Registry at `192.168.68.11:30500`
|
||||||
|
- Drone CI connected to Gitea
|
||||||
|
- ArgoCD installed
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
git push → Gitea → Drone CI (kaniko) → Docker Registry → ArgoCD → K8s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cluster Info
|
||||||
|
|
||||||
|
| Component | Value |
|
||||||
|
|-----------|-------|
|
||||||
|
| API URL | `https://invest-api.k8s.home` |
|
||||||
|
| Namespace | `invest-api` |
|
||||||
|
| Image | `192.168.68.11:30500/invest-api:latest` |
|
||||||
|
| Resources | 100m-500m CPU, 256Mi-512Mi memory |
|
||||||
|
| Health check | `GET /health` on port 8000 |
|
||||||
|
|
||||||
|
### K8s Manifests
|
||||||
|
|
||||||
|
Located in `k8s/base/` (Kustomize):
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `namespace.yaml` | `invest-api` namespace |
|
||||||
|
| `deployment.yaml` | App deployment with health probes |
|
||||||
|
| `service.yaml` | ClusterIP service on port 8000 |
|
||||||
|
| `ingress.yaml` | Ingress for `invest-api.k8s.home` |
|
||||||
|
| `secret.yaml` | Template for API keys |
|
||||||
|
| `kustomization.yaml` | Kustomize resource list |
|
||||||
|
|
||||||
|
ArgoCD Application defined in `k8s/argocd-app.yaml`.
|
||||||
|
|
||||||
|
### CI/CD Pipeline
|
||||||
|
|
||||||
|
`.drone.yml` uses kaniko to build and push:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kind: pipeline
|
||||||
|
type: kubernetes
|
||||||
|
name: build-and-push
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
branch: [main, develop]
|
||||||
|
event: [push, custom]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build-and-push
|
||||||
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
|
commands:
|
||||||
|
- /kaniko/executor
|
||||||
|
--context=/drone/src
|
||||||
|
--dockerfile=Dockerfile
|
||||||
|
--destination=192.168.68.11:30500/invest-api:${DRONE_COMMIT_SHA:0:8}
|
||||||
|
--destination=192.168.68.11:30500/invest-api:latest
|
||||||
|
--insecure --skip-tls-verify
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploy from Scratch
|
||||||
|
|
||||||
|
1. Deploy Docker Registry:
|
||||||
|
```bash
|
||||||
|
kubectl apply -k k8s-infra/registry/
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Configure containerd on worker nodes to trust insecure registry (see `HomeLab Infrastructure` doc)
|
||||||
|
|
||||||
|
3. Push code to Gitea -- Drone builds and pushes image automatically
|
||||||
|
|
||||||
|
4. Apply ArgoCD Application:
|
||||||
|
```bash
|
||||||
|
kubectl apply -f k8s/argocd-app.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Create API key secrets (optional):
|
||||||
|
```bash
|
||||||
|
kubectl -n invest-api create secret generic invest-api-secrets \
|
||||||
|
--from-literal=INVEST_API_FINNHUB_API_KEY=your_key \
|
||||||
|
--from-literal=INVEST_API_FRED_API_KEY=your_key \
|
||||||
|
--from-literal=INVEST_API_ALPHAVANTAGE_API_KEY=your_key
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Add DNS: `invest-api.k8s.home → 192.168.68.22`
|
||||||
|
|
||||||
|
7. Verify:
|
||||||
|
```bash
|
||||||
|
curl -k https://invest-api.k8s.home/health
|
||||||
|
curl -k https://invest-api.k8s.home/api/v1/stock/AAPL/quote
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
Build and run locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t invest-api .
|
||||||
|
docker run -p 8000:8000 invest-api
|
||||||
|
```
|
||||||
|
|
||||||
## 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 |
|
||||||
|
|||||||
141
calendar_service.py
Normal file
141
calendar_service.py
Normal 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 []
|
||||||
@@ -18,3 +18,4 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
|
|||||||
20
k8s/argocd-app.yaml
Normal file
20
k8s/argocd-app.yaml
Normal 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
56
k8s/base/deployment.yaml
Normal 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
|
||||||
29
k8s/base/drone-rbac.yaml
Normal file
29
k8s/base/drone-rbac.yaml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: drone-deploy
|
||||||
|
namespace: invest-api
|
||||||
|
rules:
|
||||||
|
- apiGroups: ["apps"]
|
||||||
|
resources: ["deployments"]
|
||||||
|
verbs: ["get", "list", "patch", "update"]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: drone-deploy
|
||||||
|
namespace: invest-api
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: default
|
||||||
|
namespace: drone
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: drone
|
||||||
|
namespace: drone
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: drone-runner-drone-runner-kube
|
||||||
|
namespace: drone
|
||||||
|
roleRef:
|
||||||
|
kind: Role
|
||||||
|
name: drone-deploy
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
24
k8s/base/ingress.yaml
Normal file
24
k8s/base/ingress.yaml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: invest-api
|
||||||
|
namespace: invest-api
|
||||||
|
annotations:
|
||||||
|
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- invest-api.k8s.home
|
||||||
|
secretName: invest-api-tls
|
||||||
|
rules:
|
||||||
|
- host: invest-api.k8s.home
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: invest-api
|
||||||
|
port:
|
||||||
|
number: 8000
|
||||||
12
k8s/base/kustomization.yaml
Normal file
12
k8s/base/kustomization.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
namespace: invest-api
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- secret.yaml
|
||||||
|
- deployment.yaml
|
||||||
|
- service.yaml
|
||||||
|
- ingress.yaml
|
||||||
|
|
||||||
4
k8s/base/namespace.yaml
Normal file
4
k8s/base/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: invest-api
|
||||||
10
k8s/base/secret.yaml
Normal file
10
k8s/base/secret.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: invest-api-secrets
|
||||||
|
namespace: invest-api
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
INVEST_API_FINNHUB_API_KEY: "d6n0109r01qir35in7d0d6n0109r01qir35in7dg"
|
||||||
|
INVEST_API_FRED_API_KEY: "c5388b7f4a50694adc68bfc6690ac844"
|
||||||
|
INVEST_API_ALPHAVANTAGE_API_KEY: "BM4NQG2BQ33MAFZH"
|
||||||
15
k8s/base/service.yaml
Normal file
15
k8s/base/service.yaml
Normal 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
|
||||||
@@ -36,19 +36,31 @@ def _to_dicts(result: Any) -> list[dict[str, Any]]:
|
|||||||
return [vars(result.results)]
|
return [vars(result.results)]
|
||||||
|
|
||||||
|
|
||||||
async def get_series(series_id: str, limit: int = 10) -> list[dict[str, Any]]:
|
async def get_series(
|
||||||
|
series_id: str, limit: int = 10, latest: bool = False,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
"""Get a FRED time series by ID."""
|
"""Get a FRED time series by ID."""
|
||||||
try:
|
try:
|
||||||
|
fetch_limit = limit if not latest else None
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"symbol": series_id,
|
||||||
|
"provider": PROVIDER,
|
||||||
|
}
|
||||||
|
if fetch_limit is not None:
|
||||||
|
kwargs["limit"] = fetch_limit
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
obb.economy.fred_series,
|
obb.economy.fred_series,
|
||||||
symbol=series_id,
|
**kwargs,
|
||||||
limit=limit,
|
|
||||||
provider=PROVIDER,
|
|
||||||
)
|
)
|
||||||
items = _to_dicts(result)
|
items = _to_dicts(result)
|
||||||
for item in items:
|
items = [
|
||||||
if "date" in item and not isinstance(item["date"], str):
|
{**item, "date": str(item["date"])}
|
||||||
item = {**item, "date": str(item["date"])}
|
if "date" in item and not isinstance(item["date"], str)
|
||||||
|
else item
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
|
if latest:
|
||||||
|
items = items[-limit:]
|
||||||
return items
|
return items
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to fetch FRED series %s", series_id, exc_info=True)
|
logger.warning("Failed to fetch FRED series %s", series_id, exc_info=True)
|
||||||
@@ -58,20 +70,22 @@ async def get_series(series_id: str, limit: int = 10) -> list[dict[str, Any]]:
|
|||||||
async def get_macro_overview() -> dict[str, Any]:
|
async def get_macro_overview() -> dict[str, Any]:
|
||||||
"""Get a summary of key macro indicators."""
|
"""Get a summary of key macro indicators."""
|
||||||
tasks = {
|
tasks = {
|
||||||
name: get_series(series_id, limit=1)
|
name: get_series(series_id, limit=1, latest=True)
|
||||||
for name, series_id in SERIES.items()
|
for name, series_id in SERIES.items()
|
||||||
}
|
}
|
||||||
results = await asyncio.gather(*tasks.values(), return_exceptions=True)
|
results = await asyncio.gather(*tasks.values(), return_exceptions=True)
|
||||||
|
|
||||||
overview: dict[str, Any] = {}
|
overview: dict[str, Any] = {}
|
||||||
for name, result in zip(tasks.keys(), results):
|
for (name, series_id), result in zip(SERIES.items(), results):
|
||||||
if isinstance(result, BaseException):
|
if isinstance(result, BaseException):
|
||||||
logger.warning("Failed to fetch %s: %s", name, result)
|
logger.warning("Failed to fetch %s: %s", name, result)
|
||||||
overview[name] = None
|
overview[name] = None
|
||||||
elif result and len(result) > 0:
|
elif result and len(result) > 0:
|
||||||
entry = result[0]
|
entry = result[-1]
|
||||||
|
# FRED returns values keyed by series ID, not "value"
|
||||||
|
value = entry.get(series_id) or entry.get("value")
|
||||||
overview[name] = {
|
overview[name] = {
|
||||||
"value": entry.get("value"),
|
"value": value,
|
||||||
"date": str(entry.get("date", "")),
|
"date": str(entry.get("date", "")),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
|||||||
26
main.py
26
main.py
@@ -4,11 +4,34 @@ import uvicorn
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
# Patch curl_cffi to use safari TLS fingerprint instead of chrome.
|
||||||
|
# curl_cffi's chrome impersonation triggers BoringSSL SSL_ERROR_SYSCALL on
|
||||||
|
# some networks; safari works reliably. This must happen before any import
|
||||||
|
# that creates a curl_cffi Session (yfinance, openbb).
|
||||||
|
import curl_cffi.requests as _cffi_requests
|
||||||
|
|
||||||
|
_orig_session_init = _cffi_requests.Session.__init__
|
||||||
|
|
||||||
|
def _patched_session_init(self, *args, **kwargs):
|
||||||
|
if kwargs.get("impersonate") == "chrome":
|
||||||
|
kwargs["impersonate"] = "safari"
|
||||||
|
_orig_session_init(self, *args, **kwargs)
|
||||||
|
|
||||||
|
_cffi_requests.Session.__init__ = _patched_session_init
|
||||||
|
|
||||||
|
from openbb import obb
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
|
# Register optional provider credentials with OpenBB
|
||||||
|
if settings.fred_api_key:
|
||||||
|
obb.user.credentials.fred_api_key = settings.fred_api_key
|
||||||
from routes import router
|
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 +56,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])
|
||||||
|
|||||||
163
market_service.py
Normal file
163
market_service.py
Normal 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
51
obb_utils.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Shared OpenBB result conversion utilities."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def to_list(result: Any) -> list[dict[str, Any]]:
|
||||||
|
"""Convert OBBject result to list of dicts with serialized dates."""
|
||||||
|
if result is None or result.results is None:
|
||||||
|
return []
|
||||||
|
items = result.results
|
||||||
|
if not isinstance(items, list):
|
||||||
|
items = [items]
|
||||||
|
out = []
|
||||||
|
for item in items:
|
||||||
|
if hasattr(item, "model_dump"):
|
||||||
|
d = item.model_dump()
|
||||||
|
else:
|
||||||
|
raw = vars(item)
|
||||||
|
d = dict(raw) if raw else {}
|
||||||
|
d = {
|
||||||
|
k: v.isoformat() if hasattr(v, "isoformat") else v
|
||||||
|
for k, v in d.items()
|
||||||
|
}
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def extract_single(result: Any) -> dict[str, Any]:
|
||||||
|
"""Extract data from an OBBject result (single model or list)."""
|
||||||
|
if result is None:
|
||||||
|
return {}
|
||||||
|
items = getattr(result, "results", None)
|
||||||
|
if items is None:
|
||||||
|
return {}
|
||||||
|
if hasattr(items, "model_dump"):
|
||||||
|
return items.model_dump()
|
||||||
|
if isinstance(items, list) and items:
|
||||||
|
last = items[-1]
|
||||||
|
return last.model_dump() if hasattr(last, "model_dump") else {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def safe_last(result: Any) -> dict[str, Any] | None:
|
||||||
|
"""Get the last item from a list result, or None."""
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
items = getattr(result, "results", None)
|
||||||
|
if items is None or not isinstance(items, list) or not items:
|
||||||
|
return None
|
||||||
|
last = items[-1]
|
||||||
|
return last.model_dump() if hasattr(last, "model_dump") else None
|
||||||
@@ -104,13 +104,15 @@ async def get_financials(symbol: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
async def get_price_target(symbol: str) -> float | None:
|
async def get_price_target(symbol: str) -> float | None:
|
||||||
|
"""Get consensus analyst price target via yfinance."""
|
||||||
|
import yfinance as yf
|
||||||
|
|
||||||
|
def _fetch() -> float | None:
|
||||||
|
t = yf.Ticker(symbol)
|
||||||
|
return t.info.get("targetMeanPrice")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(
|
return await asyncio.to_thread(_fetch)
|
||||||
obb.equity.estimates.price_target, symbol, provider=PROVIDER
|
|
||||||
)
|
|
||||||
items = _to_dicts(result)
|
|
||||||
if items:
|
|
||||||
return items[0].get("adj_price_target") or items[0].get("price_target")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to get price target for %s", symbol, exc_info=True)
|
logger.warning("Failed to get price target for %s", symbol, exc_info=True)
|
||||||
return None
|
return None
|
||||||
@@ -171,3 +173,30 @@ async def get_growth() -> list[dict]:
|
|||||||
obb.equity.discovery.growth_tech, provider=PROVIDER
|
obb.equity.discovery.growth_tech, provider=PROVIDER
|
||||||
)
|
)
|
||||||
return _to_dicts(result)
|
return _to_dicts(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_upgrades_downgrades(symbol: str, limit: int = 20) -> list[dict]:
|
||||||
|
"""Get analyst upgrades/downgrades via yfinance."""
|
||||||
|
import yfinance as yf
|
||||||
|
|
||||||
|
def _fetch() -> list[dict[str, Any]]:
|
||||||
|
t = yf.Ticker(symbol)
|
||||||
|
df = t.upgrades_downgrades
|
||||||
|
if df is None or df.empty:
|
||||||
|
return []
|
||||||
|
df = df.head(limit).reset_index()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"date": str(row.get("GradeDate", "")),
|
||||||
|
"company": row.get("Firm"),
|
||||||
|
"action": row.get("Action"),
|
||||||
|
"from_grade": row.get("FromGrade"),
|
||||||
|
"to_grade": row.get("ToGrade"),
|
||||||
|
"price_target_action": row.get("priceTargetAction"),
|
||||||
|
"current_price_target": row.get("currentPriceTarget"),
|
||||||
|
"prior_price_target": row.get("priorPriceTarget"),
|
||||||
|
}
|
||||||
|
for _, row in df.iterrows()
|
||||||
|
]
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_fetch)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ dependencies = [
|
|||||||
"openbb[yfinance]",
|
"openbb[yfinance]",
|
||||||
"pydantic-settings",
|
"pydantic-settings",
|
||||||
"httpx",
|
"httpx",
|
||||||
|
"curl_cffi==0.7.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
124
quantitative_service.py
Normal file
124
quantitative_service.py
Normal 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
39
route_utils.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Shared route utilities: symbol validation and error handling decorator."""
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import ParamSpec, TypeVar
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from models import SYMBOL_PATTERN
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
P = ParamSpec("P")
|
||||||
|
R = TypeVar("R")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_symbol(symbol: str) -> str:
|
||||||
|
"""Validate and normalize a stock symbol."""
|
||||||
|
if not SYMBOL_PATTERN.match(symbol):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid symbol format")
|
||||||
|
return symbol.upper()
|
||||||
|
|
||||||
|
|
||||||
|
def safe(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
|
||||||
|
"""Decorator to catch upstream errors and return 502."""
|
||||||
|
@functools.wraps(fn)
|
||||||
|
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||||
|
try:
|
||||||
|
return await fn(*args, **kwargs)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Upstream data error")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail="Data provider error. Check server logs.",
|
||||||
|
)
|
||||||
|
return wrapper # type: ignore[return-value]
|
||||||
77
routes.py
77
routes.py
@@ -1,9 +1,4 @@
|
|||||||
import functools
|
from fastapi import APIRouter, Path, Query
|
||||||
import logging
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import ParamSpec, TypeVar
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Path, Query
|
|
||||||
|
|
||||||
from mappers import (
|
from mappers import (
|
||||||
discover_items_from_list,
|
discover_items_from_list,
|
||||||
@@ -12,7 +7,6 @@ from mappers import (
|
|||||||
quote_from_dict,
|
quote_from_dict,
|
||||||
)
|
)
|
||||||
from models import (
|
from models import (
|
||||||
SYMBOL_PATTERN,
|
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
FinancialsResponse,
|
FinancialsResponse,
|
||||||
HistoricalBar,
|
HistoricalBar,
|
||||||
@@ -21,87 +15,60 @@ from models import (
|
|||||||
PortfolioResponse,
|
PortfolioResponse,
|
||||||
SummaryResponse,
|
SummaryResponse,
|
||||||
)
|
)
|
||||||
|
from route_utils import safe, validate_symbol
|
||||||
import openbb_service
|
import openbb_service
|
||||||
import analysis_service
|
import analysis_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
P = ParamSpec("P")
|
|
||||||
R = TypeVar("R")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_symbol(symbol: str) -> str:
|
|
||||||
if not SYMBOL_PATTERN.match(symbol):
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid symbol format")
|
|
||||||
return symbol.upper()
|
|
||||||
|
|
||||||
|
|
||||||
def _safe(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
|
|
||||||
"""Decorator to catch OpenBB errors and return 502."""
|
|
||||||
@functools.wraps(fn)
|
|
||||||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
||||||
try:
|
|
||||||
return await fn(*args, **kwargs)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Upstream data error")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail="Data provider error. Check server logs.",
|
|
||||||
)
|
|
||||||
return wrapper # type: ignore[return-value]
|
|
||||||
|
|
||||||
|
|
||||||
# --- Stock Data ---
|
# --- Stock Data ---
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/quote", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/quote", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_quote(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_quote(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get current quote for a stock."""
|
"""Get current quote for a stock."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await openbb_service.get_quote(symbol)
|
data = await openbb_service.get_quote(symbol)
|
||||||
return ApiResponse(data=quote_from_dict(symbol, data).model_dump())
|
return ApiResponse(data=quote_from_dict(symbol, data).model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/profile", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/profile", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_profile(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_profile(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get company profile."""
|
"""Get company profile."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await openbb_service.get_profile(symbol)
|
data = await openbb_service.get_profile(symbol)
|
||||||
return ApiResponse(data=profile_from_dict(symbol, data).model_dump())
|
return ApiResponse(data=profile_from_dict(symbol, data).model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/metrics", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/metrics", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_metrics(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_metrics(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get key financial metrics (PE, PB, ROE, etc.)."""
|
"""Get key financial metrics (PE, PB, ROE, etc.)."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await openbb_service.get_metrics(symbol)
|
data = await openbb_service.get_metrics(symbol)
|
||||||
return ApiResponse(data=metrics_from_dict(symbol, data).model_dump())
|
return ApiResponse(data=metrics_from_dict(symbol, data).model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/financials", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/financials", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_financials(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_financials(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get income statement, balance sheet, and cash flow."""
|
"""Get income statement, balance sheet, and cash flow."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await openbb_service.get_financials(symbol)
|
data = await openbb_service.get_financials(symbol)
|
||||||
return ApiResponse(data=FinancialsResponse(**data).model_dump())
|
return ApiResponse(data=FinancialsResponse(**data).model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/historical", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/historical", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_historical(
|
async def stock_historical(
|
||||||
symbol: str = Path(..., min_length=1, max_length=20),
|
symbol: str = Path(..., min_length=1, max_length=20),
|
||||||
days: int = Query(default=365, ge=1, le=3650),
|
days: int = Query(default=365, ge=1, le=3650),
|
||||||
):
|
):
|
||||||
"""Get historical price data."""
|
"""Get historical price data."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await openbb_service.get_historical(symbol, days=days)
|
data = await openbb_service.get_historical(symbol, days=days)
|
||||||
bars = [
|
bars = [
|
||||||
HistoricalBar(
|
HistoricalBar(
|
||||||
@@ -118,10 +85,10 @@ async def stock_historical(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/news", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/news", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_news(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_news(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get recent company news."""
|
"""Get recent company news."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await openbb_service.get_news(symbol)
|
data = await openbb_service.get_news(symbol)
|
||||||
news = [
|
news = [
|
||||||
NewsItem(
|
NewsItem(
|
||||||
@@ -136,10 +103,10 @@ async def stock_news(symbol: str = Path(..., min_length=1, max_length=20)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/summary", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/summary", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_summary(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_summary(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get aggregated stock data: quote + profile + metrics + financials."""
|
"""Get aggregated stock data: quote + profile + metrics + financials."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await openbb_service.get_summary(symbol)
|
data = await openbb_service.get_summary(symbol)
|
||||||
summary = SummaryResponse(
|
summary = SummaryResponse(
|
||||||
quote=quote_from_dict(symbol, data.get("quote", {})),
|
quote=quote_from_dict(symbol, data.get("quote", {})),
|
||||||
@@ -156,7 +123,7 @@ async def stock_summary(symbol: str = Path(..., min_length=1, max_length=20)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/portfolio/analyze", response_model=ApiResponse)
|
@router.post("/portfolio/analyze", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def portfolio_analyze(request: PortfolioRequest):
|
async def portfolio_analyze(request: PortfolioRequest):
|
||||||
"""Analyze portfolio holdings with rule-based engine."""
|
"""Analyze portfolio holdings with rule-based engine."""
|
||||||
result: PortfolioResponse = await analysis_service.analyze_portfolio(
|
result: PortfolioResponse = await analysis_service.analyze_portfolio(
|
||||||
@@ -169,7 +136,7 @@ async def portfolio_analyze(request: PortfolioRequest):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/discover/gainers", response_model=ApiResponse)
|
@router.get("/discover/gainers", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def discover_gainers():
|
async def discover_gainers():
|
||||||
"""Get top gainers (US market)."""
|
"""Get top gainers (US market)."""
|
||||||
data = await openbb_service.get_gainers()
|
data = await openbb_service.get_gainers()
|
||||||
@@ -177,7 +144,7 @@ async def discover_gainers():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/discover/losers", response_model=ApiResponse)
|
@router.get("/discover/losers", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def discover_losers():
|
async def discover_losers():
|
||||||
"""Get top losers (US market)."""
|
"""Get top losers (US market)."""
|
||||||
data = await openbb_service.get_losers()
|
data = await openbb_service.get_losers()
|
||||||
@@ -185,7 +152,7 @@ async def discover_losers():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/discover/active", response_model=ApiResponse)
|
@router.get("/discover/active", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def discover_active():
|
async def discover_active():
|
||||||
"""Get most active stocks (US market)."""
|
"""Get most active stocks (US market)."""
|
||||||
data = await openbb_service.get_active()
|
data = await openbb_service.get_active()
|
||||||
@@ -193,7 +160,7 @@ async def discover_active():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/discover/undervalued", response_model=ApiResponse)
|
@router.get("/discover/undervalued", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def discover_undervalued():
|
async def discover_undervalued():
|
||||||
"""Get undervalued large cap stocks."""
|
"""Get undervalued large cap stocks."""
|
||||||
data = await openbb_service.get_undervalued()
|
data = await openbb_service.get_undervalued()
|
||||||
@@ -201,7 +168,7 @@ async def discover_undervalued():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/discover/growth", response_model=ApiResponse)
|
@router.get("/discover/growth", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def discover_growth():
|
async def discover_growth():
|
||||||
"""Get growth tech stocks."""
|
"""Get growth tech stocks."""
|
||||||
data = await openbb_service.get_growth()
|
data = await openbb_service.get_growth()
|
||||||
|
|||||||
127
routes_calendar.py
Normal file
127
routes_calendar.py
Normal 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)
|
||||||
@@ -1,41 +1,16 @@
|
|||||||
"""Routes for macroeconomic data (FRED-powered)."""
|
"""Routes for macroeconomic data (FRED-powered)."""
|
||||||
|
|
||||||
import functools
|
from fastapi import APIRouter, Query
|
||||||
import logging
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import ParamSpec, TypeVar
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
|
||||||
|
|
||||||
from models import ApiResponse
|
from models import ApiResponse
|
||||||
|
from route_utils import safe
|
||||||
import macro_service
|
import macro_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
P = ParamSpec("P")
|
|
||||||
R = TypeVar("R")
|
|
||||||
|
|
||||||
|
|
||||||
def _safe(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
|
|
||||||
@functools.wraps(fn)
|
|
||||||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
||||||
try:
|
|
||||||
return await fn(*args, **kwargs)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Upstream data error")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail="Data provider error. Check server logs.",
|
|
||||||
)
|
|
||||||
return wrapper # type: ignore[return-value]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/macro/overview", response_model=ApiResponse)
|
@router.get("/macro/overview", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def macro_overview():
|
async def macro_overview():
|
||||||
"""Get key macro indicators: Fed rate, treasury yields, CPI, unemployment, GDP, VIX."""
|
"""Get key macro indicators: Fed rate, treasury yields, CPI, unemployment, GDP, VIX."""
|
||||||
data = await macro_service.get_macro_overview()
|
data = await macro_service.get_macro_overview()
|
||||||
@@ -43,7 +18,7 @@ async def macro_overview():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/macro/series/{series_id}", response_model=ApiResponse)
|
@router.get("/macro/series/{series_id}", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def macro_series(
|
async def macro_series(
|
||||||
series_id: str,
|
series_id: str,
|
||||||
limit: int = Query(default=30, ge=1, le=1000),
|
limit: int = Query(default=30, ge=1, le=1000),
|
||||||
|
|||||||
137
routes_market.py
Normal file
137
routes_market.py
Normal 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
54
routes_quantitative.py
Normal 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)
|
||||||
@@ -1,55 +1,30 @@
|
|||||||
"""Routes for sentiment, insider trades, and analyst data (Finnhub + Alpha Vantage)."""
|
"""Routes for sentiment, insider trades, and analyst data (Finnhub + Alpha Vantage)."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import functools
|
|
||||||
import logging
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import ParamSpec, TypeVar
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Path, Query
|
from fastapi import APIRouter, Path, Query
|
||||||
|
|
||||||
from models import SYMBOL_PATTERN, ApiResponse
|
from models import ApiResponse
|
||||||
|
from route_utils import safe, validate_symbol
|
||||||
import alphavantage_service
|
import alphavantage_service
|
||||||
import finnhub_service
|
import finnhub_service
|
||||||
|
import openbb_service
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
P = ParamSpec("P")
|
|
||||||
R = TypeVar("R")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_symbol(symbol: str) -> str:
|
|
||||||
if not SYMBOL_PATTERN.match(symbol):
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid symbol format")
|
|
||||||
return symbol.upper()
|
|
||||||
|
|
||||||
|
|
||||||
def _safe(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
|
|
||||||
@functools.wraps(fn)
|
|
||||||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
||||||
try:
|
|
||||||
return await fn(*args, **kwargs)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Upstream data error")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail="Data provider error. Check server logs.",
|
|
||||||
)
|
|
||||||
return wrapper # type: ignore[return-value]
|
|
||||||
|
|
||||||
|
|
||||||
# --- Sentiment & News ---
|
# --- Sentiment & News ---
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/sentiment", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/sentiment", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_sentiment(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_sentiment(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get aggregated sentiment: Alpha Vantage news sentiment + Finnhub analyst data."""
|
"""Get aggregated sentiment: Alpha Vantage news sentiment + Finnhub analyst data."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
finnhub_data, av_data = await asyncio.gather(
|
finnhub_data, av_data = await asyncio.gather(
|
||||||
finnhub_service.get_sentiment_summary(symbol),
|
finnhub_service.get_sentiment_summary(symbol),
|
||||||
alphavantage_service.get_news_sentiment(symbol, limit=20),
|
alphavantage_service.get_news_sentiment(symbol, limit=20),
|
||||||
@@ -67,22 +42,22 @@ async def stock_sentiment(symbol: str = Path(..., min_length=1, max_length=20)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/news-sentiment", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/news-sentiment", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_news_sentiment(
|
async def stock_news_sentiment(
|
||||||
symbol: str = Path(..., min_length=1, max_length=20),
|
symbol: str = Path(..., min_length=1, max_length=20),
|
||||||
limit: int = Query(default=30, ge=1, le=200),
|
limit: int = Query(default=30, ge=1, le=200),
|
||||||
):
|
):
|
||||||
"""Get news articles with per-ticker sentiment scores (Alpha Vantage)."""
|
"""Get news articles with per-ticker sentiment scores (Alpha Vantage)."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await alphavantage_service.get_news_sentiment(symbol, limit=limit)
|
data = await alphavantage_service.get_news_sentiment(symbol, limit=limit)
|
||||||
return ApiResponse(data=data)
|
return ApiResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/insider-trades", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/insider-trades", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_insider_trades(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_insider_trades(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get insider transactions (CEO/CFO buys and sells)."""
|
"""Get insider transactions (CEO/CFO buys and sells)."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
raw = await finnhub_service.get_insider_transactions(symbol)
|
raw = await finnhub_service.get_insider_transactions(symbol)
|
||||||
trades = [
|
trades = [
|
||||||
{
|
{
|
||||||
@@ -100,10 +75,10 @@ async def stock_insider_trades(symbol: str = Path(..., min_length=1, max_length=
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/recommendations", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/recommendations", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_recommendations(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_recommendations(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get analyst recommendation trends (monthly buy/hold/sell counts)."""
|
"""Get analyst recommendation trends (monthly buy/hold/sell counts)."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
raw = await finnhub_service.get_recommendation_trends(symbol)
|
raw = await finnhub_service.get_recommendation_trends(symbol)
|
||||||
recs = [
|
recs = [
|
||||||
{
|
{
|
||||||
@@ -120,19 +95,9 @@ async def stock_recommendations(symbol: str = Path(..., min_length=1, max_length
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/upgrades", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/upgrades", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_upgrades(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_upgrades(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get recent analyst upgrades and downgrades."""
|
"""Get recent analyst upgrades and downgrades (via yfinance)."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
raw = await finnhub_service.get_upgrade_downgrade(symbol)
|
data = await openbb_service.get_upgrades_downgrades(symbol)
|
||||||
upgrades = [
|
return ApiResponse(data=data)
|
||||||
{
|
|
||||||
"company": u.get("company"),
|
|
||||||
"action": u.get("action"),
|
|
||||||
"from_grade": u.get("fromGrade"),
|
|
||||||
"to_grade": u.get("toGrade"),
|
|
||||||
"date": u.get("gradeTime"),
|
|
||||||
}
|
|
||||||
for u in raw[:20]
|
|
||||||
]
|
|
||||||
return ApiResponse(data=upgrades)
|
|
||||||
|
|||||||
@@ -1,49 +1,18 @@
|
|||||||
"""Routes for technical analysis indicators."""
|
"""Routes for technical analysis indicators."""
|
||||||
|
|
||||||
import functools
|
from fastapi import APIRouter, Path
|
||||||
import logging
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import ParamSpec, TypeVar
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Path
|
from models import ApiResponse
|
||||||
|
from route_utils import safe, validate_symbol
|
||||||
from models import SYMBOL_PATTERN, ApiResponse
|
|
||||||
import technical_service
|
import technical_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
P = ParamSpec("P")
|
|
||||||
R = TypeVar("R")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_symbol(symbol: str) -> str:
|
|
||||||
if not SYMBOL_PATTERN.match(symbol):
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid symbol format")
|
|
||||||
return symbol.upper()
|
|
||||||
|
|
||||||
|
|
||||||
def _safe(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
|
|
||||||
@functools.wraps(fn)
|
|
||||||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
||||||
try:
|
|
||||||
return await fn(*args, **kwargs)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Upstream data error")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail="Data provider error. Check server logs.",
|
|
||||||
)
|
|
||||||
return wrapper # type: ignore[return-value]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/technical", response_model=ApiResponse)
|
@router.get("/stock/{symbol}/technical", response_model=ApiResponse)
|
||||||
@_safe
|
@safe
|
||||||
async def stock_technical(symbol: str = Path(..., min_length=1, max_length=20)):
|
async def stock_technical(symbol: str = Path(..., min_length=1, max_length=20)):
|
||||||
"""Get technical indicators: RSI, MACD, SMA, EMA, Bollinger Bands + signal interpretation."""
|
"""Get technical indicators: RSI, MACD, SMA, EMA, Bollinger Bands + signal interpretation."""
|
||||||
symbol = _validate_symbol(symbol)
|
symbol = validate_symbol(symbol)
|
||||||
data = await technical_service.get_technical_indicators(symbol)
|
data = await technical_service.get_technical_indicators(symbol)
|
||||||
return ApiResponse(data=data)
|
return ApiResponse(data=data)
|
||||||
|
|||||||
Reference in New Issue
Block a user