diff --git a/hftbacktest/__init__.py b/hftbacktest/__init__.py index 5fa216c..dd63cd9 100644 --- a/hftbacktest/__init__.py +++ b/hftbacktest/__init__.py @@ -5,10 +5,11 @@ from .reader import COL_EVENT, COL_EXCH_TIMESTAMP, COL_LOCAL_TIMESTAMP, COL_SIDE, COL_PRICE, COL_QTY, \ DEPTH_EVENT, DEPTH_CLEAR_EVENT, DEPTH_SNAPSHOT_EVENT, TRADE_EVENT, DataReader, Cache from .order import BUY, SELL, NONE, NEW, EXPIRED, FILLED, CANCELED, GTC, GTX, Order, OrderBus -from .backtest import SingleInstHftBacktest +from .backtest import SingleAssetHftBacktest from .data import validate_data, correct_local_timestamp, correct_exch_timestamp, correct from .proc.local import Local -from .proc.nopartialfillexchange import NoPartialFillExch +from .proc.nopartialfillexchange import NoPartialFillExchange +from .proc.partialfillexchange import PartialFillExchange from .marketdepth import MarketDepth from .state import State from .models.latencies import FeedLatency, ConstantLatency, ForwardFeedLatency, BackwardFeedLatency, IntpOrderLatency @@ -21,6 +22,7 @@ 'NONE', 'NEW', 'EXPIRED', 'FILLED', 'CANCELED', 'GTC', 'GTX', 'Order', 'HftBacktest', + 'NoPartialFillExchange', 'PartialFillExchange', 'ConstantLatency', 'FeedLatency', 'ForwardFeedLatency', 'BackwardFeedLatency', 'IntpOrderLatency', 'Linear', 'Inverse', 'RiskAverseQueueModel', 'LogProbQueueModel', 'IdentityProbQueueModel', 'SquareProbQueueModel', @@ -163,7 +165,7 @@ def HftBacktest( ) if exchange_model is None: - exchange_model = NoPartialFillExch + exchange_model = NoPartialFillExchange exch = exchange_model( exch_reader, @@ -175,4 +177,4 @@ def HftBacktest( queue_model ) - return SingleInstHftBacktest(local, exch) + return SingleAssetHftBacktest(local, exch) diff --git a/hftbacktest/backtest.py b/hftbacktest/backtest.py index fca486d..a31352e 100644 --- a/hftbacktest/backtest.py +++ b/hftbacktest/backtest.py @@ -6,7 +6,7 @@ from .reader import WAIT_ORDER_RESPONSE_NONE, COL_LOCAL_TIMESTAMP, UNTIL_END_OF_DATA -class SingleInstHftBacktest_: +class SingleAssetHftBacktest_: def __init__(self, local, exch): self.local = local self.exch = exch @@ -204,11 +204,11 @@ def goto(self, timestamp, wait_order_response=WAIT_ORDER_RESPONSE_NONE): return True -def SingleInstHftBacktest(local, exch): +def SingleAssetHftBacktest(local, exch): jitted = jitclass(spec=[ ('run', boolean), ('current_timestamp', int64), ('local', typeof(local)), ('exch', typeof(exch)), - ])(SingleInstHftBacktest_) + ])(SingleAssetHftBacktest_) return jitted(local, exch) diff --git a/hftbacktest/models/queue.py b/hftbacktest/models/queue.py index 6e8a173..f847f81 100644 --- a/hftbacktest/models/queue.py +++ b/hftbacktest/models/queue.py @@ -25,7 +25,7 @@ def depth(self, order, prev_qty, new_qty, proc): order.q[0] = min(order.q[0], new_qty) def is_filled(self, order, proc): - return -order.q[0] + return round(order.q[0] / proc.lot_size) < 0 class ProbQueueModel: @@ -64,7 +64,7 @@ def depth(self, order, prev_qty, new_qty, proc): order.q[0] = min(est_front, new_qty) def is_filled(self, order, proc): - return -order.q[0] + return round(order.q[0] / proc.lot_size) < 0 def prob(self, front, back): return np.divide(self.f(back), self.f(back) + self.f(front)) diff --git a/hftbacktest/proc/nopartialfillexchange.py b/hftbacktest/proc/nopartialfillexchange.py index 6235533..9fcdb9f 100644 --- a/hftbacktest/proc/nopartialfillexchange.py +++ b/hftbacktest/proc/nopartialfillexchange.py @@ -10,7 +10,7 @@ DEPTH_SNAPSHOT_EVENT, TRADE_EVENT -class NoPartialFillExch_(Proc): +class NoPartialFillExchange_(Proc): def __init__( self, reader, @@ -139,8 +139,7 @@ def __check_if_sell_filled(self, order, price_tick, qty, timestamp): elif order.price_tick == price_tick: # Update the order's queue position. self.queue_model.trade(order, qty, self) - exec_qty = self.queue_model.is_filled(order, self) - if round(exec_qty / self.depth.lot_size) > 0: + if self.queue_model.is_filled(order, self): self.__fill(order, timestamp, True) def __check_if_buy_filled(self, order, price_tick, qty, timestamp): @@ -149,8 +148,7 @@ def __check_if_buy_filled(self, order, price_tick, qty, timestamp): elif order.price_tick == price_tick: # Update the order's queue position. self.queue_model.trade(order, qty, self) - exec_qty = self.queue_model.is_filled(order, self) - if round(exec_qty / self.depth.lot_size) > 0: + if self.queue_model.is_filled(order, self): self.__fill(order, timestamp, True) def on_new(self, order): @@ -224,7 +222,10 @@ def __ack_new(self, order, timestamp): else: # The exchange accepts this order. self.orders[order.order_id] = order - o = self.buy_orders.setdefault(order.price_tick, Dict.empty(int64, order_ladder_ty)) + o = self.buy_orders.setdefault( + order.price_tick, + Dict.empty(int64, order_ladder_ty) + ) o[order.order_id] = order # Initialize the order's queue position. self.queue_model.new(order, self) @@ -246,7 +247,10 @@ def __ack_new(self, order, timestamp): else: # The exchange accepts this order. self.orders[order.order_id] = order - o = self.sell_orders.setdefault(order.price_tick, Dict.empty(int64, order_ladder_ty)) + o = self.sell_orders.setdefault( + order.price_tick, + Dict.empty(int64, order_ladder_ty) + ) o[order.order_id] = order # Initialize the order's queue position. self.queue_model.new(order, self) @@ -310,7 +314,7 @@ def __fill( return local_recv_timestamp -def NoPartialFillExch( +def NoPartialFillExchange( reader, orders_to_local, orders_from_local, @@ -325,7 +329,7 @@ def NoPartialFillExch( ('buy_orders', DictType(int64, order_ladder_ty)), ('queue_model', typeof(queue_model)) ] - )(NoPartialFillExch_) + )(NoPartialFillExchange_) return jitted( reader, orders_to_local, diff --git a/hftbacktest/proc/partialfillexchange.py b/hftbacktest/proc/partialfillexchange.py index 133ab19..5e04e7b 100644 --- a/hftbacktest/proc/partialfillexchange.py +++ b/hftbacktest/proc/partialfillexchange.py @@ -3,6 +3,8 @@ from numba.typed.typeddict import Dict from numba.types import DictType +import numpy as np + from .proc import Proc, proc_spec from ..marketdepth import INVALID_MAX, INVALID_MIN from ..order import BUY, SELL, NEW, CANCELED, FILLED, EXPIRED, PARTIALLY_FILLED, GTX, FOK, IOC, NONE, order_ladder_ty @@ -10,7 +12,7 @@ DEPTH_SNAPSHOT_EVENT, TRADE_EVENT -class PartialFillExch_(Proc): +class PartialFillExchange_(Proc): def __init__( self, reader, @@ -146,11 +148,12 @@ def __check_if_sell_filled(self, order, price_tick, qty, timestamp): elif order.price_tick == price_tick: # Update the order's queue position. self.queue_model.trade(order, qty, self) - exec_qty = self.queue_model.is_filled(order, self) - if round(exec_qty / self.depth.lot_size) > 0: + if self.queue_model.is_filled(order, self): + q_qty = np.ceil(-order.q[0] / self.depth.lot_size) * self.depth.lot_size + exec_qty = min(q_qty, qty, order.leaves_qty) self.__fill( order, - min(exec_qty, qty), + exec_qty, timestamp, True ) @@ -166,11 +169,12 @@ def __check_if_buy_filled(self, order, price_tick, qty, timestamp): elif order.price_tick == price_tick: # Update the order's queue position. self.queue_model.trade(order, qty, self) - exec_qty = self.queue_model.is_filled(order, self) - if round(exec_qty / self.depth.lot_size) > 0: + if self.queue_model.is_filled(order, self): + q_qty = np.ceil(-order.q[0] / self.depth.lot_size) * self.depth.lot_size + exec_qty = min(q_qty, qty, order.leaves_qty) self.__fill( order, - min(exec_qty, qty), + exec_qty, timestamp, True ) @@ -261,7 +265,7 @@ def __ack_new(self, order, timestamp): cum_qty = 0 for t in range(self.depth.best_ask_tick, order.price_tick + 1): cum_qty += self.depth.ask_depth[t] - if cum_qty >= order.qty: + if round(cum_qty / self.depth.lot_size) >= round(order.qty / self.depth.lot_size): execute = True break if execute: @@ -309,6 +313,9 @@ def __ack_new(self, order, timestamp): ) if order.status == FILLED: return local_recv_timestamp + # The buy order cannot remain in the ask book, as it cannot affect the market depth during + # backtesting based on market-data replay. So, even though it simulates partial fill, if the order + # size is not small enough, it introduces unreality. return self.__fill( order, order.leaves_qty, @@ -320,7 +327,10 @@ def __ack_new(self, order, timestamp): else: # The exchange accepts this order. self.orders[order.order_id] = order - o = self.buy_orders.setdefault(order.price_tick, Dict.empty(int64, order_ladder_ty)) + o = self.buy_orders.setdefault( + order.price_tick, + Dict.empty(int64, order_ladder_ty) + ) o[order.order_id] = order # Initialize the order's queue position. self.queue_model.new(order, self) @@ -337,7 +347,7 @@ def __ack_new(self, order, timestamp): cum_qty = 0 for t in range(self.depth.best_bid_tick, order.price_tick - 1, -1): cum_qty += self.depth.bid_depth[t] - if cum_qty >= order.qty: + if round(cum_qty / self.depth.lot_size) >= round(order.qty / self.depth.lot_size): execute = True break if execute: @@ -385,6 +395,9 @@ def __ack_new(self, order, timestamp): ) if order.status == FILLED: return local_recv_timestamp + # The sell order cannot remain in the bid book, as it cannot affect the market depth during + # backtesting based on market-data replay. So, even though it simulates partial fill, if the order + # size is not small enough, it introduces unreality. return self.__fill( order, order.leaves_qty, @@ -396,7 +409,10 @@ def __ack_new(self, order, timestamp): else: # The exchange accepts this order. self.orders[order.order_id] = order - o = self.sell_orders.setdefault(order.price_tick, Dict.empty(int64, order_ladder_ty)) + o = self.sell_orders.setdefault( + order.price_tick, + Dict.empty(int64, order_ladder_ty) + ) o[order.order_id] = order # Initialize the order's queue position. self.queue_model.new(order, self) @@ -461,7 +477,7 @@ def __fill( return local_recv_timestamp -def PartialFillExch( +def PartialFillExchange( reader, orders_to_local, orders_from_local, @@ -476,7 +492,7 @@ def PartialFillExch( ('buy_orders', DictType(int64, order_ladder_ty)), ('queue_model', typeof(queue_model)) ] - )(PartialFillExch_) + )(PartialFillExchange_) return jitted( reader, orders_to_local,