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