Skip to content

Commit 07e5165

Browse files
authored
Improve validations for StopLimitOrder (#2593)
1 parent 5bea430 commit 07e5165

File tree

1 file changed

+221
-14
lines changed

1 file changed

+221
-14
lines changed

crates/model/src/orders/stop_limit.rs

Lines changed: 221 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ use std::{
1919
};
2020

2121
use indexmap::IndexMap;
22-
use nautilus_core::{UUID4, UnixNanos};
22+
use nautilus_core::{
23+
UUID4, UnixNanos,
24+
correctness::{FAILED, check_predicate_false},
25+
};
2326
use rust_decimal::Decimal;
2427
use serde::{Deserialize, Serialize};
2528
use ustr::Ustr;
@@ -35,7 +38,10 @@ use crate::{
3538
AccountId, ClientOrderId, ExecAlgorithmId, InstrumentId, OrderListId, PositionId,
3639
StrategyId, Symbol, TradeId, TraderId, Venue, VenueOrderId,
3740
},
38-
types::{Currency, Money, Price, Quantity},
41+
types::{
42+
Currency, Money, Price, Quantity, price::check_positive_price,
43+
quantity::check_positive_quantity,
44+
},
3945
};
4046

4147
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -58,8 +64,12 @@ pub struct StopLimitOrder {
5864

5965
impl StopLimitOrder {
6066
/// Creates a new [`StopLimitOrder`] instance.
67+
///
68+
/// # Errors
69+
///
70+
/// Returns an error if the order is invalid.
6171
#[allow(clippy::too_many_arguments)]
62-
pub fn new(
72+
pub fn new_checked(
6373
trader_id: TraderId,
6474
strategy_id: StrategyId,
6575
instrument_id: InstrumentId,
@@ -87,8 +97,23 @@ impl StopLimitOrder {
8797
tags: Option<Vec<Ustr>>,
8898
init_id: UUID4,
8999
ts_init: UnixNanos,
90-
) -> Self {
91-
// TODO: Implement new_checked and check quantity positive, add error docs.
100+
) -> anyhow::Result<Self> {
101+
check_positive_quantity(quantity, stringify!(quantity))?;
102+
check_positive_price(price, stringify!(price))?;
103+
check_positive_price(trigger_price, stringify!(trigger_price))?;
104+
105+
if let Some(disp) = display_qty {
106+
check_positive_quantity(disp, stringify!(display_qty))?;
107+
check_predicate_false(disp > quantity, "`display_qty` may not exceed `quantity`")?;
108+
}
109+
110+
if time_in_force == TimeInForce::Gtd {
111+
check_predicate_false(
112+
expire_time.unwrap_or_default().is_zero(),
113+
"`expire_time` is required for `GTD` order",
114+
)?;
115+
}
116+
92117
let init_order = OrderInitialized::new(
93118
trader_id,
94119
strategy_id,
@@ -125,7 +150,7 @@ impl StopLimitOrder {
125150
tags,
126151
);
127152

128-
Self {
153+
Ok(Self {
129154
core: OrderCore::new(init_order),
130155
price,
131156
trigger_price,
@@ -136,13 +161,74 @@ impl StopLimitOrder {
136161
trigger_instrument_id,
137162
is_triggered: false,
138163
ts_triggered: None,
139-
}
164+
})
165+
}
166+
167+
#[allow(clippy::too_many_arguments)]
168+
pub fn new(
169+
trader_id: TraderId,
170+
strategy_id: StrategyId,
171+
instrument_id: InstrumentId,
172+
client_order_id: ClientOrderId,
173+
order_side: OrderSide,
174+
quantity: Quantity,
175+
price: Price,
176+
trigger_price: Price,
177+
trigger_type: TriggerType,
178+
time_in_force: TimeInForce,
179+
expire_time: Option<UnixNanos>,
180+
post_only: bool,
181+
reduce_only: bool,
182+
quote_quantity: bool,
183+
display_qty: Option<Quantity>,
184+
emulation_trigger: Option<TriggerType>,
185+
trigger_instrument_id: Option<InstrumentId>,
186+
contingency_type: Option<ContingencyType>,
187+
order_list_id: Option<OrderListId>,
188+
linked_order_ids: Option<Vec<ClientOrderId>>,
189+
parent_order_id: Option<ClientOrderId>,
190+
exec_algorithm_id: Option<ExecAlgorithmId>,
191+
exec_algorithm_params: Option<IndexMap<Ustr, Ustr>>,
192+
exec_spawn_id: Option<ClientOrderId>,
193+
tags: Option<Vec<Ustr>>,
194+
init_id: UUID4,
195+
ts_init: UnixNanos,
196+
) -> Self {
197+
Self::new_checked(
198+
trader_id,
199+
strategy_id,
200+
instrument_id,
201+
client_order_id,
202+
order_side,
203+
quantity,
204+
price,
205+
trigger_price,
206+
trigger_type,
207+
time_in_force,
208+
expire_time,
209+
post_only,
210+
reduce_only,
211+
quote_quantity,
212+
display_qty,
213+
emulation_trigger,
214+
trigger_instrument_id,
215+
contingency_type,
216+
order_list_id,
217+
linked_order_ids,
218+
parent_order_id,
219+
exec_algorithm_id,
220+
exec_algorithm_params,
221+
exec_spawn_id,
222+
tags,
223+
init_id,
224+
ts_init,
225+
)
226+
.expect(FAILED)
140227
}
141228
}
142229

143230
impl Deref for StopLimitOrder {
144231
type Target = OrderCore;
145-
146232
fn deref(&self) -> &Self::Target {
147233
&self.core
148234
}
@@ -449,15 +535,13 @@ impl From<OrderInitialized> for StopLimitOrder {
449535
event.client_order_id,
450536
event.order_side,
451537
event.quantity,
538+
event.price.expect("`price` was None for StopLimitOrder"),
452539
event
453-
.price // TODO: Improve this error, model order domain errors
454-
.expect("Error initializing order: `price` was `None` for `StopLimitOrder"),
455-
event
456-
.trigger_price // TODO: Improve this error, model order domain errors
457-
.expect("Error initializing order: `trigger_price` was `None` for `StopLimitOrder"),
540+
.trigger_price
541+
.expect("`trigger_price` was None for StopLimitOrder"),
458542
event
459543
.trigger_type
460-
.expect("Error initializing order: `trigger_type` was `None`"),
544+
.expect("`trigger_type` was None for StopLimitOrder"),
461545
event.time_in_force,
462546
event.expire_time,
463547
event.post_only,
@@ -509,3 +593,126 @@ impl Display for StopLimitOrder {
509593
)
510594
}
511595
}
596+
597+
////////////////////////////////////////////////////////////////////////////////
598+
// Tests
599+
////////////////////////////////////////////////////////////////////////////////
600+
#[cfg(test)]
601+
mod tests {
602+
use rstest::rstest;
603+
604+
use crate::{
605+
enums::{OrderSide, OrderType, TimeInForce, TriggerType},
606+
instruments::{CurrencyPair, stubs::*},
607+
orders::{Order, builder::OrderTestBuilder},
608+
types::{Price, Quantity},
609+
};
610+
611+
#[rstest]
612+
fn buy_breakout_ok(_audusd_sim: CurrencyPair) {
613+
// ---------------------------------------------------------------------
614+
let order = OrderTestBuilder::new(OrderType::StopLimit)
615+
.instrument_id(_audusd_sim.id)
616+
.side(OrderSide::Buy)
617+
.trigger_price(Price::from("0.68000"))
618+
.price(Price::from("0.68100"))
619+
.trigger_type(TriggerType::LastPrice)
620+
.quantity(Quantity::from(1))
621+
.build();
622+
623+
assert_eq!(order.trigger_price(), Some(Price::from("0.68000")));
624+
assert_eq!(order.price(), Some(Price::from("0.68100")));
625+
626+
assert_eq!(order.time_in_force(), TimeInForce::Gtc);
627+
628+
assert_eq!(order.is_triggered(), Some(false));
629+
assert_eq!(order.filled_qty(), Quantity::from(0));
630+
assert_eq!(order.leaves_qty(), Quantity::from(1));
631+
632+
assert_eq!(order.display_qty(), None);
633+
assert_eq!(order.trigger_instrument_id(), None);
634+
assert_eq!(order.order_list_id(), None);
635+
}
636+
637+
#[rstest]
638+
#[should_panic]
639+
fn display_qty_gt_quantity_err(audusd_sim: CurrencyPair) {
640+
OrderTestBuilder::new(OrderType::StopLimit)
641+
.instrument_id(audusd_sim.id)
642+
.side(OrderSide::Buy)
643+
.trigger_price(Price::from("30300"))
644+
.price(Price::from("30100"))
645+
.trigger_type(TriggerType::LastPrice)
646+
.quantity(Quantity::from(1))
647+
.display_qty(Quantity::from(2))
648+
.build();
649+
}
650+
651+
#[rstest]
652+
#[should_panic]
653+
fn display_qty_zero_err(audusd_sim: CurrencyPair) {
654+
OrderTestBuilder::new(OrderType::StopLimit)
655+
.instrument_id(audusd_sim.id)
656+
.side(OrderSide::Buy)
657+
.trigger_price(Price::from("30300"))
658+
.price(Price::from("30100"))
659+
.trigger_type(TriggerType::LastPrice)
660+
.quantity(Quantity::from(1))
661+
.display_qty(Quantity::from(0))
662+
.build();
663+
}
664+
665+
#[rstest]
666+
#[should_panic]
667+
fn display_qty_negative_err(audusd_sim: CurrencyPair) {
668+
OrderTestBuilder::new(OrderType::StopLimit)
669+
.instrument_id(audusd_sim.id)
670+
.side(OrderSide::Buy)
671+
.trigger_price(Price::from("30300"))
672+
.price(Price::from("30100"))
673+
.trigger_type(TriggerType::LastPrice)
674+
.quantity(Quantity::from(1))
675+
.display_qty(Quantity::from("-1"))
676+
.build();
677+
}
678+
679+
#[rstest]
680+
#[should_panic]
681+
fn limit_price_zero_err(audusd_sim: CurrencyPair) {
682+
OrderTestBuilder::new(OrderType::StopLimit)
683+
.instrument_id(audusd_sim.id)
684+
.side(OrderSide::Buy)
685+
.trigger_price(Price::from("30300"))
686+
.price(Price::from("0"))
687+
.trigger_type(TriggerType::LastPrice)
688+
.quantity(Quantity::from(1))
689+
.build();
690+
}
691+
692+
#[rstest]
693+
#[should_panic]
694+
fn limit_price_negative_err(audusd_sim: CurrencyPair) {
695+
OrderTestBuilder::new(OrderType::StopLimit)
696+
.instrument_id(audusd_sim.id)
697+
.side(OrderSide::Buy)
698+
.trigger_price(Price::from("30300"))
699+
.price(Price::from("-1")) // <-- bad
700+
.trigger_type(TriggerType::LastPrice)
701+
.quantity(Quantity::from(1))
702+
.build();
703+
}
704+
705+
#[rstest]
706+
#[should_panic]
707+
fn gtd_without_expire_time_err(audusd_sim: CurrencyPair) {
708+
OrderTestBuilder::new(OrderType::StopLimit)
709+
.instrument_id(audusd_sim.id)
710+
.side(OrderSide::Buy)
711+
.trigger_price(Price::from("30300"))
712+
.price(Price::from("30100"))
713+
.trigger_type(TriggerType::LastPrice)
714+
.time_in_force(TimeInForce::Gtd)
715+
.quantity(Quantity::from(1))
716+
.build();
717+
}
718+
}

0 commit comments

Comments
 (0)