1use 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
35pub(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
42fn 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#[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
75pub fn reset_test_uuid_rng() {
80 TEST_UUID_STATE.with(|cell| cell.set(TEST_UUID_SEED));
81}
82
83pub trait TestDefault {
89 fn test_default() -> Self;
91}
92
93#[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 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, );
202 book.add(order, 0, 1, 2.into());
203 }
204
205 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, );
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 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 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 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 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 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 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 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}