Skip to main content

nautilus_coinbase/
execution.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//! Live execution client implementation for the Coinbase Advanced Trade adapter.
17
18use std::{
19    collections::VecDeque,
20    future::Future,
21    str::FromStr,
22    sync::{Arc, Mutex},
23    time::{Duration, Instant},
24};
25
26use ahash::AHashMap;
27use anyhow::Context;
28use async_trait::async_trait;
29use nautilus_common::{
30    clients::ExecutionClient,
31    live::{get_runtime, runner::get_exec_event_sender, task::TaskHandles},
32    messages::execution::{
33        BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
34        GenerateFillReportsBuilder, GenerateOrderStatusReport, GenerateOrderStatusReports,
35        GenerateOrderStatusReportsBuilder, GeneratePositionStatusReports,
36        GeneratePositionStatusReportsBuilder, ModifyOrder, QueryAccount, QueryOrder, SubmitOrder,
37    },
38};
39use nautilus_core::{
40    MUTEX_POISONED, UnixNanos,
41    time::{AtomicTime, get_atomic_clock_realtime},
42};
43use nautilus_live::{ExecutionClientCore, ExecutionEventEmitter};
44use nautilus_model::{
45    accounts::AccountAny,
46    enums::{AccountType, LiquiditySide, OmsType, OrderSide, OrderStatus, OrderType, TriggerType},
47    identifiers::{
48        AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, Symbol, TradeId, Venue,
49        VenueOrderId,
50    },
51    instruments::{Instrument, InstrumentAny},
52    orders::Order,
53    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
54    types::{AccountBalance, MarginBalance, Money, Price, Quantity},
55};
56use nautilus_network::retry::RetryConfig;
57use rust_decimal::Decimal;
58use tokio::task::JoinHandle;
59use ustr::Ustr;
60
61use crate::{
62    common::{
63        consts::COINBASE_VENUE,
64        credential::CoinbaseCredential,
65        enums::{CoinbaseProductType, CoinbaseWsChannel},
66    },
67    config::CoinbaseExecClientConfig,
68    http::{
69        client::CoinbaseHttpClient,
70        error::Error as CoinbaseHttpError,
71        parse::{parse_quantity, parse_ws_cfm_account_state},
72    },
73    websocket::{
74        client::CoinbaseWebSocketClient,
75        handler::{NautilusWsMessage, UserOrderUpdate},
76        messages::WsOrderUpdate,
77        parse::parse_ws_user_event_to_fill_report,
78    },
79};
80
81// Coinbase does not publish a formal max for batch_cancel; conservative chunk
82// size mirrors the 100 used by other adapters and keeps request bodies small.
83const BATCH_CANCEL_CHUNK: usize = 100;
84
85// Bounded LRU to drop replayed fills after reconnect. Size follows the
86// pattern used elsewhere; keyed by (venue_order_id, trade_id) as owned strings
87// so the global Ustr arena is not polluted with unique trade IDs.
88const FILL_DEDUP_CAPACITY: usize = 10_000;
89
90// Bounded LRU for per-order cumulative tracking. Terminal events drop entries
91// eagerly; this cap also protects against orders that this client never
92// observes a terminal status for (e.g. cancelled out-of-band).
93const CUMULATIVE_STATE_CAPACITY: usize = 10_000;
94
95// Coinbase spot account is ready as soon as the REST account state lands, but
96// the engine registers it asynchronously; wait up to 30s for that to happen.
97const ACCOUNT_REGISTERED_TIMEOUT_SECS: f64 = 30.0;
98
99#[derive(Debug)]
100struct FillDedup {
101    seen: AHashMap<(String, String), ()>,
102    order: VecDeque<(String, String)>,
103    capacity: usize,
104}
105
106impl FillDedup {
107    fn new(capacity: usize) -> Self {
108        Self {
109            seen: AHashMap::with_capacity(capacity),
110            order: VecDeque::with_capacity(capacity),
111            capacity,
112        }
113    }
114
115    // Returns true if the key is new (and inserts it); false when already seen.
116    fn insert(&mut self, key: (String, String)) -> bool {
117        if self.seen.contains_key(&key) {
118            return false;
119        }
120
121        if self.order.len() >= self.capacity
122            && let Some(oldest) = self.order.pop_front()
123        {
124            self.seen.remove(&oldest);
125        }
126        self.order.push_back(key.clone());
127        self.seen.insert(key, ());
128        true
129    }
130}
131
132// Per-order cumulative state tracked across WS reconnects so that delta-based
133// fill synthesis remains correct even when the feed handler is recreated.
134// `avg_price` is Coinbase's cumulative weighted-average fill price; the exec
135// client derives the per-fill price from the notional delta between successive
136// cumulative states.
137//
138// `quantity` records the largest `cumulative_quantity + leaves_quantity` ever
139// observed for the order. Coinbase zeroes `leaves_quantity` on terminal updates
140// (REJECTED / CANCELLED / EXPIRED), so the OSR's quantity computed from
141// cum+leaves on those events would collapse to filled_qty (or zero). Holding
142// the max-observed total lets us restore the original order quantity before
143// emitting the terminal report.
144#[derive(Debug, Default, Clone)]
145struct OrderCumulativeState {
146    filled_qty: Option<Quantity>,
147    total_fees: Decimal,
148    avg_price: Decimal,
149    quantity: Option<Quantity>,
150}
151
152// Captures the limit / trigger metadata of a submitted order, keyed by
153// `client_order_id` so it survives the venue-id-keyed cumulative state being
154// dropped on terminal user-channel events. Coinbase's user channel does not
155// echo `price`, `stop_price`, or `trigger_type`, so without these locally
156// cached values the engine reconciler would clear the local price the moment
157// a post-fill or cancel update lands.
158#[derive(Debug, Default, Clone)]
159struct OrderContext {
160    price: Option<Price>,
161    trigger_price: Option<Price>,
162    trigger_type: Option<TriggerType>,
163    // `post_only` order fills are guaranteed `Maker` (the venue rejects an
164    // immediate match outright). The Coinbase user channel does not echo
165    // this flag, so we cache it at submit time and pass it through to the
166    // synthesized FillReport's `liquidity_side`.
167    post_only: bool,
168    // The `product_id` the order was submitted with. Coinbase rewrites
169    // aliased products to the canonical id on the user channel, so
170    // `update.product_id` always reads as the canonical (e.g. `BTC-USD`)
171    // even for an order placed on the alias side (`BTC-USDC`). Looking the
172    // submitted id up by `client_order_id` lets us re-key user-channel
173    // echoes back to the caller's id without rewriting *every* canonical
174    // event globally.
175    submitted_product_id: Option<Ustr>,
176}
177
178// Bounded map for per-order cumulative tracking. Insertions track LRU order;
179// when the live entry count reaches `capacity`, the oldest non-stale entry is
180// evicted. Terminal events call `remove()` which clears the map entry; the
181// matching deque slot becomes stale and is reclaimed during the next eviction
182// pass (the deque is also trimmed if it grows beyond `2 * capacity`).
183#[derive(Debug)]
184struct CumulativeStateMap {
185    map: AHashMap<String, OrderCumulativeState>,
186    order: VecDeque<String>,
187    capacity: usize,
188}
189
190impl CumulativeStateMap {
191    fn with_capacity(capacity: usize) -> Self {
192        Self {
193            map: AHashMap::with_capacity(capacity),
194            order: VecDeque::with_capacity(capacity),
195            capacity,
196        }
197    }
198
199    fn entry_or_default(&mut self, key: &str) -> &mut OrderCumulativeState {
200        if self.map.contains_key(key) {
201            // Hit: refresh recency so a long-lived order receiving updates
202            // is not evicted by churn on other orders. O(n) lookup and
203            // shift; tolerated because user-channel update volume is small
204            // relative to capacity
205            if let Some(pos) = self.order.iter().position(|k| k == key) {
206                self.order.remove(pos);
207            }
208            self.order.push_back(key.to_string());
209        } else {
210            self.evict_until_capacity_or_empty();
211            self.order.push_back(key.to_string());
212            self.map
213                .insert(key.to_string(), OrderCumulativeState::default());
214        }
215        self.map
216            .get_mut(key)
217            .expect("key was just inserted or confirmed present")
218    }
219
220    fn remove(&mut self, key: &str) {
221        if self.map.remove(key).is_some() {
222            // Drop the matching deque slot too. Without this, a later
223            // re-insert of the same key would leave a stale slot ahead of
224            // the new live one, and the eviction loop would pop the stale
225            // slot and remove the live entry from the map
226            self.order.retain(|k| k != key);
227        }
228    }
229
230    fn evict_until_capacity_or_empty(&mut self) {
231        // Evict the oldest live entries until we're under capacity. Stale
232        // deque entries (already removed from the map) are skipped naturally
233        // because removing a missing key is a no-op
234        while self.map.len() >= self.capacity {
235            match self.order.pop_front() {
236                Some(oldest) => {
237                    self.map.remove(&oldest);
238                }
239                None => break,
240            }
241        }
242
243        // When the deque accumulates many stale entries (e.g. a long-lived
244        // order at the front while later orders churn through terminal
245        // events), compact in place: keep live entries in their original
246        // order and drop the rest. Bounds memory without ever evicting live
247        // state
248        if self.order.len() > 2 * self.capacity {
249            self.order.retain(|key| self.map.contains_key(key));
250        }
251    }
252
253    #[cfg(test)]
254    fn len(&self) -> usize {
255        self.map.len()
256    }
257
258    #[cfg(test)]
259    fn get(&self, key: &str) -> Option<&OrderCumulativeState> {
260        self.map.get(key)
261    }
262
263    #[cfg(test)]
264    fn clear(&mut self) {
265        self.map.clear();
266        self.order.clear();
267    }
268}
269
270/// Live execution client for Coinbase Advanced Trade.
271#[derive(Debug)]
272pub struct CoinbaseExecutionClient {
273    core: ExecutionClientCore,
274    clock: &'static AtomicTime,
275    config: CoinbaseExecClientConfig,
276    emitter: ExecutionEventEmitter,
277    http_client: CoinbaseHttpClient,
278    ws_user: CoinbaseWebSocketClient,
279    ws_stream_handle: Option<JoinHandle<()>>,
280    pending_tasks: TaskHandles,
281    instruments_cache: Arc<AHashMap<String, InstrumentAny>>,
282    fill_dedup: Arc<Mutex<FillDedup>>,
283    cumulative_state: Arc<Mutex<CumulativeStateMap>>,
284    order_contexts: Arc<Mutex<AHashMap<String, OrderContext>>>,
285    // Caches REST-derived metadata for orders this client did not submit
286    // (keyed by `venue_order_id`). Populated lazily when the user-channel
287    // handler encounters an unknown order whose `OrderStatusReport` would
288    // otherwise lack `price` / `trigger_price` / `trigger_type` and panic
289    // the engine's reconstruction path. Separate from `order_contexts`
290    // because external orders may carry a `client_order_id` we never set.
291    external_order_contexts: Arc<Mutex<AHashMap<String, OrderContext>>>,
292}
293
294impl CoinbaseExecutionClient {
295    /// Creates a new [`CoinbaseExecutionClient`].
296    ///
297    /// # Errors
298    ///
299    /// Returns an error if credentials cannot be resolved or the underlying
300    /// HTTP / WebSocket client cannot be constructed.
301    pub fn new(
302        core: ExecutionClientCore,
303        config: CoinbaseExecClientConfig,
304    ) -> anyhow::Result<Self> {
305        let credential =
306            CoinbaseCredential::resolve(config.api_key.as_deref(), config.api_secret.as_deref())
307                .ok_or_else(|| {
308                    anyhow::anyhow!(
309                        "Coinbase credentials not available; set COINBASE_API_KEY and COINBASE_API_SECRET or pass them in the config"
310                    )
311                })?;
312
313        let retry_config = RetryConfig {
314            max_retries: config.max_retries,
315            initial_delay_ms: config.retry_delay_initial_ms,
316            max_delay_ms: config.retry_delay_max_ms,
317            backoff_factor: 2.0,
318            jitter_ms: 250,
319            operation_timeout_ms: Some(60_000),
320            immediate_first: false,
321            max_elapsed_ms: Some(180_000),
322        };
323
324        let http_client = CoinbaseHttpClient::with_credentials(
325            credential.clone(),
326            config.environment,
327            config.http_timeout_secs,
328            config.proxy_url.clone(),
329            Some(retry_config),
330        )
331        .map_err(|e| anyhow::anyhow!("Failed to create Coinbase HTTP client: {e}"))?;
332
333        if let Some(ref url) = config.base_url_rest {
334            http_client.set_base_url(url.clone());
335        }
336
337        let ws_url = config.ws_url();
338        let ws_user = CoinbaseWebSocketClient::with_credential(
339            &ws_url,
340            credential,
341            config.transport_backend,
342            config.proxy_url.clone(),
343        );
344
345        let clock = get_atomic_clock_realtime();
346        let emitter = ExecutionEventEmitter::new(
347            clock,
348            core.trader_id,
349            core.account_id,
350            core.account_type,
351            None,
352        );
353
354        Ok(Self {
355            core,
356            clock,
357            config,
358            emitter,
359            http_client,
360            ws_user,
361            ws_stream_handle: None,
362            pending_tasks: TaskHandles::default(),
363            instruments_cache: Arc::new(AHashMap::new()),
364            fill_dedup: Arc::new(Mutex::new(FillDedup::new(FILL_DEDUP_CAPACITY))),
365            cumulative_state: Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
366                CUMULATIVE_STATE_CAPACITY,
367            ))),
368            order_contexts: Arc::new(Mutex::new(AHashMap::new())),
369            external_order_contexts: Arc::new(Mutex::new(AHashMap::new())),
370        })
371    }
372
373    fn spawn_task<F>(&self, description: &'static str, fut: F)
374    where
375        F: Future<Output = anyhow::Result<()>> + Send + 'static,
376    {
377        let runtime = get_runtime();
378        let handle = runtime.spawn(async move {
379            if let Err(e) = fut.await {
380                log::warn!("{description} failed: {e:?}");
381            }
382        });
383
384        self.pending_tasks.push(handle);
385    }
386
387    fn abort_pending_tasks(&self) {
388        self.pending_tasks.abort_all();
389    }
390
391    // Returns true when the exec client was created with a Margin account,
392    // indicating it should handle CFM-backed derivatives traffic.
393    fn is_margin(&self) -> bool {
394        self.core.account_type == AccountType::Margin
395    }
396
397    // Returns true when the instrument resides in the connect-time bootstrap
398    // cache. For the Cash (spot) factory this gates spot-only traffic; for the
399    // Margin factory the cache contains CFM perp + future products.
400    fn is_instrument_cached(&self, instrument_id: &InstrumentId) -> bool {
401        self.instruments_cache
402            .contains_key(instrument_id.symbol.as_str())
403    }
404
405    // Polls the cache until the account is registered or the timeout is hit.
406    async fn await_account_registered(&self, timeout_secs: f64) -> anyhow::Result<()> {
407        let account_id = self.core.account_id;
408
409        if self.core.cache().account(&account_id).is_some() {
410            log::info!("Account {account_id} registered");
411            return Ok(());
412        }
413
414        let start = Instant::now();
415        let timeout = Duration::from_secs_f64(timeout_secs);
416        let interval = Duration::from_millis(10);
417
418        loop {
419            tokio::time::sleep(interval).await;
420
421            if self.core.cache().account(&account_id).is_some() {
422                log::info!("Account {account_id} registered");
423                return Ok(());
424            }
425
426            if start.elapsed() >= timeout {
427                anyhow::bail!(
428                    "Timeout waiting for account {account_id} to be registered after {timeout_secs}s"
429                );
430            }
431        }
432    }
433}
434
435// Converts a UnixNanos to a UTC chrono::DateTime; returns an error when the
436// nanosecond value is out of range.
437fn unix_nanos_to_utc(ts: UnixNanos) -> anyhow::Result<chrono::DateTime<chrono::Utc>> {
438    let secs = (ts.as_u64() / 1_000_000_000) as i64;
439    let nanos = (ts.as_u64() % 1_000_000_000) as u32;
440    chrono::DateTime::<chrono::Utc>::from_timestamp(secs, nanos)
441        .ok_or_else(|| anyhow::anyhow!("UnixNanos {ts} is out of range for chrono::DateTime"))
442}
443
444#[async_trait(?Send)]
445impl ExecutionClient for CoinbaseExecutionClient {
446    fn is_connected(&self) -> bool {
447        self.core.is_connected()
448    }
449
450    fn client_id(&self) -> ClientId {
451        self.core.client_id
452    }
453
454    fn account_id(&self) -> AccountId {
455        self.core.account_id
456    }
457
458    fn venue(&self) -> Venue {
459        *COINBASE_VENUE
460    }
461
462    fn oms_type(&self) -> OmsType {
463        self.core.oms_type
464    }
465
466    fn get_account(&self) -> Option<AccountAny> {
467        self.core.cache().account_owned(&self.core.account_id)
468    }
469
470    async fn connect(&mut self) -> anyhow::Result<()> {
471        if self.core.is_connected() {
472            return Ok(());
473        }
474
475        // If the underlying WS is still alive from a prior stop() that did not
476        // explicitly disconnect, tear it down before reconnecting. The
477        // in-handler signal path can race with the Disconnect command, leaving
478        // the inner connection_mode stale even after disconnect().await, so
479        // we rebuild the client outright to guarantee clean cmd_tx/out_rx
480        // pairs and a fresh signal.
481        if self.ws_user.is_active() || self.ws_user.is_reconnecting() {
482            log::debug!("Tearing down stale user WS before reconnect");
483            self.ws_user.disconnect().await;
484            // Abort any prior consumer task; the rebuilt ws_user gets a fresh
485            // out_rx so the previous task is otherwise leaked.
486            if let Some(handle) = self.ws_stream_handle.take() {
487                handle.abort();
488            }
489            let credential = CoinbaseCredential::resolve(
490                self.config.api_key.as_deref(),
491                self.config.api_secret.as_deref(),
492            )
493            .ok_or_else(|| anyhow::anyhow!("Coinbase credentials unavailable for WS reset"))?;
494            self.ws_user = CoinbaseWebSocketClient::with_credential(
495                &self.config.ws_url(),
496                credential,
497                self.config.transport_backend,
498                self.config.proxy_url.clone(),
499            );
500        }
501
502        if self.core.instruments_initialized() {
503            // Instruments were loaded externally; still propagate the cached
504            // set to the WS client on reconnect scenarios.
505            let cached: Vec<InstrumentAny> = self.instruments_cache.values().cloned().collect();
506            if !cached.is_empty() {
507                self.ws_user.initialize_instruments(cached).await;
508            }
509        } else {
510            // The Cash (spot) factory loads only spot products; the Margin
511            // (derivatives) factory loads the futures universe so CFM perps
512            // and dated futures can be reconciled. Mixing the two through a
513            // single client is intentionally unsupported, so each factory
514            // picks one branch.
515            let instruments = if self.is_margin() {
516                self.http_client
517                    .request_instruments(Some(CoinbaseProductType::Future))
518                    .await
519                    .context("failed to load Coinbase futures instruments")?
520            } else {
521                self.http_client
522                    .request_instruments(Some(CoinbaseProductType::Spot))
523                    .await
524                    .context("failed to load Coinbase instruments")?
525            };
526
527            let product_kind = if self.is_margin() { "futures" } else { "spot" };
528
529            if instruments.is_empty() {
530                log::warn!("Coinbase instrument bootstrap returned no {product_kind} instruments");
531            } else {
532                log::debug!(
533                    "Coinbase exec client loaded {} {product_kind} instruments",
534                    instruments.len()
535                );
536            }
537
538            let mut map: AHashMap<String, InstrumentAny> =
539                AHashMap::with_capacity(instruments.len());
540            for inst in &instruments {
541                map.insert(inst.id().symbol.as_str().to_string(), inst.clone());
542            }
543            self.instruments_cache = Arc::new(map);
544
545            // Propagate to the WS client so the feed handler can resolve
546            // user-channel product IDs to cached instruments.
547            self.ws_user.initialize_instruments(instruments).await;
548
549            self.core.set_instruments_initialized();
550        }
551
552        self.ws_user.set_account_id(self.core.account_id).await;
553        self.ws_user.connect().await?;
554
555        // Subscribe to the user channel (product-agnostic). User channel with
556        // an empty product list returns events for all products.
557        self.ws_user
558            .subscribe(CoinbaseWsChannel::User, &[])
559            .await
560            .context("failed to subscribe to Coinbase user channel")?;
561
562        if self.is_margin() {
563            self.ws_user
564                .subscribe(CoinbaseWsChannel::FuturesBalanceSummary, &[])
565                .await
566                .context("failed to subscribe to Coinbase futures_balance_summary channel")?;
567        }
568
569        if let Some(mut rx) = self.ws_user.take_out_rx() {
570            let fill_dedup = Arc::clone(&self.fill_dedup);
571            let cumulative_state = Arc::clone(&self.cumulative_state);
572            let order_contexts = Arc::clone(&self.order_contexts);
573            let external_order_contexts = Arc::clone(&self.external_order_contexts);
574            let emitter = self.emitter.clone();
575            let http_client = self.http_client.clone();
576            let account_id = self.core.account_id;
577            let clock = self.clock;
578            let is_margin = self.is_margin();
579
580            let handle = get_runtime().spawn(async move {
581                while let Some(message) = rx.recv().await {
582                    match message {
583                        NautilusWsMessage::UserOrder(carrier) => {
584                            handle_user_order_update(
585                                *carrier,
586                                &emitter,
587                                &fill_dedup,
588                                &cumulative_state,
589                                &order_contexts,
590                                &external_order_contexts,
591                                &http_client,
592                                account_id,
593                            )
594                            .await;
595                        }
596                        NautilusWsMessage::FuturesBalanceSummary(summary) => {
597                            let ts = clock.get_time_ns();
598                            match parse_ws_cfm_account_state(&summary, account_id, ts, ts) {
599                                Ok(state) => emitter.send_account_state(state),
600                                Err(e) => log::warn!(
601                                    "Failed to parse futures_balance_summary into AccountState: {e}"
602                                ),
603                            }
604                        }
605                        NautilusWsMessage::Reconnected => {
606                            log::info!("Coinbase user WebSocket reconnected");
607                            // Re-fetch account state so any balance change
608                            // during the disconnect window is picked up. The
609                            // margin flavor targets the CFM summary so the
610                            // account type matches the registered Margin
611                            // account.
612                            let refresh = if is_margin {
613                                http_client.request_cfm_account_state(account_id).await
614                            } else {
615                                http_client.request_account_state(account_id).await
616                            };
617
618                            match refresh {
619                                Ok(state) => emitter.send_account_state(state),
620                                Err(e) => {
621                                    log::warn!("Failed to refresh account state on reconnect: {e}");
622                                }
623                            }
624                        }
625                        NautilusWsMessage::Error(err) => {
626                            log::warn!("Coinbase user WebSocket error: {err}");
627                        }
628                        _ => {}
629                    }
630                }
631            });
632            self.ws_stream_handle = Some(handle);
633        }
634
635        let account_state = if self.is_margin() {
636            self.http_client
637                .request_cfm_account_state(self.core.account_id)
638                .await
639                .context("failed to request Coinbase CFM account state")?
640        } else {
641            self.http_client
642                .request_account_state(self.core.account_id)
643                .await
644                .context("failed to request Coinbase account state")?
645        };
646
647        if !account_state.balances.is_empty() {
648            log::debug!(
649                "Received account state with {} balance(s)",
650                account_state.balances.len()
651            );
652        }
653        self.emitter.send_account_state(account_state);
654
655        self.await_account_registered(ACCOUNT_REGISTERED_TIMEOUT_SECS)
656            .await?;
657
658        self.core.set_connected();
659        log::info!("Connected: client_id={}", self.core.client_id);
660        Ok(())
661    }
662
663    async fn disconnect(&mut self) -> anyhow::Result<()> {
664        if self.core.is_disconnected() {
665            return Ok(());
666        }
667
668        self.abort_pending_tasks();
669        self.ws_user.disconnect().await;
670
671        if let Some(handle) = self.ws_stream_handle.take() {
672            handle.abort();
673        }
674
675        self.core.set_disconnected();
676        log::info!("Disconnected: client_id={}", self.core.client_id);
677        Ok(())
678    }
679
680    fn start(&mut self) -> anyhow::Result<()> {
681        if self.core.is_started() {
682            return Ok(());
683        }
684
685        let sender = get_exec_event_sender();
686        self.emitter.set_sender(sender);
687        self.core.set_started();
688
689        log::info!(
690            "Started: client_id={}, account_id={}, account_type={:?}, environment={:?}",
691            self.core.client_id,
692            self.core.account_id,
693            self.core.account_type,
694            self.config.environment,
695        );
696        Ok(())
697    }
698
699    fn stop(&mut self) -> anyhow::Result<()> {
700        if self.core.is_stopped() {
701            return Ok(());
702        }
703
704        self.core.set_stopped();
705        self.core.set_disconnected();
706
707        if let Some(handle) = self.ws_stream_handle.take() {
708            handle.abort();
709        }
710        self.abort_pending_tasks();
711        log::info!("Stopped: client_id={}", self.core.client_id);
712        Ok(())
713    }
714
715    fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
716        let http_client = self.http_client.clone();
717        let account_id = self.core.account_id;
718        let emitter = self.emitter.clone();
719        let is_margin = self.is_margin();
720
721        self.spawn_task("query_account", async move {
722            let account_state = if is_margin {
723                http_client
724                    .request_cfm_account_state(account_id)
725                    .await
726                    .context("failed to request Coinbase CFM account state")?
727            } else {
728                http_client
729                    .request_account_state(account_id)
730                    .await
731                    .context("failed to request Coinbase account state")?
732            };
733            emitter.send_account_state(account_state);
734            Ok(())
735        });
736        Ok(())
737    }
738
739    fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
740        let http_client = self.http_client.clone();
741        let account_id = self.core.account_id;
742        let emitter = self.emitter.clone();
743        let client_order_id = Some(cmd.client_order_id);
744        let venue_order_id = cmd.venue_order_id;
745
746        self.spawn_task("query_order", async move {
747            match http_client
748                .request_order_status_report(account_id, client_order_id, venue_order_id)
749                .await
750            {
751                Ok(report) => emitter.send_order_status_report(report),
752                Err(e) => log::warn!("Failed to query order: {e}"),
753            }
754            Ok(())
755        });
756
757        Ok(())
758    }
759
760    fn generate_account_state(
761        &self,
762        balances: Vec<AccountBalance>,
763        margins: Vec<MarginBalance>,
764        reported: bool,
765        ts_event: UnixNanos,
766    ) -> anyhow::Result<()> {
767        self.emitter
768            .emit_account_state(balances, margins, reported, ts_event);
769        Ok(())
770    }
771
772    async fn generate_order_status_report(
773        &self,
774        cmd: &GenerateOrderStatusReport,
775    ) -> anyhow::Result<Option<OrderStatusReport>> {
776        let report = self
777            .http_client
778            .request_order_status_report(
779                self.core.account_id,
780                cmd.client_order_id,
781                cmd.venue_order_id,
782            )
783            .await
784            .ok();
785
786        // Filter reports to instruments this client bootstrapped. A Cash
787        // client drops derivatives reports (and vice-versa) so mixed activity
788        // on the same venue account does not poison the engine state
789        // associated with either exec client.
790        Ok(report.filter(|r| self.is_instrument_cached(&r.instrument_id)))
791    }
792
793    async fn generate_order_status_reports(
794        &self,
795        cmd: &GenerateOrderStatusReports,
796    ) -> anyhow::Result<Vec<OrderStatusReport>> {
797        let start = cmd.start.map(unix_nanos_to_utc).transpose()?;
798        let end = cmd.end.map(unix_nanos_to_utc).transpose()?;
799
800        let mut reports = self
801            .http_client
802            .request_order_status_reports(
803                self.core.account_id,
804                cmd.instrument_id,
805                cmd.open_only,
806                start,
807                end,
808                None,
809            )
810            .await?;
811
812        let before = reports.len();
813        reports.retain(|r| self.is_instrument_cached(&r.instrument_id));
814        if reports.len() != before {
815            let scope = if self.is_margin() {
816                "non-futures"
817            } else {
818                "non-spot"
819            };
820            log::debug!("Filtered {} {scope} order reports", before - reports.len());
821        }
822        Ok(reports)
823    }
824
825    async fn generate_fill_reports(
826        &self,
827        cmd: GenerateFillReports,
828    ) -> anyhow::Result<Vec<FillReport>> {
829        let start = cmd.start.map(unix_nanos_to_utc).transpose()?;
830        let end = cmd.end.map(unix_nanos_to_utc).transpose()?;
831
832        let mut reports = self
833            .http_client
834            .request_fill_reports(
835                self.core.account_id,
836                cmd.instrument_id,
837                cmd.venue_order_id,
838                start,
839                end,
840                None,
841            )
842            .await?;
843
844        let before = reports.len();
845        reports.retain(|r| self.is_instrument_cached(&r.instrument_id));
846        if reports.len() != before {
847            let scope = if self.is_margin() {
848                "non-futures"
849            } else {
850                "non-spot"
851            };
852            log::debug!("Filtered {} {scope} fill reports", before - reports.len());
853        }
854        Ok(reports)
855    }
856
857    async fn generate_position_status_reports(
858        &self,
859        cmd: &GeneratePositionStatusReports,
860    ) -> anyhow::Result<Vec<PositionStatusReport>> {
861        // Coinbase spot has no positions.
862        if !self.is_margin() {
863            return Ok(Vec::new());
864        }
865
866        // Errors propagate (matching `generate_order_status_reports` /
867        // `generate_fill_reports`) so `generate_mass_status` and the live
868        // manager's reconciliation path see venue failures rather than
869        // receive a silently-empty report set.
870        if let Some(instrument_id) = cmd.instrument_id {
871            let report = self
872                .http_client
873                .request_position_status_report(self.core.account_id, instrument_id)
874                .await
875                .with_context(|| format!("failed to request CFM position for {instrument_id}"))?;
876            Ok(report.map(|r| vec![r]).unwrap_or_default())
877        } else {
878            self.http_client
879                .request_position_status_reports(self.core.account_id)
880                .await
881                .context("failed to request CFM positions")
882        }
883    }
884
885    async fn generate_mass_status(
886        &self,
887        lookback_mins: Option<u64>,
888    ) -> anyhow::Result<Option<ExecutionMassStatus>> {
889        log::info!("Generating ExecutionMassStatus (lookback_mins={lookback_mins:?})");
890
891        let ts_now = self.clock.get_time_ns();
892        let start = lookback_mins.map(|mins| {
893            let lookback_ns = mins * 60 * 1_000_000_000;
894            UnixNanos::from(ts_now.as_u64().saturating_sub(lookback_ns))
895        });
896
897        let order_cmd = GenerateOrderStatusReportsBuilder::default()
898            .ts_init(ts_now)
899            .open_only(false)
900            .start(start)
901            .build()
902            .map_err(|e| anyhow::anyhow!("{e}"))?;
903        let fill_cmd = GenerateFillReportsBuilder::default()
904            .ts_init(ts_now)
905            .start(start)
906            .build()
907            .map_err(|e| anyhow::anyhow!("{e}"))?;
908        let position_cmd = GeneratePositionStatusReportsBuilder::default()
909            .ts_init(ts_now)
910            .build()
911            .map_err(|e| anyhow::anyhow!("{e}"))?;
912
913        let (order_reports, fill_reports, position_reports) = tokio::try_join!(
914            self.generate_order_status_reports(&order_cmd),
915            self.generate_fill_reports(fill_cmd),
916            self.generate_position_status_reports(&position_cmd),
917        )?;
918
919        log::info!("Received {} OrderStatusReports", order_reports.len());
920        log::info!("Received {} FillReports", fill_reports.len());
921        log::info!("Received {} PositionReports", position_reports.len());
922
923        let mut mass_status = ExecutionMassStatus::new(
924            self.core.client_id,
925            self.core.account_id,
926            *COINBASE_VENUE,
927            ts_now,
928            None,
929        );
930
931        mass_status.add_order_reports(order_reports);
932        mass_status.add_fill_reports(fill_reports);
933        mass_status.add_position_reports(position_reports);
934
935        Ok(Some(mass_status))
936    }
937
938    fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
939        let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
940        if order.is_closed() {
941            log::warn!("Cannot submit closed order {}", order.client_order_id());
942            return Ok(());
943        }
944
945        // The connect-time bootstrap caches only the product family this
946        // client was configured for (Cash -> spot, Margin -> futures). An
947        // instrument outside that family is either not loaded yet or lives on
948        // the other venue scope, so deny instead of forwarding to the venue
949        // where the account type cannot reconcile the order's state.
950        let instrument_id = order.instrument_id();
951        let symbol_key = instrument_id.symbol.as_str();
952        if !self.instruments_cache.contains_key(symbol_key) {
953            let scope = if self.is_margin() {
954                "a Coinbase futures / perpetual product"
955            } else {
956                "a Coinbase spot product"
957            };
958            self.emitter.emit_order_denied(
959                &order,
960                &format!(
961                    "Instrument {} is not {scope} in this client's bootstrap cache",
962                    order.instrument_id()
963                ),
964            );
965            return Ok(());
966        }
967
968        // The user channel does not need a product-wide alias registration:
969        // `order_contexts` (keyed by `client_order_id`) records the
970        // submitted `product_id` and `handle_user_order_update` rewrites the
971        // report's instrument id from there. A product-wide map would
972        // misroute external or canonical-side orders that share the same
973        // wire `product_id`.
974
975        log::debug!("OrderSubmitted client_order_id={}", order.client_order_id());
976        self.emitter.emit_order_submitted(&order);
977
978        let http_client = self.http_client.clone();
979        let emitter = self.emitter.clone();
980        let order_contexts = Arc::clone(&self.order_contexts);
981        let clock = self.clock;
982        let strategy_id = order.strategy_id();
983        let client_order_id = order.client_order_id();
984        let side = order.order_side();
985        let order_type = order.order_type();
986        let quantity = order.quantity();
987        let time_in_force = order.time_in_force();
988        let price = order.price();
989        let trigger_price = order.trigger_price();
990        let trigger_type = order.trigger_type();
991        let expire_time = order.expire_time();
992        let post_only = order.is_post_only();
993        let is_quote_quantity = order.is_quote_quantity();
994        let reduce_only = order.is_reduce_only();
995
996        // Cache limit/trigger metadata under `client_order_id` synchronously
997        // before the spawn so user-channel updates that race the REST submit
998        // response can still patch their reports. Coinbase's user channel does
999        // not echo `price`, `stop_price`, `trigger_type`, or whether the order
1000        // is `post_only`, so without this the engine reconciler would clear
1001        // the local price and synthesized fills would lack `LiquiditySide`.
1002        {
1003            let mut map = self.order_contexts.lock().expect(MUTEX_POISONED);
1004            map.insert(
1005                client_order_id.to_string(),
1006                OrderContext {
1007                    price,
1008                    trigger_price,
1009                    trigger_type,
1010                    post_only,
1011                    submitted_product_id: Some(instrument_id.symbol.inner()),
1012                },
1013            );
1014        }
1015        let (leverage, margin_type) = if self.core.account_type == AccountType::Margin {
1016            (
1017                self.config.default_leverage,
1018                self.config.default_margin_type,
1019            )
1020        } else {
1021            (None, None)
1022        };
1023        let retail_portfolio_id = self.config.retail_portfolio_id.clone();
1024
1025        self.spawn_task("submit_order", async move {
1026            let result = http_client
1027                .submit_order(
1028                    client_order_id,
1029                    instrument_id,
1030                    side,
1031                    order_type,
1032                    quantity,
1033                    time_in_force,
1034                    price,
1035                    trigger_price,
1036                    expire_time,
1037                    post_only,
1038                    is_quote_quantity,
1039                    leverage,
1040                    margin_type,
1041                    reduce_only,
1042                    retail_portfolio_id,
1043                )
1044                .await;
1045
1046            match result {
1047                Ok(response) => {
1048                    if response.success {
1049                        let venue_id = response
1050                            .success_response
1051                            .as_ref()
1052                            .map(|s| s.order_id.clone())
1053                            .unwrap_or(response.order_id);
1054
1055                        if venue_id.is_empty() {
1056                            log::warn!(
1057                                "Submit succeeded but no order_id returned for {client_order_id}"
1058                            );
1059                        } else {
1060                            let venue_order_id = VenueOrderId::new(&venue_id);
1061                            let ts_event = clock.get_time_ns();
1062                            emitter.emit_order_accepted(&order, venue_order_id, ts_event);
1063                        }
1064                    } else {
1065                        let reason = response.error_response.as_ref().map_or_else(
1066                            || response.failure_reason.clone(),
1067                            |e| format!("{}: {}", e.error, e.message),
1068                        );
1069                        // `INVALID_LIMIT_PRICE_POST_ONLY` is Coinbase's reject
1070                        // code when a `post_only` order would have crossed
1071                        // the spread by the time it reached the matching
1072                        // engine. Mark the rejection so strategies can react
1073                        // (typically: re-quote at the new TOB).
1074                        let due_post_only = reason.contains("INVALID_LIMIT_PRICE_POST_ONLY")
1075                            || response.error_response.as_ref().is_some_and(|e| {
1076                                e.preview_failure_reason == "PREVIEW_INVALID_LIMIT_PRICE_POSTONLY"
1077                                    || e.new_order_failure_reason == "INVALID_LIMIT_PRICE_POST_ONLY"
1078                            });
1079                        // Order never made it to the venue: drop the cached
1080                        // metadata so the map does not grow unbounded with
1081                        // dead entries.
1082                        order_contexts
1083                            .lock()
1084                            .expect(MUTEX_POISONED)
1085                            .remove(client_order_id.as_str());
1086                        let ts_event = clock.get_time_ns();
1087                        emitter.emit_order_rejected_event(
1088                            strategy_id,
1089                            instrument_id,
1090                            client_order_id,
1091                            &format!("submit-order-rejected: {reason}"),
1092                            ts_event,
1093                            due_post_only,
1094                        );
1095                    }
1096                }
1097                Err(e) => {
1098                    if is_coinbase_local_submit_failure(&e) {
1099                        order_contexts
1100                            .lock()
1101                            .expect(MUTEX_POISONED)
1102                            .remove(client_order_id.as_str());
1103                        let ts_event = clock.get_time_ns();
1104                        emitter.emit_order_rejected_event(
1105                            strategy_id,
1106                            instrument_id,
1107                            client_order_id,
1108                            &format!("submit-order-error: {e}"),
1109                            ts_event,
1110                            false,
1111                        );
1112                    } else if is_coinbase_explicit_submit_rejection(&e) {
1113                        order_contexts
1114                            .lock()
1115                            .expect(MUTEX_POISONED)
1116                            .remove(client_order_id.as_str());
1117                        let ts_event = clock.get_time_ns();
1118                        emitter.emit_order_rejected_event(
1119                            strategy_id,
1120                            instrument_id,
1121                            client_order_id,
1122                            &format!("submit-order-rejected: {e}"),
1123                            ts_event,
1124                            false,
1125                        );
1126                    } else if is_coinbase_ambiguous_command_failure(&e) {
1127                        log::warn!(
1128                            "Ambiguous submit failure for {client_order_id}, awaiting reconciliation: {e}"
1129                        );
1130                    } else {
1131                        order_contexts
1132                            .lock()
1133                            .expect(MUTEX_POISONED)
1134                            .remove(client_order_id.as_str());
1135                        log::warn!(
1136                            "Submit command failed without venue-declared outcome for {client_order_id}: {e}"
1137                        );
1138                    }
1139                    return Err(e.context("submit order failed"));
1140                }
1141            }
1142            Ok(())
1143        });
1144
1145        Ok(())
1146    }
1147
1148    fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
1149        let ts_event = self.clock.get_time_ns();
1150
1151        let Some(venue_order_id) = cmd.venue_order_id else {
1152            self.emitter.emit_order_modify_rejected_event(
1153                cmd.strategy_id,
1154                cmd.instrument_id,
1155                cmd.client_order_id,
1156                None,
1157                "modify-order requires venue_order_id",
1158                ts_event,
1159            );
1160            return Ok(());
1161        };
1162
1163        if cmd.price.is_none() && cmd.quantity.is_none() && cmd.trigger_price.is_none() {
1164            self.emitter.emit_order_modify_rejected_event(
1165                cmd.strategy_id,
1166                cmd.instrument_id,
1167                cmd.client_order_id,
1168                Some(venue_order_id),
1169                "modify-order requires price, quantity, or trigger_price",
1170                ts_event,
1171            );
1172            return Ok(());
1173        }
1174
1175        // Coinbase's `/orders/edit` requires both `price` and `size` to be
1176        // present in the request even when only one is changing; omitting
1177        // `size` is interpreted as 0 and rejected with `INVALID_EDITED_SIZE` /
1178        // `CANNOT_EDIT_TO_BELOW_FILLED_SIZE`. Auto-fill missing fields from
1179        // the cached order so strategies can call `modify_order(price=...)`
1180        // without having to look up the current quantity themselves.
1181        let (auto_price, auto_quantity) = {
1182            let cache = self.core.cache();
1183            let cached = cache.order(&cmd.client_order_id);
1184            let cached_price = cached.as_ref().and_then(|o| o.price());
1185            let cached_qty = cached.as_ref().map(|o| o.quantity());
1186            (cmd.price.or(cached_price), cmd.quantity.or(cached_qty))
1187        };
1188
1189        let http_client = self.http_client.clone();
1190        let emitter = self.emitter.clone();
1191        let order_contexts = Arc::clone(&self.order_contexts);
1192        let clock = self.clock;
1193        let strategy_id = cmd.strategy_id;
1194        let instrument_id = cmd.instrument_id;
1195        let client_order_id = cmd.client_order_id;
1196        let price = auto_price;
1197        let quantity = auto_quantity;
1198        let trigger_price = cmd.trigger_price;
1199
1200        self.spawn_task("modify_order", async move {
1201            let result = http_client
1202                .modify_order(venue_order_id, price, quantity, trigger_price)
1203                .await;
1204
1205            match result {
1206                Ok(resp) => {
1207                    if resp.success {
1208                        // Refresh the submit-time metadata cache so subsequent
1209                        // user-channel updates patch with the new price /
1210                        // trigger_price (Coinbase user channel does not echo
1211                        // these fields, so a stale cache would let the
1212                        // reconciler revert the local order to the pre-edit
1213                        // values).
1214                        let mut map = order_contexts.lock().expect(MUTEX_POISONED);
1215                        if let Some(meta) = map.get_mut(client_order_id.as_str()) {
1216                            if price.is_some() {
1217                                meta.price = price;
1218                            }
1219
1220                            if trigger_price.is_some() {
1221                                meta.trigger_price = trigger_price;
1222                            }
1223                        }
1224                    } else {
1225                        let reason = resp
1226                            .errors
1227                            .iter()
1228                            .map(|e| {
1229                                if e.edit_failure_reason.is_empty() {
1230                                    e.preview_failure_reason.clone()
1231                                } else {
1232                                    e.edit_failure_reason.clone()
1233                                }
1234                            })
1235                            .collect::<Vec<_>>()
1236                            .join(",");
1237                        let ts_event = clock.get_time_ns();
1238                        emitter.emit_order_modify_rejected_event(
1239                            strategy_id,
1240                            instrument_id,
1241                            client_order_id,
1242                            Some(venue_order_id),
1243                            &format!("modify-order-rejected: {reason}"),
1244                            ts_event,
1245                        );
1246                    }
1247                }
1248                Err(e) => {
1249                    if is_coinbase_ambiguous_command_failure(&e) {
1250                        log::warn!(
1251                            "Ambiguous modify failure for {client_order_id}, awaiting reconciliation: {e}"
1252                        );
1253                    } else {
1254                        log::warn!(
1255                            "Modify command failed without venue-declared outcome for {client_order_id}: {e}"
1256                        );
1257                    }
1258                    return Err(e.context("modify order failed"));
1259                }
1260            }
1261
1262            Ok(())
1263        });
1264
1265        Ok(())
1266    }
1267
1268    fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
1269        let Some(venue_order_id) = cmd.venue_order_id else {
1270            log::warn!(
1271                "Cancel command failed local validation for {}: venue_order_id required",
1272                cmd.client_order_id
1273            );
1274            return Ok(());
1275        };
1276
1277        let http_client = self.http_client.clone();
1278        let emitter = self.emitter.clone();
1279        let clock = self.clock;
1280        let strategy_id = cmd.strategy_id;
1281        let instrument_id = cmd.instrument_id;
1282        let client_order_id = cmd.client_order_id;
1283
1284        self.spawn_task("cancel_order", async move {
1285            match http_client.cancel_orders(&[venue_order_id]).await {
1286                Ok(resp) => {
1287                    if let Some(result) = resp.results.first()
1288                        && !result.success
1289                    {
1290                        let ts_event = clock.get_time_ns();
1291                        emitter.emit_order_cancel_rejected_event(
1292                            strategy_id,
1293                            instrument_id,
1294                            client_order_id,
1295                            Some(venue_order_id),
1296                            &format!("cancel-order-rejected: {}", result.failure_reason),
1297                            ts_event,
1298                        );
1299                    }
1300                }
1301                Err(e) => {
1302                    if is_coinbase_ambiguous_command_failure(&e) {
1303                        log::warn!(
1304                            "Ambiguous cancel failure for {client_order_id}, awaiting reconciliation: {e}"
1305                        );
1306                    } else {
1307                        log::warn!(
1308                            "Cancel command failed without venue-declared outcome for {client_order_id}: {e}"
1309                        );
1310                    }
1311                    return Err(e.context("cancel order failed"));
1312                }
1313            }
1314            Ok(())
1315        });
1316
1317        Ok(())
1318    }
1319
1320    fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
1321        let http_client = self.http_client.clone();
1322        let account_id = self.core.account_id;
1323        let instrument_id = cmd.instrument_id;
1324        let side_filter = cmd.order_side;
1325        let emitter = self.emitter.clone();
1326        let clock = self.clock;
1327        let strategy_id = cmd.strategy_id;
1328
1329        self.spawn_task("cancel_all_orders", async move {
1330            // Coinbase's `order_status=OPEN` filter excludes PENDING / QUEUED
1331            // orders that were submitted very recently and are still cancelable.
1332            // Fetch all reports and filter to any open status locally so a cancel-
1333            // all issued right after submission does not leave working orders behind.
1334            let reports = http_client
1335                .request_order_status_reports(
1336                    account_id,
1337                    Some(instrument_id),
1338                    false,
1339                    None,
1340                    None,
1341                    None,
1342                )
1343                .await
1344                .context("failed to list orders for cancel_all")?;
1345
1346            // Filter to statuses that are safe to cancel and to the requested
1347            // side since Coinbase's batch-cancel endpoint has no side parameter.
1348            //
1349            // Coinbase's `PENDING` / `QUEUED` / `OPEN` all map to `Accepted`
1350            // and are cancelable. We can't use `OrderStatus::is_open()` because
1351            // it includes `PendingCancel`, and re-cancelling a `CANCEL_QUEUED`
1352            // order risks `CancelRejected` flipping the order back to its prior
1353            // working status.
1354            let filtered: Vec<(Option<ClientOrderId>, VenueOrderId)> = reports
1355                .into_iter()
1356                .filter(|r| {
1357                    matches!(
1358                        r.order_status,
1359                        OrderStatus::Accepted
1360                            | OrderStatus::Triggered
1361                            | OrderStatus::PendingUpdate
1362                            | OrderStatus::PartiallyFilled
1363                    )
1364                })
1365                .filter(|r| side_filter == OrderSide::NoOrderSide || r.order_side == side_filter)
1366                .map(|r| (r.client_order_id, r.venue_order_id))
1367                .collect();
1368
1369            if filtered.is_empty() {
1370                return Ok(());
1371            }
1372
1373            for chunk in filtered.chunks(BATCH_CANCEL_CHUNK) {
1374                let venue_ids: Vec<VenueOrderId> = chunk.iter().map(|(_, v)| *v).collect();
1375                match http_client.cancel_orders(&venue_ids).await {
1376                    Ok(resp) => {
1377                        for result in &resp.results {
1378                            if result.success {
1379                                continue;
1380                            }
1381                            let matching = chunk
1382                                .iter()
1383                                .find(|(_, vid)| vid.as_str() == result.order_id);
1384                            if let Some((cid_opt, vid)) = matching
1385                                && let Some(cid) = cid_opt
1386                            {
1387                                let ts_event = clock.get_time_ns();
1388                                emitter.emit_order_cancel_rejected_event(
1389                                    strategy_id,
1390                                    instrument_id,
1391                                    *cid,
1392                                    Some(*vid),
1393                                    &format!("cancel-all-rejected: {}", result.failure_reason),
1394                                    ts_event,
1395                                );
1396                            }
1397                        }
1398                    }
1399                    Err(e) => {
1400                        if is_coinbase_ambiguous_command_failure(&e) {
1401                            log::warn!(
1402                                "Ambiguous cancel-all failure for {} orders on {instrument_id}, awaiting reconciliation: {e}",
1403                                chunk.len()
1404                            );
1405                        } else {
1406                            log::warn!(
1407                                "Cancel-all command failed without venue-declared outcome for {} orders on {instrument_id}: {e}",
1408                                chunk.len()
1409                            );
1410                        }
1411                    }
1412                }
1413            }
1414            Ok(())
1415        });
1416
1417        Ok(())
1418    }
1419
1420    fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
1421        if cmd.cancels.is_empty() {
1422            return Ok(());
1423        }
1424
1425        let http_client = self.http_client.clone();
1426        let emitter = self.emitter.clone();
1427        let clock = self.clock;
1428        // Preserve each child cancel's identity for per-order venue failures.
1429        let entries: Vec<(
1430            StrategyId,
1431            InstrumentId,
1432            ClientOrderId,
1433            Option<VenueOrderId>,
1434        )> = cmd
1435            .cancels
1436            .iter()
1437            .map(|c| {
1438                (
1439                    c.strategy_id,
1440                    c.instrument_id,
1441                    c.client_order_id,
1442                    c.venue_order_id,
1443                )
1444            })
1445            .collect();
1446
1447        self.spawn_task("batch_cancel_orders", async move {
1448            let venue_order_ids: Vec<VenueOrderId> =
1449                entries.iter().filter_map(|(_, _, _, v)| *v).collect();
1450
1451            for (_, _, cid, vid_opt) in &entries {
1452                if vid_opt.is_none() {
1453                    log::warn!(
1454                        "Batch cancel command failed local validation for {cid}: venue_order_id required"
1455                    );
1456                }
1457            }
1458
1459            for chunk in venue_order_ids.chunks(BATCH_CANCEL_CHUNK) {
1460                match http_client.cancel_orders(chunk).await {
1461                    Ok(resp) => {
1462                        for result in &resp.results {
1463                            if !result.success {
1464                                let vid = VenueOrderId::new(&result.order_id);
1465                                let matching = entries
1466                                    .iter()
1467                                    .find(|(_, _, _, v)| {
1468                                        v.is_some_and(|id| id.as_str() == result.order_id)
1469                                    });
1470
1471                                if let Some((strategy_id, instrument_id, cid, _)) = matching {
1472                                    let ts_event = clock.get_time_ns();
1473                                    emitter.emit_order_cancel_rejected_event(
1474                                        *strategy_id,
1475                                        *instrument_id,
1476                                        *cid,
1477                                        Some(vid),
1478                                        &format!(
1479                                            "batch-cancel-rejected: {}",
1480                                            result.failure_reason
1481                                        ),
1482                                        ts_event,
1483                                    );
1484                                }
1485                            }
1486                        }
1487                    }
1488                    Err(e) => {
1489                        if is_coinbase_ambiguous_command_failure(&e) {
1490                            log::warn!(
1491                                "Ambiguous batch cancel failure for {} orders, awaiting reconciliation: {e}",
1492                                chunk.len()
1493                            );
1494                        } else {
1495                            log::warn!(
1496                                "Batch cancel command failed without venue-declared outcome for {} orders: {e}",
1497                                chunk.len()
1498                            );
1499                        }
1500                    }
1501                }
1502            }
1503            Ok(())
1504        });
1505
1506        Ok(())
1507    }
1508}
1509
1510fn is_coinbase_local_submit_failure(err: &anyhow::Error) -> bool {
1511    match coinbase_http_error(err) {
1512        None => true,
1513        Some(CoinbaseHttpError::Auth(message)) => !message.starts_with("HTTP "),
1514        _ => false,
1515    }
1516}
1517
1518fn is_coinbase_explicit_submit_rejection(err: &anyhow::Error) -> bool {
1519    match coinbase_http_error(err) {
1520        Some(CoinbaseHttpError::Auth(message) | CoinbaseHttpError::BadRequest(message)) => {
1521            message.starts_with("HTTP ")
1522        }
1523        Some(CoinbaseHttpError::RateLimit { .. }) => true,
1524        _ => false,
1525    }
1526}
1527
1528fn is_coinbase_ambiguous_command_failure(err: &anyhow::Error) -> bool {
1529    matches!(
1530        coinbase_http_error(err),
1531        Some(
1532            CoinbaseHttpError::Transport(_)
1533                | CoinbaseHttpError::Serde(_)
1534                | CoinbaseHttpError::Exchange(_)
1535                | CoinbaseHttpError::Timeout
1536                | CoinbaseHttpError::Decode(_)
1537        )
1538    ) || matches!(
1539        coinbase_http_error(err),
1540        Some(CoinbaseHttpError::Http { status, .. }) if *status >= 500
1541    )
1542}
1543
1544fn coinbase_http_error(err: &anyhow::Error) -> Option<&CoinbaseHttpError> {
1545    err.chain()
1546        .find_map(|cause| cause.downcast_ref::<CoinbaseHttpError>())
1547}
1548
1549// Processes a single user-channel order update: emits the status report,
1550// synthesizes a FillReport from the cumulative delta, and deduplicates
1551// replayed fills by (venue_order_id, trade_id).
1552#[allow(clippy::too_many_arguments)]
1553async fn handle_user_order_update(
1554    carrier: UserOrderUpdate,
1555    emitter: &ExecutionEventEmitter,
1556    fill_dedup: &Arc<Mutex<FillDedup>>,
1557    cumulative_state: &Arc<Mutex<CumulativeStateMap>>,
1558    order_contexts: &Arc<Mutex<AHashMap<String, OrderContext>>>,
1559    external_order_contexts: &Arc<Mutex<AHashMap<String, OrderContext>>>,
1560    http_client: &CoinbaseHttpClient,
1561    account_id: AccountId,
1562) {
1563    // Coinbase's user channel does not echo `price`, `stop_price`,
1564    // `trigger_type`, or `post_only`. Resolve an `OrderContext` (cached
1565    // from `submit_order` for orders this client placed, or fetched from
1566    // REST and cached for external orders) so the report can be patched
1567    // before reaching the engine reconciler.
1568    let context = resolve_order_context(
1569        &carrier.update,
1570        carrier.report.order_type,
1571        carrier.report.price.is_none(),
1572        order_contexts,
1573        external_order_contexts,
1574        http_client,
1575        account_id,
1576    )
1577    .await;
1578
1579    let is_terminal = carrier.update.status.is_terminal();
1580    let client_order_id = carrier.update.client_order_id.clone();
1581    let venue_order_id = carrier.update.order_id.clone();
1582
1583    process_user_order_update(
1584        carrier,
1585        context,
1586        emitter,
1587        fill_dedup,
1588        cumulative_state,
1589        Some(http_client),
1590    );
1591
1592    // Drop submit-time / enrichment metadata once the order reaches a
1593    // terminal state so long-running clients do not accumulate one entry
1594    // per order. Mirrors the cumulative-state cleanup in
1595    // `process_user_order_update`.
1596    if is_terminal {
1597        if !client_order_id.is_empty() {
1598            order_contexts
1599                .lock()
1600                .expect(MUTEX_POISONED)
1601                .remove(&client_order_id);
1602        }
1603        external_order_contexts
1604            .lock()
1605            .expect(MUTEX_POISONED)
1606            .remove(&venue_order_id);
1607    }
1608}
1609
1610// Sync portion of the user-channel update handler. Split from
1611// `handle_user_order_update` so tests can drive it without a tokio runtime;
1612// the only async dependency is REST enrichment in `resolve_order_context`.
1613fn process_user_order_update(
1614    carrier: UserOrderUpdate,
1615    context: Option<OrderContext>,
1616    emitter: &ExecutionEventEmitter,
1617    fill_dedup: &Arc<Mutex<FillDedup>>,
1618    cumulative_state: &Arc<Mutex<CumulativeStateMap>>,
1619    http_client: Option<&CoinbaseHttpClient>,
1620) {
1621    let UserOrderUpdate {
1622        mut report,
1623        update,
1624        mut instrument,
1625        is_snapshot,
1626        ts_event,
1627        ts_init,
1628    } = carrier;
1629
1630    let mut fill_liquidity_side = LiquiditySide::NoLiquiditySide;
1631    let have_order_contexts = context.is_some();
1632    let mut publish_instrument_id: Option<InstrumentId> = None;
1633
1634    if let Some(meta) = context {
1635        if report.price.is_none() && meta.price.is_some() {
1636            report.price = meta.price;
1637        }
1638
1639        if report.trigger_price.is_none() && meta.trigger_price.is_some() {
1640            report.trigger_price = meta.trigger_price;
1641        }
1642
1643        if report.trigger_type.is_none() && meta.trigger_type.is_some() {
1644            report.trigger_type = meta.trigger_type;
1645        }
1646
1647        if meta.post_only {
1648            // `post_only` orders are guaranteed `Maker`. Non-post-only
1649            // orders cannot be classified from the user channel alone so
1650            // they keep `NoLiquiditySide` until the fill is reconciled
1651            // against the REST `/orders/historical/fills` endpoint.
1652            fill_liquidity_side = LiquiditySide::Maker;
1653            // The user channel does not echo `post_only`, so propagate the
1654            // cached flag to the OSR to preserve maker-only semantics for
1655            // any downstream order reconstruction.
1656            report.post_only = true;
1657        }
1658
1659        if let Some(submitted) = meta.submitted_product_id
1660            && submitted != update.product_id
1661        {
1662            let submitted_id = InstrumentId::new(Symbol::new(submitted), *COINBASE_VENUE);
1663            report.instrument_id = submitted_id;
1664            publish_instrument_id = Some(submitted_id);
1665            // Replace the carrier's instrument with the submitted-side one
1666            // (looked up from the http client's bootstrapped cache) so the
1667            // FillReport's commission currency, price/size precision, and
1668            // any other instrument-derived field reflect the actual order's
1669            // instrument rather than the canonical wire alias.
1670            if let Some(http) = http_client
1671                && let Some(submitted_instrument) = http.instruments().get_cloned(&submitted_id)
1672            {
1673                instrument = submitted_instrument;
1674            }
1675        }
1676    }
1677
1678    let size_precision = instrument.size_precision();
1679
1680    let cumulative_qty = if update.cumulative_quantity.is_empty() {
1681        Quantity::zero(size_precision)
1682    } else {
1683        match parse_quantity(&update.cumulative_quantity, size_precision) {
1684            Ok(q) => q,
1685            Err(e) => {
1686                log::warn!(
1687                    "Failed to parse cumulative_quantity for order {}: {e}",
1688                    update.order_id
1689                );
1690                return;
1691            }
1692        }
1693    };
1694
1695    let cumulative_fees = if update.total_fees.is_empty() {
1696        Decimal::ZERO
1697    } else {
1698        match Decimal::from_str(&update.total_fees) {
1699            Ok(d) => d,
1700            Err(e) => {
1701                log::warn!(
1702                    "Failed to parse total_fees for order {}: {e}",
1703                    update.order_id
1704                );
1705                return;
1706            }
1707        }
1708    };
1709
1710    let cumulative_avg = if update.avg_price.is_empty() {
1711        Decimal::ZERO
1712    } else {
1713        match Decimal::from_str(&update.avg_price) {
1714            Ok(d) => d,
1715            Err(e) => {
1716                log::warn!(
1717                    "Failed to parse avg_price for order {}: {e}",
1718                    update.order_id
1719                );
1720                return;
1721            }
1722        }
1723    };
1724    let order_id = update.order_id.clone();
1725
1726    let is_terminal = update.status.is_terminal();
1727
1728    // Snapshot previous state under lock; update immediately to avoid races
1729    // between concurrent handler tasks for the same order.
1730    let (delta_qty, delta_fees, last_fill_price_decimal, restored_quantity) = {
1731        let mut state = cumulative_state.lock().expect(MUTEX_POISONED);
1732        let entry = state.entry_or_default(&order_id);
1733        let prev_qty = entry
1734            .filled_qty
1735            .unwrap_or_else(|| Quantity::zero(size_precision));
1736        let prev_fees = entry.total_fees;
1737        let prev_avg = entry.avg_price;
1738
1739        // Track the max-observed total quantity. The freshly-built report has
1740        // quantity = cum+leaves which is correct for working orders; on
1741        // terminal events Coinbase zeroes leaves_quantity, so we use the
1742        // stored max instead.
1743        let observed_quantity = report.quantity;
1744        let stored_quantity = match entry.quantity {
1745            Some(q) if q >= observed_quantity => q,
1746            _ => observed_quantity,
1747        };
1748        entry.quantity = Some(stored_quantity);
1749
1750        // Snapshots restate the cumulative state of pre-existing open orders.
1751        // Treat them as the new baseline (so subsequent updates compute correct
1752        // deltas) but never synthesize a fill from them.
1753        if is_snapshot {
1754            entry.filled_qty = Some(cumulative_qty);
1755            entry.total_fees = cumulative_fees;
1756            entry.avg_price = cumulative_avg;
1757
1758            if is_terminal {
1759                state.remove(&order_id);
1760            }
1761            (
1762                Quantity::zero(size_precision),
1763                Decimal::ZERO,
1764                Decimal::ZERO,
1765                stored_quantity,
1766            )
1767        } else {
1768            let delta_qty = if cumulative_qty > prev_qty {
1769                cumulative_qty - prev_qty
1770            } else {
1771                Quantity::zero(size_precision)
1772            };
1773            let delta_fees = cumulative_fees - prev_fees;
1774
1775            // Derive per-fill price from the cumulative notional delta:
1776            //   last_px = (avg_now * qty_now - avg_prev * qty_prev) / delta_qty
1777            // Falls back to the cumulative avg on the first fill (where
1778            // delta_qty equals qty_now and prev_notional is zero).
1779            let last_fill_price_decimal = if delta_qty.is_positive() {
1780                let now_notional = cumulative_avg * cumulative_qty.as_decimal();
1781                let prev_notional = prev_avg * prev_qty.as_decimal();
1782                let delta_notional = now_notional - prev_notional;
1783                let delta_qty_dec = delta_qty.as_decimal();
1784                if delta_qty_dec.is_zero() {
1785                    cumulative_avg
1786                } else {
1787                    delta_notional / delta_qty_dec
1788                }
1789            } else {
1790                Decimal::ZERO
1791            };
1792
1793            entry.filled_qty = Some(cumulative_qty);
1794            entry.total_fees = cumulative_fees;
1795            entry.avg_price = cumulative_avg;
1796
1797            if is_terminal {
1798                state.remove(&order_id);
1799            }
1800
1801            (
1802                delta_qty,
1803                delta_fees,
1804                last_fill_price_decimal,
1805                stored_quantity,
1806            )
1807        }
1808    };
1809
1810    // Restore the original order quantity on terminal events when the venue's
1811    // zeroed leaves_quantity would otherwise collapse the report to filled_qty.
1812    if is_terminal && report.quantity < restored_quantity {
1813        report.quantity = restored_quantity;
1814    }
1815
1816    // Emit the synthesized FillReport before the OrderStatusReport when there
1817    // is one. The engine's reconciler treats an OrderStatusReport with status
1818    // `Filled` / `PartiallyFilled` as authoritative for `filled_qty` and will
1819    // *infer* a synthetic fill when the local order is behind the report. If
1820    // the OrderStatusReport landed first, that inferred fill would race ours
1821    // and ours would then be rejected as an overfill.
1822    let synthesized_fill = if delta_qty.is_positive()
1823        && last_fill_price_decimal.is_sign_positive()
1824        && !last_fill_price_decimal.is_zero()
1825    {
1826        let price_precision = instrument.price_precision();
1827        match Price::from_decimal_dp(last_fill_price_decimal, price_precision) {
1828            Ok(last_px) => {
1829                // Coinbase's user channel reports cumulative state and does
1830                // not assign a per-fill trade id, so we synthesize one.
1831                // `TradeId` is a 36-char stack string; a full venue UUID
1832                // (36 chars) plus the cumulative_qty would overflow. Use the
1833                // first 8 chars of the venue UUID (already random hex) as a
1834                // stable per-order discriminator.
1835                let order_id_short = &update.order_id[..update.order_id.len().min(8)];
1836                let trade_id = TradeId::new(format!("{order_id_short}-{cumulative_qty}"));
1837                let trade_id_str = trade_id.as_str().to_string();
1838
1839                let is_new = {
1840                    let mut dedup = fill_dedup.lock().expect(MUTEX_POISONED);
1841                    dedup.insert((update.order_id.clone(), trade_id_str))
1842                };
1843
1844                if is_new {
1845                    let commission_currency = instrument.quote_currency();
1846                    match Money::from_decimal(delta_fees, commission_currency) {
1847                        Ok(commission) => Some(parse_ws_user_event_to_fill_report(
1848                            &update,
1849                            delta_qty,
1850                            last_px,
1851                            commission,
1852                            trade_id,
1853                            &instrument,
1854                            emitter.account_id(),
1855                            fill_liquidity_side,
1856                            ts_event,
1857                            ts_init,
1858                        )),
1859                        Err(e) => {
1860                            log::warn!(
1861                                "Failed to build commission Money for order {}: {e}",
1862                                update.order_id
1863                            );
1864                            None
1865                        }
1866                    }
1867                } else {
1868                    log::debug!(
1869                        "Dropping duplicate fill venue_order_id={}, trade_id={}",
1870                        update.order_id,
1871                        trade_id,
1872                    );
1873                    None
1874                }
1875            }
1876            Err(e) => {
1877                log::warn!(
1878                    "Failed to build Price from derived last_fill={last_fill_price_decimal} at precision {price_precision} for order {}: {e}",
1879                    update.order_id
1880                );
1881                None
1882            }
1883        }
1884    } else {
1885        None
1886    };
1887
1888    if let Some(mut fill_report) = synthesized_fill {
1889        if let Some(id) = publish_instrument_id {
1890            fill_report.instrument_id = id;
1891        }
1892        emitter.send_fill_report(fill_report);
1893    }
1894
1895    // OSR emission policy:
1896    // - For order types that carry a price (LIMIT / STOP_LIMIT) or trigger
1897    //   (STOP_MARKET / *_IF_TOUCHED), the report must include the relevant
1898    //   field before reaching the engine reconciler; otherwise the order
1899    //   reconstruction path panics with a missing-field error. Patching
1900    //   above pulls these from the OrderContext when one is available, but
1901    //   if enrichment was needed and unavailable (REST fetch failed for an
1902    //   external order) the report is still missing the field and is unsafe
1903    //   to emit.
1904    // - Snapshots emit only when we have submit-time metadata; the
1905    //   user-channel snapshot omits these fields entirely. With metadata,
1906    //   the report has been patched above and is safe to emit (this
1907    //   preserves reconnect-time partial-fill recovery for orders submitted
1908    //   by this process). For unknown orders, the REST mass-status path
1909    //   called from `LiveNode` startup is the canonical source.
1910    let report_safe_for_type = match report.order_type {
1911        OrderType::Limit | OrderType::LimitIfTouched => report.price.is_some(),
1912        OrderType::StopLimit => report.price.is_some() && report.trigger_price.is_some(),
1913        OrderType::StopMarket | OrderType::MarketIfTouched => report.trigger_price.is_some(),
1914        _ => true,
1915    };
1916    let should_emit = (!is_snapshot || have_order_contexts) && report_safe_for_type;
1917    if should_emit {
1918        emitter.send_order_status_report(*report);
1919    } else if !report_safe_for_type {
1920        log::warn!(
1921            "Suppressed unsafe OrderStatusReport for {} {}: missing price/trigger after enrichment",
1922            report.order_type,
1923            update.order_id,
1924        );
1925    }
1926}
1927
1928// Returns the submit-time / enriched metadata for `update`, fetching from
1929// REST and populating the enrichment cache the first time an external order
1930// is seen. `order_contexts` (keyed by `client_order_id`) covers orders this
1931// client placed; `external_order_contexts` (keyed by venue `order_id`) covers
1932// external orders whose `OrderStatusReport` would otherwise be unsafe to
1933// reconstruct (LIMIT / STOP_LIMIT with `price = None`).
1934async fn resolve_order_context(
1935    update: &WsOrderUpdate,
1936    order_type: OrderType,
1937    report_price_missing: bool,
1938    order_contexts: &Arc<Mutex<AHashMap<String, OrderContext>>>,
1939    external_order_contexts: &Arc<Mutex<AHashMap<String, OrderContext>>>,
1940    http_client: &CoinbaseHttpClient,
1941    account_id: AccountId,
1942) -> Option<OrderContext> {
1943    if !update.client_order_id.is_empty() {
1944        let map = order_contexts.lock().expect(MUTEX_POISONED);
1945        if let Some(meta) = map.get(&update.client_order_id) {
1946            return Some(meta.clone());
1947        }
1948    }
1949
1950    if let Some(meta) = external_order_contexts
1951        .lock()
1952        .expect(MUTEX_POISONED)
1953        .get(&update.order_id)
1954    {
1955        return Some(meta.clone());
1956    }
1957
1958    let needs_enrichment = report_price_missing
1959        && matches!(
1960            order_type,
1961            OrderType::Limit
1962                | OrderType::StopLimit
1963                | OrderType::LimitIfTouched
1964                | OrderType::StopMarket
1965                | OrderType::MarketIfTouched
1966        );
1967
1968    if !needs_enrichment {
1969        return None;
1970    }
1971
1972    let venue_order_id = VenueOrderId::new(update.order_id.as_str());
1973    match http_client
1974        .request_order_status_report(account_id, None, Some(venue_order_id))
1975        .await
1976    {
1977        Ok(rest_report) => {
1978            let post_only_from_rest = matches!(order_type, OrderType::Limit | OrderType::StopLimit)
1979                && rest_report.post_only;
1980            let meta = OrderContext {
1981                price: rest_report.price,
1982                trigger_price: rest_report.trigger_price,
1983                trigger_type: rest_report.trigger_type,
1984                post_only: post_only_from_rest,
1985                submitted_product_id: None,
1986            };
1987            external_order_contexts
1988                .lock()
1989                .expect(MUTEX_POISONED)
1990                .insert(update.order_id.clone(), meta.clone());
1991            Some(meta)
1992        }
1993        Err(e) => {
1994            log::warn!(
1995                "Failed to enrich external order {} via REST: {e}",
1996                update.order_id
1997            );
1998            None
1999        }
2000    }
2001}
2002
2003#[cfg(test)]
2004mod tests {
2005    use nautilus_common::messages::{ExecutionEvent, ExecutionReport};
2006    use nautilus_model::{
2007        enums::AccountType,
2008        identifiers::{Symbol, TraderId},
2009        instruments::CurrencyPair,
2010        types::Currency,
2011    };
2012    use rstest::rstest;
2013    use ustr::Ustr;
2014
2015    use super::*;
2016    use crate::{
2017        common::{
2018            consts::COINBASE_VENUE,
2019            enums::{
2020                CoinbaseContractExpiryType, CoinbaseOrderSide as CbSide,
2021                CoinbaseOrderStatus as CbStatus, CoinbaseOrderType as CbType,
2022                CoinbaseProductType as CbProductType, CoinbaseRiskManagedBy,
2023                CoinbaseTimeInForce as CbTif, CoinbaseTriggerStatus,
2024            },
2025        },
2026        websocket::messages::WsOrderUpdate,
2027    };
2028
2029    #[rstest]
2030    fn test_submit_local_command_failure_classification() {
2031        let err = anyhow::anyhow!("Unsupported Coinbase order configuration");
2032
2033        assert!(is_coinbase_local_submit_failure(&err));
2034        assert!(!is_coinbase_explicit_submit_rejection(&err));
2035        assert!(!is_coinbase_ambiguous_command_failure(&err));
2036    }
2037
2038    #[rstest]
2039    fn test_submit_http_exchange_failure_classification() {
2040        let err = anyhow::Error::new(CoinbaseHttpError::exchange("HTTP 500: unavailable"))
2041            .context("failed to submit order");
2042
2043        assert!(!is_coinbase_local_submit_failure(&err));
2044        assert!(!is_coinbase_explicit_submit_rejection(&err));
2045        assert!(is_coinbase_ambiguous_command_failure(&err));
2046    }
2047
2048    #[rstest]
2049    fn test_submit_http_bad_request_failure_classification() {
2050        let err = anyhow::Error::new(CoinbaseHttpError::bad_request("HTTP 400: bad request"))
2051            .context("failed to submit order");
2052
2053        assert!(!is_coinbase_local_submit_failure(&err));
2054        assert!(is_coinbase_explicit_submit_rejection(&err));
2055        assert!(!is_coinbase_ambiguous_command_failure(&err));
2056    }
2057
2058    #[rstest]
2059    fn test_submit_http_auth_failure_classification() {
2060        let err = anyhow::Error::new(CoinbaseHttpError::auth("HTTP 401: authentication failed"))
2061            .context("failed to submit order");
2062
2063        assert!(!is_coinbase_local_submit_failure(&err));
2064        assert!(is_coinbase_explicit_submit_rejection(&err));
2065        assert!(!is_coinbase_ambiguous_command_failure(&err));
2066    }
2067
2068    #[rstest]
2069    fn test_submit_http_rate_limit_failure_classification() {
2070        let err = anyhow::Error::new(CoinbaseHttpError::rate_limit(None))
2071            .context("failed to submit order");
2072
2073        assert!(!is_coinbase_local_submit_failure(&err));
2074        assert!(is_coinbase_explicit_submit_rejection(&err));
2075        assert!(!is_coinbase_ambiguous_command_failure(&err));
2076    }
2077
2078    #[rstest]
2079    fn test_submit_unmapped_http_4xx_failure_classification() {
2080        let err = anyhow::Error::new(CoinbaseHttpError::http(409, "conflict"))
2081            .context("failed to submit order");
2082
2083        assert!(!is_coinbase_local_submit_failure(&err));
2084        assert!(!is_coinbase_explicit_submit_rejection(&err));
2085        assert!(!is_coinbase_ambiguous_command_failure(&err));
2086    }
2087
2088    #[rstest]
2089    fn test_submit_local_auth_failure_classification() {
2090        let err = anyhow::Error::new(CoinbaseHttpError::auth("No credentials configured"))
2091            .context("failed to submit order");
2092
2093        assert!(is_coinbase_local_submit_failure(&err));
2094        assert!(!is_coinbase_explicit_submit_rejection(&err));
2095        assert!(!is_coinbase_ambiguous_command_failure(&err));
2096    }
2097
2098    #[rstest]
2099    fn test_fill_dedup_rejects_duplicates() {
2100        let mut dedup = FillDedup::new(4);
2101        let key = ("venue-1".to_string(), "trade-1".to_string());
2102        assert!(dedup.insert(key.clone()));
2103        assert!(!dedup.insert(key));
2104    }
2105
2106    #[rstest]
2107    fn test_fill_dedup_evicts_oldest_when_full() {
2108        let mut dedup = FillDedup::new(2);
2109        assert!(dedup.insert(("v".to_string(), "t1".to_string())));
2110        assert!(dedup.insert(("v".to_string(), "t2".to_string())));
2111        // Insert a third; oldest (t1) should be evicted so re-insertion succeeds.
2112        assert!(dedup.insert(("v".to_string(), "t3".to_string())));
2113        assert!(dedup.insert(("v".to_string(), "t1".to_string())));
2114    }
2115
2116    #[rstest]
2117    fn test_cumulative_state_evicts_oldest_at_capacity() {
2118        let mut state = CumulativeStateMap::with_capacity(2);
2119        state.entry_or_default("a");
2120        state.entry_or_default("b");
2121        // Capacity reached; inserting a third evicts "a"
2122        state.entry_or_default("c");
2123        assert_eq!(state.len(), 2);
2124        assert!(state.map.contains_key("b"));
2125        assert!(state.map.contains_key("c"));
2126        assert!(!state.map.contains_key("a"));
2127    }
2128
2129    #[rstest]
2130    fn test_cumulative_state_remove_drops_entry_and_allows_reinsert() {
2131        let mut state = CumulativeStateMap::with_capacity(2);
2132        state.entry_or_default("a");
2133        state.entry_or_default("b");
2134        state.remove("a");
2135        // After remove, the next insert should fit without evicting "b"
2136        state.entry_or_default("c");
2137        assert_eq!(state.len(), 2);
2138        assert!(state.map.contains_key("b"));
2139        assert!(state.map.contains_key("c"));
2140    }
2141
2142    #[rstest]
2143    fn test_cumulative_state_remove_and_reinsert_does_not_evict_live_state() {
2144        // Codex repro: remove() must purge stale deque slots so a later
2145        // re-insert of the same key cannot have the eviction loop pop the
2146        // stale slot and remove the now-live entry.
2147        let mut state = CumulativeStateMap::with_capacity(2);
2148        state.entry_or_default("a");
2149        state.remove("a");
2150        state.entry_or_default("b");
2151        state.entry_or_default("a");
2152        // With the bug, inserting "c" pops the stale "a" slot at the front
2153        // and removes the live "a" entry from the map; the live "b" should
2154        // be evicted instead because it is now the oldest live entry.
2155        state.entry_or_default("c");
2156        assert_eq!(state.len(), 2);
2157        assert!(
2158            state.map.contains_key("a"),
2159            "re-inserted live key must survive"
2160        );
2161        assert!(state.map.contains_key("c"));
2162        assert!(!state.map.contains_key("b"));
2163    }
2164
2165    #[rstest]
2166    fn test_cumulative_state_hit_refreshes_lru_recency() {
2167        // A repeat access to an existing key must move it to the back of the
2168        // eviction queue so a hot order receiving many updates is not evicted
2169        // by churn on other orders.
2170        let mut state = CumulativeStateMap::with_capacity(2);
2171        state.entry_or_default("a");
2172        state.entry_or_default("b");
2173        // Re-access "a": without the LRU refresh this is a no-op and the
2174        // next insert evicts "a"; with the refresh it should evict "b".
2175        state.entry_or_default("a");
2176        state.entry_or_default("c");
2177        assert_eq!(state.len(), 2);
2178        assert!(
2179            state.map.contains_key("a"),
2180            "recently-accessed key must survive eviction"
2181        );
2182        assert!(state.map.contains_key("c"));
2183        assert!(!state.map.contains_key("b"));
2184    }
2185
2186    #[rstest]
2187    fn test_cumulative_state_preserves_live_entry_when_trimming_stale() {
2188        // A long-lived order at the front of the deque must survive any number
2189        // of terminal events on later orders, and the deque must stay bounded
2190        // (compacted) so memory does not grow without bound under high churn.
2191        let mut state = CumulativeStateMap::with_capacity(2);
2192        state.entry_or_default("live");
2193        // Churn far beyond 2*capacity to force the deque-compaction path.
2194        for i in 0..50 {
2195            let key = format!("t{i}");
2196            state.entry_or_default(&key);
2197            state.remove(&key);
2198        }
2199        assert!(
2200            state.map.contains_key("live"),
2201            "live entry must survive stale-trim cycles"
2202        );
2203        assert_eq!(state.len(), 1);
2204        assert!(
2205            state.order.len() <= 2 * state.capacity,
2206            "deque must remain bounded after compaction (was {})",
2207            state.order.len(),
2208        );
2209        // The live key must remain reachable through the deque so future
2210        // eviction can find and (correctly) evict it. A bug that drops live
2211        // keys from the deque would let the map grow past capacity on the
2212        // next series of inserts.
2213        assert!(
2214            state.order.iter().any(|k| k == "live"),
2215            "live key must remain in the deque, was: {:?}",
2216            state.order,
2217        );
2218        // Drive eviction past capacity to confirm the live key still
2219        // participates in LRU. With capacity=2, "live" plus two new keys
2220        // means the next insert must evict the next-oldest live key
2221        // ("live"), not silently grow the map.
2222        state.entry_or_default("a");
2223        state.entry_or_default("b");
2224        state.entry_or_default("c");
2225        assert_eq!(state.len(), state.capacity);
2226        assert!(
2227            !state.map.contains_key("live"),
2228            "live key should have been evicted in LRU order once capacity demanded it"
2229        );
2230    }
2231
2232    fn test_instrument() -> InstrumentAny {
2233        let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), *COINBASE_VENUE);
2234        InstrumentAny::CurrencyPair(CurrencyPair::new(
2235            instrument_id,
2236            Symbol::new("BTC-USD"),
2237            Currency::get_or_create_crypto("BTC"),
2238            Currency::get_or_create_crypto("USD"),
2239            2,
2240            8,
2241            Price::from("0.01"),
2242            Quantity::from("0.00000001"),
2243            None,
2244            None,
2245            None,
2246            Some(Quantity::from("0.00000001")),
2247            None,
2248            None,
2249            None,
2250            None,
2251            None,
2252            None,
2253            None,
2254            None,
2255            None,
2256            None,
2257            UnixNanos::default(),
2258            UnixNanos::default(),
2259        ))
2260    }
2261
2262    fn make_emitter() -> (
2263        ExecutionEventEmitter,
2264        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2265    ) {
2266        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2267        let mut emitter = ExecutionEventEmitter::new(
2268            get_atomic_clock_realtime(),
2269            TraderId::from("TRADER-001"),
2270            AccountId::new("COINBASE-001"),
2271            AccountType::Cash,
2272            None,
2273        );
2274        emitter.set_sender(tx);
2275        (emitter, rx)
2276    }
2277
2278    fn make_user_order_update(
2279        cumulative: &str,
2280        leaves: &str,
2281        avg_price: &str,
2282        total_fees: &str,
2283        status: CbStatus,
2284    ) -> WsOrderUpdate {
2285        WsOrderUpdate {
2286            order_id: "venue-1".to_string(),
2287            client_order_id: "client-1".to_string(),
2288            contract_expiry_type: CoinbaseContractExpiryType::Unknown,
2289            cumulative_quantity: cumulative.to_string(),
2290            leaves_quantity: leaves.to_string(),
2291            avg_price: avg_price.to_string(),
2292            total_fees: total_fees.to_string(),
2293            status,
2294            product_id: Ustr::from("BTC-USD"),
2295            product_type: CbProductType::Spot,
2296            creation_time: String::new(),
2297            order_side: CbSide::Buy,
2298            order_type: CbType::Limit,
2299            risk_managed_by: CoinbaseRiskManagedBy::Unknown,
2300            time_in_force: CbTif::GoodUntilCancelled,
2301            trigger_status: CoinbaseTriggerStatus::InvalidOrderType,
2302            cancel_reason: String::new(),
2303            reject_reason: String::new(),
2304            total_value_after_fees: String::new(),
2305        }
2306    }
2307
2308    fn make_carrier(update: WsOrderUpdate) -> UserOrderUpdate {
2309        make_carrier_with_kind(update, false)
2310    }
2311
2312    // Stub OrderContext with `price` populated so process_user_order_update's
2313    // safe-emission gate accepts a LIMIT report. Mirrors what `submit_order`
2314    // would have cached under production flow.
2315    fn make_limit_context() -> OrderContext {
2316        OrderContext {
2317            price: Some(Price::from("100.00")),
2318            ..OrderContext::default()
2319        }
2320    }
2321
2322    fn make_carrier_with_kind(update: WsOrderUpdate, is_snapshot: bool) -> UserOrderUpdate {
2323        let instrument = test_instrument();
2324        let report = crate::websocket::parse::parse_ws_user_event_to_order_status_report(
2325            &update,
2326            &instrument,
2327            AccountId::new("COINBASE-001"),
2328            UnixNanos::default(),
2329            UnixNanos::default(),
2330        )
2331        .unwrap();
2332        UserOrderUpdate {
2333            report: Box::new(report),
2334            update: Box::new(update),
2335            instrument,
2336            is_snapshot,
2337            ts_event: UnixNanos::default(),
2338            ts_init: UnixNanos::default(),
2339        }
2340    }
2341
2342    fn drain_fill_reports(
2343        rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2344    ) -> Vec<FillReport> {
2345        let mut reports = Vec::new();
2346
2347        while let Ok(event) = rx.try_recv() {
2348            if let ExecutionEvent::Report(ExecutionReport::Fill(report)) = event {
2349                reports.push(*report);
2350            }
2351        }
2352        reports
2353    }
2354
2355    // Drains both `OrderStatusReport`s and `FillReport`s from `rx` in a single
2356    // pass. Tests that need both must use this rather than calling
2357    // `drain_status_reports` and `drain_fill_reports` sequentially, since each
2358    // consumes the channel and discards non-matching events.
2359    fn drain_all_reports(
2360        rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2361    ) -> (Vec<OrderStatusReport>, Vec<FillReport>) {
2362        let mut orders = Vec::new();
2363        let mut fills = Vec::new();
2364
2365        while let Ok(event) = rx.try_recv() {
2366            match event {
2367                ExecutionEvent::Report(ExecutionReport::Order(r)) => orders.push(*r),
2368                ExecutionEvent::Report(ExecutionReport::Fill(r)) => fills.push(*r),
2369                _ => {}
2370            }
2371        }
2372        (orders, fills)
2373    }
2374
2375    fn drain_status_reports(
2376        rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2377    ) -> Vec<OrderStatusReport> {
2378        let mut reports = Vec::new();
2379
2380        while let Ok(event) = rx.try_recv() {
2381            if let ExecutionEvent::Report(ExecutionReport::Order(report)) = event {
2382                reports.push(*report);
2383            }
2384        }
2385        reports
2386    }
2387
2388    fn make_dedup_state_pair() -> (Arc<Mutex<FillDedup>>, Arc<Mutex<CumulativeStateMap>>) {
2389        (
2390            Arc::new(Mutex::new(FillDedup::new(64))),
2391            Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
2392                CUMULATIVE_STATE_CAPACITY,
2393            ))),
2394        )
2395    }
2396
2397    #[rstest]
2398    fn test_handle_user_order_update_emits_status_report_and_no_fill_when_zero_filled() {
2399        let (emitter, mut rx) = make_emitter();
2400        let dedup = Arc::new(Mutex::new(FillDedup::new(64)));
2401        let state = Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
2402            CUMULATIVE_STATE_CAPACITY,
2403        )));
2404
2405        // Open with no fills yet.
2406        let update = make_user_order_update("0", "1.0", "0", "0", CbStatus::Open);
2407        process_user_order_update(
2408            make_carrier(update),
2409            Some(make_limit_context()),
2410            &emitter,
2411            &dedup,
2412            &state,
2413            None,
2414        );
2415
2416        // Status report emitted, no fill report.
2417        let mut got_status = false;
2418        let mut got_fill = false;
2419
2420        while let Ok(event) = rx.try_recv() {
2421            match event {
2422                ExecutionEvent::Report(ExecutionReport::Order(_)) => got_status = true,
2423                ExecutionEvent::Report(ExecutionReport::Fill(_)) => got_fill = true,
2424                _ => {}
2425            }
2426        }
2427        assert!(got_status);
2428        assert!(!got_fill);
2429    }
2430
2431    #[rstest]
2432    fn test_handle_user_order_update_synthesizes_per_fill_price_from_notional_delta() {
2433        let (emitter, mut rx) = make_emitter();
2434        let dedup = Arc::new(Mutex::new(FillDedup::new(64)));
2435        let state = Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
2436            CUMULATIVE_STATE_CAPACITY,
2437        )));
2438
2439        // First partial: 0.5 @ 100, total_fees=0.05.
2440        let update_1 = make_user_order_update("0.5", "0.5", "100.00", "0.05", CbStatus::Open);
2441        process_user_order_update(make_carrier(update_1), None, &emitter, &dedup, &state, None);
2442
2443        // Second partial: cumulative 1.0 @ 110, total_fees=0.15.
2444        // delta_qty = 0.5; per_fill_px = (110*1.0 - 100*0.5) / 0.5 = 120.
2445        // delta_fees = 0.10.
2446        let update_2 = make_user_order_update("1.0", "0", "110.00", "0.15", CbStatus::Filled);
2447        process_user_order_update(make_carrier(update_2), None, &emitter, &dedup, &state, None);
2448
2449        let fills = drain_fill_reports(&mut rx);
2450        assert_eq!(fills.len(), 2);
2451
2452        // First synthesized fill mirrors the first partial.
2453        assert_eq!(fills[0].last_qty, Quantity::from("0.50000000"));
2454        assert_eq!(fills[0].last_px, Price::from("100.00"));
2455        assert_eq!(fills[0].commission.as_decimal().to_string(), "0.05");
2456
2457        // Second synthesized fill is per-fill price (120), not cumulative avg (110).
2458        assert_eq!(fills[1].last_qty, Quantity::from("0.50000000"));
2459        assert_eq!(fills[1].last_px, Price::from("120.00"));
2460        assert_eq!(fills[1].commission.as_decimal().to_string(), "0.10");
2461    }
2462
2463    #[rstest]
2464    fn test_handle_user_order_update_drops_replayed_fills() {
2465        let (emitter, mut rx) = make_emitter();
2466        let dedup = Arc::new(Mutex::new(FillDedup::new(64)));
2467        let state = Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
2468            CUMULATIVE_STATE_CAPACITY,
2469        )));
2470
2471        let update = make_user_order_update("0.5", "0.5", "100.00", "0.05", CbStatus::Open);
2472        process_user_order_update(
2473            make_carrier(update.clone()),
2474            None,
2475            &emitter,
2476            &dedup,
2477            &state,
2478            None,
2479        );
2480
2481        // Simulate a WS reconnect that wipes the cumulative state, then replays
2482        // the same cumulative=0.5 snapshot. The fill_dedup must drop the
2483        // synthesized fill because the trade_id matches the prior emission.
2484        {
2485            let mut s = state.lock().unwrap();
2486            s.clear();
2487        }
2488        process_user_order_update(make_carrier(update), None, &emitter, &dedup, &state, None);
2489
2490        let next_update = make_user_order_update("1.0", "0", "110.00", "0.15", CbStatus::Filled);
2491        process_user_order_update(
2492            make_carrier(next_update),
2493            None,
2494            &emitter,
2495            &dedup,
2496            &state,
2497            None,
2498        );
2499
2500        let fills = drain_fill_reports(&mut rx);
2501        assert_eq!(fills.len(), 2, "replay should be deduplicated");
2502        assert_eq!(fills[0].last_qty, Quantity::from("0.50000000"));
2503        assert_eq!(fills[1].last_qty, Quantity::from("0.50000000"));
2504    }
2505
2506    #[rstest]
2507    fn test_handle_user_order_update_clears_state_on_terminal_status() {
2508        let (emitter, mut rx) = make_emitter();
2509        let dedup = Arc::new(Mutex::new(FillDedup::new(64)));
2510        let state = Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
2511            CUMULATIVE_STATE_CAPACITY,
2512        )));
2513
2514        let update = make_user_order_update("1.0", "0", "100.00", "0.10", CbStatus::Filled);
2515        process_user_order_update(make_carrier(update), None, &emitter, &dedup, &state, None);
2516
2517        // Drain emitted events.
2518        let _ = drain_fill_reports(&mut rx);
2519
2520        let s = state.lock().unwrap();
2521        assert!(
2522            s.get("venue-1").is_none(),
2523            "terminal status should remove cumulative state entry"
2524        );
2525    }
2526
2527    #[rstest]
2528    fn test_handle_user_order_update_skips_when_avg_price_nonpositive() {
2529        let (emitter, mut rx) = make_emitter();
2530        let dedup = Arc::new(Mutex::new(FillDedup::new(64)));
2531        let state = Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
2532            CUMULATIVE_STATE_CAPACITY,
2533        )));
2534
2535        // cumulative_quantity > 0 but avg_price = 0 (defensive: should not emit fill).
2536        let update = make_user_order_update("0.5", "0.5", "0", "0", CbStatus::Open);
2537        process_user_order_update(make_carrier(update), None, &emitter, &dedup, &state, None);
2538
2539        let fills = drain_fill_reports(&mut rx);
2540        assert!(
2541            fills.is_empty(),
2542            "non-positive avg_price should not emit a fill"
2543        );
2544    }
2545
2546    #[rstest]
2547    fn test_handle_user_order_update_snapshot_does_not_synthesize_fill() {
2548        let (emitter, mut rx) = make_emitter();
2549        let dedup = Arc::new(Mutex::new(FillDedup::new(64)));
2550        let state = Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
2551            CUMULATIVE_STATE_CAPACITY,
2552        )));
2553
2554        // Cold-start snapshot: order was already partially filled before we
2555        // subscribed. Cumulative_quantity > 0 with positive avg_price would
2556        // normally synthesize a fill, but the snapshot flag must suppress it.
2557        let update = make_user_order_update("0.5", "0.5", "100.00", "0.05", CbStatus::Open);
2558        process_user_order_update(
2559            make_carrier_with_kind(update, true),
2560            None,
2561            &emitter,
2562            &dedup,
2563            &state,
2564            None,
2565        );
2566
2567        let fills = drain_fill_reports(&mut rx);
2568        assert!(
2569            fills.is_empty(),
2570            "snapshot must not synthesize a fill from pre-existing cumulative state"
2571        );
2572
2573        // The snapshot must seed cumulative_state so that the next live update
2574        // computes a correct delta.
2575        let s = state.lock().unwrap();
2576        let entry = s.get("venue-1").expect("snapshot should seed state");
2577        assert_eq!(entry.filled_qty.unwrap(), Quantity::from("0.50000000"));
2578    }
2579
2580    #[rstest]
2581    fn test_handle_user_order_update_snapshot_then_update_synthesizes_only_delta() {
2582        let (emitter, mut rx) = make_emitter();
2583        let dedup = Arc::new(Mutex::new(FillDedup::new(64)));
2584        let state = Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
2585            CUMULATIVE_STATE_CAPACITY,
2586        )));
2587
2588        // Cold-start snapshot at cumulative=0.5.
2589        let snap = make_user_order_update("0.5", "0.5", "100.00", "0.05", CbStatus::Open);
2590        process_user_order_update(
2591            make_carrier_with_kind(snap, true),
2592            None,
2593            &emitter,
2594            &dedup,
2595            &state,
2596            None,
2597        );
2598
2599        // Subsequent live update at cumulative=1.0 should emit a single fill
2600        // for the 0.5 delta only, not the full cumulative.
2601        let live = make_user_order_update("1.0", "0", "110.00", "0.15", CbStatus::Filled);
2602        process_user_order_update(make_carrier(live), None, &emitter, &dedup, &state, None);
2603
2604        let fills = drain_fill_reports(&mut rx);
2605        assert_eq!(fills.len(), 1);
2606        assert_eq!(fills[0].last_qty, Quantity::from("0.50000000"));
2607        // Per-fill price derived from notional delta: (110*1.0 - 100*0.5) / 0.5 = 120.
2608        assert_eq!(fills[0].last_px, Price::from("120.00"));
2609        // delta_fees = 0.10.
2610        assert_eq!(fills[0].commission.as_decimal().to_string(), "0.10");
2611    }
2612
2613    #[rstest]
2614    fn test_handle_user_order_update_terminal_restores_original_quantity() {
2615        use nautilus_common::messages::{ExecutionEvent, ExecutionReport};
2616
2617        let (emitter, mut rx) = make_emitter();
2618        let dedup = Arc::new(Mutex::new(FillDedup::new(64)));
2619        let state = Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
2620            CUMULATIVE_STATE_CAPACITY,
2621        )));
2622
2623        // Live partial: cumulative=0, leaves=1.0 (full size 1.0 working).
2624        let working = make_user_order_update("0", "1.0", "0", "0", CbStatus::Open);
2625        process_user_order_update(
2626            make_carrier(working),
2627            Some(make_limit_context()),
2628            &emitter,
2629            &dedup,
2630            &state,
2631            None,
2632        );
2633        // Drain the open report.
2634        while rx.try_recv().is_ok() {}
2635
2636        // Cancellation: venue zeroes leaves_quantity. cum+leaves would be 0,
2637        // but the report's quantity must stay 1.0 (the original order size).
2638        let cancelled = make_user_order_update("0", "0", "0", "0", CbStatus::Cancelled);
2639        process_user_order_update(
2640            make_carrier(cancelled),
2641            Some(make_limit_context()),
2642            &emitter,
2643            &dedup,
2644            &state,
2645            None,
2646        );
2647
2648        let mut got_terminal_report: Option<OrderStatusReport> = None;
2649
2650        while let Ok(event) = rx.try_recv() {
2651            if let ExecutionEvent::Report(ExecutionReport::Order(r)) = event {
2652                got_terminal_report = Some(*r);
2653            }
2654        }
2655        let report = got_terminal_report.expect("terminal report emitted");
2656        assert_eq!(
2657            report.quantity,
2658            Quantity::from("1.00000000"),
2659            "terminal report must restore the original order quantity"
2660        );
2661    }
2662
2663    #[rstest]
2664    fn test_process_user_order_update_suppresses_snapshot_without_context() {
2665        // Snapshot for an order we don't have context for must be suppressed
2666        // so the engine reconciler does not panic reconstructing a LIMIT
2667        // order from `report.price = None`.
2668        let (emitter, mut rx) = make_emitter();
2669        let (dedup, state) = make_dedup_state_pair();
2670
2671        let update = make_user_order_update("0", "1.0", "0", "0", CbStatus::Open);
2672        process_user_order_update(
2673            make_carrier_with_kind(update, true),
2674            None,
2675            &emitter,
2676            &dedup,
2677            &state,
2678            None,
2679        );
2680
2681        assert!(drain_status_reports(&mut rx).is_empty());
2682        assert!(drain_fill_reports(&mut rx).is_empty());
2683    }
2684
2685    #[rstest]
2686    fn test_process_user_order_update_emits_snapshot_when_context_present() {
2687        // With a known OrderContext the snapshot OSR is safe to emit and
2688        // the patched price reaches the engine.
2689        let (emitter, mut rx) = make_emitter();
2690        let (dedup, state) = make_dedup_state_pair();
2691        let context = OrderContext {
2692            price: Some(Price::from("100.00")),
2693            ..Default::default()
2694        };
2695
2696        let update = make_user_order_update("0", "1.0", "0", "0", CbStatus::Open);
2697        process_user_order_update(
2698            make_carrier_with_kind(update, true),
2699            Some(context),
2700            &emitter,
2701            &dedup,
2702            &state,
2703            None,
2704        );
2705
2706        let osrs = drain_status_reports(&mut rx);
2707        assert_eq!(osrs.len(), 1);
2708        assert_eq!(osrs[0].price, Some(Price::from("100.00")));
2709    }
2710
2711    #[rstest]
2712    fn test_process_user_order_update_patches_price_and_trigger_from_context() {
2713        // The user channel does not echo `price` / `stop_price` /
2714        // `trigger_type`. Patching from context is what stops the engine
2715        // reconciler clearing the local price.
2716        let (emitter, mut rx) = make_emitter();
2717        let (dedup, state) = make_dedup_state_pair();
2718        let context = OrderContext {
2719            price: Some(Price::from("100.50")),
2720            trigger_price: Some(Price::from("99.00")),
2721            trigger_type: Some(TriggerType::LastPrice),
2722            ..Default::default()
2723        };
2724
2725        let update = make_user_order_update("0", "1.0", "0", "0", CbStatus::Open);
2726        process_user_order_update(
2727            make_carrier(update),
2728            Some(context),
2729            &emitter,
2730            &dedup,
2731            &state,
2732            None,
2733        );
2734
2735        let osrs = drain_status_reports(&mut rx);
2736        assert_eq!(osrs[0].price, Some(Price::from("100.50")));
2737        assert_eq!(osrs[0].trigger_price, Some(Price::from("99.00")));
2738        assert_eq!(osrs[0].trigger_type, Some(TriggerType::LastPrice));
2739    }
2740
2741    #[rstest]
2742    fn test_process_user_order_update_rekeys_to_submitted_product_id() {
2743        // Wire `product_id` is `BTC-USD` (canonical) but the order was
2744        // submitted on the alias side `BTC-USDC`. Both the OSR and the
2745        // synthesized FillReport must surface the submitted id.
2746        let (emitter, mut rx) = make_emitter();
2747        let (dedup, state) = make_dedup_state_pair();
2748        let context = OrderContext {
2749            price: Some(Price::from("100.00")),
2750            submitted_product_id: Some(Ustr::from("BTC-USDC")),
2751            ..Default::default()
2752        };
2753
2754        let update = make_user_order_update("1.0", "0", "100.00", "0.05", CbStatus::Filled);
2755        process_user_order_update(
2756            make_carrier(update),
2757            Some(context),
2758            &emitter,
2759            &dedup,
2760            &state,
2761            None,
2762        );
2763
2764        let (osrs, fills) = drain_all_reports(&mut rx);
2765        assert_eq!(osrs.len(), 1);
2766        assert_eq!(
2767            osrs[0].instrument_id,
2768            InstrumentId::from("BTC-USDC.COINBASE")
2769        );
2770        assert_eq!(fills.len(), 1);
2771        assert_eq!(
2772            fills[0].instrument_id,
2773            InstrumentId::from("BTC-USDC.COINBASE")
2774        );
2775    }
2776
2777    #[rstest]
2778    #[case(true, LiquiditySide::Maker)]
2779    #[case(false, LiquiditySide::NoLiquiditySide)]
2780    fn test_process_user_order_update_stamps_liquidity_side_from_post_only(
2781        #[case] post_only: bool,
2782        #[case] expected: LiquiditySide,
2783    ) {
2784        let (emitter, mut rx) = make_emitter();
2785        let (dedup, state) = make_dedup_state_pair();
2786        let context = OrderContext {
2787            price: Some(Price::from("100.00")),
2788            post_only,
2789            ..Default::default()
2790        };
2791
2792        let update = make_user_order_update("1.0", "0", "100.00", "0.05", CbStatus::Filled);
2793        process_user_order_update(
2794            make_carrier(update),
2795            Some(context),
2796            &emitter,
2797            &dedup,
2798            &state,
2799            None,
2800        );
2801
2802        let fills = drain_fill_reports(&mut rx);
2803        assert_eq!(fills.len(), 1);
2804        assert_eq!(fills[0].liquidity_side, expected);
2805    }
2806
2807    #[rstest]
2808    fn test_process_user_order_update_propagates_post_only_to_status_report() {
2809        // Coinbase's user channel does not echo `post_only`; downstream
2810        // reconstruction would lose maker-only semantics if we did not
2811        // propagate the cached flag to the OrderStatusReport.
2812        let (emitter, mut rx) = make_emitter();
2813        let (dedup, state) = make_dedup_state_pair();
2814        let context = OrderContext {
2815            price: Some(Price::from("100.00")),
2816            post_only: true,
2817            ..Default::default()
2818        };
2819
2820        let update = make_user_order_update("0", "1.0", "0", "0", CbStatus::Open);
2821        process_user_order_update(
2822            make_carrier(update),
2823            Some(context),
2824            &emitter,
2825            &dedup,
2826            &state,
2827            None,
2828        );
2829
2830        let osrs = drain_status_reports(&mut rx);
2831        assert_eq!(osrs.len(), 1);
2832        assert!(osrs[0].post_only);
2833    }
2834
2835    #[rstest]
2836    #[case(OrderType::Limit)]
2837    #[case(OrderType::StopLimit)]
2838    fn test_process_user_order_update_suppresses_unsafe_report_when_enrichment_unavailable(
2839        #[case] order_type: OrderType,
2840    ) {
2841        // For LIMIT / STOP_LIMIT orders, missing `price` (or `trigger_price`)
2842        // would panic the engine reconciler. When enrichment is unavailable
2843        // the OSR must be suppressed rather than emitted with `None` fields.
2844        let (emitter, mut rx) = make_emitter();
2845        let (dedup, state) = make_dedup_state_pair();
2846        let mut update = make_user_order_update("0", "1.0", "0", "0", CbStatus::Open);
2847        update.order_type = match order_type {
2848            OrderType::Limit => CbType::Limit,
2849            OrderType::StopLimit => CbType::StopLimit,
2850            _ => CbType::Limit,
2851        };
2852
2853        process_user_order_update(make_carrier(update), None, &emitter, &dedup, &state, None);
2854
2855        assert!(drain_status_reports(&mut rx).is_empty());
2856    }
2857
2858    #[rstest]
2859    fn test_process_user_order_update_trade_id_fits_stack_str() {
2860        // A full Coinbase venue UUID is 36 characters; concatenating the
2861        // cumulative qty would overflow `TradeId`'s 36-char stack string,
2862        // so the synthesized id is `{order_id_prefix_8}-{cumulative_qty}`.
2863        let (emitter, mut rx) = make_emitter();
2864        let (dedup, state) = make_dedup_state_pair();
2865        let mut update = make_user_order_update("1.0", "0", "100.00", "0.05", CbStatus::Filled);
2866        update.order_id = "11d357f0-155e-4ed4-b87c-1cf966f65d10".to_string();
2867
2868        process_user_order_update(make_carrier(update), None, &emitter, &dedup, &state, None);
2869
2870        let fills = drain_fill_reports(&mut rx);
2871        assert_eq!(fills.len(), 1);
2872        let trade_id = fills[0].trade_id.as_str();
2873        assert!(
2874            trade_id.len() <= 36,
2875            "trade_id was {} chars",
2876            trade_id.len()
2877        );
2878        assert!(
2879            trade_id.starts_with("11d357f0-"),
2880            "trade_id should start with the 8-char prefix, was {trade_id}",
2881        );
2882    }
2883}