Initial commit: Discord stock bot with hourly PYPL updates

Functional Discord bot with automated hourly stock price updates during NYSE trading hours. Supports manual queries for any ticker via prefix commands.

🤖 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-02 22:25:37 -06:00
commit 3b6f0cbe4a
15 changed files with 1321 additions and 0 deletions

18
.dockerignore Normal file
View File

@@ -0,0 +1,18 @@
.env
.env.local
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info/
dist/
build/
.git/
.gitignore
README.md
.vscode/
.idea/
*.log

14
.env.example Normal file
View File

@@ -0,0 +1,14 @@
# Discord Configuration
DISCORD_TOKEN=your_discord_bot_token_here
CHANNEL_ID=your_channel_id_here
# Bot Configuration
COMMAND_PREFIX=!
# Stock Configuration
PRIMARY_TICKER=PYPL
STOCK_API_PROVIDER=finnhub
FINNHUB_API_KEY=your_finnhub_api_key_here
# Scheduling Configuration
UPDATE_INTERVAL_HOURS=1

22
.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
# Environment variables (contains secrets)
.env
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db

395
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,395 @@
# Discord Stock Bot - Deployment Guide
This guide provides detailed instructions for deploying and running the Discord stock bot.
## Prerequisites
Before starting the bot, ensure you have the following:
1. **Python 3.9 or higher** installed on your system
2. **Discord Bot Token** from Discord Developer Portal
3. **Discord Channel ID** where the bot will post updates
4. **Finnhub API Key** (free tier available at https://finnhub.io)
5. **Git** (optional, for cloning the repository)
## Quick Start (Local Python)
### 1. Clone or Download the Repository
```bash
# If using Git
git clone ssh://git@git.michaelsimard.ca:28/msimard/discord-stock-bot
cd discord-stock-bot
# Or download and extract the files manually
```
### 2. Install Dependencies
```bash
# Create a virtual environment (recommended)
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install required packages
pip install -r requirements.txt
```
### 3. Configure Environment Variables
```bash
# Copy the example environment file
cp .env.example .env
# Edit .env with your credentials
nano .env # or use any text editor
```
Required configuration in `.env`:
```env
# Discord Configuration
DISCORD_TOKEN=your_discord_bot_token_here
CHANNEL_ID=your_channel_id_here
# Bot Configuration
COMMAND_PREFIX=!
# Stock Configuration
PRIMARY_TICKER=PYPL
STOCK_API_PROVIDER=finnhub
FINNHUB_API_KEY=your_finnhub_api_key_here
# Scheduling Configuration
UPDATE_INTERVAL_HOURS=1
```
### 4. Run the Bot
```bash
python3 bot.py
```
The bot will start and display connection status in the console.
### 5. Verify Bot is Running
Check the console output for:
```
INFO - Bot connected as [bot_name] (ID: [bot_id])
INFO - Target channel found: #[channel_name]
INFO - Using Finnhub API for stock data
```
## Running in Background
### Using nohup (Unix/Linux/macOS)
```bash
nohup python3 bot.py > bot.log 2>&1 &
```
To stop:
```bash
pkill -f "python3 bot.py"
```
### Using screen (Unix/Linux/macOS)
```bash
screen -S discord-bot
python3 bot.py
# Detach: Press Ctrl+A, then D
# Reattach: screen -r discord-bot
# Kill: screen -X -S discord-bot quit
```
### Using systemd (Linux)
Create `/etc/systemd/system/discord-stock-bot.service`:
```ini
[Unit]
Description=Discord Stock Price Bot
After=network.target
[Service]
Type=simple
User=your_username
WorkingDirectory=/path/to/discord-stock-bot
Environment="PATH=/path/to/discord-stock-bot/venv/bin"
ExecStart=/path/to/discord-stock-bot/venv/bin/python3 bot.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable discord-stock-bot
sudo systemctl start discord-stock-bot
sudo systemctl status discord-stock-bot
```
View logs:
```bash
sudo journalctl -u discord-stock-bot -f
```
## Docker Deployment
### Local Docker (Development)
```bash
# Build and run with Docker Compose
docker compose up --build
# Run in background
docker compose up -d
# View logs
docker compose logs -f
# Stop
docker compose down
```
### Docker without Compose
```bash
# Build image
docker build -t discord-stock-bot .
# Run container
docker run -d \
--name discord-stock-bot \
--env-file .env \
--restart unless-stopped \
discord-stock-bot
# View logs
docker logs -f discord-stock-bot
# Stop
docker stop discord-stock-bot
docker rm discord-stock-bot
```
## Unraid Deployment
### Method 1: Docker Compose
1. Copy the project directory to your Unraid server:
```bash
scp -r discord-stock-bot/ root@unraid-server:/mnt/user/appdata/
```
2. SSH into Unraid and navigate to the directory:
```bash
ssh root@unraid-server
cd /mnt/user/appdata/discord-stock-bot
```
3. Create and configure `.env` file
4. Run with Docker Compose:
```bash
docker compose up -d
```
### Method 2: Unraid Docker Template
1. Build and push your image to Docker Hub:
```bash
docker build -t your-username/discord-stock-bot:latest .
docker login
docker push your-username/discord-stock-bot:latest
```
2. In Unraid web UI:
- Navigate to Docker tab
- Click "Add Container"
- Configure:
- **Name**: discord-stock-bot
- **Repository**: your-username/discord-stock-bot:latest
- **Network Type**: bridge
- **Add Variables**:
- DISCORD_TOKEN
- CHANNEL_ID
- PRIMARY_TICKER
- STOCK_API_PROVIDER
- FINNHUB_API_KEY
- COMMAND_PREFIX
- TZ (America/New_York)
3. Click "Apply"
## Obtaining Required Credentials
### Discord Bot Token
1. Go to https://discord.com/developers/applications
2. Click "New Application"
3. Navigate to "Bot" section
4. Click "Add Bot"
5. Enable "Message Content Intent" under Privileged Gateway Intents
6. Click "Reset Token" and copy the token
7. Navigate to OAuth2 > URL Generator
8. Select scopes: `bot`
9. Select permissions: Send Messages, Embed Links, Read Messages/View Channels
10. Use generated URL to invite bot to your server
### Discord Channel ID
1. Enable Developer Mode in Discord (User Settings > Advanced > Developer Mode)
2. Right-click the desired channel
3. Click "Copy Channel ID"
### Finnhub API Key
1. Visit https://finnhub.io/register
2. Create a free account
3. Copy your API key from the dashboard
## Troubleshooting
### Bot Does Not Connect
**Issue**: Bot shows "Could not find channel with ID"
**Solution**:
- Verify the bot has been invited to the server
- Check that CHANNEL_ID is correct
- Ensure bot has permissions to view the channel
### Stock Data Not Available
**Issue**: "Unable to retrieve data for ticker"
**Solution**:
- Verify FINNHUB_API_KEY is correct
- Check Finnhub API status
- Try a different ticker (e.g., AAPL) to test API connection
### Rate Limiting
**Issue**: "429 Too Many Requests"
**Solution**:
- This occurs with Yahoo Finance after excessive queries
- Switch to Finnhub by setting `STOCK_API_PROVIDER=finnhub`
- Wait 1-24 hours for rate limit to reset
### Import Errors
**Issue**: ModuleNotFoundError
**Solution**:
```bash
pip install -r requirements.txt
```
### Bot Crashes on Startup
**Issue**: Bot exits immediately
**Solution**:
- Check all required environment variables are set
- Verify Python version: `python3 --version` (must be 3.9+)
- Check logs for specific error messages
## Monitoring
### Check Bot Status
```bash
# Local Python
ps aux | grep "python3 bot.py"
# Docker
docker ps | grep discord-stock-bot
# Systemd
sudo systemctl status discord-stock-bot
```
### View Logs
```bash
# Local Python (if using nohup)
tail -f bot.log
# Docker Compose
docker compose logs -f
# Docker
docker logs -f discord-stock-bot
# Systemd
sudo journalctl -u discord-stock-bot -f
```
## Stopping the Bot
```bash
# Local Python
pkill -f "python3 bot.py"
# Docker Compose
docker compose down
# Docker
docker stop discord-stock-bot
# Systemd
sudo systemctl stop discord-stock-bot
```
## Updating the Bot
```bash
# Pull latest changes
git pull origin main
# Reinstall dependencies (if changed)
pip install -r requirements.txt
# Restart bot
pkill -f "python3 bot.py"
python3 bot.py
# Or with Docker
docker compose down
docker compose up --build -d
```
## Bot Commands
Once running, the bot responds to these commands in Discord:
- `!stock <TICKER>` - Get current stock price for any ticker
- `!price <TICKER>` - Alternative command for stock price
- `!market` - Check if NYSE is currently open
- `!ping` - Check bot responsiveness
**Hourly Updates**: The bot automatically posts PYPL price updates every hour during NYSE market hours (9:30 AM - 4:00 PM ET, Monday-Friday).
## Security Notes
- Never commit `.env` file to version control
- Keep your Discord token and API keys secure
- Regularly rotate API keys
- Use environment variables for all sensitive data
- Run bot with minimal necessary permissions
## Support
For issues or questions:
- Check logs for error messages
- Verify all configuration values
- Ensure all prerequisites are installed
- Review README.md for additional information

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
# Use official Python runtime as base image
FROM python:3.11-slim
# Set working directory in container
WORKDIR /app
# Set environment variables
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create non-root user for security
RUN useradd -m -u 1000 botuser && \
chown -R botuser:botuser /app
USER botuser
# Run the bot
CMD ["python", "bot.py"]

222
README.md Normal file
View File

@@ -0,0 +1,222 @@
# Discord Stock Bot
A Discord bot that automatically posts hourly stock price updates for PayPal (PYPL) during NYSE market hours. The bot also supports manual queries for any stock ticker via prefix commands.
## Features
- **Hourly automated updates**: Posts PayPal stock price every hour during market hours (9:30 AM - 4:00 PM ET, Monday-Friday)
- **Market hours awareness**: Automatically skips updates when NYSE is closed (weekends and holidays)
- **Manual stock queries**: Query any stock ticker using `!stock TICKER` or `!price TICKER`
- **Rich embeds**: Color-coded Discord embeds with price changes and market status
- **API agnostic design**: Abstract stock API layer allows easy switching between data providers
- **Docker containerized**: Consistent deployment across local and production environments
## Commands
- `!stock <TICKER>` or `!price <TICKER>` - Get current stock price for any ticker
- `!market` - Check if NYSE is currently open and view trading hours
- `!ping` - Check bot responsiveness and latency
## Project Structure
```
discord-stock-bot/
├── bot.py # Main bot application
├── config.py # Configuration management
├── market_hours.py # NYSE market hours detection
├── stock_api/
│ ├── __init__.py
│ ├── base.py # Abstract base class for stock APIs
│ └── yahoo.py # Yahoo Finance implementation
├── requirements.txt # Python dependencies
├── Dockerfile # Container definition
├── docker-compose.yml # Docker Compose configuration
└── .env # Environment variables (create from .env.example)
```
## Setup Instructions
### Prerequisites
- Python 3.11+ (for local development)
- Docker and Docker Compose (for containerized deployment)
- Discord Bot Token ([Create one here](https://discord.com/developers/applications))
- Discord Channel ID ([How to get Channel ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID))
### 1. Create Discord Bot
1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
2. Click "New Application" and give it a name
3. Navigate to "Bot" section and click "Add Bot"
4. Under "Privileged Gateway Intents", enable:
- Message Content Intent
5. Copy the bot token (you will need this for `.env` file)
6. Navigate to "OAuth2" > "URL Generator"
7. Select scopes: `bot`
8. Select bot permissions: `Send Messages`, `Embed Links`, `Read Messages/View Channels`
9. Copy the generated URL and use it to invite the bot to your server
### 2. Get Channel ID
1. Enable Developer Mode in Discord (User Settings > Advanced > Developer Mode)
2. Right-click the channel where you want price updates
3. Click "Copy Channel ID"
### 3. Configure Environment
```bash
# Navigate to project directory
cd discord-stock-bot
# Copy example environment file
cp .env.example .env
# Edit .env with your values
# DISCORD_TOKEN=your_actual_bot_token
# CHANNEL_ID=your_actual_channel_id
```
### 4. Run Locally with Docker
```bash
# Build and start the container
docker compose up --build
# Run in detached mode (background)
docker compose up -d
# View logs
docker compose logs -f
# Stop the bot
docker compose down
```
### 5. Run Locally without Docker (Development)
```bash
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
# Run the bot
python bot.py
```
## Deployment to Unraid
### Method 1: Docker Compose (Recommended)
1. Copy the entire `discord-stock-bot` directory to your Unraid server
2. SSH into your Unraid server
3. Navigate to the project directory
4. Create and configure `.env` file
5. Run: `docker compose up -d`
### Method 2: Unraid Docker Template
1. Open Unraid web interface
2. Navigate to Docker tab
3. Click "Add Container"
4. Configure with these settings:
- **Name**: `discord-stock-bot`
- **Repository**: Build and push your image to Docker Hub first, then use `your-username/discord-stock-bot:latest`
- **Network Type**: `bridge`
- **Add Variables**:
- `DISCORD_TOKEN` = your bot token
- `CHANNEL_ID` = your channel ID
- `PRIMARY_TICKER` = PYPL
- `COMMAND_PREFIX` = !
- `TZ` = America/New_York
### Method 3: Build and Push to Docker Hub
```bash
# Build the image
docker build -t your-username/discord-stock-bot:latest .
# Login to Docker Hub
docker login
# Push to Docker Hub
docker push your-username/discord-stock-bot:latest
# On Unraid, pull and run
docker pull your-username/discord-stock-bot:latest
docker run -d --name discord-stock-bot \
--env-file .env \
--restart unless-stopped \
your-username/discord-stock-bot:latest
```
## Configuration Options
Environment variables in `.env`:
| Variable | Description | Default |
|----------|-------------|---------|
| `DISCORD_TOKEN` | Your Discord bot token | Required |
| `CHANNEL_ID` | Discord channel ID for updates | Required |
| `COMMAND_PREFIX` | Command prefix for bot | `!` |
| `PRIMARY_TICKER` | Stock ticker for hourly updates | `PYPL` |
| `UPDATE_INTERVAL_HOURS` | Hours between updates | `1` |
## Switching Stock API Providers
The bot is designed with an abstract API layer. To switch providers:
1. Create a new class in `stock_api/` that inherits from `StockAPIBase`
2. Implement the required methods: `get_stock_price()` and `is_available()`
3. Update `bot.py` to use your new provider instead of `YahooFinanceAPI`
Example:
```python
# In bot.py
from stock_api import YourNewAPI
# In __init__ method
self.stock_api = YourNewAPI()
```
## Market Hours
The bot respects NYSE trading hours:
- **Trading Days**: Monday - Friday
- **Trading Hours**: 9:30 AM - 4:00 PM Eastern Time
- **Holidays**: Major NYSE holidays are observed (New Year's Day, MLK Day, Presidents Day, Good Friday, Memorial Day, Juneteenth, Independence Day, Labor Day, Thanksgiving, Christmas)
## Troubleshooting
### Bot does not connect
- Verify `DISCORD_TOKEN` is correct
- Ensure bot has been invited to your server
- Check bot has proper permissions in the channel
### Bot does not post updates
- Verify `CHANNEL_ID` is correct
- Ensure bot has permission to send messages in the channel
- Check if market is open using `!market` command
- View logs: `docker compose logs -f`
### Stock data not available
- Yahoo Finance may be experiencing issues
- Ticker symbol may be invalid
- Try using `!stock AAPL` to test with a known ticker
### Container will not start
- Verify `.env` file exists and is properly formatted
- Check Docker logs: `docker compose logs`
- Ensure no other container is using the same name
## License
This project is provided as-is for educational and personal use.
## Acknowledgments
- Stock data provided by Yahoo Finance via `yfinance`
- Built with `discord.py`
- Scheduling powered by `APScheduler`

229
bot.py Normal file
View File

@@ -0,0 +1,229 @@
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
# 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_id = int(Config.CHANNEL_ID)
self.primary_ticker = Config.PRIMARY_TICKER
async def setup_hook(self):
"""Initialize the bot and set up scheduled tasks."""
logger.info("Bot setup initiated")
# Schedule hourly updates during market hours
# Run every hour on the hour, but only post if market is open
self.scheduler.add_job(
self.send_hourly_update,
CronTrigger(minute=0, timezone=MarketHours.NYSE_TIMEZONE),
id='hourly_update',
name='Hourly Stock Price Update'
)
self.scheduler.start()
logger.info("Scheduler started for hourly updates")
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 ID: {self.target_channel_id}')
# Verify channel exists
channel = self.get_channel(self.target_channel_id)
if channel:
logger.info(f'Target channel found: #{channel.name}')
else:
logger.error(f'Could not find channel with ID {self.target_channel_id}')
async def send_hourly_update(self):
"""Send hourly stock price update if market is open."""
if not MarketHours.is_market_open():
logger.info("Market is closed, skipping hourly update")
return
logger.info(f"Sending hourly update for {self.primary_ticker}")
await self.send_stock_update(self.primary_ticker, self.target_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']
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} {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)
market_status = "🟢 Market Open" if stock_data['market_open'] else "🔴 Market Closed"
embed.set_footer(text=f"{market_status}")
return embed
# 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='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()

49
config.py Normal file
View File

@@ -0,0 +1,49 @@
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
"""Application configuration loaded from environment variables."""
# Discord configuration
DISCORD_TOKEN = os.getenv('DISCORD_TOKEN')
CHANNEL_ID = os.getenv('CHANNEL_ID')
COMMAND_PREFIX = os.getenv('COMMAND_PREFIX', '!')
# Stock configuration
PRIMARY_TICKER = os.getenv('PRIMARY_TICKER', 'PYPL')
STOCK_API_PROVIDER = os.getenv('STOCK_API_PROVIDER', 'finnhub') # 'yahoo' or 'finnhub'
FINNHUB_API_KEY = os.getenv('FINNHUB_API_KEY')
# Scheduling configuration
UPDATE_INTERVAL_HOURS = int(os.getenv('UPDATE_INTERVAL_HOURS', '1'))
@classmethod
def validate(cls) -> bool:
"""
Validate that required configuration values are present.
Returns:
True if configuration is valid, False otherwise
"""
if not cls.DISCORD_TOKEN:
print("ERROR: DISCORD_TOKEN not set in environment")
return False
if not cls.CHANNEL_ID:
print("ERROR: CHANNEL_ID not set in environment")
return False
try:
int(cls.CHANNEL_ID)
except ValueError:
print("ERROR: CHANNEL_ID must be a numeric Discord channel ID")
return False
if cls.STOCK_API_PROVIDER == 'finnhub' and not cls.FINNHUB_API_KEY:
print("ERROR: FINNHUB_API_KEY not set in environment (required when STOCK_API_PROVIDER=finnhub)")
return False
return True

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
version: '3.8'
services:
discord-stock-bot:
build: .
container_name: discord-stock-bot
restart: unless-stopped
env_file:
- .env
environment:
- TZ=America/New_York
volumes:
# Mount source code for development (comment out for production)
- ./:/app
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

121
market_hours.py Normal file
View File

@@ -0,0 +1,121 @@
from datetime import datetime, time
import pytz
import logging
logger = logging.getLogger(__name__)
class MarketHours:
"""
Utility class for determining NYSE market hours.
Market is open 9:30 AM - 4:00 PM ET, Monday-Friday (excluding holidays).
"""
NYSE_TIMEZONE = pytz.timezone('America/New_York')
MARKET_OPEN = time(9, 30)
MARKET_CLOSE = time(16, 0)
# Major NYSE holidays (this is a simplified list - production systems would use a holiday calendar API)
HOLIDAYS_2024 = [
datetime(2024, 1, 1), # New Year's Day
datetime(2024, 1, 15), # MLK Day
datetime(2024, 2, 19), # Presidents Day
datetime(2024, 3, 29), # Good Friday
datetime(2024, 5, 27), # Memorial Day
datetime(2024, 6, 19), # Juneteenth
datetime(2024, 7, 4), # Independence Day
datetime(2024, 9, 2), # Labor Day
datetime(2024, 11, 28), # Thanksgiving
datetime(2024, 12, 25), # Christmas
]
HOLIDAYS_2025 = [
datetime(2025, 1, 1), # New Year's Day
datetime(2025, 1, 20), # MLK Day
datetime(2025, 2, 17), # Presidents Day
datetime(2025, 4, 18), # Good Friday
datetime(2025, 5, 26), # Memorial Day
datetime(2025, 6, 19), # Juneteenth
datetime(2025, 7, 4), # Independence Day
datetime(2025, 9, 1), # Labor Day
datetime(2025, 11, 27), # Thanksgiving
datetime(2025, 12, 25), # Christmas
]
@classmethod
def is_market_open(cls, check_time: datetime = None) -> bool:
"""
Determine if the NYSE is currently open.
Args:
check_time: Datetime to check (defaults to now)
Returns:
True if market is open, False otherwise
"""
if check_time is None:
check_time = datetime.now(cls.NYSE_TIMEZONE)
else:
check_time = check_time.astimezone(cls.NYSE_TIMEZONE)
# Check if weekend
if check_time.weekday() >= 5: # Saturday = 5, Sunday = 6
logger.debug("Market closed: Weekend")
return False
# Check if holiday
check_date = check_time.date()
all_holidays = cls.HOLIDAYS_2024 + cls.HOLIDAYS_2025
if any(holiday.date() == check_date for holiday in all_holidays):
logger.debug(f"Market closed: Holiday ({check_date})")
return False
# Check if within market hours
current_time = check_time.time()
if cls.MARKET_OPEN <= current_time < cls.MARKET_CLOSE:
return True
else:
logger.debug(f"Market closed: Outside trading hours ({current_time})")
return False
@classmethod
def get_next_market_open(cls, from_time: datetime = None) -> datetime:
"""
Calculate the next time the market will open.
Args:
from_time: Starting datetime (defaults to now)
Returns:
Datetime of next market open
"""
if from_time is None:
from_time = datetime.now(cls.NYSE_TIMEZONE)
else:
from_time = from_time.astimezone(cls.NYSE_TIMEZONE)
# Start checking from the next day at market open
next_day = from_time.replace(hour=cls.MARKET_OPEN.hour,
minute=cls.MARKET_OPEN.minute,
second=0,
microsecond=0)
# If we have not passed today's open time, check today first
if from_time.time() < cls.MARKET_OPEN:
next_day = next_day
else:
# Otherwise start from tomorrow
next_day = next_day.replace(day=next_day.day + 1)
# Find the next valid market day
max_iterations = 14 # Search up to 2 weeks ahead
for _ in range(max_iterations):
if cls.is_market_open(next_day):
return next_day
# Move to next day
next_day = next_day.replace(day=next_day.day + 1)
# Fallback (should not reach here under normal circumstances)
logger.warning("Could not determine next market open within 2 weeks")
return next_day

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
discord.py==2.3.2
yfinance==0.2.32
finnhub-python==2.4.26
APScheduler==3.10.4
python-dotenv==1.0.0
pytz==2023.3

5
stock_api/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from .base import StockAPIBase
from .yahoo import YahooFinanceAPI
from .finnhub_api import FinnhubAPI
__all__ = ['StockAPIBase', 'YahooFinanceAPI', 'FinnhubAPI']

39
stock_api/base.py Normal file
View File

@@ -0,0 +1,39 @@
from abc import ABC, abstractmethod
from typing import Optional, Dict, Any
class StockAPIBase(ABC):
"""
Abstract base class for stock price data providers.
This design allows seamless switching between different stock APIs.
"""
@abstractmethod
def get_stock_price(self, ticker: str) -> Optional[Dict[str, Any]]:
"""
Retrieve current stock price and related data for a given ticker.
Args:
ticker: Stock ticker symbol (e.g., 'PYPL', 'AAPL')
Returns:
Dictionary containing:
- ticker: str
- current_price: float
- previous_close: float
- change_dollar: float
- change_percent: float
- market_open: bool
Returns None if ticker is invalid or data unavailable.
"""
pass
@abstractmethod
def is_available(self) -> bool:
"""
Check if the API service is currently available.
Returns:
True if API is accessible, False otherwise.
"""
pass

82
stock_api/finnhub_api.py Normal file
View File

@@ -0,0 +1,82 @@
import finnhub
import logging
from typing import Optional, Dict, Any
from .base import StockAPIBase
from datetime import datetime
import pytz
logger = logging.getLogger(__name__)
class FinnhubAPI(StockAPIBase):
"""Finnhub implementation of stock price provider."""
def __init__(self, api_key: str):
"""
Initialize Finnhub API client.
Args:
api_key: Finnhub API key
"""
self.client = finnhub.Client(api_key=api_key)
self.api_key = api_key
def get_stock_price(self, ticker: str) -> Optional[Dict[str, Any]]:
"""
Retrieve stock price data from Finnhub.
Args:
ticker: Stock ticker symbol
Returns:
Dictionary with stock data or None if unavailable
"""
try:
# Get current quote data
quote = self.client.quote(ticker)
if not quote or quote.get('c') is None or quote.get('c') == 0:
logger.warning(f"No data available for ticker: {ticker}")
return None
current_price = float(quote['c']) # Current price
previous_close = float(quote['pc']) # Previous close
if current_price == 0 or previous_close == 0:
logger.warning(f"Invalid price data for ticker: {ticker}")
return None
change_dollar = current_price - previous_close
change_percent = (change_dollar / previous_close) * 100
# Use MarketHours utility for accurate market status
from market_hours import MarketHours
market_open = MarketHours.is_market_open()
return {
'ticker': ticker.upper(),
'current_price': round(current_price, 2),
'previous_close': round(previous_close, 2),
'change_dollar': round(change_dollar, 2),
'change_percent': round(change_percent, 2),
'market_open': market_open
}
except Exception as e:
logger.error(f"Error fetching data for {ticker}: {e}")
return None
def is_available(self) -> bool:
"""
Check if Finnhub API is accessible.
Returns:
True if accessible, False otherwise
"""
try:
test_quote = self.client.quote("AAPL")
return test_quote is not None and test_quote.get('c') is not None
except Exception as e:
logger.error(f"Finnhub API unavailable: {e}")
return False

75
stock_api/yahoo.py Normal file
View File

@@ -0,0 +1,75 @@
import yfinance as yf
import logging
from typing import Optional, Dict, Any
from .base import StockAPIBase
logger = logging.getLogger(__name__)
class YahooFinanceAPI(StockAPIBase):
"""Yahoo Finance implementation of stock price provider."""
def get_stock_price(self, ticker: str) -> Optional[Dict[str, Any]]:
"""
Retrieve stock price data from Yahoo Finance.
Args:
ticker: Stock ticker symbol
Returns:
Dictionary with stock data or None if unavailable
"""
try:
stock = yf.Ticker(ticker)
# Use history() method instead of info to avoid rate limiting
# Get last 2 days of data to calculate change
hist = stock.history(period="2d")
if hist.empty or len(hist) < 1:
logger.warning(f"No historical data available for ticker: {ticker}")
return None
# Get most recent price data
current_price = float(hist['Close'].iloc[-1])
# Get previous close (either from previous day or use current data)
if len(hist) >= 2:
previous_close = float(hist['Close'].iloc[-2])
else:
previous_close = float(hist['Open'].iloc[-1])
change_dollar = current_price - previous_close
change_percent = (change_dollar / previous_close) * 100
# Market is considered open if we have today's data
market_open = True # Simplified - actual market status requires additional API call
return {
'ticker': ticker.upper(),
'current_price': round(current_price, 2),
'previous_close': round(previous_close, 2),
'change_dollar': round(change_dollar, 2),
'change_percent': round(change_percent, 2),
'market_open': market_open
}
except Exception as e:
logger.error(f"Error fetching data for {ticker}: {e}")
return None
def is_available(self) -> bool:
"""
Check if Yahoo Finance API is accessible.
Returns:
True if accessible, False otherwise
"""
try:
test_stock = yf.Ticker("AAPL")
info = test_stock.info
return info is not None and len(info) > 0
except Exception as e:
logger.error(f"Yahoo Finance API unavailable: {e}")
return False