Add matplotlib-based chart generation with Yahoo Finance data

- 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 <noreply@anthropic.com>
This commit is contained in:
Michael Simard
2025-12-25 00:25:00 -06:00
parent 3040ab3cc1
commit 71e70b77b0
5 changed files with 290 additions and 8 deletions

49
bot.py
View File

@@ -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'])

162
chart_generator.py Normal file
View File

@@ -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

View File

@@ -5,3 +5,4 @@ APScheduler==3.10.4
python-dotenv==1.0.0
pytz==2023.3
requests==2.31.0
matplotlib==3.8.2

View File

@@ -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

View File

@@ -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