Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Performance Impovement for REST Endpoints #60

Open
mmarihart opened this issue Feb 24, 2025 · 8 comments
Open

Performance Impovement for REST Endpoints #60

mmarihart opened this issue Feb 24, 2025 · 8 comments

Comments

@mmarihart
Copy link

Hi, since we often need to use preflight requests I noticed that the REST API functionality can take quite a long time to execute some functions.

What I saw was, that for every request a new TCP connection is opened and closed in

response = requests.request(method, url, verify=self.cacert, headers=headers, timeout=self._timeout, **kwargs)

Using a persistent TCP session, by utilizing requests.Session improves performance massively and does not require a lot of changes.
For the above line the updated version would be:

response = self.session.request(method, url, verify=self.cacert, headers=headers, timeout=self._timeout, **kwargs)

And then we only need the following extra code in the __init__ function at:

def __init__(

self.session = requests.Session()

For some test code I have (fetching futures contract data, market buy with bracket order, adjusting SL price, closing positions and orders) the run time reduced from ~60 seconds to 13 seconds.

I think that should work without causing any problems.

@Voyz
Copy link
Owner

Voyz commented Feb 26, 2025

hey @mmarihart thanks for this superb suggestion 👏

I tried implementing your code, and indeed it is a very easy drop in replacement. Great to see that 👍

However, there is one problem - in my tests not only that there is no improvement, but in some cases it's slower than the original method. I'm using https://github.com/Voyz/ibind/blob/master/examples/rest_05_marketdata_history.py for tests. See below:

Original

# test 1
1 conid took: 0.16s
1 symbol raw took: 0.31s
1 symbol took: 0.32s
5 symbols sync took: 0.79s
5 symbols took: 0.49s
15 symbols took: 2.36s

# test 2
1 conid took: 0.12s
1 symbol raw took: 0.22s
1 symbol took: 0.25s
5 symbols sync took: 0.76s
5 symbols took: 0.66s
15 symbols took: 2.55s

Session:


# test 3
1 conid took: 0.65s
1 symbol raw took: 0.42s
1 symbol took: 0.26s
5 symbols sync took: 1.33s
5 symbols took: 0.40s
15 symbols took: 4.13s

# test 4
1 conid took: 0.13s
1 symbol raw took: 0.73s
1 symbol took: 0.41s
5 symbols sync took: 1.82s
5 symbols took: 0.59s
15 symbols took: 1.67s

The differences in time seem arbitrary, there's no clear improvement.

On top of the changes you proposed, ChatGPT suggested I do the following:

        self.session = requests.Session()
        adapter = HTTPAdapter(pool_connections=10, pool_maxsize=10, pool_block=True)
        self.session.mount("https://", adapter)
        self.session.mount("http://", adapter)

And attach headers:

            {
                "Connection": "close" # or "keep-alive", neither helped
            }

Could I ask you to put together a minimal example script that would show how the change actually introduce improvements?

@mmarihart
Copy link
Author

Hmm, you are getting a lot better speeds than I do on the marketdata history. With the subsecond duration it's not surprising that it does not make a lot of difference.
Wondering, why I am getting speeds that are so slow as I am using a cable connection with 300Mbit/s.
I ran the example script as well just now with the following results:

Without session:

1 conid took: 2.08s
1 symbol raw took: 4.19s
1 symbol took: 4.17s
5 symbols sync took: 12.58s
5 symbols took: 6.36s
15 symbols took: 15.01s

1 conid took: 2.10s
1 symbol raw took: 4.18s
1 symbol took: 4.18s
5 symbols sync took: 12.69s
5 symbols took: 6.32s
15 symbols took: 14.67s

1 conid took: 2.08s
1 symbol raw took: 4.21s
1 symbol took: 4.22s
5 symbols sync took: 12.62s
5 symbols took: 8.45s
15 symbols took: 16.64s

With session:

1 conid took: 2.36s
1 symbol raw took: 0.17s
1 symbol took: 0.12s
5 symbols sync took: 0.87s
5 symbols took: 2.23s
15 symbols took: 4.63s

1 conid took: 2.09s
1 symbol raw took: 0.13s
1 symbol took: 0.31s
5 symbols sync took: 0.38s
5 symbols took: 6.28s
15 symbols took: 0.37s

1 conid took: 2.09s
1 symbol raw took: 0.14s
1 symbol took: 0.14s
5 symbols sync took: 0.40s
5 symbols took: 2.20s
15 symbols took: 4.60s

Here's some other testing code that I am running/using:

import time
from datetime import datetime
from functools import partial
from ibind import IbkrClient, snapshot_keys_to_ids, make_order_request, ibind_logs_initialize, QuestionType

start = time.time()
account = 'CHANGEME!!!!'
symbol = 'MNQ'
c = IbkrClient()
c.initialize_brokerage_session()
c.receive_brokerage_accounts()
result = c.security_future_by_symbol(symbols=symbol)
if result:
    result = result.data[symbol]
else:
    raise Exception("No contracts")
# Find the contract with the largest open interest
# sort them by contract month so we can skip if the OI decreases
if not result:
    raise Exception("No contracts")
result.sort(key=lambda x: x['ltd'])
conids = [str(x['conid']) for x in result]
highest_open_interest = 0
highest_oi_contract = None
field_id = snapshot_keys_to_ids(['futures_open_interest'])
c.live_marketdata_snapshot(conids=conids, fields=field_id)
print("Fetching futures open interest for {}".format(symbol))
response = c.live_marketdata_snapshot(conids=conids, fields=field_id).data
for entry in response:
    try:
        open_interest = entry[field_id[0]]
        if isinstance(open_interest, str):
            if 'K' in open_interest:
                open_interest = float(open_interest.split('K')[0]) * 1000
            else:
                open_interest = float(open_interest)
        if open_interest > highest_open_interest:
            highest_open_interest = open_interest
            highest_oi_contract = entry['conid']
        elif highest_oi_contract:
            # we are going to lower contracts now, so we can stop
            counter = 100
            break
    except KeyError:
        continue
symbol = str(highest_oi_contract)
c.marketdata_unsubscribe(conids)
# Pre-fetch for market data
c.live_marketdata_snapshot(conids=symbol, fields=snapshot_keys_to_ids(['open', 'high', 'low', 'close', 'volume', 'last_price']))
# Create market long entry order with OCO SL and TP
order_tag = f'my_order1-{datetime.now().strftime("%Y%m%d%H%M%S")}'
order_request_partial = partial(make_order_request, conid=symbol, acct_id=account, quantity=1)
parent = order_request_partial(side='BUY', order_type='MARKET', price=0.0, coid=order_tag)
stop_loss = order_request_partial(side='SELL', order_type='STP', price=21100, parent_id=order_tag)
take_profit = order_request_partial(side='SELL', order_type='LMT', price=21400, parent_id=order_tag)

requests = [parent, stop_loss, take_profit]

answers = {
    QuestionType.PRICE_PERCENTAGE_CONSTRAINT: True,
    QuestionType.ORDER_VALUE_LIMIT: True,
    QuestionType.MISSING_MARKET_DATA: True,
    QuestionType.STOP_ORDER_RISKS: True,
    'You are attempting to close a position that has open orders.': True,
}
orders = c.place_order(requests, answers, account).data
# Wait a bit
time.sleep(2)
# Pre-flight for orders
c.live_orders(account_id=account, force=True).data
print(c.live_orders(account_id=account, filters=['Submitted', 'PreSubmitted']).data)
print(c.get(f'/portfolio2/{account}/position/{symbol}').data)
# Update SL
stop_loss['price'] = 21101
for order in orders:
    if order.get('parent_order_id', None):
        # This is the SL order
        result = c.modify_order(order_id=order['order_id'], account_id=account,
                                answers=answers, order_request=stop_loss)
print(c.live_orders(account_id=account, filters=['Submitted', 'PreSubmitted']).data)
position_size = c.get(f'/portfolio2/{account}/position/{symbol}').data[0]['position']
order_tag = f'my_order2-{datetime.now().strftime("%Y%m%d%H%M%S")}'
order_request = make_order_request(
    conid=symbol,
    side='SELL',
    quantity=position_size,
    order_type='MARKET',
    acct_id=account,
    coid=order_tag
)
order_request['isClose'] = True
orders = c.place_order(order_request, answers, account).data

print(c.get(f'/portfolio2/{account}/position/{symbol}').data)
time.sleep(0.05)
print(c.live_orders(account_id=account, filters=['Submitted', 'PreSubmitted']).data)

result = c.post('iserver/marketdata/unsubscribe', params={'conid': str(symbol)})
print("Took {} seconds".format(time.time() - start))

Results I get for this:

Without session:
Took 45.15957427024841 seconds
Took 45.20374298095703 seconds
Took 45.45506429672241 seconds

With session:
Took 9.759843826293945 seconds
Took 10.203526735305786 seconds
Took 10.027248620986938 seconds

@Voyz
Copy link
Owner

Voyz commented Feb 26, 2025

Okay, wow, these are certainly undisputable differences. I'll implement it, thanks for sharing all of that. Are you using OAuth or Gateway/IBeam?

I think I'll add it as an optional functionality, as there seem to be cases where non-session implementation has advantages.


Switching gears for a second, I notice you call

c.get(f'/portfolio2/{account}/position/{symbol}')

This is the second time I see you know of some endpoints that aren't documented. How did you figure out that these are available? Do you have a fuller list of or some method of finding out what's available?

@mmarihart
Copy link
Author

mmarihart commented Feb 26, 2025

Okay, wow, these are certainly undisputable differences. I'll implement it, thanks for sharing all of that. Are you using OAuth or Gateway/IBeam?

I think I'll add it as an optional functionality, as there seem to be cases where non-session implementation has advantages.

I am using the Client Portal Gateway. Default config, like it is described in the IBKR docs.
Any idea why it might be so slow on my end? Really strange. I might be in pacing jail, since I tested a lot, but then I would expect an error message instead.
Do you know if we can select the server? I am based in Europe and if the Client Portal Gateway connects to US that might be the reason.

Switching gears for a second, I notice you call

c.get(f'/portfolio2/{account}/position/{symbol}')

This is the second time I see you know of some endpoints that aren't documented. How did you figure out that these are available? Do you have a fuller list of or some method of finding out what's available?

I just stupidly tried as I wanted to go around the caching stuff :) There is the portfolio2/{account}/positions endpoint, so why not one for specific symbol? ^^

The isClose parameter I found with curiosity (why is this functionality working in the IBKR web application?) and the browser network tab.

@Voyz
Copy link
Owner

Voyz commented Feb 26, 2025

@mmarihart try https://github.com/Voyz/ibind/tree/change-to-requests-session branch or pip install ibind==0.1.12rc1 for the version with sessions and let me know if it works for you.

Any idea why it might be so slow on my end? Really strange. I might be in pacing jail, since I tested a lot, but then I would expect an error message instead.

Yeah, possibly some restriction from IBKR if you overused the service. I think they do mention doing that somewhere in the docs.

Do you know if we can select the server? I am based in Europe and if the Client Portal Gateway connects to US that might be the reason.

Specifying a value in the conf.yaml. If you look through IBeam's issues you should find some info on how to do it. Though I'm not sure if that would help. I'm currently in Europe too for context. Try running this test to see your speeds to US:

https://testmy.net/mirror?testServer=sf

Mine is:
Download: 190.4 Mbps
Upload: 231.1 Mbps

I just stupidly tried as I wanted to go around the caching stuff

Haha awesome, I thought this could have been your method, I would have done the same 😅 Honestly, I'm kinda glad, I thought I'm missing on some huge info repository on IBKR somehow. Thanks for clarifying! If you spot these endpoints, do let me know, we should consider implementing them.

@mmarihart
Copy link
Author

@mmarihart try https://github.com/Voyz/ibind/tree/change-to-requests-session branch or pip install ibind==0.1.12rc1 for the version with sessions and let me know if it works for you.

I can confirm that ibind 0.1.12rc1 is faster and so far working for my use cases with the following results:

ibind==0.1.10
1 conid took: 2.16s
1 symbol raw took: 4.31s
1 symbol took: 4.27s
5 symbols sync took: 13.00s
5 symbols took: 6.36s
15 symbols took: 16.63s


ibind==0.1.12rc1
1 conid took: 2.35s
1 symbol raw took: 0.19s
1 symbol took: 0.14s
5 symbols sync took: 1.13s
5 symbols took: 2.29s
15 symbols took: 8.45s

Any idea why it might be so slow on my end? Really strange. I might be in pacing jail, since I tested a lot, but then I would expect an error message instead.

Yeah, possibly some restriction from IBKR if you overused the service. I think they do mention doing that somewhere in the docs.

Do you know if we can select the server? I am based in Europe and if the Client Portal Gateway connects to US that might be the reason.

Specifying a value in the conf.yaml. If you look through IBeam's issues you should find some info on how to do it. Though I'm not sure if that would help. I'm currently in Europe too for context. Try running this test to see your speeds to US:

https://testmy.net/mirror?testServer=sf

Mine is: Download: 190.4 Mbps Upload: 231.1 Mbps

I debugged why my system was so slow. I am running Windows 10 for those tests as I need Windows for my other trading stuff (-.-). On my linux systems I got good speeds as well with version 0.1.10. So it clearly has to be a Windows problem.

Using wireshark, we can see that for every request made by ibind to the Gateway portal on localhost it first tries to connect via IPv6 (::1 as destination) and gets reset packets as a response:

Image

Afterwards it switches to IPv4 (127.0.0.1 as destination) and the connection is successful. This happens for every connection establishment, making it very slow without sessions.

With this knowledge I changed one line in the example code we used for these tests

    client = IbkrClient(host='127.0.0.1', cacert=cacert, timeout=2)

and got the same fast speeds as you and on my Linux systems even with version 0.1.10.

Therefore, I would propose changing the default value from 'localhost' to '127.0.0.1' at

host: str = 'localhost',

@Voyz
Copy link
Owner

Voyz commented Feb 28, 2025

Wow, superb digging @mmarihart 👏👏

I'll absolutely change that localhost to 127.0.0.1, thanks for the suggestion!

Also glad to hear that the addition works. I'll merge it to the master branch and publish with the next release. Please try it through pip install ibind==0.1.12rc2

ps. no shame in running on Windows, video games and editing software in my case 😉

@mmarihart
Copy link
Author

Both test codes work and run fast with 0.1.12rc2!

Thanks for fixing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants