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