| """ |
| Earnings Calendar Service |
| Primary: Alpha Vantage EARNINGS_CALENDAR (free, CSV, uses ALPHA_VANTAGE_KEY or demo key) |
| Fallback: StockAnalysis.com JSON scraping |
| """ |
|
|
| import csv |
| import io |
| import json |
| import logging |
| import re |
| from datetime import datetime, timedelta |
| from typing import List, Dict, Optional |
|
|
| import requests |
| from bs4 import BeautifulSoup |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| TOP_TICKERS = { |
| "AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "GOOG", "META", "TSLA", |
| "AVGO", "JPM", "LLY", "V", "MA", "UNH", "XOM", "COST", "HD", |
| "WMT", "PG", "NFLX", "BAC", "ORCL", "CRM", "AMD", "INTC", "QCOM", |
| "GS", "MS", "CVX", "ABBV", "MRK", "BRK-B", "BRKB", "KO", "PEP", |
| "DIS", "PYPL", "ADBE", "CSCO", "TXN", "HON", "RTX", "CAT", "IBM", |
| } |
|
|
|
|
| class EarningsCalendarService: |
| """Fetches upcoming earnings report dates without mock data.""" |
|
|
| ALPHA_VANTAGE_URL = ( |
| "https://www.alphavantage.co/query" |
| "?function=EARNINGS_CALENDAR&horizon=3month&apikey={api_key}" |
| ) |
| STOCKANALYSIS_URL = "https://stockanalysis.com/stocks/earnings-calendar/" |
|
|
| def __init__(self, api_key: Optional[str] = None): |
| self.api_key = api_key or "demo" |
| self.session = requests.Session() |
| self.session.headers.update({ |
| "User-Agent": ( |
| "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " |
| "AppleWebKit/537.36 (KHTML, like Gecko) " |
| "Chrome/120.0.0.0 Safari/537.36" |
| ), |
| "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", |
| "Accept-Language": "en-US,en;q=0.9", |
| }) |
|
|
| def get_upcoming_earnings(self, days_ahead: int = 30) -> List[Dict]: |
| """ |
| Returns upcoming earnings entries sorted by date. |
| Each entry: {ticker, company, date, days_until, report_time, eps_estimate} |
| """ |
| entries = self._fetch_alpha_vantage(days_ahead) |
| if not entries: |
| logger.warning("Alpha Vantage returned no data, trying StockAnalysis fallback") |
| entries = self._fetch_stockanalysis(days_ahead) |
| return entries |
|
|
| |
| |
| |
|
|
| def _fetch_alpha_vantage(self, days_ahead: int) -> List[Dict]: |
| try: |
| url = self.ALPHA_VANTAGE_URL.format(api_key=self.api_key) |
| resp = self.session.get(url, timeout=15) |
| resp.raise_for_status() |
|
|
| |
| content = resp.text |
| if not content or "symbol" not in content[:200].lower(): |
| logger.warning("Alpha Vantage response doesn't look like CSV") |
| return [] |
|
|
| reader = csv.DictReader(io.StringIO(content)) |
| now = datetime.now() |
| cutoff = now + timedelta(days=days_ahead) |
| entries = [] |
|
|
| for row in reader: |
| try: |
| ticker = (row.get("symbol") or "").strip().upper() |
| if not ticker: |
| continue |
|
|
| |
| if ticker not in TOP_TICKERS: |
| continue |
|
|
| date_str = (row.get("reportDate") or "").strip() |
| if not date_str: |
| continue |
| date = datetime.strptime(date_str, "%Y-%m-%d") |
|
|
| if date < now - timedelta(days=1) or date > cutoff: |
| continue |
|
|
| days_until = (date.date() - now.date()).days |
|
|
| eps_str = (row.get("estimate") or "").strip() |
| eps_estimate = float(eps_str) if eps_str and eps_str != "-" else None |
|
|
| fiscal_end = (row.get("fiscalDateEnding") or "").strip() |
| currency = (row.get("currency") or "USD").strip() |
|
|
| entries.append({ |
| "ticker": ticker, |
| "company": row.get("name", ticker).strip(), |
| "date": date, |
| "days_until": days_until, |
| "report_time": None, |
| "eps_estimate": eps_estimate, |
| "fiscal_end": fiscal_end, |
| "currency": currency, |
| "source": "Alpha Vantage", |
| }) |
|
|
| except (ValueError, KeyError) as e: |
| logger.debug(f"Skipping AV row: {e}") |
| continue |
|
|
| entries.sort(key=lambda x: x["date"]) |
| logger.info(f"Alpha Vantage: {len(entries)} top-company earnings fetched") |
| return entries |
|
|
| except Exception as e: |
| logger.error(f"Alpha Vantage earnings fetch failed: {e}") |
| return [] |
|
|
| |
| |
| |
|
|
| def _fetch_stockanalysis(self, days_ahead: int) -> List[Dict]: |
| try: |
| resp = self.session.get(self.STOCKANALYSIS_URL, timeout=15) |
| resp.raise_for_status() |
|
|
| soup = BeautifulSoup(resp.text, "html.parser") |
|
|
| |
| script_tag = soup.find("script", string=re.compile(r'"earningsCalendar"')) |
| if not script_tag: |
| for tag in soup.find_all("script", type="application/json"): |
| text = tag.get_text() or "" |
| if "earningsCalendar" in text: |
| script_tag = tag |
| break |
|
|
| if not script_tag: |
| logger.warning("StockAnalysis: could not locate earnings JSON") |
| return [] |
|
|
| |
| raw = script_tag.get_text() or "" |
| match = re.search(r'"earningsCalendar"\s*:\s*(\[.*?\])', raw, re.DOTALL) |
| if not match: |
| logger.warning("StockAnalysis: earnings JSON pattern not found") |
| return [] |
|
|
| data = json.loads(match.group(1)) |
|
|
| now = datetime.now() |
| cutoff = now + timedelta(days=days_ahead) |
| entries = [] |
|
|
| for week in data: |
| date_str = week.get("date") or week.get("week", "") |
| try: |
| date = datetime.strptime(date_str[:10], "%Y-%m-%d") |
| except ValueError: |
| continue |
|
|
| if date < now - timedelta(days=1) or date > cutoff: |
| continue |
|
|
| days_until = (date.date() - now.date()).days |
|
|
| for item in week.get("stocks", []): |
| ticker = (item.get("s") or "").upper() |
| if ticker not in TOP_TICKERS: |
| continue |
|
|
| report_time_raw = item.get("t", "") |
| report_time = ( |
| "BMO" if report_time_raw == "bmo" |
| else "AMC" if report_time_raw == "amc" |
| else None |
| ) |
| eps_est = item.get("e") |
|
|
| entries.append({ |
| "ticker": ticker, |
| "company": item.get("n", ticker), |
| "date": date, |
| "days_until": days_until, |
| "report_time": report_time, |
| "eps_estimate": float(eps_est) if eps_est is not None else None, |
| "fiscal_end": None, |
| "currency": "USD", |
| "source": "StockAnalysis", |
| }) |
|
|
| entries.sort(key=lambda x: x["date"]) |
| logger.info(f"StockAnalysis: {len(entries)} earnings fetched") |
| return entries |
|
|
| except Exception as e: |
| logger.error(f"StockAnalysis earnings fetch failed: {e}") |
| return [] |
|
|