📄 sentiment.py 7,343 bytes May 01, 2026 📋 Raw

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