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