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