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