1use nautilus_model::{
18 enums::{OrderSide, OrderType, TrailingOffsetType, TriggerType},
19 orders::{Order, OrderAny, OrderError},
20 types::Price,
21};
22use rust_decimal::Decimal;
23
24pub fn trailing_stop_calculate(
40 price_increment: Price,
41 trigger_px: Option<Price>,
42 order: &OrderAny,
43 bid: Option<Price>,
44 ask: Option<Price>,
45 last: Option<Price>,
46) -> anyhow::Result<(Option<Price>, Option<Price>)> {
47 let order_side = order.order_side();
48 let order_type = order.order_type();
49
50 if !matches!(
51 order_type,
52 OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
53 ) {
54 anyhow::bail!("Invalid `OrderType` {order_type} for trailing stop calculation");
55 }
56
57 let mut trigger_price = trigger_px.or(order.trigger_price());
61
62 let mut limit_price = if order_type == OrderType::TrailingStopLimit {
63 order.price()
64 } else {
65 None
66 };
67
68 let trigger_type = order
69 .trigger_type()
70 .ok_or_else(|| anyhow::anyhow!("Missing `TriggerType` for trailing stop calculation"))?;
71 let trailing_offset = order.trailing_offset().ok_or_else(|| {
72 anyhow::anyhow!("Missing `trailing_offset` for trailing stop calculation")
73 })?;
74 let trailing_offset_type = order.trailing_offset_type().ok_or_else(|| {
75 anyhow::anyhow!("Missing `TrailingOffsetType` for trailing stop calculation")
76 })?;
77 let mut new_trigger_price: Option<Price>;
78 let mut new_limit_price: Option<Price> = None;
79
80 let maybe_move = |current: &mut Option<Price>,
81 candidate: Price,
82 better: fn(Price, Price) -> bool|
83 -> Option<Price> {
84 match current {
85 Some(p) if better(candidate, *p) => {
86 *current = Some(candidate);
87 Some(candidate)
88 }
89 None => {
90 *current = Some(candidate);
91 Some(candidate)
92 }
93 _ => None,
94 }
95 };
96
97 let better_trigger: fn(Price, Price) -> bool = match order_side {
98 OrderSide::Buy => |c, p| c < p,
99 OrderSide::Sell => |c, p| c > p,
100 };
101 let better_limit = better_trigger;
102
103 let compute = |offset: Decimal, basis: Price| {
104 trailing_stop_calculate_with_last(
105 price_increment,
106 trailing_offset_type,
107 order_side,
108 offset,
109 basis,
110 )
111 };
112
113 match trigger_type {
114 TriggerType::LastPrice | TriggerType::MarkPrice => {
115 let last = last.ok_or(OrderError::InvalidStateTransition)?;
116 let cand_trigger = compute(trailing_offset, last)?;
117 new_trigger_price = maybe_move(&mut trigger_price, cand_trigger, better_trigger);
118
119 if order_type == OrderType::TrailingStopLimit {
120 let limit_offset = order.limit_offset().ok_or_else(|| {
121 anyhow::anyhow!("Missing `limit_offset` for trailing stop limit calculation")
122 })?;
123 let cand_limit = compute(limit_offset, last)?;
124 new_limit_price = maybe_move(&mut limit_price, cand_limit, better_limit);
125 }
126 }
127 TriggerType::Default | TriggerType::BidAsk | TriggerType::LastOrBidAsk => {
128 let (bid, ask) = (
129 bid.ok_or_else(|| anyhow::anyhow!("Bid required"))?,
130 ask.ok_or_else(|| anyhow::anyhow!("Ask required"))?,
131 );
132 let basis = match order_side {
133 OrderSide::Buy => ask,
134 OrderSide::Sell => bid,
135 };
136 let cand_trigger = compute(trailing_offset, basis)?;
137 new_trigger_price = maybe_move(&mut trigger_price, cand_trigger, better_trigger);
138
139 if order_type == OrderType::TrailingStopLimit {
140 let limit_offset = order.limit_offset().ok_or_else(|| {
141 anyhow::anyhow!("Missing `limit_offset` for trailing stop limit calculation")
142 })?;
143 let cand_limit = compute(limit_offset, basis)?;
144 new_limit_price = maybe_move(&mut limit_price, cand_limit, better_limit);
145 }
146
147 if trigger_type == TriggerType::LastOrBidAsk {
148 let last = last.ok_or_else(|| anyhow::anyhow!("Last required"))?;
149 let cand_trigger = compute(trailing_offset, last)?;
150 let updated = maybe_move(&mut trigger_price, cand_trigger, better_trigger);
151 if updated.is_some() {
152 new_trigger_price = updated;
153 }
154
155 if order_type == OrderType::TrailingStopLimit {
156 let limit_offset = order.limit_offset().ok_or_else(|| {
157 anyhow::anyhow!(
158 "Missing `limit_offset` for trailing stop limit calculation"
159 )
160 })?;
161 let cand_limit = compute(limit_offset, last)?;
162 let updated = maybe_move(&mut limit_price, cand_limit, better_limit);
163 if updated.is_some() {
164 new_limit_price = updated;
165 }
166 }
167 }
168 }
169 _ => anyhow::bail!("`TriggerType` {trigger_type} not currently supported"),
170 }
171
172 Ok((new_trigger_price, new_limit_price))
173}
174
175pub fn trailing_stop_calculate_with_last(
182 price_increment: Price,
183 trailing_offset_type: TrailingOffsetType,
184 side: OrderSide,
185 offset: Decimal,
186 last: Price,
187) -> anyhow::Result<Price> {
188 let last = last.as_decimal();
189 let offset = match trailing_offset_type {
190 TrailingOffsetType::Price => offset,
191 TrailingOffsetType::BasisPoints => last * offset / Decimal::from(10_000),
192 TrailingOffsetType::Ticks => offset * price_increment.as_decimal(),
193 _ => anyhow::bail!("`TrailingOffsetType` {trailing_offset_type} not currently supported"),
194 };
195
196 let price = match side {
197 OrderSide::Buy => last + offset,
198 OrderSide::Sell => last - offset,
199 };
200
201 Price::from_decimal_dp(price, price_increment.precision).map_err(Into::into)
202}
203
204pub fn trailing_stop_calculate_with_bid_ask(
211 price_increment: Price,
212 trailing_offset_type: TrailingOffsetType,
213 side: OrderSide,
214 offset: Decimal,
215 bid: Price,
216 ask: Price,
217) -> anyhow::Result<Price> {
218 let basis = match side {
219 OrderSide::Buy => ask,
220 OrderSide::Sell => bid,
221 };
222
223 trailing_stop_calculate_with_last(price_increment, trailing_offset_type, side, offset, basis)
224}
225
226#[cfg(test)]
227mod tests {
228 use nautilus_model::{
229 enums::{OrderSide, OrderType, TrailingOffsetType, TriggerType},
230 orders::builder::OrderTestBuilder,
231 types::Quantity,
232 };
233 use rstest::rstest;
234 use rust_decimal::prelude::*;
235 use rust_decimal_macros::dec;
236
237 use super::*;
238
239 fn assert_optional_price(actual: Option<Price>, expected: Option<&str>) {
240 match (actual, expected) {
241 (Some(actual), Some(expected)) => assert_eq!(actual, Price::from(expected)),
242 (None, None) => {}
243 (actual, expected) => panic!("expected {expected:?}, was {actual:?}"),
244 }
245 }
246
247 #[rstest]
248 fn test_calculate_with_invalid_order_type() {
249 let order = OrderTestBuilder::new(OrderType::Market)
250 .instrument_id("BTCUSDT-PERP.BINANCE".into())
251 .side(OrderSide::Buy)
252 .quantity(Quantity::from(1))
253 .build();
254
255 let result = trailing_stop_calculate(Price::new(0.01, 2), None, &order, None, None, None);
256
257 assert!(result.is_err());
259 }
260
261 #[rstest]
262 #[case(OrderSide::Buy)]
263 #[case(OrderSide::Sell)]
264 fn test_calculate_with_last_price_no_last(#[case] side: OrderSide) {
265 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
266 .instrument_id("BTCUSDT-PERP.BINANCE".into())
267 .side(side)
268 .trigger_price(Price::new(100.0, 2))
269 .trailing_offset_type(TrailingOffsetType::Price)
270 .trailing_offset(dec!(1.0))
271 .trigger_type(TriggerType::LastPrice)
272 .quantity(Quantity::from(1))
273 .build();
274
275 let result = trailing_stop_calculate(Price::new(0.01, 2), None, &order, None, None, None);
276
277 assert!(result.is_err());
279 }
280
281 #[rstest]
282 #[case(OrderSide::Buy)]
283 #[case(OrderSide::Sell)]
284 fn test_calculate_with_bid_ask_no_bid_ask(#[case] side: OrderSide) {
285 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
286 .instrument_id("BTCUSDT-PERP.BINANCE".into())
287 .side(side)
288 .trigger_price(Price::new(100.0, 2))
289 .trailing_offset_type(TrailingOffsetType::Price)
290 .trailing_offset(dec!(1.0))
291 .trigger_type(TriggerType::BidAsk)
292 .quantity(Quantity::from(1))
293 .build();
294
295 let result = trailing_stop_calculate(Price::new(0.01, 2), None, &order, None, None, None);
296
297 assert!(result.is_err());
299 }
300
301 #[rstest]
302 fn test_calculate_with_unsupported_trigger_type() {
303 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
304 .instrument_id("BTCUSDT-PERP.BINANCE".into())
305 .side(OrderSide::Buy)
306 .trigger_price(Price::new(100.0, 2))
307 .trailing_offset_type(TrailingOffsetType::Price)
308 .trailing_offset(dec!(1.0))
309 .trigger_type(TriggerType::IndexPrice) .quantity(Quantity::from(1))
311 .build();
312
313 let result = trailing_stop_calculate(Price::new(0.01, 2), None, &order, None, None, None);
314
315 assert!(result.is_err());
317 }
318
319 #[rstest]
320 #[should_panic(expected = "Trailing offset type not set")]
321 fn test_build_without_trailing_offset_type_panics() {
322 let _ = OrderTestBuilder::new(OrderType::TrailingStopMarket)
323 .instrument_id("BTCUSDT-PERP.BINANCE".into())
324 .side(OrderSide::Buy)
325 .trigger_price(Price::new(100.0, 2))
326 .trailing_offset(dec!(1.0))
327 .trigger_type(TriggerType::LastPrice)
328 .quantity(Quantity::from(1))
329 .build();
330 }
331
332 #[rstest]
333 #[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(
338 #[case] side: OrderSide,
339 #[case] initial_trigger: f64,
340 #[case] offset: f64,
341 #[case] last_price: f64,
342 #[case] expected_trigger: Option<&str>,
343 ) {
344 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
345 .instrument_id("BTCUSDT-PERP.BINANCE".into())
346 .side(side)
347 .trigger_price(Price::new(initial_trigger, 2))
348 .trailing_offset_type(TrailingOffsetType::Price)
349 .trailing_offset(Decimal::from_f64(offset).unwrap())
350 .trigger_type(TriggerType::LastPrice)
351 .quantity(Quantity::from(1))
352 .build();
353
354 let result = trailing_stop_calculate(
355 Price::new(0.01, 2),
356 None,
357 &order,
358 None,
359 None,
360 Some(Price::new(last_price, 2)),
361 );
362
363 assert_optional_price(result.unwrap().0, expected_trigger);
364 }
365
366 #[rstest]
367 #[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(
370 #[case] side: OrderSide,
371 #[case] initial_trigger: f64,
372 #[case] offset: f64,
373 #[case] ask: f64,
374 #[case] bid: f64,
375 #[case] expected_trigger: Option<&str>,
376 ) {
377 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
380 .instrument_id("BTCUSDT-PERP.BINANCE".into())
381 .side(side)
382 .trigger_price(Price::new(initial_trigger, 2))
383 .trailing_offset_type(TrailingOffsetType::Price)
384 .trailing_offset(Decimal::from_f64(offset).unwrap())
385 .trigger_type(TriggerType::Default)
386 .quantity(Quantity::from(1))
387 .build();
388
389 let result = trailing_stop_calculate(
390 Price::new(0.01, 2),
391 None,
392 &order,
393 Some(Price::new(bid, 2)),
394 Some(Price::new(ask, 2)),
395 None, );
397
398 assert_optional_price(result.unwrap().0, expected_trigger);
399 }
400
401 #[rstest]
402 #[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(
407 #[case] side: OrderSide,
408 #[case] initial_trigger: f64,
409 #[case] basis_points: f64,
410 #[case] last_price: f64,
411 #[case] expected_trigger: Option<&str>,
412 ) {
413 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
414 .instrument_id("BTCUSDT-PERP.BINANCE".into())
415 .side(side)
416 .trigger_price(Price::new(initial_trigger, 2))
417 .trailing_offset_type(TrailingOffsetType::BasisPoints)
418 .trailing_offset(Decimal::from_f64(basis_points).unwrap())
419 .trigger_type(TriggerType::LastPrice)
420 .quantity(Quantity::from(1))
421 .build();
422
423 let result = trailing_stop_calculate(
424 Price::new(0.01, 2),
425 None,
426 &order,
427 None,
428 None,
429 Some(Price::new(last_price, 2)),
430 );
431
432 assert_optional_price(result.unwrap().0, expected_trigger);
433 }
434
435 #[rstest]
436 #[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(
441 #[case] side: OrderSide,
442 #[case] initial_trigger: f64,
443 #[case] offset: f64,
444 #[case] bid: f64,
445 #[case] ask: f64,
446 #[case] expected_trigger: Option<&str>,
447 ) {
448 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
449 .instrument_id("BTCUSDT-PERP.BINANCE".into())
450 .side(side)
451 .trigger_price(Price::new(initial_trigger, 2))
452 .trailing_offset_type(TrailingOffsetType::Price)
453 .trailing_offset(Decimal::from_f64(offset).unwrap())
454 .trigger_type(TriggerType::BidAsk)
455 .quantity(Quantity::from(1))
456 .build();
457
458 let result = trailing_stop_calculate(
459 Price::new(0.01, 2),
460 None,
461 &order,
462 Some(Price::new(bid, 2)),
463 Some(Price::new(ask, 2)),
464 None, );
466
467 assert_optional_price(result.unwrap().0, expected_trigger);
468 }
469
470 #[rstest]
471 #[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(
476 #[case] side: OrderSide,
477 #[case] initial_trigger: f64,
478 #[case] ticks: u32,
479 #[case] last_price: f64,
480 #[case] expected_trigger: Option<&str>,
481 ) {
482 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
483 .instrument_id("BTCUSDT-PERP.BINANCE".into())
484 .side(side)
485 .trigger_price(Price::new(initial_trigger, 2))
486 .trailing_offset_type(TrailingOffsetType::Ticks)
487 .trailing_offset(Decimal::from_u32(ticks).unwrap())
488 .trigger_type(TriggerType::LastPrice)
489 .quantity(Quantity::from(1))
490 .build();
491
492 let result = trailing_stop_calculate(
493 Price::new(0.01, 2),
494 None,
495 &order,
496 None,
497 None,
498 Some(Price::new(last_price, 2)),
499 );
500
501 assert_optional_price(result.unwrap().0, expected_trigger);
502 }
503
504 #[rstest]
505 #[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(
510 #[case] side: OrderSide,
511 #[case] initial_trigger: f64,
512 #[case] offset: f64,
513 #[case] last_price: f64,
514 #[case] bid: f64,
515 #[case] ask: f64,
516 #[case] expected_trigger: Option<&str>,
517 ) {
518 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
519 .instrument_id("BTCUSDT-PERP.BINANCE".into())
520 .side(side)
521 .trigger_price(Price::new(initial_trigger, 2))
522 .trailing_offset_type(TrailingOffsetType::Price)
523 .trailing_offset(Decimal::from_f64(offset).unwrap())
524 .trigger_type(TriggerType::LastOrBidAsk)
525 .quantity(Quantity::from(1))
526 .build();
527
528 let result = trailing_stop_calculate(
529 Price::new(0.01, 2),
530 None,
531 &order,
532 Some(Price::new(bid, 2)),
533 Some(Price::new(ask, 2)),
534 Some(Price::new(last_price, 2)),
535 );
536
537 assert_optional_price(result.unwrap().0, expected_trigger);
538 }
539
540 #[rstest]
541 #[case(OrderSide::Buy, 100.0, 1.0, 98.0, Some("99.0"))]
542 #[case(OrderSide::Sell, 100.0, 1.0, 102.0, Some("101.0"))]
543 fn test_trailing_stop_market_last_price_move_in_favor(
544 #[case] side: OrderSide,
545 #[case] initial_trigger: f64,
546 #[case] offset: f64,
547 #[case] last_price: f64,
548 #[case] expected_trigger: Option<&str>,
549 ) {
550 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
551 .instrument_id("BTCUSDT-PERP.BINANCE".into())
552 .side(side)
553 .trigger_price(Price::new(initial_trigger, 2))
554 .trailing_offset_type(TrailingOffsetType::Price)
555 .trailing_offset(Decimal::from_f64(offset).unwrap())
556 .trigger_type(TriggerType::LastPrice)
557 .quantity(Quantity::from(1))
558 .build();
559
560 let (maybe_trigger, _) = trailing_stop_calculate(
561 Price::new(0.01, 2),
562 None,
563 &order,
564 None,
565 None,
566 Some(Price::new(last_price, 2)),
567 )
568 .unwrap();
569
570 assert_optional_price(maybe_trigger, expected_trigger);
571 }
572
573 #[rstest]
574 fn test_trailing_stop_limit_last_price_buy_improve_trigger_and_limit() {
575 let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
576 .instrument_id("BTCUSDT-PERP.BINANCE".into())
577 .side(OrderSide::Buy)
578 .trigger_price(Price::new(105.0, 2))
579 .price(Price::new(104.5, 2))
580 .trailing_offset_type(TrailingOffsetType::Price)
581 .trailing_offset(dec!(1.0))
582 .limit_offset(dec!(0.5))
583 .trigger_type(TriggerType::LastPrice)
584 .quantity(Quantity::from(1))
585 .build();
586
587 let (new_trigger, new_limit) = trailing_stop_calculate(
588 Price::new(0.01, 2),
589 None,
590 &order,
591 None,
592 None,
593 Some(Price::new(100.0, 2)),
594 )
595 .unwrap();
596
597 assert_eq!(new_trigger.unwrap(), Price::from("101.0"));
598 assert_eq!(new_limit.unwrap(), Price::from("100.5"));
599 }
600
601 #[rstest]
602 fn test_trailing_stop_limit_last_price_sell_improve() {
603 let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
604 .instrument_id("BTCUSDT-PERP.BINANCE".into())
605 .side(OrderSide::Sell)
606 .trigger_price(Price::new(95.0, 2))
607 .price(Price::new(95.5, 2))
608 .trailing_offset_type(TrailingOffsetType::Price)
609 .trailing_offset(dec!(1.0))
610 .limit_offset(dec!(0.5))
611 .trigger_type(TriggerType::LastPrice)
612 .quantity(Quantity::from(1))
613 .build();
614
615 let (new_trigger, new_limit) = trailing_stop_calculate(
616 Price::new(0.01, 2),
617 None,
618 &order,
619 None,
620 None,
621 Some(Price::new(100.0, 2)),
622 )
623 .unwrap();
624
625 assert_eq!(new_trigger.unwrap(), Price::from("99.0"));
626 assert_eq!(new_limit.unwrap(), Price::from("99.5"));
627 }
628
629 #[rstest]
630 #[case(OrderSide::Buy, 100.0, 1.0, 99.0)]
631 #[case(OrderSide::Sell, 100.0, 1.0, 101.0)]
632 fn test_no_update_when_candidate_worse(
633 #[case] side: OrderSide,
634 #[case] initial_trigger: f64,
635 #[case] offset: f64,
636 #[case] basis: f64,
637 ) {
638 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
639 .instrument_id("BTCUSDT-PERP.BINANCE".into())
640 .side(side)
641 .trigger_price(Price::new(initial_trigger, 2))
642 .trailing_offset_type(TrailingOffsetType::Price)
643 .trailing_offset(Decimal::from_f64(offset).unwrap())
644 .trigger_type(TriggerType::LastPrice)
645 .quantity(Quantity::from(1))
646 .build();
647
648 let (maybe_trigger, _) = trailing_stop_calculate(
649 Price::new(0.01, 2),
650 None,
651 &order,
652 None,
653 None,
654 Some(Price::new(basis, 2)),
655 )
656 .unwrap();
657
658 assert!(maybe_trigger.is_none());
659 }
660
661 #[rstest]
662 #[case(
663 TrailingOffsetType::Price,
664 OrderSide::Buy,
665 dec!(1.25),
666 Price::from("98.00"),
667 Price::from("99.25")
668 )]
669 #[case(
670 TrailingOffsetType::BasisPoints,
671 OrderSide::Buy,
672 dec!(50),
673 Price::from("98.00"),
674 Price::from("98.49")
675 )]
676 #[case(
677 TrailingOffsetType::Ticks,
678 OrderSide::Sell,
679 dec!(5),
680 Price::from("102.00"),
681 Price::from("101.95")
682 )]
683 fn test_calculate_with_last_uses_decimal_math(
684 #[case] trailing_offset_type: TrailingOffsetType,
685 #[case] side: OrderSide,
686 #[case] offset: Decimal,
687 #[case] last: Price,
688 #[case] expected: Price,
689 ) {
690 let price = trailing_stop_calculate_with_last(
691 Price::from("0.01"),
692 trailing_offset_type,
693 side,
694 offset,
695 last,
696 )
697 .unwrap();
698
699 assert_eq!(price, expected);
700 }
701
702 #[rstest]
703 #[case(
704 TrailingOffsetType::Price,
705 OrderSide::Sell,
706 dec!(1.25),
707 Price::from("102.00"),
708 Price::from("103.00"),
709 Price::from("100.75")
710 )]
711 #[case(
712 TrailingOffsetType::BasisPoints,
713 OrderSide::Sell,
714 dec!(50),
715 Price::from("102.00"),
716 Price::from("103.00"),
717 Price::from("101.49")
718 )]
719 #[case(
720 TrailingOffsetType::Ticks,
721 OrderSide::Buy,
722 dec!(5),
723 Price::from("102.00"),
724 Price::from("103.00"),
725 Price::from("103.05")
726 )]
727 fn test_calculate_with_bid_ask_uses_decimal_math(
728 #[case] trailing_offset_type: TrailingOffsetType,
729 #[case] side: OrderSide,
730 #[case] offset: Decimal,
731 #[case] bid: Price,
732 #[case] ask: Price,
733 #[case] expected: Price,
734 ) {
735 let price = trailing_stop_calculate_with_bid_ask(
736 Price::from("0.01"),
737 trailing_offset_type,
738 side,
739 offset,
740 bid,
741 ask,
742 )
743 .unwrap();
744
745 assert_eq!(price, expected);
746 }
747
748 #[rstest]
749 fn test_trailing_stop_limit_basis_points_buy_improve() {
750 let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
751 .instrument_id("BTCUSDT-PERP.BINANCE".into())
752 .side(OrderSide::Buy)
753 .trigger_price(Price::new(110.0, 2))
754 .price(Price::new(109.5, 2))
755 .trailing_offset_type(TrailingOffsetType::BasisPoints)
756 .trailing_offset(dec!(50))
757 .limit_offset(dec!(25))
758 .trigger_type(TriggerType::LastPrice)
759 .quantity(Quantity::from(1))
760 .build();
761
762 let (new_trigger, new_limit) = trailing_stop_calculate(
763 Price::new(0.01, 2),
764 None,
765 &order,
766 None,
767 None,
768 Some(Price::new(98.0, 2)),
769 )
770 .unwrap();
771
772 assert_eq!(new_trigger.unwrap(), Price::from("98.49"));
773 assert_eq!(new_limit.unwrap(), Price::from("98.24"));
774 }
775
776 #[rstest]
777 #[case(OrderSide::Buy, "105.00", "100.00", "101.00")]
778 #[case(OrderSide::Sell, "95.00", "100.00", "99.00")]
779 fn test_trigger_override_takes_precedence(
780 #[case] side: OrderSide,
781 #[case] stored: &str,
782 #[case] override_price: &str,
783 #[case] last: &str,
784 ) {
785 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
786 .instrument_id("BTCUSDT-PERP.BINANCE".into())
787 .side(side)
788 .trigger_price(Price::from(stored))
789 .trailing_offset_type(TrailingOffsetType::Price)
790 .trailing_offset(dec!(1))
791 .trigger_type(TriggerType::LastPrice)
792 .quantity(Quantity::from(1))
793 .build();
794
795 let result = trailing_stop_calculate(
796 Price::from("0.01"),
797 Some(Price::from(override_price)),
798 &order,
799 None,
800 None,
801 Some(Price::from(last)),
802 )
803 .unwrap();
804
805 assert_eq!(result, (None, None));
806 }
807
808 #[rstest]
809 #[case(OrderSide::Buy, "100.00", "105.00", None, Some("100.50"))]
810 #[case(OrderSide::Buy, "105.00", "100.00", Some("101.00"), None)]
811 #[case(OrderSide::Sell, "100.00", "95.00", None, Some("99.50"))]
812 #[case(OrderSide::Sell, "95.00", "100.00", Some("99.00"), None)]
813 fn test_trailing_limit_prices_improve_independently(
814 #[case] side: OrderSide,
815 #[case] trigger: &str,
816 #[case] limit: &str,
817 #[case] expected_trigger: Option<&str>,
818 #[case] expected_limit: Option<&str>,
819 ) {
820 let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
821 .instrument_id("BTCUSDT-PERP.BINANCE".into())
822 .side(side)
823 .trigger_price(Price::from(trigger))
824 .price(Price::from(limit))
825 .trailing_offset_type(TrailingOffsetType::Price)
826 .trailing_offset(dec!(1))
827 .limit_offset(dec!(0.5))
828 .trigger_type(TriggerType::LastPrice)
829 .quantity(Quantity::from(1))
830 .build();
831
832 let result = trailing_stop_calculate(
833 Price::from("0.01"),
834 None,
835 &order,
836 None,
837 None,
838 Some(Price::from("100.00")),
839 )
840 .unwrap();
841
842 assert_eq!(
843 result,
844 (
845 expected_trigger.map(Price::from),
846 expected_limit.map(Price::from)
847 )
848 );
849 }
850
851 #[rstest]
852 #[case(OrderSide::Buy, "110.00", "98.00", "99.00", "98.50")]
853 #[case(OrderSide::Buy, "110.00", "103.00", "102.00", "101.50")]
854 #[case(OrderSide::Sell, "90.00", "103.00", "102.00", "102.50")]
855 #[case(OrderSide::Sell, "90.00", "98.00", "99.00", "99.50")]
856 fn test_trailing_limit_last_or_bid_ask_keeps_best_prices(
857 #[case] side: OrderSide,
858 #[case] initial: &str,
859 #[case] last: &str,
860 #[case] expected_trigger: &str,
861 #[case] expected_limit: &str,
862 ) {
863 let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
864 .instrument_id("BTCUSDT-PERP.BINANCE".into())
865 .side(side)
866 .trigger_price(Price::from(initial))
867 .price(Price::from(initial))
868 .trailing_offset_type(TrailingOffsetType::Price)
869 .trailing_offset(dec!(1))
870 .limit_offset(dec!(0.5))
871 .trigger_type(TriggerType::LastOrBidAsk)
872 .quantity(Quantity::from(1))
873 .build();
874
875 let result = trailing_stop_calculate(
876 Price::from("0.01"),
877 None,
878 &order,
879 Some(Price::from("100.00")),
880 Some(Price::from("101.00")),
881 Some(Price::from(last)),
882 )
883 .unwrap();
884
885 assert_eq!(
886 result,
887 (
888 Some(Price::from(expected_trigger)),
889 Some(Price::from(expected_limit))
890 )
891 );
892 }
893}