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::{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/// Panics if:
98/// - The liquidity side is `NoLiquiditySide`.
99/// - `fee_rate` 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    fee_rate: Decimal,
107) -> Money {
108    let liquidity_side = LiquiditySide::Taker;
109    assert_ne!(
110        liquidity_side,
111        LiquiditySide::NoLiquiditySide,
112        "Invalid liquidity side"
113    );
114    let notional = instrument
115        .calculate_notional_value(last_qty, last_px, use_quote_for_inverse)
116        .as_f64();
117    let commission = notional * fee_rate.to_f64().unwrap();
118
119    if instrument.is_inverse() && !use_quote_for_inverse.unwrap_or(false) {
120        Money::new(commission, instrument.base_currency().unwrap())
121    } else {
122        Money::new(commission, instrument.quote_currency())
123    }
124}
125
126#[fixture]
127pub fn stub_position_long(audusd_sim: CurrencyPair) -> Position {
128    let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
129    let order = OrderTestBuilder::new(OrderType::Market)
130        .instrument_id(audusd_sim.id())
131        .side(OrderSide::Buy)
132        .quantity(Quantity::from(1))
133        .build();
134    let filled = OrderFilledTestBuilder::new(&order, &audusd_sim)
135        .last_px(Price::from("1.0002"))
136        .build();
137    Position::new(&audusd_sim, filled.into())
138}
139
140#[fixture]
141pub fn stub_position_short(audusd_sim: CurrencyPair) -> Position {
142    let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
143    let order = OrderTestBuilder::new(OrderType::Market)
144        .instrument_id(audusd_sim.id())
145        .side(OrderSide::Sell)
146        .quantity(Quantity::from(1))
147        .build();
148    let filled = OrderFilledTestBuilder::new(&order, &audusd_sim)
149        .last_px(Price::from("22000.0"))
150        .build();
151    Position::new(&audusd_sim, filled.into())
152}
153
154#[must_use]
155pub fn stub_order_book_mbp_appl_xnas() -> OrderBook {
156    stub_order_book_mbp(
157        InstrumentId::from("AAPL.XNAS"),
158        101.0,
159        100.0,
160        100.0,
161        100.0,
162        2,
163        0.01,
164        0,
165        100.0,
166        10,
167    )
168}
169
170#[expect(clippy::too_many_arguments)]
171#[must_use]
172pub fn stub_order_book_mbp(
173    instrument_id: InstrumentId,
174    top_ask_price: f64,
175    top_bid_price: f64,
176    top_ask_size: f64,
177    top_bid_size: f64,
178    price_precision: u8,
179    price_increment: f64,
180    size_precision: u8,
181    size_increment: f64,
182    num_levels: usize,
183) -> OrderBook {
184    let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
185
186    // Generate bids
187    for i in 0..num_levels {
188        let price = Price::new(
189            price_increment.mul_add(-(i as f64), top_bid_price),
190            price_precision,
191        );
192        let size = Quantity::new(
193            size_increment.mul_add(i as f64, top_bid_size),
194            size_precision,
195        );
196        let order = BookOrder::new(
197            OrderSide::Buy,
198            price,
199            size,
200            0, // order_id not applicable for MBP (market by price) books
201        );
202        book.add(order, 0, 1, 2.into());
203    }
204
205    // Generate asks
206    for i in 0..num_levels {
207        let price = Price::new(
208            price_increment.mul_add(i as f64, top_ask_price),
209            price_precision,
210        );
211        let size = Quantity::new(
212            size_increment.mul_add(i as f64, top_ask_size),
213            size_precision,
214        );
215        let order = BookOrder::new(
216            OrderSide::Sell,
217            price,
218            size,
219            0, // order_id not applicable for MBP (market by price) books
220        );
221        book.add(order, 0, 1, 2.into());
222    }
223
224    book
225}
226
227#[cfg(test)]
228mod tests {
229    use std::collections::HashSet;
230
231    use rstest::rstest;
232    use rust_decimal_macros::dec;
233
234    use super::*;
235    use crate::{
236        instruments::{
237            CryptoPerpetual,
238            stubs::{btcusd_bybit, crypto_perpetual_ethusdt},
239        },
240        orderbook::BookLevel,
241        types::Currency,
242    };
243
244    #[rstest]
245    fn test_uuid_is_valid_v4_rfc4122() {
246        reset_test_uuid_rng();
247        let s = test_uuid().to_string();
248        // Format invariants per RFC 4122: position 14 is the version digit, position 19 the variant.
249        assert_eq!(s.len(), 36);
250        assert_eq!(&s[14..15], "4", "version digit must be 4, was {s}");
251        let variant = s.chars().nth(19).unwrap();
252        assert!(
253            matches!(variant, '8' | '9' | 'a' | 'b'),
254            "variant nibble must be one of 8/9/a/b, was {variant} in {s}",
255        );
256    }
257
258    #[rstest]
259    fn test_uuid_sequence_is_deterministic_and_distinct() {
260        reset_test_uuid_rng();
261        let first: Vec<UUID4> = (0..8).map(|_| test_uuid()).collect();
262        reset_test_uuid_rng();
263        let second: Vec<UUID4> = (0..8).map(|_| test_uuid()).collect();
264
265        assert_eq!(first, second, "the same seed must replay the same sequence");
266        assert_eq!(
267            first.iter().collect::<HashSet<_>>().len(),
268            first.len(),
269            "each call must yield a distinct UUID",
270        );
271    }
272
273    #[rstest]
274    fn test_uuid_advances_without_reset() {
275        reset_test_uuid_rng();
276        let first = test_uuid();
277        let second = test_uuid();
278
279        assert_ne!(first, second);
280
281        reset_test_uuid_rng();
282
283        assert_eq!(
284            test_uuid(),
285            first,
286            "reset must return to the seeded sequence"
287        );
288    }
289
290    #[rstest]
291    fn test_calculate_commission_applies_taker_fee_in_quote_currency(
292        crypto_perpetual_ethusdt: CryptoPerpetual,
293    ) {
294        // ETHUSDT-PERP carries a 0.0004 taker rate in these tests: 10 @ 2000.00 = 20,000
295        // notional -> 8.00 USDT taker, 4.00 maker.
296        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt);
297
298        let commission = calculate_commission(
299            &instrument,
300            Quantity::from("10.000"),
301            Price::from("2000.00"),
302            None,
303            dec!(0.0004),
304        );
305
306        assert_eq!(commission, Money::new(8.0, Currency::from("USDT")));
307        assert_eq!(commission.currency, instrument.quote_currency());
308    }
309
310    #[rstest]
311    #[case(None)]
312    #[case(Some(false))]
313    fn test_calculate_commission_charges_inverse_instruments_in_base_currency(
314        btcusd_bybit: CryptoPerpetual,
315        #[case] use_quote_for_inverse: Option<bool>,
316    ) {
317        // Inverse: 100,000 USD @ 50,000.00 = 2 BTC notional, taker 0.00075 -> 0.0015 BTC.
318        // `Some(false)` must behave like `None`, not like `Some(true)`.
319        let instrument = InstrumentAny::CryptoPerpetual(btcusd_bybit);
320
321        let commission = calculate_commission(
322            &instrument,
323            Quantity::from(100_000),
324            Price::from("50000.0"),
325            use_quote_for_inverse,
326            dec!(0.00075),
327        );
328
329        assert_eq!(commission, Money::new(0.0015, Currency::BTC()));
330        assert_eq!(commission.currency, instrument.base_currency().unwrap());
331    }
332
333    #[rstest]
334    fn test_calculate_commission_uses_quote_currency_when_requested_for_inverse(
335        btcusd_bybit: CryptoPerpetual,
336    ) {
337        let instrument = InstrumentAny::CryptoPerpetual(btcusd_bybit);
338
339        let commission = calculate_commission(
340            &instrument,
341            Quantity::from(100_000),
342            Price::from("50000.0"),
343            Some(true),
344            dec!(0.00075),
345        );
346
347        assert_eq!(commission, Money::new(75.0, Currency::USD()));
348        assert_eq!(commission.currency, instrument.quote_currency());
349    }
350
351    #[rstest]
352    fn test_stub_order_book_mbp_appl_xnas_levels() {
353        let book = stub_order_book_mbp_appl_xnas();
354
355        assert_eq!(book.instrument_id, InstrumentId::from("AAPL.XNAS"));
356        assert_eq!(book.book_type, BookType::L2_MBP);
357        assert_eq!(book.bids(None).count(), 10);
358        assert_eq!(book.asks(None).count(), 10);
359        assert_eq!(book.best_bid_price(), Some(Price::new(100.0, 2)));
360        assert_eq!(book.best_ask_price(), Some(Price::new(101.0, 2)));
361        assert_eq!(book.best_bid_size(), Some(Quantity::new(100.0, 0)));
362        assert_eq!(book.best_ask_size(), Some(Quantity::new(100.0, 0)));
363        // `Price` and `Quantity` compare on raw value alone, so precision needs its own assertion.
364        assert_eq!(book.best_bid_price().unwrap().precision, 2);
365        assert_eq!(book.best_ask_price().unwrap().precision, 2);
366        assert_eq!(book.best_bid_size().unwrap().precision, 0);
367        assert_eq!(book.best_ask_size().unwrap().precision, 0);
368
369        // Assert past the touch so the wrapper's own increments are pinned, not just its top level.
370        let bids: Vec<&BookLevel> = book.bids(None).collect();
371        let asks: Vec<&BookLevel> = book.asks(None).collect();
372
373        assert_eq!(bids[1].price.value.as_decimal(), dec!(99.99));
374        assert_eq!(asks[1].price.value.as_decimal(), dec!(101.01));
375        assert_eq!(
376            bids[1]
377                .first()
378                .expect("level must hold an order")
379                .size
380                .as_decimal(),
381            dec!(200),
382        );
383        assert_eq!(
384            asks[1]
385                .first()
386                .expect("level must hold an order")
387                .size
388                .as_decimal(),
389            dec!(200),
390        );
391    }
392
393    #[rstest]
394    fn test_stub_order_book_mbp_walks_prices_away_from_the_touch() {
395        // Every argument differs from `stub_order_book_mbp_appl_xnas`, so a hardcoded precision,
396        // increment, or touch price inside the builder fails here even if the wrapper test passes.
397        let book = stub_order_book_mbp(
398            InstrumentId::from("ESH5.XCME"),
399            200.500,
400            200.000,
401            7.5,
402            4.5,
403            3,
404            0.005,
405            1,
406            2.5,
407            3,
408        );
409
410        assert_eq!(book.instrument_id, InstrumentId::from("ESH5.XCME"));
411
412        let bids: Vec<&BookLevel> = book.bids(None).collect();
413        let asks: Vec<&BookLevel> = book.asks(None).collect();
414
415        // `Price` and `Quantity` compare on raw value alone, so precision needs its own assertion.
416        assert_eq!(
417            bids.iter()
418                .map(|l| l.price.value.as_decimal())
419                .collect::<Vec<_>>(),
420            vec![dec!(200.000), dec!(199.995), dec!(199.990)],
421        );
422        assert_eq!(
423            asks.iter()
424                .map(|l| l.price.value.as_decimal())
425                .collect::<Vec<_>>(),
426            vec![dec!(200.500), dec!(200.505), dec!(200.510)],
427        );
428        assert_eq!(
429            bids.iter()
430                .map(|l| l.price.value.precision)
431                .collect::<Vec<_>>(),
432            vec![3, 3, 3],
433        );
434        assert_eq!(
435            asks.iter()
436                .map(|l| l.price.value.precision)
437                .collect::<Vec<_>>(),
438            vec![3, 3, 3],
439        );
440
441        let bid_sizes: Vec<Quantity> = bids
442            .iter()
443            .map(|l| l.first().expect("level must hold an order").size)
444            .collect();
445        let ask_sizes: Vec<Quantity> = asks
446            .iter()
447            .map(|l| l.first().expect("level must hold an order").size)
448            .collect();
449
450        assert_eq!(
451            bid_sizes
452                .iter()
453                .map(Quantity::as_decimal)
454                .collect::<Vec<_>>(),
455            vec![dec!(4.5), dec!(7.0), dec!(9.5)],
456        );
457        assert_eq!(
458            ask_sizes
459                .iter()
460                .map(Quantity::as_decimal)
461                .collect::<Vec<_>>(),
462            vec![dec!(7.5), dec!(10.0), dec!(12.5)],
463        );
464        assert_eq!(
465            bid_sizes.iter().map(|q| q.precision).collect::<Vec<_>>(),
466            vec![1, 1, 1],
467        );
468        assert_eq!(
469            ask_sizes.iter().map(|q| q.precision).collect::<Vec<_>>(),
470            vec![1, 1, 1],
471        );
472    }
473}