From 3040ab3cc1f2b8573d9c277ac93b3c6602f16633 Mon Sep 17 00:00:00 2001 From: Michael Simard Date: Thu, 4 Dec 2025 23:18:00 -0600 Subject: [PATCH] Add earnings calendar feature with command and scheduled delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement comprehensive earnings calendar functionality that displays upcoming earnings for major companies (market cap > $10B). Feature includes both manual command access and automated weekly delivery. Key additions: - Finnhub API integration for earnings calendar data with market cap filtering - Discord embed formatting grouped by day with EPS/revenue estimates - !earnings command for manual queries (current or next week) - Weekly scheduled task (Sunday 6PM ET) for automatic delivery - Configuration options for market cap threshold and schedule timing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- bot.py | 293 ++++++++++++++++++++++++++++++++++++++- config.py | 5 + stock_api/finnhub_api.py | 80 +++++++++++ 3 files changed, 377 insertions(+), 1 deletion(-) diff --git a/bot.py b/bot.py index d041a90..80ea78b 100644 --- a/bot.py +++ b/bot.py @@ -70,8 +70,21 @@ class StockBot(commands.Bot): name='Market Close Update' ) + # Schedule weekly earnings calendar + self.scheduler.add_job( + self.send_weekly_earnings_calendar, + CronTrigger( + day_of_week=Config.EARNINGS_SCHEDULE_DAY, + hour=Config.EARNINGS_SCHEDULE_HOUR, + minute=0, + timezone=MarketHours.NYSE_TIMEZONE + ), + id='weekly_earnings_calendar', + name='Weekly Earnings Calendar' + ) + self.scheduler.start() - logger.info("Scheduler started for market open, noon, and market close updates") + logger.info("Scheduler started for market open, noon, market close, and weekly earnings") async def on_ready(self): """Called when bot successfully connects to Discord.""" @@ -122,6 +135,75 @@ class StockBot(commands.Bot): for channel_id in self.target_channel_ids: await self.send_stock_update(self.primary_ticker, channel_id) + async def send_weekly_earnings_calendar(self): + """Send weekly earnings calendar to target channels.""" + from collections import defaultdict + from datetime import timedelta + import asyncio + + logger.info("Sending weekly earnings calendar") + + # Calculate upcoming week (Monday-Friday) + now_et = datetime.now(MarketHours.NYSE_TIMEZONE) + + # Find next Monday + days_until_monday = (7 - now_et.weekday()) % 7 + if days_until_monday == 0: + days_until_monday = 7 + + start_date = now_et.date() + timedelta(days=days_until_monday) + end_date = start_date + timedelta(days=4) # Friday + + from_date_str = start_date.strftime('%Y-%m-%d') + to_date_str = end_date.strftime('%Y-%m-%d') + + logger.info(f"Fetching earnings from {from_date_str} to {to_date_str}") + + # Check if using Finnhub API + if Config.STOCK_API_PROVIDER != 'finnhub': + logger.warning("Earnings calendar requires Finnhub API provider") + return + + # Fetch earnings data + min_market_cap = Config.EARNINGS_MIN_MARKET_CAP_BILLIONS * 1_000_000_000 + earnings_list = self.stock_api.get_earnings_calendar(from_date_str, to_date_str, min_market_cap) + + if earnings_list is None: + logger.error("Failed to retrieve earnings calendar") + return + + if not earnings_list: + logger.info(f"No major earnings scheduled for {from_date_str} to {to_date_str}") + # Optionally send a message saying no earnings this week + return + + # Group earnings by day + earnings_by_day = defaultdict(list) + for earning in earnings_list: + date = earning['date'] + earnings_by_day[date].append(earning) + + # Create embeds + embeds = self.create_earnings_embeds(earnings_by_day, Config.EARNINGS_MIN_MARKET_CAP_BILLIONS) + + if not embeds: + logger.warning("No embeds created for earnings calendar") + return + + # Send to each target channel + for channel_id in self.target_channel_ids: + channel = self.get_channel(channel_id) + if not channel: + logger.error(f"Could not find channel {channel_id}") + continue + + # Send embeds with delay + for embed in embeds: + await channel.send(embed=embed) + await asyncio.sleep(0.5) + + logger.info(f"Sent {len(embeds)} earnings calendar embeds to channel {channel_id}") + async def send_startup_announcement(self): """Send startup announcement to configured announcement channel.""" embed = discord.Embed( @@ -288,6 +370,137 @@ class StockBot(commands.Bot): return embed + def create_earnings_embeds(self, earnings_by_day: dict, min_market_cap_billions: float) -> list: + """ + Create Discord embeds for earnings calendar, grouped by day. + + Args: + earnings_by_day: Dictionary mapping date strings to list of earnings + min_market_cap_billions: Minimum market cap filter value for footer + + Returns: + List of discord.Embed objects + """ + from collections import defaultdict + + embeds = [] + + # Process each day + for date_str in sorted(earnings_by_day.keys()): + earnings_for_day = earnings_by_day[date_str] + + if not earnings_for_day: + continue + + # Format date for title + try: + date_obj = datetime.strptime(date_str, '%Y-%m-%d') + day_name = date_obj.strftime('%A, %B %d, %Y') + except: + day_name = date_str + + # Build description + description_lines = [] + + for earning in earnings_for_day: + symbol = earning['symbol'] + company_name = earning['company_name'] + market_cap = earning['market_cap'] + eps_estimate = earning.get('eps_estimate') + revenue_estimate = earning.get('revenue_estimate') + hour = earning.get('hour', 'TBD') + + # Format market cap + if market_cap >= 1_000_000_000_000: # Trillions + market_cap_str = f"${market_cap / 1_000_000_000_000:.1f}T" + elif market_cap >= 1_000_000_000: # Billions + market_cap_str = f"${market_cap / 1_000_000_000:.1f}B" + else: + market_cap_str = f"${market_cap / 1_000_000:.0f}M" + + # Format EPS estimate + if eps_estimate is not None: + eps_str = f"${eps_estimate:.2f}" + else: + eps_str = "N/A" + + # Format revenue estimate (usually in millions) + if revenue_estimate is not None: + if revenue_estimate >= 1_000: # Billions + rev_str = f"${revenue_estimate / 1_000:.1f}B" + else: + rev_str = f"${revenue_estimate:.0f}M" + else: + rev_str = "N/A" + + # Determine timing emoji + if hour and 'bmo' in hour.lower(): + time_emoji = "🌅" + time_str = "Before Market Open" + elif hour and 'amc' in hour.lower(): + time_emoji = "🌆" + time_str = "After Market Close" + else: + time_emoji = "📅" + time_str = "TBD" + + # Build entry + entry = f"**{symbol}** - {company_name} ({market_cap_str})\n" + entry += f"├ EPS Est: {eps_str} | Rev Est: {rev_str}\n" + entry += f"└ {time_emoji} {time_str}\n\n" + + description_lines.append(entry) + + # Join all entries for this day + description = "".join(description_lines) + + # Check if description exceeds Discord limit (4096 chars) + if len(description) > 4000: + # Split into multiple embeds if needed + current_desc = "" + part_num = 1 + + for entry in description_lines: + if len(current_desc) + len(entry) > 4000: + # Create embed for current part + embed = discord.Embed( + title=f"📊 Earnings Calendar - {day_name} (Part {part_num})", + description=current_desc, + color=discord.Color.blue(), + timestamp=datetime.now(pytz.timezone('America/New_York')) + ) + embed.set_footer(text=f"Data from Finnhub | Min Market Cap: ${min_market_cap_billions:.0f}B") + embeds.append(embed) + + # Start new part + current_desc = entry + part_num += 1 + else: + current_desc += entry + + # Add final part + if current_desc: + embed = discord.Embed( + title=f"📊 Earnings Calendar - {day_name} (Part {part_num})", + description=current_desc, + color=discord.Color.blue(), + timestamp=datetime.now(pytz.timezone('America/New_York')) + ) + embed.set_footer(text=f"Data from Finnhub | Min Market Cap: ${min_market_cap_billions:.0f}B") + embeds.append(embed) + else: + # Single embed for this day + embed = discord.Embed( + title=f"📊 Earnings Calendar - {day_name}", + description=description, + color=discord.Color.blue(), + timestamp=datetime.now(pytz.timezone('America/New_York')) + ) + embed.set_footer(text=f"Data from Finnhub | Min Market Cap: ${min_market_cap_billions:.0f}B") + embeds.append(embed) + + return embeds + # Create bot instance bot = StockBot() @@ -339,6 +552,84 @@ async def get_crypto_price(ctx, symbol: str = None): await ctx.send(embed=embed) +@bot.command(name='earnings', aliases=['earn']) +async def get_earnings_calendar(ctx, timeframe: str = 'week'): + """ + Display earnings calendar for current/next week. + + Usage: !earnings or !earnings next + """ + from collections import defaultdict + from datetime import timedelta + import asyncio + + logger.info(f"Earnings calendar query by {ctx.author} (timeframe: {timeframe})") + + # Determine date range based on timeframe + now_et = datetime.now(MarketHours.NYSE_TIMEZONE) + + # Determine if we should show current week or next week + if timeframe.lower() == 'next': + # Always show next week + days_until_monday = (7 - now_et.weekday()) % 7 + if days_until_monday == 0: + days_until_monday = 7 + else: + # Show current week, or next week if it's late Friday/weekend + if now_et.weekday() == 4 and now_et.hour >= 16: # Friday after 4 PM + days_until_monday = 3 + elif now_et.weekday() >= 5: # Weekend + days_until_monday = (7 - now_et.weekday()) % 7 + else: # Sunday-Thursday or Friday before 4 PM + days_until_monday = -now_et.weekday() # Go back to Monday + + # Calculate Monday to Friday of that week + start_date = now_et.date() + timedelta(days=days_until_monday) + end_date = start_date + timedelta(days=4) # Friday + + from_date_str = start_date.strftime('%Y-%m-%d') + to_date_str = end_date.strftime('%Y-%m-%d') + + logger.info(f"Fetching earnings from {from_date_str} to {to_date_str}") + + # Check if using Finnhub API + if Config.STOCK_API_PROVIDER != 'finnhub': + await ctx.send("Earnings calendar is only available with Finnhub API provider.") + return + + # Fetch earnings data + min_market_cap = Config.EARNINGS_MIN_MARKET_CAP_BILLIONS * 1_000_000_000 + earnings_list = bot.stock_api.get_earnings_calendar(from_date_str, to_date_str, min_market_cap) + + if earnings_list is None: + await ctx.send("Unable to retrieve earnings calendar. Please try again later.") + return + + if not earnings_list: + await ctx.send(f"No major earnings (>${ Config.EARNINGS_MIN_MARKET_CAP_BILLIONS:.0f}B) scheduled for {from_date_str} to {to_date_str}.") + return + + # Group earnings by day + earnings_by_day = defaultdict(list) + for earning in earnings_list: + date = earning['date'] + earnings_by_day[date].append(earning) + + # Create embeds + embeds = bot.create_earnings_embeds(earnings_by_day, Config.EARNINGS_MIN_MARKET_CAP_BILLIONS) + + if not embeds: + await ctx.send("No earnings data available to display.") + return + + # Send embeds with delay to avoid rate limiting + for embed in embeds: + await ctx.send(embed=embed) + await asyncio.sleep(0.5) + + logger.info(f"Sent {len(embeds)} earnings calendar embeds") + + @bot.command(name='ping') async def ping(ctx): """Check if bot is responsive.""" diff --git a/config.py b/config.py index f9107f8..1da4c49 100644 --- a/config.py +++ b/config.py @@ -20,6 +20,11 @@ class Config: STOCK_API_PROVIDER = os.getenv('STOCK_API_PROVIDER', 'finnhub') # 'yahoo' or 'finnhub' FINNHUB_API_KEY = os.getenv('FINNHUB_API_KEY') + # Earnings calendar configuration + EARNINGS_MIN_MARKET_CAP_BILLIONS = float(os.getenv('EARNINGS_MIN_MARKET_CAP_BILLIONS', '10')) + EARNINGS_SCHEDULE_DAY = os.getenv('EARNINGS_SCHEDULE_DAY', 'sun') + EARNINGS_SCHEDULE_HOUR = int(os.getenv('EARNINGS_SCHEDULE_HOUR', '18')) + # Scheduling configuration UPDATE_INTERVAL_HOURS = int(os.getenv('UPDATE_INTERVAL_HOURS', '1')) diff --git a/stock_api/finnhub_api.py b/stock_api/finnhub_api.py index 19f7a55..19c0f6d 100644 --- a/stock_api/finnhub_api.py +++ b/stock_api/finnhub_api.py @@ -90,3 +90,83 @@ class FinnhubAPI(StockAPIBase): except Exception as e: logger.error(f"Finnhub API unavailable: {e}") return False + + def get_earnings_calendar(self, from_date: str, to_date: str, min_market_cap: float = 10_000_000_000) -> Optional[list]: + """ + Retrieve earnings calendar for date range, filtered by market cap. + + Args: + from_date: Start date (YYYY-MM-DD) + to_date: End date (YYYY-MM-DD) + min_market_cap: Minimum market cap threshold in dollars (default 10B) + + Returns: + List of enriched earnings dictionaries, or None if unavailable + """ + try: + # Fetch earnings calendar from Finnhub + earnings_data = self.client.earnings_calendar(_from=from_date, to=to_date, symbol='', international=False) + + if not earnings_data or 'earningsCalendar' not in earnings_data: + logger.warning(f"No earnings data available for {from_date} to {to_date}") + return [] + + earnings_list = earnings_data['earningsCalendar'] + + if not earnings_list: + logger.info(f"No earnings found for {from_date} to {to_date}") + return [] + + # Enrich each earning with company profile data and filter by market cap + enriched_earnings = [] + + for earning in earnings_list: + symbol = earning.get('symbol') + if not symbol: + continue + + try: + # Fetch company profile for market cap and name + profile = self.client.company_profile2(symbol=symbol) + + if not profile: + logger.debug(f"No profile data for {symbol}") + continue + + # Market cap in Finnhub is in millions, convert to dollars + market_cap = profile.get('marketCapitalization', 0) * 1_000_000 + + # Filter by minimum market cap + if market_cap < min_market_cap: + continue + + # Enrich earnings data + enriched_earning = { + 'symbol': symbol, + 'company_name': profile.get('name', symbol), + 'market_cap': market_cap, + 'date': earning.get('date'), + 'eps_estimate': earning.get('epsEstimate'), + 'eps_actual': earning.get('epsActual'), + 'revenue_estimate': earning.get('revenueEstimate'), + 'revenue_actual': earning.get('revenueActual'), + 'hour': earning.get('hour', 'TBD'), + 'quarter': earning.get('quarter'), + 'year': earning.get('year') + } + + enriched_earnings.append(enriched_earning) + + except Exception as e: + logger.warning(f"Could not fetch profile for {symbol}: {e}") + continue + + # Sort by date, then by market cap descending + enriched_earnings.sort(key=lambda x: (x['date'], -x['market_cap'])) + + logger.info(f"Found {len(enriched_earnings)} earnings (market cap >= ${min_market_cap/1_000_000_000:.1f}B)") + return enriched_earnings + + except Exception as e: + logger.error(f"Error fetching earnings calendar: {e}") + return None