"""Finnhub-based scanner data for market-wide analysis. Provides market movers, index levels, sector performance, and topic news using the Finnhub REST API. The public function names match the Alpha Vantage scanner equivalents (with ``_finnhub`` suffix) so they slot cleanly into the vendor routing layer in ``interface.py``. Notes on Finnhub free-tier limitations: - There is no dedicated TOP_GAINERS / TOP_LOSERS endpoint on the free tier. ``get_market_movers_finnhub`` fetches quotes for a curated basket of large-cap S&P 500 stocks and sorts by daily change percentage. - The /news endpoint maps topic strings to the four available Finnhub categories (general, forex, crypto, merger). """ from datetime import datetime from typing import Annotated from .finnhub_common import ( FinnhubError, _make_api_request, _now_str, _rate_limited_request, ) # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- # Maximum length for error messages embedded in table cells / log lines _MAX_ERROR_LEN = 60 def _safe_fmt(value, fmt: str = "${:.2f}", fallback: str = "N/A") -> str: """Safely format a numeric value, returning *fallback* on None or bad types.""" if value is None: return fallback try: return fmt.format(float(value)) except (ValueError, TypeError): return str(value) # Representative S&P 500 large-caps used as the movers basket. # Sorted roughly by market-cap weight — first 50 cover the bulk of the index. _SP500_SAMPLE: list[str] = [ "AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "TSLA", "BRK.B", "UNH", "LLY", "JPM", "XOM", "V", "AVGO", "PG", "MA", "JNJ", "HD", "MRK", "ABBV", "CVX", "COST", "CRM", "AMD", "NFLX", "WMT", "BAC", "KO", "PEP", "ADBE", "TMO", "ACN", "MCD", "CSCO", "ABT", "GE", "DHR", "TXN", "NKE", "PFE", "NEE", "WFC", "ORCL", "COP", "CAT", "DIS", "MS", "LIN", "BMY", "HON", ] # SPDR ETFs used as sector proxies (11 GICS sectors) _SECTOR_ETFS: dict[str, str] = { "Technology": "XLK", "Healthcare": "XLV", "Financials": "XLF", "Energy": "XLE", "Consumer Discretionary": "XLY", "Consumer Staples": "XLP", "Industrials": "XLI", "Materials": "XLB", "Real Estate": "XLRE", "Utilities": "XLU", "Communication Services": "XLC", } # Index ETF proxies _INDEX_PROXIES: list[tuple[str, str]] = [ ("S&P 500 (SPY)", "SPY"), ("Dow Jones (DIA)", "DIA"), ("NASDAQ (QQQ)", "QQQ"), ("Russell 2000 (IWM)", "IWM"), ("VIX (^VIX)", "^VIX"), ] # Mapping from human topic strings → Finnhub /news category _TOPIC_TO_CATEGORY: dict[str, str] = { "market": "general", "general": "general", "economy": "general", "macro": "general", "technology": "general", "tech": "general", "finance": "general", "financial": "general", "earnings": "general", "ipo": "general", "mergers": "merger", "m&a": "merger", "merger": "merger", "acquisition": "merger", "forex": "forex", "fx": "forex", "currency": "forex", "crypto": "crypto", "cryptocurrency": "crypto", "blockchain": "crypto", "bitcoin": "crypto", "ethereum": "crypto", } # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _fetch_quote(symbol: str) -> dict: """Fetch a single Finnhub quote for a symbol using the rate limiter. Args: symbol: Ticker symbol. Returns: Normalised quote dict with keys: symbol, current_price, change, change_percent, high, low, open, prev_close. Raises: FinnhubError: On API or parse errors. """ data = _rate_limited_request("quote", {"symbol": symbol}) current_price: float = data.get("c", 0.0) prev_close: float = data.get("pc", 0.0) change: float = data.get("d") or 0.0 change_pct: float = data.get("dp") or 0.0 return { "symbol": symbol, "current_price": current_price, "change": change, "change_percent": change_pct, "high": data.get("h", 0.0), "low": data.get("l", 0.0), "open": data.get("o", 0.0), "prev_close": prev_close, } # --------------------------------------------------------------------------- # Public scanner functions # --------------------------------------------------------------------------- def get_market_movers_finnhub( category: Annotated[str, "Category: 'gainers', 'losers', or 'active'"], ) -> str: """Get market movers by fetching quotes for a basket of large-cap S&P 500 stocks. Finnhub's free tier does not expose a TOP_GAINERS_LOSERS endpoint. This function fetches /quote for a pre-defined sample of 50 large-cap tickers and sorts by daily change percentage to approximate gainer/loser lists. The 'active' category uses absolute change percentage (highest volatility). Args: category: One of ``'gainers'``, ``'losers'``, or ``'active'``. Returns: Markdown table with Symbol, Price, Change, Change %, ranked by category. Raises: ValueError: When an unsupported category is requested. FinnhubError: When all quote fetches fail. """ valid_categories = {"gainers", "losers", "active"} if category not in valid_categories: raise ValueError( f"Invalid category '{category}'. Must be one of: {sorted(valid_categories)}" ) rows: list[dict] = [] errors: list[str] = [] for symbol in _SP500_SAMPLE: try: quote = _fetch_quote(symbol) # Skip symbols where the market is closed / data unavailable if quote["current_price"] == 0 and quote["prev_close"] == 0: continue rows.append(quote) except FinnhubError as exc: errors.append(f"{symbol}: {str(exc)[:_MAX_ERROR_LEN]}") if not rows: raise FinnhubError( f"All {len(_SP500_SAMPLE)} quote fetches failed for market movers. " f"Sample error: {errors[0] if errors else 'unknown'}" ) # Sort according to category if category == "gainers": rows.sort(key=lambda r: r["change_percent"], reverse=True) label = "Top Gainers" elif category == "losers": rows.sort(key=lambda r: r["change_percent"]) label = "Top Losers" else: # active — sort by absolute change % rows.sort(key=lambda r: abs(r["change_percent"]), reverse=True) label = "Most Active (by Change %)" header = ( f"# Market Movers: {label} (Finnhub — S&P 500 Sample)\n" f"# Data retrieved on: {_now_str()}\n\n" ) result = header result += "| Symbol | Price | Change | Change % |\n" result += "|--------|-------|--------|----------|\n" for row in rows[:15]: symbol = row["symbol"] price_str = f"${row['current_price']:.2f}" change_str = f"{row['change']:+.2f}" change_pct_str = f"{row['change_percent']:+.2f}%" result += f"| {symbol} | {price_str} | {change_str} | {change_pct_str} |\n" if errors: result += f"\n_Note: {len(errors)} symbols failed to fetch._\n" return result def get_market_indices_finnhub() -> str: """Get major market index levels via Finnhub /quote for ETF proxies and VIX. Fetches quotes for: SPY (S&P 500), DIA (Dow Jones), QQQ (NASDAQ), IWM (Russell 2000), and ^VIX (Volatility Index). Returns: Markdown table with Index, Price, Change, Change %. Raises: FinnhubError: When all index fetches fail. """ header = ( f"# Major Market Indices (Finnhub)\n" f"# Data retrieved on: {_now_str()}\n\n" ) result = header result += "| Index | Price | Change | Change % |\n" result += "|-------|-------|--------|----------|\n" success_count = 0 for display_name, symbol in _INDEX_PROXIES: try: quote = _fetch_quote(symbol) price = quote["current_price"] change = quote["change"] change_pct = quote["change_percent"] # VIX has no dollar sign is_vix = "VIX" in display_name price_str = f"{price:.2f}" if is_vix else f"${price:.2f}" change_str = f"{change:+.2f}" change_pct_str = f"{change_pct:+.2f}%" result += f"| {display_name} | {price_str} | {change_str} | {change_pct_str} |\n" success_count += 1 except FinnhubError as exc: result += f"| {display_name} | Error | - | {str(exc)[:_MAX_ERROR_LEN]} |\n" if success_count == 0: raise FinnhubError("All market index fetches failed.") return result def get_sector_performance_finnhub() -> str: """Get daily change % for the 11 GICS sectors via SPDR ETF quotes. Fetches one /quote call per SPDR ETF (XLK, XLV, XLF, XLE, XLI, XLY, XLP, XLRE, XLU, XLB, XLC) and presents daily performance. Returns: Markdown table with Sector, ETF, Price, Day Change %. Raises: FinnhubError: When all sector fetches fail. """ header = ( f"# Sector Performance (Finnhub — SPDR ETF Proxies)\n" f"# Data retrieved on: {_now_str()}\n\n" ) result = header result += "| Sector | ETF | Price | Day Change % |\n" result += "|--------|-----|-------|---------------|\n" success_count = 0 last_error: Exception | None = None for sector_name, etf in _SECTOR_ETFS.items(): try: quote = _fetch_quote(etf) price_str = f"${quote['current_price']:.2f}" change_pct_str = f"{quote['change_percent']:+.2f}%" result += f"| {sector_name} | {etf} | {price_str} | {change_pct_str} |\n" success_count += 1 except FinnhubError as exc: last_error = exc result += f"| {sector_name} | {etf} | Error | {str(exc)[:_MAX_ERROR_LEN]} |\n" # If ALL sectors failed, raise so route_to_vendor can fall back if success_count == 0 and last_error is not None: raise FinnhubError( f"All {len(_SECTOR_ETFS)} sector queries failed. Last error: {last_error}" ) return result def get_topic_news_finnhub( topic: Annotated[str, "News topic (e.g., 'market', 'crypto', 'mergers')"], limit: Annotated[int, "Maximum number of articles to return"] = 20, ) -> str: """Fetch topic-based market news via Finnhub /news. Maps the ``topic`` string to one of the four Finnhub news categories (general, forex, crypto, merger) and returns a formatted markdown list of recent articles. Args: topic: A topic string. Known topics are mapped to Finnhub categories; unknown topics default to ``'general'``. limit: Maximum number of articles to return (default 20). Returns: Markdown-formatted news feed. Raises: FinnhubError: On API-level errors. """ finnhub_category = _TOPIC_TO_CATEGORY.get(topic.lower(), "general") articles: list[dict] = _rate_limited_request("news", {"category": finnhub_category}) header = ( f"# News for Topic: {topic} (Finnhub — category: {finnhub_category})\n" f"# Data retrieved on: {_now_str()}\n\n" ) result = header if not articles: result += f"_No articles found for topic '{topic}'._\n" return result for article in articles[:limit]: headline = article.get("headline", "No headline") source = article.get("source", "Unknown") summary = article.get("summary", "") url = article.get("url", "") datetime_unix: int = article.get("datetime", 0) # Format publish timestamp if datetime_unix: try: published = datetime.fromtimestamp(int(datetime_unix)).strftime("%Y-%m-%d %H:%M") except (OSError, OverflowError, ValueError): published = str(datetime_unix) else: published = "" result += f"### {headline}\n" meta = f"**Source:** {source}" if published: meta += f" | **Published:** {published}" result += meta + "\n" if summary: result += f"{summary}\n" if url: result += f"**Link:** {url}\n" result += "\n" return result def get_earnings_calendar_finnhub(from_date: str, to_date: str) -> str: """Fetch upcoming earnings releases via Finnhub /calendar/earnings. Returns a formatted markdown table of companies reporting earnings between from_date and to_date, including EPS estimates and prior-year actuals. Unique capability not available in Alpha Vantage at any tier. Args: from_date: Start date in YYYY-MM-DD format. to_date: End date in YYYY-MM-DD format. Returns: Markdown-formatted table with Symbol, Date, EPS Estimate, EPS Prior. Raises: FinnhubError: On API-level errors or empty response. """ data = _rate_limited_request("calendar/earnings", {"from": from_date, "to": to_date}) earnings_list = data.get("earningsCalendar", []) header = ( f"# Earnings Calendar: {from_date} to {to_date} — Finnhub\n" f"# Data retrieved on: {_now_str()}\n\n" ) if not earnings_list: return header + "_No earnings events found in this date range._\n" lines = [ header, "| Symbol | Company | Date | EPS Estimate | EPS Prior | Revenue Estimate |", "|--------|---------|------|--------------|-----------|-----------------|", ] for item in sorted(earnings_list, key=lambda x: x.get("date", "")): symbol = item.get("symbol", "N/A") company = item.get("company", "N/A")[:30] date = item.get("date", "N/A") eps_est_s = _safe_fmt(item.get("epsEstimate")) eps_prior_s = _safe_fmt(item.get("epsPrior")) rev_raw = item.get("revenueEstimate") rev_est_s = _safe_fmt( float(rev_raw) / 1e9 if rev_raw is not None else None, fmt="${:.2f}B", ) lines.append(f"| {symbol} | {company} | {date} | {eps_est_s} | {eps_prior_s} | {rev_est_s} |") return "\n".join(lines) def get_economic_calendar_finnhub(from_date: str, to_date: str) -> str: """Fetch macro economic events via Finnhub /calendar/economic. Returns FOMC meetings, CPI releases, NFP (Non-Farm Payroll), PPI, GDP announcements, and other market-moving macro events. Unique capability not available in Alpha Vantage at any tier. Args: from_date: Start date in YYYY-MM-DD format. to_date: End date in YYYY-MM-DD format. Returns: Markdown-formatted table with Date, Event, Country, Impact, Estimate, Prior. Raises: FinnhubError: On API-level errors or empty response. """ data = _rate_limited_request("calendar/economic", {"from": from_date, "to": to_date}) events = data.get("economicCalendar", []) header = ( f"# Economic Calendar: {from_date} to {to_date} — Finnhub\n" f"# Data retrieved on: {_now_str()}\n\n" ) if not events: return header + "_No economic events found in this date range._\n" lines = [ header, "| Date | Time | Event | Country | Impact | Estimate | Prior |", "|------|------|-------|---------|--------|----------|-------|", ] for ev in sorted(events, key=lambda x: (x.get("time", ""), x.get("event", ""))): date = ev.get("time", "N/A")[:10] if ev.get("time") else "N/A" time_str = ( ev.get("time", "N/A")[11:16] if ev.get("time") and len(ev.get("time", "")) > 10 else "N/A" ) event = ev.get("event", "N/A")[:40] country = ev.get("country", "N/A") impact = ev.get("impact", "N/A") estimate = str(ev.get("estimate", "N/A")) prior = str(ev.get("prev", "N/A")) lines.append(f"| {date} | {time_str} | {event} | {country} | {impact} | {estimate} | {prior} |") return "\n".join(lines)