Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions src/exchange/exchange_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,20 @@ impl ExchangeClient {
&self,
orders: Vec<ClientOrderRequest>,
wallet: Option<&PrivateKeySigner>,
) -> Result<ExchangeResponseStatus> {
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<ClientOrderRequest>,
wallet: Option<&PrivateKeySigner>,
grouping: &str,
) -> Result<ExchangeResponseStatus> {
let wallet = wallet.unwrap_or(&self.wallet);
let timestamp = next_nonce();
Expand All @@ -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)?;
Expand All @@ -508,10 +522,24 @@ impl ExchangeClient {
}

pub async fn bulk_order_with_builder(
&self,
orders: Vec<ClientOrderRequest>,
wallet: Option<&PrivateKeySigner>,
builder: BuilderInfo,
) -> Result<ExchangeResponseStatus> {
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<ClientOrderRequest>,
wallet: Option<&PrivateKeySigner>,
mut builder: BuilderInfo,
grouping: &str,
) -> Result<ExchangeResponseStatus> {
let wallet = wallet.unwrap_or(&self.wallet);
let timestamp = next_nonce();
Expand All @@ -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)?;
Expand Down
19 changes: 17 additions & 2 deletions src/info/info_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*,
Expand Down Expand Up @@ -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": <addr>}`); 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<SpotUserStateResponse> {
let input = InfoRequest::UserTokenBalances { user: address };
self.send_info_request(input).await
}

pub async fn user_fees(&self, address: Address) -> Result<UserFeesResponse> {
let input = InfoRequest::UserFees { user: address };
self.send_info_request(input).await
Expand Down
53 changes: 53 additions & 0 deletions src/info/response_structs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,59 @@ pub struct UserTokenBalanceResponse {
pub balances: Vec<UserTokenBalance>,
}

/// 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<SpotBalance>,
}

#[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 {
Expand Down
93 changes: 90 additions & 3 deletions src/ws/message_types.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use serde::Deserialize;
use serde::{Deserialize, Serialize};

use crate::ws::sub_structs::*;

Expand All @@ -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,
}
Expand All @@ -37,7 +37,7 @@ pub struct OrderUpdates {
pub data: Vec<OrderUpdate>,
}

#[derive(Deserialize, Clone, Debug)]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UserFundings {
pub data: UserFundingsData,
}
Expand Down Expand Up @@ -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");
}
}
14 changes: 7 additions & 7 deletions src/ws/sub_structs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ pub struct AllMidsData {
pub mids: HashMap<String, String>,
}

#[derive(Deserialize, Clone, Debug)]
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct TradeInfo {
pub coin: String,
Expand All @@ -56,15 +56,15 @@ 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<bool>,
pub user: Address,
pub fills: Vec<TradeInfo>,
}

#[derive(Deserialize, Clone, Debug)]
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub enum UserData {
Fills(Vec<TradeInfo>),
Expand All @@ -73,7 +73,7 @@ pub enum UserData {
NonUserCancel(Vec<NonUserCancel>),
}

#[derive(Deserialize, Clone, Debug)]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Liquidation {
pub lid: u64,
pub liquidator: String,
Expand All @@ -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,
Expand Down Expand Up @@ -133,15 +133,15 @@ pub struct BasicOrder {
pub cloid: Option<String>,
}

#[derive(Deserialize, Clone, Debug)]
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct UserFundingsData {
pub is_snapshot: Option<bool>,
pub user: Address,
pub fundings: Vec<UserFunding>,
}

#[derive(Deserialize, Clone, Debug)]
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct UserFunding {
pub time: u64,
Expand Down