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_common::cache::Cache;
18use nautilus_model::{
19    enums::{OrderSide, OrderType, TrailingOffsetType, TriggerType},
20    orders::{Order, OrderAny, OrderError},
21    types::Price,
22};
23use rust_decimal::{Decimal, RoundingStrategy};
24
25/// Calculates the new trigger and limit prices for a trailing stop order.
26///
27/// `trigger_px` is an optional **override** for the trigger price already carried inside
28/// `order`.  If `Some(_)`, it takes priority over the value on the order itself, otherwise
29/// the function falls back to the value stored on the order.
30///
31/// # Returns
32/// A tuple with the *newly-set* trigger-price and limit-price (if any).
33/// `None` in either position means the respective price did **not** improve.
34///
35/// # Errors
36/// Returns an error if:
37/// - the order type, trigger type, or trailing offset type is invalid.
38/// - the order lacks a required trigger, trailing offset, trailing offset type, or limit offset.
39/// - the calculated price cannot be represented as a [`Price`].
40pub fn trailing_stop_calculate(
41    price_increment: Price,
42    trigger_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();
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    // Seed from the current trigger only (never the activation price): when the trigger has
59    // not yet materialized it stays `None` here so the offset candidate below becomes the
60    // initial trigger on the first update (matches v1 `TrailingStopCalculator`).
61    let mut trigger_price = trigger_px.or(order.trigger_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    let mut new_trigger_price: Option<Price>;
79    let mut new_limit_price: Option<Price> = None;
80
81    let maybe_move = |current: &mut Option<Price>,
82                      candidate: Price,
83                      better: fn(Price, Price) -> bool|
84     -> Option<Price> {
85        match current {
86            Some(p) if better(candidate, *p) => {
87                *current = Some(candidate);
88                Some(candidate)
89            }
90            None => {
91                *current = Some(candidate);
92                Some(candidate)
93            }
94            _ => None,
95        }
96    };
97
98    let better_trigger: fn(Price, Price) -> bool = match order_side {
99        OrderSide::Buy => |c, p| c < p,
100        OrderSide::Sell => |c, p| c > p,
101    };
102    let better_limit = better_trigger;
103
104    let compute = |offset: Decimal, basis: Price| {
105        trailing_stop_calculate_with_last(
106            price_increment,
107            trailing_offset_type,
108            order_side,
109            offset,
110            basis,
111        )
112    };
113
114    match trigger_type {
115        TriggerType::LastPrice | TriggerType::MarkPrice => {
116            let last = last.ok_or(OrderError::InvalidStateTransition)?;
117            let cand_trigger = compute(trailing_offset, last)?;
118            new_trigger_price = maybe_move(&mut trigger_price, cand_trigger, better_trigger);
119
120            if order_type == OrderType::TrailingStopLimit {
121                let limit_offset = order.limit_offset().ok_or_else(|| {
122                    anyhow::anyhow!("Missing `limit_offset` for trailing stop limit calculation")
123                })?;
124                let cand_limit = compute(limit_offset, last)?;
125                new_limit_price = maybe_move(&mut limit_price, cand_limit, better_limit);
126            }
127        }
128        TriggerType::Default | TriggerType::BidAsk | TriggerType::LastOrBidAsk => {
129            let (bid, ask) = (
130                bid.ok_or_else(|| anyhow::anyhow!("Bid required"))?,
131                ask.ok_or_else(|| anyhow::anyhow!("Ask required"))?,
132            );
133            let basis = match order_side {
134                OrderSide::Buy => ask,
135                OrderSide::Sell => bid,
136            };
137            let cand_trigger = compute(trailing_offset, basis)?;
138            new_trigger_price = maybe_move(&mut trigger_price, cand_trigger, better_trigger);
139
140            if order_type == OrderType::TrailingStopLimit {
141                let limit_offset = order.limit_offset().ok_or_else(|| {
142                    anyhow::anyhow!("Missing `limit_offset` for trailing stop limit calculation")
143                })?;
144                let cand_limit = compute(limit_offset, basis)?;
145                new_limit_price = maybe_move(&mut limit_price, cand_limit, better_limit);
146            }
147
148            if trigger_type == TriggerType::LastOrBidAsk {
149                let last = last.ok_or_else(|| anyhow::anyhow!("Last required"))?;
150                let cand_trigger = compute(trailing_offset, last)?;
151                let updated = maybe_move(&mut trigger_price, cand_trigger, better_trigger);
152                if updated.is_some() {
153                    new_trigger_price = updated;
154                }
155
156                if order_type == OrderType::TrailingStopLimit {
157                    let limit_offset = order.limit_offset().ok_or_else(|| {
158                        anyhow::anyhow!(
159                            "Missing `limit_offset` for trailing stop limit calculation"
160                        )
161                    })?;
162                    let cand_limit = compute(limit_offset, last)?;
163                    let updated = maybe_move(&mut limit_price, cand_limit, better_limit);
164                    if updated.is_some() {
165                        new_limit_price = updated;
166                    }
167                }
168            }
169        }
170        _ => anyhow::bail!("`TriggerType` {trigger_type} not currently supported"),
171    }
172
173    Ok((new_trigger_price, new_limit_price))
174}
175
176/// Calculates the trailing stop price using the last traded price.
177///
178/// # Errors
179///
180/// Returns an error if the offset type is unsupported or the calculated price cannot be
181/// represented as a [`Price`].
182pub fn trailing_stop_calculate_with_last(
183    price_increment: Price,
184    trailing_offset_type: TrailingOffsetType,
185    side: OrderSide,
186    offset: Decimal,
187    last: Price,
188) -> anyhow::Result<Price> {
189    let last = last.as_decimal();
190    let offset = match trailing_offset_type {
191        TrailingOffsetType::Price => offset,
192        TrailingOffsetType::BasisPoints => last * offset / Decimal::from(10_000),
193        TrailingOffsetType::Ticks => offset * price_increment.as_decimal(),
194        _ => anyhow::bail!("`TrailingOffsetType` {trailing_offset_type} not currently supported"),
195    };
196
197    let price = match side {
198        OrderSide::Buy => last + offset,
199        OrderSide::Sell => last - offset,
200    };
201
202    let rounded = Price::from_decimal_dp(price, price_increment.precision)?;
203    let increment_raw = price_increment.raw().abs();
204
205    if increment_raw == 0 || rounded.raw() % increment_raw == 0 {
206        return Ok(rounded);
207    }
208
209    // The increment is coarser than its precision, such as 0.25: use its nearest multiple
210    let increment = price_increment.as_decimal();
211    let nearest = price
212        .checked_div(increment)
213        .and_then(|increments| {
214            increments
215                .round_dp_with_strategy(0, RoundingStrategy::MidpointNearestEven)
216                .checked_mul(increment)
217        })
218        .unwrap_or(price);
219
220    Price::from_decimal_dp(nearest, price_increment.precision).map_err(Into::into)
221}
222
223/// Calculates the trailing stop price using bid and ask prices.
224///
225/// # Errors
226///
227/// Returns an error if the offset type is unsupported or the calculated price cannot be
228/// represented as a [`Price`].
229pub fn trailing_stop_calculate_with_bid_ask(
230    price_increment: Price,
231    trailing_offset_type: TrailingOffsetType,
232    side: OrderSide,
233    offset: Decimal,
234    bid: Price,
235    ask: Price,
236) -> anyhow::Result<Price> {
237    let basis = match side {
238        OrderSide::Buy => ask,
239        OrderSide::Sell => bid,
240    };
241
242    trailing_stop_calculate_with_last(price_increment, trailing_offset_type, side, offset, basis)
243}
244
245// Activation is not an order event, so copy only the activation fields into the cached order:
246// `replace_order` would append its last event to the cache database again, and copying the whole
247// order would overwrite a lifecycle state whose event is still queued for the execution engine.
248pub(crate) fn store_trailing_stop_activation(cache: &mut Cache, order: &OrderAny) {
249    let Some(mut cached) = cache.order_mut(&order.client_order_id()) else {
250        log::error!(
251            "Cannot store trailing stop activation: order {} not found",
252            order.client_order_id()
253        );
254        return;
255    };
256
257    match (&mut *cached, order) {
258        (OrderAny::TrailingStopMarket(cached), OrderAny::TrailingStopMarket(order)) => {
259            cached.activation_price = order.activation_price;
260            cached.is_activated = order.is_activated;
261        }
262        (OrderAny::TrailingStopLimit(cached), OrderAny::TrailingStopLimit(order)) => {
263            cached.activation_price = order.activation_price;
264            cached.is_activated = order.is_activated;
265        }
266        _ => {}
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use nautilus_model::{
273        enums::{OrderSide, OrderType, TrailingOffsetType, TriggerType},
274        orders::builder::OrderTestBuilder,
275        types::Quantity,
276    };
277    use rstest::rstest;
278    use rust_decimal::prelude::*;
279    use rust_decimal_macros::dec;
280
281    use super::*;
282
283    fn assert_optional_price(actual: Option<Price>, expected: Option<&str>) {
284        match (actual, expected) {
285            (Some(actual), Some(expected)) => assert_eq!(actual, Price::from(expected)),
286            (None, None) => {}
287            (actual, expected) => panic!("expected {expected:?}, was {actual:?}"),
288        }
289    }
290
291    #[rstest]
292    fn test_calculate_with_invalid_order_type() {
293        let order = OrderTestBuilder::new(OrderType::Market)
294            .instrument_id("BTCUSDT-PERP.BINANCE".into())
295            .side(OrderSide::Buy)
296            .quantity(Quantity::from(1))
297            .build();
298
299        let result = trailing_stop_calculate(Price::new(0.01, 2), None, &order, None, None, None);
300
301        // TODO: Basic error assert for now
302        assert!(result.is_err());
303    }
304
305    #[rstest]
306    #[case(OrderSide::Buy)]
307    #[case(OrderSide::Sell)]
308    fn test_calculate_with_last_price_no_last(#[case] side: OrderSide) {
309        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
310            .instrument_id("BTCUSDT-PERP.BINANCE".into())
311            .side(side)
312            .trigger_price(Price::new(100.0, 2))
313            .trailing_offset_type(TrailingOffsetType::Price)
314            .trailing_offset(dec!(1.0))
315            .trigger_type(TriggerType::LastPrice)
316            .quantity(Quantity::from(1))
317            .build();
318
319        let result = trailing_stop_calculate(Price::new(0.01, 2), None, &order, None, None, None);
320
321        // TODO: Basic error assert for now
322        assert!(result.is_err());
323    }
324
325    #[rstest]
326    #[case(OrderSide::Buy)]
327    #[case(OrderSide::Sell)]
328    fn test_calculate_with_bid_ask_no_bid_ask(#[case] side: OrderSide) {
329        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
330            .instrument_id("BTCUSDT-PERP.BINANCE".into())
331            .side(side)
332            .trigger_price(Price::new(100.0, 2))
333            .trailing_offset_type(TrailingOffsetType::Price)
334            .trailing_offset(dec!(1.0))
335            .trigger_type(TriggerType::BidAsk)
336            .quantity(Quantity::from(1))
337            .build();
338
339        let result = trailing_stop_calculate(Price::new(0.01, 2), None, &order, None, None, None);
340
341        // TODO: Basic error assert for now
342        assert!(result.is_err());
343    }
344
345    #[rstest]
346    fn test_calculate_with_unsupported_trigger_type() {
347        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
348            .instrument_id("BTCUSDT-PERP.BINANCE".into())
349            .side(OrderSide::Buy)
350            .trigger_price(Price::new(100.0, 2))
351            .trailing_offset_type(TrailingOffsetType::Price)
352            .trailing_offset(dec!(1.0))
353            .trigger_type(TriggerType::IndexPrice) // not supported by algo
354            .quantity(Quantity::from(1))
355            .build();
356
357        let result = trailing_stop_calculate(Price::new(0.01, 2), None, &order, None, None, None);
358
359        // TODO: Basic error assert for now
360        assert!(result.is_err());
361    }
362
363    #[rstest]
364    #[should_panic(expected = "Trailing offset type not set")]
365    fn test_build_without_trailing_offset_type_panics() {
366        let _ = OrderTestBuilder::new(OrderType::TrailingStopMarket)
367            .instrument_id("BTCUSDT-PERP.BINANCE".into())
368            .side(OrderSide::Buy)
369            .trigger_price(Price::new(100.0, 2))
370            .trailing_offset(dec!(1.0))
371            .trigger_type(TriggerType::LastPrice)
372            .quantity(Quantity::from(1))
373            .build();
374    }
375
376    #[rstest]
377    #[case(OrderSide::Buy, 100.0, 1.0, 99.0, None)] // Last price 99 > trigger 98, no update needed
378    #[case(OrderSide::Buy, 100.0, 1.0, 98.0, Some("99.0"))] // Last price 98 < trigger 100, update to 98 + 1
379    #[case(OrderSide::Sell, 100.0, 1.0, 101.0, None)] // Last price 101 < trigger 102, no update needed
380    #[case(OrderSide::Sell, 100.0, 1.0, 102.0, Some("101.0"))] // Last price 102 > trigger 100, update to 102 - 1
381    fn test_trailing_stop_market_last_price(
382        #[case] side: OrderSide,
383        #[case] initial_trigger: f64,
384        #[case] offset: f64,
385        #[case] last_price: f64,
386        #[case] expected_trigger: Option<&str>,
387    ) {
388        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
389            .instrument_id("BTCUSDT-PERP.BINANCE".into())
390            .side(side)
391            .trigger_price(Price::new(initial_trigger, 2))
392            .trailing_offset_type(TrailingOffsetType::Price)
393            .trailing_offset(Decimal::from_f64(offset).unwrap())
394            .trigger_type(TriggerType::LastPrice)
395            .quantity(Quantity::from(1))
396            .build();
397
398        let result = trailing_stop_calculate(
399            Price::new(0.01, 2),
400            None,
401            &order,
402            None,
403            None,
404            Some(Price::new(last_price, 2)),
405        );
406
407        assert_optional_price(result.unwrap().0, expected_trigger);
408    }
409
410    #[rstest]
411    #[case(OrderSide::Buy, 1505.0, 1.0, 1480.0, 1479.0, Some("1481.0"))] // BUY uses ask as basis
412    #[case(OrderSide::Sell, 1495.0, 1.0, 1521.0, 1520.0, Some("1519.0"))] // SELL uses bid as basis
413    fn test_trailing_stop_market_default_uses_bid_ask(
414        #[case] side: OrderSide,
415        #[case] initial_trigger: f64,
416        #[case] offset: f64,
417        #[case] ask: f64,
418        #[case] bid: f64,
419        #[case] expected_trigger: Option<&str>,
420    ) {
421        // NOTE: TriggerType::Default is documented to behave like BID_ASK (quote-based), so it
422        // should not require a last-trade price and should trail using bid/ask.
423        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
424            .instrument_id("BTCUSDT-PERP.BINANCE".into())
425            .side(side)
426            .trigger_price(Price::new(initial_trigger, 2))
427            .trailing_offset_type(TrailingOffsetType::Price)
428            .trailing_offset(Decimal::from_f64(offset).unwrap())
429            .trigger_type(TriggerType::Default)
430            .quantity(Quantity::from(1))
431            .build();
432
433        let result = trailing_stop_calculate(
434            Price::new(0.01, 2),
435            None,
436            &order,
437            Some(Price::new(bid, 2)),
438            Some(Price::new(ask, 2)),
439            None, // no last-trade price available
440        );
441
442        assert_optional_price(result.unwrap().0, expected_trigger);
443    }
444
445    #[rstest]
446    #[case(OrderSide::Buy, 100.0, 50.0, 98.0, Some("98.49"))] // 50bp = 0.5% of 98 = 0.49
447    #[case(OrderSide::Buy, 100.0, 100.0, 97.0, Some("97.97"))] // 100bp = 1% of 97 = 0.97
448    #[case(OrderSide::Sell, 100.0, 50.0, 102.0, Some("101.49"))] // 50bp = 0.5% of 102 = 0.51
449    #[case(OrderSide::Sell, 100.0, 100.0, 103.0, Some("101.97"))] // 100bp = 1% of 103 = 1.03
450    fn test_trailing_stop_market_basis_points(
451        #[case] side: OrderSide,
452        #[case] initial_trigger: f64,
453        #[case] basis_points: f64,
454        #[case] last_price: f64,
455        #[case] expected_trigger: Option<&str>,
456    ) {
457        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
458            .instrument_id("BTCUSDT-PERP.BINANCE".into())
459            .side(side)
460            .trigger_price(Price::new(initial_trigger, 2))
461            .trailing_offset_type(TrailingOffsetType::BasisPoints)
462            .trailing_offset(Decimal::from_f64(basis_points).unwrap())
463            .trigger_type(TriggerType::LastPrice)
464            .quantity(Quantity::from(1))
465            .build();
466
467        let result = trailing_stop_calculate(
468            Price::new(0.01, 2),
469            None,
470            &order,
471            None,
472            None,
473            Some(Price::new(last_price, 2)),
474        );
475
476        assert_optional_price(result.unwrap().0, expected_trigger);
477    }
478
479    #[rstest]
480    #[case(OrderSide::Buy, 100.0, 1.0, 98.0, 99.0, None)] // Ask 99 > trigger 100, no update
481    #[case(OrderSide::Buy, 100.0, 1.0, 97.0, 98.0, Some("99.0"))] // Ask 98 < trigger 100, update to 98 + 1
482    #[case(OrderSide::Sell, 100.0, 1.0, 101.0, 102.0, None)] // Bid 101 < trigger 100, no update
483    #[case(OrderSide::Sell, 100.0, 1.0, 102.0, 103.0, Some("101.0"))] // Bid 102 > trigger 100, update to 102 - 1
484    fn test_trailing_stop_market_bid_ask(
485        #[case] side: OrderSide,
486        #[case] initial_trigger: f64,
487        #[case] offset: f64,
488        #[case] bid: f64,
489        #[case] ask: f64,
490        #[case] expected_trigger: Option<&str>,
491    ) {
492        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
493            .instrument_id("BTCUSDT-PERP.BINANCE".into())
494            .side(side)
495            .trigger_price(Price::new(initial_trigger, 2))
496            .trailing_offset_type(TrailingOffsetType::Price)
497            .trailing_offset(Decimal::from_f64(offset).unwrap())
498            .trigger_type(TriggerType::BidAsk)
499            .quantity(Quantity::from(1))
500            .build();
501
502        let result = trailing_stop_calculate(
503            Price::new(0.01, 2),
504            None,
505            &order,
506            Some(Price::new(bid, 2)),
507            Some(Price::new(ask, 2)),
508            None, // last price not needed for BidAsk trigger type
509        );
510
511        assert_optional_price(result.unwrap().0, expected_trigger);
512    }
513
514    #[rstest]
515    #[case(OrderSide::Buy, 100.0, 5, 98.0, Some("98.05"))] // 5 ticks * 0.01 = 0.05 offset
516    #[case(OrderSide::Buy, 100.0, 10, 97.0, Some("97.10"))] // 10 ticks * 0.01 = 0.10 offset
517    #[case(OrderSide::Sell, 100.0, 5, 102.0, Some("101.95"))] // 5 ticks * 0.01 = 0.05 offset
518    #[case(OrderSide::Sell, 100.0, 10, 103.0, Some("102.90"))] // 10 ticks * 0.01 = 0.10 offset
519    fn test_trailing_stop_market_ticks(
520        #[case] side: OrderSide,
521        #[case] initial_trigger: f64,
522        #[case] ticks: u32,
523        #[case] last_price: f64,
524        #[case] expected_trigger: Option<&str>,
525    ) {
526        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
527            .instrument_id("BTCUSDT-PERP.BINANCE".into())
528            .side(side)
529            .trigger_price(Price::new(initial_trigger, 2))
530            .trailing_offset_type(TrailingOffsetType::Ticks)
531            .trailing_offset(Decimal::from_u32(ticks).unwrap())
532            .trigger_type(TriggerType::LastPrice)
533            .quantity(Quantity::from(1))
534            .build();
535
536        let result = trailing_stop_calculate(
537            Price::new(0.01, 2),
538            None,
539            &order,
540            None,
541            None,
542            Some(Price::new(last_price, 2)),
543        );
544
545        assert_optional_price(result.unwrap().0, expected_trigger);
546    }
547
548    #[rstest]
549    #[case(OrderSide::Buy, 100.0, 1.0, 98.0, 97.0, 98.0, Some("99.0"))] // Last price gives higher trigger
550    #[case(OrderSide::Buy, 100.0, 1.0, 97.0, 96.0, 99.0, Some("98.0"))] // Bid/Ask gives higher trigger
551    #[case(OrderSide::Sell, 100.0, 1.0, 102.0, 102.0, 103.0, Some("101.0"))] // Last price gives lower trigger
552    #[case(OrderSide::Sell, 100.0, 1.0, 103.0, 101.0, 102.0, Some("102.0"))] // Bid/Ask gives lower trigger
553    fn test_trailing_stop_last_or_bid_ask(
554        #[case] side: OrderSide,
555        #[case] initial_trigger: f64,
556        #[case] offset: f64,
557        #[case] last_price: f64,
558        #[case] bid: f64,
559        #[case] ask: f64,
560        #[case] expected_trigger: Option<&str>,
561    ) {
562        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
563            .instrument_id("BTCUSDT-PERP.BINANCE".into())
564            .side(side)
565            .trigger_price(Price::new(initial_trigger, 2))
566            .trailing_offset_type(TrailingOffsetType::Price)
567            .trailing_offset(Decimal::from_f64(offset).unwrap())
568            .trigger_type(TriggerType::LastOrBidAsk)
569            .quantity(Quantity::from(1))
570            .build();
571
572        let result = trailing_stop_calculate(
573            Price::new(0.01, 2),
574            None,
575            &order,
576            Some(Price::new(bid, 2)),
577            Some(Price::new(ask, 2)),
578            Some(Price::new(last_price, 2)),
579        );
580
581        assert_optional_price(result.unwrap().0, expected_trigger);
582    }
583
584    #[rstest]
585    #[case(OrderSide::Buy, 100.0, 1.0, 98.0, Some("99.0"))]
586    #[case(OrderSide::Sell, 100.0, 1.0, 102.0, Some("101.0"))]
587    fn test_trailing_stop_market_last_price_move_in_favor(
588        #[case] side: OrderSide,
589        #[case] initial_trigger: f64,
590        #[case] offset: f64,
591        #[case] last_price: f64,
592        #[case] expected_trigger: Option<&str>,
593    ) {
594        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
595            .instrument_id("BTCUSDT-PERP.BINANCE".into())
596            .side(side)
597            .trigger_price(Price::new(initial_trigger, 2))
598            .trailing_offset_type(TrailingOffsetType::Price)
599            .trailing_offset(Decimal::from_f64(offset).unwrap())
600            .trigger_type(TriggerType::LastPrice)
601            .quantity(Quantity::from(1))
602            .build();
603
604        let (maybe_trigger, _) = trailing_stop_calculate(
605            Price::new(0.01, 2),
606            None,
607            &order,
608            None,
609            None,
610            Some(Price::new(last_price, 2)),
611        )
612        .unwrap();
613
614        assert_optional_price(maybe_trigger, expected_trigger);
615    }
616
617    #[rstest]
618    fn test_trailing_stop_limit_last_price_buy_improve_trigger_and_limit() {
619        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
620            .instrument_id("BTCUSDT-PERP.BINANCE".into())
621            .side(OrderSide::Buy)
622            .trigger_price(Price::new(105.0, 2))
623            .price(Price::new(104.5, 2))
624            .trailing_offset_type(TrailingOffsetType::Price)
625            .trailing_offset(dec!(1.0))
626            .limit_offset(dec!(0.5))
627            .trigger_type(TriggerType::LastPrice)
628            .quantity(Quantity::from(1))
629            .build();
630
631        let (new_trigger, new_limit) = trailing_stop_calculate(
632            Price::new(0.01, 2),
633            None,
634            &order,
635            None,
636            None,
637            Some(Price::new(100.0, 2)),
638        )
639        .unwrap();
640
641        assert_eq!(new_trigger.unwrap(), Price::from("101.0"));
642        assert_eq!(new_limit.unwrap(), Price::from("100.5"));
643    }
644
645    #[rstest]
646    fn test_trailing_stop_limit_last_price_sell_improve() {
647        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
648            .instrument_id("BTCUSDT-PERP.BINANCE".into())
649            .side(OrderSide::Sell)
650            .trigger_price(Price::new(95.0, 2))
651            .price(Price::new(95.5, 2))
652            .trailing_offset_type(TrailingOffsetType::Price)
653            .trailing_offset(dec!(1.0))
654            .limit_offset(dec!(0.5))
655            .trigger_type(TriggerType::LastPrice)
656            .quantity(Quantity::from(1))
657            .build();
658
659        let (new_trigger, new_limit) = trailing_stop_calculate(
660            Price::new(0.01, 2),
661            None,
662            &order,
663            None,
664            None,
665            Some(Price::new(100.0, 2)),
666        )
667        .unwrap();
668
669        assert_eq!(new_trigger.unwrap(), Price::from("99.0"));
670        assert_eq!(new_limit.unwrap(), Price::from("99.5"));
671    }
672
673    #[rstest]
674    #[case(OrderSide::Buy, 100.0, 1.0, 99.0)]
675    #[case(OrderSide::Sell, 100.0, 1.0, 101.0)]
676    fn test_no_update_when_candidate_worse(
677        #[case] side: OrderSide,
678        #[case] initial_trigger: f64,
679        #[case] offset: f64,
680        #[case] basis: f64,
681    ) {
682        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
683            .instrument_id("BTCUSDT-PERP.BINANCE".into())
684            .side(side)
685            .trigger_price(Price::new(initial_trigger, 2))
686            .trailing_offset_type(TrailingOffsetType::Price)
687            .trailing_offset(Decimal::from_f64(offset).unwrap())
688            .trigger_type(TriggerType::LastPrice)
689            .quantity(Quantity::from(1))
690            .build();
691
692        let (maybe_trigger, _) = trailing_stop_calculate(
693            Price::new(0.01, 2),
694            None,
695            &order,
696            None,
697            None,
698            Some(Price::new(basis, 2)),
699        )
700        .unwrap();
701
702        assert!(maybe_trigger.is_none());
703    }
704
705    #[rstest]
706    #[case(
707        TrailingOffsetType::Price,
708        OrderSide::Buy,
709        dec!(1.25),
710        Price::from("98.00"),
711        Price::from("99.25")
712    )]
713    #[case(
714        TrailingOffsetType::BasisPoints,
715        OrderSide::Buy,
716        dec!(50),
717        Price::from("98.00"),
718        Price::from("98.49")
719    )]
720    #[case(
721        TrailingOffsetType::Ticks,
722        OrderSide::Sell,
723        dec!(5),
724        Price::from("102.00"),
725        Price::from("101.95")
726    )]
727    fn test_calculate_with_last_uses_decimal_math(
728        #[case] trailing_offset_type: TrailingOffsetType,
729        #[case] side: OrderSide,
730        #[case] offset: Decimal,
731        #[case] last: Price,
732        #[case] expected: Price,
733    ) {
734        let price = trailing_stop_calculate_with_last(
735            Price::from("0.01"),
736            trailing_offset_type,
737            side,
738            offset,
739            last,
740        )
741        .unwrap();
742
743        assert_eq!(price, expected);
744    }
745
746    #[rstest]
747    #[case::basis_points_sell(
748        "0.25",
749        TrailingOffsetType::BasisPoints,
750        OrderSide::Sell,
751        dec!(33),
752        "1500.00",
753        "1495.00"
754    )]
755    #[case::basis_points_buy(
756        "0.25",
757        TrailingOffsetType::BasisPoints,
758        OrderSide::Buy,
759        dec!(33),
760        "1500.25",
761        "1505.25"
762    )]
763    #[case::price_offset(
764        "0.25",
765        TrailingOffsetType::Price,
766        OrderSide::Sell,
767        dec!(0.30),
768        "1500.00",
769        "1499.75"
770    )]
771    #[case::midpoint_to_even_increment(
772        "0.50",
773        TrailingOffsetType::Price,
774        OrderSide::Sell,
775        dec!(0.25),
776        "100.50",
777        "100.00"
778    )]
779    #[case::offset_under_half_increment(
780        "0.25",
781        TrailingOffsetType::Price,
782        OrderSide::Sell,
783        dec!(0.10),
784        "1500.00",
785        "1500.00"
786    )]
787    #[case::negative_price(
788        "0.25",
789        TrailingOffsetType::Price,
790        OrderSide::Sell,
791        dec!(0.30),
792        "-10.00",
793        "-10.25"
794    )]
795    #[case::decimal_increment(
796        "0.01",
797        TrailingOffsetType::BasisPoints,
798        OrderSide::Sell,
799        dec!(1.25),
800        "100.00",
801        "99.99"
802    )]
803    #[case::decimal_increment_midpoint(
804        "0.01",
805        TrailingOffsetType::Price,
806        OrderSide::Sell,
807        dec!(0.005),
808        "100.00",
809        "100.00"
810    )]
811    #[case::decimal_increment_offset_under_half(
812        "0.01",
813        TrailingOffsetType::Price,
814        OrderSide::Sell,
815        dec!(0.004),
816        "100.00",
817        "100.00"
818    )]
819    #[case::zero_increment(
820        "0.00",
821        TrailingOffsetType::Price,
822        OrderSide::Sell,
823        dec!(0.30),
824        "1500.00",
825        "1499.70"
826    )]
827    fn test_calculate_with_last_rounds_to_price_increment(
828        #[case] price_increment: &str,
829        #[case] trailing_offset_type: TrailingOffsetType,
830        #[case] side: OrderSide,
831        #[case] offset: Decimal,
832        #[case] last: &str,
833        #[case] expected: &str,
834    ) {
835        let price = trailing_stop_calculate_with_last(
836            Price::from(price_increment),
837            trailing_offset_type,
838            side,
839            offset,
840            Price::from(last),
841        )
842        .unwrap();
843
844        assert_eq!(price, Price::from(expected));
845    }
846
847    #[rstest]
848    #[case(
849        TrailingOffsetType::Price,
850        OrderSide::Sell,
851        dec!(1.25),
852        Price::from("102.00"),
853        Price::from("103.00"),
854        Price::from("100.75")
855    )]
856    #[case(
857        TrailingOffsetType::BasisPoints,
858        OrderSide::Sell,
859        dec!(50),
860        Price::from("102.00"),
861        Price::from("103.00"),
862        Price::from("101.49")
863    )]
864    #[case(
865        TrailingOffsetType::Ticks,
866        OrderSide::Buy,
867        dec!(5),
868        Price::from("102.00"),
869        Price::from("103.00"),
870        Price::from("103.05")
871    )]
872    fn test_calculate_with_bid_ask_uses_decimal_math(
873        #[case] trailing_offset_type: TrailingOffsetType,
874        #[case] side: OrderSide,
875        #[case] offset: Decimal,
876        #[case] bid: Price,
877        #[case] ask: Price,
878        #[case] expected: Price,
879    ) {
880        let price = trailing_stop_calculate_with_bid_ask(
881            Price::from("0.01"),
882            trailing_offset_type,
883            side,
884            offset,
885            bid,
886            ask,
887        )
888        .unwrap();
889
890        assert_eq!(price, expected);
891    }
892
893    #[rstest]
894    fn test_trailing_stop_limit_basis_points_buy_improve() {
895        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
896            .instrument_id("BTCUSDT-PERP.BINANCE".into())
897            .side(OrderSide::Buy)
898            .trigger_price(Price::new(110.0, 2))
899            .price(Price::new(109.5, 2))
900            .trailing_offset_type(TrailingOffsetType::BasisPoints)
901            .trailing_offset(dec!(50))
902            .limit_offset(dec!(25))
903            .trigger_type(TriggerType::LastPrice)
904            .quantity(Quantity::from(1))
905            .build();
906
907        let (new_trigger, new_limit) = trailing_stop_calculate(
908            Price::new(0.01, 2),
909            None,
910            &order,
911            None,
912            None,
913            Some(Price::new(98.0, 2)),
914        )
915        .unwrap();
916
917        assert_eq!(new_trigger.unwrap(), Price::from("98.49"));
918        assert_eq!(new_limit.unwrap(), Price::from("98.24"));
919    }
920
921    #[rstest]
922    #[case(OrderSide::Buy, "105.00", "100.00", "101.00")]
923    #[case(OrderSide::Sell, "95.00", "100.00", "99.00")]
924    fn test_trigger_override_takes_precedence(
925        #[case] side: OrderSide,
926        #[case] stored: &str,
927        #[case] override_price: &str,
928        #[case] last: &str,
929    ) {
930        let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
931            .instrument_id("BTCUSDT-PERP.BINANCE".into())
932            .side(side)
933            .trigger_price(Price::from(stored))
934            .trailing_offset_type(TrailingOffsetType::Price)
935            .trailing_offset(dec!(1))
936            .trigger_type(TriggerType::LastPrice)
937            .quantity(Quantity::from(1))
938            .build();
939
940        let result = trailing_stop_calculate(
941            Price::from("0.01"),
942            Some(Price::from(override_price)),
943            &order,
944            None,
945            None,
946            Some(Price::from(last)),
947        )
948        .unwrap();
949
950        assert_eq!(result, (None, None));
951    }
952
953    #[rstest]
954    #[case(OrderSide::Buy, "100.00", "105.00", None, Some("100.50"))]
955    #[case(OrderSide::Buy, "105.00", "100.00", Some("101.00"), None)]
956    #[case(OrderSide::Sell, "100.00", "95.00", None, Some("99.50"))]
957    #[case(OrderSide::Sell, "95.00", "100.00", Some("99.00"), None)]
958    fn test_trailing_limit_prices_improve_independently(
959        #[case] side: OrderSide,
960        #[case] trigger: &str,
961        #[case] limit: &str,
962        #[case] expected_trigger: Option<&str>,
963        #[case] expected_limit: Option<&str>,
964    ) {
965        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
966            .instrument_id("BTCUSDT-PERP.BINANCE".into())
967            .side(side)
968            .trigger_price(Price::from(trigger))
969            .price(Price::from(limit))
970            .trailing_offset_type(TrailingOffsetType::Price)
971            .trailing_offset(dec!(1))
972            .limit_offset(dec!(0.5))
973            .trigger_type(TriggerType::LastPrice)
974            .quantity(Quantity::from(1))
975            .build();
976
977        let result = trailing_stop_calculate(
978            Price::from("0.01"),
979            None,
980            &order,
981            None,
982            None,
983            Some(Price::from("100.00")),
984        )
985        .unwrap();
986
987        assert_eq!(
988            result,
989            (
990                expected_trigger.map(Price::from),
991                expected_limit.map(Price::from)
992            )
993        );
994    }
995
996    #[rstest]
997    #[case(OrderSide::Buy, "110.00", "98.00", "99.00", "98.50")]
998    #[case(OrderSide::Buy, "110.00", "103.00", "102.00", "101.50")]
999    #[case(OrderSide::Sell, "90.00", "103.00", "102.00", "102.50")]
1000    #[case(OrderSide::Sell, "90.00", "98.00", "99.00", "99.50")]
1001    fn test_trailing_limit_last_or_bid_ask_keeps_best_prices(
1002        #[case] side: OrderSide,
1003        #[case] initial: &str,
1004        #[case] last: &str,
1005        #[case] expected_trigger: &str,
1006        #[case] expected_limit: &str,
1007    ) {
1008        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
1009            .instrument_id("BTCUSDT-PERP.BINANCE".into())
1010            .side(side)
1011            .trigger_price(Price::from(initial))
1012            .price(Price::from(initial))
1013            .trailing_offset_type(TrailingOffsetType::Price)
1014            .trailing_offset(dec!(1))
1015            .limit_offset(dec!(0.5))
1016            .trigger_type(TriggerType::LastOrBidAsk)
1017            .quantity(Quantity::from(1))
1018            .build();
1019
1020        let result = trailing_stop_calculate(
1021            Price::from("0.01"),
1022            None,
1023            &order,
1024            Some(Price::from("100.00")),
1025            Some(Price::from("101.00")),
1026            Some(Price::from(last)),
1027        )
1028        .unwrap();
1029
1030        assert_eq!(
1031            result,
1032            (
1033                Some(Price::from(expected_trigger)),
1034                Some(Price::from(expected_limit))
1035            )
1036        );
1037    }
1038}