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 = |off: Decimal, basis: Price| -> anyhow::Result<Price> {
104 let basis = basis.as_decimal();
105 let offset = match trailing_offset_type {
106 TrailingOffsetType::Price => off,
107 TrailingOffsetType::BasisPoints => basis * off / Decimal::from(10_000),
108 TrailingOffsetType::Ticks => off * price_increment.as_decimal(),
109 _ => {
110 anyhow::bail!("`TrailingOffsetType` {trailing_offset_type} not currently supported")
111 }
112 };
113 let value = match order_side {
114 OrderSide::Buy => basis + offset,
115 OrderSide::Sell => basis - offset,
116 };
117 Price::from_decimal_dp(value, price_increment.precision).map_err(Into::into)
118 };
119
120 match trigger_type {
121 TriggerType::LastPrice | TriggerType::MarkPrice => {
122 let last = last.ok_or(OrderError::InvalidStateTransition)?;
123 let cand_trigger = compute(trailing_offset, last)?;
124 new_trigger_price = maybe_move(&mut trigger_price, cand_trigger, better_trigger);
125
126 if order_type == OrderType::TrailingStopLimit {
127 let limit_offset = order.limit_offset().ok_or_else(|| {
128 anyhow::anyhow!("Missing `limit_offset` for trailing stop limit calculation")
129 })?;
130 let cand_limit = compute(limit_offset, last)?;
131 new_limit_price = maybe_move(&mut limit_price, cand_limit, better_limit);
132 }
133 }
134 TriggerType::Default | TriggerType::BidAsk | TriggerType::LastOrBidAsk => {
135 let (bid, ask) = (
136 bid.ok_or_else(|| anyhow::anyhow!("Bid required"))?,
137 ask.ok_or_else(|| anyhow::anyhow!("Ask required"))?,
138 );
139 let basis = match order_side {
140 OrderSide::Buy => ask,
141 OrderSide::Sell => bid,
142 };
143 let cand_trigger = compute(trailing_offset, basis)?;
144 new_trigger_price = maybe_move(&mut trigger_price, cand_trigger, better_trigger);
145
146 if order_type == OrderType::TrailingStopLimit {
147 let limit_offset = order.limit_offset().ok_or_else(|| {
148 anyhow::anyhow!("Missing `limit_offset` for trailing stop limit calculation")
149 })?;
150 let cand_limit = compute(limit_offset, basis)?;
151 new_limit_price = maybe_move(&mut limit_price, cand_limit, better_limit);
152 }
153
154 if trigger_type == TriggerType::LastOrBidAsk {
155 let last = last.ok_or_else(|| anyhow::anyhow!("Last required"))?;
156 let cand_trigger = compute(trailing_offset, last)?;
157 let updated = maybe_move(&mut trigger_price, cand_trigger, better_trigger);
158 if updated.is_some() {
159 new_trigger_price = updated;
160 }
161
162 if order_type == OrderType::TrailingStopLimit {
163 let limit_offset = order.limit_offset().ok_or_else(|| {
164 anyhow::anyhow!(
165 "Missing `limit_offset` for trailing stop limit calculation"
166 )
167 })?;
168 let cand_limit = compute(limit_offset, last)?;
169 let updated = maybe_move(&mut limit_price, cand_limit, better_limit);
170 if updated.is_some() {
171 new_limit_price = updated;
172 }
173 }
174 }
175 }
176 _ => anyhow::bail!("`TriggerType` {trigger_type} not currently supported"),
177 }
178
179 Ok((new_trigger_price, new_limit_price))
180}
181
182pub fn trailing_stop_calculate_with_last(
189 price_increment: Price,
190 trailing_offset_type: TrailingOffsetType,
191 side: OrderSide,
192 offset: Decimal,
193 last: Price,
194) -> anyhow::Result<Price> {
195 let last = last.as_decimal();
196 let offset = match trailing_offset_type {
197 TrailingOffsetType::Price => offset,
198 TrailingOffsetType::BasisPoints => last * offset / Decimal::from(10_000),
199 TrailingOffsetType::Ticks => offset * price_increment.as_decimal(),
200 _ => anyhow::bail!("`TrailingOffsetType` {trailing_offset_type} not currently supported"),
201 };
202
203 let price = match side {
204 OrderSide::Buy => last + offset,
205 OrderSide::Sell => last - offset,
206 };
207
208 Price::from_decimal_dp(price, price_increment.precision).map_err(Into::into)
209}
210
211pub fn trailing_stop_calculate_with_bid_ask(
218 price_increment: Price,
219 trailing_offset_type: TrailingOffsetType,
220 side: OrderSide,
221 offset: Decimal,
222 bid: Price,
223 ask: Price,
224) -> anyhow::Result<Price> {
225 let bid = bid.as_decimal();
226 let ask = ask.as_decimal();
227
228 let offset = match trailing_offset_type {
229 TrailingOffsetType::Price => offset,
230 TrailingOffsetType::BasisPoints => match side {
231 OrderSide::Buy => ask * offset / Decimal::from(10_000),
232 OrderSide::Sell => bid * offset / Decimal::from(10_000),
233 },
234 TrailingOffsetType::Ticks => offset * price_increment.as_decimal(),
235 _ => anyhow::bail!("`TrailingOffsetType` {trailing_offset_type} not currently supported"),
236 };
237
238 let price = match side {
239 OrderSide::Buy => ask + offset,
240 OrderSide::Sell => bid - offset,
241 };
242
243 Price::from_decimal_dp(price, price_increment.precision).map_err(Into::into)
244}
245
246#[cfg(test)]
247mod tests {
248 use nautilus_model::{
249 enums::{OrderSide, OrderType, TrailingOffsetType, TriggerType},
250 orders::builder::OrderTestBuilder,
251 types::Quantity,
252 };
253 use rstest::rstest;
254 use rust_decimal::prelude::*;
255 use rust_decimal_macros::dec;
256
257 use super::*;
258
259 fn assert_optional_price(actual: Option<Price>, expected: Option<&str>) {
260 match (actual, expected) {
261 (Some(actual), Some(expected)) => assert_eq!(actual, Price::from(expected)),
262 (None, None) => {}
263 (actual, expected) => panic!("expected {expected:?}, was {actual:?}"),
264 }
265 }
266
267 #[rstest]
268 fn test_calculate_with_invalid_order_type() {
269 let order = OrderTestBuilder::new(OrderType::Market)
270 .instrument_id("BTCUSDT-PERP.BINANCE".into())
271 .side(OrderSide::Buy)
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_last_price_no_last(#[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::LastPrice)
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 #[case(OrderSide::Buy)]
303 #[case(OrderSide::Sell)]
304 fn test_calculate_with_bid_ask_no_bid_ask(#[case] side: OrderSide) {
305 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
306 .instrument_id("BTCUSDT-PERP.BINANCE".into())
307 .side(side)
308 .trigger_price(Price::new(100.0, 2))
309 .trailing_offset_type(TrailingOffsetType::Price)
310 .trailing_offset(dec!(1.0))
311 .trigger_type(TriggerType::BidAsk)
312 .quantity(Quantity::from(1))
313 .build();
314
315 let result = trailing_stop_calculate(Price::new(0.01, 2), None, &order, None, None, None);
316
317 assert!(result.is_err());
319 }
320
321 #[rstest]
322 fn test_calculate_with_unsupported_trigger_type() {
323 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
324 .instrument_id("BTCUSDT-PERP.BINANCE".into())
325 .side(OrderSide::Buy)
326 .trigger_price(Price::new(100.0, 2))
327 .trailing_offset_type(TrailingOffsetType::Price)
328 .trailing_offset(dec!(1.0))
329 .trigger_type(TriggerType::IndexPrice) .quantity(Quantity::from(1))
331 .build();
332
333 let result = trailing_stop_calculate(Price::new(0.01, 2), None, &order, None, None, None);
334
335 assert!(result.is_err());
337 }
338
339 #[rstest]
340 #[should_panic(expected = "Trailing offset type not set")]
341 fn test_build_without_trailing_offset_type_panics() {
342 let _ = OrderTestBuilder::new(OrderType::TrailingStopMarket)
343 .instrument_id("BTCUSDT-PERP.BINANCE".into())
344 .side(OrderSide::Buy)
345 .trigger_price(Price::new(100.0, 2))
346 .trailing_offset(dec!(1.0))
347 .trigger_type(TriggerType::LastPrice)
348 .quantity(Quantity::from(1))
349 .build();
350 }
351
352 #[rstest]
353 #[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(
358 #[case] side: OrderSide,
359 #[case] initial_trigger: f64,
360 #[case] offset: f64,
361 #[case] last_price: f64,
362 #[case] expected_trigger: Option<&str>,
363 ) {
364 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
365 .instrument_id("BTCUSDT-PERP.BINANCE".into())
366 .side(side)
367 .trigger_price(Price::new(initial_trigger, 2))
368 .trailing_offset_type(TrailingOffsetType::Price)
369 .trailing_offset(Decimal::from_f64(offset).unwrap())
370 .trigger_type(TriggerType::LastPrice)
371 .quantity(Quantity::from(1))
372 .build();
373
374 let result = trailing_stop_calculate(
375 Price::new(0.01, 2),
376 None,
377 &order,
378 None,
379 None,
380 Some(Price::new(last_price, 2)),
381 );
382
383 assert_optional_price(result.unwrap().0, expected_trigger);
384 }
385
386 #[rstest]
387 #[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(
390 #[case] side: OrderSide,
391 #[case] initial_trigger: f64,
392 #[case] offset: f64,
393 #[case] ask: f64,
394 #[case] bid: f64,
395 #[case] expected_trigger: Option<&str>,
396 ) {
397 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
400 .instrument_id("BTCUSDT-PERP.BINANCE".into())
401 .side(side)
402 .trigger_price(Price::new(initial_trigger, 2))
403 .trailing_offset_type(TrailingOffsetType::Price)
404 .trailing_offset(Decimal::from_f64(offset).unwrap())
405 .trigger_type(TriggerType::Default)
406 .quantity(Quantity::from(1))
407 .build();
408
409 let result = trailing_stop_calculate(
410 Price::new(0.01, 2),
411 None,
412 &order,
413 Some(Price::new(bid, 2)),
414 Some(Price::new(ask, 2)),
415 None, );
417
418 assert_optional_price(result.unwrap().0, expected_trigger);
419 }
420
421 #[rstest]
422 #[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(
427 #[case] side: OrderSide,
428 #[case] initial_trigger: f64,
429 #[case] basis_points: f64,
430 #[case] last_price: f64,
431 #[case] expected_trigger: Option<&str>,
432 ) {
433 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
434 .instrument_id("BTCUSDT-PERP.BINANCE".into())
435 .side(side)
436 .trigger_price(Price::new(initial_trigger, 2))
437 .trailing_offset_type(TrailingOffsetType::BasisPoints)
438 .trailing_offset(Decimal::from_f64(basis_points).unwrap())
439 .trigger_type(TriggerType::LastPrice)
440 .quantity(Quantity::from(1))
441 .build();
442
443 let result = trailing_stop_calculate(
444 Price::new(0.01, 2),
445 None,
446 &order,
447 None,
448 None,
449 Some(Price::new(last_price, 2)),
450 );
451
452 assert_optional_price(result.unwrap().0, expected_trigger);
453 }
454
455 #[rstest]
456 #[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(
461 #[case] side: OrderSide,
462 #[case] initial_trigger: f64,
463 #[case] offset: f64,
464 #[case] bid: f64,
465 #[case] ask: f64,
466 #[case] expected_trigger: Option<&str>,
467 ) {
468 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
469 .instrument_id("BTCUSDT-PERP.BINANCE".into())
470 .side(side)
471 .trigger_price(Price::new(initial_trigger, 2))
472 .trailing_offset_type(TrailingOffsetType::Price)
473 .trailing_offset(Decimal::from_f64(offset).unwrap())
474 .trigger_type(TriggerType::BidAsk)
475 .quantity(Quantity::from(1))
476 .build();
477
478 let result = trailing_stop_calculate(
479 Price::new(0.01, 2),
480 None,
481 &order,
482 Some(Price::new(bid, 2)),
483 Some(Price::new(ask, 2)),
484 None, );
486
487 assert_optional_price(result.unwrap().0, expected_trigger);
488 }
489
490 #[rstest]
491 #[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(
496 #[case] side: OrderSide,
497 #[case] initial_trigger: f64,
498 #[case] ticks: u32,
499 #[case] last_price: f64,
500 #[case] expected_trigger: Option<&str>,
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::Ticks)
507 .trailing_offset(Decimal::from_u32(ticks).unwrap())
508 .trigger_type(TriggerType::LastPrice)
509 .quantity(Quantity::from(1))
510 .build();
511
512 let result = trailing_stop_calculate(
513 Price::new(0.01, 2),
514 None,
515 &order,
516 None,
517 None,
518 Some(Price::new(last_price, 2)),
519 );
520
521 assert_optional_price(result.unwrap().0, expected_trigger);
522 }
523
524 #[rstest]
525 #[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(
530 #[case] side: OrderSide,
531 #[case] initial_trigger: f64,
532 #[case] offset: f64,
533 #[case] last_price: f64,
534 #[case] bid: f64,
535 #[case] ask: f64,
536 #[case] expected_trigger: Option<&str>,
537 ) {
538 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
539 .instrument_id("BTCUSDT-PERP.BINANCE".into())
540 .side(side)
541 .trigger_price(Price::new(initial_trigger, 2))
542 .trailing_offset_type(TrailingOffsetType::Price)
543 .trailing_offset(Decimal::from_f64(offset).unwrap())
544 .trigger_type(TriggerType::LastOrBidAsk)
545 .quantity(Quantity::from(1))
546 .build();
547
548 let result = trailing_stop_calculate(
549 Price::new(0.01, 2),
550 None,
551 &order,
552 Some(Price::new(bid, 2)),
553 Some(Price::new(ask, 2)),
554 Some(Price::new(last_price, 2)),
555 );
556
557 assert_optional_price(result.unwrap().0, expected_trigger);
558 }
559
560 #[rstest]
561 #[case(OrderSide::Buy, 100.0, 1.0, 98.0, Some("99.0"))]
562 #[case(OrderSide::Sell, 100.0, 1.0, 102.0, Some("101.0"))]
563 fn test_trailing_stop_market_last_price_move_in_favour(
564 #[case] side: OrderSide,
565 #[case] initial_trigger: f64,
566 #[case] offset: f64,
567 #[case] last_price: f64,
568 #[case] expected_trigger: Option<&str>,
569 ) {
570 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
571 .instrument_id("BTCUSDT-PERP.BINANCE".into())
572 .side(side)
573 .trigger_price(Price::new(initial_trigger, 2))
574 .trailing_offset_type(TrailingOffsetType::Price)
575 .trailing_offset(Decimal::from_f64(offset).unwrap())
576 .trigger_type(TriggerType::LastPrice)
577 .quantity(Quantity::from(1))
578 .build();
579
580 let (maybe_trigger, _) = trailing_stop_calculate(
581 Price::new(0.01, 2),
582 None,
583 &order,
584 None,
585 None,
586 Some(Price::new(last_price, 2)),
587 )
588 .unwrap();
589
590 assert_optional_price(maybe_trigger, expected_trigger);
591 }
592
593 #[rstest]
594 fn test_trailing_stop_limit_last_price_buy_improve_trigger_and_limit() {
595 let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
596 .instrument_id("BTCUSDT-PERP.BINANCE".into())
597 .side(OrderSide::Buy)
598 .trigger_price(Price::new(105.0, 2))
599 .price(Price::new(104.5, 2))
600 .trailing_offset_type(TrailingOffsetType::Price)
601 .trailing_offset(dec!(1.0))
602 .limit_offset(dec!(0.5))
603 .trigger_type(TriggerType::LastPrice)
604 .quantity(Quantity::from(1))
605 .build();
606
607 let (new_trigger, new_limit) = trailing_stop_calculate(
608 Price::new(0.01, 2),
609 None,
610 &order,
611 None,
612 None,
613 Some(Price::new(100.0, 2)),
614 )
615 .unwrap();
616
617 assert_eq!(new_trigger.unwrap(), Price::from("101.0"));
618 assert_eq!(new_limit.unwrap(), Price::from("100.5"));
619 }
620
621 #[rstest]
622 fn test_trailing_stop_limit_last_price_sell_improve() {
623 let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
624 .instrument_id("BTCUSDT-PERP.BINANCE".into())
625 .side(OrderSide::Sell)
626 .trigger_price(Price::new(95.0, 2))
627 .price(Price::new(95.5, 2))
628 .trailing_offset_type(TrailingOffsetType::Price)
629 .trailing_offset(dec!(1.0))
630 .limit_offset(dec!(0.5))
631 .trigger_type(TriggerType::LastPrice)
632 .quantity(Quantity::from(1))
633 .build();
634
635 let (new_trigger, new_limit) = trailing_stop_calculate(
636 Price::new(0.01, 2),
637 None,
638 &order,
639 None,
640 None,
641 Some(Price::new(100.0, 2)),
642 )
643 .unwrap();
644
645 assert_eq!(new_trigger.unwrap(), Price::from("99.0"));
646 assert_eq!(new_limit.unwrap(), Price::from("99.5"));
647 }
648
649 #[rstest]
650 #[case(OrderSide::Buy, 100.0, 1.0, 99.0)]
651 #[case(OrderSide::Sell, 100.0, 1.0, 101.0)]
652 fn test_no_update_when_candidate_worse(
653 #[case] side: OrderSide,
654 #[case] initial_trigger: f64,
655 #[case] offset: f64,
656 #[case] basis: f64,
657 ) {
658 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
659 .instrument_id("BTCUSDT-PERP.BINANCE".into())
660 .side(side)
661 .trigger_price(Price::new(initial_trigger, 2))
662 .trailing_offset_type(TrailingOffsetType::Price)
663 .trailing_offset(Decimal::from_f64(offset).unwrap())
664 .trigger_type(TriggerType::LastPrice)
665 .quantity(Quantity::from(1))
666 .build();
667
668 let (maybe_trigger, _) = trailing_stop_calculate(
669 Price::new(0.01, 2),
670 None,
671 &order,
672 None,
673 None,
674 Some(Price::new(basis, 2)),
675 )
676 .unwrap();
677
678 assert!(maybe_trigger.is_none());
679 }
680
681 #[rstest]
682 #[case(
683 TrailingOffsetType::Price,
684 OrderSide::Buy,
685 dec!(1.25),
686 Price::from("98.00"),
687 Price::from("99.25")
688 )]
689 #[case(
690 TrailingOffsetType::BasisPoints,
691 OrderSide::Buy,
692 dec!(50),
693 Price::from("98.00"),
694 Price::from("98.49")
695 )]
696 #[case(
697 TrailingOffsetType::Ticks,
698 OrderSide::Sell,
699 dec!(5),
700 Price::from("102.00"),
701 Price::from("101.95")
702 )]
703 fn test_calculate_with_last_uses_decimal_math(
704 #[case] trailing_offset_type: TrailingOffsetType,
705 #[case] side: OrderSide,
706 #[case] offset: Decimal,
707 #[case] last: Price,
708 #[case] expected: Price,
709 ) {
710 let price = trailing_stop_calculate_with_last(
711 Price::from("0.01"),
712 trailing_offset_type,
713 side,
714 offset,
715 last,
716 )
717 .unwrap();
718
719 assert_eq!(price, expected);
720 }
721
722 #[rstest]
723 #[case(
724 TrailingOffsetType::Price,
725 OrderSide::Sell,
726 dec!(1.25),
727 Price::from("102.00"),
728 Price::from("103.00"),
729 Price::from("100.75")
730 )]
731 #[case(
732 TrailingOffsetType::BasisPoints,
733 OrderSide::Sell,
734 dec!(50),
735 Price::from("102.00"),
736 Price::from("103.00"),
737 Price::from("101.49")
738 )]
739 #[case(
740 TrailingOffsetType::Ticks,
741 OrderSide::Buy,
742 dec!(5),
743 Price::from("102.00"),
744 Price::from("103.00"),
745 Price::from("103.05")
746 )]
747 fn test_calculate_with_bid_ask_uses_decimal_math(
748 #[case] trailing_offset_type: TrailingOffsetType,
749 #[case] side: OrderSide,
750 #[case] offset: Decimal,
751 #[case] bid: Price,
752 #[case] ask: Price,
753 #[case] expected: Price,
754 ) {
755 let price = trailing_stop_calculate_with_bid_ask(
756 Price::from("0.01"),
757 trailing_offset_type,
758 side,
759 offset,
760 bid,
761 ask,
762 )
763 .unwrap();
764
765 assert_eq!(price, expected);
766 }
767
768 #[rstest]
769 fn test_trailing_stop_limit_basis_points_buy_improve() {
770 let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
771 .instrument_id("BTCUSDT-PERP.BINANCE".into())
772 .side(OrderSide::Buy)
773 .trigger_price(Price::new(110.0, 2))
774 .price(Price::new(109.5, 2))
775 .trailing_offset_type(TrailingOffsetType::BasisPoints)
776 .trailing_offset(dec!(50))
777 .limit_offset(dec!(25))
778 .trigger_type(TriggerType::LastPrice)
779 .quantity(Quantity::from(1))
780 .build();
781
782 let (new_trigger, new_limit) = trailing_stop_calculate(
783 Price::new(0.01, 2),
784 None,
785 &order,
786 None,
787 None,
788 Some(Price::new(98.0, 2)),
789 )
790 .unwrap();
791
792 assert_eq!(new_trigger.unwrap(), Price::from("98.49"));
793 assert_eq!(new_limit.unwrap(), Price::from("98.24"));
794 }
795}