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