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 checked fixed-point arithmetic to preserve all stored price units.
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.
34/// - the calculated price is outside the representable range.
35pub fn protection_price_calculate(
36    price_increment: Price,
37    order: &OrderAny,
38    protection_points: u32,
39    bid: Option<Price>,
40    ask: Option<Price>,
41) -> anyhow::Result<Price> {
42    let order_type = order.order_type();
43    if !matches!(order_type, OrderType::Market | OrderType::StopMarket) {
44        anyhow::bail!("Invalid `OrderType` {order_type} for protection price calculation");
45    }
46
47    let offset = price_increment
48        .raw()
49        .checked_mul(PriceRaw::from(protection_points))
50        .ok_or_else(|| anyhow::anyhow!("Protection offset exceeds raw price bounds"))?;
51
52    let raw = match order.order_side() {
53        OrderSide::Buy => ask
54            .ok_or_else(|| anyhow::anyhow!("Ask required"))?
55            .raw()
56            .checked_add(offset),
57        OrderSide::Sell => bid
58            .ok_or_else(|| anyhow::anyhow!("Bid required"))?
59            .raw()
60            .checked_sub(offset),
61    }
62    .ok_or_else(|| anyhow::anyhow!("Protection price exceeds raw price bounds"))?;
63
64    Ok(Price::from_raw_checked(raw, price_increment.precision)?)
65}
66
67#[cfg(test)]
68mod tests {
69    use nautilus_model::{
70        enums::{OrderSide, OrderType, TriggerType},
71        orders::builder::OrderTestBuilder,
72        types::Quantity,
73    };
74    use rstest::rstest;
75
76    use super::*;
77
78    fn build_stop_order(order_type: OrderType, side: OrderSide) -> OrderAny {
79        let mut builder = OrderTestBuilder::new(order_type);
80        builder
81            .instrument_id("BTCUSDT-PERP.BINANCE".into())
82            .side(side)
83            .quantity(Quantity::from(1))
84            .trigger_price(Price::new(100.0, 2))
85            .trigger_type(TriggerType::LastPrice);
86
87        if order_type == OrderType::StopLimit {
88            builder.price(Price::new(99.5, 2));
89        }
90
91        builder.build()
92    }
93
94    #[rstest]
95    #[case(OrderSide::Buy, "123.456821")]
96    #[case(OrderSide::Sell, "123.456761")]
97    fn test_protection_preserves_sub_increment_units(
98        #[case] side: OrderSide,
99        #[case] expected: &str,
100        #[values(5, 6)] quote_precision: u8,
101    ) {
102        let order = build_stop_order(OrderType::Market, side);
103        let mut quote = Price::from("123.456791");
104        quote.precision = quote_precision;
105        let price =
106            protection_price_calculate(Price::from("0.00001"), &order, 3, Some(quote), Some(quote))
107                .unwrap();
108        assert_eq!(price, Price::from(expected));
109        assert_eq!(price.precision, 5);
110    }
111
112    #[rstest]
113    fn test_calculate_with_invalid_order_type() {
114        let order = OrderTestBuilder::new(OrderType::Limit)
115            .instrument_id("BTCUSDT-PERP.BINANCE".into())
116            .side(OrderSide::Buy)
117            .price(Price::new(100.0, 2))
118            .quantity(Quantity::from(1))
119            .build();
120
121        let result = protection_price_calculate(Price::new(0.01, 2), &order, 600, None, None);
122
123        assert_eq!(
124            result.unwrap_err().to_string(),
125            "Invalid `OrderType` LIMIT for protection price calculation"
126        );
127    }
128
129    #[rstest]
130    #[case(OrderSide::Buy, "Ask required")]
131    #[case(OrderSide::Sell, "Bid required")]
132    fn test_calculate_requires_opposite_quote(#[case] side: OrderSide, #[case] expected: &str) {
133        let order = build_stop_order(OrderType::StopMarket, side);
134        let price_increment = Price::new(0.01, 2);
135
136        let (bid, ask) = match side {
137            OrderSide::Buy => (Some(Price::new(99.5, 2)), None),
138            OrderSide::Sell => (None, Some(Price::new(100.5, 2))),
139        };
140
141        let result = protection_price_calculate(price_increment, &order, 25, bid, ask);
142
143        assert_eq!(result.unwrap_err().to_string(), expected);
144    }
145
146    #[rstest]
147    #[case(OrderType::StopMarket)]
148    #[case(OrderType::Market)]
149    fn test_protection_price_buy(#[case] order_type: OrderType) {
150        let order = build_stop_order(order_type, OrderSide::Buy);
151
152        let protection_price = protection_price_calculate(
153            Price::new(0.01, 2),
154            &order,
155            50,
156            Some(Price::new(99.0, 2)),
157            Some(Price::new(101.0, 2)),
158        )
159        .unwrap();
160
161        assert_eq!(protection_price.as_f64(), 101.5);
162    }
163
164    #[rstest]
165    #[case(OrderType::StopMarket)]
166    #[case(OrderType::Market)]
167    fn test_protection_price_sell(#[case] order_type: OrderType) {
168        let order = build_stop_order(order_type, OrderSide::Sell);
169
170        let protection_price = protection_price_calculate(
171            Price::new(0.01, 2),
172            &order,
173            50,
174            Some(Price::new(99.0, 2)),
175            Some(Price::new(101.0, 2)),
176        )
177        .unwrap();
178
179        assert_eq!(protection_price.as_f64(), 98.5);
180    }
181
182    #[rstest]
183    fn test_protection_price_zero_points() {
184        let order = build_stop_order(OrderType::Market, OrderSide::Buy);
185
186        let protection_price = protection_price_calculate(
187            Price::new(0.01, 2),
188            &order,
189            0,
190            Some(Price::new(99.0, 2)),
191            Some(Price::new(101.0, 2)),
192        )
193        .unwrap();
194
195        // With 0 points, protection_price = ask + 0 = 101.0
196        assert_eq!(protection_price.as_f64(), 101.0);
197    }
198
199    #[rstest]
200    fn test_protection_price_sell_negative_result() {
201        let order = build_stop_order(OrderType::Market, OrderSide::Sell);
202
203        let protection_price = protection_price_calculate(
204            Price::new(0.01, 2),
205            &order,
206            1000,
207            Some(Price::new(5.0, 2)),
208            Some(Price::new(6.0, 2)),
209        )
210        .unwrap();
211
212        // protection_price = 5.0 - (1000 * 0.01) = 5.0 - 10.0 = -5.0
213        assert_eq!(protection_price.as_f64(), -5.0);
214    }
215
216    #[rstest]
217    fn test_protection_price_large_points() {
218        let order = build_stop_order(OrderType::Market, OrderSide::Buy);
219
220        let protection_price = protection_price_calculate(
221            Price::new(0.01, 2),
222            &order,
223            100_000,
224            Some(Price::new(50_000.0, 2)),
225            Some(Price::new(50_001.0, 2)),
226        )
227        .unwrap();
228
229        // protection_price = 50001.0 + (100_000 * 0.01) = 50001.0 + 1000.0 = 51001.0
230        assert_eq!(protection_price.as_f64(), 51001.0);
231    }
232
233    #[rstest]
234    #[case(OrderSide::Buy, "123.45682")]
235    #[case(OrderSide::Sell, "123.45667")]
236    fn test_protection_price_preserves_increment_precision(
237        #[case] side: OrderSide,
238        #[case] expected: &str,
239    ) {
240        let order = build_stop_order(OrderType::Market, side);
241        let (bid, ask) = match side {
242            OrderSide::Buy => (None, Some(Price::from("123.456790"))),
243            OrderSide::Sell => (Some(Price::from("123.456700")), None),
244        };
245
246        let price =
247            protection_price_calculate(Price::from("0.00001"), &order, 3, bid, ask).unwrap();
248
249        assert_eq!(price, Price::from(expected));
250        assert_eq!(price.precision, 5);
251    }
252}