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