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