Initial commit: Discord stock price bot with hourly PYPL updates

Implemented Discord bot for automated stock price tracking and reporting with the following features:

- Hourly PayPal (PYPL) stock price updates during NYSE market hours
- Custom branding for PYPL as "Steaks Stablecoin Value"
- Manual stock price queries via !stock and !price commands
- Multi-provider stock API support (Yahoo Finance and Finnhub)
- NYSE market hours detection with holiday awareness
- Discord embed formatting with color-coded price changes
- Docker containerization for consistent deployment
- Comprehensive documentation and deployment guides

Technical stack:
- Python 3.9+ with discord.py
- Finnhub API for stock price data
- APScheduler for hourly automated updates
- Docker support for local and Unraid deployment

🤖 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 21:54:11 -06:00
commit 5964cadf94
14 changed files with 946 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

42
.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# Environment variables
.env
.env.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Logs
*.log
# OS
.DS_Store
Thumbs.db

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