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.
This commit is contained in:
Yaojia Wang
2026-03-19 17:28:31 +01:00
parent b6f49055ad
commit 87260f4b10
24 changed files with 1877 additions and 64 deletions

156
economy_service.py Normal file
View File

@@ -0,0 +1,156 @@
"""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_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:
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 []

View File

@@ -6,6 +6,8 @@ from typing import Any
from openbb import obb from openbb import obb
from obb_utils import to_list
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PROVIDER = "fred" PROVIDER = "fred"
@@ -23,19 +25,6 @@ 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( async def get_series(
series_id: str, limit: int = 10, latest: bool = False, series_id: str, limit: int = 10, latest: bool = False,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
@@ -52,7 +41,7 @@ async def get_series(
obb.economy.fred_series, obb.economy.fred_series,
**kwargs, **kwargs,
) )
items = _to_dicts(result) items = to_list(result)
items = [ items = [
{**item, "date": str(item["date"])} {**item, "date": str(item["date"])}
if "date" in item and not isinstance(item["date"], str) if "date" in item and not isinstance(item["date"], str)

10
main.py
View File

@@ -32,6 +32,11 @@ from routes_technical import router as technical_router
from routes_quantitative import router as quantitative_router from routes_quantitative import router as quantitative_router
from routes_calendar import router as calendar_router from routes_calendar import router as calendar_router
from routes_market import router as market_router from routes_market import router as market_router
from routes_shorts import router as shorts_router
from routes_fixed_income import router as fixed_income_router
from routes_economy import router as economy_router
from routes_surveys import router as surveys_router
from routes_regulators import router as regulators_router
logging.basicConfig( logging.basicConfig(
level=settings.log_level.upper(), level=settings.log_level.upper(),
@@ -59,6 +64,11 @@ app.include_router(technical_router)
app.include_router(quantitative_router) app.include_router(quantitative_router)
app.include_router(calendar_router) app.include_router(calendar_router)
app.include_router(market_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]) @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: except Exception:
logger.warning("Futures curve failed for %s", symbol, exc_info=True) logger.warning("Futures curve failed for %s", symbol, exc_info=True)
return [] 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.""" """Shared OpenBB result conversion utilities."""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from openbb import obb
logger = logging.getLogger(__name__)
PROVIDER = "yfinance"
def to_list(result: Any) -> list[dict[str, Any]]: def to_list(result: Any) -> list[dict[str, Any]]:
"""Convert OBBject result to list of dicts with serialized dates.""" """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 return None
last = items[-1] last = items[-1]
return last.model_dump() if hasattr(last, "model_dump") else None 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

@@ -5,36 +5,18 @@ from typing import Any
from openbb import obb from openbb import obb
from obb_utils import to_list, first_or_empty
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PROVIDER = "yfinance" 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:
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.equity.price.quote, symbol, provider=PROVIDER 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]: async def get_historical(symbol: str, days: int = 365) -> list[dict]:
@@ -45,7 +27,7 @@ async def get_historical(symbol: str, days: int = 365) -> list[dict]:
start_date=start, start_date=start,
provider=PROVIDER, provider=PROVIDER,
) )
items = _to_dicts(result) items = to_list(result)
return [ return [
{**item, "date": str(item["date"])} {**item, "date": str(item["date"])}
if "date" in item and not isinstance(item["date"], str) if "date" in item and not isinstance(item["date"], str)
@@ -58,35 +40,35 @@ async def get_profile(symbol: str) -> dict:
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.equity.profile, symbol, provider=PROVIDER 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:
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.equity.fundamental.metrics, symbol, provider=PROVIDER 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]:
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.equity.fundamental.income, symbol, provider=PROVIDER 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]:
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.equity.fundamental.balance, symbol, provider=PROVIDER 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]:
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.equity.fundamental.cash, symbol, provider=PROVIDER 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:
@@ -122,7 +104,7 @@ async def get_news(symbol: str) -> list[dict]:
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.news.company, symbol, provider=PROVIDER 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:
@@ -144,35 +126,35 @@ async def get_gainers() -> list[dict]:
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.equity.discovery.gainers, provider=PROVIDER 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]:
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.equity.discovery.losers, provider=PROVIDER 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]:
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.equity.discovery.active, provider=PROVIDER 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]:
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.equity.discovery.undervalued_large_caps, provider=PROVIDER 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]:
result = await asyncio.to_thread( result = await asyncio.to_thread(
obb.equity.discovery.growth_tech, provider=PROVIDER 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]: async def get_upgrades_downgrades(symbol: str, limit: int = 20) -> list[dict]:
@@ -200,3 +182,37 @@ async def get_upgrades_downgrades(symbol: str, limit: int = 20) -> list[dict]:
] ]
return await asyncio.to_thread(_fetch) return await asyncio.to_thread(_fetch)
# --- Equity Fundamentals Extended (Group B) ---
async def get_management(symbol: str) -> list[dict]:
"""Get executive team info (name, title, compensation)."""
result = await asyncio.to_thread(
obb.equity.fundamental.management, symbol, provider=PROVIDER
)
return to_list(result)
async def get_dividends(symbol: str) -> list[dict]:
"""Get historical dividend records."""
result = await asyncio.to_thread(
obb.equity.fundamental.dividends, symbol, provider=PROVIDER
)
return to_list(result)
async def get_filings(symbol: str, form_type: str | None = None) -> list[dict]:
"""Get SEC filings (10-K, 10-Q, 8-K, etc.)."""
kwargs: dict[str, Any] = {"symbol": symbol, "provider": "sec"}
if form_type:
kwargs["type"] = form_type
result = await asyncio.to_thread(obb.equity.fundamental.filings, **kwargs)
return to_list(result)
async def search_company(query: str) -> list[dict]:
"""Search for companies by name."""
result = await asyncio.to_thread(obb.equity.search, query, provider="sec")
return to_list(result)

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

@@ -7,7 +7,7 @@ from typing import Any
from openbb import obb 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__) logger = logging.getLogger(__name__)
@@ -122,3 +122,74 @@ async def get_unitroot_test(symbol: str, days: int = 365) -> dict[str, Any]:
except Exception: except Exception:
logger.warning("Unit root test failed for %s", symbol, exc_info=True) logger.warning("Unit root test failed for %s", symbol, exc_info=True)
return {"symbol": symbol, "error": "Failed to compute unit root test"} 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: {stat}. Use: {', '.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.""" """Get growth tech stocks."""
data = await openbb_service.get_growth() data = await openbb_service.get_growth()
return ApiResponse(data=discover_items_from_list(data)) 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)

107
routes_economy.py Normal file
View File

@@ -0,0 +1,107 @@
"""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(
type: str = Query(default="real", pattern="^(nominal|real|forecast)$"),
):
"""GDP: nominal, real, or forecast."""
data = await economy_service.get_gdp(gdp_type=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-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) symbol = validate_symbol(symbol)
data = await market_service.get_futures_curve(symbol) data = await market_service.get_futures_curve(symbol)
return ApiResponse(data=data) 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) symbol = validate_symbol(symbol)
data = await quantitative_service.get_unitroot_test(symbol, days=days) data = await quantitative_service.get_unitroot_test(symbol, days=days)
return ApiResponse(data=data) 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)

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.""" """Routes for technical analysis indicators."""
from fastapi import APIRouter, Path from fastapi import APIRouter, Path, Query
from models import ApiResponse from models import ApiResponse
from route_utils import safe, validate_symbol from route_utils import safe, validate_symbol
@@ -16,3 +16,140 @@ async def stock_technical(symbol: str = Path(..., min_length=1, max_length=20)):
symbol = validate_symbol(symbol) symbol = validate_symbol(symbol)
data = await technical_service.get_technical_indicators(symbol) data = await technical_service.get_technical_indicators(symbol)
return ApiResponse(data=data) return ApiResponse(data=data)
# --- 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)

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 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( async def get_technical_indicators(
symbol: str, days: int = 400 symbol: str, days: int = 400
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Compute key technical indicators for a symbol.""" """Compute key technical indicators for a symbol."""
from datetime import datetime, timedelta hist = await fetch_historical(symbol, days)
if hist is None:
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:
return {"symbol": symbol, "error": "No historical data available"} return {"symbol": symbol, "error": "No historical data available"}
result: dict[str, Any] = {"symbol": symbol} result: dict[str, Any] = {"symbol": symbol}
@@ -144,3 +133,266 @@ def _interpret_signals(data: dict[str, Any]) -> list[str]:
signals.append("Death cross: SMA50 below SMA200 (bearish trend)") signals.append("Death cross: SMA50 below SMA200 (bearish trend)")
return signals 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_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"}