High-Frequency Grid Trading
Note: This example is for educational purposes only and demonstrates effective strategies for high-frequency market-making schemes. All backtests are based on a 0.005% rebate, the highest market maker rebate available on Binance Futures. See Binance Upgrades USDⓢ-Margined Futures Liquidity Provider Program for more details.
Plain High-Frequency Grid Trading
This is a high-frequency version of Grid Trading that keeps posting orders on grids centered around the mid-price, maintaining a fixed interval and a set number of grids.
[3]:
from numba import njit
import pandas as pd
import numpy as np
from numba.typed import Dict
from hftbacktest import NONE, NEW, HftBacktest, GTX, FeedLatency, SquareProbQueueModel, BUY, SELL, Linear, Stat, reset
@njit
def gridtrading(hbt, stat):
max_position = 5
grid_interval = hbt.tick_size * 10
grid_num = 20
half_spread = hbt.tick_size * 20
# Running interval in microseconds
while hbt.elapse(100_000):
# Clears cancelled, filled or expired orders.
hbt.clear_inactive_orders()
mid_price = (hbt.best_bid + hbt.best_ask) / 2.0
bid_order_begin = np.floor((mid_price - half_spread) / grid_interval) * grid_interval
ask_order_begin = np.ceil((mid_price + half_spread) / grid_interval) * grid_interval
order_qty = 0.1
last_order_id = -1
# Creates a new grid for buy orders.
new_bid_orders = Dict.empty(np.int64, np.float64)
if hbt.position < max_position:
for i in range(grid_num):
bid_order_begin -= i * grid_interval
bid_order_tick = round(bid_order_begin / hbt.tick_size)
# Do not post buy orders above the best bid.
if bid_order_tick > hbt.best_bid_tick:
continue
# order price in tick is used as order id.
new_bid_orders[bid_order_tick] = bid_order_begin
for order in hbt.orders.values():
# Cancels if an order is not in the new grid.
if order.side == BUY and order.cancellable and order.order_id not in new_bid_orders:
hbt.cancel(order.order_id)
last_order_id = order.order_id
for order_id, order_price in new_bid_orders.items():
# Posts an order if it doesn't exist.
if order_id not in hbt.orders:
hbt.submit_buy_order(order_id, order_price, order_qty, GTX)
last_order_id = order_id
# Creates a new grid for sell orders.
new_ask_orders = Dict.empty(np.int64, np.float64)
if hbt.position > -max_position:
for i in range(grid_num):
ask_order_begin += i * grid_interval
ask_order_tick = round(ask_order_begin / hbt.tick_size)
# Do not post sell orders below the best ask.
if ask_order_tick < hbt.best_ask_tick:
continue
# order price in tick is used as order id.
new_ask_orders[ask_order_tick] = ask_order_begin
for order in hbt.orders.values():
# Cancels if an order is not in the new grid.
if order.side == SELL and order.cancellable and order.order_id not in new_ask_orders:
hbt.cancel(order.order_id)
last_order_id = order.order_id
for order_id, order_price in new_ask_orders.items():
# Posts an order if it doesn't exist.
if order_id not in hbt.orders:
hbt.submit_sell_order(order_id, order_price, order_qty, GTX)
last_order_id = order_id
# All order requests are considered to be requested at the same time.
# Waits until one of the order responses is received.
if last_order_id >= 0:
if not hbt.wait_order_response(last_order_id):
return False
# Records the current state for stat calculation.
stat.record(hbt)
return True
[2]:
hbt = HftBacktest(
[
'data/ethusdt_20221003.npz',
'data/ethusdt_20221004.npz',
'data/ethusdt_20221005.npz',
'data/ethusdt_20221006.npz',
'data/ethusdt_20221007.npz'
],
tick_size=0.01,
lot_size=0.001,
maker_fee=-0.00005,
taker_fee=0.0007,
order_latency=FeedLatency(),
queue_model=SquareProbQueueModel(),
asset_type=Linear,
snapshot='data/ethusdt_20221002_eod.npz'
)
stat = Stat(hbt)
Load data/ethusdt_20221003.npz
[3]:
%%time
gridtrading(hbt, stat.recorder)
Load data/ethusdt_20221004.npz
Load data/ethusdt_20221005.npz
Load data/ethusdt_20221006.npz
Load data/ethusdt_20221007.npz
CPU times: user 3min 58s, sys: 6.03 s, total: 4min 4s
Wall time: 4min 5s
[3]:
True
[4]:
stat.summary(capital=15_000)
=========== Summary ===========
Sharpe ratio: 20.9
Sortino ratio: 22.4
Risk return ratio: 211.5
Annualised return: 330.53 %
Max. draw down: 1.56 %
The number of trades per day: 5954
Avg. daily trading volume: 595
Avg. daily trading amount: 798115
Max leverage: 0.52
Median leverage: 0.21
High-Frequency Grid Trading with Skewing
By incorporating position-based skewing, the strategy’s risk-adjusted returns can be improved.
[5]:
@njit
def gridtrading(hbt, stat, skew):
max_position = 5
grid_interval = hbt.tick_size * 10
grid_num = 20
half_spread = hbt.tick_size * 20
# Running interval in microseconds
while hbt.elapse(100_000):
# Clears cancelled, filled or expired orders.
hbt.clear_inactive_orders()
mid_price = (hbt.best_bid + hbt.best_ask) / 2.0
reservation_price = mid_price - skew * hbt.position * hbt.tick_size
bid_order_begin = np.floor((reservation_price - half_spread) / grid_interval) * grid_interval
ask_order_begin = np.ceil((reservation_price + half_spread) / grid_interval) * grid_interval
order_qty = 0.1 # np.round(notional_order_qty / mid_price / hbt.lot_size) * hbt.lot_size
last_order_id = -1
# Creates a new grid for buy orders.
new_bid_orders = Dict.empty(np.int64, np.float64)
if hbt.position < max_position: # hbt.position * mid_price < max_notional_position
for i in range(grid_num):
bid_order_begin -= i * grid_interval
bid_order_tick = round(bid_order_begin / hbt.tick_size)
# Do not post buy orders above the best bid.
if bid_order_tick > hbt.best_bid_tick:
continue
# order price in tick is used as order id.
new_bid_orders[bid_order_tick] = bid_order_begin
for order in hbt.orders.values():
# Cancels if an order is not in the new grid.
if order.side == BUY and order.cancellable and order.order_id not in new_bid_orders:
hbt.cancel(order.order_id)
last_order_id = order.order_id
for order_id, order_price in new_bid_orders.items():
# Posts an order if it doesn't exist.
if order_id not in hbt.orders:
hbt.submit_buy_order(order_id, order_price, order_qty, GTX)
last_order_id = order_id
# Creates a new grid for sell orders.
new_ask_orders = Dict.empty(np.int64, np.float64)
if hbt.position > -max_position: # hbt.position * mid_price > -max_notional_position
for i in range(grid_num):
ask_order_begin += i * grid_interval
ask_order_tick = round(ask_order_begin / hbt.tick_size)
# Do not post sell orders below the best ask.
if ask_order_tick < hbt.best_ask_tick:
continue
# order price in tick is used as order id.
new_ask_orders[ask_order_tick] = ask_order_begin
for order in hbt.orders.values():
# Cancels if an order is not in the new grid.
if order.side == SELL and order.cancellable and order.order_id not in new_ask_orders:
hbt.cancel(order.order_id)
last_order_id = order.order_id
for order_id, order_price in new_ask_orders.items():
# Posts an order if it doesn't exist.
if order_id not in hbt.orders:
hbt.submit_sell_order(order_id, order_price, order_qty, GTX)
last_order_id = order_id
# All order requests are considered to be requested at the same time.
# Waits until one of the order responses is received.
if last_order_id >= 0:
if not hbt.wait_order_response(last_order_id):
return False
# Records the current state for stat calculation.
stat.record(hbt)
return True
Weak skew
[6]:
reset(
hbt,
[
'data/ethusdt_20221003.npz',
'data/ethusdt_20221004.npz',
'data/ethusdt_20221005.npz',
'data/ethusdt_20221006.npz',
'data/ethusdt_20221007.npz'
],
snapshot='data/ethusdt_20221002_eod.npz'
)
stat = Stat(hbt)
skew = 1
gridtrading(hbt, stat.recorder, skew)
stat.summary(capital=15_000)
Load data/ethusdt_20221003.npz
Load data/ethusdt_20221004.npz
Load data/ethusdt_20221005.npz
Load data/ethusdt_20221006.npz
Load data/ethusdt_20221007.npz
=========== Summary ===========
Sharpe ratio: 18.0
Sortino ratio: 17.5
Risk return ratio: 169.2
Annualised return: 166.77 %
Max. draw down: 0.99 %
The number of trades per day: 6488
Avg. daily trading volume: 648
Avg. daily trading amount: 870207
Max leverage: 0.50
Median leverage: 0.10
Strong skew
[7]:
reset(
hbt,
[
'data/ethusdt_20221003.npz',
'data/ethusdt_20221004.npz',
'data/ethusdt_20221005.npz',
'data/ethusdt_20221006.npz',
'data/ethusdt_20221007.npz'
],
snapshot='data/ethusdt_20221002_eod.npz'
)
stat = Stat(hbt)
skew = 10
gridtrading(hbt, stat.recorder, skew)
stat.summary(capital=15_000)
Load data/ethusdt_20221003.npz
Load data/ethusdt_20221004.npz
Load data/ethusdt_20221005.npz
Load data/ethusdt_20221006.npz
Load data/ethusdt_20221007.npz
=========== Summary ===========
Sharpe ratio: 29.3
Sortino ratio: 33.4
Risk return ratio: 735.4
Annualised return: 100.30 %
Max. draw down: 0.14 %
The number of trades per day: 6636
Avg. daily trading volume: 663
Avg. daily trading amount: 889749
Max leverage: 0.51
Median leverage: 0.02
Multiple Assets
You might need to find the proper parameters for each asset to achieve better performance. As an example, here it uses single parameters set to demonstrate how the performance of a combination of multiple assets will be.
[8]:
@njit
def gridtrading(hbt, stat, half_spread, grid_interval, skew, order_qty):
grid_num = 20
max_position = grid_num * order_qty
# Running interval in microseconds
while hbt.elapse(100_000):
mid_price = (hbt.best_bid + hbt.best_ask) / 2.0
normalized_position = hbt.position / order_qty
bid_depth = half_spread + skew * normalized_position
ask_depth = half_spread - skew * normalized_position
bid_price = min(mid_price - bid_depth, hbt.best_bid)
ask_price = max(mid_price + ask_depth, hbt.best_ask)
grid_interval = max(np.round(half_spread / hbt.tick_size) * hbt.tick_size, hbt.tick_size)
bid_price = np.floor(bid_price / grid_interval) * grid_interval
ask_price = np.ceil(ask_price / grid_interval) * grid_interval
#--------------------------------------------------------
# Updates quotes.
hbt.clear_inactive_orders()
# Creates a new grid for buy orders.
new_bid_orders = Dict.empty(np.int64, np.float64)
if hbt.position < max_position and np.isfinite(bid_price):
for i in range(grid_num):
bid_price -= i * grid_interval
bid_price_tick = round(bid_price / hbt.tick_size)
# order price in tick is used as order id.
new_bid_orders[bid_price_tick] = bid_price
for order in hbt.orders.values():
# Cancels if an order is not in the new grid.
if order.side == BUY and order.cancellable and order.order_id not in new_bid_orders:
hbt.cancel(order.order_id)
for order_id, order_price in new_bid_orders.items():
# Posts an order if it doesn't exist.
if order_id not in hbt.orders:
hbt.submit_buy_order(order_id, order_price, order_qty, GTX)
# Creates a new grid for sell orders.
new_ask_orders = Dict.empty(np.int64, np.float64)
if hbt.position > -max_position and np.isfinite(ask_price):
for i in range(grid_num):
ask_price += i * grid_interval
ask_price_tick = round(ask_price / hbt.tick_size)
# order price in tick is used as order id.
new_ask_orders[ask_price_tick] = ask_price
for order in hbt.orders.values():
# Cancels if an order is not in the new grid.
if order.side == SELL and order.cancellable and order.order_id not in new_ask_orders:
hbt.cancel(order.order_id)
for order_id, order_price in new_ask_orders.items():
# Posts an order if it doesn't exist.
if order_id not in hbt.orders:
hbt.submit_sell_order(order_id, order_price, order_qty, GTX)
# Records the current state for stat calculation.
stat.record(hbt)
return True
[9]:
from hftbacktest import IntpOrderLatency, LogProbQueueModel2, COL_PRICE, COL_SIDE
latency_data = np.concatenate(
[np.load('../latency/order_latency_{}.npz'.format(date))['data'] for date in range(20230701, 20230732)]
)
def backtest(args):
asset_name, asset_info = args
hbt = HftBacktest(
['data/{}_{}.npz'.format(asset_name, date) for date in range(20230701, 20230732)],
tick_size=asset_info['tick_size'],
lot_size=asset_info['lot_size'],
maker_fee=-0.00005,
taker_fee=0.0007,
order_latency=IntpOrderLatency(data=latency_data),
queue_model=LogProbQueueModel2(),
asset_type=Linear,
snapshot='data/{}_20230630_eod.npz'.format(asset_name)
)
stat = Stat(hbt)
# Obtains the mid-price of the assset to determine the order quantity.
data = np.load('data/{}_20230630_eod.npz'.format(asset_name))['data']
best_bid = max(data[data[:, COL_SIDE] == 1][:, COL_PRICE])
best_ask = min(data[data[:, COL_SIDE] == -1][:, COL_PRICE])
mid = (best_bid + best_ask) / 2.0
# Sets the order quantity to be equivalent to a notional value of $100.
order_qty = max(round((100 / mid) / asset_info['lot_size']), 1) * asset_info['lot_size']
half_spread = mid * 0.0008
grid_interval = mid * 0.0008
skew = mid * 0.000025
gridtrading(hbt, stat.recorder, half_spread, grid_interval, skew, order_qty)
np.savez(
'stats/{}_stat_grid_multi'.format(asset_name),
timestamp=np.asarray(stat.timestamp),
mid=np.asarray(stat.mid),
balance=np.asarray(stat.balance),
position=np.asarray(stat.position),
fee=np.asarray(stat.fee),
)
[10]:
%%capture
import json
from multiprocessing import Pool
with open('assets.json', 'r') as f:
assets = json.load(f)
with Pool(16) as p:
print(p.map(backtest, list(assets.items())))
[11]:
from matplotlib import pyplot as plt
equity_values = {}
for asset_name in assets.keys():
stat = np.load('stats/{}_stat_grid.npz'.format(asset_name))
timestamp = stat['timestamp']
mid = stat['mid']
balance = stat['balance']
position = stat['position']
fee = stat['fee']
equity = mid * position + balance - fee
equity = pd.Series(equity, index=pd.to_datetime(timestamp, unit='us', utc=True))
equity_values[asset_name] = equity.resample('5min').last()
fig = plt.figure()
fig.set_size_inches(10, 3)
legend = []
net_equity = None
for i, equity in enumerate(list(equity_values.values())):
asset_number = i + 1
if net_equity is None:
net_equity = equity.copy()
else:
net_equity += equity.copy()
if asset_number % 10 == 0:
# 2_000 is capital for each trading asset.
net_equity_ = (net_equity / asset_number) / 2_000
net_equity_rs = net_equity_.resample('1d').last()
pnl = net_equity_rs.diff()
sr = pnl.mean() / pnl.std()
ann_sr = sr * np.sqrt(365)
legend.append('{} assets, SR={:.2f} (Daily SR={:.2f})'.format(asset_number, ann_sr, sr))
(net_equity_ * 100).plot()
plt.legend(legend)
plt.grid()
plt.ylabel('Cumulative Returns (%)')
[11]:
Text(0, 0.5, 'Cumulative Returns (%)')