commit 5964cadf9431c69e069c8d4ca1df23cf5af30cd3 Author: Michael Simard Date: Tue Dec 2 21:54:11 2025 -0600 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 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..6ee5b8c --- /dev/null +++ b/.gitignore @@ -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 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