Skip to content

Commit

Permalink
Fix FOK and IOC time in force fill handling
Browse files Browse the repository at this point in the history
  • Loading branch information
cjdsellers committed Feb 15, 2024
1 parent ed3334c commit e176ea6
Show file tree
Hide file tree
Showing 3 changed files with 140 additions and 15 deletions.
2 changes: 2 additions & 0 deletions RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ None
None

### Fixes
- Fixed FOK time in force behavior (allows fills beyond the top level, will cancel if cannot fill full size)
- Fixed IOC time in force behavior (allows fills beyond the top level, will cancel any remaining after all fills are applied)
- Fixed logging timestamps for backtesting (static clock was not being incrementally set to individual `TimeEvent` timestamps)
- Fixed `Equity` short selling for `CASH` accounts (will now reject)
- Fixed `ActorFactory.create` JSON encoding (was missing the encoding hook)
Expand Down
35 changes: 20 additions & 15 deletions nautilus_trader/backtest/matching_engine.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -1516,6 +1516,20 @@ cdef class OrderMatchingEngine:

order.liquidity_side = liquidity_side

cdef:
Price fill_px
Quantity fill_qty
uint64_t total_size_raw = 0
if order.time_in_force == TimeInForce.FOK:
# Check FOK requirement
for fill in fills:
fill_px, fill_qty = fill
total_size_raw += fill_qty._mem.raw

if order.leaves_qty._mem.raw > total_size_raw:
self.cancel_order(order)
return # Cannot fill full size - so kill/cancel

if not fills:
self._log.error(
"Cannot fill order: no fills from book when fills were expected (check sizes in data).",
Expand All @@ -1534,8 +1548,6 @@ cdef class OrderMatchingEngine:
)

cdef:
Price fill_px
Quantity fill_qty
bint initial_market_to_limit_fill = False
Price last_fill_px = None
for fill_px, fill_qty in fills:
Expand All @@ -1548,14 +1560,6 @@ cdef class OrderMatchingEngine:
trigger_price=None,
)
initial_market_to_limit_fill = True
if order.time_in_force == TimeInForce.FOK and fill_qty._mem.raw < order.quantity._mem.raw:
# FOK order cannot fill the entire quantity - cancel
self.cancel_order(order)
return
elif order.time_in_force == TimeInForce.IOC:
# IOC order has already filled at one price - cancel remaining
self.cancel_order(order)
return

if self.book_type == BookType.L1_MBP and self._fill_model.is_slipped():
if order.side == OrderSide.BUY:
Expand Down Expand Up @@ -1598,6 +1602,11 @@ cdef class OrderMatchingEngine:

last_fill_px = fill_px

if order.time_in_force == TimeInForce.IOC and order.is_open_c():
# IOC order has filled all available size
self.cancel_order(order)
return

if (
order.is_open_c()
and self.book_type == BookType.L1_MBP
Expand All @@ -1607,11 +1616,6 @@ cdef class OrderMatchingEngine:
or order.order_type == OrderType.STOP_MARKET
)
):
if order.time_in_force == TimeInForce.IOC:
# IOC order has already filled at one price - cancel remaining
self.cancel_order(order)
return

# Exhausted simulated book volume (continue aggressive filling into next level)
# This is a very basic implementation of slipping by a single tick, in the future
# we will implement more detailed fill modeling.
Expand All @@ -1633,6 +1637,7 @@ cdef class OrderMatchingEngine:
position=position,
)


cpdef void fill_order(
self,
Order order,
Expand Down
118 changes: 118 additions & 0 deletions tests/unit_tests/backtest/test_exchange_margin.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,36 @@ def test_submit_market_order_with_fok_time_in_force_cancels_immediately(self) ->
assert order.quantity == Quantity.from_int(1_000_000)
assert order.filled_qty == Quantity.from_int(0)

def test_submit_limit_order_with_fok_time_in_force_cancels_immediately(self) -> None:
# Arrange: Prepare market
tick = TestDataStubs.quote_tick(
instrument=_USDJPY_SIM,
bid_price=90.002,
ask_price=90.005,
bid_size=500_000,
ask_size=500_000,
)
self.data_engine.process(tick)
self.exchange.process_quote_tick(tick)

# Create order
order = self.strategy.order_factory.limit(
_USDJPY_SIM.id,
OrderSide.BUY,
Quantity.from_int(1_000_000),
Price.from_str("90.000"),
time_in_force=TimeInForce.FOK,
)

# Act
self.strategy.submit_order(order)
self.exchange.process(0)

# Assert
assert order.status == OrderStatus.CANCELED
assert order.quantity == Quantity.from_int(1_000_000)
assert order.filled_qty == Quantity.from_int(0)

def test_submit_market_order_with_ioc_time_in_force_cancels_remaining_qty(self) -> None:
# Arrange: Prepare market
tick = TestDataStubs.quote_tick(
Expand Down Expand Up @@ -797,6 +827,94 @@ def test_submit_market_to_limit_order_greater_than_available_top_of_book(self) -
assert order.leaves_qty == Quantity.from_int(1_000_000)
assert len(self.exchange.get_open_orders()) == 1

def test_submit_market_order_ioc_cancels_remaining(self) -> None:
# Arrange: Prepare market
tick = TestDataStubs.quote_tick(
instrument=_USDJPY_SIM,
bid_price=90.002,
ask_price=90.005,
bid_size=1_000_000,
ask_size=1_000_000,
)
self.data_engine.process(tick)
self.exchange.process_quote_tick(tick)

order = self.strategy.order_factory.market(
_USDJPY_SIM.id,
OrderSide.BUY,
Quantity.from_int(2_000_000),
time_in_force=TimeInForce.IOC,
)

# Act
self.strategy.submit_order(order)
self.exchange.process(0)

# Assert
assert order.status == OrderStatus.CANCELED
assert order.filled_qty == Quantity.from_int(1_000_000)
assert order.leaves_qty == Quantity.from_int(1_000_000)
assert len(self.exchange.get_open_orders()) == 0

def test_submit_market_order_fok_cancels_when_cannot_fill_full_size(self) -> None:
# Arrange: Prepare market
tick = TestDataStubs.quote_tick(
instrument=_USDJPY_SIM,
bid_price=90.002,
ask_price=90.005,
bid_size=1_000_000,
ask_size=1_000_000,
)
self.data_engine.process(tick)
self.exchange.process_quote_tick(tick)

order = self.strategy.order_factory.market(
_USDJPY_SIM.id,
OrderSide.BUY,
Quantity.from_int(2_000_000),
time_in_force=TimeInForce.FOK,
)

# Act
self.strategy.submit_order(order)
self.exchange.process(0)

# Assert
assert order.status == OrderStatus.CANCELED
assert order.filled_qty == Quantity.from_int(0)
assert order.leaves_qty == Quantity.from_int(2_000_000)
assert len(self.exchange.get_open_orders()) == 0

def test_submit_limit_order_fok_cancels_when_cannot_fill_full_size(self) -> None:
# Arrange: Prepare market
tick = TestDataStubs.quote_tick(
instrument=_USDJPY_SIM,
bid_price=90.002,
ask_price=90.005,
bid_size=1_000_000,
ask_size=1_000_000,
)
self.data_engine.process(tick)
self.exchange.process_quote_tick(tick)

order = self.strategy.order_factory.limit(
_USDJPY_SIM.id,
OrderSide.BUY,
Quantity.from_int(2_000_000),
Price.from_str("90.005"),
time_in_force=TimeInForce.FOK,
)

# Act
self.strategy.submit_order(order)
self.exchange.process(0)

# Assert
assert order.status == OrderStatus.CANCELED
assert order.filled_qty == Quantity.from_int(0)
assert order.leaves_qty == Quantity.from_int(2_000_000)
assert len(self.exchange.get_open_orders()) == 0

def test_modify_market_to_limit_order_after_filling_initial_quantity(self) -> None:
# Arrange: Prepare market
tick = TestDataStubs.quote_tick(
Expand Down

0 comments on commit e176ea6

Please sign in to comment.