nautilus_execution/
trailing.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 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::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::Default | 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, 1505.0, 1.0, 1480.0, 1479.0, Some(1481.0))] // BUY uses ask as basis
408    #[case(OrderSide::Sell, 1495.0, 1.0, 1521.0, 1520.0, Some(1519.0))] // SELL uses bid as basis
409    fn test_trailing_stop_market_default_uses_bid_ask(
410        #[case] side: OrderSide,
411        #[case] initial_trigger: f64,
412        #[case] offset: f64,
413        #[case] ask: f64,
414        #[case] bid: f64,
415        #[case] expected_trigger: Option<f64>,
416    ) {
417        // NOTE: TriggerType::Default is documented to behave like BID_ASK (quote-based), so it
418        // should not require a last-trade price and should trail using bid/ask.
419        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
420            .instrument_id("BTCUSDT-PERP.BINANCE".into())
421            .side(side)
422            .trigger_price(Price::new(initial_trigger, 2))
423            .trailing_offset_type(TrailingOffsetType::Price)
424            .trailing_offset(Decimal::from_f64(offset).unwrap())
425            .trigger_type(TriggerType::Default)
426            .quantity(Quantity::from(1))
427            .build();
428
429        let result = trailing_stop_calculate(
430            Price::new(0.01, 2),
431            None,
432            None,
433            &order,
434            Some(Price::new(bid, 2)),
435            Some(Price::new(ask, 2)),
436            None, // no last-trade price available
437        );
438
439        let actual_trigger = result.unwrap().0;
440        match (actual_trigger, expected_trigger) {
441            (Some(actual), Some(expected)) => assert_eq!(actual, Price::new(expected, 2)),
442            (None, None) => {}
443            (actual, expected) => {
444                panic!("Unexpected trigger: actual={actual:?} expected={expected:?}")
445            }
446        }
447    }
448
449    #[rstest]
450    #[case(OrderSide::Buy, 100.0, 50.0, 98.0, Some(98.49))] // 50bp = 0.5% of 98 = 0.49
451    #[case(OrderSide::Buy, 100.0, 100.0, 97.0, Some(97.97))] // 100bp = 1% of 97 = 0.97
452    #[case(OrderSide::Sell, 100.0, 50.0, 102.0, Some(101.49))] // 50bp = 0.5% of 102 = 0.51
453    #[case(OrderSide::Sell, 100.0, 100.0, 103.0, Some(101.97))] // 100bp = 1% of 103 = 1.03
454    fn test_trailing_stop_market_basis_points(
455        #[case] side: OrderSide,
456        #[case] initial_trigger: f64,
457        #[case] basis_points: f64,
458        #[case] last_price: f64,
459        #[case] expected_trigger: Option<f64>,
460    ) {
461        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
462            .instrument_id("BTCUSDT-PERP.BINANCE".into())
463            .side(side)
464            .trigger_price(Price::new(initial_trigger, 2))
465            .trailing_offset_type(TrailingOffsetType::BasisPoints)
466            .trailing_offset(Decimal::from_f64(basis_points).unwrap())
467            .trigger_type(TriggerType::LastPrice)
468            .quantity(Quantity::from(1))
469            .build();
470
471        let result = trailing_stop_calculate(
472            Price::new(0.01, 2),
473            None,
474            None,
475            &order,
476            None,
477            None,
478            Some(Price::new(last_price, 2)),
479        );
480
481        let actual_trigger = result.unwrap().0;
482        match (actual_trigger, expected_trigger) {
483            (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
484            (None, None) => (),
485            _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
486        }
487    }
488
489    #[rstest]
490    #[case(OrderSide::Buy, 100.0, 1.0, 98.0, 99.0, None)] // Ask 99 > trigger 100, no update
491    #[case(OrderSide::Buy, 100.0, 1.0, 97.0, 98.0, Some(99.0))] // Ask 98 < trigger 100, update to 98 + 1
492    #[case(OrderSide::Sell, 100.0, 1.0, 101.0, 102.0, None)] // Bid 101 < trigger 100, no update
493    #[case(OrderSide::Sell, 100.0, 1.0, 102.0, 103.0, Some(101.0))] // Bid 102 > trigger 100, update to 102 - 1
494    fn test_trailing_stop_market_bid_ask(
495        #[case] side: OrderSide,
496        #[case] initial_trigger: f64,
497        #[case] offset: f64,
498        #[case] bid: f64,
499        #[case] ask: f64,
500        #[case] expected_trigger: Option<f64>,
501    ) {
502        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
503            .instrument_id("BTCUSDT-PERP.BINANCE".into())
504            .side(side)
505            .trigger_price(Price::new(initial_trigger, 2))
506            .trailing_offset_type(TrailingOffsetType::Price)
507            .trailing_offset(Decimal::from_f64(offset).unwrap())
508            .trigger_type(TriggerType::BidAsk)
509            .quantity(Quantity::from(1))
510            .build();
511
512        let result = trailing_stop_calculate(
513            Price::new(0.01, 2),
514            None,
515            None,
516            &order,
517            Some(Price::new(bid, 2)),
518            Some(Price::new(ask, 2)),
519            None, // last price not needed for BidAsk trigger type
520        );
521
522        let actual_trigger = result.unwrap().0;
523        match (actual_trigger, expected_trigger) {
524            (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
525            (None, None) => (),
526            _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
527        }
528    }
529
530    #[rstest]
531    #[case(OrderSide::Buy, 100.0, 5, 98.0, Some(98.05))] // 5 ticks * 0.01 = 0.05 offset
532    #[case(OrderSide::Buy, 100.0, 10, 97.0, Some(97.10))] // 10 ticks * 0.01 = 0.10 offset
533    #[case(OrderSide::Sell, 100.0, 5, 102.0, Some(101.95))] // 5 ticks * 0.01 = 0.05 offset
534    #[case(OrderSide::Sell, 100.0, 10, 103.0, Some(102.90))] // 10 ticks * 0.01 = 0.10 offset
535    fn test_trailing_stop_market_ticks(
536        #[case] side: OrderSide,
537        #[case] initial_trigger: f64,
538        #[case] ticks: u32,
539        #[case] last_price: f64,
540        #[case] expected_trigger: Option<f64>,
541    ) {
542        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
543            .instrument_id("BTCUSDT-PERP.BINANCE".into())
544            .side(side)
545            .trigger_price(Price::new(initial_trigger, 2))
546            .trailing_offset_type(TrailingOffsetType::Ticks)
547            .trailing_offset(Decimal::from_u32(ticks).unwrap())
548            .trigger_type(TriggerType::LastPrice)
549            .quantity(Quantity::from(1))
550            .build();
551
552        let result = trailing_stop_calculate(
553            Price::new(0.01, 2),
554            None,
555            None,
556            &order,
557            None,
558            None,
559            Some(Price::new(last_price, 2)),
560        );
561
562        let actual_trigger = result.unwrap().0;
563        match (actual_trigger, expected_trigger) {
564            (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
565            (None, None) => (),
566            _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
567        }
568    }
569
570    #[rstest]
571    #[case(OrderSide::Buy, 100.0, 1.0, 98.0, 97.0, 98.0, Some(99.0))] // Last price gives higher trigger
572    #[case(OrderSide::Buy, 100.0, 1.0, 97.0, 96.0, 99.0, Some(98.0))] // Bid/Ask gives higher trigger
573    #[case(OrderSide::Sell, 100.0, 1.0, 102.0, 102.0, 103.0, Some(101.0))] // Last price gives lower trigger
574    #[case(OrderSide::Sell, 100.0, 1.0, 103.0, 101.0, 102.0, Some(102.0))] // Bid/Ask gives lower trigger
575    fn test_trailing_stop_last_or_bid_ask(
576        #[case] side: OrderSide,
577        #[case] initial_trigger: f64,
578        #[case] offset: f64,
579        #[case] last_price: f64,
580        #[case] bid: f64,
581        #[case] ask: f64,
582        #[case] expected_trigger: Option<f64>,
583    ) {
584        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
585            .instrument_id("BTCUSDT-PERP.BINANCE".into())
586            .side(side)
587            .trigger_price(Price::new(initial_trigger, 2))
588            .trailing_offset_type(TrailingOffsetType::Price)
589            .trailing_offset(Decimal::from_f64(offset).unwrap())
590            .trigger_type(TriggerType::LastOrBidAsk)
591            .quantity(Quantity::from(1))
592            .build();
593
594        let result = trailing_stop_calculate(
595            Price::new(0.01, 2),
596            None,
597            None,
598            &order,
599            Some(Price::new(bid, 2)),
600            Some(Price::new(ask, 2)),
601            Some(Price::new(last_price, 2)),
602        );
603
604        let actual_trigger = result.unwrap().0;
605        match (actual_trigger, expected_trigger) {
606            (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
607            (None, None) => (),
608            _ => panic!("Expected trigger {expected_trigger:?} but got {actual_trigger:?}"),
609        }
610    }
611
612    #[rstest]
613    #[case(OrderSide::Buy, 100.0, 1.0, 98.0, Some(99.0))]
614    #[case(OrderSide::Sell, 100.0, 1.0, 102.0, Some(101.0))]
615    fn test_trailing_stop_market_last_price_move_in_favour(
616        #[case] side: OrderSide,
617        #[case] initial_trigger: f64,
618        #[case] offset: f64,
619        #[case] last_price: f64,
620        #[case] expected_trigger: Option<f64>,
621    ) {
622        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
623            .instrument_id("BTCUSDT-PERP.BINANCE".into())
624            .side(side)
625            .trigger_price(Price::new(initial_trigger, 2))
626            .trailing_offset_type(TrailingOffsetType::Price)
627            .trailing_offset(Decimal::from_f64(offset).unwrap())
628            .trigger_type(TriggerType::LastPrice)
629            .quantity(Quantity::from(1))
630            .build();
631
632        let (maybe_trigger, _) = trailing_stop_calculate(
633            Price::new(0.01, 2),
634            None,
635            None,
636            &order,
637            None,
638            None,
639            Some(Price::new(last_price, 2)),
640        )
641        .unwrap();
642
643        match (maybe_trigger, expected_trigger) {
644            (Some(actual), Some(expected)) => assert_eq!(actual.as_f64(), expected),
645            (None, None) => (),
646            _ => panic!("expected {expected_trigger:?}, was {maybe_trigger:?}"),
647        }
648    }
649
650    #[rstest]
651    fn test_trailing_stop_limit_last_price_buy_improve_trigger_and_limit() {
652        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
653            .instrument_id("BTCUSDT-PERP.BINANCE".into())
654            .side(OrderSide::Buy)
655            .trigger_price(Price::new(105.0, 2))
656            .price(Price::new(104.5, 2))
657            .trailing_offset_type(TrailingOffsetType::Price)
658            .trailing_offset(dec!(1.0))
659            .limit_offset(dec!(0.5))
660            .trigger_type(TriggerType::LastPrice)
661            .quantity(Quantity::from(1))
662            .build();
663
664        let (new_trigger, new_limit) = trailing_stop_calculate(
665            Price::new(0.01, 2),
666            None,
667            None,
668            &order,
669            None,
670            None,
671            Some(Price::new(100.0, 2)),
672        )
673        .unwrap();
674
675        assert_eq!(new_trigger.unwrap().as_f64(), 101.0);
676        assert_eq!(new_limit.unwrap().as_f64(), 100.5);
677    }
678
679    #[rstest]
680    fn test_trailing_stop_limit_last_price_sell_improve() {
681        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
682            .instrument_id("BTCUSDT-PERP.BINANCE".into())
683            .side(OrderSide::Sell)
684            .trigger_price(Price::new(95.0, 2))
685            .price(Price::new(95.5, 2))
686            .trailing_offset_type(TrailingOffsetType::Price)
687            .trailing_offset(dec!(1.0))
688            .limit_offset(dec!(0.5))
689            .trigger_type(TriggerType::LastPrice)
690            .quantity(Quantity::from(1))
691            .build();
692
693        let (new_trigger, new_limit) = trailing_stop_calculate(
694            Price::new(0.01, 2),
695            None,
696            None,
697            &order,
698            None,
699            None,
700            Some(Price::new(100.0, 2)),
701        )
702        .unwrap();
703
704        assert_eq!(new_trigger.unwrap().as_f64(), 99.0);
705        assert_eq!(new_limit.unwrap().as_f64(), 99.5);
706    }
707
708    #[rstest]
709    #[case(OrderSide::Buy, 100.0, 1.0, 99.0)]
710    #[case(OrderSide::Sell, 100.0, 1.0, 101.0)]
711    fn test_no_update_when_candidate_worse(
712        #[case] side: OrderSide,
713        #[case] initial_trigger: f64,
714        #[case] offset: f64,
715        #[case] basis: f64,
716    ) {
717        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
718            .instrument_id("BTCUSDT-PERP.BINANCE".into())
719            .side(side)
720            .trigger_price(Price::new(initial_trigger, 2))
721            .trailing_offset_type(TrailingOffsetType::Price)
722            .trailing_offset(Decimal::from_f64(offset).unwrap())
723            .trigger_type(TriggerType::LastPrice)
724            .quantity(Quantity::from(1))
725            .build();
726
727        let (maybe_trigger, _) = trailing_stop_calculate(
728            Price::new(0.01, 2),
729            None,
730            None,
731            &order,
732            None,
733            None,
734            Some(Price::new(basis, 2)),
735        )
736        .unwrap();
737
738        assert!(maybe_trigger.is_none());
739    }
740
741    #[rstest]
742    fn test_trailing_stop_limit_basis_points_buy_improve() {
743        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
744            .instrument_id("BTCUSDT-PERP.BINANCE".into())
745            .side(OrderSide::Buy)
746            .trigger_price(Price::new(110.0, 2))
747            .price(Price::new(109.5, 2))
748            .trailing_offset_type(TrailingOffsetType::BasisPoints)
749            .trailing_offset(dec!(50))
750            .limit_offset(dec!(25))
751            .trigger_type(TriggerType::LastPrice)
752            .quantity(Quantity::from(1))
753            .build();
754
755        let (new_trigger, new_limit) = trailing_stop_calculate(
756            Price::new(0.01, 2),
757            None,
758            None,
759            &order,
760            None,
761            None,
762            Some(Price::new(98.0, 2)),
763        )
764        .unwrap();
765
766        assert_eq!(new_trigger.unwrap().as_f64(), 98.49);
767        assert_eq!(new_limit.unwrap().as_f64(), 98.25);
768    }
769}