Skip to main content

nautilus_dydx/execution/
order_builder.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 message builder for dYdX v4 protocol.
17//!
18//! This module converts Nautilus order types to dYdX proto messages (`MsgPlaceOrder`,
19//! `MsgCancelOrder`). It centralizes all order building logic including:
20//!
21//! - Market and limit order construction
22//! - Conditional orders (stop-loss, take-profit)
23//! - Short-term vs long-term order routing based on `OrderLifetime`
24//! - Price/quantity quantization via market params
25//! - Dynamic block time estimation via `BlockTimeMonitor`
26//!
27//! The builder produces `cosmrs::Any` messages ready for transaction building.
28
29use std::{collections::HashMap, sync::Arc};
30
31use chrono::{DateTime, Duration, Utc};
32use cosmrs::Any;
33use nautilus_common::cache::InstrumentLookupError;
34use nautilus_model::{
35    enums::{OrderSide, TimeInForce},
36    identifiers::InstrumentId,
37    types::{Price, Quantity},
38};
39
40use super::{
41    block_time::BlockTimeMonitor,
42    types::{
43        ConditionalOrderType, GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS, LimitOrderParams,
44        ORDER_FLAG_SHORT_TERM, OrderLifetime, calculate_conditional_order_expiration,
45    },
46};
47use crate::{
48    common::parse::{
49        nanos_to_secs_i64, order_side_to_proto, time_in_force_to_proto_with_post_only,
50    },
51    error::DydxError,
52    grpc::{OrderBuilder, OrderGoodUntil, OrderMarketParams, SHORT_TERM_ORDER_MAXIMUM_LIFETIME},
53    http::client::DydxHttpClient,
54    proto::{
55        ToAny,
56        dydxprotocol::{
57            clob::{
58                MsgBatchCancel, MsgCancelOrder, MsgPlaceOrder, OrderBatch, OrderId,
59                msg_cancel_order::GoodTilOneof,
60            },
61            subaccounts::SubaccountId,
62        },
63    },
64};
65
66/// Builds dYdX proto messages from Nautilus orders.
67///
68/// # Responsibilities
69///
70/// - Convert Nautilus order types to dYdX protocol messages
71/// - Determine short-term vs long-term routing via `OrderLifetime`
72/// - Handle price/quantity quantization via `OrderMarketParams`
73/// - Use dynamic block time estimation from `BlockTimeMonitor`
74///
75/// # Does NOT Handle
76///
77/// - Sequence management (handled by `TransactionManager`)
78/// - Transaction signing (handled by `TransactionManager`)
79/// - Broadcasting (handled by `TxBroadcaster`)
80#[derive(Debug)]
81pub struct OrderMessageBuilder {
82    http_client: DydxHttpClient,
83    wallet_address: String,
84    subaccount_number: u32,
85    /// Block time monitor for dynamic block time estimation.
86    block_time_monitor: Arc<BlockTimeMonitor>,
87}
88
89impl OrderMessageBuilder {
90    /// Creates a new order message builder.
91    #[must_use]
92    pub fn new(
93        http_client: DydxHttpClient,
94        wallet_address: String,
95        subaccount_number: u32,
96        block_time_monitor: Arc<BlockTimeMonitor>,
97    ) -> Self {
98        Self {
99            http_client,
100            wallet_address,
101            subaccount_number,
102            block_time_monitor,
103        }
104    }
105
106    /// Returns the maximum duration (in seconds) for short-term orders.
107    ///
108    /// Computed as: `SHORT_TERM_ORDER_MAXIMUM_LIFETIME (40 blocks) × seconds_per_block`
109    ///
110    /// Uses dynamic block time from `BlockTimeMonitor` when available,
111    /// falling back to 500ms/block when insufficient samples.
112    #[must_use]
113    pub fn max_short_term_secs(&self) -> f64 {
114        SHORT_TERM_ORDER_MAXIMUM_LIFETIME as f64
115            * self.block_time_monitor.seconds_per_block_or_default()
116    }
117
118    /// Converts expire_time from nanoseconds to seconds if present.
119    #[must_use]
120    fn expire_time_to_secs(
121        &self,
122        order_expire_time_ns: Option<nautilus_core::UnixNanos>,
123    ) -> Option<i64> {
124        order_expire_time_ns.map(nanos_to_secs_i64)
125    }
126
127    /// Determines the order lifetime for given parameters.
128    ///
129    /// Uses dynamic block time from `BlockTimeMonitor` to determine if an order
130    /// fits within the short-term window (20 blocks × seconds_per_block).
131    ///
132    /// # Important for Batching
133    ///
134    /// dYdX protocol restriction: **Short-term orders cannot be batched** - each must be
135    /// submitted in its own transaction. Only long-term orders can be batched.
136    /// Use this method to check before attempting to batch multiple orders.
137    #[must_use]
138    pub fn get_order_lifetime(&self, params: &LimitOrderParams) -> OrderLifetime {
139        let expire_time = self.expire_time_to_secs(params.expire_time_ns);
140        OrderLifetime::from_time_in_force(
141            params.time_in_force,
142            expire_time,
143            false,
144            self.max_short_term_secs(),
145        )
146    }
147
148    /// Checks if an order will be submitted as short-term.
149    ///
150    /// Short-term orders have protocol restrictions:
151    /// - Cannot be batched (one MsgPlaceOrder per transaction)
152    /// - Lower latency and fees
153    /// - Expire by block height (max 20 blocks)
154    #[must_use]
155    pub fn is_short_term_order(&self, params: &LimitOrderParams) -> bool {
156        self.get_order_lifetime(params).is_short_term()
157    }
158
159    /// Checks if a cancellation will be short-term based on the order's properties.
160    ///
161    /// Short-term cancellations have the same protocol restrictions as short-term placements:
162    /// - Cannot be batched (one MsgCancelOrder per transaction)
163    ///
164    /// The cancel must use the same lifetime as the original order placement.
165    #[must_use]
166    pub fn is_short_term_cancel(
167        &self,
168        time_in_force: TimeInForce,
169        expire_time_ns: Option<nautilus_core::UnixNanos>,
170    ) -> bool {
171        let expire_time = self.expire_time_to_secs(expire_time_ns);
172        OrderLifetime::from_time_in_force(
173            time_in_force,
174            expire_time,
175            false,
176            self.max_short_term_secs(),
177        )
178        .is_short_term()
179    }
180
181    /// Builds a `MsgPlaceOrder` for a market order.
182    ///
183    /// Market orders are always short-term and execute immediately at the best available price.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if market parameters cannot be retrieved or order building fails.
188    pub fn build_market_order(
189        &self,
190        instrument_id: InstrumentId,
191        client_order_id: u32,
192        client_metadata: u32,
193        side: OrderSide,
194        quantity: Quantity,
195        block_height: u32,
196    ) -> Result<Any, DydxError> {
197        let market_params = self.get_market_params(instrument_id)?;
198
199        let builder = OrderBuilder::new(
200            market_params,
201            self.wallet_address.clone(),
202            self.subaccount_number,
203            client_order_id,
204            client_metadata,
205        )
206        .market(order_side_to_proto(side), quantity.as_decimal())
207        .short_term()
208        .until(OrderGoodUntil::Block(
209            block_height + SHORT_TERM_ORDER_MAXIMUM_LIFETIME,
210        ));
211
212        let order = builder
213            .build()
214            .map_err(|e| DydxError::Order(format!("Failed to build market order: {e}")))?;
215
216        Ok(MsgPlaceOrder { order: Some(order) }.to_any())
217    }
218
219    /// Builds a `MsgPlaceOrder` for a limit order.
220    ///
221    /// Automatically routes to short-term or long-term based on `time_in_force` and `expire_time`.
222    ///
223    /// # Errors
224    ///
225    /// Returns an error if market parameters cannot be retrieved or order building fails.
226    #[expect(clippy::too_many_arguments)]
227    pub fn build_limit_order(
228        &self,
229        instrument_id: InstrumentId,
230        client_order_id: u32,
231        client_metadata: u32,
232        side: OrderSide,
233        price: Price,
234        quantity: Quantity,
235        time_in_force: TimeInForce,
236        post_only: bool,
237        reduce_only: bool,
238        block_height: u32,
239        expire_time: Option<i64>,
240    ) -> Result<Any, DydxError> {
241        let market_params = self.get_market_params(instrument_id)?;
242        let lifetime = OrderLifetime::from_time_in_force(
243            time_in_force,
244            expire_time,
245            false,
246            self.max_short_term_secs(),
247        );
248
249        let mut builder = OrderBuilder::new(
250            market_params,
251            self.wallet_address.clone(),
252            self.subaccount_number,
253            client_order_id,
254            client_metadata,
255        )
256        .limit(
257            order_side_to_proto(side),
258            price.as_decimal(),
259            quantity.as_decimal(),
260        )
261        .time_in_force(time_in_force_to_proto_with_post_only(
262            time_in_force,
263            post_only,
264        ));
265
266        if reduce_only {
267            builder = builder.reduce_only(true);
268        }
269
270        // Set expiration based on lifetime
271        builder = self.apply_order_lifetime(builder, lifetime, block_height, expire_time)?;
272
273        let order = builder
274            .build()
275            .map_err(|e| DydxError::Order(format!("Failed to build limit order: {e}")))?;
276
277        Ok(MsgPlaceOrder { order: Some(order) }.to_any())
278    }
279
280    /// Builds a `MsgPlaceOrder` for a limit order from `LimitOrderParams`.
281    ///
282    /// # Errors
283    ///
284    /// Returns an error if market parameters cannot be retrieved or order building fails.
285    pub fn build_limit_order_from_params(
286        &self,
287        params: &LimitOrderParams,
288        block_height: u32,
289    ) -> Result<Any, DydxError> {
290        let expire_time = self.expire_time_to_secs(params.expire_time_ns);
291
292        self.build_limit_order(
293            params.instrument_id,
294            params.client_order_id,
295            params.client_metadata,
296            params.side,
297            params.price,
298            params.quantity,
299            params.time_in_force,
300            params.post_only,
301            params.reduce_only,
302            block_height,
303            expire_time,
304        )
305    }
306
307    /// Builds a batch of `MsgPlaceOrder` messages for limit orders.
308    ///
309    /// # Errors
310    ///
311    /// Returns an error if any order fails to build.
312    pub fn build_limit_orders_batch(
313        &self,
314        orders: &[LimitOrderParams],
315        block_height: u32,
316    ) -> Result<Vec<Any>, DydxError> {
317        orders
318            .iter()
319            .map(|params| self.build_limit_order_from_params(params, block_height))
320            .collect()
321    }
322
323    /// Builds a `MsgCancelOrder` message.
324    ///
325    /// Automatically routes to short-term or long-term cancellation based on the order's lifetime.
326    /// Accepts raw nanoseconds and applies `default_short_term_expiry_secs` if configured.
327    ///
328    /// # Errors
329    ///
330    /// Returns an error if market parameters cannot be retrieved or order building fails.
331    pub fn build_cancel_order(
332        &self,
333        instrument_id: InstrumentId,
334        client_order_id: u32,
335        time_in_force: TimeInForce,
336        expire_time_ns: Option<nautilus_core::UnixNanos>,
337        block_height: u32,
338    ) -> Result<Any, DydxError> {
339        let expire_time = self.expire_time_to_secs(expire_time_ns);
340        let market_params = self.get_market_params(instrument_id)?;
341        let lifetime = OrderLifetime::from_time_in_force(
342            time_in_force,
343            expire_time,
344            false,
345            self.max_short_term_secs(),
346        );
347
348        let (order_flags, good_til_oneof) = match lifetime {
349            OrderLifetime::ShortTerm => (
350                0,
351                GoodTilOneof::GoodTilBlock(block_height + SHORT_TERM_ORDER_MAXIMUM_LIFETIME),
352            ),
353            OrderLifetime::LongTerm | OrderLifetime::Conditional => {
354                let cancel_good_til = (Utc::now()
355                    + Duration::days(GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS))
356                .timestamp() as u32;
357                (
358                    lifetime.order_flags(),
359                    GoodTilOneof::GoodTilBlockTime(cancel_good_til),
360                )
361            }
362        };
363
364        let msg = MsgCancelOrder {
365            order_id: Some(OrderId {
366                subaccount_id: Some(SubaccountId {
367                    owner: self.wallet_address.clone(),
368                    number: self.subaccount_number,
369                }),
370                client_id: client_order_id,
371                order_flags,
372                clob_pair_id: market_params.clob_pair_id,
373            }),
374            good_til_oneof: Some(good_til_oneof),
375        };
376
377        Ok(msg.to_any())
378    }
379
380    /// Builds a `MsgCancelOrder` message with explicit order_flags.
381    ///
382    /// Use this method when you have the original order_flags stored (e.g., from OrderContext).
383    /// This avoids re-deriving the order type which can be incorrect for expired orders.
384    ///
385    /// # Errors
386    ///
387    /// Returns an error if market parameters cannot be retrieved.
388    pub fn build_cancel_order_with_flags(
389        &self,
390        instrument_id: InstrumentId,
391        client_order_id: u32,
392        order_flags: u32,
393        block_height: u32,
394    ) -> Result<Any, DydxError> {
395        let market_params = self.get_market_params(instrument_id)?;
396
397        let good_til_oneof = if order_flags == ORDER_FLAG_SHORT_TERM {
398            GoodTilOneof::GoodTilBlock(block_height + SHORT_TERM_ORDER_MAXIMUM_LIFETIME)
399        } else {
400            let cancel_good_til = (Utc::now()
401                + Duration::days(GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS))
402            .timestamp() as u32;
403            GoodTilOneof::GoodTilBlockTime(cancel_good_til)
404        };
405
406        let msg = MsgCancelOrder {
407            order_id: Some(OrderId {
408                subaccount_id: Some(SubaccountId {
409                    owner: self.wallet_address.clone(),
410                    number: self.subaccount_number,
411                }),
412                client_id: client_order_id,
413                order_flags,
414                clob_pair_id: market_params.clob_pair_id,
415            }),
416            good_til_oneof: Some(good_til_oneof),
417        };
418
419        Ok(msg.to_any())
420    }
421
422    /// Builds a batch of `MsgCancelOrder` messages.
423    ///
424    /// Each tuple contains: (instrument_id, client_order_id, time_in_force, expire_time_ns)
425    ///
426    /// # Errors
427    ///
428    /// Returns an error if any cancellation fails to build.
429    pub fn build_cancel_orders_batch(
430        &self,
431        orders: &[(
432            InstrumentId,
433            u32,
434            TimeInForce,
435            Option<nautilus_core::UnixNanos>,
436        )],
437        block_height: u32,
438    ) -> Result<Vec<Any>, DydxError> {
439        orders
440            .iter()
441            .map(|(instrument_id, client_order_id, tif, expire_time_ns)| {
442                self.build_cancel_order(
443                    *instrument_id,
444                    *client_order_id,
445                    *tif,
446                    *expire_time_ns,
447                    block_height,
448                )
449            })
450            .collect()
451    }
452
453    /// Builds a batch of `MsgCancelOrder` messages with explicit order_flags.
454    ///
455    /// Each tuple contains: (instrument_id, client_order_id, order_flags)
456    /// Use this method when you have stored order_flags from OrderContext.
457    ///
458    /// # Errors
459    ///
460    /// Returns an error if any cancellation fails to build.
461    pub fn build_cancel_orders_batch_with_flags(
462        &self,
463        orders: &[(InstrumentId, u32, u32)],
464        block_height: u32,
465    ) -> Result<Vec<Any>, DydxError> {
466        orders
467            .iter()
468            .map(|(instrument_id, client_order_id, order_flags)| {
469                self.build_cancel_order_with_flags(
470                    *instrument_id,
471                    *client_order_id,
472                    *order_flags,
473                    block_height,
474                )
475            })
476            .collect()
477    }
478
479    /// Builds a `MsgBatchCancel` message for batch-cancelling short-term orders.
480    ///
481    /// Groups orders by `clob_pair_id` and creates a single `MsgBatchCancel` message
482    /// that cancels all listed short-term orders in one transaction.
483    ///
484    /// # Errors
485    ///
486    /// Returns an error if market parameters cannot be retrieved for any instrument.
487    pub fn build_batch_cancel_short_term(
488        &self,
489        orders: &[(InstrumentId, u32)],
490        block_height: u32,
491    ) -> Result<Any, DydxError> {
492        // Group client_ids by clob_pair_id
493        let mut clob_groups: HashMap<u32, Vec<u32>> = HashMap::new();
494
495        for (instrument_id, client_order_id) in orders {
496            let market_params = self.get_market_params(*instrument_id)?;
497            clob_groups
498                .entry(market_params.clob_pair_id)
499                .or_default()
500                .push(*client_order_id);
501        }
502
503        let short_term_cancels: Vec<OrderBatch> = clob_groups
504            .into_iter()
505            .map(|(clob_pair_id, client_ids)| OrderBatch {
506                clob_pair_id,
507                client_ids,
508            })
509            .collect();
510
511        let msg = MsgBatchCancel {
512            subaccount_id: Some(SubaccountId {
513                owner: self.wallet_address.clone(),
514                number: self.subaccount_number,
515            }),
516            short_term_cancels,
517            good_til_block: block_height + SHORT_TERM_ORDER_MAXIMUM_LIFETIME,
518        };
519
520        Ok(msg.to_any())
521    }
522
523    /// Builds a cancel-and-replace batch for order modification.
524    ///
525    /// Returns `[MsgCancelOrder, MsgPlaceOrder]` as a single atomic transaction.
526    /// This eliminates race conditions when modifying orders by combining both
527    /// operations into one transaction with a single sequence number.
528    ///
529    /// Accepts raw nanoseconds for expire times and applies `default_short_term_expiry_secs`
530    /// if configured (consistent with placement routing).
531    ///
532    /// # Arguments
533    ///
534    /// * `instrument_id` - The instrument for both cancel and new order
535    /// * `old_client_order_id` - Client ID of the order to cancel
536    /// * `new_client_order_id` - Client ID for the replacement order
537    /// * `old_time_in_force` - TimeInForce of the original order (for cancel routing)
538    /// * `old_expire_time_ns` - Expire time of the original order in nanoseconds (for cancel routing)
539    /// * `new_params` - Parameters for the replacement limit order
540    /// * `block_height` - Current block height for short-term orders
541    ///
542    /// # Errors
543    ///
544    /// Returns an error if cancellation or replacement order fails to build.
545    #[expect(clippy::too_many_arguments)]
546    pub fn build_cancel_and_replace(
547        &self,
548        instrument_id: InstrumentId,
549        old_client_order_id: u32,
550        _new_client_order_id: u32,
551        old_time_in_force: TimeInForce,
552        old_expire_time_ns: Option<nautilus_core::UnixNanos>,
553        new_params: &LimitOrderParams,
554        block_height: u32,
555    ) -> Result<Vec<Any>, DydxError> {
556        // Build cancel message for the old order (accepts nanoseconds, computes internally)
557        let cancel_msg = self.build_cancel_order(
558            instrument_id,
559            old_client_order_id,
560            old_time_in_force,
561            old_expire_time_ns,
562            block_height,
563        )?;
564
565        // Build place message for the new order (uses build_limit_order_from_params for default expiry)
566        let place_msg = self.build_limit_order_from_params(new_params, block_height)?;
567
568        // Return as [cancel, place] - order matters for atomic execution
569        Ok(vec![cancel_msg, place_msg])
570    }
571
572    /// Builds a cancel-and-replace batch with explicit order_flags for cancellation.
573    ///
574    /// Use this method when you have stored order_flags from OrderContext.
575    ///
576    /// # Errors
577    ///
578    /// Returns an error if cancellation or replacement order fails to build.
579    pub fn build_cancel_and_replace_with_flags(
580        &self,
581        instrument_id: InstrumentId,
582        old_client_order_id: u32,
583        old_order_flags: u32,
584        new_params: &LimitOrderParams,
585        block_height: u32,
586    ) -> Result<Vec<Any>, DydxError> {
587        // Build cancel message using stored order_flags
588        let cancel_msg = self.build_cancel_order_with_flags(
589            instrument_id,
590            old_client_order_id,
591            old_order_flags,
592            block_height,
593        )?;
594
595        // Build place message for the new order
596        let place_msg = self.build_limit_order_from_params(new_params, block_height)?;
597
598        // Return as [cancel, place] - order matters for atomic execution
599        Ok(vec![cancel_msg, place_msg])
600    }
601
602    /// Builds a `MsgPlaceOrder` for a conditional order (stop or take-profit).
603    ///
604    /// Conditional orders are always stored on-chain (long-term/stateful).
605    ///
606    /// # Errors
607    ///
608    /// Returns an error if market parameters cannot be retrieved or order building fails.
609    #[expect(clippy::too_many_arguments)]
610    pub fn build_conditional_order(
611        &self,
612        instrument_id: InstrumentId,
613        client_order_id: u32,
614        client_metadata: u32,
615        order_type: ConditionalOrderType,
616        side: OrderSide,
617        trigger_price: Price,
618        limit_price: Option<Price>,
619        quantity: Quantity,
620        time_in_force: Option<TimeInForce>,
621        post_only: bool,
622        reduce_only: bool,
623        expire_time: Option<i64>,
624    ) -> Result<Any, DydxError> {
625        let market_params = self.get_market_params(instrument_id)?;
626
627        let mut builder = OrderBuilder::new(
628            market_params,
629            self.wallet_address.clone(),
630            self.subaccount_number,
631            client_order_id,
632            client_metadata,
633        );
634
635        let proto_side = order_side_to_proto(side);
636        let trigger_decimal = trigger_price.as_decimal();
637        let size_decimal = quantity.as_decimal();
638
639        // Apply order-type-specific builder method
640        builder = match order_type {
641            ConditionalOrderType::StopMarket => {
642                builder.stop_market(proto_side, trigger_decimal, size_decimal)
643            }
644            ConditionalOrderType::StopLimit => {
645                let limit = limit_price.ok_or_else(|| {
646                    DydxError::Order("StopLimit requires limit_price".to_string())
647                })?;
648                builder.stop_limit(
649                    proto_side,
650                    limit.as_decimal(),
651                    trigger_decimal,
652                    size_decimal,
653                )
654            }
655            ConditionalOrderType::TakeProfitMarket => {
656                builder.take_profit_market(proto_side, trigger_decimal, size_decimal)
657            }
658            ConditionalOrderType::TakeProfitLimit => {
659                let limit = limit_price.ok_or_else(|| {
660                    DydxError::Order("TakeProfitLimit requires limit_price".to_string())
661                })?;
662                builder.take_profit_limit(
663                    proto_side,
664                    limit.as_decimal(),
665                    trigger_decimal,
666                    size_decimal,
667                )
668            }
669        };
670
671        // Apply time-in-force for limit orders
672        let effective_tif = time_in_force.unwrap_or(TimeInForce::Gtc);
673
674        if matches!(
675            order_type,
676            ConditionalOrderType::StopLimit | ConditionalOrderType::TakeProfitLimit
677        ) {
678            let proto_tif = time_in_force_to_proto_with_post_only(effective_tif, post_only);
679            builder = builder.time_in_force(proto_tif);
680        }
681
682        if reduce_only {
683            builder = builder.reduce_only(true);
684        }
685
686        // Conditional orders always use time-based expiration
687        let expire = calculate_conditional_order_expiration(effective_tif, expire_time)?;
688        builder = builder.until(OrderGoodUntil::Time(expire));
689
690        let order = builder
691            .build()
692            .map_err(|e| DydxError::Order(format!("Failed to build {order_type:?} order: {e}")))?;
693
694        Ok(MsgPlaceOrder { order: Some(order) }.to_any())
695    }
696
697    /// Builds a stop market order.
698    ///
699    /// # Errors
700    ///
701    /// Returns an error if the conditional order fails to build.
702    #[expect(clippy::too_many_arguments)]
703    pub fn build_stop_market_order(
704        &self,
705        instrument_id: InstrumentId,
706        client_order_id: u32,
707        client_metadata: u32,
708        side: OrderSide,
709        trigger_price: Price,
710        quantity: Quantity,
711        reduce_only: bool,
712        expire_time: Option<i64>,
713    ) -> Result<Any, DydxError> {
714        self.build_conditional_order(
715            instrument_id,
716            client_order_id,
717            client_metadata,
718            ConditionalOrderType::StopMarket,
719            side,
720            trigger_price,
721            None,
722            quantity,
723            None,
724            false,
725            reduce_only,
726            expire_time,
727        )
728    }
729
730    /// Builds a stop limit order.
731    ///
732    /// # Errors
733    ///
734    /// Returns an error if the conditional order fails to build.
735    #[expect(clippy::too_many_arguments)]
736    pub fn build_stop_limit_order(
737        &self,
738        instrument_id: InstrumentId,
739        client_order_id: u32,
740        client_metadata: u32,
741        side: OrderSide,
742        trigger_price: Price,
743        limit_price: Price,
744        quantity: Quantity,
745        time_in_force: TimeInForce,
746        post_only: bool,
747        reduce_only: bool,
748        expire_time: Option<i64>,
749    ) -> Result<Any, DydxError> {
750        self.build_conditional_order(
751            instrument_id,
752            client_order_id,
753            client_metadata,
754            ConditionalOrderType::StopLimit,
755            side,
756            trigger_price,
757            Some(limit_price),
758            quantity,
759            Some(time_in_force),
760            post_only,
761            reduce_only,
762            expire_time,
763        )
764    }
765
766    /// Builds a take profit market order.
767    ///
768    /// # Errors
769    ///
770    /// Returns an error if the conditional order fails to build.
771    #[expect(clippy::too_many_arguments)]
772    pub fn build_take_profit_market_order(
773        &self,
774        instrument_id: InstrumentId,
775        client_order_id: u32,
776        client_metadata: u32,
777        side: OrderSide,
778        trigger_price: Price,
779        quantity: Quantity,
780        reduce_only: bool,
781        expire_time: Option<i64>,
782    ) -> Result<Any, DydxError> {
783        self.build_conditional_order(
784            instrument_id,
785            client_order_id,
786            client_metadata,
787            ConditionalOrderType::TakeProfitMarket,
788            side,
789            trigger_price,
790            None,
791            quantity,
792            None,
793            false,
794            reduce_only,
795            expire_time,
796        )
797    }
798
799    /// Builds a take profit limit order.
800    ///
801    /// # Errors
802    ///
803    /// Returns an error if the conditional order fails to build.
804    #[expect(clippy::too_many_arguments)]
805    pub fn build_take_profit_limit_order(
806        &self,
807        instrument_id: InstrumentId,
808        client_order_id: u32,
809        client_metadata: u32,
810        side: OrderSide,
811        trigger_price: Price,
812        limit_price: Price,
813        quantity: Quantity,
814        time_in_force: TimeInForce,
815        post_only: bool,
816        reduce_only: bool,
817        expire_time: Option<i64>,
818    ) -> Result<Any, DydxError> {
819        self.build_conditional_order(
820            instrument_id,
821            client_order_id,
822            client_metadata,
823            ConditionalOrderType::TakeProfitLimit,
824            side,
825            trigger_price,
826            Some(limit_price),
827            quantity,
828            Some(time_in_force),
829            post_only,
830            reduce_only,
831            expire_time,
832        )
833    }
834
835    /// Gets market parameters from the HTTP client cache.
836    fn get_market_params(
837        &self,
838        instrument_id: InstrumentId,
839    ) -> Result<OrderMarketParams, DydxError> {
840        let market = self
841            .http_client
842            .get_market_params(&instrument_id)
843            .ok_or_else(|| {
844                DydxError::Order(InstrumentLookupError::not_found(instrument_id).to_string())
845            })?;
846
847        Ok(OrderMarketParams {
848            atomic_resolution: market.atomic_resolution,
849            clob_pair_id: market.clob_pair_id,
850            oracle_price: market.oracle_price,
851            quantum_conversion_exponent: market.quantum_conversion_exponent,
852            step_base_quantums: market.step_base_quantums,
853            subticks_per_tick: market.subticks_per_tick,
854        })
855    }
856
857    /// Applies order lifetime settings to the builder.
858    fn apply_order_lifetime(
859        &self,
860        builder: OrderBuilder,
861        lifetime: OrderLifetime,
862        block_height: u32,
863        expire_time: Option<i64>,
864    ) -> Result<OrderBuilder, DydxError> {
865        match lifetime {
866            OrderLifetime::ShortTerm => {
867                let blocks_offset = self.calculate_block_offset(expire_time);
868                Ok(builder
869                    .short_term()
870                    .until(OrderGoodUntil::Block(block_height + blocks_offset)))
871            }
872            OrderLifetime::LongTerm => {
873                let expire_dt = self.calculate_expire_datetime(expire_time)?;
874                Ok(builder.long_term().until(OrderGoodUntil::Time(expire_dt)))
875            }
876            OrderLifetime::Conditional => {
877                // Conditional orders should use build_conditional_order instead
878                Err(DydxError::Order(
879                    "Use build_conditional_order for conditional orders".to_string(),
880                ))
881            }
882        }
883    }
884
885    /// Calculates block offset from expire_time for short-term orders.
886    ///
887    /// Uses dynamic block time estimation from `BlockTimeMonitor` when available,
888    /// falling back to the default block time (500ms) when insufficient samples.
889    fn calculate_block_offset(&self, expire_time: Option<i64>) -> u32 {
890        if let Some(expire_ts) = expire_time {
891            let now = Utc::now().timestamp();
892            let seconds = expire_ts - now;
893            self.seconds_to_blocks(seconds)
894        } else {
895            SHORT_TERM_ORDER_MAXIMUM_LIFETIME
896        }
897    }
898
899    /// Converts seconds until expiry to number of blocks using dynamic block time.
900    ///
901    /// Uses `BlockTimeMonitor::seconds_per_block_or_default()` for accurate estimation
902    /// based on actual observed block times, falling back to 500ms when insufficient samples.
903    fn seconds_to_blocks(&self, seconds: i64) -> u32 {
904        if seconds <= 0 {
905            return 1; // Minimum 1 block
906        }
907
908        let secs_per_block = self.block_time_monitor.seconds_per_block_or_default();
909        let blocks = (seconds as f64 / secs_per_block).ceil() as u32;
910
911        blocks.clamp(1, SHORT_TERM_ORDER_MAXIMUM_LIFETIME)
912    }
913
914    /// Calculates expire datetime for long-term orders.
915    fn calculate_expire_datetime(
916        &self,
917        expire_time: Option<i64>,
918    ) -> Result<DateTime<Utc>, DydxError> {
919        if let Some(expire_ts) = expire_time {
920            DateTime::from_timestamp(expire_ts, 0)
921                .ok_or_else(|| DydxError::Parse(format!("Invalid expire timestamp: {expire_ts}")))
922        } else {
923            Ok(Utc::now() + Duration::days(GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS))
924        }
925    }
926}
927
928#[cfg(test)]
929mod tests {
930    use rstest::rstest;
931
932    use super::*;
933
934    // Use 10 seconds as test value (20 blocks * 0.5s)
935    const TEST_MAX_SHORT_TERM_SECS: f64 = 10.0;
936
937    #[rstest]
938    fn test_get_market_params_missing_cache_returns_canonical_error() {
939        let builder = OrderMessageBuilder::new(
940            DydxHttpClient::default(),
941            "dydx1testwalletaddress".to_string(),
942            0,
943            Arc::new(BlockTimeMonitor::new()),
944        );
945        let instrument_id = InstrumentId::from("BTC-USD.DYDX");
946
947        let result = builder.get_market_params(instrument_id);
948
949        match result {
950            Err(DydxError::Order(reason)) => {
951                assert_eq!(
952                    reason,
953                    InstrumentLookupError::not_found(instrument_id).to_string()
954                );
955            }
956            other => panic!("Expected DydxError::Order, was {other:?}"),
957        }
958    }
959
960    #[rstest]
961    fn test_order_lifetime_routing() {
962        // IOC should be short-term regardless of max_short_term_secs
963        let lifetime = OrderLifetime::from_time_in_force(
964            TimeInForce::Ioc,
965            None,
966            false,
967            TEST_MAX_SHORT_TERM_SECS,
968        );
969        assert!(lifetime.is_short_term());
970
971        // GTC without expire_time should be long-term
972        let lifetime = OrderLifetime::from_time_in_force(
973            TimeInForce::Gtc,
974            None,
975            false,
976            TEST_MAX_SHORT_TERM_SECS,
977        );
978        assert!(!lifetime.is_short_term());
979
980        // Conditional should be conditional
981        let lifetime = OrderLifetime::from_time_in_force(
982            TimeInForce::Gtc,
983            None,
984            true,
985            TEST_MAX_SHORT_TERM_SECS,
986        );
987        assert!(lifetime.is_conditional());
988    }
989
990    #[rstest]
991    fn test_order_lifetime_with_short_expiry() {
992        // Order expiring in 5 seconds should be short-term (within 10s window)
993        let expire_time = Some(Utc::now().timestamp() + 5);
994        let lifetime = OrderLifetime::from_time_in_force(
995            TimeInForce::Gtd,
996            expire_time,
997            false,
998            TEST_MAX_SHORT_TERM_SECS,
999        );
1000        assert!(lifetime.is_short_term());
1001    }
1002
1003    #[rstest]
1004    fn test_order_lifetime_with_long_expiry() {
1005        // Order expiring in 60 seconds should be long-term (beyond 10s window)
1006        let expire_time = Some(Utc::now().timestamp() + 60);
1007        let lifetime = OrderLifetime::from_time_in_force(
1008            TimeInForce::Gtd,
1009            expire_time,
1010            false,
1011            TEST_MAX_SHORT_TERM_SECS,
1012        );
1013        assert!(!lifetime.is_short_term());
1014    }
1015}