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