Skip to main content

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;
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, trigger type, or trailing offset type is invalid.
37/// - the order lacks a required trigger, trailing offset, trailing offset type, or limit offset.
38/// - the calculated price cannot be represented as a [`Price`].
39pub fn trailing_stop_calculate(
40    price_increment: Price,
41    trigger_px: Option<Price>,
42    activation_px: Option<Price>,
43    order: &OrderAny,
44    bid: Option<Price>,
45    ask: Option<Price>,
46    last: Option<Price>,
47) -> anyhow::Result<(Option<Price>, Option<Price>)> {
48    let order_side = order.order_side_specified();
49    let order_type = order.order_type();
50
51    if !matches!(
52        order_type,
53        OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
54    ) {
55        anyhow::bail!("Invalid `OrderType` {order_type} for trailing stop calculation");
56    }
57
58    let mut trigger_price = trigger_px
59        .or(order.trigger_price())
60        .or(activation_px)
61        .or(order.activation_price());
62
63    let mut limit_price = if order_type == OrderType::TrailingStopLimit {
64        order.price()
65    } else {
66        None
67    };
68
69    let trigger_type = order
70        .trigger_type()
71        .ok_or_else(|| anyhow::anyhow!("Missing `TriggerType` for trailing stop calculation"))?;
72    let trailing_offset = order.trailing_offset().ok_or_else(|| {
73        anyhow::anyhow!("Missing `trailing_offset` for trailing stop calculation")
74    })?;
75    let trailing_offset_type = order.trailing_offset_type().ok_or_else(|| {
76        anyhow::anyhow!("Missing `TrailingOffsetType` for trailing stop calculation")
77    })?;
78    anyhow::ensure!(
79        trigger_type != TriggerType::NoTrigger,
80        "Invalid `TriggerType::NoTrigger` for trailing stop calculation"
81    );
82    anyhow::ensure!(
83        trailing_offset_type != TrailingOffsetType::NoTrailingOffset,
84        "Invalid `TrailingOffsetType::NoTrailingOffset` for trailing stop calculation"
85    );
86
87    let mut new_trigger_price: Option<Price>;
88    let mut new_limit_price: Option<Price> = None;
89
90    let maybe_move = |current: &mut Option<Price>,
91                      candidate: Price,
92                      better: fn(Price, Price) -> bool|
93     -> Option<Price> {
94        match current {
95            Some(p) if better(candidate, *p) => {
96                *current = Some(candidate);
97                Some(candidate)
98            }
99            None => {
100                *current = Some(candidate);
101                Some(candidate)
102            }
103            _ => None,
104        }
105    };
106
107    let better_trigger: fn(Price, Price) -> bool = match order_side {
108        OrderSideSpecified::Buy => |c, p| c < p,
109        OrderSideSpecified::Sell => |c, p| c > p,
110    };
111    let better_limit = better_trigger;
112
113    let compute = |off: Decimal, basis: Price| -> anyhow::Result<Price> {
114        let basis = basis.as_decimal();
115        let offset = match trailing_offset_type {
116            TrailingOffsetType::Price => off,
117            TrailingOffsetType::BasisPoints => basis * off / Decimal::from(10_000),
118            TrailingOffsetType::Ticks => off * price_increment.as_decimal(),
119            _ => {
120                anyhow::bail!("`TrailingOffsetType` {trailing_offset_type} not currently supported")
121            }
122        };
123        let value = match order_side {
124            OrderSideSpecified::Buy => basis + offset,
125            OrderSideSpecified::Sell => basis - offset,
126        };
127        Price::from_decimal_dp(value, price_increment.precision).map_err(Into::into)
128    };
129
130    match trigger_type {
131        TriggerType::LastPrice | TriggerType::MarkPrice => {
132            let last = last.ok_or(OrderError::InvalidStateTransition)?;
133            let cand_trigger = compute(trailing_offset, last)?;
134            new_trigger_price = maybe_move(&mut trigger_price, cand_trigger, better_trigger);
135
136            if order_type == OrderType::TrailingStopLimit {
137                let limit_offset = order.limit_offset().ok_or_else(|| {
138                    anyhow::anyhow!("Missing `limit_offset` for trailing stop limit calculation")
139                })?;
140                let cand_limit = compute(limit_offset, last)?;
141                new_limit_price = maybe_move(&mut limit_price, cand_limit, better_limit);
142            }
143        }
144        TriggerType::Default | TriggerType::BidAsk | TriggerType::LastOrBidAsk => {
145            let (bid, ask) = (
146                bid.ok_or_else(|| anyhow::anyhow!("Bid required"))?,
147                ask.ok_or_else(|| anyhow::anyhow!("Ask required"))?,
148            );
149            let basis = match order_side {
150                OrderSideSpecified::Buy => ask,
151                OrderSideSpecified::Sell => bid,
152            };
153            let cand_trigger = compute(trailing_offset, basis)?;
154            new_trigger_price = maybe_move(&mut trigger_price, cand_trigger, better_trigger);
155
156            if order_type == OrderType::TrailingStopLimit {
157                let limit_offset = order.limit_offset().ok_or_else(|| {
158                    anyhow::anyhow!("Missing `limit_offset` for trailing stop limit calculation")
159                })?;
160                let cand_limit = compute(limit_offset, basis)?;
161                new_limit_price = maybe_move(&mut limit_price, cand_limit, better_limit);
162            }
163
164            if trigger_type == TriggerType::LastOrBidAsk {
165                let last = last.ok_or_else(|| anyhow::anyhow!("Last required"))?;
166                let cand_trigger = compute(trailing_offset, last)?;
167                let updated = maybe_move(&mut trigger_price, cand_trigger, better_trigger);
168                if updated.is_some() {
169                    new_trigger_price = updated;
170                }
171
172                if order_type == OrderType::TrailingStopLimit {
173                    let limit_offset = order.limit_offset().ok_or_else(|| {
174                        anyhow::anyhow!(
175                            "Missing `limit_offset` for trailing stop limit calculation"
176                        )
177                    })?;
178                    let cand_limit = compute(limit_offset, last)?;
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 type is unsupported or the calculated price cannot be
197/// represented as a [`Price`].
198pub fn trailing_stop_calculate_with_last(
199    price_increment: Price,
200    trailing_offset_type: TrailingOffsetType,
201    side: OrderSideSpecified,
202    offset: Decimal,
203    last: Price,
204) -> anyhow::Result<Price> {
205    let last = last.as_decimal();
206    let offset = match trailing_offset_type {
207        TrailingOffsetType::Price => offset,
208        TrailingOffsetType::BasisPoints => last * offset / Decimal::from(10_000),
209        TrailingOffsetType::Ticks => offset * price_increment.as_decimal(),
210        _ => anyhow::bail!("`TrailingOffsetType` {trailing_offset_type} not currently supported"),
211    };
212
213    let price = match side {
214        OrderSideSpecified::Buy => last + offset,
215        OrderSideSpecified::Sell => last - offset,
216    };
217
218    Price::from_decimal_dp(price, price_increment.precision).map_err(Into::into)
219}
220
221/// Calculates the trailing stop price using bid and ask prices.
222///
223/// # Errors
224///
225/// Returns an error if the offset type is unsupported or the calculated price cannot be
226/// represented as a [`Price`].
227pub fn trailing_stop_calculate_with_bid_ask(
228    price_increment: Price,
229    trailing_offset_type: TrailingOffsetType,
230    side: OrderSideSpecified,
231    offset: Decimal,
232    bid: Price,
233    ask: Price,
234) -> anyhow::Result<Price> {
235    let bid = bid.as_decimal();
236    let ask = ask.as_decimal();
237
238    let offset = match trailing_offset_type {
239        TrailingOffsetType::Price => offset,
240        TrailingOffsetType::BasisPoints => match side {
241            OrderSideSpecified::Buy => ask * offset / Decimal::from(10_000),
242            OrderSideSpecified::Sell => bid * offset / Decimal::from(10_000),
243        },
244        TrailingOffsetType::Ticks => offset * price_increment.as_decimal(),
245        _ => anyhow::bail!("`TrailingOffsetType` {trailing_offset_type} not currently supported"),
246    };
247
248    let price = match side {
249        OrderSideSpecified::Buy => ask + offset,
250        OrderSideSpecified::Sell => bid - offset,
251    };
252
253    Price::from_decimal_dp(price, price_increment.precision).map_err(Into::into)
254}
255
256#[cfg(test)]
257mod tests {
258    use nautilus_model::{
259        enums::{OrderSide, OrderType, TrailingOffsetType, TriggerType},
260        orders::builder::OrderTestBuilder,
261        types::Quantity,
262    };
263    use rstest::rstest;
264    use rust_decimal::prelude::*;
265    use rust_decimal_macros::dec;
266
267    use super::*;
268
269    fn assert_optional_price(actual: Option<Price>, expected: Option<&str>) {
270        match (actual, expected) {
271            (Some(actual), Some(expected)) => assert_eq!(actual, Price::from(expected)),
272            (None, None) => {}
273            (actual, expected) => panic!("expected {expected:?}, was {actual:?}"),
274        }
275    }
276
277    #[rstest]
278    fn test_calculate_with_invalid_order_type() {
279        let order = OrderTestBuilder::new(OrderType::Market)
280            .instrument_id("BTCUSDT-PERP.BINANCE".into())
281            .side(OrderSide::Buy)
282            .quantity(Quantity::from(1))
283            .build();
284
285        let result =
286            trailing_stop_calculate(Price::new(0.01, 2), None, None, &order, None, None, None);
287
288        // TODO: Basic error assert for now
289        assert!(result.is_err());
290    }
291
292    #[rstest]
293    #[case(OrderSide::Buy)]
294    #[case(OrderSide::Sell)]
295    fn test_calculate_with_last_price_no_last(#[case] side: OrderSide) {
296        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
297            .instrument_id("BTCUSDT-PERP.BINANCE".into())
298            .side(side)
299            .trigger_price(Price::new(100.0, 2))
300            .trailing_offset_type(TrailingOffsetType::Price)
301            .trailing_offset(dec!(1.0))
302            .trigger_type(TriggerType::LastPrice)
303            .quantity(Quantity::from(1))
304            .build();
305
306        let result =
307            trailing_stop_calculate(Price::new(0.01, 2), None, None, &order, None, None, None);
308
309        // TODO: Basic error assert for now
310        assert!(result.is_err());
311    }
312
313    #[rstest]
314    #[case(OrderSide::Buy)]
315    #[case(OrderSide::Sell)]
316    fn test_calculate_with_bid_ask_no_bid_ask(#[case] side: OrderSide) {
317        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
318            .instrument_id("BTCUSDT-PERP.BINANCE".into())
319            .side(side)
320            .trigger_price(Price::new(100.0, 2))
321            .trailing_offset_type(TrailingOffsetType::Price)
322            .trailing_offset(dec!(1.0))
323            .trigger_type(TriggerType::BidAsk)
324            .quantity(Quantity::from(1))
325            .build();
326
327        let result =
328            trailing_stop_calculate(Price::new(0.01, 2), None, None, &order, None, None, None);
329
330        // TODO: Basic error assert for now
331        assert!(result.is_err());
332    }
333
334    #[rstest]
335    fn test_calculate_with_unsupported_trigger_type() {
336        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
337            .instrument_id("BTCUSDT-PERP.BINANCE".into())
338            .side(OrderSide::Buy)
339            .trigger_price(Price::new(100.0, 2))
340            .trailing_offset_type(TrailingOffsetType::Price)
341            .trailing_offset(dec!(1.0))
342            .trigger_type(TriggerType::IndexPrice) // not supported by algo
343            .quantity(Quantity::from(1))
344            .build();
345
346        let result =
347            trailing_stop_calculate(Price::new(0.01, 2), None, None, &order, None, None, None);
348
349        // TODO: Basic error assert for now
350        assert!(result.is_err());
351    }
352
353    #[rstest]
354    fn test_calculate_with_no_trailing_offset_type_returns_error() {
355        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
356            .instrument_id("BTCUSDT-PERP.BINANCE".into())
357            .side(OrderSide::Buy)
358            .trigger_price(Price::new(100.0, 2))
359            .trailing_offset_type(TrailingOffsetType::NoTrailingOffset)
360            .trailing_offset(dec!(1.0))
361            .trigger_type(TriggerType::LastPrice)
362            .quantity(Quantity::from(1))
363            .build();
364
365        let result = trailing_stop_calculate(
366            Price::new(0.01, 2),
367            None,
368            None,
369            &order,
370            None,
371            None,
372            Some(Price::new(99.0, 2)),
373        );
374
375        assert_eq!(
376            result.unwrap_err().to_string(),
377            "Invalid `TrailingOffsetType::NoTrailingOffset` for trailing stop calculation"
378        );
379    }
380
381    #[rstest]
382    #[case(OrderSide::Buy, 100.0, 1.0, 99.0, None)] // Last price 99 > trigger 98, no update needed
383    #[case(OrderSide::Buy, 100.0, 1.0, 98.0, Some("99.0"))] // Last price 98 < trigger 100, update to 98 + 1
384    #[case(OrderSide::Sell, 100.0, 1.0, 101.0, None)] // Last price 101 < trigger 102, no update needed
385    #[case(OrderSide::Sell, 100.0, 1.0, 102.0, Some("101.0"))] // Last price 102 > trigger 100, update to 102 - 1
386    fn test_trailing_stop_market_last_price(
387        #[case] side: OrderSide,
388        #[case] initial_trigger: f64,
389        #[case] offset: f64,
390        #[case] last_price: f64,
391        #[case] expected_trigger: Option<&str>,
392    ) {
393        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
394            .instrument_id("BTCUSDT-PERP.BINANCE".into())
395            .side(side)
396            .trigger_price(Price::new(initial_trigger, 2))
397            .trailing_offset_type(TrailingOffsetType::Price)
398            .trailing_offset(Decimal::from_f64(offset).unwrap())
399            .trigger_type(TriggerType::LastPrice)
400            .quantity(Quantity::from(1))
401            .build();
402
403        let result = trailing_stop_calculate(
404            Price::new(0.01, 2),
405            None,
406            None,
407            &order,
408            None,
409            None,
410            Some(Price::new(last_price, 2)),
411        );
412
413        assert_optional_price(result.unwrap().0, expected_trigger);
414    }
415
416    #[rstest]
417    #[case(OrderSide::Buy, 1505.0, 1.0, 1480.0, 1479.0, Some("1481.0"))] // BUY uses ask as basis
418    #[case(OrderSide::Sell, 1495.0, 1.0, 1521.0, 1520.0, Some("1519.0"))] // SELL uses bid as basis
419    fn test_trailing_stop_market_default_uses_bid_ask(
420        #[case] side: OrderSide,
421        #[case] initial_trigger: f64,
422        #[case] offset: f64,
423        #[case] ask: f64,
424        #[case] bid: f64,
425        #[case] expected_trigger: Option<&str>,
426    ) {
427        // NOTE: TriggerType::Default is documented to behave like BID_ASK (quote-based), so it
428        // should not require a last-trade price and should trail using bid/ask.
429        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
430            .instrument_id("BTCUSDT-PERP.BINANCE".into())
431            .side(side)
432            .trigger_price(Price::new(initial_trigger, 2))
433            .trailing_offset_type(TrailingOffsetType::Price)
434            .trailing_offset(Decimal::from_f64(offset).unwrap())
435            .trigger_type(TriggerType::Default)
436            .quantity(Quantity::from(1))
437            .build();
438
439        let result = trailing_stop_calculate(
440            Price::new(0.01, 2),
441            None,
442            None,
443            &order,
444            Some(Price::new(bid, 2)),
445            Some(Price::new(ask, 2)),
446            None, // no last-trade price available
447        );
448
449        assert_optional_price(result.unwrap().0, expected_trigger);
450    }
451
452    #[rstest]
453    #[case(OrderSide::Buy, 100.0, 50.0, 98.0, Some("98.49"))] // 50bp = 0.5% of 98 = 0.49
454    #[case(OrderSide::Buy, 100.0, 100.0, 97.0, Some("97.97"))] // 100bp = 1% of 97 = 0.97
455    #[case(OrderSide::Sell, 100.0, 50.0, 102.0, Some("101.49"))] // 50bp = 0.5% of 102 = 0.51
456    #[case(OrderSide::Sell, 100.0, 100.0, 103.0, Some("101.97"))] // 100bp = 1% of 103 = 1.03
457    fn test_trailing_stop_market_basis_points(
458        #[case] side: OrderSide,
459        #[case] initial_trigger: f64,
460        #[case] basis_points: f64,
461        #[case] last_price: f64,
462        #[case] expected_trigger: Option<&str>,
463    ) {
464        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
465            .instrument_id("BTCUSDT-PERP.BINANCE".into())
466            .side(side)
467            .trigger_price(Price::new(initial_trigger, 2))
468            .trailing_offset_type(TrailingOffsetType::BasisPoints)
469            .trailing_offset(Decimal::from_f64(basis_points).unwrap())
470            .trigger_type(TriggerType::LastPrice)
471            .quantity(Quantity::from(1))
472            .build();
473
474        let result = trailing_stop_calculate(
475            Price::new(0.01, 2),
476            None,
477            None,
478            &order,
479            None,
480            None,
481            Some(Price::new(last_price, 2)),
482        );
483
484        assert_optional_price(result.unwrap().0, expected_trigger);
485    }
486
487    #[rstest]
488    #[case(OrderSide::Buy, 100.0, 1.0, 98.0, 99.0, None)] // Ask 99 > trigger 100, no update
489    #[case(OrderSide::Buy, 100.0, 1.0, 97.0, 98.0, Some("99.0"))] // Ask 98 < trigger 100, update to 98 + 1
490    #[case(OrderSide::Sell, 100.0, 1.0, 101.0, 102.0, None)] // Bid 101 < trigger 100, no update
491    #[case(OrderSide::Sell, 100.0, 1.0, 102.0, 103.0, Some("101.0"))] // Bid 102 > trigger 100, update to 102 - 1
492    fn test_trailing_stop_market_bid_ask(
493        #[case] side: OrderSide,
494        #[case] initial_trigger: f64,
495        #[case] offset: f64,
496        #[case] bid: f64,
497        #[case] ask: f64,
498        #[case] expected_trigger: Option<&str>,
499    ) {
500        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
501            .instrument_id("BTCUSDT-PERP.BINANCE".into())
502            .side(side)
503            .trigger_price(Price::new(initial_trigger, 2))
504            .trailing_offset_type(TrailingOffsetType::Price)
505            .trailing_offset(Decimal::from_f64(offset).unwrap())
506            .trigger_type(TriggerType::BidAsk)
507            .quantity(Quantity::from(1))
508            .build();
509
510        let result = trailing_stop_calculate(
511            Price::new(0.01, 2),
512            None,
513            None,
514            &order,
515            Some(Price::new(bid, 2)),
516            Some(Price::new(ask, 2)),
517            None, // last price not needed for BidAsk trigger type
518        );
519
520        assert_optional_price(result.unwrap().0, expected_trigger);
521    }
522
523    #[rstest]
524    #[case(OrderSide::Buy, 100.0, 5, 98.0, Some("98.05"))] // 5 ticks * 0.01 = 0.05 offset
525    #[case(OrderSide::Buy, 100.0, 10, 97.0, Some("97.10"))] // 10 ticks * 0.01 = 0.10 offset
526    #[case(OrderSide::Sell, 100.0, 5, 102.0, Some("101.95"))] // 5 ticks * 0.01 = 0.05 offset
527    #[case(OrderSide::Sell, 100.0, 10, 103.0, Some("102.90"))] // 10 ticks * 0.01 = 0.10 offset
528    fn test_trailing_stop_market_ticks(
529        #[case] side: OrderSide,
530        #[case] initial_trigger: f64,
531        #[case] ticks: u32,
532        #[case] last_price: f64,
533        #[case] expected_trigger: Option<&str>,
534    ) {
535        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
536            .instrument_id("BTCUSDT-PERP.BINANCE".into())
537            .side(side)
538            .trigger_price(Price::new(initial_trigger, 2))
539            .trailing_offset_type(TrailingOffsetType::Ticks)
540            .trailing_offset(Decimal::from_u32(ticks).unwrap())
541            .trigger_type(TriggerType::LastPrice)
542            .quantity(Quantity::from(1))
543            .build();
544
545        let result = trailing_stop_calculate(
546            Price::new(0.01, 2),
547            None,
548            None,
549            &order,
550            None,
551            None,
552            Some(Price::new(last_price, 2)),
553        );
554
555        assert_optional_price(result.unwrap().0, expected_trigger);
556    }
557
558    #[rstest]
559    #[case(OrderSide::Buy, 100.0, 1.0, 98.0, 97.0, 98.0, Some("99.0"))] // Last price gives higher trigger
560    #[case(OrderSide::Buy, 100.0, 1.0, 97.0, 96.0, 99.0, Some("98.0"))] // Bid/Ask gives higher trigger
561    #[case(OrderSide::Sell, 100.0, 1.0, 102.0, 102.0, 103.0, Some("101.0"))] // Last price gives lower trigger
562    #[case(OrderSide::Sell, 100.0, 1.0, 103.0, 101.0, 102.0, Some("102.0"))] // Bid/Ask gives lower trigger
563    fn test_trailing_stop_last_or_bid_ask(
564        #[case] side: OrderSide,
565        #[case] initial_trigger: f64,
566        #[case] offset: f64,
567        #[case] last_price: f64,
568        #[case] bid: f64,
569        #[case] ask: f64,
570        #[case] expected_trigger: Option<&str>,
571    ) {
572        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
573            .instrument_id("BTCUSDT-PERP.BINANCE".into())
574            .side(side)
575            .trigger_price(Price::new(initial_trigger, 2))
576            .trailing_offset_type(TrailingOffsetType::Price)
577            .trailing_offset(Decimal::from_f64(offset).unwrap())
578            .trigger_type(TriggerType::LastOrBidAsk)
579            .quantity(Quantity::from(1))
580            .build();
581
582        let result = trailing_stop_calculate(
583            Price::new(0.01, 2),
584            None,
585            None,
586            &order,
587            Some(Price::new(bid, 2)),
588            Some(Price::new(ask, 2)),
589            Some(Price::new(last_price, 2)),
590        );
591
592        assert_optional_price(result.unwrap().0, expected_trigger);
593    }
594
595    #[rstest]
596    #[case(OrderSide::Buy, 100.0, 1.0, 98.0, Some("99.0"))]
597    #[case(OrderSide::Sell, 100.0, 1.0, 102.0, Some("101.0"))]
598    fn test_trailing_stop_market_last_price_move_in_favour(
599        #[case] side: OrderSide,
600        #[case] initial_trigger: f64,
601        #[case] offset: f64,
602        #[case] last_price: f64,
603        #[case] expected_trigger: Option<&str>,
604    ) {
605        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
606            .instrument_id("BTCUSDT-PERP.BINANCE".into())
607            .side(side)
608            .trigger_price(Price::new(initial_trigger, 2))
609            .trailing_offset_type(TrailingOffsetType::Price)
610            .trailing_offset(Decimal::from_f64(offset).unwrap())
611            .trigger_type(TriggerType::LastPrice)
612            .quantity(Quantity::from(1))
613            .build();
614
615        let (maybe_trigger, _) = trailing_stop_calculate(
616            Price::new(0.01, 2),
617            None,
618            None,
619            &order,
620            None,
621            None,
622            Some(Price::new(last_price, 2)),
623        )
624        .unwrap();
625
626        assert_optional_price(maybe_trigger, expected_trigger);
627    }
628
629    #[rstest]
630    fn test_trailing_stop_limit_last_price_buy_improve_trigger_and_limit() {
631        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
632            .instrument_id("BTCUSDT-PERP.BINANCE".into())
633            .side(OrderSide::Buy)
634            .trigger_price(Price::new(105.0, 2))
635            .price(Price::new(104.5, 2))
636            .trailing_offset_type(TrailingOffsetType::Price)
637            .trailing_offset(dec!(1.0))
638            .limit_offset(dec!(0.5))
639            .trigger_type(TriggerType::LastPrice)
640            .quantity(Quantity::from(1))
641            .build();
642
643        let (new_trigger, new_limit) = trailing_stop_calculate(
644            Price::new(0.01, 2),
645            None,
646            None,
647            &order,
648            None,
649            None,
650            Some(Price::new(100.0, 2)),
651        )
652        .unwrap();
653
654        assert_eq!(new_trigger.unwrap(), Price::from("101.0"));
655        assert_eq!(new_limit.unwrap(), Price::from("100.5"));
656    }
657
658    #[rstest]
659    fn test_trailing_stop_limit_last_price_sell_improve() {
660        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
661            .instrument_id("BTCUSDT-PERP.BINANCE".into())
662            .side(OrderSide::Sell)
663            .trigger_price(Price::new(95.0, 2))
664            .price(Price::new(95.5, 2))
665            .trailing_offset_type(TrailingOffsetType::Price)
666            .trailing_offset(dec!(1.0))
667            .limit_offset(dec!(0.5))
668            .trigger_type(TriggerType::LastPrice)
669            .quantity(Quantity::from(1))
670            .build();
671
672        let (new_trigger, new_limit) = trailing_stop_calculate(
673            Price::new(0.01, 2),
674            None,
675            None,
676            &order,
677            None,
678            None,
679            Some(Price::new(100.0, 2)),
680        )
681        .unwrap();
682
683        assert_eq!(new_trigger.unwrap(), Price::from("99.0"));
684        assert_eq!(new_limit.unwrap(), Price::from("99.5"));
685    }
686
687    #[rstest]
688    #[case(OrderSide::Buy, 100.0, 1.0, 99.0)]
689    #[case(OrderSide::Sell, 100.0, 1.0, 101.0)]
690    fn test_no_update_when_candidate_worse(
691        #[case] side: OrderSide,
692        #[case] initial_trigger: f64,
693        #[case] offset: f64,
694        #[case] basis: f64,
695    ) {
696        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
697            .instrument_id("BTCUSDT-PERP.BINANCE".into())
698            .side(side)
699            .trigger_price(Price::new(initial_trigger, 2))
700            .trailing_offset_type(TrailingOffsetType::Price)
701            .trailing_offset(Decimal::from_f64(offset).unwrap())
702            .trigger_type(TriggerType::LastPrice)
703            .quantity(Quantity::from(1))
704            .build();
705
706        let (maybe_trigger, _) = trailing_stop_calculate(
707            Price::new(0.01, 2),
708            None,
709            None,
710            &order,
711            None,
712            None,
713            Some(Price::new(basis, 2)),
714        )
715        .unwrap();
716
717        assert!(maybe_trigger.is_none());
718    }
719
720    #[rstest]
721    #[case(
722        TrailingOffsetType::Price,
723        OrderSideSpecified::Buy,
724        dec!(1.25),
725        Price::from("98.00"),
726        Price::from("99.25")
727    )]
728    #[case(
729        TrailingOffsetType::BasisPoints,
730        OrderSideSpecified::Buy,
731        dec!(50),
732        Price::from("98.00"),
733        Price::from("98.49")
734    )]
735    #[case(
736        TrailingOffsetType::Ticks,
737        OrderSideSpecified::Sell,
738        dec!(5),
739        Price::from("102.00"),
740        Price::from("101.95")
741    )]
742    fn test_calculate_with_last_uses_decimal_math(
743        #[case] trailing_offset_type: TrailingOffsetType,
744        #[case] side: OrderSideSpecified,
745        #[case] offset: Decimal,
746        #[case] last: Price,
747        #[case] expected: Price,
748    ) {
749        let price = trailing_stop_calculate_with_last(
750            Price::from("0.01"),
751            trailing_offset_type,
752            side,
753            offset,
754            last,
755        )
756        .unwrap();
757
758        assert_eq!(price, expected);
759    }
760
761    #[rstest]
762    #[case(
763        TrailingOffsetType::Price,
764        OrderSideSpecified::Sell,
765        dec!(1.25),
766        Price::from("102.00"),
767        Price::from("103.00"),
768        Price::from("100.75")
769    )]
770    #[case(
771        TrailingOffsetType::BasisPoints,
772        OrderSideSpecified::Sell,
773        dec!(50),
774        Price::from("102.00"),
775        Price::from("103.00"),
776        Price::from("101.49")
777    )]
778    #[case(
779        TrailingOffsetType::Ticks,
780        OrderSideSpecified::Buy,
781        dec!(5),
782        Price::from("102.00"),
783        Price::from("103.00"),
784        Price::from("103.05")
785    )]
786    fn test_calculate_with_bid_ask_uses_decimal_math(
787        #[case] trailing_offset_type: TrailingOffsetType,
788        #[case] side: OrderSideSpecified,
789        #[case] offset: Decimal,
790        #[case] bid: Price,
791        #[case] ask: Price,
792        #[case] expected: Price,
793    ) {
794        let price = trailing_stop_calculate_with_bid_ask(
795            Price::from("0.01"),
796            trailing_offset_type,
797            side,
798            offset,
799            bid,
800            ask,
801        )
802        .unwrap();
803
804        assert_eq!(price, expected);
805    }
806
807    #[rstest]
808    fn test_trailing_stop_limit_basis_points_buy_improve() {
809        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
810            .instrument_id("BTCUSDT-PERP.BINANCE".into())
811            .side(OrderSide::Buy)
812            .trigger_price(Price::new(110.0, 2))
813            .price(Price::new(109.5, 2))
814            .trailing_offset_type(TrailingOffsetType::BasisPoints)
815            .trailing_offset(dec!(50))
816            .limit_offset(dec!(25))
817            .trigger_type(TriggerType::LastPrice)
818            .quantity(Quantity::from(1))
819            .build();
820
821        let (new_trigger, new_limit) = trailing_stop_calculate(
822            Price::new(0.01, 2),
823            None,
824            None,
825            &order,
826            None,
827            None,
828            Some(Price::new(98.0, 2)),
829        )
830        .unwrap();
831
832        assert_eq!(new_trigger.unwrap(), Price::from("98.49"));
833        assert_eq!(new_limit.unwrap(), Price::from("98.24"));
834    }
835}