1use std::{cell::RefCell, fmt::Debug, rc::Rc};
19
20use nautilus_common::cache::{Cache, VenueOrderIdOwnershipError};
21use nautilus_core::{UUID4, UnixNanos};
22use nautilus_model::{
23 enums::OmsType,
24 identifiers::{PositionId, TradeId, Venue, VenueOrderId},
25 orders::{Order, OrderAny},
26};
27
28const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
30const FNV_PRIME: u64 = 0x0100_0000_01b3;
31const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef";
32const TRADE_ID_COUNTER_START: usize = 19;
33
34pub struct IdsGenerator {
36 venue: Venue,
37 raw_id: u32,
38 oms_type: OmsType,
39 use_random_ids: bool,
40 use_position_ids: bool,
41 cache: Rc<RefCell<Cache>>,
42 position_count: usize,
43 order_count: usize,
44 execution_count: usize,
45}
46
47impl Debug for IdsGenerator {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 f.debug_struct(stringify!(IdsGenerator))
50 .field("venue", &self.venue)
51 .field("raw_id", &self.raw_id)
52 .finish()
53 }
54}
55
56impl IdsGenerator {
57 pub const fn new(
59 venue: Venue,
60 oms_type: OmsType,
61 raw_id: u32,
62 use_random_ids: bool,
63 use_position_ids: bool,
64 cache: Rc<RefCell<Cache>>,
65 ) -> Self {
66 Self {
67 venue,
68 raw_id,
69 oms_type,
70 cache,
71 use_random_ids,
72 use_position_ids,
73 position_count: 0,
74 order_count: 0,
75 execution_count: 0,
76 }
77 }
78
79 pub const fn reset(&mut self) {
81 self.position_count = 0;
82 self.order_count = 0;
83 self.execution_count = 0;
84 }
85
86 pub fn get_venue_order_id(&mut self, order: &OrderAny) -> anyhow::Result<VenueOrderId> {
92 if let Some(venue_order_id) = order.venue_order_id() {
94 return Ok(venue_order_id);
95 }
96
97 if let Some(venue_order_id) = self.cache.borrow().venue_order_id(&order.client_order_id()) {
99 return Ok(venue_order_id.to_owned());
100 }
101
102 let client_order_id = order.client_order_id();
103 let mut conflict_count = 0_usize;
104
105 loop {
106 let venue_order_id = self.try_generate_venue_order_id()?;
107 let claim_result = self.cache.borrow_mut().add_venue_order_id(
108 &client_order_id,
109 &venue_order_id,
110 false,
111 );
112
113 match claim_result {
114 Ok(()) => {
115 if conflict_count > 0 {
116 log::info!(
117 "Allocated venue order ID {venue_order_id} for {client_order_id} after \
118 probing past {conflict_count} ownership conflicts"
119 );
120 }
121
122 return Ok(venue_order_id);
123 }
124 Err(e) => {
125 let Some(conflict) = e.downcast_ref::<VenueOrderIdOwnershipError>() else {
126 return Err(e);
127 };
128
129 if conflict_count == 0 {
130 log::error!(
131 "Generated venue order ID conflict: candidate={}, existing_owner={}, \
132 claimant={}",
133 conflict.venue_order_id,
134 conflict.existing_client_order_id,
135 conflict.claimant_client_order_id,
136 );
137 }
138
139 conflict_count += 1;
140 }
141 }
142 }
143 }
144
145 pub fn get_position_id(
152 &mut self,
153 order: &OrderAny,
154 generate: Option<bool>,
155 ) -> Option<PositionId> {
156 let generate = generate.unwrap_or(true);
157
158 if self.oms_type == OmsType::Hedging {
159 {
160 let cache = self.cache.as_ref().borrow();
161 let position_id_result = cache.position_id(&order.client_order_id());
162 if let Some(position_id) = position_id_result {
163 return Some(position_id.to_owned());
164 }
165 }
166
167 if generate {
168 self.generate_venue_position_id()
169 } else {
170 panic!(
171 "Position id should be generated. Hedging Oms type order matching engine doesn't exist in cache."
172 )
173 }
174 } else {
175 let cache = self.cache.as_ref().borrow();
177 let positions_open = cache.positions_open(
178 None,
179 Some(&order.instrument_id()),
180 Some(&order.strategy_id()),
181 None,
182 None,
183 );
184 positions_open.first().map(|position| position.id)
185 }
186 }
187
188 pub(crate) const fn skip_trade_id(&mut self) {
190 self.execution_count += 1;
191 }
192
193 pub fn generate_trade_id(&mut self, ts_init: UnixNanos) -> TradeId {
200 self.execution_count += 1;
201
202 let hash = fnv1a_trade_id_hash(self.venue, self.raw_id, ts_init.as_u64());
209 let mut value = [0_u8; TRADE_ID_COUNTER_START + usize::BITS as usize];
210 value[..2].copy_from_slice(b"T-");
211
212 for (index, byte) in value[2..18].iter_mut().enumerate() {
213 let shift = (15 - index) * 4;
214 *byte = HEX_DIGITS[((hash >> shift) & 0x0f) as usize];
215 }
216
217 value[18] = b'-';
218
219 let mut counter = self.execution_count;
220 let mut digit_start = value.len();
221 loop {
222 digit_start -= 1;
223 value[digit_start] = b'0' + (counter % 10) as u8;
224 counter /= 10;
225 if counter == 0 {
226 break;
227 }
228 }
229
230 let digit_count = value.len() - digit_start;
231 let padding = 3_usize.saturating_sub(digit_count);
232 let value_len = TRADE_ID_COUNTER_START + padding + digit_count;
233 value[TRADE_ID_COUNTER_START..TRADE_ID_COUNTER_START + padding].fill(b'0');
234 value.copy_within(digit_start.., TRADE_ID_COUNTER_START + padding);
235
236 let value =
237 std::str::from_utf8(&value[..value_len]).expect("trade ID bytes should be valid ASCII");
238 TradeId::from(value)
239 }
240
241 pub fn generate_venue_position_id(&mut self) -> Option<PositionId> {
245 if !self.use_position_ids {
246 return None;
247 }
248
249 loop {
250 self.position_count += 1;
251
252 let position_id = if self.use_random_ids {
253 PositionId::new(UUID4::new().to_string())
254 } else {
255 PositionId::new(
256 format!("{}-{}-{}", self.venue, self.raw_id, self.position_count).as_str(),
257 )
258 };
259
260 if !self.cache.borrow().position_exists(&position_id) {
261 return Some(position_id);
262 }
263 }
264 }
265
266 fn try_generate_venue_order_id(&mut self) -> anyhow::Result<VenueOrderId> {
267 self.order_count = self
268 .order_count
269 .checked_add(1)
270 .ok_or_else(|| anyhow::anyhow!("Venue order ID counter exhausted"))?;
271
272 if self.use_random_ids {
273 Ok(VenueOrderId::new(UUID4::new().to_string()))
274 } else {
275 Ok(VenueOrderId::new(
276 format!("{}-{}-{}", self.venue, self.raw_id, self.order_count).as_str(),
277 ))
278 }
279 }
280}
281
282fn fnv1a_trade_id_hash(venue: Venue, raw_id: u32, ts_init_ns: u64) -> u64 {
283 let mut hash: u64 = FNV_OFFSET_BASIS;
284
285 for bytes in [
286 venue.as_str().as_bytes(),
287 b"\x1f",
288 &raw_id.to_le_bytes(),
289 b"\x1f",
290 &ts_init_ns.to_le_bytes(),
291 ] {
292 for &byte in bytes {
293 hash ^= u64::from(byte);
294 hash = hash.wrapping_mul(FNV_PRIME);
295 }
296 }
297
298 hash
299}
300
301#[cfg(test)]
302mod tests {
303 use std::{cell::RefCell, rc::Rc};
304
305 use nautilus_common::cache::{Cache, VenueOrderIdOwnershipError};
306 use nautilus_core::{DurationNanos, UnixNanos};
307 use nautilus_model::{
308 enums::{OmsType, OrderSide, OrderType},
309 events::{OrderFilled, order::spec::OrderFilledSpec},
310 identifiers::{
311 AccountId, ClientOrderId, PositionId, StrategyId, TradeId, Venue, VenueOrderId,
312 stubs::account_id,
313 },
314 instruments::{
315 CryptoPerpetual, Instrument, InstrumentAny, stubs::crypto_perpetual_ethusdt,
316 },
317 orders::{Order, OrderAny, OrderTestBuilder, stubs::TestOrderEventStubs},
318 position::Position,
319 types::{Price, Quantity},
320 };
321 use rstest::{fixture, rstest};
322
323 use crate::matching_engine::ids_generator::IdsGenerator;
324
325 #[fixture]
326 fn instrument_eth_usdt(crypto_perpetual_ethusdt: CryptoPerpetual) -> InstrumentAny {
327 InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt)
328 }
329
330 #[fixture]
331 fn market_order_buy(instrument_eth_usdt: InstrumentAny) -> OrderAny {
332 OrderTestBuilder::new(OrderType::Market)
333 .instrument_id(instrument_eth_usdt.id())
334 .side(OrderSide::Buy)
335 .quantity(Quantity::from("1.000"))
336 .client_order_id(ClientOrderId::from("O-19700101-000000-001-001-1"))
337 .submit(true)
338 .build()
339 }
340
341 #[fixture]
342 fn market_order_sell(instrument_eth_usdt: InstrumentAny) -> OrderAny {
343 OrderTestBuilder::new(OrderType::Market)
344 .instrument_id(instrument_eth_usdt.id())
345 .side(OrderSide::Sell)
346 .quantity(Quantity::from("1.000"))
347 .client_order_id(ClientOrderId::from("O-19700101-000000-001-001-2"))
348 .submit(true)
349 .build()
350 }
351
352 #[fixture]
353 fn market_order_fill(
354 instrument_eth_usdt: InstrumentAny,
355 account_id: AccountId,
356 market_order_buy: OrderAny,
357 ) -> OrderFilled {
358 OrderFilledSpec::builder()
359 .trader_id(market_order_buy.trader_id())
360 .strategy_id(market_order_buy.strategy_id())
361 .instrument_id(market_order_buy.instrument_id())
362 .client_order_id(market_order_buy.client_order_id())
363 .venue_order_id(VenueOrderId::new("BINANCE-1"))
364 .account_id(account_id)
365 .last_qty(Quantity::from("1"))
366 .last_px(Price::from("1000.000"))
367 .currency(instrument_eth_usdt.quote_currency())
368 .position_id(PositionId::new("P-1"))
369 .build()
370 }
371
372 fn get_ids_generator(
373 cache: Rc<RefCell<Cache>>,
374 use_position_ids: bool,
375 oms_type: OmsType,
376 ) -> IdsGenerator {
377 IdsGenerator::new(
378 Venue::from("BINANCE"),
379 oms_type,
380 1,
381 false,
382 use_position_ids,
383 cache,
384 )
385 }
386
387 #[rstest]
388 fn test_get_position_id_hedging_with_existing_position(
389 instrument_eth_usdt: InstrumentAny,
390 market_order_buy: OrderAny,
391 market_order_fill: OrderFilled,
392 ) {
393 let cache = Rc::new(RefCell::new(Cache::default()));
394 let mut ids_generator = get_ids_generator(cache.clone(), false, OmsType::Hedging);
395
396 let position = Position::new(&instrument_eth_usdt, market_order_fill);
397
398 cache
400 .borrow_mut()
401 .add_position(&position, OmsType::Hedging)
402 .unwrap();
403
404 let position_id = ids_generator.get_position_id(&market_order_buy, None);
405 assert_eq!(position_id, Some(position.id));
406 }
407
408 #[rstest]
409 fn test_get_position_id_hedging_with_generated_position(market_order_buy: OrderAny) {
410 let cache = Rc::new(RefCell::new(Cache::default()));
411 let mut ids_generator = get_ids_generator(cache, true, OmsType::Hedging);
412
413 let position_id = ids_generator.get_position_id(&market_order_buy, None);
414 assert_eq!(position_id, Some(PositionId::new("BINANCE-1-1")));
415 }
416
417 #[rstest]
418 fn test_get_position_id_probes_past_cached_positions(
419 instrument_eth_usdt: InstrumentAny,
420 market_order_buy: OrderAny,
421 market_order_sell: OrderAny,
422 market_order_fill: OrderFilled,
423 #[values(false, true)] closed: bool,
424 #[values(false, true)] reset: bool,
425 ) {
426 let cache = Rc::new(RefCell::new(Cache::default()));
427 let mut generator = get_ids_generator(cache.clone(), true, OmsType::Hedging);
428
429 if reset {
430 assert_eq!(
431 generator.generate_venue_position_id(),
432 Some(PositionId::from("BINANCE-1-1"))
433 );
434 generator.reset();
435 }
436
437 for suffix in [1, 2, 4] {
438 let mut fill = market_order_fill.clone();
439 fill.position_id = Some(PositionId::from(format!("BINANCE-1-{suffix}")));
440 if suffix != 1 {
441 fill.client_order_id = ClientOrderId::from(format!("RETAINED-{suffix}"));
442 }
443
444 let mut position = Position::new(&instrument_eth_usdt, fill.clone());
445 if closed {
446 fill.order_side = OrderSide::Sell;
447 fill.trade_id = TradeId::from(format!("CLOSE-{suffix}"));
448 position.apply(&fill);
449 }
450
451 assert_eq!(position.is_closed(), closed);
452 cache
453 .borrow_mut()
454 .add_position(&position, OmsType::Hedging)
455 .unwrap();
456 }
457
458 let mapped = generator.get_position_id(&market_order_buy, Some(false));
459 let first = generator.get_position_id(&market_order_sell, None);
460 let next = generator.get_position_id(&market_order_sell, None);
461
462 assert_eq!(mapped, Some(PositionId::from("BINANCE-1-1")));
463 assert_eq!(first, Some(PositionId::from("BINANCE-1-3")));
464 assert_eq!(next, Some(PositionId::from("BINANCE-1-5")));
465 assert_eq!(
466 cache
467 .borrow()
468 .position_id(&market_order_buy.client_order_id()),
469 Some(&PositionId::from("BINANCE-1-1"))
470 );
471 }
472
473 #[rstest]
474 fn test_get_position_id_netting(
475 instrument_eth_usdt: InstrumentAny,
476 market_order_buy: OrderAny,
477 market_order_fill: OrderFilled,
478 ) {
479 let cache = Rc::new(RefCell::new(Cache::default()));
480 let mut ids_generator = get_ids_generator(cache.clone(), false, OmsType::Netting);
481
482 let position_id = ids_generator.get_position_id(&market_order_buy, None);
484 assert_eq!(position_id, None);
485
486 let position = Position::new(&instrument_eth_usdt, market_order_fill);
488 cache
489 .as_ref()
490 .borrow_mut()
491 .add_position(&position, OmsType::Netting)
492 .unwrap();
493
494 let position_id = ids_generator.get_position_id(&market_order_buy, None);
496 assert_eq!(position_id, Some(position.id));
497 }
498
499 #[rstest]
500 fn test_get_position_id_netting_filters_by_strategy(
501 instrument_eth_usdt: InstrumentAny,
502 market_order_fill: OrderFilled,
503 ) {
504 let cache = Rc::new(RefCell::new(Cache::default()));
505 let mut ids_generator = get_ids_generator(cache.clone(), false, OmsType::Netting);
506 let position = Position::new(&instrument_eth_usdt, market_order_fill);
507 cache
508 .as_ref()
509 .borrow_mut()
510 .add_position(&position, OmsType::Netting)
511 .unwrap();
512
513 let order_for_other_strategy = OrderTestBuilder::new(OrderType::Market)
514 .instrument_id(instrument_eth_usdt.id())
515 .strategy_id(StrategyId::from("S-002"))
516 .side(OrderSide::Buy)
517 .quantity(Quantity::from("1.000"))
518 .client_order_id(ClientOrderId::from("O-19700101-000000-001-001-9"))
519 .submit(true)
520 .build();
521
522 let position_id = ids_generator.get_position_id(&order_for_other_strategy, None);
523 assert_eq!(position_id, None);
524 }
525
526 #[rstest]
527 fn test_generate_venue_position_id() {
528 let cache = Rc::new(RefCell::new(Cache::default()));
529 let mut ids_generator_with_position_ids =
530 get_ids_generator(cache.clone(), true, OmsType::Netting);
531 let mut ids_generator_no_position_ids = get_ids_generator(cache, false, OmsType::Netting);
532
533 assert_eq!(
534 ids_generator_no_position_ids.generate_venue_position_id(),
535 None
536 );
537
538 let position_id_1 = ids_generator_with_position_ids.generate_venue_position_id();
539 let position_id_2 = ids_generator_with_position_ids.generate_venue_position_id();
540 assert_eq!(position_id_1, Some(PositionId::new("BINANCE-1-1")));
541 assert_eq!(position_id_2, Some(PositionId::new("BINANCE-1-2")));
542 }
543
544 #[rstest]
545 fn test_generate_venue_position_id_random_uses_uuid4_seam() {
546 let cache = Rc::new(RefCell::new(Cache::default()));
550
551 let mut generator = IdsGenerator::new(
552 Venue::from("BINANCE"),
553 OmsType::Netting,
554 1,
555 true,
556 true,
557 cache,
558 );
559
560 let id = generator.generate_venue_position_id().expect("position id");
561 let s = id.as_str();
562
563 assert_eq!(s.len(), 36, "expected canonical UUID4 length");
564 assert_eq!(s.as_bytes()[14], b'4', "expected UUID v4 version digit");
565 assert!(
566 matches!(s.as_bytes()[19], b'8' | b'9' | b'a' | b'b'),
567 "expected RFC 4122 variant byte",
568 );
569 }
570
571 #[rstest]
572 fn get_venue_position_id(market_order_buy: OrderAny, market_order_sell: OrderAny) {
573 let cache = Rc::new(RefCell::new(Cache::default()));
574 let mut ids_generator = get_ids_generator(cache, true, OmsType::Netting);
575
576 let venue_order_id1 = ids_generator.get_venue_order_id(&market_order_buy).unwrap();
577 let venue_order_id2 = ids_generator
578 .get_venue_order_id(&market_order_sell)
579 .unwrap();
580 assert_eq!(venue_order_id1, VenueOrderId::from("BINANCE-1-1"));
581 assert_eq!(venue_order_id2, VenueOrderId::from("BINANCE-1-2"));
582
583 let venue_order_id3 = ids_generator.get_venue_order_id(&market_order_buy).unwrap();
585 assert_eq!(venue_order_id3, VenueOrderId::from("BINANCE-1-1"));
586 }
587
588 #[rstest]
589 fn test_get_venue_order_id_probes_past_preclaimed_candidates(market_order_buy: OrderAny) {
590 let cache = Rc::new(RefCell::new(Cache::default()));
591 let owner_id = ClientOrderId::from("O-OWNER");
592
593 for suffix in 1..=3 {
594 cache
595 .borrow_mut()
596 .add_venue_order_id(
597 &owner_id,
598 &VenueOrderId::from(format!("BINANCE-1-{suffix}")),
599 true,
600 )
601 .unwrap();
602 }
603
604 let mut ids_generator = get_ids_generator(Rc::clone(&cache), true, OmsType::Netting);
605
606 let venue_order_id = ids_generator.get_venue_order_id(&market_order_buy).unwrap();
607
608 assert_eq!(venue_order_id, VenueOrderId::from("BINANCE-1-4"));
609 assert_eq!(
610 cache
611 .borrow()
612 .venue_order_id(&market_order_buy.client_order_id()),
613 Some(&venue_order_id)
614 );
615
616 for suffix in 1..=3 {
617 assert_eq!(
618 cache
619 .borrow()
620 .client_order_id(&VenueOrderId::from(format!("BINANCE-1-{suffix}"))),
621 Some(&owner_id)
622 );
623 }
624 }
625
626 #[rstest]
627 fn test_get_venue_order_id_fails_when_counter_is_exhausted(market_order_buy: OrderAny) {
628 let cache = Rc::new(RefCell::new(Cache::default()));
629 let mut ids_generator = get_ids_generator(Rc::clone(&cache), true, OmsType::Netting);
630 ids_generator.order_count = usize::MAX;
631
632 let error = ids_generator
633 .get_venue_order_id(&market_order_buy)
634 .unwrap_err();
635
636 assert!(error.to_string().contains("counter exhausted"));
637 assert_eq!(
638 cache
639 .borrow()
640 .venue_order_id(&market_order_buy.client_order_id()),
641 None
642 );
643 }
644
645 #[rstest]
646 fn test_get_venue_order_id_does_not_replace_authoritative_id(mut market_order_buy: OrderAny) {
647 let cache = Rc::new(RefCell::new(Cache::default()));
648 let venue_order_id = VenueOrderId::from("V-AUTHORITATIVE");
649 let owner_id = ClientOrderId::from("O-OWNER");
650 cache
651 .borrow_mut()
652 .add_venue_order_id(&owner_id, &venue_order_id, false)
653 .unwrap();
654 let accepted = TestOrderEventStubs::accepted(
655 &market_order_buy,
656 AccountId::from("ACCOUNT-001"),
657 venue_order_id,
658 );
659 market_order_buy.apply(accepted).unwrap();
660 let mut ids_generator = get_ids_generator(Rc::clone(&cache), true, OmsType::Netting);
661
662 let returned_id = ids_generator.get_venue_order_id(&market_order_buy).unwrap();
663 let error = cache
664 .borrow_mut()
665 .add_venue_order_id(&market_order_buy.client_order_id(), &returned_id, false)
666 .unwrap_err();
667
668 assert_eq!(returned_id, venue_order_id);
669 assert!(error.is::<VenueOrderIdOwnershipError>());
670 assert_eq!(
671 cache.borrow().client_order_id(&venue_order_id),
672 Some(&owner_id)
673 );
674 assert_eq!(
675 cache
676 .borrow()
677 .venue_order_id(&market_order_buy.client_order_id()),
678 None
679 );
680 }
681
682 fn build_ids_generator(venue: Venue, raw_id: u32) -> IdsGenerator {
683 let cache = Rc::new(RefCell::new(Cache::default()));
684 IdsGenerator::new(venue, OmsType::Netting, raw_id, false, true, cache)
685 }
686
687 #[rstest]
688 fn test_generate_trade_id_format_and_length_bound() {
689 let mut generator =
690 build_ids_generator(Venue::from("SOMETHING_VERY_LONG_FOR_SAFETY"), 4_294_967_295);
691 let ts = UnixNanos::from(u64::MAX);
692
693 let trade_id = generator.generate_trade_id(ts);
694 let value = trade_id.as_str();
695
696 assert!(value.len() <= 36);
697 assert!(value.starts_with("T-"));
698 assert_eq!(value.len(), "T-0123456789abcdef-001".len());
699 }
700
701 #[rstest]
702 fn test_generate_trade_id_is_deterministic_across_reset_for_same_ts() {
703 let mut generator = build_ids_generator(Venue::from("BINANCE"), 1);
704 let ts = UnixNanos::from(1_700_000_000_000_000_000_u64);
705
706 let first = generator.generate_trade_id(ts);
707 generator.reset();
708 let second = generator.generate_trade_id(ts);
709 assert_eq!(
710 first, second,
711 "same ts_init and reset execution_count must reproduce the same id"
712 );
713 }
714
715 #[rstest]
716 fn test_generate_trade_id_differs_when_ts_init_changes() {
717 let mut generator = build_ids_generator(Venue::from("BINANCE"), 1);
718 let ts = UnixNanos::from(1_700_000_000_000_000_000_u64);
719
720 let first = generator.generate_trade_id(ts);
721 generator.reset();
722 let second = generator.generate_trade_id(ts + DurationNanos::new(1));
723 assert_ne!(
724 first, second,
725 "distinct ts_init must produce distinct ids across a reset"
726 );
727 }
728
729 #[rstest]
730 #[case(8, "T-5c080ffb681dc0d4-009")]
731 #[case(9, "T-5c080ffb681dc0d4-010")]
732 #[case(98, "T-5c080ffb681dc0d4-099")]
733 #[case(99, "T-5c080ffb681dc0d4-100")]
734 #[case(998, "T-5c080ffb681dc0d4-999")]
735 #[case(999, "T-5c080ffb681dc0d4-1000")]
736 fn test_generate_trade_id_counter_width(
737 #[case] execution_count: usize,
738 #[case] expected: &str,
739 ) {
740 let mut generator = build_ids_generator(Venue::from("BINANCE"), 1);
741 generator.execution_count = execution_count;
742
743 let trade_id = generator.generate_trade_id(UnixNanos::from(1_700_000_000_000_000_000_u64));
744
745 assert_eq!(trade_id.as_str(), expected);
746 }
747
748 #[rstest]
749 fn test_generate_trade_id_preserves_full_lowercase_hash_width() {
750 let mut generator = build_ids_generator(Venue::from("BINANCE"), 0);
751
752 let trade_id = generator.generate_trade_id(UnixNanos::from(3));
753
754 assert_eq!(trade_id.as_str(), "T-0114760c17a06c2c-001");
755 }
756
757 #[cfg(target_pointer_width = "64")]
758 #[rstest]
759 fn test_generate_trade_id_accepts_maximum_length() {
760 let mut generator = build_ids_generator(Venue::from("BINANCE"), 1);
761 generator.execution_count = 99_999_999_999_999_998;
762
763 let trade_id = generator.generate_trade_id(UnixNanos::from(1_700_000_000_000_000_000_u64));
764
765 assert_eq!(trade_id.as_str(), "T-5c080ffb681dc0d4-99999999999999999");
766 assert_eq!(trade_id.as_str().len(), 36);
767 }
768
769 #[cfg(target_pointer_width = "64")]
770 #[rstest]
771 #[case(99_999_999_999_999_999, 37)]
772 #[case(9_999_999_999_999_999_998, 38)]
773 #[case(usize::MAX - 1, 39)]
774 fn test_generate_trade_id_preserves_length_validation(
775 #[case] execution_count: usize,
776 #[case] expected_length: usize,
777 ) {
778 let mut generator = build_ids_generator(Venue::from("BINANCE"), 1);
779 generator.execution_count = execution_count;
780 let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
781 generator.generate_trade_id(UnixNanos::from(1_700_000_000_000_000_000_u64));
782 }))
783 .expect_err("an oversized trade ID should fail validation");
784
785 let message = panic
786 .downcast_ref::<String>()
787 .map(String::as_str)
788 .or_else(|| panic.downcast_ref::<&str>().copied())
789 .expect("trade ID validation should panic with a string message");
790
791 assert!(
792 message.contains(&format!(
793 "String exceeds maximum length of 36 characters, was {expected_length}"
794 )),
795 "unexpected trade ID validation panic: {message}",
796 );
797 assert_eq!(generator.execution_count, execution_count + 1);
798
799 generator.reset();
800 let trade_id = generator.generate_trade_id(UnixNanos::from(1_700_000_000_000_000_000_u64));
801 assert_eq!(trade_id.as_str(), "T-5c080ffb681dc0d4-001");
802 }
803
804 #[rstest]
805 fn test_skip_trade_id_matches_a_generated_trade_id() {
806 let ts_init = UnixNanos::from(1_700_000_000_000_000_000_u64);
807 let mut generated = build_ids_generator(Venue::from("BINANCE"), 1);
808 let mut skipped = build_ids_generator(Venue::from("BINANCE"), 1);
809
810 generated.generate_trade_id(ts_init);
811 skipped.skip_trade_id();
812
813 assert_eq!(
814 skipped.generate_trade_id(ts_init),
815 generated.generate_trade_id(ts_init)
816 );
817 }
818
819 #[rstest]
820 fn test_generate_trade_id_preserves_counter_overflow_semantics() {
821 let expected = std::panic::catch_unwind(|| {
822 let mut counter = std::hint::black_box(usize::MAX);
823 counter += 1;
824 counter
825 });
826
827 let mut generator = build_ids_generator(Venue::from("BINANCE"), 1);
828 generator.execution_count = usize::MAX;
829 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
830 generator.generate_trade_id(UnixNanos::from(1_700_000_000_000_000_000_u64))
831 }));
832
833 match (expected, result) {
834 (Ok(expected_count), Ok(trade_id)) => {
835 assert_eq!(trade_id.as_str(), "T-5c080ffb681dc0d4-000");
836 assert_eq!(generator.execution_count, expected_count);
837 }
838 (Err(_), Err(panic)) => {
839 let message = panic
840 .downcast_ref::<String>()
841 .map(String::as_str)
842 .or_else(|| panic.downcast_ref::<&str>().copied())
843 .expect("counter overflow should panic with a string message");
844
845 assert!(
846 message.contains("attempt to add with overflow"),
847 "unexpected execution counter overflow panic: {message}",
848 );
849 assert_eq!(generator.execution_count, usize::MAX);
850 }
851 (Ok(_), Err(_)) => panic!("execution counter unexpectedly used checked overflow"),
852 (Err(_), Ok(_)) => panic!("execution counter unexpectedly used unchecked overflow"),
853 }
854 }
855
856 #[rstest]
857 fn test_generate_trade_id_differs_when_venue_or_raw_id_changes() {
858 let ts = UnixNanos::from(1_700_000_000_000_000_000_u64);
859
860 let mut gen_a = build_ids_generator(Venue::from("BINANCE"), 1);
861 let mut gen_b = build_ids_generator(Venue::from("BYBIT"), 1);
862 let mut gen_c = build_ids_generator(Venue::from("BINANCE"), 2);
863
864 let a = gen_a.generate_trade_id(ts);
865 let b = gen_b.generate_trade_id(ts);
866 let c = gen_c.generate_trade_id(ts);
867 assert_ne!(a, b, "venue must distinguish ids");
868 assert_ne!(a, c, "raw_id must distinguish ids");
869 }
870
871 #[rstest]
875 #[case::zero("BINANCE", 1_u32, 0_u64, "T-59d6cf33c843f0cc-001")]
876 #[case::nanos(
877 "BINANCE",
878 1_u32,
879 1_700_000_000_000_000_000_u64,
880 "T-5c080ffb681dc0d4-001"
881 )]
882 #[case::long_venue(
883 "SOMETHING_VERY_LONG_FOR_SAFETY",
884 42_u32,
885 1_700_000_000_000_000_000_u64,
886 "T-2a2238c5cc0cbaf2-001"
887 )]
888 fn test_generate_trade_id_matches_python_parity_fixture(
889 #[case] venue: &str,
890 #[case] raw_id: u32,
891 #[case] ts_init: u64,
892 #[case] expected: &str,
893 ) {
894 let mut generator = build_ids_generator(Venue::from(venue), raw_id);
895 let trade_id = generator.generate_trade_id(UnixNanos::from(ts_init));
896 assert_eq!(trade_id.as_str(), expected);
897 }
898
899 #[rstest]
904 fn test_generate_trade_id_multi_tick_matches_python_parity_fixture() {
905 let mut generator = build_ids_generator(Venue::from("BINANCE"), 1);
906 let ts = UnixNanos::from(1_700_000_000_000_000_000_u64);
907 let sequence: Vec<String> = (0..4)
908 .map(|_| generator.generate_trade_id(ts).as_str().to_string())
909 .collect();
910
911 assert_eq!(
912 sequence,
913 vec![
914 "T-5c080ffb681dc0d4-001".to_string(),
915 "T-5c080ffb681dc0d4-002".to_string(),
916 "T-5c080ffb681dc0d4-003".to_string(),
917 "T-5c080ffb681dc0d4-004".to_string(),
918 ],
919 );
920 }
921}