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