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 cosmrs::Any;
32use jiff::{SignedDuration, Timestamp};
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        self.build_market_order_with_reduce_only(
198            instrument_id,
199            client_order_id,
200            client_metadata,
201            side,
202            quantity,
203            false,
204            block_height,
205        )
206    }
207
208    #[expect(
209        clippy::too_many_arguments,
210        reason = "mirrors build_market_order with an explicit reduce-only flag"
211    )]
212    pub(crate) fn build_market_order_with_reduce_only(
213        &self,
214        instrument_id: InstrumentId,
215        client_order_id: u32,
216        client_metadata: u32,
217        side: OrderSide,
218        quantity: Quantity,
219        reduce_only: bool,
220        block_height: u32,
221    ) -> Result<Any, DydxError> {
222        let market_params = self.get_market_params(instrument_id)?;
223
224        let builder = OrderBuilder::new(
225            market_params,
226            self.wallet_address.clone(),
227            self.subaccount_number,
228            client_order_id,
229            client_metadata,
230        )
231        .market(order_side_to_proto(side), quantity.as_decimal())
232        .reduce_only(reduce_only)
233        .short_term()
234        .until(OrderGoodUntil::Block(
235            block_height + SHORT_TERM_ORDER_MAXIMUM_LIFETIME,
236        ));
237
238        let order = builder
239            .build()
240            .map_err(|e| DydxError::Order(format!("Failed to build market order: {e}")))?;
241
242        Ok(MsgPlaceOrder { order: Some(order) }.to_any())
243    }
244
245    /// Builds a `MsgPlaceOrder` for a limit order.
246    ///
247    /// Automatically routes to short-term or long-term based on `time_in_force` and `expire_time`.
248    ///
249    /// # Errors
250    ///
251    /// Returns an error if market parameters cannot be retrieved or order building fails.
252    #[expect(clippy::too_many_arguments)]
253    pub fn build_limit_order(
254        &self,
255        instrument_id: InstrumentId,
256        client_order_id: u32,
257        client_metadata: u32,
258        side: OrderSide,
259        price: Price,
260        quantity: Quantity,
261        time_in_force: TimeInForce,
262        post_only: bool,
263        reduce_only: bool,
264        block_height: u32,
265        expire_time: Option<i64>,
266    ) -> Result<Any, DydxError> {
267        let market_params = self.get_market_params(instrument_id)?;
268        let lifetime = OrderLifetime::from_time_in_force(
269            time_in_force,
270            expire_time,
271            false,
272            self.max_short_term_secs(),
273        );
274
275        let mut builder = OrderBuilder::new(
276            market_params,
277            self.wallet_address.clone(),
278            self.subaccount_number,
279            client_order_id,
280            client_metadata,
281        )
282        .limit(
283            order_side_to_proto(side),
284            price.as_decimal(),
285            quantity.as_decimal(),
286        )
287        .time_in_force(time_in_force_to_proto_with_post_only(
288            time_in_force,
289            post_only,
290        ));
291
292        if reduce_only {
293            builder = builder.reduce_only(true);
294        }
295
296        // Set expiration based on lifetime
297        builder = self.apply_order_lifetime(builder, lifetime, block_height, expire_time)?;
298
299        let order = builder
300            .build()
301            .map_err(|e| DydxError::Order(format!("Failed to build limit order: {e}")))?;
302
303        Ok(MsgPlaceOrder { order: Some(order) }.to_any())
304    }
305
306    /// Builds a `MsgPlaceOrder` for a limit order from `LimitOrderParams`.
307    ///
308    /// # Errors
309    ///
310    /// Returns an error if market parameters cannot be retrieved or order building fails.
311    pub fn build_limit_order_from_params(
312        &self,
313        params: &LimitOrderParams,
314        block_height: u32,
315    ) -> Result<Any, DydxError> {
316        let expire_time = self.expire_time_to_secs(params.expire_time_ns);
317
318        self.build_limit_order(
319            params.instrument_id,
320            params.client_order_id,
321            params.client_metadata,
322            params.side,
323            params.price,
324            params.quantity,
325            params.time_in_force,
326            params.post_only,
327            params.reduce_only,
328            block_height,
329            expire_time,
330        )
331    }
332
333    /// Builds a batch of `MsgPlaceOrder` messages for limit orders.
334    ///
335    /// # Errors
336    ///
337    /// Returns an error if any order fails to build.
338    pub fn build_limit_orders_batch(
339        &self,
340        orders: &[LimitOrderParams],
341        block_height: u32,
342    ) -> Result<Vec<Any>, DydxError> {
343        orders
344            .iter()
345            .map(|params| self.build_limit_order_from_params(params, block_height))
346            .collect()
347    }
348
349    /// Builds a `MsgCancelOrder` message.
350    ///
351    /// Automatically routes to short-term or long-term cancellation based on the order's lifetime.
352    /// Accepts raw nanoseconds and applies `default_short_term_expiry_secs` if configured.
353    ///
354    /// # Errors
355    ///
356    /// Returns an error if market parameters cannot be retrieved or order building fails.
357    pub fn build_cancel_order(
358        &self,
359        instrument_id: InstrumentId,
360        client_order_id: u32,
361        time_in_force: TimeInForce,
362        expire_time_ns: Option<nautilus_core::UnixNanos>,
363        block_height: u32,
364    ) -> Result<Any, DydxError> {
365        let expire_time = self.expire_time_to_secs(expire_time_ns);
366        let market_params = self.get_market_params(instrument_id)?;
367        let lifetime = OrderLifetime::from_time_in_force(
368            time_in_force,
369            expire_time,
370            false,
371            self.max_short_term_secs(),
372        );
373
374        let (order_flags, good_til_oneof) = match lifetime {
375            OrderLifetime::ShortTerm => (
376                0,
377                GoodTilOneof::GoodTilBlock(block_height + SHORT_TERM_ORDER_MAXIMUM_LIFETIME),
378            ),
379            OrderLifetime::LongTerm | OrderLifetime::Conditional => {
380                let cancel_good_til = (Timestamp::now()
381                    + SignedDuration::from_hours(24 * GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS))
382                .as_second() as u32;
383                (
384                    lifetime.order_flags(),
385                    GoodTilOneof::GoodTilBlockTime(cancel_good_til),
386                )
387            }
388        };
389
390        let msg = MsgCancelOrder {
391            order_id: Some(OrderId {
392                subaccount_id: Some(SubaccountId {
393                    owner: self.wallet_address.clone(),
394                    number: self.subaccount_number,
395                }),
396                client_id: client_order_id,
397                order_flags,
398                clob_pair_id: market_params.clob_pair_id,
399            }),
400            good_til_oneof: Some(good_til_oneof),
401        };
402
403        Ok(msg.to_any())
404    }
405
406    /// Builds a `MsgCancelOrder` message with explicit order_flags.
407    ///
408    /// Use this method when you have the original order_flags stored (e.g., from OrderContext).
409    /// This avoids re-deriving the order type which can be incorrect for expired orders.
410    ///
411    /// # Errors
412    ///
413    /// Returns an error if market parameters cannot be retrieved.
414    pub fn build_cancel_order_with_flags(
415        &self,
416        instrument_id: InstrumentId,
417        client_order_id: u32,
418        order_flags: u32,
419        block_height: u32,
420    ) -> Result<Any, DydxError> {
421        let market_params = self.get_market_params(instrument_id)?;
422
423        let good_til_oneof = if order_flags == ORDER_FLAG_SHORT_TERM {
424            GoodTilOneof::GoodTilBlock(block_height + SHORT_TERM_ORDER_MAXIMUM_LIFETIME)
425        } else {
426            let cancel_good_til = (Timestamp::now()
427                + SignedDuration::from_hours(24 * GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS))
428            .as_second() as u32;
429            GoodTilOneof::GoodTilBlockTime(cancel_good_til)
430        };
431
432        let msg = MsgCancelOrder {
433            order_id: Some(OrderId {
434                subaccount_id: Some(SubaccountId {
435                    owner: self.wallet_address.clone(),
436                    number: self.subaccount_number,
437                }),
438                client_id: client_order_id,
439                order_flags,
440                clob_pair_id: market_params.clob_pair_id,
441            }),
442            good_til_oneof: Some(good_til_oneof),
443        };
444
445        Ok(msg.to_any())
446    }
447
448    /// Builds a batch of `MsgCancelOrder` messages.
449    ///
450    /// Each tuple contains: (instrument_id, client_order_id, time_in_force, expire_time_ns)
451    ///
452    /// # Errors
453    ///
454    /// Returns an error if any cancellation fails to build.
455    pub fn build_cancel_orders_batch(
456        &self,
457        orders: &[(
458            InstrumentId,
459            u32,
460            TimeInForce,
461            Option<nautilus_core::UnixNanos>,
462        )],
463        block_height: u32,
464    ) -> Result<Vec<Any>, DydxError> {
465        orders
466            .iter()
467            .map(|(instrument_id, client_order_id, tif, expire_time_ns)| {
468                self.build_cancel_order(
469                    *instrument_id,
470                    *client_order_id,
471                    *tif,
472                    *expire_time_ns,
473                    block_height,
474                )
475            })
476            .collect()
477    }
478
479    /// Builds a batch of `MsgCancelOrder` messages with explicit order_flags.
480    ///
481    /// Each tuple contains: (instrument_id, client_order_id, order_flags)
482    /// Use this method when you have stored order_flags from OrderContext.
483    ///
484    /// # Errors
485    ///
486    /// Returns an error if any cancellation fails to build.
487    pub fn build_cancel_orders_batch_with_flags(
488        &self,
489        orders: &[(InstrumentId, u32, u32)],
490        block_height: u32,
491    ) -> Result<Vec<Any>, DydxError> {
492        orders
493            .iter()
494            .map(|(instrument_id, client_order_id, order_flags)| {
495                self.build_cancel_order_with_flags(
496                    *instrument_id,
497                    *client_order_id,
498                    *order_flags,
499                    block_height,
500                )
501            })
502            .collect()
503    }
504
505    /// Builds a `MsgBatchCancel` message for batch-cancelling short-term orders.
506    ///
507    /// Groups orders by `clob_pair_id` and creates a single `MsgBatchCancel` message
508    /// that cancels all listed short-term orders in one transaction.
509    ///
510    /// # Errors
511    ///
512    /// Returns an error if market parameters cannot be retrieved for any instrument.
513    pub fn build_batch_cancel_short_term(
514        &self,
515        orders: &[(InstrumentId, u32)],
516        block_height: u32,
517    ) -> Result<Any, DydxError> {
518        // Group client_ids by clob_pair_id
519        let mut clob_groups: HashMap<u32, Vec<u32>> = HashMap::new();
520
521        for (instrument_id, client_order_id) in orders {
522            let market_params = self.get_market_params(*instrument_id)?;
523            clob_groups
524                .entry(market_params.clob_pair_id)
525                .or_default()
526                .push(*client_order_id);
527        }
528
529        let short_term_cancels: Vec<OrderBatch> = clob_groups
530            .into_iter()
531            .map(|(clob_pair_id, client_ids)| OrderBatch {
532                clob_pair_id,
533                client_ids,
534            })
535            .collect();
536
537        let msg = MsgBatchCancel {
538            subaccount_id: Some(SubaccountId {
539                owner: self.wallet_address.clone(),
540                number: self.subaccount_number,
541            }),
542            short_term_cancels,
543            good_til_block: block_height + SHORT_TERM_ORDER_MAXIMUM_LIFETIME,
544        };
545
546        Ok(msg.to_any())
547    }
548
549    /// Builds a cancel-and-replace batch for order modification.
550    ///
551    /// Returns `[MsgCancelOrder, MsgPlaceOrder]` as a single atomic transaction.
552    /// This eliminates race conditions when modifying orders by combining both
553    /// operations into one transaction with a single sequence number.
554    ///
555    /// Accepts raw nanoseconds for expire times and applies `default_short_term_expiry_secs`
556    /// if configured (consistent with placement routing).
557    ///
558    /// # Arguments
559    ///
560    /// * `instrument_id` - The instrument for both cancel and new order
561    /// * `old_client_order_id` - Client ID of the order to cancel
562    /// * `new_client_order_id` - Client ID for the replacement order
563    /// * `old_time_in_force` - TimeInForce of the original order (for cancel routing)
564    /// * `old_expire_time_ns` - Expire time of the original order in nanoseconds (for cancel routing)
565    /// * `new_params` - Parameters for the replacement limit order
566    /// * `block_height` - Current block height for short-term orders
567    ///
568    /// # Errors
569    ///
570    /// Returns an error if cancellation or replacement order fails to build.
571    #[expect(clippy::too_many_arguments)]
572    pub fn build_cancel_and_replace(
573        &self,
574        instrument_id: InstrumentId,
575        old_client_order_id: u32,
576        _new_client_order_id: u32,
577        old_time_in_force: TimeInForce,
578        old_expire_time_ns: Option<nautilus_core::UnixNanos>,
579        new_params: &LimitOrderParams,
580        block_height: u32,
581    ) -> Result<Vec<Any>, DydxError> {
582        // Build cancel message for the old order (accepts nanoseconds, computes internally)
583        let cancel_msg = self.build_cancel_order(
584            instrument_id,
585            old_client_order_id,
586            old_time_in_force,
587            old_expire_time_ns,
588            block_height,
589        )?;
590
591        // Build place message for the new order (uses build_limit_order_from_params for default expiry)
592        let place_msg = self.build_limit_order_from_params(new_params, block_height)?;
593
594        // Return as [cancel, place] - order matters for atomic execution
595        Ok(vec![cancel_msg, place_msg])
596    }
597
598    /// Builds a cancel-and-replace batch with explicit order_flags for cancellation.
599    ///
600    /// Use this method when you have stored order_flags from OrderContext.
601    ///
602    /// # Errors
603    ///
604    /// Returns an error if cancellation or replacement order fails to build.
605    pub fn build_cancel_and_replace_with_flags(
606        &self,
607        instrument_id: InstrumentId,
608        old_client_order_id: u32,
609        old_order_flags: u32,
610        new_params: &LimitOrderParams,
611        block_height: u32,
612    ) -> Result<Vec<Any>, DydxError> {
613        // Build cancel message using stored order_flags
614        let cancel_msg = self.build_cancel_order_with_flags(
615            instrument_id,
616            old_client_order_id,
617            old_order_flags,
618            block_height,
619        )?;
620
621        // Build place message for the new order
622        let place_msg = self.build_limit_order_from_params(new_params, block_height)?;
623
624        // Return as [cancel, place] - order matters for atomic execution
625        Ok(vec![cancel_msg, place_msg])
626    }
627
628    /// Builds a `MsgPlaceOrder` for a conditional order (stop or take-profit).
629    ///
630    /// Conditional orders are always stored on-chain (long-term/stateful).
631    ///
632    /// # Errors
633    ///
634    /// Returns an error if market parameters cannot be retrieved or order building fails.
635    #[expect(clippy::too_many_arguments)]
636    pub fn build_conditional_order(
637        &self,
638        instrument_id: InstrumentId,
639        client_order_id: u32,
640        client_metadata: u32,
641        order_type: ConditionalOrderType,
642        side: OrderSide,
643        trigger_price: Price,
644        limit_price: Option<Price>,
645        quantity: Quantity,
646        time_in_force: Option<TimeInForce>,
647        post_only: bool,
648        reduce_only: bool,
649        expire_time: Option<i64>,
650    ) -> Result<Any, DydxError> {
651        let market_params = self.get_market_params(instrument_id)?;
652
653        let mut builder = OrderBuilder::new(
654            market_params,
655            self.wallet_address.clone(),
656            self.subaccount_number,
657            client_order_id,
658            client_metadata,
659        );
660
661        let proto_side = order_side_to_proto(side);
662        let trigger_decimal = trigger_price.as_decimal();
663        let size_decimal = quantity.as_decimal();
664
665        // Apply order-type-specific builder method
666        builder = match order_type {
667            ConditionalOrderType::StopMarket => {
668                builder.stop_market(proto_side, trigger_decimal, size_decimal)
669            }
670            ConditionalOrderType::StopLimit => {
671                let limit = limit_price.ok_or_else(|| {
672                    DydxError::Order("StopLimit requires limit_price".to_string())
673                })?;
674                builder.stop_limit(
675                    proto_side,
676                    limit.as_decimal(),
677                    trigger_decimal,
678                    size_decimal,
679                )
680            }
681            ConditionalOrderType::TakeProfitMarket => {
682                builder.take_profit_market(proto_side, trigger_decimal, size_decimal)
683            }
684            ConditionalOrderType::TakeProfitLimit => {
685                let limit = limit_price.ok_or_else(|| {
686                    DydxError::Order("TakeProfitLimit requires limit_price".to_string())
687                })?;
688                builder.take_profit_limit(
689                    proto_side,
690                    limit.as_decimal(),
691                    trigger_decimal,
692                    size_decimal,
693                )
694            }
695        };
696
697        // Apply time-in-force for limit orders
698        let effective_tif = time_in_force.unwrap_or(TimeInForce::Gtc);
699
700        if matches!(
701            order_type,
702            ConditionalOrderType::StopLimit | ConditionalOrderType::TakeProfitLimit
703        ) {
704            let proto_tif = time_in_force_to_proto_with_post_only(effective_tif, post_only);
705            builder = builder.time_in_force(proto_tif);
706        }
707
708        if reduce_only {
709            builder = builder.reduce_only(true);
710        }
711
712        // Conditional orders always use time-based expiration
713        let expire = calculate_conditional_order_expiration(effective_tif, expire_time)?;
714        builder = builder.until(OrderGoodUntil::Time(expire));
715
716        let order = builder
717            .build()
718            .map_err(|e| DydxError::Order(format!("Failed to build {order_type:?} order: {e}")))?;
719
720        Ok(MsgPlaceOrder { order: Some(order) }.to_any())
721    }
722
723    /// Builds a stop market order.
724    ///
725    /// # Errors
726    ///
727    /// Returns an error if the conditional order fails to build.
728    #[expect(clippy::too_many_arguments)]
729    pub fn build_stop_market_order(
730        &self,
731        instrument_id: InstrumentId,
732        client_order_id: u32,
733        client_metadata: u32,
734        side: OrderSide,
735        trigger_price: Price,
736        quantity: Quantity,
737        reduce_only: bool,
738        expire_time: Option<i64>,
739    ) -> Result<Any, DydxError> {
740        self.build_conditional_order(
741            instrument_id,
742            client_order_id,
743            client_metadata,
744            ConditionalOrderType::StopMarket,
745            side,
746            trigger_price,
747            None,
748            quantity,
749            None,
750            false,
751            reduce_only,
752            expire_time,
753        )
754    }
755
756    /// Builds a stop limit order.
757    ///
758    /// # Errors
759    ///
760    /// Returns an error if the conditional order fails to build.
761    #[expect(clippy::too_many_arguments)]
762    pub fn build_stop_limit_order(
763        &self,
764        instrument_id: InstrumentId,
765        client_order_id: u32,
766        client_metadata: u32,
767        side: OrderSide,
768        trigger_price: Price,
769        limit_price: Price,
770        quantity: Quantity,
771        time_in_force: TimeInForce,
772        post_only: bool,
773        reduce_only: bool,
774        expire_time: Option<i64>,
775    ) -> Result<Any, DydxError> {
776        self.build_conditional_order(
777            instrument_id,
778            client_order_id,
779            client_metadata,
780            ConditionalOrderType::StopLimit,
781            side,
782            trigger_price,
783            Some(limit_price),
784            quantity,
785            Some(time_in_force),
786            post_only,
787            reduce_only,
788            expire_time,
789        )
790    }
791
792    /// Builds a take profit market order.
793    ///
794    /// # Errors
795    ///
796    /// Returns an error if the conditional order fails to build.
797    #[expect(clippy::too_many_arguments)]
798    pub fn build_take_profit_market_order(
799        &self,
800        instrument_id: InstrumentId,
801        client_order_id: u32,
802        client_metadata: u32,
803        side: OrderSide,
804        trigger_price: Price,
805        quantity: Quantity,
806        reduce_only: bool,
807        expire_time: Option<i64>,
808    ) -> Result<Any, DydxError> {
809        self.build_conditional_order(
810            instrument_id,
811            client_order_id,
812            client_metadata,
813            ConditionalOrderType::TakeProfitMarket,
814            side,
815            trigger_price,
816            None,
817            quantity,
818            None,
819            false,
820            reduce_only,
821            expire_time,
822        )
823    }
824
825    /// Builds a take profit limit order.
826    ///
827    /// # Errors
828    ///
829    /// Returns an error if the conditional order fails to build.
830    #[expect(clippy::too_many_arguments)]
831    pub fn build_take_profit_limit_order(
832        &self,
833        instrument_id: InstrumentId,
834        client_order_id: u32,
835        client_metadata: u32,
836        side: OrderSide,
837        trigger_price: Price,
838        limit_price: Price,
839        quantity: Quantity,
840        time_in_force: TimeInForce,
841        post_only: bool,
842        reduce_only: bool,
843        expire_time: Option<i64>,
844    ) -> Result<Any, DydxError> {
845        self.build_conditional_order(
846            instrument_id,
847            client_order_id,
848            client_metadata,
849            ConditionalOrderType::TakeProfitLimit,
850            side,
851            trigger_price,
852            Some(limit_price),
853            quantity,
854            Some(time_in_force),
855            post_only,
856            reduce_only,
857            expire_time,
858        )
859    }
860
861    /// Gets market parameters from the HTTP client cache.
862    fn get_market_params(
863        &self,
864        instrument_id: InstrumentId,
865    ) -> Result<OrderMarketParams, DydxError> {
866        let market = self
867            .http_client
868            .get_market_params(&instrument_id)
869            .ok_or_else(|| {
870                DydxError::Order(InstrumentLookupError::not_found(instrument_id).to_string())
871            })?;
872
873        Ok(OrderMarketParams {
874            atomic_resolution: market.atomic_resolution,
875            clob_pair_id: market.clob_pair_id,
876            oracle_price: market.oracle_price,
877            quantum_conversion_exponent: market.quantum_conversion_exponent,
878            step_base_quantums: market.step_base_quantums,
879            subticks_per_tick: market.subticks_per_tick,
880        })
881    }
882
883    /// Applies order lifetime settings to the builder.
884    fn apply_order_lifetime(
885        &self,
886        builder: OrderBuilder,
887        lifetime: OrderLifetime,
888        block_height: u32,
889        expire_time: Option<i64>,
890    ) -> Result<OrderBuilder, DydxError> {
891        match lifetime {
892            OrderLifetime::ShortTerm => {
893                let blocks_offset = self.calculate_block_offset(expire_time);
894                Ok(builder
895                    .short_term()
896                    .until(OrderGoodUntil::Block(block_height + blocks_offset)))
897            }
898            OrderLifetime::LongTerm => {
899                let expire_dt = self.calculate_expire_datetime(expire_time)?;
900                Ok(builder.long_term().until(OrderGoodUntil::Time(expire_dt)))
901            }
902            OrderLifetime::Conditional => {
903                // Conditional orders should use build_conditional_order instead
904                Err(DydxError::Order(
905                    "Use build_conditional_order for conditional orders".to_string(),
906                ))
907            }
908        }
909    }
910
911    /// Calculates block offset from expire_time for short-term orders.
912    ///
913    /// Uses dynamic block time estimation from `BlockTimeMonitor` when available,
914    /// falling back to the default block time (500ms) when insufficient samples.
915    fn calculate_block_offset(&self, expire_time: Option<i64>) -> u32 {
916        if let Some(expire_ts) = expire_time {
917            let now = Timestamp::now().as_second();
918            let seconds = expire_ts - now;
919            self.seconds_to_blocks(seconds)
920        } else {
921            SHORT_TERM_ORDER_MAXIMUM_LIFETIME
922        }
923    }
924
925    /// Converts seconds until expiry to number of blocks using dynamic block time.
926    ///
927    /// Uses `BlockTimeMonitor::seconds_per_block_or_default()` for accurate estimation
928    /// based on actual observed block times, falling back to 500ms when insufficient samples.
929    fn seconds_to_blocks(&self, seconds: i64) -> u32 {
930        if seconds <= 0 {
931            return 1; // Minimum 1 block
932        }
933
934        let secs_per_block = self.block_time_monitor.seconds_per_block_or_default();
935        let blocks = (seconds as f64 / secs_per_block).ceil() as u32;
936
937        blocks.clamp(1, SHORT_TERM_ORDER_MAXIMUM_LIFETIME)
938    }
939
940    /// Calculates expire datetime for long-term orders.
941    fn calculate_expire_datetime(&self, expire_time: Option<i64>) -> Result<Timestamp, DydxError> {
942        if let Some(expire_ts) = expire_time {
943            Timestamp::from_second(expire_ts)
944                .map_err(|_| DydxError::Parse(format!("Invalid expire timestamp: {expire_ts}")))
945        } else {
946            Ok(Timestamp::now()
947                + SignedDuration::from_hours(24 * GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS))
948        }
949    }
950}
951
952#[cfg(test)]
953mod tests {
954    use cosmrs::proto::traits::Message;
955    use nautilus_core::UnixNanos;
956    use nautilus_model::instruments::Instrument;
957    use rstest::rstest;
958
959    use super::*;
960    use crate::{
961        common::testing::load_json_result_fixture,
962        http::{models::MarketsResponse, parse::parse_instrument_any},
963        proto::OrderTimeInForce,
964    };
965
966    // Use 10 seconds as test value (20 blocks * 0.5s)
967    const TEST_MAX_SHORT_TERM_SECS: f64 = 10.0;
968
969    fn test_order_builder_with_market() -> (OrderMessageBuilder, InstrumentId) {
970        let json = load_json_result_fixture("http_get_perpetual_markets.json");
971        let mut response: MarketsResponse =
972            serde_json::from_value(json).expect("failed to parse markets fixture");
973        let market = response
974            .markets
975            .remove("BTC-USD")
976            .expect("BTC-USD market missing from fixture");
977        let instrument = parse_instrument_any(&market, None, None, UnixNanos::default())
978            .expect("failed to parse BTC-USD instrument");
979        let instrument_id = instrument.id();
980        let http_client = DydxHttpClient::default();
981        http_client.instrument_cache.insert(instrument, market);
982
983        (
984            OrderMessageBuilder::new(
985                http_client,
986                "dydx1testwalletaddress".to_string(),
987                0,
988                Arc::new(BlockTimeMonitor::new()),
989            ),
990            instrument_id,
991        )
992    }
993
994    #[rstest]
995    #[case::reduce_only(true)]
996    #[case::not_reduce_only(false)]
997    fn test_build_market_order_encodes_reduce_only(#[case] reduce_only: bool) {
998        let (builder, instrument_id) = test_order_builder_with_market();
999
1000        let message = builder
1001            .build_market_order_with_reduce_only(
1002                instrument_id,
1003                42,
1004                7,
1005                OrderSide::Sell,
1006                Quantity::from("0.001"),
1007                reduce_only,
1008                100,
1009            )
1010            .expect("failed to build market order");
1011        let message = MsgPlaceOrder::decode(message.value.as_slice())
1012            .expect("failed to decode MsgPlaceOrder");
1013        let order = message.order.expect("MsgPlaceOrder missing order");
1014
1015        assert_eq!(order.reduce_only, reduce_only);
1016        assert_eq!(order.time_in_force, OrderTimeInForce::Ioc as i32);
1017    }
1018
1019    #[rstest]
1020    fn test_get_market_params_missing_cache_returns_canonical_error() {
1021        let builder = OrderMessageBuilder::new(
1022            DydxHttpClient::default(),
1023            "dydx1testwalletaddress".to_string(),
1024            0,
1025            Arc::new(BlockTimeMonitor::new()),
1026        );
1027        let instrument_id = InstrumentId::from("BTC-USD.DYDX");
1028
1029        let result = builder.get_market_params(instrument_id);
1030
1031        match result {
1032            Err(DydxError::Order(reason)) => {
1033                assert_eq!(
1034                    reason,
1035                    InstrumentLookupError::not_found(instrument_id).to_string()
1036                );
1037            }
1038            other => panic!("Expected DydxError::Order, was {other:?}"),
1039        }
1040    }
1041
1042    #[rstest]
1043    fn test_order_lifetime_routing() {
1044        // IOC should be short-term regardless of max_short_term_secs
1045        let lifetime = OrderLifetime::from_time_in_force(
1046            TimeInForce::Ioc,
1047            None,
1048            false,
1049            TEST_MAX_SHORT_TERM_SECS,
1050        );
1051        assert!(lifetime.is_short_term());
1052
1053        // GTC without expire_time should be long-term
1054        let lifetime = OrderLifetime::from_time_in_force(
1055            TimeInForce::Gtc,
1056            None,
1057            false,
1058            TEST_MAX_SHORT_TERM_SECS,
1059        );
1060        assert!(!lifetime.is_short_term());
1061
1062        // Conditional should be conditional
1063        let lifetime = OrderLifetime::from_time_in_force(
1064            TimeInForce::Gtc,
1065            None,
1066            true,
1067            TEST_MAX_SHORT_TERM_SECS,
1068        );
1069        assert!(lifetime.is_conditional());
1070    }
1071
1072    #[rstest]
1073    fn test_order_lifetime_with_short_expiry() {
1074        // Order expiring in 5 seconds should be short-term (within 10s window)
1075        let expire_time = Some(Timestamp::now().as_second() + 5);
1076        let lifetime = OrderLifetime::from_time_in_force(
1077            TimeInForce::Gtd,
1078            expire_time,
1079            false,
1080            TEST_MAX_SHORT_TERM_SECS,
1081        );
1082        assert!(lifetime.is_short_term());
1083    }
1084
1085    #[rstest]
1086    fn test_order_lifetime_with_long_expiry() {
1087        // Order expiring in 60 seconds should be long-term (beyond 10s window)
1088        let expire_time = Some(Timestamp::now().as_second() + 60);
1089        let lifetime = OrderLifetime::from_time_in_force(
1090            TimeInForce::Gtd,
1091            expire_time,
1092            false,
1093            TEST_MAX_SHORT_TERM_SECS,
1094        );
1095        assert!(!lifetime.is_short_term());
1096    }
1097}