Integrating lemon.markets into your Telegram bot (Part 2 of 2)


blog photo
Joanne SnelSeptember 29, 2021
Insights


Note: If you haven’t read the first article in this two-part series, click here to find out how to set up Telegram and get it connected to the lemon.markets API. 

Hi! I’m Joanne and I’m part of the team at lemon.markets. We’re a Berlin-based start-up powering programmatic trading via APIs. Our goal is to provide the tools to allow developers to design their own brokerage experience at the stock market. There’s about a hundred and one use-cases for our product, one of them being to create your own frontend interface to place trades. You could start from scratch or you could use a preexisting service, like Telegram. In this article, I’ll be extending the project presented in our previous article. We’ll be adding various conversation states that communicate with the lemon.markets endpoints. In addition, I’ll show how to convert user-input into API requests and provide the user with useful data, such as presenting the current price of an instrument before confirming a trade. After reading this article, you will know how to program your Telegram bot such that it can place and activate a trade. 

Title Card for "Integrating lemon.markets into your Telegram botI"

I’m assuming that you’ve already set up your Telegram bot and connected it to lemon.markets. If you haven’t, I urge you to read this article first. If you want to try out our bot before implementing your own, feel free to text @LemonTraderBot directly in Telegram. We’ve set up the bot as an example for you to see how it could look like. That being said, let’s determine where we are and where we’re going:

Current State of Affairs 🔎

At the moment, the bot performs the following actions:

  1. prompt user to select Space,
  2. stores Space ID

Recall that, in the previous article, we set up and used a ConversationHandler to hold conversation states. Now, we’re simply going to add additional states that perform certain API calls based on user input. 

What’s the Plan? 🏁

We want to structure the bot such that the following steps can be fulfilled:

  1. prompt user to select instrument type, 
  2. prompt user to fill in instrument name, 
  3. search lemon.markets database, 
  4. prompt user to select desired instrument from list, 
  5. prompt user to indicate side, i.e. ‘buy’ or ‘sell’, 
  6. prompt user to indicate quantity, 
  7. place order and prompt user to confirm
  8. activate order and send confirmation message. 

We’re going to set up six additional states in this article: TYPE, REPLY, NAME, ISIN, SIDE and QUANTITY. We’ve got a ways to go, so let’s get started right away!

GIF of iPhone screen while using Telegram Bot

A sample conversation you could have with the @LemonTraderBot

Bot Implementation 🤖

If you want to follow along, the GitHub repository can be found here

Selecting Instrument Type: TYPE 🎸

Another nifty feature of the Telegram API (and the python-telegram-bot wrapper) is the ability to create custom keyboards. To increase ease of use, you can opt to allow your user to click buttons with predefined responses, rather than typing out a reply. To select an instrument type, there are only a few options available, therefore this is the perfect scenario in which to use a custom keyboard. Let’s imagine we allow our bot to trade only stocks and ETFs, then:

1reply_keyboard = [['Stock', 'ETF']]

The keyboard is implemented as follows:

1update.message.reply_text(
2    'Authentication successful.\n\n'
3    'What type of instrument do you want to trade?',
4    reply_markup=ReplyKeyboardMarkup(
5        reply_keyboard, one_time_keyboard=True,
6    ),
7)

These changes are made to the get_space() method in the TradingBot.py class. And, to continue the conversation, we’ll need to return the next state (i.e. TradingBot.TYPE) rather than end the conversation (as we did in the first article). 

PS. make sure to initialise TYPE at the beginning of your script! (along with all the other states)

Searching lemon.markets: REPLY, NAME & ISIN 🍋

We’re going to introduce three new methods in TradingBot.py: get_search_query(), get_instrument_name() and get_isin().

1def get_search_query(self, update: Update, context: CallbackContext) -> int:
2    """Prompts user to enter instrument name."""
3    # store user response in dictionary with key 'type'
4    context.user_data['type'] = update.message.text.lower()
5    update.message.reply_text(
6        f'What is the name of the {context.user_data["type"]} you would like to trade?')
7    print(f'user data: {context.user_data}')
8    return TradingBot.REPLY
9def get_instrument_name(self, update: Update, context: CallbackContext) -> int:
10    """Searches for instrument and prompts user to select an instrument."""
11    context.user_data['search_query'] = update.message.text.lower()
12    print(f'user data: {context.user_data}')
13        
14    try:
15        instruments = Instrument().
16            get_titles(context.user_data['search_query'], context.user_data['type'])
17    except Exception as e:
18        print(e)
19        update.message.reply_text(
20            "There was an error, ending the conversation."
21            "If you'd like to try again, send /start.")
22        return ConversationHandler.END
23    titles = list(instruments.keys())
24    reply_keyboard = [titles]
25    update.message.reply_text(
26        f'Please choose the instrument you wish to trade.',
27        reply_markup=ReplyKeyboardMarkup(reply_keyboard, one_time_keyboard=True)
28    )
29    return TradingBot.NAME
30def get_isin(self, update: Update, context: CallbackContext) -> int:
31    """Retrieves ISIN and prompts user to select side (buy/sell)."""
32    text = update.message.text
33    print(f'user data: {context.user_data}')
34    try:
35        instruments = Instrument().
36            get_titles(context.user_data['search_query'], context.user_data['type'])
37    except Exception as e:
38        print(e)
39        update.message.reply_text(
40            "There was an error, ending the conversation."
41            "If you'd like to try again, send /start.")
42        return ConversationHandler.END
43    context.user_data['title'] = text
44    context.user_data['isin'] = instruments.get(text)
45    reply_keyboard = [['Buy', 'Sell']]
46    update.message.reply_text(
47        f'Would you like to buy or sell {context.user_data["title"]}?',
48        reply_markup=ReplyKeyboardMarkup(reply_keyboard, one_time_keyboard=True)
49    )
50    return TradingBot.ISIN

The first function, get_search_query() is pretty self-explanatory: we store the user input (regarding type) in user_data and prompt the user to fill in the stock or ETF name.

The function get_instrument_name() is a little bit more complicated, mostly because we encounter a new class that we haven’t defined yet: Instrument (which corresponds to the lemon.markets Instrument endpoint). The next function, get_isin() uses the same Instrument class, so let’s look into the class first.

1import os
2from helpers import RequestHandler
3class Instrument(RequestHandler):
4    def get_titles(self, search_query: str, instrument_type: str):
5        endpoint = f'instruments/?search={search_query}&type={instrument_type}'
6        response = self.get_data_market(endpoint)
7        results = response['results']
8        instruments: dict = {}
9        if len(results) <= 3:
10            for result in results:
11                instruments[result['title']] = result['isin']
12        else:
13            for result in results[:4]:
14                instruments[result['title']] = result['isin']
15        return instruments
16			
17    def get_price(self, isin: str):
18        mic = os.getenv("MIC")
19        endpoint = f'quotes/?from=latest&mic={mic}&isin={isin}'
20        response = self.get_data_market(endpoint)
21        print(response)
22        bid = response['results'][0]['b']
23        ask = response['results'][0]['a']
24        return bid, ask

As you can see, the Instrument class mimics the Space class — it refers to a particular lemon.markets endpoint and retrieves a response based on user-input. In this case, we have two methods: get_titles() and get_price(). The former retrieves a dictionary of titles (key) & ISINs (value) of the top three search results following a particular query and the latter retrieves a tuple of bid and ask price based on an ISIN.

To revisit the methods in the previous code snippet, get_instrument_name() constructs a reply keyboard of dictionary keys. Then, depending on which button the user clicks, get_isin() retrieves the ISIN (value) that is associated with that title. Note that we place any calls to the API within a try-except block to catch any invalid responses from the API (which might occur if we feed it unexpected information).

Preparing the Order: SIDE, QUANTITY 💁

Now that we’ve gathered which instrument will be traded, we have to determine in which direction (buy/sell) and how many.

1def get_side(self, update: Update, context: CallbackContext) -> int:
2    """Retrieves total balance (buy) or amount of shares owned (sell), most recent price and prompts user to
3    indicate quantity. """
4    context.user_data['side'] = update.message.text.lower()
5    print(f'user data: {context.user_data}')
6    try:
7        [context.user_data['bid'], context.user_data['ask']] = Instrument().\
8            get_price(context.user_data['isin'])         
9        context.user_data['balance'] = Space().\
10            get_balance(context.user_data['space_id'])
11    except Exception as e:
12        print(e)
13        update.message.reply_text(
14            "There was an error, ending the conversation. If you'd like to try again, send /start.")
15        return ConversationHandler.END
16    # if user chooses buy, present ask price, total balance and ask how many to buy
17    if context.user_data['side'] == 'buy':
18        update.message.reply_text(
19            f'This instrument is currently trading for €{context.user_data["ask"]}, your total balance is '
20            f'€{context.user_data["balance"] / 10000:,.2f}. '
21            f'How many shares do you wish to {context.user_data["side"]}?'
22        )
23    # if user chooses sell, retrieve how many shares owned
24    else:
25        positions = Portfolio().get_portfolio(context.user_data['space_id'], )
26        # initialise shares owned to 0
27        context.user_data['shares_owned'] = 0
28        # if instrument in portfolio, update shares owned
29        if context.user_data['isin'] in positions:
30            context.user_data['shares_owned'] = \
31                positions[context.user_data['isin']][context.user_data['space_id']].get('quantity')
32            
33        update.message.reply_text(
34            f'This instrument can be sold for €{round(context.user_data["bid"], 2)}, you currently own '
35            f'{context.user_data["shares_owned"]} share(s). '
36            f'How many shares do you wish to {context.user_data["side"]}?'
37        )
38    return TradingBot.SIDE
39def get_quantity(self, update: Update, context: CallbackContext) -> int:
40    """Processes quantity, places order (if possible) and prompts
41    user to confirm order. """
42    context.user_data['quantity'] = float(update.message.text.lower())
43    print(f'user data: {context.user_data}')
44    reply_keyboard = [['Confirm', 'Cancel']]
45    # determine total cost of buy or sell
46    if context.user_data['side'] == 'buy':
47        context.user_data['total'] = context.user_data['quantity'] * float(context.user_data['ask'])
48    else:
49        context.user_data['total'] = context.user_data['quantity'] * float(context.user_data['bid'])
50    else:
51        try:
52            # place order
53            context.user_data['order_id'] = \
54                Order().place_order(
55                    isin=context.user_data['isin'],
56                    expires_at="p0d",
57                    side=context.user_data['side'],
58                    quantity=context.user_data['quantity'],
59                    space_id=context.user_data['space_id']
60                ).get('results')['id']
61        except Exception as e:
62            print(e)
63            update.message.reply_text(
64                "There was an error, ending the conversation. If you'd like to try again, send /start.")
65            return ConversationHandler.END
66        update.message.reply_text(
67            f'You\'ve indicated that you wish to {context.user_data["side"]} {int(context.user_data["quantity"])} '
68            f'share(s) of {context.user_data["title"]} at a total of €{round(context.user_data["total"], 2)}. '
69            f'Please confirm or cancel your order to continue.',
70            reply_markup=ReplyKeyboardMarkup(
71                reply_keyboard, one_time_keyboard=True,
72            )
73        )
74        return TradingBot.QUANTITY

In the above code snippet, we introduce two new classes: Portfolio and Order. The above code follows the same format that we’ve been implementing thus far, so to avoid being repetitive, let’s dive into what these classes entail instead.

1from helpers import RequestHandler
2class Portfolio(RequestHandler):
3    def get_portfolio(self, space_id: str):
4        endpoint = f'portfolio/?space_id={space_id}'
5        response = self.get_data_trading(endpoint)
6        return response['results']

The Portfolio class retrieves all the current positions (including information such as the average price, see our documentation to see the API output).

1import os
2from helpers import RequestHandler
3class Order(RequestHandler):
4    def place_order(self, isin: str, expires_at: str, quantity: int, side: str, space_id: str):
5        order_details = {
6            "isin": isin,
7            "expires_at": expires_at,
8            "side": side,
9            "quantity": quantity,
10            "venue": os.environ.get("MIC"),
11            "space_id": space_id
12        }
13        endpoint = f'orders/'
14        response = self.post_data(endpoint, order_details)
15        return response
16    def activate_order(self, order_id: str):
17        endpoint = f'orders/{order_id}/activate/'
18        response = self.post_data(endpoint, {})
19        return response
20    def get_order(self, order_id: str):
21        endpoint = f'orders/{order_id}'
22        response = self.get_data_trading(endpoint)
23        return response

The Order class can place, activate and get an order. If you want to see these classes in action, check therepo!

We use all aforementioned classes in the TradingBot class. As you can see in the code snippet at the top of this sub-section, we have the get_side() and get_quantity() functions. Depending on whether the user decides to buy or sell the instrument, the get_side() function has two different paths it can take. Space.get_balance() and Instrument.get_price() are called to obtain the available funds and current price of the chosen instrument. These two pieces of information are displayed in a message to the user when they are prompted to indicate quantity. Eventually, this information can also be used to restrict orders if they fall outside the bounds of their funds (we don’t implement that in this blog-post, but if you check out our GitHub repository, you can find it there).

If the user decides to place a buy order, the ask price is presented and no additional actions are taken. If the user chooses to sell, the Portfolio class is called to retrieve all current positions. Then, the bid price is presented along with the number of currently held positions.

TradingBot.get_quantity() stores the quantity and places the order with an up-to-date access token. The last step is to activate the order.

Activating the Order: CONFIRMATION ✅

At lemon.markets, we want to make sure that you’re aware of the trades that are being placed. Therefore, after you place an order, it also needs to be activated — that’s what’s happening in the confirm_order() function.

1def confirm_order(self, update: Update, context: CallbackContext) -> int:
2        """Activates order (if applicable), displays purchase/sale price and prompts user to indicate whether any
3        additional trades should be made. """
4        context.user_data['order_decision'] = update.message.text
5        reply_keyboard = [['Yes', 'No']]
6        if context.user_data['order_decision'] == 'Cancel':
7            update.message.reply_text(
8                'You\'ve cancelled your order. Would you like to make another trade?',
9                reply_markup=ReplyKeyboardMarkup(
10                    reply_keyboard, one_time_keyboard=True,
11                )
12            )
13        else:
14            try:
15                Order().activate_order(
16                    context.user_data['order_id'],
17                )
18            except Exception as e:
19                print(e)
20                update.message.reply_text(
21                    "There was an error, ending the conversation. If you'd like to try again, send /start.")
22                return ConversationHandler.END
23            # keep checking order status until executed so that execution price can be retrieved
24            update.message.reply_text(
25                'Please wait while we process your order.'
26            )
27            while True:
28                order_summary = Order().get_order(
29                    context.user_data['order_id'],
30                )
31                if order_summary['results'].get('status') == 'executed':
32                    print('executed')
33                    break
34                time.sleep(1)
35            context.user_data['average_price'] = order_summary['results'].get('average_price')
36            update.message.reply_text(
37                f'Your order was executed at €{context.user_data["average_price"]/10000:,.2f} per share.'
38            )
39        print(f'user_data {context.user_data}')
40        return ConversationHandler.END

In addition, the function also keeps retrieving the order information until the status goes from ‘Activated’ to ‘Executed’. Only then can the final purchase or sell price be retrieved. The bot sends a confirmation message and the conversation ends.

The last step is to insert these states and functions into our ConversationHandler. Let’s do that now.

1conv_handler = ConversationHandler(
2        # initiate the conversation
3        entry_points=[CommandHandler('start', TradingBot().start)],
4        # different conversation steps and handlers that should be used if user sends a message
5        # when conversation with them is currently in that state
6        states={
7            TradingBot.SPACE: [MessageHandler(Filters.text & ~Filters.regex('^/'), TradingBot().get_space)],
8            TradingBot.TYPE: [
9                MessageHandler(
10                    Filters.regex('^(Stock|stock|Bond|bond|Fund|fund|ETF|etf|Warrant|warrant)$') & ~Filters.regex('^/'),
11                    TradingBot().get_search_query)],
12            TradingBot.REPLY: [MessageHandler(Filters.text & ~Filters.regex('^/'), TradingBot().get_instrument_name)],
13            TradingBot.NAME: [MessageHandler(Filters.text & ~Filters.regex('^/'), TradingBot().get_isin)],
14            TradingBot.ISIN: [MessageHandler(Filters.text & ~Filters.regex('^/'), TradingBot().get_side)],
15            TradingBot.SIDE: [MessageHandler(Filters.text & ~Filters.regex('^/'), TradingBot().get_quantity)],
16            TradingBot.QUANTITY: [MessageHandler(Filters.text & ~Filters.regex('^/'), TradingBot().confirm_order)],
17        },
18        # if user currently in conversation but state has no handler or handle inappropriate for update
19        fallbacks=[CommandHandler(('cancel', 'end'), TradingBot().cancel)],
20    )

We understand that that was a lot of information and perhaps a bit difficult to follow due to the size of the project. If you’re more of a visual learner, we’ve also created a YouTube tutorial that walks you through the whole process. And, if you’d rather just see the code, check out our GitHub repository, which includes everything we’ve mentioned in this post (along with some other useful features). You can clone the repository and recreate the project on your local device.

Further Improvements

The fun thing about this project is that it can forever be expanded with new features. We’ve implemented some additional features into our bot, such as (but not limited to):

  • when searching for an instrument, allow user to select ‘Other’ if the instrument is not in the list; then, prompt user to fill in their search query again,
  • when indicating a quantity, if user enters a non-integer, send error message (so far, lemon.markets does not support fractional shares),
  • when placing a buy order, send error message if buying more than available balance allows (lemon.markets does not support leveraging),
  • when placing a sell order, send error message if selling more than user owns (lemon.markets does not support going short) and/or
  • differentiate between user_data and chat_data; store all user-specific information in the former, such as API key and space ID and all order-specific information in the latter, then when order is complete, just clear the chat_data and user does not need to log in again to place other trade.

But, besides this, there’s a lot more features you might think to implement, such as:

  • implement command that suggests a stock purchase based on technical signals,
  • allow user to quickly place trade by writing ‘buy 10 Tesla shares’ rather than having to go through the whole conversation and/or
  • implement command that outputs relative and/or absolute performance.

If you end up implementing any of these features, let us know! Or send a pull-request to the GitHub repository, we’d love to see what you built.

We hope this article served as some inspiration to integrate lemon.markets into your own Telegram bot. Remember, the contents of this article can also be found in video format on our YouTube channel (along with several other interesting projects). And, don’t forget to check out the corresponding GitHub repository.

Our community is steadily growing, have you signed up yet? We’d love to see you on board. And tell us what you’re building! Whether that’s through Slack or email.

Joanne 🍋

You might also be interested in

blog photo

Blog 38 - The market maker explained

Market Makers are crucial to provide liquidity to stock exchanges. In this blog post, we talk about what Market Makers do and why they are useful.

blog photo

5 (+1) YouTube channels for FinTech enthusiasts 

YouTube is a great way to learn about new things, including financial education or coding. Therefore, in this article we’d like to introduce you to 5 YouTube channels to level up your trading literacy.

blog photo

A short introduction to derivatives

In this article, we'd like to introduce you derivatives - they come up in finance and leave a lot of people scratching their heads, though it's totally worth it! Curious about hearing what's the difference between investing and trading, the coherence between finance and weather & why the Greeks even appear here? We'll discuss who’s using them, what they are and how they can be valued in the following.

Dive Deeper

Find more resources to get started easily

Check out our documentation to find out more about our API structure, different endpoints and specific use cases.

Engage

Join lemon.markets community

Join our Slack channel to actively participate in our community, ask questions to other users and stay up to date at all times.

Contribute

Interested in building lemon.markets with us?

We are always looking for great additions to our team that help us build a brokerage infrastructure for the 21st century.

Need any help?
Ask a question in our CommunityAsk a question in our CommunityGet started with our DocumentationGet started with our DocumentationGet inspired on our BlogGet inspired on our Blog
© lemon.markets 2021Privacy PolicyImprint
All systems normal

As a tied agent under § 3 Sec. 2 WplG on the account and under the liability of DonauCapital Wertpapier GmbH, Passauer Str. 5, 94161 Ruderting (short: DonauCapital), lemon.markets GmbH offers you the receipt and transmission of orders for clients (§ 2 Sec. 2 Nr. 3 WpIG) of financial instruments according to § 3 Sec. 5 WpIG as well as brokerage of accounts.