Skip to main content

nautilus_execution/
protection.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},
19    orders::{Order, OrderAny},
20    types::{Price, price::PriceRaw},
21};
22
23/// Calculates the protection price for stop limit and stop market orders using best bid or ask price.
24///
25/// Uses integer arithmetic on raw price values to avoid floating-point precision issues.
26///
27/// # Returns
28/// A calculated protection price.
29///
30/// # Errors
31/// Returns an error if:
32/// - the order type is invalid.
33/// - best bid/ask is not provided when required for the order side.
34pub fn protection_price_calculate(
35    price_increment: Price,
36    order: &OrderAny,
37    protection_points: u32,
38    bid: Option<Price>,
39    ask: Option<Price>,
40) -> anyhow::Result<Price> {
41    let order_type = order.order_type();
42    if !matches!(order_type, OrderType::Market | OrderType::StopMarket) {
43        anyhow::bail!("Invalid `OrderType` {order_type} for protection price calculation");
44    }
45
46    let offset_raw = PriceRaw::from(protection_points) * price_increment.raw;
47
48    let order_side = order.order_side();
49    let protection_raw = match order_side {
50        OrderSide::Buy => {
51            let opposite = ask.ok_or_else(|| anyhow::anyhow!("Ask required"))?;
52            opposite.raw + offset_raw
53        }
54        OrderSide::Sell => {
55            let opposite = bid.ok_or_else(|| anyhow::anyhow!("Bid required"))?;
56            opposite.raw - offset_raw
57        }
58    };
59
60    Ok(Price::from_raw(protection_raw, price_increment.precision))
61}
62
63#[cfg(test)]
64mod tests {
65    use nautilus_model::{
66        enums::{OrderSide, OrderType, TriggerType},
67        orders::builder::OrderTestBuilder,
68        types::Quantity,
69    };
70    use rstest::rstest;
71
72    use super::*;
73
74    fn build_stop_order(order_type: OrderType, side: OrderSide) -> OrderAny {
75        let mut builder = OrderTestBuilder::new(order_type);
76        builder
77            .instrument_id("BTCUSDT-PERP.BINANCE".into())
78            .side(side)
79            .quantity(Quantity::from(1))
80            .trigger_price(Price::new(100.0, 2))
81            .trigger_type(TriggerType::LastPrice);
82
83        if order_type == OrderType::StopLimit {
84            builder.price(Price::new(99.5, 2));
85        }
86
87        builder.build()
88    }
89
90    #[rstest]
91    fn test_calculate_with_invalid_order_type() {
92        let order = OrderTestBuilder::new(OrderType::Limit)
93            .instrument_id("BTCUSDT-PERP.BINANCE".into())
94            .side(OrderSide::Buy)
95            .price(Price::new(100.0, 2))
96            .quantity(Quantity::from(1))
97            .build();
98
99        let result = protection_price_calculate(Price::new(0.01, 2), &order, 600, None, None);
100
101        assert!(result.is_err());
102    }
103
104    #[rstest]
105    #[case(OrderSide::Buy)]
106    #[case(OrderSide::Sell)]
107    fn test_calculate_requires_opposite_quote(#[case] side: OrderSide) {
108        let order = build_stop_order(OrderType::StopMarket, side);
109        let price_increment = Price::new(0.01, 2);
110
111        let (bid, ask) = match side {
112            OrderSide::Buy => (Some(Price::new(99.5, 2)), None),
113            OrderSide::Sell => (None, Some(Price::new(100.5, 2))),
114        };
115
116        let result = protection_price_calculate(price_increment, &order, 25, bid, ask);
117
118        assert!(result.is_err());
119    }
120
121    #[rstest]
122    #[case(OrderType::StopMarket)]
123    #[case(OrderType::Market)]
124    fn test_protection_price_buy(#[case] order_type: OrderType) {
125        let order = build_stop_order(order_type, OrderSide::Buy);
126
127        let protection_price = protection_price_calculate(
128            Price::new(0.01, 2),
129            &order,
130            50,
131            Some(Price::new(99.0, 2)),
132            Some(Price::new(101.0, 2)),
133        )
134        .unwrap();
135
136        assert_eq!(protection_price.as_f64(), 101.5);
137    }
138
139    #[rstest]
140    #[case(OrderType::StopMarket)]
141    #[case(OrderType::Market)]
142    fn test_protection_price_sell(#[case] order_type: OrderType) {
143        let order = build_stop_order(order_type, OrderSide::Sell);
144
145        let protection_price = protection_price_calculate(
146            Price::new(0.01, 2),
147            &order,
148            50,
149            Some(Price::new(99.0, 2)),
150            Some(Price::new(101.0, 2)),
151        )
152        .unwrap();
153
154        assert_eq!(protection_price.as_f64(), 98.5);
155    }
156
157    #[rstest]
158    fn test_protection_price_zero_points() {
159        let order = build_stop_order(OrderType::Market, OrderSide::Buy);
160
161        let protection_price = protection_price_calculate(
162            Price::new(0.01, 2),
163            &order,
164            0,
165            Some(Price::new(99.0, 2)),
166            Some(Price::new(101.0, 2)),
167        )
168        .unwrap();
169
170        // With 0 points, protection_price = ask + 0 = 101.0
171        assert_eq!(protection_price.as_f64(), 101.0);
172    }
173
174    #[rstest]
175    fn test_protection_price_sell_negative_result() {
176        let order = build_stop_order(OrderType::Market, OrderSide::Sell);
177
178        let protection_price = protection_price_calculate(
179            Price::new(0.01, 2),
180            &order,
181            1000,
182            Some(Price::new(5.0, 2)),
183            Some(Price::new(6.0, 2)),
184        )
185        .unwrap();
186
187        // protection_price = 5.0 - (1000 * 0.01) = 5.0 - 10.0 = -5.0
188        assert_eq!(protection_price.as_f64(), -5.0);
189    }
190
191    #[rstest]
192    fn test_protection_price_large_points() {
193        let order = build_stop_order(OrderType::Market, OrderSide::Buy);
194
195        let protection_price = protection_price_calculate(
196            Price::new(0.01, 2),
197            &order,
198            100_000,
199            Some(Price::new(50_000.0, 2)),
200            Some(Price::new(50_001.0, 2)),
201        )
202        .unwrap();
203
204        // protection_price = 50001.0 + (100_000 * 0.01) = 50001.0 + 1000.0 = 51001.0
205        assert_eq!(protection_price.as_f64(), 51001.0);
206    }
207}