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