Files
discord-stock-bot/bot.py
Michael Simard 3040ab3cc1 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>
2025-12-04 23:18:00 -06:00

681 lines
24 KiB
Python

import discord
from discord.ext import commands
import logging
from datetime import datetime
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
import pytz
from config import Config
from stock_api import YahooFinanceAPI, FinnhubAPI
from market_hours import MarketHours
from crypto_api import CoinGeckoAPI
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class StockBot(commands.Bot):
"""Discord bot for stock price tracking and reporting."""
def __init__(self):
intents = discord.Intents.default()
intents.message_content = True
super().__init__(command_prefix=Config.COMMAND_PREFIX, intents=intents)
# Initialize stock API based on configuration
if Config.STOCK_API_PROVIDER == 'finnhub':
self.stock_api = FinnhubAPI(Config.FINNHUB_API_KEY)
logger.info("Using Finnhub API for stock data")
else:
self.stock_api = YahooFinanceAPI()
logger.info("Using Yahoo Finance API for stock data")
self.scheduler = AsyncIOScheduler(timezone=pytz.timezone('America/New_York'))
self.target_channel_ids = [int(id) for id in Config.CHANNEL_IDS]
self.primary_ticker = Config.PRIMARY_TICKER
self.startup_announced = False
self.crypto_api = CoinGeckoAPI()
async def setup_hook(self):
"""Initialize the bot and set up scheduled tasks."""
logger.info("Bot setup initiated")
# Schedule market open update at 9:30 AM ET on weekdays
self.scheduler.add_job(
self.send_market_open_update,
CronTrigger(hour=9, minute=30, day_of_week='mon-fri', timezone=MarketHours.NYSE_TIMEZONE),
id='market_open_update',
name='Market Open Update'
)
# Schedule noon update at 12:00 PM ET on weekdays
self.scheduler.add_job(
self.send_noon_update,
CronTrigger(hour=12, minute=0, day_of_week='mon-fri', timezone=MarketHours.NYSE_TIMEZONE),
id='noon_update',
name='Noon Update'
)
# Schedule market close update at 4:00 PM ET on weekdays
self.scheduler.add_job(
self.send_market_close_update,
CronTrigger(hour=16, minute=0, day_of_week='mon-fri', timezone=MarketHours.NYSE_TIMEZONE),
id='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()
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."""
logger.info(f'Bot connected as {self.user.name} (ID: {self.user.id})')
logger.info(f'Monitoring ticker: {self.primary_ticker}')
logger.info(f'Target channel IDs: {self.target_channel_ids}')
# Verify channels exist
for channel_id in self.target_channel_ids:
channel = self.get_channel(channel_id)
if channel:
logger.info(f'Target channel found: #{channel.name} (ID: {channel_id})')
else:
logger.error(f'Could not find channel with ID {channel_id}')
# Send startup announcement if configured and not already sent
if Config.STARTUP_ANNOUNCEMENT and not self.startup_announced:
await self.send_startup_announcement()
self.startup_announced = True
async def send_market_open_update(self):
"""Send stock price update at market open on trading days."""
if not MarketHours.is_trading_day():
logger.info("Not a trading day, skipping market open update")
return
logger.info(f"Sending market open update for {self.primary_ticker}")
for channel_id in self.target_channel_ids:
await self.send_stock_update(self.primary_ticker, channel_id)
async def send_noon_update(self):
"""Send stock price update at noon on trading days."""
if not MarketHours.is_trading_day():
logger.info("Not a trading day, skipping noon update")
return
logger.info(f"Sending noon update for {self.primary_ticker}")
for channel_id in self.target_channel_ids:
await self.send_stock_update(self.primary_ticker, channel_id)
async def send_market_close_update(self):
"""Send stock price update at market close on trading days."""
if not MarketHours.is_trading_day():
logger.info("Not a trading day, skipping market close update")
return
logger.info(f"Sending market close update for {self.primary_ticker}")
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(
title="🤖 Bot Restarted",
description=Config.STARTUP_ANNOUNCEMENT,
color=discord.Color.blue(),
timestamp=datetime.now(pytz.timezone('America/New_York'))
)
# Use dedicated announcement channel if configured, otherwise use first target channel
announcement_channel_id = Config.STARTUP_ANNOUNCEMENT_CHANNEL_ID
if announcement_channel_id:
try:
channel_id = int(announcement_channel_id)
except ValueError:
logger.error(f"Invalid STARTUP_ANNOUNCEMENT_CHANNEL_ID: {announcement_channel_id}")
return
else:
channel_id = self.target_channel_ids[0] if self.target_channel_ids else None
if not channel_id:
logger.error("No channel configured for startup announcement")
return
channel = self.get_channel(channel_id)
if channel:
await channel.send(embed=embed)
logger.info(f"Sent startup announcement to channel {channel_id}")
else:
logger.error(f"Could not find announcement channel {channel_id}")
async def send_stock_update(self, ticker: str, channel_id: int):
"""
Fetch stock data and send formatted embed to specified channel.
Args:
ticker: Stock ticker symbol
channel_id: Discord channel ID to send message to
"""
channel = self.get_channel(channel_id)
if not channel:
logger.error(f"Could not find channel {channel_id}")
return
stock_data = self.stock_api.get_stock_price(ticker)
if not stock_data:
await channel.send(f"Unable to retrieve data for ticker: {ticker}")
return
embed = self.create_stock_embed(stock_data)
await channel.send(embed=embed)
logger.info(f"Sent stock update for {ticker} to channel {channel_id}")
def create_stock_embed(self, stock_data: dict) -> discord.Embed:
"""
Create a formatted Discord embed for stock price data.
Args:
stock_data: Dictionary containing stock information
Returns:
Discord embed object
"""
ticker = stock_data['ticker']
company_name = stock_data.get('company_name', ticker)
# Custom company name for AMD
if ticker == "AMD":
company_name = "Advanced Money Destroyer"
current_price = stock_data['current_price']
change_dollar = stock_data['change_dollar']
change_percent = stock_data['change_percent']
# Determine color based on price movement
if change_dollar > 0:
color = discord.Color.green()
change_emoji = "📈"
elif change_dollar < 0:
color = discord.Color.red()
change_emoji = "📉"
else:
color = discord.Color.blue()
change_emoji = "➡️"
# Format change string
change_sign = "+" if change_dollar >= 0 else ""
change_str = f"{change_sign}${change_dollar} ({change_sign}{change_percent}%)"
# Custom title for PYPL ticker
if ticker == "PYPL":
title = f"{change_emoji} Steaks Stablecoin Value"
else:
title = f"{change_emoji} {company_name} ({ticker}) Stock Price"
# Create embed
embed = discord.Embed(
title=title,
description=f"**${current_price}**",
color=color,
timestamp=datetime.now(pytz.timezone('America/New_York'))
)
embed.add_field(name="Change", value=change_str, inline=True)
embed.add_field(name="Previous Close", value=f"${stock_data['previous_close']}", inline=True)
# Add FinViz daily chart
chart_url = f"https://finviz.com/chart.ashx?t={ticker}&ty=c&ta=1&p=d&s=l"
embed.set_image(url=chart_url)
market_status = "🟢 Market Open" if stock_data['market_open'] else "🔴 Market Closed"
embed.set_footer(text=f"{market_status}")
return embed
def create_crypto_embed(self, crypto_data: dict) -> discord.Embed:
"""
Create a formatted Discord embed for cryptocurrency price data.
Args:
crypto_data: Dictionary containing crypto information
Returns:
Discord embed object
"""
symbol = crypto_data['symbol']
current_price = crypto_data['current_price']
change_dollar = crypto_data['change_dollar']
change_percent = crypto_data['change_percent']
# Determine color based on price movement
if change_dollar > 0:
color = discord.Color.green()
change_emoji = "📈"
elif change_dollar < 0:
color = discord.Color.red()
change_emoji = "📉"
else:
color = discord.Color.blue()
change_emoji = "➡️"
# Format change string
change_sign = "+" if change_dollar >= 0 else ""
change_str = f"{change_sign}${change_dollar} ({change_sign}{change_percent}%)"
# Create embed
embed = discord.Embed(
title=f"{change_emoji} {symbol} Price",
description=f"**${current_price:,.2f}**",
color=color,
timestamp=datetime.now(pytz.timezone('America/New_York'))
)
embed.add_field(name="24h Change", value=change_str, inline=True)
embed.add_field(name="Previous Price", value=f"${crypto_data['previous_price']:,.2f}", inline=True)
# Add volume if available
volume = crypto_data.get('volume_24h', 0)
if volume > 0:
volume_str = f"${volume:,.0f}"
embed.add_field(name="24h Volume", value=volume_str, inline=False)
embed.set_footer(text="Data from CoinGecko")
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()
@bot.command(name='stock', aliases=['price'])
async def get_stock_price(ctx, ticker: str = None):
"""
Manually query stock price for any ticker.
Usage: !stock AAPL or !price TSLA
"""
if not ticker:
await ctx.send("Please provide a ticker symbol. Usage: `!stock AAPL`")
return
ticker = ticker.upper()
logger.info(f"Manual stock query for {ticker} by {ctx.author}")
stock_data = bot.stock_api.get_stock_price(ticker)
if not stock_data:
await ctx.send(f"Unable to retrieve data for ticker: {ticker}")
return
embed = bot.create_stock_embed(stock_data)
await ctx.send(embed=embed)
@bot.command(name='crypto', aliases=['c'])
async def get_crypto_price(ctx, symbol: str = None):
"""
Query cryptocurrency price.
Usage: !crypto BTC or !c ETH
"""
if not symbol:
await ctx.send("Please provide a crypto symbol. Usage: `!crypto BTC`")
return
symbol = symbol.upper()
logger.info(f"Crypto query for {symbol} by {ctx.author}")
crypto_data = bot.crypto_api.get_crypto_price(symbol)
if not crypto_data:
await ctx.send(f"Unable to retrieve data for crypto: {symbol}")
return
embed = bot.create_crypto_embed(crypto_data)
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."""
await ctx.send(f'Pong! Latency: {round(bot.latency * 1000)}ms')
@bot.command(name='market')
async def market_status(ctx):
"""Check if market is currently open."""
is_open = MarketHours.is_market_open()
status = "🟢 Market is currently OPEN" if is_open else "🔴 Market is currently CLOSED"
embed = discord.Embed(
title="NYSE Market Status",
description=status,
color=discord.Color.green() if is_open else discord.Color.red(),
timestamp=datetime.now(pytz.timezone('America/New_York'))
)
embed.add_field(
name="Trading Hours",
value="Monday-Friday\n9:30 AM - 4:00 PM ET",
inline=False
)
if not is_open:
next_open = MarketHours.get_next_market_open()
embed.add_field(
name="Next Open",
value=next_open.strftime("%A, %B %d at %I:%M %p ET"),
inline=False
)
await ctx.send(embed=embed)
def main():
"""Main entry point for the bot."""
if not Config.validate():
logger.error("Configuration validation failed")
return
logger.info("Starting bot...")
bot.run(Config.DISCORD_TOKEN)
if __name__ == '__main__':
main()