Compare commits

...

32 Commits

Author SHA1 Message Date
Yaojia Wang
ac101c663a fix: switch price target from FMP to yfinance
All checks were successful
continuous-integration/drone/push Build is passing
FMP provider requires a paid API key. yfinance provides
targetMeanPrice in ticker.info for free.
2026-03-19 15:58:17 +01:00
Yaojia Wang
f5b22deec3 fix: resolve curl_cffi TLS errors and fix FRED/upgrades endpoints
All checks were successful
continuous-integration/drone/push Build is passing
- Pin curl_cffi==0.7.4 to avoid BoringSSL bug in 0.12-0.14
- Patch curl_cffi Session to use safari TLS fingerprint instead of
  chrome, which triggers SSL_ERROR_SYSCALL on some networks
- Register FRED API key with OpenBB credentials at startup
- Fix macro overview to return latest data instead of oldest, and
  extract values by FRED series ID key
- Replace Finnhub upgrades endpoint (premium-only) with yfinance
  upgrades_downgrades which includes price target changes
- Remove redundant curl_cffi upgrade from Dockerfile
2026-03-19 15:40:41 +01:00
Yaojia Wang
b631c888a5 fix: keep libssl3 runtime dependency to prevent TLS errors in container
All checks were successful
continuous-integration/drone/push Build is passing
autoremove was removing SSL runtime libraries after purging gcc/g++,
causing curl_cffi TLS handshake failures when connecting to Yahoo Finance.
Explicitly install libssl3 as runtime dep and only purge libssl-dev.
2026-03-19 13:25:58 +01:00
Yaojia Wang
760b0a09ea Trigger build
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-19 11:29:20 +01:00
Yaojia Wang
16ad276146 fix: add list and update verbs to drone RBAC for rollout restart
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-19 11:10:11 +01:00
Yaojia Wang
e5820ebe4a fix: add list and update verbs to drone RBAC for rollout restart
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-19 11:00:32 +01:00
Yaojia Wang
cd6158b05c fix: add list and update verbs to drone RBAC for rollout restart
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-19 10:56:52 +01:00
Yaojia Wang
2446a2fde8 fix: add list and update verbs to drone RBAC for rollout restart
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-19 10:44:35 +01:00
Yaojia Wang
d3c919385f fix: add libnss3 and upgrade curl_cffi to fix TLS/SSL errors
Some checks failed
continuous-integration/drone/push Build is failing
curl_cffi uses BoringSSL internally and needs libnss3 on Debian.
Also upgrade curl_cffi to latest version for compatibility.
2026-03-19 00:38:45 +01:00
Yaojia Wang
e797f8929d fix: use fmp provider for price_target endpoint
Some checks reported errors
continuous-integration/drone/push Build was killed
yfinance is not supported by obb.equity.estimates.price_target,
which only accepts benzinga or fmp.
2026-03-19 00:13:07 +01:00
Yaojia Wang
d46e8685d7 ci: add RBAC for drone to restart invest-api deployment 2026-03-18 23:50:11 +01:00
Yaojia Wang
26cd716590 ci: simplify deploy by using kubectl rollout restart after build
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-18 23:34:54 +01:00
Yaojia Wang
42f25426ac fix: correct drone yaml environment syntax 2026-03-18 23:33:17 +01:00
Yaojia Wang
d4e06c71b7 ci: auto-update image tag in kustomization for ArgoCD sync
Add Drone CI step to update kustomization.yaml newTag with commit SHA
after image build, enabling ArgoCD to detect manifest changes and
auto-deploy new images.
2026-03-18 23:32:17 +01:00
Yaojia Wang
eac9fe963e Trigger build
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-18 21:22:59 +01:00
Yaojia Wang
f4e98653d4 fix: add ca-certificates and libssl-dev to fix OpenSSL TLS errors
yfinance curl calls failed with OPENSSL_internal:invalid library in
python:3.12-slim. Adding ca-certificates, libssl-dev and curl ensures
the OpenSSL libraries are available at runtime.
2026-03-18 15:28:11 +01:00
Yaojia Wang
dd17c4f2ae chore: add API keys to k8s secret
All checks were successful
continuous-integration/drone/push Build is passing
Add Finnhub, FRED, and Alpha Vantage API keys to the
Kubernetes secret manifest for deployment.
2026-03-10 17:23:04 +01:00
Yaojia Wang
82ac3deee4 docs: add Kubernetes deployment section to README
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-10 01:30:43 +01:00
Yaojia Wang
f9f5d37b44 feat: add ingress for invest-api.k8s.home
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-10 01:28:24 +01:00
Yaojia Wang
1a0c8ea675 fix: grant write permission to openbb package dir for build lock
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-10 00:16:12 +01:00
Yaojia Wang
8c682dc5dd fix: create home directory for OpenBB in Docker container
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-10 00:10:54 +01:00
Yaojia Wang
e807af6395 chore: trigger drone build
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-10 00:06:20 +01:00
Yaojia Wang
4565edf432 chore: trigger drone build
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2026-03-10 00:05:14 +01:00
Yaojia Wang
79d9d5a012 chore: trigger drone build 2026-03-09 23:56:47 +01:00
Yaojia Wang
64fb5fc43e chore: test drone webhook 2026-03-09 23:55:45 +01:00
Yaojia Wang
faec132bbe chore: trigger drone build 2026-03-09 23:48:14 +01:00
Yaojia Wang
e7c747d297 fix: add custom event trigger for Drone manual builds 2026-03-09 23:37:16 +01:00
Yaojia Wang
ec76e92f1e fix: switch Drone pipeline from docker to kaniko for k8s runner 2026-03-09 23:30:58 +01:00
Yaojia Wang
d05cb55cb0 feat: add Docker, Drone CI, and k8s deployment manifests
- Dockerfile for Python 3.12 FastAPI app
- Drone CI pipeline to build and push to internal registry
- Kubernetes manifests (Deployment, Service, Secret, Namespace)
- ArgoCD Application for GitOps deployment
- Kustomize base configuration
2026-03-09 23:28:31 +01:00
Yaojia Wang
e3e9c1986c docs: add route_utils and obb_utils to project structure in README 2026-03-09 14:50:54 +01:00
Yaojia Wang
003c1d6ffc refactor: fix code review issues across routes and services
- Extract shared route_utils.py (validate_symbol, safe decorator)
  removing duplication from 6 route files
- Extract shared obb_utils.py (to_list, extract_single, safe_last)
  removing duplication from calendar_service and market_service
- Fix _to_list dict mutation during iteration (use comprehension)
- Fix double vars() call and live __dict__ mutation risk
- Fix route ordering: /etf/search and /crypto/search now registered
  before /{symbol} path params to prevent shadowing
- Add date format validation (YYYY-MM-DD pattern) on calendar routes
- Use timezone-aware datetime.now(tz=timezone.utc) in all services
- Add explicit type annotation for asyncio.gather results
2026-03-09 10:56:21 +01:00
Yaojia Wang
507194397e feat: integrate quantitative, calendar, market data endpoints
Add 3 new service layers and route modules:
- quantitative_service: Sharpe ratio, CAPM, normality tests, unit root tests
- calendar_service: earnings/dividends/IPO/splits calendars, estimates, SEC ownership
- market_service: ETF, index, crypto, forex, options, futures data

Total endpoints: 50. All use free providers (yfinance, SEC).
Update README with comprehensive endpoint documentation.
2026-03-09 10:28:33 +01:00
29 changed files with 1416 additions and 209 deletions

10
.dockerignore Normal file
View File

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

29
.drone.yml Normal file
View 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
View 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
View File

@@ -1,6 +1,6 @@
# OpenBB Investment Analysis API
REST API wrapping OpenBB SDK, providing stock data query, sentiment analysis, technical indicators, macro data, and rule-based investment analysis for US and Swedish markets. Designed to be called by OpenClaw (or any AI assistant) the API returns structured data, all LLM reasoning happens on the caller side.
REST API wrapping OpenBB SDK, providing stock data, sentiment analysis, technical indicators, quantitative risk metrics, macro data, market data (ETF/index/crypto/forex/options), and rule-based investment analysis for US and Swedish markets. Designed to be called by OpenClaw (or any AI assistant) -- the API returns structured data, all LLM reasoning happens on the caller side.
## API Keys
@@ -42,6 +42,7 @@ export INVEST_API_ALPHAVANTAGE_API_KEY=your_alphavantage_key
```bash
conda env create -f environment.yml
conda activate openbb-invest-api
pip install openbb-quantitative openbb-econometrics
```
### 2. Start the server
@@ -64,15 +65,28 @@ curl http://localhost:8000/api/v1/stock/AAPL/quote
# Swedish stock quote
curl http://localhost:8000/api/v1/stock/VOLV-B.ST/quote
# Sentiment analysis (requires Finnhub + Alpha Vantage keys)
# Sentiment analysis (Finnhub + Alpha Vantage)
curl http://localhost:8000/api/v1/stock/AAPL/sentiment
# News sentiment with per-article scores (requires Alpha Vantage key)
# News sentiment with per-article scores (Alpha Vantage)
curl http://localhost:8000/api/v1/stock/AAPL/news-sentiment
# Technical indicators
curl http://localhost:8000/api/v1/stock/AAPL/technical
# Quantitative risk metrics
curl http://localhost:8000/api/v1/stock/AAPL/performance
curl http://localhost:8000/api/v1/stock/AAPL/capm
# SEC insider trading
curl http://localhost:8000/api/v1/stock/AAPL/sec-insider
# ETF info
curl http://localhost:8000/api/v1/etf/SPY/info
# Crypto price history
curl http://localhost:8000/api/v1/crypto/BTC-USD/historical?days=30
# Macro overview (requires FRED key)
curl http://localhost:8000/api/v1/macro/overview
@@ -108,7 +122,7 @@ curl -X POST http://localhost:8000/api/v1/portfolio/analyze \
|--------|------|-------------|
| GET | `/api/v1/stock/{symbol}/sentiment` | Aggregated: news sentiment + recommendations + upgrades |
| GET | `/api/v1/stock/{symbol}/news-sentiment?limit=30` | News articles with per-ticker sentiment scores (Alpha Vantage) |
| GET | `/api/v1/stock/{symbol}/insider-trades` | Insider transactions (CEO/CFO buys and sells) |
| GET | `/api/v1/stock/{symbol}/insider-trades` | Insider transactions via Finnhub |
| GET | `/api/v1/stock/{symbol}/recommendations` | Monthly analyst buy/hold/sell counts |
| GET | `/api/v1/stock/{symbol}/upgrades` | Recent analyst upgrades and downgrades |
@@ -118,6 +132,70 @@ curl -X POST http://localhost:8000/api/v1/portfolio/analyze \
|--------|------|-------------|
| GET | `/api/v1/stock/{symbol}/technical` | RSI, MACD, SMA, EMA, Bollinger Bands + signal interpretation |
### Quantitative Analysis (openbb-quantitative, no key needed)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/stock/{symbol}/performance?days=365` | Sharpe ratio, summary statistics, volatility |
| GET | `/api/v1/stock/{symbol}/capm` | CAPM: market risk, systematic risk, idiosyncratic risk |
| GET | `/api/v1/stock/{symbol}/normality?days=365` | Normality tests: Jarque-Bera, Shapiro-Wilk, Kolmogorov-Smirnov |
| GET | `/api/v1/stock/{symbol}/unitroot?days=365` | Unit root tests: ADF, KPSS for stationarity |
### Calendar Events (no key needed)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/calendar/earnings?start_date=&end_date=` | Upcoming earnings announcements |
| GET | `/api/v1/calendar/dividends?start_date=&end_date=` | Upcoming dividend dates |
| GET | `/api/v1/calendar/ipo?start_date=&end_date=` | Upcoming IPOs |
| GET | `/api/v1/calendar/splits?start_date=&end_date=` | Upcoming stock splits |
### Estimates & Ownership (yfinance + SEC, no key needed)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/stock/{symbol}/estimates` | Analyst consensus estimates |
| GET | `/api/v1/stock/{symbol}/share-statistics` | Float, shares outstanding, short interest |
| GET | `/api/v1/stock/{symbol}/sec-insider` | Insider trading from SEC (Form 4) |
| GET | `/api/v1/stock/{symbol}/institutional` | Institutional holders from SEC 13F filings |
| GET | `/api/v1/screener` | Stock screener |
### ETF Data (yfinance, no key needed)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/etf/{symbol}/info` | ETF profile, issuer, holdings |
| GET | `/api/v1/etf/{symbol}/historical?days=365` | ETF price history |
| GET | `/api/v1/etf/search?query=` | Search ETFs by name |
### Index Data (yfinance, no key needed)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/index/available` | List available indices |
| GET | `/api/v1/index/{symbol}/historical?days=365` | Index price history (^GSPC, ^DJI, ^IXIC) |
### Crypto Data (yfinance, no key needed)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/crypto/{symbol}/historical?days=365` | Crypto price history (BTC-USD, ETH-USD) |
| GET | `/api/v1/crypto/search?query=` | Search cryptocurrencies |
### Currency / Forex (yfinance, no key needed)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/currency/{symbol}/historical?days=365` | Forex price history (EURUSD, USDSEK) |
### Derivatives (yfinance, no key needed)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/options/{symbol}/chains` | Options chain data |
| GET | `/api/v1/futures/{symbol}/historical?days=365` | Futures price history |
| GET | `/api/v1/futures/{symbol}/curve` | Futures term structure/curve |
### Macro Economics (FRED, free key)
| Method | Path | Description |
@@ -189,29 +267,27 @@ openbb-invest-api/
├── config.py # Settings (env-based)
├── models.py # Pydantic request/response models
├── mappers.py # Dict-to-model mapping functions
├── route_utils.py # Shared route utilities (validation, error handling)
├── obb_utils.py # Shared OpenBB result conversion utilities
├── openbb_service.py # OpenBB SDK wrapper (async)
├── finnhub_service.py # Finnhub REST client (insider, analyst data)
├── alphavantage_service.py # Alpha Vantage REST client (news sentiment)
├── quantitative_service.py # Risk metrics, CAPM, normality tests
├── calendar_service.py # Calendar events, screening, ownership
├── market_service.py # ETF, index, crypto, currency, derivatives
├── macro_service.py # FRED macro data via OpenBB
├── technical_service.py # Technical indicators via openbb-technical
├── analysis_service.py # Rule engine for portfolio analysis
├── routes.py # Core stock data + portfolio + discovery routes
├── routes_sentiment.py # Sentiment & analyst routes (Finnhub + Alpha Vantage)
├── routes_quantitative.py # Quantitative analysis routes
├── routes_calendar.py # Calendar, estimates, ownership routes
├── routes_market.py # ETF, index, crypto, currency, derivatives routes
├── routes_macro.py # Macro economics routes (FRED)
├── routes_technical.py # Technical analysis routes
├── environment.yml # Conda environment
├── pyproject.toml # Project metadata
└── tests/ # 102 tests
├── test_models.py
├── test_mappers.py
├── test_openbb_service.py
├── test_finnhub_service.py
├── test_analysis_service.py
├── test_routes.py
├── test_routes_sentiment.py
├── test_alphavantage_service.py
├── test_routes_macro.py
└── test_routes_technical.py
```
## Running Tests
@@ -239,16 +315,126 @@ Example OpenClaw workflow:
2. OpenClaw calls `GET /api/v1/stock/AAPL/summary` for fundamental data
3. OpenClaw calls `GET /api/v1/stock/AAPL/sentiment` for news/analyst sentiment
4. OpenClaw calls `GET /api/v1/stock/AAPL/technical` for technical signals
5. OpenClaw calls `GET /api/v1/macro/overview` for market context
6. OpenClaw calls `POST /api/v1/portfolio/analyze` with user's holdings
7. OpenClaw's LLM synthesizes all structured data into a personalized recommendation
5. OpenClaw calls `GET /api/v1/stock/AAPL/performance` for risk metrics (Sharpe, volatility)
6. OpenClaw calls `GET /api/v1/stock/AAPL/sec-insider` for insider trading activity
7. OpenClaw calls `GET /api/v1/macro/overview` for market context
8. OpenClaw calls `POST /api/v1/portfolio/analyze` with user's holdings
9. OpenClaw's LLM synthesizes all structured data into a personalized recommendation
## 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
| Source | Cost | Key Required | Data Provided |
|--------|------|-------------|---------------|
| **yfinance** | Free | No | Quotes, fundamentals, financials, historical prices, news, discovery |
| **yfinance** | Free | No | Quotes, fundamentals, financials, historical prices, news, discovery, ETF, index, crypto, forex, options, futures |
| **SEC** | Free | No | Insider trading (Form 4), institutional holdings (13F), company filings |
| **Finnhub** | Free | Yes (free registration) | Insider trades, analyst recommendations, upgrades/downgrades |
| **Alpha Vantage** | Free | Yes (free registration) | News sentiment scores (bullish/bearish per ticker per article), 25 req/day |
| **FRED** | Free | Yes (free registration) | Fed rate, treasury yields, CPI, unemployment, GDP, VIX, 800K+ economic series |
| **openbb-technical** | Free | No (local computation) | RSI, MACD, SMA, EMA, Bollinger Bands |
| **openbb-quantitative** | Free | No (local computation) | Sharpe ratio, CAPM, normality tests, unit root tests, summary statistics |

141
calendar_service.py Normal file
View File

@@ -0,0 +1,141 @@
"""Calendar events (earnings, dividends, IPOs, splits), screening, and ownership."""
import asyncio
import logging
from typing import Any
from openbb import obb
from obb_utils import to_list
logger = logging.getLogger(__name__)
async def get_earnings_calendar(
start_date: str | None = None, end_date: str | None = None
) -> list[dict[str, Any]]:
"""Get upcoming earnings announcements."""
try:
kwargs: dict[str, Any] = {}
if start_date:
kwargs["start_date"] = start_date
if end_date:
kwargs["end_date"] = end_date
result = await asyncio.to_thread(obb.equity.calendar.earnings, **kwargs)
return to_list(result)
except Exception:
logger.warning("Earnings calendar failed", exc_info=True)
return []
async def get_dividend_calendar(
start_date: str | None = None, end_date: str | None = None
) -> list[dict[str, Any]]:
"""Get upcoming dividend dates."""
try:
kwargs: dict[str, Any] = {}
if start_date:
kwargs["start_date"] = start_date
if end_date:
kwargs["end_date"] = end_date
result = await asyncio.to_thread(obb.equity.calendar.dividend, **kwargs)
return to_list(result)
except Exception:
logger.warning("Dividend calendar failed", exc_info=True)
return []
async def get_ipo_calendar(
start_date: str | None = None, end_date: str | None = None
) -> list[dict[str, Any]]:
"""Get upcoming IPO dates."""
try:
kwargs: dict[str, Any] = {}
if start_date:
kwargs["start_date"] = start_date
if end_date:
kwargs["end_date"] = end_date
result = await asyncio.to_thread(obb.equity.calendar.ipo, **kwargs)
return to_list(result)
except Exception:
logger.warning("IPO calendar failed", exc_info=True)
return []
async def get_splits_calendar(
start_date: str | None = None, end_date: str | None = None
) -> list[dict[str, Any]]:
"""Get upcoming stock split dates."""
try:
kwargs: dict[str, Any] = {}
if start_date:
kwargs["start_date"] = start_date
if end_date:
kwargs["end_date"] = end_date
result = await asyncio.to_thread(obb.equity.calendar.splits, **kwargs)
return to_list(result)
except Exception:
logger.warning("Splits calendar failed", exc_info=True)
return []
async def get_analyst_estimates(symbol: str) -> dict[str, Any]:
"""Get analyst consensus estimates for a symbol."""
try:
result = await asyncio.to_thread(
obb.equity.estimates.consensus, symbol, provider="yfinance"
)
items = to_list(result)
return {"symbol": symbol, "estimates": items}
except Exception:
logger.warning("Analyst estimates failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "estimates": []}
async def get_share_statistics(symbol: str) -> dict[str, Any]:
"""Get share statistics (float, shares outstanding, etc.)."""
try:
result = await asyncio.to_thread(
obb.equity.ownership.share_statistics, symbol, provider="yfinance"
)
items = to_list(result)
return items[0] if items else {}
except Exception:
logger.warning("Share statistics failed for %s", symbol, exc_info=True)
return {}
async def get_insider_trading(symbol: str) -> list[dict[str, Any]]:
"""Get insider trading data from SEC (free)."""
try:
result = await asyncio.to_thread(
obb.equity.ownership.insider_trading, symbol, provider="sec"
)
return to_list(result)
except Exception:
logger.warning("SEC insider trading failed for %s", symbol, exc_info=True)
return []
async def get_institutional_holders(symbol: str) -> list[dict[str, Any]]:
"""Get institutional holders from SEC 13F filings."""
try:
result = await asyncio.to_thread(
obb.equity.ownership.form_13f, symbol, provider="sec"
)
return to_list(result)
except Exception:
logger.warning("13F data failed for %s", symbol, exc_info=True)
return []
async def screen_stocks() -> list[dict[str, Any]]:
"""Screen stocks using available screener."""
try:
result = await asyncio.to_thread(
obb.equity.screener, provider="yfinance"
)
return to_list(result)
except Exception:
logger.warning("Stock screener failed", exc_info=True)
return []

View File

@@ -18,3 +18,4 @@ class Settings(BaseSettings):
settings = Settings()

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

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

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

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

29
k8s/base/drone-rbac.yaml Normal file
View 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
View 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

View 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
View File

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

10
k8s/base/secret.yaml Normal file
View 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
View File

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

View File

@@ -36,19 +36,31 @@ def _to_dicts(result: Any) -> list[dict[str, Any]]:
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."""
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(
obb.economy.fred_series,
symbol=series_id,
limit=limit,
provider=PROVIDER,
**kwargs,
)
items = _to_dicts(result)
for item in items:
if "date" in item and not isinstance(item["date"], str):
item = {**item, "date": str(item["date"])}
items = [
{**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
except Exception:
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]:
"""Get a summary of key macro indicators."""
tasks = {
name: get_series(series_id, limit=1)
name: get_series(series_id, limit=1, latest=True)
for name, series_id in SERIES.items()
}
results = await asyncio.gather(*tasks.values(), return_exceptions=True)
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):
logger.warning("Failed to fetch %s: %s", name, result)
overview[name] = None
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] = {
"value": entry.get("value"),
"value": value,
"date": str(entry.get("date", "")),
}
else:

26
main.py
View File

@@ -4,11 +4,34 @@ import uvicorn
from fastapi import FastAPI
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
# 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_sentiment import router as sentiment_router
from routes_macro import router as macro_router
from routes_technical import router as technical_router
from routes_quantitative import router as quantitative_router
from routes_calendar import router as calendar_router
from routes_market import router as market_router
logging.basicConfig(
level=settings.log_level.upper(),
@@ -33,6 +56,9 @@ app.include_router(router)
app.include_router(sentiment_router)
app.include_router(macro_router)
app.include_router(technical_router)
app.include_router(quantitative_router)
app.include_router(calendar_router)
app.include_router(market_router)
@app.get("/health", response_model=dict[str, str])

163
market_service.py Normal file
View File

@@ -0,0 +1,163 @@
"""Market data: ETFs, indices, crypto, currencies, and derivatives."""
import asyncio
import logging
from datetime import datetime, timezone, timedelta
from typing import Any
from openbb import obb
from obb_utils import to_list
logger = logging.getLogger(__name__)
PROVIDER = "yfinance"
# --- ETF ---
async def get_etf_info(symbol: str) -> dict[str, Any]:
"""Get ETF profile/info."""
try:
result = await asyncio.to_thread(obb.etf.info, symbol, provider=PROVIDER)
items = to_list(result)
return items[0] if items else {}
except Exception:
logger.warning("ETF info failed for %s", symbol, exc_info=True)
return {}
async def get_etf_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]:
"""Get ETF price history."""
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
try:
result = await asyncio.to_thread(
obb.etf.historical, symbol, start_date=start, provider=PROVIDER
)
return to_list(result)
except Exception:
logger.warning("ETF historical failed for %s", symbol, exc_info=True)
return []
async def search_etf(query: str) -> list[dict[str, Any]]:
"""Search for ETFs by name or keyword."""
try:
result = await asyncio.to_thread(obb.etf.search, query)
return to_list(result)
except Exception:
logger.warning("ETF search failed for %s", query, exc_info=True)
return []
# --- Index ---
async def get_available_indices() -> list[dict[str, Any]]:
"""List available market indices."""
try:
result = await asyncio.to_thread(obb.index.available, provider=PROVIDER)
return to_list(result)
except Exception:
logger.warning("Available indices failed", exc_info=True)
return []
async def get_index_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]:
"""Get index price history."""
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
try:
result = await asyncio.to_thread(
obb.index.price.historical, symbol, start_date=start, provider=PROVIDER
)
return to_list(result)
except Exception:
logger.warning("Index historical failed for %s", symbol, exc_info=True)
return []
# --- Crypto ---
async def get_crypto_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]:
"""Get cryptocurrency price history."""
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
try:
result = await asyncio.to_thread(
obb.crypto.price.historical, symbol, start_date=start, provider=PROVIDER
)
return to_list(result)
except Exception:
logger.warning("Crypto historical failed for %s", symbol, exc_info=True)
return []
async def search_crypto(query: str) -> list[dict[str, Any]]:
"""Search for cryptocurrencies."""
try:
result = await asyncio.to_thread(obb.crypto.search, query)
return to_list(result)
except Exception:
logger.warning("Crypto search failed for %s", query, exc_info=True)
return []
# --- Currency ---
async def get_currency_historical(
symbol: str, days: int = 365
) -> list[dict[str, Any]]:
"""Get forex price history (e.g., EURUSD)."""
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
try:
result = await asyncio.to_thread(
obb.currency.price.historical, symbol, start_date=start, provider=PROVIDER
)
return to_list(result)
except Exception:
logger.warning("Currency historical failed for %s", symbol, exc_info=True)
return []
# --- Derivatives ---
async def get_options_chains(symbol: str) -> list[dict[str, Any]]:
"""Get options chain data for a symbol."""
try:
result = await asyncio.to_thread(
obb.derivatives.options.chains, symbol, provider=PROVIDER
)
return to_list(result)
except Exception:
logger.warning("Options chains failed for %s", symbol, exc_info=True)
return []
async def get_futures_historical(
symbol: str, days: int = 365
) -> list[dict[str, Any]]:
"""Get futures price history."""
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
try:
result = await asyncio.to_thread(
obb.derivatives.futures.historical, symbol, start_date=start, provider=PROVIDER
)
return to_list(result)
except Exception:
logger.warning("Futures historical failed for %s", symbol, exc_info=True)
return []
async def get_futures_curve(symbol: str) -> list[dict[str, Any]]:
"""Get futures term structure/curve."""
try:
result = await asyncio.to_thread(
obb.derivatives.futures.curve, symbol, provider=PROVIDER
)
return to_list(result)
except Exception:
logger.warning("Futures curve failed for %s", symbol, exc_info=True)
return []

51
obb_utils.py Normal file
View File

@@ -0,0 +1,51 @@
"""Shared OpenBB result conversion utilities."""
from typing import Any
def to_list(result: Any) -> list[dict[str, Any]]:
"""Convert OBBject result to list of dicts with serialized dates."""
if result is None or result.results is None:
return []
items = result.results
if not isinstance(items, list):
items = [items]
out = []
for item in items:
if hasattr(item, "model_dump"):
d = item.model_dump()
else:
raw = vars(item)
d = dict(raw) if raw else {}
d = {
k: v.isoformat() if hasattr(v, "isoformat") else v
for k, v in d.items()
}
out.append(d)
return out
def extract_single(result: Any) -> dict[str, Any]:
"""Extract data from an OBBject result (single model or list)."""
if result is None:
return {}
items = getattr(result, "results", None)
if items is None:
return {}
if hasattr(items, "model_dump"):
return items.model_dump()
if isinstance(items, list) and items:
last = items[-1]
return last.model_dump() if hasattr(last, "model_dump") else {}
return {}
def safe_last(result: Any) -> dict[str, Any] | None:
"""Get the last item from a list result, or None."""
if result is None:
return None
items = getattr(result, "results", None)
if items is None or not isinstance(items, list) or not items:
return None
last = items[-1]
return last.model_dump() if hasattr(last, "model_dump") else None

View File

@@ -104,13 +104,15 @@ async def get_financials(symbol: str) -> dict:
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:
result = await asyncio.to_thread(
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")
return await asyncio.to_thread(_fetch)
except Exception:
logger.warning("Failed to get price target for %s", symbol, exc_info=True)
return None
@@ -171,3 +173,30 @@ async def get_growth() -> list[dict]:
obb.equity.discovery.growth_tech, provider=PROVIDER
)
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)

View File

@@ -9,6 +9,7 @@ dependencies = [
"openbb[yfinance]",
"pydantic-settings",
"httpx",
"curl_cffi==0.7.4",
]
[project.optional-dependencies]

124
quantitative_service.py Normal file
View File

@@ -0,0 +1,124 @@
"""Quantitative analysis: risk metrics, performance, CAPM, normality tests."""
import asyncio
import logging
from datetime import datetime, timezone, timedelta
from typing import Any
from openbb import obb
from obb_utils import extract_single, safe_last
logger = logging.getLogger(__name__)
PROVIDER = "yfinance"
# Need 252+ trading days for default window; 730 calendar days is safe
PERF_DAYS = 730
TARGET = "close"
async def get_performance_metrics(symbol: str, days: int = 365) -> dict[str, Any]:
"""Calculate Sharpe ratio, summary stats, and volatility for a symbol."""
# Need at least 252 trading days for Sharpe window
fetch_days = max(days, PERF_DAYS)
start = (datetime.now(tz=timezone.utc) - timedelta(days=fetch_days)).strftime("%Y-%m-%d")
try:
hist = await asyncio.to_thread(
obb.equity.price.historical, symbol, start_date=start, provider=PROVIDER
)
if not hist or not hist.results:
return {"symbol": symbol, "error": "No historical data"}
results: tuple[Any, ...] = await asyncio.gather(
asyncio.to_thread(
obb.quantitative.performance.sharpe_ratio,
data=hist.results, target=TARGET,
),
asyncio.to_thread(
obb.quantitative.summary, data=hist.results, target=TARGET
),
asyncio.to_thread(
obb.quantitative.stats.stdev, data=hist.results, target=TARGET
),
return_exceptions=True,
)
sharpe_result, summary_result, stdev_result = results
sharpe = safe_last(sharpe_result) if not isinstance(sharpe_result, BaseException) else None
summary = extract_single(summary_result) if not isinstance(summary_result, BaseException) else {}
stdev = safe_last(stdev_result) if not isinstance(stdev_result, BaseException) else None
return {
"symbol": symbol,
"period_days": days,
"sharpe_ratio": sharpe,
"summary": summary,
"stdev": stdev,
}
except Exception:
logger.warning("Performance metrics failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute performance metrics"}
async def get_capm(symbol: str) -> dict[str, Any]:
"""Calculate CAPM metrics: beta, alpha, systematic/idiosyncratic risk."""
start = (datetime.now(tz=timezone.utc) - timedelta(days=PERF_DAYS)).strftime("%Y-%m-%d")
try:
hist = await asyncio.to_thread(
obb.equity.price.historical, symbol, start_date=start, provider=PROVIDER
)
if not hist or not hist.results:
return {"symbol": symbol, "error": "No historical data"}
capm = await asyncio.to_thread(
obb.quantitative.capm, data=hist.results, target=TARGET
)
return {"symbol": symbol, **extract_single(capm)}
except Exception:
logger.warning("CAPM failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute CAPM"}
async def get_normality_test(symbol: str, days: int = 365) -> dict[str, Any]:
"""Run normality tests (Jarque-Bera, Shapiro-Wilk, etc.) on returns."""
fetch_days = max(days, PERF_DAYS)
start = (datetime.now(tz=timezone.utc) - timedelta(days=fetch_days)).strftime("%Y-%m-%d")
try:
hist = await asyncio.to_thread(
obb.equity.price.historical, symbol, start_date=start, provider=PROVIDER
)
if not hist or not hist.results:
return {"symbol": symbol, "error": "No historical data"}
norm = await asyncio.to_thread(
obb.quantitative.normality, data=hist.results, target=TARGET
)
return {"symbol": symbol, **extract_single(norm)}
except Exception:
logger.warning("Normality test failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute normality tests"}
async def get_unitroot_test(symbol: str, days: int = 365) -> dict[str, Any]:
"""Run unit root tests (ADF, KPSS) for stationarity."""
fetch_days = max(days, PERF_DAYS)
start = (datetime.now(tz=timezone.utc) - timedelta(days=fetch_days)).strftime("%Y-%m-%d")
try:
hist = await asyncio.to_thread(
obb.equity.price.historical, symbol, start_date=start, provider=PROVIDER
)
if not hist or not hist.results:
return {"symbol": symbol, "error": "No historical data"}
ur = await asyncio.to_thread(
obb.quantitative.unitroot_test, data=hist.results, target=TARGET
)
return {"symbol": symbol, **extract_single(ur)}
except Exception:
logger.warning("Unit root test failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute unit root test"}

39
route_utils.py Normal file
View File

@@ -0,0 +1,39 @@
"""Shared route utilities: symbol validation and error handling decorator."""
import functools
import logging
from collections.abc import Awaitable, Callable
from typing import ParamSpec, TypeVar
from fastapi import HTTPException
from models import SYMBOL_PATTERN
logger = logging.getLogger(__name__)
P = ParamSpec("P")
R = TypeVar("R")
def validate_symbol(symbol: str) -> str:
"""Validate and normalize a stock symbol."""
if not SYMBOL_PATTERN.match(symbol):
raise HTTPException(status_code=400, detail="Invalid symbol format")
return symbol.upper()
def safe(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
"""Decorator to catch upstream errors and return 502."""
@functools.wraps(fn)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
try:
return await fn(*args, **kwargs)
except HTTPException:
raise
except Exception:
logger.exception("Upstream data error")
raise HTTPException(
status_code=502,
detail="Data provider error. Check server logs.",
)
return wrapper # type: ignore[return-value]

View File

@@ -1,9 +1,4 @@
import functools
import logging
from collections.abc import Awaitable, Callable
from typing import ParamSpec, TypeVar
from fastapi import APIRouter, HTTPException, Path, Query
from fastapi import APIRouter, Path, Query
from mappers import (
discover_items_from_list,
@@ -12,7 +7,6 @@ from mappers import (
quote_from_dict,
)
from models import (
SYMBOL_PATTERN,
ApiResponse,
FinancialsResponse,
HistoricalBar,
@@ -21,87 +15,60 @@ from models import (
PortfolioResponse,
SummaryResponse,
)
from route_utils import safe, validate_symbol
import openbb_service
import analysis_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1")
P = ParamSpec("P")
R = TypeVar("R")
def _validate_symbol(symbol: str) -> str:
if not SYMBOL_PATTERN.match(symbol):
raise HTTPException(status_code=400, detail="Invalid symbol format")
return symbol.upper()
def _safe(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
"""Decorator to catch OpenBB errors and return 502."""
@functools.wraps(fn)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
try:
return await fn(*args, **kwargs)
except HTTPException:
raise
except Exception:
logger.exception("Upstream data error")
raise HTTPException(
status_code=502,
detail="Data provider error. Check server logs.",
)
return wrapper # type: ignore[return-value]
# --- Stock Data ---
@router.get("/stock/{symbol}/quote", response_model=ApiResponse)
@_safe
@safe
async def stock_quote(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get current quote for a stock."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await openbb_service.get_quote(symbol)
return ApiResponse(data=quote_from_dict(symbol, data).model_dump())
@router.get("/stock/{symbol}/profile", response_model=ApiResponse)
@_safe
@safe
async def stock_profile(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get company profile."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await openbb_service.get_profile(symbol)
return ApiResponse(data=profile_from_dict(symbol, data).model_dump())
@router.get("/stock/{symbol}/metrics", response_model=ApiResponse)
@_safe
@safe
async def stock_metrics(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get key financial metrics (PE, PB, ROE, etc.)."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await openbb_service.get_metrics(symbol)
return ApiResponse(data=metrics_from_dict(symbol, data).model_dump())
@router.get("/stock/{symbol}/financials", response_model=ApiResponse)
@_safe
@safe
async def stock_financials(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get income statement, balance sheet, and cash flow."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await openbb_service.get_financials(symbol)
return ApiResponse(data=FinancialsResponse(**data).model_dump())
@router.get("/stock/{symbol}/historical", response_model=ApiResponse)
@_safe
@safe
async def stock_historical(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=1, le=3650),
):
"""Get historical price data."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await openbb_service.get_historical(symbol, days=days)
bars = [
HistoricalBar(
@@ -118,10 +85,10 @@ async def stock_historical(
@router.get("/stock/{symbol}/news", response_model=ApiResponse)
@_safe
@safe
async def stock_news(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get recent company news."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await openbb_service.get_news(symbol)
news = [
NewsItem(
@@ -136,10 +103,10 @@ async def stock_news(symbol: str = Path(..., min_length=1, max_length=20)):
@router.get("/stock/{symbol}/summary", response_model=ApiResponse)
@_safe
@safe
async def stock_summary(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get aggregated stock data: quote + profile + metrics + financials."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await openbb_service.get_summary(symbol)
summary = SummaryResponse(
quote=quote_from_dict(symbol, data.get("quote", {})),
@@ -156,7 +123,7 @@ async def stock_summary(symbol: str = Path(..., min_length=1, max_length=20)):
@router.post("/portfolio/analyze", response_model=ApiResponse)
@_safe
@safe
async def portfolio_analyze(request: PortfolioRequest):
"""Analyze portfolio holdings with rule-based engine."""
result: PortfolioResponse = await analysis_service.analyze_portfolio(
@@ -169,7 +136,7 @@ async def portfolio_analyze(request: PortfolioRequest):
@router.get("/discover/gainers", response_model=ApiResponse)
@_safe
@safe
async def discover_gainers():
"""Get top gainers (US market)."""
data = await openbb_service.get_gainers()
@@ -177,7 +144,7 @@ async def discover_gainers():
@router.get("/discover/losers", response_model=ApiResponse)
@_safe
@safe
async def discover_losers():
"""Get top losers (US market)."""
data = await openbb_service.get_losers()
@@ -185,7 +152,7 @@ async def discover_losers():
@router.get("/discover/active", response_model=ApiResponse)
@_safe
@safe
async def discover_active():
"""Get most active stocks (US market)."""
data = await openbb_service.get_active()
@@ -193,7 +160,7 @@ async def discover_active():
@router.get("/discover/undervalued", response_model=ApiResponse)
@_safe
@safe
async def discover_undervalued():
"""Get undervalued large cap stocks."""
data = await openbb_service.get_undervalued()
@@ -201,7 +168,7 @@ async def discover_undervalued():
@router.get("/discover/growth", response_model=ApiResponse)
@_safe
@safe
async def discover_growth():
"""Get growth tech stocks."""
data = await openbb_service.get_growth()

127
routes_calendar.py Normal file
View File

@@ -0,0 +1,127 @@
"""Routes for calendar events, screening, ownership, and estimates."""
from fastapi import APIRouter, Path, Query
from models import ApiResponse
from route_utils import safe, validate_symbol
import calendar_service
router = APIRouter(prefix="/api/v1")
DATE_PATTERN = r"^\d{4}-\d{2}-\d{2}$"
# --- Calendar Events ---
@router.get("/calendar/earnings", response_model=ApiResponse)
@safe
async def earnings_calendar(
start_date: str | None = Query(
default=None, pattern=DATE_PATTERN, description="YYYY-MM-DD"
),
end_date: str | None = Query(
default=None, pattern=DATE_PATTERN, description="YYYY-MM-DD"
),
):
"""Get upcoming earnings announcements."""
data = await calendar_service.get_earnings_calendar(start_date, end_date)
return ApiResponse(data=data)
@router.get("/calendar/dividends", response_model=ApiResponse)
@safe
async def dividend_calendar(
start_date: str | None = Query(
default=None, pattern=DATE_PATTERN, description="YYYY-MM-DD"
),
end_date: str | None = Query(
default=None, pattern=DATE_PATTERN, description="YYYY-MM-DD"
),
):
"""Get upcoming dividend dates."""
data = await calendar_service.get_dividend_calendar(start_date, end_date)
return ApiResponse(data=data)
@router.get("/calendar/ipo", response_model=ApiResponse)
@safe
async def ipo_calendar(
start_date: str | None = Query(
default=None, pattern=DATE_PATTERN, description="YYYY-MM-DD"
),
end_date: str | None = Query(
default=None, pattern=DATE_PATTERN, description="YYYY-MM-DD"
),
):
"""Get upcoming IPOs."""
data = await calendar_service.get_ipo_calendar(start_date, end_date)
return ApiResponse(data=data)
@router.get("/calendar/splits", response_model=ApiResponse)
@safe
async def splits_calendar(
start_date: str | None = Query(
default=None, pattern=DATE_PATTERN, description="YYYY-MM-DD"
),
end_date: str | None = Query(
default=None, pattern=DATE_PATTERN, description="YYYY-MM-DD"
),
):
"""Get upcoming stock splits."""
data = await calendar_service.get_splits_calendar(start_date, end_date)
return ApiResponse(data=data)
# --- Analyst Estimates ---
@router.get("/stock/{symbol}/estimates", response_model=ApiResponse)
@safe
async def stock_estimates(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get analyst consensus estimates."""
symbol = validate_symbol(symbol)
data = await calendar_service.get_analyst_estimates(symbol)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/share-statistics", response_model=ApiResponse)
@safe
async def stock_share_stats(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get share statistics: float, outstanding, short interest."""
symbol = validate_symbol(symbol)
data = await calendar_service.get_share_statistics(symbol)
return ApiResponse(data=data)
# --- Ownership (SEC, free) ---
@router.get("/stock/{symbol}/sec-insider", response_model=ApiResponse)
@safe
async def stock_sec_insider(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get insider trading data from SEC (Form 4)."""
symbol = validate_symbol(symbol)
data = await calendar_service.get_insider_trading(symbol)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/institutional", response_model=ApiResponse)
@safe
async def stock_institutional(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get institutional holders from SEC 13F filings."""
symbol = validate_symbol(symbol)
data = await calendar_service.get_institutional_holders(symbol)
return ApiResponse(data=data)
# --- Screener ---
@router.get("/screener", response_model=ApiResponse)
@safe
async def stock_screener():
"""Screen stocks using available filters."""
data = await calendar_service.screen_stocks()
return ApiResponse(data=data)

View File

@@ -1,41 +1,16 @@
"""Routes for macroeconomic data (FRED-powered)."""
import functools
import logging
from collections.abc import Awaitable, Callable
from typing import ParamSpec, TypeVar
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, Query
from models import ApiResponse
from route_utils import safe
import macro_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1")
P = ParamSpec("P")
R = TypeVar("R")
def _safe(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
@functools.wraps(fn)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
try:
return await fn(*args, **kwargs)
except HTTPException:
raise
except Exception:
logger.exception("Upstream data error")
raise HTTPException(
status_code=502,
detail="Data provider error. Check server logs.",
)
return wrapper # type: ignore[return-value]
@router.get("/macro/overview", response_model=ApiResponse)
@_safe
@safe
async def macro_overview():
"""Get key macro indicators: Fed rate, treasury yields, CPI, unemployment, GDP, VIX."""
data = await macro_service.get_macro_overview()
@@ -43,7 +18,7 @@ async def macro_overview():
@router.get("/macro/series/{series_id}", response_model=ApiResponse)
@_safe
@safe
async def macro_series(
series_id: str,
limit: int = Query(default=30, ge=1, le=1000),

137
routes_market.py Normal file
View File

@@ -0,0 +1,137 @@
"""Routes for ETF, index, crypto, currency, and derivatives data."""
from fastapi import APIRouter, Path, Query
from models import ApiResponse
from route_utils import safe, validate_symbol
import market_service
router = APIRouter(prefix="/api/v1")
# --- ETF ---
# NOTE: /etf/search MUST be registered before /etf/{symbol} to avoid shadowing.
@router.get("/etf/search", response_model=ApiResponse)
@safe
async def etf_search(query: str = Query(..., min_length=1, max_length=100)):
"""Search for ETFs by name or keyword."""
data = await market_service.search_etf(query)
return ApiResponse(data=data)
@router.get("/etf/{symbol}/info", response_model=ApiResponse)
@safe
async def etf_info(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get ETF profile and info."""
symbol = validate_symbol(symbol)
data = await market_service.get_etf_info(symbol)
return ApiResponse(data=data)
@router.get("/etf/{symbol}/historical", response_model=ApiResponse)
@safe
async def etf_historical(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=1, le=3650),
):
"""Get ETF price history."""
symbol = validate_symbol(symbol)
data = await market_service.get_etf_historical(symbol, days=days)
return ApiResponse(data=data)
# --- Index ---
@router.get("/index/available", response_model=ApiResponse)
@safe
async def index_available():
"""List available market indices."""
data = await market_service.get_available_indices()
return ApiResponse(data=data)
@router.get("/index/{symbol}/historical", response_model=ApiResponse)
@safe
async def index_historical(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=1, le=3650),
):
"""Get index price history (e.g., ^GSPC, ^DJI, ^IXIC)."""
symbol = validate_symbol(symbol)
data = await market_service.get_index_historical(symbol, days=days)
return ApiResponse(data=data)
# --- Crypto ---
# NOTE: /crypto/search MUST be registered before /crypto/{symbol} to avoid shadowing.
@router.get("/crypto/search", response_model=ApiResponse)
@safe
async def crypto_search(query: str = Query(..., min_length=1, max_length=100)):
"""Search for cryptocurrencies."""
data = await market_service.search_crypto(query)
return ApiResponse(data=data)
@router.get("/crypto/{symbol}/historical", response_model=ApiResponse)
@safe
async def crypto_historical(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=1, le=3650),
):
"""Get cryptocurrency price history (e.g., BTC-USD)."""
symbol = validate_symbol(symbol)
data = await market_service.get_crypto_historical(symbol, days=days)
return ApiResponse(data=data)
# --- Currency ---
@router.get("/currency/{symbol}/historical", response_model=ApiResponse)
@safe
async def currency_historical(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=1, le=3650),
):
"""Get forex price history (e.g., EURUSD, USDSEK)."""
symbol = validate_symbol(symbol)
data = await market_service.get_currency_historical(symbol, days=days)
return ApiResponse(data=data)
# --- Derivatives ---
@router.get("/options/{symbol}/chains", response_model=ApiResponse)
@safe
async def options_chains(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get options chain data."""
symbol = validate_symbol(symbol)
data = await market_service.get_options_chains(symbol)
return ApiResponse(data=data)
@router.get("/futures/{symbol}/historical", response_model=ApiResponse)
@safe
async def futures_historical(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=1, le=3650),
):
"""Get futures price history."""
symbol = validate_symbol(symbol)
data = await market_service.get_futures_historical(symbol, days=days)
return ApiResponse(data=data)
@router.get("/futures/{symbol}/curve", response_model=ApiResponse)
@safe
async def futures_curve(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get futures term structure/curve."""
symbol = validate_symbol(symbol)
data = await market_service.get_futures_curve(symbol)
return ApiResponse(data=data)

54
routes_quantitative.py Normal file
View File

@@ -0,0 +1,54 @@
"""Routes for quantitative analysis: risk metrics, CAPM, normality, unit root."""
from fastapi import APIRouter, Path, Query
from models import ApiResponse
from route_utils import safe, validate_symbol
import quantitative_service
router = APIRouter(prefix="/api/v1")
@router.get("/stock/{symbol}/performance", response_model=ApiResponse)
@safe
async def stock_performance(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=30, le=3650),
):
"""Performance metrics: Sharpe, Sortino, max drawdown, volatility."""
symbol = validate_symbol(symbol)
data = await quantitative_service.get_performance_metrics(symbol, days=days)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/capm", response_model=ApiResponse)
@safe
async def stock_capm(symbol: str = Path(..., min_length=1, max_length=20)):
"""CAPM: beta, alpha, systematic and idiosyncratic risk."""
symbol = validate_symbol(symbol)
data = await quantitative_service.get_capm(symbol)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/normality", response_model=ApiResponse)
@safe
async def stock_normality(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=30, le=3650),
):
"""Normality tests: Jarque-Bera, Shapiro-Wilk on returns."""
symbol = validate_symbol(symbol)
data = await quantitative_service.get_normality_test(symbol, days=days)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/unitroot", response_model=ApiResponse)
@safe
async def stock_unitroot(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=30, le=3650),
):
"""Unit root tests: ADF, KPSS for stationarity."""
symbol = validate_symbol(symbol)
data = await quantitative_service.get_unitroot_test(symbol, days=days)
return ApiResponse(data=data)

View File

@@ -1,55 +1,30 @@
"""Routes for sentiment, insider trades, and analyst data (Finnhub + Alpha Vantage)."""
import asyncio
import functools
import logging
from collections.abc import Awaitable, Callable
from typing import ParamSpec, TypeVar
from fastapi import APIRouter, HTTPException, Path, Query
from fastapi import APIRouter, Path, Query
from models import SYMBOL_PATTERN, ApiResponse
from models import ApiResponse
from route_utils import safe, validate_symbol
import alphavantage_service
import finnhub_service
import openbb_service
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1")
P = ParamSpec("P")
R = TypeVar("R")
def _validate_symbol(symbol: str) -> str:
if not SYMBOL_PATTERN.match(symbol):
raise HTTPException(status_code=400, detail="Invalid symbol format")
return symbol.upper()
def _safe(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
@functools.wraps(fn)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
try:
return await fn(*args, **kwargs)
except HTTPException:
raise
except Exception:
logger.exception("Upstream data error")
raise HTTPException(
status_code=502,
detail="Data provider error. Check server logs.",
)
return wrapper # type: ignore[return-value]
# --- Sentiment & News ---
@router.get("/stock/{symbol}/sentiment", response_model=ApiResponse)
@_safe
@safe
async def stock_sentiment(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get aggregated sentiment: Alpha Vantage news sentiment + Finnhub analyst data."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
finnhub_data, av_data = await asyncio.gather(
finnhub_service.get_sentiment_summary(symbol),
alphavantage_service.get_news_sentiment(symbol, limit=20),
@@ -67,22 +42,22 @@ async def stock_sentiment(symbol: str = Path(..., min_length=1, max_length=20)):
@router.get("/stock/{symbol}/news-sentiment", response_model=ApiResponse)
@_safe
@safe
async def stock_news_sentiment(
symbol: str = Path(..., min_length=1, max_length=20),
limit: int = Query(default=30, ge=1, le=200),
):
"""Get news articles with per-ticker sentiment scores (Alpha Vantage)."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await alphavantage_service.get_news_sentiment(symbol, limit=limit)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/insider-trades", response_model=ApiResponse)
@_safe
@safe
async def stock_insider_trades(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get insider transactions (CEO/CFO buys and sells)."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
raw = await finnhub_service.get_insider_transactions(symbol)
trades = [
{
@@ -100,10 +75,10 @@ async def stock_insider_trades(symbol: str = Path(..., min_length=1, max_length=
@router.get("/stock/{symbol}/recommendations", response_model=ApiResponse)
@_safe
@safe
async def stock_recommendations(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get analyst recommendation trends (monthly buy/hold/sell counts)."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
raw = await finnhub_service.get_recommendation_trends(symbol)
recs = [
{
@@ -120,19 +95,9 @@ async def stock_recommendations(symbol: str = Path(..., min_length=1, max_length
@router.get("/stock/{symbol}/upgrades", response_model=ApiResponse)
@_safe
@safe
async def stock_upgrades(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get recent analyst upgrades and downgrades."""
symbol = _validate_symbol(symbol)
raw = await finnhub_service.get_upgrade_downgrade(symbol)
upgrades = [
{
"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)
"""Get recent analyst upgrades and downgrades (via yfinance)."""
symbol = validate_symbol(symbol)
data = await openbb_service.get_upgrades_downgrades(symbol)
return ApiResponse(data=data)

View File

@@ -1,49 +1,18 @@
"""Routes for technical analysis indicators."""
import functools
import logging
from collections.abc import Awaitable, Callable
from typing import ParamSpec, TypeVar
from fastapi import APIRouter, Path
from fastapi import APIRouter, HTTPException, Path
from models import SYMBOL_PATTERN, ApiResponse
from models import ApiResponse
from route_utils import safe, validate_symbol
import technical_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1")
P = ParamSpec("P")
R = TypeVar("R")
def _validate_symbol(symbol: str) -> str:
if not SYMBOL_PATTERN.match(symbol):
raise HTTPException(status_code=400, detail="Invalid symbol format")
return symbol.upper()
def _safe(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
@functools.wraps(fn)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
try:
return await fn(*args, **kwargs)
except HTTPException:
raise
except Exception:
logger.exception("Upstream data error")
raise HTTPException(
status_code=502,
detail="Data provider error. Check server logs.",
)
return wrapper # type: ignore[return-value]
@router.get("/stock/{symbol}/technical", response_model=ApiResponse)
@_safe
@safe
async def stock_technical(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get technical indicators: RSI, MACD, SMA, EMA, Bollinger Bands + signal interpretation."""
symbol = _validate_symbol(symbol)
symbol = validate_symbol(symbol)
data = await technical_service.get_technical_indicators(symbol)
return ApiResponse(data=data)