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