nautilus_dydx/python/
execution.rs

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