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