Skip to content

Commit 319342d

Browse files
committed
Add contract expiration order and position closing
1 parent 2c27426 commit 319342d

File tree

4 files changed

+90
-3
lines changed

4 files changed

+90
-3
lines changed

nautilus_trader/backtest/matching_engine.pxd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ cdef class OrderMatchingEngine:
7979
cdef FillModel _fill_model
8080
cdef FeeModel _fee_model
8181
# cdef object _auction_match_algo
82+
cdef bint _instrument_has_expiration
8283
cdef bint _bar_execution
8384
cdef bint _reject_stop_orders
8485
cdef bint _support_gtd_orders

nautilus_trader/backtest/matching_engine.pyx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ cdef class OrderMatchingEngine:
185185
self.account_type = account_type
186186
self.market_status = MarketStatus.OPEN
187187

188+
self._instrument_has_expiration = instrument.instrument_class in EXPIRING_INSTRUMENT_TYPES
188189
self._bar_execution = bar_execution
189190
self._reject_stop_orders = reject_stop_orders
190191
self._support_gtd_orders = support_gtd_orders
@@ -678,8 +679,9 @@ cdef class OrderMatchingEngine:
678679
# Index identifiers
679680
self._account_ids[order.trader_id] = account_id
680681

681-
cdef uint64_t now_ns = self._clock.timestamp_ns()
682-
if self.instrument.instrument_class in EXPIRING_INSTRUMENT_TYPES:
682+
cdef uint64_t
683+
if self._instrument_has_expiration:
684+
now_ns = self._clock.timestamp_ns()
683685
if now_ns < self.instrument.activation_ns:
684686
self._generate_order_rejected(
685687
order,
@@ -1311,6 +1313,31 @@ cdef class OrderMatchingEngine:
13111313
self._target_last = 0
13121314
self._has_targets = False
13131315

1316+
# Instrument expiration
1317+
if self._instrument_has_expiration and timestamp_ns >= self.instrument.expiration_ns:
1318+
self._log.info(f"{self.instrument.id} reached expiration")
1319+
1320+
# Cancel all open orders
1321+
for order in self.get_open_orders():
1322+
self.cancel_order(order)
1323+
1324+
# Close all open positions
1325+
for position in self.cache.positions(None, self.instrument.id):
1326+
order = MarketOrder(
1327+
trader_id=position.trader_id,
1328+
strategy_id=position.strategy_id,
1329+
instrument_id=position.instrument_id,
1330+
client_order_id=ClientOrderId(str(uuid.uuid4())),
1331+
order_side=Order.closing_side_c(position.side),
1332+
quantity=position.quantity,
1333+
init_id=UUID4(),
1334+
ts_init=self._clock.timestamp_ns(),
1335+
reduce_only=True,
1336+
tags=[f"EXPIRATION_{self.venue}_CLOSE"],
1337+
)
1338+
self.cache.add_order(order, position_id=position.id)
1339+
self.fill_market_order(order)
1340+
13141341
cpdef list determine_limit_price_and_volume(self, Order order):
13151342
"""
13161343
Return the projected fills for the given *limit* order filling passively

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def bypass_logging() -> None:
3535
3636
"""
3737
init_logging(
38-
level_stdout=LogLevel.WARNING,
38+
level_stdout=LogLevel.DEBUG,
3939
bypass=True, # Set this to False to see logging in tests
4040
)
4141

tests/unit_tests/backtest/test_exchange_glbx.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,62 @@ def test_process_order_after_expiration_rejects(self) -> None:
228228
order.last_event.reason
229229
== "Contract ESH4.GLBX has expired, expiration 2024-03-15T14:30:00.000Z"
230230
)
231+
232+
def test_process_exchange_past_instrument_expiration_cancels_open_order(self) -> None:
233+
# Arrange: Prepare market
234+
one_nano_past_activation = _ESH4_GLBX.activation_ns + 1
235+
tick = TestDataStubs.quote_tick(
236+
instrument=_ESH4_GLBX,
237+
bid_price=4010.00,
238+
ask_price=4011.00,
239+
ts_init=one_nano_past_activation,
240+
)
241+
self.data_engine.process(tick)
242+
self.exchange.process_quote_tick(tick)
243+
244+
order = self.strategy.order_factory.limit(
245+
_ESH4_GLBX.id,
246+
OrderSide.BUY,
247+
Quantity.from_int(10),
248+
Price.from_str("4000.00"),
249+
)
250+
251+
self.strategy.submit_order(order)
252+
self.exchange.process(one_nano_past_activation)
253+
254+
# Act
255+
self.exchange.get_matching_engine(_ESH4_GLBX.id).iterate(_ESH4_GLBX.expiration_ns)
256+
257+
# Assert
258+
assert self.clock.timestamp_ns() == _ESH4_GLBX.expiration_ns == 1_710_513_000_000_000_000
259+
assert order.status == OrderStatus.CANCELED
260+
261+
def test_process_exchange_past_instrument_expiration_closed_open_position(self) -> None:
262+
# Arrange: Prepare market
263+
one_nano_past_activation = _ESH4_GLBX.activation_ns + 1
264+
tick = TestDataStubs.quote_tick(
265+
instrument=_ESH4_GLBX,
266+
bid_price=4010.00,
267+
ask_price=4011.00,
268+
ts_init=one_nano_past_activation,
269+
)
270+
self.data_engine.process(tick)
271+
self.exchange.process_quote_tick(tick)
272+
273+
order = self.strategy.order_factory.market(
274+
_ESH4_GLBX.id,
275+
OrderSide.BUY,
276+
Quantity.from_int(10),
277+
)
278+
279+
self.strategy.submit_order(order)
280+
self.exchange.process(one_nano_past_activation)
281+
282+
# Act
283+
self.exchange.get_matching_engine(_ESH4_GLBX.id).iterate(_ESH4_GLBX.expiration_ns)
284+
285+
# Assert
286+
assert self.clock.timestamp_ns() == _ESH4_GLBX.expiration_ns == 1_710_513_000_000_000_000
287+
assert order.status == OrderStatus.FILLED
288+
position = self.cache.positions()[0]
289+
assert position.is_closed

0 commit comments

Comments
 (0)