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