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::TestOrderEventStubs},
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 = TestOrderEventStubs::filled(
140        &order,
141        &audusd_sim,
142        None,
143        None,
144        Some(Price::from("1.0002")),
145        None,
146        None,
147        None,
148        None,
149        None,
150    );
151    Position::new(&audusd_sim, filled.into())
152}
153
154#[fixture]
155pub fn stub_position_short(audusd_sim: CurrencyPair) -> Position {
156    let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
157    let order = OrderTestBuilder::new(OrderType::Market)
158        .instrument_id(audusd_sim.id())
159        .side(OrderSide::Sell)
160        .quantity(Quantity::from(1))
161        .build();
162    let filled = TestOrderEventStubs::filled(
163        &order,
164        &audusd_sim,
165        None,
166        None,
167        Some(Price::from("22000.0")),
168        None,
169        None,
170        None,
171        None,
172        None,
173    );
174    Position::new(&audusd_sim, filled.into())
175}
176
177#[must_use]
178pub fn stub_order_book_mbp_appl_xnas() -> OrderBook {
179    stub_order_book_mbp(
180        InstrumentId::from("AAPL.XNAS"),
181        101.0,
182        100.0,
183        100.0,
184        100.0,
185        2,
186        0.01,
187        0,
188        100.0,
189        10,
190    )
191}
192
193#[expect(clippy::too_many_arguments)]
194#[must_use]
195pub fn stub_order_book_mbp(
196    instrument_id: InstrumentId,
197    top_ask_price: f64,
198    top_bid_price: f64,
199    top_ask_size: f64,
200    top_bid_size: f64,
201    price_precision: u8,
202    price_increment: f64,
203    size_precision: u8,
204    size_increment: f64,
205    num_levels: usize,
206) -> OrderBook {
207    let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
208
209    // Generate bids
210    for i in 0..num_levels {
211        let price = Price::new(
212            price_increment.mul_add(-(i as f64), top_bid_price),
213            price_precision,
214        );
215        let size = Quantity::new(
216            size_increment.mul_add(i as f64, top_bid_size),
217            size_precision,
218        );
219        let order = BookOrder::new(
220            OrderSide::Buy,
221            price,
222            size,
223            0, // order_id not applicable for MBP (market by price) books
224        );
225        book.add(order, 0, 1, 2.into());
226    }
227
228    // Generate asks
229    for i in 0..num_levels {
230        let price = Price::new(
231            price_increment.mul_add(i as f64, top_ask_price),
232            price_precision,
233        );
234        let size = Quantity::new(
235            size_increment.mul_add(i as f64, top_ask_size),
236            size_precision,
237        );
238        let order = BookOrder::new(
239            OrderSide::Sell,
240            price,
241            size,
242            0, // order_id not applicable for MBP (market by price) books
243        );
244        book.add(order, 0, 1, 2.into());
245    }
246
247    book
248}
249
250#[cfg(test)]
251mod tests {
252    use rstest::rstest;
253
254    use super::*;
255
256    #[rstest]
257    fn test_uuid_is_valid_v4_rfc4122() {
258        reset_test_uuid_rng();
259        let s = test_uuid().to_string();
260        // Format invariants per RFC 4122: position 14 is the version digit, position 19 the variant.
261        assert_eq!(s.len(), 36);
262        assert_eq!(&s[14..15], "4", "version digit must be 4, was {s}");
263        let variant = s.chars().nth(19).unwrap();
264        assert!(
265            matches!(variant, '8' | '9' | 'a' | 'b'),
266            "variant nibble must be one of 8/9/a/b, was {variant} in {s}",
267        );
268    }
269}