From 3b6f0cbe4a70a4ff20f575de4858d0b6cf5bd24c Mon Sep 17 00:00:00 2001 From: Michael Simard Date: Tue, 2 Dec 2025 22:25:37 -0600 Subject: [PATCH] Initial commit: Discord stock bot with hourly PYPL updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .dockerignore | 18 ++ .env.example | 14 ++ .gitignore | 22 +++ DEPLOYMENT.md | 395 +++++++++++++++++++++++++++++++++++++++ Dockerfile | 25 +++ README.md | 222 ++++++++++++++++++++++ bot.py | 229 +++++++++++++++++++++++ config.py | 49 +++++ docker-compose.yml | 19 ++ market_hours.py | 121 ++++++++++++ requirements.txt | 6 + stock_api/__init__.py | 5 + stock_api/base.py | 39 ++++ stock_api/finnhub_api.py | 82 ++++++++ stock_api/yahoo.py | 75 ++++++++ 15 files changed, 1321 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bot.py create mode 100644 config.py create mode 100644 docker-compose.yml create mode 100644 market_hours.py create mode 100644 requirements.txt create mode 100644 stock_api/__init__.py create mode 100644 stock_api/base.py create mode 100644 stock_api/finnhub_api.py create mode 100644 stock_api/yahoo.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..daa2432 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..eb770c7 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71733b0 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..f7a33a1 --- /dev/null +++ b/DEPLOYMENT.md @@ -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 ` - Get current stock price for any ticker +- `!price ` - 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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ada0dc1 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..90ebfdf --- /dev/null +++ b/README.md @@ -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 ` or `!price ` - 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` diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..03da942 --- /dev/null +++ b/bot.py @@ -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() diff --git a/config.py b/config.py new file mode 100644 index 0000000..e59828f --- /dev/null +++ b/config.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3306645 --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/market_hours.py b/market_hours.py new file mode 100644 index 0000000..892661c --- /dev/null +++ b/market_hours.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a34a381 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/stock_api/__init__.py b/stock_api/__init__.py new file mode 100644 index 0000000..9e82114 --- /dev/null +++ b/stock_api/__init__.py @@ -0,0 +1,5 @@ +from .base import StockAPIBase +from .yahoo import YahooFinanceAPI +from .finnhub_api import FinnhubAPI + +__all__ = ['StockAPIBase', 'YahooFinanceAPI', 'FinnhubAPI'] diff --git a/stock_api/base.py b/stock_api/base.py new file mode 100644 index 0000000..282883f --- /dev/null +++ b/stock_api/base.py @@ -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 diff --git a/stock_api/finnhub_api.py b/stock_api/finnhub_api.py new file mode 100644 index 0000000..102fa89 --- /dev/null +++ b/stock_api/finnhub_api.py @@ -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 diff --git a/stock_api/yahoo.py b/stock_api/yahoo.py new file mode 100644 index 0000000..390a749 --- /dev/null +++ b/stock_api/yahoo.py @@ -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