df-hyperliquid-core 0.0.8-beta.1

Hyperliquid SDK for Rust: markets, account, trading, transfers, earn and deploy, with exact decimals and signing. Native and wasm.
Documentation
use std::{
    cell::RefCell,
    future::{Future, ready},
    pin::pin,
    task::{Context, Poll, Waker},
};

use crate::client::TransportError;
use crate::trade::{MarginMode, Side, Tif};
use crate::utils::Dec;
use crate::utils::sign::L1Action;

use super::*;
use crate::trade::perps::{LeverageError, add_margin, remove_margin, update_leverage};

/// Live, mainnet, 2026-10-06, trimmed: the first three perps and their contexts as sent, then HPOS
/// (delisted, strictly isolated; 33rd live) and its context.
const META: &str = r#"{"universe":[{"szDecimals":5,"name":"BTC","maxLeverage":40,"marginTableId":56},{"szDecimals":4,"name":"ETH","maxLeverage":25,"marginTableId":55},{"szDecimals":2,"name":"ATOM","maxLeverage":5,"marginTableId":5},{"szDecimals":0,"name":"HPOS","maxLeverage":3,"marginTableId":3,"onlyIsolated":true,"isDelisted":true,"marginMode":"strictIsolated"}],"marginTables":[[50,{"description":"","marginTiers":[{"lowerBound":"0.0","maxLeverage":50}]}]],"collateralToken":0}"#;
const CTXS: &str = r#"[{"funding":"0.0000125","openInterest":"40762.72024","prevDayPx":"85286.0","dayNtlVlm":"1825095986.3497385979","premium":"-0.0002918515","oraclePx":"85660.0","markPx":"85634.0","midPx":"85634.5","impactPxs":["85631.1","85635.0"],"dayBaseVlm":"21250.29166"},{"funding":"0.0000125","openInterest":"1178184.1025999999","prevDayPx":"2695.6","dayNtlVlm":"665475998.0603500605","premium":"-0.0003446832","oraclePx":"2698.13","markPx":"2697.1","midPx":"2697.15","impactPxs":["2697.1","2697.2"],"dayBaseVlm":"245640.2553999999"},{"funding":"0.0000007923","openInterest":"1556009.5","prevDayPx":"1.796","dayNtlVlm":"690086.4431889998","premium":"-0.0002230276","oraclePx":"1.7935","markPx":"1.7923","midPx":"1.7923","impactPxs":["1.7915","1.7931"],"dayBaseVlm":"382125.4999999999"},{"funding":"0.0","openInterest":"0.0","prevDayPx":"0.041845","dayNtlVlm":"0.0","premium":null,"oraclePx":"0.042203","markPx":"0.041873","midPx":null,"impactPxs":null,"dayBaseVlm":"0.0"}]"#;

fn dec(text: &str) -> Dec {
    Dec::parse(text).unwrap()
}

fn both() -> MetaAndAssetCtxs {
    serde_json::from_str(&format!("[{META},{CTXS}]")).unwrap()
}

#[test]
fn meta_reads_the_live_answer_and_names_each_perp_s_asset_and_grid() {
    let meta: Meta = serde_json::from_str(META).unwrap();
    assert_eq!(meta.universe.len(), 4);
    assert_eq!(meta.collateral_token, Some(0));
    let (asset, btc) = meta.find("BTC").unwrap();
    assert_eq!((asset.id(), btc.sz_decimals, btc.max_leverage), (0, 5, 40));
    assert_eq!(btc.grid(), Grid::perp(5));
    assert_eq!(meta.find("ATOM").unwrap().0, Asset::perp(2).unwrap());
    assert_eq!(meta.find("btc"), None, "names are matched as the venue spells them");
    assert_eq!(meta.get(Asset::perp(1).unwrap()).map(|perp| perp.name.as_str()), Some("ETH"));
    assert_eq!(meta.get(Asset::from_id(10_001)), None, "a spot id is not a perp");
    let hpos = meta.find("HPOS").unwrap().1;
    assert!(hpos.is_delisted && hpos.only_isolated && !hpos.allows_cross());
    assert!(btc.allows_cross());
}

#[test]
fn contexts_line_up_with_the_universe_and_a_quiet_market_has_no_mid() {
    let both = both();
    let (asset, _, btc) = both.find("BTC").unwrap();
    assert_eq!(asset.id(), 0);
    assert_eq!((btc.mark_px, btc.mid_px, btc.reference()), (dec("85634"), Some(dec("85634.5")), dec("85634.5")));
    assert_eq!(btc.funding, dec("0.0000125"));
    assert_eq!(btc.impact_pxs, Some(vec![dec("85631.1"), dec("85635")]));
    let (_, _, hpos) = both.find("HPOS").unwrap();
    assert_eq!((hpos.mid_px, hpos.premium, hpos.reference()), (None, None, dec("0.041873")));
    assert_eq!(both.meta.margin_tables.len(), 1, "the one table this trimmed answer kept");
    assert_eq!(both.meta.margin_table(50).and_then(|table| table.max_leverage_at(Dec::ZERO)), Some(50));
}

/// BTC's context recorded live on mainnet, 2026-09-26, when its day change was -0.68%.
#[test]
fn a_context_says_its_day_change_and_its_open_interest_at_the_mark() {
    let btc: PerpCtx = serde_json::from_str(r#"{"funding":"0.0000125","openInterest":"37697.76106","prevDayPx":"84556.0","dayNtlVlm":"2460964638.0624189377","premium":"-0.0004701178","oraclePx":"84021.5","markPx":"83982.0","midPx":"83981.5","impactPxs":["83981.0","83982.0"],"dayBaseVlm":"29257.91492"}"#).unwrap();
    assert_eq!(btc.day_change(), Some(dec("-0.68")));
    assert_eq!(btc.open_interest_value(), Some(dec("3165933369.34092")));
    let both = both();
    assert_eq!(both.find("BTC").unwrap().2.day_change(), Some(dec("0.41")));
}

#[test]
fn a_market_order_goes_out_on_the_perp_s_grid_as_frontend_market() {
    let both = both();
    let (asset, btc, ctx) = both.find("BTC").unwrap();
    let order = btc.grid().market(asset, Side::Buy, ctx.reference(), dec("0.01"), dec("0.001234")).unwrap();
    assert_eq!((order.price(), order.size(), order.tif()), (dec("86490"), dec("0.00123"), Some(Tif::FrontendMarket)));
}

#[test]
fn leverage_is_held_to_what_the_asset_allows() {
    let meta: Meta = serde_json::from_str(META).unwrap();
    let (btc_asset, btc) = meta.find("BTC").unwrap();
    let set = update_leverage(btc_asset, btc, MarginMode::Cross, 40).unwrap();
    assert_eq!(set.wire(), r#"{"type":"updateLeverage","asset":0,"isCross":true,"leverage":40}"#);
    assert_eq!(
        update_leverage(btc_asset, btc, MarginMode::Cross, 41),
        Err(LeverageError::TooHigh { name: "BTC".to_owned(), max: 40, asked: 41 })
    );
    let (hpos_asset, hpos) = meta.find("HPOS").unwrap();
    assert_eq!(update_leverage(hpos_asset, hpos, MarginMode::Cross, 2), Err(LeverageError::NoCross("HPOS".to_owned())));
    assert!(update_leverage(hpos_asset, hpos, MarginMode::Isolated, 2).is_ok());
    assert!(matches!(update_leverage(btc_asset, btc, MarginMode::Isolated, 0), Err(LeverageError::Trade(_))));
}

#[test]
fn margin_goes_in_positive_and_comes_out_negative() {
    let eth = Asset::perp(1).unwrap();
    assert_eq!(add_margin(eth, Side::Buy, dec("12.5")).unwrap().ntli(), 12_500_000);
    assert_eq!(remove_margin(eth, Side::Sell, dec("2.5")).unwrap().ntli(), -2_500_000);
    assert!(add_margin(eth, Side::Buy, dec("-1")).is_err(), "a negative add is a removal, said as one");
    assert!(remove_margin(eth, Side::Buy, Dec::ZERO).is_err());
}

struct Recorded {
    answer: String,
    sent: RefCell<Vec<(String, String)>>,
}

impl Transport for Recorded {
    fn post(&self, path: &str, body: String) -> impl Future<Output = Result<String, TransportError>> {
        self.sent.borrow_mut().push((path.to_owned(), body));
        ready(Ok(self.answer.clone()))
    }
}

fn block_on<F: Future>(future: F) -> F::Output {
    let mut future = pin!(future);
    let mut context = Context::from_waker(Waker::noop());
    loop {
        if let Poll::Ready(value) = future.as_mut().poll(&mut context) {
            return value;
        }
    }
}

#[test]
fn the_reads_post_their_type_to_info() {
    let transport = Recorded { answer: format!("[{META},{CTXS}]"), sent: RefCell::new(Vec::new()) };
    let both = block_on(meta_and_asset_ctxs(&transport)).unwrap();
    assert_eq!(both.ctxs.len(), 4);
    assert_eq!(transport.sent.borrow()[0], ("/info".to_owned(), r#"{"type":"metaAndAssetCtxs"}"#.to_owned()));
    assert_eq!(meta_request(), r#"{"type":"meta"}"#);
}