nautilus_execution/
protection.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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,
21};
22
23/// Calculates the protection price for stop limit and stop market orders using best bid or ask price..
24///
25/// # Returns
26/// A calculated protection price.
27///
28/// # Errors
29/// Returns an error if:
30/// - the order type is invalid.
31/// - protection points or best bid/ask are provided but not valid
32///
33/// # Panics
34///
35/// Panics if the values required for calculation cannot be converted to a float.
36pub fn protection_price_calculate(
37    price_increment: Price,
38    order: &OrderAny,
39    protection_points: Option<u32>,
40    bid: Option<Price>,
41    ask: Option<Price>,
42) -> anyhow::Result<Price> {
43    let order_type = order.order_type();
44    if !matches!(order_type, OrderType::Market | OrderType::StopMarket) {
45        anyhow::bail!("Invalid `OrderType` {order_type} for protection price calculation");
46    }
47
48    let protection_points =
49        protection_points.ok_or_else(|| anyhow::anyhow!("Protection points required"))?;
50    let offset = f64::from(protection_points) * price_increment.as_f64();
51
52    let order_side = order.order_side_specified();
53    let protection_price = match order_side {
54        OrderSideSpecified::Buy => {
55            let opposite = ask.ok_or_else(|| anyhow::anyhow!("Ask required"))?;
56            let opposite_f64 = opposite.as_f64();
57            opposite_f64 + offset
58        }
59        OrderSideSpecified::Sell => {
60            let opposite = bid.ok_or_else(|| anyhow::anyhow!("Bid required"))?;
61            let opposite_f64 = opposite.as_f64();
62            opposite_f64 - offset
63        }
64    };
65
66    Ok(Price::new(protection_price, price_increment.precision))
67}
68
69#[cfg(test)]
70mod tests {
71    use nautilus_model::{
72        enums::{OrderSide, OrderType, TriggerType},
73        orders::builder::OrderTestBuilder,
74        types::Quantity,
75    };
76    use rstest::rstest;
77
78    use super::*;
79
80    fn build_stop_order(order_type: OrderType, side: OrderSide) -> OrderAny {
81        let mut builder = OrderTestBuilder::new(order_type);
82        builder
83            .instrument_id("BTCUSDT-PERP.BINANCE".into())
84            .side(side)
85            .quantity(Quantity::from(1))
86            .trigger_price(Price::new(100.0, 2))
87            .trigger_type(TriggerType::LastPrice);
88
89        if order_type == OrderType::StopLimit {
90            builder.price(Price::new(99.5, 2));
91        }
92
93        builder.build()
94    }
95
96    #[rstest]
97    fn test_calculate_with_invalid_order_type() {
98        let order = OrderTestBuilder::new(OrderType::Limit)
99            .instrument_id("BTCUSDT-PERP.BINANCE".into())
100            .side(OrderSide::Buy)
101            .price(Price::new(100.0, 2))
102            .quantity(Quantity::from(1))
103            .build();
104
105        let result = protection_price_calculate(Price::new(0.01, 2), &order, Some(600), None, None);
106
107        assert!(result.is_err());
108    }
109
110    #[rstest]
111    fn test_calculate_requires_protection_points() {
112        let order = build_stop_order(OrderType::StopMarket, OrderSide::Buy);
113
114        let result = protection_price_calculate(
115            Price::new(0.01, 2),
116            &order,
117            None,
118            Some(Price::new(99.0, 2)),
119            Some(Price::new(101.0, 2)),
120        );
121
122        assert!(result.is_err());
123    }
124
125    #[rstest]
126    #[case(OrderSide::Buy)]
127    #[case(OrderSide::Sell)]
128    fn test_calculate_requires_opposite_quote(#[case] side: OrderSide) {
129        let order = build_stop_order(OrderType::StopMarket, side);
130        let price_increment = Price::new(0.01, 2);
131
132        let (bid, ask) = match side {
133            OrderSide::Buy => (Some(Price::new(99.5, 2)), None),
134            OrderSide::Sell => (None, Some(Price::new(100.5, 2))),
135            OrderSide::NoOrderSide => panic!("Side is required"),
136        };
137
138        let result = protection_price_calculate(price_increment, &order, Some(25), bid, ask);
139
140        assert!(result.is_err());
141    }
142
143    #[rstest]
144    #[case(OrderType::StopMarket)]
145    #[case(OrderType::Market)]
146    fn test_protection_price_buy(#[case] order_type: OrderType) {
147        let order = build_stop_order(order_type, OrderSide::Buy);
148
149        let protection_price = protection_price_calculate(
150            Price::new(0.01, 2),
151            &order,
152            Some(50),
153            Some(Price::new(99.0, 2)),
154            Some(Price::new(101.0, 2)),
155        )
156        .unwrap();
157
158        assert_eq!(protection_price.as_f64(), 101.5);
159    }
160
161    #[rstest]
162    #[case(OrderType::StopMarket)]
163    #[case(OrderType::Market)]
164    fn test_protection_price_sell(#[case] order_type: OrderType) {
165        let order = build_stop_order(order_type, OrderSide::Sell);
166
167        let protection_price = protection_price_calculate(
168            Price::new(0.01, 2),
169            &order,
170            Some(50),
171            Some(Price::new(99.0, 2)),
172            Some(Price::new(101.0, 2)),
173        )
174        .unwrap();
175
176        assert_eq!(protection_price.as_f64(), 98.5);
177    }
178}