Compare commits

..

36 Commits

Author SHA1 Message Date
Yaojia Wang
ca8d7099b3 docs: update SKILL.md for 99 endpoints
All checks were successful
continuous-integration/drone/push Build is passing
- Add all new sections: technical (14 indicators), quantitative
  extended (Sortino/Omega/rolling), shorts/dark pool, fixed income,
  macro expanded, economy/surveys, regulators, equity fundamentals
- Add recommended workflows: comprehensive analysis, macro/fixed
  income, sector rotation (RRG), short squeeze screening
- Update description to reflect 99 endpoints and 14 providers
- Add multi-country support note
2026-03-19 20:03:41 +01:00
Yaojia Wang
c5c9c7db83 docs: comprehensive README update for 99 endpoints
- Update endpoint count from 32 to 99
- Add all new endpoint sections: technical (14), quantitative extended,
  shorts/dark pool, fixed income, macro expanded, economy, surveys,
  regulators, equity fundamentals
- Add free providers table (stockgrid, FINRA, CFTC, multpl, ECB, OECD)
- Update data sources table with all 14 providers
- Update project structure with all new service/route files
- Update OpenClaw integration workflow with new endpoints
- Update quick start examples with new features
2026-03-19 19:59:44 +01:00
Yaojia Wang
a57a6835c5 test: fix broken tests after refactor
- Update test_openbb_service to import from obb_utils (to_list,
  first_or_empty) instead of removed _to_dicts/_first_or_empty
- Update test_stock_upgrades to mock openbb_service instead of
  finnhub_service (upgrades moved to yfinance)
2026-03-19 17:47:36 +01:00
Yaojia Wang
89bdc6c552 refactor: address python review findings
- Move FRED credential registration to FastAPI lifespan (was fragile
  import-order-dependent side-effect)
- Add noqa E402 annotations for imports after curl_cffi patch
- Fix all return type hints: bare dict -> dict[str, Any]
- Move yfinance import to module level (was inline in functions)
- Fix datetime.now() -> datetime.now(tz=timezone.utc) in openbb_service
- Add try/except error handling to Group B service functions
- Fix dict mutation in relative_rotation (immutable pattern)
- Extract _classify_rrg_quadrant helper function
- Fix type builtin shadow in routes_economy (type -> gdp_type)
- Fix falsy int guard (if year: -> if year is not None:)
- Remove user input echo from error messages
2026-03-19 17:40:47 +01:00
Yaojia Wang
e2cf6e2488 fix: redesign relative rotation for multi-symbol comparison
- Accept comma-separated symbols query param instead of single path param
- Move endpoint from /stock/{symbol}/technical/relative-rotation to
  /technical/relative-rotation?symbols=AAPL,MSFT,GOOGL&benchmark=SPY
- Fetch all symbols + benchmark in single obb.equity.price.historical call
- Add RRG quadrant classification (Leading/Weakening/Lagging/Improving)
- Support study parameter (price/volume/volatility)
2026-03-19 17:34:18 +01:00
Yaojia Wang
615f17a3bb feat: add remaining 5 endpoints (VWAP, relative rotation, fred-regional, primary dealer)
Complete all 67 planned endpoints:
- VWAP and Relative Rotation technical indicators
- FRED regional data (by state/county/MSA)
- Primary dealer positioning (Fed data)
2026-03-19 17:31:08 +01:00
Yaojia Wang
87260f4b10 feat: add 67 new endpoints across 10 feature groups
Prerequisite refactor:
- Consolidate duplicate _to_dicts into shared obb_utils.to_list
- Add fetch_historical and first_or_empty helpers to obb_utils

Phase 1 - Local computation (no provider risk):
- Group I: 12 technical indicators (ATR, ADX, Stoch, OBV, Ichimoku,
  Donchian, Aroon, CCI, Keltner, Fibonacci, A/D, Volatility Cones)
- Group J: Sortino, Omega ratios + rolling stats (variance, stdev,
  mean, skew, kurtosis, quantile via generic endpoint)
- Group H: ECB currency reference rates

Phase 2 - FRED/Federal Reserve providers:
- Group C: 10 fixed income endpoints (treasury rates, yield curve,
  auctions, TIPS, EFFR, SOFR, HQM, commercial paper, spot rates,
  spreads)
- Group D: 11 economy endpoints (CPI, GDP, unemployment, PCE, money
  measures, CLI, HPI, FRED search, balance of payments, Fed holdings,
  FOMC documents)
- Group E: 5 survey endpoints (Michigan, SLOOS, NFP, Empire State,
  BLS search)

Phase 3 - SEC/stockgrid/FINRA providers:
- Group B: 4 equity fundamental endpoints (management, dividends,
  SEC filings, company search)
- Group A: 4 shorts/dark pool endpoints (short volume, FTD, short
  interest, OTC dark pool)
- Group F: 3 index/ETF enhanced (S&P 500 multiples, index
  constituents, ETF N-PORT)

Phase 4 - Regulators:
- Group G: 5 regulatory endpoints (COT report, COT search, SEC
  litigation, institution search, CIK mapping)

Security hardening:
- Service-layer allowlists for all getattr dynamic dispatch
- Regex validation on date, country, security_type, form_type params
- Exception handling in fetch_historical
- Callable guard on rolling stat dispatch

Total: 32 existing + 67 new = 99 endpoints, all free providers.
2026-03-19 17:28:31 +01:00
Yaojia Wang
b6f49055ad docs: update README for yfinance upgrades/price target, curl_cffi pin
- Remove FMP from optional keys (no longer used)
- Update Finnhub description (upgrades moved to yfinance)
- Update upgrades endpoint description to note yfinance + price targets
- Update data sources table
- Fix conda setup instructions to use pip install -e .
- Add Known Issues section for curl_cffi TLS workaround
2026-03-19 16:36:03 +01:00
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
37 changed files with 3015 additions and 232 deletions

View File

@@ -1,5 +1,5 @@
kind: pipeline
type: docker
type: kubernetes
name: build-and-push
trigger:
@@ -8,15 +8,22 @@ trigger:
- develop
event:
- push
- custom
steps:
- name: build-and-push
image: plugins/docker
settings:
repo: 192.168.68.11:30500/invest-api
registry: 192.168.68.11:30500
insecure: true
tags:
- ${DRONE_COMMIT_SHA:0:8}
- latest
dockerfile: Dockerfile
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

View File

@@ -3,20 +3,27 @@ FROM python:3.12-slim AS base
WORKDIR /app
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc g++ && \
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++ && \
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 nobody
USER appuser
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

377
README.md
View File

@@ -1,6 +1,6 @@
# OpenBB Investment Analysis API
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.
REST API wrapping OpenBB SDK with 99 endpoints covering stock data, sentiment analysis, technical indicators, quantitative risk metrics, fixed income, macro economics, shorts/dark pool, regulators, and rule-based investment analysis. 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
@@ -12,18 +12,24 @@ The core functionality uses **yfinance** (free, no API key). The API works witho
| Provider | Env Variable | How to Get | What It Unlocks | Free Limit |
|----------|-------------|------------|-----------------|------------|
| **Finnhub** | `INVEST_API_FINNHUB_API_KEY` | https://finnhub.io/register | Insider trades, analyst upgrades, recommendation trends | 60 calls/min |
| **FRED** | `INVEST_API_FRED_API_KEY` | https://fred.stlouisfed.org/docs/api/api_key.html | Macro data: Fed rate, CPI, GDP, unemployment, treasury yields | 120 calls/min |
| **Finnhub** | `INVEST_API_FINNHUB_API_KEY` | https://finnhub.io/register | Insider trades, recommendation trends, company news | 60 calls/min |
| **FRED** | `INVEST_API_FRED_API_KEY` | https://fred.stlouisfed.org/docs/api/api_key.html | Macro data, fixed income, surveys, money supply | 120 calls/min |
| **Alpha Vantage** | `INVEST_API_ALPHAVANTAGE_API_KEY` | https://www.alphavantage.co/support/#api-key | News sentiment scores (bullish/bearish per article per ticker) | 25 calls/day |
### Optional Paid Keys (for higher quality data)
### Free Providers (no key needed)
| Provider | Env Variable | What It Adds |
|----------|-------------|--------------|
| **FMP** | `OBB_FMP_API_KEY` | More granular financials, earnings transcripts (250 calls/day free) |
| **Intrinio** | `OBB_INTRINIO_API_KEY` | Institutional-grade fundamentals |
| **Tiingo** | `OBB_TIINGO_TOKEN` | Reliable historical price data |
| **Benzinga** | `OBB_BENZINGA_API_KEY` | Real-time news, analyst ratings |
| Provider | Data Provided |
|----------|---------------|
| **yfinance** | Quotes, fundamentals, financials, historical prices, news, ETF, index, crypto, forex, options, futures, analyst upgrades, price targets, dividends, management |
| **SEC** | Insider trading (Form 4), institutional holdings (13F), company filings, N-PORT disclosures, CIK mapping |
| **stockgrid** | Short volume data |
| **FINRA** | Short interest, dark pool OTC data |
| **multpl** | S&P 500 historical valuation multiples |
| **CFTC** | Commitment of Traders reports |
| **ECB** | Currency reference rates |
| **OECD** | GDP, unemployment, CPI, CLI, housing price index |
| **openbb-technical** | 14 technical indicators (local computation) |
| **openbb-quantitative** | Risk metrics, CAPM, normality tests (local computation) |
### Configuration
@@ -40,9 +46,10 @@ export INVEST_API_ALPHAVANTAGE_API_KEY=your_alphavantage_key
### 1. Create conda environment
```bash
conda env create -f environment.yml
conda create -n openbb-invest-api python=3.12 -y
conda activate openbb-invest-api
pip install openbb-quantitative openbb-econometrics
pip install -e .
pip install openbb-quantitative openbb-econometrics openbb-technical
```
### 2. Start the server
@@ -59,36 +66,39 @@ Server starts at `http://localhost:8000`. Visit `http://localhost:8000/docs` for
# Health check
curl http://localhost:8000/health
# US stock quote
# Stock quote
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 (Finnhub + Alpha Vantage)
curl http://localhost:8000/api/v1/stock/AAPL/sentiment
# News sentiment with per-article scores (Alpha Vantage)
curl http://localhost:8000/api/v1/stock/AAPL/news-sentiment
# Technical indicators
# Technical indicators (14 individual + composite)
curl http://localhost:8000/api/v1/stock/AAPL/technical
curl http://localhost:8000/api/v1/stock/AAPL/technical/ichimoku
# Quantitative risk metrics
curl http://localhost:8000/api/v1/stock/AAPL/performance
curl http://localhost:8000/api/v1/stock/AAPL/capm
# Relative Rotation Graph (multi-symbol)
curl "http://localhost:8000/api/v1/technical/relative-rotation?symbols=AAPL,MSFT,GOOGL&benchmark=SPY"
# SEC insider trading
curl http://localhost:8000/api/v1/stock/AAPL/sec-insider
# Quantitative analysis
curl http://localhost:8000/api/v1/stock/AAPL/sortino
curl http://localhost:8000/api/v1/stock/AAPL/rolling/skew?window=20
# ETF info
curl http://localhost:8000/api/v1/etf/SPY/info
# Fixed income
curl http://localhost:8000/api/v1/fixed-income/yield-curve
curl http://localhost:8000/api/v1/fixed-income/treasury-rates
# Crypto price history
curl http://localhost:8000/api/v1/crypto/BTC-USD/historical?days=30
# Macro overview (requires FRED key)
# Macro economics
curl http://localhost:8000/api/v1/macro/overview
curl http://localhost:8000/api/v1/macro/cpi
curl http://localhost:8000/api/v1/macro/money-measures
# Economy surveys
curl http://localhost:8000/api/v1/economy/surveys/michigan
curl http://localhost:8000/api/v1/economy/surveys/sloos
# Shorts & dark pool
curl http://localhost:8000/api/v1/stock/AAPL/shorts/volume
curl http://localhost:8000/api/v1/darkpool/AAPL/otc
# Regulators
curl "http://localhost:8000/api/v1/regulators/cot/search?query=gold"
# Portfolio analysis
curl -X POST http://localhost:8000/api/v1/portfolio/analyze \
@@ -96,7 +106,7 @@ curl -X POST http://localhost:8000/api/v1/portfolio/analyze \
-d '{"holdings":[{"symbol":"AAPL","shares":100,"buy_in_price":150},{"symbol":"VOLV-B.ST","shares":50,"buy_in_price":250}]}'
```
## API Endpoints
## API Endpoints (99 total)
### Health
@@ -115,31 +125,111 @@ curl -X POST http://localhost:8000/api/v1/portfolio/analyze \
| GET | `/api/v1/stock/{symbol}/historical?days=365` | Historical OHLCV data |
| GET | `/api/v1/stock/{symbol}/news` | Recent company news |
| GET | `/api/v1/stock/{symbol}/summary` | Aggregated: quote + profile + metrics + financials |
| GET | `/api/v1/stock/{symbol}/management` | Executive team: name, title, compensation |
| GET | `/api/v1/stock/{symbol}/dividends` | Historical dividend records |
| GET | `/api/v1/stock/{symbol}/filings?form_type=10-K` | SEC filings (10-K, 10-Q, 8-K) |
| GET | `/api/v1/search?query=` | Company search by name (SEC/NASDAQ) |
### Sentiment & Analyst Data (Finnhub + Alpha Vantage, free keys)
### Sentiment & Analyst Data (Finnhub + Alpha Vantage + yfinance)
| Method | Path | Description |
|--------|------|-------------|
| 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 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 |
| GET | `/api/v1/stock/{symbol}/recommendations` | Monthly analyst buy/hold/sell counts (Finnhub) |
| GET | `/api/v1/stock/{symbol}/upgrades` | Analyst upgrades/downgrades with price targets (yfinance) |
### Technical Analysis (local computation, no key needed)
### Technical Analysis (14 indicators, local computation, no key needed)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/stock/{symbol}/technical` | RSI, MACD, SMA, EMA, Bollinger Bands + signal interpretation |
| GET | `/api/v1/stock/{symbol}/technical` | Composite: RSI, MACD, SMA, EMA, Bollinger Bands + signals |
| GET | `/api/v1/stock/{symbol}/technical/atr` | Average True Range (volatility, position sizing) |
| GET | `/api/v1/stock/{symbol}/technical/adx` | Average Directional Index (trend strength) |
| GET | `/api/v1/stock/{symbol}/technical/stoch` | Stochastic Oscillator (overbought/oversold) |
| GET | `/api/v1/stock/{symbol}/technical/obv` | On-Balance Volume (volume-price divergence) |
| GET | `/api/v1/stock/{symbol}/technical/ichimoku` | Ichimoku Cloud (comprehensive trend system) |
| GET | `/api/v1/stock/{symbol}/technical/donchian` | Donchian Channels (breakout detection) |
| GET | `/api/v1/stock/{symbol}/technical/aroon` | Aroon Indicator (trend direction/changes) |
| GET | `/api/v1/stock/{symbol}/technical/cci` | Commodity Channel Index (cyclical trends) |
| GET | `/api/v1/stock/{symbol}/technical/kc` | Keltner Channels (ATR-based volatility bands) |
| GET | `/api/v1/stock/{symbol}/technical/fib` | Fibonacci Retracement (support/resistance levels) |
| GET | `/api/v1/stock/{symbol}/technical/ad` | Accumulation/Distribution Line |
| GET | `/api/v1/stock/{symbol}/technical/cones` | Volatility Cones (realized vol quantiles) |
| GET | `/api/v1/stock/{symbol}/technical/vwap` | Volume Weighted Average Price |
| GET | `/api/v1/technical/relative-rotation?symbols=&benchmark=SPY` | Relative Rotation Graph (multi-symbol sector rotation) |
### Quantitative Analysis (openbb-quantitative, no key needed)
### Quantitative Analysis (local computation, 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}/normality?days=365` | Normality tests: Jarque-Bera, Shapiro-Wilk, K-S |
| GET | `/api/v1/stock/{symbol}/unitroot?days=365` | Unit root tests: ADF, KPSS for stationarity |
| GET | `/api/v1/stock/{symbol}/sortino?days=365` | Sortino ratio (downside risk only) |
| GET | `/api/v1/stock/{symbol}/omega?days=365` | Omega ratio (full distribution gain/loss) |
| GET | `/api/v1/stock/{symbol}/rolling/{stat}?days=365&window=30` | Rolling stats: variance, stdev, mean, skew, kurtosis, quantile |
### Shorts & Dark Pool (stockgrid/FINRA/SEC, no key needed)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/stock/{symbol}/shorts/volume` | Daily short volume and percent (stockgrid) |
| GET | `/api/v1/stock/{symbol}/shorts/ftd` | Fails-to-deliver records (SEC) |
| GET | `/api/v1/stock/{symbol}/shorts/interest` | Short interest, days to cover (FINRA) |
| GET | `/api/v1/darkpool/{symbol}/otc` | OTC/dark pool aggregate trade volume (FINRA) |
### Fixed Income (FRED/Federal Reserve, free key)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/fixed-income/treasury-rates` | Full yield curve rates (4W-30Y) |
| GET | `/api/v1/fixed-income/yield-curve?date=` | Yield curve with maturity/rate pairs |
| GET | `/api/v1/fixed-income/treasury-auctions` | Treasury auction bid-to-cover, yields |
| GET | `/api/v1/fixed-income/tips-yields` | TIPS real yields by maturity |
| GET | `/api/v1/fixed-income/effr` | Effective Federal Funds Rate with percentiles |
| GET | `/api/v1/fixed-income/sofr` | SOFR rate with 30/90/180-day moving averages |
| GET | `/api/v1/fixed-income/hqm` | High Quality Market corporate bond yields |
| GET | `/api/v1/fixed-income/commercial-paper` | Commercial paper rates by maturity/type |
| GET | `/api/v1/fixed-income/spot-rates` | Corporate bond spot rates and par yields |
| GET | `/api/v1/fixed-income/spreads?series=tcm` | Treasury/corporate spreads |
### Macro Economics (FRED/OECD, free key)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/macro/overview` | Key indicators: Fed rate, treasury yields, CPI, unemployment, GDP, VIX |
| GET | `/api/v1/macro/series/{series_id}?limit=30` | Any FRED time series by ID |
| GET | `/api/v1/macro/cpi?country=united_states` | Consumer Price Index (multi-country) |
| GET | `/api/v1/macro/gdp?gdp_type=real` | GDP: nominal, real, or forecast |
| GET | `/api/v1/macro/unemployment?country=united_states` | Unemployment rate (multi-country) |
| GET | `/api/v1/macro/pce` | Personal Consumption Expenditures (Fed preferred inflation) |
| GET | `/api/v1/macro/money-measures` | M1/M2 money supply |
| GET | `/api/v1/macro/cli?country=united_states` | Composite Leading Indicator (recession predictor) |
| GET | `/api/v1/macro/house-price-index?country=united_states` | Housing price index (multi-country) |
### Economy Data (FRED/Federal Reserve)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/economy/fred-search?query=` | Search 800K+ FRED economic series |
| GET | `/api/v1/economy/fred-regional?series_id=` | Regional economic data (by state/county/MSA) |
| GET | `/api/v1/economy/balance-of-payments` | Current/capital/financial account balances |
| GET | `/api/v1/economy/central-bank-holdings` | Fed SOMA portfolio holdings |
| GET | `/api/v1/economy/primary-dealer-positioning` | Primary dealer net positions |
| GET | `/api/v1/economy/fomc-documents?year=` | FOMC meeting documents |
### Economy Surveys (FRED/BLS)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/economy/surveys/michigan` | University of Michigan Consumer Sentiment |
| GET | `/api/v1/economy/surveys/sloos` | Senior Loan Officer Survey (recession signal) |
| GET | `/api/v1/economy/surveys/nonfarm-payrolls` | Detailed employment data |
| GET | `/api/v1/economy/surveys/empire-state` | NY manufacturing outlook |
| GET | `/api/v1/economy/surveys/bls-search?query=` | Search BLS data series |
### Calendar Events (no key needed)
@@ -160,20 +250,23 @@ curl -X POST http://localhost:8000/api/v1/portfolio/analyze \
| GET | `/api/v1/stock/{symbol}/institutional` | Institutional holders from SEC 13F filings |
| GET | `/api/v1/screener` | Stock screener |
### ETF Data (yfinance, no key needed)
### ETF Data (yfinance + SEC, 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/{symbol}/nport` | Detailed ETF holdings from SEC N-PORT filings |
| GET | `/api/v1/etf/search?query=` | Search ETFs by name |
### Index Data (yfinance, no key needed)
### Index Data (yfinance + multpl + cboe, 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) |
| GET | `/api/v1/index/sp500-multiples?series=pe_ratio` | Historical S&P 500 valuation (PE, Shiller PE, P/B, dividend yield) |
| GET | `/api/v1/index/{symbol}/constituents` | Index member stocks with sector/price data |
### Crypto Data (yfinance, no key needed)
@@ -182,11 +275,12 @@ curl -X POST http://localhost:8000/api/v1/portfolio/analyze \
| 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)
### Currency / Forex (yfinance + ECB, no key needed)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/currency/{symbol}/historical?days=365` | Forex price history (EURUSD, USDSEK) |
| GET | `/api/v1/currency/reference-rates` | ECB reference rates for 28 major currencies |
### Derivatives (yfinance, no key needed)
@@ -196,12 +290,15 @@ curl -X POST http://localhost:8000/api/v1/portfolio/analyze \
| 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)
### Regulators (CFTC/SEC, no key needed)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/macro/overview` | Key indicators: Fed rate, treasury yields, CPI, unemployment, GDP, VIX |
| GET | `/api/v1/macro/series/{series_id}?limit=30` | Any FRED time series by ID |
| GET | `/api/v1/regulators/cot?symbol=` | Commitment of Traders (futures positions) |
| GET | `/api/v1/regulators/cot/search?query=` | Search COT report symbols |
| GET | `/api/v1/regulators/sec/litigation` | SEC litigation releases |
| GET | `/api/v1/regulators/sec/institutions?query=` | Search institutional investors |
| GET | `/api/v1/regulators/sec/cik-map/{symbol}` | Ticker to SEC CIK mapping |
### Portfolio Analysis (no key needed)
@@ -256,37 +353,50 @@ All settings are configurable via environment variables with the `INVEST_API_` p
| `INVEST_API_LOG_LEVEL` | `info` | Logging level |
| `INVEST_API_DEBUG` | `false` | Enable debug mode (auto-reload) |
| `INVEST_API_FINNHUB_API_KEY` | _(empty)_ | Finnhub API key for analyst data |
| `INVEST_API_FRED_API_KEY` | _(empty)_ | FRED API key for macro data |
| `INVEST_API_FRED_API_KEY` | _(empty)_ | FRED API key for macro/fixed income/surveys |
| `INVEST_API_ALPHAVANTAGE_API_KEY` | _(empty)_ | Alpha Vantage API key for news sentiment |
## Project Structure
```
openbb-invest-api/
├── main.py # FastAPI app entry point
├── main.py # FastAPI app entry point (lifespan, curl_cffi patch)
├── 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)
├── obb_utils.py # Shared OpenBB result conversion + fetch helpers
├── openbb_service.py # Equity data via OpenBB/yfinance (quote, profile, metrics, etc.)
├── 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
├── technical_service.py # 14 technical indicators via openbb-technical
├── quantitative_service.py # Risk metrics, CAPM, Sortino, Omega, rolling stats
├── macro_service.py # FRED macro data via OpenBB
├── technical_service.py # Technical indicators via openbb-technical
├── economy_service.py # Economy data: CPI, GDP, Fed holdings, FOMC docs
├── surveys_service.py # Economy surveys: Michigan, SLOOS, NFP, BLS
├── fixed_income_service.py # Fixed income: yield curve, treasury, SOFR, spreads
├── shorts_service.py # Shorts & dark pool (stockgrid, FINRA, SEC)
├── regulators_service.py # CFTC COT reports, SEC litigation, institutions
├── market_service.py # ETF, index, crypto, currency, derivatives
├── calendar_service.py # Calendar events, screening, ownership
├── 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_sentiment.py # Sentiment & analyst routes
├── routes_technical.py # Technical analysis routes (14 indicators)
├── routes_quantitative.py # Quantitative analysis routes
├── routes_macro.py # Macro economics routes
├── routes_economy.py # Economy data routes
├── routes_surveys.py # Economy survey routes
├── routes_fixed_income.py # Fixed income routes
├── routes_shorts.py # Shorts & dark pool routes
├── routes_regulators.py # Regulator data 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
├── Dockerfile # Docker build (curl_cffi==0.7.4, safari TLS patch)
├── pyproject.toml # Project metadata + dependencies
└── tests/ # 102 tests
```
@@ -315,20 +425,143 @@ 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/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
5. OpenClaw calls `GET /api/v1/stock/AAPL/performance` for risk metrics (Sharpe, Sortino)
6. OpenClaw calls `GET /api/v1/stock/AAPL/shorts/volume` for short selling activity
7. OpenClaw calls `GET /api/v1/stock/AAPL/sec-insider` for insider trading activity
8. OpenClaw calls `GET /api/v1/macro/overview` for market context
9. OpenClaw calls `GET /api/v1/fixed-income/yield-curve` for rate environment
10. OpenClaw calls `POST /api/v1/portfolio/analyze` with user's holdings
11. 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, 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 |
| **yfinance** | Free | No | Quotes, fundamentals, financials, historical prices, news, discovery, ETF, index, crypto, forex, options, futures, analyst upgrades/downgrades, price targets, dividends, management |
| **SEC** | Free | No | Insider trading (Form 4), institutional holdings (13F), company filings, N-PORT disclosures, CIK mapping, litigation releases |
| **stockgrid** | Free | No | Daily short volume data |
| **FINRA** | Free | No | Short interest, dark pool OTC trade data |
| **CFTC** | Free | No | Commitment of Traders reports |
| **multpl** | Free | No | S&P 500 historical valuation multiples (PE, Shiller PE, P/B, dividend yield) |
| **ECB** | Free | No | Currency reference rates (28 currencies) |
| **OECD** | Free | No | GDP, unemployment, CPI, Composite Leading Indicator, housing price index |
| **Finnhub** | Free | Yes (free registration) | Insider trades, analyst recommendations, company news |
| **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 |
| **FRED** | Free | Yes (free registration) | Fed rate, treasury yields, CPI, PCE, money supply, surveys, 800K+ economic series |
| **Federal Reserve** | Free | No | EFFR, SOFR, money measures, central bank holdings, primary dealer positions, FOMC documents |
| **openbb-technical** | Free | No (local) | ATR, ADX, Stochastic, OBV, Ichimoku, Donchian, Aroon, CCI, Keltner, Fibonacci, A/D, VWAP, Volatility Cones, Relative Rotation |
| **openbb-quantitative** | Free | No (local) | Sharpe, Sortino, Omega ratios, CAPM, normality tests, unit root tests, rolling statistics |
## Known Issues
### curl_cffi TLS Fingerprint
yfinance depends on `curl_cffi` for browser-impersonated HTTP requests. Versions 0.12+ have a BoringSSL bug
that causes `SSL_ERROR_SYSCALL` on some networks (especially Linux). This project pins `curl_cffi==0.7.4`
and patches the default TLS fingerprint from `chrome` to `safari` at startup (in `main.py`) to work around this.

410
SKILL.md Normal file
View File

@@ -0,0 +1,410 @@
---
name: invest-api
description: "Investment analysis via OpenBB Invest API (99 endpoints). Use when: user asks about stocks, ETFs, crypto, portfolio analysis, technical indicators (14 types), quantitative risk metrics, fixed income/yield curve, macro economics, shorts/dark pool, regulators/COT, economy surveys, or investment advice. Covers US and Swedish markets. NOT for: real-time trading, order execution, or account management. No API key needed for most endpoints."
homepage: https://invest-api.k8s.home/docs
metadata: { "openclaw": { "emoji": "📈", "requires": { "bins": ["curl"] } } }
---
# Investment Analysis Skill
Query stock data, run portfolio analysis, and get investment insights via the OpenBB Invest API. 99 endpoints across 14 data providers.
## When to Use
- "What's AAPL trading at?"
- "Analyze my portfolio" / "Should I buy or sell?"
- "Show me technical indicators for TSLA" / "What's the Ichimoku cloud for NVDA?"
- "What are the top gainers today?"
- "Any upcoming earnings this week?"
- "What's the macro outlook?" / "What's the yield curve look like?"
- "How risky is my VOLV-B.ST position?" / "What's the Sortino ratio?"
- "Is anyone shorting AAPL?" / "Show dark pool activity"
- "What are the latest FOMC documents?"
- "Where are gold futures positioned?" (COT data)
- "Compare AAPL, MSFT, GOOGL sector rotation vs SPY"
## When NOT to Use
- Placing trades or managing brokerage accounts
- Real-time tick-by-tick data (this API has request latency)
- Cryptocurrency on-chain analysis
- Tax or accounting advice
## API Base URL
```
BASE=https://invest-api.k8s.home
```
All responses follow `{"success": true, "data": {...}, "error": null}`.
## Stock Data (no key needed)
```bash
# Current quote
curl -sk "$BASE/api/v1/stock/AAPL/quote"
# Company profile (sector, industry, description)
curl -sk "$BASE/api/v1/stock/AAPL/profile"
# Key ratios (PE, PB, ROE, EPS, beta, dividend yield)
curl -sk "$BASE/api/v1/stock/AAPL/metrics"
# Financial statements (income, balance sheet, cash flow)
curl -sk "$BASE/api/v1/stock/AAPL/financials"
# Historical prices (OHLCV)
curl -sk "$BASE/api/v1/stock/AAPL/historical?days=365"
# Recent news
curl -sk "$BASE/api/v1/stock/AAPL/news"
# All-in-one summary (quote + profile + metrics + financials)
curl -sk "$BASE/api/v1/stock/AAPL/summary"
# Executive team and compensation
curl -sk "$BASE/api/v1/stock/AAPL/management"
# Historical dividend records
curl -sk "$BASE/api/v1/stock/AAPL/dividends"
# SEC filings (10-K, 10-Q, 8-K)
curl -sk "$BASE/api/v1/stock/AAPL/filings?form_type=10-K"
# Company search by name
curl -sk "$BASE/api/v1/search?query=apple"
```
## Swedish Stocks
Append `.ST` suffix for Stockholm exchange:
```bash
curl -sk "$BASE/api/v1/stock/VOLV-B.ST/quote" # Volvo
curl -sk "$BASE/api/v1/stock/ERIC-B.ST/quote" # Ericsson
curl -sk "$BASE/api/v1/stock/HM-B.ST/quote" # H&M
curl -sk "$BASE/api/v1/stock/SEB-A.ST/quote" # SEB
```
## Portfolio Analysis
Provide holdings with buy-in cost. Returns BUY_MORE / HOLD / SELL recommendation per holding with confidence and reasons.
```bash
curl -sk -X POST "$BASE/api/v1/portfolio/analyze" \
-H "Content-Type: application/json" \
-d '{
"holdings": [
{"symbol": "AAPL", "shares": 100, "buy_in_price": 150.0},
{"symbol": "VOLV-B.ST", "shares": 50, "buy_in_price": 250.0},
{"symbol": "MSFT", "shares": 30, "buy_in_price": 380.0}
]
}'
```
Max 50 holdings per request.
## Technical Analysis (14 indicators)
```bash
# Composite: RSI, MACD, SMA, EMA, Bollinger Bands + signal interpretation
curl -sk "$BASE/api/v1/stock/AAPL/technical"
# Individual indicators
curl -sk "$BASE/api/v1/stock/AAPL/technical/atr" # Average True Range (volatility)
curl -sk "$BASE/api/v1/stock/AAPL/technical/adx" # Trend strength (>25 strong)
curl -sk "$BASE/api/v1/stock/AAPL/technical/stoch" # Stochastic (overbought/oversold)
curl -sk "$BASE/api/v1/stock/AAPL/technical/obv" # On-Balance Volume
curl -sk "$BASE/api/v1/stock/AAPL/technical/ichimoku" # Ichimoku Cloud
curl -sk "$BASE/api/v1/stock/AAPL/technical/donchian" # Donchian Channels (breakouts)
curl -sk "$BASE/api/v1/stock/AAPL/technical/aroon" # Aroon (trend direction)
curl -sk "$BASE/api/v1/stock/AAPL/technical/cci" # Commodity Channel Index
curl -sk "$BASE/api/v1/stock/AAPL/technical/kc" # Keltner Channels
curl -sk "$BASE/api/v1/stock/AAPL/technical/fib" # Fibonacci Retracement
curl -sk "$BASE/api/v1/stock/AAPL/technical/ad" # Accumulation/Distribution
curl -sk "$BASE/api/v1/stock/AAPL/technical/cones" # Volatility Cones
curl -sk "$BASE/api/v1/stock/AAPL/technical/vwap" # Volume Weighted Average Price
# Relative Rotation Graph (multi-symbol sector rotation)
curl -sk "$BASE/api/v1/technical/relative-rotation?symbols=AAPL,MSFT,GOOGL&benchmark=SPY"
# Returns quadrant per symbol: Leading / Weakening / Lagging / Improving
```
## Quantitative Risk Metrics
```bash
# Sharpe ratio, volatility, summary statistics
curl -sk "$BASE/api/v1/stock/AAPL/performance?days=365"
# CAPM: market risk, systematic risk, idiosyncratic risk
curl -sk "$BASE/api/v1/stock/AAPL/capm"
# Sortino ratio (downside risk only -- better than Sharpe for asymmetric returns)
curl -sk "$BASE/api/v1/stock/AAPL/sortino?days=365"
# Omega ratio (full distribution gain/loss)
curl -sk "$BASE/api/v1/stock/AAPL/omega?days=365"
# Normality tests (Jarque-Bera, Shapiro-Wilk)
curl -sk "$BASE/api/v1/stock/AAPL/normality?days=365"
# Unit root / stationarity tests (ADF, KPSS)
curl -sk "$BASE/api/v1/stock/AAPL/unitroot?days=365"
# Rolling statistics (variance, stdev, mean, skew, kurtosis, quantile)
curl -sk "$BASE/api/v1/stock/AAPL/rolling/skew?days=365&window=30"
curl -sk "$BASE/api/v1/stock/AAPL/rolling/variance?days=365&window=20"
```
## Shorts & Dark Pool (no key needed)
```bash
# Daily short volume and percent
curl -sk "$BASE/api/v1/stock/AAPL/shorts/volume"
# Fails-to-deliver records (SEC)
curl -sk "$BASE/api/v1/stock/AAPL/shorts/ftd"
# Short interest, days to cover (FINRA)
curl -sk "$BASE/api/v1/stock/AAPL/shorts/interest"
# Dark pool / OTC trade volume (FINRA)
curl -sk "$BASE/api/v1/darkpool/AAPL/otc"
```
## Sentiment & Analyst Data
```bash
# Aggregated sentiment (news + recommendations + upgrades)
curl -sk "$BASE/api/v1/stock/AAPL/sentiment"
# Per-article news sentiment scores
curl -sk "$BASE/api/v1/stock/AAPL/news-sentiment?limit=30"
# Insider trades (SEC/Finnhub)
curl -sk "$BASE/api/v1/stock/AAPL/insider-trades"
# Analyst buy/hold/sell counts
curl -sk "$BASE/api/v1/stock/AAPL/recommendations"
# Recent upgrades/downgrades with price targets
curl -sk "$BASE/api/v1/stock/AAPL/upgrades"
```
## Fixed Income (FRED/Federal Reserve)
```bash
# Full yield curve rates (4W-30Y)
curl -sk "$BASE/api/v1/fixed-income/treasury-rates"
# Yield curve with maturity/rate pairs (optional date)
curl -sk "$BASE/api/v1/fixed-income/yield-curve"
curl -sk "$BASE/api/v1/fixed-income/yield-curve?date=2025-01-15"
# Treasury auction data (bid-to-cover, yields)
curl -sk "$BASE/api/v1/fixed-income/treasury-auctions"
# TIPS real yields
curl -sk "$BASE/api/v1/fixed-income/tips-yields"
# Effective Federal Funds Rate (with percentiles)
curl -sk "$BASE/api/v1/fixed-income/effr"
# SOFR rate (with moving averages)
curl -sk "$BASE/api/v1/fixed-income/sofr"
# Corporate bond yields (AAA/AA/A)
curl -sk "$BASE/api/v1/fixed-income/hqm"
# Commercial paper rates
curl -sk "$BASE/api/v1/fixed-income/commercial-paper"
# Corporate bond spot rates
curl -sk "$BASE/api/v1/fixed-income/spot-rates"
# Treasury/corporate spreads
curl -sk "$BASE/api/v1/fixed-income/spreads?series=tcm"
```
## Macro Economics
```bash
# Key indicators: Fed rate, treasury yields, CPI, unemployment, GDP, VIX
curl -sk "$BASE/api/v1/macro/overview"
# Any FRED time series by ID
curl -sk "$BASE/api/v1/macro/series/DFF?limit=30" # Fed Funds Rate
curl -sk "$BASE/api/v1/macro/series/CPIAUCSL?limit=12" # CPI
# Structured macro data (multi-country support)
curl -sk "$BASE/api/v1/macro/cpi?country=united_states"
curl -sk "$BASE/api/v1/macro/gdp?gdp_type=real"
curl -sk "$BASE/api/v1/macro/unemployment?country=united_states"
curl -sk "$BASE/api/v1/macro/pce" # Fed preferred inflation
curl -sk "$BASE/api/v1/macro/money-measures" # M1/M2 money supply
curl -sk "$BASE/api/v1/macro/cli?country=united_states" # Leading indicator
curl -sk "$BASE/api/v1/macro/house-price-index?country=united_states"
```
## Economy Data & Surveys
```bash
# Search 800K+ FRED economic series
curl -sk "$BASE/api/v1/economy/fred-search?query=inflation"
# Regional economic data (by state/county)
curl -sk "$BASE/api/v1/economy/fred-regional?series_id=UNRATE"
# Fed balance sheet / SOMA portfolio
curl -sk "$BASE/api/v1/economy/central-bank-holdings"
# Primary dealer positions (Wall Street firm positioning)
curl -sk "$BASE/api/v1/economy/primary-dealer-positioning"
# Balance of payments
curl -sk "$BASE/api/v1/economy/balance-of-payments"
# FOMC meeting documents
curl -sk "$BASE/api/v1/economy/fomc-documents?year=2026"
# Consumer sentiment (recession predictor)
curl -sk "$BASE/api/v1/economy/surveys/michigan"
# Senior Loan Officer Survey (strongest recession signal)
curl -sk "$BASE/api/v1/economy/surveys/sloos"
# Detailed employment data
curl -sk "$BASE/api/v1/economy/surveys/nonfarm-payrolls"
# NY manufacturing outlook
curl -sk "$BASE/api/v1/economy/surveys/empire-state"
# BLS data search
curl -sk "$BASE/api/v1/economy/surveys/bls-search?query=wages"
```
## Market Data
```bash
# ETF
curl -sk "$BASE/api/v1/etf/SPY/info"
curl -sk "$BASE/api/v1/etf/SPY/historical?days=365"
curl -sk "$BASE/api/v1/etf/SPY/nport" # Detailed N-PORT holdings
curl -sk "$BASE/api/v1/etf/search?query=technology"
# Index
curl -sk "$BASE/api/v1/index/available"
curl -sk "$BASE/api/v1/index/%5EGSPC/historical?days=365" # S&P 500
curl -sk "$BASE/api/v1/index/sp500-multiples?series=pe_ratio" # Historical S&P 500 valuation
curl -sk "$BASE/api/v1/index/%5EGSPC/constituents" # Index member stocks
# Crypto
curl -sk "$BASE/api/v1/crypto/BTC-USD/historical?days=30"
curl -sk "$BASE/api/v1/crypto/search?query=bitcoin"
# Forex
curl -sk "$BASE/api/v1/currency/USDSEK/historical?days=365"
curl -sk "$BASE/api/v1/currency/reference-rates" # ECB rates (28 currencies)
# Options
curl -sk "$BASE/api/v1/options/AAPL/chains"
# Futures
curl -sk "$BASE/api/v1/futures/CL=F/historical?days=365"
curl -sk "$BASE/api/v1/futures/CL=F/curve"
```
## Regulators (no key needed)
```bash
# Commitment of Traders (futures positions)
curl -sk "$BASE/api/v1/regulators/cot?symbol=GC=F"
curl -sk "$BASE/api/v1/regulators/cot/search?query=gold"
# SEC litigation releases
curl -sk "$BASE/api/v1/regulators/sec/litigation"
# Search institutional investors
curl -sk "$BASE/api/v1/regulators/sec/institutions?query=berkshire"
# Ticker to CIK mapping
curl -sk "$BASE/api/v1/regulators/sec/cik-map/AAPL"
```
## Calendar Events
```bash
curl -sk "$BASE/api/v1/calendar/earnings?start_date=2026-03-10&end_date=2026-03-17"
curl -sk "$BASE/api/v1/calendar/dividends?start_date=2026-03-10&end_date=2026-03-17"
curl -sk "$BASE/api/v1/calendar/ipo?start_date=2026-03-01&end_date=2026-03-31"
curl -sk "$BASE/api/v1/calendar/splits?start_date=2026-03-01&end_date=2026-03-31"
```
## Stock Discovery
```bash
curl -sk "$BASE/api/v1/discover/gainers" # Top gainers
curl -sk "$BASE/api/v1/discover/losers" # Top losers
curl -sk "$BASE/api/v1/discover/active" # Most active
curl -sk "$BASE/api/v1/discover/undervalued" # Undervalued large caps
curl -sk "$BASE/api/v1/discover/growth" # Growth tech stocks
```
## Estimates & Ownership
```bash
curl -sk "$BASE/api/v1/stock/AAPL/estimates" # Analyst consensus
curl -sk "$BASE/api/v1/stock/AAPL/share-statistics" # Float, short interest
curl -sk "$BASE/api/v1/stock/AAPL/sec-insider" # SEC Form 4
curl -sk "$BASE/api/v1/stock/AAPL/institutional" # 13F institutional holders
curl -sk "$BASE/api/v1/screener" # Stock screener
```
## Recommended Workflows
### Comprehensive Stock Analysis
1. `/stock/{symbol}/summary` -- fundamentals overview
2. `/stock/{symbol}/technical` -- composite technical signals
3. `/stock/{symbol}/technical/ichimoku` -- trend system
4. `/stock/{symbol}/performance` -- Sharpe, volatility
5. `/stock/{symbol}/sortino` -- downside risk
6. `/stock/{symbol}/shorts/volume` -- short selling pressure
7. `/stock/{symbol}/sentiment` -- market sentiment
8. `/stock/{symbol}/sec-insider` -- insider activity
9. `/stock/{symbol}/upgrades` -- analyst actions + price targets
10. `/macro/overview` -- market context
11. `/portfolio/analyze` -- rule-engine recommendation
### Macro & Fixed Income Analysis
1. `/macro/overview` -- headline indicators
2. `/fixed-income/yield-curve` -- rate environment
3. `/fixed-income/effr` -- Fed funds rate detail
4. `/macro/cpi` -- inflation
5. `/macro/pce` -- Fed preferred inflation
6. `/macro/money-measures` -- M1/M2 money supply
7. `/economy/surveys/michigan` -- consumer confidence
8. `/economy/surveys/sloos` -- lending conditions
9. `/macro/cli` -- leading indicator (recession risk)
### Sector Rotation Analysis
1. `/technical/relative-rotation?symbols=XLK,XLF,XLE,XLV&benchmark=SPY` -- RRG quadrants
2. `/index/sp500-multiples?series=shiller_pe_ratio` -- market valuation context
3. `/discover/gainers` + `/discover/losers` -- daily movers
### Short Squeeze Screening
1. `/stock/{symbol}/shorts/volume` -- short volume %
2. `/stock/{symbol}/shorts/interest` -- days to cover
3. `/stock/{symbol}/shorts/ftd` -- fails to deliver
4. `/darkpool/{symbol}/otc` -- dark pool activity
5. `/stock/{symbol}/share-statistics` -- float, short % of float
Synthesize all data into a coherent recommendation. The API provides structured data only -- all reasoning and natural language analysis should be done by the LLM.
## Notes
- Prices are delayed (not real-time); yfinance data is typically 15-20 min delayed
- Swedish stocks use `.ST` suffix (Stockholm exchange)
- Portfolio analysis uses a rule engine (PE, revenue growth, P&L, analyst targets) returning BUY_MORE/HOLD/SELL
- The `-k` flag is needed because the API uses a self-signed TLS certificate
- Multi-country support for CPI, GDP, unemployment, CLI, HPI (use `country=united_states`, `country=japan`, etc.)
- Health check: `curl -sk "$BASE/health"`

View File

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

185
economy_service.py Normal file
View File

@@ -0,0 +1,185 @@
"""Economy data: FRED search, regional data, Fed holdings, FOMC documents."""
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_cpi(country: str = "united_states") -> list[dict[str, Any]]:
"""Get Consumer Price Index data."""
try:
result = await asyncio.to_thread(
obb.economy.cpi, country=country, provider="fred"
)
return to_list(result)
except Exception:
logger.warning("CPI failed for %s", country, exc_info=True)
return []
_VALID_GDP_TYPES = {"nominal", "real", "forecast"}
async def get_gdp(gdp_type: str = "real") -> list[dict[str, Any]]:
"""Get GDP data (nominal, real, or forecast)."""
if gdp_type not in _VALID_GDP_TYPES:
return []
try:
fn = getattr(obb.economy.gdp, gdp_type, None)
if fn is None:
return []
result = await asyncio.to_thread(fn, provider="oecd")
return to_list(result)
except Exception:
logger.warning("GDP %s failed", gdp_type, exc_info=True)
return []
async def get_unemployment(country: str = "united_states") -> list[dict[str, Any]]:
"""Get unemployment rate data."""
try:
result = await asyncio.to_thread(
obb.economy.unemployment, country=country, provider="oecd"
)
return to_list(result)
except Exception:
logger.warning("Unemployment failed for %s", country, exc_info=True)
return []
async def get_composite_leading_indicator(
country: str = "united_states",
) -> list[dict[str, Any]]:
"""Get Composite Leading Indicator (recession predictor)."""
try:
result = await asyncio.to_thread(
obb.economy.composite_leading_indicator, country=country, provider="oecd"
)
return to_list(result)
except Exception:
logger.warning("CLI failed for %s", country, exc_info=True)
return []
async def get_house_price_index(
country: str = "united_states",
) -> list[dict[str, Any]]:
"""Get housing price index."""
try:
result = await asyncio.to_thread(
obb.economy.house_price_index, country=country, provider="oecd"
)
return to_list(result)
except Exception:
logger.warning("HPI failed for %s", country, exc_info=True)
return []
async def get_pce() -> list[dict[str, Any]]:
"""Get Personal Consumption Expenditures (Fed preferred inflation)."""
try:
result = await asyncio.to_thread(
obb.economy.pce, provider="fred"
)
return to_list(result)
except Exception:
logger.warning("PCE failed", exc_info=True)
return []
async def get_money_measures() -> list[dict[str, Any]]:
"""Get M1/M2 money supply data."""
try:
result = await asyncio.to_thread(
obb.economy.money_measures, provider="federal_reserve"
)
return to_list(result)
except Exception:
logger.warning("Money measures failed", exc_info=True)
return []
async def fred_search(query: str) -> list[dict[str, Any]]:
"""Search FRED series by keyword."""
try:
result = await asyncio.to_thread(
obb.economy.fred_search, query, provider="fred"
)
return to_list(result)
except Exception:
logger.warning("FRED search failed for %s", query, exc_info=True)
return []
async def get_balance_of_payments() -> list[dict[str, Any]]:
"""Get balance of payments (current/capital/financial account)."""
try:
result = await asyncio.to_thread(
obb.economy.balance_of_payments, provider="fred"
)
return to_list(result)
except Exception:
logger.warning("Balance of payments failed", exc_info=True)
return []
async def get_central_bank_holdings() -> list[dict[str, Any]]:
"""Get Fed SOMA portfolio holdings."""
try:
result = await asyncio.to_thread(
obb.economy.central_bank_holdings, provider="federal_reserve"
)
return to_list(result)
except Exception:
logger.warning("Central bank holdings failed", exc_info=True)
return []
async def get_fred_regional(
series_id: str, region: str | None = None,
) -> list[dict[str, Any]]:
"""Get geographically disaggregated FRED data (by state, county, MSA)."""
try:
kwargs: dict[str, Any] = {"symbol": series_id, "provider": "fred"}
if region:
kwargs["region_type"] = region
result = await asyncio.to_thread(
obb.economy.fred_regional, **kwargs
)
return to_list(result)
except Exception:
logger.warning("FRED regional failed for %s", series_id, exc_info=True)
return []
async def get_primary_dealer_positioning() -> list[dict[str, Any]]:
"""Get primary dealer net positions in treasuries, MBS, corporate bonds."""
try:
result = await asyncio.to_thread(
obb.economy.primary_dealer_positioning, provider="federal_reserve"
)
return to_list(result)
except Exception:
logger.warning("Primary dealer positioning failed", exc_info=True)
return []
async def get_fomc_documents(year: int | None = None) -> list[dict[str, Any]]:
"""Get FOMC meeting documents (minutes, projections, etc.)."""
try:
kwargs: dict[str, Any] = {"provider": "federal_reserve"}
if year is not None:
kwargs["year"] = year
result = await asyncio.to_thread(
obb.economy.fomc_documents, **kwargs
)
return to_list(result)
except Exception:
logger.warning("FOMC documents failed", exc_info=True)
return []

29
findings.md Normal file
View File

@@ -0,0 +1,29 @@
# Research Findings
## Architecture Analysis (2026-03-19)
### Current Codebase
- 22 Python files, flat layout, all under 250 lines
- Pattern: service file (async OpenBB wrapper) + route file (FastAPI router with @safe decorator)
- Shared utils: obb_utils.py, route_utils.py, mappers.py, models.py
### Technical Debt
1. Duplicated `_to_dicts` in openbb_service.py and macro_service.py (same as obb_utils.to_list)
2. calendar_service.py has scope creep (ownership, screening mixed with calendar events)
3. No shared `fetch_historical` helper (duplicated in technical_service.py and quantitative_service.py)
### Provider Availability (Verified)
- **No API key needed:** yfinance, stockgrid, finra, multpl, cftc, government_us, sec, ecb, cboe
- **Already configured:** fred, finnhub, alphavantage
- **Not needed:** fmp (removed), intrinio, tiingo, benzinga
### Key Design Decisions
- Keep flat file layout (avoid breaking all imports for ~40 files)
- Domain-prefixed naming for new files
- Generic technical indicator dispatcher pattern for 14 new indicators
- Consolidate _to_dicts before adding new services
### OpenBB Features Discovered
- 67 new endpoints across 10 groups (A-J)
- 3 Small, 4 Medium, 3 Large complexity groups
- All use free providers (no new API keys required)

143
fixed_income_service.py Normal file
View File

@@ -0,0 +1,143 @@
"""Fixed income data: treasury rates, yield curve, auctions, corporate bonds."""
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_treasury_rates() -> list[dict[str, Any]]:
"""Get full treasury yield curve rates (4W-30Y)."""
try:
result = await asyncio.to_thread(
obb.fixedincome.government.treasury_rates, provider="federal_reserve"
)
return to_list(result)
except Exception:
logger.warning("Treasury rates failed", exc_info=True)
return []
async def get_yield_curve(date: str | None = None) -> list[dict[str, Any]]:
"""Get yield curve with maturity/rate pairs."""
try:
kwargs: dict[str, Any] = {"provider": "federal_reserve"}
if date:
kwargs["date"] = date
result = await asyncio.to_thread(
obb.fixedincome.government.yield_curve, **kwargs
)
return to_list(result)
except Exception:
logger.warning("Yield curve failed", exc_info=True)
return []
async def get_treasury_auctions(security_type: str | None = None) -> list[dict[str, Any]]:
"""Get treasury auction data (bid-to-cover, yields)."""
try:
kwargs: dict[str, Any] = {"provider": "government_us"}
if security_type:
kwargs["security_type"] = security_type
result = await asyncio.to_thread(
obb.fixedincome.government.treasury_auctions, **kwargs
)
return to_list(result)
except Exception:
logger.warning("Treasury auctions failed", exc_info=True)
return []
async def get_tips_yields() -> list[dict[str, Any]]:
"""Get TIPS real yields by maturity."""
try:
result = await asyncio.to_thread(
obb.fixedincome.government.tips_yields, provider="fred"
)
return to_list(result)
except Exception:
logger.warning("TIPS yields failed", exc_info=True)
return []
async def get_effr() -> list[dict[str, Any]]:
"""Get Effective Federal Funds Rate with percentiles."""
try:
result = await asyncio.to_thread(
obb.fixedincome.rate.effr, provider="federal_reserve"
)
return to_list(result)
except Exception:
logger.warning("EFFR failed", exc_info=True)
return []
async def get_sofr() -> list[dict[str, Any]]:
"""Get SOFR rate with moving averages."""
try:
result = await asyncio.to_thread(
obb.fixedincome.rate.sofr, provider="federal_reserve"
)
return to_list(result)
except Exception:
logger.warning("SOFR failed", exc_info=True)
return []
async def get_hqm() -> list[dict[str, Any]]:
"""Get High Quality Market corporate bond yields."""
try:
result = await asyncio.to_thread(
obb.fixedincome.corporate.hqm, provider="fred"
)
return to_list(result)
except Exception:
logger.warning("HQM failed", exc_info=True)
return []
async def get_commercial_paper() -> list[dict[str, Any]]:
"""Get commercial paper rates by maturity and type."""
try:
result = await asyncio.to_thread(
obb.fixedincome.corporate.commercial_paper, provider="fred"
)
return to_list(result)
except Exception:
logger.warning("Commercial paper failed", exc_info=True)
return []
async def get_spot_rates() -> list[dict[str, Any]]:
"""Get corporate bond spot rates."""
try:
result = await asyncio.to_thread(
obb.fixedincome.corporate.spot_rates, provider="fred"
)
return to_list(result)
except Exception:
logger.warning("Spot rates failed", exc_info=True)
return []
_VALID_SPREAD_SERIES = {"tcm", "tcm_effr", "treasury_effr"}
async def get_spreads(series: str = "tcm") -> list[dict[str, Any]]:
"""Get treasury/corporate spreads (tcm, tcm_effr, treasury_effr)."""
if series not in _VALID_SPREAD_SERIES:
return []
try:
fn = getattr(obb.fixedincome.spreads, series, None)
if fn is None:
return []
result = await asyncio.to_thread(fn, provider="fred")
return to_list(result)
except Exception:
logger.warning("Spreads %s failed", series, exc_info=True)
return []

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

@@ -8,3 +8,5 @@ resources:
- secret.yaml
- deployment.yaml
- service.yaml
- ingress.yaml

View File

@@ -5,7 +5,6 @@ metadata:
namespace: invest-api
type: Opaque
stringData:
# Replace with your actual keys before applying, or use sealed-secrets / external-secrets
INVEST_API_FINNHUB_API_KEY: ""
INVEST_API_FRED_API_KEY: ""
INVEST_API_ALPHAVANTAGE_API_KEY: ""
INVEST_API_FINNHUB_API_KEY: "d6n0109r01qir35in7d0d6n0109r01qir35in7dg"
INVEST_API_FRED_API_KEY: "c5388b7f4a50694adc68bfc6690ac844"
INVEST_API_ALPHAVANTAGE_API_KEY: "BM4NQG2BQ33MAFZH"

View File

@@ -6,6 +6,8 @@ from typing import Any
from openbb import obb
from obb_utils import to_list
logger = logging.getLogger(__name__)
PROVIDER = "fred"
@@ -23,32 +25,31 @@ SERIES = {
}
def _to_dicts(result: Any) -> list[dict[str, Any]]:
if result is None or result.results is None:
return []
if isinstance(result.results, list):
return [
item.model_dump() if hasattr(item, "model_dump") else vars(item)
for item in result.results
]
if hasattr(result.results, "model_dump"):
return [result.results.model_dump()]
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 = to_list(result)
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 +59,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:

58
main.py
View File

@@ -1,27 +1,64 @@
import logging
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from config import settings
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
# 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 # noqa: E402 - must be after curl_cffi patch
from config import settings # noqa: E402
from routes import router # noqa: E402
from routes_calendar import router as calendar_router # noqa: E402
from routes_economy import router as economy_router # noqa: E402
from routes_fixed_income import router as fixed_income_router # noqa: E402
from routes_macro import router as macro_router # noqa: E402
from routes_market import router as market_router # noqa: E402
from routes_quantitative import router as quantitative_router # noqa: E402
from routes_regulators import router as regulators_router # noqa: E402
from routes_sentiment import router as sentiment_router # noqa: E402
from routes_shorts import router as shorts_router # noqa: E402
from routes_surveys import router as surveys_router # noqa: E402
from routes_technical import router as technical_router # noqa: E402
logging.basicConfig(
level=settings.log_level.upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Register provider credentials once at startup."""
if settings.fred_api_key:
obb.user.credentials.fred_api_key = settings.fred_api_key
logger.info("FRED API key registered")
yield
app = FastAPI(
title="OpenBB Investment Analysis API",
version="0.1.0",
description="REST API for stock data and rule-based investment analysis, powered by OpenBB SDK.",
lifespan=lifespan,
)
app.add_middleware(
@@ -39,6 +76,11 @@ app.include_router(technical_router)
app.include_router(quantitative_router)
app.include_router(calendar_router)
app.include_router(market_router)
app.include_router(shorts_router)
app.include_router(fixed_income_router)
app.include_router(economy_router)
app.include_router(surveys_router)
app.include_router(regulators_router)
@app.get("/health", response_model=dict[str, str])

View File

@@ -161,3 +161,57 @@ async def get_futures_curve(symbol: str) -> list[dict[str, Any]]:
except Exception:
logger.warning("Futures curve failed for %s", symbol, exc_info=True)
return []
# --- Currency Reference Rates (Group H) ---
async def get_currency_reference_rates() -> list[dict[str, Any]]:
"""Get ECB reference exchange rates for major currencies."""
try:
result = await asyncio.to_thread(
obb.currency.reference_rates, provider="ecb"
)
return to_list(result)
except Exception:
logger.warning("Currency reference rates failed", exc_info=True)
return []
# --- Index Enhanced (Group F) ---
async def get_sp500_multiples(series_name: str = "pe_ratio") -> list[dict[str, Any]]:
"""Get historical S&P 500 valuation multiples (PE, Shiller PE, P/B, etc.)."""
try:
result = await asyncio.to_thread(
obb.index.sp500_multiples, series_name=series_name, provider="multpl"
)
return to_list(result)
except Exception:
logger.warning("SP500 multiples failed for %s", series_name, exc_info=True)
return []
async def get_index_constituents(symbol: str) -> list[dict[str, Any]]:
"""Get index member stocks with sector and price data."""
try:
result = await asyncio.to_thread(
obb.index.constituents, symbol, provider="cboe"
)
return to_list(result)
except Exception:
logger.warning("Index constituents failed for %s", symbol, exc_info=True)
return []
async def get_etf_nport(symbol: str) -> list[dict[str, Any]]:
"""Get detailed ETF holdings from SEC N-PORT filings."""
try:
result = await asyncio.to_thread(
obb.etf.nport_disclosure, symbol, provider="sec"
)
return to_list(result)
except Exception:
logger.warning("ETF N-PORT failed for %s", symbol, exc_info=True)
return []

View File

@@ -1,7 +1,16 @@
"""Shared OpenBB result conversion utilities."""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
from openbb import obb
logger = logging.getLogger(__name__)
PROVIDER = "yfinance"
def to_list(result: Any) -> list[dict[str, Any]]:
"""Convert OBBject result to list of dicts with serialized dates."""
@@ -49,3 +58,26 @@ def safe_last(result: Any) -> dict[str, Any] | None:
return None
last = items[-1]
return last.model_dump() if hasattr(last, "model_dump") else None
def first_or_empty(result: Any) -> dict[str, Any]:
"""Get first result as dict, or empty dict."""
items = to_list(result)
return items[0] if items else {}
async def fetch_historical(
symbol: str, days: int = 365, provider: str = PROVIDER,
) -> Any | None:
"""Fetch historical price data, returning the OBBject result or None."""
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
try:
result = await asyncio.to_thread(
obb.equity.price.historical, symbol, start_date=start, provider=provider,
)
except Exception:
logger.warning("Historical fetch failed for %s", symbol, exc_info=True)
return None
if result is None or result.results is None:
return None
return result

View File

@@ -1,51 +1,34 @@
import asyncio
import logging
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Any
import yfinance as yf
from openbb import obb
from obb_utils import to_list, first_or_empty
logger = logging.getLogger(__name__)
PROVIDER = "yfinance"
def _to_dicts(result: Any) -> list[dict[str, Any]]:
"""Convert OBBject results to list of dicts."""
if result is None or result.results is None:
return []
if isinstance(result.results, list):
return [
item.model_dump() if hasattr(item, "model_dump") else vars(item)
for item in result.results
]
if hasattr(result.results, "model_dump"):
return [result.results.model_dump()]
return [vars(result.results)]
def _first_or_empty(result: Any) -> dict[str, Any]:
"""Get first result as dict, or empty dict."""
items = _to_dicts(result)
return items[0] if items else {}
async def get_quote(symbol: str) -> dict:
async def get_quote(symbol: str) -> dict[str, Any]:
result = await asyncio.to_thread(
obb.equity.price.quote, symbol, provider=PROVIDER
)
return _first_or_empty(result)
return first_or_empty(result)
async def get_historical(symbol: str, days: int = 365) -> list[dict]:
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
async def get_historical(symbol: str, days: int = 365) -> list[dict[str, Any]]:
start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
result = await asyncio.to_thread(
obb.equity.price.historical,
symbol,
start_date=start,
provider=PROVIDER,
)
items = _to_dicts(result)
items = to_list(result)
return [
{**item, "date": str(item["date"])}
if "date" in item and not isinstance(item["date"], str)
@@ -54,42 +37,42 @@ async def get_historical(symbol: str, days: int = 365) -> list[dict]:
]
async def get_profile(symbol: str) -> dict:
async def get_profile(symbol: str) -> dict[str, Any]:
result = await asyncio.to_thread(
obb.equity.profile, symbol, provider=PROVIDER
)
return _first_or_empty(result)
return first_or_empty(result)
async def get_metrics(symbol: str) -> dict:
async def get_metrics(symbol: str) -> dict[str, Any]:
result = await asyncio.to_thread(
obb.equity.fundamental.metrics, symbol, provider=PROVIDER
)
return _first_or_empty(result)
return first_or_empty(result)
async def get_income(symbol: str) -> list[dict]:
async def get_income(symbol: str) -> list[dict[str, Any]]:
result = await asyncio.to_thread(
obb.equity.fundamental.income, symbol, provider=PROVIDER
)
return _to_dicts(result)
return to_list(result)
async def get_balance(symbol: str) -> list[dict]:
async def get_balance(symbol: str) -> list[dict[str, Any]]:
result = await asyncio.to_thread(
obb.equity.fundamental.balance, symbol, provider=PROVIDER
)
return _to_dicts(result)
return to_list(result)
async def get_cash_flow(symbol: str) -> list[dict]:
async def get_cash_flow(symbol: str) -> list[dict[str, Any]]:
result = await asyncio.to_thread(
obb.equity.fundamental.cash, symbol, provider=PROVIDER
)
return _to_dicts(result)
return to_list(result)
async def get_financials(symbol: str) -> dict:
async def get_financials(symbol: str) -> dict[str, Any]:
income, balance, cash_flow = await asyncio.gather(
get_income(symbol),
get_balance(symbol),
@@ -104,26 +87,26 @@ async def get_financials(symbol: str) -> dict:
async def get_price_target(symbol: str) -> float | None:
"""Get consensus analyst price target via yfinance."""
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
async def get_news(symbol: str) -> list[dict]:
async def get_news(symbol: str) -> list[dict[str, Any]]:
result = await asyncio.to_thread(
obb.news.company, symbol, provider=PROVIDER
)
return _to_dicts(result)
return to_list(result)
async def get_summary(symbol: str) -> dict:
async def get_summary(symbol: str) -> dict[str, Any]:
quote, profile, metrics, financials = await asyncio.gather(
get_quote(symbol),
get_profile(symbol),
@@ -138,36 +121,119 @@ async def get_summary(symbol: str) -> dict:
}
async def get_gainers() -> list[dict]:
async def get_gainers() -> list[dict[str, Any]]:
result = await asyncio.to_thread(
obb.equity.discovery.gainers, provider=PROVIDER
)
return _to_dicts(result)
return to_list(result)
async def get_losers() -> list[dict]:
async def get_losers() -> list[dict[str, Any]]:
result = await asyncio.to_thread(
obb.equity.discovery.losers, provider=PROVIDER
)
return _to_dicts(result)
return to_list(result)
async def get_active() -> list[dict]:
async def get_active() -> list[dict[str, Any]]:
result = await asyncio.to_thread(
obb.equity.discovery.active, provider=PROVIDER
)
return _to_dicts(result)
return to_list(result)
async def get_undervalued() -> list[dict]:
async def get_undervalued() -> list[dict[str, Any]]:
result = await asyncio.to_thread(
obb.equity.discovery.undervalued_large_caps, provider=PROVIDER
)
return _to_dicts(result)
return to_list(result)
async def get_growth() -> list[dict]:
async def get_growth() -> list[dict[str, Any]]:
result = await asyncio.to_thread(
obb.equity.discovery.growth_tech, provider=PROVIDER
)
return _to_dicts(result)
return to_list(result)
async def get_upgrades_downgrades(
symbol: str, limit: int = 20,
) -> list[dict[str, Any]]:
"""Get analyst upgrades/downgrades via yfinance."""
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)
# --- Equity Fundamentals Extended (Group B) ---
async def get_management(symbol: str) -> list[dict[str, Any]]:
"""Get executive team info (name, title, compensation)."""
try:
result = await asyncio.to_thread(
obb.equity.fundamental.management, symbol, provider=PROVIDER
)
return to_list(result)
except Exception:
logger.warning("Management failed for %s", symbol, exc_info=True)
return []
async def get_dividends(symbol: str) -> list[dict[str, Any]]:
"""Get historical dividend records."""
try:
result = await asyncio.to_thread(
obb.equity.fundamental.dividends, symbol, provider=PROVIDER
)
return to_list(result)
except Exception:
logger.warning("Dividends failed for %s", symbol, exc_info=True)
return []
async def get_filings(
symbol: str, form_type: str | None = None,
) -> list[dict[str, Any]]:
"""Get SEC filings (10-K, 10-Q, 8-K, etc.)."""
try:
kwargs: dict[str, Any] = {"symbol": symbol, "provider": "sec"}
if form_type is not None:
kwargs["type"] = form_type
result = await asyncio.to_thread(
obb.equity.fundamental.filings, **kwargs
)
return to_list(result)
except Exception:
logger.warning("Filings failed for %s", symbol, exc_info=True)
return []
async def search_company(query: str) -> list[dict[str, Any]]:
"""Search for companies by name."""
try:
result = await asyncio.to_thread(
obb.equity.search, query, provider="sec"
)
return to_list(result)
except Exception:
logger.warning("Company search failed for %s", query, exc_info=True)
return []

38
progress.md Normal file
View File

@@ -0,0 +1,38 @@
# Progress Log
## Session 2026-03-19
### Completed
- [x] Fixed Dockerfile SSL issue (libssl3 runtime dep)
- [x] Fixed curl_cffi TLS error (pin 0.7.4, safari fingerprint patch)
- [x] Registered FRED API key with OpenBB credentials
- [x] Fixed macro_service to return latest data (not oldest)
- [x] Switched upgrades endpoint from Finnhub to yfinance
- [x] Switched price_target from FMP to yfinance
- [x] Tested all 32 endpoints locally and on deployed environment
- [x] Updated README
- [x] Researched OpenBB features for expansion (67 new endpoints identified)
- [x] Architecture analysis complete
- [x] Implementation plan created (task_plan.md)
### Implementation Progress
- [x] P0: Consolidated `_to_dicts` -> `obb_utils.to_list` in openbb_service.py and macro_service.py
- [x] P0: Added `fetch_historical` and `first_or_empty` to obb_utils.py
- [x] P0: Updated technical_service.py and quantitative_service.py to use shared helpers
- [x] Phase 1 Group I: 12 new technical indicators (ATR, ADX, Stoch, OBV, Ichimoku, Donchian, Aroon, CCI, KC, Fib, A/D, Cones)
- [x] Phase 1 Group J: Sortino, Omega, rolling stats (6 stats via generic endpoint)
- [x] Phase 1 Group H: Currency reference rates (ECB)
- [x] Phase 2 Group C: Fixed income (10 endpoints) - new service + routes
- [x] Phase 2 Group D: Economy expanded (11 endpoints) - new service + routes
- [x] Phase 2 Group E: Surveys (5 endpoints) - new service + routes
- [x] Phase 3 Group B: Equity fundamentals (4 endpoints) - management, dividends, filings, search
- [x] Phase 3 Group A: Shorts & dark pool (4 endpoints) - new service + routes
- [x] Phase 3 Group F: Index/ETF enhanced (3 endpoints) - sp500 multiples, constituents, nport
- [x] Phase 4 Group G: Regulators (5 endpoints) - COT, SEC litigation, institutions
- [x] All 5 new routers registered in main.py
- [x] App imports verified: 108 routes total
### Current State
- 108 total routes (including OpenAPI/docs)
- Code reviewer and security reviewer running in background
- Pending: review feedback, testing, commit

View File

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

View File

@@ -7,7 +7,7 @@ from typing import Any
from openbb import obb
from obb_utils import extract_single, safe_last
from obb_utils import extract_single, safe_last, fetch_historical, to_list
logger = logging.getLogger(__name__)
@@ -122,3 +122,74 @@ async def get_unitroot_test(symbol: str, days: int = 365) -> dict[str, Any]:
except Exception:
logger.warning("Unit root test failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute unit root test"}
# --- Extended Quantitative (Phase 1, Group J) ---
async def get_sortino(symbol: str, days: int = 365) -> dict[str, Any]:
"""Sortino ratio -- risk-adjusted return penalizing only downside deviation."""
fetch_days = max(days, PERF_DAYS)
hist = await fetch_historical(symbol, fetch_days)
if hist is None:
return {"symbol": symbol, "error": "No historical data"}
try:
result = await asyncio.to_thread(
obb.quantitative.performance.sortino_ratio,
data=hist.results, target=TARGET,
)
return {"symbol": symbol, "period_days": days, "sortino": safe_last(result)}
except Exception:
logger.warning("Sortino failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute Sortino ratio"}
async def get_omega(symbol: str, days: int = 365) -> dict[str, Any]:
"""Omega ratio -- probability-weighted gain vs loss ratio."""
fetch_days = max(days, PERF_DAYS)
hist = await fetch_historical(symbol, fetch_days)
if hist is None:
return {"symbol": symbol, "error": "No historical data"}
try:
result = await asyncio.to_thread(
obb.quantitative.performance.omega_ratio,
data=hist.results, target=TARGET,
)
return {"symbol": symbol, "period_days": days, "omega": safe_last(result)}
except Exception:
logger.warning("Omega failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute Omega ratio"}
async def get_rolling_stat(
symbol: str, stat: str, days: int = 365, window: int = 30,
) -> dict[str, Any]:
"""Compute a rolling statistic (variance, stdev, mean, skew, kurtosis, quantile)."""
valid_stats = {"variance", "stdev", "mean", "skew", "kurtosis", "quantile"}
if stat not in valid_stats:
return {"symbol": symbol, "error": f"Invalid stat. Valid options: {', '.join(sorted(valid_stats))}"}
fetch_days = max(days, PERF_DAYS)
hist = await fetch_historical(symbol, fetch_days)
if hist is None:
return {"symbol": symbol, "error": "No historical data"}
try:
fn = getattr(obb.quantitative.rolling, stat, None)
if fn is None or not callable(fn):
return {"symbol": symbol, "error": f"Stat '{stat}' not available"}
result = await asyncio.to_thread(
fn, data=hist.results, target=TARGET, window=window,
)
items = to_list(result)
# Return last N items matching the requested window
tail = items[-window:] if len(items) > window else items
return {
"symbol": symbol,
"stat": stat,
"window": window,
"period_days": days,
"data": tail,
}
except Exception:
logger.warning("Rolling %s failed for %s", stat, symbol, exc_info=True)
return {"symbol": symbol, "error": f"Failed to compute rolling {stat}"}

71
regulators_service.py Normal file
View File

@@ -0,0 +1,71 @@
"""Regulatory data: CFTC COT reports, SEC litigation, institutional data."""
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_cot(symbol: str) -> list[dict[str, Any]]:
"""Get Commitment of Traders report for a futures symbol."""
try:
result = await asyncio.to_thread(
obb.regulators.cftc.cot, symbol, provider="cftc"
)
return to_list(result)
except Exception:
logger.warning("COT failed for %s", symbol, exc_info=True)
return []
async def cot_search(query: str) -> list[dict[str, Any]]:
"""Search COT report symbols."""
try:
result = await asyncio.to_thread(
obb.regulators.cftc.cot_search, query, provider="cftc"
)
return to_list(result)
except Exception:
logger.warning("COT search failed for %s", query, exc_info=True)
return []
async def get_sec_litigation() -> list[dict[str, Any]]:
"""Get SEC litigation releases."""
try:
result = await asyncio.to_thread(
obb.regulators.sec.rss_litigation, provider="sec"
)
return to_list(result)
except Exception:
logger.warning("SEC litigation failed", exc_info=True)
return []
async def search_institutions(query: str) -> list[dict[str, Any]]:
"""Search for institutional investors filing with SEC."""
try:
result = await asyncio.to_thread(
obb.regulators.sec.institutions_search, query, provider="sec"
)
return to_list(result)
except Exception:
logger.warning("Institution search failed for %s", query, exc_info=True)
return []
async def get_cik_map(symbol: str) -> list[dict[str, Any]]:
"""Map ticker symbol to CIK number."""
try:
result = await asyncio.to_thread(
obb.regulators.sec.cik_map, symbol, provider="sec"
)
return to_list(result)
except Exception:
logger.warning("CIK map failed for %s", symbol, exc_info=True)
return []

View File

@@ -173,3 +173,44 @@ async def discover_growth():
"""Get growth tech stocks."""
data = await openbb_service.get_growth()
return ApiResponse(data=discover_items_from_list(data))
# --- Equity Fundamentals Extended (Group B) ---
@router.get("/stock/{symbol}/management", response_model=ApiResponse)
@safe
async def stock_management(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get executive team: name, title, compensation."""
symbol = validate_symbol(symbol)
data = await openbb_service.get_management(symbol)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/dividends", response_model=ApiResponse)
@safe
async def stock_dividends(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get historical dividend records."""
symbol = validate_symbol(symbol)
data = await openbb_service.get_dividends(symbol)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/filings", response_model=ApiResponse)
@safe
async def stock_filings(
symbol: str = Path(..., min_length=1, max_length=20),
form_type: str = Query(default=None, max_length=20, pattern=r"^[A-Za-z0-9/-]+$"),
):
"""Get SEC filings (10-K, 10-Q, 8-K, etc.)."""
symbol = validate_symbol(symbol)
data = await openbb_service.get_filings(symbol, form_type=form_type)
return ApiResponse(data=data)
@router.get("/search", response_model=ApiResponse)
@safe
async def company_search(query: str = Query(..., min_length=1, max_length=100)):
"""Search for companies by name (SEC/NASDAQ)."""
data = await openbb_service.search_company(query)
return ApiResponse(data=data)

126
routes_economy.py Normal file
View File

@@ -0,0 +1,126 @@
"""Routes for expanded economy data."""
from fastapi import APIRouter, Query
from models import ApiResponse
from route_utils import safe
import economy_service
router = APIRouter(prefix="/api/v1")
# --- Structured macro indicators (Group D) ---
@router.get("/macro/cpi", response_model=ApiResponse)
@safe
async def macro_cpi(country: str = Query(default="united_states", max_length=50, pattern=r"^[a-z_]+$")):
"""Consumer Price Index (multi-country)."""
data = await economy_service.get_cpi(country=country)
return ApiResponse(data=data)
@router.get("/macro/gdp", response_model=ApiResponse)
@safe
async def macro_gdp(
gdp_type: str = Query(default="real", pattern="^(nominal|real|forecast)$"),
):
"""GDP: nominal, real, or forecast."""
data = await economy_service.get_gdp(gdp_type=gdp_type)
return ApiResponse(data=data)
@router.get("/macro/unemployment", response_model=ApiResponse)
@safe
async def macro_unemployment(
country: str = Query(default="united_states", max_length=50, pattern=r"^[a-z_]+$"),
):
"""Unemployment rate (multi-country, with demographic breakdowns)."""
data = await economy_service.get_unemployment(country=country)
return ApiResponse(data=data)
@router.get("/macro/pce", response_model=ApiResponse)
@safe
async def macro_pce():
"""Personal Consumption Expenditures (Fed preferred inflation measure)."""
data = await economy_service.get_pce()
return ApiResponse(data=data)
@router.get("/macro/money-measures", response_model=ApiResponse)
@safe
async def macro_money_measures():
"""M1/M2 money supply, currency in circulation."""
data = await economy_service.get_money_measures()
return ApiResponse(data=data)
@router.get("/macro/cli", response_model=ApiResponse)
@safe
async def macro_cli(country: str = Query(default="united_states", max_length=50, pattern=r"^[a-z_]+$")):
"""Composite Leading Indicator (predicts recessions 6-9 months ahead)."""
data = await economy_service.get_composite_leading_indicator(country=country)
return ApiResponse(data=data)
@router.get("/macro/house-price-index", response_model=ApiResponse)
@safe
async def macro_hpi(country: str = Query(default="united_states", max_length=50, pattern=r"^[a-z_]+$")):
"""Housing price index (multi-country)."""
data = await economy_service.get_house_price_index(country=country)
return ApiResponse(data=data)
# --- Economy data endpoints ---
@router.get("/economy/fred-regional", response_model=ApiResponse)
@safe
async def economy_fred_regional(
series_id: str = Query(..., min_length=1, max_length=30),
region: str = Query(default=None, max_length=20, pattern=r"^[a-z_]+$"),
):
"""Regional FRED data by state, county, or MSA."""
data = await economy_service.get_fred_regional(series_id=series_id, region=region)
return ApiResponse(data=data)
@router.get("/economy/primary-dealer-positioning", response_model=ApiResponse)
@safe
async def economy_primary_dealer():
"""Primary dealer net positions: treasuries, MBS, corporate bonds."""
data = await economy_service.get_primary_dealer_positioning()
return ApiResponse(data=data)
@router.get("/economy/fred-search", response_model=ApiResponse)
@safe
async def economy_fred_search(query: str = Query(..., min_length=1, max_length=100)):
"""Search FRED series by keyword (800K+ economic series)."""
data = await economy_service.fred_search(query=query)
return ApiResponse(data=data)
@router.get("/economy/balance-of-payments", response_model=ApiResponse)
@safe
async def economy_bop():
"""Balance of payments: current/capital/financial account."""
data = await economy_service.get_balance_of_payments()
return ApiResponse(data=data)
@router.get("/economy/central-bank-holdings", response_model=ApiResponse)
@safe
async def economy_fed_holdings():
"""Fed SOMA portfolio: holdings by security type."""
data = await economy_service.get_central_bank_holdings()
return ApiResponse(data=data)
@router.get("/economy/fomc-documents", response_model=ApiResponse)
@safe
async def economy_fomc(year: int = Query(default=None, ge=2000, le=2099)):
"""FOMC meeting documents: minutes, projections, press conferences."""
data = await economy_service.get_fomc_documents(year=year)
return ApiResponse(data=data)

93
routes_fixed_income.py Normal file
View File

@@ -0,0 +1,93 @@
"""Routes for fixed income data."""
from fastapi import APIRouter, Query
from models import ApiResponse
from route_utils import safe
import fixed_income_service
router = APIRouter(prefix="/api/v1/fixed-income")
@router.get("/treasury-rates", response_model=ApiResponse)
@safe
async def treasury_rates():
"""Full treasury yield curve rates (4W-30Y)."""
data = await fixed_income_service.get_treasury_rates()
return ApiResponse(data=data)
@router.get("/yield-curve", response_model=ApiResponse)
@safe
async def yield_curve(date: str = Query(default=None, max_length=10, pattern=r"^\d{4}-\d{2}-\d{2}$")):
"""Yield curve with maturity/rate pairs."""
data = await fixed_income_service.get_yield_curve(date=date)
return ApiResponse(data=data)
@router.get("/treasury-auctions", response_model=ApiResponse)
@safe
async def treasury_auctions(
security_type: str = Query(default=None, max_length=30, pattern=r"^[a-zA-Z_ -]+$"),
):
"""Treasury auction data: bid-to-cover ratios, yields."""
data = await fixed_income_service.get_treasury_auctions(security_type=security_type)
return ApiResponse(data=data)
@router.get("/tips-yields", response_model=ApiResponse)
@safe
async def tips_yields():
"""TIPS real yields by maturity."""
data = await fixed_income_service.get_tips_yields()
return ApiResponse(data=data)
@router.get("/effr", response_model=ApiResponse)
@safe
async def effr():
"""Effective Federal Funds Rate with percentiles and volume."""
data = await fixed_income_service.get_effr()
return ApiResponse(data=data)
@router.get("/sofr", response_model=ApiResponse)
@safe
async def sofr():
"""SOFR rate with 30/90/180-day moving averages."""
data = await fixed_income_service.get_sofr()
return ApiResponse(data=data)
@router.get("/hqm", response_model=ApiResponse)
@safe
async def hqm():
"""High Quality Market corporate bond yields (AAA/AA/A)."""
data = await fixed_income_service.get_hqm()
return ApiResponse(data=data)
@router.get("/commercial-paper", response_model=ApiResponse)
@safe
async def commercial_paper():
"""Commercial paper rates by maturity and type."""
data = await fixed_income_service.get_commercial_paper()
return ApiResponse(data=data)
@router.get("/spot-rates", response_model=ApiResponse)
@safe
async def spot_rates():
"""Corporate bond spot rates and par yields."""
data = await fixed_income_service.get_spot_rates()
return ApiResponse(data=data)
@router.get("/spreads", response_model=ApiResponse)
@safe
async def spreads(
series: str = Query(default="tcm", pattern="^(tcm|tcm_effr|treasury_effr)$"),
):
"""Treasury/corporate spreads (tcm, tcm_effr, treasury_effr)."""
data = await fixed_income_service.get_spreads(series=series)
return ApiResponse(data=data)

View File

@@ -135,3 +135,45 @@ async def futures_curve(symbol: str = Path(..., min_length=1, max_length=20)):
symbol = validate_symbol(symbol)
data = await market_service.get_futures_curve(symbol)
return ApiResponse(data=data)
# --- Currency Reference Rates (Group H) ---
@router.get("/currency/reference-rates", response_model=ApiResponse)
@safe
async def currency_reference_rates():
"""Get ECB reference exchange rates for 28 major currencies."""
data = await market_service.get_currency_reference_rates()
return ApiResponse(data=data)
# --- Index Enhanced (Group F) ---
@router.get("/index/sp500-multiples", response_model=ApiResponse)
@safe
async def sp500_multiples(
series: str = Query(default="pe_ratio", pattern="^[a-z_]+$"),
):
"""Historical S&P 500 valuation: pe_ratio, shiller_pe_ratio, dividend_yield, etc."""
data = await market_service.get_sp500_multiples(series)
return ApiResponse(data=data)
@router.get("/index/{symbol}/constituents", response_model=ApiResponse)
@safe
async def index_constituents(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get index member stocks with sector and price data."""
symbol = validate_symbol(symbol)
data = await market_service.get_index_constituents(symbol)
return ApiResponse(data=data)
@router.get("/etf/{symbol}/nport", response_model=ApiResponse)
@safe
async def etf_nport(symbol: str = Path(..., min_length=1, max_length=20)):
"""Detailed ETF holdings from SEC N-PORT filings."""
symbol = validate_symbol(symbol)
data = await market_service.get_etf_nport(symbol)
return ApiResponse(data=data)

View File

@@ -52,3 +52,44 @@ async def stock_unitroot(
symbol = validate_symbol(symbol)
data = await quantitative_service.get_unitroot_test(symbol, days=days)
return ApiResponse(data=data)
# --- Extended Quantitative (Group J) ---
@router.get("/stock/{symbol}/sortino", response_model=ApiResponse)
@safe
async def stock_sortino(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=30, le=3650),
):
"""Sortino ratio -- risk-adjusted return penalizing only downside deviation."""
symbol = validate_symbol(symbol)
data = await quantitative_service.get_sortino(symbol, days=days)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/omega", response_model=ApiResponse)
@safe
async def stock_omega(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=365, ge=30, le=3650),
):
"""Omega ratio -- probability-weighted gain vs loss."""
symbol = validate_symbol(symbol)
data = await quantitative_service.get_omega(symbol, days=days)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/rolling/{stat}", response_model=ApiResponse)
@safe
async def stock_rolling(
symbol: str = Path(..., min_length=1, max_length=20),
stat: str = Path(..., pattern="^(variance|stdev|mean|skew|kurtosis|quantile)$"),
days: int = Query(default=365, ge=30, le=3650),
window: int = Query(default=30, ge=5, le=252),
):
"""Rolling statistics: variance, stdev, mean, skew, kurtosis, quantile."""
symbol = validate_symbol(symbol)
data = await quantitative_service.get_rolling_stat(symbol, stat=stat, days=days, window=window)
return ApiResponse(data=data)

51
routes_regulators.py Normal file
View File

@@ -0,0 +1,51 @@
"""Routes for regulatory data (CFTC, SEC)."""
from fastapi import APIRouter, Path, Query
from models import ApiResponse
from route_utils import safe, validate_symbol
import regulators_service
router = APIRouter(prefix="/api/v1/regulators")
@router.get("/cot", response_model=ApiResponse)
@safe
async def cot_report(symbol: str = Query(..., min_length=1, max_length=20)):
"""Commitment of Traders: commercial/speculator positions for futures."""
symbol = validate_symbol(symbol)
data = await regulators_service.get_cot(symbol)
return ApiResponse(data=data)
@router.get("/cot/search", response_model=ApiResponse)
@safe
async def cot_search(query: str = Query(..., min_length=1, max_length=100)):
"""Search COT report symbols."""
data = await regulators_service.cot_search(query)
return ApiResponse(data=data)
@router.get("/sec/litigation", response_model=ApiResponse)
@safe
async def sec_litigation():
"""SEC litigation releases RSS feed."""
data = await regulators_service.get_sec_litigation()
return ApiResponse(data=data)
@router.get("/sec/institutions", response_model=ApiResponse)
@safe
async def sec_institutions(query: str = Query(..., min_length=1, max_length=100)):
"""Search institutional investors filing with SEC."""
data = await regulators_service.search_institutions(query)
return ApiResponse(data=data)
@router.get("/sec/cik-map/{symbol}", response_model=ApiResponse)
@safe
async def sec_cik_map(symbol: str = Path(..., min_length=1, max_length=20)):
"""Map ticker symbol to SEC CIK number."""
symbol = validate_symbol(symbol)
data = await regulators_service.get_cik_map(symbol)
return ApiResponse(data=data)

View File

@@ -8,6 +8,7 @@ from models import ApiResponse
from route_utils import safe, validate_symbol
import alphavantage_service
import finnhub_service
import openbb_service
import logging
@@ -96,17 +97,7 @@ async def stock_recommendations(symbol: str = Path(..., min_length=1, max_length
@router.get("/stock/{symbol}/upgrades", response_model=ApiResponse)
@safe
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)
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)
data = await openbb_service.get_upgrades_downgrades(symbol)
return ApiResponse(data=data)

45
routes_shorts.py Normal file
View File

@@ -0,0 +1,45 @@
"""Routes for equity shorts and dark pool data."""
from fastapi import APIRouter, Path
from models import ApiResponse
from route_utils import safe, validate_symbol
import shorts_service
router = APIRouter(prefix="/api/v1")
@router.get("/stock/{symbol}/shorts/volume", response_model=ApiResponse)
@safe
async def short_volume(symbol: str = Path(..., min_length=1, max_length=20)):
"""Daily short volume and percent (stockgrid)."""
symbol = validate_symbol(symbol)
data = await shorts_service.get_short_volume(symbol)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/shorts/ftd", response_model=ApiResponse)
@safe
async def fails_to_deliver(symbol: str = Path(..., min_length=1, max_length=20)):
"""Fails-to-deliver records from SEC."""
symbol = validate_symbol(symbol)
data = await shorts_service.get_fails_to_deliver(symbol)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/shorts/interest", response_model=ApiResponse)
@safe
async def short_interest(symbol: str = Path(..., min_length=1, max_length=20)):
"""Short interest positions, days to cover (FINRA)."""
symbol = validate_symbol(symbol)
data = await shorts_service.get_short_interest(symbol)
return ApiResponse(data=data)
@router.get("/darkpool/{symbol}/otc", response_model=ApiResponse)
@safe
async def darkpool_otc(symbol: str = Path(..., min_length=1, max_length=20)):
"""OTC/dark pool aggregate trade volume (FINRA)."""
symbol = validate_symbol(symbol)
data = await shorts_service.get_darkpool_otc(symbol)
return ApiResponse(data=data)

49
routes_surveys.py Normal file
View File

@@ -0,0 +1,49 @@
"""Routes for economy surveys."""
from fastapi import APIRouter, Query
from models import ApiResponse
from route_utils import safe
import surveys_service
router = APIRouter(prefix="/api/v1/economy/surveys")
@router.get("/michigan", response_model=ApiResponse)
@safe
async def survey_michigan():
"""University of Michigan Consumer Sentiment + inflation expectations."""
data = await surveys_service.get_michigan()
return ApiResponse(data=data)
@router.get("/sloos", response_model=ApiResponse)
@safe
async def survey_sloos():
"""Senior Loan Officer Opinion Survey (lending standards, recession signal)."""
data = await surveys_service.get_sloos()
return ApiResponse(data=data)
@router.get("/nonfarm-payrolls", response_model=ApiResponse)
@safe
async def survey_nfp():
"""Detailed employment data: employees, hours, earnings by industry."""
data = await surveys_service.get_nonfarm_payrolls()
return ApiResponse(data=data)
@router.get("/empire-state", response_model=ApiResponse)
@safe
async def survey_empire():
"""Empire State Manufacturing Survey (NY manufacturing outlook)."""
data = await surveys_service.get_empire_state()
return ApiResponse(data=data)
@router.get("/bls-search", response_model=ApiResponse)
@safe
async def survey_bls_search(query: str = Query(..., min_length=1, max_length=100)):
"""Search BLS data series (CPI components, wages, employment, etc.)."""
data = await surveys_service.bls_search(query=query)
return ApiResponse(data=data)

View File

@@ -1,6 +1,6 @@
"""Routes for technical analysis indicators."""
from fastapi import APIRouter, Path
from fastapi import APIRouter, Path, Query
from models import ApiResponse
from route_utils import safe, validate_symbol
@@ -16,3 +16,171 @@ async def stock_technical(symbol: str = Path(..., min_length=1, max_length=20)):
symbol = validate_symbol(symbol)
data = await technical_service.get_technical_indicators(symbol)
return ApiResponse(data=data)
# --- Individual Technical Indicators (Group I) ---
@router.get("/stock/{symbol}/technical/atr", response_model=ApiResponse)
@safe
async def stock_atr(
symbol: str = Path(..., min_length=1, max_length=20),
length: int = Query(default=14, ge=1, le=100),
):
"""Average True Range -- volatility for position sizing and stop-loss."""
symbol = validate_symbol(symbol)
data = await technical_service.get_atr(symbol, length=length)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/technical/adx", response_model=ApiResponse)
@safe
async def stock_adx(
symbol: str = Path(..., min_length=1, max_length=20),
length: int = Query(default=14, ge=1, le=100),
):
"""Average Directional Index -- trend strength (>25 strong, <20 range-bound)."""
symbol = validate_symbol(symbol)
data = await technical_service.get_adx(symbol, length=length)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/technical/stoch", response_model=ApiResponse)
@safe
async def stock_stoch(
symbol: str = Path(..., min_length=1, max_length=20),
fast_k: int = Query(default=14, ge=1, le=100),
slow_d: int = Query(default=3, ge=1, le=100),
slow_k: int = Query(default=3, ge=1, le=100),
):
"""Stochastic Oscillator -- overbought/oversold momentum signal."""
symbol = validate_symbol(symbol)
data = await technical_service.get_stoch(symbol, fast_k=fast_k, slow_d=slow_d, slow_k=slow_k)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/technical/obv", response_model=ApiResponse)
@safe
async def stock_obv(symbol: str = Path(..., min_length=1, max_length=20)):
"""On-Balance Volume -- cumulative volume for divergence detection."""
symbol = validate_symbol(symbol)
data = await technical_service.get_obv(symbol)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/technical/ichimoku", response_model=ApiResponse)
@safe
async def stock_ichimoku(symbol: str = Path(..., min_length=1, max_length=20)):
"""Ichimoku Cloud -- comprehensive trend system with support/resistance."""
symbol = validate_symbol(symbol)
data = await technical_service.get_ichimoku(symbol)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/technical/donchian", response_model=ApiResponse)
@safe
async def stock_donchian(
symbol: str = Path(..., min_length=1, max_length=20),
length: int = Query(default=20, ge=1, le=100),
):
"""Donchian Channels -- breakout detection system."""
symbol = validate_symbol(symbol)
data = await technical_service.get_donchian(symbol, length=length)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/technical/aroon", response_model=ApiResponse)
@safe
async def stock_aroon(
symbol: str = Path(..., min_length=1, max_length=20),
length: int = Query(default=25, ge=1, le=100),
):
"""Aroon Indicator -- identifies trend direction and potential changes."""
symbol = validate_symbol(symbol)
data = await technical_service.get_aroon(symbol, length=length)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/technical/cci", response_model=ApiResponse)
@safe
async def stock_cci(
symbol: str = Path(..., min_length=1, max_length=20),
length: int = Query(default=14, ge=1, le=100),
):
"""Commodity Channel Index -- cyclical trend identification."""
symbol = validate_symbol(symbol)
data = await technical_service.get_cci(symbol, length=length)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/technical/kc", response_model=ApiResponse)
@safe
async def stock_kc(
symbol: str = Path(..., min_length=1, max_length=20),
length: int = Query(default=20, ge=1, le=100),
):
"""Keltner Channels -- ATR-based volatility bands."""
symbol = validate_symbol(symbol)
data = await technical_service.get_kc(symbol, length=length)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/technical/fib", response_model=ApiResponse)
@safe
async def stock_fib(
symbol: str = Path(..., min_length=1, max_length=20),
days: int = Query(default=120, ge=5, le=365),
):
"""Fibonacci Retracement -- key support/resistance levels."""
symbol = validate_symbol(symbol)
data = await technical_service.get_fib(symbol, days=days)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/technical/ad", response_model=ApiResponse)
@safe
async def stock_ad(symbol: str = Path(..., min_length=1, max_length=20)):
"""Accumulation/Distribution Line -- volume-based trend indicator."""
symbol = validate_symbol(symbol)
data = await technical_service.get_ad(symbol)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/technical/cones", response_model=ApiResponse)
@safe
async def stock_cones(symbol: str = Path(..., min_length=1, max_length=20)):
"""Volatility Cones -- realized vol quantiles for options analysis."""
symbol = validate_symbol(symbol)
data = await technical_service.get_cones(symbol)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/technical/vwap", response_model=ApiResponse)
@safe
async def stock_vwap(symbol: str = Path(..., min_length=1, max_length=20)):
"""Volume Weighted Average Price -- intraday fair value benchmark."""
symbol = validate_symbol(symbol)
data = await technical_service.get_vwap(symbol)
return ApiResponse(data=data)
@router.get("/technical/relative-rotation", response_model=ApiResponse)
@safe
async def relative_rotation(
symbols: str = Query(..., min_length=1, max_length=200, description="Comma-separated symbols, e.g. AAPL,MSFT,GOOGL"),
benchmark: str = Query(default="SPY", min_length=1, max_length=20),
study: str = Query(default="price", pattern="^(price|volume|volatility)$"),
):
"""Relative Rotation Graph -- compare multiple symbols vs benchmark.
Returns RS-Ratio and RS-Momentum for each symbol, indicating
RRG quadrant: Leading, Weakening, Lagging, or Improving.
"""
symbol_list = [validate_symbol(s.strip()) for s in symbols.split(",") if s.strip()]
if not symbol_list:
return ApiResponse(data=[], error="No valid symbols provided")
benchmark = validate_symbol(benchmark)
data = await technical_service.get_relative_rotation(
symbol_list, benchmark=benchmark, study=study,
)
return ApiResponse(data=data)

59
shorts_service.py Normal file
View File

@@ -0,0 +1,59 @@
"""Equity shorts and dark pool data (stockgrid, FINRA, SEC)."""
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_short_volume(symbol: str) -> list[dict[str, Any]]:
"""Get daily short volume data (stockgrid)."""
try:
result = await asyncio.to_thread(
obb.equity.shorts.short_volume, symbol, provider="stockgrid"
)
return to_list(result)
except Exception:
logger.warning("Short volume failed for %s", symbol, exc_info=True)
return []
async def get_fails_to_deliver(symbol: str) -> list[dict[str, Any]]:
"""Get fails-to-deliver records (SEC)."""
try:
result = await asyncio.to_thread(
obb.equity.shorts.fails_to_deliver, symbol, provider="sec"
)
return to_list(result)
except Exception:
logger.warning("FTD failed for %s", symbol, exc_info=True)
return []
async def get_short_interest(symbol: str) -> list[dict[str, Any]]:
"""Get short interest positions (FINRA)."""
try:
result = await asyncio.to_thread(
obb.equity.shorts.short_interest, symbol, provider="finra"
)
return to_list(result)
except Exception:
logger.warning("Short interest failed for %s", symbol, exc_info=True)
return []
async def get_darkpool_otc(symbol: str) -> list[dict[str, Any]]:
"""Get OTC/dark pool aggregate trade data (FINRA)."""
try:
result = await asyncio.to_thread(
obb.equity.darkpool.otc, symbol, provider="finra"
)
return to_list(result)
except Exception:
logger.warning("Dark pool OTC failed for %s", symbol, exc_info=True)
return []

71
surveys_service.py Normal file
View File

@@ -0,0 +1,71 @@
"""Economy surveys: Michigan, SLOOS, NFP, Empire State, BLS."""
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_michigan() -> list[dict[str, Any]]:
"""Get University of Michigan Consumer Sentiment + inflation expectations."""
try:
result = await asyncio.to_thread(
obb.economy.survey.university_of_michigan, provider="fred"
)
return to_list(result)
except Exception:
logger.warning("Michigan survey failed", exc_info=True)
return []
async def get_sloos() -> list[dict[str, Any]]:
"""Get Senior Loan Officer Opinion Survey (recession predictor)."""
try:
result = await asyncio.to_thread(
obb.economy.survey.sloos, provider="fred"
)
return to_list(result)
except Exception:
logger.warning("SLOOS failed", exc_info=True)
return []
async def get_nonfarm_payrolls() -> list[dict[str, Any]]:
"""Get detailed employment data (NFP)."""
try:
result = await asyncio.to_thread(
obb.economy.survey.nonfarm_payrolls, provider="fred"
)
return to_list(result)
except Exception:
logger.warning("NFP failed", exc_info=True)
return []
async def get_empire_state() -> list[dict[str, Any]]:
"""Get Empire State Manufacturing Survey."""
try:
result = await asyncio.to_thread(
obb.economy.survey.manufacturing_outlook_ny, provider="fred"
)
return to_list(result)
except Exception:
logger.warning("Empire State failed", exc_info=True)
return []
async def bls_search(query: str) -> list[dict[str, Any]]:
"""Search BLS data series."""
try:
result = await asyncio.to_thread(
obb.economy.survey.bls_search, query, provider="bls"
)
return to_list(result)
except Exception:
logger.warning("BLS search failed for %s", query, exc_info=True)
return []

216
task_plan.md Normal file
View File

@@ -0,0 +1,216 @@
# OpenBB Feature Expansion Plan
> 67 new endpoints across 10 feature groups. All use free providers.
## Prerequisites (Do First)
### P0: Consolidate Shared Utilities
- [ ] Replace duplicate `_to_dicts` in `openbb_service.py` and `macro_service.py` with `obb_utils.to_list`
- [ ] Add `fetch_historical(symbol, days, provider)` helper to `obb_utils.py`
- [ ] Add `serialize_dates(items)` helper to `obb_utils.py`
- **Files:** `obb_utils.py`, `openbb_service.py`, `macro_service.py`, `technical_service.py`, `quantitative_service.py`
- **Complexity:** S
---
## Phase 1: Local Computation (No Provider Risk)
### Group I: Technical Analysis Extended (14 endpoints)
- [ ] Add generic indicator dispatcher to `technical_service.py`
- [ ] Implement indicators: ATR, ADX, Stochastic, OBV, VWAP, Ichimoku, Donchian, Aroon, CCI, Keltner Channels, Fibonacci, A/D Line, Volatility Cones, Relative Rotation
- [ ] Add individual endpoints to `routes_technical.py`
- [ ] Add generic endpoint: `GET /api/v1/stock/{symbol}/technical/{indicator}`
- **New endpoints:**
- `GET /api/v1/stock/{symbol}/technical/atr` -- Average True Range (volatility, position sizing)
- `GET /api/v1/stock/{symbol}/technical/adx` -- Average Directional Index (trend strength)
- `GET /api/v1/stock/{symbol}/technical/stoch` -- Stochastic Oscillator (overbought/oversold)
- `GET /api/v1/stock/{symbol}/technical/obv` -- On-Balance Volume (volume-price divergence)
- `GET /api/v1/stock/{symbol}/technical/vwap` -- Volume Weighted Average Price
- `GET /api/v1/stock/{symbol}/technical/ichimoku` -- Ichimoku Cloud (comprehensive trend)
- `GET /api/v1/stock/{symbol}/technical/donchian` -- Donchian Channels (breakout detection)
- `GET /api/v1/stock/{symbol}/technical/aroon` -- Aroon Indicator (trend changes)
- `GET /api/v1/stock/{symbol}/technical/cci` -- Commodity Channel Index (cyclical trends)
- `GET /api/v1/stock/{symbol}/technical/kc` -- Keltner Channels (volatility bands)
- `GET /api/v1/stock/{symbol}/technical/fib` -- Fibonacci Retracement (support/resistance)
- `GET /api/v1/stock/{symbol}/technical/ad` -- Accumulation/Distribution Line
- `GET /api/v1/stock/{symbol}/technical/cones` -- Volatility Cones (implied vs realized vol)
- `GET /api/v1/stock/{symbol}/technical/relative_rotation` -- RRG (sector rotation)
- **Extend:** `technical_service.py` (+200 lines), `routes_technical.py` (+80 lines)
- **Complexity:** L (high volume, low individual complexity)
### Group J: Quantitative Extended (8 endpoints)
- [ ] Add Sortino ratio, Omega ratio
- [ ] Add rolling statistics: variance, stdev, mean, skew, kurtosis, quantile
- **New endpoints:**
- `GET /api/v1/stock/{symbol}/sortino?days=365` -- Sortino ratio (downside risk only)
- `GET /api/v1/stock/{symbol}/omega?days=365` -- Omega ratio (full distribution)
- `GET /api/v1/stock/{symbol}/rolling/variance?days=365&window=30` -- Rolling variance
- `GET /api/v1/stock/{symbol}/rolling/stdev?days=365&window=30` -- Rolling std deviation
- `GET /api/v1/stock/{symbol}/rolling/mean?days=365&window=30` -- Rolling mean
- `GET /api/v1/stock/{symbol}/rolling/skew?days=365&window=30` -- Rolling skewness
- `GET /api/v1/stock/{symbol}/rolling/kurtosis?days=365&window=30` -- Rolling kurtosis
- `GET /api/v1/stock/{symbol}/rolling/quantile?days=365&window=30&quantile=0.5` -- Rolling quantile
- **Extend:** `quantitative_service.py` (+120 lines), `routes_quantitative.py` (+60 lines)
- **Complexity:** M
### Group H: Currency Reference Rates (1 endpoint)
- [ ] Add ECB reference rates to `market_service.py`
- **New endpoint:**
- `GET /api/v1/currency/reference-rates` -- ECB reference rates for 28 currencies
- **Extend:** `market_service.py` (+15 lines), `routes_market.py` (+10 lines)
- **Complexity:** S
---
## Phase 2: FRED/Federal Reserve Providers
### Group C: Fixed Income (10 endpoints)
- [ ] Create `fixed_income_service.py` -- treasury rates, yield curve, auctions, TIPS, EFFR, SOFR, HQM, commercial paper, spot rates, spreads
- [ ] Create `routes_fixed_income.py`
- [ ] Register router in `main.py`
- **New endpoints:**
- `GET /api/v1/fixed-income/treasury-rates` -- Full yield curve rates (4W-30Y)
- `GET /api/v1/fixed-income/yield-curve?date=` -- Yield curve with maturity/rate pairs
- `GET /api/v1/fixed-income/treasury-auctions?security_type=` -- Auction bid-to-cover, yields
- `GET /api/v1/fixed-income/tips-yields` -- TIPS real yields by maturity
- `GET /api/v1/fixed-income/effr` -- Effective Fed Funds Rate with percentiles
- `GET /api/v1/fixed-income/sofr` -- SOFR rate with moving averages
- `GET /api/v1/fixed-income/hqm` -- High Quality Market corporate bond yields
- `GET /api/v1/fixed-income/commercial-paper` -- CP rates by maturity/type
- `GET /api/v1/fixed-income/spot-rates` -- Corporate bond spot rates
- `GET /api/v1/fixed-income/spreads?series=tcm` -- Treasury/corporate spreads
- **New files:** `fixed_income_service.py` (~250 lines), `routes_fixed_income.py` (~180 lines)
- **Complexity:** L
### Group D: Economy Expanded (13 endpoints)
- [ ] Extend `macro_service.py` with structured FRED indicators (CPI, GDP, unemployment, PCE, money measures)
- [ ] Create `economy_service.py` for non-series endpoints (fred_search, fred_regional, balance_of_payments, central_bank_holdings, primary_dealer_positioning, fomc_documents)
- [ ] Extend `routes_macro.py` for FRED-based indicators
- [ ] Create `routes_economy.py` for search/institutional data
- [ ] Register new router in `main.py`
- **New endpoints (extend routes_macro.py):**
- `GET /api/v1/macro/cpi?country=united_states` -- Consumer Price Index (multi-country)
- `GET /api/v1/macro/gdp?type=real` -- GDP nominal/real/forecast
- `GET /api/v1/macro/unemployment?country=united_states` -- Unemployment rate (multi-country)
- `GET /api/v1/macro/pce` -- Personal Consumption Expenditures (Fed preferred inflation)
- `GET /api/v1/macro/money-measures` -- M1/M2 money supply
- `GET /api/v1/macro/cli?country=united_states` -- Composite Leading Indicator
- `GET /api/v1/macro/house-price-index?country=united_states` -- Housing price index
- **New endpoints (new routes_economy.py):**
- `GET /api/v1/economy/fred-search?query=` -- Search FRED series by keyword
- `GET /api/v1/economy/fred-regional?series_id=&region=` -- Regional economic data
- `GET /api/v1/economy/balance-of-payments` -- Current/capital/financial account
- `GET /api/v1/economy/central-bank-holdings` -- Fed SOMA portfolio
- `GET /api/v1/economy/primary-dealer-positioning` -- Wall Street firm positions
- `GET /api/v1/economy/fomc-documents?year=` -- FOMC meeting documents
- **New files:** `economy_service.py` (~200 lines), `routes_economy.py` (~150 lines)
- **Extend:** `macro_service.py` (+80 lines), `routes_macro.py` (+50 lines)
- **Complexity:** L
### Group E: Economy Surveys (5 endpoints)
- [ ] Create `surveys_service.py` -- Michigan, SLOOS, NFP, Empire State, BLS
- [ ] Create `routes_surveys.py`
- [ ] Register router in `main.py`
- **New endpoints:**
- `GET /api/v1/economy/surveys/michigan` -- Consumer Sentiment + inflation expectations
- `GET /api/v1/economy/surveys/sloos` -- Senior Loan Officer survey (recession predictor)
- `GET /api/v1/economy/surveys/nonfarm-payrolls` -- Detailed employment data
- `GET /api/v1/economy/surveys/empire-state` -- NY manufacturing outlook
- `GET /api/v1/economy/surveys/bls-search?query=` -- BLS data series search
- **New files:** `surveys_service.py` (~130 lines), `routes_surveys.py` (~100 lines)
- **Complexity:** M
---
## Phase 3: SEC/Stockgrid/CFTC Providers
### Group B: Equity Fundamentals (4 endpoints)
- [ ] Add management, dividends, filings, search to `openbb_service.py`
- [ ] Add endpoints to `routes.py`
- **New endpoints:**
- `GET /api/v1/stock/{symbol}/management` -- Executive team, titles, compensation
- `GET /api/v1/stock/{symbol}/dividends` -- Historical dividend records
- `GET /api/v1/stock/{symbol}/filings?form_type=10-K` -- SEC filings (10-K, 10-Q, 8-K)
- `GET /api/v1/search?query=` -- Company search by name (SEC/NASDAQ)
- **Extend:** `openbb_service.py` (+60 lines), `routes.py` (+40 lines)
- **Complexity:** S
### Group A: Equity Shorts & Dark Pool (4 endpoints)
- [ ] Create `shorts_service.py` -- short volume, FTD, short interest, OTC dark pool
- [ ] Create `routes_shorts.py`
- [ ] Register router in `main.py`
- **New endpoints:**
- `GET /api/v1/stock/{symbol}/shorts/volume` -- Daily short volume & percent (stockgrid)
- `GET /api/v1/stock/{symbol}/shorts/ftd` -- Fails-to-deliver records (SEC)
- `GET /api/v1/stock/{symbol}/shorts/interest` -- Short interest, days to cover (FINRA)
- `GET /api/v1/darkpool/{symbol}/otc` -- OTC/dark pool trade volume (FINRA)
- **New files:** `shorts_service.py` (~120 lines), `routes_shorts.py` (~80 lines)
- **Complexity:** M
### Group F: Index & ETF Enhanced (3 endpoints)
- [ ] Add sp500_multiples, index_constituents, etf nport_disclosure to `market_service.py`
- [ ] Add endpoints to `routes_market.py`
- **New endpoints:**
- `GET /api/v1/index/sp500-multiples?series=pe_ratio` -- Historical S&P 500 valuation (Shiller PE, P/B, P/S, dividend yield)
- `GET /api/v1/index/{symbol}/constituents` -- Index member stocks with sector/price data
- `GET /api/v1/etf/{symbol}/nport` -- Detailed ETF holdings from SEC N-PORT filings
- **Extend:** `market_service.py` (+60 lines), `routes_market.py` (+50 lines)
- **Complexity:** S
---
## Phase 4: Regulators
### Group G: Regulators (5 endpoints)
- [ ] Create `regulators_service.py` -- COT, COT search, SEC litigation, institution search, CIK mapping
- [ ] Create `routes_regulators.py`
- [ ] Register router in `main.py`
- **New endpoints:**
- `GET /api/v1/regulators/cot?symbol=` -- Commitment of Traders report (commercial/speculator positions)
- `GET /api/v1/regulators/cot/search?query=` -- Search COT report symbols
- `GET /api/v1/regulators/sec/litigation` -- SEC litigation releases RSS feed
- `GET /api/v1/regulators/sec/institutions?query=` -- Search institutional investors
- `GET /api/v1/regulators/sec/cik-map?symbol=` -- Ticker to CIK mapping
- **New files:** `regulators_service.py` (~150 lines), `routes_regulators.py` (~100 lines)
- **Complexity:** M
---
## Summary
| Phase | Groups | Endpoints | New Files | Complexity |
|-------|--------|-----------|-----------|------------|
| P0 Prereq | - | 0 | 0 | S |
| Phase 1 | I, J, H | 23 | 0 | L+M+S |
| Phase 2 | C, D, E | 28 | 6 | L+L+M |
| Phase 3 | B, A, F | 11 | 2 | S+M+S |
| Phase 4 | G | 5 | 2 | M |
| **Total** | **10** | **67** | **10** | |
### File Impact
**New files (10):**
- `shorts_service.py`, `routes_shorts.py`
- `fixed_income_service.py`, `routes_fixed_income.py`
- `economy_service.py`, `routes_economy.py`
- `surveys_service.py`, `routes_surveys.py`
- `regulators_service.py`, `routes_regulators.py`
**Extended files (12):**
- `obb_utils.py` (shared helpers)
- `openbb_service.py` (Group B fundamentals)
- `routes.py` (Group B endpoints)
- `macro_service.py` (Group D indicators)
- `routes_macro.py` (Group D endpoints)
- `market_service.py` (Groups F, H)
- `routes_market.py` (Groups F, H)
- `technical_service.py` (Group I indicators)
- `routes_technical.py` (Group I endpoints)
- `quantitative_service.py` (Group J metrics)
- `routes_quantitative.py` (Group J endpoints)
- `main.py` (register 5 new routers)
### Endpoint Count After Completion
- Current: 32 endpoints
- New: 67 endpoints
- **Total: 99 endpoints**

View File

@@ -6,28 +6,17 @@ from typing import Any
from openbb import obb
logger = logging.getLogger(__name__)
from obb_utils import fetch_historical, to_list
PROVIDER = "yfinance"
logger = logging.getLogger(__name__)
async def get_technical_indicators(
symbol: str, days: int = 400
) -> dict[str, Any]:
"""Compute key technical indicators for a symbol."""
from datetime import datetime, timedelta
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
# Fetch historical data first
hist = await asyncio.to_thread(
obb.equity.price.historical,
symbol,
start_date=start,
provider=PROVIDER,
)
if hist is None or hist.results is None:
hist = await fetch_historical(symbol, days)
if hist is None:
return {"symbol": symbol, "error": "No historical data available"}
result: dict[str, Any] = {"symbol": symbol}
@@ -144,3 +133,355 @@ def _interpret_signals(data: dict[str, Any]) -> list[str]:
signals.append("Death cross: SMA50 below SMA200 (bearish trend)")
return signals
# --- Individual Indicator Functions (Phase 1, Group I) ---
async def get_atr(symbol: str, length: int = 14, days: int = 400) -> dict[str, Any]:
"""Average True Range -- volatility measurement for position sizing."""
hist = await fetch_historical(symbol, days)
if hist is None:
return {"symbol": symbol, "error": "No historical data"}
try:
result = await asyncio.to_thread(
obb.technical.atr, data=hist.results, length=length
)
latest = _extract_latest(result)
return {
"symbol": symbol,
"length": length,
"atr": latest.get(f"ATRr_{length}"),
}
except Exception:
logger.warning("ATR failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute ATR"}
async def get_adx(symbol: str, length: int = 14, days: int = 400) -> dict[str, Any]:
"""Average Directional Index -- trend strength (>25 strong, <20 range-bound)."""
hist = await fetch_historical(symbol, days)
if hist is None:
return {"symbol": symbol, "error": "No historical data"}
try:
result = await asyncio.to_thread(
obb.technical.adx, data=hist.results, length=length
)
latest = _extract_latest(result)
adx_val = latest.get(f"ADX_{length}")
signal = "strong trend" if adx_val and adx_val > 25 else "range-bound"
return {
"symbol": symbol,
"length": length,
"adx": adx_val,
"dmp": latest.get(f"DMP_{length}"),
"dmn": latest.get(f"DMN_{length}"),
"signal": signal,
}
except Exception:
logger.warning("ADX failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute ADX"}
async def get_stoch(
symbol: str, fast_k: int = 14, slow_d: int = 3, slow_k: int = 3, days: int = 400,
) -> dict[str, Any]:
"""Stochastic Oscillator -- overbought/oversold momentum signal."""
hist = await fetch_historical(symbol, days)
if hist is None:
return {"symbol": symbol, "error": "No historical data"}
try:
result = await asyncio.to_thread(
obb.technical.stoch, data=hist.results,
fast_k=fast_k, slow_d=slow_d, slow_k=slow_k,
)
latest = _extract_latest(result)
k_val = latest.get(f"STOCHk_{fast_k}_{slow_d}_{slow_k}")
d_val = latest.get(f"STOCHd_{fast_k}_{slow_d}_{slow_k}")
signal = "neutral"
if k_val is not None:
if k_val > 80:
signal = "overbought"
elif k_val < 20:
signal = "oversold"
return {
"symbol": symbol,
"stoch_k": k_val,
"stoch_d": d_val,
"signal": signal,
}
except Exception:
logger.warning("Stochastic failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute Stochastic"}
async def get_obv(symbol: str, days: int = 400) -> dict[str, Any]:
"""On-Balance Volume -- cumulative volume indicator for divergence detection."""
hist = await fetch_historical(symbol, days)
if hist is None:
return {"symbol": symbol, "error": "No historical data"}
try:
result = await asyncio.to_thread(obb.technical.obv, data=hist.results)
latest = _extract_latest(result)
return {
"symbol": symbol,
"obv": latest.get("OBV"),
}
except Exception:
logger.warning("OBV failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute OBV"}
async def get_ichimoku(symbol: str, days: int = 400) -> dict[str, Any]:
"""Ichimoku Cloud -- comprehensive trend system."""
hist = await fetch_historical(symbol, days)
if hist is None:
return {"symbol": symbol, "error": "No historical data"}
try:
result = await asyncio.to_thread(obb.technical.ichimoku, data=hist.results)
latest = _extract_latest(result)
return {
"symbol": symbol,
"tenkan_sen": latest.get("ITS_9"),
"kijun_sen": latest.get("IKS_26"),
"senkou_span_a": latest.get("ISA_9"),
"senkou_span_b": latest.get("ISB_26"),
"chikou_span": latest.get("ICS_26"),
}
except Exception:
logger.warning("Ichimoku failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute Ichimoku"}
async def get_donchian(symbol: str, length: int = 20, days: int = 400) -> dict[str, Any]:
"""Donchian Channels -- breakout detection system."""
hist = await fetch_historical(symbol, days)
if hist is None:
return {"symbol": symbol, "error": "No historical data"}
try:
result = await asyncio.to_thread(
obb.technical.donchian, data=hist.results, lower_length=length, upper_length=length,
)
latest = _extract_latest(result)
return {
"symbol": symbol,
"length": length,
"upper": latest.get(f"DCU_{length}_{length}"),
"middle": latest.get(f"DCM_{length}_{length}"),
"lower": latest.get(f"DCL_{length}_{length}"),
}
except Exception:
logger.warning("Donchian failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute Donchian"}
async def get_aroon(symbol: str, length: int = 25, days: int = 400) -> dict[str, Any]:
"""Aroon Indicator -- trend direction and strength."""
hist = await fetch_historical(symbol, days)
if hist is None:
return {"symbol": symbol, "error": "No historical data"}
try:
result = await asyncio.to_thread(
obb.technical.aroon, data=hist.results, length=length,
)
latest = _extract_latest(result)
up = latest.get(f"AROONU_{length}")
down = latest.get(f"AROOND_{length}")
osc = latest.get(f"AROONOSC_{length}")
return {
"symbol": symbol,
"length": length,
"aroon_up": up,
"aroon_down": down,
"aroon_oscillator": osc,
}
except Exception:
logger.warning("Aroon failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute Aroon"}
async def get_cci(symbol: str, length: int = 14, days: int = 400) -> dict[str, Any]:
"""Commodity Channel Index -- cyclical trend identification."""
hist = await fetch_historical(symbol, days)
if hist is None:
return {"symbol": symbol, "error": "No historical data"}
try:
result = await asyncio.to_thread(
obb.technical.cci, data=hist.results, length=length,
)
latest = _extract_latest(result)
cci_val = latest.get(f"CCI_{length}_{0.015}")
signal = "neutral"
if cci_val is not None:
if cci_val > 100:
signal = "overbought"
elif cci_val < -100:
signal = "oversold"
return {
"symbol": symbol,
"length": length,
"cci": cci_val,
"signal": signal,
}
except Exception:
logger.warning("CCI failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute CCI"}
async def get_kc(symbol: str, length: int = 20, days: int = 400) -> dict[str, Any]:
"""Keltner Channels -- ATR-based volatility bands."""
hist = await fetch_historical(symbol, days)
if hist is None:
return {"symbol": symbol, "error": "No historical data"}
try:
result = await asyncio.to_thread(
obb.technical.kc, data=hist.results, length=length,
)
latest = _extract_latest(result)
return {
"symbol": symbol,
"length": length,
"upper": latest.get(f"KCUe_{length}_2"),
"middle": latest.get(f"KCBe_{length}_2"),
"lower": latest.get(f"KCLe_{length}_2"),
}
except Exception:
logger.warning("Keltner failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute Keltner Channels"}
async def get_fib(symbol: str, days: int = 120) -> dict[str, Any]:
"""Fibonacci Retracement levels from recent price range."""
hist = await fetch_historical(symbol, days)
if hist is None:
return {"symbol": symbol, "error": "No historical data"}
try:
result = await asyncio.to_thread(obb.technical.fib, data=hist.results)
latest = _extract_latest(result)
return {"symbol": symbol, **latest}
except Exception:
logger.warning("Fibonacci failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute Fibonacci"}
async def get_ad(symbol: str, days: int = 400) -> dict[str, Any]:
"""Accumulation/Distribution Line -- volume-based trend indicator."""
hist = await fetch_historical(symbol, days)
if hist is None:
return {"symbol": symbol, "error": "No historical data"}
try:
result = await asyncio.to_thread(obb.technical.ad, data=hist.results)
latest = _extract_latest(result)
return {
"symbol": symbol,
"ad": latest.get("AD"),
"ad_obv": latest.get("AD_OBV"),
}
except Exception:
logger.warning("A/D failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute A/D Line"}
async def get_vwap(symbol: str, days: int = 5) -> dict[str, Any]:
"""Volume Weighted Average Price -- intraday fair value benchmark."""
hist = await fetch_historical(symbol, days)
if hist is None:
return {"symbol": symbol, "error": "No historical data"}
try:
result = await asyncio.to_thread(obb.technical.vwap, data=hist.results)
latest = _extract_latest(result)
return {
"symbol": symbol,
"vwap": latest.get("VWAP_D"),
}
except Exception:
logger.warning("VWAP failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute VWAP"}
async def get_relative_rotation(
symbols: list[str],
benchmark: str = "SPY",
days: int = 365,
study: str = "price",
) -> dict[str, Any]:
"""Relative Rotation -- strength ratio and momentum vs benchmark.
Requires multiple symbols compared against a single benchmark.
Returns RS-Ratio and RS-Momentum for each symbol, indicating
which RRG quadrant they occupy (Leading/Weakening/Lagging/Improving).
"""
from datetime import datetime, timedelta, timezone as tz
start = (datetime.now(tz=tz.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
all_symbols = ",".join(symbols + [benchmark])
try:
hist = await asyncio.to_thread(
obb.equity.price.historical,
all_symbols,
start_date=start,
provider="yfinance",
)
if hist is None or hist.results is None:
return {"symbols": symbols, "benchmark": benchmark, "error": "No historical data"}
result = await asyncio.to_thread(
obb.technical.relative_rotation,
data=hist.results,
benchmark=benchmark,
study=study,
)
items = to_list(result)
latest_by_symbol: dict[str, dict[str, Any]] = {}
for item in items:
sym = item.get("symbol")
if sym and sym != benchmark:
latest_by_symbol[sym] = item
entries = [
{**item, "quadrant": _classify_rrg_quadrant(item)}
for item in latest_by_symbol.values()
]
return {
"symbols": symbols,
"benchmark": benchmark,
"study": study,
"data": entries,
}
except Exception:
logger.warning("Relative rotation failed for %s", symbols, exc_info=True)
return {"symbols": symbols, "error": "Failed to compute relative rotation"}
def _classify_rrg_quadrant(item: dict[str, Any]) -> str | None:
"""Classify RRG quadrant from RS-Ratio and RS-Momentum."""
rs_ratio = item.get("rs_ratio")
rs_momentum = item.get("rs_momentum")
if rs_ratio is None or rs_momentum is None:
return None
if rs_ratio > 100 and rs_momentum > 100:
return "Leading"
if rs_ratio > 100:
return "Weakening"
if rs_momentum <= 100:
return "Lagging"
return "Improving"
async def get_cones(symbol: str, days: int = 365) -> dict[str, Any]:
"""Volatility Cones -- realized volatility quantiles for options analysis."""
hist = await fetch_historical(symbol, days)
if hist is None:
return {"symbol": symbol, "error": "No historical data"}
try:
result = await asyncio.to_thread(
obb.technical.cones, data=hist.results,
)
items = to_list(result)
return {"symbol": symbol, "cones": items}
except Exception:
logger.warning("Volatility cones failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute volatility cones"}

View File

@@ -1,4 +1,4 @@
from openbb_service import _to_dicts, _first_or_empty
from obb_utils import to_list, first_or_empty
class MockModel:
@@ -14,34 +14,34 @@ class MockOBBject:
self.results = results
class TestToDicts:
class TestToList:
def test_none_result(self):
assert _to_dicts(None) == []
assert to_list(None) == []
def test_none_results(self):
obj = MockOBBject(results=None)
assert _to_dicts(obj) == []
assert to_list(obj) == []
def test_list_results(self):
obj = MockOBBject(results=[
MockModel({"a": 1}),
MockModel({"b": 2}),
])
result = _to_dicts(obj)
result = to_list(obj)
assert len(result) == 2
assert result[0] == {"a": 1}
def test_single_result(self):
obj = MockOBBject(results=MockModel({"x": 42}))
result = _to_dicts(obj)
result = to_list(obj)
assert result == [{"x": 42}]
class TestFirstOrEmpty:
def test_empty(self):
assert _first_or_empty(None) == {}
assert first_or_empty(None) == {}
def test_with_data(self):
obj = MockOBBject(results=[MockModel({"price": 150.0})])
result = _first_or_empty(obj)
result = first_or_empty(obj)
assert result == {"price": 150.0}

View File

@@ -66,10 +66,12 @@ async def test_stock_recommendations(mock_recs, client):
@pytest.mark.asyncio
@patch("routes_sentiment.finnhub_service.get_upgrade_downgrade", new_callable=AsyncMock)
@patch("routes_sentiment.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock)
async def test_stock_upgrades(mock_upgrades, client):
mock_upgrades.return_value = [
{"company": "Morgan Stanley", "action": "upgrade", "fromGrade": "Hold", "toGrade": "Buy"}
{"date": "2026-03-05", "company": "Morgan Stanley", "action": "upgrade",
"from_grade": "Hold", "to_grade": "Buy", "price_target_action": "Raises",
"current_price_target": 300.0, "prior_price_target": 250.0}
]
resp = await client.get("/api/v1/stock/AAPL/upgrades")
assert resp.status_code == 200