1use std::{
27 sync::{
28 Arc, Mutex,
29 atomic::{AtomicBool, Ordering},
30 },
31 time::{Duration, Instant},
32};
33
34use ahash::AHashSet;
35use anyhow::Context;
36use async_trait::async_trait;
37use nautilus_common::{
38 cache::ORDER_NOT_FOUND,
39 clients::ExecutionClient,
40 live::{get_runtime, runner::get_exec_event_sender},
41 messages::execution::{
42 BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
43 GenerateOrderStatusReport, GenerateOrderStatusReports, GeneratePositionStatusReports,
44 ModifyOrder, QueryAccount, QueryOrder, SubmitOrder, SubmitOrderList,
45 },
46};
47use nautilus_core::{
48 AtomicMap, MUTEX_POISONED, UUID4, UnixNanos,
49 time::{AtomicTime, get_atomic_clock_realtime},
50};
51use nautilus_live::{ExecutionClientCore, ExecutionEventEmitter};
52use nautilus_model::{
53 accounts::AccountAny,
54 data::QuoteTick,
55 enums::{OmsType, OrderSide, OrderStatus, OrderType, PositionSideSpecified},
56 events::{
57 OrderAccepted, OrderCanceled, OrderEventAny, OrderExpired, OrderFilled, OrderRejected,
58 },
59 identifiers::{AccountId, ClientId, ClientOrderId, InstrumentId, Symbol, Venue, VenueOrderId},
60 instruments::InstrumentAny,
61 orders::Order,
62 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
63 types::{AccountBalance, Currency, MarginBalance, Price, Quantity},
64};
65use rust_decimal::Decimal;
66use tokio::task::JoinHandle;
67use tokio_util::sync::CancellationToken;
68use ustr::Ustr;
69
70use crate::{
71 common::{
72 consts::{
73 DERIVE_ACCOUNT_REGISTRATION_TIMEOUT_SECS, DERIVE_VENUE, MIN_SIGNATURE_TTL,
74 TRIGGER_ORDER_SIGNATURE_TTL,
75 },
76 credential::DeriveCredential,
77 enums::{DeriveInstrumentType, DeriveOrderSide},
78 parse::{derive_rejection_due_post_only, format_instrument_id, format_venue_symbol},
79 retry::{http_retry_config, is_write_outcome_ambiguous_ws},
80 },
81 config::DeriveExecClientConfig,
82 http::{
83 DeriveCredentials, DeriveHttpClient,
84 models::{DeriveInstrument, DeriveOrder, DeriveTrade},
85 parse::{
86 parse_derive_order_to_report, parse_derive_position_to_report,
87 parse_derive_subaccount_to_balances, parse_derive_trade_to_fill_report,
88 },
89 query::{
90 DeriveCancelAllParams, DeriveCancelParams, DeriveCancelTriggerOrderParams,
91 DeriveGetOpenOrdersParams, DeriveGetOrderHistoryParams, DeriveGetOrderParams,
92 DeriveGetPositionsParams, DeriveGetSubaccountParams, DeriveGetTradeHistoryParams,
93 DeriveGetTriggerOrdersParams, order_replace_to_derive_payload, order_to_derive_payload,
94 trigger_order_to_derive_payload,
95 },
96 },
97 signing::{
98 context::{SigningContext, resolve_signing_context},
99 nonce::NonceManager,
100 },
101 websocket::{
102 DeriveOrdersSubscriptionData, DeriveTradesSubscriptionData, DeriveWebSocketClient,
103 DeriveWsChannel, DeriveWsCredentials, DeriveWsError, DeriveWsExecutionHandle,
104 DeriveWsMessage, OrderIdentity, WsDispatchState, parse::parse_ticker_quote_from_rest,
105 },
106};
107
108const DERIVE_PRIVATE_PAGE_SIZE: u32 = 500;
109
110#[derive(Debug)]
117pub struct DeriveExecutionClient {
118 core: ExecutionClientCore,
119 clock: &'static AtomicTime,
120 config: DeriveExecClientConfig,
121 credential: DeriveCredential,
122 emitter: ExecutionEventEmitter,
123 http_client: DeriveHttpClient,
124 ws_client: DeriveWebSocketClient,
125 ws_exec: DeriveWsExecutionHandle,
126 instruments: Arc<AtomicMap<InstrumentId, DeriveInstrument>>,
127 nonce_manager: Arc<NonceManager>,
128 signing: SigningContext,
129 is_connected: AtomicBool,
130 cancellation_token: CancellationToken,
131 pending_tasks: Mutex<Vec<JoinHandle<()>>>,
132 ws_stream_handle: Mutex<Option<JoinHandle<()>>>,
133 dispatch_state: Arc<WsDispatchState>,
134}
135
136impl DeriveExecutionClient {
137 pub fn new(core: ExecutionClientCore, config: DeriveExecClientConfig) -> anyhow::Result<Self> {
152 let credential = DeriveCredential::resolve(
153 config.wallet_address.clone(),
154 config.session_key.clone(),
155 config.subaccount_id,
156 config.environment,
157 )?;
158
159 let http_credentials = DeriveCredentials::new(
160 credential.wallet_address().to_string(),
161 credential.session_key(),
162 )
163 .context("failed to build Derive HTTP credentials")?;
164 let retry_config = http_retry_config(
165 config.max_retries,
166 config.retry_delay_initial_ms,
167 config.retry_delay_max_ms,
168 );
169 let http_client = DeriveHttpClient::with_credentials(
170 config.rest_url(),
171 http_credentials,
172 Some(config.http_timeout_secs),
173 config.proxy_url.clone(),
174 Some(retry_config),
175 )
176 .context("failed to create Derive HTTP client")?;
177
178 let ws_credentials = DeriveWsCredentials::new(
179 credential.wallet_address().to_string(),
180 credential.session_key(),
181 )
182 .context("failed to build Derive WebSocket credentials")?;
183 let ws_client = DeriveWebSocketClient::with_credentials(
184 Some(config.ws_url()),
185 config.environment,
186 config.transport_backend,
187 config.proxy_url.clone(),
188 ws_credentials,
189 config.max_matching_requests_per_second,
190 );
191 let ws_exec = ws_client.execution_handle();
194
195 let signing = resolve_signing_context(&credential, &config)?;
196
197 let clock = get_atomic_clock_realtime();
198 let emitter = ExecutionEventEmitter::new(
199 clock,
200 core.trader_id,
201 core.account_id,
202 core.account_type,
203 core.base_currency,
204 );
205
206 Ok(Self {
207 core,
208 clock,
209 config,
210 credential,
211 emitter,
212 http_client,
213 ws_client,
214 ws_exec,
215 instruments: Arc::new(AtomicMap::new()),
216 nonce_manager: Arc::new(NonceManager::new()),
217 signing,
218 is_connected: AtomicBool::new(false),
219 cancellation_token: CancellationToken::new(),
220 pending_tasks: Mutex::new(Vec::new()),
221 ws_stream_handle: Mutex::new(None),
222 dispatch_state: Arc::new(WsDispatchState::new()),
223 })
224 }
225
226 #[must_use]
228 pub const fn subaccount_id(&self) -> u64 {
229 self.credential.subaccount_id()
230 }
231
232 #[must_use]
234 pub fn config(&self) -> &DeriveExecClientConfig {
235 &self.config
236 }
237
238 #[must_use]
240 pub fn http_client(&self) -> &DeriveHttpClient {
241 &self.http_client
242 }
243
244 pub fn cache_instrument(&self, instrument: DeriveInstrument) {
248 let instrument_id = format_instrument_id(instrument.instrument_name);
249 self.instruments.insert(instrument_id, instrument);
250 }
251
252 fn spawn_task<F>(&self, description: &'static str, fut: F)
254 where
255 F: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
256 {
257 let runtime = get_runtime();
258 let handle = runtime.spawn(async move {
259 if let Err(e) = fut.await {
260 log::warn!("{description} failed: {e:?}");
261 }
262 });
263
264 let mut tasks = self.pending_tasks.lock().expect(MUTEX_POISONED);
265 tasks.retain(|handle| !handle.is_finished());
266 tasks.push(handle);
267 }
268
269 fn abort_pending_tasks(&self) {
270 let mut tasks = self.pending_tasks.lock().expect(MUTEX_POISONED);
271 for handle in tasks.drain(..) {
272 handle.abort();
273 }
274 }
275
276 async fn ensure_instruments_initialized(&self) -> anyhow::Result<()> {
277 if self.core.instruments_initialized() {
278 return Ok(());
279 }
280 self.core.set_instruments_initialized();
283 Ok(())
284 }
285
286 async fn refresh_account_state(&self) -> anyhow::Result<()> {
287 let value = self
288 .http_client
289 .get_subaccount(&DeriveGetSubaccountParams::new(
290 self.credential.subaccount_id(),
291 ))
292 .await
293 .context("failed to fetch Derive subaccount snapshot")?;
294 let (balances, margins) = parse_derive_subaccount_to_balances(&value)
295 .context("failed to parse Derive subaccount balances")?;
296 let ts_event = self.clock.get_time_ns();
297 self.emitter
298 .emit_account_state(balances, margins, true, ts_event);
299 Ok(())
300 }
301
302 async fn await_account_registered(&self, timeout_secs: f64) -> anyhow::Result<()> {
310 let account_id = self.core.account_id;
311
312 if self.core.cache().account(&account_id).is_some() {
313 log::info!("Account {account_id} registered");
314 return Ok(());
315 }
316
317 let start = Instant::now();
318 let timeout = Duration::from_secs_f64(timeout_secs);
319 let interval = Duration::from_millis(10);
320
321 loop {
322 tokio::time::sleep(interval).await;
323
324 if self.core.cache().account(&account_id).is_some() {
325 log::info!("Account {account_id} registered");
326 return Ok(());
327 }
328
329 if start.elapsed() >= timeout {
330 anyhow::bail!(
331 "Timeout waiting for account {account_id} to be registered after {timeout_secs}s"
332 );
333 }
334 }
335 }
336
337 async fn teardown_partial_connect(&mut self) {
342 self.cancellation_token.cancel();
343
344 if let Some(handle) = self.ws_stream_handle.lock().expect(MUTEX_POISONED).take() {
345 handle.abort();
346 }
347
348 if let Err(e) = self.ws_client.disconnect().await {
349 log::warn!("Error tearing down Derive WebSocket after connect failure: {e}");
350 }
351 self.abort_pending_tasks();
352 }
353
354 fn start_ws_dispatch(&self, rx: tokio::sync::mpsc::UnboundedReceiver<DeriveWsMessage>) {
355 let emitter = self.emitter.clone();
356 let account_id = self.core.account_id;
357 let clock = self.clock;
358 let cancellation = self.cancellation_token.clone();
359 let dispatch_state = self.dispatch_state.clone();
360
361 let handle = get_runtime().spawn(async move {
362 let mut rx = rx;
363
364 loop {
365 tokio::select! {
366 biased;
367 () = cancellation.cancelled() => break,
368 maybe = rx.recv() => {
369 match maybe {
370 Some(message) => handle_ws_message(
371 message,
372 &emitter,
373 account_id,
374 clock,
375 &dispatch_state,
376 ),
377 None => break,
378 }
379 }
380 }
381 }
382 });
383 *self.ws_stream_handle.lock().expect(MUTEX_POISONED) = Some(handle);
384 }
385}
386
387#[async_trait(?Send)]
388impl ExecutionClient for DeriveExecutionClient {
389 fn is_connected(&self) -> bool {
390 self.is_connected.load(Ordering::Acquire)
391 }
392
393 fn client_id(&self) -> ClientId {
394 self.core.client_id
395 }
396
397 fn account_id(&self) -> AccountId {
398 self.core.account_id
399 }
400
401 fn venue(&self) -> Venue {
402 *DERIVE_VENUE
403 }
404
405 fn oms_type(&self) -> OmsType {
406 self.core.oms_type
407 }
408
409 fn get_account(&self) -> Option<AccountAny> {
410 self.core.cache().account_owned(&self.core.account_id)
411 }
412
413 fn start(&mut self) -> anyhow::Result<()> {
414 if self.core.is_started() {
415 return Ok(());
416 }
417
418 let sender = get_exec_event_sender();
419 self.emitter.set_sender(sender);
420 self.core.set_started();
421
422 log::info!(
423 "Started: client_id={}, account_id={}, subaccount_id={}, environment={:?}, proxy_url={:?}",
424 self.core.client_id,
425 self.core.account_id,
426 self.credential.subaccount_id(),
427 self.config.environment,
428 self.config.proxy_url,
429 );
430 Ok(())
431 }
432
433 fn stop(&mut self) -> anyhow::Result<()> {
434 if self.core.is_stopped() {
435 return Ok(());
436 }
437
438 log::info!("Stopping Derive execution client");
439
440 self.cancellation_token.cancel();
441
442 if let Some(handle) = self.ws_stream_handle.lock().expect(MUTEX_POISONED).take() {
443 handle.abort();
444 }
445 self.abort_pending_tasks();
446
447 self.core.set_disconnected();
448 self.core.set_stopped();
449 self.is_connected.store(false, Ordering::Release);
450
451 log::info!("Derive execution client stopped");
452 Ok(())
453 }
454
455 async fn connect(&mut self) -> anyhow::Result<()> {
456 if self.is_connected() {
457 return Ok(());
458 }
459
460 log::info!("Connecting Derive execution client");
461
462 if self.cancellation_token.is_cancelled() {
463 self.cancellation_token = CancellationToken::new();
464 }
465
466 self.ensure_instruments_initialized()
467 .await
468 .context("failed to initialize Derive instruments")?;
469
470 self.ws_client
471 .connect()
472 .await
473 .context("failed to connect Derive WebSocket")?;
474 let rx = self
475 .ws_client
476 .take_event_receiver()
477 .context("Derive execution WS event receiver not initialized")?;
478
479 let subaccount_id = self.credential.subaccount_id();
480 let channels = vec![
481 DeriveWsChannel::orders(subaccount_id),
482 DeriveWsChannel::private_trades(subaccount_id),
483 DeriveWsChannel::balances(subaccount_id),
484 ];
485
486 if let Err(e) = self.ws_client.subscribe_channels(channels).await {
487 log::warn!("Derive private WS subscriptions failed: {e}");
488 }
489
490 self.start_ws_dispatch(rx);
491
492 if let Err(e) = self.refresh_account_state().await {
497 log::warn!("Initial Derive account state refresh failed: {e}; tearing down");
498 self.teardown_partial_connect().await;
499 return Err(e.context("failed initial Derive account state refresh"));
500 }
501
502 if let Err(e) = self
503 .await_account_registered(DERIVE_ACCOUNT_REGISTRATION_TIMEOUT_SECS)
504 .await
505 {
506 log::warn!("Derive account did not register in time: {e}; tearing down");
507 self.teardown_partial_connect().await;
508 return Err(e.context("failed waiting for Derive account registration"));
509 }
510
511 self.core.set_connected();
512 self.is_connected.store(true, Ordering::Release);
513 log::info!(
514 "Connected Derive execution client ({:?})",
515 self.config.environment
516 );
517 Ok(())
518 }
519
520 async fn disconnect(&mut self) -> anyhow::Result<()> {
521 if !self.is_connected() {
522 return Ok(());
523 }
524
525 log::info!("Disconnecting Derive execution client");
526 self.cancellation_token.cancel();
527
528 if let Err(e) = self.ws_client.disconnect().await {
529 log::warn!("Error while disconnecting Derive execution WebSocket: {e}");
530 }
531
532 if let Some(handle) = self.ws_stream_handle.lock().expect(MUTEX_POISONED).take() {
533 handle.abort();
534 }
535 self.abort_pending_tasks();
536
537 self.core.set_disconnected();
538 self.is_connected.store(false, Ordering::Release);
539 log::info!("Derive execution client disconnected");
540 Ok(())
541 }
542
543 fn generate_account_state(
544 &self,
545 balances: Vec<AccountBalance>,
546 margins: Vec<MarginBalance>,
547 reported: bool,
548 ts_event: UnixNanos,
549 ) -> anyhow::Result<()> {
550 self.emitter
551 .emit_account_state(balances, margins, reported, ts_event);
552 Ok(())
553 }
554
555 fn on_instrument(&mut self, _instrument: InstrumentAny) {
556 }
562
563 async fn generate_order_status_report(
564 &self,
565 cmd: &GenerateOrderStatusReport,
566 ) -> anyhow::Result<Option<OrderStatusReport>> {
567 if cmd.venue_order_id.is_none() && cmd.client_order_id.is_none() {
568 log::warn!(
569 "Derive generate_order_status_report requires venue_order_id or client_order_id"
570 );
571 return Ok(None);
572 }
573
574 let subaccount_id = self.credential.subaccount_id();
575 let order = if let Some(venue_order_id) = cmd.venue_order_id {
576 match self
577 .http_client
578 .get_order(&DeriveGetOrderParams::new(
579 subaccount_id,
580 venue_order_id.as_str(),
581 ))
582 .await
583 {
584 Ok(order) => Some(order),
585 Err(e) => {
586 let trigger_orders = self
587 .http_client
588 .get_trigger_orders(&DeriveGetTriggerOrdersParams::new(subaccount_id))
589 .await?
590 .orders;
591
592 match trigger_orders
593 .into_iter()
594 .find(|o| o.order_id.as_str() == venue_order_id.as_str())
595 {
596 Some(order) => Some(order),
597 None => return Err(e.into()),
598 }
599 }
600 }
601 } else {
602 let label = cmd.client_order_id.expect("guarded above");
607 let open_orders = self
608 .http_client
609 .get_open_orders(&DeriveGetOpenOrdersParams::new(subaccount_id))
610 .await?
611 .orders;
612 let mut found = open_orders
613 .into_iter()
614 .find(|o| o.label.as_str() == label.as_str());
615
616 if found.is_none() {
617 let trigger_orders = self
618 .http_client
619 .get_trigger_orders(&DeriveGetTriggerOrdersParams::new(subaccount_id))
620 .await?
621 .orders;
622 found = trigger_orders
623 .into_iter()
624 .find(|o| o.label.as_str() == label.as_str());
625 }
626
627 if found.is_none() {
628 let instrument_name = cmd.instrument_id.map(|id| id.symbol.as_str().to_string());
629 let mut page: u32 = 1;
630
631 'history: loop {
632 let mut params = DeriveGetOrderHistoryParams::new(
633 subaccount_id,
634 page,
635 DERIVE_PRIVATE_PAGE_SIZE,
636 );
637
638 if let Some(name) = instrument_name.as_deref() {
639 params = params.with_instrument_name(name);
640 }
641
642 let result = self.http_client.get_order_history(¶ms).await?;
643 let total_pages = result.pagination.num_pages;
644
645 for order in result.orders {
646 if order.label.as_str() == label.as_str() {
647 found = Some(order);
648 break 'history;
649 }
650 }
651
652 if (page as i64) >= total_pages || total_pages == 0 {
653 break;
654 }
655 page += 1;
656 }
657 }
658 found
659 };
660
661 let Some(order) = order else {
662 return Ok(None);
663 };
664
665 if let Some(instrument_id) = cmd.instrument_id
666 && InstrumentId::new(Symbol::new(order.instrument_name.as_str()), *DERIVE_VENUE)
667 != instrument_id
668 {
669 log::warn!(
670 "Derive order {} is for {} but report requested {}",
671 order.order_id,
672 order.instrument_name.as_str(),
673 instrument_id,
674 );
675 return Ok(None);
676 }
677
678 let ts_init = self.clock.get_time_ns();
679 let mut report = parse_derive_order_to_report(&order, self.core.account_id, ts_init)?;
680 if report.client_order_id.is_none()
683 && let Some(client_order_id) = cmd.client_order_id
684 {
685 report = report.with_client_order_id(client_order_id);
686 }
687 Ok(Some(report))
688 }
689
690 async fn generate_order_status_reports(
691 &self,
692 cmd: &GenerateOrderStatusReports,
693 ) -> anyhow::Result<Vec<OrderStatusReport>> {
694 let subaccount_id = self.credential.subaccount_id();
695 let instrument_name = cmd.instrument_id.map(|id| id.symbol.as_str().to_string());
696
697 let orders: Vec<DeriveOrder> = if cmd.open_only {
703 let mut orders = self
704 .http_client
705 .get_open_orders(&DeriveGetOpenOrdersParams::new(subaccount_id))
706 .await?
707 .orders;
708 orders.extend(
709 self.http_client
710 .get_trigger_orders(&DeriveGetTriggerOrdersParams::new(subaccount_id))
711 .await?
712 .orders,
713 );
714 orders
715 } else {
716 let start_ms = cmd.start.map(|t| t.as_millis() as i64);
717 let end_ms = cmd.end.map(|t| t.as_millis() as i64);
718 let mut page: u32 = 1;
719 let mut collected: Vec<DeriveOrder> = Vec::new();
720
721 loop {
722 let mut params =
723 DeriveGetOrderHistoryParams::new(subaccount_id, page, DERIVE_PRIVATE_PAGE_SIZE)
724 .with_window(start_ms, end_ms);
725
726 if let Some(name) = instrument_name.as_deref() {
727 params = params.with_instrument_name(name);
728 }
729
730 let result = self.http_client.get_order_history(¶ms).await?;
731 let total_pages = result.pagination.num_pages;
732 collected.extend(result.orders);
733
734 if (page as i64) >= total_pages || total_pages == 0 {
735 break;
736 }
737 page += 1;
738 }
739 collected
740 };
741
742 let ts_init = self.clock.get_time_ns();
743 let start_ms = cmd.start.map(|t| t.as_millis() as i64);
744 let end_ms = cmd.end.map(|t| t.as_millis() as i64);
745 let mut reports = Vec::with_capacity(orders.len());
746 for order in orders {
747 if let Some(instrument_id) = cmd.instrument_id
748 && InstrumentId::new(Symbol::new(order.instrument_name.as_str()), *DERIVE_VENUE)
749 != instrument_id
750 {
751 continue;
752 }
753 if let Some(start) = start_ms
757 && order.last_update_timestamp < start
758 {
759 continue;
760 }
761
762 if let Some(end) = end_ms
763 && order.last_update_timestamp > end
764 {
765 continue;
766 }
767
768 match parse_derive_order_to_report(&order, self.core.account_id, ts_init) {
769 Ok(report) => reports.push(report),
770 Err(e) => log::warn!("Skipping order in status report: {e}"),
771 }
772 }
773 Ok(reports)
774 }
775
776 async fn generate_fill_reports(
777 &self,
778 cmd: GenerateFillReports,
779 ) -> anyhow::Result<Vec<FillReport>> {
780 let instrument_name = cmd.instrument_id.map(|id| id.symbol.as_str().to_string());
781 let mut page: u32 = 1;
782 let mut all_trades: Vec<DeriveTrade> = Vec::new();
783
784 loop {
785 let mut params = DeriveGetTradeHistoryParams::new(
786 self.credential.subaccount_id(),
787 page,
788 DERIVE_PRIVATE_PAGE_SIZE,
789 )
790 .with_window(
791 cmd.start.map(|t| t.as_millis() as i64),
792 cmd.end.map(|t| t.as_millis() as i64),
793 );
794
795 if let Some(name) = instrument_name.as_deref() {
796 params = params.with_instrument_name(name);
797 }
798
799 let result = self.http_client.get_private_trade_history(¶ms).await?;
800 let total_pages = result.pagination.num_pages;
801 all_trades.extend(result.trades);
802
803 if (page as i64) >= total_pages || total_pages == 0 {
804 break;
805 }
806 page += 1;
807 }
808
809 let ts_init = self.clock.get_time_ns();
810 let fee_currency = Currency::USDC();
811 let venue_order_id_filter = cmd
812 .venue_order_id
813 .as_ref()
814 .map(|id| id.as_str().to_string());
815 let mut reports = Vec::with_capacity(all_trades.len());
816 for trade in all_trades {
817 if let Some(target) = venue_order_id_filter.as_deref()
818 && trade.order_id != target
819 {
820 continue;
821 }
822
823 match parse_derive_trade_to_fill_report(
824 &trade,
825 self.core.account_id,
826 fee_currency,
827 ts_init,
828 ) {
829 Ok(Some(report)) => {
830 if self.dispatch_state.check_and_insert_trade(report.trade_id) {
834 log::debug!(
835 "Skipping duplicate Derive fill (trade_id={}) in generate_fill_reports",
836 report.trade_id,
837 );
838 continue;
839 }
840 reports.push(report);
841 }
842 Ok(None) => {}
843 Err(e) => log::warn!("Skipping trade in fill report: {e}"),
844 }
845 }
846 Ok(reports)
847 }
848
849 async fn generate_position_status_reports(
850 &self,
851 cmd: &GeneratePositionStatusReports,
852 ) -> anyhow::Result<Vec<PositionStatusReport>> {
853 let positions = self
854 .http_client
855 .get_positions(&DeriveGetPositionsParams::new(
856 self.credential.subaccount_id(),
857 ))
858 .await?
859 .positions;
860 let ts_init = self.clock.get_time_ns();
861 let mut reports = Vec::with_capacity(positions.len());
862 for position in positions {
863 if let Some(target) = cmd.instrument_id
864 && InstrumentId::new(
865 Symbol::new(position.instrument_name.as_str()),
866 *DERIVE_VENUE,
867 ) != target
868 {
869 continue;
870 }
871
872 match parse_derive_position_to_report(&position, self.core.account_id, ts_init) {
873 Ok(report) => reports.push(report),
874 Err(e) => log::warn!("Skipping position in status report: {e}"),
875 }
876 }
877 Ok(reports)
878 }
879
880 async fn generate_mass_status(
881 &self,
882 lookback_mins: Option<u64>,
883 ) -> anyhow::Result<Option<ExecutionMassStatus>> {
884 log::info!("Generating ExecutionMassStatus (lookback_mins={lookback_mins:?})");
885
886 let ts_now = self.clock.get_time_ns();
887 let start = lookback_mins.map(|mins| {
888 let lookback_ns = mins.saturating_mul(60).saturating_mul(1_000_000_000);
889 UnixNanos::from(ts_now.as_u64().saturating_sub(lookback_ns))
890 });
891
892 let open_order_cmd = GenerateOrderStatusReports::new(
893 UUID4::new(),
894 ts_now,
895 true,
896 None,
897 None,
898 None,
899 None,
900 None,
901 );
902 let history_order_cmd = GenerateOrderStatusReports::new(
903 UUID4::new(),
904 ts_now,
905 false,
906 None,
907 start,
908 None,
909 None,
910 None,
911 );
912 let fill_cmd =
913 GenerateFillReports::new(UUID4::new(), ts_now, None, None, start, None, None, None);
914 let position_cmd =
915 GeneratePositionStatusReports::new(UUID4::new(), ts_now, None, None, None, None, None);
916
917 let (history_order_reports, open_order_reports, fill_reports, position_reports) = tokio::try_join!(
918 self.generate_order_status_reports(&history_order_cmd),
919 self.generate_order_status_reports(&open_order_cmd),
920 self.generate_fill_reports(fill_cmd),
921 self.generate_position_status_reports(&position_cmd),
922 )?;
923
924 log::info!(
925 "Received {} historical OrderStatusReports",
926 history_order_reports.len()
927 );
928 log::info!(
929 "Received {} open OrderStatusReports",
930 open_order_reports.len()
931 );
932 log::info!("Received {} FillReports", fill_reports.len());
933 log::info!("Received {} PositionReports", position_reports.len());
934
935 let mut touched_instruments = AHashSet::new();
936
937 for report in history_order_reports
938 .iter()
939 .chain(open_order_reports.iter())
940 {
941 touched_instruments.insert(report.instrument_id);
942 }
943
944 for report in &fill_reports {
945 touched_instruments.insert(report.instrument_id);
946 }
947
948 let mut mass_status = ExecutionMassStatus::new(
949 self.core.client_id,
950 self.core.account_id,
951 *DERIVE_VENUE,
952 ts_now,
953 None,
954 );
955
956 mass_status.add_order_reports(history_order_reports);
957 mass_status.add_order_reports(open_order_reports);
958 mass_status.add_fill_reports(fill_reports);
959 mass_status.add_position_reports(position_reports);
960 add_missing_flat_position_reports(
961 &mut mass_status,
962 self.core.account_id,
963 touched_instruments,
964 ts_now,
965 );
966
967 Ok(Some(mass_status))
968 }
969
970 fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
971 let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
972
973 if order.is_closed() {
974 log::warn!("Cannot submit closed order {}", order.client_order_id());
975 return Ok(());
976 }
977
978 if order.is_reduce_only()
982 && matches!(
983 self.core.cache().instrument(&cmd.instrument_id),
984 Some(InstrumentAny::CurrencyPair(_))
985 )
986 {
987 let reason = format!(
988 "reduce-only is not supported for spot instrument {}; Derive spot has no position to reduce",
989 cmd.instrument_id,
990 );
991 log::warn!("{reason}");
992 self.emitter.emit_order_denied(&order, &reason);
993 return Ok(());
994 }
995
996 let is_trigger_order = is_derive_trigger_order_type(order.order_type());
998 let market_quote = if order.order_type() == OrderType::Market {
999 match self.core.cache().quote(&cmd.instrument_id) {
1000 Some(_) => Some(()),
1001 None => {
1002 let reason = format!(
1003 "no cached quote for {}; subscribe to quote data before submitting market orders",
1004 cmd.instrument_id,
1005 );
1006 log::warn!("{reason}");
1007 self.emitter.emit_order_denied(&order, &reason);
1008 return Ok(());
1009 }
1010 }
1011 } else {
1012 None
1013 };
1014
1015 let venue_symbol = format_venue_symbol(&cmd.instrument_id)?.to_string();
1016 let http_client = self.http_client.clone();
1017 let ws_exec = self.ws_exec.clone();
1018 let signing = self.signing.clone();
1019 let nonce_manager = self.nonce_manager.clone();
1020 let wallet_str = self.credential.wallet_address().to_string();
1021 let emitter = self.emitter.clone();
1022 let clock = self.clock;
1023 let instruments = self.instruments.clone();
1024 let instrument_id = cmd.instrument_id;
1025 let order_for_task = order.clone();
1026 let account_id = self.core.account_id;
1027
1028 let identity = OrderIdentity {
1031 instrument_id: order.instrument_id(),
1032 strategy_id: order.strategy_id(),
1033 order_side: order.order_side(),
1034 order_type: order.order_type(),
1035 };
1036 self.dispatch_state
1037 .register_identity(order.client_order_id(), identity);
1038
1039 self.emitter.emit_order_submitted(&order);
1040
1041 let slippage_bps = self.signing.market_order_slippage_bps;
1042 let dispatch_state = self.dispatch_state.clone();
1043
1044 self.spawn_task("submit_order", async move {
1045 let instrument = match cached_or_fetch_instrument(
1046 &http_client,
1047 &instruments,
1048 &instrument_id,
1049 &venue_symbol,
1050 )
1051 .await
1052 {
1053 Ok(i) => i,
1054 Err(e) => {
1055 log::warn!("Failed to resolve instrument {venue_symbol}: {e}");
1056 dispatch_state.forget(&order_for_task.client_order_id());
1057 let ts = clock.get_time_ns();
1058 emitter.emit_order_rejected(
1059 &order_for_task,
1060 &format!("instrument resolution failed: {e}"),
1061 ts,
1062 false,
1063 );
1064 return Ok(());
1065 }
1066 };
1067
1068 if order_for_task.is_reduce_only()
1072 && instrument.instrument_type == DeriveInstrumentType::Erc20
1073 {
1074 let reason = format!(
1075 "reduce-only is not supported for spot instrument {}; Derive spot has no position to reduce",
1076 order_for_task.instrument_id(),
1077 );
1078 log::warn!("{reason}");
1079 dispatch_state.forget(&order_for_task.client_order_id());
1080 let ts = clock.get_time_ns();
1081 emitter.emit_order_rejected(&order_for_task, &reason, ts, false);
1082 return Ok(());
1083 }
1084
1085 let explicit_price = if market_quote.is_some() {
1087 let quote = match refresh_market_order_quote(
1088 &http_client,
1089 &venue_symbol,
1090 &instrument,
1091 clock,
1092 )
1093 .await
1094 {
1095 Ok(quote) => quote,
1096 Err(e) => {
1097 let reason = format!(
1098 "market-order quote refresh failed for {}: {e}",
1099 order_for_task.client_order_id(),
1100 );
1101 log::warn!("{reason}");
1102 dispatch_state.forget(&order_for_task.client_order_id());
1103 let ts = clock.get_time_ns();
1104 emitter.emit_order_rejected(&order_for_task, &reason, ts, false);
1105 return Ok(());
1106 }
1107 };
1108
1109 match market_order_limit_price(
1110 "e,
1111 order_for_task.order_side(),
1112 slippage_bps,
1113 instrument.tick_size,
1114 ) {
1115 Some(p) => Some(p),
1116 None => {
1117 let reason = format!(
1118 "market-order slippage bound is non-positive for {} ({} bps)",
1119 order_for_task.client_order_id(),
1120 slippage_bps,
1121 );
1122 log::warn!("{reason}");
1123 dispatch_state.forget(&order_for_task.client_order_id());
1124 let ts = clock.get_time_ns();
1125 emitter.emit_order_rejected(&order_for_task, &reason, ts, false);
1126 return Ok(());
1127 }
1128 }
1129 } else if matches!(
1130 order_for_task.order_type(),
1131 OrderType::StopMarket | OrderType::MarketIfTouched
1132 ) {
1133 let trigger_price = match order_for_task.trigger_price() {
1134 Some(price) => price.as_decimal(),
1135 None => {
1136 let reason = format!(
1137 "trigger market order {} is missing trigger_price",
1138 order_for_task.client_order_id(),
1139 );
1140 log::warn!("{reason}");
1141 dispatch_state.forget(&order_for_task.client_order_id());
1142 let ts = clock.get_time_ns();
1143 emitter.emit_order_rejected(&order_for_task, &reason, ts, false);
1144 return Ok(());
1145 }
1146 };
1147
1148 match trigger_market_limit_price(
1149 trigger_price,
1150 order_for_task.order_side(),
1151 slippage_bps,
1152 instrument.tick_size,
1153 ) {
1154 Some(p) => Some(p),
1155 None => {
1156 let reason = format!(
1157 "trigger market-order slippage bound is non-positive for {} ({} bps)",
1158 order_for_task.client_order_id(),
1159 slippage_bps,
1160 );
1161 log::warn!("{reason}");
1162 dispatch_state.forget(&order_for_task.client_order_id());
1163 let ts = clock.get_time_ns();
1164 emitter.emit_order_rejected(&order_for_task, &reason, ts, false);
1165 return Ok(());
1166 }
1167 }
1168 } else {
1169 None
1170 };
1171
1172 if is_trigger_order {
1173 let nonce = nonce_manager.next_nonce(&wallet_str, signing.subaccount_id)?;
1174 let expiry = trigger_order_signature_expiry(clock);
1175 let payload = match trigger_order_to_derive_payload(
1176 &order_for_task,
1177 &instrument,
1178 signing.subaccount_id,
1179 signing.wallet_address,
1180 &signing.signer,
1181 nonce,
1182 expiry,
1183 signing.trade_module_address,
1184 signing.domain_separator,
1185 signing.action_typehash,
1186 signing.max_fee_per_contract,
1187 explicit_price,
1188 ws_exec.conn_id(),
1189 UUID4::new().to_string(),
1190 ) {
1191 Ok(p) => p,
1192 Err(e) => {
1193 log::warn!(
1194 "Trigger order encode failed for {}: {e}",
1195 order_for_task.client_order_id()
1196 );
1197 dispatch_state.forget(&order_for_task.client_order_id());
1198 let ts = clock.get_time_ns();
1199 emitter.emit_order_rejected(
1200 &order_for_task,
1201 &format!("order encoding failed: {e}"),
1202 ts,
1203 false,
1204 );
1205 return Ok(());
1206 }
1207 };
1208
1209 log::debug!(
1210 "Derive trigger submit payload client_order_id={} instrument_name={} direction={} order_type={} time_in_force={} amount={} limit_price={} trigger_price={:?} trigger_price_type={:?} trigger_type={:?}",
1211 order_for_task.client_order_id(),
1212 payload.order.instrument_name.as_str(),
1213 payload.order.direction,
1214 payload.order.order_type,
1215 payload.order.time_in_force,
1216 payload.order.amount,
1217 payload.order.limit_price,
1218 payload.order.trigger_price,
1219 payload.order.trigger_price_type,
1220 payload.order.trigger_type,
1221 );
1222
1223 match ws_exec.submit_trigger_order(&payload).await {
1224 Ok(order) => {
1225 let venue_order_id = VenueOrderId::new(order.order_id.as_str());
1226 dispatch_state.record_venue_order_id(
1227 order_for_task.client_order_id(),
1228 venue_order_id,
1229 );
1230 let ts_now = clock.get_time_ns();
1231 ensure_accepted_emitted(
1232 &emitter,
1233 &dispatch_state,
1234 order_for_task.client_order_id(),
1235 identity,
1236 venue_order_id,
1237 account_id,
1238 ts_now,
1239 ts_now,
1240 );
1241 log::debug!(
1242 "Trigger order submitted: client_order_id={} venue_order_id={venue_order_id}",
1243 order_for_task.client_order_id(),
1244 );
1245 }
1246 Err(e) if is_write_outcome_ambiguous_ws(&e) => {
1247 log::warn!(
1248 "Derive trigger submit for {} returned ambiguous WS outcome: {e}; awaiting reconciliation",
1249 order_for_task.client_order_id(),
1250 );
1251 }
1252 Err(e) => {
1253 let (reason, due_post_only) = ws_rejection_reason(&e);
1254 log::debug!(
1255 "Derive rejected trigger order {}: {reason}",
1256 order_for_task.client_order_id(),
1257 );
1258 dispatch_state.forget(&order_for_task.client_order_id());
1259 let ts = clock.get_time_ns();
1260 emitter.emit_order_rejected(
1261 &order_for_task,
1262 &reason,
1263 ts,
1264 due_post_only,
1265 );
1266 }
1267 }
1268 return Ok(());
1269 }
1270
1271 let expiry =
1272 match normal_order_signature_expiry(clock, signing.signature_expiry_secs) {
1273 Ok(expiry) => expiry,
1274 Err(e) => {
1275 log::warn!(
1276 "Order expiry validation failed for {}: {e}",
1277 order_for_task.client_order_id()
1278 );
1279 dispatch_state.forget(&order_for_task.client_order_id());
1280 let ts = clock.get_time_ns();
1281 emitter.emit_order_rejected(
1282 &order_for_task,
1283 &format!("order expiry validation failed: {e}"),
1284 ts,
1285 false,
1286 );
1287 return Ok(());
1288 }
1289 };
1290 let nonce = nonce_manager.next_nonce(&wallet_str, signing.subaccount_id)?;
1291 let payload = match order_to_derive_payload(
1292 &order_for_task,
1293 &instrument,
1294 signing.subaccount_id,
1295 signing.wallet_address,
1296 &signing.signer,
1297 nonce,
1298 expiry,
1299 signing.trade_module_address,
1300 signing.domain_separator,
1301 signing.action_typehash,
1302 signing.max_fee_per_contract,
1303 explicit_price,
1304 ) {
1305 Ok(p) => p,
1306 Err(e) => {
1307 log::warn!("Order encode failed for {}: {e}", order_for_task.client_order_id());
1308 dispatch_state.forget(&order_for_task.client_order_id());
1309 let ts = clock.get_time_ns();
1310 emitter.emit_order_rejected(
1311 &order_for_task,
1312 &format!("order encoding failed: {e}"),
1313 ts,
1314 false,
1315 );
1316 return Ok(());
1317 }
1318 };
1319
1320 log::debug!(
1323 "Derive submit payload client_order_id={} instrument_name={} direction={} order_type={} time_in_force={} amount={} limit_price={}",
1324 order_for_task.client_order_id(),
1325 payload.instrument_name.as_str(),
1326 payload.direction,
1327 payload.order_type,
1328 payload.time_in_force,
1329 payload.amount,
1330 payload.limit_price,
1331 );
1332
1333 match ws_exec.submit_order(&payload).await {
1336 Ok(_) => {
1337 log::debug!(
1338 "Order submitted: client_order_id={}",
1339 order_for_task.client_order_id(),
1340 );
1341 }
1342 Err(e) if is_write_outcome_ambiguous_ws(&e) => {
1344 log::warn!(
1345 "Derive submit for {} returned ambiguous WS outcome: {e}; awaiting reconciliation",
1346 order_for_task.client_order_id(),
1347 );
1348 }
1349 Err(e) => {
1350 let (reason, due_post_only) = ws_rejection_reason(&e);
1351 log::debug!(
1352 "Derive rejected order {}: {reason}",
1353 order_for_task.client_order_id(),
1354 );
1355 dispatch_state.forget(&order_for_task.client_order_id());
1356 let ts = clock.get_time_ns();
1357 emitter.emit_order_rejected(&order_for_task, &reason, ts, due_post_only);
1358 }
1359 }
1360 Ok(())
1361 });
1362
1363 Ok(())
1364 }
1365
1366 fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
1367 let orders = self.core.get_orders_for_list(&cmd.order_list)?;
1368 for order in orders {
1369 let sub = SubmitOrder::from_order(
1370 &order,
1371 cmd.trader_id,
1372 cmd.client_id,
1373 cmd.position_id,
1374 UUID4::new(),
1375 cmd.ts_init,
1376 );
1377 self.submit_order(sub)?;
1378 }
1379 Ok(())
1380 }
1381
1382 fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
1383 let Some(venue_order_id) = cmd.venue_order_id else {
1384 log::warn!(
1385 "Derive cancel_order requires venue_order_id (client_order_id={})",
1386 cmd.client_order_id,
1387 );
1388 return Ok(());
1389 };
1390 let ws_exec = self.ws_exec.clone();
1391 let subaccount_id = self.credential.subaccount_id();
1392 let venue_symbol = format_venue_symbol(&cmd.instrument_id)?.to_string();
1393 let voi = venue_order_id.to_string();
1394 let emitter = self.emitter.clone();
1395 let clock = self.clock;
1396 let strategy_id = cmd.strategy_id;
1397 let instrument_id = cmd.instrument_id;
1398 let client_order_id = cmd.client_order_id;
1399 let stale_venue_order_id = venue_order_id;
1400 let is_trigger_order = self
1401 .core
1402 .cache()
1403 .order(&client_order_id)
1404 .is_some_and(|order| is_derive_trigger_order_type(order.order_type()));
1405
1406 self.spawn_task("cancel_order", async move {
1407 let outcome = if is_trigger_order {
1408 ws_exec
1409 .cancel_trigger_order(&DeriveCancelTriggerOrderParams::new(
1410 subaccount_id,
1411 voi.as_str(),
1412 ))
1413 .await
1414 .map(|_| ())
1415 } else {
1416 ws_exec
1417 .cancel_order(&DeriveCancelParams::new(
1418 subaccount_id,
1419 venue_symbol.as_str(),
1420 voi.as_str(),
1421 ))
1422 .await
1423 };
1424
1425 match outcome {
1426 Ok(()) => {}
1427 Err(e) if is_write_outcome_ambiguous_ws(&e) => {
1429 log::warn!(
1430 "Derive cancel for {client_order_id} returned ambiguous WS outcome: {e}; awaiting reconciliation",
1431 );
1432 }
1433 Err(e) => {
1434 let (reason, _) = ws_rejection_reason(&e);
1435 log::debug!("Derive rejected cancel for {client_order_id}: {reason}");
1436 let ts = clock.get_time_ns();
1437 emitter.emit_order_cancel_rejected_event(
1438 strategy_id,
1439 instrument_id,
1440 client_order_id,
1441 Some(stale_venue_order_id),
1442 &reason,
1443 ts,
1444 );
1445 }
1446 }
1447 Ok(())
1448 });
1449 Ok(())
1450 }
1451
1452 fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
1453 let http_client = self.http_client.clone();
1454 let ws_exec = self.ws_exec.clone();
1455 let subaccount_id = self.credential.subaccount_id();
1456 let venue_symbol = format_venue_symbol(&cmd.instrument_id)?.to_string();
1457 let side_filter = cmd.order_side;
1458
1459 self.spawn_task("cancel_all_orders", async move {
1460 if matches!(side_filter, OrderSide::Buy | OrderSide::Sell) {
1466 let open_params = DeriveGetOpenOrdersParams::new(subaccount_id);
1467 let mut orders = match http_client.get_open_orders(&open_params).await {
1468 Ok(v) => v,
1469 Err(e) => {
1470 log::warn!(
1471 "Derive cancel_all_orders: failed to list open orders for side filter {side_filter:?}: {e}",
1472 );
1473 return Ok(());
1474 }
1475 }
1476 .orders;
1477
1478 match http_client
1479 .get_trigger_orders(&DeriveGetTriggerOrdersParams::new(subaccount_id))
1480 .await
1481 {
1482 Ok(result) => orders.extend(result.orders),
1483 Err(e) => {
1484 log::warn!(
1485 "Derive cancel_all_orders: failed to list trigger orders for side filter {side_filter:?}: {e}",
1486 );
1487 }
1488 }
1489
1490 for order in orders {
1491 if order.instrument_name.as_str() != venue_symbol {
1492 continue;
1493 }
1494 let order_side = match order.direction {
1495 DeriveOrderSide::Buy => OrderSide::Buy,
1496 DeriveOrderSide::Sell => OrderSide::Sell,
1497 };
1498
1499 if order_side != side_filter {
1500 continue;
1501 }
1502
1503 let outcome = if order.trigger_type.is_some() {
1504 ws_exec
1505 .cancel_trigger_order(&DeriveCancelTriggerOrderParams::new(
1506 subaccount_id,
1507 order.order_id.as_str(),
1508 ))
1509 .await
1510 .map(|_| ())
1511 } else {
1512 ws_exec
1513 .cancel_order(&DeriveCancelParams::new(
1514 subaccount_id,
1515 venue_symbol.as_str(),
1516 order.order_id.as_str(),
1517 ))
1518 .await
1519 };
1520
1521 if let Err(e) = outcome {
1522 log::warn!(
1523 "Derive cancel_all_orders: cancel for {} failed: {e}",
1524 order.order_id,
1525 );
1526 }
1527 }
1528 } else if let Err(e) = ws_exec
1529 .cancel_all_orders(
1530 &DeriveCancelAllParams::new(subaccount_id)
1531 .with_instrument_name(venue_symbol.as_str()),
1532 )
1533 .await
1534 {
1535 log::warn!("Derive cancel_all_orders failed for {venue_symbol}: {e}");
1536 }
1537
1538 if !matches!(side_filter, OrderSide::Buy | OrderSide::Sell) {
1539 let trigger_orders = match http_client
1540 .get_trigger_orders(&DeriveGetTriggerOrdersParams::new(subaccount_id))
1541 .await
1542 {
1543 Ok(result) => result.orders,
1544 Err(e) => {
1545 log::warn!(
1546 "Derive cancel_all_orders: failed to list trigger orders for {venue_symbol}: {e}",
1547 );
1548 return Ok(());
1549 }
1550 };
1551
1552 for order in trigger_orders {
1553 if order.instrument_name.as_str() != venue_symbol {
1554 continue;
1555 }
1556
1557 if let Err(e) = ws_exec
1558 .cancel_trigger_order(&DeriveCancelTriggerOrderParams::new(
1559 subaccount_id,
1560 order.order_id.as_str(),
1561 ))
1562 .await
1563 {
1564 log::warn!(
1565 "Derive cancel_all_orders: trigger cancel for {} failed: {e}",
1566 order.order_id,
1567 );
1568 }
1569 }
1570 }
1571 Ok(())
1572 });
1573 Ok(())
1574 }
1575
1576 fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
1577 for inner in cmd.cancels {
1578 self.cancel_order(inner)?;
1579 }
1580 Ok(())
1581 }
1582
1583 fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
1584 let ts_now = self.clock.get_time_ns();
1585
1586 let Some(venue_order_id) = cmd.venue_order_id else {
1587 let reason = "venue_order_id is required for modify";
1588 log::warn!("Cannot modify order {}: {reason}", cmd.client_order_id);
1589 self.emitter.emit_order_modify_rejected_event(
1590 cmd.strategy_id,
1591 cmd.instrument_id,
1592 cmd.client_order_id,
1593 None,
1594 reason,
1595 ts_now,
1596 );
1597 return Ok(());
1598 };
1599
1600 let Ok(order) = self.core.cache().try_order_owned(&cmd.client_order_id) else {
1601 let reason = ORDER_NOT_FOUND;
1602 log::warn!("Cannot modify order {}: {reason}", cmd.client_order_id);
1603 self.emitter.emit_order_modify_rejected_event(
1604 cmd.strategy_id,
1605 cmd.instrument_id,
1606 cmd.client_order_id,
1607 Some(venue_order_id),
1608 reason,
1609 ts_now,
1610 );
1611 return Ok(());
1612 };
1613
1614 if is_derive_trigger_order_type(order.order_type()) {
1615 let reason = "Derive trigger orders cannot be modified; cancel and resubmit";
1616 log::warn!("Cannot modify order {}: {reason}", cmd.client_order_id);
1617 self.emitter.emit_order_modify_rejected_event(
1618 cmd.strategy_id,
1619 cmd.instrument_id,
1620 cmd.client_order_id,
1621 Some(venue_order_id),
1622 reason,
1623 ts_now,
1624 );
1625 return Ok(());
1626 }
1627
1628 let target_quantity = cmd.quantity.unwrap_or_else(|| order.quantity());
1629 let target_price = cmd.price.or_else(|| order.price());
1630
1631 let venue_symbol = format_venue_symbol(&cmd.instrument_id)?.to_string();
1632 let http_client = self.http_client.clone();
1633 let ws_exec = self.ws_exec.clone();
1634 let signing = self.signing.clone();
1635 let nonce_manager = self.nonce_manager.clone();
1636 let wallet_str = self.credential.wallet_address().to_string();
1637 let emitter = self.emitter.clone();
1638 let clock = self.clock;
1639 let instruments = self.instruments.clone();
1640 let dispatch_state = self.dispatch_state.clone();
1641 let order_for_task = order;
1642 let strategy_id = cmd.strategy_id;
1643 let instrument_id = cmd.instrument_id;
1644 let client_order_id = cmd.client_order_id;
1645 let stale_venue_order_id = venue_order_id;
1646 let voi_str = venue_order_id.to_string();
1647
1648 self.spawn_task("modify_order", async move {
1649 let instrument = match cached_or_fetch_instrument(
1650 &http_client,
1651 &instruments,
1652 &instrument_id,
1653 &venue_symbol,
1654 )
1655 .await
1656 {
1657 Ok(i) => i,
1658 Err(e) => {
1659 let reason = format!("instrument resolution failed: {e}");
1660 log::warn!("Cannot modify order {client_order_id}: {reason}");
1661 let ts = clock.get_time_ns();
1662 emitter.emit_order_modify_rejected_event(
1663 strategy_id,
1664 instrument_id,
1665 client_order_id,
1666 Some(stale_venue_order_id),
1667 &reason,
1668 ts,
1669 );
1670 return Ok(());
1671 }
1672 };
1673
1674 let expiry = match normal_order_signature_expiry(clock, signing.signature_expiry_secs) {
1675 Ok(expiry) => expiry,
1676 Err(e) => {
1677 let reason = format!("replace expiry validation failed: {e}");
1678 log::warn!("Cannot modify order {client_order_id}: {reason}");
1679 let ts = clock.get_time_ns();
1680 emitter.emit_order_modify_rejected_event(
1681 strategy_id,
1682 instrument_id,
1683 client_order_id,
1684 Some(stale_venue_order_id),
1685 &reason,
1686 ts,
1687 );
1688 return Ok(());
1689 }
1690 };
1691 let nonce = nonce_manager.next_nonce(&wallet_str, signing.subaccount_id)?;
1692
1693 let payload = match order_replace_to_derive_payload(
1694 &order_for_task,
1695 &instrument,
1696 signing.subaccount_id,
1697 signing.wallet_address,
1698 &signing.signer,
1699 nonce,
1700 expiry,
1701 signing.trade_module_address,
1702 signing.domain_separator,
1703 signing.action_typehash,
1704 signing.max_fee_per_contract,
1705 Some(target_quantity.as_decimal()),
1706 target_price.map(|p| p.as_decimal()),
1707 &voi_str,
1708 ) {
1709 Ok(p) => p,
1710 Err(e) => {
1711 let reason = format!("replace encoding failed: {e}");
1712 log::warn!("Cannot modify order {client_order_id}: {reason}");
1713 let ts = clock.get_time_ns();
1714 emitter.emit_order_modify_rejected_event(
1715 strategy_id,
1716 instrument_id,
1717 client_order_id,
1718 Some(stale_venue_order_id),
1719 &reason,
1720 ts,
1721 );
1722 return Ok(());
1723 }
1724 };
1725
1726 dispatch_state.mark_pending_modify(client_order_id, stale_venue_order_id);
1729
1730 match ws_exec.modify_order(&payload).await {
1731 Ok(order) => {
1732 let new_voi = VenueOrderId::new(order.order_id.as_str());
1733 log::debug!(
1734 "Order replaced: client_order_id={client_order_id}, new venue_order_id={new_voi}",
1735 );
1736 dispatch_state.record_venue_order_id(client_order_id, new_voi);
1739 dispatch_state.clear_pending_modify(&client_order_id);
1740 let ts = clock.get_time_ns();
1741 emitter.emit_order_updated(
1742 &order_for_task,
1743 new_voi,
1744 target_quantity,
1745 target_price,
1746 None,
1747 None,
1748 ts,
1749 );
1750 }
1751 Err(e) if is_write_outcome_ambiguous_ws(&e) => {
1753 dispatch_state.clear_pending_modify(&client_order_id);
1754 log::warn!(
1755 "Derive modify for {client_order_id} returned ambiguous WS outcome: {e}; awaiting reconciliation",
1756 );
1757 }
1758 Err(e) => {
1759 dispatch_state.clear_pending_modify(&client_order_id);
1760 let (reason, _) = ws_rejection_reason(&e);
1761 log::debug!("Derive rejected modify for {client_order_id}: {reason}");
1762 let ts = clock.get_time_ns();
1763 emitter.emit_order_modify_rejected_event(
1764 strategy_id,
1765 instrument_id,
1766 client_order_id,
1767 Some(stale_venue_order_id),
1768 &reason,
1769 ts,
1770 );
1771 }
1772 }
1773 Ok(())
1774 });
1775 Ok(())
1776 }
1777
1778 fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
1779 let http_client = self.http_client.clone();
1780 let subaccount_id = self.credential.subaccount_id();
1781 let emitter = self.emitter.clone();
1782 let clock = self.clock;
1783 self.spawn_task("query_account", async move {
1784 let subaccount = http_client
1785 .get_subaccount(&DeriveGetSubaccountParams::new(subaccount_id))
1786 .await?;
1787 let (balances, margins) = parse_derive_subaccount_to_balances(&subaccount)?;
1788 let ts_event = clock.get_time_ns();
1789 emitter.emit_account_state(balances, margins, true, ts_event);
1790 Ok(())
1791 });
1792 Ok(())
1793 }
1794
1795 fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
1796 let Some(venue_order_id) = cmd.venue_order_id else {
1797 log::warn!(
1798 "Derive query_order requires venue_order_id (client_order_id={})",
1799 cmd.client_order_id,
1800 );
1801 return Ok(());
1802 };
1803 let http_client = self.http_client.clone();
1804 let subaccount_id = self.credential.subaccount_id();
1805 let account_id = self.core.account_id;
1806 let emitter = self.emitter.clone();
1807 let clock = self.clock;
1808 let voi = venue_order_id.to_string();
1809
1810 self.spawn_task("query_order", async move {
1811 let order = match http_client
1812 .get_order(&DeriveGetOrderParams::new(subaccount_id, voi.as_str()))
1813 .await
1814 {
1815 Ok(o) => o,
1816 Err(e) => {
1817 let trigger_orders = match http_client
1818 .get_trigger_orders(&DeriveGetTriggerOrdersParams::new(subaccount_id))
1819 .await
1820 {
1821 Ok(result) => result.orders,
1822 Err(trigger_err) => {
1823 log::warn!(
1824 "Failed to fetch Derive order {voi}: {e}; trigger lookup also failed: {trigger_err}",
1825 );
1826 return Ok(());
1827 }
1828 };
1829
1830 match trigger_orders
1831 .into_iter()
1832 .find(|o| o.order_id.as_str() == voi.as_str())
1833 {
1834 Some(order) => order,
1835 None => {
1836 log::warn!("Failed to fetch Derive order {voi}: {e}");
1837 return Ok(());
1838 }
1839 }
1840 }
1841 };
1842 let ts_init = clock.get_time_ns();
1843 let report = parse_derive_order_to_report(&order, account_id, ts_init)?;
1844 emitter.send_order_status_report(report);
1845 Ok(())
1846 });
1847 Ok(())
1848 }
1849}
1850
1851fn ws_rejection_reason(error: &DeriveWsError) -> (String, bool) {
1854 match error {
1855 DeriveWsError::JsonRpc { code, message, .. } => (
1856 format!("JSON-RPC {code}: {message}"),
1857 derive_rejection_due_post_only(Some(*code), message),
1858 ),
1859 other => (other.to_string(), false),
1860 }
1861}
1862
1863fn add_missing_flat_position_reports(
1864 mass_status: &mut ExecutionMassStatus,
1865 account_id: AccountId,
1866 touched_instruments: AHashSet<InstrumentId>,
1867 ts_init: UnixNanos,
1868) {
1869 let active_position_instruments: AHashSet<InstrumentId> =
1870 mass_status.position_reports().keys().copied().collect();
1871 let mut flat_reports = Vec::new();
1872
1873 for instrument_id in touched_instruments {
1874 if active_position_instruments.contains(&instrument_id) {
1875 continue;
1876 }
1877
1878 flat_reports.push(PositionStatusReport::new(
1879 account_id,
1880 instrument_id,
1881 PositionSideSpecified::Flat,
1882 Quantity::from("0"),
1883 ts_init,
1884 ts_init,
1885 Some(UUID4::new()),
1886 None,
1887 None,
1888 ));
1889 }
1890
1891 if !flat_reports.is_empty() {
1892 log::info!(
1893 "Added {} flat PositionReports for Derive instruments absent from current positions",
1894 flat_reports.len()
1895 );
1896 mass_status.add_position_reports(flat_reports);
1897 }
1898}
1899
1900fn handle_ws_message(
1901 message: DeriveWsMessage,
1902 emitter: &ExecutionEventEmitter,
1903 account_id: AccountId,
1904 clock: &'static AtomicTime,
1905 dispatch_state: &WsDispatchState,
1906) {
1907 let payload = match message {
1908 DeriveWsMessage::Subscription(payload) => payload,
1909 DeriveWsMessage::Authenticated | DeriveWsMessage::Reconnected => return,
1910 };
1911
1912 let is_orders_channel = payload.channel.as_str().ends_with(".orders");
1913 let is_trades_channel = payload.channel.as_str().ends_with(".trades");
1914
1915 if is_orders_channel {
1916 let data = match serde_json::from_str::<DeriveOrdersSubscriptionData>(payload.data.get()) {
1917 Ok(data) => data,
1918 Err(_) => return,
1919 };
1920 dispatch_orders_payload(data, emitter, account_id, clock, dispatch_state);
1921 } else if is_trades_channel {
1922 let data = match serde_json::from_str::<DeriveTradesSubscriptionData>(payload.data.get()) {
1923 Ok(data) => data,
1924 Err(_) => return,
1925 };
1926 dispatch_trades_payload(data, emitter, account_id, clock, dispatch_state);
1927 }
1928}
1929
1930pub fn dispatch_orders_payload(
1937 data: DeriveOrdersSubscriptionData,
1938 emitter: &ExecutionEventEmitter,
1939 account_id: AccountId,
1940 clock: &'static AtomicTime,
1941 dispatch_state: &WsDispatchState,
1942) {
1943 let ts_init = clock.get_time_ns();
1944 for order in data.orders {
1945 let report = match parse_derive_order_to_report(&order, account_id, ts_init) {
1946 Ok(report) => report,
1947 Err(e) => {
1948 log::warn!("Failed to parse Derive order WS update: {e}");
1949 continue;
1950 }
1951 };
1952
1953 let identity = tracked_order_identity(report.client_order_id, dispatch_state);
1954
1955 match identity {
1956 Some((client_order_id, identity)) => emit_tracked_order_event(
1957 emitter,
1958 dispatch_state,
1959 client_order_id,
1960 identity,
1961 &report,
1962 account_id,
1963 ts_init,
1964 ),
1965 None => emitter.send_order_status_report(report),
1966 }
1967 }
1968}
1969
1970pub fn dispatch_trades_payload(
1977 data: DeriveTradesSubscriptionData,
1978 emitter: &ExecutionEventEmitter,
1979 account_id: AccountId,
1980 clock: &'static AtomicTime,
1981 dispatch_state: &WsDispatchState,
1982) {
1983 let ts_init = clock.get_time_ns();
1984 let fee_currency = Currency::USDC();
1985 for trade in data.trades {
1986 match parse_derive_trade_to_fill_report(&trade, account_id, fee_currency, ts_init) {
1987 Ok(Some(report)) => {
1988 if dispatch_state.check_and_insert_trade(report.trade_id) {
1989 log::debug!(
1990 "Skipping duplicate Derive fill (trade_id={}) on WS dispatch",
1991 report.trade_id,
1992 );
1993 continue;
1994 }
1995
1996 let identity = tracked_order_identity(report.client_order_id, dispatch_state);
1997
1998 match identity {
1999 Some((client_order_id, identity)) => emit_tracked_fill(
2000 emitter,
2001 dispatch_state,
2002 client_order_id,
2003 identity,
2004 &report,
2005 account_id,
2006 ts_init,
2007 ),
2008 None => emitter.send_fill_report(report),
2009 }
2010 }
2011 Ok(None) => {}
2012 Err(e) => log::warn!("Failed to parse Derive trade WS update: {e}"),
2013 }
2014 }
2015}
2016
2017fn tracked_order_identity(
2018 client_order_id: Option<ClientOrderId>,
2019 dispatch_state: &WsDispatchState,
2020) -> Option<(ClientOrderId, OrderIdentity)> {
2021 client_order_id.and_then(|cid| {
2022 dispatch_state
2023 .identity(&cid)
2024 .map(|identity| (cid, identity))
2025 })
2026}
2027
2028#[expect(clippy::too_many_arguments)]
2033fn ensure_accepted_emitted(
2034 emitter: &ExecutionEventEmitter,
2035 dispatch_state: &WsDispatchState,
2036 client_order_id: ClientOrderId,
2037 identity: OrderIdentity,
2038 venue_order_id: VenueOrderId,
2039 account_id: AccountId,
2040 ts_event: UnixNanos,
2041 ts_init: UnixNanos,
2042) {
2043 if dispatch_state.mark_accepted(client_order_id) {
2044 return;
2045 }
2046 let accepted = OrderAccepted::new(
2047 emitter.trader_id(),
2048 identity.strategy_id,
2049 identity.instrument_id,
2050 client_order_id,
2051 venue_order_id,
2052 account_id,
2053 UUID4::new(),
2054 ts_event,
2055 ts_init,
2056 false,
2057 );
2058 emitter.send_order_event(OrderEventAny::Accepted(accepted));
2059}
2060
2061fn emit_tracked_order_event(
2062 emitter: &ExecutionEventEmitter,
2063 dispatch_state: &WsDispatchState,
2064 client_order_id: ClientOrderId,
2065 identity: OrderIdentity,
2066 report: &OrderStatusReport,
2067 account_id: AccountId,
2068 ts_init: UnixNanos,
2069) {
2070 let venue_order_id = report.venue_order_id;
2071 let ts_accepted = report.ts_accepted;
2072 let ts_event = report.ts_last;
2073
2074 if dispatch_state.pending_modify(&client_order_id) == Some(venue_order_id) {
2080 log::debug!(
2081 "Skipping cancel-replace leg for {client_order_id}: stale venue_order_id={venue_order_id}",
2082 );
2083 return;
2084 }
2085
2086 if let Some(bound) = dispatch_state.bound_venue_order_id(&client_order_id)
2087 && bound != venue_order_id
2088 {
2089 log::debug!(
2090 "Skipping stale {:?} for {client_order_id}: venue_order_id={venue_order_id} superseded by {bound}",
2091 report.order_status,
2092 );
2093 return;
2094 }
2095
2096 match report.order_status {
2097 OrderStatus::Accepted | OrderStatus::PartiallyFilled => {
2098 if dispatch_state.contains_filled(&client_order_id) {
2099 log::debug!("Skipping stale Accepted for {client_order_id} (already filled)",);
2100 return;
2101 }
2102 dispatch_state.record_venue_order_id(client_order_id, venue_order_id);
2103 ensure_accepted_emitted(
2104 emitter,
2105 dispatch_state,
2106 client_order_id,
2107 identity,
2108 venue_order_id,
2109 account_id,
2110 ts_accepted,
2111 ts_init,
2112 );
2113 }
2114 OrderStatus::Filled => {
2115 dispatch_state.record_venue_order_id(client_order_id, venue_order_id);
2116 ensure_accepted_emitted(
2117 emitter,
2118 dispatch_state,
2119 client_order_id,
2120 identity,
2121 venue_order_id,
2122 account_id,
2123 ts_accepted,
2124 ts_init,
2125 );
2126 dispatch_state.mark_filled(client_order_id);
2134 }
2135 OrderStatus::Canceled => {
2136 ensure_accepted_emitted(
2137 emitter,
2138 dispatch_state,
2139 client_order_id,
2140 identity,
2141 venue_order_id,
2142 account_id,
2143 ts_accepted,
2144 ts_init,
2145 );
2146 let canceled = OrderCanceled::new(
2147 emitter.trader_id(),
2148 identity.strategy_id,
2149 identity.instrument_id,
2150 client_order_id,
2151 UUID4::new(),
2152 ts_event,
2153 ts_init,
2154 false,
2155 Some(venue_order_id),
2156 Some(account_id),
2157 );
2158 emitter.send_order_event(OrderEventAny::Canceled(canceled));
2159 dispatch_state.forget(&client_order_id);
2160 }
2161 OrderStatus::Expired => {
2162 ensure_accepted_emitted(
2163 emitter,
2164 dispatch_state,
2165 client_order_id,
2166 identity,
2167 venue_order_id,
2168 account_id,
2169 ts_accepted,
2170 ts_init,
2171 );
2172 let expired = OrderExpired::new(
2173 emitter.trader_id(),
2174 identity.strategy_id,
2175 identity.instrument_id,
2176 client_order_id,
2177 UUID4::new(),
2178 ts_event,
2179 ts_init,
2180 false,
2181 Some(venue_order_id),
2182 Some(account_id),
2183 );
2184 emitter.send_order_event(OrderEventAny::Expired(expired));
2185 dispatch_state.forget(&client_order_id);
2186 }
2187 OrderStatus::Rejected => {
2188 let reason = report
2189 .cancel_reason
2190 .as_deref()
2191 .unwrap_or("Order rejected by Derive");
2192 let due_post_only = derive_rejection_due_post_only(None, reason);
2193 let rejected = OrderRejected::new(
2194 emitter.trader_id(),
2195 identity.strategy_id,
2196 identity.instrument_id,
2197 client_order_id,
2198 account_id,
2199 Ustr::from(reason),
2200 UUID4::new(),
2201 ts_event,
2202 ts_init,
2203 false,
2204 due_post_only,
2205 );
2206 emitter.send_order_event(OrderEventAny::Rejected(rejected));
2207 dispatch_state.forget(&client_order_id);
2208 }
2209 other => {
2210 log::debug!(
2211 "Unhandled tracked order status {other:?} for {client_order_id}, sending as report",
2212 );
2213 emitter.send_order_status_report(report.clone());
2214 }
2215 }
2216}
2217
2218fn emit_tracked_fill(
2219 emitter: &ExecutionEventEmitter,
2220 dispatch_state: &WsDispatchState,
2221 client_order_id: ClientOrderId,
2222 identity: OrderIdentity,
2223 report: &FillReport,
2224 account_id: AccountId,
2225 ts_init: UnixNanos,
2226) {
2227 ensure_accepted_emitted(
2228 emitter,
2229 dispatch_state,
2230 client_order_id,
2231 identity,
2232 report.venue_order_id,
2233 account_id,
2234 report.ts_event,
2235 ts_init,
2236 );
2237
2238 let filled = OrderFilled::new(
2239 emitter.trader_id(),
2240 identity.strategy_id,
2241 identity.instrument_id,
2242 client_order_id,
2243 report.venue_order_id,
2244 account_id,
2245 report.trade_id,
2246 identity.order_side,
2247 identity.order_type,
2248 report.last_qty,
2249 report.last_px,
2250 report.commission.currency,
2251 report.liquidity_side,
2252 UUID4::new(),
2253 report.ts_event,
2254 ts_init,
2255 false,
2256 report.venue_position_id,
2257 Some(report.commission),
2258 );
2259 emitter.send_order_event(OrderEventAny::Filled(filled));
2260}
2261
2262fn market_order_limit_price(
2273 quote: &QuoteTick,
2274 side: OrderSide,
2275 slippage_bps: u32,
2276 tick_size: Decimal,
2277) -> Option<Decimal> {
2278 let bps = Decimal::from(slippage_bps);
2279 let scale = Decimal::from(10_000_u32);
2280 let one = Decimal::ONE;
2281 let raw = match side {
2282 OrderSide::Buy => quote.ask_price.as_decimal() * (one + bps / scale),
2283 OrderSide::Sell => quote.bid_price.as_decimal() * (one - bps / scale),
2284 OrderSide::NoOrderSide => return None,
2286 };
2287 let rounded = round_to_tick(raw, tick_size, side);
2288 if rounded <= Decimal::ZERO {
2289 return None;
2290 }
2291 Some(rounded)
2292}
2293
2294fn trigger_market_limit_price(
2295 trigger_price: Decimal,
2296 side: OrderSide,
2297 slippage_bps: u32,
2298 tick_size: Decimal,
2299) -> Option<Decimal> {
2300 let bps = Decimal::from(slippage_bps);
2301 let scale = Decimal::from(10_000_u32);
2302 let one = Decimal::ONE;
2303 let raw = match side {
2304 OrderSide::Buy => trigger_price * (one + bps / scale),
2305 OrderSide::Sell => trigger_price * (one - bps / scale),
2306 OrderSide::NoOrderSide => return None,
2307 };
2308 let rounded = round_to_tick(raw, tick_size, side);
2309 if rounded <= Decimal::ZERO {
2310 return None;
2311 }
2312 Some(rounded)
2313}
2314
2315fn is_derive_trigger_order_type(order_type: OrderType) -> bool {
2316 matches!(
2317 order_type,
2318 OrderType::StopMarket
2319 | OrderType::StopLimit
2320 | OrderType::MarketIfTouched
2321 | OrderType::LimitIfTouched
2322 )
2323}
2324
2325fn trigger_order_signature_expiry(clock: &'static AtomicTime) -> i64 {
2326 let now_secs = (clock.get_time_ns().as_u64() / 1_000_000_000) as i64;
2327 now_secs + TRIGGER_ORDER_SIGNATURE_TTL.as_secs() as i64
2328}
2329
2330fn normal_order_signature_expiry(
2331 clock: &'static AtomicTime,
2332 signature_expiry_secs: u64,
2333) -> anyhow::Result<i64> {
2334 let min_ttl_secs = MIN_SIGNATURE_TTL.as_secs();
2335 if signature_expiry_secs <= min_ttl_secs {
2336 anyhow::bail!(
2337 "signature_expiry_secs {signature_expiry_secs}s must be greater than the Derive minimum {min_ttl_secs}s"
2338 );
2339 }
2340
2341 let now_secs_u64 = clock.get_time_ns().as_u64() / 1_000_000_000;
2342 let now_secs = i64::try_from(now_secs_u64).with_context(|| {
2343 format!("current UNIX time {now_secs_u64}s cannot fit in Derive signature_expiry_sec")
2344 })?;
2345 let ttl_secs = i64::try_from(signature_expiry_secs).with_context(|| {
2346 format!(
2347 "signature_expiry_secs {signature_expiry_secs}s cannot fit in Derive signature_expiry_sec"
2348 )
2349 })?;
2350
2351 now_secs.checked_add(ttl_secs).ok_or_else(|| {
2352 anyhow::anyhow!(
2353 "signature expiry overflows Derive signature_expiry_sec: now {now_secs}s plus TTL {ttl_secs}s"
2354 )
2355 })
2356}
2357
2358async fn refresh_market_order_quote(
2359 http_client: &DeriveHttpClient,
2360 venue_symbol: &str,
2361 instrument: &DeriveInstrument,
2362 clock: &'static AtomicTime,
2363) -> anyhow::Result<QuoteTick> {
2364 let ticker = http_client.get_ticker(venue_symbol).await?;
2365 let price_precision = Price::from_decimal(instrument.tick_size)
2366 .with_context(|| format!("invalid Derive tick_size for {venue_symbol}"))?
2367 .precision;
2368 let size_precision = Quantity::from_decimal(instrument.amount_step)
2369 .with_context(|| format!("invalid Derive amount_step for {venue_symbol}"))?
2370 .precision;
2371
2372 parse_ticker_quote_from_rest(
2373 &ticker,
2374 price_precision,
2375 size_precision,
2376 clock.get_time_ns(),
2377 )
2378}
2379
2380fn round_to_tick(value: Decimal, tick_size: Decimal, side: OrderSide) -> Decimal {
2385 if tick_size <= Decimal::ZERO {
2386 return value;
2387 }
2388 let ratio = value / tick_size;
2389 let ticks = match side {
2390 OrderSide::Buy => ratio.ceil(),
2391 OrderSide::Sell => ratio.floor(),
2392 OrderSide::NoOrderSide => ratio.round(),
2393 };
2394 ticks * tick_size
2395}
2396
2397async fn cached_or_fetch_instrument(
2398 http_client: &DeriveHttpClient,
2399 instruments: &Arc<AtomicMap<InstrumentId, DeriveInstrument>>,
2400 instrument_id: &InstrumentId,
2401 venue_symbol: &str,
2402) -> anyhow::Result<DeriveInstrument> {
2403 if let Some(cached) = instruments.get_cloned(instrument_id) {
2404 return Ok(cached);
2405 }
2406 let instrument = http_client
2407 .get_instrument(venue_symbol)
2408 .await
2409 .with_context(|| format!("failed to fetch instrument {venue_symbol}"))?;
2410 instruments.insert(*instrument_id, instrument.clone());
2411 Ok(instrument)
2412}
2413
2414#[cfg(test)]
2415mod tests {
2416 use std::{cell::RefCell, rc::Rc};
2417
2418 use nautilus_common::{cache::Cache, messages::ExecutionEvent};
2419 use nautilus_core::UnixNanos;
2420 use nautilus_live::ExecutionClientCore;
2421 use nautilus_model::{
2422 data::QuoteTick,
2423 enums::{AccountType, OmsType, TimeInForce},
2424 identifiers::{AccountId, ClientId, InstrumentId, StrategyId, TraderId},
2425 types::{Price, Quantity},
2426 };
2427 use rstest::rstest;
2428 use rust_decimal_macros::dec;
2429
2430 use super::*;
2431 use crate::common::{consts::DERIVE, enums::DeriveEnvironment};
2432
2433 const TEST_WALLET: &str = "0x0000000000000000000000000000000000001234";
2434 const TEST_SESSION_KEY: &str =
2435 "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
2436 const TEST_SUBACCOUNT: u64 = 30769;
2437
2438 fn test_core() -> ExecutionClientCore {
2439 let cache = Rc::new(RefCell::new(Cache::default()));
2440 ExecutionClientCore::new(
2441 TraderId::from("TRADER-001"),
2442 ClientId::from(DERIVE),
2443 *DERIVE_VENUE,
2444 OmsType::Netting,
2445 AccountId::from("DERIVE-001"),
2446 AccountType::Margin,
2447 None,
2448 cache,
2449 )
2450 }
2451
2452 fn test_config() -> DeriveExecClientConfig {
2453 DeriveExecClientConfig {
2454 wallet_address: Some(TEST_WALLET.to_string()),
2455 session_key: Some(TEST_SESSION_KEY.to_string()),
2456 subaccount_id: Some(TEST_SUBACCOUNT),
2457 environment: DeriveEnvironment::Testnet,
2458 domain_separator: Some(
2459 "0x2222222222222222222222222222222222222222222222222222222222222222".to_string(),
2460 ),
2461 action_typehash: Some(
2462 "0x1111111111111111111111111111111111111111111111111111111111111111".to_string(),
2463 ),
2464 trade_module_address: Some("0x000000000000000000000000000000000000bbbb".to_string()),
2465 ..DeriveExecClientConfig::default()
2466 }
2467 }
2468
2469 #[rstest]
2470 fn test_market_order_limit_price_buy_lifts_ask_and_rounds_up_to_tick() {
2471 let quote = QuoteTick::new(
2472 InstrumentId::from("ETH-PERP.DERIVE"),
2473 Price::from("3500.00"),
2474 Price::from("3501.00"),
2475 Quantity::from("1.000"),
2476 Quantity::from("1.000"),
2477 UnixNanos::from(0),
2478 UnixNanos::from(0),
2479 );
2480 let price = market_order_limit_price("e, OrderSide::Buy, 50, dec!(0.01)).unwrap();
2482 assert_eq!(price, dec!(3518.51));
2483 }
2484
2485 #[rstest]
2486 fn test_market_order_limit_price_sell_drops_bid_rounds_down_and_denies_non_positive() {
2487 let quote = QuoteTick::new(
2488 InstrumentId::from("ETH-PERP.DERIVE"),
2489 Price::from("3500.00"),
2490 Price::from("3501.00"),
2491 Quantity::from("1.000"),
2492 Quantity::from("1.000"),
2493 UnixNanos::from(0),
2494 UnixNanos::from(0),
2495 );
2496 let price = market_order_limit_price("e, OrderSide::Sell, 50, dec!(0.01)).unwrap();
2498 assert_eq!(price, dec!(3482.5));
2499
2500 let zero = market_order_limit_price("e, OrderSide::Sell, 20_000, dec!(0.01));
2502 assert!(zero.is_none());
2503 }
2504
2505 #[rstest]
2506 fn test_trigger_market_limit_price_uses_trigger_price_bound() {
2507 let buy = trigger_market_limit_price(dec!(3600), OrderSide::Buy, 50, dec!(0.01)).unwrap();
2508 let sell = trigger_market_limit_price(dec!(3600), OrderSide::Sell, 50, dec!(0.01)).unwrap();
2509 let zero = trigger_market_limit_price(dec!(1), OrderSide::Sell, 20_000, dec!(0.01));
2510
2511 assert_eq!(buy, dec!(3618));
2512 assert_eq!(sell, dec!(3582));
2513 assert!(zero.is_none());
2514 }
2515
2516 #[rstest]
2517 fn test_normal_order_signature_expiry_accepts_ttl_above_minimum() {
2518 let clock = get_atomic_clock_realtime();
2519 let start_secs = (clock.get_time_ns().as_u64() / 1_000_000_000) as i64;
2520 let ttl_secs = MIN_SIGNATURE_TTL.as_secs() + 1;
2521
2522 let expiry = normal_order_signature_expiry(clock, ttl_secs).expect("expiry is valid");
2523
2524 assert!(expiry >= start_secs + ttl_secs as i64);
2525 }
2526
2527 #[rstest]
2528 #[case(MIN_SIGNATURE_TTL.as_secs(), "must be greater than the Derive minimum")]
2529 #[case(MIN_SIGNATURE_TTL.as_secs() - 1, "must be greater than the Derive minimum")]
2530 fn test_normal_order_signature_expiry_rejects_minimum_or_lower_ttl(
2531 #[case] ttl_secs: u64,
2532 #[case] reason_fragment: &str,
2533 ) {
2534 let clock = get_atomic_clock_realtime();
2535
2536 let err = normal_order_signature_expiry(clock, ttl_secs).expect_err("TTL is too short");
2537
2538 assert!(
2539 err.to_string().contains(reason_fragment),
2540 "unexpected error: {err}",
2541 );
2542 }
2543
2544 #[rstest]
2545 #[case(i64::MAX as u64, "overflows Derive signature_expiry_sec")]
2546 #[case(u64::MAX, "cannot fit in Derive signature_expiry_sec")]
2547 fn test_normal_order_signature_expiry_rejects_extreme_ttl(
2548 #[case] ttl_secs: u64,
2549 #[case] reason_fragment: &str,
2550 ) {
2551 let clock = get_atomic_clock_realtime();
2552
2553 let err = normal_order_signature_expiry(clock, ttl_secs).expect_err("TTL is invalid");
2554
2555 assert!(
2556 err.to_string().contains(reason_fragment),
2557 "unexpected error: {err}",
2558 );
2559 }
2560
2561 #[rstest]
2562 #[case(OrderType::StopMarket, true)]
2563 #[case(OrderType::StopLimit, true)]
2564 #[case(OrderType::MarketIfTouched, true)]
2565 #[case(OrderType::LimitIfTouched, true)]
2566 #[case(OrderType::Market, false)]
2567 #[case(OrderType::Limit, false)]
2568 #[case(OrderType::MarketToLimit, false)]
2569 #[case(OrderType::TrailingStopMarket, false)]
2570 fn test_is_derive_trigger_order_type(#[case] order_type: OrderType, #[case] expected: bool) {
2571 assert_eq!(is_derive_trigger_order_type(order_type), expected);
2572 }
2573
2574 #[rstest]
2575 #[case(dec!(0))]
2576 #[case(dec!(-1))]
2577 fn test_round_to_tick_treats_non_positive_tick_as_no_op(#[case] tick: Decimal) {
2578 assert_eq!(
2581 round_to_tick(dec!(3501.55), tick, OrderSide::Buy),
2582 dec!(3501.55)
2583 );
2584 assert_eq!(
2585 round_to_tick(dec!(3501.55), tick, OrderSide::Sell),
2586 dec!(3501.55)
2587 );
2588 }
2589
2590 #[rstest]
2591 fn test_resolve_signing_context_rejects_placeholder_domain_separator() {
2592 let mut config = test_config();
2596 config.environment = DeriveEnvironment::Mainnet;
2597 config.domain_separator =
2598 Some("0x<paste_from_docs.derive.xyz_protocol_constants>".to_string());
2599 let err = DeriveExecutionClient::new(test_core(), config).expect_err("must reject");
2600 let msg = err.to_string();
2601 assert!(msg.contains("placeholder"), "unexpected error: {msg}",);
2602 }
2603
2604 #[rstest]
2605 fn test_resolve_signing_context_uses_mainnet_defaults() {
2606 let mut config = test_config();
2607 config.environment = DeriveEnvironment::Mainnet;
2608 config.domain_separator = None;
2609 config.action_typehash = None;
2610 config.trade_module_address = None;
2611
2612 DeriveExecutionClient::new(test_core(), config).expect("mainnet defaults should parse");
2613 }
2614
2615 #[rstest]
2616 fn test_resolve_signing_context_uses_testnet_defaults() {
2617 let mut config = test_config();
2618 config.environment = DeriveEnvironment::Testnet;
2619 config.domain_separator = None;
2620 config.action_typehash = None;
2621 config.trade_module_address = None;
2622
2623 DeriveExecutionClient::new(test_core(), config).expect("testnet defaults should parse");
2624 }
2625
2626 #[rstest]
2627 fn test_market_order_limit_price_rounds_to_coarse_tick() {
2628 let quote = QuoteTick::new(
2631 InstrumentId::from("ETH-20260627-3500-C.DERIVE"),
2632 Price::from("3500"),
2633 Price::from("3501"),
2634 Quantity::from("1.000"),
2635 Quantity::from("1.000"),
2636 UnixNanos::from(0),
2637 UnixNanos::from(0),
2638 );
2639 let buy = market_order_limit_price("e, OrderSide::Buy, 50, dec!(1)).unwrap();
2640 assert_eq!(buy, dec!(3519));
2641 let sell = market_order_limit_price("e, OrderSide::Sell, 50, dec!(1)).unwrap();
2642 assert_eq!(sell, dec!(3482));
2643 }
2644
2645 #[rstest]
2646 fn test_new_populates_identity() {
2647 let core = test_core();
2648 let client = DeriveExecutionClient::new(core, test_config()).unwrap();
2649
2650 assert_eq!(client.client_id(), ClientId::from(DERIVE));
2651 assert_eq!(client.account_id(), AccountId::from("DERIVE-001"));
2652 assert_eq!(client.venue(), *DERIVE_VENUE);
2653 assert_eq!(client.oms_type(), OmsType::Netting);
2654 assert_eq!(client.subaccount_id(), TEST_SUBACCOUNT);
2655 assert!(!client.is_connected());
2656 }
2657
2658 #[rstest]
2659 fn test_emit_tracked_event_suppresses_in_flight_replace_cancel_leg() {
2660 let clock = get_atomic_clock_realtime();
2667 let account_id = AccountId::from("DERIVE-001");
2668 let instrument_id = InstrumentId::from("ETH-PERP.DERIVE");
2669 let cid = ClientOrderId::from("STRAT-MOD-INFLIGHT");
2670 let stale_voi = VenueOrderId::from("ord-stale-1");
2671 let identity = OrderIdentity {
2672 instrument_id,
2673 strategy_id: StrategyId::from("S-1"),
2674 order_side: OrderSide::Buy,
2675 order_type: OrderType::Limit,
2676 };
2677 let report = OrderStatusReport::new(
2680 account_id,
2681 instrument_id,
2682 Some(cid),
2683 stale_voi,
2684 OrderSide::Buy,
2685 OrderType::Limit,
2686 TimeInForce::Gtc,
2687 OrderStatus::Canceled,
2688 Quantity::from("1.000"),
2689 Quantity::from("0.000"),
2690 UnixNanos::from(1_000),
2691 UnixNanos::from(2_000),
2692 UnixNanos::from(3_000),
2693 None,
2694 );
2695
2696 let new_emitter = || {
2697 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2698 let mut emitter = ExecutionEventEmitter::new(
2699 clock,
2700 TraderId::from("TRADER-001"),
2701 account_id,
2702 AccountType::Margin,
2703 Some(Currency::USDC()),
2704 );
2705 emitter.set_sender(tx);
2706 (emitter, rx)
2707 };
2708
2709 let (emitter, mut rx) = new_emitter();
2712 let state = WsDispatchState::new();
2713 state.mark_pending_modify(cid, stale_voi);
2714 emit_tracked_order_event(
2715 &emitter,
2716 &state,
2717 cid,
2718 identity,
2719 &report,
2720 account_id,
2721 UnixNanos::from(0),
2722 );
2723 let suppressed = rx.try_recv().is_err();
2724
2725 let (emitter, mut rx) = new_emitter();
2728 let state = WsDispatchState::new();
2729 state.mark_pending_modify(cid, VenueOrderId::from("ord-other"));
2730 emit_tracked_order_event(
2731 &emitter,
2732 &state,
2733 cid,
2734 identity,
2735 &report,
2736 account_id,
2737 UnixNanos::from(0),
2738 );
2739 let mut saw_canceled = false;
2740
2741 while let Ok(event) = rx.try_recv() {
2742 if matches!(event, ExecutionEvent::Order(OrderEventAny::Canceled(_))) {
2743 saw_canceled = true;
2744 }
2745 }
2746
2747 assert!(
2748 suppressed,
2749 "in-flight cancel-of-old leg must be suppressed by the pending-modify marker",
2750 );
2751 assert!(
2752 saw_canceled,
2753 "a pending-modify marker for a different venue order id must not suppress",
2754 );
2755 }
2756}