Skip to content

Commit

Permalink
feat(ExchangeClient): Add market open and close methods (#55)
Browse files Browse the repository at this point in the history
## Description

This PR introduces `market_open` and `market_close` methods to the
`ExchangeClient`, aligning the Rust SDK's functionality with the
existing Hyperliquid Python SDK.

## Key Changes

- Added `market_open` method for placing market buy/sell orders
- Added `market_close` method for closing positions with market orders
- Implemented price slippage calculation with respect to asset-specific
decimal requirements
- Added helper functions for rounding to significant figures and decimal
places

## Implementation Details

- Mirrors the functionality of the Python SDK's `market_open` and
`market_close` methods
- Uses the existing `InfoClient` to fetch asset metadata and current
mid-prices
- Simulates market orders using IOC (Immediate-or-Cancel) limit orders
- Respects Hyperliquid's tick size and lot size rules for each asset

## Usage Example

```rust
let market_open_params = MarketOrderParams {
    asset: "ETH",
    is_buy: true,
    sz: 0.01,
    px: None,
    slippage: Some(0.01),
    cloid: None,
    wallet: None,
};

let response = exchange_client.market_open(market_open_params).await?;
```

## Testing

- Manually tested against Hyperliquid testnet
- Verified behavior matches Python SDK output for equivalent inputs

<img width="1332" alt="Screenshot 2024-09-04 at 02 19 37"
src="https://github.com/user-attachments/assets/10629c0c-973b-43a5-86c1-bc553a1cf397">


## Related Issues

Closes #44
  • Loading branch information
mathdroid authored Sep 4, 2024
1 parent 8c65509 commit 44c0beb
Show file tree
Hide file tree
Showing 4 changed files with 245 additions and 1 deletion.
79 changes: 79 additions & 0 deletions src/bin/market_order_and_cancel.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
use ethers::signers::LocalWallet;
use log::info;

use hyperliquid_rust_sdk::{
BaseUrl, ExchangeClient, ExchangeDataStatus, ExchangeResponseStatus, MarketCloseParams,
MarketOrderParams,
};
use std::{thread::sleep, time::Duration};

#[tokio::main]
async fn main() {
env_logger::init();
// Key was randomly generated for testing and shouldn't be used with any real funds
let wallet: LocalWallet = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e"
.parse()
.unwrap();

let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None)
.await
.unwrap();

// Market open order
let market_open_params = MarketOrderParams {
asset: "ETH",
is_buy: true,
sz: 0.01,
px: None,
slippage: Some(0.01), // 1% slippage
cloid: None,
wallet: None,
};

let response = exchange_client
.market_open(market_open_params)
.await
.unwrap();
info!("Market open order placed: {response:?}");

let response = match response {
ExchangeResponseStatus::Ok(exchange_response) => exchange_response,
ExchangeResponseStatus::Err(e) => panic!("Error with exchange response: {e}"),
};
let status = response.data.unwrap().statuses[0].clone();
match status {
ExchangeDataStatus::Filled(order) => info!("Order filled: {order:?}"),
ExchangeDataStatus::Resting(order) => info!("Order resting: {order:?}"),
_ => panic!("Unexpected status: {status:?}"),
};

// Wait for a while before closing the position
sleep(Duration::from_secs(10));

// Market close order
let market_close_params = MarketCloseParams {
asset: "ETH",
sz: None, // Close entire position
px: None,
slippage: Some(0.01), // 1% slippage
cloid: None,
wallet: None,
};

let response = exchange_client
.market_close(market_close_params)
.await
.unwrap();
info!("Market close order placed: {response:?}");

let response = match response {
ExchangeResponseStatus::Ok(exchange_response) => exchange_response,
ExchangeResponseStatus::Err(e) => panic!("Error with exchange response: {e}"),
};
let status = response.data.unwrap().statuses[0].clone();
match status {
ExchangeDataStatus::Filled(order) => info!("Close order filled: {order:?}"),
ExchangeDataStatus::Resting(order) => info!("Close order resting: {order:?}"),
_ => panic!("Unexpected status: {status:?}"),
};
}
142 changes: 142 additions & 0 deletions src/exchange/exchange_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;

use super::cancel::ClientCancelRequestCloid;
use super::order::{MarketCloseParams, MarketOrderParams};
use super::{ClientLimit, ClientOrder};

pub struct ExchangeClient {
pub http_client: HttpClient,
Expand Down Expand Up @@ -224,6 +226,133 @@ impl ExchangeClient {
self.post(action, signature, timestamp).await
}

pub async fn market_open(
&self,
params: MarketOrderParams<'_>,
) -> Result<ExchangeResponseStatus> {
let slippage = params.slippage.unwrap_or(0.05); // Default 5% slippage
let (px, sz_decimals) = self
.calculate_slippage_price(params.asset, params.is_buy, slippage, params.px)
.await?;

let order = ClientOrderRequest {
asset: params.asset.to_string(),
is_buy: params.is_buy,
reduce_only: false,
limit_px: px,
sz: round_to_decimals(params.sz, sz_decimals),
cloid: params.cloid,
order_type: ClientOrder::Limit(ClientLimit {
tif: "Ioc".to_string(),
}),
};

self.order(order, params.wallet).await
}

pub async fn market_close(
&self,
params: MarketCloseParams<'_>,
) -> Result<ExchangeResponseStatus> {
let slippage = params.slippage.unwrap_or(0.05); // Default 5% slippage
let wallet = params.wallet.unwrap_or(&self.wallet);

let base_url = match self.http_client.base_url.as_str() {
"https://api.hyperliquid.xyz" => BaseUrl::Mainnet,
"https://api.hyperliquid-testnet.xyz" => BaseUrl::Testnet,
_ => return Err(Error::GenericRequest("Invalid base URL".to_string())),
};
let info_client = InfoClient::new(None, Some(base_url)).await?;
let user_state = info_client.user_state(wallet.address()).await?;

let position = user_state
.asset_positions
.iter()
.find(|p| p.position.coin == params.asset)
.ok_or_else(|| Error::AssetNotFound)?;

let szi = position
.position
.szi
.parse::<f64>()
.map_err(|_| Error::FloatStringParse)?;

let (px, sz_decimals) = self
.calculate_slippage_price(params.asset, szi < 0.0, slippage, params.px)
.await?;

let sz = round_to_decimals(params.sz.unwrap_or_else(|| szi.abs()), sz_decimals);

let order = ClientOrderRequest {
asset: params.asset.to_string(),
is_buy: szi < 0.0,
reduce_only: true,
limit_px: px,
sz,
cloid: params.cloid,
order_type: ClientOrder::Limit(ClientLimit {
tif: "Ioc".to_string(),
}),
};

self.order(order, Some(wallet)).await
}

async fn calculate_slippage_price(
&self,
asset: &str,
is_buy: bool,
slippage: f64,
px: Option<f64>,
) -> Result<(f64, u32)> {
let base_url = match self.http_client.base_url.as_str() {
"https://api.hyperliquid.xyz" => BaseUrl::Mainnet,
"https://api.hyperliquid-testnet.xyz" => BaseUrl::Testnet,
_ => return Err(Error::GenericRequest("Invalid base URL".to_string())),
};
let info_client = InfoClient::new(None, Some(base_url)).await?;
let meta = info_client.meta().await?;

let asset_meta = meta
.universe
.iter()
.find(|a| a.name == asset)
.ok_or_else(|| Error::AssetNotFound)?;

let sz_decimals = asset_meta.sz_decimals;
let max_decimals: u32 = if self.coin_to_asset[asset] < 10000 {
6
} else {
8
};
let price_decimals = max_decimals.saturating_sub(sz_decimals);

let px = if let Some(px) = px {
px
} else {
let all_mids = info_client.all_mids().await?;
all_mids
.get(asset)
.ok_or_else(|| Error::AssetNotFound)?
.parse::<f64>()
.map_err(|_| Error::FloatStringParse)?
};

debug!("px before slippage: {px:?}");
let slippage_factor = if is_buy {
1.0 + slippage
} else {
1.0 - slippage
};
let px = px * slippage_factor;

// Round to the correct number of decimal places and significant figures
let px = round_to_significant_and_decimal(px, 5, price_decimals);

debug!("px after slippage: {px:?}");
Ok((px, sz_decimals))
}

pub async fn order(
&self,
order: ClientOrderRequest,
Expand Down Expand Up @@ -497,6 +626,19 @@ impl ExchangeClient {
}
}

fn round_to_decimals(value: f64, decimals: u32) -> f64 {
let factor = 10f64.powi(decimals as i32);
(value * factor).round() / factor
}

fn round_to_significant_and_decimal(value: f64, sig_figs: u32, max_decimals: u32) -> f64 {
let abs_value = value.abs();
let magnitude = abs_value.log10().floor() as i32;
let scale = 10f64.powi(sig_figs as i32 - magnitude - 1);
let rounded = (abs_value * scale).round() / scale;
round_to_decimals(rounded.copysign(value), max_decimals)
}

#[cfg(test)]
mod tests {
use std::str::FromStr;
Expand Down
5 changes: 4 additions & 1 deletion src/exchange/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ pub use actions::*;
pub use cancel::{ClientCancelRequest, ClientCancelRequestCloid};
pub use exchange_client::*;
pub use exchange_responses::*;
pub use order::{ClientLimit, ClientOrder, ClientOrderRequest, ClientTrigger, Order};
pub use order::{
ClientLimit, ClientOrder, ClientOrderRequest, ClientTrigger, MarketCloseParams,
MarketOrderParams, Order,
};
20 changes: 20 additions & 0 deletions src/exchange/order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use crate::{
helpers::{float_to_string_for_hashing, uuid_to_hex_string},
prelude::*,
};
use ethers::signers::LocalWallet;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
Expand Down Expand Up @@ -56,6 +57,25 @@ pub struct ClientTrigger {
pub tpsl: String,
}

pub struct MarketOrderParams<'a> {
pub asset: &'a str,
pub is_buy: bool,
pub sz: f64,
pub px: Option<f64>,
pub slippage: Option<f64>,
pub cloid: Option<Uuid>,
pub wallet: Option<&'a LocalWallet>,
}

pub struct MarketCloseParams<'a> {
pub asset: &'a str,
pub sz: Option<f64>,
pub px: Option<f64>,
pub slippage: Option<f64>,
pub cloid: Option<Uuid>,
pub wallet: Option<&'a LocalWallet>,
}

pub enum ClientOrder {
Limit(ClientLimit),
Trigger(ClientTrigger),
Expand Down

0 comments on commit 44c0beb

Please sign in to comment.