#![cfg(feature = "hyperliquid")]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use rust_decimal_macros::dec;
use rustrade_execution::{
client::{
ExecutionClient,
hyperliquid::{HyperliquidClient, config::HyperliquidConfig},
},
order::{
OrderKey, OrderKind, TimeInForce,
id::{ClientOrderId, StrategyId},
request::RequestOpen,
state::{ActiveOrderState, 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-USD-PERP".into()
}
fn eth_instrument() -> InstrumentNameExchange {
"ETH-USD-PERP".into()
}
#[tokio::test]
#[ignore]
async fn test_connection() {
init_logging();
let config = test_config();
assert!(config.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, false).expect("Invalid private key");
assert!(!config.testnet, "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.as_ref().map(|p| p.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, &instruments).await;
assert!(result.is_ok(), "fetch_trades failed: {:?}", result.err());
let trades = result.unwrap();
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_place_and_cancel_limit_order() {
init_logging();
let config = test_config();
assert!(config.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(format!("test-{}", chrono::Utc::now().timestamp_millis()));
let order_key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &instrument,
strategy: strategy.clone(),
cid: order_cid.clone(),
};
let request_open = RequestOpen {
side: Side::Buy,
price: Some(dec!(50000.0)),
quantity: dec!(0.001), kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
position_id: None,
reduce_only: false,
};
let open_request = rustrade_execution::order::OrderEvent {
key: order_key.clone(),
state: request_open,
};
println!("Placing limit order: BUY 0.001 BTC-USD-PERP @ $50,000 (won't fill)");
let response = client.open_order(open_request).await;
assert!(response.is_some(), "Expected order response");
let response = response.unwrap();
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;
assert!(cancel_response.is_some(), "Expected cancel response");
let cancel_response = cancel_response.unwrap();
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_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!(config.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::new(format!("stream-{}", chrono::Utc::now().timestamp_millis()));
let order_key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &instrument,
strategy: strategy.clone(),
cid: order_cid.clone(),
};
let request_open = RequestOpen {
side: Side::Buy,
price: Some(dec!(2000.0)),
quantity: dec!(0.01),
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
position_id: None,
reduce_only: false,
};
let open_request = rustrade_execution::order::OrderEvent {
key: order_key,
state: request_open,
};
let response = client.open_order(open_request).await;
assert!(response.is_some());
let response = response.unwrap();
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_cancel_nonexistent_order() {
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("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(rustrade_execution::order::id::OrderId::new("999999999")),
},
};
let response = client.cancel_order(cancel_request).await;
assert!(response.is_some());
let response = response.unwrap();
println!("Cancel nonexistent order result: {:?}", 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.is_some());
let response = response.unwrap();
assert!(
response.state.is_err(),
"Expected rejection when order ID is missing"
);
println!("Cancel correctly rejected: {:?}", response.state.err());
}