Skip to content

Commit 20234c6

Browse files
committed
Add risk engine check for GTD order expire time
1 parent 7768d08 commit 20234c6

File tree

5 files changed

+147
-5
lines changed

5 files changed

+147
-5
lines changed

RELEASES.md

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ This release adds support for Python 3.13 (*not* yet compatible with free-thread
77
### Enhancements
88
- Added `allow_past` boolean flag for `Clock.set_timer(...)` to control behavior with start times in the past (default `True` to allow start times in the past)
99
- Added `allow_past` boolean flag for `Clock.set_time_alert(...)` to control behavior with alert times in the past (default `True` to fire immediate alert)
10+
- Added risk engine check for GTD order expire time, which will deny if expire time is already in the past
1011
- Added instrument updating for exchange and matching engine
1112
- Added additional price and quantity precision validations for matching engine
1213
- Added log file rotation with additional config options `max_file_size` and `max_backup_count` (#2468), thanks @xingyanan and @twitu

crates/adapters/tardis/src/python/mod.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ pub mod enums;
2121
pub mod http;
2222
pub mod machine;
2323

24-
use nautilus_core::python::to_pyvalue_err;
2524
use std::str::FromStr;
2625

26+
use nautilus_core::python::to_pyvalue_err;
2727
use pyo3::prelude::*;
2828
use ustr::Ustr;
2929

crates/risk/src/engine/mod.rs

+94-3
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ use nautilus_core::UUID4;
2929
use nautilus_execution::messages::{ModifyOrder, SubmitOrder, SubmitOrderList, TradingCommand};
3030
use nautilus_model::{
3131
accounts::{Account, AccountAny},
32-
enums::{InstrumentClass, OrderSide, OrderStatus, TradingState},
32+
enums::{InstrumentClass, OrderSide, OrderStatus, TimeInForce, TradingState},
3333
events::{OrderDenied, OrderEventAny, OrderModifyRejected},
3434
identifiers::InstrumentId,
3535
instruments::{Instrument, InstrumentAny},
@@ -508,6 +508,18 @@ impl RiskEngine {
508508
////////////////////////////////////////////////////////////////////////////////
509509
// VALIDATION CHECKS
510510
////////////////////////////////////////////////////////////////////////////////
511+
if order.time_in_force() == TimeInForce::Gtd {
512+
// SAFETY: GTD guarantees an expire time
513+
let expire_time = order.expire_time().unwrap();
514+
if expire_time <= self.clock.borrow().timestamp_ns() {
515+
self.deny_order(
516+
order,
517+
&format!("GTD {} already past", expire_time.to_rfc3339()),
518+
);
519+
return false; // Denied
520+
}
521+
}
522+
511523
if !self.check_order_price(instrument.clone(), order.clone())
512524
|| !self.check_order_quantity(instrument, order)
513525
{
@@ -1049,7 +1061,7 @@ mod tests {
10491061

10501062
use nautilus_common::{
10511063
cache::Cache,
1052-
clock::TestClock,
1064+
clock::{Clock, TestClock},
10531065
msgbus::{
10541066
self,
10551067
handler::ShareableMessageHandler,
@@ -1069,7 +1081,7 @@ mod tests {
10691081
stubs::{cash_account, margin_account},
10701082
},
10711083
data::{QuoteTick, stubs::quote_audusd},
1072-
enums::{AccountType, LiquiditySide, OrderSide, OrderType, TradingState},
1084+
enums::{AccountType, LiquiditySide, OrderSide, OrderType, TimeInForce, TradingState},
10731085
events::{
10741086
AccountState, OrderAccepted, OrderDenied, OrderEventAny, OrderEventType, OrderFilled,
10751087
OrderSubmitted, account::stubs::cash_account_state_million_usd,
@@ -4363,4 +4375,83 @@ mod tests {
43634375

43644376
#[rstest]
43654377
fn test_partial_fill_and_full_fill_account_balance_correct() {}
4378+
4379+
#[rstest]
4380+
fn test_submit_order_with_gtd_expire_time_already_passed(
4381+
clock: TestClock,
4382+
strategy_id_ema_cross: StrategyId,
4383+
client_id_binance: ClientId,
4384+
trader_id: TraderId,
4385+
client_order_id: ClientOrderId,
4386+
instrument_xbtusd_bitmex: InstrumentAny,
4387+
venue_order_id: VenueOrderId,
4388+
process_order_event_handler: ShareableMessageHandler,
4389+
execute_order_event_handler: ShareableMessageHandler,
4390+
bitmex_cash_account_state_multi: AccountState,
4391+
mut simple_cache: Cache,
4392+
) {
4393+
msgbus::register(
4394+
MessagingSwitchboard::exec_engine_process(),
4395+
process_order_event_handler.clone(),
4396+
);
4397+
msgbus::register(
4398+
MessagingSwitchboard::exec_engine_execute(),
4399+
execute_order_event_handler.clone(),
4400+
);
4401+
4402+
let quote = QuoteTick::new(
4403+
instrument_xbtusd_bitmex.id(),
4404+
Price::from("0.6109"),
4405+
Price::from("0.6110"),
4406+
Quantity::from("1000"),
4407+
Quantity::from("1000"),
4408+
UnixNanos::default(),
4409+
UnixNanos::default(),
4410+
);
4411+
4412+
simple_cache
4413+
.add_instrument(instrument_xbtusd_bitmex.clone())
4414+
.unwrap();
4415+
4416+
simple_cache
4417+
.add_account(AccountAny::Cash(cash_account(
4418+
bitmex_cash_account_state_multi,
4419+
)))
4420+
.unwrap();
4421+
4422+
simple_cache.add_quote(quote).unwrap();
4423+
4424+
let cache = Rc::new(RefCell::new(simple_cache));
4425+
4426+
let mut risk_engine = get_risk_engine(Some(cache.clone()), None, None, false);
4427+
let order = OrderTestBuilder::new(OrderType::Limit)
4428+
.instrument_id(instrument_xbtusd_bitmex.id())
4429+
.side(OrderSide::Buy)
4430+
.price(Price::from("100_000.0"))
4431+
.quantity(Quantity::from_str("440").unwrap())
4432+
.time_in_force(TimeInForce::Gtd)
4433+
.expire_time(UnixNanos::from(1_000)) // <-- Set expire time in the past
4434+
.build();
4435+
4436+
let submit_order = SubmitOrder::new(
4437+
trader_id,
4438+
client_id_binance,
4439+
strategy_id_ema_cross,
4440+
instrument_xbtusd_bitmex.id(),
4441+
client_order_id,
4442+
venue_order_id,
4443+
order,
4444+
None,
4445+
None,
4446+
UUID4::new(),
4447+
clock.timestamp_ns(),
4448+
)
4449+
.unwrap();
4450+
4451+
clock.set_time(UnixNanos::from(2_000)); // <-- Set time to 2,000 nanos past epoch
4452+
4453+
risk_engine.execute(TradingCommand::SubmitOrder(submit_order));
4454+
4455+
// TODO: Change command messages to not require owned orders
4456+
}
43664457
}

nautilus_trader/risk/engine.pyx

+12-1
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@ from nautilus_trader.common.component cimport MessageBus
3333
from nautilus_trader.common.component cimport Throttler
3434
from nautilus_trader.common.messages cimport TradingStateChanged
3535
from nautilus_trader.core.correctness cimport Condition
36+
from nautilus_trader.core.datetime cimport unix_nanos_to_dt
3637
from nautilus_trader.core.message cimport Command
3738
from nautilus_trader.core.message cimport Event
3839
from nautilus_trader.core.rust.model cimport AccountType
3940
from nautilus_trader.core.rust.model cimport InstrumentClass
4041
from nautilus_trader.core.rust.model cimport OrderSide
4142
from nautilus_trader.core.rust.model cimport OrderStatus
4243
from nautilus_trader.core.rust.model cimport OrderType
44+
from nautilus_trader.core.rust.model cimport TimeInForce
4345
from nautilus_trader.core.rust.model cimport TradingState
4446
from nautilus_trader.core.rust.model cimport TriggerType
4547
from nautilus_trader.core.uuid cimport UUID4
@@ -109,7 +111,7 @@ cdef class RiskEngine(Component):
109111
Cache cache not None,
110112
Clock clock not None,
111113
config: RiskEngineConfig | None = None,
112-
):
114+
) -> None:
113115
if config is None:
114116
config = RiskEngineConfig()
115117
Condition.type(config, RiskEngineConfig, "config")
@@ -555,9 +557,18 @@ cdef class RiskEngine(Component):
555557
########################################################################
556558
if not self._check_order_price(instrument, order):
557559
return False # Denied
560+
558561
if not self._check_order_quantity(instrument, order):
559562
return False # Denied
560563

564+
if order.time_in_force == TimeInForce.GTD:
565+
if order.expire_time_ns <= self._clock.timestamp_ns():
566+
self._deny_order(
567+
order=order,
568+
reason=f"GTD {unix_nanos_to_dt(order.expire_time_ns)} already passed",
569+
)
570+
return False # Denied
571+
561572
return True # Check passed
562573

563574
cpdef bint _check_order_price(self, Instrument instrument, Order order):

tests/unit_tests/risk/test_engine.py

+39
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from datetime import timedelta
1717
from decimal import Decimal
1818

19+
import pandas as pd
1920
import pytest
2021

2122
from nautilus_trader.common.component import MessageBus
@@ -40,6 +41,7 @@
4041
from nautilus_trader.model.enums import AccountType
4142
from nautilus_trader.model.enums import OrderSide
4243
from nautilus_trader.model.enums import OrderStatus
44+
from nautilus_trader.model.enums import TimeInForce
4345
from nautilus_trader.model.enums import TradingState
4446
from nautilus_trader.model.enums import TriggerType
4547
from nautilus_trader.model.events import AccountState
@@ -1956,6 +1958,43 @@ def test_modify_order_for_emulated_order_then_sends_to_emulator(self):
19561958
# Assert
19571959
assert order.trigger_price == new_trigger_price
19581960

1961+
def test_submit_order_with_passed_gtd(self):
1962+
# Arrange
1963+
self.exec_engine.start()
1964+
1965+
strategy = Strategy()
1966+
strategy.register(
1967+
trader_id=self.trader_id,
1968+
portfolio=self.portfolio,
1969+
msgbus=self.msgbus,
1970+
cache=self.cache,
1971+
clock=self.clock,
1972+
)
1973+
1974+
self.clock.set_time(2_000) # <-- Set clock to 2,000 nanos past epoch
1975+
1976+
order = strategy.order_factory.stop_market(
1977+
_AUDUSD_SIM.id,
1978+
OrderSide.BUY,
1979+
Quantity.from_int(100_000),
1980+
Price.from_str("1.00020"),
1981+
time_in_force=TimeInForce.GTD,
1982+
expire_time=pd.Timestamp(1_000), # <-- Expire time prior to time now
1983+
)
1984+
submit_order = SubmitOrder(
1985+
trader_id=order.trader_id,
1986+
strategy_id=order.strategy_id,
1987+
position_id=None,
1988+
order=order,
1989+
command_id=UUID4(),
1990+
ts_init=self.clock.timestamp_ns(),
1991+
)
1992+
1993+
self.risk_engine.execute(submit_order)
1994+
1995+
# Assert
1996+
assert order.status == OrderStatus.DENIED
1997+
19591998

19601999
class TestRiskEngineWithBettingAccount:
19612000
def setup(self):

0 commit comments

Comments
 (0)