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