nautilus_execution/
trailing.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16// TODO: We'll use anyhow for now, but would be best to implement some specific Error(s)
17use nautilus_model::{
18    enums::{OrderSideSpecified, OrderType, TrailingOffsetType, TriggerType},
19    orders::{Order, OrderAny, OrderError},
20    types::Price,
21};
22use rust_decimal::{Decimal, prelude::*};
23
24pub fn trailing_stop_calculate(
25    price_increment: Price,
26    order: &OrderAny,
27    bid: Option<Price>,
28    ask: Option<Price>,
29    last: Option<Price>,
30) -> anyhow::Result<(Option<Price>, Option<Price>)> {
31    let order_side = order.order_side_specified();
32    let order_type = order.order_type();
33    if !matches!(
34        order_type,
35        OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
36    ) {
37        anyhow::bail!("Invalid `OrderType` {order_type} for trailing stop calculation");
38    }
39
40    // SAFETY: TrailingStop order guaranteed to have trigger_type and offset properties
41    let trigger_type = order.trigger_type().unwrap();
42    let trailing_offset = order.trailing_offset().unwrap();
43    let trailing_offset_type = order.trailing_offset_type().unwrap();
44    assert!(trigger_type != TriggerType::NoTrigger);
45    assert!(trailing_offset_type != TrailingOffsetType::NoTrailingOffset,);
46
47    let mut trigger_price = order.trigger_price();
48    let mut price = None;
49    let mut new_trigger_price = None;
50    let mut new_price = None;
51
52    if order_type == OrderType::TrailingStopLimit {
53        price = order.price();
54    }
55
56    match trigger_type {
57        TriggerType::Default | TriggerType::LastPrice | TriggerType::MarkPrice => {
58            let last = last.ok_or(OrderError::InvalidStateTransition)?;
59
60            let temp_trigger_price = trailing_stop_calculate_with_last(
61                price_increment,
62                trailing_offset_type,
63                order_side,
64                trailing_offset,
65                last,
66            )?;
67
68            match order_side {
69                OrderSideSpecified::Buy => {
70                    if let Some(trigger) = trigger_price {
71                        if trigger > temp_trigger_price {
72                            new_trigger_price = Some(temp_trigger_price);
73                        }
74                    } else {
75                        new_trigger_price = Some(temp_trigger_price);
76                    }
77
78                    if order_type == OrderType::TrailingStopLimit {
79                        let temp_price = trailing_stop_calculate_with_last(
80                            price_increment,
81                            trailing_offset_type,
82                            order_side,
83                            order.limit_offset().expect("Invalid order"),
84                            last,
85                        )?;
86                        if let Some(p) = price {
87                            if p > temp_price {
88                                new_price = Some(temp_price);
89                            }
90                        } else {
91                            new_price = Some(temp_price);
92                        }
93                    }
94                }
95                OrderSideSpecified::Sell => {
96                    if let Some(trigger) = trigger_price {
97                        if trigger < temp_trigger_price {
98                            new_trigger_price = Some(temp_trigger_price);
99                        }
100                    } else {
101                        new_trigger_price = Some(temp_trigger_price);
102                    }
103
104                    if order_type == OrderType::TrailingStopLimit {
105                        let temp_price = trailing_stop_calculate_with_last(
106                            price_increment,
107                            trailing_offset_type,
108                            order_side,
109                            order.limit_offset().expect("Invalid order"),
110                            last,
111                        )?;
112                        if let Some(p) = price {
113                            if p < temp_price {
114                                new_price = Some(temp_price);
115                            }
116                        } else {
117                            new_price = Some(temp_price);
118                        }
119                    }
120                }
121            }
122        }
123        TriggerType::BidAsk => {
124            let bid =
125                bid.ok_or_else(|| anyhow::anyhow!("`BidAsk` calculation requires `bid` price"))?;
126            let ask =
127                ask.ok_or_else(|| anyhow::anyhow!("`BidAsk` calculation requires `ask` price"))?;
128
129            let temp_trigger_price = trailing_stop_calculate_with_bid_ask(
130                price_increment,
131                trailing_offset_type,
132                order_side,
133                trailing_offset,
134                bid,
135                ask,
136            )?;
137
138            match order_side {
139                OrderSideSpecified::Buy => {
140                    if let Some(trigger) = trigger_price {
141                        if trigger > temp_trigger_price {
142                            new_trigger_price = Some(temp_trigger_price);
143                        }
144                    } else {
145                        new_trigger_price = Some(temp_trigger_price);
146                    }
147
148                    if order.order_type() == OrderType::TrailingStopLimit {
149                        let temp_price = trailing_stop_calculate_with_bid_ask(
150                            price_increment,
151                            trailing_offset_type,
152                            order_side,
153                            order.limit_offset().expect("Invalid order"),
154                            bid,
155                            ask,
156                        )?;
157                        if let Some(p) = price {
158                            if p > temp_price {
159                                new_price = Some(temp_price);
160                            }
161                        } else {
162                            new_price = Some(temp_price);
163                        }
164                    }
165                }
166                OrderSideSpecified::Sell => {
167                    if let Some(trigger) = trigger_price {
168                        if trigger < temp_trigger_price {
169                            new_trigger_price = Some(temp_trigger_price);
170                        }
171                    } else {
172                        new_trigger_price = Some(temp_trigger_price);
173                    }
174
175                    if order_type == OrderType::TrailingStopLimit {
176                        let temp_price = trailing_stop_calculate_with_bid_ask(
177                            price_increment,
178                            trailing_offset_type,
179                            order_side,
180                            order.limit_offset().expect("Invalid order"),
181                            bid,
182                            ask,
183                        )?;
184                        if let Some(p) = price {
185                            if p < temp_price {
186                                new_price = Some(temp_price);
187                            }
188                        } else {
189                            new_price = Some(temp_price);
190                        }
191                    }
192                }
193            }
194        }
195        TriggerType::LastOrBidAsk => {
196            let bid = bid.ok_or_else(|| {
197                anyhow::anyhow!("`LastOrBidAsk` calculation requires `bid` price")
198            })?;
199            let ask = ask.ok_or_else(|| {
200                anyhow::anyhow!("`LastOrBidAsk` calculation requires `ask` price")
201            })?;
202            let last = last.ok_or_else(|| {
203                anyhow::anyhow!("`LastOrBidAsk` calculation requires `last` price")
204            })?;
205
206            let mut temp_trigger_price = trailing_stop_calculate_with_last(
207                price_increment,
208                trailing_offset_type,
209                order_side,
210                trailing_offset,
211                last,
212            )?;
213
214            match order_side {
215                OrderSideSpecified::Buy => {
216                    if let Some(trigger) = trigger_price {
217                        if trigger > temp_trigger_price {
218                            new_trigger_price = Some(temp_trigger_price);
219                            trigger_price = new_trigger_price;
220                        }
221                    } else {
222                        new_trigger_price = Some(temp_trigger_price);
223                        trigger_price = new_trigger_price;
224                    }
225                    if order.order_type() == OrderType::TrailingStopLimit {
226                        let temp_price = trailing_stop_calculate_with_last(
227                            price_increment,
228                            trailing_offset_type,
229                            order_side,
230                            order.limit_offset().expect("Invalid order"),
231                            last,
232                        )?;
233                        if let Some(p) = price {
234                            if p > temp_price {
235                                new_price = Some(temp_price);
236                                price = new_price;
237                            }
238                        } else {
239                            new_price = Some(temp_price);
240                            price = new_price;
241                        }
242                    }
243                    temp_trigger_price = trailing_stop_calculate_with_bid_ask(
244                        price_increment,
245                        trailing_offset_type,
246                        order_side,
247                        trailing_offset,
248                        bid,
249                        ask,
250                    )?;
251                    if let Some(trigger) = trigger_price {
252                        if trigger > temp_trigger_price {
253                            new_trigger_price = Some(temp_trigger_price);
254                        }
255                    } else {
256                        new_trigger_price = Some(temp_trigger_price);
257                    }
258                    if order_type == OrderType::TrailingStopLimit {
259                        let temp_price = trailing_stop_calculate_with_bid_ask(
260                            price_increment,
261                            trailing_offset_type,
262                            order_side,
263                            order.limit_offset().expect("Invalid order"),
264                            bid,
265                            ask,
266                        )?;
267                        if let Some(p) = price {
268                            if p > temp_price {
269                                new_price = Some(temp_price);
270                            }
271                        } else {
272                            new_price = Some(temp_price);
273                        }
274                    }
275                }
276                OrderSideSpecified::Sell => {
277                    if let Some(trigger) = trigger_price {
278                        if trigger < temp_trigger_price {
279                            new_trigger_price = Some(temp_trigger_price);
280                            trigger_price = new_trigger_price;
281                        }
282                    } else {
283                        new_trigger_price = Some(temp_trigger_price);
284                        trigger_price = new_trigger_price;
285                    }
286
287                    if order.order_type() == OrderType::TrailingStopLimit {
288                        let temp_price = trailing_stop_calculate_with_last(
289                            price_increment,
290                            trailing_offset_type,
291                            order_side,
292                            order.limit_offset().expect("Invalid order"),
293                            last,
294                        )?;
295                        if let Some(p) = price {
296                            if p < temp_price {
297                                new_price = Some(temp_price);
298                                price = new_price;
299                            }
300                        } else {
301                            new_price = Some(temp_price);
302                            price = new_price;
303                        }
304                    }
305                    temp_trigger_price = trailing_stop_calculate_with_bid_ask(
306                        price_increment,
307                        trailing_offset_type,
308                        order_side,
309                        trailing_offset,
310                        bid,
311                        ask,
312                    )?;
313                    if let Some(trigger) = trigger_price {
314                        if trigger < temp_trigger_price {
315                            new_trigger_price = Some(temp_trigger_price);
316                        }
317                    } else {
318                        new_trigger_price = Some(temp_trigger_price);
319                    }
320                    if order_type == OrderType::TrailingStopLimit {
321                        let temp_price = trailing_stop_calculate_with_bid_ask(
322                            price_increment,
323                            trailing_offset_type,
324                            order_side,
325                            order.limit_offset().expect("Invalid order"),
326                            bid,
327                            ask,
328                        )?;
329                        if let Some(p) = price {
330                            if p < temp_price {
331                                new_price = Some(temp_price);
332                            }
333                        } else {
334                            new_price = Some(temp_price);
335                        }
336                    }
337                }
338            }
339        }
340        _ => anyhow::bail!("`TriggerType` {trigger_type} not currently supported"),
341    }
342
343    Ok((new_trigger_price, new_price))
344}
345
346pub fn trailing_stop_calculate_with_last(
347    price_increment: Price,
348    trailing_offset_type: TrailingOffsetType,
349    side: OrderSideSpecified,
350    offset: Decimal,
351    last: Price,
352) -> anyhow::Result<Price> {
353    let mut offset_value = offset.to_f64().expect("Invalid `offset` value");
354    let last_f64 = last.as_f64();
355
356    match trailing_offset_type {
357        TrailingOffsetType::Price => {} // Offset already calculated
358        TrailingOffsetType::BasisPoints => {
359            offset_value = last_f64 * (offset_value / 100.0) / 100.0;
360        }
361        TrailingOffsetType::Ticks => {
362            offset_value *= price_increment.as_f64();
363        }
364        _ => anyhow::bail!("`TrailingOffsetType` {trailing_offset_type} not currently supported"),
365    }
366
367    let price_value = match side {
368        OrderSideSpecified::Buy => last_f64 + offset_value,
369        OrderSideSpecified::Sell => last_f64 - offset_value,
370    };
371
372    Ok(Price::new(price_value, price_increment.precision))
373}
374
375pub fn trailing_stop_calculate_with_bid_ask(
376    price_increment: Price,
377    trailing_offset_type: TrailingOffsetType,
378    side: OrderSideSpecified,
379    offset: Decimal,
380    bid: Price,
381    ask: Price,
382) -> anyhow::Result<Price> {
383    let mut offset_value = offset.to_f64().expect("Invalid `offset` value");
384    let bid_f64 = bid.as_f64();
385    let ask_f64 = ask.as_f64();
386
387    match trailing_offset_type {
388        TrailingOffsetType::Price => {} // Offset already calculated
389        TrailingOffsetType::BasisPoints => match side {
390            OrderSideSpecified::Buy => offset_value = ask_f64 * (offset_value / 100.0) / 100.0,
391            OrderSideSpecified::Sell => offset_value = bid_f64 * (offset_value / 100.0) / 100.0,
392        },
393        TrailingOffsetType::Ticks => {
394            offset_value *= price_increment.as_f64();
395        }
396        _ => anyhow::bail!("`TrailingOffsetType` {trailing_offset_type} not currently supported"),
397    }
398
399    let price_value = match side {
400        OrderSideSpecified::Buy => ask_f64 + offset_value,
401        OrderSideSpecified::Sell => bid_f64 - offset_value,
402    };
403
404    Ok(Price::new(price_value, price_increment.precision))
405}
406
407////////////////////////////////////////////////////////////////////////////////
408// Tests
409////////////////////////////////////////////////////////////////////////////////
410#[cfg(test)]
411mod tests {
412    use nautilus_model::{
413        enums::{OrderSide, OrderType, TrailingOffsetType, TriggerType},
414        orders::builder::OrderTestBuilder,
415        types::Quantity,
416    };
417    use rstest::rstest;
418    use rust_decimal::prelude::*;
419    use rust_decimal_macros::dec;
420
421    use super::*;
422
423    #[rstest]
424    fn test_calculate_with_invalid_order_type() {
425        let order = OrderTestBuilder::new(OrderType::Market)
426            .instrument_id("BTCUSDT-PERP.BINANCE".into())
427            .side(OrderSide::Buy)
428            .quantity(Quantity::from(1))
429            .build();
430
431        let result = trailing_stop_calculate(Price::new(0.01, 2), &order, None, None, None);
432
433        // TODO: Basic error assert for now
434        assert!(result.is_err());
435    }
436
437    #[rstest]
438    #[case(OrderSide::Buy)]
439    #[case(OrderSide::Sell)]
440    fn test_calculate_with_last_price_no_last(#[case] side: OrderSide) {
441        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
442            .instrument_id("BTCUSDT-PERP.BINANCE".into())
443            .side(side)
444            .trigger_price(Price::new(100.0, 2))
445            .trailing_offset_type(TrailingOffsetType::Price)
446            .trailing_offset(dec!(1.0))
447            .trigger_type(TriggerType::LastPrice)
448            .quantity(Quantity::from(1))
449            .build();
450
451        let result = trailing_stop_calculate(Price::new(0.01, 2), &order, None, None, None);
452
453        // TODO: Basic error assert for now
454        assert!(result.is_err());
455    }
456
457    #[rstest]
458    #[case(OrderSide::Buy)]
459    #[case(OrderSide::Sell)]
460    fn test_calculate_with_bid_ask_no_bid_ask(#[case] side: OrderSide) {
461        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
462            .instrument_id("BTCUSDT-PERP.BINANCE".into())
463            .side(side)
464            .trigger_price(Price::new(100.0, 2))
465            .trailing_offset_type(TrailingOffsetType::Price)
466            .trailing_offset(dec!(1.0))
467            .trigger_type(TriggerType::BidAsk)
468            .quantity(Quantity::from(1))
469            .build();
470
471        let result = trailing_stop_calculate(Price::new(0.01, 2), &order, None, None, None);
472
473        // TODO: Basic error assert for now
474        assert!(result.is_err());
475    }
476
477    #[rstest]
478    fn test_calculate_with_unsupported_trigger_type() {
479        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
480            .instrument_id("BTCUSDT-PERP.BINANCE".into())
481            .side(OrderSide::Buy)
482            .trigger_price(Price::new(100.0, 2))
483            .trailing_offset_type(TrailingOffsetType::Price)
484            .trailing_offset(dec!(1.0))
485            .trigger_type(TriggerType::IndexPrice)
486            .quantity(Quantity::from(1))
487            .build();
488
489        let result = trailing_stop_calculate(Price::new(0.01, 2), &order, None, None, None);
490
491        // TODO: Basic error assert for now
492        assert!(result.is_err());
493    }
494
495    #[rstest]
496    #[case(OrderSide::Buy, 100.0, 1.0, 99.0, None)] // Last price 99 > trigger 98, no update needed
497    #[case(OrderSide::Buy, 100.0, 1.0, 98.0, Some(99.0))] // Last price 98 < trigger 100, update to 98 + 1
498    #[case(OrderSide::Sell, 100.0, 1.0, 101.0, None)] // Last price 101 < trigger 102, no update needed
499    #[case(OrderSide::Sell, 100.0, 1.0, 102.0, Some(101.0))] // Last price 102 > trigger 100, update to 102 - 1
500    fn test_trailing_stop_market_last_price(
501        #[case] side: OrderSide,
502        #[case] initial_trigger: f64,
503        #[case] offset: f64,
504        #[case] last_price: f64,
505        #[case] expected_trigger: Option<f64>,
506    ) {
507        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
508            .instrument_id("BTCUSDT-PERP.BINANCE".into())
509            .side(side)
510            .trigger_price(Price::new(initial_trigger, 2))
511            .trailing_offset_type(TrailingOffsetType::Price)
512            .trailing_offset(Decimal::from_f64(offset).unwrap())
513            .trigger_type(TriggerType::LastPrice)
514            .quantity(Quantity::from(1))
515            .build();
516
517        let result = trailing_stop_calculate(
518            Price::new(0.01, 2),
519            &order,
520            None,
521            None,
522            Some(Price::new(last_price, 2)),
523        );
524
525        let actual_trigger = result.unwrap().0;
526        match (actual_trigger, expected_trigger) {
527            (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
528            (None, None) => (),
529            _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
530        }
531    }
532
533    #[rstest]
534    #[case(OrderSide::Buy, 100.0, 50.0, 98.0, Some(98.49))] // 50bp = 0.5% of 98 = 0.49
535    #[case(OrderSide::Buy, 100.0, 100.0, 97.0, Some(97.97))] // 100bp = 1% of 97 = 0.97
536    #[case(OrderSide::Sell, 100.0, 50.0, 102.0, Some(101.49))] // 50bp = 0.5% of 102 = 0.51
537    #[case(OrderSide::Sell, 100.0, 100.0, 103.0, Some(101.97))] // 100bp = 1% of 103 = 1.03
538    fn test_trailing_stop_market_basis_points(
539        #[case] side: OrderSide,
540        #[case] initial_trigger: f64,
541        #[case] basis_points: f64,
542        #[case] last_price: f64,
543        #[case] expected_trigger: Option<f64>,
544    ) {
545        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
546            .instrument_id("BTCUSDT-PERP.BINANCE".into())
547            .side(side)
548            .trigger_price(Price::new(initial_trigger, 2))
549            .trailing_offset_type(TrailingOffsetType::BasisPoints)
550            .trailing_offset(Decimal::from_f64(basis_points).unwrap())
551            .trigger_type(TriggerType::LastPrice)
552            .quantity(Quantity::from(1))
553            .build();
554
555        let result = trailing_stop_calculate(
556            Price::new(0.01, 2),
557            &order,
558            None,
559            None,
560            Some(Price::new(last_price, 2)),
561        );
562
563        let actual_trigger = result.unwrap().0;
564        match (actual_trigger, expected_trigger) {
565            (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
566            (None, None) => (),
567            _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
568        }
569    }
570
571    #[rstest]
572    #[case(OrderSide::Buy, 100.0, 1.0, 98.0, 99.0, None)] // Ask 99 > trigger 100, no update
573    #[case(OrderSide::Buy, 100.0, 1.0, 97.0, 98.0, Some(99.0))] // Ask 98 < trigger 100, update to 98 + 1
574    #[case(OrderSide::Sell, 100.0, 1.0, 101.0, 102.0, None)] // Bid 101 < trigger 100, no update
575    #[case(OrderSide::Sell, 100.0, 1.0, 102.0, 103.0, Some(101.0))] // Bid 102 > trigger 100, update to 102 - 1
576    fn test_trailing_stop_market_bid_ask(
577        #[case] side: OrderSide,
578        #[case] initial_trigger: f64,
579        #[case] offset: f64,
580        #[case] bid: f64,
581        #[case] ask: f64,
582        #[case] expected_trigger: Option<f64>,
583    ) {
584        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
585            .instrument_id("BTCUSDT-PERP.BINANCE".into())
586            .side(side)
587            .trigger_price(Price::new(initial_trigger, 2))
588            .trailing_offset_type(TrailingOffsetType::Price)
589            .trailing_offset(Decimal::from_f64(offset).unwrap())
590            .trigger_type(TriggerType::BidAsk)
591            .quantity(Quantity::from(1))
592            .build();
593
594        let result = trailing_stop_calculate(
595            Price::new(0.01, 2),
596            &order,
597            Some(Price::new(bid, 2)),
598            Some(Price::new(ask, 2)),
599            None, // last price not needed for BidAsk trigger type
600        );
601
602        let actual_trigger = result.unwrap().0;
603        match (actual_trigger, expected_trigger) {
604            (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
605            (None, None) => (),
606            _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
607        }
608    }
609
610    #[rstest]
611    #[case(OrderSide::Buy, 100.0, 5, 98.0, Some(98.05))] // 5 ticks * 0.01 = 0.05 offset
612    #[case(OrderSide::Buy, 100.0, 10, 97.0, Some(97.10))] // 10 ticks * 0.01 = 0.10 offset
613    #[case(OrderSide::Sell, 100.0, 5, 102.0, Some(101.95))] // 5 ticks * 0.01 = 0.05 offset
614    #[case(OrderSide::Sell, 100.0, 10, 103.0, Some(102.90))] // 10 ticks * 0.01 = 0.10 offset
615    fn test_trailing_stop_market_ticks(
616        #[case] side: OrderSide,
617        #[case] initial_trigger: f64,
618        #[case] ticks: u32,
619        #[case] last_price: f64,
620        #[case] expected_trigger: Option<f64>,
621    ) {
622        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
623            .instrument_id("BTCUSDT-PERP.BINANCE".into())
624            .side(side)
625            .trigger_price(Price::new(initial_trigger, 2))
626            .trailing_offset_type(TrailingOffsetType::Ticks)
627            .trailing_offset(Decimal::from_u32(ticks).unwrap())
628            .trigger_type(TriggerType::LastPrice)
629            .quantity(Quantity::from(1))
630            .build();
631
632        let result = trailing_stop_calculate(
633            Price::new(0.01, 2),
634            &order,
635            None,
636            None,
637            Some(Price::new(last_price, 2)),
638        );
639
640        let actual_trigger = result.unwrap().0;
641        match (actual_trigger, expected_trigger) {
642            (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
643            (None, None) => (),
644            _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
645        }
646    }
647
648    #[rstest]
649    #[case(OrderSide::Buy, 100.0, 1.0, 98.0, 97.0, 98.0, Some(99.0))] // Last price gives higher trigger
650    #[case(OrderSide::Buy, 100.0, 1.0, 97.0, 96.0, 99.0, Some(98.0))] // Bid/Ask gives higher trigger
651    #[case(OrderSide::Sell, 100.0, 1.0, 102.0, 102.0, 103.0, Some(101.0))] // Last price gives lower trigger
652    #[case(OrderSide::Sell, 100.0, 1.0, 103.0, 101.0, 102.0, Some(102.0))] // Bid/Ask gives lower trigger
653    fn test_trailing_stop_last_or_bid_ask(
654        #[case] side: OrderSide,
655        #[case] initial_trigger: f64,
656        #[case] offset: f64,
657        #[case] last_price: f64,
658        #[case] bid: f64,
659        #[case] ask: f64,
660        #[case] expected_trigger: Option<f64>,
661    ) {
662        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
663            .instrument_id("BTCUSDT-PERP.BINANCE".into())
664            .side(side)
665            .trigger_price(Price::new(initial_trigger, 2))
666            .trailing_offset_type(TrailingOffsetType::Price)
667            .trailing_offset(Decimal::from_f64(offset).unwrap())
668            .trigger_type(TriggerType::LastOrBidAsk)
669            .quantity(Quantity::from(1))
670            .build();
671
672        let result = trailing_stop_calculate(
673            Price::new(0.01, 2),
674            &order,
675            Some(Price::new(bid, 2)),
676            Some(Price::new(ask, 2)),
677            Some(Price::new(last_price, 2)),
678        );
679
680        let actual_trigger = result.unwrap().0;
681        match (actual_trigger, expected_trigger) {
682            (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
683            (None, None) => (),
684            _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
685        }
686    }
687}