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