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