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