Using the OMS: quantpylib.hft.oms + hedge orders
We released the docs (discount is over):
Talked about order-tracking:
We have additionally created the tick-data feeds to go along with the OMS:
in the future we will demonstrate how to create a simple market maker from this. Quantpylib is our community Github repo for annual subscribers:
For this post - we are going to walk through the new OMS module.
Examples can be found here:
https://hangukquant.github.io/hft/hft/#oms
We will demonstrate the walkthrough:
It is easy to create a manager class - it is similar to the quantpylib.hft.feed.Feed
objects. Simply - create a gateway object with the correct keys and pass them in.
import os
import asyncio
from pprint import pprint
from dotenv import load_dotenv
load_dotenv()
from quantpylib.hft.oms import OMS
from quantpylib.gateway.master import Gateway
import quantpylib.standards.markets as markets
config_keys = {
'binance': {
'key': '1234',
'secret': '1234',
},
'hyperliquid': {
'key': '1234',
'secret': '1234',
}
}
gateway = Gateway(config_keys)
async def main():
await gateway.init_clients()
oms = OMS(gateway)
await oms.init()
#code goes here...
if __name__ == "__main__":
asyncio.run(main())
For all of our socket-based message handlers, we will use a generic printer to showcase results:
async def printer(data):
if isinstance(data,dict) or isinstance(data,list):
pprint(data)
else:
try: pprint(data.as_dict())
except: pprint(data.as_list())
Now, let us take a look at the functionalities. We can get trading specifications for the contracts on initiated exchanges:
pprint(oms.contract_specs(exc='hyperliquid', ticker='BTC'))
>>
{'base_asset': 'BTC',
'min_notional': Decimal('10.0'),
'price_precision': 1,
'quantity_precision': 5,
'quote_asset': 'USDT'}
Some useful statistics and utilities:
We would like to get some positions data. Note that when oms.init()
is called, all orders and positions are automatically mirrored using underlying exchange socket subscriptions. We can make connection-less request by retrieving local state:
pprint(oms.get_position(exc='hyperliquid', ticker='SOL')) #Decimal('1.0') (no requests made)
or we can force the OMS to make a HTTP request:
pprint(await oms.positions_get(exc='hyperliquid')) #HTTP requests made
pprint(await oms.positions_get_all())
>>>
{'SOL': {'amount': Decimal('1.0'),
'entry': Decimal('134.81'),
'ticker': 'SOL',
'unrealized_pnl': -0.3,
'value': Decimal('134.51')}}
{'binance': {'QUANTUSDT': {'amount': Decimal('826'),
'entry': Decimal('0.1250522412206'),
'ticker': 'QUANTUSDT',
'unrealized_pnl': 1.03231002,
'value': Decimal('104.32546026')}},
'hyperliquid': {'SOL': {'amount': Decimal('1.0'),
'entry': Decimal('134.81'),
'ticker': 'SOL',
'unrealized_pnl': -0.3,
'value': Decimal('134.51')}}}
respectively. Only one of each schema level is shown - obviously if more positions were held, more entries would be seen.
If we want to get the live positions object that tracks all positions, or register a handler on position change, we may do so. In particular, we can register handler on_update
which passes the entire positions page (and/or) on_delta
which passes the change in positions. Furthermore, the return value is the Positions
object which is 'alive', so to speak, and keeps up to date with filled orders.
We may do the same with orders:
pprint(await oms.orders_get(exc='hyperliquid')) #HTTP
pprint(await oms.orders_get_all())
>>>
{1234: {
'amount': Decimal('1.0'),
'cloid': '',
'filled_sz': Decimal('0.0'),
'oid': '1234',
'ord_status': 'NEW',
'price': Decimal('100.0'),
'ticker': 'SOL',
'timestamp': 1726113126684
}
}
{'hyperliquid': {1234: {'amount': Decimal('1.0'),
'cloid': '',
'filled_sz': Decimal('0.0'),
'oid': '1234',
'ord_status': 'NEW',
'price': Decimal('100.0'),
'ticker': 'SOL',
'timestamp': 1726113126684}}}
'binance': .... {}
Or...register handlers for orders page snapshot (and/or) just the changes. This also returns us a live Orders
object:
Now that we have registered some handlers for orders, and what not - let us see what the messages look like. We can make a limit order through the OMS - the parameters are the same as in Gateway
usage:
Recall that our handlers are registered for hyperliquid
, so let's see what gets printed: The on_delta
handler receives two messages:
{'amount': Decimal('1'),
'cloid': '0x272e45bf06706c3259f41079a1d48d2a',
'exc': 'hyperliquid',
'filled_sz': Decimal('0'),
'last_fill_sz': Decimal('0'),
'oid': None,
'ord_status': 'PENDING',
'ord_type': None,
'price': Decimal('129.56'),
'price_match': None,
'reduce_only': None,
'sl': None,
'ticker': 'SOL',
'tif': None,
'timestamp': 1726154778980,
'tp': None}
{'amount': Decimal('1.0'),
'cloid': '0x272e45bf06706c3259f41079a1d48d2a',
'exc': 'hyperliquid',
'filled_sz': Decimal('0.0'),
'last_fill_sz': Decimal('0.0'),
'oid': '1234',
'ord_status': 'NEW',
'ord_type': None,
'price': Decimal('129.56'),
'price_match': None,
'reduce_only': None,
'sl': None,
'ticker': 'SOL',
'tif': None,
'timestamp': 1726154781307,
'tp': None}
The first is order-creation: a request has been sent to the exchange, but not yet acknowledged. Only the local trading agent is aware, but the order is possibly unsuccessful; hence PENDING
status. This is followed by a NEW
order which means the order was acknowledged successful by the exchange. The on_update
handler sends this order, along with all the other open orders - which we print as a list:
[{'amount': Decimal('1.0'),
...
'timestamp': 1726123317580,
'tp': None},
{'amount': Decimal('1.0'),
'cloid': '0x272e45bf06706c3259f41079a1d48d2a',
'exc': 'hyperliquid',
'filled_sz': Decimal('0.0'),
'last_fill_sz': Decimal('0.0'),
'oid': '1234',
'ord_status': 'NEW',
'ord_type': None,
'price': Decimal('129.56'),
'price_match': None,
'reduce_only': None,
'sl': None,
'ticker': 'SOL',
'tif': None,
'timestamp': 1726154781307,
'tp': None}]
Next we submit an order cancel:
await oms.cancel_order(exc='hyperliquid',ticker='SOL',cloid=cloid) #or use oid
and this is acknowledged on_delta
:
{'amount': Decimal('1.0'),
'cloid': '0x272e45bf06706c3259f41079a1d48d2a',
'exc': 'hyperliquid',
'filled_sz': Decimal('0.0'),
'last_fill_sz': Decimal('0.0'),
'oid': '1234',
'ord_status': 'CANCELLED',
'ord_type': None,
'price': Decimal('129.56'),
'price_match': None,
'reduce_only': None,
'sl': None,
'ticker': 'SOL',
'tif': None,
'timestamp': 1726154781307,
'tp': None}
and the on_update
prints the new list (not shown), this time without the cancelled order - since it is not on the orders page anymore (it is no longer open).
We can of course, do a market-order:
await oms.market_order(exc='hyperliquid',ticker='SOL',amount=-1)
Which also gives a on_delta
, PENDING
message:
{'amount': Decimal('-1'),
'cloid': '0xa70c993f525b2e8106ffd60fd19af35e',
'exc': 'hyperliquid',
'filled_sz': Decimal('0'),
'last_fill_sz': Decimal('0'),
'oid': None,
'ord_status': 'PENDING',
'ord_type': None,
'price': None,
'price_match': None,
'reduce_only': None,
'sl': None,
'ticker': 'SOL',
'tif': None,
'timestamp': 1726154778980,
'tp': None}
This is filled immediately in two blocks, and the positions
's on_delta
message is called with each fill
{'amount': Decimal('0.19'),
'delta': Decimal('-0.81'),
'entry': Decimal('134.81'),
'ticker': 'SOL'}
{'amount': Decimal('0.00'),
'delta': Decimal('-0.19'),
'entry': Decimal('134.79'),
'ticker': 'SOL'}
where amount
is the new signed position held after delta
is filled - at the end of this market order our SOL
position is closed fully, so our positions
's on_update
receives the positions page (no open positions):
{}
on the other hand the PENDING
order created is acknowledged by exchange to NEW
and then immediately FILLED
on creation with on_delta
triggers:
{'amount': Decimal('-1.0'),
'cloid': '0xa70c993f525b2e8106ffd60fd19af35e',
'exc': 'hyperliquid',
'filled_sz': Decimal('0.0'),
'last_fill_sz': Decimal('0.0'),
'oid': 'yomama',
'ord_status': 'NEW',
'ord_type': None,
'price': Decimal('127.69'), #hyperliquid's market order is an aggressive limit order
'price_match': None,
'reduce_only': None,
'sl': None,
'ticker': 'SOL',
'tif': None,
'timestamp': 1726154797325,
'tp': None}
{'amount': Decimal('-1.0'),
'cloid': '0xa70c993f525b2e8106ffd60fd19af35e',
'exc': 'hyperliquid',
'filled_sz': Decimal('1.0'),
'last_fill_sz': Decimal('1.0'),
'oid': 'yomama',
'ord_status': 'FILLED',
'ord_type': None,
'price': Decimal('127.69'),
'price_match': None,
'reduce_only': None,
'sl': None,
'ticker': 'SOL',
'tif': None,
'timestamp': 1726154797325,
'tp': None}
and on_update
triggers:
[{'amount': Decimal('1.0'),
...
'timestamp': 1726123317580,
'tp': None},]
We will demonstrate a complex order supported by the OMS - let's call it hedge_order
. It is quite often that we want one order to trigger another in a multi-leg trade. For instance, a triangular arbitrage, cross-exchange market making, funding arbitrage and l/s arbitrage all often use similar fixtures. A hedge order allows us to submit a maker-order, and the matching taker order is triggered with size matching that of the filled amount on the maker-leg. When lot size rounding doesn't allow for complete hedging, the remaining balance is stored and flushed with the next best available order. Let's see how we can make use of this. To get information from both exchanges, we wil add the binance handlers:
#hedge order
await oms.hedge_order(
maker_order = {
"exc": "binance",
"ticker": "SOLUSDT",
"amount": -3,
"price_match": markets.PRICE_MATCH_QUEUE_5
},
hedge_order = {
"exc": "hyperliquid",
"ticker": "SOL",
}
)
Note that an amount is not specified for the hedge-order - since we are listening for the filled sizes on binance. First - a pending order is created on binance, then acknowledged
>> orders delta:
{'amount': Decimal('-3'),
'cloid': 'c484811d8ce145004eeb26c917013c29',
'exc': 'binance',
'filled_sz': Decimal('0'),
'last_fill_sz': Decimal('0'),
'oid': None,
'ord_status': 'PENDING',
'ord_type': None,
'price': None,
'price_match': 'QUEUE_5',
'reduce_only': None,
'sl': None,
'ticker': 'SOLUSDT',
'tif': None,
'timestamp': 1726199235551,
'tp': None}
{'amount': Decimal('-3'),
'cloid': 'c484811d8ce145004eeb26c917013c29',
'exc': 'binance',
'filled_sz': Decimal('0'),
'last_fill_sz': Decimal('0'),
'oid': '68254554715',
'ord_status': 'NEW',
'ord_type': 'LIMIT',
'price': Decimal('134.5460'),
'price_match': 'QUEUE_5',
'reduce_only': False,
'sl': None,
'ticker': 'SOLUSDT',
'tif': 'GTC',
'timestamp': 1726199241966,
'tp': None}
>> orders snapshot:
[{'amount': Decimal('-3'),
'cloid': 'c484811d8ce145004eeb26c917013c29',
'exc': 'binance',
'filled_sz': Decimal('0'),
'last_fill_sz': Decimal('0'),
'oid': '68254554715',
'ord_status': 'NEW',
'ord_type': 'LIMIT',
'price': Decimal('134.5460'),
'price_match': 'QUEUE_5',
'reduce_only': False,
'sl': None,
'ticker': 'SOLUSDT',
'tif': 'GTC',
'timestamp': 1726199241966,
'tp': None}]
It is later filled:
>> positions delta:
{'amount': Decimal('-3'),
'delta': Decimal('-3'),
'entry': Decimal('134.546'),
'ticker': 'SOLUSDT'}
>> positions snapshot:
{'SOLUSDT': {'amount': Decimal('-3'),
'entry': Decimal('134.546'),
'ticker': 'SOLUSDT'}}
Which also shows up in the orders:
{'amount': Decimal('-3'),
'cloid': 'c484811d8ce145004eeb26c917013c29',
'exc': 'binance',
'filled_sz': Decimal('3'),
'last_fill_sz': Decimal('3'),
'oid': '68254554715',
'ord_status': 'FILLED',
'ord_type': 'LIMIT',
'price': Decimal('134.5460'),
'price_match': 'QUEUE_5',
'reduce_only': False,
'sl': None,
'ticker': 'SOLUSDT',
'tif': 'GTC',
'timestamp': 1726199256915,
'tp': None}
This triggers a taker order on hyperliquid - which goes from PENDING
to NEW
to FILLED
{'amount': Decimal('3.0'),
'cloid': '0xb09f965b3b58613bce2b12f15a94ad47',
'exc': 'hyperliquid',
'filled_sz': Decimal('0'),
'last_fill_sz': Decimal('0'),
'oid': None,
'ord_status': 'PENDING',
'ord_type': None,
'price': None,
'price_match': None,
'reduce_only': None,
'sl': None,
'ticker': 'SOL',
'tif': None,
'timestamp': 1726199235551,
'tp': None}
{'amount': Decimal('3.0'),
'cloid': '0xb09f965b3b58613bce2b12f15a94ad47',
'exc': 'hyperliquid',
'filled_sz': Decimal('0.0'),
'last_fill_sz': Decimal('0.0'),
'oid': '37771235449',
'ord_status': 'NEW',
'ord_type': None,
'price': Decimal('141.36'),
'price_match': None,
'reduce_only': None,
'sl': None,
'ticker': 'SOL',
'tif': None,
'timestamp': 1726199257706,
'tp': None}
{'amount': Decimal('3.0'),
'cloid': '0xb09f965b3b58613bce2b12f15a94ad47',
'exc': 'hyperliquid',
'filled_sz': Decimal('3.0'),
'last_fill_sz': Decimal('3.0'),
'oid': '37771235449',
'ord_status': 'FILLED',
'ord_type': None,
'price': Decimal('141.36'),
'price_match': None,
'reduce_only': None,
'sl': None,
'ticker': 'SOL',
'tif': None,
'timestamp': 1726199257706,
'tp': None}
We also get the positions delta and snapshots on hyperliquid:
{'amount': Decimal('3.0'),
'delta': Decimal('3.0'),
'entry': Decimal('134.65'),
'ticker': 'SOL'}
{'SOL': {'amount': Decimal('3.0'), 'entry': Decimal('134.65'), 'ticker': 'SOL'}}
Note that the orders are sent even if it is a partial-fill with matching size - we don't have to wait for the entire maker order to be complete.