Skip to content

Commit 2f9d8ac

Browse files
authored
Improve validations for LimitIfTouchedOrder (#2533)
1 parent b8d89a8 commit 2f9d8ac

File tree

1 file changed

+207
-9
lines changed

1 file changed

+207
-9
lines changed

crates/model/src/orders/limit_if_touched.rs

Lines changed: 207 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,21 @@
1313
// limitations under the License.
1414
// -------------------------------------------------------------------------------------------------
1515

16-
use std::ops::{Deref, DerefMut};
16+
use std::{
17+
fmt::Display,
18+
ops::{Deref, DerefMut},
19+
};
1720

1821
use indexmap::IndexMap;
19-
use nautilus_core::{UUID4, UnixNanos};
22+
use nautilus_core::{
23+
UUID4, UnixNanos,
24+
correctness::{FAILED, check_predicate_false},
25+
};
2026
use rust_decimal::Decimal;
2127
use serde::{Deserialize, Serialize};
2228
use ustr::Ustr;
2329

24-
use super::{Order, OrderAny, OrderCore, OrderError};
30+
use super::{Order, OrderAny, OrderCore};
2531
use crate::{
2632
enums::{
2733
ContingencyType, LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSide,
@@ -32,7 +38,11 @@ use crate::{
3238
AccountId, ClientOrderId, ExecAlgorithmId, InstrumentId, OrderListId, PositionId,
3339
StrategyId, Symbol, TradeId, TraderId, Venue, VenueOrderId,
3440
},
35-
types::{Currency, Money, Price, Quantity},
41+
orders::OrderError,
42+
types::{
43+
Currency, Money, Price, Quantity, price::check_positive_price,
44+
quantity::check_positive_quantity,
45+
},
3646
};
3747

3848
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -53,6 +63,7 @@ pub struct LimitIfTouchedOrder {
5363
core: OrderCore,
5464
}
5565

66+
#[allow(clippy::too_many_arguments)]
5667
impl LimitIfTouchedOrder {
5768
/// Creates a new [`LimitIfTouchedOrder`] instance.
5869
///
@@ -90,7 +101,94 @@ impl LimitIfTouchedOrder {
90101
init_id: UUID4,
91102
ts_init: UnixNanos,
92103
) -> Self {
93-
// TODO: Implement new_checked and check quantity positive, add error docs.
104+
Self::new_checked(
105+
trader_id,
106+
strategy_id,
107+
instrument_id,
108+
client_order_id,
109+
order_side,
110+
quantity,
111+
price,
112+
trigger_price,
113+
trigger_type,
114+
time_in_force,
115+
expire_time,
116+
post_only,
117+
reduce_only,
118+
quote_quantity,
119+
display_qty,
120+
emulation_trigger,
121+
trigger_instrument_id,
122+
contingency_type,
123+
order_list_id,
124+
linked_order_ids,
125+
parent_order_id,
126+
exec_algorithm_id,
127+
exec_algorithm_params,
128+
exec_spawn_id,
129+
tags,
130+
init_id,
131+
ts_init,
132+
)
133+
.expect(FAILED)
134+
}
135+
136+
#[allow(clippy::too_many_arguments)]
137+
pub fn new_checked(
138+
trader_id: TraderId,
139+
strategy_id: StrategyId,
140+
instrument_id: InstrumentId,
141+
client_order_id: ClientOrderId,
142+
order_side: OrderSide,
143+
quantity: Quantity,
144+
price: Price,
145+
trigger_price: Price,
146+
trigger_type: TriggerType,
147+
time_in_force: TimeInForce,
148+
expire_time: Option<UnixNanos>,
149+
post_only: bool,
150+
reduce_only: bool,
151+
quote_quantity: bool,
152+
display_qty: Option<Quantity>,
153+
emulation_trigger: Option<TriggerType>,
154+
trigger_instrument_id: Option<InstrumentId>,
155+
contingency_type: Option<ContingencyType>,
156+
order_list_id: Option<OrderListId>,
157+
linked_order_ids: Option<Vec<ClientOrderId>>,
158+
parent_order_id: Option<ClientOrderId>,
159+
exec_algorithm_id: Option<ExecAlgorithmId>,
160+
exec_algorithm_params: Option<IndexMap<Ustr, Ustr>>,
161+
exec_spawn_id: Option<ClientOrderId>,
162+
tags: Option<Vec<Ustr>>,
163+
init_id: UUID4,
164+
ts_init: UnixNanos,
165+
) -> anyhow::Result<Self> {
166+
check_positive_quantity(quantity, "quantity")?;
167+
check_positive_price(price, "price")?;
168+
check_positive_price(trigger_price, "trigger_price")?;
169+
170+
if let Some(disp) = display_qty {
171+
check_positive_quantity(disp, "display_qty")?;
172+
check_predicate_false(disp > quantity, "`display_qty` may not exceed `quantity`")?;
173+
}
174+
175+
if time_in_force == TimeInForce::Gtd {
176+
check_predicate_false(
177+
expire_time.unwrap_or_default() == 0,
178+
"Condition failed: `expire_time` is required for `GTD` order",
179+
)?;
180+
}
181+
182+
match order_side {
183+
OrderSide::Buy if trigger_price > price => {
184+
anyhow::bail!("BUY Limit-If-Touched must have `trigger_price` <= `price`")
185+
}
186+
OrderSide::Sell if trigger_price < price => {
187+
anyhow::bail!("SELL Limit-If-Touched must have `trigger_price` >= `price`")
188+
}
189+
_ => {}
190+
}
191+
94192
let init_order = OrderInitialized::new(
95193
trader_id,
96194
strategy_id,
@@ -126,8 +224,8 @@ impl LimitIfTouchedOrder {
126224
exec_spawn_id,
127225
tags,
128226
);
129-
Self {
130-
core: OrderCore::new(init_order),
227+
228+
Ok(Self {
131229
price,
132230
trigger_price,
133231
trigger_type,
@@ -137,7 +235,8 @@ impl LimitIfTouchedOrder {
137235
trigger_instrument_id,
138236
is_triggered: false,
139237
ts_triggered: None,
140-
}
238+
core: OrderCore::new(init_order),
239+
})
141240
}
142241
}
143242

@@ -421,7 +520,7 @@ impl Order for LimitIfTouchedOrder {
421520
}
422521

423522
fn set_liquidity_side(&mut self, liquidity_side: LiquiditySide) {
424-
self.liquidity_side = Some(liquidity_side)
523+
self.liquidity_side = Some(liquidity_side);
425524
}
426525

427526
fn would_reduce_only(&self, side: PositionSide, position_qty: Quantity) -> bool {
@@ -433,6 +532,23 @@ impl Order for LimitIfTouchedOrder {
433532
}
434533
}
435534

535+
impl Display for LimitIfTouchedOrder {
536+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
537+
write!(
538+
f,
539+
"LimitIfTouchedOrder({} {} {} @ {} / trigger {} ({:?}) {}, status={})",
540+
self.side,
541+
self.quantity.to_formatted_string(),
542+
self.instrument_id,
543+
self.price,
544+
self.trigger_price,
545+
self.trigger_type,
546+
self.time_in_force,
547+
self.status
548+
)
549+
}
550+
}
551+
436552
impl From<OrderInitialized> for LimitIfTouchedOrder {
437553
fn from(event: OrderInitialized) -> Self {
438554
Self::new(
@@ -474,3 +590,85 @@ impl From<OrderInitialized> for LimitIfTouchedOrder {
474590
)
475591
}
476592
}
593+
594+
////////////////////////////////////////////////////////////////////////////////
595+
// Tests
596+
////////////////////////////////////////////////////////////////////////////////
597+
#[cfg(test)]
598+
mod tests {
599+
use rstest::rstest;
600+
601+
use crate::{
602+
enums::{OrderSide, OrderType, TimeInForce, TriggerType},
603+
instruments::{CurrencyPair, stubs::*},
604+
orders::builder::OrderTestBuilder,
605+
types::{Price, Quantity},
606+
};
607+
608+
#[rstest]
609+
fn ok(audusd_sim: CurrencyPair) {
610+
let _ = OrderTestBuilder::new(OrderType::LimitIfTouched)
611+
.instrument_id(audusd_sim.id)
612+
.side(OrderSide::Buy)
613+
.trigger_price(Price::from("30200"))
614+
.price(Price::from("30200"))
615+
.trigger_type(TriggerType::LastPrice)
616+
.quantity(Quantity::from(1))
617+
.build();
618+
}
619+
620+
#[rstest]
621+
#[should_panic(
622+
expected = "Condition failed: invalid `Quantity` for 'quantity' not positive, was 0"
623+
)]
624+
fn quantity_zero(audusd_sim: CurrencyPair) {
625+
let _ = OrderTestBuilder::new(OrderType::LimitIfTouched)
626+
.instrument_id(audusd_sim.id)
627+
.side(OrderSide::Buy)
628+
.price(Price::from("30000"))
629+
.trigger_price(Price::from("30200"))
630+
.trigger_type(TriggerType::LastPrice)
631+
.quantity(Quantity::from(0))
632+
.build();
633+
}
634+
635+
#[rstest]
636+
#[should_panic(expected = "Condition failed: `expire_time` is required for `GTD` order")]
637+
fn gtd_without_expire(audusd_sim: CurrencyPair) {
638+
let _ = OrderTestBuilder::new(OrderType::LimitIfTouched)
639+
.instrument_id(audusd_sim.id)
640+
.side(OrderSide::Buy)
641+
.price(Price::from("30000"))
642+
.trigger_price(Price::from("30200"))
643+
.trigger_type(TriggerType::LastPrice)
644+
.quantity(Quantity::from(1))
645+
.time_in_force(TimeInForce::Gtd)
646+
.build();
647+
}
648+
649+
#[rstest]
650+
#[should_panic(expected = "BUY Limit-If-Touched must have `trigger_price` <= `price`")]
651+
fn buy_trigger_gt_price(audusd_sim: CurrencyPair) {
652+
OrderTestBuilder::new(OrderType::LimitIfTouched)
653+
.instrument_id(audusd_sim.id)
654+
.side(OrderSide::Buy)
655+
.trigger_price(Price::from("30300")) // Invalid trigger > price
656+
.price(Price::from("30200"))
657+
.trigger_type(TriggerType::LastPrice)
658+
.quantity(Quantity::from(1))
659+
.build();
660+
}
661+
662+
#[rstest]
663+
#[should_panic(expected = "SELL Limit-If-Touched must have `trigger_price` >= `price`")]
664+
fn sell_trigger_lt_price(audusd_sim: CurrencyPair) {
665+
OrderTestBuilder::new(OrderType::LimitIfTouched)
666+
.instrument_id(audusd_sim.id)
667+
.side(OrderSide::Sell)
668+
.trigger_price(Price::from("30100")) // Invalid trigger < price
669+
.price(Price::from("30200"))
670+
.trigger_type(TriggerType::LastPrice)
671+
.quantity(Quantity::from(1))
672+
.build();
673+
}
674+
}

0 commit comments

Comments
 (0)