ccxt_exchanges/hyperliquid/
parser.rs

1//! HyperLiquid data parser module.
2//!
3//! Converts HyperLiquid API response data into standardized CCXT format structures.
4
5use ccxt_core::{
6    Result,
7    error::{Error, ParseError},
8    parser_utils::{parse_decimal, parse_timestamp, value_to_hashmap},
9    types::{
10        Balance, BalanceEntry, Market, MarketLimits, MarketPrecision, MarketType, MinMax, Order,
11        OrderBook, OrderBookEntry, OrderSide, OrderStatus, OrderType, Ticker, Trade,
12        financial::{Amount, Cost, Price},
13    },
14};
15use rust_decimal::Decimal;
16use rust_decimal::prelude::{FromPrimitive, FromStr};
17use serde_json::Value;
18use std::collections::HashMap;
19
20// Re-export for backward compatibility
21pub use ccxt_core::parser_utils::timestamp_to_datetime;
22
23// ============================================================================
24// Market Parser
25// ============================================================================
26
27/// Parse market data from HyperLiquid meta response.
28pub fn parse_market(data: &Value, index: usize) -> Result<Market> {
29    let name = data["name"]
30        .as_str()
31        .ok_or_else(|| Error::from(ParseError::missing_field("name")))?;
32
33    // HyperLiquid uses format like "BTC" for the asset name
34    // We convert to unified format: "BTC/USDC:USDC"
35    let symbol = format!("{}/USDC:USDC", name);
36    let id = index.to_string();
37
38    // Parse size decimals for precision
39    let sz_decimals = data["szDecimals"].as_u64().unwrap_or(4) as u32;
40    let amount_precision = Decimal::new(1, sz_decimals);
41
42    // Price precision (HyperLiquid uses 5 significant figures typically)
43    let price_precision = Decimal::new(1, 5);
44
45    // Parse the symbol to get structured representation
46    let parsed_symbol = ccxt_core::symbol::SymbolParser::parse(&symbol).ok();
47
48    Ok(Market {
49        id,
50        symbol: symbol.clone(),
51        parsed_symbol,
52        base: name.to_string(),
53        quote: "USDC".to_string(),
54        settle: Some("USDC".to_string()),
55        base_id: Some(name.to_string()),
56        quote_id: Some("USDC".to_string()),
57        settle_id: Some("USDC".to_string()),
58        market_type: MarketType::Swap,
59        active: true,
60        margin: true,
61        contract: Some(true),
62        linear: Some(true),
63        inverse: Some(false),
64        contract_size: Some(Decimal::ONE),
65        expiry: None,
66        expiry_datetime: None,
67        strike: None,
68        option_type: None,
69        precision: MarketPrecision {
70            price: Some(price_precision),
71            amount: Some(amount_precision),
72            base: None,
73            quote: None,
74        },
75        limits: MarketLimits {
76            amount: Some(MinMax {
77                min: Some(amount_precision),
78                max: None,
79            }),
80            price: None,
81            cost: Some(MinMax {
82                min: Some(Decimal::new(10, 0)), // $10 minimum
83                max: None,
84            }),
85            leverage: Some(MinMax {
86                min: Some(Decimal::ONE),
87                max: Some(Decimal::new(50, 0)),
88            }),
89        },
90        maker: Some(Decimal::new(2, 4)), // 0.02%
91        taker: Some(Decimal::new(5, 4)), // 0.05%
92        percentage: Some(true),
93        tier_based: Some(true),
94        fee_side: Some("quote".to_string()),
95        info: value_to_hashmap(data),
96    })
97}
98
99// ============================================================================
100// Ticker Parser
101// ============================================================================
102
103/// Parse ticker data from HyperLiquid all_mids response.
104pub fn parse_ticker(symbol: &str, mid_price: Decimal, _market: Option<&Market>) -> Result<Ticker> {
105    let timestamp = chrono::Utc::now().timestamp_millis();
106
107    Ok(Ticker {
108        symbol: symbol.to_string(),
109        timestamp,
110        datetime: timestamp_to_datetime(timestamp),
111        high: None,
112        low: None,
113        bid: Some(Price::new(mid_price)),
114        bid_volume: None,
115        ask: Some(Price::new(mid_price)),
116        ask_volume: None,
117        vwap: None,
118        open: None,
119        close: Some(Price::new(mid_price)),
120        last: Some(Price::new(mid_price)),
121        previous_close: None,
122        change: None,
123        percentage: None,
124        average: None,
125        base_volume: None,
126        quote_volume: None,
127        funding_rate: None,
128        open_interest: None,
129        index_price: None,
130        mark_price: None,
131        info: HashMap::new(),
132    })
133}
134
135// ============================================================================
136// OrderBook Parser
137// ============================================================================
138
139/// Parse orderbook data from HyperLiquid L2 book response.
140pub fn parse_orderbook(data: &Value, symbol: String) -> Result<OrderBook> {
141    let timestamp = chrono::Utc::now().timestamp_millis();
142
143    let mut bids = Vec::new();
144    let mut asks = Vec::new();
145
146    // Parse levels
147    if let Some(levels) = data["levels"].as_array() {
148        if levels.len() >= 2 {
149            // First array is bids, second is asks
150            if let Some(bid_levels) = levels[0].as_array() {
151                for level in bid_levels {
152                    if let (Some(px), Some(sz)) = (
153                        level["px"].as_str().and_then(|s| Decimal::from_str(s).ok()),
154                        level["sz"].as_str().and_then(|s| Decimal::from_str(s).ok()),
155                    ) {
156                        bids.push(OrderBookEntry {
157                            price: Price::new(px),
158                            amount: Amount::new(sz),
159                        });
160                    }
161                }
162            }
163
164            if let Some(ask_levels) = levels[1].as_array() {
165                for level in ask_levels {
166                    if let (Some(px), Some(sz)) = (
167                        level["px"].as_str().and_then(|s| Decimal::from_str(s).ok()),
168                        level["sz"].as_str().and_then(|s| Decimal::from_str(s).ok()),
169                    ) {
170                        asks.push(OrderBookEntry {
171                            price: Price::new(px),
172                            amount: Amount::new(sz),
173                        });
174                    }
175                }
176            }
177        }
178    }
179
180    // Sort bids descending, asks ascending
181    bids.sort_by(|a, b| b.price.cmp(&a.price));
182    asks.sort_by(|a, b| a.price.cmp(&b.price));
183
184    Ok(OrderBook {
185        symbol,
186        timestamp,
187        datetime: timestamp_to_datetime(timestamp),
188        nonce: None,
189        bids,
190        asks,
191        buffered_deltas: std::collections::VecDeque::new(),
192        bids_map: std::collections::BTreeMap::new(),
193        asks_map: std::collections::BTreeMap::new(),
194        is_synced: false,
195        needs_resync: false,
196        last_resync_time: 0,
197        info: value_to_hashmap(data),
198    })
199}
200
201// ============================================================================
202// Trade Parser
203// ============================================================================
204
205/// Parse trade data from HyperLiquid fill response.
206pub fn parse_trade(data: &Value, market: Option<&Market>) -> Result<Trade> {
207    let symbol = market.map_or_else(
208        || data["coin"].as_str().unwrap_or("").to_string(),
209        |m| m.symbol.clone(),
210    );
211
212    let timestamp = parse_timestamp(data, "time").unwrap_or(0);
213
214    let side = match data["side"].as_str() {
215        Some("A" | "sell" | "Sell") => OrderSide::Sell,
216        _ => OrderSide::Buy,
217    };
218
219    let price = parse_decimal(data, "px").unwrap_or(Decimal::ZERO);
220    let amount = parse_decimal(data, "sz").unwrap_or(Decimal::ZERO);
221    let cost = price * amount;
222
223    Ok(Trade {
224        id: data["tid"]
225            .as_str()
226            .or(data["hash"].as_str())
227            .map(ToString::to_string),
228        order: data["oid"].as_str().map(ToString::to_string),
229        timestamp,
230        datetime: timestamp_to_datetime(timestamp),
231        symbol,
232        trade_type: None,
233        side,
234        taker_or_maker: None,
235        price: Price::new(price),
236        amount: Amount::new(amount),
237        cost: Some(Cost::new(cost)),
238        fee: None,
239        info: value_to_hashmap(data),
240    })
241}
242
243// ============================================================================
244// Order Parser
245// ============================================================================
246
247/// Parse order status from HyperLiquid status string.
248pub fn parse_order_status(status: &str) -> OrderStatus {
249    match status.to_lowercase().as_str() {
250        "filled" => OrderStatus::Closed,
251        "canceled" | "cancelled" => OrderStatus::Cancelled,
252        "rejected" => OrderStatus::Rejected,
253        _ => OrderStatus::Open,
254    }
255}
256
257/// Parse order data from HyperLiquid order response.
258pub fn parse_order(data: &Value, market: Option<&Market>) -> Result<Order> {
259    let symbol = market.map_or_else(
260        || {
261            data["coin"]
262                .as_str()
263                .map_or_else(String::new, |c| format!("{}/USDC:USDC", c))
264        },
265        |m| m.symbol.clone(),
266    );
267
268    let id = data["oid"]
269        .as_u64()
270        .map(|n| n.to_string())
271        .or_else(|| data["oid"].as_str().map(ToString::to_string))
272        .unwrap_or_default();
273
274    let timestamp = parse_timestamp(data, "timestamp");
275
276    let status_str = data["status"].as_str().unwrap_or("open");
277    let status = parse_order_status(status_str);
278
279    let side = match data["side"].as_str() {
280        Some("B" | "buy") => OrderSide::Buy,
281        Some("A" | "sell") => OrderSide::Sell,
282        _ => {
283            // Check isBuy field
284            if data["isBuy"].as_bool().unwrap_or(true) {
285                OrderSide::Buy
286            } else {
287                OrderSide::Sell
288            }
289        }
290    };
291
292    let order_type = match data["orderType"].as_str() {
293        Some("Market" | "market") => OrderType::Market,
294        _ => OrderType::Limit,
295    };
296
297    let price = parse_decimal(data, "limitPx").or_else(|| parse_decimal(data, "px"));
298    let amount = parse_decimal(data, "sz")
299        .or_else(|| parse_decimal(data, "origSz"))
300        .unwrap_or(Decimal::ZERO);
301    let filled = parse_decimal(data, "filledSz");
302    let remaining = filled.map(|f| amount - f);
303
304    Ok(Order {
305        id,
306        client_order_id: data["cloid"].as_str().map(ToString::to_string),
307        timestamp,
308        datetime: timestamp.and_then(timestamp_to_datetime),
309        last_trade_timestamp: None,
310        status,
311        symbol,
312        order_type,
313        time_in_force: data["tif"].as_str().map(str::to_uppercase),
314        side,
315        price,
316        average: parse_decimal(data, "avgPx"),
317        amount,
318        filled,
319        remaining,
320        cost: None,
321        trades: None,
322        fee: None,
323        post_only: None,
324        reduce_only: data["reduceOnly"].as_bool(),
325        trigger_price: parse_decimal(data, "triggerPx"),
326        stop_price: None,
327        take_profit_price: None,
328        stop_loss_price: None,
329        trailing_delta: None,
330        trailing_percent: None,
331        activation_price: None,
332        callback_rate: None,
333        working_type: None,
334        fees: Some(Vec::new()),
335        info: value_to_hashmap(data),
336    })
337}
338
339// ============================================================================
340// OHLCV Parser
341// ============================================================================
342
343/// Parse OHLCV (candlestick) data from HyperLiquid candle response.
344///
345/// HyperLiquid returns candle data as arrays: [timestamp, open, high, low, close, volume]
346pub fn parse_ohlcv(data: &Value) -> Result<ccxt_core::types::Ohlcv> {
347    // Handle array format: [timestamp, open, high, low, close, volume]
348    if let Some(arr) = data.as_array() {
349        if arr.len() >= 6 {
350            let timestamp = arr[0]
351                .as_i64()
352                .or_else(|| arr[0].as_str().and_then(|s| s.parse().ok()))
353                .ok_or_else(|| Error::from(ParseError::missing_field("timestamp")))?;
354
355            let open = parse_decimal_from_value(&arr[1])
356                .ok_or_else(|| Error::from(ParseError::missing_field("open")))?;
357            let high = parse_decimal_from_value(&arr[2])
358                .ok_or_else(|| Error::from(ParseError::missing_field("high")))?;
359            let low = parse_decimal_from_value(&arr[3])
360                .ok_or_else(|| Error::from(ParseError::missing_field("low")))?;
361            let close = parse_decimal_from_value(&arr[4])
362                .ok_or_else(|| Error::from(ParseError::missing_field("close")))?;
363            let volume = parse_decimal_from_value(&arr[5])
364                .ok_or_else(|| Error::from(ParseError::missing_field("volume")))?;
365
366            return Ok(ccxt_core::types::Ohlcv {
367                timestamp,
368                open: ccxt_core::types::financial::Price::new(open),
369                high: ccxt_core::types::financial::Price::new(high),
370                low: ccxt_core::types::financial::Price::new(low),
371                close: ccxt_core::types::financial::Price::new(close),
372                volume: ccxt_core::types::financial::Amount::new(volume),
373            });
374        }
375    }
376
377    // Handle object format
378    let timestamp = parse_timestamp(data, "t")
379        .or_else(|| parse_timestamp(data, "timestamp"))
380        .ok_or_else(|| Error::from(ParseError::missing_field("timestamp")))?;
381
382    let open = parse_decimal(data, "o")
383        .or_else(|| parse_decimal(data, "open"))
384        .ok_or_else(|| Error::from(ParseError::missing_field("open")))?;
385    let high = parse_decimal(data, "h")
386        .or_else(|| parse_decimal(data, "high"))
387        .ok_or_else(|| Error::from(ParseError::missing_field("high")))?;
388    let low = parse_decimal(data, "l")
389        .or_else(|| parse_decimal(data, "low"))
390        .ok_or_else(|| Error::from(ParseError::missing_field("low")))?;
391    let close = parse_decimal(data, "c")
392        .or_else(|| parse_decimal(data, "close"))
393        .ok_or_else(|| Error::from(ParseError::missing_field("close")))?;
394    let volume = parse_decimal(data, "v")
395        .or_else(|| parse_decimal(data, "volume"))
396        .ok_or_else(|| Error::from(ParseError::missing_field("volume")))?;
397
398    Ok(ccxt_core::types::Ohlcv {
399        timestamp,
400        open: ccxt_core::types::financial::Price::new(open),
401        high: ccxt_core::types::financial::Price::new(high),
402        low: ccxt_core::types::financial::Price::new(low),
403        close: ccxt_core::types::financial::Price::new(close),
404        volume: ccxt_core::types::financial::Amount::new(volume),
405    })
406}
407
408/// Helper to parse decimal from a JSON value directly
409fn parse_decimal_from_value(v: &Value) -> Option<Decimal> {
410    if let Some(num) = v.as_f64() {
411        Decimal::from_f64(num)
412    } else if let Some(s) = v.as_str() {
413        Decimal::from_str(s).ok()
414    } else {
415        None
416    }
417}
418
419// ============================================================================
420// Balance Parser
421// ============================================================================
422
423/// Parse balance data from HyperLiquid user state response.
424pub fn parse_balance(data: &Value) -> Result<Balance> {
425    let mut balances = HashMap::new();
426
427    // Parse margin summary
428    if let Some(margin) = data.get("marginSummary") {
429        let account_value = parse_decimal(margin, "accountValue").unwrap_or(Decimal::ZERO);
430        let total_margin_used = parse_decimal(margin, "totalMarginUsed").unwrap_or(Decimal::ZERO);
431        let available = account_value - total_margin_used;
432
433        balances.insert(
434            "USDC".to_string(),
435            BalanceEntry {
436                free: available,
437                used: total_margin_used,
438                total: account_value,
439            },
440        );
441    }
442
443    // Also check withdrawable
444    if let Some(withdrawable) = data.get("withdrawable") {
445        if let Some(w) = withdrawable
446            .as_str()
447            .and_then(|s| Decimal::from_str(s).ok())
448        {
449            if let Some(entry) = balances.get_mut("USDC") {
450                entry.free = w;
451            }
452        }
453    }
454
455    Ok(Balance {
456        balances,
457        info: value_to_hashmap(data),
458    })
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use rust_decimal_macros::dec;
465    use serde_json::json;
466
467    #[test]
468    fn test_parse_market() {
469        let data = json!({
470            "name": "BTC",
471            "szDecimals": 4
472        });
473
474        let market = parse_market(&data, 0).unwrap();
475        assert_eq!(market.symbol, "BTC/USDC:USDC");
476        assert_eq!(market.base, "BTC");
477        assert_eq!(market.quote, "USDC");
478        assert!(market.active);
479    }
480
481    #[test]
482    fn test_parse_ticker() {
483        let ticker = parse_ticker("BTC/USDC:USDC", dec!(50000), None).unwrap();
484        assert_eq!(ticker.symbol, "BTC/USDC:USDC");
485        assert_eq!(ticker.last, Some(Price::new(dec!(50000))));
486    }
487
488    #[test]
489    fn test_parse_orderbook() {
490        let data = json!({
491            "levels": [
492                [{"px": "50000", "sz": "1.5"}, {"px": "49999", "sz": "2.0"}],
493                [{"px": "50001", "sz": "1.0"}, {"px": "50002", "sz": "3.0"}]
494            ]
495        });
496
497        let orderbook = parse_orderbook(&data, "BTC/USDC:USDC".to_string()).unwrap();
498        assert_eq!(orderbook.bids.len(), 2);
499        assert_eq!(orderbook.asks.len(), 2);
500        // Bids should be sorted descending
501        assert!(orderbook.bids[0].price >= orderbook.bids[1].price);
502        // Asks should be sorted ascending
503        assert!(orderbook.asks[0].price <= orderbook.asks[1].price);
504    }
505
506    #[test]
507    fn test_parse_trade() {
508        let data = json!({
509            "coin": "BTC",
510            "side": "B",
511            "px": "50000",
512            "sz": "0.5",
513            "time": 1700000000000i64,
514            "tid": "123456"
515        });
516
517        let trade = parse_trade(&data, None).unwrap();
518        assert_eq!(trade.side, OrderSide::Buy);
519        assert_eq!(trade.price, Price::new(dec!(50000)));
520        assert_eq!(trade.amount, Amount::new(dec!(0.5)));
521    }
522
523    #[test]
524    fn test_parse_order_status() {
525        assert_eq!(parse_order_status("open"), OrderStatus::Open);
526        assert_eq!(parse_order_status("filled"), OrderStatus::Closed);
527        assert_eq!(parse_order_status("canceled"), OrderStatus::Cancelled);
528    }
529
530    #[test]
531    fn test_parse_balance() {
532        let data = json!({
533            "marginSummary": {
534                "accountValue": "10000",
535                "totalMarginUsed": "2000"
536            },
537            "withdrawable": "8000"
538        });
539
540        let balance = parse_balance(&data).unwrap();
541        let usdc = balance.get("USDC").unwrap();
542        assert_eq!(usdc.total, dec!(10000));
543        assert_eq!(usdc.free, dec!(8000));
544    }
545}