#![cfg(feature = "hyperliquid")]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use rust_decimal_macros::dec;
use rustrade_execution::{
client::{
ExecutionClient, OrderStatusClient,
hyperliquid::{HyperliquidClient, Network, config::HyperliquidConfig},
},
order::{
OrderKey, OrderKind, TimeInForce,
id::{ClientOrderId, StrategyId, VenueOrderId},
request::RequestOpen,
state::{ActiveOrderState, InactiveOrderState, OrderState},
},
};
use rustrade_instrument::{
Side, asset::name::AssetNameExchange, exchange::ExchangeId,
instrument::name::InstrumentNameExchange,
};
use std::time::Duration;
use tokio_stream::StreamExt;
use tracing_subscriber::{EnvFilter, fmt};
fn init_logging() {
let _ = fmt()
.with_env_filter(
EnvFilter::builder()
.with_default_directive(tracing::Level::DEBUG.into())
.from_env_lossy(),
)
.try_init();
}
fn test_config() -> HyperliquidConfig {
HyperliquidConfig::from_env().expect(
"HYPERLIQUID_PRIVATE_KEY env var required. Set HYPERLIQUID_TESTNET=true for testnet.",
)
}
fn btc_instrument() -> InstrumentNameExchange {
"BTC-USDC-PERP".into()
}
fn eth_instrument() -> InstrumentNameExchange {
"ETH-USDC-PERP".into()
}
#[tokio::test]
#[ignore]
async fn test_connection() {
init_logging();
let config = test_config();
assert_eq!(
config.network,
Network::Testnet,
"Integration tests must run on testnet"
);
println!("Wallet address: {}", config.wallet_address_hex());
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
assert_eq!(HyperliquidClient::EXCHANGE, ExchangeId::HyperliquidPerp);
println!("Client wallet: {}", client.wallet_address());
println!("Client created successfully");
}
#[tokio::test]
#[ignore]
async fn test_mainnet_authentication() {
init_logging();
let private_key =
std::env::var("HYPERLIQUID_PRIVATE_KEY").expect("HYPERLIQUID_PRIVATE_KEY env var required");
let config = HyperliquidConfig::from_private_key(&private_key, Network::Mainnet)
.expect("Invalid private key");
assert_eq!(
config.network,
Network::Mainnet,
"This test must run on mainnet"
);
println!("Mainnet wallet address: {}", config.wallet_address_hex());
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect to mainnet");
let assets: Vec<AssetNameExchange> = vec![];
let result = client.fetch_balances(&assets).await;
assert!(
result.is_ok(),
"Mainnet fetch_balances failed: {:?}",
result.err()
);
let balances = result.unwrap();
println!("Mainnet balances: {} asset(s)", balances.len());
for balance in &balances {
println!(
" {}: total={}, free={}",
balance.asset, balance.balance.total, balance.balance.free
);
}
println!("Mainnet authentication successful (read-only)");
}
#[tokio::test]
#[ignore]
async fn test_fetch_balances() {
init_logging();
let config = test_config();
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let assets: Vec<AssetNameExchange> = vec![];
let result = client.fetch_balances(&assets).await;
assert!(result.is_ok(), "fetch_balances failed: {:?}", result.err());
let balances = result.unwrap();
println!("Fetched {} balance(s)", balances.len());
for balance in &balances {
println!(
" {}: total={}, free={}",
balance.asset, balance.balance.total, balance.balance.free
);
}
assert!(!balances.is_empty(), "Expected at least USDC balance");
}
#[tokio::test]
#[ignore]
async fn test_account_snapshot() {
init_logging();
let config = test_config();
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let assets: Vec<AssetNameExchange> = vec![];
let instruments: Vec<InstrumentNameExchange> = vec![];
let result = client.account_snapshot(&assets, &instruments).await;
assert!(
result.is_ok(),
"account_snapshot failed: {:?}",
result.err()
);
let snapshot = result.unwrap();
println!("Exchange: {:?}", snapshot.exchange);
println!("Balances: {}", snapshot.balances.len());
for balance in &snapshot.balances {
println!(
" {}: total={}, free={}",
balance.asset, balance.balance.total, balance.balance.free
);
}
println!(
"Instruments with positions/orders: {}",
snapshot.instruments.len()
);
for inst in &snapshot.instruments {
println!(
" {}: orders={}, position={:?}",
inst.instrument,
inst.orders.len(),
inst.position.quantity()
);
}
}
#[tokio::test]
#[ignore]
async fn test_account_snapshot_filtered() {
init_logging();
let config = test_config();
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let assets: Vec<AssetNameExchange> = vec![];
let instruments = vec![btc_instrument(), eth_instrument()];
let result = client.account_snapshot(&assets, &instruments).await;
assert!(
result.is_ok(),
"account_snapshot failed: {:?}",
result.err()
);
let snapshot = result.unwrap();
println!("Filtered snapshot for BTC and ETH perps");
println!("Instruments returned: {}", snapshot.instruments.len());
}
#[tokio::test]
#[ignore]
async fn test_fetch_open_orders() {
init_logging();
let config = test_config();
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let instruments: Vec<InstrumentNameExchange> = vec![];
let result = client.fetch_open_orders(&instruments).await;
assert!(
result.is_ok(),
"fetch_open_orders failed: {:?}",
result.err()
);
let orders = result.unwrap();
println!("Open orders: {}", orders.len());
for order in &orders {
println!(
" {:?} {} {} @ {:?}",
order.side, order.quantity, order.key.instrument, order.price
);
}
}
#[tokio::test]
#[ignore]
async fn test_fetch_trades() {
init_logging();
let config = test_config();
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let since = chrono::Utc::now() - chrono::Duration::days(7);
let instruments: Vec<InstrumentNameExchange> = vec![];
let result = client
.fetch_trades(since, chrono::Utc::now(), &instruments)
.await;
assert!(result.is_ok(), "fetch_trades failed: {:?}", result.err());
let read = result.unwrap();
assert_eq!(read.resume, None, "the venue reads a span whole");
let trades = read.trades;
println!("Trades in last 7 days: {}", trades.len());
for trade in trades.iter().take(10) {
println!(
" {} {:?} {} {} @ {} (fee: {})",
trade.time_exchange.format("%Y-%m-%d %H:%M:%S"),
trade.side,
trade.quantity,
trade.instrument,
trade.price,
trade.fees.fees
);
}
}
#[tokio::test]
#[ignore]
async fn test_fetch_trades_to_an_end_past_the_venues_clock() {
init_logging();
let config = test_config();
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let now = chrono::Utc::now();
let since = now - chrono::Duration::days(7);
let instruments: Vec<InstrumentNameExchange> = vec![];
let to_now = client
.fetch_trades(since, now, &instruments)
.await
.expect("a read ending now");
let ahead = client
.fetch_trades(since, now + chrono::Duration::hours(1), &instruments)
.await
.expect("a read ending an hour ahead");
println!(
"Trades to now: {}, to an hour ahead: {}",
to_now.trades.len(),
ahead.trades.len()
);
assert!(ahead.trades.len() >= to_now.trades.len());
}
#[tokio::test]
#[ignore]
async fn test_place_and_cancel_limit_order() {
init_logging();
let config = test_config();
assert_eq!(
config.network,
Network::Testnet,
"This test MUST run on testnet only!"
);
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let instrument = btc_instrument();
let strategy = StrategyId::new("test-strategy");
let order_cid = ClientOrderId::uuid();
let order_key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &instrument,
strategy: strategy.clone(),
cid: order_cid.clone(),
};
let request_open = RequestOpen::new(
Side::Buy,
Some(dec!(50000.0)),
dec!(0.001), OrderKind::Limit,
TimeInForce::GoodUntilCancelled { post_only: false },
);
let open_request = rustrade_execution::order::OrderEvent {
key: order_key.clone(),
state: request_open,
};
println!("Placing limit order: BUY 0.001 BTC-USDC-PERP @ $50,000 (won't fill)");
let response = client.open_order(open_request).await;
match &response.state {
OrderState::Active(ActiveOrderState::Open(open_state)) => {
println!("Order placed successfully!");
println!(" Client Order ID: {}", response.key.cid);
println!(" Exchange Order ID: {}", open_state.id);
tokio::time::sleep(Duration::from_millis(500)).await;
let cancel_key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &instrument,
strategy: response.key.strategy.clone(),
cid: response.key.cid.clone(),
};
let cancel_request = rustrade_execution::order::OrderEvent {
key: cancel_key,
state: rustrade_execution::order::request::RequestCancel {
id: Some(open_state.id.clone()),
},
};
println!("Canceling order...");
let cancel_response = client.cancel_order(cancel_request).await;
match &cancel_response.state {
Ok(cancelled) => {
println!("Order canceled successfully!");
println!(" Cancelled at: {}", cancelled.time_exchange);
}
Err(e) => {
panic!("Cancel rejected: {:?}", e);
}
}
}
OrderState::Inactive(e) => {
panic!("Order rejected: {:?}", e);
}
other => {
panic!("Unexpected order state: {:?}", other);
}
}
}
#[tokio::test]
#[ignore]
async fn test_fetch_ended_orders_reports_a_cancelled_order() {
init_logging();
let config = test_config();
assert_eq!(
config.network,
Network::Testnet,
"This test MUST run on testnet only!"
);
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let instrument = btc_instrument();
let strategy = StrategyId::new("test-strategy");
let key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &instrument,
strategy: strategy.clone(),
cid: ClientOrderId::uuid(),
};
let owned_key = OrderKey {
exchange: key.exchange,
instrument: instrument.clone(),
strategy: strategy.clone(),
cid: key.cid.clone(),
};
let unknown_key = OrderKey {
cid: ClientOrderId::uuid(),
..owned_key.clone()
};
let response = client
.open_order(rustrade_execution::order::OrderEvent {
key: key.clone(),
state: RequestOpen::new(
Side::Buy,
Some(dec!(50000.0)),
dec!(0.001),
OrderKind::Limit,
TimeInForce::GoodUntilCancelled { post_only: false },
),
})
.await;
let OrderState::Active(ActiveOrderState::Open(open)) = &response.state else {
panic!("Order not resting: {:?}", response.state);
};
let live = client
.fetch_ended_orders(&[owned_key.clone(), unknown_key.clone()])
.await
.expect("lookup failed");
assert!(
live.is_empty(),
"a live order and an unknown id are omitted: {live:?}"
);
let cancelled = client
.cancel_order(rustrade_execution::order::OrderEvent {
key,
state: rustrade_execution::order::request::RequestCancel {
id: Some(open.id.clone()),
},
})
.await;
assert!(
cancelled.state.is_ok(),
"Cancel rejected: {:?}",
cancelled.state
);
let ended = client
.fetch_ended_orders(&[unknown_key, owned_key.clone()])
.await
.expect("lookup failed");
println!("Ended: {ended:?}");
assert_eq!(ended.len(), 1);
assert_eq!(
ended[0].key, owned_key,
"reported under the key asked about"
);
let InactiveOrderState::Cancelled(cancelled) = &ended[0].state else {
panic!("expected cancelled, got {:?}", ended[0].state);
};
assert_eq!(cancelled.filled_quantity, Some(dec!(0)));
}
#[tokio::test]
#[ignore]
async fn test_hip3_order_round_trip() {
init_logging();
let config = test_config().with_dexes(["test"]);
assert_eq!(
config.network,
Network::Testnet,
"This test MUST run on testnet only!"
);
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let instrument = InstrumentNameExchange::from("test:ABC-USDC-PERP");
let key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &instrument,
strategy: StrategyId::new("test-strategy"),
cid: ClientOrderId::uuid(),
};
let response = client
.open_order(rustrade_execution::order::OrderEvent {
key: key.clone(),
state: RequestOpen::new(
Side::Buy,
Some(dec!(0.9)),
dec!(12),
OrderKind::Limit,
TimeInForce::GoodUntilCancelled { post_only: false },
),
})
.await;
let OrderState::Active(ActiveOrderState::Open(open)) = &response.state else {
panic!("Order not resting: {:?}", response.state);
};
let listed = client
.fetch_open_orders(std::slice::from_ref(&instrument))
.await
.expect("listing failed");
assert!(
listed.iter().any(|order| order.key.cid == key.cid),
"the order is listed under its client id: {listed:?}"
);
let snapshot = client
.account_snapshot(&[], std::slice::from_ref(&instrument))
.await
.expect("snapshot failed");
println!("Balances: {:?}", snapshot.balances);
let entry = snapshot
.instruments
.iter()
.find(|entry| entry.instrument == instrument)
.expect("the requested instrument is listed");
assert!(entry.orders_complete);
assert!(entry.orders.iter().any(|order| order.key.cid == key.cid));
assert!(
snapshot
.balances
.iter()
.any(|balance| balance.asset.as_ref() == "USDC" && balance.balance.total > dec!(0)),
"the default DEX's collateral is reported, wherever the account's mode holds it"
);
let cancelled = client
.cancel_order(rustrade_execution::order::OrderEvent {
key,
state: rustrade_execution::order::request::RequestCancel {
id: Some(open.id.clone()),
},
})
.await;
assert!(
cancelled.state.is_ok(),
"Cancel rejected: {:?}",
cancelled.state
);
let unconfigured = InstrumentNameExchange::from("unit:ABC-USDC-PERP");
let error = client
.fetch_open_orders(std::slice::from_ref(&unconfigured))
.await
.expect_err("a DEX not configured is refused");
assert!(
matches!(
error,
rustrade_execution::error::ClientError::Api(
rustrade_execution::error::ApiError::InstrumentInvalid(..)
)
),
"{error:?}"
);
}
#[tokio::test]
#[ignore]
async fn test_stop_order_requires_uuid_cid() {
init_logging();
let config = test_config();
assert_eq!(
config.network,
Network::Testnet,
"This test MUST run on testnet only!"
);
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let instrument = btc_instrument();
let strategy = StrategyId::new("test-strategy");
let non_uuid_cid =
ClientOrderId::new(format!("test-{}", chrono::Utc::now().timestamp_millis()));
let order_key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &instrument,
strategy: strategy.clone(),
cid: non_uuid_cid.clone(),
};
let request_open = RequestOpen::new(
Side::Sell,
None, dec!(0.001),
OrderKind::Stop {
trigger_price: dec!(80000.0), },
TimeInForce::GoodUntilCancelled { post_only: false },
);
let open_request = rustrade_execution::order::OrderEvent {
key: order_key,
state: request_open,
};
println!("Placing Stop order with non-UUID cid (should be rejected)...");
let response = client.open_order(open_request).await;
match &response.state {
OrderState::Inactive(rustrade_execution::order::state::InactiveOrderState::OpenFailed(
rustrade_execution::error::OrderError::Rejected(err),
)) => {
println!("Stop order correctly rejected: {:?}", err);
assert!(
err.to_string().contains("UUID"),
"Error message should mention UUID requirement"
);
}
other => {
panic!("Expected rejection for non-UUID cid, got: {:?}", other);
}
}
}
#[tokio::test]
#[ignore]
async fn test_place_and_cancel_stop_order() {
init_logging();
let config = test_config();
assert_eq!(
config.network,
Network::Testnet,
"This test MUST run on testnet only!"
);
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let instrument = btc_instrument();
let strategy = StrategyId::new("test-strategy");
let uuid_cid = ClientOrderId::uuid();
println!("Using UUID cid: {}", uuid_cid);
let order_key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &instrument,
strategy: strategy.clone(),
cid: uuid_cid.clone(),
};
let request_open = RequestOpen::new(
Side::Sell,
None, dec!(0.001),
OrderKind::Stop {
trigger_price: dec!(50000.0),
},
TimeInForce::GoodUntilCancelled { post_only: false },
);
let open_request = rustrade_execution::order::OrderEvent {
key: order_key.clone(),
state: request_open,
};
println!("Placing Stop order: SELL 0.001 BTC-USDC-PERP @ stop $50,000");
let response = client.open_order(open_request).await;
match &response.state {
OrderState::Active(ActiveOrderState::Open(open_state)) => {
println!("Stop order placed successfully!");
println!(" Client Order ID (UUID): {}", response.key.cid);
println!(" Order ID: {}", open_state.id);
println!(
" Addressable by: {}",
match &open_state.id {
VenueOrderId::Assigned(_) => "venue oid",
VenueOrderId::ClientAssigned => "client id (cloid)",
}
);
tokio::time::sleep(Duration::from_millis(500)).await;
let cancel_key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &instrument,
strategy: response.key.strategy.clone(),
cid: response.key.cid.clone(),
};
let cancel_request = rustrade_execution::order::OrderEvent {
key: cancel_key,
state: rustrade_execution::order::request::RequestCancel {
id: Some(open_state.id.clone()),
},
};
println!("Canceling Stop order...");
let cancel_response = client.cancel_order(cancel_request).await;
match &cancel_response.state {
Ok(cancelled) => {
println!("Stop order canceled successfully!");
println!(" Cancelled at: {}", cancelled.time_exchange);
}
Err(e) => {
panic!("Cancel rejected: {:?}", e);
}
}
}
OrderState::Inactive(e) => {
panic!("Stop order rejected: {:?}", e);
}
other => {
panic!("Unexpected order state: {:?}", other);
}
}
}
#[tokio::test]
#[ignore]
async fn test_place_and_cancel_take_profit_order() {
init_logging();
let config = test_config();
assert_eq!(
config.network,
Network::Testnet,
"This test MUST run on testnet only!"
);
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let instrument = btc_instrument();
let strategy = StrategyId::new("test-strategy");
let uuid_cid = ClientOrderId::uuid();
let order_key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &instrument,
strategy: strategy.clone(),
cid: uuid_cid.clone(),
};
let request_open = RequestOpen::new(
Side::Sell,
None, dec!(0.001),
OrderKind::TakeProfit {
trigger_price: dec!(150000.0),
},
TimeInForce::GoodUntilCancelled { post_only: false },
);
let open_request = rustrade_execution::order::OrderEvent {
key: order_key,
state: request_open,
};
println!("Placing TakeProfit order: SELL 0.001 BTC-USDC-PERP @ TP $150,000");
let response = client.open_order(open_request).await;
match &response.state {
OrderState::Active(ActiveOrderState::Open(open_state)) => {
println!("TakeProfit order placed successfully!");
println!(" Order ID (cloid): {}", open_state.id);
tokio::time::sleep(Duration::from_millis(500)).await;
let cancel_key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &response.key.instrument,
strategy: response.key.strategy.clone(),
cid: response.key.cid.clone(),
};
let cancel_request = rustrade_execution::order::OrderEvent {
key: cancel_key,
state: rustrade_execution::order::request::RequestCancel {
id: Some(open_state.id.clone()),
},
};
println!("Canceling TakeProfit order...");
let cancel_response = client.cancel_order(cancel_request).await;
match &cancel_response.state {
Ok(_) => println!("TakeProfit order canceled successfully!"),
Err(e) => panic!("Cancel rejected: {:?}", e),
}
}
OrderState::Inactive(e) => {
panic!("TakeProfit order rejected: {:?}", e);
}
other => {
panic!("Unexpected order state: {:?}", other);
}
}
}
#[tokio::test]
#[ignore]
async fn test_trailing_stop_unsupported() {
init_logging();
let config = test_config();
assert_eq!(
config.network,
Network::Testnet,
"This test MUST run on testnet only!"
);
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let instrument = btc_instrument();
let order_key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &instrument,
strategy: StrategyId::new("test"),
cid: ClientOrderId::uuid(),
};
let request_open = RequestOpen::new(
Side::Sell,
None,
dec!(0.001),
OrderKind::TrailingStop {
offset: dec!(100.0),
offset_type: rustrade_execution::order::TrailingOffsetType::Absolute,
activation_price: None,
},
TimeInForce::GoodUntilCancelled { post_only: false },
);
let open_request = rustrade_execution::order::OrderEvent {
key: order_key,
state: request_open,
};
println!("Placing TrailingStop order (should be rejected as unsupported)...");
let response = client.open_order(open_request).await;
match &response.state {
OrderState::Inactive(rustrade_execution::order::state::InactiveOrderState::OpenFailed(
rustrade_execution::error::OrderError::UnsupportedOrderType(msg),
)) => {
println!("TrailingStop correctly rejected: {}", msg);
assert!(msg.to_lowercase().contains("trailing"));
}
other => {
panic!("Expected UnsupportedOrderType, got: {:?}", other);
}
}
}
#[tokio::test]
#[ignore]
async fn test_account_stream() {
init_logging();
let config = test_config();
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let assets: Vec<AssetNameExchange> = vec![];
let instruments: Vec<InstrumentNameExchange> = vec![];
let stream_result = client.account_stream(&assets, &instruments).await;
assert!(
stream_result.is_ok(),
"account_stream failed: {:?}",
stream_result.err()
);
let mut stream = stream_result.unwrap();
println!("Account stream started. Waiting for events (10 second timeout)...");
println!(
"(Place/cancel an order manually to see events, or run test_account_stream_with_order)"
);
let timeout = tokio::time::timeout(Duration::from_secs(10), async {
let mut count = 0;
while let Some(event) = stream.next().await {
println!("Event: {:?}", event.kind);
count += 1;
if count >= 3 {
break;
}
}
count
})
.await;
match timeout {
Ok(count) => println!("Received {} events", count),
Err(_) => println!("Timeout reached (this is normal if no orders are active)"),
}
}
#[tokio::test]
#[ignore]
async fn test_account_stream_with_order() {
init_logging();
let config = test_config();
assert_eq!(
config.network,
Network::Testnet,
"This test MUST run on testnet only!"
);
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let assets: Vec<AssetNameExchange> = vec![];
let instruments: Vec<InstrumentNameExchange> = vec![];
let stream_result = client.account_stream(&assets, &instruments).await;
assert!(
stream_result.is_ok(),
"account_stream failed: {:?}",
stream_result.err()
);
let mut stream = stream_result.unwrap();
println!("Account stream started, placing order to trigger events...");
tokio::time::sleep(Duration::from_millis(500)).await;
let instrument = eth_instrument();
let strategy = StrategyId::new("stream-test");
let order_cid = ClientOrderId::uuid();
let order_key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &instrument,
strategy: strategy.clone(),
cid: order_cid.clone(),
};
let request_open = RequestOpen::new(
Side::Buy,
Some(dec!(2000.0)),
dec!(0.01),
OrderKind::Limit,
TimeInForce::GoodUntilCancelled { post_only: false },
);
let open_request = rustrade_execution::order::OrderEvent {
key: order_key,
state: request_open,
};
let response = client.open_order(open_request).await;
let order_id = match &response.state {
OrderState::Active(ActiveOrderState::Open(open_state)) => {
println!("Order placed: {}", open_state.id);
Some(open_state.id.clone())
}
other => {
println!("Order failed (may still get stream events): {:?}", other);
None
}
};
println!("Collecting stream events...");
let events = tokio::time::timeout(Duration::from_secs(5), async {
let mut events = Vec::new();
while let Some(event) = stream.next().await {
println!(" Stream event: {:?}", event.kind);
events.push(event);
if events.len() >= 5 {
break;
}
}
events
})
.await;
match events {
Ok(events) => println!("Received {} stream events", events.len()),
Err(_) => println!("Stream timeout (events may have been received)"),
}
if let Some(oid) = order_id {
let cancel_key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &instrument,
strategy,
cid: order_cid,
};
let cancel_request = rustrade_execution::order::OrderEvent {
key: cancel_key,
state: rustrade_execution::order::request::RequestCancel { id: Some(oid) },
};
let _ = client.cancel_order(cancel_request).await;
println!("Cleanup: order cancelled");
}
}
#[tokio::test]
#[ignore]
async fn test_order_is_reported_under_its_client_id() {
init_logging();
let config = test_config();
assert_eq!(
config.network,
Network::Testnet,
"This test MUST run on testnet only!"
);
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let mut stream = client
.account_stream(&[], &[])
.await
.expect("account_stream failed");
tokio::time::sleep(Duration::from_millis(500)).await;
let instrument = eth_instrument();
let order_cid = ClientOrderId::uuid();
let key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &instrument,
strategy: StrategyId::new("cid-test"),
cid: order_cid.clone(),
};
let response = client
.open_order(rustrade_execution::order::OrderEvent {
key: key.clone(),
state: RequestOpen::new(
Side::Buy,
Some(dec!(1600.0)),
dec!(0.01),
OrderKind::Limit,
TimeInForce::GoodUntilCancelled { post_only: true },
),
})
.await;
let venue_id = match &response.state {
OrderState::Active(ActiveOrderState::Open(open)) => open.id.clone(),
other => panic!("Order did not rest: {other:?}"),
};
assert!(
matches!(venue_id, VenueOrderId::Assigned(_)),
"a resting order is named by its oid"
);
let snapshot = client
.account_snapshot(&[], std::slice::from_ref(&instrument))
.await
.expect("account_snapshot failed");
let entry = snapshot
.instruments
.iter()
.find(|entry| entry.instrument == instrument)
.expect("the requested instrument has an entry");
assert!(entry.orders_complete, "the listing should be complete");
let listed = entry
.orders
.iter()
.find(|order| order.key.cid == order_cid)
.unwrap_or_else(|| panic!("snapshot does not list {order_cid}: {:?}", entry.orders));
match &listed.state {
OrderState::Active(ActiveOrderState::Open(open)) => assert_eq!(open.id, venue_id),
other => panic!("listed order is not Open: {other:?}"),
}
let open_orders = client
.fetch_open_orders(std::slice::from_ref(&instrument))
.await
.expect("fetch_open_orders failed");
assert!(
open_orders.iter().any(|order| order.key.cid == order_cid),
"fetch_open_orders does not list {order_cid}"
);
let cancel = client
.cancel_order(rustrade_execution::order::OrderEvent {
key: key.clone(),
state: rustrade_execution::order::request::RequestCancel { id: Some(venue_id) },
})
.await;
assert!(cancel.state.is_ok(), "cancel failed: {:?}", cancel.state);
let (mut saw_open, mut saw_cancelled) = (false, false);
let _ = tokio::time::timeout(Duration::from_secs(10), async {
while let Some(event) = stream.next().await {
if let rustrade_execution::AccountEventKind::OrderSnapshot(snapshot) = event.kind {
let order = snapshot.0;
if order.key.cid != order_cid {
continue;
}
match order.state {
OrderState::Active(ActiveOrderState::Open(_)) => saw_open = true,
OrderState::Inactive(InactiveOrderState::Cancelled(_)) => saw_cancelled = true,
_ => {}
}
if saw_open && saw_cancelled {
break;
}
}
}
})
.await;
assert!(saw_open, "no Open update under {order_cid}");
assert!(saw_cancelled, "no Cancelled update under {order_cid}");
let snapshot = client
.account_snapshot(&[], std::slice::from_ref(&instrument))
.await
.expect("account_snapshot failed");
let entry = snapshot
.instruments
.iter()
.find(|entry| entry.instrument == instrument)
.expect("a requested instrument is listed even with nothing open");
assert!(entry.orders_complete);
assert!(
entry.orders.iter().all(|order| order.key.cid != order_cid),
"the cancelled order is still listed"
);
}
#[tokio::test]
#[ignore]
async fn test_cancel_nonexistent_order() {
init_logging();
let config = test_config();
assert_eq!(
config.network,
Network::Testnet,
"This test MUST run on testnet only!"
);
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let instrument = btc_instrument();
let strategy = StrategyId::new("test-strategy");
let order_cid = ClientOrderId::new("nonexistent-order");
let cancel_key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &instrument,
strategy,
cid: order_cid,
};
let cancel_request = rustrade_execution::order::OrderEvent {
key: cancel_key,
state: rustrade_execution::order::request::RequestCancel {
id: Some(VenueOrderId::Assigned(
rustrade_execution::order::id::OrderId::new("999999999"),
)),
},
};
let response = client.cancel_order(cancel_request).await;
println!("Cancel nonexistent order result: {:?}", response.state);
assert!(
matches!(
response.state,
Err(rustrade_execution::error::OrderError::Rejected(
rustrade_execution::error::ApiError::OrderRejected(_)
))
),
"a cancel of an order Hyperliquid does not hold must be rejected, got {:?}",
response.state
);
}
#[tokio::test]
#[ignore]
async fn test_cancel_without_order_id() {
init_logging();
let config = test_config();
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let instrument = btc_instrument();
let strategy = StrategyId::new("test-strategy");
let order_cid = ClientOrderId::new("no-id-order");
let cancel_key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &instrument,
strategy,
cid: order_cid,
};
let cancel_request = rustrade_execution::order::OrderEvent {
key: cancel_key,
state: rustrade_execution::order::request::RequestCancel { id: None },
};
let response = client.cancel_order(cancel_request).await;
assert!(
response.state.is_err(),
"Expected rejection when order ID is missing"
);
println!("Cancel correctly rejected: {:?}", response.state.err());
}
#[tokio::test]
#[ignore] async fn test_boundary_precision_orders_rest() {
use rust_decimal::{Decimal, RoundingStrategy};
init_logging();
let config = test_config();
assert_eq!(
config.network,
Network::Testnet,
"This test MUST run on testnet only!"
);
let client = HyperliquidClient::connect(config)
.await
.expect("Failed to connect");
let info =
hyperliquid_rust_sdk::InfoClient::new(None, Some(hyperliquid_rust_sdk::BaseUrl::Testnet))
.await
.expect("Failed to create InfoClient");
let mids = info.all_mids().await.expect("Failed to read allMids");
let mid = |coin: &str| -> Decimal {
mids.get(coin)
.unwrap_or_else(|| panic!("allMids has no {coin} mid"))
.parse()
.expect("mid is a decimal")
};
let btc_price = (mid("BTC") * dec!(1.2))
.max(dec!(100000))
.trunc()
.checked_div(dec!(10))
.expect("divides")
.trunc()
* dec!(10)
+ dec!(1);
let eth = eth_instrument();
let eth_precision = client.order_precision(ð).expect("ETH is listed");
let eth_price = eth_precision.round_price(mid("ETH") * dec!(0.7), RoundingStrategy::ToZero);
let btc = btc_instrument();
let btc_precision = client.order_precision(&btc).expect("BTC is listed");
for (instrument, precision, side, price) in [
(&btc, btc_precision, Side::Sell, btc_price),
(ð, eth_precision, Side::Buy, eth_price),
] {
let quantity = precision.round_quantity(dec!(12) / price, RoundingStrategy::AwayFromZero);
precision
.check_price(price)
.expect("the price meets the rules");
precision
.check_quantity(quantity)
.expect("the quantity meets the rules");
println!("Placing {side:?} {quantity} {instrument} @ {price}");
let key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument,
strategy: StrategyId::new("test-precision"),
cid: ClientOrderId::uuid(),
};
let response = client
.open_order(rustrade_execution::order::OrderEvent {
key: key.clone(),
state: RequestOpen::new(
side,
Some(price),
quantity,
OrderKind::Limit,
TimeInForce::GoodUntilCancelled { post_only: false },
),
})
.await;
let OrderState::Active(ActiveOrderState::Open(open)) = &response.state else {
panic!(
"{instrument} @ {price} x {quantity} was not resting: {:?}",
response.state
);
};
tokio::time::sleep(Duration::from_millis(500)).await;
let cancelled = client
.cancel_order(rustrade_execution::order::OrderEvent {
key,
state: rustrade_execution::order::request::RequestCancel {
id: Some(open.id.clone()),
},
})
.await;
assert!(
cancelled.state.is_ok(),
"cancel of {instrument}: {:?}",
cancelled.state
);
}
}