#![allow(clippy::unwrap_used, clippy::expect_used)]
use rust_decimal_macros::dec;
use rustrade_execution::{
client::{
ExecutionClient,
hyperliquid::{config::HyperliquidConfig, spot::HyperliquidSpotClient},
},
order::{
OrderEvent, OrderKey, OrderKind, TimeInForce,
id::{ClientOrderId, StrategyId},
request::{RequestCancel, 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::{info, warn};
#[tokio::main]
async fn main() {
init_logging();
let config = match HyperliquidConfig::from_env() {
Ok(c) => c,
Err(e) => {
warn!("Configuration error: {e}");
warn!("Set HYPERLIQUID_PRIVATE_KEY and optionally HYPERLIQUID_TESTNET=true");
return;
}
};
let network = if config.testnet { "TESTNET" } else { "MAINNET" };
info!("Connecting to Hyperliquid {network} (SPOT)...");
info!("Wallet: {}", config.wallet_address_hex());
if !config.testnet {
warn!("WARNING: Running on MAINNET - real funds at risk!");
warn!("Set HYPERLIQUID_TESTNET=true for safe testing");
}
let client = match HyperliquidSpotClient::connect(config).await {
Ok(c) => c,
Err(e) => {
warn!("Failed to connect: {e}");
return;
}
};
info!("Connected!");
info!("");
info!("=== Account Snapshot (Spot) ===");
let assets: Vec<AssetNameExchange> = vec![];
let instruments: Vec<InstrumentNameExchange> = vec![];
match client.account_snapshot(&assets, &instruments).await {
Ok(snapshot) => {
info!("Spot Token Balances:");
if snapshot.balances.is_empty() {
info!(" (no spot balances)");
}
for balance in &snapshot.balances {
info!(
" {}: total={}, free={}",
balance.asset, balance.balance.total, balance.balance.free
);
}
info!("Open Spot Orders:");
let total_orders: usize = snapshot.instruments.iter().map(|i| i.orders.len()).sum();
if total_orders == 0 {
info!(" (no open orders)");
}
for inst in &snapshot.instruments {
for order in &inst.orders {
info!(
" {} {:?} {} @ {:?}",
inst.instrument, order.side, order.quantity, order.price
);
}
}
}
Err(e) => {
warn!("Failed to get account snapshot: {e}");
}
}
info!("");
info!("=== Placing Spot Limit Order ===");
let btc_spot: InstrumentNameExchange = "BTC-USDC-SPOT".into();
let order_cid = ClientOrderId::new(format!(
"spot-example-{}",
chrono::Utc::now().timestamp_millis()
));
let strategy = StrategyId::new("demo-spot-strategy");
let order_key = OrderKey {
exchange: ExchangeId::HyperliquidSpot,
instrument: &btc_spot,
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 = OrderEvent {
key: order_key.clone(),
state: request_open,
};
info!("Placing BUY 0.001 BTC-USDC-SPOT @ $50,000 (won't fill - below market)");
match client.open_order(open_request).await {
Some(response) => {
match &response.state {
OrderState::Active(ActiveOrderState::Open(open_state)) => {
info!("Order placed successfully!");
info!(" Client Order ID: {}", response.key.cid);
info!(" Exchange Order ID: {}", open_state.id);
info!("");
info!("=== Canceling Order ===");
tokio::time::sleep(Duration::from_secs(1)).await;
let cancel_key = OrderKey {
exchange: ExchangeId::HyperliquidSpot,
instrument: &btc_spot,
strategy: response.key.strategy.clone(),
cid: response.key.cid.clone(),
};
let cancel_request = OrderEvent {
key: cancel_key,
state: RequestCancel {
id: Some(open_state.id.clone()),
},
};
match client.cancel_order(cancel_request).await {
Some(cancel_response) => match &cancel_response.state {
Ok(_cancelled) => {
info!("Order canceled successfully!");
}
Err(e) => {
warn!("Cancel rejected: {e:?}");
}
},
None => {
info!("Cancel request sent (no immediate response)");
}
}
}
OrderState::Inactive(e) => {
warn!("Order rejected: {e:?}");
warn!("This may be due to:");
warn!(" - Insufficient spot balance");
warn!(" - Order below $10 minimum notional");
warn!(" - Invalid order parameters");
}
other => {
info!("Unexpected order state: {other:?}");
}
}
}
None => {
info!("Order request sent (no immediate response)");
}
}
info!("");
info!("=== Account Stream (5 seconds) ===");
match client.account_stream(&[], &[]).await {
Ok(mut stream) => {
info!("Listening for spot account events...");
info!("(Note: receives both spot AND perp events - we filter to spot only)");
let timeout = tokio::time::timeout(Duration::from_secs(5), async {
let mut count = 0;
while let Some(event) = stream.next().await {
info!(" Event: {:?}", event.kind);
count += 1;
if count >= 5 {
break;
}
}
count
})
.await;
match timeout {
Ok(count) => info!("Received {count} events"),
Err(_) => info!("Timeout (no events - this is normal if no activity)"),
}
}
Err(e) => {
warn!("Failed to start account stream: {e}");
}
}
info!("");
info!("Example complete");
}
fn init_logging() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::filter::EnvFilter::builder()
.with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into())
.from_env_lossy(),
)
.with_ansi(cfg!(debug_assertions))
.init()
}