Skip to main content

nautilus_execution/matching_engine/
ids_generator.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//! Identifier generation for the order matching engine.
17
18use 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
28// FNV-1a 64-bit constants (see http://www.isthe.com/chongo/tech/comp/fnv/).
29const 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
34/// Generates venue order, position, and trade identifiers.
35pub 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    /// Creates a new identifier generator.
58    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    /// Resets all identifier counters.
80    pub const fn reset(&mut self) {
81        self.position_count = 0;
82        self.order_count = 0;
83        self.execution_count = 0;
84    }
85
86    /// Retrieves or generates a unique venue order ID for the given order.
87    ///
88    /// # Errors
89    ///
90    /// Returns an error if ID generation fails.
91    pub fn get_venue_order_id(&mut self, order: &OrderAny) -> anyhow::Result<VenueOrderId> {
92        // Check existing on order
93        if let Some(venue_order_id) = order.venue_order_id() {
94            return Ok(venue_order_id);
95        }
96
97        // Check existing in cache
98        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    /// Retrieves or generates a position ID for the given order.
144    ///
145    /// # Panics
146    ///
147    /// Panics if `generate` is `Some(true)` but no cached position ID is available.
148    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            // Netting OMS (position id will be derived from instrument and strategy)
173            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    /// Generates a deterministic trade ID.
186    ///
187    /// # Panics
188    ///
189    /// Panics if overflow checks are enabled and the execution counter overflows, or if the
190    /// generated trade ID exceeds 36 characters.
191    pub fn generate_trade_id(&mut self, ts_init: UnixNanos) -> TradeId {
192        self.execution_count += 1;
193
194        // Trade IDs are always deterministic; `use_random_ids` only affects
195        // venue order IDs and position IDs. A bounded FNV-1a hash of
196        // `(venue, raw_id, ts_init)` keeps the ID under the 36-character
197        // `TradeId` cap for arbitrary-length venue names; `ts_init` protects
198        // against collisions after `reset()` rewinds `execution_count`, and
199        // the trailing counter distinguishes multiple fills at the same ts.
200        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    /// Generates a venue position ID when position IDs are enabled.
235    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    /// Generates a venue order ID.
252    ///
253    /// # Panics
254    ///
255    /// Panics if the deterministic order counter is exhausted.
256    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        // Add position to cache
393        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        // position id should be none in non-initialized position id for this instrument
421        let position_id = ids_generator.get_position_id(&market_order_buy, None);
422        assert_eq!(position_id, None);
423
424        // create and add position in cache
425        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        // position id should be returned for the existing position
433        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        // Pin that the use_random_ids branch routes through the UUID4 seam
485        // (RFC 4122 v4) rather than a raw uuid::Uuid::new_v4 call. The seam
486        // already swaps to madsim::rand::thread_rng() under cfg(madsim).
487        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        // check if venue order id is cached again
521        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    // Parity fixtures: if either Rust or Python changes the hashing scheme,
808    // one of these assertions will fail and flag the drift.
809    // The Python mirror lives at python/tests/unit/backtest/test_trade_id_parity.py
810    #[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    // Multi-tick parity: four consecutive bumps at the same ts_init (the
836    // bar O/H/L/C pattern) must produce counters 001..004. Mirrored in
837    // tests/unit_tests/backtest/test_trade_id_parity.py
838    // (test_trade_id_multi_tick_counter_matches_rust_parity_fixture).
839    #[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}