diff --git a/src/exchange/exchange_client.rs b/src/exchange/exchange_client.rs index 70b686b..34b9b7b 100644 --- a/src/exchange/exchange_client.rs +++ b/src/exchange/exchange_client.rs @@ -484,6 +484,20 @@ impl ExchangeClient { &self, orders: Vec, wallet: Option<&PrivateKeySigner>, + ) -> Result { + self.bulk_order_with_grouping(orders, wallet, "na").await + } + + /// Like `bulk_order`, but lets the caller specify the L1 `grouping` + /// field on the action. Valid values include `"na"` (default for + /// `bulk_order`), `"normalTpsl"` (atomic IOC entry + paired SL + /// trigger), and `"positionTpsl"`. Mirrors the `grouping` kwarg the + /// Python SDK exposes on `exchange.bulk_orders`. + pub async fn bulk_order_with_grouping( + &self, + orders: Vec, + wallet: Option<&PrivateKeySigner>, + grouping: &str, ) -> Result { let wallet = wallet.unwrap_or(&self.wallet); let timestamp = next_nonce(); @@ -496,7 +510,7 @@ impl ExchangeClient { let action = Actions::Order(BulkOrder { orders: transformed_orders, - grouping: "na".to_string(), + grouping: grouping.to_string(), builder: None, }); let connection_id = action.hash(timestamp, self.vault_address)?; @@ -508,10 +522,24 @@ impl ExchangeClient { } pub async fn bulk_order_with_builder( + &self, + orders: Vec, + wallet: Option<&PrivateKeySigner>, + builder: BuilderInfo, + ) -> Result { + self.bulk_order_with_builder_and_grouping(orders, wallet, builder, "na") + .await + } + + /// Like `bulk_order_with_builder`, but lets the caller specify the + /// L1 `grouping` field. See `bulk_order_with_grouping` for the + /// accepted values. + pub async fn bulk_order_with_builder_and_grouping( &self, orders: Vec, wallet: Option<&PrivateKeySigner>, mut builder: BuilderInfo, + grouping: &str, ) -> Result { let wallet = wallet.unwrap_or(&self.wallet); let timestamp = next_nonce(); @@ -526,7 +554,7 @@ impl ExchangeClient { let action = Actions::Order(BulkOrder { orders: transformed_orders, - grouping: "na".to_string(), + grouping: grouping.to_string(), builder: Some(builder), }); let connection_id = action.hash(timestamp, self.vault_address)?; diff --git a/src/info/info_client.rs b/src/info/info_client.rs index b3d8ba2..16203d4 100644 --- a/src/info/info_client.rs +++ b/src/info/info_client.rs @@ -8,8 +8,8 @@ use tokio::sync::mpsc::UnboundedSender; use crate::{ info::{ ActiveAssetDataResponse, CandlesSnapshotResponse, FundingHistoryResponse, - L2SnapshotResponse, OpenOrdersResponse, OrderInfo, RecentTradesResponse, UserFillsResponse, - UserStateResponse, + L2SnapshotResponse, OpenOrdersResponse, OrderInfo, RecentTradesResponse, + SpotUserStateResponse, UserFillsResponse, UserStateResponse, }, meta::{AssetContext, Meta, SpotMeta, SpotMetaAndAssetCtxs}, prelude::*, @@ -202,6 +202,21 @@ impl InfoClient { self.send_info_request(input).await } + /// Spot clearinghouse view — returns per-coin spot balances for `address`. + /// + /// Mirrors the Python SDK's `Info.spot_user_state(addr)` so the Rust port + /// can compute total account equity as `perps + spot USDC` (CLAUDE.md + /// §Position sizing — the 2026-04-22 incident class). + /// + /// Wire-identical to `user_token_balances` (both POST + /// `{"type": "spotClearinghouseState", "user": }`); the response + /// type differs only in that `SpotUserStateResponse::balances` omits the + /// `entry_ntl` field for parity with the Python `dict` shape. + pub async fn spot_user_state(&self, address: Address) -> Result { + let input = InfoRequest::UserTokenBalances { user: address }; + self.send_info_request(input).await + } + pub async fn user_fees(&self, address: Address) -> Result { let input = InfoRequest::UserFees { user: address }; self.send_info_request(input).await diff --git a/src/info/response_structs.rs b/src/info/response_structs.rs index 221dbf9..61336a1 100644 --- a/src/info/response_structs.rs +++ b/src/info/response_structs.rs @@ -22,6 +22,59 @@ pub struct UserTokenBalanceResponse { pub balances: Vec, } +/// Per-coin spot balance, returned by `InfoClient::spot_user_state`. +/// +/// Mirrors the Python SDK's `dict` shape (`{"coin", "hold", "total"}`) so +/// downstream code computing `perps + spot USDC` for equity (CLAUDE.md +/// §Position sizing, incident 2026-04-22) can read the same fields it would +/// in Python. All numeric values are wire-encoded as strings by HL. +/// +/// The HL API also returns an `entryNtl` field; serde ignores unknown fields +/// by default, so it is intentionally omitted here for parity with Python. +#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SpotBalance { + pub coin: String, + pub hold: String, + pub total: String, +} + +/// Response shape for `InfoClient::spot_user_state` — the spot clearinghouse +/// equivalent of `UserStateResponse`. Equity computations sum +/// `marginSummary.accountValue` (perps) plus `balances[coin="USDC"].total` +/// (this struct, spot). +#[derive(Deserialize, Debug, Clone)] +pub struct SpotUserStateResponse { + pub balances: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Canned fixture matches the real public-API shape returned by + /// `POST /info` with `{"type": "spotClearinghouseState", "user": ...}`. + /// The `entryNtl` key is present on the wire; deserialisation drops it. + #[test] + fn spot_user_state_response_deserialises_canned_fixture() { + let payload = r#"{ + "balances": [ + {"coin": "USDC", "hold": "0.0", "total": "1234.56", "entryNtl": "0.0"}, + {"coin": "PURR", "hold": "0.0", "total": "42.0", "entryNtl": "0.0"} + ] + }"#; + + let parsed: SpotUserStateResponse = + serde_json::from_str(payload).expect("canned fixture must deserialise"); + assert_eq!(parsed.balances.len(), 2); + assert_eq!(parsed.balances[0].coin, "USDC"); + assert_eq!(parsed.balances[0].total, "1234.56"); + assert_eq!(parsed.balances[0].hold, "0.0"); + assert_eq!(parsed.balances[1].coin, "PURR"); + assert_eq!(parsed.balances[1].total, "42.0"); + } +} + #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct UserFeesResponse { diff --git a/src/ws/message_types.rs b/src/ws/message_types.rs index 332a9e2..8a549be 100644 --- a/src/ws/message_types.rs +++ b/src/ws/message_types.rs @@ -1,4 +1,4 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::ws::sub_structs::*; @@ -17,7 +17,7 @@ pub struct AllMids { pub data: AllMidsData, } -#[derive(Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct User { pub data: UserData, } @@ -37,7 +37,7 @@ pub struct OrderUpdates { pub data: Vec, } -#[derive(Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct UserFundings { pub data: UserFundingsData, } @@ -76,3 +76,90 @@ pub struct ActiveAssetData { pub struct Bbo { pub data: BboData, } + +#[cfg(test)] +mod tests { + //! Round-trip tests for the `User` and `UserFundings` WSS message + //! wrappers. The Hathor `InfoBridge::msg_to_value` translates these + //! into `serde_json::Value` envelopes for downstream callbacks; the + //! trailing-stop / partial-TP logic depends on the inner `fills` and + //! `fundings` payloads reaching the callback (Hathor #215). + use super::*; + use crate::ws::sub_structs::{TradeInfo, UserData, UserFunding, UserFundingsData}; + use alloy::primitives::Address; + + fn sample_address() -> Address { + "0x0000000000000000000000000000000000000001" + .parse() + .expect("static valid address") + } + + fn sample_trade_info() -> TradeInfo { + TradeInfo { + coin: "BTC".to_string(), + side: "B".to_string(), + px: "50000.0".to_string(), + sz: "0.01".to_string(), + time: 1_700_000_000_000, + hash: "0xabc".to_string(), + start_position: "0.0".to_string(), + dir: "Open Long".to_string(), + closed_pnl: "0.0".to_string(), + oid: 42, + cloid: None, + crossed: true, + fee: "0.5".to_string(), + fee_token: "USDC".to_string(), + tid: 7, + } + } + + #[test] + fn user_data_round_trips_with_fills() { + let user = User { + data: UserData::Fills(vec![sample_trade_info()]), + }; + let value = serde_json::to_value(&user).expect("serialize User"); + + // Wrapper serialises as `{"data": {"fills": [...]}}` because + // `UserData` is an internally-tagged enum with `Fills` variant + // becoming `{"fills": [...]}` under serde's default enum repr. + let fills = value + .pointer("/data/fills") + .and_then(|v| v.as_array()) + .expect("fills array present at /data/fills"); + assert_eq!(fills.len(), 1, "one fill expected"); + assert_eq!(fills[0]["coin"], "BTC"); + assert_eq!(fills[0]["px"], "50000.0"); + assert_eq!(fills[0]["side"], "B"); + assert_eq!(fills[0]["oid"], 42); + assert_eq!(fills[0]["tid"], 7); + } + + #[test] + fn user_fundings_round_trips() { + let fundings = UserFundings { + data: UserFundingsData { + is_snapshot: Some(true), + user: sample_address(), + fundings: vec![UserFunding { + time: 1_700_000_000_000, + coin: "BTC".to_string(), + usdc: "1.5".to_string(), + szi: "0.01".to_string(), + funding_rate: "0.0001".to_string(), + }], + }, + }; + let value = serde_json::to_value(&fundings).expect("serialize UserFundings"); + + let inner = value + .pointer("/data/fundings") + .and_then(|v| v.as_array()) + .expect("fundings array present at /data/fundings"); + assert_eq!(inner.len(), 1); + assert_eq!(inner[0]["coin"], "BTC"); + assert_eq!(inner[0]["usdc"], "1.5"); + assert_eq!(inner[0]["fundingRate"], "0.0001"); + } +} diff --git a/src/ws/sub_structs.rs b/src/ws/sub_structs.rs index 4c3c94e..54e5bee 100644 --- a/src/ws/sub_structs.rs +++ b/src/ws/sub_structs.rs @@ -36,7 +36,7 @@ pub struct AllMidsData { pub mids: HashMap, } -#[derive(Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct TradeInfo { pub coin: String, @@ -56,7 +56,7 @@ pub struct TradeInfo { pub tid: u64, } -#[derive(Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct UserFillsData { pub is_snapshot: Option, @@ -64,7 +64,7 @@ pub struct UserFillsData { pub fills: Vec, } -#[derive(Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub enum UserData { Fills(Vec), @@ -73,7 +73,7 @@ pub enum UserData { NonUserCancel(Vec), } -#[derive(Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct Liquidation { pub lid: u64, pub liquidator: String, @@ -82,7 +82,7 @@ pub struct Liquidation { pub liquidated_account_value: String, } -#[derive(Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct NonUserCancel { pub coin: String, pub oid: u64, @@ -133,7 +133,7 @@ pub struct BasicOrder { pub cloid: Option, } -#[derive(Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct UserFundingsData { pub is_snapshot: Option, @@ -141,7 +141,7 @@ pub struct UserFundingsData { pub fundings: Vec, } -#[derive(Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct UserFunding { pub time: u64,