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::{OrderSideSpecified, 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_specified();
49    let protection_raw = match order_side {
50        OrderSideSpecified::Buy => {
51            let opposite = ask.ok_or_else(|| anyhow::anyhow!("Ask required"))?;
52            opposite.raw + offset_raw
53        }
54        OrderSideSpecified::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            OrderSide::NoOrderSide => panic!("Side is required"),
115        };
116
117        let result = protection_price_calculate(price_increment, &order, 25, bid, ask);
118
119        assert!(result.is_err());
120    }
121
122    #[rstest]
123    #[case(OrderType::StopMarket)]
124    #[case(OrderType::Market)]
125    fn test_protection_price_buy(#[case] order_type: OrderType) {
126        let order = build_stop_order(order_type, OrderSide::Buy);
127
128        let protection_price = protection_price_calculate(
129            Price::new(0.01, 2),
130            &order,
131            50,
132            Some(Price::new(99.0, 2)),
133            Some(Price::new(101.0, 2)),
134        )
135        .unwrap();
136
137        assert_eq!(protection_price.as_f64(), 101.5);
138    }
139
140    #[rstest]
141    #[case(OrderType::StopMarket)]
142    #[case(OrderType::Market)]
143    fn test_protection_price_sell(#[case] order_type: OrderType) {
144        let order = build_stop_order(order_type, OrderSide::Sell);
145
146        let protection_price = protection_price_calculate(
147            Price::new(0.01, 2),
148            &order,
149            50,
150            Some(Price::new(99.0, 2)),
151            Some(Price::new(101.0, 2)),
152        )
153        .unwrap();
154
155        assert_eq!(protection_price.as_f64(), 98.5);
156    }
157
158    #[rstest]
159    fn test_protection_price_zero_points() {
160        let order = build_stop_order(OrderType::Market, OrderSide::Buy);
161
162        let protection_price = protection_price_calculate(
163            Price::new(0.01, 2),
164            &order,
165            0,
166            Some(Price::new(99.0, 2)),
167            Some(Price::new(101.0, 2)),
168        )
169        .unwrap();
170
171        // With 0 points, protection_price = ask + 0 = 101.0
172        assert_eq!(protection_price.as_f64(), 101.0);
173    }
174
175    #[rstest]
176    fn test_protection_price_sell_negative_result() {
177        let order = build_stop_order(OrderType::Market, OrderSide::Sell);
178
179        let protection_price = protection_price_calculate(
180            Price::new(0.01, 2),
181            &order,
182            1000,
183            Some(Price::new(5.0, 2)),
184            Some(Price::new(6.0, 2)),
185        )
186        .unwrap();
187
188        // protection_price = 5.0 - (1000 * 0.01) = 5.0 - 10.0 = -5.0
189        assert_eq!(protection_price.as_f64(), -5.0);
190    }
191
192    #[rstest]
193    fn test_protection_price_large_points() {
194        let order = build_stop_order(OrderType::Market, OrderSide::Buy);
195
196        let protection_price = protection_price_calculate(
197            Price::new(0.01, 2),
198            &order,
199            100_000,
200            Some(Price::new(50_000.0, 2)),
201            Some(Price::new(50_001.0, 2)),
202        )
203        .unwrap();
204
205        // protection_price = 50001.0 + (100_000 * 0.01) = 50001.0 + 1000.0 = 51001.0
206        assert_eq!(protection_price.as_f64(), 51001.0);
207    }
208}