1use nautilus_model::{
18 enums::{OrderSide, OrderType},
19 orders::{Order, OrderAny},
20 types::{Price, price::PriceRaw},
21};
22
23pub 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 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 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 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}