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