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