Skip to main content

nautilus_model/
stubs.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//! Type stubs to facilitate testing.
17
18use std::cell::Cell;
19
20use nautilus_core::UUID4;
21use rstest::fixture;
22use rust_decimal::prelude::ToPrimitive;
23
24use crate::{
25    data::order::BookOrder,
26    enums::{BookType, LiquiditySide, OrderSide, OrderType},
27    identifiers::InstrumentId,
28    instruments::{CurrencyPair, Instrument, InstrumentAny, stubs::audusd_sim},
29    orderbook::OrderBook,
30    orders::{builder::OrderTestBuilder, stubs::OrderFilledTestBuilder},
31    position::Position,
32    types::{Money, Price, Quantity},
33};
34
35/// Seed used by [`test_uuid`] for deterministic UUIDs in test fixtures.
36pub(crate) const TEST_UUID_SEED: u64 = 42;
37
38thread_local! {
39    static TEST_UUID_STATE: Cell<u64> = const { Cell::new(TEST_UUID_SEED) };
40}
41
42// SplitMix64 PRNG (Steele, Lea, Flood 2014): owning the algorithm here keeps the test UUID
43// sequence stable regardless of upstream PRNG crate versions, with zero added dependencies.
44fn splitmix64(state: &mut u64) -> u64 {
45    *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
46    let mut z = *state;
47    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
48    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
49    z ^ (z >> 31)
50}
51
52/// Returns the next [`UUID4`] in a per-thread deterministic sequence seeded with a fixed value.
53///
54/// The official test runner is `cargo nextest`, which spawns one process per test, so the
55/// sequence resets at every test boundary without explicit teardown. Multiple events constructed
56/// within a single test get distinct UUIDs, and re-running the same test produces the same
57/// sequence.
58///
59/// Intended for use as a default in test specs and fixtures only.
60#[must_use]
61pub fn test_uuid() -> UUID4 {
62    TEST_UUID_STATE.with(|cell| {
63        let mut state = cell.get();
64        let hi = splitmix64(&mut state).to_be_bytes();
65        let lo = splitmix64(&mut state).to_be_bytes();
66        cell.set(state);
67
68        let mut bytes = [0u8; 16];
69        bytes[..8].copy_from_slice(&hi);
70        bytes[8..].copy_from_slice(&lo);
71        UUID4::from_bytes(bytes)
72    })
73}
74
75/// Resets the per-thread test UUID state to its seed.
76///
77/// Only needed under runners that share a process across tests (e.g. plain `cargo test`); under
78/// nextest each test starts with fresh thread-local state already.
79pub fn reset_test_uuid_rng() {
80    TEST_UUID_STATE.with(|cell| cell.set(TEST_UUID_SEED));
81}
82
83/// A trait for providing test-only default values.
84///
85/// This trait is intentionally separate from [`Default`] to make it clear
86/// that these default values are only meaningful in testing contexts and should
87/// not be used in production code.
88pub trait TestDefault {
89    /// Creates a new instance with test-appropriate default values.
90    fn test_default() -> Self;
91}
92
93/// Calculate commission for testing.
94///
95/// # Panics
96///
97/// This function panics if:
98/// - The liquidity side is `NoLiquiditySide`.
99/// - `instrument.maker_fee()` or `instrument.taker_fee()` cannot be converted to `f64`.
100#[must_use]
101pub fn calculate_commission(
102    instrument: &InstrumentAny,
103    last_qty: Quantity,
104    last_px: Price,
105    use_quote_for_inverse: Option<bool>,
106) -> Money {
107    let liquidity_side = LiquiditySide::Taker;
108    assert_ne!(
109        liquidity_side,
110        LiquiditySide::NoLiquiditySide,
111        "Invalid liquidity side"
112    );
113    let notional = instrument
114        .calculate_notional_value(last_qty, last_px, use_quote_for_inverse)
115        .as_f64();
116    let commission = if liquidity_side == LiquiditySide::Maker {
117        notional * instrument.maker_fee().to_f64().unwrap()
118    } else if liquidity_side == LiquiditySide::Taker {
119        notional * instrument.taker_fee().to_f64().unwrap()
120    } else {
121        panic!("Invalid liquidity side {liquidity_side}")
122    };
123
124    if instrument.is_inverse() && !use_quote_for_inverse.unwrap_or(false) {
125        Money::new(commission, instrument.base_currency().unwrap())
126    } else {
127        Money::new(commission, instrument.quote_currency())
128    }
129}
130
131#[fixture]
132pub fn stub_position_long(audusd_sim: CurrencyPair) -> Position {
133    let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
134    let order = OrderTestBuilder::new(OrderType::Market)
135        .instrument_id(audusd_sim.id())
136        .side(OrderSide::Buy)
137        .quantity(Quantity::from(1))
138        .build();
139    let filled = OrderFilledTestBuilder::new(&order, &audusd_sim)
140        .last_px(Price::from("1.0002"))
141        .build();
142    Position::new(&audusd_sim, filled.into())
143}
144
145#[fixture]
146pub fn stub_position_short(audusd_sim: CurrencyPair) -> Position {
147    let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
148    let order = OrderTestBuilder::new(OrderType::Market)
149        .instrument_id(audusd_sim.id())
150        .side(OrderSide::Sell)
151        .quantity(Quantity::from(1))
152        .build();
153    let filled = OrderFilledTestBuilder::new(&order, &audusd_sim)
154        .last_px(Price::from("22000.0"))
155        .build();
156    Position::new(&audusd_sim, filled.into())
157}
158
159#[must_use]
160pub fn stub_order_book_mbp_appl_xnas() -> OrderBook {
161    stub_order_book_mbp(
162        InstrumentId::from("AAPL.XNAS"),
163        101.0,
164        100.0,
165        100.0,
166        100.0,
167        2,
168        0.01,
169        0,
170        100.0,
171        10,
172    )
173}
174
175#[expect(clippy::too_many_arguments)]
176#[must_use]
177pub fn stub_order_book_mbp(
178    instrument_id: InstrumentId,
179    top_ask_price: f64,
180    top_bid_price: f64,
181    top_ask_size: f64,
182    top_bid_size: f64,
183    price_precision: u8,
184    price_increment: f64,
185    size_precision: u8,
186    size_increment: f64,
187    num_levels: usize,
188) -> OrderBook {
189    let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
190
191    // Generate bids
192    for i in 0..num_levels {
193        let price = Price::new(
194            price_increment.mul_add(-(i as f64), top_bid_price),
195            price_precision,
196        );
197        let size = Quantity::new(
198            size_increment.mul_add(i as f64, top_bid_size),
199            size_precision,
200        );
201        let order = BookOrder::new(
202            OrderSide::Buy,
203            price,
204            size,
205            0, // order_id not applicable for MBP (market by price) books
206        );
207        book.add(order, 0, 1, 2.into());
208    }
209
210    // Generate asks
211    for i in 0..num_levels {
212        let price = Price::new(
213            price_increment.mul_add(i as f64, top_ask_price),
214            price_precision,
215        );
216        let size = Quantity::new(
217            size_increment.mul_add(i as f64, top_ask_size),
218            size_precision,
219        );
220        let order = BookOrder::new(
221            OrderSide::Sell,
222            price,
223            size,
224            0, // order_id not applicable for MBP (market by price) books
225        );
226        book.add(order, 0, 1, 2.into());
227    }
228
229    book
230}
231
232#[cfg(test)]
233mod tests {
234    use std::collections::HashSet;
235
236    use rstest::rstest;
237    use rust_decimal_macros::dec;
238
239    use super::*;
240    use crate::{
241        instruments::{
242            CryptoPerpetual,
243            stubs::{crypto_perpetual_ethusdt, xbtusd_bitmex},
244        },
245        orderbook::BookLevel,
246        types::Currency,
247    };
248
249    #[rstest]
250    fn test_uuid_is_valid_v4_rfc4122() {
251        reset_test_uuid_rng();
252        let s = test_uuid().to_string();
253        // Format invariants per RFC 4122: position 14 is the version digit, position 19 the variant.
254        assert_eq!(s.len(), 36);
255        assert_eq!(&s[14..15], "4", "version digit must be 4, was {s}");
256        let variant = s.chars().nth(19).unwrap();
257        assert!(
258            matches!(variant, '8' | '9' | 'a' | 'b'),
259            "variant nibble must be one of 8/9/a/b, was {variant} in {s}",
260        );
261    }
262
263    #[rstest]
264    fn test_uuid_sequence_is_deterministic_and_distinct() {
265        reset_test_uuid_rng();
266        let first: Vec<UUID4> = (0..8).map(|_| test_uuid()).collect();
267        reset_test_uuid_rng();
268        let second: Vec<UUID4> = (0..8).map(|_| test_uuid()).collect();
269
270        assert_eq!(first, second, "the same seed must replay the same sequence");
271        assert_eq!(
272            first.iter().collect::<HashSet<_>>().len(),
273            first.len(),
274            "each call must yield a distinct UUID",
275        );
276    }
277
278    #[rstest]
279    fn test_uuid_advances_without_reset() {
280        reset_test_uuid_rng();
281        let first = test_uuid();
282        let second = test_uuid();
283
284        assert_ne!(first, second);
285
286        reset_test_uuid_rng();
287
288        assert_eq!(
289            test_uuid(),
290            first,
291            "reset must return to the seeded sequence"
292        );
293    }
294
295    #[rstest]
296    fn test_calculate_commission_applies_taker_fee_in_quote_currency(
297        crypto_perpetual_ethusdt: CryptoPerpetual,
298    ) {
299        // ETHUSDT-PERP has distinct fees (maker 0.0002, taker 0.0004), so a maker/taker swap
300        // changes the result: 10 @ 2000.00 = 20,000 notional -> 8.00 USDT taker, 4.00 maker.
301        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt);
302
303        let commission = calculate_commission(
304            &instrument,
305            Quantity::from("10.000"),
306            Price::from("2000.00"),
307            None,
308        );
309
310        assert_eq!(commission, Money::new(8.0, Currency::from("USDT")));
311        assert_eq!(commission.currency, instrument.quote_currency());
312    }
313
314    #[rstest]
315    #[case(None)]
316    #[case(Some(false))]
317    fn test_calculate_commission_charges_inverse_instruments_in_base_currency(
318        xbtusd_bitmex: CryptoPerpetual,
319        #[case] use_quote_for_inverse: Option<bool>,
320    ) {
321        // Inverse: 100,000 USD @ 50,000.00 = 2 BTC notional, taker 0.00075 -> 0.0015 BTC.
322        // `Some(false)` must behave like `None`, not like `Some(true)`.
323        let instrument = InstrumentAny::CryptoPerpetual(xbtusd_bitmex);
324
325        let commission = calculate_commission(
326            &instrument,
327            Quantity::from(100_000),
328            Price::from("50000.0"),
329            use_quote_for_inverse,
330        );
331
332        assert_eq!(commission, Money::new(0.0015, Currency::BTC()));
333        assert_eq!(commission.currency, instrument.base_currency().unwrap());
334    }
335
336    #[rstest]
337    fn test_calculate_commission_uses_quote_currency_when_requested_for_inverse(
338        xbtusd_bitmex: CryptoPerpetual,
339    ) {
340        let instrument = InstrumentAny::CryptoPerpetual(xbtusd_bitmex);
341
342        let commission = calculate_commission(
343            &instrument,
344            Quantity::from(100_000),
345            Price::from("50000.0"),
346            Some(true),
347        );
348
349        assert_eq!(commission, Money::new(75.0, Currency::USD()));
350        assert_eq!(commission.currency, instrument.quote_currency());
351    }
352
353    #[rstest]
354    fn test_stub_order_book_mbp_appl_xnas_levels() {
355        let book = stub_order_book_mbp_appl_xnas();
356
357        assert_eq!(book.instrument_id, InstrumentId::from("AAPL.XNAS"));
358        assert_eq!(book.book_type, BookType::L2_MBP);
359        assert_eq!(book.bids(None).count(), 10);
360        assert_eq!(book.asks(None).count(), 10);
361        assert_eq!(book.best_bid_price(), Some(Price::new(100.0, 2)));
362        assert_eq!(book.best_ask_price(), Some(Price::new(101.0, 2)));
363        assert_eq!(book.best_bid_size(), Some(Quantity::new(100.0, 0)));
364        assert_eq!(book.best_ask_size(), Some(Quantity::new(100.0, 0)));
365        // `Price` and `Quantity` compare on raw value alone, so precision needs its own assertion.
366        assert_eq!(book.best_bid_price().unwrap().precision, 2);
367        assert_eq!(book.best_ask_price().unwrap().precision, 2);
368        assert_eq!(book.best_bid_size().unwrap().precision, 0);
369        assert_eq!(book.best_ask_size().unwrap().precision, 0);
370
371        // Assert past the touch so the wrapper's own increments are pinned, not just its top level.
372        let bids: Vec<&BookLevel> = book.bids(None).collect();
373        let asks: Vec<&BookLevel> = book.asks(None).collect();
374
375        assert_eq!(bids[1].price.value.as_decimal(), dec!(99.99));
376        assert_eq!(asks[1].price.value.as_decimal(), dec!(101.01));
377        assert_eq!(
378            bids[1]
379                .first()
380                .expect("level must hold an order")
381                .size
382                .as_decimal(),
383            dec!(200),
384        );
385        assert_eq!(
386            asks[1]
387                .first()
388                .expect("level must hold an order")
389                .size
390                .as_decimal(),
391            dec!(200),
392        );
393    }
394
395    #[rstest]
396    fn test_stub_order_book_mbp_walks_prices_away_from_the_touch() {
397        // Every argument differs from `stub_order_book_mbp_appl_xnas`, so a hardcoded precision,
398        // increment, or touch price inside the builder fails here even if the wrapper test passes.
399        let book = stub_order_book_mbp(
400            InstrumentId::from("ESH5.XCME"),
401            200.500,
402            200.000,
403            7.5,
404            4.5,
405            3,
406            0.005,
407            1,
408            2.5,
409            3,
410        );
411
412        assert_eq!(book.instrument_id, InstrumentId::from("ESH5.XCME"));
413
414        let bids: Vec<&BookLevel> = book.bids(None).collect();
415        let asks: Vec<&BookLevel> = book.asks(None).collect();
416
417        // `Price` and `Quantity` compare on raw value alone, so precision needs its own assertion.
418        assert_eq!(
419            bids.iter()
420                .map(|l| l.price.value.as_decimal())
421                .collect::<Vec<_>>(),
422            vec![dec!(200.000), dec!(199.995), dec!(199.990)],
423        );
424        assert_eq!(
425            asks.iter()
426                .map(|l| l.price.value.as_decimal())
427                .collect::<Vec<_>>(),
428            vec![dec!(200.500), dec!(200.505), dec!(200.510)],
429        );
430        assert_eq!(
431            bids.iter()
432                .map(|l| l.price.value.precision)
433                .collect::<Vec<_>>(),
434            vec![3, 3, 3],
435        );
436        assert_eq!(
437            asks.iter()
438                .map(|l| l.price.value.precision)
439                .collect::<Vec<_>>(),
440            vec![3, 3, 3],
441        );
442
443        let bid_sizes: Vec<Quantity> = bids
444            .iter()
445            .map(|l| l.first().expect("level must hold an order").size)
446            .collect();
447        let ask_sizes: Vec<Quantity> = asks
448            .iter()
449            .map(|l| l.first().expect("level must hold an order").size)
450            .collect();
451
452        assert_eq!(
453            bid_sizes
454                .iter()
455                .map(Quantity::as_decimal)
456                .collect::<Vec<_>>(),
457            vec![dec!(4.5), dec!(7.0), dec!(9.5)],
458        );
459        assert_eq!(
460            ask_sizes
461                .iter()
462                .map(Quantity::as_decimal)
463                .collect::<Vec<_>>(),
464            vec![dec!(7.5), dec!(10.0), dec!(12.5)],
465        );
466        assert_eq!(
467            bid_sizes.iter().map(|q| q.precision).collect::<Vec<_>>(),
468            vec![1, 1, 1],
469        );
470        assert_eq!(
471            ask_sizes.iter().map(|q| q.precision).collect::<Vec<_>>(),
472            vec![1, 1, 1],
473        );
474    }
475}