Skip to main content

nautilus_model/orderbook/
analysis.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//! Functions related to order book analysis.
17
18use std::collections::BTreeMap;
19
20use nautilus_core::correctness::{CorrectnessResult, CorrectnessResultExt};
21#[cfg(not(feature = "defi"))]
22use rust_decimal::Decimal;
23
24use super::{BookLevel, BookPrice, OrderBook};
25#[cfg(feature = "defi")]
26pub use crate::defi::orderbook::{
27    get_avg_px_for_quantity, get_avg_px_qty_for_exposure, get_worst_px_for_quantity,
28};
29#[cfg(not(feature = "defi"))]
30use crate::types::{fixed::FIXED_SCALAR, quantity::QuantityRaw};
31use crate::{
32    enums::{BookType, OrderSide},
33    orderbook::BookIntegrityError,
34    types::{Price, Quantity, fixed::check_fixed_precision},
35};
36
37/// Calculates the estimated fill quantity for a specified price from a set of
38/// order book levels and order side.
39#[must_use]
40pub fn get_quantity_for_price(
41    price: Price,
42    order_side: OrderSide,
43    levels: &BTreeMap<BookPrice, BookLevel>,
44) -> f64 {
45    let mut matched_size: f64 = 0.0;
46
47    for (book_price, level) in levels {
48        if !is_level_within_price(order_side, book_price.value, price) {
49            break;
50        }
51
52        matched_size += level.size();
53    }
54
55    matched_size
56}
57
58/// Returns all price levels that would be crossed by an order at the given price.
59///
60/// Unlike `get_quantity_for_price` which returns just the total, this returns
61/// each individual level as (price, size). Used when liquidity consumption
62/// tracking needs visibility into all available levels.
63///
64/// # Panics
65///
66/// Panics if:
67/// - `size_precision` exceeds the supported fixed precision.
68/// - An aggregated level size cannot be represented as a [`Quantity`].
69#[must_use]
70pub fn get_levels_for_price(
71    price: Price,
72    order_side: OrderSide,
73    levels: &BTreeMap<BookPrice, BookLevel>,
74    size_precision: u8,
75) -> Vec<(Price, Quantity)> {
76    get_levels_for_price_checked(price, order_side, levels, size_precision)
77        .expect_display("Failed to collect crossed order book levels")
78}
79
80/// Returns all price levels that would be crossed by an order at the given price.
81///
82/// # Errors
83///
84/// Returns an error if:
85/// - `size_precision` exceeds the supported fixed precision.
86/// - An aggregated level size cannot be represented as a [`Quantity`].
87pub(crate) fn get_levels_for_price_checked(
88    price: Price,
89    order_side: OrderSide,
90    levels: &BTreeMap<BookPrice, BookLevel>,
91    size_precision: u8,
92) -> CorrectnessResult<Vec<(Price, Quantity)>> {
93    check_fixed_precision(size_precision)?;
94    let mut result = Vec::new();
95
96    for (book_price, level) in levels {
97        if !is_level_within_price(order_side, book_price.value, price) {
98            break;
99        }
100
101        let level_size = Quantity::from_raw_checked(level.size_raw_checked()?, size_precision)?;
102        result.push((level.price.value, level_size));
103    }
104
105    Ok(result)
106}
107
108fn is_level_within_price(order_side: OrderSide, level_price: Price, limit_price: Price) -> bool {
109    match order_side {
110        OrderSide::Buy => level_price <= limit_price,
111        OrderSide::Sell => level_price >= limit_price,
112    }
113}
114
115/// Calculates the estimated average price for a specified quantity from a set of
116/// order book levels.
117///
118/// # Panics
119///
120/// Panics if the calculated average price cannot be parsed as an `f64`.
121#[cfg(not(feature = "defi"))]
122#[must_use]
123pub fn get_avg_px_for_quantity(qty: Quantity, levels: &BTreeMap<BookPrice, BookLevel>) -> f64 {
124    let mut cumulative_size_raw: QuantityRaw = 0;
125    let mut cumulative_size = Decimal::ZERO;
126    let mut cumulative_value = Decimal::ZERO;
127
128    for (book_price, level) in levels {
129        let size_this_level = level.size_raw().min(qty.raw() - cumulative_size_raw);
130        let size_this_level_decimal = Quantity::raw_as_decimal(size_this_level);
131        cumulative_size_raw += size_this_level;
132        cumulative_size += size_this_level_decimal;
133        cumulative_value += book_price.value.as_decimal() * size_this_level_decimal;
134
135        if cumulative_size_raw >= qty.raw() {
136            break;
137        }
138    }
139
140    if cumulative_size_raw == 0 {
141        0.0
142    } else {
143        (cumulative_value / cumulative_size)
144            .to_string()
145            .parse::<f64>()
146            .expect("Decimal average price must parse as f64")
147    }
148}
149
150/// Calculates the worst (last-touched) price while filling a specified quantity
151/// from order book levels.
152///
153/// For buy-side traversal this is the highest ask touched; for sell-side traversal
154/// this is the lowest bid touched. Returns `None` when no quantity can be matched.
155#[cfg(not(feature = "defi"))]
156#[must_use]
157pub fn get_worst_px_for_quantity(
158    qty: Quantity,
159    levels: &BTreeMap<BookPrice, BookLevel>,
160) -> Option<Price> {
161    let mut cumulative_size_raw: QuantityRaw = 0;
162    let mut worst_price: Option<Price> = None;
163
164    for (book_price, level) in levels {
165        let size_this_level = level.size_raw().min(qty.raw() - cumulative_size_raw);
166
167        if size_this_level == 0 {
168            continue;
169        }
170
171        cumulative_size_raw += size_this_level;
172        worst_price = Some(book_price.value);
173
174        if cumulative_size_raw >= qty.raw() {
175            break;
176        }
177    }
178
179    if cumulative_size_raw == 0 {
180        None
181    } else {
182        worst_price
183    }
184}
185
186/// Calculates the estimated average price for a specified exposure from a set of
187/// order book levels.
188#[cfg(not(feature = "defi"))]
189#[must_use]
190pub fn get_avg_px_qty_for_exposure(
191    target_exposure: Quantity,
192    levels: &BTreeMap<BookPrice, BookLevel>,
193) -> (f64, f64, f64) {
194    let mut cumulative_exposure = 0.0;
195    let mut cumulative_size_raw: QuantityRaw = 0;
196    let mut final_price = levels
197        .first_key_value()
198        .map_or(0.0, |(price, _)| price.value.as_f64());
199
200    let target_exposure_raw = target_exposure.raw() as f64;
201
202    for (book_price, level) in levels {
203        let price = book_price.value.as_f64();
204
205        if price == 0.0 {
206            continue;
207        }
208
209        let level_exposure = price * level.size_raw() as f64;
210        let exposure_this_level = level_exposure.min(target_exposure_raw - cumulative_exposure);
211        let size_this_level = (exposure_this_level / price).floor() as QuantityRaw;
212
213        if size_this_level == 0 {
214            continue;
215        }
216
217        final_price = price;
218        cumulative_exposure += price * size_this_level as f64;
219        cumulative_size_raw += size_this_level;
220
221        if cumulative_exposure >= target_exposure_raw {
222            break;
223        }
224    }
225
226    if cumulative_size_raw == 0 {
227        (0.0, 0.0, final_price)
228    } else {
229        let avg_price = cumulative_exposure / cumulative_size_raw as f64;
230        (
231            avg_price,
232            cumulative_size_raw as f64 / FIXED_SCALAR,
233            final_price,
234        )
235    }
236}
237
238/// Checks the integrity of the given order `book`.
239///
240/// # Errors
241///
242/// Returns an error if a book integrity check fails.
243pub fn book_check_integrity(book: &OrderBook) -> Result<(), BookIntegrityError> {
244    match book.book_type {
245        BookType::L1_MBP => {
246            for (side, ladder) in [(OrderSide::Buy, &book.bids), (OrderSide::Sell, &book.asks)] {
247                let level_count = ladder.len();
248
249                if level_count > 1 {
250                    return Err(BookIntegrityError::TooManyLevels(side, level_count));
251                }
252            }
253        }
254        BookType::L2_MBP => {
255            for (side, ladder) in [(OrderSide::Buy, &book.bids), (OrderSide::Sell, &book.asks)] {
256                for level in ladder.levels.values() {
257                    let order_count = level.orders.len();
258
259                    if order_count > 1 {
260                        return Err(BookIntegrityError::TooManyOrders(side, order_count));
261                    }
262                }
263            }
264        }
265        BookType::L3_MBO => {}
266    }
267
268    if let (Some(top_bid_level), Some(top_ask_level)) = (book.bids.top(), book.asks.top()) {
269        let best_bid = top_bid_level.price;
270        let best_ask = top_ask_level.price;
271
272        // Only strictly crossed books (bid > ask) are invalid; locked markets (bid == ask) are valid
273        if best_bid.value > best_ask.value {
274            return Err(BookIntegrityError::OrdersCrossed(best_bid, best_ask));
275        }
276    }
277
278    Ok(())
279}