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:
18
.dockerignore
Normal file
18
.dockerignore
Normal 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
14
.env.example
Normal 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
22
.gitignore
vendored
Normal 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
395
DEPLOYMENT.md
Normal 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
25
Dockerfile
Normal 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
222
README.md
Normal 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
229
bot.py
Normal 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
49
config.py
Normal 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
19
docker-compose.yml
Normal 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
121
market_hours.py
Normal 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
6
requirements.txt
Normal 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
5
stock_api/__init__.py
Normal 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
39
stock_api/base.py
Normal 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
82
stock_api/finnhub_api.py
Normal 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
75
stock_api/yahoo.py
Normal 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
|
||||||
Reference in New Issue
Block a user