diff --git a/README.md b/README.md index 73f1b69..72a019f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A Discord bot that automatically posts hourly stock price updates for PayPal (PY ## Commands - `!stock ` or `!price ` - Get current stock price for any ticker +- `!crypto ` or `!c ` - Get current cryptocurrency price (BTC, ETH, etc.) - `!market` - Check if NYSE is currently open and view trading hours - `!ping` - Check bot responsiveness and latency diff --git a/bot.py b/bot.py index 1f22871..66b452b 100644 --- a/bot.py +++ b/bot.py @@ -9,6 +9,7 @@ import pytz from config import Config from stock_api import YahooFinanceAPI, FinnhubAPI from market_hours import MarketHours +from crypto_api import CoinGeckoAPI # Configure logging @@ -39,6 +40,7 @@ class StockBot(commands.Bot): self.target_channel_ids = [int(id) for id in Config.CHANNEL_IDS] self.primary_ticker = Config.PRIMARY_TICKER self.startup_announced = False + self.crypto_api = CoinGeckoAPI() async def setup_hook(self): """Initialize the bot and set up scheduled tasks.""" @@ -212,6 +214,57 @@ class StockBot(commands.Bot): return embed + def create_crypto_embed(self, crypto_data: dict) -> discord.Embed: + """ + Create a formatted Discord embed for cryptocurrency price data. + + Args: + crypto_data: Dictionary containing crypto information + + Returns: + Discord embed object + """ + symbol = crypto_data['symbol'] + current_price = crypto_data['current_price'] + change_dollar = crypto_data['change_dollar'] + change_percent = crypto_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}%)" + + # Create embed + embed = discord.Embed( + title=f"{change_emoji} {symbol} Price", + description=f"**${current_price:,.2f}**", + color=color, + timestamp=datetime.now(pytz.timezone('America/New_York')) + ) + + embed.add_field(name="24h Change", value=change_str, inline=True) + embed.add_field(name="Previous Price", value=f"${crypto_data['previous_price']:,.2f}", inline=True) + + # Add volume if available + volume = crypto_data.get('volume_24h', 0) + if volume > 0: + volume_str = f"${volume:,.0f}" + embed.add_field(name="24h Volume", value=volume_str, inline=False) + + embed.set_footer(text="Data from CoinGecko") + + return embed + # Create bot instance bot = StockBot() @@ -240,6 +293,29 @@ async def get_stock_price(ctx, ticker: str = None): await ctx.send(embed=embed) +@bot.command(name='crypto', aliases=['c']) +async def get_crypto_price(ctx, symbol: str = None): + """ + Query cryptocurrency price. + + Usage: !crypto BTC or !c ETH + """ + if not symbol: + await ctx.send("Please provide a crypto symbol. Usage: `!crypto BTC`") + return + + symbol = symbol.upper() + logger.info(f"Crypto query for {symbol} by {ctx.author}") + + crypto_data = bot.crypto_api.get_crypto_price(symbol) + if not crypto_data: + await ctx.send(f"Unable to retrieve data for crypto: {symbol}") + return + + embed = bot.create_crypto_embed(crypto_data) + await ctx.send(embed=embed) + + @bot.command(name='ping') async def ping(ctx): """Check if bot is responsive.""" diff --git a/crypto_api.py b/crypto_api.py new file mode 100644 index 0000000..7f7356f --- /dev/null +++ b/crypto_api.py @@ -0,0 +1,113 @@ +import requests +import logging +from typing import Optional, Dict, Any + +logger = logging.getLogger(__name__) + + +class CoinGeckoAPI: + """CoinGecko implementation for cryptocurrency price data.""" + + BASE_URL = "https://api.coingecko.com/api/v3" + + # Common crypto symbol to CoinGecko ID mapping + SYMBOL_MAP = { + 'BTC': 'bitcoin', + 'ETH': 'ethereum', + 'USDT': 'tether', + 'BNB': 'binancecoin', + 'SOL': 'solana', + 'XRP': 'ripple', + 'USDC': 'usd-coin', + 'ADA': 'cardano', + 'DOGE': 'dogecoin', + 'TRX': 'tron', + 'DOT': 'polkadot', + 'MATIC': 'matic-network', + 'LTC': 'litecoin', + 'SHIB': 'shiba-inu', + 'AVAX': 'avalanche-2', + 'LINK': 'chainlink', + 'UNI': 'uniswap', + 'XLM': 'stellar', + 'ATOM': 'cosmos', + 'XMR': 'monero' + } + + def get_crypto_price(self, symbol: str) -> Optional[Dict[str, Any]]: + """ + Retrieve cryptocurrency price data from CoinGecko. + + Args: + symbol: Crypto symbol (e.g., BTC, ETH, DOGE) + + Returns: + Dictionary with crypto data or None if unavailable + """ + try: + # Convert symbol to CoinGecko ID + symbol = symbol.upper() + coin_id = self.SYMBOL_MAP.get(symbol, symbol.lower()) + + # Fetch price data + url = f"{self.BASE_URL}/simple/price" + params = { + 'ids': coin_id, + 'vs_currencies': 'usd', + 'include_24hr_change': 'true', + 'include_24hr_vol': 'true', + 'include_last_updated_at': 'true' + } + + response = requests.get(url, params=params, timeout=10) + response.raise_for_status() + data = response.json() + + if coin_id not in data: + logger.warning(f"No data available for crypto: {symbol}") + return None + + crypto_data = data[coin_id] + current_price = crypto_data.get('usd', 0) + change_percent = crypto_data.get('usd_24h_change', 0) + + if current_price == 0: + logger.warning(f"Invalid price data for crypto: {symbol}") + return None + + # Calculate previous price from 24h change + change_decimal = change_percent / 100 + previous_price = current_price / (1 + change_decimal) + change_dollar = current_price - previous_price + + return { + 'symbol': symbol, + 'coin_id': coin_id, + 'current_price': round(current_price, 2), + 'previous_price': round(previous_price, 2), + 'change_dollar': round(change_dollar, 2), + 'change_percent': round(change_percent, 2), + 'volume_24h': crypto_data.get('usd_24h_vol', 0) + } + + except requests.exceptions.RequestException as e: + logger.error(f"Error fetching data for {symbol}: {e}") + return None + except Exception as e: + logger.error(f"Unexpected error fetching data for {symbol}: {e}") + return None + + def is_available(self) -> bool: + """ + Check if CoinGecko API is accessible. + + Returns: + True if accessible, False otherwise + """ + try: + url = f"{self.BASE_URL}/ping" + response = requests.get(url, timeout=5) + return response.status_code == 200 + except Exception as e: + logger.error(f"CoinGecko API unavailable: {e}") + return False