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