diff --git a/.gitignore b/.gitignore index be952f7..5e5820c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .vscode/settings.json -__pycache__/functions.cpython-38.pyc +__pycache__ +.devcontainer \ No newline at end of file diff --git a/D_info.py b/D_info.py new file mode 100644 index 0000000..5ccb85e --- /dev/null +++ b/D_info.py @@ -0,0 +1,73 @@ +"""Functions and Info specific to the Telegram Bot +""" + +import re + +import requests as r + + +class D_info: + license = re.sub( + r"\b\n", + " ", + r.get( + "https://gitlab.com/simple-stock-bots/simple-discord-stock-bot/-/raw/master/LICENSE" + ).text, + ) + + help_text = """ +Thanks for using this bot, consider supporting it by [buying me a beer.](https://www.buymeacoffee.com/Anson) + +Keep up with the latest news for the bot in its Telegram Channel: https://t.me/simplestockbotnews + +Full documentation on using and running your own stock bot can be found on the bots [docs.](https://docs.simplestockbot.com) + +The bot detects _"Symbols"_ using either one `$` or two `$$` dollar signs before the symbol. One dollar sign is for a stock market ticker, while two is for a cryptocurrency coin. `/chart $$eth` would return a chart of the past month of data for Ethereum, while `/dividend $psec` returns dividend information for Prospect Capital stock. + +Simply calling a symbol in any message that the bot can see will also return the price. So a message like: `I wonder if $$btc will go to the Moon now that $tsla accepts it as payment` would return the current price for both Bitcoin and Tesla. + +**Commands** + - `/donate [amount in USD]` to donate. đŸŽ—ī¸ + - `/dividend $[symbol]` Dividend information for the symbol. 📅 + - `/intra $[symbol]` Plot of the stocks movement since the last market open. 📈 + - `/chart $[symbol]` Plot of the stocks movement for the past 1 month. 📊 + - `/news $[symbol]` News about the symbol. 📰 + - `/info $[symbol]` General information about the symbol. â„šī¸ + - `/stat $[symbol]` Key statistics about the symbol. đŸ”ĸ + - `/cap $[symbol]` Market Capitalization of symbol. 💰 + - `/trending` Trending Stocks and Cryptos. đŸ’Ŧ + - `/help` Get some help using the bot. 🆘 + + + Market data is provided by [IEX Cloud](https://iexcloud.io) + + If you believe the bot is not behaving properly run `/status` or [get in touch](https://docs.simplestockbot.com/contact). + """ + + donate_text = """ +Simple Stock Bot is run entirely on donations[.](https://www.buymeacoffee.com/Anson) +All donations go directly towards paying for servers, and market data is provided by +[IEX Cloud](https://iexcloud.io/). + +The easiest way to donate is to run the `/donate [amount in USD]` command with US dollars you would like to donate. + +Example: `/donate 2` would donate 2 USD. + +An alternative way to donate is through https://www.buymeacoffee.com/Anson which requires no account and accepts Paypal or Credit card. +If you have any questions see the [website](https://docs.simplestockbot.com) + + """ + + +commands = """ +donate - Donate to the bot đŸŽ—ī¸ +help - Get some help using the bot. 🆘 +info - $[symbol] General information about the symbol. â„šī¸ +news - $[symbol] News about the symbol. 📰 +stat - $[symbol] Key statistics about the symbol. đŸ”ĸ +cap - $[symbol] Market Capitalization of symbol. 💰 +dividend - $[symbol] Dividend info 📅 +trending - Trending Stocks and Cryptos. đŸ’Ŧ +intra - $[symbol] Plot since the last market open. 📈 +chart - $[chart] Plot of the past month. 📊 +""" # Not used by the bot but for updaing commands with BotFather diff --git a/Dockerfile b/Dockerfile index eb36991..2de21c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.8-buster +FROM python:3.9-buster COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt diff --git a/IEX_Symbol.py b/IEX_Symbol.py new file mode 100644 index 0000000..87d16f3 --- /dev/null +++ b/IEX_Symbol.py @@ -0,0 +1,531 @@ +"""Class with functions for running the bot with IEX Cloud. +""" + +import os +from datetime import datetime +from logging import warning +from typing import List, Optional, Tuple + +import pandas as pd +import requests as r +import schedule +from fuzzywuzzy import fuzz + +from Symbol import Stock + + +class IEX_Symbol: + """ + Functions for finding stock market information about symbols. + """ + + SYMBOL_REGEX = "[$]([a-zA-Z]{1,4})" + + searched_symbols = {} + otc_list = [] + charts = {} + + def __init__(self) -> None: + """Creates a Symbol Object + + Parameters + ---------- + IEX_TOKEN : str + IEX API Token + """ + try: + self.IEX_TOKEN = os.environ["IEX"] + except KeyError: + self.IEX_TOKEN = "" + warning( + "Starting without an IEX Token will not allow you to get market data!" + ) + + if self.IEX_TOKEN != "": + self.get_symbol_list() + + schedule.every().day.do(self.get_symbol_list) + schedule.every().day.do(self.clear_charts) + + def clear_charts(self) -> None: + """ + Clears cache of chart data. + Charts are cached so that only 1 API call per 24 hours is needed since the + chart data is expensive and a large download. + """ + self.charts = {} + + def get_symbol_list( + self, return_df=False + ) -> Optional[Tuple[pd.DataFrame, datetime]]: + """Gets list of all symbols supported by IEX + + Parameters + ---------- + return_df : bool, optional + return the dataframe of all stock symbols, by default False + + Returns + ------- + Optional[Tuple[pd.DataFrame, datetime]] + If `return_df` is set to `True` returns a dataframe, otherwise returns `None`. + """ + + reg_symbols = r.get( + f"https://cloud.iexapis.com/stable/ref-data/symbols?token={self.IEX_TOKEN}", + timeout=5, + ).json() + otc_symbols = r.get( + f"https://cloud.iexapis.com/stable/ref-data/otc/symbols?token={self.IEX_TOKEN}", + timeout=5, + ).json() + + reg = pd.DataFrame(data=reg_symbols) + otc = pd.DataFrame(data=otc_symbols) + self.otc_list = set(otc["symbol"].to_list()) + + symbols = pd.concat([reg, otc]) + + symbols["description"] = "$" + symbols["symbol"] + ": " + symbols["name"] + symbols["id"] = symbols["symbol"] + symbols["type_id"] = "$" + symbols["symbol"].str.lower() + + symbols = symbols[["id", "symbol", "name", "description", "type_id"]] + self.symbol_list = symbols + if return_df: + return symbols, datetime.now() + + def status(self) -> str: + """Checks IEX Status dashboard for any current API issues. + + Returns + ------- + str + Human readable text on status of IEX API + """ + resp = r.get( + "https://pjmps0c34hp7.statuspage.io/api/v2/status.json", + timeout=5, + ) + + if resp.status_code == 200: + status = resp.json()["status"] + else: + return "IEX Cloud did not respond. Please check their status page for more information. https://status.iexapis.com" + + if status["indicator"] == "none": + return "IEX Cloud is currently not reporting any issues with its API." + else: + return ( + f"{status['indicator']}: {status['description']}." + + " Please check the status page for more information. https://status.iexapis.com" + ) + + def search_symbols(self, search: str) -> List[Tuple[str, str]]: + """Performs a fuzzy search to find stock symbols closest to a search term. + + Parameters + ---------- + search : str + String used to search, could be a company name or something close to the companies stock ticker. + + Returns + ------- + List[tuple[str, str]] + A list tuples of every stock sorted in order of how well they match. Each tuple contains: (Symbol, Issue Name). + """ + + schedule.run_pending() + search = search.lower() + try: # https://stackoverflow.com/a/3845776/8774114 + return self.searched_symbols[search] + except KeyError: + pass + + symbols = self.symbol_list + symbols["Match"] = symbols.apply( + lambda x: fuzz.ratio(search, f"{x['symbol']}".lower()), + axis=1, + ) + + symbols.sort_values(by="Match", ascending=False, inplace=True) + if symbols["Match"].head().sum() < 300: + symbols["Match"] = symbols.apply( + lambda x: fuzz.partial_ratio(search, x["name"].lower()), + axis=1, + ) + + symbols.sort_values(by="Match", ascending=False, inplace=True) + symbols = symbols.head(10) + symbol_list = list(zip(list(symbols["symbol"]), list(symbols["description"]))) + self.searched_symbols[search] = symbol_list + return symbol_list + + def price_reply(self, symbol: Stock) -> str: + """Returns price movement of Stock for the last market day, or after hours. + + Parameters + ---------- + symbol : Stock + + Returns + ------- + str + Formatted markdown + """ + + IEXurl = f"https://cloud.iexapis.com/stable/stock/{symbol.id}/quote?token={self.IEX_TOKEN}" + + response = r.get( + IEXurl, + timeout=5, + ) + if response.status_code == 200: + IEXData = response.json() + + if symbol.symbol.upper() in self.otc_list: + return f"OTC - {symbol.symbol.upper()}, {IEXData['companyName']} most recent price is: $**{IEXData['latestPrice']}**" + + keys = ( + "extendedChangePercent", + "extendedPrice", + "companyName", + "latestPrice", + "changePercent", + ) + + if set(keys).issubset(IEXData): + + if change := IEXData.get("changePercent", 0): + change = round(change * 100, 2) + else: + change = 0 + + if ( + IEXData.get("isUSMarketOpen", True) + or (IEXData["extendedChangePercent"] is None) + or (IEXData["extendedPrice"] is None) + ): # Check if market is open. + message = f"The current stock price of {IEXData['companyName']} is $**{IEXData['latestPrice']}**" + else: + message = ( + f"{IEXData['companyName']} closed at $**{IEXData['latestPrice']}** with a change of {change}%," + + f" after hours _(15 minutes delayed)_ the stock price is $**{IEXData['extendedPrice']}**" + ) + if change := IEXData.get("extendedChangePercent", 0): + change = round(change * 100, 2) + else: + change = 0 + + # Determine wording of change text + if change > 0: + message += f", the stock is currently **up {change}%**" + elif change < 0: + message += f", the stock is currently **down {change}%**" + else: + message += ", the stock hasn't shown any movement today." + else: + message = ( + f"The symbol: {symbol} encountered and error. This could be due to " + ) + + else: + message = f"The symbol: {symbol} was not found." + + return message + + def dividend_reply(self, symbol: Stock) -> str: + """Returns the most recent, or next dividend date for a stock symbol. + + Parameters + ---------- + symbol : Stock + + Returns + ------- + str + Formatted markdown + """ + if symbol.symbol.upper() in self.otc_list: + return "OTC stocks do not currently support any commands." + + IEXurl = f"https://cloud.iexapis.com/stable/stock/{symbol}/dividends/next?token={self.IEX_TOKEN}" + response = r.get( + IEXurl, + timeout=5, + ) + if response.status_code == 200 and response.json(): + IEXData = response.json()[0] + keys = ( + "amount", + "currency", + "declaredDate", + "exDate", + "frequency", + "paymentDate", + "flag", + ) + + if set(keys).issubset(IEXData): + + if IEXData["currency"] == "USD": + price = f"${IEXData['amount']}" + else: + price = f"{IEXData['amount']} {IEXData['currency']}" + + # Pattern IEX uses for dividend date. + pattern = "%Y-%m-%d" + + declared = datetime.strptime(IEXData["declaredDate"], pattern).strftime( + "%A, %B %w" + ) + ex = datetime.strptime(IEXData["exDate"], pattern).strftime("%A, %B %w") + payment = datetime.strptime(IEXData["paymentDate"], pattern).strftime( + "%A, %B %w" + ) + + daysDelta = ( + datetime.strptime(IEXData["paymentDate"], pattern) - datetime.now() + ).days + + return ( + "The next dividend for " + + f"{self.symbol_list[self.symbol_list['symbol']==symbol.id.upper()]['description'].item()}" # Get full name without api call + + f" is on {payment} which is in {daysDelta} days." + + f" The dividend is for {price} per share." + + f"\n\nThe dividend was declared on {declared} and the ex-dividend date is {ex}" + ) + + return f"${symbol.id.upper()} either doesn't exist or pays no dividend." + + def news_reply(self, symbol: Stock) -> str: + """Gets most recent, english, non-paywalled news + + Parameters + ---------- + symbol : Stock + + Returns + ------- + str + Formatted markdown + """ + if symbol.symbol.upper() in self.otc_list: + return "OTC stocks do not currently support any commands." + + IEXurl = f"https://cloud.iexapis.com/stable/stock/{symbol}/news/last/15?token={self.IEX_TOKEN}" + response = r.get( + IEXurl, + timeout=5, + ) + if response.status_code == 200: + data = response.json() + if data: + line = [] + + for news in data: + if news["lang"] == "en" and not news["hasPaywall"]: + line.append( + f"*{news['source']}*: [{news['headline']}]({news['url']})" + ) + + else: + return f"No news found for: {symbol}\nEither today is boring or the symbol does not exist." + else: + return f"No news found for: {symbol}\nEither today is boring or the symbol does not exist." + + return f"News for **{symbol.id.upper()}**:\n" + "\n".join(line[:5]) + + def info_reply(self, symbol: Stock) -> str: + """Gets description for Stock + + Parameters + ---------- + symbol : Stock + + Returns + ------- + str + Formatted text + """ + if symbol.symbol.upper() in self.otc_list: + return "OTC stocks do not currently support any commands." + + IEXurl = f"https://cloud.iexapis.com/stable/stock/{symbol}/company?token={self.IEX_TOKEN}" + response = r.get( + IEXurl, + timeout=5, + ) + + if response.status_code == 200: + data = response.json() + [data.pop(k) for k in list(data) if data[k] == ""] + + if "description" in data: + return data["description"] + + return f"No information found for: {symbol}\nEither today is boring or the symbol does not exist." + + def stat_reply(self, symbol: Stock) -> str: + """Key statistics on a Stock + + Parameters + ---------- + symbol : Stock + + Returns + ------- + str + Formatted markdown + """ + if symbol.symbol.upper() in self.otc_list: + return "OTC stocks do not currently support any commands." + + IEXurl = f"https://cloud.iexapis.com/stable/stock/{symbol}/stats?token={self.IEX_TOKEN}" + response = r.get( + IEXurl, + timeout=5, + ) + + if response.status_code == 200: + data = response.json() + [data.pop(k) for k in list(data) if data[k] == ""] + + m = "" + if "companyName" in data: + m += f"Company Name: {data['companyName']}\n" + if "marketcap" in data: + m += f"Market Cap: ${data['marketcap']:,}\n" + if "week52high" in data: + m += f"52 Week (high-low): {data['week52high']:,} " + if "week52low" in data: + m += f"- {data['week52low']:,}\n" + if "employees" in data: + m += f"Number of Employees: {data['employees']:,}\n" + if "nextEarningsDate" in data: + m += f"Next Earnings Date: {data['nextEarningsDate']}\n" + if "peRatio" in data: + m += f"Price to Earnings: {data['peRatio']:.3f}\n" + if "beta" in data: + m += f"Beta: {data['beta']:.3f}\n" + return m + else: + return f"No information found for: {symbol}\nEither today is boring or the symbol does not exist." + + def cap_reply(self, stock: Stock) -> str: + """Get the Market Cap of a stock""" + response = r.get( + f"https://cloud.iexapis.com/stable/stock/{stock.id}/stats?token={self.IEX_TOKEN}", + timeout=5, + ) + if response.status_code == 200: + + try: + data = response.json() + + cap = data["marketcap"] + except KeyError: + return f"{stock.id} returned an error." + + message = f"The current market cap of {stock.name} is $**{cap:,.2f}**" + + else: + message = f"The Coin: {stock.name} was not found or returned and error." + + return message + + def intra_reply(self, symbol: Stock) -> pd.DataFrame: + """Returns price data for a symbol since the last market open. + + Parameters + ---------- + symbol : str + Stock symbol. + + Returns + ------- + pd.DataFrame + Returns a timeseries dataframe with high, low, and volume data if its available. Otherwise returns empty pd.DataFrame. + """ + if symbol.symbol.upper() in self.otc_list: + return pd.DataFrame() + + if symbol.id.upper() not in list(self.symbol_list["symbol"]): + return pd.DataFrame() + + IEXurl = f"https://cloud.iexapis.com/stable/stock/{symbol}/intraday-prices?token={self.IEX_TOKEN}" + response = r.get( + IEXurl, + timeout=5, + ) + if response.status_code == 200: + df = pd.DataFrame(response.json()) + df.dropna(inplace=True, subset=["date", "minute", "high", "low", "volume"]) + df["DT"] = pd.to_datetime(df["date"] + "T" + df["minute"]) + df = df.set_index("DT") + return df + + return pd.DataFrame() + + def chart_reply(self, symbol: Stock) -> pd.DataFrame: + """Returns price data for a symbol of the past month up until the previous trading days close. + Also caches multiple requests made in the same day. + + Parameters + ---------- + symbol : str + Stock symbol. + + Returns + ------- + pd.DataFrame + Returns a timeseries dataframe with high, low, and volume data if its available. Otherwise returns empty pd.DataFrame. + """ + schedule.run_pending() + + if symbol.symbol.upper() in self.otc_list: + return pd.DataFrame() + + if symbol.id.upper() not in list(self.symbol_list["symbol"]): + return pd.DataFrame() + + try: # https://stackoverflow.com/a/3845776/8774114 + return self.charts[symbol.id.upper()] + except KeyError: + pass + + response = r.get( + f"https://cloud.iexapis.com/stable/stock/{symbol}/chart/1mm?token={self.IEX_TOKEN}&chartInterval=3&includeToday=false", + timeout=5, + ) + + if response.status_code == 200: + df = pd.DataFrame(response.json()) + df.dropna(inplace=True, subset=["date", "minute", "high", "low", "volume"]) + df["DT"] = pd.to_datetime(df["date"] + "T" + df["minute"]) + df = df.set_index("DT") + self.charts[symbol.id.upper()] = df + return df + + return pd.DataFrame() + + def trending(self) -> list[str]: + """Gets current coins trending on IEX. Only returns when market is open. + + Returns + ------- + list[str] + list of $ID: NAME, CHANGE% + """ + + stocks = r.get( + f"https://cloud.iexapis.com/stable/stock/market/list/mostactive?token={self.IEX_TOKEN}", + timeout=5, + ) + if stocks.status_code == 200: + return [ + f"`${s['symbol']}`: {s['companyName']}, {100*s['changePercent']:.2f}%" + for s in stocks.json() + ] + else: + return ["Trending Stocks Currently Unavailable."] diff --git a/Symbol.py b/Symbol.py new file mode 100644 index 0000000..db04b4a --- /dev/null +++ b/Symbol.py @@ -0,0 +1,58 @@ +import functools + +import requests as r + + +class Symbol: + """ + symbol: What the user calls it. ie tsla or btc + id: What the api expects. ie tsla or bitcoin + name: Human readable. ie Tesla or Bitcoin + """ + + currency = "usd" + pass + + def __init__(self, symbol) -> None: + self.symbol = symbol + self.id = symbol + self.name = symbol + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} instance of {self.id} at {id(self)}>" + + def __str__(self) -> str: + return self.id + + +class Stock(Symbol): + """Stock Market Object. Gets data from IEX Cloud""" + + def __init__(self, symbol: str) -> None: + self.symbol = symbol + self.id = symbol + self.name = "$" + symbol.upper() + + +# Used by Coin to change symbols for ids +coins = r.get("https://api.coingecko.com/api/v3/coins/list").json() + + +class Coin(Symbol): + """Cryptocurrency Object. Gets data from CoinGecko.""" + + @functools.cache + def __init__(self, symbol: str) -> None: + self.symbol = symbol + self.get_data() + + def get_data(self) -> None: + self.id = list(filter(lambda coin: coin["symbol"] == self.symbol, coins))[0][ + "id" + ] + data = r.get("https://api.coingecko.com/api/v3/coins/" + self.id).json() + self.data = data + + self.name = data["name"] + self.description = data["description"] + # self.price = data["market_data"]["current_price"][self.currency] diff --git a/bot.py b/bot.py index b9cc699..0d596da 100644 --- a/bot.py +++ b/bot.py @@ -1,22 +1,29 @@ import datetime +import html import io +import json +import logging import os +import random +import string +import traceback +from logging import critical, debug, error, info, warning +from uuid import uuid4 import mplfinance as mpf + import discord from discord.ext import commands -from functions import Symbol +from symbol_router import Router +from D_info import D_info + DISCORD_TOKEN = os.environ["DISCORD"] -try: - IEX_TOKEN = os.environ["IEX"] -except KeyError: - IEX_TOKEN = "" - print("Starting without an IEX Token will not allow you to get market data!") -s = Symbol(IEX_TOKEN) +s = Router() +d = D_info() client = discord.Client() @@ -24,7 +31,7 @@ client = discord.Client() bot = commands.Bot( command_prefix="/", - description=s.help_text, + description=d.help_text, ) @@ -38,16 +45,12 @@ async def on_ready(): @bot.command() -async def status(ctx): +async def status(ctx: commands): """Debug command for diagnosing if the bot is experiencing any issues.""" message = "" try: message = "Contact MisterBiggs#0465 if you need help.\n" - # IEX Status - message += s.iex_status() + "\n" - - # Message Status - message += s.message_status() + message += s.status("") + "\n" except Exception as ex: message += ( f"*\n\nERROR ENCOUNTERED:*\n{ex}\n\n" @@ -57,59 +60,59 @@ async def status(ctx): @bot.command() -async def license(ctx): +async def license(ctx: commands): """Returns the bots license agreement.""" - await ctx.send(s.license) + await ctx.send(d.license) @bot.command() -async def donate(ctx): +async def donate(ctx: commands): """Details on how to support the development and hosting of the bot.""" - await ctx.send(s.donate_text) + await ctx.send(d.donate_text) @bot.command() -async def stat(ctx, *, sym: str): +async def stat(ctx: commands, *, sym: str): """Get statistics on a list of stock symbols.""" symbols = s.find_symbols(sym) if symbols: - for reply in s.stat_reply(symbols).items(): - await ctx.send(reply[1]) + for reply in s.stat_reply(symbols): + await ctx.send(reply) @bot.command() -async def dividend(ctx, *, sym: str): +async def dividend(ctx: commands, *, sym: str): """Get dividend information on a stock symbol.""" symbols = s.find_symbols(sym) if symbols: - for symbol in symbols: - await ctx.send(s.dividend_reply(symbol)) + for reply in s.dividend_reply(symbols): + await ctx.send(reply) @bot.command() -async def news(ctx, *, sym: str): +async def news(ctx: commands, *, sym: str): """Get recent english news on a stock symbol.""" symbols = s.find_symbols(sym) if symbols: - for reply in s.news_reply(symbols).items(): - await ctx.send(reply[1]) + for reply in s.news_reply(symbols): + await ctx.send(reply) @bot.command() -async def info(ctx, *, sym: str): +async def info(ctx: commands, *, sym: str): """Get information of a stock ticker.""" symbols = s.find_symbols(sym) if symbols: - for reply in s.info_reply(symbols).items(): - await ctx.send(reply[1]) + for reply in s.info_reply(symbols): + await ctx.send(reply[0:1900]) @bot.command() -async def search(ctx, *, query: str): +async def search(ctx: commands, *, query: str): """Search for a stock symbol using either symbol of company name.""" results = s.search_symbols(query) if results: @@ -120,90 +123,110 @@ async def search(ctx, *, query: str): @bot.command() -async def crypto(ctx, symbol: str): +async def crypto(ctx: commands, symbol: str): """Get the price of a cryptocurrency using in USD.""" - reply = s.crypto_reply(symbol) - if reply: - await ctx.send(reply) - else: - await ctx.send("Crypto Symbol could not be found.") + await ctx.send( + "Crypto now has native support. Any crypto can be called using two dollar signs: `$$eth` `$$btc` `$$doge`" + ) @bot.command() -async def intra(ctx, sym: str): +async def intra(ctx: commands, sym: str): """Get a chart for the stocks movement since market open.""" + symbols = s.find_symbols(sym) + + if len(symbols): + symbol = symbols[0] + else: + await ctx.send("No symbols or coins found.") + return + + df = s.intra_reply(symbol) + if df.empty: + await ctx.send("Invalid symbol please see `/help` for usage details.") + return with ctx.channel.typing(): - symbol = s.find_symbols(sym)[0] - - df = s.intra_reply(symbol) - if df.empty: - await ctx.send("Invalid symbol please see `/help` for usage details.") - return - buf = io.BytesIO() mpf.plot( df, type="renko", - title=f"\n${symbol.upper()}", - volume=True, + title=f"\n{symbol.name}", + volume="volume" in df.keys(), style="yahoo", - mav=20, savefig=dict(fname=buf, dpi=400, bbox_inches="tight"), ) + buf.seek(0) - caption = ( - f"\nIntraday chart for ${symbol.upper()} from {df.first_valid_index().strftime('%I:%M')} to" - + f" {df.last_valid_index().strftime('%I:%M')} ET on" - + f" {datetime.date.today().strftime('%d, %b %Y')}" - ) - + # Get price so theres no request lag after the image is sent + price_reply = s.price_reply([symbol])[0] await ctx.send( - content=caption, file=discord.File( buf, - filename=f"{symbol.upper()}:{datetime.date.today().strftime('%d%b%Y')}.png", + filename=f"{symbol.name}:intra{datetime.date.today().strftime('%S%M%d%b%Y')}.png", ), + content=f"\nIntraday chart for {symbol.name} from {df.first_valid_index().strftime('%d %b at %H:%M')} to" + + f" {df.last_valid_index().strftime('%d %b at %H:%M')}", ) - await ctx.send(f"{s.price_reply([symbol])[symbol]}") + await ctx.send(price_reply) @bot.command() -async def chart(ctx, sym: str): - """Get a chart for the stocks movement for the past month.""" +async def chart(ctx: commands, sym: str): + """returns a chart of the past month of data for a symbol""" + + symbols = s.find_symbols(sym) + + if len(symbols): + symbol = symbols[0] + else: + await ctx.send("No symbols or coins found.") + return + + df = s.chart_reply(symbol) + if df.empty: + await ctx.send("Invalid symbol please see `/help` for usage details.") + return with ctx.channel.typing(): - symbol = s.find_symbols(sym)[0] - - df = s.intra_reply(symbol) - if df.empty: - await ctx.send("Invalid symbol please see `/help` for usage details.") - return buf = io.BytesIO() mpf.plot( df, type="candle", - title=f"\n${symbol.upper()}", - volume=True, + title=f"\n{symbol.name}", + volume="volume" in df.keys(), style="yahoo", savefig=dict(fname=buf, dpi=400, bbox_inches="tight"), ) buf.seek(0) - caption = ( - f"\n1 Month chart for ${symbol.upper()} from {df.first_valid_index().strftime('%d, %b %Y')}" - + f" to {df.last_valid_index().strftime('%d, %b %Y')}" - ) - + # Get price so theres no request lag after the image is sent + price_reply = s.price_reply([symbol])[0] await ctx.send( - content=caption, file=discord.File( buf, - filename=f"{symbol.upper()}:{datetime.date.today().strftime('1M%d%b%Y')}.png", + filename=f"{symbol.name}:1M{datetime.date.today().strftime('%d%b%Y')}.png", ), + content=f"\n1 Month chart for {symbol.name} from {df.first_valid_index().strftime('%d, %b %Y')}" + + f" to {df.last_valid_index().strftime('%d, %b %Y')}", ) - await ctx.send(f"{s.price_reply([symbol])[symbol]}") + await ctx.send(price_reply) + + +@bot.command() +async def cap(ctx: commands, sym: str): + symbols = s.find_symbols(sym) + if symbols: + with ctx.channel.typing(): + for reply in s.cap_reply(symbols): + await ctx.send(reply) + + +@bot.command() +async def trending(ctx: commands): + with ctx.channel.typing(): + await ctx.send(s.trending()) @bot.event @@ -211,18 +234,18 @@ async def on_message(message): if message.author.id == bot.user.id: return - - if message.content[0] == "/": - await bot.process_commands(message) - return - - if "$" in message.content: - symbols = s.find_symbols(message.content) - - if symbols: - for reply in s.price_reply(symbols).items(): - await message.channel.send(reply[1]) + if message.content: + if message.content[0] == "/": + await bot.process_commands(message) return + if "$" in message.content: + symbols = s.find_symbols(message.content) + + if symbols: + for reply in s.price_reply(symbols): + await message.channel.send(reply) + return + bot.run(DISCORD_TOKEN) diff --git a/cg_Crypto.py b/cg_Crypto.py new file mode 100644 index 0000000..00f248c --- /dev/null +++ b/cg_Crypto.py @@ -0,0 +1,384 @@ +"""Class with functions for running the bot with IEX Cloud. +""" + +from datetime import datetime +from typing import List, Optional, Tuple + +import pandas as pd +import requests as r +import schedule +from fuzzywuzzy import fuzz +from markdownify import markdownify + +from Symbol import Coin + + +class cg_Crypto: + """ + Functions for finding crypto info + """ + + vs_currency = "usd" # simple/supported_vs_currencies for list of options + + searched_symbols = {} + + def __init__(self) -> None: + """Creates a Symbol Object + + Parameters + ---------- + IEX_TOKEN : str + IEX Token + """ + self.get_symbol_list() + schedule.every().day.do(self.get_symbol_list) + + def symbol_id(self, symbol) -> str: + try: + return self.symbol_list[self.symbol_list["symbol"] == symbol]["id"].values[ + 0 + ] + except KeyError: + return "" + + def get_symbol_list( + self, return_df=False + ) -> Optional[Tuple[pd.DataFrame, datetime]]: + + raw_symbols = r.get( + "https://api.coingecko.com/api/v3/coins/list", + timeout=5, + ).json() + symbols = pd.DataFrame(data=raw_symbols) + + symbols["description"] = ( + "$$" + symbols["symbol"].str.upper() + ": " + symbols["name"] + ) + symbols = symbols[["id", "symbol", "name", "description"]] + symbols["type_id"] = "$$" + symbols["id"] + + self.symbol_list = symbols + if return_df: + return symbols, datetime.now() + + def status(self) -> str: + """Checks CoinGecko /ping endpoint for API issues. + + Returns + ------- + str + Human readable text on status of CoinGecko API + """ + status = r.get( + "https://api.coingecko.com/api/v3/ping", + timeout=5, + ) + + if status.status_code == 200: + return f"CoinGecko API responded that it was OK in {status.elapsed.total_seconds()} Seconds." + else: + return f"CoinGecko API returned an error in {status.elapsed.total_seconds()} Seconds." + + def search_symbols(self, search: str) -> List[Tuple[str, str]]: + """Performs a fuzzy search to find coin symbols closest to a search term. + + Parameters + ---------- + search : str + String used to search, could be a company name or something close to the companies coin ticker. + + Returns + ------- + List[tuple[str, str]] + A list tuples of every coin sorted in order of how well they match. Each tuple contains: (Symbol, Issue Name). + """ + schedule.run_pending() + search = search.lower() + try: # https://stackoverflow.com/a/3845776/8774114 + return self.searched_symbols[search] + except KeyError: + pass + + symbols = self.symbol_list + symbols["Match"] = symbols.apply( + lambda x: fuzz.ratio(search, f"{x['symbol']}".lower()), + axis=1, + ) + + symbols.sort_values(by="Match", ascending=False, inplace=True) + if symbols["Match"].head().sum() < 300: + symbols["Match"] = symbols.apply( + lambda x: fuzz.partial_ratio(search, x["name"].lower()), + axis=1, + ) + + symbols.sort_values(by="Match", ascending=False, inplace=True) + symbols = symbols.head(10) + symbol_list = list(zip(list(symbols["symbol"]), list(symbols["description"]))) + self.searched_symbols[search] = symbol_list + return symbol_list + + def price_reply(self, coin: Coin) -> str: + """Returns current market price or after hours if its available for a given coin symbol. + + Parameters + ---------- + symbols : list + List of coin symbols. + + Returns + ------- + Dict[str, str] + Each symbol passed in is a key with its value being a human readable + markdown formatted string of the symbols price and movement. + """ + + response = r.get( + f"https://api.coingecko.com/api/v3/simple/price?ids={coin.id}&vs_currencies={self.vs_currency}&include_24hr_change=true", + timeout=5, + ) + if response.status_code == 200: + + try: + data = response.json()[coin.id] + + price = data[self.vs_currency] + change = data[self.vs_currency + "_24h_change"] + if change is None: + change = 0 + except KeyError: + return f"{coin.id} returned an error." + + message = f"The current price of {coin.name} is $**{price:,}**" + + # Determine wording of change text + if change > 0: + message += f", the coin is currently **up {change:.3f}%** for today" + elif change < 0: + message += f", the coin is currently **down {change:.3f}%** for today" + else: + message += ", the coin hasn't shown any movement today." + + else: + message = f"The Coin: {coin.name} was not found." + + return message + + def intra_reply(self, symbol: Coin) -> pd.DataFrame: + """Returns price data for a symbol since the last market open. + + Parameters + ---------- + symbol : str + Stock symbol. + + Returns + ------- + pd.DataFrame + Returns a timeseries dataframe with high, low, and volume data if its available. Otherwise returns empty pd.DataFrame. + """ + response = r.get( + f"https://api.coingecko.com/api/v3/coins/{symbol.id}/ohlc?vs_currency=usd&days=1", + timeout=5, + ) + if response.status_code == 200: + df = pd.DataFrame( + response.json(), columns=["Date", "Open", "High", "Low", "Close"] + ).dropna() + df["Date"] = pd.to_datetime(df["Date"], unit="ms") + df = df.set_index("Date") + return df + + return pd.DataFrame() + + def chart_reply(self, symbol: Coin) -> pd.DataFrame: + """Returns price data for a symbol of the past month up until the previous trading days close. + Also caches multiple requests made in the same day. + + Parameters + ---------- + symbol : str + Stock symbol. + + Returns + ------- + pd.DataFrame + Returns a timeseries dataframe with high, low, and volume data if its available. Otherwise returns empty pd.DataFrame. + """ + response = r.get( + f"https://api.coingecko.com/api/v3/coins/{symbol.id}/ohlc?vs_currency=usd&days=30", + timeout=5, + ) + + if response.status_code == 200: + df = pd.DataFrame( + response.json(), columns=["Date", "Open", "High", "Low", "Close"] + ).dropna() + df["Date"] = pd.to_datetime(df["Date"], unit="ms") + df = df.set_index("Date") + return df + + return pd.DataFrame() + + def stat_reply(self, symbol: Coin) -> str: + """Gathers key statistics on coin. Mostly just CoinGecko scores. + + Parameters + ---------- + symbol : Coin + + Returns + ------- + str + Preformatted markdown. + """ + + response = r.get( + f"https://api.coingecko.com/api/v3/coins/{symbol.id}?localization=false", + timeout=5, + ) + if response.status_code == 200: + data = response.json() + + return f""" + [{data['name']}]({data['links']['homepage'][0]}) Statistics: + Market Cap: ${data['market_data']['market_cap'][self.vs_currency]:,} + Market Cap Ranking: {data.get('market_cap_rank',"Not Available")} + CoinGecko Scores: + Overall: {data.get('coingecko_score','Not Available')} + Development: {data.get('developer_score','Not Available')} + Community: {data.get('community_score','Not Available')} + Public Interest: {data.get('public_interest_score','Not Available')} + """ + else: + return f"{symbol.symbol} returned an error." + + def cap_reply(self, coin: Coin) -> str: + """Gets market cap for Coin + + Parameters + ---------- + coin : Coin + + Returns + ------- + str + Preformatted markdown. + """ + response = r.get( + f"https://api.coingecko.com/api/v3/simple/price?ids={coin.id}&vs_currencies={self.vs_currency}&include_market_cap=true", + timeout=5, + ) + if response.status_code == 200: + + try: + data = response.json()[coin.id] + + price = data[self.vs_currency] + cap = data[self.vs_currency + "_market_cap"] + except KeyError: + return f"{coin.id} returned an error." + + if cap == 0: + return f"The market cap for {coin.name} is not available for unknown reasons." + + message = f"The current price of {coin.name} is $**{price:,}** and its market cap is $**{cap:,.2f}** {self.vs_currency.upper()}" + + else: + message = f"The Coin: {coin.name} was not found or returned and error." + + return message + + def info_reply(self, symbol: Coin) -> str: + """Gets coin description + + Parameters + ---------- + symbol : Coin + + Returns + ------- + str + Preformatted markdown. + """ + + response = r.get( + f"https://api.coingecko.com/api/v3/coins/{symbol.id}?localization=false", + timeout=5, + ) + if response.status_code == 200: + data = response.json() + try: + return markdownify(data["description"]["en"]) + except KeyError: + return f"{symbol} does not have a description available." + + return f"No information found for: {symbol}\nEither today is boring or the symbol does not exist." + + def trending(self) -> list[str]: + """Gets current coins trending on coingecko + + Returns + ------- + list[str] + list of $$ID: NAME, CHANGE% + """ + + coins = r.get( + "https://api.coingecko.com/api/v3/search/trending", + timeout=5, + ) + try: + trending = [] + if coins.status_code == 200: + for coin in coins.json()["coins"]: + c = coin["item"] + + sym = c["symbol"].upper() + name = c["name"] + change = r.get( + f"https://api.coingecko.com/api/v3/simple/price?ids={c['id']}&vs_currencies={self.vs_currency}&include_24hr_change=true" + ).json()[c["id"]]["usd_24h_change"] + + msg = f"`$${sym}`: {name}, {change:.2f}%" + + trending.append(msg) + + except Exception as e: + print(e) + trending = ["Trending Coins Currently Unavailable."] + + return trending + + def batch_price(self, coins: list[Coin]) -> list[str]: + """Gets price of a list of coins all in one API call + + Parameters + ---------- + coins : list[Coin] + + Returns + ------- + list[str] + returns preformatted list of strings detailing price movement of each coin passed in. + """ + query = ",".join([c.id for c in coins]) + + prices = r.get( + f"https://api.coingecko.com/api/v3/simple/price?ids={query}&vs_currencies=usd&include_24hr_change=true", + timeout=5, + ).json() + + replies = [] + for coin in coins: + if coin.id in prices: + p = prices[coin.id] + + if p.get("usd_24h_change") is None: + p["usd_24h_change"] = 0 + + replies.append( + f"{coin.name}: ${p.get('usd',0):,} and has moved {p.get('usd_24h_change',0.0):.2f}% in the past 24 hours." + ) + + return replies diff --git a/requirements.txt b/requirements.txt index 0215480..7b7e5d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ -discord.py==1.6 +discord.py==1.7.3 requests==2.25.1 pandas==1.2.1 fuzzywuzzy==0.18.0 python-Levenshtein==0.12.1 schedule==1.0.0 -mplfinance==0.12.7a5 \ No newline at end of file +mplfinance==0.12.7a5 +markdownify==0.6.5 \ No newline at end of file diff --git a/symbol_router.py b/symbol_router.py new file mode 100644 index 0000000..efcecd4 --- /dev/null +++ b/symbol_router.py @@ -0,0 +1,421 @@ +"""Function that routes symbols to the correct API provider. +""" + +import datetime +import random +import re +from logging import critical, debug, error, info, warning + +import pandas as pd +from fuzzywuzzy import fuzz + +from cg_Crypto import cg_Crypto +from IEX_Symbol import IEX_Symbol +from Symbol import Coin, Stock, Symbol + + +class Router: + STOCK_REGEX = "(?:^|[^\\$])\\$([a-zA-Z.]{1,6})" + CRYPTO_REGEX = "[$]{2}([a-zA-Z]{1,20})" + searched_symbols = {} + + def __init__(self): + self.stock = IEX_Symbol() + self.crypto = cg_Crypto() + + def find_symbols(self, text: str) -> list[Symbol]: + """Finds stock tickers starting with a dollar sign, and cryptocurrencies with two dollar signs + in a blob of text and returns them in a list. + + Parameters + ---------- + text : str + Blob of text. + + Returns + ------- + list[str] + List of stock symbols as strings without dollar sign. + """ + symbols = [] + stocks = set(re.findall(self.STOCK_REGEX, text)) + for stock in stocks: + if stock.upper() in self.stock.symbol_list["symbol"].values: + symbols.append(Stock(stock)) + else: + info(f"{stock} is not in list of stocks") + + coins = set(re.findall(self.CRYPTO_REGEX, text)) + for coin in coins: + if coin.lower() in self.crypto.symbol_list["symbol"].values: + symbols.append(Coin(coin.lower())) + else: + info(f"{coin} is not in list of coins") + info(symbols) + return symbols + + def status(self, bot_resp) -> str: + """Checks for any issues with APIs. + + Returns + ------- + str + Human readable text on status of the bot and relevant APIs + """ + + stats = f""" + Bot Status: + {bot_resp} + + Stock Market Data: + {self.stock.status()} + + Cryptocurrency Data: + {self.crypto.status()} + """ + + warning(stats) + + return stats + + def search_symbols(self, search: str) -> list[tuple[str, str]]: + """Performs a fuzzy search to find stock symbols closest to a search term. + + Parameters + ---------- + search : str + String used to search, could be a company name or something close to the companies stock ticker. + + Returns + ------- + list[tuple[str, str]] + A list tuples of every stock sorted in order of how well they match. + Each tuple contains: (Symbol, Issue Name). + """ + + df = pd.concat([self.stock.symbol_list, self.crypto.symbol_list]) + + search = search.lower() + + df["Match"] = df.apply( + lambda x: fuzz.ratio(search, f"{x['symbol']}".lower()), + axis=1, + ) + + df.sort_values(by="Match", ascending=False, inplace=True) + # if df["Match"].head().sum() < 300: + # df["Match"] = df.apply( + # lambda x: fuzz.partial_ratio(search, x["name"].lower()), + # axis=1, + # ) + + # df.sort_values(by="Match", ascending=False, inplace=True) + + symbols = df.head(20) + symbol_list = list(zip(list(symbols["symbol"]), list(symbols["description"]))) + self.searched_symbols[search] = symbol_list + return symbol_list + + def inline_search(self, search: str) -> list[tuple[str, str]]: + """Searches based on the shortest symbol that contains the same string as the search. + Should be very fast compared to a fuzzy search. + + Parameters + ---------- + search : str + String used to match against symbols. + + Returns + ------- + list[tuple[str, str]] + Each tuple contains: (Symbol, Issue Name). + """ + + df = pd.concat([self.stock.symbol_list, self.crypto.symbol_list]) + + search = search.lower() + + df = df[df["type_id"].str.contains(search, regex=False)].sort_values( + by="type_id", key=lambda x: x.str.len() + ) + + symbols = df.head(20) + symbol_list = list(zip(list(symbols["symbol"]), list(symbols["description"]))) + self.searched_symbols[search] = symbol_list + return symbol_list + + def price_reply(self, symbols: list[Symbol]) -> list[str]: + """Returns current market price or after hours if its available for a given stock symbol. + + Parameters + ---------- + symbols : list + List of stock symbols. + + Returns + ------- + Dict[str, str] + Each symbol passed in is a key with its value being a human readable + markdown formatted string of the symbols price and movement. + """ + replies = [] + + for symbol in symbols: + info(symbol) + if isinstance(symbol, Stock): + replies.append(self.stock.price_reply(symbol)) + elif isinstance(symbol, Coin): + replies.append(self.crypto.price_reply(symbol)) + else: + info(f"{symbol} is not a Stock or Coin") + + return replies + + def dividend_reply(self, symbols: list) -> list[str]: + """Returns the most recent, or next dividend date for a stock symbol. + + Parameters + ---------- + symbols : list + List of stock symbols. + + Returns + ------- + Dict[str, str] + Each symbol passed in is a key with its value being a human readable + formatted string of the symbols div dates. + """ + replies = [] + for symbol in symbols: + if isinstance(symbol, Stock): + replies.append(self.stock.dividend_reply(symbol)) + elif isinstance(symbol, Coin): + replies.append("Cryptocurrencies do no have Dividends.") + else: + print(f"{symbol} is not a Stock or Coin") + + return replies + + def news_reply(self, symbols: list) -> list[str]: + """Gets recent english news on stock symbols. + + Parameters + ---------- + symbols : list + List of stock symbols. + + Returns + ------- + Dict[str, str] + Each symbol passed in is a key with its value being a human + readable markdown formatted string of the symbols news. + """ + replies = [] + + for symbol in symbols: + if isinstance(symbol, Stock): + replies.append(self.stock.news_reply(symbol)) + elif isinstance(symbol, Coin): + # replies.append(self.crypto.news_reply(symbol)) + replies.append( + "News is not yet supported for cryptocurrencies. If you have any suggestions for news sources please contatct @MisterBiggs" + ) + else: + print(f"{symbol} is not a Stock or Coin") + + return replies + + def info_reply(self, symbols: list) -> list[str]: + """Gets information on stock symbols. + + Parameters + ---------- + symbols : list[str] + List of stock symbols. + + Returns + ------- + Dict[str, str] + Each symbol passed in is a key with its value being a human readable formatted + string of the symbols information. + """ + replies = [] + + for symbol in symbols: + if isinstance(symbol, Stock): + replies.append(self.stock.info_reply(symbol)) + elif isinstance(symbol, Coin): + replies.append(self.crypto.info_reply(symbol)) + else: + print(f"{symbol} is not a Stock or Coin") + + return replies + + def intra_reply(self, symbol: Symbol) -> pd.DataFrame: + """Returns price data for a symbol since the last market open. + + Parameters + ---------- + symbol : str + Stock symbol. + + Returns + ------- + pd.DataFrame + Returns a timeseries dataframe with high, low, and volume data if its available. + Otherwise returns empty pd.DataFrame. + """ + + if isinstance(symbol, Stock): + return self.stock.intra_reply(symbol) + elif isinstance(symbol, Coin): + return self.crypto.intra_reply(symbol) + else: + print(f"{symbol} is not a Stock or Coin") + return pd.DataFrame() + + def chart_reply(self, symbol: Symbol) -> pd.DataFrame: + """Returns price data for a symbol of the past month up until the previous trading days close. + Also caches multiple requests made in the same day. + + Parameters + ---------- + symbol : str + Stock symbol. + + Returns + ------- + pd.DataFrame + Returns a timeseries dataframe with high, low, and volume data if its available. + Otherwise returns empty pd.DataFrame. + """ + if isinstance(symbol, Stock): + return self.stock.chart_reply(symbol) + elif isinstance(symbol, Coin): + return self.crypto.chart_reply(symbol) + else: + print(f"{symbol} is not a Stock or Coin") + return pd.DataFrame() + + def stat_reply(self, symbols: list[Symbol]) -> list[str]: + """Gets key statistics for each symbol in the list + + Parameters + ---------- + symbols : list[str] + List of stock symbols + + Returns + ------- + Dict[str, str] + Each symbol passed in is a key with its value being a human readable + formatted string of the symbols statistics. + """ + replies = [] + + for symbol in symbols: + if isinstance(symbol, Stock): + replies.append(self.stock.stat_reply(symbol)) + elif isinstance(symbol, Coin): + replies.append(self.crypto.stat_reply(symbol)) + else: + print(f"{symbol} is not a Stock or Coin") + + return replies + + def cap_reply(self, symbols: list[Symbol]) -> list[str]: + """Gets market cap for each symbol in the list + + Parameters + ---------- + symbols : list[str] + List of stock symbols + + Returns + ------- + Dict[str, str] + Each symbol passed in is a key with its value being a human readable + formatted string of the symbols market cap. + """ + replies = [] + + for symbol in symbols: + if isinstance(symbol, Stock): + replies.append(self.stock.cap_reply(symbol)) + elif isinstance(symbol, Coin): + replies.append(self.crypto.cap_reply(symbol)) + else: + print(f"{symbol} is not a Stock or Coin") + + return replies + + def trending(self) -> str: + """Checks APIs for trending symbols. + + Returns + ------- + list[str] + List of preformatted strings to be sent to user. + """ + + stocks = self.stock.trending() + coins = self.crypto.trending() + + reply = "Trending Stocks:\n" + reply += "-" * len("Trending Stocks:") + "\n" + for stock in stocks: + reply += stock + "\n" + + reply += "\n\nTrending Crypto:\n" + reply += "-" * len("Trending Crypto:") + "\n" + for coin in coins: + reply += coin + "\n" + + return reply + + def random_pick(self) -> str: + + choice = random.choice( + list(self.stock.symbol_list["description"]) + + list(self.crypto.symbol_list["description"]) + ) + hold = ( + datetime.date.today() + datetime.timedelta(random.randint(1, 365)) + ).strftime("%b %d, %Y") + + return f"{choice}\nBuy and hold until: {hold}" + + def batch_price_reply(self, symbols: list[Symbol]) -> list[str]: + """Returns current market price or after hours if its available for a given stock symbol. + + Parameters + ---------- + symbols : list + List of stock symbols. + + Returns + ------- + Dict[str, str] + Each symbol passed in is a key with its value being a human readable + markdown formatted string of the symbols price and movement. + """ + replies = [] + stocks = [] + coins = [] + + for symbol in symbols: + if isinstance(symbol, Stock): + stocks.append(symbol) + elif isinstance(symbol, Coin): + coins.append(symbol) + else: + print(f"{symbol} is not a Stock or Coin") + + if stocks: + # IEX batch endpoint doesnt seem to be working right now + for stock in stocks: + replies.append(self.stock.price_reply(stock)) + if coins: + replies = replies + self.crypto.batch_price(coins) + + return replies