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