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