""" Finnhub /company-news client (free tier). Rate limit: 60 calls/min on the free plan. We enforce 1 second between calls via an asyncio.Semaphore + sleep, which is the simplest correct shape since the entire ingest pipeline is single-process. Endpoint shape: /company-news?symbol=AAPL&from=2026-04-01&to=2026-04-25 Free-tier history: ~12 months. Cumulative archive must be self-built by running `scripts/news_backfill.py` once at install time, then daily. """ from __future__ import annotations import asyncio import logging from datetime import date from typing import Any import httpx from app.core.config import settings logger = logging.getLogger(__name__) class FinnhubClient: def __init__( self, api_key: str | None = None, base_url: str | None = None, request_interval_sec: float = 1.05, ): self.api_key = api_key or settings.FINNHUB_API_KEY self.base_url = (base_url or settings.FINNHUB_BASE_URL).rstrip("/") self._client: httpx.AsyncClient | None = None # Single-slot semaphore + sleep enforces 60-calls/min ceiling self._gate = asyncio.Semaphore(1) self._interval = request_interval_sec def is_configured(self) -> bool: return bool(self.api_key) async def _get_client(self) -> httpx.AsyncClient: if self._client is None or self._client.is_closed: self._client = httpx.AsyncClient(base_url=self.base_url, timeout=30.0) return self._client async def close(self) -> None: if self._client and not self._client.is_closed: await self._client.aclose() async def _request(self, path: str, params: dict[str, Any], retries: int = 3) -> Any: client = await self._get_client() params = {**params, "token": self.api_key} async with self._gate: last_exc: Exception | None = None for attempt in range(retries): try: resp = await client.get(path, params=params) if resp.status_code == 429: wait = 2 ** attempt logger.warning(f"Finnhub 429 — sleeping {wait}s (attempt {attempt + 1})") await asyncio.sleep(wait) continue if resp.status_code >= 500: wait = 2 ** attempt logger.warning(f"Finnhub {resp.status_code} — retry in {wait}s") await asyncio.sleep(wait) continue resp.raise_for_status() await asyncio.sleep(self._interval) return resp.json() except httpx.HTTPStatusError as exc: last_exc = exc if attempt < retries - 1 and exc.response.status_code in (429, 500, 502, 503, 504): await asyncio.sleep(2 ** attempt) continue raise except (httpx.ConnectError, httpx.ReadTimeout) as exc: last_exc = exc if attempt < retries - 1: await asyncio.sleep(2 ** attempt) continue raise raise last_exc # type: ignore[misc] async def fetch_company_news( self, symbol: str, from_date: date, to_date: date, ) -> list[dict[str, Any]]: """Fetch company-news for [from_date, to_date] inclusive. One call covers the full range, so split by month at the caller side for backfill granularity. """ payload = await self._request( "/company-news", params={ "symbol": symbol.strip().upper(), "from": from_date.isoformat(), "to": to_date.isoformat(), }, ) if not isinstance(payload, list): logger.warning(f"Finnhub /company-news returned non-list for {symbol}") return [] return payload