Add earnings calendar feature with command and scheduled delivery

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 <noreply@anthropic.com>
This commit is contained in:
Michael Simard
2025-12-04 23:18:00 -06:00
parent 02783de4f3
commit 3040ab3cc1
3 changed files with 377 additions and 1 deletions

293
bot.py
View File

@@ -70,8 +70,21 @@ class StockBot(commands.Bot):
name='Market Close Update' 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() 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): async def on_ready(self):
"""Called when bot successfully connects to Discord.""" """Called when bot successfully connects to Discord."""
@@ -122,6 +135,75 @@ class StockBot(commands.Bot):
for channel_id in self.target_channel_ids: for channel_id in self.target_channel_ids:
await self.send_stock_update(self.primary_ticker, channel_id) 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): async def send_startup_announcement(self):
"""Send startup announcement to configured announcement channel.""" """Send startup announcement to configured announcement channel."""
embed = discord.Embed( embed = discord.Embed(
@@ -288,6 +370,137 @@ class StockBot(commands.Bot):
return embed 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 # Create bot instance
bot = StockBot() bot = StockBot()
@@ -339,6 +552,84 @@ async def get_crypto_price(ctx, symbol: str = None):
await ctx.send(embed=embed) 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') @bot.command(name='ping')
async def ping(ctx): async def ping(ctx):
"""Check if bot is responsive.""" """Check if bot is responsive."""

View File

@@ -20,6 +20,11 @@ class Config:
STOCK_API_PROVIDER = os.getenv('STOCK_API_PROVIDER', 'finnhub') # 'yahoo' or 'finnhub' STOCK_API_PROVIDER = os.getenv('STOCK_API_PROVIDER', 'finnhub') # 'yahoo' or 'finnhub'
FINNHUB_API_KEY = os.getenv('FINNHUB_API_KEY') 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 # Scheduling configuration
UPDATE_INTERVAL_HOURS = int(os.getenv('UPDATE_INTERVAL_HOURS', '1')) UPDATE_INTERVAL_HOURS = int(os.getenv('UPDATE_INTERVAL_HOURS', '1'))

View File

@@ -90,3 +90,83 @@ class FinnhubAPI(StockAPIBase):
except Exception as e: except Exception as e:
logger.error(f"Finnhub API unavailable: {e}") logger.error(f"Finnhub API unavailable: {e}")
return False 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