1use nautilus_common::cache::Cache;
18use nautilus_model::{
19 enums::{OrderSide, OrderType, TrailingOffsetType, TriggerType},
20 orders::{Order, OrderAny, OrderError},
21 types::Price,
22};
23use rust_decimal::{Decimal, RoundingStrategy};
24
25pub fn trailing_stop_calculate(
41 price_increment: Price,
42 trigger_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();
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.or(order.trigger_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 let mut new_trigger_price: Option<Price>;
79 let mut new_limit_price: Option<Price> = None;
80
81 let maybe_move = |current: &mut Option<Price>,
82 candidate: Price,
83 better: fn(Price, Price) -> bool|
84 -> Option<Price> {
85 match current {
86 Some(p) if better(candidate, *p) => {
87 *current = Some(candidate);
88 Some(candidate)
89 }
90 None => {
91 *current = Some(candidate);
92 Some(candidate)
93 }
94 _ => None,
95 }
96 };
97
98 let better_trigger: fn(Price, Price) -> bool = match order_side {
99 OrderSide::Buy => |c, p| c < p,
100 OrderSide::Sell => |c, p| c > p,
101 };
102 let better_limit = better_trigger;
103
104 let compute = |offset: Decimal, basis: Price| {
105 trailing_stop_calculate_with_last(
106 price_increment,
107 trailing_offset_type,
108 order_side,
109 offset,
110 basis,
111 )
112 };
113
114 match trigger_type {
115 TriggerType::LastPrice | TriggerType::MarkPrice => {
116 let last = last.ok_or(OrderError::InvalidStateTransition)?;
117 let cand_trigger = compute(trailing_offset, last)?;
118 new_trigger_price = maybe_move(&mut trigger_price, cand_trigger, better_trigger);
119
120 if order_type == OrderType::TrailingStopLimit {
121 let limit_offset = order.limit_offset().ok_or_else(|| {
122 anyhow::anyhow!("Missing `limit_offset` for trailing stop limit calculation")
123 })?;
124 let cand_limit = compute(limit_offset, last)?;
125 new_limit_price = maybe_move(&mut limit_price, cand_limit, better_limit);
126 }
127 }
128 TriggerType::Default | TriggerType::BidAsk | TriggerType::LastOrBidAsk => {
129 let (bid, ask) = (
130 bid.ok_or_else(|| anyhow::anyhow!("Bid required"))?,
131 ask.ok_or_else(|| anyhow::anyhow!("Ask required"))?,
132 );
133 let basis = match order_side {
134 OrderSide::Buy => ask,
135 OrderSide::Sell => bid,
136 };
137 let cand_trigger = compute(trailing_offset, basis)?;
138 new_trigger_price = maybe_move(&mut trigger_price, cand_trigger, better_trigger);
139
140 if order_type == OrderType::TrailingStopLimit {
141 let limit_offset = order.limit_offset().ok_or_else(|| {
142 anyhow::anyhow!("Missing `limit_offset` for trailing stop limit calculation")
143 })?;
144 let cand_limit = compute(limit_offset, basis)?;
145 new_limit_price = maybe_move(&mut limit_price, cand_limit, better_limit);
146 }
147
148 if trigger_type == TriggerType::LastOrBidAsk {
149 let last = last.ok_or_else(|| anyhow::anyhow!("Last required"))?;
150 let cand_trigger = compute(trailing_offset, last)?;
151 let updated = maybe_move(&mut trigger_price, cand_trigger, better_trigger);
152 if updated.is_some() {
153 new_trigger_price = updated;
154 }
155
156 if order_type == OrderType::TrailingStopLimit {
157 let limit_offset = order.limit_offset().ok_or_else(|| {
158 anyhow::anyhow!(
159 "Missing `limit_offset` for trailing stop limit calculation"
160 )
161 })?;
162 let cand_limit = compute(limit_offset, last)?;
163 let updated = maybe_move(&mut limit_price, cand_limit, better_limit);
164 if updated.is_some() {
165 new_limit_price = updated;
166 }
167 }
168 }
169 }
170 _ => anyhow::bail!("`TriggerType` {trigger_type} not currently supported"),
171 }
172
173 Ok((new_trigger_price, new_limit_price))
174}
175
176pub fn trailing_stop_calculate_with_last(
183 price_increment: Price,
184 trailing_offset_type: TrailingOffsetType,
185 side: OrderSide,
186 offset: Decimal,
187 last: Price,
188) -> anyhow::Result<Price> {
189 let last = last.as_decimal();
190 let offset = match trailing_offset_type {
191 TrailingOffsetType::Price => offset,
192 TrailingOffsetType::BasisPoints => last * offset / Decimal::from(10_000),
193 TrailingOffsetType::Ticks => offset * price_increment.as_decimal(),
194 _ => anyhow::bail!("`TrailingOffsetType` {trailing_offset_type} not currently supported"),
195 };
196
197 let price = match side {
198 OrderSide::Buy => last + offset,
199 OrderSide::Sell => last - offset,
200 };
201
202 let rounded = Price::from_decimal_dp(price, price_increment.precision)?;
203 let increment_raw = price_increment.raw().abs();
204
205 if increment_raw == 0 || rounded.raw() % increment_raw == 0 {
206 return Ok(rounded);
207 }
208
209 let increment = price_increment.as_decimal();
211 let nearest = price
212 .checked_div(increment)
213 .and_then(|increments| {
214 increments
215 .round_dp_with_strategy(0, RoundingStrategy::MidpointNearestEven)
216 .checked_mul(increment)
217 })
218 .unwrap_or(price);
219
220 Price::from_decimal_dp(nearest, price_increment.precision).map_err(Into::into)
221}
222
223pub fn trailing_stop_calculate_with_bid_ask(
230 price_increment: Price,
231 trailing_offset_type: TrailingOffsetType,
232 side: OrderSide,
233 offset: Decimal,
234 bid: Price,
235 ask: Price,
236) -> anyhow::Result<Price> {
237 let basis = match side {
238 OrderSide::Buy => ask,
239 OrderSide::Sell => bid,
240 };
241
242 trailing_stop_calculate_with_last(price_increment, trailing_offset_type, side, offset, basis)
243}
244
245pub(crate) fn store_trailing_stop_activation(cache: &mut Cache, order: &OrderAny) {
249 let Some(mut cached) = cache.order_mut(&order.client_order_id()) else {
250 log::error!(
251 "Cannot store trailing stop activation: order {} not found",
252 order.client_order_id()
253 );
254 return;
255 };
256
257 match (&mut *cached, order) {
258 (OrderAny::TrailingStopMarket(cached), OrderAny::TrailingStopMarket(order)) => {
259 cached.activation_price = order.activation_price;
260 cached.is_activated = order.is_activated;
261 }
262 (OrderAny::TrailingStopLimit(cached), OrderAny::TrailingStopLimit(order)) => {
263 cached.activation_price = order.activation_price;
264 cached.is_activated = order.is_activated;
265 }
266 _ => {}
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use nautilus_model::{
273 enums::{OrderSide, OrderType, TrailingOffsetType, TriggerType},
274 orders::builder::OrderTestBuilder,
275 types::Quantity,
276 };
277 use rstest::rstest;
278 use rust_decimal::prelude::*;
279 use rust_decimal_macros::dec;
280
281 use super::*;
282
283 fn assert_optional_price(actual: Option<Price>, expected: Option<&str>) {
284 match (actual, expected) {
285 (Some(actual), Some(expected)) => assert_eq!(actual, Price::from(expected)),
286 (None, None) => {}
287 (actual, expected) => panic!("expected {expected:?}, was {actual:?}"),
288 }
289 }
290
291 #[rstest]
292 fn test_calculate_with_invalid_order_type() {
293 let order = OrderTestBuilder::new(OrderType::Market)
294 .instrument_id("BTCUSDT-PERP.BINANCE".into())
295 .side(OrderSide::Buy)
296 .quantity(Quantity::from(1))
297 .build();
298
299 let result = trailing_stop_calculate(Price::new(0.01, 2), 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 = trailing_stop_calculate(Price::new(0.01, 2), None, &order, None, None, None);
320
321 assert!(result.is_err());
323 }
324
325 #[rstest]
326 #[case(OrderSide::Buy)]
327 #[case(OrderSide::Sell)]
328 fn test_calculate_with_bid_ask_no_bid_ask(#[case] side: OrderSide) {
329 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
330 .instrument_id("BTCUSDT-PERP.BINANCE".into())
331 .side(side)
332 .trigger_price(Price::new(100.0, 2))
333 .trailing_offset_type(TrailingOffsetType::Price)
334 .trailing_offset(dec!(1.0))
335 .trigger_type(TriggerType::BidAsk)
336 .quantity(Quantity::from(1))
337 .build();
338
339 let result = trailing_stop_calculate(Price::new(0.01, 2), None, &order, None, None, None);
340
341 assert!(result.is_err());
343 }
344
345 #[rstest]
346 fn test_calculate_with_unsupported_trigger_type() {
347 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
348 .instrument_id("BTCUSDT-PERP.BINANCE".into())
349 .side(OrderSide::Buy)
350 .trigger_price(Price::new(100.0, 2))
351 .trailing_offset_type(TrailingOffsetType::Price)
352 .trailing_offset(dec!(1.0))
353 .trigger_type(TriggerType::IndexPrice) .quantity(Quantity::from(1))
355 .build();
356
357 let result = trailing_stop_calculate(Price::new(0.01, 2), None, &order, None, None, None);
358
359 assert!(result.is_err());
361 }
362
363 #[rstest]
364 #[should_panic(expected = "Trailing offset type not set")]
365 fn test_build_without_trailing_offset_type_panics() {
366 let _ = OrderTestBuilder::new(OrderType::TrailingStopMarket)
367 .instrument_id("BTCUSDT-PERP.BINANCE".into())
368 .side(OrderSide::Buy)
369 .trigger_price(Price::new(100.0, 2))
370 .trailing_offset(dec!(1.0))
371 .trigger_type(TriggerType::LastPrice)
372 .quantity(Quantity::from(1))
373 .build();
374 }
375
376 #[rstest]
377 #[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(
382 #[case] side: OrderSide,
383 #[case] initial_trigger: f64,
384 #[case] offset: f64,
385 #[case] last_price: f64,
386 #[case] expected_trigger: Option<&str>,
387 ) {
388 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
389 .instrument_id("BTCUSDT-PERP.BINANCE".into())
390 .side(side)
391 .trigger_price(Price::new(initial_trigger, 2))
392 .trailing_offset_type(TrailingOffsetType::Price)
393 .trailing_offset(Decimal::from_f64(offset).unwrap())
394 .trigger_type(TriggerType::LastPrice)
395 .quantity(Quantity::from(1))
396 .build();
397
398 let result = trailing_stop_calculate(
399 Price::new(0.01, 2),
400 None,
401 &order,
402 None,
403 None,
404 Some(Price::new(last_price, 2)),
405 );
406
407 assert_optional_price(result.unwrap().0, expected_trigger);
408 }
409
410 #[rstest]
411 #[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(
414 #[case] side: OrderSide,
415 #[case] initial_trigger: f64,
416 #[case] offset: f64,
417 #[case] ask: f64,
418 #[case] bid: f64,
419 #[case] expected_trigger: Option<&str>,
420 ) {
421 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
424 .instrument_id("BTCUSDT-PERP.BINANCE".into())
425 .side(side)
426 .trigger_price(Price::new(initial_trigger, 2))
427 .trailing_offset_type(TrailingOffsetType::Price)
428 .trailing_offset(Decimal::from_f64(offset).unwrap())
429 .trigger_type(TriggerType::Default)
430 .quantity(Quantity::from(1))
431 .build();
432
433 let result = trailing_stop_calculate(
434 Price::new(0.01, 2),
435 None,
436 &order,
437 Some(Price::new(bid, 2)),
438 Some(Price::new(ask, 2)),
439 None, );
441
442 assert_optional_price(result.unwrap().0, expected_trigger);
443 }
444
445 #[rstest]
446 #[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(
451 #[case] side: OrderSide,
452 #[case] initial_trigger: f64,
453 #[case] basis_points: f64,
454 #[case] last_price: f64,
455 #[case] expected_trigger: Option<&str>,
456 ) {
457 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
458 .instrument_id("BTCUSDT-PERP.BINANCE".into())
459 .side(side)
460 .trigger_price(Price::new(initial_trigger, 2))
461 .trailing_offset_type(TrailingOffsetType::BasisPoints)
462 .trailing_offset(Decimal::from_f64(basis_points).unwrap())
463 .trigger_type(TriggerType::LastPrice)
464 .quantity(Quantity::from(1))
465 .build();
466
467 let result = trailing_stop_calculate(
468 Price::new(0.01, 2),
469 None,
470 &order,
471 None,
472 None,
473 Some(Price::new(last_price, 2)),
474 );
475
476 assert_optional_price(result.unwrap().0, expected_trigger);
477 }
478
479 #[rstest]
480 #[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(
485 #[case] side: OrderSide,
486 #[case] initial_trigger: f64,
487 #[case] offset: f64,
488 #[case] bid: f64,
489 #[case] ask: f64,
490 #[case] expected_trigger: Option<&str>,
491 ) {
492 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
493 .instrument_id("BTCUSDT-PERP.BINANCE".into())
494 .side(side)
495 .trigger_price(Price::new(initial_trigger, 2))
496 .trailing_offset_type(TrailingOffsetType::Price)
497 .trailing_offset(Decimal::from_f64(offset).unwrap())
498 .trigger_type(TriggerType::BidAsk)
499 .quantity(Quantity::from(1))
500 .build();
501
502 let result = trailing_stop_calculate(
503 Price::new(0.01, 2),
504 None,
505 &order,
506 Some(Price::new(bid, 2)),
507 Some(Price::new(ask, 2)),
508 None, );
510
511 assert_optional_price(result.unwrap().0, expected_trigger);
512 }
513
514 #[rstest]
515 #[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(
520 #[case] side: OrderSide,
521 #[case] initial_trigger: f64,
522 #[case] ticks: u32,
523 #[case] last_price: f64,
524 #[case] expected_trigger: Option<&str>,
525 ) {
526 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
527 .instrument_id("BTCUSDT-PERP.BINANCE".into())
528 .side(side)
529 .trigger_price(Price::new(initial_trigger, 2))
530 .trailing_offset_type(TrailingOffsetType::Ticks)
531 .trailing_offset(Decimal::from_u32(ticks).unwrap())
532 .trigger_type(TriggerType::LastPrice)
533 .quantity(Quantity::from(1))
534 .build();
535
536 let result = trailing_stop_calculate(
537 Price::new(0.01, 2),
538 None,
539 &order,
540 None,
541 None,
542 Some(Price::new(last_price, 2)),
543 );
544
545 assert_optional_price(result.unwrap().0, expected_trigger);
546 }
547
548 #[rstest]
549 #[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(
554 #[case] side: OrderSide,
555 #[case] initial_trigger: f64,
556 #[case] offset: f64,
557 #[case] last_price: f64,
558 #[case] bid: f64,
559 #[case] ask: f64,
560 #[case] expected_trigger: Option<&str>,
561 ) {
562 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
563 .instrument_id("BTCUSDT-PERP.BINANCE".into())
564 .side(side)
565 .trigger_price(Price::new(initial_trigger, 2))
566 .trailing_offset_type(TrailingOffsetType::Price)
567 .trailing_offset(Decimal::from_f64(offset).unwrap())
568 .trigger_type(TriggerType::LastOrBidAsk)
569 .quantity(Quantity::from(1))
570 .build();
571
572 let result = trailing_stop_calculate(
573 Price::new(0.01, 2),
574 None,
575 &order,
576 Some(Price::new(bid, 2)),
577 Some(Price::new(ask, 2)),
578 Some(Price::new(last_price, 2)),
579 );
580
581 assert_optional_price(result.unwrap().0, expected_trigger);
582 }
583
584 #[rstest]
585 #[case(OrderSide::Buy, 100.0, 1.0, 98.0, Some("99.0"))]
586 #[case(OrderSide::Sell, 100.0, 1.0, 102.0, Some("101.0"))]
587 fn test_trailing_stop_market_last_price_move_in_favor(
588 #[case] side: OrderSide,
589 #[case] initial_trigger: f64,
590 #[case] offset: f64,
591 #[case] last_price: f64,
592 #[case] expected_trigger: Option<&str>,
593 ) {
594 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
595 .instrument_id("BTCUSDT-PERP.BINANCE".into())
596 .side(side)
597 .trigger_price(Price::new(initial_trigger, 2))
598 .trailing_offset_type(TrailingOffsetType::Price)
599 .trailing_offset(Decimal::from_f64(offset).unwrap())
600 .trigger_type(TriggerType::LastPrice)
601 .quantity(Quantity::from(1))
602 .build();
603
604 let (maybe_trigger, _) = trailing_stop_calculate(
605 Price::new(0.01, 2),
606 None,
607 &order,
608 None,
609 None,
610 Some(Price::new(last_price, 2)),
611 )
612 .unwrap();
613
614 assert_optional_price(maybe_trigger, expected_trigger);
615 }
616
617 #[rstest]
618 fn test_trailing_stop_limit_last_price_buy_improve_trigger_and_limit() {
619 let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
620 .instrument_id("BTCUSDT-PERP.BINANCE".into())
621 .side(OrderSide::Buy)
622 .trigger_price(Price::new(105.0, 2))
623 .price(Price::new(104.5, 2))
624 .trailing_offset_type(TrailingOffsetType::Price)
625 .trailing_offset(dec!(1.0))
626 .limit_offset(dec!(0.5))
627 .trigger_type(TriggerType::LastPrice)
628 .quantity(Quantity::from(1))
629 .build();
630
631 let (new_trigger, new_limit) = trailing_stop_calculate(
632 Price::new(0.01, 2),
633 None,
634 &order,
635 None,
636 None,
637 Some(Price::new(100.0, 2)),
638 )
639 .unwrap();
640
641 assert_eq!(new_trigger.unwrap(), Price::from("101.0"));
642 assert_eq!(new_limit.unwrap(), Price::from("100.5"));
643 }
644
645 #[rstest]
646 fn test_trailing_stop_limit_last_price_sell_improve() {
647 let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
648 .instrument_id("BTCUSDT-PERP.BINANCE".into())
649 .side(OrderSide::Sell)
650 .trigger_price(Price::new(95.0, 2))
651 .price(Price::new(95.5, 2))
652 .trailing_offset_type(TrailingOffsetType::Price)
653 .trailing_offset(dec!(1.0))
654 .limit_offset(dec!(0.5))
655 .trigger_type(TriggerType::LastPrice)
656 .quantity(Quantity::from(1))
657 .build();
658
659 let (new_trigger, new_limit) = trailing_stop_calculate(
660 Price::new(0.01, 2),
661 None,
662 &order,
663 None,
664 None,
665 Some(Price::new(100.0, 2)),
666 )
667 .unwrap();
668
669 assert_eq!(new_trigger.unwrap(), Price::from("99.0"));
670 assert_eq!(new_limit.unwrap(), Price::from("99.5"));
671 }
672
673 #[rstest]
674 #[case(OrderSide::Buy, 100.0, 1.0, 99.0)]
675 #[case(OrderSide::Sell, 100.0, 1.0, 101.0)]
676 fn test_no_update_when_candidate_worse(
677 #[case] side: OrderSide,
678 #[case] initial_trigger: f64,
679 #[case] offset: f64,
680 #[case] basis: f64,
681 ) {
682 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
683 .instrument_id("BTCUSDT-PERP.BINANCE".into())
684 .side(side)
685 .trigger_price(Price::new(initial_trigger, 2))
686 .trailing_offset_type(TrailingOffsetType::Price)
687 .trailing_offset(Decimal::from_f64(offset).unwrap())
688 .trigger_type(TriggerType::LastPrice)
689 .quantity(Quantity::from(1))
690 .build();
691
692 let (maybe_trigger, _) = trailing_stop_calculate(
693 Price::new(0.01, 2),
694 None,
695 &order,
696 None,
697 None,
698 Some(Price::new(basis, 2)),
699 )
700 .unwrap();
701
702 assert!(maybe_trigger.is_none());
703 }
704
705 #[rstest]
706 #[case(
707 TrailingOffsetType::Price,
708 OrderSide::Buy,
709 dec!(1.25),
710 Price::from("98.00"),
711 Price::from("99.25")
712 )]
713 #[case(
714 TrailingOffsetType::BasisPoints,
715 OrderSide::Buy,
716 dec!(50),
717 Price::from("98.00"),
718 Price::from("98.49")
719 )]
720 #[case(
721 TrailingOffsetType::Ticks,
722 OrderSide::Sell,
723 dec!(5),
724 Price::from("102.00"),
725 Price::from("101.95")
726 )]
727 fn test_calculate_with_last_uses_decimal_math(
728 #[case] trailing_offset_type: TrailingOffsetType,
729 #[case] side: OrderSide,
730 #[case] offset: Decimal,
731 #[case] last: Price,
732 #[case] expected: Price,
733 ) {
734 let price = trailing_stop_calculate_with_last(
735 Price::from("0.01"),
736 trailing_offset_type,
737 side,
738 offset,
739 last,
740 )
741 .unwrap();
742
743 assert_eq!(price, expected);
744 }
745
746 #[rstest]
747 #[case::basis_points_sell(
748 "0.25",
749 TrailingOffsetType::BasisPoints,
750 OrderSide::Sell,
751 dec!(33),
752 "1500.00",
753 "1495.00"
754 )]
755 #[case::basis_points_buy(
756 "0.25",
757 TrailingOffsetType::BasisPoints,
758 OrderSide::Buy,
759 dec!(33),
760 "1500.25",
761 "1505.25"
762 )]
763 #[case::price_offset(
764 "0.25",
765 TrailingOffsetType::Price,
766 OrderSide::Sell,
767 dec!(0.30),
768 "1500.00",
769 "1499.75"
770 )]
771 #[case::midpoint_to_even_increment(
772 "0.50",
773 TrailingOffsetType::Price,
774 OrderSide::Sell,
775 dec!(0.25),
776 "100.50",
777 "100.00"
778 )]
779 #[case::offset_under_half_increment(
780 "0.25",
781 TrailingOffsetType::Price,
782 OrderSide::Sell,
783 dec!(0.10),
784 "1500.00",
785 "1500.00"
786 )]
787 #[case::negative_price(
788 "0.25",
789 TrailingOffsetType::Price,
790 OrderSide::Sell,
791 dec!(0.30),
792 "-10.00",
793 "-10.25"
794 )]
795 #[case::decimal_increment(
796 "0.01",
797 TrailingOffsetType::BasisPoints,
798 OrderSide::Sell,
799 dec!(1.25),
800 "100.00",
801 "99.99"
802 )]
803 #[case::decimal_increment_midpoint(
804 "0.01",
805 TrailingOffsetType::Price,
806 OrderSide::Sell,
807 dec!(0.005),
808 "100.00",
809 "100.00"
810 )]
811 #[case::decimal_increment_offset_under_half(
812 "0.01",
813 TrailingOffsetType::Price,
814 OrderSide::Sell,
815 dec!(0.004),
816 "100.00",
817 "100.00"
818 )]
819 #[case::zero_increment(
820 "0.00",
821 TrailingOffsetType::Price,
822 OrderSide::Sell,
823 dec!(0.30),
824 "1500.00",
825 "1499.70"
826 )]
827 fn test_calculate_with_last_rounds_to_price_increment(
828 #[case] price_increment: &str,
829 #[case] trailing_offset_type: TrailingOffsetType,
830 #[case] side: OrderSide,
831 #[case] offset: Decimal,
832 #[case] last: &str,
833 #[case] expected: &str,
834 ) {
835 let price = trailing_stop_calculate_with_last(
836 Price::from(price_increment),
837 trailing_offset_type,
838 side,
839 offset,
840 Price::from(last),
841 )
842 .unwrap();
843
844 assert_eq!(price, Price::from(expected));
845 }
846
847 #[rstest]
848 #[case(
849 TrailingOffsetType::Price,
850 OrderSide::Sell,
851 dec!(1.25),
852 Price::from("102.00"),
853 Price::from("103.00"),
854 Price::from("100.75")
855 )]
856 #[case(
857 TrailingOffsetType::BasisPoints,
858 OrderSide::Sell,
859 dec!(50),
860 Price::from("102.00"),
861 Price::from("103.00"),
862 Price::from("101.49")
863 )]
864 #[case(
865 TrailingOffsetType::Ticks,
866 OrderSide::Buy,
867 dec!(5),
868 Price::from("102.00"),
869 Price::from("103.00"),
870 Price::from("103.05")
871 )]
872 fn test_calculate_with_bid_ask_uses_decimal_math(
873 #[case] trailing_offset_type: TrailingOffsetType,
874 #[case] side: OrderSide,
875 #[case] offset: Decimal,
876 #[case] bid: Price,
877 #[case] ask: Price,
878 #[case] expected: Price,
879 ) {
880 let price = trailing_stop_calculate_with_bid_ask(
881 Price::from("0.01"),
882 trailing_offset_type,
883 side,
884 offset,
885 bid,
886 ask,
887 )
888 .unwrap();
889
890 assert_eq!(price, expected);
891 }
892
893 #[rstest]
894 fn test_trailing_stop_limit_basis_points_buy_improve() {
895 let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
896 .instrument_id("BTCUSDT-PERP.BINANCE".into())
897 .side(OrderSide::Buy)
898 .trigger_price(Price::new(110.0, 2))
899 .price(Price::new(109.5, 2))
900 .trailing_offset_type(TrailingOffsetType::BasisPoints)
901 .trailing_offset(dec!(50))
902 .limit_offset(dec!(25))
903 .trigger_type(TriggerType::LastPrice)
904 .quantity(Quantity::from(1))
905 .build();
906
907 let (new_trigger, new_limit) = trailing_stop_calculate(
908 Price::new(0.01, 2),
909 None,
910 &order,
911 None,
912 None,
913 Some(Price::new(98.0, 2)),
914 )
915 .unwrap();
916
917 assert_eq!(new_trigger.unwrap(), Price::from("98.49"));
918 assert_eq!(new_limit.unwrap(), Price::from("98.24"));
919 }
920
921 #[rstest]
922 #[case(OrderSide::Buy, "105.00", "100.00", "101.00")]
923 #[case(OrderSide::Sell, "95.00", "100.00", "99.00")]
924 fn test_trigger_override_takes_precedence(
925 #[case] side: OrderSide,
926 #[case] stored: &str,
927 #[case] override_price: &str,
928 #[case] last: &str,
929 ) {
930 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
931 .instrument_id("BTCUSDT-PERP.BINANCE".into())
932 .side(side)
933 .trigger_price(Price::from(stored))
934 .trailing_offset_type(TrailingOffsetType::Price)
935 .trailing_offset(dec!(1))
936 .trigger_type(TriggerType::LastPrice)
937 .quantity(Quantity::from(1))
938 .build();
939
940 let result = trailing_stop_calculate(
941 Price::from("0.01"),
942 Some(Price::from(override_price)),
943 &order,
944 None,
945 None,
946 Some(Price::from(last)),
947 )
948 .unwrap();
949
950 assert_eq!(result, (None, None));
951 }
952
953 #[rstest]
954 #[case(OrderSide::Buy, "100.00", "105.00", None, Some("100.50"))]
955 #[case(OrderSide::Buy, "105.00", "100.00", Some("101.00"), None)]
956 #[case(OrderSide::Sell, "100.00", "95.00", None, Some("99.50"))]
957 #[case(OrderSide::Sell, "95.00", "100.00", Some("99.00"), None)]
958 fn test_trailing_limit_prices_improve_independently(
959 #[case] side: OrderSide,
960 #[case] trigger: &str,
961 #[case] limit: &str,
962 #[case] expected_trigger: Option<&str>,
963 #[case] expected_limit: Option<&str>,
964 ) {
965 let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
966 .instrument_id("BTCUSDT-PERP.BINANCE".into())
967 .side(side)
968 .trigger_price(Price::from(trigger))
969 .price(Price::from(limit))
970 .trailing_offset_type(TrailingOffsetType::Price)
971 .trailing_offset(dec!(1))
972 .limit_offset(dec!(0.5))
973 .trigger_type(TriggerType::LastPrice)
974 .quantity(Quantity::from(1))
975 .build();
976
977 let result = trailing_stop_calculate(
978 Price::from("0.01"),
979 None,
980 &order,
981 None,
982 None,
983 Some(Price::from("100.00")),
984 )
985 .unwrap();
986
987 assert_eq!(
988 result,
989 (
990 expected_trigger.map(Price::from),
991 expected_limit.map(Price::from)
992 )
993 );
994 }
995
996 #[rstest]
997 #[case(OrderSide::Buy, "110.00", "98.00", "99.00", "98.50")]
998 #[case(OrderSide::Buy, "110.00", "103.00", "102.00", "101.50")]
999 #[case(OrderSide::Sell, "90.00", "103.00", "102.00", "102.50")]
1000 #[case(OrderSide::Sell, "90.00", "98.00", "99.00", "99.50")]
1001 fn test_trailing_limit_last_or_bid_ask_keeps_best_prices(
1002 #[case] side: OrderSide,
1003 #[case] initial: &str,
1004 #[case] last: &str,
1005 #[case] expected_trigger: &str,
1006 #[case] expected_limit: &str,
1007 ) {
1008 let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
1009 .instrument_id("BTCUSDT-PERP.BINANCE".into())
1010 .side(side)
1011 .trigger_price(Price::from(initial))
1012 .price(Price::from(initial))
1013 .trailing_offset_type(TrailingOffsetType::Price)
1014 .trailing_offset(dec!(1))
1015 .limit_offset(dec!(0.5))
1016 .trigger_type(TriggerType::LastOrBidAsk)
1017 .quantity(Quantity::from(1))
1018 .build();
1019
1020 let result = trailing_stop_calculate(
1021 Price::from("0.01"),
1022 None,
1023 &order,
1024 Some(Price::from("100.00")),
1025 Some(Price::from("101.00")),
1026 Some(Price::from(last)),
1027 )
1028 .unwrap();
1029
1030 assert_eq!(
1031 result,
1032 (
1033 Some(Price::from(expected_trigger)),
1034 Some(Price::from(expected_limit))
1035 )
1036 );
1037 }
1038}