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'
)
# 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."""