1use std::{
19 collections::VecDeque,
20 future::Future,
21 str::FromStr,
22 sync::{Arc, Mutex},
23 time::{Duration, Instant},
24};
25
26use ahash::AHashMap;
27use anyhow::Context;
28use async_trait::async_trait;
29use nautilus_common::{
30 clients::ExecutionClient,
31 live::{get_runtime, runner::get_exec_event_sender},
32 messages::execution::{
33 BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
34 GenerateFillReportsBuilder, GenerateOrderStatusReport, GenerateOrderStatusReports,
35 GenerateOrderStatusReportsBuilder, GeneratePositionStatusReports,
36 GeneratePositionStatusReportsBuilder, ModifyOrder, QueryAccount, QueryOrder, SubmitOrder,
37 },
38};
39use nautilus_core::{
40 MUTEX_POISONED, UnixNanos,
41 time::{AtomicTime, get_atomic_clock_realtime},
42};
43use nautilus_live::{ExecutionClientCore, ExecutionEventEmitter};
44use nautilus_model::{
45 accounts::AccountAny,
46 enums::{AccountType, LiquiditySide, OmsType, OrderSide, OrderStatus, OrderType, TriggerType},
47 identifiers::{
48 AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, Symbol, TradeId, Venue,
49 VenueOrderId,
50 },
51 instruments::{Instrument, InstrumentAny},
52 orders::Order,
53 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
54 types::{AccountBalance, MarginBalance, Money, Price, Quantity},
55};
56use nautilus_network::retry::RetryConfig;
57use rust_decimal::Decimal;
58use tokio::task::JoinHandle;
59use ustr::Ustr;
60
61use crate::{
62 common::{
63 consts::COINBASE_VENUE,
64 credential::CoinbaseCredential,
65 enums::{CoinbaseProductType, CoinbaseWsChannel},
66 },
67 config::CoinbaseExecClientConfig,
68 http::{
69 client::CoinbaseHttpClient,
70 error::Error as CoinbaseHttpError,
71 parse::{parse_quantity, parse_ws_cfm_account_state},
72 },
73 websocket::{
74 client::CoinbaseWebSocketClient,
75 handler::{NautilusWsMessage, UserOrderUpdate},
76 messages::WsOrderUpdate,
77 parse::parse_ws_user_event_to_fill_report,
78 },
79};
80
81const BATCH_CANCEL_CHUNK: usize = 100;
84
85const FILL_DEDUP_CAPACITY: usize = 10_000;
89
90const CUMULATIVE_STATE_CAPACITY: usize = 10_000;
94
95const ACCOUNT_REGISTERED_TIMEOUT_SECS: f64 = 30.0;
98
99#[derive(Debug)]
100struct FillDedup {
101 seen: AHashMap<(String, String), ()>,
102 order: VecDeque<(String, String)>,
103 capacity: usize,
104}
105
106impl FillDedup {
107 fn new(capacity: usize) -> Self {
108 Self {
109 seen: AHashMap::with_capacity(capacity),
110 order: VecDeque::with_capacity(capacity),
111 capacity,
112 }
113 }
114
115 fn insert(&mut self, key: (String, String)) -> bool {
117 if self.seen.contains_key(&key) {
118 return false;
119 }
120
121 if self.order.len() >= self.capacity
122 && let Some(oldest) = self.order.pop_front()
123 {
124 self.seen.remove(&oldest);
125 }
126 self.order.push_back(key.clone());
127 self.seen.insert(key, ());
128 true
129 }
130}
131
132#[derive(Debug, Default, Clone)]
145struct OrderCumulativeState {
146 filled_qty: Option<Quantity>,
147 total_fees: Decimal,
148 avg_price: Decimal,
149 quantity: Option<Quantity>,
150}
151
152#[derive(Debug, Default, Clone)]
159struct OrderContext {
160 price: Option<Price>,
161 trigger_price: Option<Price>,
162 trigger_type: Option<TriggerType>,
163 post_only: bool,
168 submitted_product_id: Option<Ustr>,
176}
177
178#[derive(Debug)]
184struct CumulativeStateMap {
185 map: AHashMap<String, OrderCumulativeState>,
186 order: VecDeque<String>,
187 capacity: usize,
188}
189
190impl CumulativeStateMap {
191 fn with_capacity(capacity: usize) -> Self {
192 Self {
193 map: AHashMap::with_capacity(capacity),
194 order: VecDeque::with_capacity(capacity),
195 capacity,
196 }
197 }
198
199 fn entry_or_default(&mut self, key: &str) -> &mut OrderCumulativeState {
200 if self.map.contains_key(key) {
201 if let Some(pos) = self.order.iter().position(|k| k == key) {
206 self.order.remove(pos);
207 }
208 self.order.push_back(key.to_string());
209 } else {
210 self.evict_until_capacity_or_empty();
211 self.order.push_back(key.to_string());
212 self.map
213 .insert(key.to_string(), OrderCumulativeState::default());
214 }
215 self.map
216 .get_mut(key)
217 .expect("key was just inserted or confirmed present")
218 }
219
220 fn remove(&mut self, key: &str) {
221 if self.map.remove(key).is_some() {
222 self.order.retain(|k| k != key);
227 }
228 }
229
230 fn evict_until_capacity_or_empty(&mut self) {
231 while self.map.len() >= self.capacity {
235 match self.order.pop_front() {
236 Some(oldest) => {
237 self.map.remove(&oldest);
238 }
239 None => break,
240 }
241 }
242
243 if self.order.len() > 2 * self.capacity {
249 self.order.retain(|key| self.map.contains_key(key));
250 }
251 }
252
253 #[cfg(test)]
254 fn len(&self) -> usize {
255 self.map.len()
256 }
257
258 #[cfg(test)]
259 fn get(&self, key: &str) -> Option<&OrderCumulativeState> {
260 self.map.get(key)
261 }
262
263 #[cfg(test)]
264 fn clear(&mut self) {
265 self.map.clear();
266 self.order.clear();
267 }
268}
269
270#[derive(Debug)]
272pub struct CoinbaseExecutionClient {
273 core: ExecutionClientCore,
274 clock: &'static AtomicTime,
275 config: CoinbaseExecClientConfig,
276 emitter: ExecutionEventEmitter,
277 http_client: CoinbaseHttpClient,
278 ws_user: CoinbaseWebSocketClient,
279 ws_stream_handle: Option<JoinHandle<()>>,
280 pending_tasks: Mutex<Vec<JoinHandle<()>>>,
281 instruments_cache: Arc<AHashMap<String, InstrumentAny>>,
282 fill_dedup: Arc<Mutex<FillDedup>>,
283 cumulative_state: Arc<Mutex<CumulativeStateMap>>,
284 order_contexts: Arc<Mutex<AHashMap<String, OrderContext>>>,
285 external_order_contexts: Arc<Mutex<AHashMap<String, OrderContext>>>,
292}
293
294impl CoinbaseExecutionClient {
295 pub fn new(
302 core: ExecutionClientCore,
303 config: CoinbaseExecClientConfig,
304 ) -> anyhow::Result<Self> {
305 let credential =
306 CoinbaseCredential::resolve(config.api_key.as_deref(), config.api_secret.as_deref())
307 .ok_or_else(|| {
308 anyhow::anyhow!(
309 "Coinbase credentials not available; set COINBASE_API_KEY and COINBASE_API_SECRET or pass them in the config"
310 )
311 })?;
312
313 let retry_config = RetryConfig {
314 max_retries: config.max_retries,
315 initial_delay_ms: config.retry_delay_initial_ms,
316 max_delay_ms: config.retry_delay_max_ms,
317 backoff_factor: 2.0,
318 jitter_ms: 250,
319 operation_timeout_ms: Some(60_000),
320 immediate_first: false,
321 max_elapsed_ms: Some(180_000),
322 };
323
324 let http_client = CoinbaseHttpClient::with_credentials(
325 credential.clone(),
326 config.environment,
327 config.http_timeout_secs,
328 config.proxy_url.clone(),
329 Some(retry_config),
330 )
331 .map_err(|e| anyhow::anyhow!("Failed to create Coinbase HTTP client: {e}"))?;
332
333 if let Some(ref url) = config.base_url_rest {
334 http_client.set_base_url(url.clone());
335 }
336
337 let ws_url = config.ws_url();
338 let ws_user = CoinbaseWebSocketClient::with_credential(
339 &ws_url,
340 credential,
341 config.transport_backend,
342 config.proxy_url.clone(),
343 );
344
345 let clock = get_atomic_clock_realtime();
346 let emitter = ExecutionEventEmitter::new(
347 clock,
348 core.trader_id,
349 core.account_id,
350 core.account_type,
351 None,
352 );
353
354 Ok(Self {
355 core,
356 clock,
357 config,
358 emitter,
359 http_client,
360 ws_user,
361 ws_stream_handle: None,
362 pending_tasks: Mutex::new(Vec::new()),
363 instruments_cache: Arc::new(AHashMap::new()),
364 fill_dedup: Arc::new(Mutex::new(FillDedup::new(FILL_DEDUP_CAPACITY))),
365 cumulative_state: Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
366 CUMULATIVE_STATE_CAPACITY,
367 ))),
368 order_contexts: Arc::new(Mutex::new(AHashMap::new())),
369 external_order_contexts: Arc::new(Mutex::new(AHashMap::new())),
370 })
371 }
372
373 fn spawn_task<F>(&self, description: &'static str, fut: F)
374 where
375 F: Future<Output = anyhow::Result<()>> + Send + 'static,
376 {
377 let runtime = get_runtime();
378 let handle = runtime.spawn(async move {
379 if let Err(e) = fut.await {
380 log::warn!("{description} failed: {e:?}");
381 }
382 });
383
384 let mut tasks = self.pending_tasks.lock().expect(MUTEX_POISONED);
385 tasks.retain(|h| !h.is_finished());
386 tasks.push(handle);
387 }
388
389 fn abort_pending_tasks(&self) {
390 let mut tasks = self.pending_tasks.lock().expect(MUTEX_POISONED);
391 for handle in tasks.drain(..) {
392 handle.abort();
393 }
394 }
395
396 fn is_margin(&self) -> bool {
399 self.core.account_type == AccountType::Margin
400 }
401
402 fn is_instrument_cached(&self, instrument_id: &InstrumentId) -> bool {
406 self.instruments_cache
407 .contains_key(instrument_id.symbol.as_str())
408 }
409
410 async fn await_account_registered(&self, timeout_secs: f64) -> anyhow::Result<()> {
412 let account_id = self.core.account_id;
413
414 if self.core.cache().account(&account_id).is_some() {
415 log::info!("Account {account_id} registered");
416 return Ok(());
417 }
418
419 let start = Instant::now();
420 let timeout = Duration::from_secs_f64(timeout_secs);
421 let interval = Duration::from_millis(10);
422
423 loop {
424 tokio::time::sleep(interval).await;
425
426 if self.core.cache().account(&account_id).is_some() {
427 log::info!("Account {account_id} registered");
428 return Ok(());
429 }
430
431 if start.elapsed() >= timeout {
432 anyhow::bail!(
433 "Timeout waiting for account {account_id} to be registered after {timeout_secs}s"
434 );
435 }
436 }
437 }
438}
439
440fn unix_nanos_to_utc(ts: UnixNanos) -> anyhow::Result<chrono::DateTime<chrono::Utc>> {
443 let secs = (ts.as_u64() / 1_000_000_000) as i64;
444 let nanos = (ts.as_u64() % 1_000_000_000) as u32;
445 chrono::DateTime::<chrono::Utc>::from_timestamp(secs, nanos)
446 .ok_or_else(|| anyhow::anyhow!("UnixNanos {ts} is out of range for chrono::DateTime"))
447}
448
449#[async_trait(?Send)]
450impl ExecutionClient for CoinbaseExecutionClient {
451 fn is_connected(&self) -> bool {
452 self.core.is_connected()
453 }
454
455 fn client_id(&self) -> ClientId {
456 self.core.client_id
457 }
458
459 fn account_id(&self) -> AccountId {
460 self.core.account_id
461 }
462
463 fn venue(&self) -> Venue {
464 *COINBASE_VENUE
465 }
466
467 fn oms_type(&self) -> OmsType {
468 self.core.oms_type
469 }
470
471 fn get_account(&self) -> Option<AccountAny> {
472 self.core.cache().account_owned(&self.core.account_id)
473 }
474
475 async fn connect(&mut self) -> anyhow::Result<()> {
476 if self.core.is_connected() {
477 return Ok(());
478 }
479
480 if self.ws_user.is_active() || self.ws_user.is_reconnecting() {
487 log::debug!("Tearing down stale user WS before reconnect");
488 self.ws_user.disconnect().await;
489 if let Some(handle) = self.ws_stream_handle.take() {
492 handle.abort();
493 }
494 let credential = CoinbaseCredential::resolve(
495 self.config.api_key.as_deref(),
496 self.config.api_secret.as_deref(),
497 )
498 .ok_or_else(|| anyhow::anyhow!("Coinbase credentials unavailable for WS reset"))?;
499 self.ws_user = CoinbaseWebSocketClient::with_credential(
500 &self.config.ws_url(),
501 credential,
502 self.config.transport_backend,
503 self.config.proxy_url.clone(),
504 );
505 }
506
507 if self.core.instruments_initialized() {
508 let cached: Vec<InstrumentAny> = self.instruments_cache.values().cloned().collect();
511 if !cached.is_empty() {
512 self.ws_user.initialize_instruments(cached).await;
513 }
514 } else {
515 let instruments = if self.is_margin() {
521 self.http_client
522 .request_instruments(Some(CoinbaseProductType::Future))
523 .await
524 .context("failed to load Coinbase futures instruments")?
525 } else {
526 self.http_client
527 .request_instruments(Some(CoinbaseProductType::Spot))
528 .await
529 .context("failed to load Coinbase instruments")?
530 };
531
532 let product_kind = if self.is_margin() { "futures" } else { "spot" };
533
534 if instruments.is_empty() {
535 log::warn!("Coinbase instrument bootstrap returned no {product_kind} instruments");
536 } else {
537 log::debug!(
538 "Coinbase exec client loaded {} {product_kind} instruments",
539 instruments.len()
540 );
541 }
542
543 let mut map: AHashMap<String, InstrumentAny> =
544 AHashMap::with_capacity(instruments.len());
545 for inst in &instruments {
546 map.insert(inst.id().symbol.as_str().to_string(), inst.clone());
547 }
548 self.instruments_cache = Arc::new(map);
549
550 self.ws_user.initialize_instruments(instruments).await;
553
554 self.core.set_instruments_initialized();
555 }
556
557 self.ws_user.set_account_id(self.core.account_id).await;
558 self.ws_user.connect().await?;
559
560 self.ws_user
563 .subscribe(CoinbaseWsChannel::User, &[])
564 .await
565 .context("failed to subscribe to Coinbase user channel")?;
566
567 if self.is_margin() {
568 self.ws_user
569 .subscribe(CoinbaseWsChannel::FuturesBalanceSummary, &[])
570 .await
571 .context("failed to subscribe to Coinbase futures_balance_summary channel")?;
572 }
573
574 if let Some(mut rx) = self.ws_user.take_out_rx() {
575 let fill_dedup = Arc::clone(&self.fill_dedup);
576 let cumulative_state = Arc::clone(&self.cumulative_state);
577 let order_contexts = Arc::clone(&self.order_contexts);
578 let external_order_contexts = Arc::clone(&self.external_order_contexts);
579 let emitter = self.emitter.clone();
580 let http_client = self.http_client.clone();
581 let account_id = self.core.account_id;
582 let clock = self.clock;
583 let is_margin = self.is_margin();
584
585 let handle = get_runtime().spawn(async move {
586 while let Some(message) = rx.recv().await {
587 match message {
588 NautilusWsMessage::UserOrder(carrier) => {
589 handle_user_order_update(
590 *carrier,
591 &emitter,
592 &fill_dedup,
593 &cumulative_state,
594 &order_contexts,
595 &external_order_contexts,
596 &http_client,
597 account_id,
598 )
599 .await;
600 }
601 NautilusWsMessage::FuturesBalanceSummary(summary) => {
602 let ts = clock.get_time_ns();
603 match parse_ws_cfm_account_state(&summary, account_id, ts, ts) {
604 Ok(state) => emitter.send_account_state(state),
605 Err(e) => log::warn!(
606 "Failed to parse futures_balance_summary into AccountState: {e}"
607 ),
608 }
609 }
610 NautilusWsMessage::Reconnected => {
611 log::info!("Coinbase user WebSocket reconnected");
612 let refresh = if is_margin {
618 http_client.request_cfm_account_state(account_id).await
619 } else {
620 http_client.request_account_state(account_id).await
621 };
622
623 match refresh {
624 Ok(state) => emitter.send_account_state(state),
625 Err(e) => {
626 log::warn!("Failed to refresh account state on reconnect: {e}");
627 }
628 }
629 }
630 NautilusWsMessage::Error(err) => {
631 log::warn!("Coinbase user WebSocket error: {err}");
632 }
633 _ => {}
634 }
635 }
636 });
637 self.ws_stream_handle = Some(handle);
638 }
639
640 let account_state = if self.is_margin() {
641 self.http_client
642 .request_cfm_account_state(self.core.account_id)
643 .await
644 .context("failed to request Coinbase CFM account state")?
645 } else {
646 self.http_client
647 .request_account_state(self.core.account_id)
648 .await
649 .context("failed to request Coinbase account state")?
650 };
651
652 if !account_state.balances.is_empty() {
653 log::debug!(
654 "Received account state with {} balance(s)",
655 account_state.balances.len()
656 );
657 }
658 self.emitter.send_account_state(account_state);
659
660 self.await_account_registered(ACCOUNT_REGISTERED_TIMEOUT_SECS)
661 .await?;
662
663 self.core.set_connected();
664 log::info!("Connected: client_id={}", self.core.client_id);
665 Ok(())
666 }
667
668 async fn disconnect(&mut self) -> anyhow::Result<()> {
669 if self.core.is_disconnected() {
670 return Ok(());
671 }
672
673 self.abort_pending_tasks();
674 self.ws_user.disconnect().await;
675
676 if let Some(handle) = self.ws_stream_handle.take() {
677 handle.abort();
678 }
679
680 self.core.set_disconnected();
681 log::info!("Disconnected: client_id={}", self.core.client_id);
682 Ok(())
683 }
684
685 fn start(&mut self) -> anyhow::Result<()> {
686 if self.core.is_started() {
687 return Ok(());
688 }
689
690 let sender = get_exec_event_sender();
691 self.emitter.set_sender(sender);
692 self.core.set_started();
693
694 log::info!(
695 "Started: client_id={}, account_id={}, account_type={:?}, environment={:?}",
696 self.core.client_id,
697 self.core.account_id,
698 self.core.account_type,
699 self.config.environment,
700 );
701 Ok(())
702 }
703
704 fn stop(&mut self) -> anyhow::Result<()> {
705 if self.core.is_stopped() {
706 return Ok(());
707 }
708
709 self.core.set_stopped();
710 self.core.set_disconnected();
711
712 if let Some(handle) = self.ws_stream_handle.take() {
713 handle.abort();
714 }
715 self.abort_pending_tasks();
716 log::info!("Stopped: client_id={}", self.core.client_id);
717 Ok(())
718 }
719
720 fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
721 let http_client = self.http_client.clone();
722 let account_id = self.core.account_id;
723 let emitter = self.emitter.clone();
724 let is_margin = self.is_margin();
725
726 self.spawn_task("query_account", async move {
727 let account_state = if is_margin {
728 http_client
729 .request_cfm_account_state(account_id)
730 .await
731 .context("failed to request Coinbase CFM account state")?
732 } else {
733 http_client
734 .request_account_state(account_id)
735 .await
736 .context("failed to request Coinbase account state")?
737 };
738 emitter.send_account_state(account_state);
739 Ok(())
740 });
741 Ok(())
742 }
743
744 fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
745 let http_client = self.http_client.clone();
746 let account_id = self.core.account_id;
747 let emitter = self.emitter.clone();
748 let client_order_id = Some(cmd.client_order_id);
749 let venue_order_id = cmd.venue_order_id;
750
751 self.spawn_task("query_order", async move {
752 match http_client
753 .request_order_status_report(account_id, client_order_id, venue_order_id)
754 .await
755 {
756 Ok(report) => emitter.send_order_status_report(report),
757 Err(e) => log::warn!("Failed to query order: {e}"),
758 }
759 Ok(())
760 });
761
762 Ok(())
763 }
764
765 fn generate_account_state(
766 &self,
767 balances: Vec<AccountBalance>,
768 margins: Vec<MarginBalance>,
769 reported: bool,
770 ts_event: UnixNanos,
771 ) -> anyhow::Result<()> {
772 self.emitter
773 .emit_account_state(balances, margins, reported, ts_event);
774 Ok(())
775 }
776
777 async fn generate_order_status_report(
778 &self,
779 cmd: &GenerateOrderStatusReport,
780 ) -> anyhow::Result<Option<OrderStatusReport>> {
781 let report = self
782 .http_client
783 .request_order_status_report(
784 self.core.account_id,
785 cmd.client_order_id,
786 cmd.venue_order_id,
787 )
788 .await
789 .ok();
790
791 Ok(report.filter(|r| self.is_instrument_cached(&r.instrument_id)))
796 }
797
798 async fn generate_order_status_reports(
799 &self,
800 cmd: &GenerateOrderStatusReports,
801 ) -> anyhow::Result<Vec<OrderStatusReport>> {
802 let start = cmd.start.map(unix_nanos_to_utc).transpose()?;
803 let end = cmd.end.map(unix_nanos_to_utc).transpose()?;
804
805 let mut reports = self
806 .http_client
807 .request_order_status_reports(
808 self.core.account_id,
809 cmd.instrument_id,
810 cmd.open_only,
811 start,
812 end,
813 None,
814 )
815 .await?;
816
817 let before = reports.len();
818 reports.retain(|r| self.is_instrument_cached(&r.instrument_id));
819 if reports.len() != before {
820 let scope = if self.is_margin() {
821 "non-futures"
822 } else {
823 "non-spot"
824 };
825 log::debug!("Filtered {} {scope} order reports", before - reports.len());
826 }
827 Ok(reports)
828 }
829
830 async fn generate_fill_reports(
831 &self,
832 cmd: GenerateFillReports,
833 ) -> anyhow::Result<Vec<FillReport>> {
834 let start = cmd.start.map(unix_nanos_to_utc).transpose()?;
835 let end = cmd.end.map(unix_nanos_to_utc).transpose()?;
836
837 let mut reports = self
838 .http_client
839 .request_fill_reports(
840 self.core.account_id,
841 cmd.instrument_id,
842 cmd.venue_order_id,
843 start,
844 end,
845 None,
846 )
847 .await?;
848
849 let before = reports.len();
850 reports.retain(|r| self.is_instrument_cached(&r.instrument_id));
851 if reports.len() != before {
852 let scope = if self.is_margin() {
853 "non-futures"
854 } else {
855 "non-spot"
856 };
857 log::debug!("Filtered {} {scope} fill reports", before - reports.len());
858 }
859 Ok(reports)
860 }
861
862 async fn generate_position_status_reports(
863 &self,
864 cmd: &GeneratePositionStatusReports,
865 ) -> anyhow::Result<Vec<PositionStatusReport>> {
866 if !self.is_margin() {
868 return Ok(Vec::new());
869 }
870
871 if let Some(instrument_id) = cmd.instrument_id {
876 let report = self
877 .http_client
878 .request_position_status_report(self.core.account_id, instrument_id)
879 .await
880 .with_context(|| format!("failed to request CFM position for {instrument_id}"))?;
881 Ok(report.map(|r| vec![r]).unwrap_or_default())
882 } else {
883 self.http_client
884 .request_position_status_reports(self.core.account_id)
885 .await
886 .context("failed to request CFM positions")
887 }
888 }
889
890 async fn generate_mass_status(
891 &self,
892 lookback_mins: Option<u64>,
893 ) -> anyhow::Result<Option<ExecutionMassStatus>> {
894 log::info!("Generating ExecutionMassStatus (lookback_mins={lookback_mins:?})");
895
896 let ts_now = self.clock.get_time_ns();
897 let start = lookback_mins.map(|mins| {
898 let lookback_ns = mins * 60 * 1_000_000_000;
899 UnixNanos::from(ts_now.as_u64().saturating_sub(lookback_ns))
900 });
901
902 let order_cmd = GenerateOrderStatusReportsBuilder::default()
903 .ts_init(ts_now)
904 .open_only(false)
905 .start(start)
906 .build()
907 .map_err(|e| anyhow::anyhow!("{e}"))?;
908 let fill_cmd = GenerateFillReportsBuilder::default()
909 .ts_init(ts_now)
910 .start(start)
911 .build()
912 .map_err(|e| anyhow::anyhow!("{e}"))?;
913 let position_cmd = GeneratePositionStatusReportsBuilder::default()
914 .ts_init(ts_now)
915 .build()
916 .map_err(|e| anyhow::anyhow!("{e}"))?;
917
918 let (order_reports, fill_reports, position_reports) = tokio::try_join!(
919 self.generate_order_status_reports(&order_cmd),
920 self.generate_fill_reports(fill_cmd),
921 self.generate_position_status_reports(&position_cmd),
922 )?;
923
924 log::info!("Received {} OrderStatusReports", order_reports.len());
925 log::info!("Received {} FillReports", fill_reports.len());
926 log::info!("Received {} PositionReports", position_reports.len());
927
928 let mut mass_status = ExecutionMassStatus::new(
929 self.core.client_id,
930 self.core.account_id,
931 *COINBASE_VENUE,
932 ts_now,
933 None,
934 );
935
936 mass_status.add_order_reports(order_reports);
937 mass_status.add_fill_reports(fill_reports);
938 mass_status.add_position_reports(position_reports);
939
940 Ok(Some(mass_status))
941 }
942
943 fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
944 let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
945 if order.is_closed() {
946 log::warn!("Cannot submit closed order {}", order.client_order_id());
947 return Ok(());
948 }
949
950 let instrument_id = order.instrument_id();
956 let symbol_key = instrument_id.symbol.as_str();
957 if !self.instruments_cache.contains_key(symbol_key) {
958 let scope = if self.is_margin() {
959 "a Coinbase futures / perpetual product"
960 } else {
961 "a Coinbase spot product"
962 };
963 self.emitter.emit_order_denied(
964 &order,
965 &format!(
966 "Instrument {} is not {scope} in this client's bootstrap cache",
967 order.instrument_id()
968 ),
969 );
970 return Ok(());
971 }
972
973 log::debug!("OrderSubmitted client_order_id={}", order.client_order_id());
981 self.emitter.emit_order_submitted(&order);
982
983 let http_client = self.http_client.clone();
984 let emitter = self.emitter.clone();
985 let order_contexts = Arc::clone(&self.order_contexts);
986 let clock = self.clock;
987 let strategy_id = order.strategy_id();
988 let client_order_id = order.client_order_id();
989 let side = order.order_side();
990 let order_type = order.order_type();
991 let quantity = order.quantity();
992 let time_in_force = order.time_in_force();
993 let price = order.price();
994 let trigger_price = order.trigger_price();
995 let trigger_type = order.trigger_type();
996 let expire_time = order.expire_time();
997 let post_only = order.is_post_only();
998 let is_quote_quantity = order.is_quote_quantity();
999 let reduce_only = order.is_reduce_only();
1000
1001 {
1008 let mut map = self.order_contexts.lock().expect(MUTEX_POISONED);
1009 map.insert(
1010 client_order_id.to_string(),
1011 OrderContext {
1012 price,
1013 trigger_price,
1014 trigger_type,
1015 post_only,
1016 submitted_product_id: Some(instrument_id.symbol.inner()),
1017 },
1018 );
1019 }
1020 let (leverage, margin_type) = if self.core.account_type == AccountType::Margin {
1021 (
1022 self.config.default_leverage,
1023 self.config.default_margin_type,
1024 )
1025 } else {
1026 (None, None)
1027 };
1028 let retail_portfolio_id = self.config.retail_portfolio_id.clone();
1029
1030 self.spawn_task("submit_order", async move {
1031 let result = http_client
1032 .submit_order(
1033 client_order_id,
1034 instrument_id,
1035 side,
1036 order_type,
1037 quantity,
1038 time_in_force,
1039 price,
1040 trigger_price,
1041 expire_time,
1042 post_only,
1043 is_quote_quantity,
1044 leverage,
1045 margin_type,
1046 reduce_only,
1047 retail_portfolio_id,
1048 )
1049 .await;
1050
1051 match result {
1052 Ok(response) => {
1053 if response.success {
1054 let venue_id = response
1055 .success_response
1056 .as_ref()
1057 .map(|s| s.order_id.clone())
1058 .unwrap_or(response.order_id);
1059
1060 if venue_id.is_empty() {
1061 log::warn!(
1062 "Submit succeeded but no order_id returned for {client_order_id}"
1063 );
1064 } else {
1065 let venue_order_id = VenueOrderId::new(&venue_id);
1066 let ts_event = clock.get_time_ns();
1067 emitter.emit_order_accepted(&order, venue_order_id, ts_event);
1068 }
1069 } else {
1070 let reason = response.error_response.as_ref().map_or_else(
1071 || response.failure_reason.clone(),
1072 |e| format!("{}: {}", e.error, e.message),
1073 );
1074 let due_post_only = reason.contains("INVALID_LIMIT_PRICE_POST_ONLY")
1080 || response.error_response.as_ref().is_some_and(|e| {
1081 e.preview_failure_reason == "PREVIEW_INVALID_LIMIT_PRICE_POSTONLY"
1082 || e.new_order_failure_reason == "INVALID_LIMIT_PRICE_POST_ONLY"
1083 });
1084 order_contexts
1088 .lock()
1089 .expect(MUTEX_POISONED)
1090 .remove(client_order_id.as_str());
1091 let ts_event = clock.get_time_ns();
1092 emitter.emit_order_rejected_event(
1093 strategy_id,
1094 instrument_id,
1095 client_order_id,
1096 &format!("submit-order-rejected: {reason}"),
1097 ts_event,
1098 due_post_only,
1099 );
1100 }
1101 }
1102 Err(e) => {
1103 if is_coinbase_local_submit_failure(&e) {
1104 order_contexts
1105 .lock()
1106 .expect(MUTEX_POISONED)
1107 .remove(client_order_id.as_str());
1108 let ts_event = clock.get_time_ns();
1109 emitter.emit_order_rejected_event(
1110 strategy_id,
1111 instrument_id,
1112 client_order_id,
1113 &format!("submit-order-error: {e}"),
1114 ts_event,
1115 false,
1116 );
1117 } else if is_coinbase_explicit_submit_rejection(&e) {
1118 order_contexts
1119 .lock()
1120 .expect(MUTEX_POISONED)
1121 .remove(client_order_id.as_str());
1122 let ts_event = clock.get_time_ns();
1123 emitter.emit_order_rejected_event(
1124 strategy_id,
1125 instrument_id,
1126 client_order_id,
1127 &format!("submit-order-rejected: {e}"),
1128 ts_event,
1129 false,
1130 );
1131 } else if is_coinbase_ambiguous_command_failure(&e) {
1132 log::warn!(
1133 "Ambiguous submit failure for {client_order_id}, awaiting reconciliation: {e}"
1134 );
1135 } else {
1136 order_contexts
1137 .lock()
1138 .expect(MUTEX_POISONED)
1139 .remove(client_order_id.as_str());
1140 log::warn!(
1141 "Submit command failed without venue-declared outcome for {client_order_id}: {e}"
1142 );
1143 }
1144 return Err(e.context("submit order failed"));
1145 }
1146 }
1147 Ok(())
1148 });
1149
1150 Ok(())
1151 }
1152
1153 fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
1154 let ts_event = self.clock.get_time_ns();
1155
1156 let Some(venue_order_id) = cmd.venue_order_id else {
1157 self.emitter.emit_order_modify_rejected_event(
1158 cmd.strategy_id,
1159 cmd.instrument_id,
1160 cmd.client_order_id,
1161 None,
1162 "modify-order requires venue_order_id",
1163 ts_event,
1164 );
1165 return Ok(());
1166 };
1167
1168 if cmd.price.is_none() && cmd.quantity.is_none() && cmd.trigger_price.is_none() {
1169 self.emitter.emit_order_modify_rejected_event(
1170 cmd.strategy_id,
1171 cmd.instrument_id,
1172 cmd.client_order_id,
1173 Some(venue_order_id),
1174 "modify-order requires price, quantity, or trigger_price",
1175 ts_event,
1176 );
1177 return Ok(());
1178 }
1179
1180 let (auto_price, auto_quantity) = {
1187 let cache = self.core.cache();
1188 let cached = cache.order(&cmd.client_order_id);
1189 let cached_price = cached.as_ref().and_then(|o| o.price());
1190 let cached_qty = cached.as_ref().map(|o| o.quantity());
1191 (cmd.price.or(cached_price), cmd.quantity.or(cached_qty))
1192 };
1193
1194 let http_client = self.http_client.clone();
1195 let emitter = self.emitter.clone();
1196 let order_contexts = Arc::clone(&self.order_contexts);
1197 let clock = self.clock;
1198 let strategy_id = cmd.strategy_id;
1199 let instrument_id = cmd.instrument_id;
1200 let client_order_id = cmd.client_order_id;
1201 let price = auto_price;
1202 let quantity = auto_quantity;
1203 let trigger_price = cmd.trigger_price;
1204
1205 self.spawn_task("modify_order", async move {
1206 let result = http_client
1207 .modify_order(venue_order_id, price, quantity, trigger_price)
1208 .await;
1209
1210 match result {
1211 Ok(resp) => {
1212 if resp.success {
1213 let mut map = order_contexts.lock().expect(MUTEX_POISONED);
1220 if let Some(meta) = map.get_mut(client_order_id.as_str()) {
1221 if price.is_some() {
1222 meta.price = price;
1223 }
1224
1225 if trigger_price.is_some() {
1226 meta.trigger_price = trigger_price;
1227 }
1228 }
1229 } else {
1230 let reason = resp
1231 .errors
1232 .iter()
1233 .map(|e| {
1234 if e.edit_failure_reason.is_empty() {
1235 e.preview_failure_reason.clone()
1236 } else {
1237 e.edit_failure_reason.clone()
1238 }
1239 })
1240 .collect::<Vec<_>>()
1241 .join(",");
1242 let ts_event = clock.get_time_ns();
1243 emitter.emit_order_modify_rejected_event(
1244 strategy_id,
1245 instrument_id,
1246 client_order_id,
1247 Some(venue_order_id),
1248 &format!("modify-order-rejected: {reason}"),
1249 ts_event,
1250 );
1251 }
1252 }
1253 Err(e) => {
1254 if is_coinbase_ambiguous_command_failure(&e) {
1255 log::warn!(
1256 "Ambiguous modify failure for {client_order_id}, awaiting reconciliation: {e}"
1257 );
1258 } else {
1259 log::warn!(
1260 "Modify command failed without venue-declared outcome for {client_order_id}: {e}"
1261 );
1262 }
1263 return Err(e.context("modify order failed"));
1264 }
1265 }
1266
1267 Ok(())
1268 });
1269
1270 Ok(())
1271 }
1272
1273 fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
1274 let Some(venue_order_id) = cmd.venue_order_id else {
1275 log::warn!(
1276 "Cancel command failed local validation for {}: venue_order_id required",
1277 cmd.client_order_id
1278 );
1279 return Ok(());
1280 };
1281
1282 let http_client = self.http_client.clone();
1283 let emitter = self.emitter.clone();
1284 let clock = self.clock;
1285 let strategy_id = cmd.strategy_id;
1286 let instrument_id = cmd.instrument_id;
1287 let client_order_id = cmd.client_order_id;
1288
1289 self.spawn_task("cancel_order", async move {
1290 match http_client.cancel_orders(&[venue_order_id]).await {
1291 Ok(resp) => {
1292 if let Some(result) = resp.results.first()
1293 && !result.success
1294 {
1295 let ts_event = clock.get_time_ns();
1296 emitter.emit_order_cancel_rejected_event(
1297 strategy_id,
1298 instrument_id,
1299 client_order_id,
1300 Some(venue_order_id),
1301 &format!("cancel-order-rejected: {}", result.failure_reason),
1302 ts_event,
1303 );
1304 }
1305 }
1306 Err(e) => {
1307 if is_coinbase_ambiguous_command_failure(&e) {
1308 log::warn!(
1309 "Ambiguous cancel failure for {client_order_id}, awaiting reconciliation: {e}"
1310 );
1311 } else {
1312 log::warn!(
1313 "Cancel command failed without venue-declared outcome for {client_order_id}: {e}"
1314 );
1315 }
1316 return Err(e.context("cancel order failed"));
1317 }
1318 }
1319 Ok(())
1320 });
1321
1322 Ok(())
1323 }
1324
1325 fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
1326 let http_client = self.http_client.clone();
1327 let account_id = self.core.account_id;
1328 let instrument_id = cmd.instrument_id;
1329 let side_filter = cmd.order_side;
1330 let emitter = self.emitter.clone();
1331 let clock = self.clock;
1332 let strategy_id = cmd.strategy_id;
1333
1334 self.spawn_task("cancel_all_orders", async move {
1335 let reports = http_client
1340 .request_order_status_reports(
1341 account_id,
1342 Some(instrument_id),
1343 false,
1344 None,
1345 None,
1346 None,
1347 )
1348 .await
1349 .context("failed to list orders for cancel_all")?;
1350
1351 let filtered: Vec<(Option<ClientOrderId>, VenueOrderId)> = reports
1360 .into_iter()
1361 .filter(|r| {
1362 matches!(
1363 r.order_status,
1364 OrderStatus::Accepted
1365 | OrderStatus::Triggered
1366 | OrderStatus::PendingUpdate
1367 | OrderStatus::PartiallyFilled
1368 )
1369 })
1370 .filter(|r| side_filter == OrderSide::NoOrderSide || r.order_side == side_filter)
1371 .map(|r| (r.client_order_id, r.venue_order_id))
1372 .collect();
1373
1374 if filtered.is_empty() {
1375 return Ok(());
1376 }
1377
1378 for chunk in filtered.chunks(BATCH_CANCEL_CHUNK) {
1379 let venue_ids: Vec<VenueOrderId> = chunk.iter().map(|(_, v)| *v).collect();
1380 match http_client.cancel_orders(&venue_ids).await {
1381 Ok(resp) => {
1382 for result in &resp.results {
1383 if result.success {
1384 continue;
1385 }
1386 let matching = chunk
1387 .iter()
1388 .find(|(_, vid)| vid.as_str() == result.order_id);
1389 if let Some((cid_opt, vid)) = matching
1390 && let Some(cid) = cid_opt
1391 {
1392 let ts_event = clock.get_time_ns();
1393 emitter.emit_order_cancel_rejected_event(
1394 strategy_id,
1395 instrument_id,
1396 *cid,
1397 Some(*vid),
1398 &format!("cancel-all-rejected: {}", result.failure_reason),
1399 ts_event,
1400 );
1401 }
1402 }
1403 }
1404 Err(e) => {
1405 if is_coinbase_ambiguous_command_failure(&e) {
1406 log::warn!(
1407 "Ambiguous cancel-all failure for {} orders on {instrument_id}, awaiting reconciliation: {e}",
1408 chunk.len()
1409 );
1410 } else {
1411 log::warn!(
1412 "Cancel-all command failed without venue-declared outcome for {} orders on {instrument_id}: {e}",
1413 chunk.len()
1414 );
1415 }
1416 }
1417 }
1418 }
1419 Ok(())
1420 });
1421
1422 Ok(())
1423 }
1424
1425 fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
1426 if cmd.cancels.is_empty() {
1427 return Ok(());
1428 }
1429
1430 let http_client = self.http_client.clone();
1431 let emitter = self.emitter.clone();
1432 let clock = self.clock;
1433 let entries: Vec<(
1435 StrategyId,
1436 InstrumentId,
1437 ClientOrderId,
1438 Option<VenueOrderId>,
1439 )> = cmd
1440 .cancels
1441 .iter()
1442 .map(|c| {
1443 (
1444 c.strategy_id,
1445 c.instrument_id,
1446 c.client_order_id,
1447 c.venue_order_id,
1448 )
1449 })
1450 .collect();
1451
1452 self.spawn_task("batch_cancel_orders", async move {
1453 let venue_order_ids: Vec<VenueOrderId> =
1454 entries.iter().filter_map(|(_, _, _, v)| *v).collect();
1455
1456 for (_, _, cid, vid_opt) in &entries {
1457 if vid_opt.is_none() {
1458 log::warn!(
1459 "Batch cancel command failed local validation for {cid}: venue_order_id required"
1460 );
1461 }
1462 }
1463
1464 for chunk in venue_order_ids.chunks(BATCH_CANCEL_CHUNK) {
1465 match http_client.cancel_orders(chunk).await {
1466 Ok(resp) => {
1467 for result in &resp.results {
1468 if !result.success {
1469 let vid = VenueOrderId::new(&result.order_id);
1470 let matching = entries
1471 .iter()
1472 .find(|(_, _, _, v)| {
1473 v.is_some_and(|id| id.as_str() == result.order_id)
1474 });
1475
1476 if let Some((strategy_id, instrument_id, cid, _)) = matching {
1477 let ts_event = clock.get_time_ns();
1478 emitter.emit_order_cancel_rejected_event(
1479 *strategy_id,
1480 *instrument_id,
1481 *cid,
1482 Some(vid),
1483 &format!(
1484 "batch-cancel-rejected: {}",
1485 result.failure_reason
1486 ),
1487 ts_event,
1488 );
1489 }
1490 }
1491 }
1492 }
1493 Err(e) => {
1494 if is_coinbase_ambiguous_command_failure(&e) {
1495 log::warn!(
1496 "Ambiguous batch cancel failure for {} orders, awaiting reconciliation: {e}",
1497 chunk.len()
1498 );
1499 } else {
1500 log::warn!(
1501 "Batch cancel command failed without venue-declared outcome for {} orders: {e}",
1502 chunk.len()
1503 );
1504 }
1505 }
1506 }
1507 }
1508 Ok(())
1509 });
1510
1511 Ok(())
1512 }
1513}
1514
1515fn is_coinbase_local_submit_failure(err: &anyhow::Error) -> bool {
1516 match coinbase_http_error(err) {
1517 None => true,
1518 Some(CoinbaseHttpError::Auth(message)) => !message.starts_with("HTTP "),
1519 _ => false,
1520 }
1521}
1522
1523fn is_coinbase_explicit_submit_rejection(err: &anyhow::Error) -> bool {
1524 match coinbase_http_error(err) {
1525 Some(CoinbaseHttpError::Auth(message) | CoinbaseHttpError::BadRequest(message)) => {
1526 message.starts_with("HTTP ")
1527 }
1528 Some(CoinbaseHttpError::RateLimit { .. }) => true,
1529 _ => false,
1530 }
1531}
1532
1533fn is_coinbase_ambiguous_command_failure(err: &anyhow::Error) -> bool {
1534 matches!(
1535 coinbase_http_error(err),
1536 Some(
1537 CoinbaseHttpError::Transport(_)
1538 | CoinbaseHttpError::Serde(_)
1539 | CoinbaseHttpError::Exchange(_)
1540 | CoinbaseHttpError::Timeout
1541 | CoinbaseHttpError::Decode(_)
1542 )
1543 ) || matches!(
1544 coinbase_http_error(err),
1545 Some(CoinbaseHttpError::Http { status, .. }) if *status >= 500
1546 )
1547}
1548
1549fn coinbase_http_error(err: &anyhow::Error) -> Option<&CoinbaseHttpError> {
1550 err.chain()
1551 .find_map(|cause| cause.downcast_ref::<CoinbaseHttpError>())
1552}
1553
1554#[allow(clippy::too_many_arguments)]
1558async fn handle_user_order_update(
1559 carrier: UserOrderUpdate,
1560 emitter: &ExecutionEventEmitter,
1561 fill_dedup: &Arc<Mutex<FillDedup>>,
1562 cumulative_state: &Arc<Mutex<CumulativeStateMap>>,
1563 order_contexts: &Arc<Mutex<AHashMap<String, OrderContext>>>,
1564 external_order_contexts: &Arc<Mutex<AHashMap<String, OrderContext>>>,
1565 http_client: &CoinbaseHttpClient,
1566 account_id: AccountId,
1567) {
1568 let context = resolve_order_context(
1574 &carrier.update,
1575 carrier.report.order_type,
1576 carrier.report.price.is_none(),
1577 order_contexts,
1578 external_order_contexts,
1579 http_client,
1580 account_id,
1581 )
1582 .await;
1583
1584 let is_terminal = carrier.update.status.is_terminal();
1585 let client_order_id = carrier.update.client_order_id.clone();
1586 let venue_order_id = carrier.update.order_id.clone();
1587
1588 process_user_order_update(
1589 carrier,
1590 context,
1591 emitter,
1592 fill_dedup,
1593 cumulative_state,
1594 Some(http_client),
1595 );
1596
1597 if is_terminal {
1602 if !client_order_id.is_empty() {
1603 order_contexts
1604 .lock()
1605 .expect(MUTEX_POISONED)
1606 .remove(&client_order_id);
1607 }
1608 external_order_contexts
1609 .lock()
1610 .expect(MUTEX_POISONED)
1611 .remove(&venue_order_id);
1612 }
1613}
1614
1615fn process_user_order_update(
1619 carrier: UserOrderUpdate,
1620 context: Option<OrderContext>,
1621 emitter: &ExecutionEventEmitter,
1622 fill_dedup: &Arc<Mutex<FillDedup>>,
1623 cumulative_state: &Arc<Mutex<CumulativeStateMap>>,
1624 http_client: Option<&CoinbaseHttpClient>,
1625) {
1626 let UserOrderUpdate {
1627 mut report,
1628 update,
1629 mut instrument,
1630 is_snapshot,
1631 ts_event,
1632 ts_init,
1633 } = carrier;
1634
1635 let mut fill_liquidity_side = LiquiditySide::NoLiquiditySide;
1636 let have_order_contexts = context.is_some();
1637 let mut publish_instrument_id: Option<InstrumentId> = None;
1638
1639 if let Some(meta) = context {
1640 if report.price.is_none() && meta.price.is_some() {
1641 report.price = meta.price;
1642 }
1643
1644 if report.trigger_price.is_none() && meta.trigger_price.is_some() {
1645 report.trigger_price = meta.trigger_price;
1646 }
1647
1648 if report.trigger_type.is_none() && meta.trigger_type.is_some() {
1649 report.trigger_type = meta.trigger_type;
1650 }
1651
1652 if meta.post_only {
1653 fill_liquidity_side = LiquiditySide::Maker;
1658 report.post_only = true;
1662 }
1663
1664 if let Some(submitted) = meta.submitted_product_id
1665 && submitted != update.product_id
1666 {
1667 let submitted_id = InstrumentId::new(Symbol::new(submitted), *COINBASE_VENUE);
1668 report.instrument_id = submitted_id;
1669 publish_instrument_id = Some(submitted_id);
1670 if let Some(http) = http_client
1676 && let Some(submitted_instrument) = http.instruments().get_cloned(&submitted_id)
1677 {
1678 instrument = submitted_instrument;
1679 }
1680 }
1681 }
1682
1683 let size_precision = instrument.size_precision();
1684
1685 let cumulative_qty = if update.cumulative_quantity.is_empty() {
1686 Quantity::zero(size_precision)
1687 } else {
1688 match parse_quantity(&update.cumulative_quantity, size_precision) {
1689 Ok(q) => q,
1690 Err(e) => {
1691 log::warn!(
1692 "Failed to parse cumulative_quantity for order {}: {e}",
1693 update.order_id
1694 );
1695 return;
1696 }
1697 }
1698 };
1699
1700 let cumulative_fees = if update.total_fees.is_empty() {
1701 Decimal::ZERO
1702 } else {
1703 match Decimal::from_str(&update.total_fees) {
1704 Ok(d) => d,
1705 Err(e) => {
1706 log::warn!(
1707 "Failed to parse total_fees for order {}: {e}",
1708 update.order_id
1709 );
1710 return;
1711 }
1712 }
1713 };
1714
1715 let cumulative_avg = if update.avg_price.is_empty() {
1716 Decimal::ZERO
1717 } else {
1718 match Decimal::from_str(&update.avg_price) {
1719 Ok(d) => d,
1720 Err(e) => {
1721 log::warn!(
1722 "Failed to parse avg_price for order {}: {e}",
1723 update.order_id
1724 );
1725 return;
1726 }
1727 }
1728 };
1729 let order_id = update.order_id.clone();
1730
1731 let is_terminal = update.status.is_terminal();
1732
1733 let (delta_qty, delta_fees, last_fill_price_decimal, restored_quantity) = {
1736 let mut state = cumulative_state.lock().expect(MUTEX_POISONED);
1737 let entry = state.entry_or_default(&order_id);
1738 let prev_qty = entry
1739 .filled_qty
1740 .unwrap_or_else(|| Quantity::zero(size_precision));
1741 let prev_fees = entry.total_fees;
1742 let prev_avg = entry.avg_price;
1743
1744 let observed_quantity = report.quantity;
1749 let stored_quantity = match entry.quantity {
1750 Some(q) if q >= observed_quantity => q,
1751 _ => observed_quantity,
1752 };
1753 entry.quantity = Some(stored_quantity);
1754
1755 if is_snapshot {
1759 entry.filled_qty = Some(cumulative_qty);
1760 entry.total_fees = cumulative_fees;
1761 entry.avg_price = cumulative_avg;
1762
1763 if is_terminal {
1764 state.remove(&order_id);
1765 }
1766 (
1767 Quantity::zero(size_precision),
1768 Decimal::ZERO,
1769 Decimal::ZERO,
1770 stored_quantity,
1771 )
1772 } else {
1773 let delta_qty = if cumulative_qty > prev_qty {
1774 cumulative_qty - prev_qty
1775 } else {
1776 Quantity::zero(size_precision)
1777 };
1778 let delta_fees = cumulative_fees - prev_fees;
1779
1780 let last_fill_price_decimal = if delta_qty.is_positive() {
1785 let now_notional = cumulative_avg * cumulative_qty.as_decimal();
1786 let prev_notional = prev_avg * prev_qty.as_decimal();
1787 let delta_notional = now_notional - prev_notional;
1788 let delta_qty_dec = delta_qty.as_decimal();
1789 if delta_qty_dec.is_zero() {
1790 cumulative_avg
1791 } else {
1792 delta_notional / delta_qty_dec
1793 }
1794 } else {
1795 Decimal::ZERO
1796 };
1797
1798 entry.filled_qty = Some(cumulative_qty);
1799 entry.total_fees = cumulative_fees;
1800 entry.avg_price = cumulative_avg;
1801
1802 if is_terminal {
1803 state.remove(&order_id);
1804 }
1805
1806 (
1807 delta_qty,
1808 delta_fees,
1809 last_fill_price_decimal,
1810 stored_quantity,
1811 )
1812 }
1813 };
1814
1815 if is_terminal && report.quantity < restored_quantity {
1818 report.quantity = restored_quantity;
1819 }
1820
1821 let synthesized_fill = if delta_qty.is_positive()
1828 && last_fill_price_decimal.is_sign_positive()
1829 && !last_fill_price_decimal.is_zero()
1830 {
1831 let price_precision = instrument.price_precision();
1832 match Price::from_decimal_dp(last_fill_price_decimal, price_precision) {
1833 Ok(last_px) => {
1834 let order_id_short = &update.order_id[..update.order_id.len().min(8)];
1841 let trade_id = TradeId::new(format!("{order_id_short}-{cumulative_qty}"));
1842 let trade_id_str = trade_id.as_str().to_string();
1843
1844 let is_new = {
1845 let mut dedup = fill_dedup.lock().expect(MUTEX_POISONED);
1846 dedup.insert((update.order_id.clone(), trade_id_str))
1847 };
1848
1849 if is_new {
1850 let commission_currency = instrument.quote_currency();
1851 match Money::from_decimal(delta_fees, commission_currency) {
1852 Ok(commission) => Some(parse_ws_user_event_to_fill_report(
1853 &update,
1854 delta_qty,
1855 last_px,
1856 commission,
1857 trade_id,
1858 &instrument,
1859 emitter.account_id(),
1860 fill_liquidity_side,
1861 ts_event,
1862 ts_init,
1863 )),
1864 Err(e) => {
1865 log::warn!(
1866 "Failed to build commission Money for order {}: {e}",
1867 update.order_id
1868 );
1869 None
1870 }
1871 }
1872 } else {
1873 log::debug!(
1874 "Dropping duplicate fill venue_order_id={}, trade_id={}",
1875 update.order_id,
1876 trade_id,
1877 );
1878 None
1879 }
1880 }
1881 Err(e) => {
1882 log::warn!(
1883 "Failed to build Price from derived last_fill={last_fill_price_decimal} at precision {price_precision} for order {}: {e}",
1884 update.order_id
1885 );
1886 None
1887 }
1888 }
1889 } else {
1890 None
1891 };
1892
1893 if let Some(mut fill_report) = synthesized_fill {
1894 if let Some(id) = publish_instrument_id {
1895 fill_report.instrument_id = id;
1896 }
1897 emitter.send_fill_report(fill_report);
1898 }
1899
1900 let report_safe_for_type = match report.order_type {
1916 OrderType::Limit | OrderType::LimitIfTouched => report.price.is_some(),
1917 OrderType::StopLimit => report.price.is_some() && report.trigger_price.is_some(),
1918 OrderType::StopMarket | OrderType::MarketIfTouched => report.trigger_price.is_some(),
1919 _ => true,
1920 };
1921 let should_emit = (!is_snapshot || have_order_contexts) && report_safe_for_type;
1922 if should_emit {
1923 emitter.send_order_status_report(*report);
1924 } else if !report_safe_for_type {
1925 log::warn!(
1926 "Suppressed unsafe OrderStatusReport for {} {}: missing price/trigger after enrichment",
1927 report.order_type,
1928 update.order_id,
1929 );
1930 }
1931}
1932
1933async fn resolve_order_context(
1940 update: &WsOrderUpdate,
1941 order_type: OrderType,
1942 report_price_missing: bool,
1943 order_contexts: &Arc<Mutex<AHashMap<String, OrderContext>>>,
1944 external_order_contexts: &Arc<Mutex<AHashMap<String, OrderContext>>>,
1945 http_client: &CoinbaseHttpClient,
1946 account_id: AccountId,
1947) -> Option<OrderContext> {
1948 if !update.client_order_id.is_empty() {
1949 let map = order_contexts.lock().expect(MUTEX_POISONED);
1950 if let Some(meta) = map.get(&update.client_order_id) {
1951 return Some(meta.clone());
1952 }
1953 }
1954
1955 if let Some(meta) = external_order_contexts
1956 .lock()
1957 .expect(MUTEX_POISONED)
1958 .get(&update.order_id)
1959 {
1960 return Some(meta.clone());
1961 }
1962
1963 let needs_enrichment = report_price_missing
1964 && matches!(
1965 order_type,
1966 OrderType::Limit
1967 | OrderType::StopLimit
1968 | OrderType::LimitIfTouched
1969 | OrderType::StopMarket
1970 | OrderType::MarketIfTouched
1971 );
1972
1973 if !needs_enrichment {
1974 return None;
1975 }
1976
1977 let venue_order_id = VenueOrderId::new(update.order_id.as_str());
1978 match http_client
1979 .request_order_status_report(account_id, None, Some(venue_order_id))
1980 .await
1981 {
1982 Ok(rest_report) => {
1983 let post_only_from_rest = matches!(order_type, OrderType::Limit | OrderType::StopLimit)
1984 && rest_report.post_only;
1985 let meta = OrderContext {
1986 price: rest_report.price,
1987 trigger_price: rest_report.trigger_price,
1988 trigger_type: rest_report.trigger_type,
1989 post_only: post_only_from_rest,
1990 submitted_product_id: None,
1991 };
1992 external_order_contexts
1993 .lock()
1994 .expect(MUTEX_POISONED)
1995 .insert(update.order_id.clone(), meta.clone());
1996 Some(meta)
1997 }
1998 Err(e) => {
1999 log::warn!(
2000 "Failed to enrich external order {} via REST: {e}",
2001 update.order_id
2002 );
2003 None
2004 }
2005 }
2006}
2007
2008#[cfg(test)]
2009mod tests {
2010 use nautilus_common::messages::{ExecutionEvent, ExecutionReport};
2011 use nautilus_model::{
2012 enums::AccountType,
2013 identifiers::{Symbol, TraderId},
2014 instruments::CurrencyPair,
2015 types::Currency,
2016 };
2017 use rstest::rstest;
2018 use ustr::Ustr;
2019
2020 use super::*;
2021 use crate::{
2022 common::{
2023 consts::COINBASE_VENUE,
2024 enums::{
2025 CoinbaseContractExpiryType, CoinbaseOrderSide as CbSide,
2026 CoinbaseOrderStatus as CbStatus, CoinbaseOrderType as CbType,
2027 CoinbaseProductType as CbProductType, CoinbaseRiskManagedBy,
2028 CoinbaseTimeInForce as CbTif, CoinbaseTriggerStatus,
2029 },
2030 },
2031 websocket::messages::WsOrderUpdate,
2032 };
2033
2034 #[rstest]
2035 fn test_submit_local_command_failure_classification() {
2036 let err = anyhow::anyhow!("Unsupported Coinbase order configuration");
2037
2038 assert!(is_coinbase_local_submit_failure(&err));
2039 assert!(!is_coinbase_explicit_submit_rejection(&err));
2040 assert!(!is_coinbase_ambiguous_command_failure(&err));
2041 }
2042
2043 #[rstest]
2044 fn test_submit_http_exchange_failure_classification() {
2045 let err = anyhow::Error::new(CoinbaseHttpError::exchange("HTTP 500: unavailable"))
2046 .context("failed to submit order");
2047
2048 assert!(!is_coinbase_local_submit_failure(&err));
2049 assert!(!is_coinbase_explicit_submit_rejection(&err));
2050 assert!(is_coinbase_ambiguous_command_failure(&err));
2051 }
2052
2053 #[rstest]
2054 fn test_submit_http_bad_request_failure_classification() {
2055 let err = anyhow::Error::new(CoinbaseHttpError::bad_request("HTTP 400: bad request"))
2056 .context("failed to submit order");
2057
2058 assert!(!is_coinbase_local_submit_failure(&err));
2059 assert!(is_coinbase_explicit_submit_rejection(&err));
2060 assert!(!is_coinbase_ambiguous_command_failure(&err));
2061 }
2062
2063 #[rstest]
2064 fn test_submit_http_auth_failure_classification() {
2065 let err = anyhow::Error::new(CoinbaseHttpError::auth("HTTP 401: authentication failed"))
2066 .context("failed to submit order");
2067
2068 assert!(!is_coinbase_local_submit_failure(&err));
2069 assert!(is_coinbase_explicit_submit_rejection(&err));
2070 assert!(!is_coinbase_ambiguous_command_failure(&err));
2071 }
2072
2073 #[rstest]
2074 fn test_submit_http_rate_limit_failure_classification() {
2075 let err = anyhow::Error::new(CoinbaseHttpError::rate_limit(None))
2076 .context("failed to submit order");
2077
2078 assert!(!is_coinbase_local_submit_failure(&err));
2079 assert!(is_coinbase_explicit_submit_rejection(&err));
2080 assert!(!is_coinbase_ambiguous_command_failure(&err));
2081 }
2082
2083 #[rstest]
2084 fn test_submit_unmapped_http_4xx_failure_classification() {
2085 let err = anyhow::Error::new(CoinbaseHttpError::http(409, "conflict"))
2086 .context("failed to submit order");
2087
2088 assert!(!is_coinbase_local_submit_failure(&err));
2089 assert!(!is_coinbase_explicit_submit_rejection(&err));
2090 assert!(!is_coinbase_ambiguous_command_failure(&err));
2091 }
2092
2093 #[rstest]
2094 fn test_submit_local_auth_failure_classification() {
2095 let err = anyhow::Error::new(CoinbaseHttpError::auth("No credentials configured"))
2096 .context("failed to submit order");
2097
2098 assert!(is_coinbase_local_submit_failure(&err));
2099 assert!(!is_coinbase_explicit_submit_rejection(&err));
2100 assert!(!is_coinbase_ambiguous_command_failure(&err));
2101 }
2102
2103 #[rstest]
2104 fn test_fill_dedup_rejects_duplicates() {
2105 let mut dedup = FillDedup::new(4);
2106 let key = ("venue-1".to_string(), "trade-1".to_string());
2107 assert!(dedup.insert(key.clone()));
2108 assert!(!dedup.insert(key));
2109 }
2110
2111 #[rstest]
2112 fn test_fill_dedup_evicts_oldest_when_full() {
2113 let mut dedup = FillDedup::new(2);
2114 assert!(dedup.insert(("v".to_string(), "t1".to_string())));
2115 assert!(dedup.insert(("v".to_string(), "t2".to_string())));
2116 assert!(dedup.insert(("v".to_string(), "t3".to_string())));
2118 assert!(dedup.insert(("v".to_string(), "t1".to_string())));
2119 }
2120
2121 #[rstest]
2122 fn test_cumulative_state_evicts_oldest_at_capacity() {
2123 let mut state = CumulativeStateMap::with_capacity(2);
2124 state.entry_or_default("a");
2125 state.entry_or_default("b");
2126 state.entry_or_default("c");
2128 assert_eq!(state.len(), 2);
2129 assert!(state.map.contains_key("b"));
2130 assert!(state.map.contains_key("c"));
2131 assert!(!state.map.contains_key("a"));
2132 }
2133
2134 #[rstest]
2135 fn test_cumulative_state_remove_drops_entry_and_allows_reinsert() {
2136 let mut state = CumulativeStateMap::with_capacity(2);
2137 state.entry_or_default("a");
2138 state.entry_or_default("b");
2139 state.remove("a");
2140 state.entry_or_default("c");
2142 assert_eq!(state.len(), 2);
2143 assert!(state.map.contains_key("b"));
2144 assert!(state.map.contains_key("c"));
2145 }
2146
2147 #[rstest]
2148 fn test_cumulative_state_remove_and_reinsert_does_not_evict_live_state() {
2149 let mut state = CumulativeStateMap::with_capacity(2);
2153 state.entry_or_default("a");
2154 state.remove("a");
2155 state.entry_or_default("b");
2156 state.entry_or_default("a");
2157 state.entry_or_default("c");
2161 assert_eq!(state.len(), 2);
2162 assert!(
2163 state.map.contains_key("a"),
2164 "re-inserted live key must survive"
2165 );
2166 assert!(state.map.contains_key("c"));
2167 assert!(!state.map.contains_key("b"));
2168 }
2169
2170 #[rstest]
2171 fn test_cumulative_state_hit_refreshes_lru_recency() {
2172 let mut state = CumulativeStateMap::with_capacity(2);
2176 state.entry_or_default("a");
2177 state.entry_or_default("b");
2178 state.entry_or_default("a");
2181 state.entry_or_default("c");
2182 assert_eq!(state.len(), 2);
2183 assert!(
2184 state.map.contains_key("a"),
2185 "recently-accessed key must survive eviction"
2186 );
2187 assert!(state.map.contains_key("c"));
2188 assert!(!state.map.contains_key("b"));
2189 }
2190
2191 #[rstest]
2192 fn test_cumulative_state_preserves_live_entry_when_trimming_stale() {
2193 let mut state = CumulativeStateMap::with_capacity(2);
2197 state.entry_or_default("live");
2198 for i in 0..50 {
2200 let key = format!("t{i}");
2201 state.entry_or_default(&key);
2202 state.remove(&key);
2203 }
2204 assert!(
2205 state.map.contains_key("live"),
2206 "live entry must survive stale-trim cycles"
2207 );
2208 assert_eq!(state.len(), 1);
2209 assert!(
2210 state.order.len() <= 2 * state.capacity,
2211 "deque must remain bounded after compaction (was {})",
2212 state.order.len(),
2213 );
2214 assert!(
2219 state.order.iter().any(|k| k == "live"),
2220 "live key must remain in the deque, was: {:?}",
2221 state.order,
2222 );
2223 state.entry_or_default("a");
2228 state.entry_or_default("b");
2229 state.entry_or_default("c");
2230 assert_eq!(state.len(), state.capacity);
2231 assert!(
2232 !state.map.contains_key("live"),
2233 "live key should have been evicted in LRU order once capacity demanded it"
2234 );
2235 }
2236
2237 fn test_instrument() -> InstrumentAny {
2238 let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), *COINBASE_VENUE);
2239 InstrumentAny::CurrencyPair(CurrencyPair::new(
2240 instrument_id,
2241 Symbol::new("BTC-USD"),
2242 Currency::get_or_create_crypto("BTC"),
2243 Currency::get_or_create_crypto("USD"),
2244 2,
2245 8,
2246 Price::from("0.01"),
2247 Quantity::from("0.00000001"),
2248 None,
2249 None,
2250 None,
2251 Some(Quantity::from("0.00000001")),
2252 None,
2253 None,
2254 None,
2255 None,
2256 None,
2257 None,
2258 None,
2259 None,
2260 None,
2261 None,
2262 UnixNanos::default(),
2263 UnixNanos::default(),
2264 ))
2265 }
2266
2267 fn make_emitter() -> (
2268 ExecutionEventEmitter,
2269 tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2270 ) {
2271 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2272 let mut emitter = ExecutionEventEmitter::new(
2273 get_atomic_clock_realtime(),
2274 TraderId::from("TRADER-001"),
2275 AccountId::new("COINBASE-001"),
2276 AccountType::Cash,
2277 None,
2278 );
2279 emitter.set_sender(tx);
2280 (emitter, rx)
2281 }
2282
2283 fn make_user_order_update(
2284 cumulative: &str,
2285 leaves: &str,
2286 avg_price: &str,
2287 total_fees: &str,
2288 status: CbStatus,
2289 ) -> WsOrderUpdate {
2290 WsOrderUpdate {
2291 order_id: "venue-1".to_string(),
2292 client_order_id: "client-1".to_string(),
2293 contract_expiry_type: CoinbaseContractExpiryType::Unknown,
2294 cumulative_quantity: cumulative.to_string(),
2295 leaves_quantity: leaves.to_string(),
2296 avg_price: avg_price.to_string(),
2297 total_fees: total_fees.to_string(),
2298 status,
2299 product_id: Ustr::from("BTC-USD"),
2300 product_type: CbProductType::Spot,
2301 creation_time: String::new(),
2302 order_side: CbSide::Buy,
2303 order_type: CbType::Limit,
2304 risk_managed_by: CoinbaseRiskManagedBy::Unknown,
2305 time_in_force: CbTif::GoodUntilCancelled,
2306 trigger_status: CoinbaseTriggerStatus::InvalidOrderType,
2307 cancel_reason: String::new(),
2308 reject_reason: String::new(),
2309 total_value_after_fees: String::new(),
2310 }
2311 }
2312
2313 fn make_carrier(update: WsOrderUpdate) -> UserOrderUpdate {
2314 make_carrier_with_kind(update, false)
2315 }
2316
2317 fn make_limit_context() -> OrderContext {
2321 OrderContext {
2322 price: Some(Price::from("100.00")),
2323 ..OrderContext::default()
2324 }
2325 }
2326
2327 fn make_carrier_with_kind(update: WsOrderUpdate, is_snapshot: bool) -> UserOrderUpdate {
2328 let instrument = test_instrument();
2329 let report = crate::websocket::parse::parse_ws_user_event_to_order_status_report(
2330 &update,
2331 &instrument,
2332 AccountId::new("COINBASE-001"),
2333 UnixNanos::default(),
2334 UnixNanos::default(),
2335 )
2336 .unwrap();
2337 UserOrderUpdate {
2338 report: Box::new(report),
2339 update: Box::new(update),
2340 instrument,
2341 is_snapshot,
2342 ts_event: UnixNanos::default(),
2343 ts_init: UnixNanos::default(),
2344 }
2345 }
2346
2347 fn drain_fill_reports(
2348 rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2349 ) -> Vec<FillReport> {
2350 let mut reports = Vec::new();
2351
2352 while let Ok(event) = rx.try_recv() {
2353 if let ExecutionEvent::Report(ExecutionReport::Fill(report)) = event {
2354 reports.push(*report);
2355 }
2356 }
2357 reports
2358 }
2359
2360 fn drain_all_reports(
2365 rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2366 ) -> (Vec<OrderStatusReport>, Vec<FillReport>) {
2367 let mut orders = Vec::new();
2368 let mut fills = Vec::new();
2369
2370 while let Ok(event) = rx.try_recv() {
2371 match event {
2372 ExecutionEvent::Report(ExecutionReport::Order(r)) => orders.push(*r),
2373 ExecutionEvent::Report(ExecutionReport::Fill(r)) => fills.push(*r),
2374 _ => {}
2375 }
2376 }
2377 (orders, fills)
2378 }
2379
2380 fn drain_status_reports(
2381 rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2382 ) -> Vec<OrderStatusReport> {
2383 let mut reports = Vec::new();
2384
2385 while let Ok(event) = rx.try_recv() {
2386 if let ExecutionEvent::Report(ExecutionReport::Order(report)) = event {
2387 reports.push(*report);
2388 }
2389 }
2390 reports
2391 }
2392
2393 fn make_dedup_state_pair() -> (Arc<Mutex<FillDedup>>, Arc<Mutex<CumulativeStateMap>>) {
2394 (
2395 Arc::new(Mutex::new(FillDedup::new(64))),
2396 Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
2397 CUMULATIVE_STATE_CAPACITY,
2398 ))),
2399 )
2400 }
2401
2402 #[rstest]
2403 fn test_handle_user_order_update_emits_status_report_and_no_fill_when_zero_filled() {
2404 let (emitter, mut rx) = make_emitter();
2405 let dedup = Arc::new(Mutex::new(FillDedup::new(64)));
2406 let state = Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
2407 CUMULATIVE_STATE_CAPACITY,
2408 )));
2409
2410 let update = make_user_order_update("0", "1.0", "0", "0", CbStatus::Open);
2412 process_user_order_update(
2413 make_carrier(update),
2414 Some(make_limit_context()),
2415 &emitter,
2416 &dedup,
2417 &state,
2418 None,
2419 );
2420
2421 let mut got_status = false;
2423 let mut got_fill = false;
2424
2425 while let Ok(event) = rx.try_recv() {
2426 match event {
2427 ExecutionEvent::Report(ExecutionReport::Order(_)) => got_status = true,
2428 ExecutionEvent::Report(ExecutionReport::Fill(_)) => got_fill = true,
2429 _ => {}
2430 }
2431 }
2432 assert!(got_status);
2433 assert!(!got_fill);
2434 }
2435
2436 #[rstest]
2437 fn test_handle_user_order_update_synthesizes_per_fill_price_from_notional_delta() {
2438 let (emitter, mut rx) = make_emitter();
2439 let dedup = Arc::new(Mutex::new(FillDedup::new(64)));
2440 let state = Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
2441 CUMULATIVE_STATE_CAPACITY,
2442 )));
2443
2444 let update_1 = make_user_order_update("0.5", "0.5", "100.00", "0.05", CbStatus::Open);
2446 process_user_order_update(make_carrier(update_1), None, &emitter, &dedup, &state, None);
2447
2448 let update_2 = make_user_order_update("1.0", "0", "110.00", "0.15", CbStatus::Filled);
2452 process_user_order_update(make_carrier(update_2), None, &emitter, &dedup, &state, None);
2453
2454 let fills = drain_fill_reports(&mut rx);
2455 assert_eq!(fills.len(), 2);
2456
2457 assert_eq!(fills[0].last_qty, Quantity::from("0.50000000"));
2459 assert_eq!(fills[0].last_px, Price::from("100.00"));
2460 assert_eq!(fills[0].commission.as_decimal().to_string(), "0.05");
2461
2462 assert_eq!(fills[1].last_qty, Quantity::from("0.50000000"));
2464 assert_eq!(fills[1].last_px, Price::from("120.00"));
2465 assert_eq!(fills[1].commission.as_decimal().to_string(), "0.10");
2466 }
2467
2468 #[rstest]
2469 fn test_handle_user_order_update_drops_replayed_fills() {
2470 let (emitter, mut rx) = make_emitter();
2471 let dedup = Arc::new(Mutex::new(FillDedup::new(64)));
2472 let state = Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
2473 CUMULATIVE_STATE_CAPACITY,
2474 )));
2475
2476 let update = make_user_order_update("0.5", "0.5", "100.00", "0.05", CbStatus::Open);
2477 process_user_order_update(
2478 make_carrier(update.clone()),
2479 None,
2480 &emitter,
2481 &dedup,
2482 &state,
2483 None,
2484 );
2485
2486 {
2490 let mut s = state.lock().unwrap();
2491 s.clear();
2492 }
2493 process_user_order_update(make_carrier(update), None, &emitter, &dedup, &state, None);
2494
2495 let fills = drain_fill_reports(&mut rx);
2496 assert_eq!(fills.len(), 1, "replay should be deduplicated");
2497 }
2498
2499 #[rstest]
2500 fn test_handle_user_order_update_clears_state_on_terminal_status() {
2501 let (emitter, mut rx) = make_emitter();
2502 let dedup = Arc::new(Mutex::new(FillDedup::new(64)));
2503 let state = Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
2504 CUMULATIVE_STATE_CAPACITY,
2505 )));
2506
2507 let update = make_user_order_update("1.0", "0", "100.00", "0.10", CbStatus::Filled);
2508 process_user_order_update(make_carrier(update), None, &emitter, &dedup, &state, None);
2509
2510 let _ = drain_fill_reports(&mut rx);
2512
2513 let s = state.lock().unwrap();
2514 assert!(
2515 s.get("venue-1").is_none(),
2516 "terminal status should remove cumulative state entry"
2517 );
2518 }
2519
2520 #[rstest]
2521 fn test_handle_user_order_update_skips_when_avg_price_nonpositive() {
2522 let (emitter, mut rx) = make_emitter();
2523 let dedup = Arc::new(Mutex::new(FillDedup::new(64)));
2524 let state = Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
2525 CUMULATIVE_STATE_CAPACITY,
2526 )));
2527
2528 let update = make_user_order_update("0.5", "0.5", "0", "0", CbStatus::Open);
2530 process_user_order_update(make_carrier(update), None, &emitter, &dedup, &state, None);
2531
2532 let fills = drain_fill_reports(&mut rx);
2533 assert!(
2534 fills.is_empty(),
2535 "non-positive avg_price should not emit a fill"
2536 );
2537 }
2538
2539 #[rstest]
2540 fn test_handle_user_order_update_snapshot_does_not_synthesize_fill() {
2541 let (emitter, mut rx) = make_emitter();
2542 let dedup = Arc::new(Mutex::new(FillDedup::new(64)));
2543 let state = Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
2544 CUMULATIVE_STATE_CAPACITY,
2545 )));
2546
2547 let update = make_user_order_update("0.5", "0.5", "100.00", "0.05", CbStatus::Open);
2551 process_user_order_update(
2552 make_carrier_with_kind(update, true),
2553 None,
2554 &emitter,
2555 &dedup,
2556 &state,
2557 None,
2558 );
2559
2560 let fills = drain_fill_reports(&mut rx);
2561 assert!(
2562 fills.is_empty(),
2563 "snapshot must not synthesize a fill from pre-existing cumulative state"
2564 );
2565
2566 let s = state.lock().unwrap();
2569 let entry = s.get("venue-1").expect("snapshot should seed state");
2570 assert_eq!(entry.filled_qty.unwrap(), Quantity::from("0.50000000"));
2571 }
2572
2573 #[rstest]
2574 fn test_handle_user_order_update_snapshot_then_update_synthesizes_only_delta() {
2575 let (emitter, mut rx) = make_emitter();
2576 let dedup = Arc::new(Mutex::new(FillDedup::new(64)));
2577 let state = Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
2578 CUMULATIVE_STATE_CAPACITY,
2579 )));
2580
2581 let snap = make_user_order_update("0.5", "0.5", "100.00", "0.05", CbStatus::Open);
2583 process_user_order_update(
2584 make_carrier_with_kind(snap, true),
2585 None,
2586 &emitter,
2587 &dedup,
2588 &state,
2589 None,
2590 );
2591
2592 let live = make_user_order_update("1.0", "0", "110.00", "0.15", CbStatus::Filled);
2595 process_user_order_update(make_carrier(live), None, &emitter, &dedup, &state, None);
2596
2597 let fills = drain_fill_reports(&mut rx);
2598 assert_eq!(fills.len(), 1);
2599 assert_eq!(fills[0].last_qty, Quantity::from("0.50000000"));
2600 assert_eq!(fills[0].last_px, Price::from("120.00"));
2602 assert_eq!(fills[0].commission.as_decimal().to_string(), "0.10");
2604 }
2605
2606 #[rstest]
2607 fn test_handle_user_order_update_terminal_restores_original_quantity() {
2608 use nautilus_common::messages::{ExecutionEvent, ExecutionReport};
2609
2610 let (emitter, mut rx) = make_emitter();
2611 let dedup = Arc::new(Mutex::new(FillDedup::new(64)));
2612 let state = Arc::new(Mutex::new(CumulativeStateMap::with_capacity(
2613 CUMULATIVE_STATE_CAPACITY,
2614 )));
2615
2616 let working = make_user_order_update("0", "1.0", "0", "0", CbStatus::Open);
2618 process_user_order_update(
2619 make_carrier(working),
2620 Some(make_limit_context()),
2621 &emitter,
2622 &dedup,
2623 &state,
2624 None,
2625 );
2626 while rx.try_recv().is_ok() {}
2628
2629 let cancelled = make_user_order_update("0", "0", "0", "0", CbStatus::Cancelled);
2632 process_user_order_update(
2633 make_carrier(cancelled),
2634 Some(make_limit_context()),
2635 &emitter,
2636 &dedup,
2637 &state,
2638 None,
2639 );
2640
2641 let mut got_terminal_report: Option<OrderStatusReport> = None;
2642
2643 while let Ok(event) = rx.try_recv() {
2644 if let ExecutionEvent::Report(ExecutionReport::Order(r)) = event {
2645 got_terminal_report = Some(*r);
2646 }
2647 }
2648 let report = got_terminal_report.expect("terminal report emitted");
2649 assert_eq!(
2650 report.quantity,
2651 Quantity::from("1.00000000"),
2652 "terminal report must restore the original order quantity"
2653 );
2654 }
2655
2656 #[rstest]
2657 fn test_process_user_order_update_suppresses_snapshot_without_context() {
2658 let (emitter, mut rx) = make_emitter();
2662 let (dedup, state) = make_dedup_state_pair();
2663
2664 let update = make_user_order_update("0", "1.0", "0", "0", CbStatus::Open);
2665 process_user_order_update(
2666 make_carrier_with_kind(update, true),
2667 None,
2668 &emitter,
2669 &dedup,
2670 &state,
2671 None,
2672 );
2673
2674 assert!(drain_status_reports(&mut rx).is_empty());
2675 assert!(drain_fill_reports(&mut rx).is_empty());
2676 }
2677
2678 #[rstest]
2679 fn test_process_user_order_update_emits_snapshot_when_context_present() {
2680 let (emitter, mut rx) = make_emitter();
2683 let (dedup, state) = make_dedup_state_pair();
2684 let context = OrderContext {
2685 price: Some(Price::from("100.00")),
2686 ..Default::default()
2687 };
2688
2689 let update = make_user_order_update("0", "1.0", "0", "0", CbStatus::Open);
2690 process_user_order_update(
2691 make_carrier_with_kind(update, true),
2692 Some(context),
2693 &emitter,
2694 &dedup,
2695 &state,
2696 None,
2697 );
2698
2699 let osrs = drain_status_reports(&mut rx);
2700 assert_eq!(osrs.len(), 1);
2701 assert_eq!(osrs[0].price, Some(Price::from("100.00")));
2702 }
2703
2704 #[rstest]
2705 fn test_process_user_order_update_patches_price_and_trigger_from_context() {
2706 let (emitter, mut rx) = make_emitter();
2710 let (dedup, state) = make_dedup_state_pair();
2711 let context = OrderContext {
2712 price: Some(Price::from("100.50")),
2713 trigger_price: Some(Price::from("99.00")),
2714 trigger_type: Some(TriggerType::LastPrice),
2715 ..Default::default()
2716 };
2717
2718 let update = make_user_order_update("0", "1.0", "0", "0", CbStatus::Open);
2719 process_user_order_update(
2720 make_carrier(update),
2721 Some(context),
2722 &emitter,
2723 &dedup,
2724 &state,
2725 None,
2726 );
2727
2728 let osrs = drain_status_reports(&mut rx);
2729 assert_eq!(osrs[0].price, Some(Price::from("100.50")));
2730 assert_eq!(osrs[0].trigger_price, Some(Price::from("99.00")));
2731 assert_eq!(osrs[0].trigger_type, Some(TriggerType::LastPrice));
2732 }
2733
2734 #[rstest]
2735 fn test_process_user_order_update_rekeys_to_submitted_product_id() {
2736 let (emitter, mut rx) = make_emitter();
2740 let (dedup, state) = make_dedup_state_pair();
2741 let context = OrderContext {
2742 price: Some(Price::from("100.00")),
2743 submitted_product_id: Some(Ustr::from("BTC-USDC")),
2744 ..Default::default()
2745 };
2746
2747 let update = make_user_order_update("1.0", "0", "100.00", "0.05", CbStatus::Filled);
2748 process_user_order_update(
2749 make_carrier(update),
2750 Some(context),
2751 &emitter,
2752 &dedup,
2753 &state,
2754 None,
2755 );
2756
2757 let (osrs, fills) = drain_all_reports(&mut rx);
2758 assert_eq!(osrs.len(), 1);
2759 assert_eq!(
2760 osrs[0].instrument_id,
2761 InstrumentId::from("BTC-USDC.COINBASE")
2762 );
2763 assert_eq!(fills.len(), 1);
2764 assert_eq!(
2765 fills[0].instrument_id,
2766 InstrumentId::from("BTC-USDC.COINBASE")
2767 );
2768 }
2769
2770 #[rstest]
2771 #[case(true, LiquiditySide::Maker)]
2772 #[case(false, LiquiditySide::NoLiquiditySide)]
2773 fn test_process_user_order_update_stamps_liquidity_side_from_post_only(
2774 #[case] post_only: bool,
2775 #[case] expected: LiquiditySide,
2776 ) {
2777 let (emitter, mut rx) = make_emitter();
2778 let (dedup, state) = make_dedup_state_pair();
2779 let context = OrderContext {
2780 price: Some(Price::from("100.00")),
2781 post_only,
2782 ..Default::default()
2783 };
2784
2785 let update = make_user_order_update("1.0", "0", "100.00", "0.05", CbStatus::Filled);
2786 process_user_order_update(
2787 make_carrier(update),
2788 Some(context),
2789 &emitter,
2790 &dedup,
2791 &state,
2792 None,
2793 );
2794
2795 let fills = drain_fill_reports(&mut rx);
2796 assert_eq!(fills.len(), 1);
2797 assert_eq!(fills[0].liquidity_side, expected);
2798 }
2799
2800 #[rstest]
2801 fn test_process_user_order_update_propagates_post_only_to_status_report() {
2802 let (emitter, mut rx) = make_emitter();
2806 let (dedup, state) = make_dedup_state_pair();
2807 let context = OrderContext {
2808 price: Some(Price::from("100.00")),
2809 post_only: true,
2810 ..Default::default()
2811 };
2812
2813 let update = make_user_order_update("0", "1.0", "0", "0", CbStatus::Open);
2814 process_user_order_update(
2815 make_carrier(update),
2816 Some(context),
2817 &emitter,
2818 &dedup,
2819 &state,
2820 None,
2821 );
2822
2823 let osrs = drain_status_reports(&mut rx);
2824 assert_eq!(osrs.len(), 1);
2825 assert!(osrs[0].post_only);
2826 }
2827
2828 #[rstest]
2829 #[case(OrderType::Limit)]
2830 #[case(OrderType::StopLimit)]
2831 fn test_process_user_order_update_suppresses_unsafe_report_when_enrichment_unavailable(
2832 #[case] order_type: OrderType,
2833 ) {
2834 let (emitter, mut rx) = make_emitter();
2838 let (dedup, state) = make_dedup_state_pair();
2839 let mut update = make_user_order_update("0", "1.0", "0", "0", CbStatus::Open);
2840 update.order_type = match order_type {
2841 OrderType::Limit => CbType::Limit,
2842 OrderType::StopLimit => CbType::StopLimit,
2843 _ => CbType::Limit,
2844 };
2845
2846 process_user_order_update(make_carrier(update), None, &emitter, &dedup, &state, None);
2847
2848 assert!(drain_status_reports(&mut rx).is_empty());
2849 }
2850
2851 #[rstest]
2852 fn test_process_user_order_update_trade_id_fits_stack_str() {
2853 let (emitter, mut rx) = make_emitter();
2857 let (dedup, state) = make_dedup_state_pair();
2858 let mut update = make_user_order_update("1.0", "0", "100.00", "0.05", CbStatus::Filled);
2859 update.order_id = "11d357f0-155e-4ed4-b87c-1cf966f65d10".to_string();
2860
2861 process_user_order_update(make_carrier(update), None, &emitter, &dedup, &state, None);
2862
2863 let fills = drain_fill_reports(&mut rx);
2864 assert_eq!(fills.len(), 1);
2865 let trade_id = fills[0].trade_id.as_str();
2866 assert!(
2867 trade_id.len() <= 36,
2868 "trade_id was {} chars",
2869 trade_id.len()
2870 );
2871 assert!(
2872 trade_id.starts_with("11d357f0-"),
2873 "trade_id should start with the 8-char prefix, was {trade_id}",
2874 );
2875 }
2876}