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::{IntoPyObjectExt, prelude::*, types::PyList};
32use rust_decimal::Decimal;
33use serde_json::to_string;
34
35use crate::{
36    common::enums::HyperliquidEnvironment,
37    http::{client::HyperliquidHttpClient, parse::HyperliquidMarketType},
38};
39
40#[pymethods]
41#[pyo3_stub_gen::derive::gen_stub_pymethods]
42impl HyperliquidHttpClient {
43    /// Provides a high-level HTTP client for the [Hyperliquid](https://hyperliquid.xyz/) REST API.
44    ///
45    /// This domain client wraps `HyperliquidRawHttpClient` and provides methods that work
46    /// with Nautilus domain types. It maintains an instrument cache and handles conversions
47    /// between Hyperliquid API responses and Nautilus domain models.
48    #[new]
49    #[pyo3(signature = (private_key=None, vault_address=None, account_address=None, environment=HyperliquidEnvironment::Mainnet, timeout_secs=60, proxy_url=None, normalize_prices=true, include_builder_attribution=true))]
50    #[expect(clippy::too_many_arguments)]
51    fn py_new(
52        private_key: Option<String>,
53        vault_address: Option<String>,
54        account_address: Option<&str>,
55        environment: HyperliquidEnvironment,
56        timeout_secs: u64,
57        proxy_url: Option<String>,
58        normalize_prices: bool,
59        include_builder_attribution: bool,
60    ) -> PyResult<Self> {
61        let mut client = Self::with_credentials(
62            private_key,
63            vault_address,
64            account_address,
65            environment,
66            timeout_secs,
67            proxy_url,
68        )
69        .map_err(to_pyvalue_err)?;
70        client.set_normalize_prices(normalize_prices);
71        client.set_include_builder_attribution(include_builder_attribution);
72        Ok(client)
73    }
74
75    /// Creates an authenticated client from environment variables for the specified network.
76    ///
77    /// # Errors
78    ///
79    /// Returns `Error.Auth` if required environment variables are not set.
80    #[staticmethod]
81    #[pyo3(name = "from_env", signature = (environment=HyperliquidEnvironment::Mainnet, include_builder_attribution=true))]
82    fn py_from_env(
83        environment: HyperliquidEnvironment,
84        include_builder_attribution: bool,
85    ) -> PyResult<Self> {
86        let mut client = Self::from_env(environment).map_err(to_pyvalue_err)?;
87        client.set_include_builder_attribution(include_builder_attribution);
88        Ok(client)
89    }
90
91    /// Creates a new `HyperliquidHttpClient` configured with explicit credentials.
92    ///
93    /// # Errors
94    ///
95    /// Returns `Error.Auth` if the private key is invalid or cannot be parsed.
96    #[staticmethod]
97    #[pyo3(name = "from_credentials", signature = (private_key, vault_address=None, environment=HyperliquidEnvironment::Mainnet, timeout_secs=60, proxy_url=None, include_builder_attribution=true))]
98    fn py_from_credentials(
99        private_key: &str,
100        vault_address: Option<&str>,
101        environment: HyperliquidEnvironment,
102        timeout_secs: u64,
103        proxy_url: Option<String>,
104        include_builder_attribution: bool,
105    ) -> PyResult<Self> {
106        let mut client = Self::from_credentials(
107            private_key,
108            vault_address,
109            environment,
110            timeout_secs,
111            proxy_url,
112        )
113        .map_err(to_pyvalue_err)?;
114        client.set_include_builder_attribution(include_builder_attribution);
115        Ok(client)
116    }
117
118    /// Caches a single instrument.
119    ///
120    /// This is required for parsing orders, fills, and positions into reports.
121    /// Any existing instrument with the same symbol will be replaced.
122    ///
123    /// The venue asset index is taken from the instrument's `info` map so an
124    /// instrument arriving on the message bus becomes submittable without
125    /// refetching venue metadata. An instrument without the key keeps its
126    /// existing asset index, if any, because guessing one would route orders to
127    /// the wrong asset.
128    #[pyo3(name = "cache_instrument")]
129    fn py_cache_instrument(&self, py: Python<'_>, instrument: Py<PyAny>) -> PyResult<()> {
130        self.cache_instrument(&pyobject_to_instrument_any(py, instrument)?);
131        Ok(())
132    }
133
134    /// Set the account ID for this client.
135    ///
136    /// This is required for generating reports with the correct account ID.
137    #[pyo3(name = "set_account_id")]
138    fn py_set_account_id(&mut self, account_id: &str) {
139        let account_id = AccountId::from(account_id);
140        self.set_account_id(account_id);
141    }
142
143    /// Gets the user address derived from the private key (if client has credentials).
144    ///
145    /// # Errors
146    ///
147    /// Returns `Error.Auth` if the client has no signer configured.
148    #[pyo3(name = "get_user_address")]
149    fn py_get_user_address(&self) -> PyResult<String> {
150        self.get_user_address().map_err(to_pyvalue_err)
151    }
152
153    /// Get mapping from spot fill coin identifiers to instrument symbols.
154    ///
155    /// Hyperliquid WebSocket fills for spot use `@{pair_index}` format (e.g., `@107`),
156    /// while instruments are identified by full symbols (e.g., `HYPE-USDC-SPOT`).
157    /// This mapping allows looking up the instrument from a spot fill.
158    ///
159    /// This method also caches the mapping internally for use by fill parsing methods.
160    #[pyo3(name = "get_spot_fill_coin_mapping")]
161    fn py_get_spot_fill_coin_mapping(&self) -> HashMap<String, String> {
162        self.get_spot_fill_coin_mapping()
163            .into_iter()
164            .map(|(k, v)| (k.to_string(), v.to_string()))
165            .collect()
166    }
167
168    /// Gets spot metadata for internal use.
169    #[pyo3(name = "get_spot_meta")]
170    fn py_get_spot_meta<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
171        let client = self.clone();
172        pyo3_async_runtimes::tokio::future_into_py(py, async move {
173            let meta = client.get_spot_meta().await.map_err(to_pyvalue_err)?;
174            to_string(&meta).map_err(to_pyvalue_err)
175        })
176    }
177
178    #[pyo3(name = "get_perp_meta")]
179    fn py_get_perp_meta<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
180        let client = self.clone();
181        pyo3_async_runtimes::tokio::future_into_py(py, async move {
182            let meta = client.load_perp_meta().await.map_err(to_pyvalue_err)?;
183            to_string(&meta).map_err(to_pyvalue_err)
184        })
185    }
186
187    /// Builds the `allDexsAssetCtxs` normalization map from dex name to ordered instrument IDs.
188    ///
189    /// The order of instrument IDs must match the venue universe ordering for each perp dex so
190    /// incoming `ctxs` arrays can be normalized without leaking raw positional payloads.
191    #[pyo3(name = "build_all_dex_asset_ctxs_instrument_ids")]
192    fn py_build_all_dex_asset_ctxs_instrument_ids<'py>(
193        &self,
194        py: Python<'py>,
195    ) -> PyResult<Bound<'py, PyAny>> {
196        let client = self.clone();
197        pyo3_async_runtimes::tokio::future_into_py(py, async move {
198            let mapping = client
199                .build_all_dex_asset_ctxs_instrument_ids()
200                .await
201                .map_err(to_pyvalue_err)?;
202            Ok(mapping.into_iter().collect::<HashMap<_, _>>())
203        })
204    }
205
206    #[pyo3(name = "load_instrument_definitions", signature = (include_spot=true, include_perps=true, include_perps_hip3=false, include_outcomes=false))]
207    fn py_load_instrument_definitions<'py>(
208        &self,
209        py: Python<'py>,
210        include_spot: bool,
211        include_perps: bool,
212        include_perps_hip3: bool,
213        include_outcomes: bool,
214    ) -> PyResult<Bound<'py, PyAny>> {
215        let client = self.clone();
216
217        pyo3_async_runtimes::tokio::future_into_py(py, async move {
218            let mut defs = client
219                .request_instrument_defs()
220                .await
221                .map_err(to_pyvalue_err)?;
222
223            defs.retain(|def| match def.market_type {
224                HyperliquidMarketType::Perp => {
225                    if def.is_hip3 {
226                        include_perps_hip3
227                    } else {
228                        include_perps
229                    }
230                }
231                HyperliquidMarketType::Spot => include_spot,
232                HyperliquidMarketType::Outcome => include_outcomes,
233            });
234
235            let mut instruments = client.convert_defs(defs);
236            instruments.sort_by_key(|instrument| instrument.id());
237
238            Python::attach(|py| {
239                let mut py_instruments = Vec::with_capacity(instruments.len());
240                for instrument in instruments {
241                    py_instruments.push(instrument_any_to_pyobject(py, instrument)?);
242                }
243
244                let py_list = PyList::new(py, &py_instruments)?;
245                Ok(py_list.into_any().unbind())
246            })
247        })
248    }
249
250    #[pyo3(name = "request_quote_ticks", signature = (instrument_id, start=None, end=None, limit=None))]
251    fn py_request_quote_ticks<'py>(
252        &self,
253        py: Python<'py>,
254        instrument_id: InstrumentId,
255        start: Option<jiff::Timestamp>,
256        end: Option<jiff::Timestamp>,
257        limit: Option<u32>,
258    ) -> PyResult<Bound<'py, PyAny>> {
259        let _ = (instrument_id, start, end, limit);
260        pyo3_async_runtimes::tokio::future_into_py(py, async move {
261            Err::<Vec<u8>, _>(to_pyvalue_err(anyhow::anyhow!(
262                "Hyperliquid does not provide historical quotes via HTTP API"
263            )))
264        })
265    }
266
267    #[pyo3(name = "request_trade_ticks", signature = (instrument_id, start=None, end=None, limit=None))]
268    fn py_request_trade_ticks<'py>(
269        &self,
270        py: Python<'py>,
271        instrument_id: InstrumentId,
272        start: Option<jiff::Timestamp>,
273        end: Option<jiff::Timestamp>,
274        limit: Option<u32>,
275    ) -> PyResult<Bound<'py, PyAny>> {
276        let _ = (instrument_id, start, end, limit);
277        pyo3_async_runtimes::tokio::future_into_py(py, async move {
278            Err::<Vec<u8>, _>(to_pyvalue_err(anyhow::anyhow!(
279                "Hyperliquid does not provide historical market trades via HTTP API"
280            )))
281        })
282    }
283
284    /// Request the recent public trade snapshot for an instrument.
285    ///
286    /// Hyperliquid's `recentTrades` endpoint is a bounded newest-first snapshot,
287    /// rather than a range-query endpoint. The returned trades are normalized to
288    /// ascending event time and then constrained to the requested window.
289    ///
290    /// A self-hosted node without the indexer responds with HTTP 422. This is
291    /// treated as no available coverage so requests can still complete.
292    #[pyo3(name = "request_public_trades", signature = (instrument_id, start=None, end=None, limit=None))]
293    #[gen_stub(override_return_type(type_repr = "typing.Any", imports = ("typing",)))]
294    fn py_request_public_trades<'py>(
295        &self,
296        py: Python<'py>,
297        instrument_id: InstrumentId,
298        start: Option<jiff::Timestamp>,
299        end: Option<jiff::Timestamp>,
300        limit: Option<u32>,
301    ) -> PyResult<Bound<'py, PyAny>> {
302        let client = self.clone();
303
304        pyo3_async_runtimes::tokio::future_into_py(py, async move {
305            let trades = client
306                .request_public_trades(instrument_id, start, end, limit.map(|limit| limit as usize))
307                .await
308                .map_err(to_pyvalue_err)?;
309
310            Python::attach(|py| {
311                let py_trades = trades
312                    .into_iter()
313                    .map(|trade| trade.into_py_any(py))
314                    .collect::<PyResult<Vec<_>>>()?;
315                let pylist = PyList::new(py, py_trades)?;
316                Ok(pylist.into_py_any_unwrap(py))
317            })
318        })
319    }
320
321    /// Request historical bars for an instrument.
322    ///
323    /// Fetches candle data from the Hyperliquid API and converts it to Nautilus bars.
324    /// Incomplete bars (where end_timestamp >= current time) are filtered out.
325    ///
326    /// # Errors
327    ///
328    /// Returns an error if:
329    /// - The instrument is not found in cache.
330    /// - The bar aggregation is unsupported by Hyperliquid.
331    /// - The API request fails.
332    /// - Parsing fails.
333    ///
334    /// # References
335    ///
336    /// <https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#candles-snapshot>
337    #[pyo3(name = "request_bars", signature = (bar_type, start=None, end=None, limit=None))]
338    fn py_request_bars<'py>(
339        &self,
340        py: Python<'py>,
341        bar_type: BarType,
342        start: Option<jiff::Timestamp>,
343        end: Option<jiff::Timestamp>,
344        limit: Option<u32>,
345    ) -> PyResult<Bound<'py, PyAny>> {
346        let client = self.clone();
347
348        pyo3_async_runtimes::tokio::future_into_py(py, async move {
349            let bars = client
350                .request_bars(bar_type, start, end, limit)
351                .await
352                .map_err(to_pyvalue_err)?;
353
354            Python::attach(|py| {
355                let py_bars = bars
356                    .into_iter()
357                    .map(|bar| bar.into_py_any(py))
358                    .collect::<PyResult<Vec<_>>>()?;
359                let pylist = PyList::new(py, py_bars)?;
360                Ok(pylist.into_py_any_unwrap(py))
361            })
362        })
363    }
364
365    /// Submits an order to the exchange.
366    ///
367    /// # Errors
368    ///
369    /// Returns an error if credentials are missing, order validation fails, serialization fails,
370    /// or the API returns an error.
371    #[pyo3(name = "submit_order", signature = (
372        instrument_id,
373        client_order_id,
374        order_side,
375        order_type,
376        quantity,
377        time_in_force,
378        price=None,
379        trigger_price=None,
380        post_only=false,
381        reduce_only=false,
382    ))]
383    #[expect(clippy::too_many_arguments)]
384    fn py_submit_order<'py>(
385        &self,
386        py: Python<'py>,
387        instrument_id: InstrumentId,
388        client_order_id: ClientOrderId,
389        order_side: OrderSide,
390        order_type: OrderType,
391        quantity: Quantity,
392        time_in_force: TimeInForce,
393        price: Option<Price>,
394        trigger_price: Option<Price>,
395        post_only: bool,
396        reduce_only: bool,
397    ) -> PyResult<Bound<'py, PyAny>> {
398        let client = self.clone();
399
400        pyo3_async_runtimes::tokio::future_into_py(py, async move {
401            let report = client
402                .submit_order(
403                    instrument_id,
404                    client_order_id,
405                    order_side,
406                    order_type,
407                    quantity,
408                    time_in_force,
409                    price,
410                    trigger_price,
411                    post_only,
412                    reduce_only,
413                )
414                .await
415                .map_err(to_pyvalue_err)?;
416
417            Python::attach(|py| report.into_py_any(py))
418        })
419    }
420
421    /// Cancel an order on the Hyperliquid exchange.
422    ///
423    /// Can cancel either by venue order ID or client order ID.
424    /// At least one ID must be provided.
425    ///
426    /// # Errors
427    ///
428    /// Returns an error if credentials are missing, no order ID is provided,
429    /// or the API returns an error.
430    #[pyo3(name = "cancel_order", signature = (
431        instrument_id,
432        client_order_id=None,
433        venue_order_id=None,
434    ))]
435    fn py_cancel_order<'py>(
436        &self,
437        py: Python<'py>,
438        instrument_id: InstrumentId,
439        client_order_id: Option<ClientOrderId>,
440        venue_order_id: Option<VenueOrderId>,
441    ) -> PyResult<Bound<'py, PyAny>> {
442        let client = self.clone();
443
444        pyo3_async_runtimes::tokio::future_into_py(py, async move {
445            client
446                .cancel_order(instrument_id, client_order_id, venue_order_id)
447                .await
448                .map_err(to_pyvalue_err)?;
449            Ok(())
450        })
451    }
452
453    /// Modify an order on the Hyperliquid exchange.
454    ///
455    /// The HL modify API requires a full replacement order spec plus a venue
456    /// order ID or cached CLOID target. The caller must provide all order fields.
457    ///
458    /// # Errors
459    ///
460    /// Returns an error if the asset index is not found, no safe modify target
461    /// exists, the venue order ID is invalid, or the API returns an error.
462    #[pyo3(name = "modify_order")]
463    #[expect(clippy::too_many_arguments)]
464    fn py_modify_order<'py>(
465        &self,
466        py: Python<'py>,
467        instrument_id: InstrumentId,
468        venue_order_id: Option<VenueOrderId>,
469        order_side: OrderSide,
470        order_type: OrderType,
471        price: Price,
472        quantity: Quantity,
473        trigger_price: Option<Price>,
474        reduce_only: bool,
475        post_only: bool,
476        time_in_force: TimeInForce,
477        client_order_id: Option<ClientOrderId>,
478    ) -> PyResult<Bound<'py, PyAny>> {
479        let client = self.clone();
480
481        pyo3_async_runtimes::tokio::future_into_py(py, async move {
482            client
483                .modify_order(
484                    instrument_id,
485                    venue_order_id,
486                    order_side,
487                    order_type,
488                    price,
489                    quantity,
490                    trigger_price,
491                    reduce_only,
492                    post_only,
493                    time_in_force,
494                    client_order_id,
495                )
496                .await
497                .map_err(to_pyvalue_err)?;
498            Ok(())
499        })
500    }
501
502    /// Submit multiple orders to the Hyperliquid exchange in a single request.
503    ///
504    /// # Errors
505    ///
506    /// Returns an error if credentials are missing, order validation fails, serialization fails,
507    /// or the API returns an error. Also returns an error for any quote-denominated quantity:
508    /// this raw path has no cached market data for a quote-to-base conversion, so such orders
509    /// must be submitted through the execution client instead.
510    #[pyo3(name = "submit_orders")]
511    fn py_submit_orders<'py>(
512        &self,
513        py: Python<'py>,
514        orders: Vec<Py<PyAny>>,
515    ) -> PyResult<Bound<'py, PyAny>> {
516        let client = self.clone();
517
518        pyo3_async_runtimes::tokio::future_into_py(py, async move {
519            let order_anys: Vec<OrderAny> = Python::attach(|py| {
520                orders
521                    .into_iter()
522                    .map(|order| pyobject_to_order_any(py, order))
523                    .collect::<PyResult<Vec<_>>>()
524                    .map_err(to_pyvalue_err)
525            })?;
526
527            let order_refs: Vec<&OrderAny> = order_anys.iter().collect();
528
529            let reports = client
530                .submit_orders(&order_refs)
531                .await
532                .map_err(to_pyvalue_err)?;
533
534            Python::attach(|py| {
535                let py_reports = reports
536                    .into_iter()
537                    .map(|report| report.into_py_any(py))
538                    .collect::<PyResult<Vec<_>>>()?;
539                let pylist = PyList::new(py, py_reports)?;
540                Ok(pylist.into_py_any_unwrap(py))
541            })
542        })
543    }
544
545    /// Request order status reports for a user.
546    ///
547    /// Fetches frontend open orders from the default and all cached builder dexes when unfiltered,
548    /// or from the dex selected by an instrument filter, then parses them into OrderStatusReports.
549    /// This method requires instruments to be added to the client cache via `cache_instrument()`.
550    ///
551    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
552    /// will be created automatically.
553    ///
554    /// # Errors
555    ///
556    /// Returns an error if the API request fails, parsing fails, or a venue row cannot be resolved
557    /// to an instrument or converted into a report (the snapshot is then incomplete and must not be
558    /// treated as authoritative).
559    #[pyo3(name = "request_order_status_reports")]
560    fn py_request_order_status_reports<'py>(
561        &self,
562        py: Python<'py>,
563        instrument_id: Option<&str>,
564    ) -> PyResult<Bound<'py, PyAny>> {
565        let client = self.clone();
566        let instrument_id = instrument_id.map(InstrumentId::from);
567
568        pyo3_async_runtimes::tokio::future_into_py(py, async move {
569            let account_address = client.get_account_address().map_err(to_pyvalue_err)?;
570            let reports = client
571                .request_order_status_reports(&account_address, instrument_id)
572                .await
573                .map_err(to_pyvalue_err)?;
574
575            Python::attach(|py| {
576                let py_reports = reports
577                    .into_iter()
578                    .map(|report| report.into_py_any(py))
579                    .collect::<PyResult<Vec<_>>>()?;
580                let pylist = PyList::new(py, py_reports)?;
581                Ok(pylist.into_py_any_unwrap(py))
582            })
583        })
584    }
585
586    /// Request a single order status report by venue order ID.
587    ///
588    /// Queries `info_frontend_open_orders` and filters for the given oid so the
589    /// result includes trigger metadata (trigger_px, tpsl, trailing_stop, etc.).
590    /// Falls back to `info_order_status` when the order is no longer open.
591    ///
592    /// # Errors
593    ///
594    /// Returns an error if the API request fails, parsing fails, or the matched venue row cannot be
595    /// resolved to an instrument or converted into a report. A genuinely absent order returns
596    /// `Ok(None)`.
597    #[pyo3(name = "request_order_status_report")]
598    #[pyo3(signature = (venue_order_id=None, client_order_id=None))]
599    fn py_request_order_status_report<'py>(
600        &self,
601        py: Python<'py>,
602        venue_order_id: Option<&str>,
603        client_order_id: Option<&str>,
604    ) -> PyResult<Bound<'py, PyAny>> {
605        let client = self.clone();
606        let venue_order_id = venue_order_id.map(VenueOrderId::from);
607        let client_order_id = client_order_id.map(ClientOrderId::from);
608
609        pyo3_async_runtimes::tokio::future_into_py(py, async move {
610            if venue_order_id.is_none() && client_order_id.is_none() {
611                return Err(to_pyvalue_err(
612                    "at least one of venue_order_id or client_order_id is required",
613                ));
614            }
615
616            let account_address = client.get_account_address().map_err(to_pyvalue_err)?;
617
618            if let Some(coid) = client_order_id.as_ref()
619                && let Some(report) = client
620                    .request_order_status_report_by_client_order_id(&account_address, coid)
621                    .await
622                    .map_err(to_pyvalue_err)?
623            {
624                return Python::attach(|py| report.into_py_any(py));
625            }
626
627            let report = if let Some(vid) = venue_order_id.as_ref() {
628                let oid: u64 = vid
629                    .as_str()
630                    .parse()
631                    .map_err(|e| to_pyvalue_err(format!("invalid venue_order_id: {e}")))?;
632
633                client
634                    .request_order_status_report(&account_address, oid)
635                    .await
636                    .map_err(to_pyvalue_err)?
637            } else {
638                None
639            };
640
641            Python::attach(|py| match report {
642                Some(report) => report.into_py_any(py),
643                None => Ok(py.None()),
644            })
645        })
646    }
647
648    /// Request fill reports for a user.
649    ///
650    /// Fetches user fills via `info_user_fills` and parses them into FillReports.
651    /// This method requires instruments to be added to the client cache via `cache_instrument()`.
652    ///
653    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
654    /// will be created automatically.
655    ///
656    /// # Errors
657    ///
658    /// Returns an error if the API request fails, parsing fails, or a venue row cannot be resolved
659    /// to an instrument or converted into a report (the snapshot is then incomplete and must not be
660    /// treated as authoritative).
661    ///
662    /// Returns an error if `account_id` is not set on the client.
663    #[pyo3(name = "request_fill_reports")]
664    fn py_request_fill_reports<'py>(
665        &self,
666        py: Python<'py>,
667        instrument_id: Option<&str>,
668    ) -> PyResult<Bound<'py, PyAny>> {
669        let client = self.clone();
670        let instrument_id = instrument_id.map(InstrumentId::from);
671
672        pyo3_async_runtimes::tokio::future_into_py(py, async move {
673            let account_address = client.get_account_address().map_err(to_pyvalue_err)?;
674            let reports = client
675                .request_fill_reports(&account_address, instrument_id)
676                .await
677                .map_err(to_pyvalue_err)?;
678
679            Python::attach(|py| {
680                let py_reports = reports
681                    .into_iter()
682                    .map(|report| report.into_py_any(py))
683                    .collect::<PyResult<Vec<_>>>()?;
684                let pylist = PyList::new(py, py_reports)?;
685                Ok(pylist.into_py_any_unwrap(py))
686            })
687        })
688    }
689
690    /// Request position status reports for a user.
691    ///
692    /// Fetches clearinghouse state from the default and all cached builder dexes when unfiltered,
693    /// plus spot clearinghouse state, then returns the union of perp asset positions (short/long
694    /// with PnL) and spot holdings (long only). This method requires instruments to be added to the
695    /// client cache via `cache_instrument()`.
696    ///
697    /// When `instrument_id` resolves to a specific product type, the opposite
698    /// product's endpoint is skipped to avoid wasted round trips and make
699    /// filtered queries independent of the unused endpoint's availability.
700    /// HIP-4 outcomes live in `spotClearinghouseState`, so an outcome filter
701    /// is routed like a spot filter (perp leg skipped).
702    ///
703    /// For vault tokens (starting with "vntls:") that are not in the cache,
704    /// synthetic instruments will be created automatically.
705    ///
706    /// # Errors
707    ///
708    /// Returns an error if any clearinghouse request fails (when that product or dex is in scope),
709    /// parsing fails, or a venue row cannot be resolved to an instrument or converted into a
710    /// report (the snapshot is then incomplete and must not be treated as authoritative).
711    ///
712    /// Returns an error if `account_id` has not been set on the client.
713    #[pyo3(name = "request_position_status_reports")]
714    fn py_request_position_status_reports<'py>(
715        &self,
716        py: Python<'py>,
717        instrument_id: Option<&str>,
718    ) -> PyResult<Bound<'py, PyAny>> {
719        let client = self.clone();
720        let instrument_id = instrument_id.map(InstrumentId::from);
721
722        pyo3_async_runtimes::tokio::future_into_py(py, async move {
723            let account_address = client.get_account_address().map_err(to_pyvalue_err)?;
724            let reports = client
725                .request_position_status_reports(&account_address, instrument_id)
726                .await
727                .map_err(to_pyvalue_err)?;
728
729            Python::attach(|py| {
730                let py_reports = reports
731                    .into_iter()
732                    .map(|report| report.into_py_any(py))
733                    .collect::<PyResult<Vec<_>>>()?;
734                let pylist = PyList::new(py, py_reports)?;
735                Ok(pylist.into_py_any_unwrap(py))
736            })
737        })
738    }
739
740    /// Request account state (balances and margins) for a user.
741    ///
742    /// Fetches perp and spot clearinghouse state from Hyperliquid and merges them
743    /// into a single `AccountState`. USDC comes from the perp margin summary only
744    /// when that summary reflects non-zero collateral, margin used, or withdrawable
745    /// balance; if the summary is absent or zeroed, spot USDC is used instead. Non-USDC
746    /// tokens are always appended from the spot balances.
747    ///
748    /// # Errors
749    ///
750    /// Returns an error if `account_id` is not set, or if either the perp or
751    /// spot clearinghouse request fails. Spot failures are propagated so the
752    /// caller sees real API errors instead of a silently truncated snapshot.
753    #[pyo3(name = "request_account_state")]
754    fn py_request_account_state<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
755        let client = self.clone();
756
757        pyo3_async_runtimes::tokio::future_into_py(py, async move {
758            let account_address = client.get_account_address().map_err(to_pyvalue_err)?;
759            let account_state = client
760                .request_account_state(&account_address)
761                .await
762                .map_err(to_pyvalue_err)?;
763
764            Python::attach(|py| account_state.into_py_any(py))
765        })
766    }
767
768    /// Request spot token balances for a user.
769    ///
770    /// Fetches `spotClearinghouseState` and returns one `AccountBalance` per
771    /// non-zero token. USDC is included as a separate balance entry when present;
772    /// callers that also report perp margin state must dedupe currencies before
773    /// emitting an `AccountState`.
774    ///
775    /// # Errors
776    ///
777    /// Returns an error if the API request fails or the response cannot be parsed.
778    #[pyo3(name = "request_spot_balances")]
779    fn py_request_spot_balances<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
780        let client = self.clone();
781
782        pyo3_async_runtimes::tokio::future_into_py(py, async move {
783            let account_address = client.get_account_address().map_err(to_pyvalue_err)?;
784            let balances = client
785                .request_spot_balances(&account_address)
786                .await
787                .map_err(to_pyvalue_err)?;
788
789            Python::attach(|py| {
790                let py_balances = balances
791                    .into_iter()
792                    .map(|balance| balance.into_py_any(py))
793                    .collect::<PyResult<Vec<_>>>()?;
794                let pylist = PyList::new(py, py_balances)?;
795                Ok(pylist.into_py_any_unwrap(py))
796            })
797        })
798    }
799
800    /// Request spot position status reports for a user.
801    ///
802    /// Each non-zero spot balance is reported as a Long position against its
803    /// `{BASE}-{QUOTE}-SPOT` instrument. HIP-4 outcome side tokens arrive on
804    /// this same endpoint with `coin` set to the `+<encoding>` token form;
805    /// those balances are resolved against the matching Outcome instrument so
806    /// outcome holdings surface as positions through the standard reconcile
807    /// path.
808    ///
809    /// # Errors
810    ///
811    /// Returns an error if `account_id` has not been set, the API request fails,
812    /// or a non-zero balance cannot be resolved to an instrument or converted
813    /// into a report (the snapshot is then incomplete and must not be treated
814    /// as authoritative).
815    #[pyo3(name = "request_spot_position_status_reports")]
816    fn py_request_spot_position_status_reports<'py>(
817        &self,
818        py: Python<'py>,
819        instrument_id: Option<&str>,
820    ) -> PyResult<Bound<'py, PyAny>> {
821        let client = self.clone();
822        let instrument_id = instrument_id.map(InstrumentId::from);
823
824        pyo3_async_runtimes::tokio::future_into_py(py, async move {
825            let account_address = client.get_account_address().map_err(to_pyvalue_err)?;
826            let reports = client
827                .request_spot_position_status_reports(&account_address, instrument_id)
828                .await
829                .map_err(to_pyvalue_err)?;
830
831            Python::attach(|py| {
832                let py_reports = reports
833                    .into_iter()
834                    .map(|report| report.into_py_any(py))
835                    .collect::<PyResult<Vec<_>>>()?;
836                let pylist = PyList::new(py, py_reports)?;
837                Ok(pylist.into_py_any_unwrap(py))
838            })
839        })
840    }
841
842    /// Get spot clearinghouse state (per-token spot balances) for a user.
843    #[pyo3(name = "info_spot_clearinghouse_state")]
844    fn py_info_spot_clearinghouse_state<'py>(
845        &self,
846        py: Python<'py>,
847    ) -> PyResult<Bound<'py, PyAny>> {
848        let client = self.clone();
849
850        pyo3_async_runtimes::tokio::future_into_py(py, async move {
851            let account_address = client.get_account_address().map_err(to_pyvalue_err)?;
852            let json = client
853                .info_spot_clearinghouse_state(&account_address)
854                .await
855                .map_err(to_pyvalue_err)?;
856            to_string(&json).map_err(to_pyvalue_err)
857        })
858    }
859
860    /// Get user fee schedule and effective rates.
861    #[pyo3(name = "info_user_fees")]
862    fn py_info_user_fees<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
863        let client = self.clone();
864
865        pyo3_async_runtimes::tokio::future_into_py(py, async move {
866            let account_address = client.get_account_address().map_err(to_pyvalue_err)?;
867            let json = client
868                .info_user_fees(&account_address)
869                .await
870                .map_err(to_pyvalue_err)?;
871            to_string(&json).map_err(to_pyvalue_err)
872        })
873    }
874
875    /// Split an HIP-4 outcome's quote tokens into matched Yes and No side tokens.
876    ///
877    /// Submits a `userOutcome` exchange action with the `splitOutcome` operation:
878    /// debits `amount` quote tokens (USDH) and credits `amount` Yes plus `amount`
879    /// No side tokens for the given `outcome` index. Ordinary directional
880    /// buys and sells on outcome instruments go through the standard order path
881    /// without calling this; the action is for dual-side market making and
882    /// inventory creation.
883    ///
884    /// # Errors
885    ///
886    /// Returns an error if credentials are missing, the venue rejects the
887    /// action, or the response cannot be parsed.
888    #[pyo3(name = "submit_split_outcome")]
889    fn py_submit_split_outcome<'py>(
890        &self,
891        py: Python<'py>,
892        outcome: u32,
893        amount: Decimal,
894    ) -> PyResult<Bound<'py, PyAny>> {
895        let client = self.clone();
896
897        pyo3_async_runtimes::tokio::future_into_py(py, async move {
898            let response = client
899                .submit_split_outcome(outcome, amount)
900                .await
901                .map_err(to_pyvalue_err)?;
902            to_string(&response).map_err(to_pyvalue_err)
903        })
904    }
905
906    /// Merge matched Yes + No side-token pairs of an HIP-4 outcome back into quote tokens.
907    ///
908    /// Submits a `userOutcome` action with the `mergeOutcome` operation. Pass
909    /// `amount = None` to merge the maximum mergeable balance (venue-side
910    /// `null`).
911    ///
912    /// # Errors
913    ///
914    /// Returns an error if credentials are missing, the venue rejects the
915    /// action, or the response cannot be parsed.
916    #[pyo3(name = "submit_merge_outcome", signature = (outcome, amount=None))]
917    fn py_submit_merge_outcome<'py>(
918        &self,
919        py: Python<'py>,
920        outcome: u32,
921        amount: Option<Decimal>,
922    ) -> PyResult<Bound<'py, PyAny>> {
923        let client = self.clone();
924
925        pyo3_async_runtimes::tokio::future_into_py(py, async move {
926            let response = client
927                .submit_merge_outcome(outcome, amount)
928                .await
929                .map_err(to_pyvalue_err)?;
930            to_string(&response).map_err(to_pyvalue_err)
931        })
932    }
933
934    /// Merge `Yes` shares of every outcome in a multi-outcome question into quote tokens.
935    ///
936    /// Submits a `userOutcome` action with the `mergeQuestion` operation. Pass
937    /// `amount = None` to merge the maximum balance.
938    ///
939    /// # Errors
940    ///
941    /// Returns an error if credentials are missing, the venue rejects the
942    /// action, or the response cannot be parsed.
943    #[pyo3(name = "submit_merge_question", signature = (question, amount=None))]
944    fn py_submit_merge_question<'py>(
945        &self,
946        py: Python<'py>,
947        question: u32,
948        amount: Option<Decimal>,
949    ) -> PyResult<Bound<'py, PyAny>> {
950        let client = self.clone();
951
952        pyo3_async_runtimes::tokio::future_into_py(py, async move {
953            let response = client
954                .submit_merge_question(question, amount)
955                .await
956                .map_err(to_pyvalue_err)?;
957            to_string(&response).map_err(to_pyvalue_err)
958        })
959    }
960
961    /// Swap `No` shares of one outcome into `Yes` shares of every other outcome.
962    ///
963    /// Submits a `userOutcome` action with the `negateOutcome` operation. Both
964    /// outcomes must belong to the same multi-outcome `question`.
965    ///
966    /// # Errors
967    ///
968    /// Returns an error if credentials are missing, the venue rejects the
969    /// action, or the response cannot be parsed.
970    #[pyo3(name = "submit_negate_outcome")]
971    fn py_submit_negate_outcome<'py>(
972        &self,
973        py: Python<'py>,
974        question: u32,
975        outcome: u32,
976        amount: Decimal,
977    ) -> PyResult<Bound<'py, PyAny>> {
978        let client = self.clone();
979
980        pyo3_async_runtimes::tokio::future_into_py(py, async move {
981            let response = client
982                .submit_negate_outcome(question, outcome, amount)
983                .await
984                .map_err(to_pyvalue_err)?;
985            to_string(&response).map_err(to_pyvalue_err)
986        })
987    }
988}