Skip to main content

nautilus_dydx/execution/
types.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//! Shared types for dYdX v4 execution module.
17//!
18//! This module centralizes type definitions used across order submission,
19//! transaction management, and WebSocket handling components.
20
21use jiff::{SignedDuration, Timestamp};
22use nautilus_core::UnixNanos;
23use nautilus_model::{
24    enums::{OrderSide, TimeInForce},
25    identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
26    types::{Price, Quantity},
27};
28
29use crate::error::DydxError;
30
31/// Default expiration for GTC conditional orders (90 days).
32///
33/// The protocol limit (`StatefulOrderTimeWindow`) is 95 days. This default
34/// leaves a 5-day buffer so the order timestamp never exceeds the window
35/// when the local clock is slightly ahead of the last block time.
36pub const GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS: i64 = 90;
37
38/// Order flag for short-term orders (expire by block height).
39pub const ORDER_FLAG_SHORT_TERM: u32 = 0;
40
41/// Order flag for conditional orders (stop-loss, take-profit).
42pub const ORDER_FLAG_CONDITIONAL: u32 = 32;
43
44/// Order flag for long-term/stateful orders (expire by timestamp).
45pub const ORDER_FLAG_LONG_TERM: u32 = 64;
46
47/// Order lifetime type determined by time_in_force and expire_time.
48///
49/// dYdX v4 has different execution paths for orders based on their expected lifetime:
50/// - **ShortTerm**: Lower latency/fees, expire by block height (max 20 blocks ~30s)
51/// - **LongTerm**: Stored on-chain, expire by timestamp, explicit cancel events
52/// - **Conditional**: Triggered by price conditions, always stored on-chain
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum OrderLifetime {
55    /// Short-term orders expire by block height (max 20 blocks).
56    /// Lower latency and fees, but expire silently without cancel events.
57    /// Used for IOC, FOK, or orders expiring within 60 seconds.
58    ShortTerm,
59    /// Long-term orders expire by timestamp.
60    /// Stored on-chain with explicit cancel events when they expire or are cancelled.
61    /// Used for GTC, GTD orders with expiry > 60 seconds.
62    LongTerm,
63    /// Conditional orders triggered by price conditions.
64    /// Always stored on-chain (stateful), used for stop-loss and take-profit orders.
65    Conditional,
66}
67
68impl OrderLifetime {
69    /// Determines order lifetime based on time_in_force, expire_time, and max short-term duration.
70    ///
71    /// The `max_short_term_secs` is computed dynamically from `BlockTimeMonitor`:
72    /// `max_short_term_secs = SHORT_TERM_ORDER_MAXIMUM_LIFETIME (40 blocks) × seconds_per_block`
73    ///
74    /// Returns `ShortTerm` when:
75    /// - TimeInForce is IOC or FOK (immediate execution orders)
76    /// - expire_time is set and within `max_short_term_secs` from now
77    ///
78    /// Returns `LongTerm` for GTC/GTD orders with expiry beyond short-term window.
79    /// Returns `Conditional` for stop/take-profit orders (when `is_conditional` is true).
80    #[must_use]
81    pub fn from_time_in_force(
82        time_in_force: TimeInForce,
83        expire_time: Option<i64>,
84        is_conditional: bool,
85        max_short_term_secs: f64,
86    ) -> Self {
87        if is_conditional {
88            return Self::Conditional;
89        }
90
91        // IOC and FOK are always short-term (immediate execution)
92        if matches!(time_in_force, TimeInForce::Ioc | TimeInForce::Fok) {
93            return Self::ShortTerm;
94        }
95
96        // Check if expire_time is within the short-term window
97        if let Some(expire_ts) = expire_time {
98            let now = Timestamp::now().as_second();
99            let time_until_expiry = expire_ts - now;
100            if time_until_expiry > 0 && (time_until_expiry as f64) <= max_short_term_secs {
101                return Self::ShortTerm;
102            }
103        }
104
105        Self::LongTerm
106    }
107
108    /// Returns the dYdX order_flags value for this lifetime.
109    ///
110    /// These flags are used in both `MsgPlaceOrder` and `MsgCancelOrder` to identify
111    /// the order type on-chain.
112    #[must_use]
113    pub const fn order_flags(&self) -> u32 {
114        match self {
115            Self::ShortTerm => ORDER_FLAG_SHORT_TERM,
116            Self::LongTerm => ORDER_FLAG_LONG_TERM,
117            Self::Conditional => ORDER_FLAG_CONDITIONAL,
118        }
119    }
120
121    /// Returns true if this is a short-term order.
122    #[must_use]
123    pub const fn is_short_term(&self) -> bool {
124        matches!(self, Self::ShortTerm)
125    }
126
127    /// Returns true if this is a conditional order (stop/take-profit).
128    #[must_use]
129    pub const fn is_conditional(&self) -> bool {
130        matches!(self, Self::Conditional)
131    }
132}
133
134/// Conditional order types supported by dYdX.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum ConditionalOrderType {
137    /// Triggers at trigger price, executes as market order.
138    StopMarket,
139    /// Triggers at trigger price, places limit order at limit price.
140    StopLimit,
141    /// Triggers at trigger price for profit taking, executes as market order.
142    TakeProfitMarket,
143    /// Triggers at trigger price for profit taking, places limit order at limit price.
144    TakeProfitLimit,
145}
146
147/// Parameters for a limit order in batch submission.
148#[derive(Debug, Clone)]
149pub struct LimitOrderParams {
150    /// Instrument to trade.
151    pub instrument_id: InstrumentId,
152    /// Client-assigned order ID (u32 for dYdX protocol).
153    pub client_order_id: u32,
154    /// Client metadata for bidirectional ClientOrderId encoding.
155    /// Used to store identity bits (trader/strategy/count) for deterministic decoding.
156    pub client_metadata: u32,
157    /// Order side (Buy or Sell).
158    pub side: OrderSide,
159    /// Limit price.
160    pub price: Price,
161    /// Order quantity.
162    pub quantity: Quantity,
163    /// Time in force.
164    pub time_in_force: TimeInForce,
165    /// Whether this is a post-only order.
166    pub post_only: bool,
167    /// Whether this is a reduce-only order.
168    pub reduce_only: bool,
169    /// Optional expiration timestamp (nanoseconds since epoch).
170    /// The builder will convert this to seconds and apply default_short_term_expiry if configured.
171    pub expire_time_ns: Option<UnixNanos>,
172}
173
174/// Contains the raw bytes and metadata for retry handling.
175#[derive(Debug, Clone)]
176pub struct PreparedTransaction {
177    /// Serialized transaction bytes.
178    pub tx_bytes: Vec<u8>,
179    /// Sequence number used for this transaction.
180    pub sequence: u64,
181    /// Human-readable operation name for logging.
182    pub operation: String,
183}
184
185/// Order context passed from submission to WebSocket confirmation handler.
186///
187/// This context is registered before transaction submission and used by the
188/// WebSocket handler to correlate incoming order updates with the original
189/// submission request, similar to Deribit's `order_contexts` pattern.
190#[derive(Debug, Clone)]
191pub struct OrderContext {
192    /// Nautilus client order ID.
193    pub client_order_id: ClientOrderId,
194    /// Trader ID from the order.
195    pub trader_id: TraderId,
196    /// Strategy ID that submitted the order.
197    pub strategy_id: StrategyId,
198    /// Instrument being traded.
199    pub instrument_id: InstrumentId,
200    /// Timestamp when the order was submitted.
201    pub submitted_at: UnixNanos,
202    /// dYdX order flags (0=short-term, 32=conditional, 64=long-term).
203    /// Stored at submission time to ensure cancellation uses correct flags.
204    pub order_flags: u32,
205}
206
207/// Calculates the expiration time for conditional orders based on TimeInForce.
208///
209/// - `GTD` with explicit `expire_time`: uses the provided timestamp.
210/// - `GTC` or no `expire_time`: defaults to 90 days from now.
211/// - `IOC`/`FOK`: uses 1 hour (these are unusual for conditional orders).
212///
213/// # Errors
214///
215/// Returns `DydxError::Parse` if the provided `expire_time` timestamp is invalid.
216pub fn calculate_conditional_order_expiration(
217    time_in_force: TimeInForce,
218    expire_time: Option<i64>,
219) -> Result<Timestamp, DydxError> {
220    if let Some(expire_ts) = expire_time {
221        Timestamp::from_second(expire_ts)
222            .map_err(|_| DydxError::Parse(format!("Invalid expire timestamp: {expire_ts}")))
223    } else {
224        let expiration = match time_in_force {
225            TimeInForce::Gtc => {
226                Timestamp::now()
227                    + SignedDuration::from_hours(24 * GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS)
228            }
229            TimeInForce::Ioc | TimeInForce::Fok => {
230                // IOC/FOK don't typically apply to conditional orders, use short expiration
231                Timestamp::now() + SignedDuration::from_hours(1)
232            }
233            // GTD without expire_time, or any other TIF - use long default
234            _ => {
235                Timestamp::now()
236                    + SignedDuration::from_hours(24 * GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS)
237            }
238        };
239        Ok(expiration)
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use rstest::rstest;
246
247    use super::*;
248
249    // Default max short-term seconds for testing (20 blocks × 3 sec/block = 60 sec)
250    const TEST_MAX_SHORT_TERM_SECS: f64 = 60.0;
251
252    #[rstest]
253    fn test_order_lifetime_ioc_is_short_term() {
254        let lifetime = OrderLifetime::from_time_in_force(
255            TimeInForce::Ioc,
256            None,
257            false,
258            TEST_MAX_SHORT_TERM_SECS,
259        );
260        assert_eq!(lifetime, OrderLifetime::ShortTerm);
261        assert!(lifetime.is_short_term());
262    }
263
264    #[rstest]
265    fn test_order_lifetime_fok_is_short_term() {
266        let lifetime = OrderLifetime::from_time_in_force(
267            TimeInForce::Fok,
268            None,
269            false,
270            TEST_MAX_SHORT_TERM_SECS,
271        );
272        assert_eq!(lifetime, OrderLifetime::ShortTerm);
273    }
274
275    #[rstest]
276    fn test_order_lifetime_gtc_is_long_term() {
277        let lifetime = OrderLifetime::from_time_in_force(
278            TimeInForce::Gtc,
279            None,
280            false,
281            TEST_MAX_SHORT_TERM_SECS,
282        );
283        assert_eq!(lifetime, OrderLifetime::LongTerm);
284        assert!(!lifetime.is_short_term());
285    }
286
287    #[rstest]
288    fn test_order_lifetime_conditional_flag() {
289        let lifetime = OrderLifetime::from_time_in_force(
290            TimeInForce::Gtc,
291            None,
292            true,
293            TEST_MAX_SHORT_TERM_SECS,
294        );
295        assert_eq!(lifetime, OrderLifetime::Conditional);
296        assert!(lifetime.is_conditional());
297    }
298
299    #[rstest]
300    fn test_order_lifetime_short_expire_time() {
301        // Expire time 30 seconds from now should be short-term (within 60s max)
302        let expire_time = Some(Timestamp::now().as_second() + 30);
303        let lifetime = OrderLifetime::from_time_in_force(
304            TimeInForce::Gtc,
305            expire_time,
306            false,
307            TEST_MAX_SHORT_TERM_SECS,
308        );
309        assert_eq!(lifetime, OrderLifetime::ShortTerm);
310    }
311
312    #[rstest]
313    fn test_order_lifetime_long_expire_time() {
314        // Expire time 5 minutes from now should be long-term (beyond 60s max)
315        let expire_time = Some(Timestamp::now().as_second() + 300);
316        let lifetime = OrderLifetime::from_time_in_force(
317            TimeInForce::Gtd,
318            expire_time,
319            false,
320            TEST_MAX_SHORT_TERM_SECS,
321        );
322        assert_eq!(lifetime, OrderLifetime::LongTerm);
323    }
324
325    #[rstest]
326    fn test_order_lifetime_expire_at_boundary() {
327        // Expire time exactly at max_short_term_secs should be short-term
328        let expire_time = Some(Timestamp::now().as_second() + 60);
329        let lifetime = OrderLifetime::from_time_in_force(
330            TimeInForce::Gtd,
331            expire_time,
332            false,
333            TEST_MAX_SHORT_TERM_SECS,
334        );
335        assert_eq!(lifetime, OrderLifetime::ShortTerm);
336    }
337
338    #[rstest]
339    fn test_order_lifetime_expire_just_beyond_boundary() {
340        // Expire time 1 second beyond max should be long-term
341        let expire_time = Some(Timestamp::now().as_second() + 61);
342        let lifetime = OrderLifetime::from_time_in_force(
343            TimeInForce::Gtd,
344            expire_time,
345            false,
346            TEST_MAX_SHORT_TERM_SECS,
347        );
348        assert_eq!(lifetime, OrderLifetime::LongTerm);
349    }
350
351    #[rstest]
352    fn test_order_flags() {
353        assert_eq!(
354            OrderLifetime::ShortTerm.order_flags(),
355            ORDER_FLAG_SHORT_TERM
356        );
357        assert_eq!(OrderLifetime::LongTerm.order_flags(), ORDER_FLAG_LONG_TERM);
358        assert_eq!(
359            OrderLifetime::Conditional.order_flags(),
360            ORDER_FLAG_CONDITIONAL
361        );
362    }
363}