""" 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) # ============= News v2 — multi-source structured ingest ============= def get_news_headlines( self, symbols: Optional[Union[str, List[str]]] = None, start: Optional[str] = None, end: Optional[str] = None, sources: Optional[Union[str, List[str]]] = None, limit: int = 100, cursor: Optional[str] = None, ) -> Dict: """ Fetch raw multi-source news headlines (Alpaca News, StockTwits, Finnhub, GDELT). Args: symbols: Ticker(s) — string "AAPL" or list ["AAPL", "MSFT"]. Max 50. start: UTC ISO datetime lower bound (inclusive) end: UTC ISO datetime upper bound (exclusive) sources: Optional filter, subset of {alpaca_benzinga, stocktwits, finnhub, gdelt} limit: Max rows (1-500, default 100) cursor: published_at_lt cursor (ISO datetime) for pagination """ params: Dict[str, Any] = {"limit": limit} if symbols is not None: params["symbols"] = symbols if isinstance(symbols, str) else ",".join(symbols) if start is not None: params["start"] = start if end is not None: params["end"] = end if sources is not None: params["sources"] = sources if isinstance(sources, str) else ",".join(sources) if cursor is not None: params["cursor"] = cursor return self._make_request("GET", "/api/v1/news/v2/headlines", params=params) def get_news_session_aggregate( self, symbol: str, session_date: str, window: str = "premarket", sources: Optional[Union[str, List[str]]] = None, force_refresh: bool = False, ) -> Dict: """ Session-aggregated news for one ticker. Args: symbol: Ticker session_date: ET session date (YYYY-MM-DD) window: One of "premarket", "intraday", "post", "full_session" sources: Optional source filter force_refresh: Bypass Redis cache """ params: Dict[str, Any] = { "symbol": symbol, "session_date": session_date, "window": window, "force_refresh": str(force_refresh).lower(), } if sources is not None: params["sources"] = sources if isinstance(sources, str) else ",".join(sources) return self._make_request("GET", "/api/v1/news/v2/session_aggregate", params=params) def get_news_session_aggregate_batch( self, session_date: str, window: str, symbols: List[str], sources: Optional[List[str]] = None, ) -> Dict: """ Batch session-aggregated news. Up to 200 tickers per call. fithia2's V49 backtest hot path — call once per session, cache locally. """ body: Dict[str, Any] = { "session_date": session_date, "window": window, "symbols": symbols, } if sources is not None: body["sources"] = sources return self._make_request("POST", "/api/v1/news/v2/session_aggregate/batch", json=body) def get_news_coverage(self, source: str, symbol: Optional[str] = None) -> Dict: """ Per-source ingest depth probe — call before backtest window selection. Args: source: One of {alpaca_benzinga, stocktwits, finnhub, gdelt} symbol: Optional ticker filter """ params: Dict[str, Any] = {"source": source} if symbol is not None: params["symbol"] = symbol return self._make_request("GET", "/api/v1/news/v2/coverage", 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()