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