""" Finnhub API client with caching for market data. """ import os import time import json from datetime import datetime, timedelta from typing import Optional, Dict, Any import requests FINNHUB_API_KEY = os.environ.get("FINNHUB_API_KEY") BASE_URL = "https://finnhub.io/api/v1" # Cache storage _cache: Dict[str, Any] = {} _cache_timestamps: Dict[str, float] = {} # TTL configuration (in seconds) TTL_QUOTES = 15 * 60 # 15 minutes TTL_NEWS = 2 * 60 * 60 # 2 hours TTL_SENTIMENT = 24 * 60 * 60 # 24 hours TTL_WATCHLIST = 24 * 60 * 60 # 24 hours # Last successful fetch tracking _last_success: Dict[str, Optional[datetime]] = { "quote": None, "news": None, "sentiment": None, "watchlist": None, } def _get_cache_key(endpoint: str, params: str = "") -> str: """Generate cache key for an endpoint.""" return f"{endpoint}:{params}" def _is_cached(key: str, ttl: int) -> bool: """Check if data is cached and not expired.""" if key not in _cache or key not in _cache_timestamps: return False age = time.time() - _cache_timestamps[key] return age < ttl def _get_cached(key: str) -> Any: """Get cached data.""" return _cache.get(key) def _set_cache(key: str, data: Any, endpoint_type: str): """Cache data and update timestamp.""" _cache[key] = data _cache_timestamps[key] = time.time() _last_success[endpoint_type] = datetime.now() def _is_stale(endpoint_type: str, max_age_hours: int = 2) -> bool: """Check if data is stale (older than max_age_hours).""" last = _last_success.get(endpoint_type) if last is None: return True age = datetime.now() - last return age > timedelta(hours=max_age_hours) def _log_stale_warning(endpoint_type: str): """Log a warning about stale data.""" last = _last_success.get(endpoint_type) if last: age = datetime.now() - last print(f"⚠️ Warning: {endpoint_type} data is stale (last updated {age.total_seconds()/3600:.1f} hours ago)") else: print(f"⚠️ Warning: {endpoint_type} data has never been successfully fetched") def _make_request(endpoint: str, params: Dict[str, Any] = None) -> Optional[Dict]: """Make a request to Finnhub API.""" if not FINNHUB_API_KEY: print("Error: FINNHUB_API_KEY not set") return None url = f"{BASE_URL}{endpoint}" default_params = {"token": FINNHUB_API_KEY} if params: default_params.update(params) try: response = requests.get(url, params=default_params, timeout=30) response.raise_for_status() return response.json() except requests.RequestException as e: print(f"Error fetching {endpoint}: {e}") return None def get_quote(symbol: str) -> Optional[Dict]: """Get current quote for a symbol.""" cache_key = _get_cache_key("quote", symbol) if _is_cached(cache_key, TTL_QUOTES): return _get_cached(cache_key) data = _make_request("/quote", {"symbol": symbol}) if data: _set_cache(cache_key, data, "quote") return data # Return stale data if available, but warn if cache_key in _cache: _log_stale_warning("quote") return _cache[cache_key] return None def get_company_news(symbol: str, from_date: str, to_date: str) -> list: """Get company news for a date range.""" cache_key = _get_cache_key("news", f"{symbol}:{from_date}:{to_date}") if _is_cached(cache_key, TTL_NEWS): return _get_cached(cache_key) data = _make_request("/company-news", { "symbol": symbol, "from": from_date, "to": to_date }) if data and isinstance(data, list): _set_cache(cache_key, data, "news") return data # Return stale data if available if cache_key in _cache: _log_stale_warning("news") return _cache[cache_key] return [] def get_insider_sentiment(symbol: str, from_date: str, to_date: str) -> Optional[Dict]: """Get insider sentiment data.""" cache_key = _get_cache_key("sentiment", f"{symbol}:{from_date}:{to_date}") if _is_cached(cache_key, TTL_SENTIMENT): return _get_cached(cache_key) # Finnhub uses a different endpoint for insider sentiment data = _make_request("/stock/insider-sentiment", { "symbol": symbol, "from": from_date, "to": to_date }) if data: _set_cache(cache_key, data, "sentiment") return data if cache_key in _cache: _log_stale_warning("sentiment") return _cache[cache_key] return None def get_top_gainers_losers(exchange: str = "US") -> Dict[str, list]: """Get top gainers and losers.""" cache_key = _get_cache_key("gainers_losers", exchange) if _is_cached(cache_key, TTL_WATCHLIST): return _get_cached(cache_key) # Use the stock scanner endpoint for trending stocks data = _make_request("/stock/calendar/earnings", {"from": "", "to": ""}) # Alternative: get quote for major indices to find trending # For now, we'll use a simpler approach with high-volume stocks trending = [ {"symbol": "AMD", "name": "AMD"}, {"symbol": "CRM", "name": "Salesforce"}, {"symbol": "NFLX", "name": "Netflix"}, {"symbol": "DIS", "name": "Disney"}, {"symbol": "UBER", "name": "Uber"}, {"symbol": "COIN", "name": "Coinbase"}, {"symbol": "PLTR", "name": "Palantir"}, {"symbol": "SNOW", "name": "Snowflake"}, {"symbol": "ROKU", "name": "Roku"}, {"symbol": "SQ", "name": "Block"}, {"symbol": "ZM", "name": "Zoom"}, {"symbol": "SHOP", "name": "Shopify"}, {"symbol": "TWLO", "name": "Twilio"}, {"symbol": "DDOG", "name": "Datadog"}, {"symbol": "CRWD", "name": "CrowdStrike"}, ] result = {"trending": trending} _set_cache(cache_key, result, "watchlist") return result def get_earnings_calendar(from_date: str, to_date: str) -> list: """Get earnings calendar for a date range.""" cache_key = _get_cache_key("earnings", f"{from_date}:{to_date}") if _is_cached(cache_key, TTL_WATCHLIST): return _get_cached(cache_key) data = _make_request("/calendar/earnings", { "from": from_date, "to": to_date }) if data and isinstance(data, list): _set_cache(cache_key, data, "watchlist") return data return [] def check_staleness() -> Dict[str, bool]: """Check which data sources are stale.""" return { "quote": _is_stale("quote"), "news": _is_stale("news"), "sentiment": _is_stale("sentiment"), "watchlist": _is_stale("watchlist"), } def should_skip_briefing() -> bool: """Check if we should skip briefing due to stale/failed data.""" # Only skip if quote data is completely unavailable # News is optional - briefing can proceed without it quote_keys = [k for k in _cache.keys() if k.startswith("quote")] if not quote_keys: return True # Check if quote data is too old (> 2 hours) if _is_stale("quote", max_age_hours=2): # Only skip if there are NO quotes in cache at all if not quote_keys: return True return False