nautilus_execution/
trailing.rs

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