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