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