use nautilus_model::{
enums::{OrderSide, OrderType},
orders::{Order, OrderAny},
types::{Price, price::PriceRaw},
};
pub fn protection_price_calculate(
price_increment: Price,
order: &OrderAny,
protection_points: u32,
bid: Option<Price>,
ask: Option<Price>,
) -> anyhow::Result<Price> {
let order_type = order.order_type();
if !matches!(order_type, OrderType::Market | OrderType::StopMarket) {
anyhow::bail!("Invalid `OrderType` {order_type} for protection price calculation");
}
let offset = price_increment
.raw()
.checked_mul(PriceRaw::from(protection_points))
.ok_or_else(|| anyhow::anyhow!("Protection offset exceeds raw price bounds"))?;
let raw = match order.order_side() {
OrderSide::Buy => ask
.ok_or_else(|| anyhow::anyhow!("Ask required"))?
.raw()
.checked_add(offset),
OrderSide::Sell => bid
.ok_or_else(|| anyhow::anyhow!("Bid required"))?
.raw()
.checked_sub(offset),
}
.ok_or_else(|| anyhow::anyhow!("Protection price exceeds raw price bounds"))?;
Ok(Price::from_raw_checked(raw, price_increment.precision)?)
}
#[cfg(test)]
mod tests {
use nautilus_model::{
enums::{OrderSide, OrderType, TriggerType},
orders::builder::OrderTestBuilder,
types::Quantity,
};
use rstest::rstest;
use super::*;
fn build_stop_order(order_type: OrderType, side: OrderSide) -> OrderAny {
let mut builder = OrderTestBuilder::new(order_type);
builder
.instrument_id("BTCUSDT-PERP.BINANCE".into())
.side(side)
.quantity(Quantity::from(1))
.trigger_price(Price::new(100.0, 2))
.trigger_type(TriggerType::LastPrice);
if order_type == OrderType::StopLimit {
builder.price(Price::new(99.5, 2));
}
builder.build()
}
#[rstest]
#[case(OrderSide::Buy, "123.456821")]
#[case(OrderSide::Sell, "123.456761")]
fn test_protection_preserves_sub_increment_units(
#[case] side: OrderSide,
#[case] expected: &str,
#[values(5, 6)] quote_precision: u8,
) {
let order = build_stop_order(OrderType::Market, side);
let mut quote = Price::from("123.456791");
quote.precision = quote_precision;
let price =
protection_price_calculate(Price::from("0.00001"), &order, 3, Some(quote), Some(quote))
.unwrap();
assert_eq!(price, Price::from(expected));
assert_eq!(price.precision, 5);
}
#[rstest]
fn test_calculate_with_invalid_order_type() {
let order = OrderTestBuilder::new(OrderType::Limit)
.instrument_id("BTCUSDT-PERP.BINANCE".into())
.side(OrderSide::Buy)
.price(Price::new(100.0, 2))
.quantity(Quantity::from(1))
.build();
let result = protection_price_calculate(Price::new(0.01, 2), &order, 600, None, None);
assert_eq!(
result.unwrap_err().to_string(),
"Invalid `OrderType` LIMIT for protection price calculation"
);
}
#[rstest]
#[case(OrderSide::Buy, "Ask required")]
#[case(OrderSide::Sell, "Bid required")]
fn test_calculate_requires_opposite_quote(#[case] side: OrderSide, #[case] expected: &str) {
let order = build_stop_order(OrderType::StopMarket, side);
let price_increment = Price::new(0.01, 2);
let (bid, ask) = match side {
OrderSide::Buy => (Some(Price::new(99.5, 2)), None),
OrderSide::Sell => (None, Some(Price::new(100.5, 2))),
};
let result = protection_price_calculate(price_increment, &order, 25, bid, ask);
assert_eq!(result.unwrap_err().to_string(), expected);
}
#[rstest]
#[case(OrderType::StopMarket)]
#[case(OrderType::Market)]
fn test_protection_price_buy(#[case] order_type: OrderType) {
let order = build_stop_order(order_type, OrderSide::Buy);
let protection_price = protection_price_calculate(
Price::new(0.01, 2),
&order,
50,
Some(Price::new(99.0, 2)),
Some(Price::new(101.0, 2)),
)
.unwrap();
assert_eq!(protection_price.as_f64(), 101.5);
}
#[rstest]
#[case(OrderType::StopMarket)]
#[case(OrderType::Market)]
fn test_protection_price_sell(#[case] order_type: OrderType) {
let order = build_stop_order(order_type, OrderSide::Sell);
let protection_price = protection_price_calculate(
Price::new(0.01, 2),
&order,
50,
Some(Price::new(99.0, 2)),
Some(Price::new(101.0, 2)),
)
.unwrap();
assert_eq!(protection_price.as_f64(), 98.5);
}
#[rstest]
fn test_protection_price_zero_points() {
let order = build_stop_order(OrderType::Market, OrderSide::Buy);
let protection_price = protection_price_calculate(
Price::new(0.01, 2),
&order,
0,
Some(Price::new(99.0, 2)),
Some(Price::new(101.0, 2)),
)
.unwrap();
assert_eq!(protection_price.as_f64(), 101.0);
}
#[rstest]
fn test_protection_price_sell_negative_result() {
let order = build_stop_order(OrderType::Market, OrderSide::Sell);
let protection_price = protection_price_calculate(
Price::new(0.01, 2),
&order,
1000,
Some(Price::new(5.0, 2)),
Some(Price::new(6.0, 2)),
)
.unwrap();
assert_eq!(protection_price.as_f64(), -5.0);
}
#[rstest]
fn test_protection_price_large_points() {
let order = build_stop_order(OrderType::Market, OrderSide::Buy);
let protection_price = protection_price_calculate(
Price::new(0.01, 2),
&order,
100_000,
Some(Price::new(50_000.0, 2)),
Some(Price::new(50_001.0, 2)),
)
.unwrap();
assert_eq!(protection_price.as_f64(), 51001.0);
}
#[rstest]
#[case(OrderSide::Buy, "123.45682")]
#[case(OrderSide::Sell, "123.45667")]
fn test_protection_price_preserves_increment_precision(
#[case] side: OrderSide,
#[case] expected: &str,
) {
let order = build_stop_order(OrderType::Market, side);
let (bid, ask) = match side {
OrderSide::Buy => (None, Some(Price::from("123.456790"))),
OrderSide::Sell => (Some(Price::from("123.456700")), None),
};
let price =
protection_price_calculate(Price::from("0.00001"), &order, 3, bid, ask).unwrap();
assert_eq!(price, Price::from(expected));
assert_eq!(price.precision, 5);
}
}