Skip to main content

nautilus_model/defi/
orderbook.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//! Order book depth analysis for native DeFi quantity scales.
17
18use std::collections::BTreeMap;
19
20use alloy_primitives::{I256, U256};
21use rust_decimal::Decimal;
22
23use crate::{
24    defi::{WEI_PRECISION, tick_map::full_math::FullMath},
25    orderbook::{BookLevel, BookPrice},
26    types::{
27        Price, Quantity,
28        fixed::{FIXED_PRECISION, raw_scale},
29        quantity::QuantityRaw,
30    },
31};
32
33const DEPTH_SCALE_FACTORS: [u128; 3] = [1, 10, 100];
34
35/// Calculates the estimated average price for a specified quantity from a set of
36/// order book levels.
37///
38/// # Panics
39///
40/// Panics if the calculated average price cannot be parsed as an `f64`.
41#[must_use]
42pub fn get_avg_px_for_quantity(qty: Quantity, levels: &BTreeMap<BookPrice, BookLevel>) -> f64 {
43    let precision = WEI_PRECISION;
44    let target_size_raw = scaled_quantity_raw(qty, precision);
45    let mut cumulative_size_raw: QuantityRaw = 0;
46    let mut cumulative_value = I256::ZERO;
47
48    for (book_price, level) in levels {
49        let size_this_level =
50            scaled_level_size_raw(level, precision).min(target_size_raw - cumulative_size_raw);
51        cumulative_size_raw += size_this_level;
52        let price_raw = book_price.value.raw
53            * DEPTH_SCALE_FACTORS
54                [usize::from(precision - book_price.value.precision.max(FIXED_PRECISION))]
55                as i128;
56        cumulative_value += I256::try_from(price_raw)
57            .expect("Normalized raw price must fit in I256")
58            * I256::from_raw(U256::from(size_this_level));
59
60        if cumulative_size_raw >= target_size_raw {
61            break;
62        }
63    }
64
65    if cumulative_size_raw == 0 {
66        0.0
67    } else {
68        depth_average_as_f64(cumulative_value, cumulative_size_raw, precision)
69    }
70}
71
72/// Calculates the worst (last-touched) price while filling a specified quantity
73/// from order book levels.
74///
75/// For buy-side traversal this is the highest ask touched; for sell-side traversal
76/// this is the lowest bid touched. Returns `None` when no quantity can be matched.
77#[must_use]
78pub fn get_worst_px_for_quantity(
79    qty: Quantity,
80    levels: &BTreeMap<BookPrice, BookLevel>,
81) -> Option<Price> {
82    let precision = WEI_PRECISION;
83    let target_size_raw = scaled_quantity_raw(qty, precision);
84    let mut cumulative_size_raw: QuantityRaw = 0;
85    let mut worst_price: Option<Price> = None;
86
87    for (book_price, level) in levels {
88        let size_this_level =
89            scaled_level_size_raw(level, precision).min(target_size_raw - cumulative_size_raw);
90
91        if size_this_level == 0 {
92            continue;
93        }
94
95        cumulative_size_raw += size_this_level;
96        worst_price = Some(book_price.value);
97
98        if cumulative_size_raw >= target_size_raw {
99            break;
100        }
101    }
102
103    if cumulative_size_raw == 0 {
104        None
105    } else {
106        worst_price
107    }
108}
109
110/// Calculates the estimated average price for a specified exposure from a set of
111/// order book levels.
112#[must_use]
113pub fn get_avg_px_qty_for_exposure(
114    target_exposure: Quantity,
115    levels: &BTreeMap<BookPrice, BookLevel>,
116) -> (f64, f64, f64) {
117    // Use the book's scale to preserve its fill quantum and exposure rounding
118    let precision = depth_precision(levels);
119    let mut cumulative_exposure = 0.0;
120    let mut cumulative_size_raw: QuantityRaw = 0;
121    let mut final_price = levels
122        .first_key_value()
123        .map_or(0.0, |(price, _)| price.value.as_f64());
124
125    let target_exposure_raw = exposure_raw_as_f64(target_exposure, precision);
126
127    for (book_price, level) in levels {
128        let price = book_price.value.as_f64();
129
130        if price == 0.0 {
131            continue;
132        }
133
134        let level_size_raw = scaled_level_size_raw(level, precision);
135        let level_exposure = price * level_size_raw as f64;
136        let exposure_this_level = level_exposure.min(target_exposure_raw - cumulative_exposure);
137        let size_this_level = (exposure_this_level / price).floor() as QuantityRaw;
138
139        if size_this_level == 0 {
140            continue;
141        }
142
143        final_price = price;
144        cumulative_exposure += price * size_this_level as f64;
145        cumulative_size_raw += size_this_level;
146
147        if cumulative_exposure >= target_exposure_raw {
148            break;
149        }
150    }
151
152    if cumulative_size_raw == 0 {
153        (0.0, 0.0, final_price)
154    } else {
155        let size_raw = cumulative_size_raw as f64;
156        let avg_price = cumulative_exposure / size_raw;
157        (
158            avg_price,
159            size_raw / raw_scale(precision) as f64,
160            final_price,
161        )
162    }
163}
164
165fn depth_average_as_f64(value: I256, size_raw: QuantityRaw, precision: u8) -> f64 {
166    if value.is_zero() {
167        return 0.0;
168    }
169
170    let mut numerator = value.unsigned_abs();
171    let mut denominator = U256::from(size_raw) * U256::from(raw_scale(precision));
172    let mut exponent = 0_i32;
173    let ten = U256::from(10);
174    while numerator < denominator {
175        numerator *= ten;
176        exponent -= 1;
177    }
178
179    while numerator >= denominator * ten {
180        denominator *= ten;
181        exponent += 1;
182    }
183
184    // Keep significant digits even when the average is below Decimal's smallest nonzero value
185    let scale = Decimal::MAX_SCALE - 1;
186    let mantissa = FullMath::mul_div(numerator, U256::from(10_u128.pow(scale)), denominator)
187        .expect("Normalized average price mantissa must fit in U256");
188
189    let sign = if value.is_negative() { "-" } else { "" };
190    format!("{sign}{mantissa}e{}", exponent - scale as i32)
191        .parse::<f64>()
192        .expect("Average price must parse as f64")
193}
194
195fn depth_precision(levels: &BTreeMap<BookPrice, BookLevel>) -> u8 {
196    levels
197        .values()
198        .flat_map(BookLevel::iter)
199        .map(|order| order.size.precision)
200        .max()
201        .unwrap_or(FIXED_PRECISION)
202        .max(FIXED_PRECISION)
203}
204
205fn scaled_quantity_raw(qty: Quantity, precision: u8) -> QuantityRaw {
206    let precision_diff = precision - qty.precision.max(FIXED_PRECISION);
207    if precision_diff == 0 {
208        return qty.raw();
209    }
210
211    qty.raw() * DEPTH_SCALE_FACTORS[usize::from(precision_diff)]
212}
213
214fn scaled_level_size_raw(level: &BookLevel, precision: u8) -> QuantityRaw {
215    level
216        .iter()
217        .try_fold(0_u128, |total, order| {
218            total.checked_add(scaled_quantity_raw(order.size, precision))
219        })
220        .expect("Overflow occurred when summing scaled order book size")
221}
222
223fn exposure_raw_as_f64(exposure: Quantity, precision: u8) -> f64 {
224    if exposure.precision > precision {
225        let factor = raw_scale(exposure.precision) / raw_scale(precision);
226        let raw = exposure.raw();
227        return (raw / factor) as f64 + (raw % factor) as f64 / factor as f64;
228    }
229
230    scaled_quantity_raw(exposure, precision) as f64
231}
232
233#[cfg(test)]
234mod tests {
235    use rstest::rstest;
236    use rust_decimal_macros::dec;
237
238    use super::*;
239    use crate::{
240        data::order::BookOrder,
241        enums::{BookType, OrderSide},
242        identifiers::InstrumentId,
243        orderbook::OrderBook,
244        types::quantity::QUANTITY_RAW_MAX,
245    };
246
247    #[rstest]
248    #[case(2, 18, 18)]
249    #[case(18, 2, 2)]
250    #[case(2, 17, 17)]
251    #[case(17, 2, 2)]
252    #[case(2, 2, 18)]
253    #[case(18, 18, 2)]
254    #[case(17, 18, 17)]
255    fn test_book_depth_normalizes_quantity_scales(
256        #[case] query_precision: u8,
257        #[case] first_precision: u8,
258        #[case] second_precision: u8,
259        #[values(OrderSide::Buy, OrderSide::Sell)] side: OrderSide,
260        #[values(false, true)] mixed_orders: bool,
261    ) {
262        let mut book = OrderBook::new(InstrumentId::from("ETHUSDT-PERP.BINANCE"), BookType::L3_MBO);
263        let order_side = side.opposite();
264
265        let (first_price, second_price) = match side {
266            OrderSide::Buy => (100, 200),
267            OrderSide::Sell => (200, 100),
268        };
269
270        for (level, price, precision) in [
271            (0, first_price, first_precision),
272            (1, second_price, second_precision),
273        ] {
274            let sizes = if mixed_orders {
275                [
276                    (dec!(0.25), first_precision),
277                    (dec!(0.75), second_precision),
278                ]
279            } else {
280                [(dec!(0.25), precision), (dec!(0.75), precision)]
281            };
282
283            for (order, (size, size_precision)) in sizes.into_iter().enumerate() {
284                book.add(
285                    BookOrder::new(
286                        order_side,
287                        Price::from(price.to_string()),
288                        Quantity::from_decimal_dp(size, size_precision).unwrap(),
289                        level * 2 + order as u64,
290                    ),
291                    0,
292                    0,
293                    0.into(),
294                );
295            }
296        }
297
298        let query = Quantity::from_decimal_dp(dec!(1.5), query_precision).unwrap();
299        let exposure = Quantity::from_decimal_dp(
300            Decimal::from(first_price + second_price / 2),
301            query_precision,
302        )
303        .unwrap();
304        let expected_average = f64::from(first_price + second_price / 2) / 1.5;
305
306        assert_eq!(book.get_avg_px_for_quantity(query, side), expected_average);
307        assert_eq!(
308            book.get_worst_px_for_quantity(query, side),
309            Some(Price::from(second_price.to_string()))
310        );
311        assert_eq!(
312            book.get_avg_px_qty_for_exposure(exposure, side),
313            (expected_average, 1.5, f64::from(second_price))
314        );
315    }
316
317    #[rstest]
318    #[case(2, 2)]
319    #[case(2, 17)]
320    #[case(2, 18)]
321    #[case(17, 2)]
322    #[case(18, 2)]
323    fn test_book_exposure_preserves_book_scale_rounding(
324        #[case] size_precision: u8,
325        #[case] target_precision: u8,
326    ) {
327        let mut book = OrderBook::new(InstrumentId::from("ETHUSDT-PERP.BINANCE"), BookType::L2_MBP);
328        book.add(
329            BookOrder::new(
330                OrderSide::Sell,
331                Price::from("99"),
332                Quantity::from_decimal_dp(dec!(1), size_precision).unwrap(),
333                1,
334            ),
335            0,
336            0,
337            0.into(),
338        );
339        let target = Quantity::from_decimal_dp(dec!(1), target_precision).unwrap();
340
341        let expected = if size_precision <= FIXED_PRECISION {
342            (99.000_000_000_000_01, 0.010_101_010_101_010_1, 99.0)
343        } else if size_precision == 17 {
344            (99.0, 0.010_101_010_101_010_1, 99.0)
345        } else {
346            (99.0, 0.010_101_010_101_010_102, 99.0)
347        };
348
349        assert_eq!(
350            book.get_avg_px_qty_for_exposure(target, OrderSide::Buy),
351            expected
352        );
353    }
354
355    #[rstest]
356    fn test_book_depth_preserves_native_units() {
357        let mut book = OrderBook::new(InstrumentId::from("ETHUSDT-PERP.BINANCE"), BookType::L2_MBP);
358        for (id, price) in [(1, 100), (2, 200)] {
359            book.add(
360                BookOrder::new(
361                    OrderSide::Sell,
362                    Price::from(price.to_string()),
363                    Quantity::from_raw(1, 18),
364                    id,
365                ),
366                0,
367                0,
368                0.into(),
369            );
370        }
371
372        assert_eq!(
373            book.get_avg_px_for_quantity(Quantity::from(1), OrderSide::Buy),
374            150.0
375        );
376        assert_eq!(
377            book.get_worst_px_for_quantity(Quantity::from(1), OrderSide::Buy),
378            Some(Price::from("200"))
379        );
380        assert_eq!(
381            book.get_avg_px_qty_for_exposure(Quantity::from(1), OrderSide::Buy),
382            (150.0, 2e-18, 200.0)
383        );
384    }
385
386    #[rstest]
387    #[case("1", "2", 1.9)]
388    #[case("10000000000000", "11000000000000", 10_900_000_000_000.0)]
389    fn test_book_depth_normalizes_max_raw_sizes(
390        #[case] first_price: &str,
391        #[case] second_price: &str,
392        #[case] expected_average: f64,
393    ) {
394        let mut book = OrderBook::new(InstrumentId::from("ETHUSDT-PERP.BINANCE"), BookType::L2_MBP);
395        book.add(
396            BookOrder::new(
397                OrderSide::Sell,
398                Price::from(first_price),
399                Quantity::from_raw(QUANTITY_RAW_MAX, 18),
400                1,
401            ),
402            0,
403            0,
404            0.into(),
405        );
406        book.add(
407            BookOrder::new(
408                OrderSide::Sell,
409                Price::from(second_price),
410                Quantity::from_raw(QUANTITY_RAW_MAX, FIXED_PRECISION),
411                2,
412            ),
413            0,
414            0,
415            0.into(),
416        );
417        let query = Quantity::from_raw(QUANTITY_RAW_MAX / 10, FIXED_PRECISION);
418
419        assert_eq!(
420            book.get_avg_px_for_quantity(query, OrderSide::Buy),
421            expected_average
422        );
423        assert_eq!(
424            book.get_worst_px_for_quantity(query, OrderSide::Buy),
425            Some(Price::from(second_price))
426        );
427    }
428
429    #[rstest]
430    #[case("0.00000000001", 1e-11)]
431    #[case("0.0000000000000001", 1e-16)]
432    #[case("-0.00000000001", -1e-11)]
433    #[case("0.000000000000000001", 1e-18)]
434    fn test_book_depth_native_query_preserves_small_prices(
435        #[case] price: &str,
436        #[case] expected: f64,
437    ) {
438        let mut book = OrderBook::new(InstrumentId::from("ETHUSDT-PERP.BINANCE"), BookType::L2_MBP);
439        book.add(
440            BookOrder::new(OrderSide::Sell, Price::from(price), Quantity::from(1), 1),
441            0,
442            0,
443            0.into(),
444        );
445
446        assert_eq!(
447            book.get_avg_px_for_quantity(Quantity::from_raw(1, 18), OrderSide::Buy),
448            expected
449        );
450    }
451
452    #[rstest]
453    fn test_book_depth_average_preserves_sub_price_units() {
454        let mut book = OrderBook::new(InstrumentId::from("ETHUSDT-PERP.BINANCE"), BookType::L2_MBP);
455        for (id, price, size) in [
456            (1, "0", Quantity::from(1)),
457            (2, "0.0000000000000001", Quantity::from_raw(1, 18)),
458        ] {
459            book.add(
460                BookOrder::new(OrderSide::Sell, Price::from(price), size, id),
461                0,
462                0,
463                0.into(),
464            );
465        }
466
467        assert_eq!(
468            book.get_avg_px_for_quantity(Quantity::from(2), OrderSide::Buy),
469            1e-34
470        );
471    }
472
473    #[rstest]
474    #[case(0, 0.0, None)]
475    #[case(1, 100.0, Some(Price::from("100")))]
476    fn test_book_depth_native_query_against_fixed_sizes(
477        #[case] query_raw: QuantityRaw,
478        #[case] expected_average: f64,
479        #[case] expected_worst: Option<Price>,
480    ) {
481        let mut book = OrderBook::new(InstrumentId::from("ETHUSDT-PERP.BINANCE"), BookType::L2_MBP);
482        book.add(
483            BookOrder::new(OrderSide::Sell, Price::from("100"), Quantity::from(1), 1),
484            0,
485            0,
486            0.into(),
487        );
488        let query = Quantity::from_raw(query_raw, 18);
489
490        assert_eq!(
491            book.get_avg_px_for_quantity(query, OrderSide::Buy),
492            expected_average
493        );
494        assert_eq!(
495            book.get_worst_px_for_quantity(query, OrderSide::Buy),
496            expected_worst
497        );
498    }
499
500    #[rstest]
501    #[case(199, (0.0, 0.0, 3.0))]
502    #[case(300, (3.0, 1e-16, 3.0))]
503    fn test_book_exposure_finer_target_preserves_fill_quantum(
504        #[case] target_raw: QuantityRaw,
505        #[case] expected: (f64, f64, f64),
506    ) {
507        let mut book = OrderBook::new(InstrumentId::from("ETHUSDT-PERP.BINANCE"), BookType::L2_MBP);
508        book.add(
509            BookOrder::new(OrderSide::Sell, Price::from("3"), Quantity::from(1), 1),
510            0,
511            0,
512            0.into(),
513        );
514        let target = Quantity::from_raw(target_raw, 18);
515
516        assert_eq!(
517            book.get_avg_px_qty_for_exposure(target, OrderSide::Buy),
518            expected
519        );
520    }
521
522    #[rstest]
523    fn test_book_worst_price_preserves_mixed_scale_traversal() {
524        let mut book = OrderBook::new(InstrumentId::from("AAPL.XNAS"), BookType::L2_MBP);
525        let price = Price::from("1.00");
526        book.add(
527            BookOrder::new(OrderSide::Sell, price, Quantity::from_raw(1, 18), 1),
528            0,
529            0,
530            0.into(),
531        );
532        assert_eq!(
533            book.get_worst_px_for_quantity(Quantity::from(2), OrderSide::Buy),
534            Some(price)
535        );
536    }
537
538    #[rstest]
539    fn test_book_exposure_accepts_native_scale_quantities() {
540        let mut book = OrderBook::new(InstrumentId::from("AAPL.XNAS"), BookType::L2_MBP);
541        let target = Quantity::from_raw(1_000_000_000_000_000_000, 18);
542        assert_eq!(
543            book.get_avg_px_qty_for_exposure(target, OrderSide::Buy),
544            (0.0, 0.0, 0.0)
545        );
546        book.add(
547            BookOrder::new(
548                OrderSide::Sell,
549                Price::from("1.00"),
550                Quantity::from_raw(2_000_000_000_000_000_000, 18),
551                1,
552            ),
553            0,
554            0,
555            0.into(),
556        );
557        assert_eq!(
558            book.get_avg_px_qty_for_exposure(target, OrderSide::Buy),
559            (1.0, 1.0, 1.0)
560        );
561    }
562}