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