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 stock_api import YahooFinanceAPI, FinnhubAPI
|
||||||
from market_hours import MarketHours
|
from market_hours import MarketHours
|
||||||
from crypto_api import CoinGeckoAPI
|
from crypto_api import CoinGeckoAPI
|
||||||
|
from chart_generator import ChartGenerator
|
||||||
|
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -36,6 +37,10 @@ class StockBot(commands.Bot):
|
|||||||
self.stock_api = YahooFinanceAPI()
|
self.stock_api = YahooFinanceAPI()
|
||||||
logger.info("Using Yahoo Finance API for stock data")
|
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.scheduler = AsyncIOScheduler(timezone=pytz.timezone('America/New_York'))
|
||||||
self.target_channel_ids = [int(id) for id in Config.CHANNEL_IDS]
|
self.target_channel_ids = [int(id) for id in Config.CHANNEL_IDS]
|
||||||
self.primary_ticker = Config.PRIMARY_TICKER
|
self.primary_ticker = Config.PRIMARY_TICKER
|
||||||
@@ -254,7 +259,25 @@ class StockBot(commands.Bot):
|
|||||||
return
|
return
|
||||||
|
|
||||||
embed = self.create_stock_embed(stock_data)
|
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)
|
await channel.send(embed=embed)
|
||||||
|
|
||||||
logger.info(f"Sent stock update for {ticker} to channel {channel_id}")
|
logger.info(f"Sent stock update for {ticker} to channel {channel_id}")
|
||||||
|
|
||||||
def create_stock_embed(self, stock_data: dict) -> discord.Embed:
|
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="Change", value=change_str, inline=True)
|
||||||
embed.add_field(name="Previous Close", value=f"${stock_data['previous_close']}", inline=True)
|
embed.add_field(name="Previous Close", value=f"${stock_data['previous_close']}", inline=True)
|
||||||
|
|
||||||
# Add FinViz daily chart
|
# Reference attached chart image
|
||||||
chart_url = f"https://finviz.com/chart.ashx?t={ticker}&ty=c&ta=1&p=d&s=l"
|
embed.set_image(url="attachment://chart.png")
|
||||||
embed.set_image(url=chart_url)
|
|
||||||
|
|
||||||
market_status = "🟢 Market Open" if stock_data['market_open'] else "🔴 Market Closed"
|
market_status = "🟢 Market Open" if stock_data['market_open'] else "🔴 Market Closed"
|
||||||
embed.set_footer(text=f"{market_status}")
|
embed.set_footer(text=f"{market_status}")
|
||||||
@@ -526,6 +548,23 @@ async def get_stock_price(ctx, ticker: str = None):
|
|||||||
return
|
return
|
||||||
|
|
||||||
embed = bot.create_stock_embed(stock_data)
|
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)
|
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
|
python-dotenv==1.0.0
|
||||||
pytz==2023.3
|
pytz==2023.3
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
|
matplotlib==3.8.2
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import finnhub
|
import finnhub
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, List
|
||||||
from .base import StockAPIBase
|
from .base import StockAPIBase
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
|
|
||||||
@@ -170,3 +170,40 @@ class FinnhubAPI(StockAPIBase):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching earnings calendar: {e}")
|
logger.error(f"Error fetching earnings calendar: {e}")
|
||||||
return None
|
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 yfinance as yf
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, List
|
||||||
from .base import StockAPIBase
|
from .base import StockAPIBase
|
||||||
|
|
||||||
|
|
||||||
@@ -83,3 +83,46 @@ class YahooFinanceAPI(StockAPIBase):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Yahoo Finance API unavailable: {e}")
|
logger.error(f"Yahoo Finance API unavailable: {e}")
|
||||||
return False
|
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