Skip to main content

nautilus_dydx/execution/
encoder.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//! True bidirectional client order ID encoder for dYdX.
17//!
18//! dYdX chain requires u32 client IDs, but Nautilus uses string-based `ClientOrderId`.
19//! This module provides deterministic encoding that:
20//! - Encodes the full ClientOrderId into (client_id, client_metadata) u32 pair
21//! - Decodes back to the exact original ClientOrderId string
22//! - Works across restarts without persisted state
23//! - Enables reconciliation of orders from previous sessions
24//!
25//! # Encoding Scheme
26//!
27//! For O-format ClientOrderIds (`O-YYYYMMDD-HHMMSS-TTT-SSS-CCC`):
28//! - `client_id` (32 bits): `[trader:10][strategy:10][count:12]` - **unique per order**
29//! - `client_metadata` (32 bits): Seconds since base epoch (2020-01-01 00:00:00 UTC)
30//!
31//! **IMPORTANT**: dYdX uses `client_id` for order identity/deduplication, so the
32//! unique part (trader+strategy+count) must be in `client_id`, not `client_metadata`.
33//!
34//! For numeric ClientOrderIds (e.g., "12345"):
35//! - `client_id`: The parsed u32 value
36//! - `client_metadata`: `DEFAULT_RUST_CLIENT_METADATA` (4) - legacy marker
37//!
38//! For non-standard formats:
39//! - Falls back to sequential allocation with in-memory reverse mapping
40
41use std::sync::atomic::{AtomicU32, Ordering};
42
43use dashmap::{DashMap, DashSet, mapref::entry::Entry};
44use jiff::{Timestamp, tz::Offset};
45use nautilus_model::identifiers::ClientOrderId;
46use thiserror::Error;
47
48/// Base epoch for timestamp encoding: 2020-01-01 00:00:00 UTC.
49/// This gives us ~136 years of range with 32-bit seconds.
50pub const DYDX_BASE_EPOCH: i64 = 1577836800;
51
52/// Value used to identify legacy/numeric client IDs.
53/// When `client_metadata == 4`, the client_id is treated as a literal numeric ID.
54pub const DEFAULT_RUST_CLIENT_METADATA: u32 = 4;
55
56/// Maximum safe client order ID value before warning about overflow.
57/// Leave room for ~1000 additional orders after reaching this threshold.
58pub const MAX_SAFE_CLIENT_ID: u32 = u32::MAX - 1000;
59
60/// Bit positions for client_metadata packing.
61const TRADER_SHIFT: u32 = 22; // Bits [31:22]
62const STRATEGY_SHIFT: u32 = 12; // Bits [21:12]
63const COUNT_MASK: u32 = 0xFFF; // Bits [11:0] = 12 bits
64const TRADER_MASK: u32 = 0x3FF; // 10 bits
65const STRATEGY_MASK: u32 = 0x3FF; // 10 bits
66
67/// Marker value for client_metadata to identify sequential allocation.
68/// Sequential IDs use: client_id = counter (unique), client_metadata = SEQUENTIAL_METADATA_MARKER
69/// This marker (0xFFFFFFFF) won't collide with O-format metadata (timestamps) until year ~2156.
70const SEQUENTIAL_METADATA_MARKER: u32 = u32::MAX;
71
72/// Encoded client order ID pair for dYdX.
73///
74/// dYdX provides two u32 fields that survive the full order lifecycle:
75/// - `client_id`: Primary identifier (timestamp-based for O-format)
76/// - `client_metadata`: Secondary identifier (identity bits for O-format)
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct EncodedClientOrderId {
79    /// Primary client ID for dYdX protocol.
80    pub client_id: u32,
81    /// Metadata field for encoding additional identity information.
82    pub client_metadata: u32,
83}
84
85/// Error type for client order ID encoding operations.
86#[derive(Debug, Clone, Error)]
87pub enum EncoderError {
88    /// The encoder has reached the maximum safe client ID value.
89    #[error(
90        "Client order ID counter overflow: current value {0} exceeds safe limit {MAX_SAFE_CLIENT_ID}"
91    )]
92    CounterOverflow(u32),
93
94    /// Failed to parse the O-format ClientOrderId.
95    #[error("Failed to parse O-format ClientOrderId: {0}")]
96    ParseError(String),
97
98    /// Value overflow in encoding (e.g., trader tag > 1023).
99    #[error("Value overflow in encoding: {0}")]
100    ValueOverflow(String),
101}
102
103/// Manages bidirectional mapping of ClientOrderId ↔ (client_id, client_metadata) for dYdX.
104///
105/// # Encoding Strategy
106///
107/// 1. **Numeric IDs** (e.g., "12345"): Encoded as `(12345, 4)` for backward compatibility
108/// 2. **O-format IDs** (e.g., "O-20260131-174827-001-001-1"): Deterministically encoded
109/// 3. **Other formats**: Sequential allocation with in-memory mapping
110///
111/// # Thread Safety
112///
113/// All operations are thread-safe using `DashMap` and `AtomicU32`.
114#[derive(Debug)]
115pub struct ClientOrderIdEncoder {
116    /// Forward mapping for non-deterministic IDs: ClientOrderId → EncodedClientOrderId
117    forward: DashMap<ClientOrderId, EncodedClientOrderId>,
118    /// Reverse mapping for non-deterministic IDs: (client_id, client_metadata) → ClientOrderId
119    reverse: DashMap<(u32, u32), ClientOrderId>,
120    /// Next ID to allocate for sequential fallback (starts at 1, never 0)
121    next_id: AtomicU32,
122
123    /// Client IDs seen during reconciliation from previous sessions.
124    ///
125    /// Used to detect collisions when a new O-format encoding or sequential
126    /// allocation produces a client_id that was already used by a prior session's
127    /// order. The set is intentionally unbounded: each entry is a `u32` and the
128    /// set only needs to grow as long as those IDs are still live on the venue;
129    /// bounding it would let old IDs silently become reusable and reintroduce
130    /// the venue-UUID collision this guard was added to prevent.
131    known_client_ids: DashSet<u32>,
132}
133
134impl Default for ClientOrderIdEncoder {
135    fn default() -> Self {
136        Self::new()
137    }
138}
139
140impl ClientOrderIdEncoder {
141    /// Creates a new encoder with counter starting at 1.
142    #[must_use]
143    pub fn new() -> Self {
144        Self {
145            forward: DashMap::new(),
146            reverse: DashMap::new(),
147            next_id: AtomicU32::new(1),
148            known_client_ids: DashSet::new(),
149        }
150    }
151
152    /// Registers a client_id observed during order reconciliation.
153    ///
154    /// This prevents the encoder from producing a new order with the same
155    /// client_id, which would generate an identical venue order UUID and
156    /// cause overfill/collision errors.
157    pub fn register_known_client_id(&self, client_id: u32) {
158        self.known_client_ids.insert(client_id);
159    }
160
161    /// Encodes a ClientOrderId to (client_id, client_metadata) pair.
162    ///
163    /// # Encoding Rules
164    ///
165    /// 1. If already mapped in cache, returns existing encoded pair
166    /// 2. If numeric (e.g., "12345"), returns `(12345, DEFAULT_RUST_CLIENT_METADATA)`
167    /// 3. If O-format, deterministically encodes timestamp + identity bits
168    /// 4. Otherwise, allocates sequential ID for fallback
169    ///
170    /// # Errors
171    ///
172    /// Returns `EncoderError::CounterOverflow` if sequential counter exceeds safe limit.
173    /// Returns `EncoderError::ValueOverflow` if O-format values exceed bit limits.
174    pub fn encode(&self, id: ClientOrderId) -> Result<EncodedClientOrderId, EncoderError> {
175        // Fast path: already mapped (for non-deterministic IDs)
176        if let Some(existing) = self.forward.get(&id) {
177            let encoded = *existing.value();
178            return Ok(encoded);
179        }
180
181        let id_str = id.as_str();
182
183        // Try parsing as direct integer (backward compatible)
184        if let Ok(numeric_id) = id_str.parse::<u32>() {
185            let encoded = EncodedClientOrderId {
186                client_id: numeric_id,
187                client_metadata: DEFAULT_RUST_CLIENT_METADATA,
188            };
189            // Cache for reverse lookup
190            self.forward.insert(id, encoded);
191            self.reverse
192                .insert((encoded.client_id, encoded.client_metadata), id);
193            return Ok(encoded);
194        }
195
196        // Try O-format deterministic encoding
197        if id_str.starts_with("O-") {
198            match self.encode_o_format(id_str) {
199                Ok(encoded) => {
200                    // Check if this client_id was used by a previous session's order.
201                    // On restart the counter may reuse a count value, producing the
202                    // same client_id → same venue UUID → overfill corruption.
203                    if self.known_client_ids.contains(&encoded.client_id) {
204                        log::warn!(
205                            "[ENCODER] client_id {} for '{id}' collides with \
206                             reconciled order, falling back to sequential",
207                            encoded.client_id,
208                        );
209                    } else {
210                        // Cache for reverse lookup so decode_if_known can verify
211                        self.reverse
212                            .insert((encoded.client_id, encoded.client_metadata), id);
213                        return Ok(encoded);
214                    }
215                }
216                Err(e) => {
217                    log::warn!(
218                        "[ENCODER] O-format parse failed for '{id}': {e}, falling back to sequential",
219                    );
220                    // Fall through to sequential allocation
221                }
222            }
223        }
224
225        // Fallback: sequential allocation for non-standard formats
226        self.allocate_sequential(id)
227    }
228
229    fn encode_o_format(&self, id_str: &str) -> Result<EncodedClientOrderId, EncoderError> {
230        // Parse: O-YYYYMMDD-HHMMSS-TTT-SSS-CCC
231        let parts: Vec<&str> = id_str.split('-').collect();
232        if parts.len() != 6 || parts[0] != "O" {
233            return Err(EncoderError::ParseError(format!(
234                "Expected O-YYYYMMDD-HHMMSS-TTT-SSS-CCC, received: {id_str}",
235            )));
236        }
237
238        let date_str = parts[1]; // YYYYMMDD
239        let time_str = parts[2]; // HHMMSS
240        let trader_str = parts[3]; // TTT
241        let strategy_str = parts[4]; // SSS
242        let count_str = parts[5]; // CCC
243
244        // Validate lengths
245        if date_str.len() != 8 || time_str.len() != 6 {
246            return Err(EncoderError::ParseError(format!(
247                "Invalid date/time format in: {id_str}"
248            )));
249        }
250
251        // Parse datetime components
252        let year: i32 = date_str[0..4]
253            .parse()
254            .map_err(|_| EncoderError::ParseError(format!("Invalid year in: {id_str}")))?;
255        let month: u32 = date_str[4..6]
256            .parse()
257            .map_err(|_| EncoderError::ParseError(format!("Invalid month in: {id_str}")))?;
258        let day: u32 = date_str[6..8]
259            .parse()
260            .map_err(|_| EncoderError::ParseError(format!("Invalid day in: {id_str}")))?;
261        let hour: u32 = time_str[0..2]
262            .parse()
263            .map_err(|_| EncoderError::ParseError(format!("Invalid hour in: {id_str}")))?;
264        let minute: u32 = time_str[2..4]
265            .parse()
266            .map_err(|_| EncoderError::ParseError(format!("Invalid minute in: {id_str}")))?;
267        let second: u32 = time_str[4..6]
268            .parse()
269            .map_err(|_| EncoderError::ParseError(format!("Invalid second in: {id_str}")))?;
270
271        // Parse identity components
272        let trader: u32 = trader_str
273            .parse()
274            .map_err(|_| EncoderError::ParseError(format!("Invalid trader in: {id_str}")))?;
275        let strategy: u32 = strategy_str
276            .parse()
277            .map_err(|_| EncoderError::ParseError(format!("Invalid strategy in: {id_str}")))?;
278        let count: u32 = count_str
279            .parse()
280            .map_err(|_| EncoderError::ParseError(format!("Invalid count in: {id_str}")))?;
281
282        // Validate ranges
283        if trader > TRADER_MASK {
284            return Err(EncoderError::ValueOverflow(format!(
285                "Trader tag {trader} exceeds max {TRADER_MASK}"
286            )));
287        }
288
289        if strategy > STRATEGY_MASK {
290            return Err(EncoderError::ValueOverflow(format!(
291                "Strategy tag {strategy} exceeds max {STRATEGY_MASK}"
292            )));
293        }
294
295        if count > COUNT_MASK {
296            return Err(EncoderError::ValueOverflow(format!(
297                "Count {count} exceeds max {COUNT_MASK}"
298            )));
299        }
300
301        // Convert to Unix timestamp
302        let dt = format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
303            .parse::<Timestamp>()
304            .map_err(|_| EncoderError::ParseError(format!("Invalid datetime in: {id_str}")))?;
305
306        let timestamp = dt.as_second();
307
308        // Validate timestamp is after base epoch
309        let seconds_since_epoch = timestamp - DYDX_BASE_EPOCH;
310        if seconds_since_epoch < 0 {
311            return Err(EncoderError::ValueOverflow(format!(
312                "Timestamp {timestamp} is before base epoch {DYDX_BASE_EPOCH}"
313            )));
314        }
315
316        // IMPORTANT: dYdX uses client_id for order identity/deduplication.
317        // We put the UNIQUE part (trader+strategy+count) in client_id,
318        // and the timestamp in client_metadata.
319        //
320        // client_id: [trader:10][strategy:10][count:12] - unique per order
321        // client_metadata: timestamp (seconds since epoch)
322        let client_id =
323            (trader << TRADER_SHIFT) | (strategy << STRATEGY_SHIFT) | (count & COUNT_MASK);
324        let client_metadata = seconds_since_epoch as u32;
325
326        Ok(EncodedClientOrderId {
327            client_id,
328            client_metadata,
329        })
330    }
331
332    fn allocate_sequential(&self, id: ClientOrderId) -> Result<EncodedClientOrderId, EncoderError> {
333        // Check for overflow before allocating
334        let current = self.next_id.load(Ordering::Relaxed);
335        if current >= MAX_SAFE_CLIENT_ID {
336            log::error!(
337                "[ENCODER] allocate_sequential() OVERFLOW: counter {current} >= MAX_SAFE {MAX_SAFE_CLIENT_ID}"
338            );
339            return Err(EncoderError::CounterOverflow(current));
340        }
341
342        // Use entry API to handle race conditions
343        match self.forward.entry(id) {
344            Entry::Occupied(entry) => {
345                let encoded = *entry.get();
346                Ok(encoded)
347            }
348            Entry::Vacant(vacant) => {
349                // Allocate a counter value, skipping any that collide with
350                // reconciled orders from previous sessions
351                let mut counter = self.next_id.fetch_add(1, Ordering::Relaxed);
352                while self.known_client_ids.contains(&counter) {
353                    counter = self.next_id.fetch_add(1, Ordering::Relaxed);
354                }
355
356                if counter >= MAX_SAFE_CLIENT_ID {
357                    return Err(EncoderError::CounterOverflow(counter));
358                }
359
360                // Use counter as client_id (unique per order, for dYdX identity)
361                // Use SEQUENTIAL_METADATA_MARKER in client_metadata to identify as sequential
362                let encoded = EncodedClientOrderId {
363                    client_id: counter,
364                    client_metadata: SEQUENTIAL_METADATA_MARKER,
365                };
366                vacant.insert(encoded);
367                self.reverse
368                    .insert((encoded.client_id, encoded.client_metadata), id);
369                Ok(encoded)
370            }
371        }
372    }
373
374    /// Decodes (client_id, client_metadata) back to the original ClientOrderId.
375    ///
376    /// # Decoding Rules
377    ///
378    /// 1. If `client_metadata == DEFAULT_RUST_CLIENT_METADATA (4)`: Return numeric string
379    /// 2. If `client_metadata == SEQUENTIAL_METADATA_MARKER`: Look up in sequential reverse mapping
380    /// 3. Otherwise: Decode as O-format using timestamp + identity bits
381    ///
382    /// Returns `None` if decoding fails (e.g., sequential ID not in cache).
383    #[must_use]
384    pub fn decode(&self, client_id: u32, client_metadata: u32) -> Option<ClientOrderId> {
385        // Legacy numeric IDs
386        if client_metadata == DEFAULT_RUST_CLIENT_METADATA {
387            let id = ClientOrderId::from(client_id.to_string().as_str());
388            return Some(id);
389        }
390
391        // Sequential allocation (identified by metadata marker)
392        if client_metadata == SEQUENTIAL_METADATA_MARKER {
393            let result = self
394                .reverse
395                .get(&(client_id, client_metadata))
396                .map(|r| *r.value());
397            return result;
398        }
399
400        // O-format decoding
401        self.decode_o_format(client_id, client_metadata)
402    }
403
404    /// Decodes deterministic pairs or pairs known to this instance.
405    ///
406    /// Unlike [`Self::decode`], sequential IDs (non-deterministic) require the
407    /// reverse map. Numeric and O-format are deterministic and always decode.
408    #[must_use]
409    pub fn decode_if_known(&self, client_id: u32, client_metadata: u32) -> Option<ClientOrderId> {
410        // Reverse map covers all encoding types for the current session
411        if let Some(entry) = self.reverse.get(&(client_id, client_metadata)) {
412            return Some(*entry.value());
413        }
414
415        // Sequential IDs are non-deterministic, reverse map only
416        if client_metadata == SEQUENTIAL_METADATA_MARKER {
417            return None;
418        }
419
420        // Numeric IDs: deterministic (safe across restarts)
421        if client_metadata == DEFAULT_RUST_CLIENT_METADATA {
422            return Some(ClientOrderId::from(client_id.to_string().as_str()));
423        }
424
425        // O-format: deterministic (safe across restarts)
426        self.decode_o_format(client_id, client_metadata)
427    }
428
429    fn decode_o_format(&self, client_id: u32, client_metadata: u32) -> Option<ClientOrderId> {
430        // Extract identity components from client_id (unique part)
431        let trader = (client_id >> TRADER_SHIFT) & TRADER_MASK;
432        let strategy = (client_id >> STRATEGY_SHIFT) & STRATEGY_MASK;
433        let count = client_id & COUNT_MASK;
434
435        // Convert client_metadata back to timestamp
436        let timestamp = (client_metadata as i64) + DYDX_BASE_EPOCH;
437
438        // Convert to datetime
439        let dt = Offset::UTC.to_datetime(Timestamp::from_second(timestamp).ok()?);
440
441        // Format: O-YYYYMMDD-HHMMSS-TTT-SSS-CCC
442        let id_str = format!(
443            "O-{:04}{:02}{:02}-{:02}{:02}{:02}-{:03}-{:03}-{}",
444            dt.year(),
445            dt.month(),
446            dt.day(),
447            dt.hour(),
448            dt.minute(),
449            dt.second(),
450            trader,
451            strategy,
452            count
453        );
454
455        let id = ClientOrderId::from(id_str.as_str());
456        Some(id)
457    }
458
459    /// Gets the existing encoded pair without allocating a new one.
460    ///
461    /// First checks the forward mapping (for updated/modified orders),
462    /// then falls back to deterministic computation for O-format and numeric IDs.
463    #[must_use]
464    pub fn get(&self, id: &ClientOrderId) -> Option<EncodedClientOrderId> {
465        // Check forward mapping first (handles update_mapping scenarios)
466        if let Some(entry) = self.forward.get(id) {
467            return Some(*entry.value());
468        }
469
470        let id_str = id.as_str();
471
472        // Try parsing as numeric
473        if let Ok(numeric_id) = id_str.parse::<u32>() {
474            return Some(EncodedClientOrderId {
475                client_id: numeric_id,
476                client_metadata: DEFAULT_RUST_CLIENT_METADATA,
477            });
478        }
479
480        // Try O-format encoding
481        if id_str.starts_with("O-")
482            && let Ok(encoded) = self.encode_o_format(id_str)
483        {
484            return Some(encoded);
485        }
486
487        None
488    }
489
490    /// Removes the mapping for a given encoded pair.
491    ///
492    /// Returns the original ClientOrderId if it was mapped.
493    pub fn remove(&self, client_id: u32, client_metadata: u32) -> Option<ClientOrderId> {
494        if let Some((_, client_order_id)) = self.reverse.remove(&(client_id, client_metadata)) {
495            self.forward.remove(&client_order_id);
496            return Some(client_order_id);
497        }
498        None
499    }
500
501    /// Legacy remove method for backward compatibility.
502    /// Removes by client_id only, assumes DEFAULT_RUST_CLIENT_METADATA.
503    pub fn remove_by_client_id(&self, client_id: u32) -> Option<ClientOrderId> {
504        // Try with default metadata first
505        if let result @ Some(_) = self.remove(client_id, DEFAULT_RUST_CLIENT_METADATA) {
506            return result;
507        }
508
509        // Try to find in reverse map with any metadata
510        let key_to_remove = self
511            .reverse
512            .iter()
513            .find(|r| r.key().0 == client_id)
514            .map(|r| *r.key());
515
516        if let Some((cid, meta)) = key_to_remove {
517            return self.remove(cid, meta);
518        }
519
520        None
521    }
522
523    /// Returns the current counter value (for debugging/monitoring).
524    #[must_use]
525    pub fn current_counter(&self) -> u32 {
526        self.next_id.load(Ordering::Relaxed)
527    }
528
529    /// Returns the number of non-deterministic mappings currently stored.
530    #[must_use]
531    pub fn len(&self) -> usize {
532        self.forward.len()
533    }
534
535    /// Returns true if no non-deterministic mappings are stored.
536    #[must_use]
537    pub fn is_empty(&self) -> bool {
538        self.forward.is_empty()
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use rstest::rstest;
545
546    use super::*;
547
548    #[rstest]
549    fn test_encode_numeric_id() {
550        let encoder = ClientOrderIdEncoder::new();
551        let id = ClientOrderId::from("12345");
552
553        let result = encoder.encode(id);
554        assert!(result.is_ok());
555        let encoded = result.unwrap();
556        assert_eq!(encoded.client_id, 12345);
557        assert_eq!(encoded.client_metadata, DEFAULT_RUST_CLIENT_METADATA);
558    }
559
560    #[rstest]
561    fn test_encode_o_format() {
562        let encoder = ClientOrderIdEncoder::new();
563        let id = ClientOrderId::from("O-20260131-174827-001-001-1");
564
565        let result = encoder.encode(id);
566        assert!(result.is_ok());
567        let encoded = result.unwrap();
568
569        // New encoding scheme (swapped for uniqueness):
570        // client_id: [trader:10][strategy:10][count:12] - unique per order
571        // client_metadata: timestamp (seconds since epoch)
572
573        // Verify client_id encoding: trader=1, strategy=1, count=1
574        let expected_client_id = (1 << TRADER_SHIFT) | (1 << STRATEGY_SHIFT) | 1;
575        assert_eq!(encoded.client_id, expected_client_id);
576
577        // Verify timestamp in metadata (seconds since 2020-01-01)
578        // 2026-01-31 17:48:27 UTC
579        let expected_timestamp = "2026-01-31T17:48:27Z"
580            .parse::<Timestamp>()
581            .unwrap()
582            .as_second();
583        let expected_metadata = (expected_timestamp - DYDX_BASE_EPOCH) as u32;
584        assert_eq!(encoded.client_metadata, expected_metadata);
585    }
586
587    #[rstest]
588    fn test_roundtrip_o_format() {
589        let encoder = ClientOrderIdEncoder::new();
590        let id = ClientOrderId::from("O-20260131-174827-001-001-1");
591
592        let encoded = encoder.encode(id).unwrap();
593        let decoded = encoder.decode(encoded.client_id, encoded.client_metadata);
594
595        assert_eq!(decoded, Some(id));
596    }
597
598    #[rstest]
599    fn test_roundtrip_o_format_various() {
600        let encoder = ClientOrderIdEncoder::new();
601        let test_cases = vec![
602            "O-20260131-000000-001-001-1",
603            "O-20260131-235959-999-999-4095",
604            "O-20200101-000000-000-000-0",
605            "O-20251215-123456-123-456-789",
606        ];
607
608        for id_str in test_cases {
609            let id = ClientOrderId::from(id_str);
610            let encoded = encoder.encode(id).unwrap();
611            let decoded = encoder.decode(encoded.client_id, encoded.client_metadata);
612            assert_eq!(decoded, Some(id), "Roundtrip failed for {id_str}");
613        }
614    }
615
616    #[rstest]
617    fn test_roundtrip_numeric() {
618        let encoder = ClientOrderIdEncoder::new();
619        let id = ClientOrderId::from("12345");
620
621        let encoded = encoder.encode(id).unwrap();
622        let decoded = encoder.decode(encoded.client_id, encoded.client_metadata);
623
624        assert_eq!(decoded, Some(id));
625    }
626
627    #[rstest]
628    fn test_encode_non_standard_uses_sequential() {
629        let encoder = ClientOrderIdEncoder::new();
630        let id = ClientOrderId::from("custom-order-id");
631
632        let result = encoder.encode(id);
633        assert!(result.is_ok());
634        let encoded = result.unwrap();
635
636        // Sequential allocation uses SEQUENTIAL_METADATA_MARKER in client_metadata
637        assert_eq!(
638            encoded.client_metadata, SEQUENTIAL_METADATA_MARKER,
639            "Expected client_metadata == SEQUENTIAL_METADATA_MARKER"
640        );
641    }
642
643    #[rstest]
644    fn test_roundtrip_sequential() {
645        let encoder = ClientOrderIdEncoder::new();
646        let id = ClientOrderId::from("custom-order-id");
647
648        let encoded = encoder.encode(id).unwrap();
649        let decoded = encoder.decode(encoded.client_id, encoded.client_metadata);
650
651        assert_eq!(decoded, Some(id));
652    }
653
654    #[rstest]
655    fn test_sequential_lost_after_restart() {
656        // Simulate restart: new encoder without previous mappings
657        let encoder1 = ClientOrderIdEncoder::new();
658        let id = ClientOrderId::from("custom-order-id");
659
660        let encoded = encoder1.encode(id).unwrap();
661
662        // New encoder (simulating restart)
663        let encoder2 = ClientOrderIdEncoder::new();
664        let decoded = encoder2.decode(encoded.client_id, encoded.client_metadata);
665
666        // Sequential mappings are lost after restart
667        assert!(decoded.is_none());
668    }
669
670    #[rstest]
671    fn test_o_format_survives_restart() {
672        let encoder1 = ClientOrderIdEncoder::new();
673        let id = ClientOrderId::from("O-20260131-174827-001-001-1");
674
675        let encoded = encoder1.encode(id).unwrap();
676
677        // New encoder (simulating restart)
678        let encoder2 = ClientOrderIdEncoder::new();
679        let decoded = encoder2.decode(encoded.client_id, encoded.client_metadata);
680
681        // O-format is deterministic - survives restart!
682        assert_eq!(decoded, Some(id));
683    }
684
685    #[rstest]
686    fn test_get_without_encode() {
687        let encoder = ClientOrderIdEncoder::new();
688
689        // Numeric - should work without encode
690        let numeric_id = ClientOrderId::from("12345");
691        let actual = encoder.get(&numeric_id);
692        assert_eq!(
693            actual,
694            Some(EncodedClientOrderId {
695                client_id: 12345,
696                client_metadata: DEFAULT_RUST_CLIENT_METADATA
697            })
698        );
699
700        // O-format - should work without encode
701        let o_id = ClientOrderId::from("O-20260131-174827-001-001-1");
702        let actual = encoder.get(&o_id);
703        assert!(actual.is_some());
704
705        // Non-standard - requires encode first
706        let custom_id = ClientOrderId::from("custom");
707        let actual = encoder.get(&custom_id);
708        assert!(actual.is_none());
709    }
710
711    #[rstest]
712    fn test_remove_sequential() {
713        let encoder = ClientOrderIdEncoder::new();
714        let id = ClientOrderId::from("custom-order-id");
715
716        let encoded = encoder.encode(id).unwrap();
717        assert_eq!(encoder.len(), 1);
718
719        let removed = encoder.remove(encoded.client_id, encoded.client_metadata);
720        assert_eq!(removed, Some(id));
721        assert_eq!(encoder.len(), 0);
722    }
723
724    #[rstest]
725    fn test_max_values_o_format() {
726        let encoder = ClientOrderIdEncoder::new();
727        // Max trader (1023), max strategy (1023), max count (4095)
728        let id = ClientOrderId::from("O-20260131-235959-999-999-4095");
729
730        let result = encoder.encode(id);
731        assert!(result.is_ok());
732
733        let encoded = result.unwrap();
734        let decoded = encoder.decode(encoded.client_id, encoded.client_metadata);
735        assert_eq!(decoded, Some(id));
736    }
737
738    #[rstest]
739    fn test_overflow_trader_tag() {
740        let encoder = ClientOrderIdEncoder::new();
741        // Trader tag 1024 exceeds 10-bit limit (1023)
742        let id = ClientOrderId::from("O-20260131-174827-1024-001-1");
743
744        let result = encoder.encode(id);
745        // Should fall back to sequential, not error
746        assert!(result.is_ok());
747        assert_eq!(
748            result.unwrap().client_metadata,
749            SEQUENTIAL_METADATA_MARKER,
750            "Overflow should fall back to sequential allocation"
751        );
752    }
753
754    #[rstest]
755    fn test_date_before_base_epoch_falls_back_to_sequential() {
756        let encoder = ClientOrderIdEncoder::new();
757        // Date 2019-12-31 is before base epoch (2020-01-01)
758        let id = ClientOrderId::from("O-20191231-235959-001-001-1");
759
760        let result = encoder.encode(id);
761        // Should fall back to sequential allocation, not error or wrap around
762        assert!(result.is_ok());
763        let encoded = result.unwrap();
764        assert_eq!(
765            encoded.client_metadata, SEQUENTIAL_METADATA_MARKER,
766            "Pre-2020 dates should fall back to sequential allocation"
767        );
768
769        // Should still be decodable via sequential lookup
770        let decoded = encoder.decode(encoded.client_id, encoded.client_metadata);
771        assert_eq!(decoded, Some(id));
772    }
773
774    #[rstest]
775    fn test_encode_same_id_returns_same_value() {
776        let encoder = ClientOrderIdEncoder::new();
777        let id = ClientOrderId::from("O-20260131-174827-001-001-1");
778
779        let first = encoder.encode(id).unwrap();
780        let second = encoder.encode(id).unwrap();
781
782        assert_eq!(first, second);
783    }
784
785    #[rstest]
786    fn test_same_second_different_count_has_unique_client_ids() {
787        // This is the critical test: orders submitted in the same second
788        // MUST have different client_ids for dYdX deduplication to work.
789        let encoder = ClientOrderIdEncoder::new();
790
791        // Same timestamp, different counts (like the real error case)
792        let id1 = ClientOrderId::from("O-20260201-084653-001-001-1");
793        let id2 = ClientOrderId::from("O-20260201-084653-001-001-2");
794
795        let encoded1 = encoder.encode(id1).unwrap();
796        let encoded2 = encoder.encode(id2).unwrap();
797
798        // client_ids MUST be different (this was the bug before the fix)
799        assert_ne!(
800            encoded1.client_id, encoded2.client_id,
801            "Orders in the same second must have different client_ids for dYdX"
802        );
803
804        // client_metadata can be the same (timestamp)
805        assert_eq!(encoded1.client_metadata, encoded2.client_metadata);
806
807        // Both should decode correctly
808        assert_eq!(
809            encoder.decode(encoded1.client_id, encoded1.client_metadata),
810            Some(id1)
811        );
812        assert_eq!(
813            encoder.decode(encoded2.client_id, encoded2.client_metadata),
814            Some(id2)
815        );
816    }
817
818    #[rstest]
819    fn test_encode_different_ids_returns_different_values() {
820        let encoder = ClientOrderIdEncoder::new();
821        let id1 = ClientOrderId::from("O-20260131-174827-001-001-1");
822        let id2 = ClientOrderId::from("O-20260131-174828-001-001-2");
823
824        let result1 = encoder.encode(id1).unwrap();
825        let result2 = encoder.encode(id2).unwrap();
826
827        assert_ne!(result1, result2);
828    }
829
830    #[rstest]
831    fn test_current_counter() {
832        let encoder = ClientOrderIdEncoder::new();
833        assert_eq!(encoder.current_counter(), 1);
834
835        encoder.encode(ClientOrderId::from("custom-1")).unwrap();
836        assert_eq!(encoder.current_counter(), 2);
837
838        encoder.encode(ClientOrderId::from("custom-2")).unwrap();
839        assert_eq!(encoder.current_counter(), 3);
840
841        // O-format doesn't increment counter
842        encoder
843            .encode(ClientOrderId::from("O-20260131-174827-001-001-1"))
844            .unwrap();
845        assert_eq!(encoder.current_counter(), 3);
846    }
847
848    #[rstest]
849    fn test_is_empty() {
850        let encoder = ClientOrderIdEncoder::new();
851        assert!(encoder.is_empty());
852
853        encoder.encode(ClientOrderId::from("custom")).unwrap();
854        assert!(!encoder.is_empty());
855    }
856
857    #[rstest]
858    fn test_o_format_collision_falls_back_to_sequential() {
859        let encoder = ClientOrderIdEncoder::new();
860        let id = ClientOrderId::from("O-20260220-031943-001-000-51");
861
862        // Compute the expected O-format client_id: (1 << 22) | (0 << 12) | 51
863        let colliding_client_id = (1 << TRADER_SHIFT) | (0 << STRATEGY_SHIFT) | 51;
864
865        encoder.register_known_client_id(colliding_client_id);
866        let encoded = encoder.encode(id).unwrap();
867        assert_eq!(
868            encoded.client_metadata, SEQUENTIAL_METADATA_MARKER,
869            "Collision should fall back to sequential allocation"
870        );
871        assert_ne!(encoded.client_id, colliding_client_id);
872
873        // The original O-format still round-trips via decode (deterministic)
874        let decoded = encoder.decode_o_format(colliding_client_id, {
875            let dt = "2026-02-20T03:19:43Z"
876                .parse::<Timestamp>()
877                .unwrap()
878                .as_second();
879            (dt - DYDX_BASE_EPOCH) as u32
880        });
881        assert_eq!(decoded, Some(id));
882    }
883
884    #[rstest]
885    fn test_sequential_skips_known_client_ids() {
886        let encoder = ClientOrderIdEncoder::new();
887
888        encoder.register_known_client_id(1);
889        encoder.register_known_client_id(2);
890
891        let encoded = encoder.encode(ClientOrderId::from("custom-order")).unwrap();
892        assert_eq!(encoded.client_id, 3);
893        assert_eq!(encoded.client_metadata, SEQUENTIAL_METADATA_MARKER);
894    }
895
896    #[rstest]
897    fn test_sequential_overflow_after_skipping_known_ids() {
898        let encoder = ClientOrderIdEncoder::new();
899
900        let near_limit = MAX_SAFE_CLIENT_ID - 1;
901        encoder.next_id.store(near_limit, Ordering::Relaxed);
902
903        // Register the near-limit value so the skip loop pushes past the threshold
904        encoder.register_known_client_id(near_limit);
905
906        let result = encoder.encode(ClientOrderId::from("overflow-order"));
907        assert!(
908            matches!(result, Err(EncoderError::CounterOverflow(_))),
909            "Expected CounterOverflow after skipping past MAX_SAFE_CLIENT_ID"
910        );
911    }
912}