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