use std::{
collections::HashMap,
net::SocketAddr,
path::PathBuf,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
use axum::{
Router,
extract::State,
http::StatusCode,
response::{IntoResponse, Json, Response},
routing::post,
};
use nautilus_common::testing::wait_until_async;
use nautilus_hyperliquid::{
HyperliquidHttpClient,
common::enums::HyperliquidInfoRequestType,
http::{
models::{
Cloid, HyperliquidFills, HyperliquidL2Book, PerpMeta, PerpMetaAndCtxs, SpotMeta,
SpotMetaAndCtxs,
},
query::{InfoRequest, InfoRequestParams},
},
};
use nautilus_model::{
enums::OrderStatus,
identifiers::{AccountId, ClientOrderId},
};
use nautilus_network::http::{HttpClient, Method};
use rstest::rstest;
use serde_json::{Value, json};
#[derive(Clone)]
struct TestServerState {
request_count: Arc<tokio::sync::Mutex<usize>>,
last_request_body: Arc<tokio::sync::Mutex<Option<Value>>>,
rate_limit_after: Arc<AtomicUsize>,
frontend_open_orders_response: Arc<tokio::sync::Mutex<Option<Value>>>,
order_status_response: Arc<tokio::sync::Mutex<Option<Value>>>,
}
impl Default for TestServerState {
fn default() -> Self {
Self {
request_count: Arc::new(tokio::sync::Mutex::new(0)),
last_request_body: Arc::new(tokio::sync::Mutex::new(None)),
rate_limit_after: Arc::new(AtomicUsize::new(usize::MAX)),
frontend_open_orders_response: Arc::new(tokio::sync::Mutex::new(None)),
order_status_response: Arc::new(tokio::sync::Mutex::new(None)),
}
}
}
fn data_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data")
}
fn load_json(filename: &str) -> Value {
let content = std::fs::read_to_string(data_path().join(filename))
.unwrap_or_else(|_| panic!("failed to read {filename}"));
serde_json::from_str(&content).expect("invalid json")
}
async fn wait_for_server(addr: SocketAddr, path: &str) {
let health_url = format!("http://{addr}{path}");
let http_client =
HttpClient::new(HashMap::new(), Vec::new(), Vec::new(), None, None, None).unwrap();
wait_until_async(
|| {
let url = health_url.clone();
let client = http_client.clone();
async move { client.get(url, None, None, Some(1), None).await.is_ok() }
},
Duration::from_secs(5),
)
.await;
}
async fn handle_info(State(state): State<TestServerState>, body: axum::body::Bytes) -> Response {
let mut count = state.request_count.lock().await;
*count += 1;
let limit_after = state.rate_limit_after.load(Ordering::Relaxed);
if *count > limit_after {
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "Rate limit exceeded"
})),
)
.into_response();
}
let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "Invalid JSON body"
})),
)
.into_response();
};
*state.last_request_body.lock().await = Some(request_body.clone());
let request_type = request_body
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("");
match request_type {
"meta" => {
let meta = load_json("http_meta_perp_sample.json");
Json(meta).into_response()
}
"allPerpMetas" => {
let meta = load_json("http_meta_perp_sample.json");
Json(json!([meta])).into_response()
}
"spotMeta" => Json(json!({
"universe": [],
"tokens": []
}))
.into_response(),
"metaAndAssetCtxs" => {
let meta = load_json("http_meta_perp_sample.json");
Json(json!([meta, []])).into_response()
}
"spotMetaAndAssetCtxs" => Json(json!([
{"universe": [], "tokens": []},
[]
]))
.into_response(),
"l2Book" => {
let book = load_json("http_l2_book_btc.json");
Json(book).into_response()
}
"userFills" => Json(json!([])).into_response(),
"orderStatus" => {
let custom = state.order_status_response.lock().await;
Json(custom.clone().unwrap_or(json!({"statuses": []}))).into_response()
}
"openOrders" => Json(json!([])).into_response(),
"frontendOpenOrders" => {
let custom = state.frontend_open_orders_response.lock().await;
Json(custom.clone().unwrap_or(json!([]))).into_response()
}
"clearinghouseState" => Json(json!({
"marginSummary": {
"accountValue": "10000.0",
"totalMarginUsed": "0.0",
"totalNtlPos": "0.0",
"totalRawUsd": "10000.0"
},
"crossMarginSummary": {
"accountValue": "10000.0",
"totalMarginUsed": "0.0",
"totalNtlPos": "0.0",
"totalRawUsd": "10000.0"
},
"crossMaintenanceMarginUsed": "0.0",
"withdrawable": "10000.0",
"assetPositions": []
}))
.into_response(),
"candleSnapshot" => Json(json!([
{
"t": 1703875200000u64,
"T": 1703875260000u64,
"s": "BTC",
"i": "1m",
"o": "98450.00",
"c": "98460.00",
"h": "98470.00",
"l": "98440.00",
"v": "100.5",
"n": 50
}
]))
.into_response(),
_ => (
StatusCode::BAD_REQUEST,
Json(json!({
"error": format!("Unknown request type: {}", request_type)
})),
)
.into_response(),
}
}
async fn handle_exchange(
State(state): State<TestServerState>,
body: axum::body::Bytes,
) -> Response {
let mut count = state.request_count.lock().await;
*count += 1;
let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"status": "err",
"response": {
"type": "error",
"data": "Invalid JSON body"
}
})),
)
.into_response();
};
*state.last_request_body.lock().await = Some(request_body.clone());
if request_body.get("action").is_none()
|| request_body.get("nonce").is_none()
|| request_body.get("signature").is_none()
{
return (
StatusCode::BAD_REQUEST,
Json(json!({
"status": "err",
"response": {
"type": "error",
"data": "Missing required fields"
}
})),
)
.into_response();
}
Json(json!({
"status": "ok",
"response": {
"type": "order",
"data": {
"statuses": [{
"resting": {
"oid": 12345
}
}]
}
}
}))
.into_response()
}
async fn handle_health() -> impl IntoResponse {
StatusCode::OK
}
fn create_test_router(state: TestServerState) -> Router {
Router::new()
.route("/info", post(handle_info))
.route("/exchange", post(handle_exchange))
.route("/health", axum::routing::get(handle_health))
.with_state(state)
}
async fn start_mock_server(state: TestServerState) -> SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let router = create_test_router(state);
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
wait_for_server(addr, "/health").await;
addr
}
#[rstest]
#[tokio::test]
async fn test_info_meta_returns_market_metadata() {
let state = TestServerState::default();
let addr = start_mock_server(state.clone()).await;
let client = create_test_client(&addr);
let result = client.info_meta().await;
assert!(result.is_ok());
let meta = result.unwrap();
assert!(!meta.universe.is_empty());
assert_eq!(meta.universe[0].name, "BTC");
}
#[rstest]
#[tokio::test]
async fn test_info_l2_book_returns_orderbook() {
let state = TestServerState::default();
let addr = start_mock_server(state.clone()).await;
let client = create_test_client(&addr);
let result = client.info_l2_book("BTC").await;
assert!(result.is_ok());
let book = result.unwrap();
assert_eq!(book.coin, "BTC");
assert_eq!(book.levels.len(), 2); }
#[rstest]
#[tokio::test]
async fn test_spot_meta_returns_spot_metadata() {
let state = TestServerState::default();
let addr = start_mock_server(state.clone()).await;
let client = create_test_client(&addr);
let meta = client.get_spot_meta().await.unwrap();
assert!(meta.tokens.is_empty());
assert!(meta.universe.is_empty());
}
#[rstest]
#[tokio::test]
async fn test_perp_meta_and_ctxs_returns_metadata_with_contexts() {
let state = TestServerState::default();
let addr = start_mock_server(state.clone()).await;
let client = create_test_client(&addr);
let PerpMetaAndCtxs::Payload(data) = client.get_perp_meta_and_ctxs().await.unwrap();
let (meta, ctxs) = *data;
assert!(!meta.universe.is_empty());
assert!(ctxs.is_empty());
}
#[rstest]
#[tokio::test]
async fn test_spot_meta_and_ctxs_returns_metadata_with_contexts() {
let state = TestServerState::default();
let addr = start_mock_server(state.clone()).await;
let client = create_test_client(&addr);
let SpotMetaAndCtxs::Payload(data) = client.get_spot_meta_and_ctxs().await.unwrap();
let (meta, ctxs) = *data;
assert!(meta.tokens.is_empty());
assert!(meta.universe.is_empty());
assert!(ctxs.is_empty());
}
#[rstest]
#[tokio::test]
async fn test_info_user_fills_returns_empty_for_new_user() {
let state = TestServerState::default();
let addr = start_mock_server(state.clone()).await;
let client = create_test_client(&addr);
let result = client
.info_user_fills("0x1234567890123456789012345678901234567890")
.await;
assert!(result.is_ok());
let fills = result.unwrap();
assert!(fills.is_empty());
}
#[rstest]
#[tokio::test]
async fn test_info_open_orders_returns_empty_array() {
let state = TestServerState::default();
let addr = start_mock_server(state.clone()).await;
let client = create_test_client(&addr);
let orders = client
.info_open_orders("0x1234567890123456789012345678901234567890")
.await
.unwrap();
assert!(orders.is_array());
assert!(orders.as_array().unwrap().is_empty());
}
#[rstest]
#[tokio::test]
async fn test_info_clearinghouse_state_returns_account_state() {
let state = TestServerState::default();
let addr = start_mock_server(state.clone()).await;
let client = create_test_client(&addr);
let result = client
.info_clearinghouse_state("0x1234567890123456789012345678901234567890")
.await;
assert!(result.is_ok());
let state = result.unwrap();
assert!(state.get("marginSummary").is_some());
}
#[rstest]
#[tokio::test]
async fn test_rate_limit_triggers_429_response() {
let state = TestServerState::default();
state.rate_limit_after.store(2, Ordering::Relaxed);
let addr = start_mock_server(state.clone()).await;
let client = create_test_client(&addr);
assert!(client.info_meta().await.is_ok());
assert!(client.info_meta().await.is_ok());
let result = client.info_meta().await;
assert!(result.is_err());
}
#[rstest]
#[tokio::test]
async fn test_invalid_request_type_returns_error() {
let state = TestServerState::default();
let addr = start_mock_server(state.clone()).await;
let client = create_test_client(&addr);
let request = InfoRequest {
request_type: HyperliquidInfoRequestType::Meta,
params: InfoRequestParams::None,
};
let result = client.send_info_request_raw(&request).await;
assert!(result.is_ok());
}
#[rstest]
#[tokio::test]
async fn test_l2_book_request_includes_coin_parameter() {
let state = TestServerState::default();
let addr = start_mock_server(state.clone()).await;
let client = create_test_client(&addr);
let _ = client.info_l2_book("ETH").await;
let last_request = state.last_request_body.lock().await;
let request_body = last_request.as_ref().unwrap();
assert_eq!(
request_body.get("type").unwrap().as_str().unwrap(),
"l2Book"
);
assert_eq!(request_body.get("coin").unwrap().as_str().unwrap(), "ETH");
}
#[rstest]
#[tokio::test]
async fn test_user_fills_request_includes_user_parameter() {
let state = TestServerState::default();
let addr = start_mock_server(state.clone()).await;
let client = create_test_client(&addr);
let user = "0xabcdef1234567890abcdef1234567890abcdef12";
let _ = client.info_user_fills(user).await;
let last_request = state.last_request_body.lock().await;
let request_body = last_request.as_ref().unwrap();
assert_eq!(
request_body.get("type").unwrap().as_str().unwrap(),
"userFills"
);
assert_eq!(request_body.get("user").unwrap().as_str().unwrap(), user);
}
fn create_test_client(addr: &SocketAddr) -> TestHttpClient {
TestHttpClient::new(format!("http://{addr}"))
}
struct TestHttpClient {
client: HttpClient,
base_url: String,
}
impl TestHttpClient {
fn new(base_url: String) -> Self {
let client = HttpClient::new(
HashMap::from([("Content-Type".to_string(), "application/json".to_string())]),
vec![],
vec![],
None,
None,
None,
)
.unwrap();
Self { client, base_url }
}
async fn send_info_request(&self, request: &InfoRequest) -> Result<Value, String> {
let url = format!("{}/info", self.base_url);
let body = serde_json::to_vec(request).map_err(|e| e.to_string())?;
let response = self
.client
.request(Method::POST, url, None, None, Some(body), None, None)
.await
.map_err(|e| e.to_string())?;
if !response.status.is_success() {
return Err(format!("HTTP error: {:?}", response.status));
}
serde_json::from_slice(&response.body).map_err(|e| e.to_string())
}
async fn info_meta(&self) -> Result<PerpMeta, String> {
let request = InfoRequest::meta();
let value = self.send_info_request(&request).await?;
serde_json::from_value(value).map_err(|e| e.to_string())
}
async fn get_spot_meta(&self) -> Result<SpotMeta, String> {
let request = InfoRequest::spot_meta();
let value = self.send_info_request(&request).await?;
serde_json::from_value(value).map_err(|e| e.to_string())
}
async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs, String> {
let request = InfoRequest::meta_and_asset_ctxs();
let value = self.send_info_request(&request).await?;
serde_json::from_value(value).map_err(|e| e.to_string())
}
async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs, String> {
let request = InfoRequest::spot_meta_and_asset_ctxs();
let value = self.send_info_request(&request).await?;
serde_json::from_value(value).map_err(|e| e.to_string())
}
async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book, String> {
let request = InfoRequest::l2_book(coin);
let value = self.send_info_request(&request).await?;
serde_json::from_value(value).map_err(|e| e.to_string())
}
async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills, String> {
let request = InfoRequest::user_fills(user);
let value = self.send_info_request(&request).await?;
serde_json::from_value(value).map_err(|e| e.to_string())
}
async fn info_open_orders(&self, user: &str) -> Result<Value, String> {
let request = InfoRequest::open_orders(user);
self.send_info_request(&request).await
}
async fn info_clearinghouse_state(&self, user: &str) -> Result<Value, String> {
let request = InfoRequest::clearinghouse_state(user);
self.send_info_request(&request).await
}
async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value, String> {
self.send_info_request(request).await
}
}
fn create_domain_client(addr: &SocketAddr) -> HyperliquidHttpClient {
let mut client = HyperliquidHttpClient::new(true, 60, None).unwrap();
client.set_base_info_url(format!("http://{addr}/info"));
client.set_base_exchange_url(format!("http://{addr}/exchange"));
client.set_account_id(AccountId::new("HYPERLIQUID-master"));
client
}
fn cache_btc_instrument(client: &HyperliquidHttpClient) {
use nautilus_model::{
enums::CurrencyType,
identifiers::{InstrumentId, Symbol},
instruments::{CryptoPerpetual, InstrumentAny},
types::{Currency, Money, Price, Quantity},
};
let btc = Currency::new("BTC", 8, 0, "BTC", CurrencyType::Crypto);
let usd = Currency::new("USD", 2, 0, "USD", CurrencyType::Fiat);
let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
let ts = nautilus_core::time::get_atomic_clock_realtime().get_time_ns();
let instrument = CryptoPerpetual::new_checked(
InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"),
Symbol::new("BTC"),
btc,
usd,
usdc,
false,
1,
5,
Price::from("0.1"),
Quantity::from("0.00001"),
None,
None,
None,
None,
None,
Some(Money::from("0.1 USDC")),
None,
None,
None,
None,
None,
None,
None,
ts,
ts,
)
.unwrap();
client.cache_instrument(&InstrumentAny::CryptoPerpetual(instrument));
}
#[rstest]
#[tokio::test]
async fn test_request_order_status_report_open_order() {
let state = TestServerState::default();
*state.frontend_open_orders_response.lock().await = Some(json!([{
"coin": "BTC",
"side": "B",
"limitPx": "95000.0",
"sz": "0.05",
"oid": 12345,
"timestamp": 1700000000000u64,
"origSz": "0.1",
"cloid": "0xaabbccdd00112233aabbccdd00112233"
}]));
let addr = start_mock_server(state).await;
let client = create_domain_client(&addr);
cache_btc_instrument(&client);
let report = client
.request_order_status_report("0xuser", 12345)
.await
.unwrap()
.expect("should find open order");
assert_eq!(report.order_status, OrderStatus::Accepted);
assert!(report.price.is_some(), "open order retains limit price");
assert_eq!(report.filled_qty.as_f64(), 0.05);
assert_eq!(report.quantity.as_f64(), 0.1);
}
#[rstest]
#[tokio::test]
async fn test_request_order_status_report_triggered_order() {
let state = TestServerState::default();
*state.frontend_open_orders_response.lock().await = Some(json!([{
"coin": "BTC",
"side": "A",
"limitPx": "90000.0",
"sz": "0.1",
"oid": 99999,
"timestamp": 1700000000000u64,
"origSz": "0.1",
"triggerPx": "91000.0",
"isMarket": true,
"tpsl": "sl",
"triggerActivated": true
}]));
let addr = start_mock_server(state).await;
let client = create_domain_client(&addr);
cache_btc_instrument(&client);
let report = client
.request_order_status_report("0xuser", 99999)
.await
.unwrap()
.expect("should find triggered order");
assert_eq!(report.order_status, OrderStatus::Triggered);
}
#[rstest]
#[tokio::test]
async fn test_request_order_status_report_closed_order_fallback() {
let state = TestServerState::default();
*state.order_status_response.lock().await = Some(json!({
"statuses": [{
"order": {
"coin": "BTC",
"side": "B",
"limitPx": "95000.0",
"sz": "0.0",
"oid": 55555,
"timestamp": 1700000000000u64,
"origSz": "0.1"
},
"status": "filled",
"statusTimestamp": 1700001000000u64
}]
}));
let addr = start_mock_server(state).await;
let client = create_domain_client(&addr);
cache_btc_instrument(&client);
let report = client
.request_order_status_report("0xuser", 55555)
.await
.unwrap()
.expect("should find closed order via fallback");
assert_eq!(report.order_status, OrderStatus::Filled);
assert_eq!(
report.ts_last.as_u64(),
1700001000000u64 * 1_000_000,
"ts_last should use statusTimestamp"
);
}
#[rstest]
#[tokio::test]
async fn test_request_order_status_report_not_found() {
let state = TestServerState::default();
*state.order_status_response.lock().await = Some(json!({"statuses": []}));
let addr = start_mock_server(state).await;
let client = create_domain_client(&addr);
cache_btc_instrument(&client);
let report = client
.request_order_status_report("0xuser", 99999)
.await
.unwrap();
assert!(report.is_none());
}
#[rstest]
#[tokio::test]
async fn test_request_order_status_report_by_client_order_id_matches_cloid() {
let coid = ClientOrderId::new("O-20240101-000001");
let cloid_hex = Cloid::from_client_order_id(coid).to_hex();
let state = TestServerState::default();
*state.frontend_open_orders_response.lock().await = Some(json!([{
"coin": "BTC",
"side": "B",
"limitPx": "95000.0",
"sz": "0.1",
"oid": 77777,
"timestamp": 1700000000000u64,
"origSz": "0.1",
"cloid": cloid_hex
}]));
let addr = start_mock_server(state).await;
let client = create_domain_client(&addr);
cache_btc_instrument(&client);
let report = client
.request_order_status_report_by_client_order_id("0xuser", &coid)
.await
.unwrap()
.expect("should match by cloid hash");
assert_eq!(
report.client_order_id,
Some(coid),
"should return original client_order_id, not cloid hash"
);
assert_eq!(report.order_status, OrderStatus::Accepted);
}
#[rstest]
#[tokio::test]
async fn test_request_order_status_report_by_client_order_id_no_match() {
let state = TestServerState::default();
*state.frontend_open_orders_response.lock().await = Some(json!([{
"coin": "BTC",
"side": "B",
"limitPx": "95000.0",
"sz": "0.1",
"oid": 88888,
"timestamp": 1700000000000u64,
"origSz": "0.1",
"cloid": "0x0000000000000000000000000000dead"
}]));
let addr = start_mock_server(state).await;
let client = create_domain_client(&addr);
cache_btc_instrument(&client);
let coid = ClientOrderId::new("O-20240101-999999");
let report = client
.request_order_status_report_by_client_order_id("0xuser", &coid)
.await
.unwrap();
assert!(report.is_none(), "should not match different cloid");
}