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