Skip to main content

nautilus_dydx/python/
submitter.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//! Python bindings for dYdX order submitter.
17
18#![allow(clippy::missing_errors_doc)]
19
20use std::{str::FromStr, sync::Arc};
21
22use chrono::Utc;
23use nautilus_core::python::{to_pyruntime_err, to_pyvalue_err};
24use nautilus_model::{
25    enums::{OrderSide, TimeInForce},
26    identifiers::InstrumentId,
27    types::{Price, Quantity},
28};
29use pyo3::prelude::*;
30
31use super::grpc::PyDydxGrpcClient;
32use crate::{
33    execution::{block_time::BlockTimeMonitor, submitter::OrderSubmitter},
34    grpc::{DEFAULT_RUST_CLIENT_METADATA, types::ChainId},
35    http::client::DydxHttpClient,
36};
37
38/// Python wrapper for OrderSubmitter.
39///
40/// # Breaking Change
41///
42/// This class now takes `private_key` in the constructor instead of requiring
43/// a wallet to be passed to each method. The wallet is owned internally.
44///
45/// ```python
46/// # Before (old API):
47/// wallet = DydxWallet.from_private_key("...")
48/// submitter = DydxOrderSubmitter(grpc, http, address, ...)
49/// submitter.submit_market_order(wallet, instrument_id, ...)
50///
51/// # After (new API):
52/// submitter = DydxOrderSubmitter(grpc, http, private_key="...", ...)
53/// submitter.submit_market_order(instrument_id, ...)  # no wallet param
54/// ```
55#[pyclass(name = "DydxOrderSubmitter")]
56#[derive(Debug)]
57pub struct PyDydxOrderSubmitter {
58    pub(crate) inner: Arc<OrderSubmitter>,
59    /// Block time monitor - updated via `record_block()`.
60    block_time_monitor: Arc<BlockTimeMonitor>,
61}
62
63#[pymethods]
64impl PyDydxOrderSubmitter {
65    /// Create a new order submitter with wallet owned internally.
66    ///
67    /// # Arguments
68    ///
69    /// * `grpc_client` - gRPC client for chain operations
70    /// * `http_client` - HTTP client (provides market params cache)
71    /// * `private_key` - Private key (hex-encoded) for signing transactions
72    /// * `wallet_address` - Main account address (may differ from derived address for permissioned keys)
73    /// * `subaccount_number` - dYdX subaccount number (default: 0)
74    /// * `chain_id` - Chain ID string (default: "dydx-mainnet-1")
75    ///
76    /// # Errors
77    ///
78    /// Returns an error if chain_id is invalid or wallet creation fails.
79    #[new]
80    #[pyo3(signature = (
81        grpc_client,
82        http_client,
83        private_key,
84        wallet_address,
85        subaccount_number=0,
86        chain_id=None
87    ))]
88    pub fn py_new(
89        grpc_client: PyDydxGrpcClient,
90        http_client: DydxHttpClient,
91        private_key: &str,
92        wallet_address: String,
93        subaccount_number: u32,
94        chain_id: Option<&str>,
95    ) -> PyResult<Self> {
96        let chain_id = if let Some(chain_str) = chain_id {
97            ChainId::from_str(chain_str).map_err(to_pyvalue_err)?
98        } else {
99            ChainId::Mainnet1
100        };
101
102        // Create block time monitor (updated via record_block)
103        let block_time_monitor = Arc::new(BlockTimeMonitor::new());
104
105        let submitter = OrderSubmitter::new(
106            grpc_client.inner.as_ref().clone(),
107            http_client,
108            private_key,
109            wallet_address,
110            subaccount_number,
111            chain_id,
112            Arc::clone(&block_time_monitor),
113        )
114        .map_err(to_pyvalue_err)?;
115
116        Ok(Self {
117            inner: Arc::new(submitter),
118            block_time_monitor,
119        })
120    }
121
122    /// Record a block height update with timestamp.
123    ///
124    /// Call this when receiving block updates from WebSocket.
125    /// The timestamp should be the block's timestamp (ISO 8601 format).
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if the timestamp cannot be parsed.
130    #[pyo3(name = "record_block")]
131    fn py_record_block(&self, height: u64, timestamp: Option<&str>) -> PyResult<()> {
132        let time = if let Some(ts) = timestamp {
133            chrono::DateTime::parse_from_rfc3339(ts)
134                .map(|dt| dt.with_timezone(&Utc))
135                .map_err(|e| to_pyvalue_err(format!("Invalid timestamp: {e}")))?
136        } else {
137            Utc::now()
138        };
139        self.block_time_monitor.record_block(height, time);
140        Ok(())
141    }
142
143    /// Set the current block height (legacy API, uses current time).
144    ///
145    /// Prefer using `record_block` with actual block timestamp for accurate
146    /// block time estimation.
147    #[pyo3(name = "set_block_height")]
148    fn py_set_block_height(&self, height: u64) {
149        self.block_time_monitor.record_block(height, Utc::now());
150    }
151
152    /// Get the current block height.
153    #[pyo3(name = "get_block_height")]
154    fn py_get_block_height(&self) -> u64 {
155        self.block_time_monitor.current_block_height()
156    }
157
158    /// Get the estimated seconds per block (based on rolling average).
159    ///
160    /// Returns None if insufficient samples have been collected.
161    #[pyo3(name = "estimated_seconds_per_block")]
162    fn py_estimated_seconds_per_block(&self) -> Option<f64> {
163        self.block_time_monitor.estimated_seconds_per_block()
164    }
165
166    /// Check if the block time monitor has enough samples for reliable estimates.
167    #[pyo3(name = "is_block_time_ready")]
168    fn py_is_block_time_ready(&self) -> bool {
169        self.block_time_monitor.is_ready()
170    }
171
172    /// Get the wallet address.
173    #[pyo3(name = "wallet_address")]
174    fn py_wallet_address(&self) -> String {
175        self.inner.wallet_address().to_string()
176    }
177
178    /// Resolve authenticator IDs for permissioned key trading.
179    ///
180    /// Call this during connect() when using an API trading key.
181    /// Automatically detects if the signing wallet differs from the main account
182    /// and fetches matching authenticators from the chain.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error if:
187    /// - Using permissioned key but no authenticators found
188    /// - No authenticator matches the wallet's public key
189    /// - gRPC query fails
190    #[pyo3(name = "resolve_authenticators")]
191    fn py_resolve_authenticators<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
192        let submitter = self.inner.clone();
193        pyo3_async_runtimes::tokio::future_into_py(py, async move {
194            submitter
195                .tx_manager()
196                .resolve_authenticators()
197                .await
198                .map_err(to_pyruntime_err)
199        })
200    }
201
202    /// Submit a market order to dYdX via gRPC.
203    ///
204    /// Block height is read from the internal state (set via `set_block_height`).
205    #[pyo3(name = "submit_market_order")]
206    #[pyo3(signature = (instrument_id, client_order_id, side, quantity, client_metadata=None))]
207    fn py_submit_market_order<'py>(
208        &self,
209        py: Python<'py>,
210        instrument_id: &str,
211        client_order_id: u32,
212        side: i64,
213        quantity: &str,
214        client_metadata: Option<u32>,
215    ) -> PyResult<Bound<'py, PyAny>> {
216        let submitter = self.inner.clone();
217        let instrument_id = InstrumentId::from(instrument_id);
218        let side = OrderSide::from_repr(side as usize)
219            .ok_or_else(|| to_pyvalue_err("Invalid OrderSide"))?;
220        let quantity = Quantity::from(quantity);
221        let client_metadata = client_metadata.unwrap_or(DEFAULT_RUST_CLIENT_METADATA);
222
223        pyo3_async_runtimes::tokio::future_into_py(py, async move {
224            let tx_hash = submitter
225                .submit_market_order(
226                    instrument_id,
227                    client_order_id,
228                    client_metadata,
229                    side,
230                    quantity,
231                )
232                .await
233                .map_err(to_pyruntime_err)?;
234            Ok(tx_hash)
235        })
236    }
237
238    /// Submit a limit order to dYdX via gRPC.
239    ///
240    /// Block height is read from the internal state (set via `set_block_height`).
241    #[pyo3(name = "submit_limit_order")]
242    #[pyo3(signature = (instrument_id, client_order_id, side, price, quantity, time_in_force, post_only, reduce_only, expire_time=None, client_metadata=None))]
243    #[allow(clippy::too_many_arguments)]
244    fn py_submit_limit_order<'py>(
245        &self,
246        py: Python<'py>,
247        instrument_id: &str,
248        client_order_id: u32,
249        side: i64,
250        price: &str,
251        quantity: &str,
252        time_in_force: i64,
253        post_only: bool,
254        reduce_only: bool,
255        expire_time: Option<i64>,
256        client_metadata: Option<u32>,
257    ) -> PyResult<Bound<'py, PyAny>> {
258        let submitter = self.inner.clone();
259        let instrument_id = InstrumentId::from(instrument_id);
260        let side = OrderSide::from_repr(side as usize)
261            .ok_or_else(|| to_pyvalue_err("Invalid OrderSide"))?;
262        let price = Price::from(price);
263        let quantity = Quantity::from(quantity);
264        let time_in_force = TimeInForce::from_repr(time_in_force as usize)
265            .ok_or_else(|| to_pyvalue_err("Invalid TimeInForce"))?;
266        let client_metadata = client_metadata.unwrap_or(DEFAULT_RUST_CLIENT_METADATA);
267
268        pyo3_async_runtimes::tokio::future_into_py(py, async move {
269            let tx_hash = submitter
270                .submit_limit_order(
271                    instrument_id,
272                    client_order_id,
273                    client_metadata,
274                    side,
275                    price,
276                    quantity,
277                    time_in_force,
278                    post_only,
279                    reduce_only,
280                    expire_time,
281                )
282                .await
283                .map_err(to_pyruntime_err)?;
284            Ok(tx_hash)
285        })
286    }
287
288    /// Submit a stop market order to dYdX via gRPC.
289    #[pyo3(name = "submit_stop_market_order")]
290    #[pyo3(signature = (instrument_id, client_order_id, side, trigger_price, quantity, reduce_only, expire_time=None, client_metadata=None))]
291    #[allow(clippy::too_many_arguments)]
292    fn py_submit_stop_market_order<'py>(
293        &self,
294        py: Python<'py>,
295        instrument_id: &str,
296        client_order_id: u32,
297        side: i64,
298        trigger_price: &str,
299        quantity: &str,
300        reduce_only: bool,
301        expire_time: Option<i64>,
302        client_metadata: Option<u32>,
303    ) -> PyResult<Bound<'py, PyAny>> {
304        let submitter = self.inner.clone();
305        let instrument_id = InstrumentId::from(instrument_id);
306        let side = OrderSide::from_repr(side as usize)
307            .ok_or_else(|| to_pyvalue_err("Invalid OrderSide"))?;
308        let trigger_price = Price::from(trigger_price);
309        let quantity = Quantity::from(quantity);
310        let client_metadata = client_metadata.unwrap_or(DEFAULT_RUST_CLIENT_METADATA);
311
312        pyo3_async_runtimes::tokio::future_into_py(py, async move {
313            let tx_hash = submitter
314                .submit_stop_market_order(
315                    instrument_id,
316                    client_order_id,
317                    client_metadata,
318                    side,
319                    trigger_price,
320                    quantity,
321                    reduce_only,
322                    expire_time,
323                )
324                .await
325                .map_err(to_pyruntime_err)?;
326            Ok(tx_hash)
327        })
328    }
329
330    /// Submit a stop limit order to dYdX via gRPC.
331    #[pyo3(name = "submit_stop_limit_order")]
332    #[pyo3(signature = (instrument_id, client_order_id, side, trigger_price, limit_price, quantity, time_in_force, post_only, reduce_only, expire_time=None, client_metadata=None))]
333    #[allow(clippy::too_many_arguments)]
334    fn py_submit_stop_limit_order<'py>(
335        &self,
336        py: Python<'py>,
337        instrument_id: &str,
338        client_order_id: u32,
339        side: i64,
340        trigger_price: &str,
341        limit_price: &str,
342        quantity: &str,
343        time_in_force: i64,
344        post_only: bool,
345        reduce_only: bool,
346        expire_time: Option<i64>,
347        client_metadata: Option<u32>,
348    ) -> PyResult<Bound<'py, PyAny>> {
349        let submitter = self.inner.clone();
350        let instrument_id = InstrumentId::from(instrument_id);
351        let side = OrderSide::from_repr(side as usize)
352            .ok_or_else(|| to_pyvalue_err("Invalid OrderSide"))?;
353        let trigger_price = Price::from(trigger_price);
354        let limit_price = Price::from(limit_price);
355        let quantity = Quantity::from(quantity);
356        let time_in_force = TimeInForce::from_repr(time_in_force as usize)
357            .ok_or_else(|| to_pyvalue_err("Invalid TimeInForce"))?;
358        let client_metadata = client_metadata.unwrap_or(DEFAULT_RUST_CLIENT_METADATA);
359
360        pyo3_async_runtimes::tokio::future_into_py(py, async move {
361            let tx_hash = submitter
362                .submit_stop_limit_order(
363                    instrument_id,
364                    client_order_id,
365                    client_metadata,
366                    side,
367                    trigger_price,
368                    limit_price,
369                    quantity,
370                    time_in_force,
371                    post_only,
372                    reduce_only,
373                    expire_time,
374                )
375                .await
376                .map_err(to_pyruntime_err)?;
377            Ok(tx_hash)
378        })
379    }
380
381    /// Submit a take profit market order to dYdX via gRPC.
382    #[pyo3(name = "submit_take_profit_market_order")]
383    #[pyo3(signature = (instrument_id, client_order_id, side, trigger_price, quantity, reduce_only, expire_time=None, client_metadata=None))]
384    #[allow(clippy::too_many_arguments)]
385    fn py_submit_take_profit_market_order<'py>(
386        &self,
387        py: Python<'py>,
388        instrument_id: &str,
389        client_order_id: u32,
390        side: i64,
391        trigger_price: &str,
392        quantity: &str,
393        reduce_only: bool,
394        expire_time: Option<i64>,
395        client_metadata: Option<u32>,
396    ) -> PyResult<Bound<'py, PyAny>> {
397        let submitter = self.inner.clone();
398        let instrument_id = InstrumentId::from(instrument_id);
399        let side = OrderSide::from_repr(side as usize)
400            .ok_or_else(|| to_pyvalue_err("Invalid OrderSide"))?;
401        let trigger_price = Price::from(trigger_price);
402        let quantity = Quantity::from(quantity);
403        let client_metadata = client_metadata.unwrap_or(DEFAULT_RUST_CLIENT_METADATA);
404
405        pyo3_async_runtimes::tokio::future_into_py(py, async move {
406            let tx_hash = submitter
407                .submit_take_profit_market_order(
408                    instrument_id,
409                    client_order_id,
410                    client_metadata,
411                    side,
412                    trigger_price,
413                    quantity,
414                    reduce_only,
415                    expire_time,
416                )
417                .await
418                .map_err(to_pyruntime_err)?;
419            Ok(tx_hash)
420        })
421    }
422
423    /// Submit a take profit limit order to dYdX via gRPC.
424    #[pyo3(name = "submit_take_profit_limit_order")]
425    #[pyo3(signature = (instrument_id, client_order_id, side, trigger_price, limit_price, quantity, time_in_force, post_only, reduce_only, expire_time=None, client_metadata=None))]
426    #[allow(clippy::too_many_arguments)]
427    fn py_submit_take_profit_limit_order<'py>(
428        &self,
429        py: Python<'py>,
430        instrument_id: &str,
431        client_order_id: u32,
432        side: i64,
433        trigger_price: &str,
434        limit_price: &str,
435        quantity: &str,
436        time_in_force: i64,
437        post_only: bool,
438        reduce_only: bool,
439        expire_time: Option<i64>,
440        client_metadata: Option<u32>,
441    ) -> PyResult<Bound<'py, PyAny>> {
442        let submitter = self.inner.clone();
443        let instrument_id = InstrumentId::from(instrument_id);
444        let side = OrderSide::from_repr(side as usize)
445            .ok_or_else(|| to_pyvalue_err("Invalid OrderSide"))?;
446        let trigger_price = Price::from(trigger_price);
447        let limit_price = Price::from(limit_price);
448        let quantity = Quantity::from(quantity);
449        let time_in_force = TimeInForce::from_repr(time_in_force as usize)
450            .ok_or_else(|| to_pyvalue_err("Invalid TimeInForce"))?;
451        let client_metadata = client_metadata.unwrap_or(DEFAULT_RUST_CLIENT_METADATA);
452
453        pyo3_async_runtimes::tokio::future_into_py(py, async move {
454            let tx_hash = submitter
455                .submit_take_profit_limit_order(
456                    instrument_id,
457                    client_order_id,
458                    client_metadata,
459                    side,
460                    trigger_price,
461                    limit_price,
462                    quantity,
463                    time_in_force,
464                    post_only,
465                    reduce_only,
466                    expire_time,
467                )
468                .await
469                .map_err(to_pyruntime_err)?;
470            Ok(tx_hash)
471        })
472    }
473
474    /// Cancel an order on dYdX.
475    ///
476    /// Block height is read from the internal state (set via `set_block_height`).
477    #[pyo3(name = "cancel_order")]
478    #[pyo3(signature = (instrument_id, client_order_id, time_in_force=None, expire_time_ns=None))]
479    fn py_cancel_order<'py>(
480        &self,
481        py: Python<'py>,
482        instrument_id: &str,
483        client_order_id: u32,
484        time_in_force: Option<i64>,
485        expire_time_ns: Option<u64>,
486    ) -> PyResult<Bound<'py, PyAny>> {
487        let submitter = self.inner.clone();
488        let instrument_id = InstrumentId::from(instrument_id);
489        let time_in_force = time_in_force
490            .and_then(|tif| TimeInForce::from_repr(tif as usize))
491            .unwrap_or(TimeInForce::Gtc);
492        let expire_time_ns = expire_time_ns.map(nautilus_core::UnixNanos::from);
493
494        pyo3_async_runtimes::tokio::future_into_py(py, async move {
495            let tx_hash = submitter
496                .cancel_order(
497                    instrument_id,
498                    client_order_id,
499                    time_in_force,
500                    expire_time_ns,
501                )
502                .await
503                .map_err(to_pyruntime_err)?;
504            Ok(tx_hash)
505        })
506    }
507
508    /// Cancel multiple orders in a single transaction.
509    ///
510    /// Each order is specified as (instrument_id, client_order_id, time_in_force, expire_time_ns).
511    /// For simplified usage, time_in_force and expire_time_ns can be omitted (defaults to GTC).
512    #[pyo3(name = "cancel_orders_batch")]
513    fn py_cancel_orders_batch<'py>(
514        &self,
515        py: Python<'py>,
516        orders: Vec<(String, u32, Option<i64>, Option<u64>)>,
517    ) -> PyResult<Bound<'py, PyAny>> {
518        let submitter = self.inner.clone();
519        let orders: Vec<(
520            InstrumentId,
521            u32,
522            TimeInForce,
523            Option<nautilus_core::UnixNanos>,
524        )> = orders
525            .into_iter()
526            .map(|(id, client_id, tif, expire_ns)| {
527                let tif = tif
528                    .and_then(|t| TimeInForce::from_repr(t as usize))
529                    .unwrap_or(TimeInForce::Gtc);
530                let expire_ns = expire_ns.map(nautilus_core::UnixNanos::from);
531                (InstrumentId::from(id), client_id, tif, expire_ns)
532            })
533            .collect();
534
535        pyo3_async_runtimes::tokio::future_into_py(py, async move {
536            let tx_hash = submitter
537                .cancel_orders_batch(&orders)
538                .await
539                .map_err(to_pyruntime_err)?;
540            Ok(tx_hash)
541        })
542    }
543
544    fn __repr__(&self) -> String {
545        format!(
546            "DydxOrderSubmitter(address={}, block_height={})",
547            self.inner.wallet_address(),
548            self.block_time_monitor.current_block_height()
549        )
550    }
551}