Creating a Discord Stock Data Bot With the AllyInvest.py Library

In a previous blog post, I detailed the steps I took to create an API wrapper around the Ally Invest API. This API has a lot of functionality to handle account data but also provides calls to snapshot or stream stock data. In another post, I document the steps I used to create a Discord bot in JavaScipt. Fortunately, the process to create a Discord bot using Python and the Discord.py package is very similar.

In this post, I am going to combine these two ideas to create a StockBot for Discord. The bot will be used to quote real-time stock data for a provided list of stock tickers. The functionality can easily be extended given the amount of data provided by the Ally Invest API and the structure of the results returned by the AllyInvest.py library.

Dependencies

The bot depends on the asyncio, AllyInvest.py and Discord.py libraries as well as the native Python3 json library. As mentioned above, AllyInvest.py is a library I created to wrap the Ally Invest API. The library doesn’t contain all of the functionality the API offers but has enough functionality to complete this project and projects like this. Therefore, I decided to upload it to the PyPi repository so it can be installed with the pip package manager.

To install the dependencies using pip, execute the following command (or whatever variation is required in your particular case):

pip3 install discord asyncio AllyInvestPy

StockBot

There are three files associated with the bot. Two of them just store keys that allow access to the Discord and Ally Invest APIs. These files could easily be combined if you so choose.

auth.json

{
"token": "YOUR KEY"
}

Here, YOUR KEY is retrieved from Discord when creating a bot for a Discord App. This is documented in my previous post on creating Discord bots.

allykeys.json

{
    "consumerkey": "YOUR CONSUMER KEY",
    "oauthsecret": "YOUR OAUTH SECRET",
    "oauthtoken": "YOUR OAUTH TOKEN"
}

Again, the keys in this file are retrieved from Ally when setting up an Ally Invest API project. These keys are private and should only be seen by the project owner, thusly I will not display mine here. This is also why I keep them in a separate file.

stockbot.py

The following imports are used by the bot.

from ally.ally import AllyAPI
from ally.requests import QuotesRequest
from ally.responses import QuotesResponse
import discord
import json
import asyncio

From the AllyInvestAPI only three objects are needed:

  1. AllyAPI – this class allows access to the Ally Invest API. Due to the structure of AllyInvest.py, the API object only needs to be passed into the Request objects. The request objects then use the API object to make the API calls, after which the request objects format the raw response data into more convenient objects used by the developer.
  2. QuotesRequest – this request object takes a list of ticker symbols and returns data for the ticker. The data returned from the API call is extensive and can be seen in the API documentation along with descriptions of the data. Another listing, without descriptions, can be seen in the quote.py class of the AllyInvest.py library.
  3. QuotesResponse – this response object is returned by the QuotesRequest object once the API call is executed. The role of response objects in the AllyInvest.py library is to parse the raw XML or JSON data and return objects corresponding to the API’s response. In this particular case, the response object creates a list of quote objects containing all of the ticker data returned by Ally.

Now we can create the CustomClient class that will handle receiving messages from Discord chats.

class CustomClient(discord.Client):
    def __init__(self):
        super(CustomClient, self).__init__()
        with open("allykeys.json") as jfile:
            data = json.load(jfile)
            self.oauthsecret = data["oauthsecret"]
            self.oauthtoken = data["oauthtoken"]
            self.consumerkey = data["consumerkey"]
        self.api = AllyAPI(self.oauthsecret, self.oauthtoken, self.consumerkey, response_format="json")

    def exit(self):
        exit(0)

    async def on_message(self, message):
        guildId = message.guild.id
        channelId = message.channel.id
        msg = message.content
        if msg[0] == "!":
            msg = msg[1:]
            params = []
            if " " in msg:
                parts = msg.split()
                cmd = parts[0]
                params = parts[1:]
            else:
                cmd = msg
            await self.process_command(cmd, params, guildId, channelId)

    async def process_command(self, command, params, guildId, channelId):
        if command == 'quote':   ## assume we are trying to get a price quote
            symbols = params
            request = QuotesRequest(symbols=symbols)
            quotes = request.execute(self.api).get_quotes()
            msg = ""
            for quote in quotes:
                # TOOD: deal with bad tickers
                msg += ("**Ticker: " + quote.symbol + "**" +
                        "\n\t*Name*:\t" + quote.name +
                        "\n\t*Bid/Ask*:\t" + quote.bid + "/" + quote.ask +
                        "\n\t*EPS*:\t" + quote.eps +
                        "\n\t*P/E*:\t" + quote.pe +
                        "\n\t*Div ($)*:\t" + quote.div +
                        "\n\t*Div (%)*:\t" + quote.yld + "%" +
                        "\n\t*52-Week Range*:\t" + quote.wk52lo + " - " + quote.wk52hi +
                        "\n\t*Beta*:\t" + quote.beta +
                        "\n")
            await self.send_channel_message(guildId, channelId, msg)

    async def send_channel_message(self, guildId, channelId, message):
        msg_guild = None
        for guild in self.guilds:
            if guild.id == guildId:
                msg_guild = guild
                break

        if msg_guild:
            msg_channel = None
            for channel in msg_guild.channels:
                if channel.id == channelId:
                    msg_channel = channel
                    break
            if msg_channel:
                await msg_channel.send(message)

In the class’ constructor

def __init__(self):
    super(CustomClient, self).__init__()
    with open("allykeys.json") as jfile:
        data = json.load(jfile)
        self.oauthsecret = data["oauthsecret"]
        self.oauthtoken = data["oauthtoken"]
        self.consumerkey = data["consumerkey"]
    self.api = AllyAPI(self.oauthsecret, self.oauthtoken, self.consumerkey, response_format="json")

The Ally Invest API keys are read from the allykeys.json file and used to create the AllyAPI object.

The class uses a helper function to send messages to a specified Discord guild (server) and channel:

async def send_channel_message(self, guildId, channelId, message):
    msg_guild = None
    for guild in self.guilds:
        if guild.id == guildId:
            msg_guild = guild
            break

    if msg_guild:
        msg_channel = None
        for channel in msg_guild.channels:
            if channel.id == channelId:
                msg_channel = channel
                break
        if msg_channel:
            await msg_channel.send(message)

Here the logic checks if the guild and channel exist and sends the message if so.

The on_message(…) function is a callback defined in the Discord.py API’s Client class (which our custom class extends). In our case, the message that is sent by members of the Discord guild is processed only if they begin with a ‘!’.

async def on_message(self, message):
    guildId = message.guild.id
    channelId = message.channel.id
    msg = message.content
    if msg[0] == "!":
        msg = msg[1:]
        params = []
        if " " in msg:
            parts = msg.split()
            cmd = parts[0]
            params = parts[1:]
        else:
            cmd = msg
        await self.process_command(cmd, params, guildId, channelId)

If the message begins with an exclamation point (bang), the message is split into its component parts separated by a space. The first component is the command and the following components are the parameters to the command. The command and parameters are passed to another function which is used to process the command/parameter pair and perform an action accordingly.

async def process_command(self, command, params, guildId, channelId):
    if command == 'quote':
        symbols = params
        request = QuotesRequest(symbols=symbols)
        quotes = request.execute(self.api).get_quotes()
        msg = ""
        for quote in quotes:
            # TOOD: deal with bad tickers
            msg += ("**Ticker: " + quote.symbol + "**" +
                    "\n\t*Name*:\t" + quote.name +
                    "\n\t*Bid/Ask*:\t" + quote.bid + "/" + quote.ask +
                    "\n\t*EPS*:\t" + quote.eps +
                    "\n\t*P/E*:\t" + quote.pe +
                    "\n\t*Div ($)*:\t" + quote.div +
                    "\n\t*Div (%)*:\t" + quote.yld + "%" +
                    "\n\t*52-Week Range*:\t" + quote.wk52lo + " - " + quote.wk52hi +
                    "\n\t*Beta*:\t" + quote.beta +
                    "\n")
        await self.send_channel_message(guildId, channelId, msg)

So far, only one command is implemented by the bot: return stock data for a list of ticker symbols. As seen here, this is fairly simple (two lines of code) because of the AllyInvest.py library.

request = QuotesRequest(symbols=symbols)
quotes = request.execute(self.api).get_quotes()

These two lines of code create the API request, execute it, and return a list of quote objects to be processed by our bot. The bot then uses the quote data to create a message string that is displayed to the user.

Outside of the CustomClient class a few lines of code must be executed for the bot to log in to Discord and start processing commands. In this chunk of logic, the Discord key is read from our auth.json file and the bot is started.

token = ""
with open("auth.json") as jfile:
    data = json.load(jfile)
    token = data['token']

client = CustomClient()
client.run(token)

With this setup, a user is able to enter a quote command followed by a list of tickers and receive up-to-date stock information. For example, the command

!quote aapl msft tsla

will return stock data on the companies Apple, Microsoft, and Tesla.

Conclusion and Future Additions

As shown here, a simple stock data bot can easily be created using Python programming language and few convenient libraries (one created by yours truly). In under 100 lines of code (full code below), a Discord bot was set up to accept a list of ticker symbols and return up-to-date information on the companies behind the tickers. Due to the structure of the code, this can easily be extended by simply adding more if/else statements to the process_command(…) function.

For example, some future functionality I’m considering is allowing users to create and update their own stock portfolios. In this case, the bot will interface with a database (MongoDb or MySQL) and accept commands like !portfolio create <name> or !portfolio add <name> <ticker> <shares> <price> so that members of Discord server can share and track their portfolio ideas.

Full Code

from ally.ally import AllyAPI
from ally.requests import QuotesRequest
from ally.responses import QuotesResponse
import discord
import json
import asyncio

class CustomClient(discord.Client):
    def __init__(self):
        super(CustomClient, self).__init__()
        with open("allykeys.json") as jfile:
            data = json.load(jfile)
            self.oauthsecret = data["oauthsecret"]
            self.oauthtoken = data["oauthtoken"]
            self.consumerkey = data["consumerkey"]
        self.api = AllyAPI(self.oauthsecret, self.oauthtoken, self.consumerkey, response_format="json")

    def exit(self):
        exit(0)

    async def on_message(self, message):
        guildId = message.guild.id
        channelId = message.channel.id
        msg = message.content
        if msg[0] == "!":
            msg = msg[1:]
            params = []
            if " " in msg:
                parts = msg.split()
                cmd = parts[0]
                params = parts[1:]
            else:
                cmd = msg
            await self.process_command(cmd, params, guildId, channelId)

    async def process_command(self, command, params, guildId, channelId):
        if command == 'quote':   ## assume we are trying to get a price quote
            symbols = params
            request = QuotesRequest(symbols=symbols)
            quotes = request.execute(self.api).get_quotes()
            msg = ""
            for quote in quotes:
                # TOOD: deal with bad tickers
                msg += ("**Ticker: " + quote.symbol + "**" +
                        "\n\t*Name*:\t" + quote.name +
                        "\n\t*Bid/Ask*:\t" + quote.bid + "/" + quote.ask +
                        "\n\t*EPS*:\t" + quote.eps +
                        "\n\t*P/E*:\t" + quote.pe +
                        "\n\t*Div ($)*:\t" + quote.div +
                        "\n\t*Div (%)*:\t" + quote.yld + "%" +
                        "\n\t*52-Week Range*:\t" + quote.wk52lo + " - " + quote.wk52hi +
                        "\n\t*Beta*:\t" + quote.beta +
                        "\n")
            await self.send_channel_message(guildId, channelId, msg)

    async def send_channel_message(self, guildId, channelId, message):
        msg_guild = None
        for guild in self.guilds:
            if guild.id == guildId:
                msg_guild = guild
                break

        if msg_guild:
            msg_channel = None
            for channel in msg_guild.channels:
                if channel.id == channelId:
                    msg_channel = channel
                    break
            if msg_channel:
                await msg_channel.send(message)

token = ""
with open("auth.json") as jfile:
    data = json.load(jfile)
    token = data['token']

client = CustomClient()
client.run(token)

Leave a Reply

Your email address will not be published. Required fields are marked *