Skip to main content

nautilus_dydx/execution/
mod.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 dYdX adapter.
17//!
18//! This module provides the execution client for submitting orders, cancellations,
19//! and managing positions on dYdX v4.
20//!
21//! # Order Types
22//!
23//! dYdX supports the following order types:
24//!
25//! - **Market**: Execute immediately at best available price.
26//! - **Limit**: Execute at specified price or better.
27//! - **Stop Market**: Triggered when price crosses stop price, then executes as market order.
28//! - **Stop Limit**: Triggered when price crosses stop price, then places limit order.
29//! - **Take Profit Market**: Close position at profit target, executes as market order.
30//! - **Take Profit Limit**: Close position at profit target, places limit order.
31//!
32//! See <https://docs.dydx.xyz/concepts/trading/orders#types> for details.
33//!
34//! # Order Lifetimes
35//!
36//! Orders can be short-term (expire by block height) or long-term/stateful (expire by timestamp).
37//! Conditional orders (Stop/TakeProfit) are always stateful.
38//!
39//! See <https://docs.dydx.xyz/concepts/trading/orders#short-term-vs-long-term> for details.
40
41use std::{
42    sync::{
43        Arc,
44        atomic::{AtomicU64, Ordering},
45    },
46    time::{Duration, Instant},
47};
48
49use ahash::AHashMap;
50use anyhow::Context;
51use async_trait::async_trait;
52use dashmap::DashMap;
53use futures_util::{Stream, StreamExt, pin_mut};
54use nautilus_common::{
55    clients::ExecutionClient,
56    live::runner::get_exec_event_sender,
57    messages::execution::{
58        BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
59        GenerateOrderStatusReport, GenerateOrderStatusReports, GeneratePositionStatusReports,
60        ModifyOrder, QueryAccount, QueryOrder, SubmitOrder, SubmitOrderList,
61    },
62};
63use nautilus_core::{
64    DurationNanos, Params, UUID4, UnixNanos,
65    time::{AtomicTime, get_atomic_clock_realtime},
66};
67use nautilus_live::{
68    ExecutionClientCore, ExecutionEventEmitter, SocketControlFactory,
69    execution::reports::retain_order_status_reports,
70    task::{TaskGroup, TaskGroupGuard},
71};
72use nautilus_model::{
73    accounts::AccountAny,
74    enums::{AccountType, OmsType, OrderStatus, OrderType, TimeInForce},
75    events::{AccountState, OrderAccepted, OrderCanceled, OrderEventAny, OrderExpired},
76    identifiers::{
77        AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, Symbol, Venue, VenueOrderId,
78    },
79    instruments::{Instrument, InstrumentAny},
80    orders::{Order, OrderAny},
81    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
82    types::{AccountBalance, Currency, MarginBalance, Money},
83};
84use nautilus_network::retry::RetryConfig;
85use parking_lot::Mutex;
86use rust_decimal::Decimal;
87
88use crate::{
89    common::{
90        consts::DYDX_VENUE,
91        credential::{DydxCredential, credential_env_vars},
92        instrument_cache::InstrumentCache,
93        parse::nanos_to_secs_i64,
94    },
95    config::DydxAdapterConfig,
96    error::DydxError,
97    execution::{
98        broadcaster::TxBroadcaster,
99        encoder::ClientOrderIdEncoder,
100        order_builder::OrderMessageBuilder,
101        tx_manager::TransactionManager,
102        types::{LimitOrderParams, OrderContext},
103    },
104    grpc::{DydxGrpcClient, SHORT_TERM_ORDER_MAXIMUM_LIFETIME, types::ChainId},
105    http::{
106        client::DydxHttpClient,
107        parse::{
108            parse_account_state, parse_fill_report, parse_order_status_report,
109            parse_position_status_report,
110        },
111    },
112    websocket::{
113        DydxWsDispatchState, OrderIdentity,
114        client::DydxWebSocketClient,
115        enums::DydxWsOutputMessage,
116        fill_report_to_order_filled,
117        parse::{parse_ws_fill_report, parse_ws_order_report, parse_ws_position_report},
118    },
119};
120
121pub mod block_time;
122pub mod broadcaster;
123pub mod encoder;
124pub mod order_builder;
125pub mod submitter;
126pub mod tx_manager;
127pub mod types;
128pub mod wallet;
129
130use block_time::BlockTimeMonitor;
131
132const DYDX_INDEXER_REPORT_LIMIT: u32 = 1_000;
133
134/// Live execution client for the dYdX v4 exchange adapter.
135///
136/// Supports Market, Limit, Stop Market, Stop Limit, Take Profit Market (MarketIfTouched),
137/// and Take Profit Limit (LimitIfTouched) orders via gRPC. Trailing stops are NOT supported
138/// by the dYdX v4 protocol. dYdX requires u32 client IDs - strings are hashed to fit.
139///
140/// # Architecture
141///
142/// The client follows a two-layer execution model:
143/// 1. **Synchronous validation** - Immediate checks and event generation.
144/// 2. **Async submission** - Non-blocking gRPC calls via `TransactionManager`, `TxBroadcaster`, and `OrderMessageBuilder`.
145#[derive(Debug)]
146pub struct DydxExecutionClient {
147    core: ExecutionClientCore,
148    clock: &'static AtomicTime,
149    config: DydxAdapterConfig,
150    emitter: ExecutionEventEmitter,
151    http_client: DydxHttpClient,
152    ws_client: DydxWebSocketClient,
153    grpc_client: Arc<tokio::sync::RwLock<Option<DydxGrpcClient>>>,
154    instrument_cache: Arc<InstrumentCache>,
155    block_time_monitor: Arc<BlockTimeMonitor>,
156    oracle_prices: Arc<DashMap<InstrumentId, Decimal>>,
157    encoder: Arc<ClientOrderIdEncoder>,
158    dispatch_state: Arc<DydxWsDispatchState>,
159    order_contexts: Arc<DashMap<u32, OrderContext>>,
160    order_id_map: Arc<DashMap<String, (u32, u32)>>,
161    wallet_address: String,
162    subaccount_number: u32,
163    tx_manager: Option<Arc<TransactionManager>>,
164    broadcaster: Option<Arc<TxBroadcaster>>,
165    order_builder: Option<Arc<OrderMessageBuilder>>,
166    session_tasks: TaskGroup,
167    pending_tasks: TaskGroup,
168    shutdown_errors: Vec<String>,
169    pending_task_labels: Arc<Mutex<AHashMap<u64, &'static str>>>,
170    next_pending_task_id: AtomicU64,
171}
172
173impl DydxExecutionClient {
174    /// Creates a new [`DydxExecutionClient`].
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if credentials are not found or client fails to construct.
179    pub fn new(
180        core: ExecutionClientCore,
181        config: DydxAdapterConfig,
182        wallet_address: String,
183        subaccount_number: u32,
184    ) -> anyhow::Result<Self> {
185        let trader_id = core.trader_id;
186        let account_id = core.account_id;
187        let clock = get_atomic_clock_realtime();
188        let emitter =
189            ExecutionEventEmitter::new(clock, trader_id, account_id, AccountType::Margin, None);
190
191        let retry_config = RetryConfig {
192            max_retries: config.max_retries,
193            initial_delay_ms: config.retry_delay_initial_ms,
194            max_delay_ms: config.retry_delay_max_ms,
195            ..Default::default()
196        };
197        let http_client = DydxHttpClient::new(
198            Some(config.base_url.clone()),
199            config.timeout_secs,
200            config
201                .proxy_url
202                .as_ref()
203                .map(|value| value.expose_secret().to_owned()),
204            config.network,
205            Some(retry_config),
206        )?;
207
208        let instrument_cache = http_client.instrument_cache().clone();
209
210        let credential = DydxCredential::resolve(
211            config
212                .private_key
213                .as_ref()
214                .map(|value| value.expose_secret()),
215            config.network,
216            config.authenticator_ids.clone(),
217        )?
218        .ok_or_else(|| anyhow::anyhow!("Credentials required for execution client"))?;
219
220        let ws_client = DydxWebSocketClient::new_private_with_cache(
221            config.ws_url.clone(),
222            credential,
223            core.account_id,
224            instrument_cache.clone(),
225            Some(20),
226            config.transport_backend,
227            config
228                .proxy_url
229                .as_ref()
230                .map(|value| value.expose_secret().to_owned()),
231        )
232        .with_socket_factory(SocketControlFactory::new(core.client_id, Some(*DYDX_VENUE)));
233
234        let grpc_client = Arc::new(tokio::sync::RwLock::new(None));
235
236        let session_tasks = TaskGroup::new();
237        let pending_tasks = TaskGroup::new();
238
239        Ok(Self {
240            core,
241            clock,
242            config,
243            emitter,
244            http_client,
245            ws_client,
246            grpc_client,
247            instrument_cache,
248            block_time_monitor: Arc::new(BlockTimeMonitor::new()),
249            oracle_prices: Arc::new(DashMap::new()),
250            encoder: Arc::new(ClientOrderIdEncoder::new()),
251            dispatch_state: Arc::new(DydxWsDispatchState::default()),
252            order_contexts: Arc::new(DashMap::new()),
253            order_id_map: Arc::new(DashMap::new()),
254            wallet_address,
255            subaccount_number,
256            tx_manager: None,
257            broadcaster: None,
258            order_builder: None,
259            session_tasks,
260            pending_tasks,
261            shutdown_errors: Vec::new(),
262            pending_task_labels: Arc::new(Mutex::new(AHashMap::new())),
263            next_pending_task_id: AtomicU64::new(0),
264        })
265    }
266
267    fn resolve_private_key(config: &DydxAdapterConfig) -> anyhow::Result<String> {
268        let (private_key_env, _) = credential_env_vars(config.network);
269
270        if let Some(ref pk) = config.private_key
271            && !pk.expose_secret().trim().is_empty()
272        {
273            return Ok(pk.expose_secret().to_owned());
274        }
275
276        if let Some(pk) = std::env::var(private_key_env)
277            .ok()
278            .filter(|s| !s.trim().is_empty())
279        {
280            return Ok(pk);
281        }
282
283        anyhow::bail!("{private_key_env} not found in config or environment")
284    }
285
286    fn register_order_context(&self, client_id_u32: u32, context: OrderContext) {
287        self.order_contexts.insert(client_id_u32, context);
288    }
289
290    fn get_order_context(&self, client_id_u32: u32) -> Option<OrderContext> {
291        self.order_contexts
292            .get(&client_id_u32)
293            .map(|r| r.value().clone())
294    }
295
296    fn get_chain_id(&self) -> ChainId {
297        self.config.get_chain_id()
298    }
299
300    fn spawn_ws_stream_handler(
301        &self,
302        stream: impl Stream<Item = DydxWsOutputMessage> + Send + 'static,
303    ) -> anyhow::Result<()> {
304        if !self.session_tasks.is_empty() {
305            anyhow::bail!("dYdX WebSocket stream task is already registered");
306        }
307
308        log::debug!("Starting execution WebSocket message processing task");
309
310        let trader_id = self.core.trader_id;
311        let account_id = self.core.account_id;
312        let instrument_cache = self.instrument_cache.clone();
313        let oracle_prices = self.oracle_prices.clone();
314        let encoder = self.encoder.clone();
315        let order_contexts = self.order_contexts.clone();
316        let order_id_map = self.order_id_map.clone();
317        let dispatch_state = self.dispatch_state.clone();
318        let block_time_monitor = self.block_time_monitor.clone();
319        let emitter = self.emitter.clone();
320        let clock = self.clock;
321
322        let future = async move {
323            log::debug!("Execution WebSocket message loop started");
324
325            let mut cum_fill_totals: AHashMap<VenueOrderId, (Decimal, Decimal)> = AHashMap::new();
326
327            pin_mut!(stream);
328            while let Some(msg) = stream.next().await {
329                match msg {
330                    DydxWsOutputMessage::SubaccountSubscribed(msg) => {
331                        log::debug!("Parsing subaccount subscription with full context");
332
333                        let inst_map = instrument_cache.to_instrument_id_map();
334
335                        let oracle_map: std::collections::HashMap<_, _> = oracle_prices
336                            .iter()
337                            .map(|entry| (*entry.key(), *entry.value()))
338                            .collect();
339
340                        let ts_init = clock.get_time_ns();
341                        let ts_event = ts_init;
342
343                        if let Some(ref subaccount) = msg.contents.subaccount {
344                            match parse_account_state(
345                                subaccount,
346                                account_id,
347                                &inst_map,
348                                &oracle_map,
349                                ts_event,
350                                ts_init,
351                            ) {
352                                Ok(account_state) => {
353                                    log::debug!(
354                                        "Parsed account state: {} balance(s), {} margin(s)",
355                                        account_state.balances.len(),
356                                        account_state.margins.len()
357                                    );
358                                    emitter.send_account_state(account_state);
359                                }
360                                Err(e) => {
361                                    log::error!("Failed to parse account state: {e}");
362                                }
363                            }
364
365                            if let Some(ref positions) = subaccount.open_perpetual_positions {
366                                log::debug!(
367                                    "Parsing {} position(s) from subscription",
368                                    positions.len()
369                                );
370
371                                for (market, ws_position) in positions {
372                                    match parse_ws_position_report(
373                                        ws_position,
374                                        &instrument_cache,
375                                        account_id,
376                                        ts_init,
377                                    ) {
378                                        Ok(report) => {
379                                            log::debug!(
380                                                "Parsed position report: {} {} {} {}",
381                                                report.instrument_id,
382                                                report.position_side,
383                                                report.quantity,
384                                                market
385                                            );
386                                            emitter.send_position_report(report);
387                                        }
388                                        Err(e) => {
389                                            log::error!(
390                                                "Failed to parse WebSocket position for {market}: {e}"
391                                            );
392                                        }
393                                    }
394                                }
395                            }
396                        } else {
397                            log::warn!(
398                                "Subaccount subscription without initial state (new/empty subaccount)"
399                            );
400
401                            let currency =
402                                Currency::get_or_create_crypto_with_context("USDC", None);
403                            let zero = Money::zero(currency);
404                            let balance = AccountBalance::new_checked(zero, zero, zero)
405                                .expect("zero balance should always be valid");
406                            let account_state = AccountState::new(
407                                account_id,
408                                AccountType::Margin,
409                                vec![balance],
410                                vec![],
411                                true,
412                                UUID4::new(),
413                                ts_init,
414                                ts_init,
415                                None,
416                            );
417                            emitter.send_account_state(account_state);
418                        }
419                    }
420                    DydxWsOutputMessage::SubaccountsChannelData(data) => {
421                        log::debug!(
422                            "Processing subaccounts channel data (orders={:?}, fills={:?})",
423                            data.contents.orders.as_ref().map(|o| o.len()),
424                            data.contents.fills.as_ref().map(|f| f.len())
425                        );
426                        let ts_init = clock.get_time_ns();
427
428                        let mut terminal_orders: Vec<(u32, u32, String)> = Vec::new();
429                        let mut pending_order_reports = Vec::new();
430
431                        if let Some(ref orders) = data.contents.orders {
432                            for ws_order in orders {
433                                log::debug!(
434                                    "Parsing WS order: clob_pair_id={}, status={:?}, client_id={}",
435                                    ws_order.clob_pair_id,
436                                    ws_order.status,
437                                    ws_order.client_id
438                                );
439
440                                if let Ok(client_id_u32) = ws_order.client_id.parse::<u32>() {
441                                    let client_meta = ws_order
442                                        .client_metadata
443                                        .as_ref()
444                                        .and_then(|s| s.parse::<u32>().ok())
445                                        .unwrap_or(crate::grpc::DEFAULT_RUST_CLIENT_METADATA);
446                                    order_id_map
447                                        .insert(ws_order.id.clone(), (client_id_u32, client_meta));
448                                }
449
450                                match parse_ws_order_report(
451                                    ws_order,
452                                    &instrument_cache,
453                                    &order_contexts,
454                                    &encoder,
455                                    account_id,
456                                    ts_init,
457                                ) {
458                                    Ok(report) => {
459                                        if report.order_status.is_closed()
460                                            && let Ok(cid) = ws_order.client_id.parse::<u32>()
461                                        {
462                                            let meta = ws_order
463                                                .client_metadata
464                                                .as_ref()
465                                                .and_then(|s| s.parse::<u32>().ok())
466                                                .unwrap_or(
467                                                    crate::grpc::DEFAULT_RUST_CLIENT_METADATA,
468                                                );
469                                            terminal_orders.push((cid, meta, ws_order.id.clone()));
470                                        }
471                                        let order_side = report
472                                            .order_side
473                                            .as_ref()
474                                            .map_or("NO_ORDER_SIDE", AsRef::as_ref);
475                                        log::debug!(
476                                            "Parsed order report: {} {} {:?} qty={} client_order_id={:?}",
477                                            report.instrument_id,
478                                            order_side,
479                                            report.order_status,
480                                            report.quantity,
481                                            report.client_order_id
482                                        );
483                                        pending_order_reports.push(report);
484                                    }
485                                    Err(e) => {
486                                        log::error!("Failed to parse WebSocket order: {e}");
487                                    }
488                                }
489                            }
490                        }
491
492                        // Emit fills before order status for correct reconciliation
493                        if let Some(ref fills) = data.contents.fills {
494                            for ws_fill in fills {
495                                match parse_ws_fill_report(
496                                    ws_fill,
497                                    &instrument_cache,
498                                    &order_id_map,
499                                    &order_contexts,
500                                    &encoder,
501                                    account_id,
502                                    ts_init,
503                                ) {
504                                    Ok(report) => {
505                                        log::debug!(
506                                            "Parsed fill report: {} {} {} @ {} client_order_id={:?}",
507                                            report.instrument_id,
508                                            report.venue_order_id,
509                                            report.last_qty,
510                                            report.last_px,
511                                            report.client_order_id
512                                        );
513
514                                        let identity = report.client_order_id.and_then(|cid| {
515                                            dispatch_state
516                                                .order_identities
517                                                .get(&cid)
518                                                .map(|r| (cid, r.clone()))
519                                        });
520
521                                        if let Some((cid, ident)) = identity {
522                                            if !dispatch_state.emitted_accepted.contains(&cid) {
523                                                dispatch_state.insert_accepted(cid);
524                                                let accepted = OrderAccepted::new(
525                                                    trader_id,
526                                                    ident.strategy_id,
527                                                    ident.instrument_id,
528                                                    cid,
529                                                    report.venue_order_id,
530                                                    account_id,
531                                                    UUID4::new(),
532                                                    ts_init,
533                                                    ts_init,
534                                                    false,
535                                                );
536                                                emitter.send_order_event(OrderEventAny::Accepted(
537                                                    accepted,
538                                                ));
539                                            }
540
541                                            dispatch_state.insert_filled(cid);
542                                            let instrument =
543                                                instrument_cache.get(&report.instrument_id);
544                                            let quote_currency = instrument
545                                                .map_or_else(Currency::USD, |i: InstrumentAny| {
546                                                    i.quote_currency()
547                                                });
548                                            let filled = fill_report_to_order_filled(
549                                                &report,
550                                                trader_id,
551                                                &ident,
552                                                quote_currency,
553                                            );
554                                            emitter.send_order_event(OrderEventAny::Filled(filled));
555                                        } else {
556                                            let entry = cum_fill_totals
557                                                .entry(report.venue_order_id)
558                                                .or_default();
559                                            let qty = report.last_qty.as_decimal();
560                                            entry.0 += report.last_px.as_decimal() * qty;
561                                            entry.1 += qty;
562                                            emitter.send_fill_report(report);
563                                        }
564                                    }
565                                    Err(e) => {
566                                        log::error!("Failed to parse WebSocket fill: {e}");
567                                    }
568                                }
569                            }
570                        }
571
572                        for report in &mut pending_order_reports {
573                            if let Some((notional, total_qty)) =
574                                cum_fill_totals.get(&report.venue_order_id)
575                                && !total_qty.is_zero()
576                            {
577                                report.avg_px = Some(notional / total_qty);
578                            }
579                        }
580
581                        for report in pending_order_reports {
582                            let identity = report.client_order_id.and_then(|cid| {
583                                dispatch_state
584                                    .order_identities
585                                    .get(&cid)
586                                    .map(|r| (cid, r.clone()))
587                            });
588
589                            if let Some((cid, ident)) = identity {
590                                match report.order_status {
591                                    OrderStatus::Accepted => {
592                                        if dispatch_state.emitted_accepted.contains(&cid)
593                                            || dispatch_state.filled_orders.contains(&cid)
594                                        {
595                                            log::debug!("Skipping duplicate Accepted for {cid}");
596                                            continue;
597                                        }
598                                        dispatch_state.insert_accepted(cid);
599                                        let accepted = OrderAccepted::new(
600                                            trader_id,
601                                            ident.strategy_id,
602                                            ident.instrument_id,
603                                            cid,
604                                            report.venue_order_id,
605                                            account_id,
606                                            UUID4::new(),
607                                            report.ts_last,
608                                            ts_init,
609                                            false,
610                                        );
611                                        emitter.send_order_event(OrderEventAny::Accepted(accepted));
612                                    }
613                                    OrderStatus::Canceled => {
614                                        if !dispatch_state.emitted_accepted.contains(&cid) {
615                                            dispatch_state.insert_accepted(cid);
616                                            let accepted = OrderAccepted::new(
617                                                trader_id,
618                                                ident.strategy_id,
619                                                ident.instrument_id,
620                                                cid,
621                                                report.venue_order_id,
622                                                account_id,
623                                                UUID4::new(),
624                                                ts_init,
625                                                ts_init,
626                                                false,
627                                            );
628                                            emitter.send_order_event(OrderEventAny::Accepted(
629                                                accepted,
630                                            ));
631                                        }
632                                        // Map venue cancel-on-expiry to OrderExpired
633                                        // (dYdX reports GTD expiry as a cancel event).
634                                        let is_expiry = report
635                                            .expire_time
636                                            .is_some_and(|exp| report.ts_last >= exp);
637
638                                        if is_expiry {
639                                            let expired = OrderExpired::new(
640                                                trader_id,
641                                                ident.strategy_id,
642                                                ident.instrument_id,
643                                                cid,
644                                                UUID4::new(),
645                                                report.ts_last,
646                                                ts_init,
647                                                false,
648                                                Some(report.venue_order_id),
649                                                Some(account_id),
650                                            );
651                                            emitter
652                                                .send_order_event(OrderEventAny::Expired(expired));
653                                        } else {
654                                            let canceled = OrderCanceled::new(
655                                                trader_id,
656                                                ident.strategy_id,
657                                                ident.instrument_id,
658                                                cid,
659                                                UUID4::new(),
660                                                report.ts_last,
661                                                ts_init,
662                                                false,
663                                                Some(report.venue_order_id),
664                                                Some(account_id),
665                                                None,
666                                            );
667                                            emitter.send_order_event(OrderEventAny::Canceled(
668                                                canceled,
669                                            ));
670                                        }
671                                        dispatch_state.cleanup_terminal(&cid);
672                                    }
673                                    OrderStatus::Filled => {
674                                        // Fills are emitted before the order status
675                                        dispatch_state.cleanup_terminal(&cid);
676                                    }
677                                    OrderStatus::Expired => {
678                                        // Reached when the parser reclassifies a venue
679                                        // cancel-on-expiry to `Expired` (HTTP path) or
680                                        // when the venue reports `Expired` directly. Mirror
681                                        // the Canceled arm's Accepted-synthesis so the
682                                        // strategy sees a complete lifecycle.
683                                        if !dispatch_state.emitted_accepted.contains(&cid) {
684                                            dispatch_state.insert_accepted(cid);
685                                            let accepted = OrderAccepted::new(
686                                                trader_id,
687                                                ident.strategy_id,
688                                                ident.instrument_id,
689                                                cid,
690                                                report.venue_order_id,
691                                                account_id,
692                                                UUID4::new(),
693                                                ts_init,
694                                                ts_init,
695                                                false,
696                                            );
697                                            emitter.send_order_event(OrderEventAny::Accepted(
698                                                accepted,
699                                            ));
700                                        }
701                                        let expired = OrderExpired::new(
702                                            trader_id,
703                                            ident.strategy_id,
704                                            ident.instrument_id,
705                                            cid,
706                                            UUID4::new(),
707                                            report.ts_last,
708                                            ts_init,
709                                            false,
710                                            Some(report.venue_order_id),
711                                            Some(account_id),
712                                        );
713                                        emitter.send_order_event(OrderEventAny::Expired(expired));
714                                        dispatch_state.cleanup_terminal(&cid);
715                                    }
716                                    _ => {
717                                        emitter.send_order_status_report(report);
718                                    }
719                                }
720                            } else {
721                                emitter.send_order_status_report(report);
722                            }
723                        }
724
725                        for (client_id, client_metadata, order_id) in terminal_orders {
726                            order_contexts.remove(&client_id);
727                            encoder.remove(client_id, client_metadata);
728                            order_id_map.remove(&order_id);
729                            cum_fill_totals.remove(&VenueOrderId::new(&order_id));
730                        }
731                    }
732                    DydxWsOutputMessage::Markets(contents) => {
733                        if let Some(ref oracle_map) = contents.oracle_prices {
734                            for (symbol_str, oracle_market) in oracle_map {
735                                let instrument_id = {
736                                    let symbol = format!("{symbol_str}-PERP");
737                                    InstrumentId::new(
738                                        Symbol::new(&symbol),
739                                        *crate::common::consts::DYDX_VENUE,
740                                    )
741                                };
742
743                                if instrument_cache.get(&instrument_id).is_some()
744                                    && let Ok(price_dec) =
745                                        oracle_market.oracle_price.parse::<Decimal>()
746                                {
747                                    oracle_prices.insert(instrument_id, price_dec);
748                                    log::trace!(
749                                        "Updated oracle price for {instrument_id}: {price_dec}"
750                                    );
751                                }
752                            }
753                        }
754
755                        if let Some(ref markets) = contents.markets {
756                            for (symbol_str, market_data) in markets {
757                                if let Some(oracle_price_str) = &market_data.oracle_price {
758                                    let instrument_id = {
759                                        let symbol = format!("{symbol_str}-PERP");
760                                        InstrumentId::new(
761                                            Symbol::new(&symbol),
762                                            *crate::common::consts::DYDX_VENUE,
763                                        )
764                                    };
765
766                                    if instrument_cache.get(&instrument_id).is_some()
767                                        && let Ok(price_dec) = oracle_price_str.parse::<Decimal>()
768                                    {
769                                        oracle_prices.insert(instrument_id, price_dec);
770                                    }
771                                }
772                            }
773                        }
774                    }
775                    DydxWsOutputMessage::BlockHeight { height, time } => {
776                        log::debug!("Block height update: {height} at {time}");
777                        block_time_monitor.record_block(height, time);
778                    }
779                    DydxWsOutputMessage::Error(err) => {
780                        log::warn!("WebSocket error: {err:?}");
781                    }
782                    DydxWsOutputMessage::Reconnected { .. } => {
783                        log::info!("WebSocket reconnected");
784                    }
785                    _ => {}
786                }
787            }
788            log::debug!("WebSocket message processing task ended");
789        };
790
791        self.session_tasks
792            .spawn(future)
793            .context("failed to register dYdX execution WebSocket stream task")?;
794        log::debug!("WebSocket stream handler started");
795        Ok(())
796    }
797
798    fn mark_instruments_initialized(&self) {
799        let count = self.instrument_cache.len();
800        self.core.set_instruments_initialized();
801        log::debug!("Instruments initialized: {count} instruments in shared cache");
802    }
803
804    fn get_instrument_by_market(&self, market: &str) -> Option<InstrumentAny> {
805        self.instrument_cache.get_by_market(market)
806    }
807
808    fn get_instrument_by_clob_pair_id(&self, clob_pair_id: u32) -> Option<InstrumentAny> {
809        let instrument = self.instrument_cache.get_by_clob_id(clob_pair_id);
810
811        if instrument.is_none() {
812            self.instrument_cache.log_missing_clob_pair_id(clob_pair_id);
813        }
814
815        instrument
816    }
817
818    fn get_execution_components(
819        &self,
820    ) -> anyhow::Result<(
821        Arc<TransactionManager>,
822        Arc<TxBroadcaster>,
823        Arc<OrderMessageBuilder>,
824    )> {
825        let tx_manager = self
826            .tx_manager
827            .as_ref()
828            .ok_or_else(|| {
829                anyhow::anyhow!("TransactionManager not initialized - call connect() first")
830            })?
831            .clone();
832        let broadcaster = self
833            .broadcaster
834            .as_ref()
835            .ok_or_else(|| anyhow::anyhow!("TxBroadcaster not initialized - call connect() first"))?
836            .clone();
837        let order_builder = self
838            .order_builder
839            .as_ref()
840            .ok_or_else(|| {
841                anyhow::anyhow!("OrderMessageBuilder not initialized - call connect() first")
842            })?
843            .clone();
844        Ok((tx_manager, broadcaster, order_builder))
845    }
846
847    fn spawn_task<F>(&self, label: &'static str, fut: F)
848    where
849        F: Future<Output = anyhow::Result<()>> + Send + 'static,
850    {
851        let future = async move {
852            if let Err(e) = fut.await {
853                log::error!("{label}: {e:?}");
854            }
855        };
856
857        self.spawn_labeled(label, future);
858    }
859
860    // Generates an `OrderRejected` only for a definitive CheckTx rejection; other
861    // failures remain in flight for reconciliation
862    fn spawn_order_task<F>(
863        &self,
864        label: &'static str,
865        strategy_id: StrategyId,
866        instrument_id: InstrumentId,
867        client_order_id: ClientOrderId,
868        fut: F,
869    ) where
870        F: Future<Output = anyhow::Result<()>> + Send + 'static,
871    {
872        let emitter = self.emitter.clone();
873        let clock = self.clock;
874
875        let future = async move {
876            if let Err(e) = fut.await {
877                if is_definitive_broadcast_rejection(&e) {
878                    let error_msg = format!("{label} failed: {e:?}");
879                    log::warn!("{error_msg}");
880
881                    let ts_event = clock.get_time_ns();
882                    emitter.emit_order_rejected_event(
883                        strategy_id,
884                        instrument_id,
885                        client_order_id,
886                        &error_msg,
887                        ts_event,
888                        false,
889                    );
890                } else {
891                    log::warn!(
892                        "Ambiguous dYdX {label} failure for {client_order_id}, awaiting reconciliation: {e:?}"
893                    );
894                }
895            }
896        };
897
898        self.spawn_labeled(label, future);
899    }
900
901    fn spawn_labeled<F>(&self, label: &'static str, future: F)
902    where
903        F: Future<Output = ()> + Send + 'static,
904    {
905        let id = self.next_pending_task_id.fetch_add(1, Ordering::Relaxed);
906        self.pending_task_labels.lock().insert(id, label);
907
908        let label_guard = PendingTaskLabel {
909            id,
910            labels: Arc::clone(&self.pending_task_labels),
911        };
912        let wrapped = async move {
913            let _label = label_guard;
914            future.await;
915        };
916
917        if let Err(e) = self.pending_tasks.spawn(wrapped) {
918            self.pending_task_labels.lock().remove(&id);
919            log::warn!("Skipping dYdX {label} after shutdown began: {e}");
920        }
921    }
922
923    fn begin_pending_shutdown(&self) {
924        let labels = self.pending_task_labels.lock();
925        let pending_cancels = labels
926            .values()
927            .filter(|label| label.contains("cancel"))
928            .count();
929        let pending_other = labels.len() - pending_cancels;
930
931        if pending_cancels > 0 {
932            log::warn!(
933                "Waiting for {pending_cancels} in-flight cancel task(s) before disconnect; orders may still be open on the venue"
934            );
935        }
936
937        if pending_other > 0 {
938            log::debug!("Waiting for {pending_other} other in-flight task(s) before disconnect");
939        }
940        drop(labels);
941
942        self.pending_tasks.begin_shutdown();
943    }
944
945    async fn finish_tasks(&self) -> anyhow::Result<()> {
946        let (session_result, pending_result) = tokio::join!(
947            self.session_tasks
948                .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
949            self.pending_tasks
950                .finish_shutdown(Duration::from_secs(2), Duration::from_secs(2)),
951        );
952        session_result.context("failed to finish dYdX execution session tasks")?;
953        pending_result.context("failed to finish dYdX execution command tasks")?;
954        Ok(())
955    }
956
957    async fn prepare_task_groups(&mut self) -> anyhow::Result<()> {
958        if !self.session_tasks.is_open() || !self.pending_tasks.is_open() {
959            self.session_tasks.begin_shutdown();
960            self.begin_pending_shutdown();
961            self.ws_client.begin_shutdown();
962            self.finish_shutdown().await?;
963            self.session_tasks
964                .start_generation()
965                .context("failed to start dYdX execution session task generation")?;
966            self.pending_tasks
967                .start_generation()
968                .context("failed to start dYdX execution command task generation")?;
969        }
970        Ok(())
971    }
972
973    async fn finish_shutdown(&mut self) -> anyhow::Result<()> {
974        if let Err(e) = self
975            .ws_client
976            .disconnect()
977            .await
978            .context("failed to disconnect dYdX websocket")
979        {
980            self.shutdown_errors.push(e.to_string());
981        }
982
983        if let Err(e) = self.finish_tasks().await {
984            self.shutdown_errors.push(e.to_string());
985        }
986
987        if !self.shutdown_errors.is_empty() {
988            anyhow::bail!(std::mem::take(&mut self.shutdown_errors).join("; "));
989        }
990        Ok(())
991    }
992
993    async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
994        self.begin_pending_shutdown();
995        self.session_tasks.begin_shutdown();
996        self.ws_client.begin_shutdown();
997        let shutdown_result = self.finish_shutdown().await;
998        self.core.set_disconnected();
999        shutdown_result
1000    }
1001
1002    fn send_modify_rejected(
1003        &self,
1004        strategy_id: StrategyId,
1005        instrument_id: InstrumentId,
1006        client_order_id: ClientOrderId,
1007        venue_order_id: Option<VenueOrderId>,
1008        reason: &str,
1009    ) {
1010        let ts_event = self.clock.get_time_ns();
1011        self.emitter.emit_order_modify_rejected_event(
1012            strategy_id,
1013            instrument_id,
1014            client_order_id,
1015            venue_order_id,
1016            reason,
1017            ts_event,
1018        );
1019    }
1020
1021    async fn await_account_registered(&self, timeout_secs: f64) -> anyhow::Result<()> {
1022        let account_id = self.core.account_id;
1023
1024        if self.core.cache().account(&account_id).is_some() {
1025            log::info!("Account {account_id} registered");
1026            return Ok(());
1027        }
1028
1029        let start = Instant::now();
1030        let timeout = Duration::from_secs_f64(timeout_secs);
1031        let interval = Duration::from_millis(10);
1032
1033        loop {
1034            tokio::time::sleep(interval).await;
1035
1036            if self.core.cache().account(&account_id).is_some() {
1037                log::info!("Account {account_id} registered");
1038                return Ok(());
1039            }
1040
1041            if start.elapsed() >= timeout {
1042                anyhow::bail!(
1043                    "Timeout waiting for account {account_id} to be registered after {timeout_secs}s"
1044                );
1045            }
1046        }
1047    }
1048}
1049
1050/// Broadcasts cancel orders with optimal partitioned strategy.
1051///
1052/// Partitions orders into short-term and long-term/conditional groups:
1053/// - Short-term -> single `MsgBatchCancel` via `broadcast_short_term()`
1054/// - Long-term/conditional -> batched `MsgCancelOrder` via `broadcast_with_retry()`
1055///
1056/// At most 2 gRPC calls regardless of order count or mix.
1057async fn broadcast_partitioned_cancels(
1058    orders: Vec<DydxCancelOrderRequest>,
1059    block_height: u32,
1060    tx_manager: Arc<TransactionManager>,
1061    broadcaster: Arc<TxBroadcaster>,
1062    order_builder: Arc<OrderMessageBuilder>,
1063    emitter: ExecutionEventEmitter,
1064    clock: &'static AtomicTime,
1065) -> anyhow::Result<()> {
1066    if orders.is_empty() {
1067        return Ok(());
1068    }
1069
1070    let (short_term_orders, long_term_orders): (Vec<_>, Vec<_>) = orders
1071        .into_iter()
1072        .partition(|order| order.order_flags == types::ORDER_FLAG_SHORT_TERM);
1073
1074    let mut errors = Vec::new();
1075
1076    if !short_term_orders.is_empty() {
1077        let st_pairs: Vec<_> = short_term_orders
1078            .iter()
1079            .map(|order| (order.instrument_id, order.client_id))
1080            .collect();
1081
1082        log::debug!(
1083            "Batch cancelling {} short-term orders with MsgBatchCancel",
1084            st_pairs.len()
1085        );
1086
1087        match order_builder.build_batch_cancel_short_term(&st_pairs, block_height) {
1088            Ok(msg) => {
1089                let operation = format!("BatchCancel {} short-term orders", st_pairs.len());
1090                match broadcaster
1091                    .broadcast_short_term(&tx_manager, vec![msg], &operation)
1092                    .await
1093                {
1094                    Ok(tx_hash) => {
1095                        log::debug!(
1096                            "Successfully batch cancelled {} short-term orders, tx_hash: {}",
1097                            st_pairs.len(),
1098                            tx_hash
1099                        );
1100                    }
1101                    Err(e) => {
1102                        let msg = format!("Short-term batch cancel failed: {e:?}");
1103                        log::warn!("{msg}");
1104
1105                        // A CheckTx rejection refuses the whole transaction
1106                        // atomically: a venue result for every cancel in it.
1107                        if e.is_definitive_broadcast_rejection() {
1108                            emit_partitioned_cancel_rejections(
1109                                &short_term_orders,
1110                                &emitter,
1111                                clock,
1112                                &msg,
1113                            );
1114                        }
1115                        errors.push(msg);
1116                    }
1117                }
1118            }
1119            Err(e) => {
1120                let msg = format!("Failed to build MsgBatchCancel: {e:?}");
1121                log::error!("{msg}");
1122                errors.push(msg);
1123            }
1124        }
1125    }
1126
1127    if !long_term_orders.is_empty() {
1128        let lt_tuples: Vec<_> = long_term_orders
1129            .iter()
1130            .map(|order| (order.instrument_id, order.client_id, order.order_flags))
1131            .collect();
1132
1133        log::debug!(
1134            "Batch cancelling {} long-term orders",
1135            long_term_orders.len(),
1136        );
1137
1138        match order_builder.build_cancel_orders_batch_with_flags(&lt_tuples, block_height) {
1139            Ok(cancel_msgs) => {
1140                let operation = format!("BatchCancel {} long-term orders", long_term_orders.len());
1141                match broadcaster
1142                    .broadcast_with_retry(&tx_manager, cancel_msgs, &operation)
1143                    .await
1144                {
1145                    Ok(tx_hash) => {
1146                        log::debug!(
1147                            "Successfully batch cancelled {} long-term orders, tx_hash: {}",
1148                            long_term_orders.len(),
1149                            tx_hash
1150                        );
1151                    }
1152                    Err(e) => {
1153                        let msg = format!("Long-term batch cancel failed: {e:?}");
1154                        log::warn!("{msg}");
1155
1156                        if e.is_definitive_broadcast_rejection() {
1157                            emit_partitioned_cancel_rejections(
1158                                &long_term_orders,
1159                                &emitter,
1160                                clock,
1161                                &msg,
1162                            );
1163                        }
1164                        errors.push(msg);
1165                    }
1166                }
1167            }
1168            Err(e) => {
1169                let msg = format!("Failed to build long-term cancel messages: {e:?}");
1170                log::error!("{msg}");
1171                errors.push(msg);
1172            }
1173        }
1174    }
1175
1176    if !errors.is_empty() {
1177        anyhow::bail!("partitioned cancel failed: {}", errors.join("; "));
1178    }
1179
1180    Ok(())
1181}
1182
1183fn emit_partitioned_cancel_rejections(
1184    orders: &[DydxCancelOrderRequest],
1185    emitter: &ExecutionEventEmitter,
1186    clock: &AtomicTime,
1187    reason: &str,
1188) {
1189    for order in orders {
1190        emitter.emit_order_cancel_rejected_event(
1191            order.strategy_id,
1192            order.instrument_id,
1193            order.client_order_id,
1194            order.venue_order_id,
1195            reason,
1196            clock.get_time_ns(),
1197        );
1198    }
1199}
1200
1201fn is_definitive_broadcast_rejection(err: &anyhow::Error) -> bool {
1202    err.downcast_ref::<DydxError>()
1203        .is_some_and(DydxError::is_definitive_broadcast_rejection)
1204}
1205
1206#[async_trait(?Send)]
1207impl ExecutionClient for DydxExecutionClient {
1208    fn is_connected(&self) -> bool {
1209        self.core.is_connected()
1210    }
1211
1212    fn client_id(&self) -> ClientId {
1213        self.core.client_id
1214    }
1215
1216    fn account_id(&self) -> AccountId {
1217        self.core.account_id
1218    }
1219
1220    fn venue(&self) -> Venue {
1221        *DYDX_VENUE
1222    }
1223
1224    fn oms_type(&self) -> OmsType {
1225        self.core.oms_type
1226    }
1227
1228    fn get_account(&self) -> Option<AccountAny> {
1229        self.core.cache().account_owned(&self.core.account_id)
1230    }
1231
1232    fn generate_account_state(
1233        &self,
1234        balances: Vec<AccountBalance>,
1235        margins: Vec<MarginBalance>,
1236        reported: bool,
1237        ts_event: UnixNanos,
1238        info: Option<Params>,
1239    ) -> anyhow::Result<()> {
1240        self.emitter
1241            .emit_account_state(balances, margins, reported, ts_event, info);
1242        Ok(())
1243    }
1244
1245    fn start(&mut self) -> anyhow::Result<()> {
1246        if self.core.is_started() {
1247            log::warn!("dYdX execution client already started");
1248            return Ok(());
1249        }
1250
1251        let sender = get_exec_event_sender();
1252        self.emitter.set_sender(sender);
1253        log::info!("Starting dYdX execution client");
1254        self.core.set_started();
1255        Ok(())
1256    }
1257
1258    fn stop(&mut self) -> anyhow::Result<()> {
1259        if self.core.is_stopped() {
1260            log::warn!("dYdX execution client not started");
1261            return Ok(());
1262        }
1263
1264        log::info!("Stopping dYdX execution client");
1265        self.session_tasks.begin_shutdown();
1266        self.begin_pending_shutdown();
1267        self.ws_client.begin_shutdown();
1268        self.core.set_stopped();
1269        self.core.set_disconnected();
1270        Ok(())
1271    }
1272
1273    fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
1274        if !self.is_connected() {
1275            let reason = "Cannot submit order: execution client not connected";
1276            log::error!("{reason}");
1277            anyhow::bail!(reason);
1278        }
1279
1280        let current_block = self.block_time_monitor.current_block_height();
1281        let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
1282
1283        let client_order_id = order.client_order_id();
1284        let instrument_id = order.instrument_id();
1285        let strategy_id = order.strategy_id();
1286
1287        if current_block == 0 {
1288            let reason = "Block height not initialized";
1289            log::warn!("Cannot submit order {client_order_id}: {reason}");
1290            self.emitter.emit_order_denied(&order, reason);
1291            return Ok(());
1292        }
1293
1294        if order.is_closed() {
1295            log::warn!("Cannot submit closed order {client_order_id}");
1296            return Ok(());
1297        }
1298
1299        if order.is_quote_quantity() {
1300            let reason = "Quote quantity orders are not supported by dYdX";
1301            log::warn!("{reason}");
1302            self.emitter.emit_order_denied(&order, reason);
1303            return Ok(());
1304        }
1305
1306        // Deny unsupported time-in-force values up front so the strategy gets
1307        // an immediate `OrderDenied` rather than a venue-side `code=48` (FOK
1308        // deprecated) or a silent translation to GTC (DAY).
1309        let unsupported_tif_reason = match order.time_in_force() {
1310            TimeInForce::Fok => Some(
1311                "Fill-or-kill (FOK) orders are deprecated by dYdX v4 (chain rejects with code=48)",
1312            ),
1313            TimeInForce::Day => Some("DAY time-in-force is not supported by dYdX v4"),
1314            _ => None,
1315        };
1316
1317        if let Some(reason) = unsupported_tif_reason {
1318            log::warn!("{reason}");
1319            self.emitter.emit_order_denied(&order, reason);
1320            return Ok(());
1321        }
1322
1323        match order.order_type() {
1324            OrderType::Market
1325            | OrderType::Limit
1326            | OrderType::StopMarket
1327            | OrderType::StopLimit
1328            | OrderType::MarketIfTouched
1329            | OrderType::LimitIfTouched => {}
1330            OrderType::TrailingStopMarket | OrderType::TrailingStopLimit => {
1331                let reason = "Trailing stop orders not supported by dYdX v4 protocol";
1332                log::warn!("{reason}");
1333                self.emitter.emit_order_denied(&order, reason);
1334                return Ok(());
1335            }
1336            order_type => {
1337                let reason = format!("Order type {order_type:?} not supported by dYdX");
1338                log::warn!("{reason}");
1339                self.emitter.emit_order_denied(&order, &reason);
1340                return Ok(());
1341            }
1342        }
1343
1344        let (tx_manager, broadcaster, order_builder) = match self.get_execution_components() {
1345            Ok(components) => components,
1346            Err(e) => {
1347                log::error!("Failed to get execution components: {e}");
1348                self.emitter.emit_order_denied(&order, &e.to_string());
1349                return Ok(());
1350            }
1351        };
1352
1353        let block_height = self.block_time_monitor.current_block_height() as u32;
1354
1355        let encoded = match self.encoder.encode(client_order_id) {
1356            Ok(enc) => enc,
1357            Err(e) => {
1358                log::error!("Failed to generate client order ID: {e}");
1359                self.emitter.emit_order_denied(&order, &e.to_string());
1360                return Ok(());
1361            }
1362        };
1363
1364        self.emitter.emit_order_submitted(&order);
1365        let client_id_u32 = encoded.client_id;
1366        let client_metadata = encoded.client_metadata;
1367
1368        log::debug!(
1369            "[SUBMIT_ORDER] Nautilus '{}' -> dYdX u32={} meta={:#x} | instrument={} side={:?} qty={} type={:?}",
1370            client_order_id,
1371            client_id_u32,
1372            client_metadata,
1373            instrument_id,
1374            order.order_side(),
1375            order.quantity(),
1376            order.order_type()
1377        );
1378
1379        let expire_time = order.expire_time().map(nanos_to_secs_i64);
1380
1381        let order_flags = match order.order_type() {
1382            OrderType::StopMarket
1383            | OrderType::StopLimit
1384            | OrderType::MarketIfTouched
1385            | OrderType::LimitIfTouched => types::ORDER_FLAG_CONDITIONAL,
1386            OrderType::Market => types::ORDER_FLAG_SHORT_TERM,
1387            OrderType::Limit => {
1388                let lifetime = types::OrderLifetime::from_time_in_force(
1389                    order.time_in_force(),
1390                    expire_time,
1391                    false,
1392                    order_builder.max_short_term_secs(),
1393                );
1394                lifetime.order_flags()
1395            }
1396            _ => types::ORDER_FLAG_LONG_TERM,
1397        };
1398
1399        let ts_submitted = self.clock.get_time_ns();
1400        let trader_id = order.trader_id();
1401        self.register_order_context(
1402            client_id_u32,
1403            OrderContext {
1404                client_order_id,
1405                trader_id,
1406                strategy_id,
1407                instrument_id,
1408                submitted_at: ts_submitted,
1409                order_flags,
1410            },
1411        );
1412
1413        // Register dispatch identity so the WS handler emits proper order
1414        // events (OrderAccepted, OrderFilled, OrderCanceled) instead of reports
1415        self.dispatch_state.order_identities.insert(
1416            client_order_id,
1417            OrderIdentity {
1418                instrument_id,
1419                strategy_id,
1420                order_side: order.order_side(),
1421                order_type: order.order_type(),
1422            },
1423        );
1424
1425        self.spawn_order_task(
1426            "submit_order",
1427            strategy_id,
1428            instrument_id,
1429            client_order_id,
1430            async move {
1431                let (msg, order_type_str) = match order.order_type() {
1432                    OrderType::Market => {
1433                        let msg = order_builder.build_market_order_with_reduce_only(
1434                            instrument_id,
1435                            client_id_u32,
1436                            client_metadata,
1437                            order.order_side(),
1438                            order.quantity(),
1439                            order.is_reduce_only(),
1440                            block_height,
1441                        )?;
1442                        (msg, "market")
1443                    }
1444                    OrderType::Limit => {
1445                        let msg = order_builder.build_limit_order(
1446                            instrument_id,
1447                            client_id_u32,
1448                            client_metadata,
1449                            order.order_side(),
1450                            order
1451                                .price()
1452                                .ok_or_else(|| anyhow::anyhow!("Limit order missing price"))?,
1453                            order.quantity(),
1454                            order.time_in_force(),
1455                            order.is_post_only(),
1456                            order.is_reduce_only(),
1457                            block_height,
1458                            expire_time, // Uses default_short_term_expiry if configured
1459                        )?;
1460                        (msg, "limit")
1461                    }
1462                    // Conditional orders use their own expiration logic (not affected by default_short_term_expiry)
1463                    // They are always stored on-chain with long-term semantics
1464                    OrderType::StopMarket => {
1465                        let trigger_price = order.trigger_price().ok_or_else(|| {
1466                            anyhow::anyhow!("Stop market order missing trigger_price")
1467                        })?;
1468                        let cond_expire = order.expire_time().map(nanos_to_secs_i64);
1469                        let msg = order_builder.build_stop_market_order(
1470                            instrument_id,
1471                            client_id_u32,
1472                            client_metadata,
1473                            order.order_side(),
1474                            trigger_price,
1475                            order.quantity(),
1476                            order.is_reduce_only(),
1477                            cond_expire,
1478                        )?;
1479                        (msg, "stop_market")
1480                    }
1481                    OrderType::StopLimit => {
1482                        let trigger_price = order.trigger_price().ok_or_else(|| {
1483                            anyhow::anyhow!("Stop limit order missing trigger_price")
1484                        })?;
1485                        let limit_price = order.price().ok_or_else(|| {
1486                            anyhow::anyhow!("Stop limit order missing limit price")
1487                        })?;
1488                        let cond_expire = order.expire_time().map(nanos_to_secs_i64);
1489                        let msg = order_builder.build_stop_limit_order(
1490                            instrument_id,
1491                            client_id_u32,
1492                            client_metadata,
1493                            order.order_side(),
1494                            trigger_price,
1495                            limit_price,
1496                            order.quantity(),
1497                            order.time_in_force(),
1498                            order.is_post_only(),
1499                            order.is_reduce_only(),
1500                            cond_expire,
1501                        )?;
1502                        (msg, "stop_limit")
1503                    }
1504                    // dYdX TakeProfitMarket maps to Nautilus MarketIfTouched
1505                    OrderType::MarketIfTouched => {
1506                        let trigger_price = order.trigger_price().ok_or_else(|| {
1507                            anyhow::anyhow!("Take profit market order missing trigger_price")
1508                        })?;
1509                        let cond_expire = order.expire_time().map(nanos_to_secs_i64);
1510                        let msg = order_builder.build_take_profit_market_order(
1511                            instrument_id,
1512                            client_id_u32,
1513                            client_metadata,
1514                            order.order_side(),
1515                            trigger_price,
1516                            order.quantity(),
1517                            order.is_reduce_only(),
1518                            cond_expire,
1519                        )?;
1520                        (msg, "take_profit_market")
1521                    }
1522                    // dYdX TakeProfitLimit maps to Nautilus LimitIfTouched
1523                    OrderType::LimitIfTouched => {
1524                        let trigger_price = order.trigger_price().ok_or_else(|| {
1525                            anyhow::anyhow!("Take profit limit order missing trigger_price")
1526                        })?;
1527                        let limit_price = order.price().ok_or_else(|| {
1528                            anyhow::anyhow!("Take profit limit order missing limit price")
1529                        })?;
1530                        let cond_expire = order.expire_time().map(nanos_to_secs_i64);
1531                        let msg = order_builder.build_take_profit_limit_order(
1532                            instrument_id,
1533                            client_id_u32,
1534                            client_metadata,
1535                            order.order_side(),
1536                            trigger_price,
1537                            limit_price,
1538                            order.quantity(),
1539                            order.time_in_force(),
1540                            order.is_post_only(),
1541                            order.is_reduce_only(),
1542                            cond_expire,
1543                        )?;
1544                        (msg, "take_profit_limit")
1545                    }
1546                    _ => unreachable!("Order type already validated"),
1547                };
1548
1549                // Broadcast: short-term orders use cached sequence (no increment),
1550                // stateful orders use broadcast_with_retry (proper sequence management)
1551                let operation = format!("Submit {order_type_str} order {client_order_id}");
1552
1553                if order_flags == types::ORDER_FLAG_SHORT_TERM {
1554                    broadcaster
1555                        .broadcast_short_term(&tx_manager, vec![msg], &operation)
1556                        .await?;
1557                } else {
1558                    broadcaster
1559                        .broadcast_with_retry(&tx_manager, vec![msg], &operation)
1560                        .await?;
1561                }
1562                log::debug!("Successfully submitted {order_type_str} order: {client_order_id}");
1563
1564                Ok(())
1565            },
1566        );
1567
1568        Ok(())
1569    }
1570
1571    fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
1572        let orders = self.core.get_orders_for_list(&cmd.order_list)?;
1573        let order_count = orders.len();
1574
1575        if !self.is_connected() {
1576            let reason = "Cannot submit order list: execution client not connected";
1577            log::error!("{reason}");
1578            anyhow::bail!(reason);
1579        }
1580
1581        // Pre-submission TIF gate: deny FOK and DAY before doing any further
1582        // work, mirroring `submit_order`'s gate. Done up front so the strategy
1583        // sees an immediate `OrderDenied` even when the rest of the pipeline
1584        // (block height, TX manager) is not yet ready.
1585        let unsupported_tif_reason = |order: &OrderAny| match order.time_in_force() {
1586            TimeInForce::Fok => Some(
1587                "Fill-or-kill (FOK) orders are deprecated by dYdX v4 (chain rejects with code=48)",
1588            ),
1589            TimeInForce::Day => Some("DAY time-in-force is not supported by dYdX v4"),
1590            _ => None,
1591        };
1592
1593        if orders
1594            .iter()
1595            .any(|order| unsupported_tif_reason(order).is_some())
1596        {
1597            // Deny the whole list: dYdX has no atomic batch semantics worth
1598            // preserving, and siblings must not be left unresolved.
1599            for order in &orders {
1600                let reason = unsupported_tif_reason(order)
1601                    .unwrap_or("Order list denied: sibling order has unsupported time in force");
1602                self.emitter.emit_order_denied(order, reason);
1603            }
1604            return Ok(());
1605        }
1606
1607        let current_block = self.block_time_monitor.current_block_height();
1608        if current_block == 0 {
1609            let reason = "Block height not initialized";
1610            log::warn!("Cannot submit order list: {reason}");
1611            for order in &orders {
1612                self.emitter.emit_order_denied(order, reason);
1613            }
1614            return Ok(());
1615        }
1616
1617        let (tx_manager, broadcaster, order_builder) = match self.get_execution_components() {
1618            Ok(components) => components,
1619            Err(e) => {
1620                log::error!("Failed to get execution components for batch: {e}");
1621                for order in &orders {
1622                    self.emitter.emit_order_denied(order, &e.to_string());
1623                }
1624                return Ok(());
1625            }
1626        };
1627
1628        let mut order_params: Vec<LimitOrderParams> = Vec::with_capacity(order_count);
1629        let mut order_info: Vec<(ClientOrderId, InstrumentId, StrategyId)> =
1630            Vec::with_capacity(order_count);
1631
1632        for order in &orders {
1633            if order.order_type() != OrderType::Limit {
1634                log::debug!(
1635                    "Order {} has type {:?}, falling back to individual submission",
1636                    order.client_order_id(),
1637                    order.order_type()
1638                );
1639                let submit_cmd = SubmitOrder::new(
1640                    cmd.trader_id,
1641                    cmd.client_id,
1642                    cmd.strategy_id,
1643                    order.instrument_id(),
1644                    order.client_order_id(),
1645                    order.init_event().clone(),
1646                    cmd.exec_algorithm_id,
1647                    cmd.position_id,
1648                    cmd.params.clone(),
1649                    UUID4::new(),
1650                    cmd.ts_init,
1651                    cmd.correlation_id,
1652                );
1653
1654                if let Err(e) = self.submit_order(submit_cmd) {
1655                    log::error!(
1656                        "Failed to submit order {} from order list: {e}",
1657                        order.client_order_id()
1658                    );
1659                }
1660                continue;
1661            }
1662
1663            if order.is_quote_quantity() {
1664                self.emitter
1665                    .emit_order_denied(order, "Quote quantity orders are not supported by dYdX");
1666                continue;
1667            }
1668
1669            let Some(price) = order.price() else {
1670                self.emitter
1671                    .emit_order_denied(order, "Limit order missing price");
1672                continue;
1673            };
1674
1675            let encoded = match self.encoder.encode(order.client_order_id()) {
1676                Ok(enc) => enc,
1677                Err(e) => {
1678                    log::error!("Failed to generate client order ID: {e}");
1679                    self.emitter.emit_order_denied(order, &e.to_string());
1680                    continue;
1681                }
1682            };
1683            let client_id_u32 = encoded.client_id;
1684            let client_metadata = encoded.client_metadata;
1685
1686            self.emitter.emit_order_submitted(order);
1687
1688            let expire_time_secs = order.expire_time().map(nanos_to_secs_i64);
1689            let lifetime = types::OrderLifetime::from_time_in_force(
1690                order.time_in_force(),
1691                expire_time_secs,
1692                false,
1693                order_builder.max_short_term_secs(),
1694            );
1695
1696            let ts_submitted = self.clock.get_time_ns();
1697            self.register_order_context(
1698                client_id_u32,
1699                OrderContext {
1700                    client_order_id: order.client_order_id(),
1701                    trader_id: order.trader_id(),
1702                    strategy_id: order.strategy_id(),
1703                    instrument_id: order.instrument_id(),
1704                    submitted_at: ts_submitted,
1705                    order_flags: lifetime.order_flags(),
1706                },
1707            );
1708
1709            self.dispatch_state.order_identities.insert(
1710                order.client_order_id(),
1711                OrderIdentity {
1712                    instrument_id: order.instrument_id(),
1713                    strategy_id: order.strategy_id(),
1714                    order_side: order.order_side(),
1715                    order_type: order.order_type(),
1716                },
1717            );
1718
1719            order_params.push(LimitOrderParams {
1720                instrument_id: order.instrument_id(),
1721                client_order_id: client_id_u32,
1722                client_metadata,
1723                side: order.order_side(),
1724                price,
1725                quantity: order.quantity(),
1726                time_in_force: order.time_in_force(),
1727                post_only: order.is_post_only(),
1728                reduce_only: order.is_reduce_only(),
1729                expire_time_ns: order.expire_time(),
1730            });
1731            order_info.push((
1732                order.client_order_id(),
1733                order.instrument_id(),
1734                order.strategy_id(),
1735            ));
1736        }
1737
1738        if order_params.is_empty() {
1739            return Ok(());
1740        }
1741
1742        // dYdX requires each short-term order in its own transaction
1743        let has_short_term = order_params
1744            .iter()
1745            .any(|params| order_builder.is_short_term_order(params));
1746
1747        let block_height = current_block as u32;
1748        let emitter = self.emitter.clone();
1749        let clock = self.clock;
1750
1751        if has_short_term {
1752            log::debug!(
1753                "Submitting {} short-term limit orders concurrently (sequence not consumed)",
1754                order_params.len()
1755            );
1756
1757            self.spawn_labeled("batch_submit_short_term", async move {
1758                // Build and broadcast all orders concurrently -- no sequence coordination needed.
1759                // Short-term orders use cached sequence (not incremented) via broadcast_short_term.
1760                let mut handles = tokio::task::JoinSet::new();
1761
1762                for (params, (client_order_id, instrument_id, strategy_id)) in
1763                    order_params.into_iter().zip(order_info)
1764                {
1765                    let tx_manager = tx_manager.clone();
1766                    let broadcaster = broadcaster.clone();
1767                    let order_builder = order_builder.clone();
1768                    let emitter = emitter.clone();
1769
1770                    handles.spawn(async move {
1771                        let msg = match order_builder
1772                            .build_limit_order_from_params(&params, block_height)
1773                        {
1774                            Ok(m) => m,
1775                            Err(e) => {
1776                                // Local failure after OrderSubmitted: leave the
1777                                // order for in-flight resolution.
1778                                log::error!(
1779                                    "Failed to build order message for {client_order_id}: {e:?}"
1780                                );
1781                                return;
1782                            }
1783                        };
1784
1785                        // Broadcast with cached sequence (short-term orders don't consume sequences)
1786                        let operation = format!("Submit short-term order {client_order_id}");
1787
1788                        if let Err(e) = broadcaster
1789                            .broadcast_short_term(&tx_manager, vec![msg], &operation)
1790                            .await
1791                        {
1792                            if e.is_definitive_broadcast_rejection() {
1793                                let error_msg = format!("Order submission failed: {e:?}");
1794                                log::warn!("{error_msg}");
1795                                let ts_event = clock.get_time_ns();
1796                                emitter.emit_order_rejected_event(
1797                                    strategy_id,
1798                                    instrument_id,
1799                                    client_order_id,
1800                                    &error_msg,
1801                                    ts_event,
1802                                    false,
1803                                );
1804                            } else {
1805                                log::warn!(
1806                                    "Ambiguous dYdX submit failure for {client_order_id}, awaiting reconciliation: {e:?}"
1807                                );
1808                            }
1809                        }
1810                    });
1811                }
1812
1813                while let Some(result) = handles.join_next().await {
1814                    if let Err(e) = result
1815                        && !e.is_cancelled()
1816                    {
1817                        log::warn!("dYdX short-term order task failed: {e}");
1818                    }
1819                }
1820            });
1821        } else {
1822            log::debug!(
1823                "Batch submitting {} long-term limit orders in single transaction",
1824                order_params.len()
1825            );
1826
1827            self.spawn_labeled("batch_submit_long_term", async move {
1828                let msgs: Result<Vec<_>, _> = order_params
1829                    .iter()
1830                    .map(|params| order_builder.build_limit_order_from_params(params, block_height))
1831                    .collect();
1832
1833                let msgs = match msgs {
1834                    Ok(m) => m,
1835                    Err(e) => {
1836                        // Local failure after OrderSubmitted: leave the orders
1837                        // for in-flight resolution.
1838                        log::error!("Failed to build batch order messages: {e:?}");
1839                        return;
1840                    }
1841                };
1842
1843                let operation = format!("Submit batch of {} limit orders", msgs.len());
1844
1845                if let Err(e) = broadcaster
1846                    .broadcast_with_retry(&tx_manager, msgs, &operation)
1847                    .await
1848                {
1849                    if e.is_definitive_broadcast_rejection() {
1850                        // A CheckTx rejection refuses the whole transaction
1851                        // atomically: a venue result for every order in it.
1852                        let error_msg = format!("Batch order submission failed: {e:?}");
1853                        log::warn!("{error_msg}");
1854                        let ts_event = clock.get_time_ns();
1855
1856                        for (client_order_id, instrument_id, strategy_id) in order_info {
1857                            emitter.emit_order_rejected_event(
1858                                strategy_id,
1859                                instrument_id,
1860                                client_order_id,
1861                                &error_msg,
1862                                ts_event,
1863                                false,
1864                            );
1865                        }
1866                    } else {
1867                        log::warn!(
1868                            "Ambiguous dYdX batch submit failure, awaiting reconciliation: {e:?}"
1869                        );
1870                    }
1871                }
1872            });
1873        }
1874
1875        Ok(())
1876    }
1877
1878    /// dYdX does not support native order modification.
1879    ///
1880    /// Strategies should handle `OrderModifyRejected` by canceling and resubmitting.
1881    fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
1882        let reason = "dYdX does not support order modification. Use cancel and resubmit instead.";
1883        log::warn!("{reason}");
1884
1885        self.send_modify_rejected(
1886            cmd.strategy_id,
1887            cmd.instrument_id,
1888            cmd.client_order_id,
1889            cmd.venue_order_id,
1890            reason,
1891        );
1892        Ok(())
1893    }
1894
1895    fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
1896        if !self.is_connected() {
1897            anyhow::bail!("Cannot cancel order: not connected");
1898        }
1899
1900        let client_order_id = cmd.client_order_id;
1901        let instrument_id = cmd.instrument_id;
1902        let strategy_id = cmd.strategy_id;
1903        let venue_order_id = cmd.venue_order_id;
1904
1905        let (order_time_in_force, order_expire_time) = {
1906            let cache = self.core.cache();
1907
1908            let order = match cache.order(&client_order_id) {
1909                Some(order) => order,
1910                None => {
1911                    log::error!("Cannot cancel order {client_order_id}: not found in cache");
1912                    return Ok(()); // Not an error - order may have been filled/canceled already
1913                }
1914            };
1915
1916            if order.is_closed() {
1917                log::warn!(
1918                    "CancelOrder command for {} when order already {} (will not send to exchange)",
1919                    client_order_id,
1920                    order.status()
1921                );
1922                return Ok(());
1923            }
1924
1925            if cache.instrument(&instrument_id).is_none() {
1926                log::error!(
1927                    "Cannot cancel order {client_order_id}: instrument {instrument_id} not found in cache"
1928                );
1929                return Ok(()); // Not an error - missing instrument is a cache issue
1930            }
1931
1932            (
1933                order.time_in_force(),
1934                order.expire_time().map(nanos_to_secs_i64),
1935            )
1936        }; // Cache borrow released here
1937
1938        log::debug!("Cancelling order {client_order_id} for instrument {instrument_id}");
1939
1940        let (tx_manager, broadcaster, order_builder) = match self.get_execution_components() {
1941            Ok(components) => components,
1942            Err(e) => {
1943                log::error!("Failed to get execution components for cancel: {e}");
1944                return Ok(());
1945            }
1946        };
1947
1948        let block_height = self.block_time_monitor.current_block_height() as u32;
1949
1950        let encoded = match self.encoder.get(&client_order_id) {
1951            Some(enc) => enc,
1952            None => {
1953                log::error!("Client order ID {client_order_id} not found in cache");
1954                anyhow::bail!("Client order ID not found in cache")
1955            }
1956        };
1957        let client_id_u32 = encoded.client_id;
1958
1959        log::debug!(
1960            "[CANCEL_ORDER] Nautilus '{client_order_id}' -> dYdX u32={client_id_u32} | instrument={instrument_id}"
1961        );
1962
1963        // Stored flags remain authoritative after the order expires
1964        let order_flags = self.get_order_context(client_id_u32).map_or_else(
1965            || {
1966                log::debug!(
1967                    "Order context not found for {client_order_id}, deriving flags from order"
1968                );
1969                types::OrderLifetime::from_time_in_force(
1970                    order_time_in_force, // Using extracted value
1971                    order_expire_time,   // Using extracted value
1972                    false,
1973                    order_builder.max_short_term_secs(),
1974                )
1975                .order_flags()
1976            },
1977            |ctx| ctx.order_flags,
1978        );
1979
1980        let clock = self.clock;
1981        let emitter = self.emitter.clone();
1982
1983        self.spawn_task("cancel_order", async move {
1984            let cancel_msg = match order_builder.build_cancel_order_with_flags(
1985                instrument_id,
1986                client_id_u32,
1987                order_flags,
1988                block_height,
1989            ) {
1990                Ok(msg) => msg,
1991                Err(e) => {
1992                    // Local validation failure: leave the cancel outcome for
1993                    // in-flight resolution.
1994                    log::warn!(
1995                        "Cancel command failed local validation for {client_order_id}: {e:?}"
1996                    );
1997                    return Ok(());
1998                }
1999            };
2000
2001            // Broadcast cancel: short-term uses cached sequence, stateful uses retry
2002            let cancel_op = format!("Cancel order {client_order_id}");
2003            let result = if order_flags == types::ORDER_FLAG_SHORT_TERM {
2004                broadcaster
2005                    .broadcast_short_term(&tx_manager, vec![cancel_msg], &cancel_op)
2006                    .await
2007            } else {
2008                broadcaster
2009                    .broadcast_with_retry(&tx_manager, vec![cancel_msg], &cancel_op)
2010                    .await
2011            };
2012
2013            match result {
2014                Ok(_) => {
2015                    log::debug!("Successfully cancelled order: {client_order_id}");
2016                }
2017                Err(e) if e.is_definitive_broadcast_rejection() => {
2018                    log::warn!("Failed to cancel order {client_order_id}: {e:?}");
2019
2020                    let ts_event = clock.get_time_ns();
2021                    emitter.emit_order_cancel_rejected_event(
2022                        strategy_id,
2023                        instrument_id,
2024                        client_order_id,
2025                        venue_order_id,
2026                        &format!("Cancel order failed: {e:?}"),
2027                        ts_event,
2028                    );
2029                }
2030                Err(e) => {
2031                    log::warn!(
2032                        "Ambiguous dYdX cancel failure for {client_order_id}, awaiting reconciliation: {e:?}"
2033                    );
2034                }
2035            }
2036
2037            Ok(())
2038        });
2039
2040        Ok(())
2041    }
2042
2043    fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
2044        if !self.is_connected() {
2045            anyhow::bail!("Cannot cancel orders: not connected");
2046        }
2047
2048        let instrument_id = cmd.instrument_id;
2049        let order_side_filter = cmd.order_side;
2050
2051        let order_data: Vec<CancelAllOrderData> = {
2052            let cache = self.core.cache();
2053            let side_filter = order_side_filter;
2054            cache
2055                .orders_open(None, Some(&instrument_id), None, None, side_filter)
2056                .into_iter()
2057                .map(|order| {
2058                    (
2059                        order.strategy_id(),
2060                        order.client_order_id(),
2061                        order.venue_order_id(),
2062                        order.time_in_force(),
2063                        order.expire_time(),
2064                    )
2065                })
2066                .collect()
2067        }; // Cache borrow released here
2068
2069        let short_term_count = order_data
2070            .iter()
2071            .filter(|(_, _, _, tif, _)| matches!(tif, TimeInForce::Ioc | TimeInForce::Fok))
2072            .count();
2073        let long_term_count = order_data.len() - short_term_count;
2074
2075        log::debug!(
2076            "Cancel all orders: total={}, short_term={}, long_term={}, instrument_id={instrument_id}, order_side={order_side_filter:?}",
2077            order_data.len(),
2078            short_term_count,
2079            long_term_count
2080        );
2081
2082        let (tx_manager, broadcaster, order_builder) = match self.get_execution_components() {
2083            Ok(components) => components,
2084            Err(e) => {
2085                log::error!("Failed to get execution components for cancel_all: {e}");
2086                return Ok(());
2087            }
2088        };
2089
2090        let block_height = self.block_time_monitor.current_block_height() as u32;
2091
2092        let mut orders_to_cancel = Vec::new();
2093
2094        for (strategy_id, client_order_id, venue_order_id, _time_in_force, _expire_time) in
2095            &order_data
2096        {
2097            let Some(encoded) = self.encoder.get(client_order_id) else {
2098                log::warn!("Cannot cancel order {client_order_id}: not found in encoder");
2099                continue;
2100            };
2101            let client_id_u32 = encoded.client_id;
2102
2103            let Some(ctx) = self.get_order_context(client_id_u32) else {
2104                log::debug!(
2105                    "Skipping cancel for {client_order_id}: order context already cleaned up (terminal)"
2106                );
2107                continue;
2108            };
2109            orders_to_cancel.push(DydxCancelOrderRequest {
2110                instrument_id,
2111                client_id: client_id_u32,
2112                order_flags: ctx.order_flags,
2113                strategy_id: *strategy_id,
2114                client_order_id: *client_order_id,
2115                venue_order_id: *venue_order_id,
2116            });
2117        }
2118
2119        if orders_to_cancel.is_empty() {
2120            return Ok(());
2121        }
2122
2123        log::debug!(
2124            "Cancel all: {} orders (short_term={}, long_term={}), instrument_id={instrument_id}, order_side={order_side_filter:?}",
2125            orders_to_cancel.len(),
2126            orders_to_cancel
2127                .iter()
2128                .filter(|order| order.order_flags == types::ORDER_FLAG_SHORT_TERM)
2129                .count(),
2130            orders_to_cancel
2131                .iter()
2132                .filter(|order| order.order_flags != types::ORDER_FLAG_SHORT_TERM)
2133                .count(),
2134        );
2135
2136        let clock = self.clock;
2137        let emitter = self.emitter.clone();
2138
2139        self.spawn_task("cancel_all_orders", async move {
2140            broadcast_partitioned_cancels(
2141                orders_to_cancel,
2142                block_height,
2143                tx_manager,
2144                broadcaster,
2145                order_builder,
2146                emitter,
2147                clock,
2148            )
2149            .await
2150        });
2151
2152        Ok(())
2153    }
2154
2155    fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
2156        if cmd.cancels.is_empty() {
2157            return Ok(());
2158        }
2159
2160        if !self.is_connected() {
2161            anyhow::bail!("Cannot cancel orders: not connected");
2162        }
2163
2164        let (tx_manager, broadcaster, order_builder) = match self.get_execution_components() {
2165            Ok(components) => components,
2166            Err(e) => {
2167                log::error!("Failed to get execution components for batch cancel: {e}");
2168                return Ok(());
2169            }
2170        };
2171
2172        let mut orders_to_cancel = Vec::with_capacity(cmd.cancels.len());
2173        for cancel in &cmd.cancels {
2174            let client_order_id = cancel.client_order_id;
2175            let encoded = match self.encoder.get(&client_order_id) {
2176                Some(enc) => enc,
2177                None => {
2178                    log::warn!(
2179                        "No u32 mapping found for client_order_id={client_order_id}, skipping cancel"
2180                    );
2181                    continue;
2182                }
2183            };
2184            let client_id_u32 = encoded.client_id;
2185
2186            let Some(ctx) = self.get_order_context(client_id_u32) else {
2187                log::debug!(
2188                    "Skipping cancel for {client_order_id}: order context already cleaned up (terminal)"
2189                );
2190                continue;
2191            };
2192
2193            orders_to_cancel.push(DydxCancelOrderRequest {
2194                instrument_id: cancel.instrument_id,
2195                client_id: client_id_u32,
2196                order_flags: ctx.order_flags,
2197                strategy_id: cancel.strategy_id,
2198                client_order_id,
2199                venue_order_id: cancel.venue_order_id,
2200            });
2201        }
2202
2203        if orders_to_cancel.is_empty() {
2204            log::warn!("No valid orders to cancel in batch");
2205            return Ok(());
2206        }
2207
2208        let block_height = self.block_time_monitor.current_block_height() as u32;
2209        let clock = self.clock;
2210        let emitter = self.emitter.clone();
2211
2212        log::debug!(
2213            "Batch cancelling {} orders via partitioned strategy",
2214            orders_to_cancel.len(),
2215        );
2216
2217        self.spawn_task("batch_cancel_orders", async move {
2218            broadcast_partitioned_cancels(
2219                orders_to_cancel,
2220                block_height,
2221                tx_manager,
2222                broadcaster,
2223                order_builder,
2224                emitter,
2225                clock,
2226            )
2227            .await
2228        });
2229
2230        Ok(())
2231    }
2232
2233    fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
2234        let http_client = self.http_client.clone();
2235        let wallet_address = self.wallet_address.clone();
2236        let subaccount_number = self.subaccount_number;
2237        let account_id = self.core.account_id;
2238        let emitter = self.emitter.clone();
2239
2240        self.spawn_task("query_account", async move {
2241            let account_state = http_client
2242                .request_account_state(&wallet_address, subaccount_number, account_id)
2243                .await
2244                .context("failed to query account state")?;
2245
2246            emitter.emit_account_state(
2247                account_state.balances.clone(),
2248                account_state.margins.clone(),
2249                account_state.is_reported,
2250                account_state.ts_event,
2251                account_state.info,
2252            );
2253            Ok(())
2254        });
2255
2256        Ok(())
2257    }
2258
2259    fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
2260        log::debug!("Querying order: client_order_id={}", cmd.client_order_id);
2261
2262        let http_client = self.http_client.clone();
2263        let wallet_address = self.wallet_address.clone();
2264        let subaccount_number = self.subaccount_number;
2265        let account_id = self.core.account_id;
2266        let emitter = self.emitter.clone();
2267        let client_order_id = cmd.client_order_id;
2268        let venue_order_id = cmd.venue_order_id;
2269        let instrument_id = cmd.instrument_id;
2270
2271        self.spawn_task("query_order", async move {
2272            let reports = http_client
2273                .request_order_status_reports(
2274                    &wallet_address,
2275                    subaccount_number,
2276                    account_id,
2277                    Some(instrument_id),
2278                )
2279                .await
2280                .context("failed to query order status")?;
2281
2282            let report = reports.into_iter().find(|r| {
2283                if venue_order_id.is_some_and(|vid| r.venue_order_id == vid) {
2284                    return true;
2285                }
2286                r.client_order_id.is_some_and(|cid| cid == client_order_id)
2287            });
2288
2289            if let Some(report) = report {
2290                emitter.send_order_status_report(report);
2291            } else {
2292                log::warn!(
2293                    "No order found for client_order_id={client_order_id}, venue_order_id={venue_order_id:?}"
2294                );
2295            }
2296
2297            Ok(())
2298        });
2299
2300        Ok(())
2301    }
2302
2303    async fn connect(&mut self) -> anyhow::Result<()> {
2304        if self.core.is_connected() && self.session_tasks.is_open() && self.pending_tasks.is_open()
2305        {
2306            log::warn!("dYdX execution client already connected");
2307            return Ok(());
2308        }
2309
2310        log::info!("Connecting to dYdX");
2311
2312        self.prepare_task_groups().await?;
2313        let ws_client = self.ws_client.clone();
2314        let setup_guard =
2315            TaskGroupGuard::new(&[&self.session_tasks, &self.pending_tasks], move || {
2316                ws_client.begin_shutdown();
2317            });
2318
2319        log::debug!("Loading instruments from HTTP API");
2320        self.http_client.fetch_and_cache_instruments().await?;
2321        log::debug!(
2322            "Loaded {} instruments from HTTP into shared cache",
2323            self.http_client.cached_instruments_count()
2324        );
2325        self.mark_instruments_initialized();
2326
2327        // Initialize gRPC client (deferred from constructor to avoid blocking)
2328        let grpc_urls = self.config.get_grpc_urls();
2329        let mut grpc_client = DydxGrpcClient::new_with_fallback(&grpc_urls)
2330            .await
2331            .context("failed to construct dYdX gRPC client")?;
2332        log::debug!("gRPC client initialized");
2333
2334        // Fetch the initial height synchronously so orders can be submitted immediately
2335        let initial_height = grpc_client
2336            .latest_block_height()
2337            .await
2338            .context("failed to fetch initial block height")?;
2339        // Use current time as approximation; actual timestamps will come from WebSocket updates
2340        self.block_time_monitor
2341            .record_block(initial_height.0 as u64, jiff::Timestamp::now());
2342        log::debug!("Initial block height: {}", initial_height.0);
2343
2344        *self.grpc_client.write().await = Some(grpc_client.clone());
2345
2346        let private_key =
2347            Self::resolve_private_key(&self.config).context("failed to resolve private key")?;
2348        let tx_manager = Arc::new(
2349            TransactionManager::new(
2350                grpc_client.clone(),
2351                &private_key,
2352                self.wallet_address.clone(),
2353                self.get_chain_id(),
2354            )
2355            .context("failed to create TransactionManager")?,
2356        );
2357
2358        tx_manager
2359            .resolve_authenticators()
2360            .await
2361            .context("failed to resolve authenticators")?;
2362
2363        // Proactively initialize sequence from chain so orders can be submitted
2364        // immediately after connect() without first-transaction latency penalty.
2365        tx_manager
2366            .initialize_sequence()
2367            .await
2368            .context("failed to initialize sequence")?;
2369
2370        self.tx_manager = Some(tx_manager);
2371        self.broadcaster = Some(Arc::new(TxBroadcaster::new(
2372            grpc_client,
2373            self.config.grpc_quota(),
2374        )));
2375        self.order_builder = Some(Arc::new(OrderMessageBuilder::new(
2376            self.http_client.clone(),
2377            self.wallet_address.clone(),
2378            self.subaccount_number,
2379            self.block_time_monitor.clone(),
2380        )));
2381        log::debug!(
2382            "OrderMessageBuilder initialized (block_time_monitor ready: {}, max_short_term: {:.1}s)",
2383            self.block_time_monitor.is_ready(),
2384            SHORT_TERM_ORDER_MAXIMUM_LIFETIME as f64
2385                * self.block_time_monitor.seconds_per_block_or_default()
2386        );
2387
2388        let session_result = async {
2389            self.ws_client.connect().await?;
2390            log::debug!("WebSocket connected");
2391
2392            self.ws_client.subscribe_block_height().await?;
2393            log::debug!("Subscribed to block height updates");
2394
2395            self.ws_client.subscribe_markets().await?;
2396            log::debug!("Subscribed to markets");
2397
2398            log::debug!(
2399                "Using wallet address for queries: {} (subaccount {})",
2400                self.wallet_address,
2401                self.subaccount_number
2402            );
2403            self.ws_client
2404                .subscribe_subaccount(&self.wallet_address, self.subaccount_number)
2405                .await?;
2406            log::debug!(
2407                "Subscribed to subaccount updates: {}/{}",
2408                self.wallet_address,
2409                self.subaccount_number
2410            );
2411
2412            let stream = self.ws_client.stream();
2413            self.spawn_ws_stream_handler(stream)?;
2414
2415            // Fills require a registered account for portfolio updates
2416            self.await_account_registered(30.0).await?;
2417
2418            Ok::<(), anyhow::Error>(())
2419        }
2420        .await;
2421
2422        if let Err(e) = session_result {
2423            if let Err(teardown_error) = self.teardown_partial_connect().await {
2424                return Err(e.context(format!(
2425                    "dYdX execution startup teardown failed: {teardown_error}"
2426                )));
2427            }
2428            return Err(e);
2429        }
2430
2431        self.core.set_connected();
2432        setup_guard.disarm();
2433        log::info!("Connected: client_id={}", self.core.client_id);
2434        Ok(())
2435    }
2436
2437    async fn disconnect(&mut self) -> anyhow::Result<()> {
2438        log::info!("Disconnecting from dYdX");
2439
2440        self.begin_pending_shutdown();
2441        let ws_client = self.ws_client.clone();
2442        let disconnect_guard = TaskGroupGuard::new(&[&self.session_tasks], move || {
2443            ws_client.begin_shutdown();
2444        });
2445
2446        let _ = self
2447            .ws_client
2448            .unsubscribe_subaccount(&self.wallet_address, self.subaccount_number)
2449            .await
2450            .map_err(|e| log::warn!("Failed to unsubscribe from subaccount: {e}"));
2451
2452        let _ = self
2453            .ws_client
2454            .unsubscribe_markets()
2455            .await
2456            .map_err(|e| log::warn!("Failed to unsubscribe from markets: {e}"));
2457
2458        let _ = self
2459            .ws_client
2460            .unsubscribe_block_height()
2461            .await
2462            .map_err(|e| log::warn!("Failed to unsubscribe from block height: {e}"));
2463
2464        let shutdown_result = self.teardown_partial_connect().await;
2465        disconnect_guard.disarm();
2466        shutdown_result?;
2467        log::info!("Disconnected: client_id={}", self.core.client_id);
2468        Ok(())
2469    }
2470
2471    async fn generate_order_status_report(
2472        &self,
2473        cmd: &GenerateOrderStatusReport,
2474    ) -> anyhow::Result<Option<OrderStatusReport>> {
2475        // dYdX Indexer `/v4/orders` caps at `limit` and has no offset cursor, so we
2476        // request the maximum page to maximize the chance of finding a match
2477        // on active subaccounts. Callers looking for older orders should prefer
2478        // `generate_mass_status` or narrow via `instrument_id`.
2479        let market = cmd
2480            .instrument_id
2481            .map(|id| id.symbol.as_str().trim_end_matches("-PERP").to_string());
2482
2483        let response = self
2484            .http_client
2485            .inner
2486            .get_orders(
2487                &self.wallet_address,
2488                self.subaccount_number,
2489                market.as_deref(),
2490                Some(DYDX_INDEXER_REPORT_LIMIT),
2491            )
2492            .await
2493            .context("failed to fetch order from dYdX API")?;
2494
2495        if response.is_empty() {
2496            log::debug!(
2497                "No orders returned for {}/subaccount={} (market_filter={:?})",
2498                self.wallet_address,
2499                self.subaccount_number,
2500                market,
2501            );
2502            return Ok(None);
2503        }
2504
2505        let ts_init = UnixNanos::default();
2506        let scanned_count = response.len();
2507
2508        let report = find_matching_order_report(
2509            &response,
2510            cmd.instrument_id,
2511            cmd.client_order_id,
2512            cmd.venue_order_id,
2513            |clob_pair_id| self.get_instrument_by_clob_pair_id(clob_pair_id),
2514            &self.encoder,
2515            self.core.account_id,
2516            ts_init,
2517        )?;
2518
2519        if report.is_none() {
2520            // The target order was not in the fetched page. Surface the scope so
2521            // callers can tell whether the order is older than the page or the
2522            // filters simply didn't match any returned order.
2523            let page_full = scanned_count == DYDX_INDEXER_REPORT_LIMIT as usize;
2524            log::debug!(
2525                "No order matched filters for {}/subaccount={} \
2526                 (client_order_id={:?}, venue_order_id={:?}, instrument_id={:?}, \
2527                 scanned={scanned_count}, page_full={page_full}, limit={DYDX_INDEXER_REPORT_LIMIT})",
2528                self.wallet_address,
2529                self.subaccount_number,
2530                cmd.client_order_id,
2531                cmd.venue_order_id,
2532                cmd.instrument_id,
2533            );
2534        }
2535
2536        Ok(report)
2537    }
2538
2539    async fn generate_order_status_reports(
2540        &self,
2541        cmd: &GenerateOrderStatusReports,
2542    ) -> anyhow::Result<Vec<OrderStatusReport>> {
2543        let response = self
2544            .http_client
2545            .inner
2546            .get_orders(
2547                &self.wallet_address,
2548                self.subaccount_number,
2549                None, // market filter
2550                Some(DYDX_INDEXER_REPORT_LIMIT),
2551            )
2552            .await
2553            .context("failed to fetch orders from dYdX API")?;
2554
2555        let mut reports = Vec::new();
2556        let ts_init = UnixNanos::default();
2557
2558        for order in response {
2559            let instrument = match self.get_instrument_by_clob_pair_id(order.clob_pair_id) {
2560                Some(inst) => inst,
2561                None => continue,
2562            };
2563
2564            if let Some(filter_id) = cmd.instrument_id
2565                && instrument.id() != filter_id
2566            {
2567                continue;
2568            }
2569
2570            match parse_order_status_report(&order, &instrument, self.core.account_id, ts_init) {
2571                Ok(mut r) => {
2572                    if !order.client_id.is_empty()
2573                        && let Ok(client_id_u32) = order.client_id.parse::<u32>()
2574                    {
2575                        self.encoder.register_known_client_id(client_id_u32);
2576
2577                        if let Some(decoded) = self
2578                            .encoder
2579                            .decode_if_known(client_id_u32, order.client_metadata)
2580                        {
2581                            log::debug!(
2582                                "Decoded order: dYdX client_id={} meta={:#x} -> '{}'",
2583                                client_id_u32,
2584                                order.client_metadata,
2585                                decoded,
2586                            );
2587                            r.client_order_id = Some(decoded);
2588                        }
2589                    }
2590                    reports.push(r);
2591                }
2592                Err(e) => {
2593                    log::warn!("Failed to parse order status report: {e}");
2594                }
2595            }
2596        }
2597
2598        retain_order_status_reports(&mut reports, cmd);
2599
2600        // Drop reports that conflict with the local cache: if we already have
2601        // the order in a terminal status (FILLED/CANCELED/EXPIRED/REJECTED/DENIED),
2602        // replaying an `Accepted` from open-check would error in the ExecEngine.
2603        // The venue may legitimately still consider the order open due to clock
2604        // skew between our inferred fill and the next venue update; skipping
2605        // here keeps the engine quiet while reconciliation eventually catches up.
2606        {
2607            let cache = self.core.cache();
2608            reports.retain(|r| {
2609                let Some(cid) = r.client_order_id else {
2610                    return true;
2611                };
2612
2613                match cache.order(&cid) {
2614                    Some(order) if order.status().is_closed() => {
2615                        log::debug!(
2616                            "Skipping reconciliation report for terminal order {cid} \
2617                             (cache status={:?}, venue status={:?})",
2618                            order.status(),
2619                            r.order_status,
2620                        );
2621                        false
2622                    }
2623                    _ => true,
2624                }
2625            });
2626        }
2627
2628        Ok(reports)
2629    }
2630
2631    async fn generate_fill_reports(
2632        &self,
2633        cmd: GenerateFillReports,
2634    ) -> anyhow::Result<Vec<FillReport>> {
2635        let response = self
2636            .http_client
2637            .inner
2638            .get_fills(
2639                &self.wallet_address,
2640                self.subaccount_number,
2641                None, // market filter
2642                Some(DYDX_INDEXER_REPORT_LIMIT),
2643            )
2644            .await
2645            .context("failed to fetch fills from dYdX API")?;
2646
2647        let mut reports = Vec::new();
2648        let ts_init = UnixNanos::default();
2649
2650        for fill in response.fills {
2651            let instrument = match self.get_instrument_by_market(&fill.market) {
2652                Some(inst) => inst,
2653                None => {
2654                    log::warn!("Unknown market in fill: {}", fill.market);
2655                    continue;
2656                }
2657            };
2658
2659            if let Some(filter_id) = cmd.instrument_id
2660                && instrument.id() != filter_id
2661            {
2662                continue;
2663            }
2664
2665            let report = match parse_fill_report(&fill, &instrument, self.core.account_id, ts_init)
2666            {
2667                Ok(r) => r,
2668                Err(e) => {
2669                    log::warn!("Failed to parse fill report: {e}");
2670                    continue;
2671                }
2672            };
2673
2674            reports.push(report);
2675        }
2676
2677        if let Some(venue_order_id) = cmd.venue_order_id {
2678            reports.retain(|r| r.venue_order_id.as_str() == venue_order_id.as_str());
2679        }
2680
2681        Ok(reports)
2682    }
2683
2684    async fn generate_position_status_reports(
2685        &self,
2686        cmd: &GeneratePositionStatusReports,
2687    ) -> anyhow::Result<Vec<PositionStatusReport>> {
2688        let response = self
2689            .http_client
2690            .inner
2691            .get_subaccount(&self.wallet_address, self.subaccount_number)
2692            .await
2693            .context("failed to fetch subaccount from dYdX API")?;
2694
2695        let mut reports = Vec::new();
2696        let ts_init = UnixNanos::default();
2697
2698        for (market_ticker, perp_position) in &response.subaccount.open_perpetual_positions {
2699            let instrument = match self.get_instrument_by_market(market_ticker) {
2700                Some(inst) => inst,
2701                None => {
2702                    log::warn!("Unknown market in position: {market_ticker}");
2703                    continue;
2704                }
2705            };
2706
2707            if let Some(filter_id) = cmd.instrument_id
2708                && instrument.id() != filter_id
2709            {
2710                continue;
2711            }
2712
2713            let report = match parse_position_status_report(
2714                perp_position,
2715                &instrument,
2716                self.core.account_id,
2717                ts_init,
2718            ) {
2719                Ok(r) => r,
2720                Err(e) => {
2721                    log::warn!("Failed to parse position status report: {e}");
2722                    continue;
2723                }
2724            };
2725
2726            reports.push(report);
2727        }
2728
2729        Ok(reports)
2730    }
2731
2732    async fn generate_mass_status(
2733        &self,
2734        lookback_mins: Option<u64>,
2735    ) -> anyhow::Result<Option<ExecutionMassStatus>> {
2736        let ts_init = self.clock.get_time_ns();
2737
2738        let orders_response = self
2739            .http_client
2740            .inner
2741            .get_orders(
2742                &self.wallet_address,
2743                self.subaccount_number,
2744                None,
2745                Some(DYDX_INDEXER_REPORT_LIMIT),
2746            )
2747            .await
2748            .context("failed to fetch orders for mass status")?;
2749
2750        let subaccount_response = self
2751            .http_client
2752            .inner
2753            .get_subaccount(&self.wallet_address, self.subaccount_number)
2754            .await
2755            .context("failed to fetch subaccount for mass status")?;
2756
2757        let fills_response = self
2758            .http_client
2759            .inner
2760            .get_fills(
2761                &self.wallet_address,
2762                self.subaccount_number,
2763                None,
2764                Some(DYDX_INDEXER_REPORT_LIMIT),
2765            )
2766            .await
2767            .context("failed to fetch fills for mass status")?;
2768
2769        let mut order_reports = Vec::new();
2770        let mut orders_filtered = 0usize;
2771
2772        for order in orders_response {
2773            let instrument = match self.get_instrument_by_clob_pair_id(order.clob_pair_id) {
2774                Some(inst) => inst,
2775                None => {
2776                    orders_filtered += 1;
2777                    continue;
2778                }
2779            };
2780
2781            match parse_order_status_report(&order, &instrument, self.core.account_id, ts_init) {
2782                Ok(mut r) => {
2783                    if !order.client_id.is_empty()
2784                        && let Ok(client_id_u32) = order.client_id.parse::<u32>()
2785                    {
2786                        self.encoder.register_known_client_id(client_id_u32);
2787
2788                        if let Some(decoded) = self
2789                            .encoder
2790                            .decode_if_known(client_id_u32, order.client_metadata)
2791                        {
2792                            log::debug!(
2793                                "Decoded reconciliation order: dYdX client_id={} meta={:#x} -> '{}'",
2794                                client_id_u32,
2795                                order.client_metadata,
2796                                decoded,
2797                            );
2798                            r.client_order_id = Some(decoded);
2799                        }
2800                    }
2801                    order_reports.push(r);
2802                }
2803                Err(e) => {
2804                    log::warn!("Failed to parse order status report: {e}");
2805                    orders_filtered += 1;
2806                }
2807            }
2808        }
2809
2810        let mut position_reports = Vec::new();
2811
2812        for (market_ticker, perp_position) in
2813            &subaccount_response.subaccount.open_perpetual_positions
2814        {
2815            let instrument = match self.get_instrument_by_market(market_ticker) {
2816                Some(inst) => inst,
2817                None => continue,
2818            };
2819
2820            match parse_position_status_report(
2821                perp_position,
2822                &instrument,
2823                self.core.account_id,
2824                ts_init,
2825            ) {
2826                Ok(r) => position_reports.push(r),
2827                Err(e) => {
2828                    log::warn!("Failed to parse position status report: {e}");
2829                }
2830            }
2831        }
2832
2833        let mut fill_reports = Vec::new();
2834        let mut fills_filtered = 0usize;
2835
2836        for fill in fills_response.fills {
2837            let instrument = match self.get_instrument_by_market(&fill.market) {
2838                Some(inst) => inst,
2839                None => {
2840                    fills_filtered += 1;
2841                    continue;
2842                }
2843            };
2844
2845            match parse_fill_report(&fill, &instrument, self.core.account_id, ts_init) {
2846                Ok(r) => fill_reports.push(r),
2847                Err(e) => {
2848                    log::warn!("Failed to parse fill report: {e}");
2849                    fills_filtered += 1;
2850                }
2851            }
2852        }
2853
2854        apply_avg_px_from_fills(&mut order_reports, &fill_reports);
2855
2856        // Drop reports that conflict with the local cache: same rationale as in
2857        // `generate_order_status_reports`. On a cold start the cache is empty so
2858        // this is a no-op; on a hot reconciliation pass it prevents replaying
2859        // `Accepted` events for orders the engine already considers terminal.
2860        {
2861            let cache = self.core.cache();
2862            order_reports.retain(|r| {
2863                let Some(cid) = r.client_order_id else {
2864                    return true;
2865                };
2866
2867                match cache.order(&cid) {
2868                    Some(order) if order.status().is_closed() => {
2869                        log::debug!(
2870                            "Skipping reconciliation report for terminal order {cid} \
2871                             (cache status={:?}, venue status={:?})",
2872                            order.status(),
2873                            r.order_status,
2874                        );
2875                        false
2876                    }
2877                    _ => true,
2878                }
2879            });
2880        }
2881
2882        if let Some(mins) = lookback_mins {
2883            let now_ns = self.clock.get_time_ns();
2884            let cutoff = now_ns.saturating_sub(DurationNanos::try_from_mins(mins)?);
2885
2886            let orders_before = order_reports.len();
2887            order_reports.retain(|r| r.ts_last >= cutoff);
2888            let orders_removed = orders_before - order_reports.len();
2889
2890            let fills_before = fill_reports.len();
2891            fill_reports.retain(|r| r.ts_event >= cutoff);
2892            let fills_removed = fills_before - fill_reports.len();
2893
2894            log::debug!(
2895                "Lookback filter ({}min): orders {}->{} (removed {}), fills {}->{} (removed {}), positions {} (unfiltered)",
2896                mins,
2897                orders_before,
2898                order_reports.len(),
2899                orders_removed,
2900                fills_before,
2901                fill_reports.len(),
2902                fills_removed,
2903                position_reports.len(),
2904            );
2905        } else {
2906            log::debug!(
2907                "Generated mass status: {} orders ({} filtered), {} positions, {} fills ({} filtered)",
2908                order_reports.len(),
2909                orders_filtered,
2910                position_reports.len(),
2911                fill_reports.len(),
2912                fills_filtered,
2913            );
2914        }
2915
2916        let mut mass_status = ExecutionMassStatus::new(
2917            self.core.client_id,
2918            self.core.account_id,
2919            self.core.venue,
2920            ts_init,
2921            None, // report_id will be auto-generated
2922        );
2923
2924        mass_status.add_order_reports(order_reports);
2925        mass_status.add_position_reports(position_reports);
2926        mass_status.add_fill_reports(fill_reports);
2927
2928        Ok(Some(mass_status))
2929    }
2930}
2931
2932type CancelAllOrderData = (
2933    StrategyId,
2934    ClientOrderId,
2935    Option<VenueOrderId>,
2936    TimeInForce,
2937    Option<UnixNanos>,
2938);
2939
2940#[derive(Clone, Copy)]
2941struct DydxCancelOrderRequest {
2942    instrument_id: InstrumentId,
2943    client_id: u32,
2944    order_flags: u32,
2945    strategy_id: StrategyId,
2946    client_order_id: ClientOrderId,
2947    venue_order_id: Option<VenueOrderId>,
2948}
2949
2950fn apply_avg_px_from_fills(order_reports: &mut [OrderStatusReport], fill_reports: &[FillReport]) {
2951    let mut totals: AHashMap<VenueOrderId, (Decimal, Decimal)> = AHashMap::new();
2952    for fill in fill_reports {
2953        let entry = totals.entry(fill.venue_order_id).or_default();
2954        let qty = fill.last_qty.as_decimal();
2955        entry.0 += fill.last_px.as_decimal() * qty;
2956        entry.1 += qty;
2957    }
2958
2959    for report in order_reports {
2960        if let Some((notional, total_qty)) = totals.get(&report.venue_order_id)
2961            && !total_qty.is_zero()
2962        {
2963            report.avg_px = Some(notional / total_qty);
2964        }
2965    }
2966}
2967
2968struct PendingTaskLabel {
2969    id: u64,
2970    labels: Arc<Mutex<AHashMap<u64, &'static str>>>,
2971}
2972
2973impl Drop for PendingTaskLabel {
2974    fn drop(&mut self) {
2975        self.labels.lock().remove(&self.id);
2976    }
2977}
2978
2979#[allow(clippy::too_many_arguments)]
2980fn find_matching_order_report<F>(
2981    orders: &[crate::http::models::Order],
2982    instrument_filter: Option<InstrumentId>,
2983    client_order_id_filter: Option<ClientOrderId>,
2984    venue_order_id_filter: Option<VenueOrderId>,
2985    lookup_instrument: F,
2986    encoder: &ClientOrderIdEncoder,
2987    account_id: AccountId,
2988    ts_init: UnixNanos,
2989) -> anyhow::Result<Option<OrderStatusReport>>
2990where
2991    F: Fn(u32) -> Option<InstrumentAny>,
2992{
2993    for order in orders {
2994        let instrument = match lookup_instrument(order.clob_pair_id) {
2995            Some(inst) => inst,
2996            None => continue,
2997        };
2998
2999        if let Some(filter_id) = instrument_filter
3000            && instrument.id() != filter_id
3001        {
3002            continue;
3003        }
3004
3005        let mut report = parse_order_status_report(order, &instrument, account_id, ts_init)
3006            .context("failed to parse order status report")?;
3007
3008        if !order.client_id.is_empty()
3009            && let Ok(client_id_u32) = order.client_id.parse::<u32>()
3010        {
3011            encoder.register_known_client_id(client_id_u32);
3012
3013            if let Some(decoded) = encoder.decode_if_known(client_id_u32, order.client_metadata) {
3014                log::debug!(
3015                    "Decoded order: dYdX client_id={} meta={:#x} -> '{}'",
3016                    client_id_u32,
3017                    order.client_metadata,
3018                    decoded,
3019                );
3020                report.client_order_id = Some(decoded);
3021            }
3022        }
3023
3024        if let Some(client_order_id) = client_order_id_filter
3025            && report.client_order_id != Some(client_order_id)
3026        {
3027            continue;
3028        }
3029
3030        if let Some(venue_order_id) = venue_order_id_filter
3031            && report.venue_order_id.as_str() != venue_order_id.as_str()
3032        {
3033            continue;
3034        }
3035
3036        return Ok(Some(report));
3037    }
3038
3039    Ok(None)
3040}
3041
3042#[cfg(test)]
3043mod tests {
3044    use std::{cell::RefCell, rc::Rc};
3045
3046    use axum::{Json, Router, http::Uri, routing::get};
3047    use jiff::Timestamp;
3048    use nautilus_common::{
3049        cache::Cache, clock::VirtualClock, factories::OrderFactory, messages::ExecutionEvent,
3050    };
3051    use nautilus_model::{
3052        enums::OrderSide,
3053        identifiers::{Symbol, TraderId},
3054        instruments::{CryptoPerpetual, InstrumentAny},
3055        orders::{Order as _, OrderAny},
3056        types::{Currency, Price, Quantity},
3057    };
3058    use rstest::rstest;
3059    use rust_decimal_macros::dec;
3060    use serde_json::Value;
3061
3062    use super::*;
3063    use crate::{
3064        common::{
3065            consts::DYDX_CLIENT_ID,
3066            enums::{DydxOrderStatus, DydxOrderType, DydxTimeInForce},
3067        },
3068        http::models::Order,
3069    };
3070
3071    fn test_instrument(symbol: &str, venue: &str) -> InstrumentAny {
3072        let instrument_id = InstrumentId::new(Symbol::new(symbol), Venue::new(venue));
3073        InstrumentAny::CryptoPerpetual(
3074            CryptoPerpetual::builder()
3075                .instrument_id(instrument_id)
3076                .raw_symbol(instrument_id.symbol)
3077                .base_currency(Currency::BTC())
3078                .quote_currency(Currency::USD())
3079                .settlement_currency(Currency::USD())
3080                .is_inverse(false)
3081                .price_precision(2)
3082                .size_precision(3)
3083                .price_increment(Price::new(0.01, 2))
3084                .size_increment(Quantity::new(0.001, 3))
3085                .ts_event(UnixNanos::default())
3086                .ts_init(UnixNanos::default())
3087                .build()
3088                .unwrap(),
3089        )
3090    }
3091
3092    fn test_order(id: &str, clob_pair_id: u32, client_id: &str) -> Order {
3093        Order {
3094            id: id.to_string(),
3095            subaccount_id: "sub-1".to_string(),
3096            client_id: client_id.to_string(),
3097            clob_pair_id,
3098            side: OrderSide::Buy,
3099            size: dec!(1.0),
3100            total_filled: dec!(0),
3101            price: dec!(50000),
3102            status: DydxOrderStatus::Open,
3103            order_type: DydxOrderType::Limit,
3104            time_in_force: DydxTimeInForce::Gtt,
3105            reduce_only: false,
3106            post_only: false,
3107            order_flags: 64,
3108            good_til_block: None,
3109            good_til_block_time: None,
3110            created_at_height: Some(100),
3111            client_metadata: 4,
3112            trigger_price: None,
3113            condition_type: None,
3114            conditional_order_trigger_subticks: None,
3115            execution: None,
3116            updated_at: None,
3117            updated_at_height: None,
3118            ticker: None,
3119            subaccount_number: 0,
3120            order_router_address: None,
3121        }
3122    }
3123
3124    fn test_order_factory() -> OrderFactory {
3125        let clock = Rc::new(RefCell::new(VirtualClock::new()));
3126        OrderFactory::new(
3127            TraderId::from("TRADER-001"),
3128            StrategyId::from("S-001"),
3129            Some(0),
3130            Some(0),
3131            clock,
3132            false,
3133            false,
3134        )
3135    }
3136
3137    fn create_execution_client() -> (
3138        DydxExecutionClient,
3139        Rc<RefCell<Cache>>,
3140        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
3141    ) {
3142        const TEST_PRIVATE_KEY: &str =
3143            "0000000000000000000000000000000000000000000000000000000000000001";
3144
3145        let cache = Rc::new(RefCell::new(Cache::default()));
3146        let core = ExecutionClientCore::new(
3147            TraderId::from("TRADER-001"),
3148            *DYDX_CLIENT_ID,
3149            *DYDX_VENUE,
3150            OmsType::Netting,
3151            AccountId::from("DYDX-001"),
3152            AccountType::Margin,
3153            None,
3154            cache.clone(),
3155        );
3156        core.set_connected();
3157
3158        let config = DydxAdapterConfig {
3159            base_url: "http://127.0.0.1:1".to_string(),
3160            ws_url: "ws://127.0.0.1:1".to_string(),
3161            grpc_url: "http://127.0.0.1:1".to_string(),
3162            grpc_urls: vec!["http://127.0.0.1:1".to_string()],
3163            wallet_address: Some("dydx1test".to_string()),
3164            private_key: Some(TEST_PRIVATE_KEY.into()),
3165            ..Default::default()
3166        };
3167
3168        let mut client =
3169            DydxExecutionClient::new(core, config, "dydx1test".to_string(), 0).unwrap();
3170        client
3171            .block_time_monitor
3172            .record_block(100, Timestamp::now());
3173
3174        let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<ExecutionEvent>();
3175        client.emitter.set_sender(sender);
3176
3177        (client, cache, receiver)
3178    }
3179
3180    #[rstest]
3181    #[tokio::test]
3182    async fn test_mass_status_captures_collection_start() {
3183        let clock: &'static AtomicTime =
3184            Box::leak(Box::new(AtomicTime::new(false, UnixNanos::from(100))));
3185
3186        let router = Router::new().fallback(get(move |uri: Uri| async move {
3187            clock.set_time(UnixNanos::from(200));
3188
3189            let fixture = if uri.path() == "/v4/orders" {
3190                include_str!("../../test_data/http_get_orders.json")
3191            } else if uri.path() == "/v4/fills" {
3192                include_str!("../../test_data/http_get_fills.json")
3193            } else {
3194                include_str!("../../test_data/http_get_subaccount.json")
3195            };
3196
3197            let value: Value = serde_json::from_str(fixture).unwrap();
3198            Json(value["result"].clone())
3199        }));
3200
3201        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3202        let addr = listener.local_addr().unwrap();
3203
3204        let server = tokio::spawn(async move {
3205            axum::serve(listener, router).await.unwrap();
3206        });
3207
3208        let (mut client, _cache, _rx) = create_execution_client();
3209        client.clock = clock;
3210        client.http_client = DydxHttpClient::new(
3211            Some(format!("http://{addr}")),
3212            5,
3213            None,
3214            client.config.network,
3215            None,
3216        )
3217        .unwrap();
3218
3219        let snapshot = client.generate_mass_status(None).await.unwrap().unwrap();
3220        server.abort();
3221
3222        assert_eq!(snapshot.ts_init, UnixNanos::from(100));
3223        assert_eq!(clock.get_time_ns(), UnixNanos::from(200));
3224    }
3225
3226    fn cache_order(cache: &Rc<RefCell<Cache>>, order: OrderAny) {
3227        cache
3228            .borrow_mut()
3229            .add_order(order, None, Some(*DYDX_CLIENT_ID), false)
3230            .unwrap();
3231    }
3232
3233    fn recv_order_event(
3234        rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
3235    ) -> OrderEventAny {
3236        match rx.try_recv().expect("expected execution event") {
3237            ExecutionEvent::Order(event) => event,
3238            event => panic!("Expected order event, was {event:?}"),
3239        }
3240    }
3241
3242    #[rstest]
3243    fn test_submit_order_denies_quote_quantity() {
3244        let (client, cache, mut rx) = create_execution_client();
3245        let mut factory = test_order_factory();
3246        let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
3247        let order = factory.market(
3248            instrument_id,
3249            OrderSide::Buy,
3250            Quantity::from("100"),
3251            Some(TimeInForce::Ioc),
3252            None,
3253            Some(true),
3254            None,
3255            None,
3256            None,
3257            Some(ClientOrderId::from("O-QUOTE-QTY")),
3258        );
3259        cache_order(&cache, order.clone());
3260
3261        let command = SubmitOrder::from_order(
3262            &order,
3263            order.trader_id(),
3264            Some(*DYDX_CLIENT_ID),
3265            None,
3266            UUID4::new(),
3267            UnixNanos::default(),
3268        );
3269        client.submit_order(command).unwrap();
3270
3271        match recv_order_event(&mut rx) {
3272            OrderEventAny::Denied(event) => {
3273                assert_eq!(event.client_order_id, order.client_order_id());
3274                assert_eq!(event.instrument_id, instrument_id);
3275                assert_eq!(
3276                    event.reason,
3277                    "Quote quantity orders are not supported by dYdX"
3278                );
3279            }
3280            event => panic!("Expected order denied event, was {event:?}"),
3281        }
3282    }
3283
3284    #[rstest]
3285    fn test_submit_order_denies_market_fok() {
3286        let (client, cache, mut rx) = create_execution_client();
3287        let mut factory = test_order_factory();
3288        let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
3289        let order = factory.market(
3290            instrument_id,
3291            OrderSide::Buy,
3292            Quantity::from("0.1"),
3293            Some(TimeInForce::Fok),
3294            None,
3295            Some(false),
3296            None,
3297            None,
3298            None,
3299            Some(ClientOrderId::from("O-MARKET-FOK")),
3300        );
3301        cache_order(&cache, order.clone());
3302
3303        let command = SubmitOrder::from_order(
3304            &order,
3305            order.trader_id(),
3306            Some(*DYDX_CLIENT_ID),
3307            None,
3308            UUID4::new(),
3309            UnixNanos::default(),
3310        );
3311        client.submit_order(command).unwrap();
3312
3313        match recv_order_event(&mut rx) {
3314            OrderEventAny::Denied(event) => {
3315                assert_eq!(event.client_order_id, order.client_order_id());
3316                assert_eq!(event.instrument_id, instrument_id);
3317                assert!(
3318                    event.reason.contains("Fill-or-kill") && event.reason.contains("deprecated"),
3319                    "expected FOK-deprecation reason, was: {}",
3320                    event.reason,
3321                );
3322            }
3323            event => panic!("Expected order denied event, was {event:?}"),
3324        }
3325    }
3326
3327    #[rstest]
3328    fn test_submit_order_denies_limit_fok() {
3329        // dYdX v4 deprecated FOK at the protocol level (chain returns code=48).
3330        // The adapter must deny FOK pre-submission so the strategy gets an
3331        // immediate `OrderDenied` instead of a venue-side broadcast failure.
3332        let (client, cache, mut rx) = create_execution_client();
3333        let mut factory = test_order_factory();
3334        let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
3335        let order = factory.limit(
3336            instrument_id,
3337            OrderSide::Buy,
3338            Quantity::from("0.1"),
3339            Price::from("50000.0"),
3340            Some(TimeInForce::Fok),
3341            None,
3342            Some(false),
3343            None,
3344            Some(false),
3345            None,
3346            None,
3347            None,
3348            None,
3349            None,
3350            None,
3351            Some(ClientOrderId::from("O-LIMIT-FOK")),
3352        );
3353        cache_order(&cache, order.clone());
3354
3355        let command = SubmitOrder::from_order(
3356            &order,
3357            order.trader_id(),
3358            Some(*DYDX_CLIENT_ID),
3359            None,
3360            UUID4::new(),
3361            UnixNanos::default(),
3362        );
3363        client.submit_order(command).unwrap();
3364
3365        match recv_order_event(&mut rx) {
3366            OrderEventAny::Denied(event) => {
3367                assert_eq!(event.client_order_id, order.client_order_id());
3368                assert!(
3369                    event.reason.contains("Fill-or-kill") && event.reason.contains("deprecated"),
3370                    "expected FOK-deprecation reason, was: {}",
3371                    event.reason,
3372                );
3373            }
3374            event => panic!("Expected order denied event, was {event:?}"),
3375        }
3376    }
3377
3378    #[rstest]
3379    fn test_submit_order_denies_day_tif() {
3380        // DAY TIF is not supported by dYdX v4; the adapter must deny pre-submission
3381        // rather than silently translate to GTC.
3382        let (client, cache, mut rx) = create_execution_client();
3383        let mut factory = test_order_factory();
3384        let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
3385        let order = factory.limit(
3386            instrument_id,
3387            OrderSide::Buy,
3388            Quantity::from("0.1"),
3389            Price::from("50000.0"),
3390            Some(TimeInForce::Day),
3391            None,
3392            Some(false),
3393            None,
3394            Some(false),
3395            None,
3396            None,
3397            None,
3398            None,
3399            None,
3400            None,
3401            Some(ClientOrderId::from("O-LIMIT-DAY")),
3402        );
3403        cache_order(&cache, order.clone());
3404
3405        let command = SubmitOrder::from_order(
3406            &order,
3407            order.trader_id(),
3408            Some(*DYDX_CLIENT_ID),
3409            None,
3410            UUID4::new(),
3411            UnixNanos::default(),
3412        );
3413        client.submit_order(command).unwrap();
3414
3415        match recv_order_event(&mut rx) {
3416            OrderEventAny::Denied(event) => {
3417                assert_eq!(event.client_order_id, order.client_order_id());
3418                assert!(
3419                    event.reason.contains("DAY"),
3420                    "expected DAY-unsupported reason, was: {}",
3421                    event.reason,
3422                );
3423            }
3424            event => panic!("Expected order denied event, was {event:?}"),
3425        }
3426    }
3427
3428    // `submit_order_list` mirrors the single-submit TIF gate; an order in the
3429    // list with FOK or DAY must be denied pre-submission rather than
3430    // forwarded to the gRPC builder.
3431    #[rstest]
3432    #[case(TimeInForce::Fok, "Fill-or-kill")]
3433    #[case(TimeInForce::Day, "DAY")]
3434    fn test_submit_order_list_denies_unsupported_tif(
3435        #[case] tif: TimeInForce,
3436        #[case] reason_substring: &str,
3437    ) {
3438        use nautilus_common::messages::execution::SubmitOrderList;
3439        use nautilus_model::{identifiers::OrderListId, orders::OrderList};
3440
3441        let (client, cache, mut rx) = create_execution_client();
3442        let mut factory = test_order_factory();
3443        let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
3444        let order = factory.limit(
3445            instrument_id,
3446            OrderSide::Buy,
3447            Quantity::from("0.1"),
3448            Price::from("50000.0"),
3449            Some(tif),
3450            None,
3451            Some(false),
3452            None,
3453            Some(false),
3454            None,
3455            None,
3456            None,
3457            None,
3458            None,
3459            None,
3460            Some(ClientOrderId::from("O-LIST-1")),
3461        );
3462        cache_order(&cache, order.clone());
3463
3464        let order_list = OrderList::new(
3465            OrderListId::from("OL-1"),
3466            instrument_id,
3467            order.strategy_id(),
3468            vec![order.client_order_id()],
3469            UnixNanos::default(),
3470        );
3471        let init = order.init_event().clone();
3472
3473        let cmd = SubmitOrderList::new(
3474            order.trader_id(),
3475            Some(*DYDX_CLIENT_ID),
3476            order.strategy_id(),
3477            order_list,
3478            vec![init],
3479            None,
3480            None,
3481            None,
3482            UUID4::new(),
3483            UnixNanos::default(),
3484            None, // correlation_id
3485        );
3486        client.submit_order_list(cmd).unwrap();
3487
3488        match recv_order_event(&mut rx) {
3489            OrderEventAny::Denied(event) => {
3490                assert_eq!(event.client_order_id, order.client_order_id());
3491                assert!(
3492                    event.reason.contains(reason_substring),
3493                    "expected reason containing {reason_substring:?}, was: {}",
3494                    event.reason,
3495                );
3496            }
3497            event => panic!("Expected order denied event, was {event:?}"),
3498        }
3499    }
3500
3501    // A mixed list must not leave supported sibling orders unresolved: when
3502    // the list is aborted for an unsupported TIF, every order is denied.
3503    #[rstest]
3504    fn test_submit_order_list_denies_supported_siblings_on_abort() {
3505        use nautilus_common::messages::execution::SubmitOrderList;
3506        use nautilus_model::{identifiers::OrderListId, orders::OrderList};
3507
3508        let (client, cache, mut rx) = create_execution_client();
3509        let mut factory = test_order_factory();
3510        let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
3511        let order_gtc = factory.limit(
3512            instrument_id,
3513            OrderSide::Buy,
3514            Quantity::from("0.1"),
3515            Price::from("50000.0"),
3516            Some(TimeInForce::Gtc),
3517            None,
3518            Some(false),
3519            None,
3520            Some(false),
3521            None,
3522            None,
3523            None,
3524            None,
3525            None,
3526            None,
3527            Some(ClientOrderId::from("O-MIXED-GTC")),
3528        );
3529        let order_fok = factory.limit(
3530            instrument_id,
3531            OrderSide::Sell,
3532            Quantity::from("0.1"),
3533            Price::from("51000.0"),
3534            Some(TimeInForce::Fok),
3535            None,
3536            Some(false),
3537            None,
3538            Some(false),
3539            None,
3540            None,
3541            None,
3542            None,
3543            None,
3544            None,
3545            Some(ClientOrderId::from("O-MIXED-FOK")),
3546        );
3547        cache_order(&cache, order_gtc.clone());
3548        cache_order(&cache, order_fok.clone());
3549
3550        let order_list = OrderList::new(
3551            OrderListId::from("OL-MIXED"),
3552            instrument_id,
3553            order_gtc.strategy_id(),
3554            vec![order_gtc.client_order_id(), order_fok.client_order_id()],
3555            UnixNanos::default(),
3556        );
3557
3558        let cmd = SubmitOrderList::new(
3559            order_gtc.trader_id(),
3560            Some(*DYDX_CLIENT_ID),
3561            order_gtc.strategy_id(),
3562            order_list,
3563            vec![
3564                order_gtc.init_event().clone(),
3565                order_fok.init_event().clone(),
3566            ],
3567            None,
3568            None,
3569            None,
3570            UUID4::new(),
3571            UnixNanos::default(),
3572            None, // correlation_id
3573        );
3574        client.submit_order_list(cmd).unwrap();
3575
3576        let mut denied_reasons = std::collections::HashMap::new();
3577
3578        for _ in 0..2 {
3579            match recv_order_event(&mut rx) {
3580                OrderEventAny::Denied(event) => {
3581                    denied_reasons.insert(event.client_order_id, event.reason);
3582                }
3583                event => panic!("Expected order denied event, was {event:?}"),
3584            }
3585        }
3586
3587        assert!(
3588            denied_reasons[&order_gtc.client_order_id()].contains("sibling order"),
3589            "GTC sibling should be denied with list-abort reason",
3590        );
3591        assert!(
3592            denied_reasons[&order_fok.client_order_id()].contains("Fill-or-kill"),
3593            "FOK order should be denied with TIF reason",
3594        );
3595    }
3596
3597    // Shutdown should retain cancel-related labels until the registered task
3598    // completes or its join is observed after forced abort.
3599    #[tokio::test]
3600    async fn test_pending_task_labels_drain_with_scope() {
3601        let (client, _cache, _rx) = create_execution_client();
3602
3603        client.spawn_labeled("cancel_all_orders", async {
3604            tokio::time::sleep(std::time::Duration::from_secs(60)).await;
3605        });
3606        client.spawn_labeled("submit_order", async {
3607            tokio::time::sleep(std::time::Duration::from_secs(60)).await;
3608        });
3609
3610        {
3611            let labels = client.pending_task_labels.lock();
3612            assert_eq!(labels.len(), 2);
3613        }
3614
3615        client.begin_pending_shutdown();
3616        client
3617            .pending_tasks
3618            .finish_shutdown(Duration::ZERO, Duration::from_secs(1))
3619            .await
3620            .unwrap();
3621
3622        assert!(client.pending_tasks.is_empty());
3623        assert!(client.pending_task_labels.lock().is_empty());
3624    }
3625
3626    // The label substring used by `begin_pending_shutdown` to recognize cancel
3627    // tasks must match the labels actually used at spawn sites. Pin those
3628    // sites here so a rename in only one place is caught.
3629    #[rstest]
3630    #[case("cancel_all_orders", true)]
3631    #[case("batch_cancel_orders", true)]
3632    #[case("Submit order", false)]
3633    #[case("batch_submit_short_term", false)]
3634    #[case("batch_submit_long_term", false)]
3635    fn test_pending_task_label_classification(
3636        #[case] label: &'static str,
3637        #[case] is_cancel: bool,
3638    ) {
3639        // The classification branch is `label.contains("cancel")` (lowercase).
3640        // This test locks the substring so an accidental rename of a labeled
3641        // spawn site breaks the assertion at compile time.
3642        assert_eq!(label.contains("cancel"), is_cancel);
3643    }
3644
3645    #[rstest]
3646    fn test_emit_partitioned_cancel_rejections_emits_each_cancel() {
3647        let clock = get_atomic_clock_realtime();
3648        let mut emitter = ExecutionEventEmitter::new(
3649            clock,
3650            TraderId::from("TRADER-001"),
3651            AccountId::from("DYDX-001"),
3652            AccountType::Margin,
3653            None,
3654        );
3655        let (sender, mut rx) = tokio::sync::mpsc::unbounded_channel::<ExecutionEvent>();
3656        emitter.set_sender(sender);
3657
3658        let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
3659        let orders = vec![
3660            DydxCancelOrderRequest {
3661                instrument_id,
3662                client_id: 1,
3663                order_flags: types::ORDER_FLAG_SHORT_TERM,
3664                strategy_id: StrategyId::from("S-001"),
3665                client_order_id: ClientOrderId::from("O-CANCEL-1"),
3666                venue_order_id: Some(VenueOrderId::from("V-1")),
3667            },
3668            DydxCancelOrderRequest {
3669                instrument_id,
3670                client_id: 2,
3671                order_flags: types::ORDER_FLAG_LONG_TERM,
3672                strategy_id: StrategyId::from("S-001"),
3673                client_order_id: ClientOrderId::from("O-CANCEL-2"),
3674                venue_order_id: Some(VenueOrderId::from("V-2")),
3675            },
3676        ];
3677
3678        emit_partitioned_cancel_rejections(&orders, &emitter, clock, "broadcast failed");
3679
3680        for order in &orders {
3681            match recv_order_event(&mut rx) {
3682                OrderEventAny::CancelRejected(event) => {
3683                    assert_eq!(event.client_order_id, order.client_order_id);
3684                    assert_eq!(event.instrument_id, order.instrument_id);
3685                    assert_eq!(event.venue_order_id, order.venue_order_id);
3686                    assert_eq!(event.reason, "broadcast failed");
3687                }
3688                event => panic!("Expected order cancel rejected event, was {event:?}"),
3689            }
3690        }
3691        assert!(rx.try_recv().is_err());
3692    }
3693
3694    #[rstest]
3695    fn test_find_matching_order_report_returns_later_match() {
3696        // Regression guard: earlier implementation fetched limit=1 and returned None
3697        // when the first order didn't match the filter. The fixed iteration logic must
3698        // scan the whole response and return the matching entry.
3699        let btc_inst = test_instrument("BTC-USD-PERP", "DYDX");
3700        let eth_inst = test_instrument("ETH-USD-PERP", "DYDX");
3701
3702        let orders = vec![
3703            test_order("order-eth", 1, "22222"),
3704            test_order("order-btc", 0, "11111"),
3705        ];
3706
3707        let encoder = ClientOrderIdEncoder::new();
3708        let report = find_matching_order_report(
3709            &orders,
3710            Some(btc_inst.id()),
3711            None,
3712            None,
3713            |clob_pair_id| match clob_pair_id {
3714                0 => Some(btc_inst.clone()),
3715                1 => Some(eth_inst.clone()),
3716                _ => None,
3717            },
3718            &encoder,
3719            AccountId::new("DYDX-001"),
3720            UnixNanos::default(),
3721        )
3722        .expect("lookup should succeed");
3723
3724        let report = report.expect("matching order should be found");
3725        assert_eq!(report.instrument_id, btc_inst.id());
3726        assert_eq!(report.venue_order_id.as_str(), "order-btc");
3727    }
3728
3729    #[rstest]
3730    fn test_find_matching_order_report_returns_none_when_no_match() {
3731        let btc_inst = test_instrument("BTC-USD-PERP", "DYDX");
3732        let eth_inst = test_instrument("ETH-USD-PERP", "DYDX");
3733
3734        let orders = vec![
3735            test_order("order-eth-1", 1, "22222"),
3736            test_order("order-eth-2", 1, "33333"),
3737        ];
3738
3739        let encoder = ClientOrderIdEncoder::new();
3740        let report = find_matching_order_report(
3741            &orders,
3742            Some(btc_inst.id()),
3743            None,
3744            None,
3745            |clob_pair_id| match clob_pair_id {
3746                0 => Some(btc_inst.clone()),
3747                1 => Some(eth_inst.clone()),
3748                _ => None,
3749            },
3750            &encoder,
3751            AccountId::new("DYDX-001"),
3752            UnixNanos::default(),
3753        )
3754        .expect("lookup should succeed");
3755
3756        assert!(report.is_none());
3757    }
3758
3759    #[rstest]
3760    fn test_find_matching_order_report_filters_by_venue_order_id() {
3761        let btc_inst = test_instrument("BTC-USD-PERP", "DYDX");
3762
3763        let orders = vec![
3764            test_order("order-a", 0, "11111"),
3765            test_order("order-b", 0, "22222"),
3766            test_order("order-c", 0, "33333"),
3767        ];
3768
3769        let encoder = ClientOrderIdEncoder::new();
3770        let target = VenueOrderId::new("order-b");
3771        let report = find_matching_order_report(
3772            &orders,
3773            None,
3774            None,
3775            Some(target),
3776            |_| Some(btc_inst.clone()),
3777            &encoder,
3778            AccountId::new("DYDX-001"),
3779            UnixNanos::default(),
3780        )
3781        .expect("lookup should succeed")
3782        .expect("matching order should be found");
3783
3784        assert_eq!(report.venue_order_id.as_str(), "order-b");
3785    }
3786
3787    #[rstest]
3788    fn test_find_matching_order_report_skips_orders_without_cached_instrument() {
3789        let btc_inst = test_instrument("BTC-USD-PERP", "DYDX");
3790
3791        let orders = vec![
3792            test_order("order-unknown", 99, "11111"),
3793            test_order("order-btc", 0, "22222"),
3794        ];
3795
3796        let encoder = ClientOrderIdEncoder::new();
3797        let report = find_matching_order_report(
3798            &orders,
3799            Some(btc_inst.id()),
3800            None,
3801            None,
3802            |clob_pair_id| (clob_pair_id == 0).then(|| btc_inst.clone()),
3803            &encoder,
3804            AccountId::new("DYDX-001"),
3805            UnixNanos::default(),
3806        )
3807        .expect("lookup should succeed")
3808        .expect("matching order should be found");
3809
3810        assert_eq!(report.venue_order_id.as_str(), "order-btc");
3811    }
3812}