Skip to main content

nautilus_hyperliquid/python/
http.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
16use std::collections::HashMap;
17
18use nautilus_core::python::{IntoPyObjectNautilusExt, to_pyvalue_err};
19use nautilus_model::{
20    data::BarType,
21    enums::{OrderSide, OrderType, TimeInForce},
22    identifiers::{AccountId, ClientOrderId, InstrumentId, VenueOrderId},
23    instruments::Instrument,
24    orders::OrderAny,
25    python::{
26        instruments::{instrument_any_to_pyobject, pyobject_to_instrument_any},
27        orders::pyobject_to_order_any,
28    },
29    types::{Price, Quantity},
30};
31use pyo3::{prelude::*, types::PyList};
32use serde_json::to_string;
33
34use crate::{
35    common::enums::HyperliquidEnvironment,
36    http::{client::HyperliquidHttpClient, parse::HyperliquidMarketType},
37};
38
39#[pymethods]
40#[pyo3_stub_gen::derive::gen_stub_pymethods]
41impl HyperliquidHttpClient {
42    /// Provides a high-level HTTP client for the [Hyperliquid](https://hyperliquid.xyz/) REST API.
43    ///
44    /// This domain client wraps `HyperliquidRawHttpClient` and provides methods that work
45    /// with Nautilus domain types. It maintains an instrument cache and handles conversions
46    /// between Hyperliquid API responses and Nautilus domain models.
47    #[new]
48    #[pyo3(signature = (private_key=None, vault_address=None, account_address=None, environment=HyperliquidEnvironment::Mainnet, timeout_secs=60, proxy_url=None, normalize_prices=true))]
49    fn py_new(
50        private_key: Option<String>,
51        vault_address: Option<String>,
52        account_address: Option<String>,
53        environment: HyperliquidEnvironment,
54        timeout_secs: u64,
55        proxy_url: Option<String>,
56        normalize_prices: bool,
57    ) -> PyResult<Self> {
58        let mut client = Self::with_credentials(
59            private_key,
60            vault_address,
61            account_address,
62            environment,
63            timeout_secs,
64            proxy_url,
65        )
66        .map_err(to_pyvalue_err)?;
67        client.set_normalize_prices(normalize_prices);
68        Ok(client)
69    }
70
71    /// Creates an authenticated client from environment variables for the specified network.
72    ///
73    /// # Errors
74    ///
75    /// Returns `Error.Auth` if required environment variables are not set.
76    #[staticmethod]
77    #[pyo3(name = "from_env", signature = (environment=HyperliquidEnvironment::Mainnet))]
78    fn py_from_env(environment: HyperliquidEnvironment) -> PyResult<Self> {
79        Self::from_env(environment).map_err(to_pyvalue_err)
80    }
81
82    /// Creates a new `HyperliquidHttpClient` configured with explicit credentials.
83    #[staticmethod]
84    #[pyo3(name = "from_credentials", signature = (private_key, vault_address=None, environment=HyperliquidEnvironment::Mainnet, timeout_secs=60, proxy_url=None))]
85    fn py_from_credentials(
86        private_key: &str,
87        vault_address: Option<&str>,
88        environment: HyperliquidEnvironment,
89        timeout_secs: u64,
90        proxy_url: Option<String>,
91    ) -> PyResult<Self> {
92        Self::from_credentials(
93            private_key,
94            vault_address,
95            environment,
96            timeout_secs,
97            proxy_url,
98        )
99        .map_err(to_pyvalue_err)
100    }
101
102    /// Caches a single instrument.
103    ///
104    /// This is required for parsing orders, fills, and positions into reports.
105    /// Any existing instrument with the same symbol will be replaced.
106    #[pyo3(name = "cache_instrument")]
107    fn py_cache_instrument(&self, py: Python<'_>, instrument: Py<PyAny>) -> PyResult<()> {
108        self.cache_instrument(&pyobject_to_instrument_any(py, instrument)?);
109        Ok(())
110    }
111
112    /// Set the account ID for this client.
113    ///
114    /// This is required for generating reports with the correct account ID.
115    #[pyo3(name = "set_account_id")]
116    fn py_set_account_id(&mut self, account_id: &str) {
117        let account_id = AccountId::from(account_id);
118        self.set_account_id(account_id);
119    }
120
121    /// Gets the user address derived from the private key (if client has credentials).
122    ///
123    /// # Errors
124    ///
125    /// Returns `Error.Auth` if the client has no signer configured.
126    #[pyo3(name = "get_user_address")]
127    fn py_get_user_address(&self) -> PyResult<String> {
128        self.get_user_address().map_err(to_pyvalue_err)
129    }
130
131    /// Get mapping from spot fill coin identifiers to instrument symbols.
132    ///
133    /// Hyperliquid WebSocket fills for spot use `@{pair_index}` format (e.g., `@107`),
134    /// while instruments are identified by full symbols (e.g., `HYPE-USDC-SPOT`).
135    /// This mapping allows looking up the instrument from a spot fill.
136    ///
137    /// This method also caches the mapping internally for use by fill parsing methods.
138    #[pyo3(name = "get_spot_fill_coin_mapping")]
139    fn py_get_spot_fill_coin_mapping(&self) -> HashMap<String, String> {
140        self.get_spot_fill_coin_mapping()
141            .into_iter()
142            .map(|(k, v)| (k.to_string(), v.to_string()))
143            .collect()
144    }
145
146    /// Get spot metadata (internal helper).
147    #[pyo3(name = "get_spot_meta")]
148    fn py_get_spot_meta<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
149        let client = self.clone();
150        pyo3_async_runtimes::tokio::future_into_py(py, async move {
151            let meta = client.get_spot_meta().await.map_err(to_pyvalue_err)?;
152            to_string(&meta).map_err(to_pyvalue_err)
153        })
154    }
155
156    #[pyo3(name = "get_perp_meta")]
157    fn py_get_perp_meta<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
158        let client = self.clone();
159        pyo3_async_runtimes::tokio::future_into_py(py, async move {
160            let meta = client.load_perp_meta().await.map_err(to_pyvalue_err)?;
161            to_string(&meta).map_err(to_pyvalue_err)
162        })
163    }
164
165    #[pyo3(name = "load_instrument_definitions", signature = (include_spot=true, include_perps=true, include_perps_hip3=false))]
166    fn py_load_instrument_definitions<'py>(
167        &self,
168        py: Python<'py>,
169        include_spot: bool,
170        include_perps: bool,
171        include_perps_hip3: bool,
172    ) -> PyResult<Bound<'py, PyAny>> {
173        let client = self.clone();
174
175        pyo3_async_runtimes::tokio::future_into_py(py, async move {
176            let mut defs = client
177                .request_instrument_defs()
178                .await
179                .map_err(to_pyvalue_err)?;
180
181            defs.retain(|def| match def.market_type {
182                HyperliquidMarketType::Perp => {
183                    if def.is_hip3 {
184                        include_perps_hip3
185                    } else {
186                        include_perps
187                    }
188                }
189                HyperliquidMarketType::Spot => include_spot,
190            });
191
192            let mut instruments = client.convert_defs(defs);
193            instruments.sort_by_key(|instrument| instrument.id());
194
195            Python::attach(|py| {
196                let mut py_instruments = Vec::with_capacity(instruments.len());
197                for instrument in instruments {
198                    py_instruments.push(instrument_any_to_pyobject(py, instrument)?);
199                }
200
201                let py_list = PyList::new(py, &py_instruments)?;
202                Ok(py_list.into_any().unbind())
203            })
204        })
205    }
206
207    #[pyo3(name = "request_quote_ticks", signature = (instrument_id, start=None, end=None, limit=None))]
208    fn py_request_quote_ticks<'py>(
209        &self,
210        py: Python<'py>,
211        instrument_id: InstrumentId,
212        start: Option<chrono::DateTime<chrono::Utc>>,
213        end: Option<chrono::DateTime<chrono::Utc>>,
214        limit: Option<u32>,
215    ) -> PyResult<Bound<'py, PyAny>> {
216        let _ = (instrument_id, start, end, limit);
217        pyo3_async_runtimes::tokio::future_into_py(py, async move {
218            Err::<Vec<u8>, _>(to_pyvalue_err(anyhow::anyhow!(
219                "Hyperliquid does not provide historical quotes via HTTP API"
220            )))
221        })
222    }
223
224    #[pyo3(name = "request_trade_ticks", signature = (instrument_id, start=None, end=None, limit=None))]
225    fn py_request_trade_ticks<'py>(
226        &self,
227        py: Python<'py>,
228        instrument_id: InstrumentId,
229        start: Option<chrono::DateTime<chrono::Utc>>,
230        end: Option<chrono::DateTime<chrono::Utc>>,
231        limit: Option<u32>,
232    ) -> PyResult<Bound<'py, PyAny>> {
233        let _ = (instrument_id, start, end, limit);
234        pyo3_async_runtimes::tokio::future_into_py(py, async move {
235            Err::<Vec<u8>, _>(to_pyvalue_err(anyhow::anyhow!(
236                "Hyperliquid does not provide historical market trades via HTTP API"
237            )))
238        })
239    }
240
241    /// Request historical bars for an instrument.
242    ///
243    /// Fetches candle data from the Hyperliquid API and converts it to Nautilus bars.
244    /// Incomplete bars (where end_timestamp >= current time) are filtered out.
245    ///
246    /// # References
247    ///
248    /// <https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#candles-snapshot>
249    #[pyo3(name = "request_bars", signature = (bar_type, start=None, end=None, limit=None))]
250    fn py_request_bars<'py>(
251        &self,
252        py: Python<'py>,
253        bar_type: BarType,
254        start: Option<chrono::DateTime<chrono::Utc>>,
255        end: Option<chrono::DateTime<chrono::Utc>>,
256        limit: Option<u32>,
257    ) -> PyResult<Bound<'py, PyAny>> {
258        let client = self.clone();
259
260        pyo3_async_runtimes::tokio::future_into_py(py, async move {
261            let bars = client
262                .request_bars(bar_type, start, end, limit)
263                .await
264                .map_err(to_pyvalue_err)?;
265
266            Python::attach(|py| {
267                let pylist = PyList::new(py, bars.into_iter().map(|b| b.into_py_any_unwrap(py)))?;
268                Ok(pylist.into_py_any_unwrap(py))
269            })
270        })
271    }
272
273    /// Submits an order to the exchange.
274    #[pyo3(name = "submit_order", signature = (
275        instrument_id,
276        client_order_id,
277        order_side,
278        order_type,
279        quantity,
280        time_in_force,
281        price=None,
282        trigger_price=None,
283        post_only=false,
284        reduce_only=false,
285    ))]
286    #[expect(clippy::too_many_arguments)]
287    fn py_submit_order<'py>(
288        &self,
289        py: Python<'py>,
290        instrument_id: InstrumentId,
291        client_order_id: ClientOrderId,
292        order_side: OrderSide,
293        order_type: OrderType,
294        quantity: Quantity,
295        time_in_force: TimeInForce,
296        price: Option<Price>,
297        trigger_price: Option<Price>,
298        post_only: bool,
299        reduce_only: bool,
300    ) -> PyResult<Bound<'py, PyAny>> {
301        let client = self.clone();
302
303        pyo3_async_runtimes::tokio::future_into_py(py, async move {
304            let report = client
305                .submit_order(
306                    instrument_id,
307                    client_order_id,
308                    order_side,
309                    order_type,
310                    quantity,
311                    time_in_force,
312                    price,
313                    trigger_price,
314                    post_only,
315                    reduce_only,
316                )
317                .await
318                .map_err(to_pyvalue_err)?;
319
320            Python::attach(|py| Ok(report.into_py_any_unwrap(py)))
321        })
322    }
323
324    /// Cancel an order on the Hyperliquid exchange.
325    ///
326    /// Can cancel either by venue order ID or client order ID.
327    /// At least one ID must be provided.
328    #[pyo3(name = "cancel_order", signature = (
329        instrument_id,
330        client_order_id=None,
331        venue_order_id=None,
332    ))]
333    fn py_cancel_order<'py>(
334        &self,
335        py: Python<'py>,
336        instrument_id: InstrumentId,
337        client_order_id: Option<ClientOrderId>,
338        venue_order_id: Option<VenueOrderId>,
339    ) -> PyResult<Bound<'py, PyAny>> {
340        let client = self.clone();
341
342        pyo3_async_runtimes::tokio::future_into_py(py, async move {
343            client
344                .cancel_order(instrument_id, client_order_id, venue_order_id)
345                .await
346                .map_err(to_pyvalue_err)?;
347            Ok(())
348        })
349    }
350
351    /// Modify an order on the Hyperliquid exchange.
352    ///
353    /// The HL modify API requires a full replacement order spec plus the
354    /// venue order ID. The caller must provide all order fields.
355    #[pyo3(name = "modify_order")]
356    #[expect(clippy::too_many_arguments)]
357    fn py_modify_order<'py>(
358        &self,
359        py: Python<'py>,
360        instrument_id: InstrumentId,
361        venue_order_id: VenueOrderId,
362        order_side: OrderSide,
363        order_type: OrderType,
364        price: Price,
365        quantity: Quantity,
366        trigger_price: Option<Price>,
367        reduce_only: bool,
368        post_only: bool,
369        time_in_force: TimeInForce,
370        client_order_id: Option<ClientOrderId>,
371    ) -> PyResult<Bound<'py, PyAny>> {
372        let client = self.clone();
373
374        pyo3_async_runtimes::tokio::future_into_py(py, async move {
375            client
376                .modify_order(
377                    instrument_id,
378                    venue_order_id,
379                    order_side,
380                    order_type,
381                    price,
382                    quantity,
383                    trigger_price,
384                    reduce_only,
385                    post_only,
386                    time_in_force,
387                    client_order_id,
388                )
389                .await
390                .map_err(to_pyvalue_err)?;
391            Ok(())
392        })
393    }
394
395    /// Submit multiple orders to the Hyperliquid exchange in a single request.
396    #[pyo3(name = "submit_orders")]
397    fn py_submit_orders<'py>(
398        &self,
399        py: Python<'py>,
400        orders: Vec<Py<PyAny>>,
401    ) -> PyResult<Bound<'py, PyAny>> {
402        let client = self.clone();
403
404        pyo3_async_runtimes::tokio::future_into_py(py, async move {
405            let order_anys: Vec<OrderAny> = Python::attach(|py| {
406                orders
407                    .into_iter()
408                    .map(|order| pyobject_to_order_any(py, order))
409                    .collect::<PyResult<Vec<_>>>()
410                    .map_err(to_pyvalue_err)
411            })?;
412
413            let order_refs: Vec<&OrderAny> = order_anys.iter().collect();
414
415            let reports = client
416                .submit_orders(&order_refs)
417                .await
418                .map_err(to_pyvalue_err)?;
419
420            Python::attach(|py| {
421                let pylist =
422                    PyList::new(py, reports.into_iter().map(|r| r.into_py_any_unwrap(py)))?;
423                Ok(pylist.into_py_any_unwrap(py))
424            })
425        })
426    }
427
428    /// Request order status reports for a user.
429    ///
430    /// Fetches open orders via `info_frontend_open_orders` and parses them into OrderStatusReports.
431    /// This method requires instruments to be added to the client cache via `cache_instrument()`.
432    ///
433    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
434    /// will be created automatically.
435    #[pyo3(name = "request_order_status_reports")]
436    fn py_request_order_status_reports<'py>(
437        &self,
438        py: Python<'py>,
439        instrument_id: Option<&str>,
440    ) -> PyResult<Bound<'py, PyAny>> {
441        let client = self.clone();
442        let instrument_id = instrument_id.map(InstrumentId::from);
443
444        pyo3_async_runtimes::tokio::future_into_py(py, async move {
445            let account_address = client.get_account_address().map_err(to_pyvalue_err)?;
446            let reports = client
447                .request_order_status_reports(&account_address, instrument_id)
448                .await
449                .map_err(to_pyvalue_err)?;
450
451            Python::attach(|py| {
452                let pylist =
453                    PyList::new(py, reports.into_iter().map(|r| r.into_py_any_unwrap(py)))?;
454                Ok(pylist.into_py_any_unwrap(py))
455            })
456        })
457    }
458
459    /// Request a single order status report by venue order ID.
460    ///
461    /// Queries `info_frontend_open_orders` and filters for the given oid so the
462    /// result includes trigger metadata (trigger_px, tpsl, trailing_stop, etc.).
463    /// Falls back to `info_order_status` when the order is no longer open.
464    #[pyo3(name = "request_order_status_report")]
465    #[pyo3(signature = (venue_order_id=None, client_order_id=None))]
466    fn py_request_order_status_report<'py>(
467        &self,
468        py: Python<'py>,
469        venue_order_id: Option<&str>,
470        client_order_id: Option<&str>,
471    ) -> PyResult<Bound<'py, PyAny>> {
472        let client = self.clone();
473        let venue_order_id = venue_order_id.map(VenueOrderId::from);
474        let client_order_id = client_order_id.map(ClientOrderId::from);
475
476        pyo3_async_runtimes::tokio::future_into_py(py, async move {
477            if venue_order_id.is_none() && client_order_id.is_none() {
478                return Err(to_pyvalue_err(
479                    "at least one of venue_order_id or client_order_id is required",
480                ));
481            }
482
483            let account_address = client.get_account_address().map_err(to_pyvalue_err)?;
484
485            if let Some(coid) = client_order_id.as_ref()
486                && let Some(report) = client
487                    .request_order_status_report_by_client_order_id(&account_address, coid)
488                    .await
489                    .map_err(to_pyvalue_err)?
490            {
491                return Python::attach(|py| Ok(report.into_py_any_unwrap(py)));
492            }
493
494            let report = if let Some(vid) = venue_order_id.as_ref() {
495                let oid: u64 = vid
496                    .as_str()
497                    .parse()
498                    .map_err(|e| to_pyvalue_err(format!("invalid venue_order_id: {e}")))?;
499
500                client
501                    .request_order_status_report(&account_address, oid)
502                    .await
503                    .map_err(to_pyvalue_err)?
504            } else {
505                None
506            };
507
508            Python::attach(|py| match report {
509                Some(r) => Ok(r.into_py_any_unwrap(py)),
510                None => Ok(py.None()),
511            })
512        })
513    }
514
515    /// Request fill reports for a user.
516    ///
517    /// Fetches user fills via `info_user_fills` and parses them into FillReports.
518    /// This method requires instruments to be added to the client cache via `cache_instrument()`.
519    ///
520    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
521    /// will be created automatically.
522    #[pyo3(name = "request_fill_reports")]
523    fn py_request_fill_reports<'py>(
524        &self,
525        py: Python<'py>,
526        instrument_id: Option<&str>,
527    ) -> PyResult<Bound<'py, PyAny>> {
528        let client = self.clone();
529        let instrument_id = instrument_id.map(InstrumentId::from);
530
531        pyo3_async_runtimes::tokio::future_into_py(py, async move {
532            let account_address = client.get_account_address().map_err(to_pyvalue_err)?;
533            let reports = client
534                .request_fill_reports(&account_address, instrument_id)
535                .await
536                .map_err(to_pyvalue_err)?;
537
538            Python::attach(|py| {
539                let pylist =
540                    PyList::new(py, reports.into_iter().map(|r| r.into_py_any_unwrap(py)))?;
541                Ok(pylist.into_py_any_unwrap(py))
542            })
543        })
544    }
545
546    /// Request position status reports for a user.
547    ///
548    /// Fetches perp clearinghouse state and spot clearinghouse state, then returns
549    /// the union of perp asset positions (short/long with PnL) and spot holdings
550    /// (long only). This method requires instruments to be added to the client
551    /// cache via `cache_instrument()`.
552    ///
553    /// When `instrument_id` resolves to a specific product type, the opposite
554    /// product's endpoint is skipped to avoid wasted round trips and make
555    /// filtered queries independent of the unused endpoint's availability.
556    ///
557    /// For vault tokens (starting with "vntls:") that are not in the cache,
558    /// synthetic instruments will be created automatically. Spot balances whose
559    /// base token has no cached instrument are skipped with a debug log.
560    #[pyo3(name = "request_position_status_reports")]
561    fn py_request_position_status_reports<'py>(
562        &self,
563        py: Python<'py>,
564        instrument_id: Option<&str>,
565    ) -> PyResult<Bound<'py, PyAny>> {
566        let client = self.clone();
567        let instrument_id = instrument_id.map(InstrumentId::from);
568
569        pyo3_async_runtimes::tokio::future_into_py(py, async move {
570            let account_address = client.get_account_address().map_err(to_pyvalue_err)?;
571            let reports = client
572                .request_position_status_reports(&account_address, instrument_id)
573                .await
574                .map_err(to_pyvalue_err)?;
575
576            Python::attach(|py| {
577                let pylist =
578                    PyList::new(py, reports.into_iter().map(|r| r.into_py_any_unwrap(py)))?;
579                Ok(pylist.into_py_any_unwrap(py))
580            })
581        })
582    }
583
584    /// Request account state (balances and margins) for a user.
585    ///
586    /// Fetches perp and spot clearinghouse state from Hyperliquid and merges them
587    /// into a single `AccountState`. USDC is taken from the perp margin summary
588    /// when present (to avoid double-counting combined `withdrawable`); non-USDC
589    /// tokens are appended from the spot balances.
590    ///
591    /// # Errors
592    ///
593    /// Returns an error if `account_id` is not set, or if either the perp or
594    /// spot clearinghouse request fails. Spot failures are propagated so the
595    /// caller sees real API errors instead of a silently truncated snapshot.
596    #[pyo3(name = "request_account_state")]
597    fn py_request_account_state<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
598        let client = self.clone();
599
600        pyo3_async_runtimes::tokio::future_into_py(py, async move {
601            let account_address = client.get_account_address().map_err(to_pyvalue_err)?;
602            let account_state = client
603                .request_account_state(&account_address)
604                .await
605                .map_err(to_pyvalue_err)?;
606
607            Python::attach(|py| Ok(account_state.into_py_any_unwrap(py)))
608        })
609    }
610
611    /// Request spot token balances for a user.
612    ///
613    /// Fetches `spotClearinghouseState` and returns one `AccountBalance` per
614    /// non-zero token. USDC is included as a separate balance entry when present;
615    /// callers that also report perp margin state must dedupe currencies before
616    /// emitting an `AccountState`.
617    ///
618    /// # Errors
619    ///
620    /// Returns an error if the API request fails or the response cannot be parsed.
621    #[pyo3(name = "request_spot_balances")]
622    fn py_request_spot_balances<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
623        let client = self.clone();
624
625        pyo3_async_runtimes::tokio::future_into_py(py, async move {
626            let account_address = client.get_account_address().map_err(to_pyvalue_err)?;
627            let balances = client
628                .request_spot_balances(&account_address)
629                .await
630                .map_err(to_pyvalue_err)?;
631
632            Python::attach(|py| {
633                let pylist =
634                    PyList::new(py, balances.into_iter().map(|b| b.into_py_any_unwrap(py)))?;
635                Ok(pylist.into_py_any_unwrap(py))
636            })
637        })
638    }
639
640    /// Request spot position status reports for a user.
641    ///
642    /// Each non-zero spot balance is reported as a Long position against its
643    /// `{BASE}-{QUOTE}-SPOT` instrument. Balances whose base token has no
644    /// matching instrument in the cache are skipped with a debug log (callers
645    /// should ensure `request_instruments` has run
646    /// first).
647    #[pyo3(name = "request_spot_position_status_reports")]
648    fn py_request_spot_position_status_reports<'py>(
649        &self,
650        py: Python<'py>,
651        instrument_id: Option<&str>,
652    ) -> PyResult<Bound<'py, PyAny>> {
653        let client = self.clone();
654        let instrument_id = instrument_id.map(InstrumentId::from);
655
656        pyo3_async_runtimes::tokio::future_into_py(py, async move {
657            let account_address = client.get_account_address().map_err(to_pyvalue_err)?;
658            let reports = client
659                .request_spot_position_status_reports(&account_address, instrument_id)
660                .await
661                .map_err(to_pyvalue_err)?;
662
663            Python::attach(|py| {
664                let pylist =
665                    PyList::new(py, reports.into_iter().map(|r| r.into_py_any_unwrap(py)))?;
666                Ok(pylist.into_py_any_unwrap(py))
667            })
668        })
669    }
670
671    /// Get spot clearinghouse state (per-token spot balances) for a user.
672    #[pyo3(name = "info_spot_clearinghouse_state")]
673    fn py_info_spot_clearinghouse_state<'py>(
674        &self,
675        py: Python<'py>,
676    ) -> PyResult<Bound<'py, PyAny>> {
677        let client = self.clone();
678
679        pyo3_async_runtimes::tokio::future_into_py(py, async move {
680            let account_address = client.get_account_address().map_err(to_pyvalue_err)?;
681            let json = client
682                .info_spot_clearinghouse_state(&account_address)
683                .await
684                .map_err(to_pyvalue_err)?;
685            to_string(&json).map_err(to_pyvalue_err)
686        })
687    }
688
689    /// Get user fee schedule and effective rates.
690    #[pyo3(name = "info_user_fees")]
691    fn py_info_user_fees<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
692        let client = self.clone();
693
694        pyo3_async_runtimes::tokio::future_into_py(py, async move {
695            let account_address = client.get_account_address().map_err(to_pyvalue_err)?;
696            let json = client
697                .info_user_fees(&account_address)
698                .await
699                .map_err(to_pyvalue_err)?;
700            to_string(&json).map_err(to_pyvalue_err)
701        })
702    }
703}