Skip to main content

nautilus_dydx/execution/
submitter.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//! Order submission facade for dYdX v4.
17//!
18//! This module provides [`OrderSubmitter`], a unified facade for submitting orders to dYdX.
19//! It internally uses the extracted components:
20//! - [`TransactionManager`]: Sequence tracking and transaction signing
21//! - [`TxBroadcaster`]: gRPC broadcast with retry logic
22//! - [`OrderMessageBuilder`]: Proto message construction
23//!
24//! The wallet is owned internally by `TransactionManager`, so method signatures
25//! don't require passing `&wallet` on each call.
26
27use std::sync::Arc;
28
29use futures_util::{StreamExt, stream::FuturesUnordered};
30use nautilus_model::{
31    enums::{OrderSide, TimeInForce},
32    identifiers::InstrumentId,
33    types::{Price, Quantity},
34};
35use nautilus_network::ratelimiter::quota::Quota;
36
37use crate::{
38    error::DydxError,
39    execution::{
40        block_time::BlockTimeMonitor,
41        broadcaster::TxBroadcaster,
42        order_builder::OrderMessageBuilder,
43        tx_manager::TransactionManager,
44        types::{ConditionalOrderType, LimitOrderParams, OrderLifetime},
45    },
46    grpc::{DydxGrpcClient, types::ChainId},
47    http::client::DydxHttpClient,
48};
49
50/// Order submission facade for dYdX v4.
51///
52/// Provides a clean API for order submission, internally coordinating:
53/// - [`TransactionManager`]: Owns wallet, handles sequence + signing
54/// - [`TxBroadcaster`]: Handles gRPC broadcast with retry
55/// - [`OrderMessageBuilder`]: Constructs proto messages
56///
57/// # Wallet Ownership
58///
59/// The wallet is owned by `TransactionManager` (passed at construction via `private_key`).
60/// This eliminates the need to pass `&wallet` to every method.
61///
62/// # Block Time Monitor
63///
64/// `block_time_monitor` provides current block height and dynamic block time estimation.
65/// Updated externally by WebSocket, read by order methods.
66///
67/// # Thread Safety
68///
69/// All methods are safe to call from multiple tasks concurrently.
70#[derive(Debug)]
71pub struct OrderSubmitter {
72    /// Transaction manager - owns wallet, handles sequence and signing.
73    tx_manager: Arc<TransactionManager>,
74    /// Transaction broadcaster with retry logic.
75    broadcaster: Arc<TxBroadcaster>,
76    /// Order message builder for proto construction.
77    order_builder: Arc<OrderMessageBuilder>,
78    /// Block time monitor - provides current height and block time estimation.
79    block_time_monitor: Arc<BlockTimeMonitor>,
80}
81
82impl OrderSubmitter {
83    /// Creates a new order submitter with wallet owned internally.
84    ///
85    /// # Arguments
86    ///
87    /// * `grpc_client` - gRPC client for chain queries and broadcasting
88    /// * `http_client` - HTTP client (provides market params cache)
89    /// * `private_key` - Private key (hex-encoded) - wallet created internally
90    /// * `wallet_address` - Main account address (may differ from derived address for permissioned keys)
91    /// * `subaccount_number` - dYdX subaccount number (typically 0)
92    /// * `chain_id` - dYdX chain ID
93    /// * `block_time_monitor` - Block time monitor (provides current height and dynamic block time)
94    /// * `grpc_quota` - Optional rate limit quota for gRPC calls
95    ///
96    /// # Errors
97    ///
98    /// Returns error if wallet creation from private key fails.
99    #[expect(clippy::too_many_arguments)]
100    pub fn new(
101        grpc_client: DydxGrpcClient,
102        http_client: DydxHttpClient,
103        private_key: &str,
104        wallet_address: String,
105        subaccount_number: u32,
106        chain_id: ChainId,
107        block_time_monitor: Arc<BlockTimeMonitor>,
108        grpc_quota: Option<Quota>,
109    ) -> Result<Self, DydxError> {
110        // Create transaction manager (owns wallet and sequence management)
111        let tx_manager = Arc::new(TransactionManager::new(
112            grpc_client.clone(),
113            private_key,
114            wallet_address.clone(),
115            chain_id,
116        )?);
117
118        let broadcaster = Arc::new(TxBroadcaster::new(grpc_client, grpc_quota));
119
120        let order_builder = Arc::new(OrderMessageBuilder::new(
121            http_client,
122            wallet_address,
123            subaccount_number,
124            block_time_monitor.clone(),
125        ));
126
127        Ok(Self {
128            tx_manager,
129            broadcaster,
130            order_builder,
131            block_time_monitor,
132        })
133    }
134
135    /// Creates a new order submitter from pre-built components.
136    ///
137    /// Use this when you already have initialized components (e.g., from `DydxExecutionClient`).
138    pub fn from_components(
139        tx_manager: Arc<TransactionManager>,
140        broadcaster: Arc<TxBroadcaster>,
141        order_builder: Arc<OrderMessageBuilder>,
142        block_time_monitor: Arc<BlockTimeMonitor>,
143    ) -> Self {
144        Self {
145            tx_manager,
146            broadcaster,
147            order_builder,
148            block_time_monitor,
149        }
150    }
151
152    /// Returns the current block height.
153    #[must_use]
154    pub fn current_block_height(&self) -> u32 {
155        self.block_time_monitor.current_block_height() as u32
156    }
157
158    /// Returns a reference to the block time monitor.
159    #[must_use]
160    pub fn block_time_monitor(&self) -> &BlockTimeMonitor {
161        &self.block_time_monitor
162    }
163
164    /// Returns the wallet address.
165    #[must_use]
166    pub fn wallet_address(&self) -> &str {
167        self.tx_manager.wallet_address()
168    }
169
170    /// Returns a reference to the order builder.
171    #[must_use]
172    pub fn order_builder(&self) -> &OrderMessageBuilder {
173        &self.order_builder
174    }
175
176    /// Returns a reference to the transaction manager.
177    #[must_use]
178    pub fn tx_manager(&self) -> &TransactionManager {
179        &self.tx_manager
180    }
181
182    /// Submits a market order to dYdX via gRPC.
183    ///
184    /// Market orders execute immediately at the best available price.
185    /// Block height is read from the shared `block_height` state.
186    ///
187    /// # Returns
188    ///
189    /// The transaction hash on success.
190    ///
191    /// # Errors
192    ///
193    /// Returns `DydxError` if gRPC submission fails.
194    pub async fn submit_market_order(
195        &self,
196        instrument_id: InstrumentId,
197        client_order_id: u32,
198        client_metadata: u32,
199        side: OrderSide,
200        quantity: Quantity,
201    ) -> Result<String, DydxError> {
202        log::debug!(
203            "Submitting market order: client_id={client_order_id}, meta={client_metadata:#x}, side={side:?}, quantity={quantity}"
204        );
205
206        let block_height = self.current_block_height();
207
208        // Build proto message
209        let msg = self.order_builder.build_market_order(
210            instrument_id,
211            client_order_id,
212            client_metadata,
213            side,
214            quantity,
215            block_height,
216        )?;
217
218        // Market orders are always short-term: use cached sequence (no increment)
219        let operation = format!("Submit market order {client_order_id}");
220        let tx_hash = self
221            .broadcaster
222            .broadcast_short_term(&self.tx_manager, vec![msg], &operation)
223            .await?;
224
225        Ok(tx_hash)
226    }
227
228    /// Submits a limit order to dYdX via gRPC.
229    ///
230    /// Limit orders execute only at the specified price or better.
231    /// Block height is read from the shared `block_height` state.
232    ///
233    /// # Returns
234    ///
235    /// The transaction hash on success.
236    ///
237    /// # Errors
238    ///
239    /// Returns `DydxError` if gRPC submission fails.
240    #[expect(clippy::too_many_arguments)]
241    pub async fn submit_limit_order(
242        &self,
243        instrument_id: InstrumentId,
244        client_order_id: u32,
245        client_metadata: u32,
246        side: OrderSide,
247        price: Price,
248        quantity: Quantity,
249        time_in_force: TimeInForce,
250        post_only: bool,
251        reduce_only: bool,
252        expire_time: Option<i64>,
253    ) -> Result<String, DydxError> {
254        log::debug!(
255            "Submitting limit order: client_id={client_order_id}, meta={client_metadata:#x}, side={side:?}, price={price}, \
256             quantity={quantity}, tif={time_in_force:?}, post_only={post_only}, reduce_only={reduce_only}"
257        );
258
259        let block_height = self.current_block_height();
260
261        // Build proto message
262        let msg = self.order_builder.build_limit_order(
263            instrument_id,
264            client_order_id,
265            client_metadata,
266            side,
267            price,
268            quantity,
269            time_in_force,
270            post_only,
271            reduce_only,
272            block_height,
273            expire_time,
274        )?;
275
276        // Determine if short-term based on time_in_force and expire_time
277        let is_short_term = OrderLifetime::from_time_in_force(
278            time_in_force,
279            expire_time,
280            false,
281            self.order_builder.max_short_term_secs(),
282        )
283        .is_short_term();
284
285        // Short-term: cached sequence, no retry. Stateful: proper sequence management.
286        let operation = format!("Submit limit order {client_order_id}");
287        let tx_hash = if is_short_term {
288            self.broadcaster
289                .broadcast_short_term(&self.tx_manager, vec![msg], &operation)
290                .await?
291        } else {
292            self.broadcaster
293                .broadcast_with_retry(&self.tx_manager, vec![msg], &operation)
294                .await?
295        };
296
297        Ok(tx_hash)
298    }
299
300    /// Submits a batch of limit orders.
301    ///
302    /// # Protocol Constraints
303    ///
304    /// - **Short-term orders cannot be batched**: If any order is short-term (IOC, FOK, or
305    ///   expire_time within 60s), each order is submitted in a separate transaction.
306    /// - **Long-term orders can be batched**: All orders in a single transaction.
307    ///
308    /// # Returns
309    ///
310    /// A vector of transaction hashes (one per transaction).
311    ///
312    /// # Errors
313    ///
314    /// Returns `DydxError` if any submission fails.
315    pub async fn submit_limit_orders_batch(
316        &self,
317        orders: Vec<LimitOrderParams>,
318    ) -> Result<Vec<String>, DydxError> {
319        if orders.is_empty() {
320            return Ok(Vec::new());
321        }
322
323        let block_height = self.current_block_height();
324
325        // Check if any orders are short-term (cannot be batched)
326        let has_short_term = orders
327            .iter()
328            .any(|params| self.order_builder.is_short_term_order(params));
329
330        if has_short_term {
331            // Short-term orders must be submitted individually.
332            // They don't consume Cosmos SDK sequences (GTB replay protection),
333            // so we use broadcast_short_term for concurrent submission.
334            log::debug!(
335                "Submitting {} short-term limit orders concurrently (sequence not consumed)",
336                orders.len()
337            );
338
339            let mut tx_hashes = Vec::with_capacity(orders.len());
340            let mut submissions = FuturesUnordered::new();
341
342            for params in orders {
343                let tx_manager = Arc::clone(&self.tx_manager);
344                let broadcaster = Arc::clone(&self.broadcaster);
345                let order_builder = Arc::clone(&self.order_builder);
346
347                submissions.push(async move {
348                    let msg = order_builder.build_limit_order_from_params(&params, block_height)?;
349                    let operation = format!("Submit short-term order {}", params.client_order_id);
350                    broadcaster
351                        .broadcast_short_term(&tx_manager, vec![msg], &operation)
352                        .await
353                });
354            }
355
356            // Collect results
357            while let Some(result) = submissions.next().await {
358                match result {
359                    Ok(tx_hash) => tx_hashes.push(tx_hash),
360                    Err(e) => return Err(e),
361                }
362            }
363
364            Ok(tx_hashes)
365        } else {
366            // Long-term orders can be batched in a single transaction
367            log::debug!(
368                "Batch submitting {} long-term limit orders in single transaction",
369                orders.len()
370            );
371
372            let msgs = self
373                .order_builder
374                .build_limit_orders_batch(&orders, block_height)?;
375
376            let operation = format!("Submit batch of {} limit orders", msgs.len());
377            let tx_hash = self
378                .broadcaster
379                .broadcast_with_retry(&self.tx_manager, msgs, &operation)
380                .await?;
381
382            Ok(vec![tx_hash])
383        }
384    }
385
386    /// Cancels an order on dYdX via gRPC.
387    ///
388    /// Block height is read from the shared `block_height` state.
389    ///
390    /// # Returns
391    ///
392    /// The transaction hash on success.
393    ///
394    /// # Errors
395    ///
396    /// Returns `DydxError` if gRPC cancellation fails or market params not found.
397    pub async fn cancel_order(
398        &self,
399        instrument_id: InstrumentId,
400        client_order_id: u32,
401        time_in_force: TimeInForce,
402        expire_time_ns: Option<nautilus_core::UnixNanos>,
403    ) -> Result<String, DydxError> {
404        log::debug!("Cancelling order: client_id={client_order_id}, instrument={instrument_id}");
405
406        let block_height = self.current_block_height();
407
408        // Build cancel message
409        let msg = self.order_builder.build_cancel_order(
410            instrument_id,
411            client_order_id,
412            time_in_force,
413            expire_time_ns,
414            block_height,
415        )?;
416
417        // Determine if this is a short-term cancel
418        let is_short_term = self
419            .order_builder
420            .is_short_term_cancel(time_in_force, expire_time_ns);
421
422        // Short-term: cached sequence, no retry. Stateful: proper sequence management.
423        let operation = format!("Cancel order {client_order_id}");
424        let tx_hash = if is_short_term {
425            self.broadcaster
426                .broadcast_short_term(&self.tx_manager, vec![msg], &operation)
427                .await?
428        } else {
429            self.broadcaster
430                .broadcast_with_retry(&self.tx_manager, vec![msg], &operation)
431                .await?
432        };
433
434        Ok(tx_hash)
435    }
436
437    /// Cancels multiple orders with optimal partitioned broadcasting.
438    ///
439    /// Partitions orders into short-term and long-term groups:
440    /// - Short-term orders: single `MsgBatchCancel` via `broadcast_short_term()`
441    /// - Long-term orders: batched `MsgCancelOrder` messages via `broadcast_with_retry()`
442    ///
443    /// # Arguments
444    ///
445    /// * `orders` - Slice of (instrument_id, client_order_id, time_in_force, expire_time_ns) tuples
446    ///
447    /// # Returns
448    ///
449    /// Comma-separated transaction hashes on success (one per partition).
450    ///
451    /// # Errors
452    ///
453    /// Returns `DydxError` if transaction broadcast fails or market params not found.
454    pub async fn cancel_orders_batch(
455        &self,
456        orders: &[(
457            InstrumentId,
458            u32,
459            TimeInForce,
460            Option<nautilus_core::UnixNanos>,
461        )],
462    ) -> Result<String, DydxError> {
463        if orders.is_empty() {
464            return Err(DydxError::Order("No orders to cancel".to_string()));
465        }
466
467        let block_height = self.current_block_height();
468
469        // Partition into short-term and long-term orders
470        let (short_term, long_term): (Vec<_>, Vec<_>) =
471            orders.iter().partition(|(_, _, tif, expire_ns)| {
472                self.order_builder.is_short_term_cancel(*tif, *expire_ns)
473            });
474
475        log::debug!(
476            "Batch cancelling {} orders (short_term={}, long_term={})",
477            orders.len(),
478            short_term.len(),
479            long_term.len(),
480        );
481
482        let mut tx_hashes = Vec::new();
483
484        // Cancel short-term orders with MsgBatchCancel (single gRPC call)
485        if !short_term.is_empty() {
486            let st_pairs: Vec<_> = short_term
487                .iter()
488                .map(|(inst_id, client_id, _, _)| (*inst_id, *client_id))
489                .collect();
490
491            let msg = self
492                .order_builder
493                .build_batch_cancel_short_term(&st_pairs, block_height)?;
494
495            let operation = format!("BatchCancel {} short-term orders", st_pairs.len());
496            let tx_hash = self
497                .broadcaster
498                .broadcast_short_term(&self.tx_manager, vec![msg], &operation)
499                .await?;
500            tx_hashes.push(tx_hash);
501        }
502
503        // Cancel long-term orders with batched MsgCancelOrder (single gRPC call)
504        if !long_term.is_empty() {
505            let lt_orders: Vec<_> = long_term
506                .iter()
507                .map(|(inst_id, client_id, tif, expire_ns)| {
508                    (*inst_id, *client_id, *tif, *expire_ns)
509                })
510                .collect();
511
512            let msgs = self
513                .order_builder
514                .build_cancel_orders_batch(&lt_orders, block_height)?;
515
516            let operation = format!("BatchCancel {} long-term orders", lt_orders.len());
517            let tx_hash = self
518                .broadcaster
519                .broadcast_with_retry(&self.tx_manager, msgs, &operation)
520                .await?;
521            tx_hashes.push(tx_hash);
522        }
523
524        Ok(tx_hashes.join(","))
525    }
526
527    /// Submits a stop market order to dYdX via gRPC.
528    ///
529    /// Stop market orders are triggered when the price reaches `trigger_price`.
530    ///
531    /// # Returns
532    ///
533    /// The transaction hash on success.
534    ///
535    /// # Errors
536    ///
537    /// Returns `DydxError` if gRPC submission fails.
538    #[expect(clippy::too_many_arguments)]
539    pub async fn submit_stop_market_order(
540        &self,
541        instrument_id: InstrumentId,
542        client_order_id: u32,
543        client_metadata: u32,
544        side: OrderSide,
545        trigger_price: Price,
546        quantity: Quantity,
547        reduce_only: bool,
548        expire_time: Option<i64>,
549    ) -> Result<String, DydxError> {
550        log::debug!(
551            "Submitting stop market order: client_id={client_order_id}, meta={client_metadata:#x}, side={side:?}, \
552             trigger={trigger_price}, qty={quantity}"
553        );
554
555        // Build proto message
556        let msg = self.order_builder.build_stop_market_order(
557            instrument_id,
558            client_order_id,
559            client_metadata,
560            side,
561            trigger_price,
562            quantity,
563            reduce_only,
564            expire_time,
565        )?;
566
567        // Broadcast with retry
568        let operation = format!("Submit stop market order {client_order_id}");
569        let tx_hash = self
570            .broadcaster
571            .broadcast_with_retry(&self.tx_manager, vec![msg], &operation)
572            .await?;
573
574        Ok(tx_hash)
575    }
576
577    /// Submits a stop limit order to dYdX via gRPC.
578    ///
579    /// Stop limit orders are triggered when the price reaches `trigger_price`,
580    /// then placed as a limit order at `limit_price`.
581    ///
582    /// # Returns
583    ///
584    /// The transaction hash on success.
585    ///
586    /// # Errors
587    ///
588    /// Returns `DydxError` if gRPC submission fails.
589    #[expect(clippy::too_many_arguments)]
590    pub async fn submit_stop_limit_order(
591        &self,
592        instrument_id: InstrumentId,
593        client_order_id: u32,
594        client_metadata: u32,
595        side: OrderSide,
596        trigger_price: Price,
597        limit_price: Price,
598        quantity: Quantity,
599        time_in_force: TimeInForce,
600        post_only: bool,
601        reduce_only: bool,
602        expire_time: Option<i64>,
603    ) -> Result<String, DydxError> {
604        log::debug!(
605            "Submitting stop limit order: client_id={client_order_id}, meta={client_metadata:#x}, side={side:?}, \
606             trigger={trigger_price}, limit={limit_price}, qty={quantity}"
607        );
608
609        // Build proto message
610        let msg = self.order_builder.build_stop_limit_order(
611            instrument_id,
612            client_order_id,
613            client_metadata,
614            side,
615            trigger_price,
616            limit_price,
617            quantity,
618            time_in_force,
619            post_only,
620            reduce_only,
621            expire_time,
622        )?;
623
624        // Broadcast with retry
625        let operation = format!("Submit stop limit order {client_order_id}");
626        let tx_hash = self
627            .broadcaster
628            .broadcast_with_retry(&self.tx_manager, vec![msg], &operation)
629            .await?;
630
631        Ok(tx_hash)
632    }
633
634    /// Submits a take profit market order to dYdX via gRPC.
635    ///
636    /// Take profit market orders are triggered when the price reaches `trigger_price`,
637    /// then executed as a market order.
638    ///
639    /// # Returns
640    ///
641    /// The transaction hash on success.
642    ///
643    /// # Errors
644    ///
645    /// Returns `DydxError` if gRPC submission fails.
646    #[expect(clippy::too_many_arguments)]
647    pub async fn submit_take_profit_market_order(
648        &self,
649        instrument_id: InstrumentId,
650        client_order_id: u32,
651        client_metadata: u32,
652        side: OrderSide,
653        trigger_price: Price,
654        quantity: Quantity,
655        reduce_only: bool,
656        expire_time: Option<i64>,
657    ) -> Result<String, DydxError> {
658        log::debug!(
659            "Submitting take profit market order: client_id={client_order_id}, meta={client_metadata:#x}, side={side:?}, \
660             trigger={trigger_price}, qty={quantity}"
661        );
662
663        // Build proto message
664        let msg = self.order_builder.build_take_profit_market_order(
665            instrument_id,
666            client_order_id,
667            client_metadata,
668            side,
669            trigger_price,
670            quantity,
671            reduce_only,
672            expire_time,
673        )?;
674
675        // Broadcast with retry
676        let operation = format!("Submit take profit market order {client_order_id}");
677        let tx_hash = self
678            .broadcaster
679            .broadcast_with_retry(&self.tx_manager, vec![msg], &operation)
680            .await?;
681
682        Ok(tx_hash)
683    }
684
685    /// Submits a take profit limit order to dYdX via gRPC.
686    ///
687    /// Take profit limit orders are triggered when the price reaches `trigger_price`,
688    /// then placed as a limit order at `limit_price`.
689    ///
690    /// # Returns
691    ///
692    /// The transaction hash on success.
693    ///
694    /// # Errors
695    ///
696    /// Returns `DydxError` if gRPC submission fails.
697    #[expect(clippy::too_many_arguments)]
698    pub async fn submit_take_profit_limit_order(
699        &self,
700        instrument_id: InstrumentId,
701        client_order_id: u32,
702        client_metadata: u32,
703        side: OrderSide,
704        trigger_price: Price,
705        limit_price: Price,
706        quantity: Quantity,
707        time_in_force: TimeInForce,
708        post_only: bool,
709        reduce_only: bool,
710        expire_time: Option<i64>,
711    ) -> Result<String, DydxError> {
712        log::debug!(
713            "Submitting take profit limit order: client_id={client_order_id}, meta={client_metadata:#x}, side={side:?}, \
714             trigger={trigger_price}, limit={limit_price}, qty={quantity}"
715        );
716
717        // Build proto message
718        let msg = self.order_builder.build_take_profit_limit_order(
719            instrument_id,
720            client_order_id,
721            client_metadata,
722            side,
723            trigger_price,
724            limit_price,
725            quantity,
726            time_in_force,
727            post_only,
728            reduce_only,
729            expire_time,
730        )?;
731
732        // Broadcast with retry
733        let operation = format!("Submit take profit limit order {client_order_id}");
734        let tx_hash = self
735            .broadcaster
736            .broadcast_with_retry(&self.tx_manager, vec![msg], &operation)
737            .await?;
738
739        Ok(tx_hash)
740    }
741
742    /// Submits a conditional order (generic interface).
743    ///
744    /// This method handles all conditional order types: StopMarket, StopLimit,
745    /// TakeProfitMarket, and TakeProfitLimit.
746    ///
747    /// # Returns
748    ///
749    /// The transaction hash on success.
750    ///
751    /// # Errors
752    ///
753    /// Returns `DydxError` if gRPC submission fails or `limit_price` is missing for limit orders.
754    #[expect(clippy::too_many_arguments)]
755    pub async fn submit_conditional_order(
756        &self,
757        instrument_id: InstrumentId,
758        client_order_id: u32,
759        client_metadata: u32,
760        order_type: ConditionalOrderType,
761        side: OrderSide,
762        trigger_price: Price,
763        limit_price: Option<Price>,
764        quantity: Quantity,
765        time_in_force: Option<TimeInForce>,
766        post_only: bool,
767        reduce_only: bool,
768        expire_time: Option<i64>,
769    ) -> Result<String, DydxError> {
770        // Build proto message
771        let msg = self.order_builder.build_conditional_order(
772            instrument_id,
773            client_order_id,
774            client_metadata,
775            order_type,
776            side,
777            trigger_price,
778            limit_price,
779            quantity,
780            time_in_force,
781            post_only,
782            reduce_only,
783            expire_time,
784        )?;
785
786        // Broadcast with retry
787        let operation = format!("Submit {order_type:?} order {client_order_id}");
788        let tx_hash = self
789            .broadcaster
790            .broadcast_with_retry(&self.tx_manager, vec![msg], &operation)
791            .await?;
792
793        Ok(tx_hash)
794    }
795}