rustrade-execution 0.19.0

Stream private account data from financial venues, and execute (live or mock) orders.
Documentation
//! Hyperliquid Execution Example
//!
//! Demonstrates order execution with Hyperliquid perpetual futures:
//! - Connecting to Hyperliquid (testnet or mainnet)
//! - Fetching account balances and positions
//! - Placing a limit order
//! - Canceling an order
//! - Streaming account events
//!
//! # Prerequisites
//!
//! 1. Hyperliquid account with funds (testnet recommended)
//! 2. Wallet private key (EVM-compatible)
//! 3. Environment variables:
//!    - `HYPERLIQUID_PRIVATE_KEY`: Hex-encoded private key (with or without 0x prefix)
//!    - `HYPERLIQUID_TESTNET`: Set to "true" for testnet (recommended)
//!    - `HYPERLIQUID_DEXES` (optional): builder-deployed (HIP-3) DEXs to trade too, such as
//!      `xyz,flx`. Their perpetuals are named after the DEX's collateral, e.g. `xyz:TSLA-USDC-PERP`
//!
//! # Usage
//!
//! ```bash
//! # Using testnet (recommended)
//! HYPERLIQUID_PRIVATE_KEY=0x... HYPERLIQUID_TESTNET=true \
//!     cargo run --example hyperliquid_execution --features hyperliquid
//!
//! # Or source from .env file
//! source .env && cargo run --example hyperliquid_execution --features hyperliquid
//! ```
//!
//! # Safety Note
//!
//! This example places a LIMIT order far from market price that should NOT fill.
//! Always use TESTNET for testing to avoid risking real funds.

// Examples use unwrap/expect for brevity — not production code
#![allow(clippy::unwrap_used, clippy::expect_used)]

use rust_decimal_macros::dec;
use rustrade_execution::{
    client::{
        ExecutionClient,
        hyperliquid::{HyperliquidClient, Network, config::HyperliquidConfig},
    },
    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();

    // Load configuration from environment
    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;
        }
    };

    info!("Connecting to Hyperliquid {:?}...", config.network);
    info!("Wallet: {}", config.wallet_address_hex());

    if config.network == Network::Mainnet {
        warn!("WARNING: Running on MAINNET - real funds at risk!");
        warn!("Set HYPERLIQUID_TESTNET=true for safe testing");
    }

    // Connect to Hyperliquid
    let client = match HyperliquidClient::connect(config).await {
        Ok(c) => c,
        Err(e) => {
            warn!("Failed to connect: {e}");
            return;
        }
    };

    info!("Connected!");

    // Fetch account snapshot
    info!("");
    info!("=== Account Snapshot ===");

    let assets: Vec<AssetNameExchange> = vec![];
    let instruments: Vec<InstrumentNameExchange> = vec![];

    match client.account_snapshot(&assets, &instruments).await {
        Ok(snapshot) => {
            info!("Balances:");
            for balance in &snapshot.balances {
                info!(
                    "  {}: total={}, free={}",
                    balance.asset, balance.balance.total, balance.balance.free
                );
            }

            info!("Positions:");
            let positions: Vec<_> = snapshot
                .instruments
                .iter()
                .filter_map(|i| Some((&i.instrument, i.position.open()?)))
                .collect();

            if positions.is_empty() {
                info!("  (no open positions)");
            }
            for (instrument, pos) in positions {
                info!(
                    "  {}: qty={}, entry={:?}, pnl={:?}",
                    instrument, pos.quantity, pos.entry_price, pos.unrealized_pnl
                );
            }

            info!("Open 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}");
        }
    }

    // Place a limit order (far from market to avoid filling)
    info!("");
    info!("=== Placing Limit Order ===");

    let btc_perp: InstrumentNameExchange = "BTC-USDC-PERP".into();
    // Hyperliquid accepts only client ids in `ClientOrderId::uuid()` form.
    let order_cid = ClientOrderId::uuid();
    let strategy = StrategyId::new("demo-strategy");

    let order_key = OrderKey {
        exchange: ExchangeId::HyperliquidPerp,
        instrument: &btc_perp,
        strategy: strategy.clone(),
        cid: order_cid.clone(),
    };

    // Price must be within 80% of market price on Hyperliquid
    // BTC ~$95k, so $50k is ~47% below - safe limit
    let request_open = RequestOpen::new(
        Side::Buy,
        Some(dec!(50000.0)),
        dec!(0.001), // Minimum BTC size
        OrderKind::Limit,
        TimeInForce::GoodUntilCancelled { post_only: false },
    );

    let open_request = OrderEvent {
        key: order_key.clone(),
        state: request_open,
    };

    info!("Placing BUY 0.001 BTC-USDC-PERP @ $50,000 (won't fill - below market)");

    let response = client.open_order(open_request).await;
    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);

            // Wait a moment then cancel
            info!("");
            info!("=== Canceling Order ===");
            tokio::time::sleep(Duration::from_secs(1)).await;

            let cancel_key = OrderKey {
                exchange: ExchangeId::HyperliquidPerp,
                instrument: &btc_perp,
                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.state {
                Ok(_cancelled) => {
                    info!("Order canceled successfully!");
                }
                Err(e) => {
                    warn!("Cancel rejected: {e:?}");
                }
            }
        }
        OrderState::Inactive(e) => {
            warn!("Order rejected: {e:?}");
            warn!("This may be due to:");
            warn!("  - Insufficient margin/balance");
            warn!("  - Price more than 80% from market");
            warn!("  - Invalid order parameters");
        }
        other => {
            info!("Unexpected order state: {other:?}");
        }
    }

    // Demonstrate account stream (optional - brief listen)
    info!("");
    info!("=== Account Stream (5 seconds) ===");

    match client.account_stream(&[], &[]).await {
        Ok(mut stream) => {
            info!("Listening for account events...");

            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()
}