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(
45 price_increment: Price,
46 trigger_px: Option<Price>,
47 activation_px: Option<Price>,
48 order: &OrderAny,
49 bid: Option<Price>,
50 ask: Option<Price>,
51 last: Option<Price>,
52) -> anyhow::Result<(Option<Price>, Option<Price>)> {
53 let order_side = order.order_side_specified();
54 let order_type = order.order_type();
55
56 if !matches!(
57 order_type,
58 OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
59 ) {
60 anyhow::bail!("Invalid `OrderType` {order_type} for trailing stop calculation");
61 }
62
63 let mut trigger_price = trigger_px
64 .or(order.trigger_price())
65 .or(activation_px)
66 .or(order.activation_price());
67
68 let mut limit_price = if order_type == OrderType::TrailingStopLimit {
69 order.price()
70 } else {
71 None
72 };
73
74 let trigger_type = order.trigger_type().unwrap();
75 let trailing_offset = order.trailing_offset().unwrap();
76 let trailing_offset_type = order.trailing_offset_type().unwrap();
77 anyhow::ensure!(
78 trigger_type != TriggerType::NoTrigger,
79 "Invalid `TriggerType::NoTrigger` for trailing stop calculation"
80 );
81 anyhow::ensure!(
82 trailing_offset_type != TrailingOffsetType::NoTrailingOffset,
83 "Invalid `TrailingOffsetType::NoTrailingOffset` for trailing stop calculation"
84 );
85
86 let mut new_trigger_price: Option<Price>;
87 let mut new_limit_price: Option<Price> = None;
88
89 let maybe_move = |current: &mut Option<Price>,
90 candidate: Price,
91 better: fn(Price, Price) -> bool|
92 -> Option<Price> {
93 match current {
94 Some(p) if better(candidate, *p) => {
95 *current = Some(candidate);
96 Some(candidate)
97 }
98 None => {
99 *current = Some(candidate);
100 Some(candidate)
101 }
102 _ => None,
103 }
104 };
105
106 let better_trigger: fn(Price, Price) -> bool = match order_side {
107 OrderSideSpecified::Buy => |c, p| c < p,
108 OrderSideSpecified::Sell => |c, p| c > p,
109 };
110 let better_limit = better_trigger;
111
112 let compute = |off: Decimal, basis: f64| -> Price {
113 Price::new(
114 match trailing_offset_type {
115 TrailingOffsetType::Price => off.to_f64().unwrap().mul_add(
116 match order_side {
117 OrderSideSpecified::Buy => 1.0,
118 OrderSideSpecified::Sell => -1.0,
119 },
120 basis,
121 ),
122 TrailingOffsetType::BasisPoints => {
123 let delta = basis * (off.to_f64().unwrap() / 10_000.0);
124 delta.mul_add(
125 match order_side {
126 OrderSideSpecified::Buy => 1.0,
127 OrderSideSpecified::Sell => -1.0,
128 },
129 basis,
130 )
131 }
132 TrailingOffsetType::Ticks => {
133 let delta = off.to_f64().unwrap() * price_increment.as_f64();
134 delta.mul_add(
135 match order_side {
136 OrderSideSpecified::Buy => 1.0,
137 OrderSideSpecified::Sell => -1.0,
138 },
139 basis,
140 )
141 }
142 _ => unreachable!("checked above"),
143 },
144 price_increment.precision,
145 )
146 };
147
148 match trigger_type {
149 TriggerType::LastPrice | TriggerType::MarkPrice => {
150 let last = last.ok_or(OrderError::InvalidStateTransition)?;
151 let cand_trigger = compute(trailing_offset, last.as_f64());
152 new_trigger_price = maybe_move(&mut trigger_price, cand_trigger, better_trigger);
153
154 if order_type == OrderType::TrailingStopLimit {
155 let cand_limit = compute(order.limit_offset().unwrap(), last.as_f64());
156 new_limit_price = maybe_move(&mut limit_price, cand_limit, better_limit);
157 }
158 }
159 TriggerType::Default | TriggerType::BidAsk | TriggerType::LastOrBidAsk => {
160 let (bid, ask) = (
161 bid.ok_or_else(|| anyhow::anyhow!("Bid required"))?,
162 ask.ok_or_else(|| anyhow::anyhow!("Ask required"))?,
163 );
164 let basis = match order_side {
165 OrderSideSpecified::Buy => ask.as_f64(),
166 OrderSideSpecified::Sell => bid.as_f64(),
167 };
168 let cand_trigger = compute(trailing_offset, basis);
169 new_trigger_price = maybe_move(&mut trigger_price, cand_trigger, better_trigger);
170
171 if order_type == OrderType::TrailingStopLimit {
172 let cand_limit = compute(order.limit_offset().unwrap(), basis);
173 new_limit_price = maybe_move(&mut limit_price, cand_limit, better_limit);
174 }
175
176 if trigger_type == TriggerType::LastOrBidAsk {
177 let last = last.ok_or_else(|| anyhow::anyhow!("Last required"))?;
178 let cand_trigger = compute(trailing_offset, last.as_f64());
179 let updated = maybe_move(&mut trigger_price, cand_trigger, better_trigger);
180 if updated.is_some() {
181 new_trigger_price = updated;
182 }
183
184 if order_type == OrderType::TrailingStopLimit {
185 let cand_limit = compute(order.limit_offset().unwrap(), last.as_f64());
186 let updated = maybe_move(&mut limit_price, cand_limit, better_limit);
187 if updated.is_some() {
188 new_limit_price = updated;
189 }
190 }
191 }
192 }
193 _ => anyhow::bail!("`TriggerType` {trigger_type} not currently supported"),
194 }
195
196 Ok((new_trigger_price, new_limit_price))
197}
198
199pub fn trailing_stop_calculate_with_last(
209 price_increment: Price,
210 trailing_offset_type: TrailingOffsetType,
211 side: OrderSideSpecified,
212 offset: Decimal,
213 last: Price,
214) -> anyhow::Result<Price> {
215 let mut offset_value = offset.to_f64().expect("Invalid `offset` value");
216 let last_f64 = last.as_f64();
217
218 match trailing_offset_type {
219 TrailingOffsetType::Price => {} TrailingOffsetType::BasisPoints => {
221 offset_value = last_f64 * (offset_value / 100.0) / 100.0;
222 }
223 TrailingOffsetType::Ticks => {
224 offset_value *= price_increment.as_f64();
225 }
226 _ => anyhow::bail!("`TrailingOffsetType` {trailing_offset_type} not currently supported"),
227 }
228
229 let price_value = match side {
230 OrderSideSpecified::Buy => last_f64 + offset_value,
231 OrderSideSpecified::Sell => last_f64 - offset_value,
232 };
233
234 Ok(Price::new(price_value, price_increment.precision))
235}
236
237pub fn trailing_stop_calculate_with_bid_ask(
247 price_increment: Price,
248 trailing_offset_type: TrailingOffsetType,
249 side: OrderSideSpecified,
250 offset: Decimal,
251 bid: Price,
252 ask: Price,
253) -> anyhow::Result<Price> {
254 let mut offset_value = offset.to_f64().expect("Invalid `offset` value");
255 let bid_f64 = bid.as_f64();
256 let ask_f64 = ask.as_f64();
257
258 match trailing_offset_type {
259 TrailingOffsetType::Price => {} TrailingOffsetType::BasisPoints => match side {
261 OrderSideSpecified::Buy => offset_value = ask_f64 * (offset_value / 100.0) / 100.0,
262 OrderSideSpecified::Sell => offset_value = bid_f64 * (offset_value / 100.0) / 100.0,
263 },
264 TrailingOffsetType::Ticks => {
265 offset_value *= price_increment.as_f64();
266 }
267 _ => anyhow::bail!("`TrailingOffsetType` {trailing_offset_type} not currently supported"),
268 }
269
270 let price_value = match side {
271 OrderSideSpecified::Buy => ask_f64 + offset_value,
272 OrderSideSpecified::Sell => bid_f64 - offset_value,
273 };
274
275 Ok(Price::new(price_value, price_increment.precision))
276}
277
278#[cfg(test)]
279mod tests {
280 use nautilus_model::{
281 enums::{OrderSide, OrderType, TrailingOffsetType, TriggerType},
282 orders::builder::OrderTestBuilder,
283 types::Quantity,
284 };
285 use rstest::rstest;
286 use rust_decimal_macros::dec;
287
288 use super::*;
289
290 #[rstest]
291 fn test_calculate_with_invalid_order_type() {
292 let order = OrderTestBuilder::new(OrderType::Market)
293 .instrument_id("BTCUSDT-PERP.BINANCE".into())
294 .side(OrderSide::Buy)
295 .quantity(Quantity::from(1))
296 .build();
297
298 let result =
299 trailing_stop_calculate(Price::new(0.01, 2), None, None, &order, None, None, None);
300
301 assert!(result.is_err());
303 }
304
305 #[rstest]
306 #[case(OrderSide::Buy)]
307 #[case(OrderSide::Sell)]
308 fn test_calculate_with_last_price_no_last(#[case] side: OrderSide) {
309 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
310 .instrument_id("BTCUSDT-PERP.BINANCE".into())
311 .side(side)
312 .trigger_price(Price::new(100.0, 2))
313 .trailing_offset_type(TrailingOffsetType::Price)
314 .trailing_offset(dec!(1.0))
315 .trigger_type(TriggerType::LastPrice)
316 .quantity(Quantity::from(1))
317 .build();
318
319 let result =
320 trailing_stop_calculate(Price::new(0.01, 2), None, None, &order, None, None, None);
321
322 assert!(result.is_err());
324 }
325
326 #[rstest]
327 #[case(OrderSide::Buy)]
328 #[case(OrderSide::Sell)]
329 fn test_calculate_with_bid_ask_no_bid_ask(#[case] side: OrderSide) {
330 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
331 .instrument_id("BTCUSDT-PERP.BINANCE".into())
332 .side(side)
333 .trigger_price(Price::new(100.0, 2))
334 .trailing_offset_type(TrailingOffsetType::Price)
335 .trailing_offset(dec!(1.0))
336 .trigger_type(TriggerType::BidAsk)
337 .quantity(Quantity::from(1))
338 .build();
339
340 let result =
341 trailing_stop_calculate(Price::new(0.01, 2), None, None, &order, None, None, None);
342
343 assert!(result.is_err());
345 }
346
347 #[rstest]
348 fn test_calculate_with_unsupported_trigger_type() {
349 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
350 .instrument_id("BTCUSDT-PERP.BINANCE".into())
351 .side(OrderSide::Buy)
352 .trigger_price(Price::new(100.0, 2))
353 .trailing_offset_type(TrailingOffsetType::Price)
354 .trailing_offset(dec!(1.0))
355 .trigger_type(TriggerType::IndexPrice) .quantity(Quantity::from(1))
357 .build();
358
359 let result =
360 trailing_stop_calculate(Price::new(0.01, 2), None, None, &order, None, None, None);
361
362 assert!(result.is_err());
364 }
365
366 #[rstest]
367 #[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(
372 #[case] side: OrderSide,
373 #[case] initial_trigger: f64,
374 #[case] offset: f64,
375 #[case] last_price: f64,
376 #[case] expected_trigger: Option<f64>,
377 ) {
378 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
379 .instrument_id("BTCUSDT-PERP.BINANCE".into())
380 .side(side)
381 .trigger_price(Price::new(initial_trigger, 2))
382 .trailing_offset_type(TrailingOffsetType::Price)
383 .trailing_offset(Decimal::from_f64(offset).unwrap())
384 .trigger_type(TriggerType::LastPrice)
385 .quantity(Quantity::from(1))
386 .build();
387
388 let result = trailing_stop_calculate(
389 Price::new(0.01, 2),
390 None,
391 None,
392 &order,
393 None,
394 None,
395 Some(Price::new(last_price, 2)),
396 );
397
398 let actual_trigger = result.unwrap().0;
399 match (actual_trigger, expected_trigger) {
400 (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
401 (None, None) => (),
402 _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
403 }
404 }
405
406 #[rstest]
407 #[case(OrderSide::Buy, 1505.0, 1.0, 1480.0, 1479.0, Some(1481.0))] #[case(OrderSide::Sell, 1495.0, 1.0, 1521.0, 1520.0, Some(1519.0))] fn test_trailing_stop_market_default_uses_bid_ask(
410 #[case] side: OrderSide,
411 #[case] initial_trigger: f64,
412 #[case] offset: f64,
413 #[case] ask: f64,
414 #[case] bid: f64,
415 #[case] expected_trigger: Option<f64>,
416 ) {
417 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
420 .instrument_id("BTCUSDT-PERP.BINANCE".into())
421 .side(side)
422 .trigger_price(Price::new(initial_trigger, 2))
423 .trailing_offset_type(TrailingOffsetType::Price)
424 .trailing_offset(Decimal::from_f64(offset).unwrap())
425 .trigger_type(TriggerType::Default)
426 .quantity(Quantity::from(1))
427 .build();
428
429 let result = trailing_stop_calculate(
430 Price::new(0.01, 2),
431 None,
432 None,
433 &order,
434 Some(Price::new(bid, 2)),
435 Some(Price::new(ask, 2)),
436 None, );
438
439 let actual_trigger = result.unwrap().0;
440 match (actual_trigger, expected_trigger) {
441 (Some(actual), Some(expected)) => assert_eq!(actual, Price::new(expected, 2)),
442 (None, None) => {}
443 (actual, expected) => {
444 panic!("Unexpected trigger: actual={actual:?} expected={expected:?}")
445 }
446 }
447 }
448
449 #[rstest]
450 #[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(
455 #[case] side: OrderSide,
456 #[case] initial_trigger: f64,
457 #[case] basis_points: f64,
458 #[case] last_price: f64,
459 #[case] expected_trigger: Option<f64>,
460 ) {
461 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
462 .instrument_id("BTCUSDT-PERP.BINANCE".into())
463 .side(side)
464 .trigger_price(Price::new(initial_trigger, 2))
465 .trailing_offset_type(TrailingOffsetType::BasisPoints)
466 .trailing_offset(Decimal::from_f64(basis_points).unwrap())
467 .trigger_type(TriggerType::LastPrice)
468 .quantity(Quantity::from(1))
469 .build();
470
471 let result = trailing_stop_calculate(
472 Price::new(0.01, 2),
473 None,
474 None,
475 &order,
476 None,
477 None,
478 Some(Price::new(last_price, 2)),
479 );
480
481 let actual_trigger = result.unwrap().0;
482 match (actual_trigger, expected_trigger) {
483 (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
484 (None, None) => (),
485 _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
486 }
487 }
488
489 #[rstest]
490 #[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(
495 #[case] side: OrderSide,
496 #[case] initial_trigger: f64,
497 #[case] offset: f64,
498 #[case] bid: f64,
499 #[case] ask: f64,
500 #[case] expected_trigger: Option<f64>,
501 ) {
502 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
503 .instrument_id("BTCUSDT-PERP.BINANCE".into())
504 .side(side)
505 .trigger_price(Price::new(initial_trigger, 2))
506 .trailing_offset_type(TrailingOffsetType::Price)
507 .trailing_offset(Decimal::from_f64(offset).unwrap())
508 .trigger_type(TriggerType::BidAsk)
509 .quantity(Quantity::from(1))
510 .build();
511
512 let result = trailing_stop_calculate(
513 Price::new(0.01, 2),
514 None,
515 None,
516 &order,
517 Some(Price::new(bid, 2)),
518 Some(Price::new(ask, 2)),
519 None, );
521
522 let actual_trigger = result.unwrap().0;
523 match (actual_trigger, expected_trigger) {
524 (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
525 (None, None) => (),
526 _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
527 }
528 }
529
530 #[rstest]
531 #[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(
536 #[case] side: OrderSide,
537 #[case] initial_trigger: f64,
538 #[case] ticks: u32,
539 #[case] last_price: f64,
540 #[case] expected_trigger: Option<f64>,
541 ) {
542 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
543 .instrument_id("BTCUSDT-PERP.BINANCE".into())
544 .side(side)
545 .trigger_price(Price::new(initial_trigger, 2))
546 .trailing_offset_type(TrailingOffsetType::Ticks)
547 .trailing_offset(Decimal::from_u32(ticks).unwrap())
548 .trigger_type(TriggerType::LastPrice)
549 .quantity(Quantity::from(1))
550 .build();
551
552 let result = trailing_stop_calculate(
553 Price::new(0.01, 2),
554 None,
555 None,
556 &order,
557 None,
558 None,
559 Some(Price::new(last_price, 2)),
560 );
561
562 let actual_trigger = result.unwrap().0;
563 match (actual_trigger, expected_trigger) {
564 (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
565 (None, None) => (),
566 _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
567 }
568 }
569
570 #[rstest]
571 #[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(
576 #[case] side: OrderSide,
577 #[case] initial_trigger: f64,
578 #[case] offset: f64,
579 #[case] last_price: 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::LastOrBidAsk)
591 .quantity(Quantity::from(1))
592 .build();
593
594 let result = trailing_stop_calculate(
595 Price::new(0.01, 2),
596 None,
597 None,
598 &order,
599 Some(Price::new(bid, 2)),
600 Some(Price::new(ask, 2)),
601 Some(Price::new(last_price, 2)),
602 );
603
604 let actual_trigger = result.unwrap().0;
605 match (actual_trigger, expected_trigger) {
606 (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
607 (None, None) => (),
608 _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
609 }
610 }
611
612 #[rstest]
613 #[case(OrderSide::Buy, 100.0, 1.0, 98.0, Some(99.0))]
614 #[case(OrderSide::Sell, 100.0, 1.0, 102.0, Some(101.0))]
615 fn test_trailing_stop_market_last_price_move_in_favour(
616 #[case] side: OrderSide,
617 #[case] initial_trigger: f64,
618 #[case] offset: f64,
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::Price)
627 .trailing_offset(Decimal::from_f64(offset).unwrap())
628 .trigger_type(TriggerType::LastPrice)
629 .quantity(Quantity::from(1))
630 .build();
631
632 let (maybe_trigger, _) = trailing_stop_calculate(
633 Price::new(0.01, 2),
634 None,
635 None,
636 &order,
637 None,
638 None,
639 Some(Price::new(last_price, 2)),
640 )
641 .unwrap();
642
643 match (maybe_trigger, expected_trigger) {
644 (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
645 (None, None) => (),
646 _ => panic!("expected {expected_trigger:?}, was {maybe_trigger:?}"),
647 }
648 }
649
650 #[rstest]
651 fn test_trailing_stop_limit_last_price_buy_improve_trigger_and_limit() {
652 let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
653 .instrument_id("BTCUSDT-PERP.BINANCE".into())
654 .side(OrderSide::Buy)
655 .trigger_price(Price::new(105.0, 2))
656 .price(Price::new(104.5, 2))
657 .trailing_offset_type(TrailingOffsetType::Price)
658 .trailing_offset(dec!(1.0))
659 .limit_offset(dec!(0.5))
660 .trigger_type(TriggerType::LastPrice)
661 .quantity(Quantity::from(1))
662 .build();
663
664 let (new_trigger, new_limit) = trailing_stop_calculate(
665 Price::new(0.01, 2),
666 None,
667 None,
668 &order,
669 None,
670 None,
671 Some(Price::new(100.0, 2)),
672 )
673 .unwrap();
674
675 assert_eq!(new_trigger.unwrap().as_f64(), 101.0);
676 assert_eq!(new_limit.unwrap().as_f64(), 100.5);
677 }
678
679 #[rstest]
680 fn test_trailing_stop_limit_last_price_sell_improve() {
681 let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
682 .instrument_id("BTCUSDT-PERP.BINANCE".into())
683 .side(OrderSide::Sell)
684 .trigger_price(Price::new(95.0, 2))
685 .price(Price::new(95.5, 2))
686 .trailing_offset_type(TrailingOffsetType::Price)
687 .trailing_offset(dec!(1.0))
688 .limit_offset(dec!(0.5))
689 .trigger_type(TriggerType::LastPrice)
690 .quantity(Quantity::from(1))
691 .build();
692
693 let (new_trigger, new_limit) = trailing_stop_calculate(
694 Price::new(0.01, 2),
695 None,
696 None,
697 &order,
698 None,
699 None,
700 Some(Price::new(100.0, 2)),
701 )
702 .unwrap();
703
704 assert_eq!(new_trigger.unwrap().as_f64(), 99.0);
705 assert_eq!(new_limit.unwrap().as_f64(), 99.5);
706 }
707
708 #[rstest]
709 #[case(OrderSide::Buy, 100.0, 1.0, 99.0)]
710 #[case(OrderSide::Sell, 100.0, 1.0, 101.0)]
711 fn test_no_update_when_candidate_worse(
712 #[case] side: OrderSide,
713 #[case] initial_trigger: f64,
714 #[case] offset: f64,
715 #[case] basis: f64,
716 ) {
717 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
718 .instrument_id("BTCUSDT-PERP.BINANCE".into())
719 .side(side)
720 .trigger_price(Price::new(initial_trigger, 2))
721 .trailing_offset_type(TrailingOffsetType::Price)
722 .trailing_offset(Decimal::from_f64(offset).unwrap())
723 .trigger_type(TriggerType::LastPrice)
724 .quantity(Quantity::from(1))
725 .build();
726
727 let (maybe_trigger, _) = trailing_stop_calculate(
728 Price::new(0.01, 2),
729 None,
730 None,
731 &order,
732 None,
733 None,
734 Some(Price::new(basis, 2)),
735 )
736 .unwrap();
737
738 assert!(maybe_trigger.is_none());
739 }
740
741 #[rstest]
742 fn test_trailing_stop_limit_basis_points_buy_improve() {
743 let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
744 .instrument_id("BTCUSDT-PERP.BINANCE".into())
745 .side(OrderSide::Buy)
746 .trigger_price(Price::new(110.0, 2))
747 .price(Price::new(109.5, 2))
748 .trailing_offset_type(TrailingOffsetType::BasisPoints)
749 .trailing_offset(dec!(50))
750 .limit_offset(dec!(25))
751 .trigger_type(TriggerType::LastPrice)
752 .quantity(Quantity::from(1))
753 .build();
754
755 let (new_trigger, new_limit) = trailing_stop_calculate(
756 Price::new(0.01, 2),
757 None,
758 None,
759 &order,
760 None,
761 None,
762 Some(Price::new(98.0, 2)),
763 )
764 .unwrap();
765
766 assert_eq!(new_trigger.unwrap().as_f64(), 98.49);
767 assert_eq!(new_limit.unwrap().as_f64(), 98.25);
768 }
769}