"""
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