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