1use 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
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) -> 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 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, );
207 book.add(order, 0, 1, 2.into());
208 }
209
210 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, );
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 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 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 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 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 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 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 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}