Skip to main content

nautilus_kraken/websocket/dispatch/
mod.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//! WebSocket execution dispatch for the Kraken Spot and Futures clients.
17//!
18//! Implements the two-tier execution dispatch contract from
19//! `docs/developer_guide/adapters.md#tracked-and-external-execution-updates`:
20//!
21//! 1. The execution client registers an [`OrderIdentity`] in [`WsDispatchState`]
22//!    when it submits an order.
23//! 2. WebSocket execution messages are routed through the per-product dispatch
24//!    functions in [`futures`] and [`spot`]. For tracked orders the dispatch
25//!    builds typed [`OrderEventAny`] events and emits them directly via
26//!    [`ExecutionEventEmitter::send_order_event`]. For untracked / external
27//!    orders the dispatch falls back to `OrderStatusReport` / `FillReport`
28//!    so the engine can reconcile.
29//!
30//! The dispatch state lives in an `Arc<WsDispatchState>` shared between the
31//! main client thread (which registers identities at submission time) and the
32//! spawned WebSocket consumer task. `DashMap`/`DashSet` provide lock-free
33//! concurrent access.
34
35pub mod futures;
36pub mod spot;
37pub mod spot_orders;
38
39use std::sync::{
40    Arc,
41    atomic::{AtomicBool, Ordering},
42};
43
44use dashmap::{DashMap, DashSet};
45use indexmap::IndexSet;
46use nautilus_core::{AtomicMap, UUID4, UnixNanos};
47use nautilus_live::ExecutionEventEmitter;
48use nautilus_model::{
49    enums::{OrderSide, OrderType},
50    events::{OrderAccepted, OrderEventAny, OrderFilled},
51    identifiers::{
52        AccountId, ClientOrderId, InstrumentId, StrategyId, TradeId, TraderId, VenueOrderId,
53    },
54    reports::FillReport,
55    types::{Currency, Quantity},
56};
57use parking_lot::Mutex;
58use rust_decimal::Decimal;
59
60const DEDUP_CAPACITY: usize = 10_000;
61
62/// Snapshot of the mutable fields seen on a tracked `OpenOrdersDelta`.
63///
64/// Used by the futures delta path to discriminate partial fills (filled
65/// increased), modify acknowledgements (qty / price / trigger_price changed),
66/// and no-op deltas (nothing changed) when a follow-up delta arrives.
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub struct DeltaSnapshot {
69    pub qty: Quantity,
70    pub filled: Quantity,
71    pub limit_price: Option<Decimal>,
72    pub stop_price: Option<Decimal>,
73}
74
75impl DeltaSnapshot {
76    pub(crate) fn new(
77        qty: Quantity,
78        filled: Quantity,
79        limit_price: Option<Decimal>,
80        stop_price: Option<Decimal>,
81    ) -> Self {
82        Self {
83            qty,
84            filled,
85            limit_price,
86            stop_price,
87        }
88    }
89
90    pub(crate) fn non_fill_fields_match(&self, other: &Self) -> bool {
91        self.qty == other.qty
92            && self.limit_price == other.limit_price
93            && self.stop_price == other.stop_price
94    }
95}
96
97/// Identity metadata captured when an order is submitted through this client.
98///
99/// Stored in [`WsDispatchState::order_identities`] keyed by the full Nautilus
100/// [`ClientOrderId`]. The dispatch functions use the identity to build typed
101/// order events for tracked orders without needing access to the engine cache
102/// (which is `!Send` and unreachable from the spawned WebSocket task).
103#[derive(Debug, Clone)]
104pub struct OrderIdentity {
105    /// Strategy that owns the order.
106    pub strategy_id: StrategyId,
107    /// Instrument the order targets.
108    pub instrument_id: InstrumentId,
109    /// Order side captured at submission.
110    pub order_side: OrderSide,
111    /// Order type captured at submission.
112    pub order_type: OrderType,
113    /// Order quantity captured at submission. Used to detect terminal fills.
114    pub quantity: Quantity,
115}
116
117/// Per-client dispatch state shared between order submission and the
118/// WebSocket consumer task.
119///
120/// Tracks which orders were submitted through this client (so we can route
121/// venue events to typed [`OrderEventAny`] emissions for tracked orders, and
122/// fall back to reports for external orders), and provides cross-stream
123/// dedup for `OrderAccepted` and `OrderFilled` emissions.
124#[derive(Debug)]
125pub struct WsDispatchState {
126    /// Tracked orders keyed by full Nautilus [`ClientOrderId`].
127    pub order_identities: DashMap<ClientOrderId, OrderIdentity>,
128    /// Client order IDs for which an `OrderAccepted` event has been emitted.
129    pub emitted_accepted: DashSet<ClientOrderId>,
130    /// Client order IDs that have reached the filled terminal state.
131    pub filled_orders: DashSet<ClientOrderId>,
132    /// Symbol captured from execution frames for a venue order id.
133    ///
134    /// Kraken's spot v2 executions channel sends a `pending_new` frame with
135    /// full order details, then follow-up frames (`new`, `amended`,
136    /// `restated`, `status`) that omit fields which have not changed -
137    /// Kraken's docs show `symbol` omitted on the `new` delta. The dispatch
138    /// needs the symbol to resolve the instrument, so we cache it here from
139    /// any frame that carries it (first writer wins). Keyed by venue
140    /// `order_id` because delta frames often lack `cl_ord_id` as well.
141    ///
142    /// Known limitation: the live spot execution client currently subscribes
143    /// with `snap_orders=false` (see `execution/spot.rs`). Orders that were
144    /// already open at the venue before this process connected therefore do
145    /// not receive an in-session `pending_new`, and if their next delta
146    /// frame omits `symbol` it is dropped at symbol resolution. State for
147    /// such orders is recovered via REST reconciliation
148    /// (`request_order_status_reports`). Enabling `snap_orders=true` would
149    /// allow the executions snapshot to seed the cache for pre-existing
150    /// orders.
151    ///
152    /// Steady-state eviction happens on terminal exec types
153    /// (`Canceled`/`Filled`/`Expired`). Bounded by `DEDUP_CAPACITY` as a
154    /// safety net so missed terminal frames (reconnects, partial replays)
155    /// cannot leak entries indefinitely.
156    pub order_symbol_cache: DashMap<String, String>,
157    /// `ClientOrderId` captured from execution frames for a venue order id.
158    ///
159    /// Kraken's `pending_new` echoes our submitted `cl_ord_id`, but follow-up
160    /// delta frames (`new`, `amended`, `restated`, `status`) routinely omit
161    /// it. Without this mapping the dispatch cannot resolve the tracked
162    /// order from a delta and falls back to the untracked report path,
163    /// which loses the typed `OrderAccepted` event - the symptom behind
164    /// issue #4051.
165    ///
166    /// Populated whenever a frame resolves a `cl_ord_id` (first writer
167    /// wins, keyed by venue `order_id`). Consulted when `exec.cl_ord_id`
168    /// is `None`. Mirrors the `venue_client_map` used by the futures
169    /// dispatch path.
170    ///
171    /// Eviction policy matches `order_symbol_cache`: cleared on terminal
172    /// exec types and bounded by `DEDUP_CAPACITY` as a safety net.
173    pub order_client_id_cache: DashMap<String, ClientOrderId>,
174    /// Last snapshot of qty / filled / price / trigger_price seen on a
175    /// tracked `OpenOrdersDelta`.
176    ///
177    /// The futures delta path uses this map to discriminate partial-fill
178    /// notifications (the new delta carries `filled` greater than the
179    /// previously seen value), modify acknowledgements (a non-fill field
180    /// changed), and pure no-op deltas (nothing changed). It is updated only
181    /// by the delta path so that the fill path's own cumulative is not
182    /// double-counted.
183    pub delta_snapshots: DashMap<ClientOrderId, DeltaSnapshot>,
184    /// Cumulative filled quantity per tracked client order id, populated by
185    /// the fill side of dispatch.
186    ///
187    /// Compared against `OrderIdentity::quantity` to decide when to clean up
188    /// tracked state on a terminal fill.
189    pub order_filled_qty: DashMap<ClientOrderId, Quantity>,
190    /// Trade IDs for which an `OrderFilled` event has been emitted.
191    ///
192    /// Bounded FIFO dedup: when capacity is reached, the oldest entry is
193    /// evicted on the next insert. A simple `clear()` at the threshold would
194    /// drop all recent trade IDs at once, opening a window where a reconnect
195    /// or replay immediately after the rollover could re-emit duplicate
196    /// `OrderFilled` events.
197    pub emitted_trades: Mutex<IndexSet<TradeId>>,
198    clearing: AtomicBool,
199}
200
201impl Default for WsDispatchState {
202    fn default() -> Self {
203        Self {
204            order_identities: DashMap::new(),
205            emitted_accepted: DashSet::default(),
206            filled_orders: DashSet::default(),
207            order_symbol_cache: DashMap::new(),
208            order_client_id_cache: DashMap::new(),
209            delta_snapshots: DashMap::new(),
210            order_filled_qty: DashMap::new(),
211            emitted_trades: Mutex::new(IndexSet::with_capacity(DEDUP_CAPACITY)),
212            clearing: AtomicBool::new(false),
213        }
214    }
215}
216
217impl WsDispatchState {
218    /// Creates a new empty dispatch state.
219    #[must_use]
220    pub fn new() -> Self {
221        Self::default()
222    }
223
224    /// Registers an order identity. Called by the execution client at order
225    /// submission time, before any WebSocket events for the order can arrive.
226    pub fn register_identity(&self, client_order_id: ClientOrderId, identity: OrderIdentity) {
227        self.order_identities.insert(client_order_id, identity);
228    }
229
230    /// Returns a clone of the identity for the given client order id, if any.
231    #[must_use]
232    pub fn lookup_identity(&self, client_order_id: &ClientOrderId) -> Option<OrderIdentity> {
233        self.order_identities
234            .get(client_order_id)
235            .map(|r| r.clone())
236    }
237
238    /// Atomically marks an `OrderAccepted` event as emitted for this order.
239    ///
240    /// Returns `true` when the entry was newly inserted (caller should emit the
241    /// event), and `false` when an entry was already present (caller should
242    /// skip emission). Replaces the racier "contains-then-insert" pattern,
243    /// which allowed concurrent emitters to both observe `false` from
244    /// `contains` and then both insert + emit duplicate `OrderAccepted` events.
245    pub fn insert_accepted(&self, cid: ClientOrderId) -> bool {
246        self.evict_if_full(&self.emitted_accepted);
247        self.emitted_accepted.insert(cid)
248    }
249
250    /// Marks an order as having reached the filled terminal state.
251    pub fn insert_filled(&self, cid: ClientOrderId) {
252        self.evict_if_full(&self.filled_orders);
253        self.filled_orders.insert(cid);
254    }
255
256    /// Caches the symbol for a venue `order_id` if not already present.
257    ///
258    /// Atomic via [`DashMap::entry`] so concurrent callers cannot overwrite an
259    /// existing cached value - first writer wins, all later writers no-op.
260    /// The cheap `contains_key` fast path skips the key allocation when the
261    /// entry already exists; the `or_insert_with` covers the race that opens
262    /// between that check and the insert.
263    pub fn cache_order_symbol(&self, order_id: &str, symbol: &str) {
264        if self.order_symbol_cache.contains_key(order_id) {
265            return;
266        }
267        self.evict_map_if_full(&self.order_symbol_cache);
268        self.order_symbol_cache
269            .entry(order_id.to_string())
270            .or_insert_with(|| symbol.to_string());
271    }
272
273    /// Returns the symbol previously cached for a venue `order_id`, if any.
274    #[must_use]
275    pub fn lookup_order_symbol(&self, order_id: &str) -> Option<String> {
276        self.order_symbol_cache
277            .get(order_id)
278            .map(|r| r.value().clone())
279    }
280
281    /// Removes any cached symbol for a venue `order_id`. Called when the order
282    /// reaches a terminal state on the executions stream.
283    pub fn forget_order_symbol(&self, order_id: &str) {
284        self.order_symbol_cache.remove(order_id);
285    }
286
287    /// Caches the resolved `ClientOrderId` for a venue `order_id` if not
288    /// already present.
289    ///
290    /// Atomic via [`DashMap::entry`] so concurrent callers cannot overwrite an
291    /// existing cached value - first writer wins. The cheap `contains_key`
292    /// fast path skips the key allocation when the entry already exists.
293    pub fn cache_order_client_id(&self, order_id: &str, client_order_id: ClientOrderId) {
294        if self.order_client_id_cache.contains_key(order_id) {
295            return;
296        }
297        self.evict_map_if_full(&self.order_client_id_cache);
298        self.order_client_id_cache
299            .entry(order_id.to_string())
300            .or_insert(client_order_id);
301    }
302
303    /// Returns the `ClientOrderId` previously cached for a venue `order_id`,
304    /// if any.
305    #[must_use]
306    pub fn lookup_order_client_id(&self, order_id: &str) -> Option<ClientOrderId> {
307        self.order_client_id_cache.get(order_id).map(|r| *r)
308    }
309
310    /// Removes any cached `ClientOrderId` for a venue `order_id`. Called when
311    /// the order reaches a terminal state on the executions stream.
312    pub fn forget_order_client_id(&self, order_id: &str) {
313        self.order_client_id_cache.remove(order_id);
314    }
315
316    /// Atomically inserts a trade id into the dedup set.
317    ///
318    /// Returns `true` when the trade was already present (i.e. it is a
319    /// duplicate), `false` otherwise. When the dedup set is at capacity the
320    /// oldest entry is evicted to make room, preserving the `DEDUP_CAPACITY`
321    /// most recently seen trade IDs.
322    pub fn check_and_insert_trade(&self, trade_id: TradeId) -> bool {
323        let mut set = self.emitted_trades.lock();
324
325        if set.contains(&trade_id) {
326            return true;
327        }
328
329        if set.len() >= DEDUP_CAPACITY {
330            set.shift_remove_index(0);
331        }
332
333        set.insert(trade_id);
334        false
335    }
336
337    /// Removes all dispatch state for an order that has reached a terminal state.
338    pub fn cleanup_terminal(&self, client_order_id: &ClientOrderId) {
339        self.order_identities.remove(client_order_id);
340        self.emitted_accepted.remove(client_order_id);
341        self.order_filled_qty.remove(client_order_id);
342        self.delta_snapshots.remove(client_order_id);
343    }
344
345    /// Records cumulative filled quantity for a tracked order. Used by the
346    /// fill side of dispatch only.
347    pub fn record_filled_qty(&self, client_order_id: ClientOrderId, qty: Quantity) {
348        self.order_filled_qty.insert(client_order_id, qty);
349    }
350
351    /// Returns the previously recorded cumulative filled quantity, if any.
352    #[must_use]
353    pub fn previous_filled_qty(&self, client_order_id: &ClientOrderId) -> Option<Quantity> {
354        self.order_filled_qty.get(client_order_id).map(|r| *r)
355    }
356
357    /// Records the latest delta snapshot for a tracked order. Used by the
358    /// delta side of dispatch only.
359    pub fn record_delta_snapshot(&self, client_order_id: ClientOrderId, snapshot: DeltaSnapshot) {
360        self.delta_snapshots.insert(client_order_id, snapshot);
361    }
362
363    /// Returns the previously recorded delta snapshot, if any.
364    #[must_use]
365    pub fn previous_delta_snapshot(
366        &self,
367        client_order_id: &ClientOrderId,
368    ) -> Option<DeltaSnapshot> {
369        self.delta_snapshots.get(client_order_id).map(|r| *r)
370    }
371
372    /// Updates the tracked `quantity` for an order following a successful
373    /// modify acknowledgement, leaving all other identity fields untouched.
374    pub fn update_identity_quantity(&self, client_order_id: &ClientOrderId, quantity: Quantity) {
375        if let Some(mut entry) = self.order_identities.get_mut(client_order_id) {
376            entry.quantity = quantity;
377        }
378    }
379
380    fn evict_if_full(&self, set: &DashSet<ClientOrderId>) {
381        if set.len() >= DEDUP_CAPACITY
382            && self
383                .clearing
384                .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
385                .is_ok()
386        {
387            set.clear();
388            self.clearing.store(false, Ordering::Release);
389        }
390    }
391
392    fn evict_map_if_full<K, V>(&self, map: &DashMap<K, V>)
393    where
394        K: Eq + std::hash::Hash,
395    {
396        if map.len() >= DEDUP_CAPACITY
397            && self
398                .clearing
399                .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
400                .is_ok()
401        {
402            map.clear();
403            self.clearing.store(false, Ordering::Release);
404        }
405    }
406}
407
408/// Resolves a Kraken-truncated client order id to its full Nautilus form.
409///
410/// Kraken truncates non-UUID client order ids to 18 characters; the truncation
411/// map is populated at submission time so the WebSocket consumer can recover
412/// the original id. Falls back to constructing a fresh `ClientOrderId` from
413/// the truncated string when no mapping exists (the order is then treated as
414/// external by downstream lookup).
415pub(crate) fn resolve_client_order_id(
416    truncated: &str,
417    truncated_id_map: &Arc<AtomicMap<String, ClientOrderId>>,
418) -> ClientOrderId {
419    truncated_id_map
420        .load()
421        .get(truncated)
422        .copied()
423        .unwrap_or_else(|| ClientOrderId::new(truncated))
424}
425
426/// Synthesizes and emits an `OrderAccepted` event when one has not yet been
427/// emitted for the given order.
428///
429/// Used before emitting non-Accepted events (Filled, Canceled, Expired,
430/// Updated) so that strategies always observe the canonical
431/// `Submitted -> Accepted -> ...` lifecycle even when the venue compresses
432/// the acceptance and follow-up event into a single message (fast fills).
433#[expect(clippy::too_many_arguments)]
434pub(crate) fn ensure_accepted_emitted(
435    client_order_id: ClientOrderId,
436    venue_order_id: VenueOrderId,
437    account_id: AccountId,
438    identity: &OrderIdentity,
439    state: &WsDispatchState,
440    emitter: &ExecutionEventEmitter,
441    ts_event: UnixNanos,
442    ts_init: UnixNanos,
443) {
444    if !state.insert_accepted(client_order_id) {
445        return;
446    }
447    let accepted = OrderAccepted::new(
448        emitter.trader_id(),
449        identity.strategy_id,
450        identity.instrument_id,
451        client_order_id,
452        venue_order_id,
453        account_id,
454        UUID4::new(),
455        ts_event,
456        ts_init,
457        false,
458    );
459    emitter.send_order_event(OrderEventAny::Accepted(accepted));
460}
461
462/// Builds an [`OrderFilled`] event from a [`FillReport`] and tracked
463/// [`OrderIdentity`].
464pub(crate) fn fill_report_to_order_filled(
465    report: &FillReport,
466    trader_id: TraderId,
467    identity: &OrderIdentity,
468    quote_currency: Currency,
469    client_order_id: ClientOrderId,
470) -> OrderFilled {
471    OrderFilled::new(
472        trader_id,
473        identity.strategy_id,
474        identity.instrument_id,
475        client_order_id,
476        report.venue_order_id,
477        report.account_id,
478        report.trade_id,
479        identity.order_side,
480        identity.order_type,
481        report.last_qty,
482        report.last_px,
483        quote_currency,
484        report.liquidity_side,
485        UUID4::new(),
486        report.ts_event,
487        report.ts_init,
488        false,
489        report.venue_position_id,
490        Some(report.commission),
491        None,
492    )
493}
494
495#[cfg(test)]
496mod tests {
497    use nautilus_model::{
498        enums::{OrderSide, OrderType},
499        identifiers::{ClientOrderId, InstrumentId, StrategyId, TradeId},
500    };
501    use rstest::rstest;
502
503    use super::*;
504
505    fn make_identity() -> OrderIdentity {
506        OrderIdentity {
507            strategy_id: StrategyId::new("EXEC_TESTER-001"),
508            instrument_id: InstrumentId::from("PF_XBTUSD.KRAKEN"),
509            order_side: OrderSide::Buy,
510            order_type: OrderType::Limit,
511            quantity: Quantity::from("0.0001"),
512        }
513    }
514
515    #[rstest]
516    fn test_register_and_lookup_identity() {
517        let state = WsDispatchState::new();
518        let cid = ClientOrderId::new("uuid-1");
519        state.register_identity(cid, make_identity());
520
521        let found = state.lookup_identity(&cid);
522        assert!(found.is_some());
523        let identity = found.unwrap();
524        assert_eq!(identity.strategy_id.as_str(), "EXEC_TESTER-001");
525        assert_eq!(identity.order_side, OrderSide::Buy);
526    }
527
528    #[rstest]
529    fn test_lookup_identity_missing_returns_none() {
530        let state = WsDispatchState::new();
531        let cid = ClientOrderId::new("not-tracked");
532        assert!(state.lookup_identity(&cid).is_none());
533    }
534
535    #[rstest]
536    fn test_insert_accepted_dedup() {
537        let state = WsDispatchState::new();
538        let cid = ClientOrderId::new("uuid-2");
539        assert!(!state.emitted_accepted.contains(&cid));
540        state.insert_accepted(cid);
541        assert!(state.emitted_accepted.contains(&cid));
542        // Second insert is a no-op.
543        state.insert_accepted(cid);
544        assert!(state.emitted_accepted.contains(&cid));
545    }
546
547    #[rstest]
548    fn test_insert_accepted_returns_true_on_first_insert_false_on_repeat() {
549        let state = WsDispatchState::new();
550        let cid = ClientOrderId::new("uuid-atomic");
551
552        assert!(
553            state.insert_accepted(cid),
554            "first insert must report newly inserted",
555        );
556        assert!(
557            !state.insert_accepted(cid),
558            "second insert must report already present (atomic dedup)",
559        );
560    }
561
562    #[rstest]
563    fn test_cache_order_symbol_first_write_wins() {
564        let state = WsDispatchState::new();
565        state.cache_order_symbol("v-order-1", "BTC/USD");
566        state.cache_order_symbol("v-order-1", "ETH/USD");
567
568        assert_eq!(
569            state.lookup_order_symbol("v-order-1").as_deref(),
570            Some("BTC/USD"),
571            "later writes must not overwrite the original cached symbol",
572        );
573    }
574
575    #[rstest]
576    fn test_cache_order_symbol_bounded_by_capacity() {
577        let state = WsDispatchState::new();
578        for i in 0..DEDUP_CAPACITY {
579            state.cache_order_symbol(format!("v-order-{i}").as_str(), "BTC/USD");
580        }
581        // At capacity; the next insert triggers a `clear()` before insertion.
582        state.cache_order_symbol("v-order-overflow", "BTC/USD");
583
584        assert!(
585            state.order_symbol_cache.len() <= DEDUP_CAPACITY,
586            "cache must stay within DEDUP_CAPACITY after overflow",
587        );
588        assert_eq!(
589            state.lookup_order_symbol("v-order-overflow").as_deref(),
590            Some("BTC/USD"),
591            "the overflow entry was inserted after eviction",
592        );
593    }
594
595    #[rstest]
596    fn test_check_and_insert_trade_detects_duplicates() {
597        let state = WsDispatchState::new();
598        let trade = TradeId::new("trade-1");
599        // First insert: not a duplicate.
600        assert!(!state.check_and_insert_trade(trade));
601        // Second insert: duplicate.
602        assert!(state.check_and_insert_trade(trade));
603    }
604
605    #[rstest]
606    fn test_check_and_insert_trade_fifo_eviction_preserves_recent_ids() {
607        // Verifies the dedup window slides rather than collapsing to zero at
608        // the capacity threshold. Overshooting by one entry must evict only
609        // the oldest (`trade-0`), leaving every other ID still deduped.
610        let state = WsDispatchState::new();
611        for i in 0..DEDUP_CAPACITY {
612            let trade = TradeId::new(format!("trade-{i}").as_str());
613            assert!(!state.check_and_insert_trade(trade));
614        }
615        // At capacity; the next insert evicts `trade-0`.
616        let overflow = TradeId::new(format!("trade-{DEDUP_CAPACITY}").as_str());
617        assert!(!state.check_and_insert_trade(overflow));
618
619        // Inspect the dedup set directly to confirm FIFO behaviour without
620        // perturbing state via another `check_and_insert_trade` call.
621        let set = state.emitted_trades.lock();
622        assert_eq!(set.len(), DEDUP_CAPACITY);
623        assert!(
624            !set.contains(&TradeId::new("trade-0")),
625            "oldest entry should have been evicted",
626        );
627        assert!(
628            set.contains(&TradeId::new("trade-1")),
629            "second-oldest remains"
630        );
631        assert!(
632            set.contains(&TradeId::new(
633                format!("trade-{}", DEDUP_CAPACITY - 1).as_str(),
634            )),
635            "most-recent pre-overflow entry remains",
636        );
637        assert!(
638            set.contains(&overflow),
639            "the overflow entry was inserted after eviction",
640        );
641    }
642
643    #[rstest]
644    fn test_cleanup_terminal_removes_state() {
645        let state = WsDispatchState::new();
646        let cid = ClientOrderId::new("uuid-3");
647        state.register_identity(cid, make_identity());
648        state.insert_accepted(cid);
649
650        assert!(state.lookup_identity(&cid).is_some());
651        assert!(state.emitted_accepted.contains(&cid));
652
653        state.cleanup_terminal(&cid);
654
655        assert!(state.lookup_identity(&cid).is_none());
656        assert!(!state.emitted_accepted.contains(&cid));
657    }
658
659    #[rstest]
660    fn test_resolve_client_order_id_via_truncated_map() {
661        let map: Arc<AtomicMap<String, ClientOrderId>> = Arc::new(AtomicMap::new());
662        let full = ClientOrderId::new("full-uuid-12345");
663        map.insert("trunc-id".to_string(), full);
664
665        let resolved = resolve_client_order_id("trunc-id", &map);
666        assert_eq!(resolved, full);
667    }
668
669    #[rstest]
670    fn test_resolve_client_order_id_falls_back_to_input() {
671        let map: Arc<AtomicMap<String, ClientOrderId>> = Arc::new(AtomicMap::new());
672        let resolved = resolve_client_order_id("unknown", &map);
673        assert_eq!(resolved.as_str(), "unknown");
674    }
675}