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
16use std::{cell::RefCell, fmt::Debug, rc::Rc};
17
18use nautilus_common::cache::Cache;
19use nautilus_core::{UUID4, UnixNanos};
20use nautilus_model::{
21    enums::OmsType,
22    identifiers::{PositionId, TradeId, Venue, VenueOrderId},
23    orders::{Order, OrderAny},
24};
25
26// FNV-1a 64-bit constants (see http://www.isthe.com/chongo/tech/comp/fnv/).
27const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
28const FNV_PRIME: u64 = 0x0100_0000_01b3;
29
30pub struct IdsGenerator {
31    venue: Venue,
32    raw_id: u32,
33    oms_type: OmsType,
34    use_random_ids: bool,
35    use_position_ids: bool,
36    cache: Rc<RefCell<Cache>>,
37    position_count: usize,
38    order_count: usize,
39    execution_count: usize,
40}
41
42impl Debug for IdsGenerator {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.debug_struct(stringify!(IdsGenerator))
45            .field("venue", &self.venue)
46            .field("raw_id", &self.raw_id)
47            .finish()
48    }
49}
50
51impl IdsGenerator {
52    pub const fn new(
53        venue: Venue,
54        oms_type: OmsType,
55        raw_id: u32,
56        use_random_ids: bool,
57        use_position_ids: bool,
58        cache: Rc<RefCell<Cache>>,
59    ) -> Self {
60        Self {
61            venue,
62            raw_id,
63            oms_type,
64            cache,
65            use_random_ids,
66            use_position_ids,
67            position_count: 0,
68            order_count: 0,
69            execution_count: 0,
70        }
71    }
72
73    pub const fn reset(&mut self) {
74        self.position_count = 0;
75        self.order_count = 0;
76        self.execution_count = 0;
77    }
78
79    /// Retrieves or generates a unique venue order ID for the given order.
80    ///
81    /// # Errors
82    ///
83    /// Returns an error if ID generation fails.
84    pub fn get_venue_order_id(&mut self, order: &OrderAny) -> anyhow::Result<VenueOrderId> {
85        // check existing on order
86        if let Some(venue_order_id) = order.venue_order_id() {
87            return Ok(venue_order_id);
88        }
89
90        // check existing in cache
91        if let Some(venue_order_id) = self.cache.borrow().venue_order_id(&order.client_order_id()) {
92            return Ok(venue_order_id.to_owned());
93        }
94
95        let venue_order_id = self.generate_venue_order_id();
96        self.cache.borrow_mut().add_venue_order_id(
97            &order.client_order_id(),
98            &venue_order_id,
99            false,
100        )?;
101        Ok(venue_order_id)
102    }
103
104    /// Retrieves or generates a position ID for the given order.
105    ///
106    /// # Panics
107    ///
108    /// Panics if `generate` is `Some(true)` but no cached position ID is available.
109    pub fn get_position_id(
110        &mut self,
111        order: &OrderAny,
112        generate: Option<bool>,
113    ) -> Option<PositionId> {
114        let generate = generate.unwrap_or(true);
115
116        if self.oms_type == OmsType::Hedging {
117            {
118                let cache = self.cache.as_ref().borrow();
119                let position_id_result = cache.position_id(&order.client_order_id());
120                if let Some(position_id) = position_id_result {
121                    return Some(position_id.to_owned());
122                }
123            }
124
125            if generate {
126                self.generate_venue_position_id()
127            } else {
128                panic!(
129                    "Position id should be generated. Hedging Oms type order matching engine doesn't exist in cache."
130                )
131            }
132        } else {
133            // Netting OMS (position id will be derived from instrument and strategy)
134            let cache = self.cache.as_ref().borrow();
135            let positions_open = cache.positions_open(
136                None,
137                Some(&order.instrument_id()),
138                Some(&order.strategy_id()),
139                None,
140                None,
141            );
142            positions_open.first().map(|position| position.id)
143        }
144    }
145
146    pub fn generate_trade_id(&mut self, ts_init: UnixNanos) -> TradeId {
147        self.execution_count += 1;
148        // Trade IDs are always deterministic; `use_random_ids` only affects
149        // venue order IDs and position IDs. A bounded FNV-1a hash of
150        // `(venue, raw_id, ts_init)` keeps the ID under the 36-character
151        // `TradeId` cap for arbitrary-length venue names; `ts_init` protects
152        // against collisions after `reset()` rewinds `execution_count`, and
153        // the trailing counter distinguishes multiple fills at the same ts.
154        let hash = fnv1a_trade_id_hash(self.venue, self.raw_id, ts_init.as_u64());
155        let trade_id = format!("T-{hash:016x}-{:03}", self.execution_count);
156        TradeId::from(trade_id.as_str())
157    }
158
159    pub fn generate_venue_position_id(&mut self) -> Option<PositionId> {
160        if !self.use_position_ids {
161            return None;
162        }
163
164        self.position_count += 1;
165
166        if self.use_random_ids {
167            Some(PositionId::new(UUID4::new().to_string()))
168        } else {
169            Some(PositionId::new(
170                format!("{}-{}-{}", self.venue, self.raw_id, self.position_count).as_str(),
171            ))
172        }
173    }
174
175    pub fn generate_venue_order_id(&mut self) -> VenueOrderId {
176        self.order_count += 1;
177
178        if self.use_random_ids {
179            VenueOrderId::new(UUID4::new().to_string())
180        } else {
181            VenueOrderId::new(
182                format!("{}-{}-{}", self.venue, self.raw_id, self.order_count).as_str(),
183            )
184        }
185    }
186}
187
188fn fnv1a_trade_id_hash(venue: Venue, raw_id: u32, ts_init_ns: u64) -> u64 {
189    let mut hash: u64 = FNV_OFFSET_BASIS;
190
191    for bytes in [
192        venue.as_str().as_bytes(),
193        b"\x1f",
194        &raw_id.to_le_bytes(),
195        b"\x1f",
196        &ts_init_ns.to_le_bytes(),
197    ] {
198        for &byte in bytes {
199            hash ^= u64::from(byte);
200            hash = hash.wrapping_mul(FNV_PRIME);
201        }
202    }
203    hash
204}
205
206#[cfg(test)]
207mod tests {
208    use std::{cell::RefCell, rc::Rc};
209
210    use nautilus_common::cache::Cache;
211    use nautilus_core::UnixNanos;
212    use nautilus_model::{
213        enums::{OmsType, OrderSide, OrderType},
214        events::{OrderFilled, order::spec::OrderFilledSpec},
215        identifiers::{
216            AccountId, ClientOrderId, PositionId, StrategyId, Venue, VenueOrderId,
217            stubs::account_id,
218        },
219        instruments::{
220            CryptoPerpetual, Instrument, InstrumentAny, stubs::crypto_perpetual_ethusdt,
221        },
222        orders::{Order, OrderAny, OrderTestBuilder},
223        position::Position,
224        types::{Price, Quantity},
225    };
226    use rstest::{fixture, rstest};
227
228    use crate::matching_engine::ids_generator::IdsGenerator;
229
230    #[fixture]
231    fn instrument_eth_usdt(crypto_perpetual_ethusdt: CryptoPerpetual) -> InstrumentAny {
232        InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt)
233    }
234
235    #[fixture]
236    fn market_order_buy(instrument_eth_usdt: InstrumentAny) -> OrderAny {
237        OrderTestBuilder::new(OrderType::Market)
238            .instrument_id(instrument_eth_usdt.id())
239            .side(OrderSide::Buy)
240            .quantity(Quantity::from("1.000"))
241            .client_order_id(ClientOrderId::from("O-19700101-000000-001-001-1"))
242            .submit(true)
243            .build()
244    }
245
246    #[fixture]
247    fn market_order_sell(instrument_eth_usdt: InstrumentAny) -> OrderAny {
248        OrderTestBuilder::new(OrderType::Market)
249            .instrument_id(instrument_eth_usdt.id())
250            .side(OrderSide::Sell)
251            .quantity(Quantity::from("1.000"))
252            .client_order_id(ClientOrderId::from("O-19700101-000000-001-001-2"))
253            .submit(true)
254            .build()
255    }
256
257    #[fixture]
258    fn market_order_fill(
259        instrument_eth_usdt: InstrumentAny,
260        account_id: AccountId,
261        market_order_buy: OrderAny,
262    ) -> OrderFilled {
263        OrderFilledSpec::builder()
264            .trader_id(market_order_buy.trader_id())
265            .strategy_id(market_order_buy.strategy_id())
266            .instrument_id(market_order_buy.instrument_id())
267            .client_order_id(market_order_buy.client_order_id())
268            .venue_order_id(VenueOrderId::new("BINANCE-1"))
269            .account_id(account_id)
270            .last_qty(Quantity::from("1"))
271            .last_px(Price::from("1000.000"))
272            .currency(instrument_eth_usdt.quote_currency())
273            .position_id(PositionId::new("P-1"))
274            .build()
275    }
276
277    fn get_ids_generator(
278        cache: Rc<RefCell<Cache>>,
279        use_position_ids: bool,
280        oms_type: OmsType,
281    ) -> IdsGenerator {
282        IdsGenerator::new(
283            Venue::from("BINANCE"),
284            oms_type,
285            1,
286            false,
287            use_position_ids,
288            cache,
289        )
290    }
291
292    #[rstest]
293    fn test_get_position_id_hedging_with_existing_position(
294        instrument_eth_usdt: InstrumentAny,
295        market_order_buy: OrderAny,
296        market_order_fill: OrderFilled,
297    ) {
298        let cache = Rc::new(RefCell::new(Cache::default()));
299        let mut ids_generator = get_ids_generator(cache.clone(), false, OmsType::Hedging);
300
301        let position = Position::new(&instrument_eth_usdt, market_order_fill);
302
303        // Add position to cache
304        cache
305            .borrow_mut()
306            .add_position(&position, OmsType::Hedging)
307            .unwrap();
308
309        let position_id = ids_generator.get_position_id(&market_order_buy, None);
310        assert_eq!(position_id, Some(position.id));
311    }
312
313    #[rstest]
314    fn test_get_position_id_hedging_with_generated_position(market_order_buy: OrderAny) {
315        let cache = Rc::new(RefCell::new(Cache::default()));
316        let mut ids_generator = get_ids_generator(cache, true, OmsType::Hedging);
317
318        let position_id = ids_generator.get_position_id(&market_order_buy, None);
319        assert_eq!(position_id, Some(PositionId::new("BINANCE-1-1")));
320    }
321
322    #[rstest]
323    fn test_get_position_id_netting(
324        instrument_eth_usdt: InstrumentAny,
325        market_order_buy: OrderAny,
326        market_order_fill: OrderFilled,
327    ) {
328        let cache = Rc::new(RefCell::new(Cache::default()));
329        let mut ids_generator = get_ids_generator(cache.clone(), false, OmsType::Netting);
330
331        // position id should be none in non-initialized position id for this instrument
332        let position_id = ids_generator.get_position_id(&market_order_buy, None);
333        assert_eq!(position_id, None);
334
335        // create and add position in cache
336        let position = Position::new(&instrument_eth_usdt, market_order_fill);
337        cache
338            .as_ref()
339            .borrow_mut()
340            .add_position(&position, OmsType::Netting)
341            .unwrap();
342
343        // position id should be returned for the existing position
344        let position_id = ids_generator.get_position_id(&market_order_buy, None);
345        assert_eq!(position_id, Some(position.id));
346    }
347
348    #[rstest]
349    fn test_get_position_id_netting_filters_by_strategy(
350        instrument_eth_usdt: InstrumentAny,
351        market_order_fill: OrderFilled,
352    ) {
353        let cache = Rc::new(RefCell::new(Cache::default()));
354        let mut ids_generator = get_ids_generator(cache.clone(), false, OmsType::Netting);
355        let position = Position::new(&instrument_eth_usdt, market_order_fill);
356        cache
357            .as_ref()
358            .borrow_mut()
359            .add_position(&position, OmsType::Netting)
360            .unwrap();
361
362        let order_for_other_strategy = OrderTestBuilder::new(OrderType::Market)
363            .instrument_id(instrument_eth_usdt.id())
364            .strategy_id(StrategyId::from("S-002"))
365            .side(OrderSide::Buy)
366            .quantity(Quantity::from("1.000"))
367            .client_order_id(ClientOrderId::from("O-19700101-000000-001-001-9"))
368            .submit(true)
369            .build();
370
371        let position_id = ids_generator.get_position_id(&order_for_other_strategy, None);
372        assert_eq!(position_id, None);
373    }
374
375    #[rstest]
376    fn test_generate_venue_position_id() {
377        let cache = Rc::new(RefCell::new(Cache::default()));
378        let mut ids_generator_with_position_ids =
379            get_ids_generator(cache.clone(), true, OmsType::Netting);
380        let mut ids_generator_no_position_ids = get_ids_generator(cache, false, OmsType::Netting);
381
382        assert_eq!(
383            ids_generator_no_position_ids.generate_venue_position_id(),
384            None
385        );
386
387        let position_id_1 = ids_generator_with_position_ids.generate_venue_position_id();
388        let position_id_2 = ids_generator_with_position_ids.generate_venue_position_id();
389        assert_eq!(position_id_1, Some(PositionId::new("BINANCE-1-1")));
390        assert_eq!(position_id_2, Some(PositionId::new("BINANCE-1-2")));
391    }
392
393    #[rstest]
394    fn test_generate_venue_position_id_random_uses_uuid4_seam() {
395        // Pin that the use_random_ids branch routes through the UUID4 seam
396        // (RFC 4122 v4) rather than a raw uuid::Uuid::new_v4 call. The seam
397        // already swaps to madsim::rand::thread_rng() under cfg(madsim).
398        let cache = Rc::new(RefCell::new(Cache::default()));
399        let mut generator = IdsGenerator::new(
400            Venue::from("BINANCE"),
401            OmsType::Netting,
402            1,
403            true,
404            true,
405            cache,
406        );
407
408        let id = generator.generate_venue_position_id().expect("position id");
409        let s = id.as_str();
410
411        assert_eq!(s.len(), 36, "expected canonical UUID4 length");
412        assert_eq!(s.as_bytes()[14], b'4', "expected UUID v4 version digit");
413        assert!(
414            matches!(s.as_bytes()[19], b'8' | b'9' | b'a' | b'b'),
415            "expected RFC 4122 variant byte",
416        );
417    }
418
419    #[rstest]
420    fn get_venue_position_id(market_order_buy: OrderAny, market_order_sell: OrderAny) {
421        let cache = Rc::new(RefCell::new(Cache::default()));
422        let mut ids_generator = get_ids_generator(cache, true, OmsType::Netting);
423
424        let venue_order_id1 = ids_generator.get_venue_order_id(&market_order_buy).unwrap();
425        let venue_order_id2 = ids_generator
426            .get_venue_order_id(&market_order_sell)
427            .unwrap();
428        assert_eq!(venue_order_id1, VenueOrderId::from("BINANCE-1-1"));
429        assert_eq!(venue_order_id2, VenueOrderId::from("BINANCE-1-2"));
430
431        // check if venue order id is cached again
432        let venue_order_id3 = ids_generator.get_venue_order_id(&market_order_buy).unwrap();
433        assert_eq!(venue_order_id3, VenueOrderId::from("BINANCE-1-1"));
434    }
435
436    fn build_ids_generator(venue: Venue, raw_id: u32) -> IdsGenerator {
437        let cache = Rc::new(RefCell::new(Cache::default()));
438        IdsGenerator::new(venue, OmsType::Netting, raw_id, false, true, cache)
439    }
440
441    #[rstest]
442    fn test_generate_trade_id_format_and_length_bound() {
443        let mut generator =
444            build_ids_generator(Venue::from("SOMETHING_VERY_LONG_FOR_SAFETY"), 4_294_967_295);
445        let ts = UnixNanos::from(u64::MAX);
446
447        let trade_id = generator.generate_trade_id(ts);
448        let value = trade_id.as_str();
449
450        assert!(value.len() <= 36);
451        assert!(value.starts_with("T-"));
452        assert_eq!(value.len(), "T-0123456789abcdef-001".len());
453    }
454
455    #[rstest]
456    fn test_generate_trade_id_is_deterministic_across_reset_for_same_ts() {
457        let mut generator = build_ids_generator(Venue::from("BINANCE"), 1);
458        let ts = UnixNanos::from(1_700_000_000_000_000_000_u64);
459
460        let first = generator.generate_trade_id(ts);
461        generator.reset();
462        let second = generator.generate_trade_id(ts);
463        assert_eq!(
464            first, second,
465            "same ts_init and reset execution_count must reproduce the same id"
466        );
467    }
468
469    #[rstest]
470    fn test_generate_trade_id_differs_when_ts_init_changes() {
471        let mut generator = build_ids_generator(Venue::from("BINANCE"), 1);
472        let ts = UnixNanos::from(1_700_000_000_000_000_000_u64);
473
474        let first = generator.generate_trade_id(ts);
475        generator.reset();
476        let second = generator.generate_trade_id(ts + UnixNanos::from(1));
477        assert_ne!(
478            first, second,
479            "distinct ts_init must produce distinct ids across a reset"
480        );
481    }
482
483    #[rstest]
484    fn test_generate_trade_id_counter_tiebreaker_for_same_ts() {
485        let mut generator = build_ids_generator(Venue::from("BINANCE"), 1);
486        let ts = UnixNanos::from(1_700_000_000_000_000_000_u64);
487
488        let first = generator.generate_trade_id(ts);
489        let second = generator.generate_trade_id(ts);
490        let third = generator.generate_trade_id(ts);
491        assert_ne!(first, second);
492        assert_ne!(second, third);
493        assert!(first.as_str().ends_with("-001"));
494        assert!(second.as_str().ends_with("-002"));
495        assert!(third.as_str().ends_with("-003"));
496    }
497
498    #[rstest]
499    fn test_generate_trade_id_differs_when_venue_or_raw_id_changes() {
500        let ts = UnixNanos::from(1_700_000_000_000_000_000_u64);
501
502        let mut gen_a = build_ids_generator(Venue::from("BINANCE"), 1);
503        let mut gen_b = build_ids_generator(Venue::from("BYBIT"), 1);
504        let mut gen_c = build_ids_generator(Venue::from("BINANCE"), 2);
505
506        let a = gen_a.generate_trade_id(ts);
507        let b = gen_b.generate_trade_id(ts);
508        let c = gen_c.generate_trade_id(ts);
509        assert_ne!(a, b, "venue must distinguish ids");
510        assert_ne!(a, c, "raw_id must distinguish ids");
511    }
512
513    // Parity fixtures: if either Rust or Python changes the hashing scheme,
514    // one of these assertions will fail and flag the drift.
515    // The Python mirror lives at python/tests/unit/backtest/test_trade_id_parity.py
516    #[rstest]
517    #[case::zero("BINANCE", 1_u32, 0_u64, "T-59d6cf33c843f0cc-001")]
518    #[case::nanos(
519        "BINANCE",
520        1_u32,
521        1_700_000_000_000_000_000_u64,
522        "T-5c080ffb681dc0d4-001"
523    )]
524    #[case::long_venue(
525        "SOMETHING_VERY_LONG_FOR_SAFETY",
526        42_u32,
527        1_700_000_000_000_000_000_u64,
528        "T-2a2238c5cc0cbaf2-001"
529    )]
530    fn test_generate_trade_id_matches_python_parity_fixture(
531        #[case] venue: &str,
532        #[case] raw_id: u32,
533        #[case] ts_init: u64,
534        #[case] expected: &str,
535    ) {
536        let mut generator = build_ids_generator(Venue::from(venue), raw_id);
537        let trade_id = generator.generate_trade_id(UnixNanos::from(ts_init));
538        assert_eq!(trade_id.as_str(), expected);
539    }
540
541    // Multi-tick parity: four consecutive bumps at the same ts_init (the
542    // bar O/H/L/C pattern) must produce counters 001..004. Mirrored in
543    // tests/unit_tests/backtest/test_trade_id_parity.py
544    // (test_trade_id_multi_tick_counter_matches_rust_parity_fixture).
545    #[rstest]
546    fn test_generate_trade_id_multi_tick_matches_python_parity_fixture() {
547        let mut generator = build_ids_generator(Venue::from("BINANCE"), 1);
548        let ts = UnixNanos::from(1_700_000_000_000_000_000_u64);
549        let sequence: Vec<String> = (0..4)
550            .map(|_| generator.generate_trade_id(ts).as_str().to_string())
551            .collect();
552
553        assert_eq!(
554            sequence,
555            vec![
556                "T-5c080ffb681dc0d4-001".to_string(),
557                "T-5c080ffb681dc0d4-002".to_string(),
558                "T-5c080ffb681dc0d4-003".to_string(),
559                "T-5c080ffb681dc0d4-004".to_string(),
560            ],
561        );
562    }
563}