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:
45
bot.py
45
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)
|
||||
|
||||
# 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,6 +548,23 @@ async def get_stock_price(ctx, ticker: str = None):
|
||||
return
|
||||
|
||||
embed = bot.create_stock_embed(stock_data)
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
162
chart_generator.py
Normal file
162
chart_generator.py
Normal 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
|
||||
@@ -5,3 +5,4 @@ APScheduler==3.10.4
|
||||
python-dotenv==1.0.0
|
||||
pytz==2023.3
|
||||
requests==2.31.0
|
||||
matplotlib==3.8.2
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user