Skip to main content

nautilus_derive/
execution.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Live execution client implementation for the Derive adapter.
17//!
18//! Mirrors the Hyperliquid adapter's structural pattern: an
19//! [`ExecutionClientCore`] holds identity and connection state, an
20//! [`ExecutionEventEmitter`] publishes order/account events back to the live
21//! engine, and the venue clients ([`DeriveHttpClient`], [`DeriveWebSocketClient`])
22//! handle the wire. All state-changing requests are EIP-712 typed-data signed
23//! against the per-action module contracts on the Derive Chain; the
24//! `private/order` body in particular is built by [`order_to_derive_payload`].
25
26use 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/// Live execution client for Derive.
111///
112/// Owns the HTTP and WebSocket clients used to talk to the venue plus an
113/// [`ExecutionEventEmitter`] that publishes order/account events back to the
114/// live engine. Order operations are signed against the per-environment
115/// EIP-712 signing context resolved at construction.
116#[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    /// Creates a new [`DeriveExecutionClient`].
138    ///
139    /// Resolves wallet/session-key/subaccount from the supplied config, falling
140    /// back to the documented environment variables when fields are unset, and
141    /// parses the EIP-712 signing constants (domain separator, action typehash,
142    /// trade-module address) from config overrides or the shipped per-environment
143    /// defaults.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error when:
148    /// - Required credentials are not provided via config or environment.
149    /// - Signing constants are still placeholders or cannot be parsed as hex.
150    /// - The HTTP or WebSocket client cannot be constructed.
151    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        // The handle shares the client's command channel, which survives the
192        // reconnect swap, so it stays valid for the client's lifetime.
193        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    /// Returns the resolved subaccount id.
227    #[must_use]
228    pub const fn subaccount_id(&self) -> u64 {
229        self.credential.subaccount_id()
230    }
231
232    /// Returns a reference to the resolved configuration.
233    #[must_use]
234    pub fn config(&self) -> &DeriveExecClientConfig {
235        &self.config
236    }
237
238    /// Returns a reference to the underlying HTTP client.
239    #[must_use]
240    pub fn http_client(&self) -> &DeriveHttpClient {
241        &self.http_client
242    }
243
244    /// Caches a Derive instrument by instrument ID so order submission can
245    /// resolve `base_asset_address` and `base_asset_sub_id` without
246    /// re-querying the venue.
247    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    /// Spawns a fire-and-forget task tracked in `pending_tasks` for teardown.
253    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        // Lazy bootstrap: exec-side fetches per-instrument on first reference.
281        // Marking the flag prevents duplicate work across reconnect cycles.
282        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    /// Blocks until the account appears in the cache, or `timeout_secs` elapses.
303    ///
304    /// The execution engine populates the cache from the [`refresh_account_state`]
305    /// event asynchronously; strategies that begin issuing orders before the
306    /// account is registered race the portfolio. Connecting blocks here so the
307    /// runner can rely on `core.cache().account(account_id)` immediately after
308    /// `connect()` returns.
309    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    /// Reverses the partial state `connect()` set up before the failing step:
338    /// cancels the shared cancellation token, aborts the WS dispatch task,
339    /// and closes the WS client. Used when initial account state cannot be
340    /// loaded so that the next `connect()` call starts from a clean slate.
341    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        // Fail-fast if the initial account snapshot cannot load: without it,
493        // `await_account_registered` would block the full timeout window and
494        // surface a misleading registration timeout. Tear down the WS we
495        // already started so the caller does not leak the dispatch task.
496        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        // The exec-side instrument cache holds `DeriveInstrument` records so
557        // signing can pull `base_asset_address` / `base_asset_sub_id`; the
558        // generic `InstrumentAny` shape published on the bus does not carry
559        // those, so the data client populates the cache via
560        // [`Self::cache_instrument`] from its bootstrap pass instead.
561    }
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            // Derive has no by-label lookup endpoint; scan open orders first,
603            // then trigger orders, then fall through to paginated history so
604            // terminal orders resolve for reconcilers that only carry the
605            // client_order_id.
606            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(&params).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        // Prefer the parsed label (the venue's source of truth); only stamp
681        // the cmd's id when the venue order has no label at all.
682        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        // open_only routes to private/get_open_orders and
698        // private/get_trigger_orders regardless of window; the venue
699        // endpoints have no time bound but the caller's start/end is applied
700        // below. For full history we walk private/get_order_history pages,
701        // scoped to the optional window.
702        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(&params).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            // open_only routed via private/get_open_orders ignores time bounds
754            // at the venue level; apply the command's window here so callers
755            // asking for "open orders since X" get exactly that.
756            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(&params).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                    // Cross-source dedup against the WS dispatch path: if the
831                    // live stream already emitted this trade, the reconciler
832                    // should not see it again.
833                    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        // Spot has no position to reduce; the venue rejects reduce-only
979        // unconditionally (11025), so deny locally. Perp/option reduce-only is
980        // position-conditional and must still reach the venue.
981        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        // Keep the existing OrderDenied path here, then refresh before signing
997        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        // Capture identity so the WS dispatch can route subsequent updates
1029        // for this order to proper events rather than execution reports.
1030        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            // Lazy-resolution net: the synchronous deny is skipped when the
1069            // cache was empty at submit time. OrderSubmitted already fired, so
1070            // reject here rather than deny.
1071            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            // Avoid signing against a quote captured before instrument resolution
1086            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                    &quote,
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            // Pre-flight debug log so a venue 11012-style rejection can be
1321            // diagnosed without re-running with full payload tracing.
1322            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            // Discard the result (and any `trades` it carries): fills arrive on
1334            // the `.trades` channel and are deduped by trade id.
1335            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                // See docs/integrations/derive.md "Order rejection semantics".
1343                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                // See docs/integrations/derive.md "Order rejection semantics".
1428                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            // The venue endpoint scopes by instrument only, so when the
1461            // caller asks for a single side we list open orders (an idempotent
1462            // private read kept on HTTP), filter by side, and cancel each one
1463            // over the WebSocket. Calling `cancel_all` directly would drop both
1464            // sides and violate the command's filter.
1465            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            // Mark before sending so the cancel-of-old leg is suppressed even if
1727            // it arrives before this response.
1728            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                    // Rebind before clearing the marker so a later cancel-of-old
1737                    // stays suppressed by the bound-id check.
1738                    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                // See docs/integrations/derive.md "Order rejection semantics".
1752                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
1851// Reason text and post-only classification for a definitive WS write failure.
1852// Non-JSON-RPC errors carry no venue code and are never post-only crossings.
1853fn 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
1930/// Dispatches a parsed `{subaccount_id}.orders` payload to the execution event
1931/// emitter.
1932///
1933/// Emits tracked order events when an order's client order id resolves to a
1934/// registered identity in `dispatch_state`, and forwards a raw status report
1935/// otherwise.
1936pub 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
1970/// Dispatches a parsed `{subaccount_id}.trades` payload to the execution event
1971/// emitter.
1972///
1973/// Deduplicates by trade id, then emits a tracked fill when the trade's client
1974/// order id resolves to a registered identity in `dispatch_state`, and forwards
1975/// a raw fill report otherwise.
1976pub 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/// Synthesizes and emits `OrderAccepted` when one has not yet been emitted
2029/// for the order. Used to guarantee the `Submitted -> Accepted -> ...`
2030/// lifecycle when a fill or terminal event arrives before (or instead of)
2031/// the venue's `Open` notice.
2032#[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    // A `private/replace` cancels the old order and opens a new one under the
2075    // same label; suppress events for the superseded old venue order id so they
2076    // don't terminate the order that `modify_order` rebinds via `OrderUpdated`.
2077    // `pending_modify` covers the in-flight window; the bound-id check covers
2078    // after the rebind.
2079    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            // Mark the order terminal so replayed Accepted frames are
2127            // suppressed, but keep its identity alive: the matching
2128            // `.trades` frame may arrive after this `.orders` Filled
2129            // notice and still needs the tracked path to emit a proper
2130            // `OrderFilled`. Identity is retired by Canceled/Expired/
2131            // Rejected paths; full-fill leaks are bounded by submission
2132            // throughput.
2133            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
2262/// Derives the worst-acceptable limit price for a market order from the
2263/// top-of-book quote and a slippage bound in basis points, rounded to the
2264/// instrument's `tick_size`.
2265///
2266/// Buys lift the ask by `slippage_bps` then round up to the next tick; sells
2267/// drop the bid by the same and round down. The result is the signed
2268/// `limit_price` slot in the EIP-712 trade module data; the venue uses it
2269/// as a worst-case bound while the order sweeps. A non-positive sell bound
2270/// is rejected (`None`) so the caller can deny the order rather than sign
2271/// an invalid zero limit.
2272fn 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        // NoOrderSide is rejected upstream by `order_side_to_derive`.
2285        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
2380/// Rounds `value` to the nearest multiple of `tick_size`. Buys round up so
2381/// the signed bound remains acceptable to the venue; sells round down so the
2382/// caller does not accidentally tighten the floor. A non-positive `tick_size`
2383/// is treated as a no-op.
2384fn 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        // 50 bps; raw = 3501 * 1.005 = 3518.505; tick 0.01 rounds up to 3518.51.
2481        let price = market_order_limit_price(&quote, 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        // 50 bps; raw = 3500 * 0.995 = 3482.5; tick 0.01 stays at 3482.5.
2497        let price = market_order_limit_price(&quote, OrderSide::Sell, 50, dec!(0.01)).unwrap();
2498        assert_eq!(price, dec!(3482.5));
2499
2500        // 20_000 bps = 200% slippage drives the rounded bound below zero; deny.
2501        let zero = market_order_limit_price(&quote, 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        // Non-positive tick must pass through both sides untouched so the
2579        // signing path does not divide by zero or amplify garbage tick data.
2580        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        // The shipped mainnet defaults are real Protocol Constants, so force
2593        // an explicit placeholder via the config override to verify the
2594        // placeholder-detection path still refuses to construct.
2595        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        // Coarse tick = 1.0 (e.g. weekly option strikes); raw 3518.505 rounds
2629        // up to 3519, raw 3482.5 rounds down to 3482.
2630        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(&quote, OrderSide::Buy, 50, dec!(1)).unwrap();
2640        assert_eq!(buy, dec!(3519));
2641        let sell = market_order_limit_price(&quote, 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        // Derive's `private/replace` cancels the old order; the `.orders`
2661        // cancel-of-old leg can arrive before `modify_order` rebinds the order,
2662        // i.e. while the replace is in flight. In that window only the
2663        // `pending_modify` marker (not the bound-id check) can suppress it. The
2664        // integration suite covers the post-rebind bound-id branch; this covers
2665        // the in-flight branch, which is otherwise unexercised end to end.
2666        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        // A `cancelled` report for the stale leg, identical across both cases:
2678        // only the dispatch-state marker differs.
2679        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        // Marker targets the cancel's venue order id and no bound id is
2710        // recorded, so suppression can only come from the in-flight branch.
2711        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        // A marker for a different venue order id must not suppress: the guard
2726        // keys on the specific id, so the cancel-of-old still terminates.
2727        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}