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