You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

844 lines
28 KiB
Python

"""
Stock Oracle Python Client
A comprehensive Python client library for accessing the Stock Oracle API.
Provides easy-to-use methods for retrieving financial, price, and ETF holdings data.
Usage:
from stock_oracle_client import StockOracleClient
client = StockOracleClient("http://localhost:18001")
# Get financial data using period
data = client.get_financial_data("AAPL", period="1y")
# Get price data using date range
prices = client.get_price_data("MSFT", start_date="2024-01-01", end_date="2024-12-31")
# Get ETF holdings
etf = client.get_etf_holdings("QQQ", as_of_date="2024-01-01")
"""
import requests
import json
from datetime import datetime, date, timedelta
from typing import Dict, List, Optional, Union, Any, Tuple
import logging
from enum import Enum
logger = logging.getLogger(__name__)
class StockOracleError(Exception):
"""Base exception for Stock Oracle client errors"""
pass
class StockOracleAPIError(StockOracleError):
"""API-specific errors"""
def __init__(self, message: str, status_code: int = None, response_data: Dict = None):
super().__init__(message)
self.status_code = status_code
self.response_data = response_data
class ETFDataNotAvailableError(StockOracleError):
"""ETF data not available for requested date"""
def __init__(self, message: str, availability_info: Dict = None):
super().__init__(message)
self.availability_info = availability_info
class PriceInterval(Enum):
"""Valid price data intervals"""
ONE_MINUTE = "1m"
TWO_MINUTES = "2m"
FIVE_MINUTES = "5m"
FIFTEEN_MINUTES = "15m"
THIRTY_MINUTES = "30m"
SIXTY_MINUTES = "60m"
NINETY_MINUTES = "90m"
ONE_HOUR = "1h"
ONE_DAY = "1d"
FIVE_DAYS = "5d"
ONE_WEEK = "1wk"
ONE_MONTH = "1mo"
THREE_MONTHS = "3mo"
class PeriodType(Enum):
"""Period types for financial data"""
QUARTERLY = "quarterly"
ANNUAL = "annual"
ALL = "all"
class StockOracleClient:
"""
Python client for Stock Oracle API
Args:
base_url: Base URL of the Stock Oracle API (e.g., "http://localhost:18001")
api_key: Optional API key for authentication (not implemented yet)
timeout: Request timeout in seconds (default: 30)
auto_retry: Automatically retry failed requests (default: True)
max_retries: Maximum number of retries (default: 3)
"""
def __init__(
self,
base_url: str,
api_key: Optional[str] = None,
timeout: int = 30,
auto_retry: bool = True,
max_retries: int = 3
):
self.base_url = base_url.rstrip('/')
self.api_key = api_key
self.timeout = timeout
self.auto_retry = auto_retry
self.max_retries = max_retries
self.session = requests.Session()
# Set up session headers
self.session.headers.update({
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'StockOracle-Python-Client/2.0.0'
})
if api_key:
self.session.headers['Authorization'] = f'Bearer {api_key}'
def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict:
"""
Make HTTP request to API with retry logic
Args:
method: HTTP method (GET, POST, etc.)
endpoint: API endpoint path
**kwargs: Additional arguments for requests
Returns:
Parsed JSON response
Raises:
StockOracleAPIError: On API errors
"""
url = f"{self.base_url}{endpoint}"
kwargs.setdefault('timeout', self.timeout)
retries = 0
while retries <= (self.max_retries if self.auto_retry else 0):
try:
response = self.session.request(method, url, **kwargs)
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
try:
error_data = response.json()
except (ValueError, AttributeError):
error_data = {"message": response.text}
# Don't retry on client errors (4xx)
if response.status_code < 500:
raise StockOracleAPIError(
f"API Error: {error_data.get('message', error_data.get('detail', str(e)))}",
status_code=response.status_code,
response_data=error_data
)
# Retry on server errors (5xx)
retries += 1
if retries > self.max_retries:
raise StockOracleAPIError(
f"API Error after {self.max_retries} retries: {error_data.get('message', str(e))}",
status_code=response.status_code,
response_data=error_data
)
except requests.exceptions.RequestException as e:
retries += 1
if retries > self.max_retries:
raise StockOracleAPIError(f"Request Error after {self.max_retries} retries: {str(e)}")
def _format_date(self, date_obj: Union[str, date, datetime]) -> str:
"""Format date object to API-compatible string"""
if isinstance(date_obj, str):
return date_obj
elif isinstance(date_obj, datetime):
return date_obj.date().isoformat()
elif isinstance(date_obj, date):
return date_obj.isoformat()
else:
raise ValueError(f"Invalid date type: {type(date_obj)}")
# ============= Health & Status =============
def get_health(self) -> Dict:
"""
Get API health status
Returns:
Health status information
"""
return self._make_request('GET', '/api/v1/health')
def get_detailed_health(self) -> Dict:
"""
Get detailed health status including database and cache
Returns:
Detailed health status
"""
return self._make_request('GET', '/api/v1/health/detailed')
# ============= Financial Data API =============
def get_financial_data(
self,
ticker: str,
start_date: Optional[Union[str, date, datetime]] = None,
end_date: Optional[Union[str, date, datetime]] = None,
quarters: Optional[List[str]] = None,
period: Optional[str] = None,
period_type: Union[str, PeriodType] = PeriodType.ALL,
include_metrics: bool = True,
force_refresh: bool = False
) -> Dict:
"""
Get financial data for a stock ticker
⚠️ IMPORTANT: Period parameter now uses yesterday as end date to ensure data availability
Args:
ticker: Stock ticker symbol (e.g., "AAPL")
start_date: Start date (string, date, or datetime)
end_date: End date (string, date, or datetime)
quarters: List of quarters (e.g., ["2024Q1", "2024Q2"])
period: Period string (e.g., "1d", "3m", "2y") - automatically excludes today's data
period_type: Type of periods (PeriodType enum or string)
include_metrics: Include calculated metrics
force_refresh: Force refresh from SEC data
Returns:
Financial data response
Note:
Must specify exactly one of: (start_date + end_date), quarters, or period
"""
if isinstance(period_type, PeriodType):
period_type = period_type.value
data = {
"ticker": ticker,
"period_type": period_type,
"include_metrics": include_metrics,
"force_refresh": force_refresh
}
# Add time parameters
if period:
data["period"] = period
elif quarters:
data["quarters"] = quarters
elif start_date and end_date:
data["start_date"] = self._format_date(start_date)
data["end_date"] = self._format_date(end_date)
else:
# Default to last year
data["period"] = "1y"
return self._make_request('POST', '/api/v1/financial/data', json=data)
# ============= Price Data API =============
def get_price_data(
self,
ticker: str,
start_date: Optional[Union[str, date, datetime]] = None,
end_date: Optional[Union[str, date, datetime]] = None,
quarters: Optional[List[str]] = None,
period: Optional[str] = None,
interval: Union[str, PriceInterval] = PriceInterval.ONE_DAY,
force_refresh: bool = False
) -> Dict:
"""
Get price data for a stock ticker
⚠️ IMPORTANT: Period parameter now uses yesterday as end date to ensure data availability
Args:
ticker: Stock ticker symbol (e.g., "AAPL")
start_date: Start date (string, date, or datetime)
end_date: End date (string, date, or datetime)
quarters: List of quarters (e.g., ["2024Q1", "2024Q2"])
period: Period string (e.g., "1d", "3m", "2y") - automatically excludes today's data
interval: Data interval (PriceInterval enum or string)
force_refresh: Force refresh from Yahoo Finance
Returns:
Price data response
Note:
Must specify exactly one of: (start_date + end_date), quarters, or period
"""
if isinstance(interval, PriceInterval):
interval = interval.value
data = {
"ticker": ticker,
"interval": interval,
"force_refresh": force_refresh
}
# Add time parameters
if period:
data["period"] = period
elif quarters:
data["quarters"] = quarters
elif start_date and end_date:
data["start_date"] = self._format_date(start_date)
data["end_date"] = self._format_date(end_date)
else:
# Default to last year
data["period"] = "1y"
return self._make_request('POST', '/api/v1/price/data', json=data)
# ============= Bulk Data API =============
def get_bulk_financial_data(
self,
tickers: List[str],
start_date: Optional[Union[str, date, datetime]] = None,
end_date: Optional[Union[str, date, datetime]] = None,
quarters: Optional[List[str]] = None,
period: Optional[str] = None,
period_type: Union[str, PeriodType] = PeriodType.ALL,
include_metrics: bool = True,
force_refresh: bool = False
) -> Dict:
"""
Get financial data for multiple stock tickers
⚠️ IMPORTANT: Period parameter now uses yesterday as end date to ensure data availability
Args:
tickers: List of stock ticker symbols (max 500)
start_date: Start date (string, date, or datetime)
end_date: End date (string, date, or datetime)
quarters: List of quarters (e.g., ["2024Q1", "2024Q2"])
period: Period string (e.g., "1d", "3m", "2y") - automatically excludes today's data
period_type: Type of periods (PeriodType enum or string)
include_metrics: Include calculated metrics
force_refresh: Force refresh from SEC data
Returns:
Bulk financial data response
Note:
Must specify exactly one of: (start_date + end_date), quarters, or period
"""
if isinstance(period_type, PeriodType):
period_type = period_type.value
data = {
"tickers": tickers,
"period_type": period_type,
"include_metrics": include_metrics,
"force_refresh": force_refresh
}
# Add time parameters
if period:
data["period"] = period
elif quarters:
data["quarters"] = quarters
elif start_date and end_date:
data["start_date"] = self._format_date(start_date)
data["end_date"] = self._format_date(end_date)
else:
# Default to last year
data["period"] = "1y"
return self._make_request('POST', '/api/v1/financial/data/bulk', json=data)
def get_bulk_price_data(
self,
tickers: List[str],
start_date: Optional[Union[str, date, datetime]] = None,
end_date: Optional[Union[str, date, datetime]] = None,
quarters: Optional[List[str]] = None,
period: Optional[str] = None,
interval: Union[str, PriceInterval] = PriceInterval.ONE_DAY,
force_refresh: bool = False
) -> Dict:
"""
Get price data for multiple stock tickers
⚠️ IMPORTANT: Period parameter now uses yesterday as end date to ensure data availability
Args:
tickers: List of stock ticker symbols (max 500)
start_date: Start date (string, date, or datetime)
end_date: End date (string, date, or datetime)
quarters: List of quarters (e.g., ["2024Q1", "2024Q2"])
period: Period string (e.g., "1d", "3m", "2y") - automatically excludes today's data
interval: Data interval (PriceInterval enum or string)
force_refresh: Force refresh from Yahoo Finance
Returns:
Bulk price data response
Note:
Must specify exactly one of: (start_date + end_date), quarters, or period
"""
if isinstance(interval, PriceInterval):
interval = interval.value
data = {
"tickers": tickers,
"interval": interval,
"force_refresh": force_refresh
}
# Add time parameters
if period:
data["period"] = period
elif quarters:
data["quarters"] = quarters
elif start_date and end_date:
data["start_date"] = self._format_date(start_date)
data["end_date"] = self._format_date(end_date)
else:
# Default to last year
data["period"] = "1y"
return self._make_request('POST', '/api/v1/price/data/bulk', json=data)
# ============= News & Social Media API =============
def get_news_social_data(
self,
ticker: str,
days_back: int = 7,
max_articles: int = 20,
max_social_posts: int = 15,
include_social: bool = True
) -> Dict:
"""
Get news and social media data for a stock ticker
Args:
ticker: Stock ticker symbol (e.g., "AAPL")
days_back: Number of days to look back (default: 7)
max_articles: Maximum number of news articles (default: 20)
max_social_posts: Maximum number of social posts (default: 15)
include_social: Include social media data (default: True)
Returns:
News and social media data response
"""
params = {
"days_back": days_back,
"max_articles": max_articles,
"max_social_posts": max_social_posts,
"include_social": str(include_social).lower()
}
return self._make_request('GET', f'/api/v1/news/{ticker}', params=params)
def get_news_only(
self,
ticker: str,
days_back: int = 7,
max_articles: int = 30
) -> Dict:
"""
Get news data only (faster response)
Args:
ticker: Stock ticker symbol (e.g., "AAPL")
days_back: Number of days to look back (default: 7)
max_articles: Maximum number of news articles (default: 30)
Returns:
News data response
"""
params = {
"days_back": days_back,
"max_articles": max_articles
}
return self._make_request('GET', f'/api/v1/news/{ticker}/news-only', params=params)
def get_social_only(
self,
ticker: str,
days_back: int = 7,
max_social_posts: int = 20
) -> Dict:
"""
Get social media data only
Args:
ticker: Stock ticker symbol (e.g., "AAPL")
days_back: Number of days to look back (default: 7)
max_social_posts: Maximum number of social posts (default: 20)
Returns:
Social media data response
"""
params = {
"days_back": days_back,
"max_social_posts": max_social_posts
}
return self._make_request('GET', f'/api/v1/news/{ticker}/social-only', params=params)
# ============= Metadata & Catalog =============
def get_data_catalog(self) -> Dict:
"""
Get data field catalog
Returns:
Data catalog with field descriptions
"""
return self._make_request('GET', '/api/v1/metadata/catalog')
def get_error_logs(
self,
limit: int = 100,
offset: int = 0,
min_level: str = "ERROR"
) -> Dict:
"""
Get error logs (admin)
Args:
limit: Number of logs to retrieve
offset: Pagination offset
min_level: Minimum log level (ERROR, WARNING, INFO)
Returns:
Error logs
"""
params = {
"limit": limit,
"offset": offset,
"min_level": min_level
}
return self._make_request('GET', '/api/v1/admin/errors', params=params)
# ============= Migration & Admin =============
def migrate_data(
self,
source_url: str,
api_key: str,
tickers: Optional[List[str]] = None,
start_date: Optional[Union[str, date, datetime]] = None,
end_date: Optional[Union[str, date, datetime]] = None
) -> Dict:
"""
Migrate data from another Stock Oracle instance
Args:
source_url: Source API URL
api_key: API key for migration
tickers: Specific tickers to migrate (optional)
start_date: Start date for migration (optional)
end_date: End date for migration (optional)
Returns:
Migration status
"""
data = {
"source_url": source_url,
"api_key": api_key
}
if tickers:
data["tickers"] = tickers
if start_date:
data["start_date"] = self._format_date(start_date)
if end_date:
data["end_date"] = self._format_date(end_date)
# Server expects X-API-Key header (dependency verify_migration_key)
headers = {"X-API-Key": api_key}
return self._make_request('POST', '/api/v1/admin/migrate', json=data, headers=headers)
# ============= Utility Methods =============
def search_tickers(self, query: str) -> List[str]:
"""
Search for ticker symbols (client-side)
Args:
query: Search query
Returns:
List of matching ticker symbols
"""
# This is a placeholder - you might want to implement a real search
# against a ticker database or API endpoint
common_tickers = [
"AAPL", "MSFT", "GOOGL", "AMZN", "META", "TSLA", "NVDA",
"QQQ", "SPY", "IWM", "EFA", "EEM", "VTI", "VOO", "ARKK"
]
query = query.upper()
return [t for t in common_tickers if query in t]
def validate_ticker(self, ticker: str) -> bool:
"""
Validate if a ticker exists
Args:
ticker: Ticker symbol to validate
Returns:
True if ticker is valid
"""
try:
# Try to get minimal data to validate ticker
response = self.get_financial_data(
ticker,
period="1d",
include_metrics=False
)
return 'error' not in response
except:
return False
def get_latest_filing_date(self, ticker: str) -> Optional[str]:
"""
Get the latest SEC filing date for a ticker
Args:
ticker: Stock ticker symbol
Returns:
Latest filing date as string or None
"""
try:
data = self.get_financial_data(ticker, period="1d")
if data.get('financial_data'):
return data['financial_data'][0].get('date')
except:
pass
return None
# Convenience functions for quick access
def get_financial_data(ticker: str, period: str = "1y", base_url: str = "http://localhost:18001") -> Dict:
"""
Quick function to get financial data
Args:
ticker: Stock ticker symbol
period: Period string (e.g., "1d", "3m", "2y")
base_url: API base URL
Returns:
Financial data
"""
client = StockOracleClient(base_url)
return client.get_financial_data(ticker, period=period)
def get_price_data(ticker: str, period: str = "1y", base_url: str = "http://localhost:18001") -> Dict:
"""
Quick function to get price data
Args:
ticker: Stock ticker symbol
period: Period string (e.g., "1d", "3m", "2y")
base_url: API base URL
Returns:
Price data
"""
client = StockOracleClient(base_url)
return client.get_price_data(ticker, period=period)
def get_etf_holdings(ticker: str, as_of_date: Optional[str] = None, base_url: str = "http://localhost:18001") -> Dict:
"""
Quick function to get ETF holdings
Args:
ticker: ETF ticker symbol
as_of_date: Optional date for historical data
base_url: API base URL
Returns:
ETF holdings data
"""
client = StockOracleClient(base_url)
return client.get_etf_holdings(ticker, as_of_date=as_of_date)
def get_news_social_data(
ticker: str,
days_back: int = 7,
max_articles: int = 20,
include_social: bool = True,
base_url: str = "http://localhost:18001"
) -> Dict:
"""
Quick function to get news and social media data
Args:
ticker: Stock ticker symbol
days_back: Number of days to look back
max_articles: Maximum number of news articles
include_social: Include social media data
base_url: API base URL
Returns:
News and social media data
"""
client = StockOracleClient(base_url)
return client.get_news_social_data(ticker, days_back, max_articles, include_social=include_social)
if __name__ == "__main__":
# Example usage
client = StockOracleClient("http://localhost:18001")
try:
# Test health
health = client.get_health()
print("API Health:", health["status"])
print("\n" + "="*50)
print("FINANCIAL DATA EXAMPLES")
print("="*50)
# Get financial data using period (POST endpoint)
print("\n=== Financial Data (Period: 1y) ===")
financial = client.get_financial_data("AAPL", period="1y")
print(f"Company: {financial['company']['name']}")
print(f"Data points: {len(financial['financial_data'])}")
if financial['financial_data']:
latest = financial['financial_data'][0]
print(f"Latest filing: {latest['date']}")
if 'metrics' in latest:
print(f" P/E Ratio: {latest['metrics'].get('pe_ratio', 'N/A')}")
print(f" ROE: {latest['metrics'].get('return_on_equity', 'N/A')}")
print("\n" + "="*50)
print("PRICE DATA EXAMPLES")
print("="*50)
# Get price data using date range
print("\n=== Price Data (Date Range) ===")
from datetime import date
price = client.get_price_data(
"AAPL",
start_date=date(2024, 1, 1),
end_date=date(2024, 12, 31),
interval=PriceInterval.ONE_DAY
)
print(f"Ticker: {price['ticker']}")
print(f"Price points: {len(price['price_data'])}")
if price['price_data']:
latest = price['price_data'][-1]
print(f"Latest date: {latest['date']}")
print(f" Close: ${latest['close']:.2f}")
print(f" Volume: {latest['volume']:,}")
print("\n" + "="*50)
print("BULK DATA EXAMPLES")
print("="*50)
# Bulk financial data
print("\n=== Bulk Financial Data ===")
bulk_financial = client.get_bulk_financial_data(
["AAPL", "MSFT", "GOOGL"],
period="3m",
period_type=PeriodType.QUARTERLY
)
print(f"Requested: {len(bulk_financial['requested_tickers'])} tickers")
print(f"Successful: {len(bulk_financial['results'])} tickers")
if bulk_financial.get('errors'):
print(f"Failed: {len(bulk_financial['errors'])} tickers")
print("\n" + "="*50)
print("NEWS & SOCIAL MEDIA EXAMPLES")
print("="*50)
# Get news and social media data
print("\n=== News & Social Media Data ===")
news_social = client.get_news_social_data("AAPL", days_back=7, max_articles=10, include_social=True)
print(f"Ticker: {news_social['ticker']}")
print(f"Retrieved at: {news_social['retrieved_at']}")
print(f"Total news articles: {news_social['news']['total_articles']}")
print(f"Total social posts: {news_social['social_media']['total_posts']}")
print(f"Total items: {news_social['summary']['total_items']}")
if news_social['news']['articles']:
print("\nLatest news article:")
article = news_social['news']['articles'][0]
print(f" Title: {article['title'][:80]}...")
print(f" Source: {article['source']}")
print(f" Published: {article.get('published_at', 'N/A')}")
if news_social['social_media']['posts']:
print("\nLatest social post:")
post = news_social['social_media']['posts'][0]
print(f" Title: {post['title'][:80]}...")
print(f" Platform: {post['platform']}")
print(f" Score: {post.get('score', 'N/A')}")
# Get news only (faster)
print("\n=== News Only (Faster) ===")
news_only = client.get_news_only("TSLA", days_back=3, max_articles=5)
print(f"News articles for TSLA: {news_only['news']['total_articles']}")
# Get social media only
print("\n=== Social Media Only ===")
social_only = client.get_social_only("NVDA", days_back=5, max_social_posts=10)
print(f"Social posts for NVDA: {social_only['social_media']['total_posts']}")
print("\n" + "="*50)
print("ADDITIONAL FEATURES")
print("="*50)
# Get supported ETFs
print("\n=== Supported ETFs ===")
supported = client.get_supported_etfs()
print(f"Total supported ETFs: {supported['total_etfs']}")
print(f"Examples: {', '.join(supported['supported_tickers'][:10])}...")
# Search tickers (client-side example)
print("\n=== Ticker Search ===")
results = client.search_tickers("AA")
print(f"Search 'AA' results: {results}")
# Validate ticker
print("\n=== Ticker Validation ===")
is_valid = client.validate_ticker("AAPL")
print(f"AAPL is valid: {is_valid}")
is_valid = client.validate_ticker("INVALID123")
print(f"INVALID123 is valid: {is_valid}")
except StockOracleAPIError as e:
print(f"\nAPI Error: {e}")
print(f"Status Code: {e.status_code}")
print(f"Response: {e.response_data}")
except ETFDataNotAvailableError as e:
print(f"\nETF Data Not Available: {e}")
if e.availability_info:
print(f"Availability Info: {json.dumps(e.availability_info, indent=2)}")
except Exception as e:
print(f"\nError: {e}")
import traceback
traceback.print_exc()