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 chrono::{DateTime, Duration, Utc};
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 (20 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 = Utc::now().timestamp();
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<DateTime<Utc>, DydxError> {
220    if let Some(expire_ts) = expire_time {
221        DateTime::from_timestamp(expire_ts, 0)
222            .ok_or_else(|| DydxError::Parse(format!("Invalid expire timestamp: {expire_ts}")))
223    } else {
224        let expiration = match time_in_force {
225            TimeInForce::Gtc => Utc::now() + Duration::days(GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS),
226            TimeInForce::Ioc | TimeInForce::Fok => {
227                // IOC/FOK don't typically apply to conditional orders, use short expiration
228                Utc::now() + Duration::hours(1)
229            }
230            // GTD without expire_time, or any other TIF - use long default
231            _ => Utc::now() + Duration::days(GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS),
232        };
233        Ok(expiration)
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use rstest::rstest;
240
241    use super::*;
242
243    // Default max short-term seconds for testing (20 blocks × 3 sec/block = 60 sec)
244    const TEST_MAX_SHORT_TERM_SECS: f64 = 60.0;
245
246    #[rstest]
247    fn test_order_lifetime_ioc_is_short_term() {
248        let lifetime = OrderLifetime::from_time_in_force(
249            TimeInForce::Ioc,
250            None,
251            false,
252            TEST_MAX_SHORT_TERM_SECS,
253        );
254        assert_eq!(lifetime, OrderLifetime::ShortTerm);
255        assert!(lifetime.is_short_term());
256    }
257
258    #[rstest]
259    fn test_order_lifetime_fok_is_short_term() {
260        let lifetime = OrderLifetime::from_time_in_force(
261            TimeInForce::Fok,
262            None,
263            false,
264            TEST_MAX_SHORT_TERM_SECS,
265        );
266        assert_eq!(lifetime, OrderLifetime::ShortTerm);
267    }
268
269    #[rstest]
270    fn test_order_lifetime_gtc_is_long_term() {
271        let lifetime = OrderLifetime::from_time_in_force(
272            TimeInForce::Gtc,
273            None,
274            false,
275            TEST_MAX_SHORT_TERM_SECS,
276        );
277        assert_eq!(lifetime, OrderLifetime::LongTerm);
278        assert!(!lifetime.is_short_term());
279    }
280
281    #[rstest]
282    fn test_order_lifetime_conditional_flag() {
283        let lifetime = OrderLifetime::from_time_in_force(
284            TimeInForce::Gtc,
285            None,
286            true,
287            TEST_MAX_SHORT_TERM_SECS,
288        );
289        assert_eq!(lifetime, OrderLifetime::Conditional);
290        assert!(lifetime.is_conditional());
291    }
292
293    #[rstest]
294    fn test_order_lifetime_short_expire_time() {
295        // Expire time 30 seconds from now should be short-term (within 60s max)
296        let expire_time = Some(Utc::now().timestamp() + 30);
297        let lifetime = OrderLifetime::from_time_in_force(
298            TimeInForce::Gtc,
299            expire_time,
300            false,
301            TEST_MAX_SHORT_TERM_SECS,
302        );
303        assert_eq!(lifetime, OrderLifetime::ShortTerm);
304    }
305
306    #[rstest]
307    fn test_order_lifetime_long_expire_time() {
308        // Expire time 5 minutes from now should be long-term (beyond 60s max)
309        let expire_time = Some(Utc::now().timestamp() + 300);
310        let lifetime = OrderLifetime::from_time_in_force(
311            TimeInForce::Gtd,
312            expire_time,
313            false,
314            TEST_MAX_SHORT_TERM_SECS,
315        );
316        assert_eq!(lifetime, OrderLifetime::LongTerm);
317    }
318
319    #[rstest]
320    fn test_order_lifetime_expire_at_boundary() {
321        // Expire time exactly at max_short_term_secs should be short-term
322        let expire_time = Some(Utc::now().timestamp() + 60);
323        let lifetime = OrderLifetime::from_time_in_force(
324            TimeInForce::Gtd,
325            expire_time,
326            false,
327            TEST_MAX_SHORT_TERM_SECS,
328        );
329        assert_eq!(lifetime, OrderLifetime::ShortTerm);
330    }
331
332    #[rstest]
333    fn test_order_lifetime_expire_just_beyond_boundary() {
334        // Expire time 1 second beyond max should be long-term
335        let expire_time = Some(Utc::now().timestamp() + 61);
336        let lifetime = OrderLifetime::from_time_in_force(
337            TimeInForce::Gtd,
338            expire_time,
339            false,
340            TEST_MAX_SHORT_TERM_SECS,
341        );
342        assert_eq!(lifetime, OrderLifetime::LongTerm);
343    }
344
345    #[rstest]
346    fn test_order_flags() {
347        assert_eq!(
348            OrderLifetime::ShortTerm.order_flags(),
349            ORDER_FLAG_SHORT_TERM
350        );
351        assert_eq!(OrderLifetime::LongTerm.order_flags(), ORDER_FLAG_LONG_TERM);
352        assert_eq!(
353            OrderLifetime::Conditional.order_flags(),
354            ORDER_FLAG_CONDITIONAL
355        );
356    }
357}