AnalyticalPlatform / app /services /earnings_calendar.py
Dmitry Beresnev
project init
7b14cb0
"""
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 companies to highlight (filter from the full calendar)
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
# ------------------------------------------------------------------
# Primary: Alpha Vantage
# ------------------------------------------------------------------
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()
# Alpha Vantage returns CSV for this endpoint
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
# Filter to top companies only
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, # AV doesn't provide BMO/AMC
"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 []
# ------------------------------------------------------------------
# Fallback: StockAnalysis.com
# ------------------------------------------------------------------
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")
# StockAnalysis embeds earnings data as JSON in a <script> tag
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 []
# Extract JSON blob
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 []