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