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:
293
bot.py
293
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."""
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user