From 71e70b77b0b1d7e33830ee69ee3f7591302f2ca3 Mon Sep 17 00:00:00 2001 From: Michael Simard Date: Thu, 25 Dec 2025 00:25:00 -0600 Subject: [PATCH] Add matplotlib-based chart generation with Yahoo Finance data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add chart_generator.py with price and candlestick chart support - Implement Yahoo Finance candle data fetching for free historical data - Update bot to generate and attach charts to stock embeds - Add matplotlib dependency to requirements.txt - Configure dual API approach: Finnhub for quotes, Yahoo for charts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- bot.py | 49 ++++++++++-- chart_generator.py | 162 +++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + stock_api/finnhub_api.py | 41 +++++++++- stock_api/yahoo.py | 45 ++++++++++- 5 files changed, 290 insertions(+), 8 deletions(-) create mode 100644 chart_generator.py diff --git a/bot.py b/bot.py index 80ea78b..71737dd 100644 --- a/bot.py +++ b/bot.py @@ -10,6 +10,7 @@ from config import Config from stock_api import YahooFinanceAPI, FinnhubAPI from market_hours import MarketHours from crypto_api import CoinGeckoAPI +from chart_generator import ChartGenerator # Configure logging @@ -36,6 +37,10 @@ class StockBot(commands.Bot): self.stock_api = YahooFinanceAPI() logger.info("Using Yahoo Finance API for stock data") + # Always initialize Yahoo Finance for chart data (free historical data) + self.yahoo_api = YahooFinanceAPI() + logger.info("Using Yahoo Finance API for chart data") + self.scheduler = AsyncIOScheduler(timezone=pytz.timezone('America/New_York')) self.target_channel_ids = [int(id) for id in Config.CHANNEL_IDS] self.primary_ticker = Config.PRIMARY_TICKER @@ -254,7 +259,25 @@ class StockBot(commands.Bot): return embed = self.create_stock_embed(stock_data) - await channel.send(embed=embed) + + # Generate chart using Yahoo Finance historical data + chart_file = None + candle_data = self.yahoo_api.get_candles(ticker, days=30) + if candle_data: + chart_buffer = ChartGenerator.create_price_chart( + ticker, + candle_data, + stock_data.get('company_name') + ) + if chart_buffer: + chart_file = discord.File(chart_buffer, filename="chart.png") + + # Send with chart attachment if available + if chart_file: + await channel.send(embed=embed, file=chart_file) + else: + 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: @@ -310,9 +333,8 @@ class StockBot(commands.Bot): embed.add_field(name="Change", value=change_str, inline=True) embed.add_field(name="Previous Close", value=f"${stock_data['previous_close']}", inline=True) - # Add FinViz daily chart - chart_url = f"https://finviz.com/chart.ashx?t={ticker}&ty=c&ta=1&p=d&s=l" - embed.set_image(url=chart_url) + # Reference attached chart image + embed.set_image(url="attachment://chart.png") market_status = "🟢 Market Open" if stock_data['market_open'] else "🔴 Market Closed" embed.set_footer(text=f"{market_status}") @@ -526,7 +548,24 @@ async def get_stock_price(ctx, ticker: str = None): return embed = bot.create_stock_embed(stock_data) - await ctx.send(embed=embed) + + # Generate chart using Yahoo Finance historical data + chart_file = None + candle_data = bot.yahoo_api.get_candles(ticker, days=30) + if candle_data: + chart_buffer = ChartGenerator.create_price_chart( + ticker, + candle_data, + stock_data.get('company_name') + ) + if chart_buffer: + chart_file = discord.File(chart_buffer, filename="chart.png") + + # Send with chart attachment if available + if chart_file: + await ctx.send(embed=embed, file=chart_file) + else: + await ctx.send(embed=embed) @bot.command(name='crypto', aliases=['c']) diff --git a/chart_generator.py b/chart_generator.py new file mode 100644 index 0000000..312d990 --- /dev/null +++ b/chart_generator.py @@ -0,0 +1,162 @@ +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +from datetime import datetime +from typing import Dict, List, Optional +import logging +import io + +logger = logging.getLogger(__name__) + + +class ChartGenerator: + """Generates stock price charts using matplotlib.""" + + @staticmethod + def create_price_chart( + ticker: str, + candle_data: Dict[str, List], + company_name: Optional[str] = None + ) -> Optional[io.BytesIO]: + """ + Create a price chart from candle data. + + Args: + ticker: Stock ticker symbol + candle_data: Dictionary with timestamps, open, high, low, close, volume + company_name: Optional company name for title + + Returns: + BytesIO buffer containing the chart image, or None if error + """ + try: + # Convert timestamps to datetime objects + dates = [datetime.fromtimestamp(ts) for ts in candle_data['timestamps']] + closes = candle_data['close'] + + # Create figure and axis + fig, ax = plt.subplots(figsize=(12, 6), facecolor='#1e1e1e') + ax.set_facecolor('#2d2d2d') + + # Plot closing prices + ax.plot(dates, closes, color='#00d4ff', linewidth=2) + + # Fill area under the line + ax.fill_between(dates, closes, alpha=0.3, color='#00d4ff') + + # Format x-axis + ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d')) + ax.xaxis.set_major_locator(mdates.AutoDateLocator()) + plt.xticks(rotation=45, ha='right') + + # Grid styling + ax.grid(True, alpha=0.2, color='#ffffff', linestyle='-', linewidth=0.5) + + # Labels and title + title = f"{company_name} ({ticker})" if company_name else ticker + ax.set_title(title, color='#ffffff', fontsize=14, fontweight='bold', pad=20) + ax.set_xlabel('Date', color='#ffffff', fontsize=10) + ax.set_ylabel('Price ($)', color='#ffffff', fontsize=10) + + # Tick colors + ax.tick_params(colors='#ffffff', which='both') + + # Spine colors + for spine in ax.spines.values(): + spine.set_color('#444444') + + # Tight layout + plt.tight_layout() + + # Save to buffer + buffer = io.BytesIO() + plt.savefig(buffer, format='png', facecolor='#1e1e1e', dpi=100) + buffer.seek(0) + plt.close(fig) + + return buffer + + except Exception as e: + logger.error(f"Error generating chart for {ticker}: {e}") + return None + + @staticmethod + def create_candlestick_chart( + ticker: str, + candle_data: Dict[str, List], + company_name: Optional[str] = None + ) -> Optional[io.BytesIO]: + """ + Create a candlestick chart from candle data. + + Args: + ticker: Stock ticker symbol + candle_data: Dictionary with timestamps, open, high, low, close, volume + company_name: Optional company name for title + + Returns: + BytesIO buffer containing the chart image, or None if error + """ + try: + # Convert timestamps to datetime objects + dates = [datetime.fromtimestamp(ts) for ts in candle_data['timestamps']] + opens = candle_data['open'] + highs = candle_data['high'] + lows = candle_data['low'] + closes = candle_data['close'] + + # Create figure and axis + fig, ax = plt.subplots(figsize=(12, 6), facecolor='#1e1e1e') + ax.set_facecolor('#2d2d2d') + + # Calculate bar width + width = 0.6 + + # Plot candlesticks + for i in range(len(dates)): + color = '#00ff88' if closes[i] >= opens[i] else '#ff4444' + + # High-low line + ax.plot([dates[i], dates[i]], [lows[i], highs[i]], color=color, linewidth=1) + + # Open-close rectangle + height = abs(closes[i] - opens[i]) + bottom = min(opens[i], closes[i]) + ax.bar(dates[i], height, width, bottom=bottom, color=color, alpha=0.8) + + # Format x-axis + ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d')) + ax.xaxis.set_major_locator(mdates.AutoDateLocator()) + plt.xticks(rotation=45, ha='right') + + # Grid styling + ax.grid(True, alpha=0.2, color='#ffffff', linestyle='-', linewidth=0.5) + + # Labels and title + title = f"{company_name} ({ticker})" if company_name else ticker + ax.set_title(title, color='#ffffff', fontsize=14, fontweight='bold', pad=20) + ax.set_xlabel('Date', color='#ffffff', fontsize=10) + ax.set_ylabel('Price ($)', color='#ffffff', fontsize=10) + + # Tick colors + ax.tick_params(colors='#ffffff', which='both') + + # Spine colors + for spine in ax.spines.values(): + spine.set_color('#444444') + + # Tight layout + plt.tight_layout() + + # Save to buffer + buffer = io.BytesIO() + plt.savefig(buffer, format='png', facecolor='#1e1e1e', dpi=100) + buffer.seek(0) + plt.close(fig) + + return buffer + + except Exception as e: + logger.error(f"Error generating candlestick chart for {ticker}: {e}") + return None diff --git a/requirements.txt b/requirements.txt index 41f4863..a666615 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ APScheduler==3.10.4 python-dotenv==1.0.0 pytz==2023.3 requests==2.31.0 +matplotlib==3.8.2 diff --git a/stock_api/finnhub_api.py b/stock_api/finnhub_api.py index 19c0f6d..b73d144 100644 --- a/stock_api/finnhub_api.py +++ b/stock_api/finnhub_api.py @@ -1,8 +1,8 @@ import finnhub import logging -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, List from .base import StockAPIBase -from datetime import datetime +from datetime import datetime, timedelta import pytz @@ -170,3 +170,40 @@ class FinnhubAPI(StockAPIBase): except Exception as e: logger.error(f"Error fetching earnings calendar: {e}") return None + + def get_candles(self, ticker: str, days: int = 30, resolution: str = 'D') -> Optional[Dict[str, List]]: + """ + Retrieve historical candlestick data from Finnhub. + + Args: + ticker: Stock ticker symbol + days: Number of days of historical data to fetch + resolution: Candle resolution (1, 5, 15, 30, 60, D, W, M) + + Returns: + Dictionary with OHLCV data or None if unavailable + """ + try: + # Calculate timestamps + end_time = int(datetime.now().timestamp()) + start_time = int((datetime.now() - timedelta(days=days)).timestamp()) + + # Fetch candle data + candles = self.client.stock_candles(ticker, resolution, start_time, end_time) + + if not candles or candles.get('s') != 'ok': + logger.warning(f"No candle data available for ticker: {ticker}") + return None + + return { + 'timestamps': candles['t'], + 'open': candles['o'], + 'high': candles['h'], + 'low': candles['l'], + 'close': candles['c'], + 'volume': candles['v'] + } + + except Exception as e: + logger.error(f"Error fetching candle data for {ticker}: {e}") + return None diff --git a/stock_api/yahoo.py b/stock_api/yahoo.py index 136e48e..5ed3f2b 100644 --- a/stock_api/yahoo.py +++ b/stock_api/yahoo.py @@ -1,6 +1,6 @@ import yfinance as yf import logging -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, List from .base import StockAPIBase @@ -83,3 +83,46 @@ class YahooFinanceAPI(StockAPIBase): except Exception as e: logger.error(f"Yahoo Finance API unavailable: {e}") return False + + def get_candles(self, ticker: str, days: int = 30, resolution: str = 'D') -> Optional[Dict[str, List]]: + """ + Retrieve historical candlestick data from Yahoo Finance. + + Args: + ticker: Stock ticker symbol + days: Number of days of historical data to fetch + resolution: Candle resolution (ignored, always daily for Yahoo Finance) + + Returns: + Dictionary with OHLCV data or None if unavailable + """ + try: + stock = yf.Ticker(ticker) + + # Fetch historical data + hist = stock.history(period=f"{days}d") + + if hist.empty: + logger.warning(f"No candle data available for ticker: {ticker}") + return None + + # Convert to the format expected by chart generator + timestamps = [int(date.timestamp()) for date in hist.index] + opens = hist['Open'].tolist() + highs = hist['High'].tolist() + lows = hist['Low'].tolist() + closes = hist['Close'].tolist() + volumes = hist['Volume'].tolist() + + return { + 'timestamps': timestamps, + 'open': opens, + 'high': highs, + 'low': lows, + 'close': closes, + 'volume': volumes + } + + except Exception as e: + logger.error(f"Error fetching candle data for {ticker}: {e}") + return None