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