1use nautilus_model::{
18 enums::{OrderSideSpecified, OrderType, TrailingOffsetType, TriggerType},
19 orders::{Order, OrderAny, OrderError},
20 types::Price,
21};
22use rust_decimal::{Decimal, prelude::*};
23
24pub fn trailing_stop_calculate(
34 price_increment: Price,
35 order: &OrderAny,
36 bid: Option<Price>,
37 ask: Option<Price>,
38 last: Option<Price>,
39) -> anyhow::Result<(Option<Price>, Option<Price>)> {
40 let order_side = order.order_side_specified();
41 let order_type = order.order_type();
42 if !matches!(
43 order_type,
44 OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
45 ) {
46 anyhow::bail!("Invalid `OrderType` {order_type} for trailing stop calculation");
47 }
48
49 let trigger_type = order.trigger_type().unwrap();
51 let trailing_offset = order.trailing_offset().unwrap();
52 let trailing_offset_type = order.trailing_offset_type().unwrap();
53 assert!(trigger_type != TriggerType::NoTrigger);
54 assert!(trailing_offset_type != TrailingOffsetType::NoTrailingOffset,);
55
56 let mut trigger_price = order.trigger_price();
57 let mut price = None;
58 let mut new_trigger_price = None;
59 let mut new_price = None;
60
61 if order_type == OrderType::TrailingStopLimit {
62 price = order.price();
63 }
64
65 match trigger_type {
66 TriggerType::Default | TriggerType::LastPrice | TriggerType::MarkPrice => {
67 let last = last.ok_or(OrderError::InvalidStateTransition)?;
68
69 let temp_trigger_price = trailing_stop_calculate_with_last(
70 price_increment,
71 trailing_offset_type,
72 order_side,
73 trailing_offset,
74 last,
75 )?;
76
77 match order_side {
78 OrderSideSpecified::Buy => {
79 if let Some(trigger) = trigger_price {
80 if trigger > temp_trigger_price {
81 new_trigger_price = Some(temp_trigger_price);
82 }
83 } else {
84 new_trigger_price = Some(temp_trigger_price);
85 }
86
87 if order_type == OrderType::TrailingStopLimit {
88 let temp_price = trailing_stop_calculate_with_last(
89 price_increment,
90 trailing_offset_type,
91 order_side,
92 order.limit_offset().expect("Invalid order"),
93 last,
94 )?;
95 if let Some(p) = price {
96 if p > temp_price {
97 new_price = Some(temp_price);
98 }
99 } else {
100 new_price = Some(temp_price);
101 }
102 }
103 }
104 OrderSideSpecified::Sell => {
105 if let Some(trigger) = trigger_price {
106 if trigger < temp_trigger_price {
107 new_trigger_price = Some(temp_trigger_price);
108 }
109 } else {
110 new_trigger_price = Some(temp_trigger_price);
111 }
112
113 if order_type == OrderType::TrailingStopLimit {
114 let temp_price = trailing_stop_calculate_with_last(
115 price_increment,
116 trailing_offset_type,
117 order_side,
118 order.limit_offset().expect("Invalid order"),
119 last,
120 )?;
121 if let Some(p) = price {
122 if p < temp_price {
123 new_price = Some(temp_price);
124 }
125 } else {
126 new_price = Some(temp_price);
127 }
128 }
129 }
130 }
131 }
132 TriggerType::BidAsk => {
133 let bid =
134 bid.ok_or_else(|| anyhow::anyhow!("`BidAsk` calculation requires `bid` price"))?;
135 let ask =
136 ask.ok_or_else(|| anyhow::anyhow!("`BidAsk` calculation requires `ask` price"))?;
137
138 let temp_trigger_price = trailing_stop_calculate_with_bid_ask(
139 price_increment,
140 trailing_offset_type,
141 order_side,
142 trailing_offset,
143 bid,
144 ask,
145 )?;
146
147 match order_side {
148 OrderSideSpecified::Buy => {
149 if let Some(trigger) = trigger_price {
150 if trigger > temp_trigger_price {
151 new_trigger_price = Some(temp_trigger_price);
152 }
153 } else {
154 new_trigger_price = Some(temp_trigger_price);
155 }
156
157 if order.order_type() == OrderType::TrailingStopLimit {
158 let temp_price = trailing_stop_calculate_with_bid_ask(
159 price_increment,
160 trailing_offset_type,
161 order_side,
162 order.limit_offset().expect("Invalid order"),
163 bid,
164 ask,
165 )?;
166 if let Some(p) = price {
167 if p > temp_price {
168 new_price = Some(temp_price);
169 }
170 } else {
171 new_price = Some(temp_price);
172 }
173 }
174 }
175 OrderSideSpecified::Sell => {
176 if let Some(trigger) = trigger_price {
177 if trigger < temp_trigger_price {
178 new_trigger_price = Some(temp_trigger_price);
179 }
180 } else {
181 new_trigger_price = Some(temp_trigger_price);
182 }
183
184 if order_type == OrderType::TrailingStopLimit {
185 let temp_price = trailing_stop_calculate_with_bid_ask(
186 price_increment,
187 trailing_offset_type,
188 order_side,
189 order.limit_offset().expect("Invalid order"),
190 bid,
191 ask,
192 )?;
193 if let Some(p) = price {
194 if p < temp_price {
195 new_price = Some(temp_price);
196 }
197 } else {
198 new_price = Some(temp_price);
199 }
200 }
201 }
202 }
203 }
204 TriggerType::LastOrBidAsk => {
205 let bid = bid.ok_or_else(|| {
206 anyhow::anyhow!("`LastOrBidAsk` calculation requires `bid` price")
207 })?;
208 let ask = ask.ok_or_else(|| {
209 anyhow::anyhow!("`LastOrBidAsk` calculation requires `ask` price")
210 })?;
211 let last = last.ok_or_else(|| {
212 anyhow::anyhow!("`LastOrBidAsk` calculation requires `last` price")
213 })?;
214
215 let mut temp_trigger_price = trailing_stop_calculate_with_last(
216 price_increment,
217 trailing_offset_type,
218 order_side,
219 trailing_offset,
220 last,
221 )?;
222
223 match order_side {
224 OrderSideSpecified::Buy => {
225 if let Some(trigger) = trigger_price {
226 if trigger > temp_trigger_price {
227 new_trigger_price = Some(temp_trigger_price);
228 trigger_price = new_trigger_price;
229 }
230 } else {
231 new_trigger_price = Some(temp_trigger_price);
232 trigger_price = new_trigger_price;
233 }
234 if order.order_type() == OrderType::TrailingStopLimit {
235 let temp_price = trailing_stop_calculate_with_last(
236 price_increment,
237 trailing_offset_type,
238 order_side,
239 order.limit_offset().expect("Invalid order"),
240 last,
241 )?;
242 if let Some(p) = price {
243 if p > temp_price {
244 new_price = Some(temp_price);
245 price = new_price;
246 }
247 } else {
248 new_price = Some(temp_price);
249 price = new_price;
250 }
251 }
252 temp_trigger_price = trailing_stop_calculate_with_bid_ask(
253 price_increment,
254 trailing_offset_type,
255 order_side,
256 trailing_offset,
257 bid,
258 ask,
259 )?;
260 if let Some(trigger) = trigger_price {
261 if trigger > temp_trigger_price {
262 new_trigger_price = Some(temp_trigger_price);
263 }
264 } else {
265 new_trigger_price = Some(temp_trigger_price);
266 }
267 if order_type == OrderType::TrailingStopLimit {
268 let temp_price = trailing_stop_calculate_with_bid_ask(
269 price_increment,
270 trailing_offset_type,
271 order_side,
272 order.limit_offset().expect("Invalid order"),
273 bid,
274 ask,
275 )?;
276 if let Some(p) = price {
277 if p > temp_price {
278 new_price = Some(temp_price);
279 }
280 } else {
281 new_price = Some(temp_price);
282 }
283 }
284 }
285 OrderSideSpecified::Sell => {
286 if let Some(trigger) = trigger_price {
287 if trigger < temp_trigger_price {
288 new_trigger_price = Some(temp_trigger_price);
289 trigger_price = new_trigger_price;
290 }
291 } else {
292 new_trigger_price = Some(temp_trigger_price);
293 trigger_price = new_trigger_price;
294 }
295
296 if order.order_type() == OrderType::TrailingStopLimit {
297 let temp_price = trailing_stop_calculate_with_last(
298 price_increment,
299 trailing_offset_type,
300 order_side,
301 order.limit_offset().expect("Invalid order"),
302 last,
303 )?;
304 if let Some(p) = price {
305 if p < temp_price {
306 new_price = Some(temp_price);
307 price = new_price;
308 }
309 } else {
310 new_price = Some(temp_price);
311 price = new_price;
312 }
313 }
314 temp_trigger_price = trailing_stop_calculate_with_bid_ask(
315 price_increment,
316 trailing_offset_type,
317 order_side,
318 trailing_offset,
319 bid,
320 ask,
321 )?;
322 if let Some(trigger) = trigger_price {
323 if trigger < temp_trigger_price {
324 new_trigger_price = Some(temp_trigger_price);
325 }
326 } else {
327 new_trigger_price = Some(temp_trigger_price);
328 }
329 if order_type == OrderType::TrailingStopLimit {
330 let temp_price = trailing_stop_calculate_with_bid_ask(
331 price_increment,
332 trailing_offset_type,
333 order_side,
334 order.limit_offset().expect("Invalid order"),
335 bid,
336 ask,
337 )?;
338 if let Some(p) = price {
339 if p < temp_price {
340 new_price = Some(temp_price);
341 }
342 } else {
343 new_price = Some(temp_price);
344 }
345 }
346 }
347 }
348 }
349 _ => anyhow::bail!("`TriggerType` {trigger_type} not currently supported"),
350 }
351
352 Ok((new_trigger_price, new_price))
353}
354
355pub fn trailing_stop_calculate_with_last(
365 price_increment: Price,
366 trailing_offset_type: TrailingOffsetType,
367 side: OrderSideSpecified,
368 offset: Decimal,
369 last: Price,
370) -> anyhow::Result<Price> {
371 let mut offset_value = offset.to_f64().expect("Invalid `offset` value");
372 let last_f64 = last.as_f64();
373
374 match trailing_offset_type {
375 TrailingOffsetType::Price => {} TrailingOffsetType::BasisPoints => {
377 offset_value = last_f64 * (offset_value / 100.0) / 100.0;
378 }
379 TrailingOffsetType::Ticks => {
380 offset_value *= price_increment.as_f64();
381 }
382 _ => anyhow::bail!("`TrailingOffsetType` {trailing_offset_type} not currently supported"),
383 }
384
385 let price_value = match side {
386 OrderSideSpecified::Buy => last_f64 + offset_value,
387 OrderSideSpecified::Sell => last_f64 - offset_value,
388 };
389
390 Ok(Price::new(price_value, price_increment.precision))
391}
392
393pub fn trailing_stop_calculate_with_bid_ask(
403 price_increment: Price,
404 trailing_offset_type: TrailingOffsetType,
405 side: OrderSideSpecified,
406 offset: Decimal,
407 bid: Price,
408 ask: Price,
409) -> anyhow::Result<Price> {
410 let mut offset_value = offset.to_f64().expect("Invalid `offset` value");
411 let bid_f64 = bid.as_f64();
412 let ask_f64 = ask.as_f64();
413
414 match trailing_offset_type {
415 TrailingOffsetType::Price => {} TrailingOffsetType::BasisPoints => match side {
417 OrderSideSpecified::Buy => offset_value = ask_f64 * (offset_value / 100.0) / 100.0,
418 OrderSideSpecified::Sell => offset_value = bid_f64 * (offset_value / 100.0) / 100.0,
419 },
420 TrailingOffsetType::Ticks => {
421 offset_value *= price_increment.as_f64();
422 }
423 _ => anyhow::bail!("`TrailingOffsetType` {trailing_offset_type} not currently supported"),
424 }
425
426 let price_value = match side {
427 OrderSideSpecified::Buy => ask_f64 + offset_value,
428 OrderSideSpecified::Sell => bid_f64 - offset_value,
429 };
430
431 Ok(Price::new(price_value, price_increment.precision))
432}
433
434#[cfg(test)]
438mod tests {
439 use nautilus_model::{
440 enums::{OrderSide, OrderType, TrailingOffsetType, TriggerType},
441 orders::builder::OrderTestBuilder,
442 types::Quantity,
443 };
444 use rstest::rstest;
445 use rust_decimal::prelude::*;
446 use rust_decimal_macros::dec;
447
448 use super::*;
449
450 #[rstest]
451 fn test_calculate_with_invalid_order_type() {
452 let order = OrderTestBuilder::new(OrderType::Market)
453 .instrument_id("BTCUSDT-PERP.BINANCE".into())
454 .side(OrderSide::Buy)
455 .quantity(Quantity::from(1))
456 .build();
457
458 let result = trailing_stop_calculate(Price::new(0.01, 2), &order, None, None, None);
459
460 assert!(result.is_err());
462 }
463
464 #[rstest]
465 #[case(OrderSide::Buy)]
466 #[case(OrderSide::Sell)]
467 fn test_calculate_with_last_price_no_last(#[case] side: OrderSide) {
468 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
469 .instrument_id("BTCUSDT-PERP.BINANCE".into())
470 .side(side)
471 .trigger_price(Price::new(100.0, 2))
472 .trailing_offset_type(TrailingOffsetType::Price)
473 .trailing_offset(dec!(1.0))
474 .trigger_type(TriggerType::LastPrice)
475 .quantity(Quantity::from(1))
476 .build();
477
478 let result = trailing_stop_calculate(Price::new(0.01, 2), &order, None, None, None);
479
480 assert!(result.is_err());
482 }
483
484 #[rstest]
485 #[case(OrderSide::Buy)]
486 #[case(OrderSide::Sell)]
487 fn test_calculate_with_bid_ask_no_bid_ask(#[case] side: OrderSide) {
488 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
489 .instrument_id("BTCUSDT-PERP.BINANCE".into())
490 .side(side)
491 .trigger_price(Price::new(100.0, 2))
492 .trailing_offset_type(TrailingOffsetType::Price)
493 .trailing_offset(dec!(1.0))
494 .trigger_type(TriggerType::BidAsk)
495 .quantity(Quantity::from(1))
496 .build();
497
498 let result = trailing_stop_calculate(Price::new(0.01, 2), &order, None, None, None);
499
500 assert!(result.is_err());
502 }
503
504 #[rstest]
505 fn test_calculate_with_unsupported_trigger_type() {
506 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
507 .instrument_id("BTCUSDT-PERP.BINANCE".into())
508 .side(OrderSide::Buy)
509 .trigger_price(Price::new(100.0, 2))
510 .trailing_offset_type(TrailingOffsetType::Price)
511 .trailing_offset(dec!(1.0))
512 .trigger_type(TriggerType::IndexPrice)
513 .quantity(Quantity::from(1))
514 .build();
515
516 let result = trailing_stop_calculate(Price::new(0.01, 2), &order, None, None, None);
517
518 assert!(result.is_err());
520 }
521
522 #[rstest]
523 #[case(OrderSide::Buy, 100.0, 1.0, 99.0, None)] #[case(OrderSide::Buy, 100.0, 1.0, 98.0, Some(99.0))] #[case(OrderSide::Sell, 100.0, 1.0, 101.0, None)] #[case(OrderSide::Sell, 100.0, 1.0, 102.0, Some(101.0))] fn test_trailing_stop_market_last_price(
528 #[case] side: OrderSide,
529 #[case] initial_trigger: f64,
530 #[case] offset: f64,
531 #[case] last_price: f64,
532 #[case] expected_trigger: Option<f64>,
533 ) {
534 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
535 .instrument_id("BTCUSDT-PERP.BINANCE".into())
536 .side(side)
537 .trigger_price(Price::new(initial_trigger, 2))
538 .trailing_offset_type(TrailingOffsetType::Price)
539 .trailing_offset(Decimal::from_f64(offset).unwrap())
540 .trigger_type(TriggerType::LastPrice)
541 .quantity(Quantity::from(1))
542 .build();
543
544 let result = trailing_stop_calculate(
545 Price::new(0.01, 2),
546 &order,
547 None,
548 None,
549 Some(Price::new(last_price, 2)),
550 );
551
552 let actual_trigger = result.unwrap().0;
553 match (actual_trigger, expected_trigger) {
554 (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
555 (None, None) => (),
556 _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
557 }
558 }
559
560 #[rstest]
561 #[case(OrderSide::Buy, 100.0, 50.0, 98.0, Some(98.49))] #[case(OrderSide::Buy, 100.0, 100.0, 97.0, Some(97.97))] #[case(OrderSide::Sell, 100.0, 50.0, 102.0, Some(101.49))] #[case(OrderSide::Sell, 100.0, 100.0, 103.0, Some(101.97))] fn test_trailing_stop_market_basis_points(
566 #[case] side: OrderSide,
567 #[case] initial_trigger: f64,
568 #[case] basis_points: f64,
569 #[case] last_price: f64,
570 #[case] expected_trigger: Option<f64>,
571 ) {
572 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
573 .instrument_id("BTCUSDT-PERP.BINANCE".into())
574 .side(side)
575 .trigger_price(Price::new(initial_trigger, 2))
576 .trailing_offset_type(TrailingOffsetType::BasisPoints)
577 .trailing_offset(Decimal::from_f64(basis_points).unwrap())
578 .trigger_type(TriggerType::LastPrice)
579 .quantity(Quantity::from(1))
580 .build();
581
582 let result = trailing_stop_calculate(
583 Price::new(0.01, 2),
584 &order,
585 None,
586 None,
587 Some(Price::new(last_price, 2)),
588 );
589
590 let actual_trigger = result.unwrap().0;
591 match (actual_trigger, expected_trigger) {
592 (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
593 (None, None) => (),
594 _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
595 }
596 }
597
598 #[rstest]
599 #[case(OrderSide::Buy, 100.0, 1.0, 98.0, 99.0, None)] #[case(OrderSide::Buy, 100.0, 1.0, 97.0, 98.0, Some(99.0))] #[case(OrderSide::Sell, 100.0, 1.0, 101.0, 102.0, None)] #[case(OrderSide::Sell, 100.0, 1.0, 102.0, 103.0, Some(101.0))] fn test_trailing_stop_market_bid_ask(
604 #[case] side: OrderSide,
605 #[case] initial_trigger: f64,
606 #[case] offset: f64,
607 #[case] bid: f64,
608 #[case] ask: f64,
609 #[case] expected_trigger: Option<f64>,
610 ) {
611 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
612 .instrument_id("BTCUSDT-PERP.BINANCE".into())
613 .side(side)
614 .trigger_price(Price::new(initial_trigger, 2))
615 .trailing_offset_type(TrailingOffsetType::Price)
616 .trailing_offset(Decimal::from_f64(offset).unwrap())
617 .trigger_type(TriggerType::BidAsk)
618 .quantity(Quantity::from(1))
619 .build();
620
621 let result = trailing_stop_calculate(
622 Price::new(0.01, 2),
623 &order,
624 Some(Price::new(bid, 2)),
625 Some(Price::new(ask, 2)),
626 None, );
628
629 let actual_trigger = result.unwrap().0;
630 match (actual_trigger, expected_trigger) {
631 (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
632 (None, None) => (),
633 _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
634 }
635 }
636
637 #[rstest]
638 #[case(OrderSide::Buy, 100.0, 5, 98.0, Some(98.05))] #[case(OrderSide::Buy, 100.0, 10, 97.0, Some(97.10))] #[case(OrderSide::Sell, 100.0, 5, 102.0, Some(101.95))] #[case(OrderSide::Sell, 100.0, 10, 103.0, Some(102.90))] fn test_trailing_stop_market_ticks(
643 #[case] side: OrderSide,
644 #[case] initial_trigger: f64,
645 #[case] ticks: u32,
646 #[case] last_price: f64,
647 #[case] expected_trigger: Option<f64>,
648 ) {
649 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
650 .instrument_id("BTCUSDT-PERP.BINANCE".into())
651 .side(side)
652 .trigger_price(Price::new(initial_trigger, 2))
653 .trailing_offset_type(TrailingOffsetType::Ticks)
654 .trailing_offset(Decimal::from_u32(ticks).unwrap())
655 .trigger_type(TriggerType::LastPrice)
656 .quantity(Quantity::from(1))
657 .build();
658
659 let result = trailing_stop_calculate(
660 Price::new(0.01, 2),
661 &order,
662 None,
663 None,
664 Some(Price::new(last_price, 2)),
665 );
666
667 let actual_trigger = result.unwrap().0;
668 match (actual_trigger, expected_trigger) {
669 (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
670 (None, None) => (),
671 _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
672 }
673 }
674
675 #[rstest]
676 #[case(OrderSide::Buy, 100.0, 1.0, 98.0, 97.0, 98.0, Some(99.0))] #[case(OrderSide::Buy, 100.0, 1.0, 97.0, 96.0, 99.0, Some(98.0))] #[case(OrderSide::Sell, 100.0, 1.0, 102.0, 102.0, 103.0, Some(101.0))] #[case(OrderSide::Sell, 100.0, 1.0, 103.0, 101.0, 102.0, Some(102.0))] fn test_trailing_stop_last_or_bid_ask(
681 #[case] side: OrderSide,
682 #[case] initial_trigger: f64,
683 #[case] offset: f64,
684 #[case] last_price: f64,
685 #[case] bid: f64,
686 #[case] ask: f64,
687 #[case] expected_trigger: Option<f64>,
688 ) {
689 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
690 .instrument_id("BTCUSDT-PERP.BINANCE".into())
691 .side(side)
692 .trigger_price(Price::new(initial_trigger, 2))
693 .trailing_offset_type(TrailingOffsetType::Price)
694 .trailing_offset(Decimal::from_f64(offset).unwrap())
695 .trigger_type(TriggerType::LastOrBidAsk)
696 .quantity(Quantity::from(1))
697 .build();
698
699 let result = trailing_stop_calculate(
700 Price::new(0.01, 2),
701 &order,
702 Some(Price::new(bid, 2)),
703 Some(Price::new(ask, 2)),
704 Some(Price::new(last_price, 2)),
705 );
706
707 let actual_trigger = result.unwrap().0;
708 match (actual_trigger, expected_trigger) {
709 (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
710 (None, None) => (),
711 _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
712 }
713 }
714}