nautilus_dydx/python/
execution.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 execution components.
17
18#![allow(clippy::missing_errors_doc)]
19
20use std::{str::FromStr, sync::Arc};
21
22use nautilus_core::python::IntoPyObjectNautilusExt;
23use nautilus_model::{
24    enums::{OrderSide, TimeInForce},
25    identifiers::InstrumentId,
26    types::{Price, Quantity},
27};
28use pyo3::prelude::*;
29
30use crate::{
31    execution::submitter::OrderSubmitter,
32    grpc::{DydxGrpcClient, Wallet, types::ChainId},
33    http::client::DydxHttpClient,
34};
35
36/// Python wrapper for the Wallet.
37#[pyclass(name = "DydxWallet")]
38#[derive(Debug, Clone)]
39pub struct PyDydxWallet {
40    pub(crate) inner: Arc<Wallet>,
41}
42
43#[pymethods]
44impl PyDydxWallet {
45    /// Create a wallet from a 24-word English mnemonic phrase.
46    ///
47    /// # Errors
48    ///
49    /// Returns an error if the mnemonic is invalid.
50    #[staticmethod]
51    #[pyo3(name = "from_mnemonic")]
52    pub fn py_from_mnemonic(mnemonic: &str) -> PyResult<Self> {
53        let wallet = Wallet::from_mnemonic(mnemonic)
54            .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("{e}")))?;
55        Ok(Self {
56            inner: Arc::new(wallet),
57        })
58    }
59
60    /// Get the wallet address (derives from account index 0).
61    ///
62    /// # Errors
63    ///
64    /// Returns an error if address derivation fails.
65    #[pyo3(name = "address")]
66    pub fn py_address(&self) -> PyResult<String> {
67        let account = self
68            .inner
69            .account_offline(0)
70            .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{e}")))?;
71        Ok(account.address)
72    }
73
74    fn __repr__(&self) -> String {
75        "DydxWallet(<redacted>)".to_string()
76    }
77}
78
79/// Python wrapper for OrderSubmitter.
80#[pyclass(name = "DydxOrderSubmitter")]
81#[derive(Debug)]
82pub struct PyDydxOrderSubmitter {
83    pub(crate) inner: Arc<OrderSubmitter>,
84}
85
86#[pymethods]
87impl PyDydxOrderSubmitter {
88    /// Create a new order submitter.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if chain_id is invalid.
93    #[new]
94    #[pyo3(signature = (grpc_client, http_client, wallet_address, subaccount_number=0, chain_id=None, authenticator_ids=None))]
95    pub fn py_new(
96        grpc_client: PyDydxGrpcClient,
97        http_client: DydxHttpClient,
98        wallet_address: String,
99        subaccount_number: u32,
100        chain_id: Option<&str>,
101        authenticator_ids: Option<Vec<u64>>,
102    ) -> PyResult<Self> {
103        let chain_id = if let Some(chain_str) = chain_id {
104            ChainId::from_str(chain_str)
105                .map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("{e}")))?
106        } else {
107            ChainId::Mainnet1
108        };
109
110        let submitter = OrderSubmitter::new(
111            grpc_client.inner.as_ref().clone(),
112            http_client,
113            wallet_address,
114            subaccount_number,
115            chain_id,
116            authenticator_ids.unwrap_or_default(),
117        );
118
119        Ok(Self {
120            inner: Arc::new(submitter),
121        })
122    }
123
124    /// Submit a market order to dYdX via gRPC.
125    #[pyo3(name = "submit_market_order")]
126    #[allow(clippy::too_many_arguments)]
127    fn py_submit_market_order<'py>(
128        &self,
129        py: Python<'py>,
130        wallet: PyDydxWallet,
131        instrument_id: &str,
132        client_order_id: u32,
133        side: i64,
134        quantity: &str,
135        block_height: u32,
136    ) -> PyResult<Bound<'py, PyAny>> {
137        let submitter = self.inner.clone();
138        let wallet_inner = wallet.inner;
139        let instrument_id = InstrumentId::from(instrument_id);
140        let side = OrderSide::from_repr(side as usize)
141            .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("Invalid OrderSide"))?;
142        let quantity = Quantity::from(quantity);
143
144        pyo3_async_runtimes::tokio::future_into_py(py, async move {
145            submitter
146                .submit_market_order(
147                    &wallet_inner,
148                    instrument_id,
149                    client_order_id,
150                    side,
151                    quantity,
152                    block_height,
153                )
154                .await
155                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{e}")))?;
156            Ok(())
157        })
158    }
159
160    /// Submit a limit order to dYdX via gRPC.
161    #[pyo3(name = "submit_limit_order")]
162    #[allow(clippy::too_many_arguments)]
163    fn py_submit_limit_order<'py>(
164        &self,
165        py: Python<'py>,
166        wallet: PyDydxWallet,
167        instrument_id: &str,
168        client_order_id: u32,
169        side: i64,
170        price: &str,
171        quantity: &str,
172        time_in_force: i64,
173        post_only: bool,
174        reduce_only: bool,
175        block_height: u32,
176        expire_time: Option<i64>,
177    ) -> PyResult<Bound<'py, PyAny>> {
178        let submitter = self.inner.clone();
179        let wallet_inner = wallet.inner;
180        let instrument_id = InstrumentId::from(instrument_id);
181        let side = OrderSide::from_repr(side as usize)
182            .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("Invalid OrderSide"))?;
183        let price = Price::from(price);
184        let quantity = Quantity::from(quantity);
185        let time_in_force = TimeInForce::from_repr(time_in_force as usize).ok_or_else(|| {
186            PyErr::new::<pyo3::exceptions::PyValueError, _>("Invalid TimeInForce")
187        })?;
188
189        pyo3_async_runtimes::tokio::future_into_py(py, async move {
190            submitter
191                .submit_limit_order(
192                    &wallet_inner,
193                    instrument_id,
194                    client_order_id,
195                    side,
196                    price,
197                    quantity,
198                    time_in_force,
199                    post_only,
200                    reduce_only,
201                    block_height,
202                    expire_time,
203                )
204                .await
205                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{e}")))?;
206            Ok(())
207        })
208    }
209
210    #[pyo3(name = "submit_stop_market_order")]
211    #[allow(clippy::too_many_arguments)]
212    fn py_submit_stop_market_order<'py>(
213        &self,
214        py: Python<'py>,
215        wallet: PyDydxWallet,
216        instrument_id: &str,
217        client_order_id: u32,
218        side: i64,
219        trigger_price: &str,
220        quantity: &str,
221        reduce_only: bool,
222        expire_time: Option<i64>,
223    ) -> PyResult<Bound<'py, PyAny>> {
224        let submitter = self.inner.clone();
225        let wallet_inner = wallet.inner;
226        let instrument_id = InstrumentId::from(instrument_id);
227        let side = OrderSide::from_repr(side as usize)
228            .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("Invalid OrderSide"))?;
229        let trigger_price = Price::from(trigger_price);
230        let quantity = Quantity::from(quantity);
231
232        pyo3_async_runtimes::tokio::future_into_py(py, async move {
233            submitter
234                .submit_stop_market_order(
235                    &wallet_inner,
236                    instrument_id,
237                    client_order_id,
238                    side,
239                    trigger_price,
240                    quantity,
241                    reduce_only,
242                    expire_time,
243                )
244                .await
245                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{e}")))?;
246            Ok(())
247        })
248    }
249
250    #[pyo3(name = "submit_stop_limit_order")]
251    #[allow(clippy::too_many_arguments)]
252    fn py_submit_stop_limit_order<'py>(
253        &self,
254        py: Python<'py>,
255        wallet: PyDydxWallet,
256        instrument_id: &str,
257        client_order_id: u32,
258        side: i64,
259        trigger_price: &str,
260        limit_price: &str,
261        quantity: &str,
262        time_in_force: i64,
263        post_only: bool,
264        reduce_only: bool,
265        expire_time: Option<i64>,
266    ) -> PyResult<Bound<'py, PyAny>> {
267        let submitter = self.inner.clone();
268        let wallet_inner = wallet.inner;
269        let instrument_id = InstrumentId::from(instrument_id);
270        let side = OrderSide::from_repr(side as usize)
271            .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("Invalid OrderSide"))?;
272        let trigger_price = Price::from(trigger_price);
273        let limit_price = Price::from(limit_price);
274        let quantity = Quantity::from(quantity);
275        let time_in_force = TimeInForce::from_repr(time_in_force as usize).ok_or_else(|| {
276            PyErr::new::<pyo3::exceptions::PyValueError, _>("Invalid TimeInForce")
277        })?;
278
279        pyo3_async_runtimes::tokio::future_into_py(py, async move {
280            submitter
281                .submit_stop_limit_order(
282                    &wallet_inner,
283                    instrument_id,
284                    client_order_id,
285                    side,
286                    trigger_price,
287                    limit_price,
288                    quantity,
289                    time_in_force,
290                    post_only,
291                    reduce_only,
292                    expire_time,
293                )
294                .await
295                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{e}")))?;
296            Ok(())
297        })
298    }
299
300    #[pyo3(name = "submit_take_profit_market_order")]
301    #[allow(clippy::too_many_arguments)]
302    fn py_submit_take_profit_market_order<'py>(
303        &self,
304        py: Python<'py>,
305        wallet: PyDydxWallet,
306        instrument_id: &str,
307        client_order_id: u32,
308        side: i64,
309        trigger_price: &str,
310        quantity: &str,
311        reduce_only: bool,
312        expire_time: Option<i64>,
313    ) -> PyResult<Bound<'py, PyAny>> {
314        let submitter = self.inner.clone();
315        let wallet_inner = wallet.inner;
316        let instrument_id = InstrumentId::from(instrument_id);
317        let side = OrderSide::from_repr(side as usize)
318            .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("Invalid OrderSide"))?;
319        let trigger_price = Price::from(trigger_price);
320        let quantity = Quantity::from(quantity);
321
322        pyo3_async_runtimes::tokio::future_into_py(py, async move {
323            submitter
324                .submit_take_profit_market_order(
325                    &wallet_inner,
326                    instrument_id,
327                    client_order_id,
328                    side,
329                    trigger_price,
330                    quantity,
331                    reduce_only,
332                    expire_time,
333                )
334                .await
335                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{e}")))?;
336            Ok(())
337        })
338    }
339
340    #[pyo3(name = "submit_take_profit_limit_order")]
341    #[allow(clippy::too_many_arguments)]
342    fn py_submit_take_profit_limit_order<'py>(
343        &self,
344        py: Python<'py>,
345        wallet: PyDydxWallet,
346        instrument_id: &str,
347        client_order_id: u32,
348        side: i64,
349        trigger_price: &str,
350        limit_price: &str,
351        quantity: &str,
352        time_in_force: i64,
353        post_only: bool,
354        reduce_only: bool,
355        expire_time: Option<i64>,
356    ) -> PyResult<Bound<'py, PyAny>> {
357        let submitter = self.inner.clone();
358        let wallet_inner = wallet.inner;
359        let instrument_id = InstrumentId::from(instrument_id);
360        let side = OrderSide::from_repr(side as usize)
361            .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("Invalid OrderSide"))?;
362        let trigger_price = Price::from(trigger_price);
363        let limit_price = Price::from(limit_price);
364        let quantity = Quantity::from(quantity);
365        let time_in_force = TimeInForce::from_repr(time_in_force as usize).ok_or_else(|| {
366            PyErr::new::<pyo3::exceptions::PyValueError, _>("Invalid TimeInForce")
367        })?;
368
369        pyo3_async_runtimes::tokio::future_into_py(py, async move {
370            submitter
371                .submit_take_profit_limit_order(
372                    &wallet_inner,
373                    instrument_id,
374                    client_order_id,
375                    side,
376                    trigger_price,
377                    limit_price,
378                    quantity,
379                    time_in_force,
380                    post_only,
381                    reduce_only,
382                    expire_time,
383                )
384                .await
385                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{e}")))?;
386            Ok(())
387        })
388    }
389
390    #[pyo3(name = "cancel_order")]
391    fn py_cancel_order<'py>(
392        &self,
393        py: Python<'py>,
394        wallet: PyDydxWallet,
395        instrument_id: &str,
396        client_order_id: u32,
397        block_height: u32,
398    ) -> PyResult<Bound<'py, PyAny>> {
399        let submitter = self.inner.clone();
400        let wallet_inner = wallet.inner;
401        let instrument_id = InstrumentId::from(instrument_id);
402
403        pyo3_async_runtimes::tokio::future_into_py(py, async move {
404            submitter
405                .cancel_order(&wallet_inner, instrument_id, client_order_id, block_height)
406                .await
407                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{e}")))?;
408            Ok(())
409        })
410    }
411
412    #[pyo3(name = "cancel_orders_batch")]
413    fn py_cancel_orders_batch<'py>(
414        &self,
415        py: Python<'py>,
416        wallet: PyDydxWallet,
417        orders: Vec<(String, u32)>,
418        block_height: u32,
419    ) -> PyResult<Bound<'py, PyAny>> {
420        let submitter = self.inner.clone();
421        let wallet_inner = wallet.inner;
422        let orders: Vec<(InstrumentId, u32)> = orders
423            .into_iter()
424            .map(|(id, client_id)| (InstrumentId::from(id.as_str()), client_id))
425            .collect();
426
427        pyo3_async_runtimes::tokio::future_into_py(py, async move {
428            submitter
429                .cancel_orders_batch(&wallet_inner, &orders, block_height)
430                .await
431                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{e}")))?;
432            Ok(())
433        })
434    }
435
436    fn __repr__(&self) -> String {
437        "DydxOrderSubmitter()".to_string()
438    }
439}
440
441#[pyclass(name = "DydxGrpcClient")]
442#[derive(Debug, Clone)]
443pub struct PyDydxGrpcClient {
444    pub(crate) inner: Arc<DydxGrpcClient>,
445}
446
447#[pymethods]
448impl PyDydxGrpcClient {
449    /// Create a new gRPC client.
450    ///
451    /// # Errors
452    ///
453    /// Returns an error if connection fails.
454    #[staticmethod]
455    #[pyo3(name = "connect")]
456    pub fn py_connect(py: Python<'_>, grpc_url: String) -> PyResult<Bound<'_, PyAny>> {
457        pyo3_async_runtimes::tokio::future_into_py(py, async move {
458            let client = DydxGrpcClient::new(grpc_url)
459                .await
460                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{e}")))?;
461
462            Ok(Self {
463                inner: Arc::new(client),
464            })
465        })
466    }
467
468    /// Create a new gRPC client with fallback URLs.
469    ///
470    /// # Errors
471    ///
472    /// Returns an error if all connection attempts fail.
473    #[staticmethod]
474    #[pyo3(name = "connect_with_fallback")]
475    pub fn py_connect_with_fallback(
476        py: Python<'_>,
477        grpc_urls: Vec<String>,
478    ) -> PyResult<Bound<'_, PyAny>> {
479        pyo3_async_runtimes::tokio::future_into_py(py, async move {
480            let urls: Vec<&str> = grpc_urls.iter().map(String::as_str).collect();
481            let client = DydxGrpcClient::new_with_fallback(&urls)
482                .await
483                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{e}")))?;
484
485            Ok(Self {
486                inner: Arc::new(client),
487            })
488        })
489    }
490
491    /// Fetch the latest block height from the chain.
492    ///
493    /// # Errors
494    ///
495    /// Returns an error if the gRPC request fails.
496    #[pyo3(name = "latest_block_height")]
497    pub fn py_latest_block_height<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
498        let client = self.inner.clone();
499        pyo3_async_runtimes::tokio::future_into_py(py, async move {
500            let mut client = (*client).clone();
501            let height = client
502                .latest_block_height()
503                .await
504                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{e}")))?;
505            Ok(height.0 as u64)
506        })
507    }
508
509    /// Query account information (account_number, sequence).
510    ///
511    /// # Errors
512    ///
513    /// Returns an error if the gRPC request fails.
514    #[pyo3(name = "get_account")]
515    pub fn py_get_account<'py>(
516        &self,
517        py: Python<'py>,
518        address: String,
519    ) -> PyResult<Bound<'py, PyAny>> {
520        let client = self.inner.clone();
521        pyo3_async_runtimes::tokio::future_into_py(py, async move {
522            let mut client = (*client).clone();
523            let account = client
524                .get_account(&address)
525                .await
526                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{e}")))?;
527            Ok((account.account_number, account.sequence))
528        })
529    }
530
531    /// Query account balances.
532    ///
533    /// # Errors
534    ///
535    /// Returns an error if the gRPC request fails.
536    #[pyo3(name = "get_account_balances")]
537    pub fn py_get_account_balances<'py>(
538        &self,
539        py: Python<'py>,
540        address: String,
541    ) -> PyResult<Bound<'py, PyAny>> {
542        let client = self.inner.clone();
543        pyo3_async_runtimes::tokio::future_into_py(py, async move {
544            let mut client = (*client).clone();
545            let balances = client
546                .get_account_balances(&address)
547                .await
548                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{e}")))?;
549            let result: Vec<(String, String)> =
550                balances.into_iter().map(|c| (c.denom, c.amount)).collect();
551            Ok(result)
552        })
553    }
554
555    /// Query subaccount information.
556    ///
557    /// # Errors
558    ///
559    /// Returns an error if the gRPC request fails.
560    #[pyo3(name = "get_subaccount")]
561    pub fn py_get_subaccount<'py>(
562        &self,
563        py: Python<'py>,
564        address: String,
565        subaccount_number: u32,
566    ) -> PyResult<Bound<'py, PyAny>> {
567        let client = self.inner.clone();
568        pyo3_async_runtimes::tokio::future_into_py(py, async move {
569            let mut client = (*client).clone();
570            let subaccount = client
571                .get_subaccount(&address, subaccount_number)
572                .await
573                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{e}")))?;
574
575            // Return as dict-like structure
576            // quantums is bytes representing a big-endian signed integer
577            let result: Vec<(String, String)> = subaccount
578                .asset_positions
579                .into_iter()
580                .map(|p| {
581                    let quantums_str = if p.quantums.is_empty() {
582                        "0".to_string()
583                    } else {
584                        // Convert bytes to hex string for now
585                        hex::encode(&p.quantums)
586                    };
587                    (p.asset_id.to_string(), quantums_str)
588                })
589                .collect();
590            Ok(result)
591        })
592    }
593
594    /// Get node information from the gRPC endpoint.
595    ///
596    /// # Errors
597    ///
598    /// Returns an error if the gRPC request fails.
599    #[pyo3(name = "get_node_info")]
600    pub fn py_get_node_info<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
601        let client = self.inner.clone();
602        pyo3_async_runtimes::tokio::future_into_py(py, async move {
603            let mut client = (*client).clone();
604            let info = client
605                .get_node_info()
606                .await
607                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{e}")))?;
608
609            // Return node info as a dict
610            Python::attach(|py| {
611                use pyo3::types::PyDict;
612                let dict = PyDict::new(py);
613                if let Some(default_node_info) = info.default_node_info {
614                    dict.set_item("network", default_node_info.network)?;
615                    dict.set_item("moniker", default_node_info.moniker)?;
616                    dict.set_item("version", default_node_info.version)?;
617                }
618                if let Some(app_info) = info.application_version {
619                    dict.set_item("app_name", app_info.name)?;
620                    dict.set_item("app_version", app_info.version)?;
621                }
622                Ok(dict.into_py_any_unwrap(py))
623            })
624        })
625    }
626
627    /// Simulate a transaction to estimate gas.
628    ///
629    /// # Errors
630    ///
631    /// Returns an error if the gRPC request fails.
632    #[pyo3(name = "simulate_tx")]
633    pub fn py_simulate_tx<'py>(
634        &self,
635        py: Python<'py>,
636        tx_bytes: Vec<u8>,
637    ) -> PyResult<Bound<'py, PyAny>> {
638        let client = self.inner.clone();
639        pyo3_async_runtimes::tokio::future_into_py(py, async move {
640            let mut client = (*client).clone();
641            let gas_used = client
642                .simulate_tx(tx_bytes)
643                .await
644                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{e}")))?;
645            Ok(gas_used)
646        })
647    }
648
649    /// Get transaction details by hash.
650    ///
651    /// # Errors
652    ///
653    /// Returns an error if the gRPC request fails.
654    #[pyo3(name = "get_tx")]
655    pub fn py_get_tx<'py>(&self, py: Python<'py>, hash: String) -> PyResult<Bound<'py, PyAny>> {
656        let client = self.inner.clone();
657        pyo3_async_runtimes::tokio::future_into_py(py, async move {
658            let mut client = (*client).clone();
659            let tx = client
660                .get_tx(&hash)
661                .await
662                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{e}")))?;
663
664            // Return tx as JSON string
665            let result = format!("Tx(body_bytes_len={})", tx.body.messages.len());
666            Ok(result)
667        })
668    }
669
670    fn __repr__(&self) -> String {
671        "DydxGrpcClient()".to_string()
672    }
673}