Skip to main content

ccxt_exchanges/okx/
parser.rs

1//! OKX data parser module.
2//!
3//! Converts OKX 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, OHLCV,
11        Order, 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::{datetime_to_timestamp, timestamp_to_datetime};
22
23// ============================================================================
24// Market Data Parser Functions
25// ============================================================================
26
27/// Parse market data from OKX exchange info.
28///
29/// OKX uses `instId` for instrument ID (e.g., "BTC-USDT").
30///
31/// # Arguments
32///
33/// * `data` - OKX market data JSON object
34///
35/// # Returns
36///
37/// Returns a CCXT [`Market`] structure.
38pub fn parse_market(data: &Value) -> Result<Market> {
39    // OKX uses "instId" for the exchange-specific ID (e.g., "BTC-USDT")
40    let id = data["instId"]
41        .as_str()
42        .ok_or_else(|| Error::from(ParseError::missing_field("instId")))?
43        .to_string();
44
45    // Base and quote currencies
46    let base = data["baseCcy"]
47        .as_str()
48        .ok_or_else(|| Error::from(ParseError::missing_field("baseCcy")))?
49        .to_string();
50
51    let quote = data["quoteCcy"]
52        .as_str()
53        .ok_or_else(|| Error::from(ParseError::missing_field("quoteCcy")))?
54        .to_string();
55
56    // Instrument type
57    let inst_type = data["instType"].as_str().unwrap_or("SPOT");
58    let market_type = match inst_type {
59        "SWAP" => MarketType::Swap,
60        "FUTURES" => MarketType::Futures,
61        "OPTION" => MarketType::Option,
62        _ => MarketType::Spot,
63    };
64
65    // Market status
66    let state = data["state"].as_str().unwrap_or("live");
67    let active = state == "live";
68
69    // Parse precision - OKX uses tickSz and lotSz
70    let price_precision = parse_decimal(data, "tickSz");
71    let amount_precision = parse_decimal(data, "lotSz");
72
73    // Parse limits
74    let min_amount = parse_decimal(data, "minSz");
75    let max_amount = parse_decimal(data, "maxLmtSz");
76
77    // Contract-specific fields
78    let contract = inst_type != "SPOT";
79    let linear = if contract {
80        Some(data["ctType"].as_str() == Some("linear"))
81    } else {
82        None
83    };
84    let inverse = if contract {
85        Some(data["ctType"].as_str() == Some("inverse"))
86    } else {
87        None
88    };
89    let contract_size = parse_decimal(data, "ctVal");
90
91    // Settlement currency for derivatives
92    let settle = data["settleCcy"].as_str().map(ToString::to_string);
93    let settle_id = settle.clone();
94
95    // Expiry for futures/options
96    let expiry = parse_timestamp(data, "expTime");
97    let expiry_datetime = expiry.and_then(timestamp_to_datetime);
98
99    // Build unified symbol format based on market type:
100    // - Spot: BASE/QUOTE (e.g., "BTC/USDT")
101    // - Swap: BASE/QUOTE:SETTLE (e.g., "BTC/USDT:USDT")
102    // - Futures: BASE/QUOTE:SETTLE-YYMMDD (e.g., "BTC/USDT:USDT-241231")
103    let symbol = match market_type {
104        MarketType::Spot => format!("{}/{}", base, quote),
105        MarketType::Swap => {
106            if let Some(ref s) = settle {
107                format!("{}/{}:{}", base, quote, s)
108            } else {
109                // Fallback: use quote as settle for linear
110                format!("{}/{}:{}", base, quote, quote)
111            }
112        }
113        MarketType::Futures | MarketType::Option => {
114            if let (Some(s), Some(exp_ts)) = (&settle, expiry) {
115                // Convert timestamp to YYMMDD format
116                if let Some(dt) = chrono::DateTime::from_timestamp_millis(exp_ts) {
117                    let year = (dt.format("%y").to_string().parse::<u8>()).unwrap_or(0);
118                    let month = (dt.format("%m").to_string().parse::<u8>()).unwrap_or(1);
119                    let day = (dt.format("%d").to_string().parse::<u8>()).unwrap_or(1);
120                    format!("{}/{}:{}-{:02}{:02}{:02}", base, quote, s, year, month, day)
121                } else {
122                    format!("{}/{}:{}", base, quote, s)
123                }
124            } else if let Some(ref s) = settle {
125                format!("{}/{}:{}", base, quote, s)
126            } else {
127                format!("{}/{}", base, quote)
128            }
129        }
130    };
131
132    // Parse the symbol to get structured representation
133    let parsed_symbol = ccxt_core::symbol::SymbolParser::parse(&symbol).ok();
134
135    Ok(Market {
136        id,
137        symbol,
138        parsed_symbol,
139        base: base.clone(),
140        quote: quote.clone(),
141        settle,
142        base_id: Some(base),
143        quote_id: Some(quote),
144        settle_id,
145        market_type,
146        active,
147        margin: inst_type == "MARGIN",
148        contract: Some(contract),
149        linear,
150        inverse,
151        contract_size,
152        expiry,
153        expiry_datetime,
154        strike: parse_decimal(data, "stk"),
155        option_type: data["optType"].as_str().map(ToString::to_string),
156        precision: MarketPrecision {
157            price: price_precision,
158            amount: amount_precision,
159            base: None,
160            quote: None,
161        },
162        limits: MarketLimits {
163            amount: Some(MinMax {
164                min: min_amount,
165                max: max_amount,
166            }),
167            price: None,
168            cost: None,
169            leverage: None,
170        },
171        maker: parse_decimal(data, "makerFee"),
172        taker: parse_decimal(data, "takerFee"),
173        percentage: Some(true),
174        tier_based: Some(false),
175        fee_side: Some("quote".to_string()),
176        info: value_to_hashmap(data),
177    })
178}
179
180/// Parse ticker data from OKX ticker response.
181///
182/// # Arguments
183///
184/// * `data` - OKX ticker data JSON object
185/// * `market` - Optional market information for symbol resolution
186///
187/// # Returns
188///
189/// Returns a CCXT [`Ticker`] structure.
190pub fn parse_ticker(data: &Value, market: Option<&Market>) -> Result<Ticker> {
191    let symbol = if let Some(m) = market {
192        m.symbol.clone()
193    } else {
194        // Try to construct symbol from instId
195        data["instId"]
196            .as_str()
197            .map(|s| s.replace('-', "/"))
198            .unwrap_or_default()
199    };
200
201    // OKX uses "ts" for timestamp
202    let timestamp = parse_timestamp(data, "ts").unwrap_or(0);
203
204    Ok(Ticker {
205        symbol,
206        timestamp,
207        datetime: timestamp_to_datetime(timestamp),
208        high: parse_decimal(data, "high24h").map(Price::new),
209        low: parse_decimal(data, "low24h").map(Price::new),
210        bid: parse_decimal(data, "bidPx").map(Price::new),
211        bid_volume: parse_decimal(data, "bidSz").map(Amount::new),
212        ask: parse_decimal(data, "askPx").map(Price::new),
213        ask_volume: parse_decimal(data, "askSz").map(Amount::new),
214        vwap: None,
215        open: parse_decimal(data, "open24h")
216            .or_else(|| parse_decimal(data, "sodUtc0"))
217            .map(Price::new),
218        close: parse_decimal(data, "last").map(Price::new),
219        last: parse_decimal(data, "last").map(Price::new),
220        previous_close: None,
221        change: None, // OKX doesn't provide direct change value
222        percentage: parse_decimal(data, "sodUtc0").and_then(|open| {
223            parse_decimal(data, "last").map(|last| {
224                if open.is_zero() {
225                    Decimal::ZERO
226                } else {
227                    ((last - open) / open) * Decimal::from(100)
228                }
229            })
230        }),
231        average: None,
232        base_volume: parse_decimal(data, "vol24h")
233            .or_else(|| parse_decimal(data, "volCcy24h"))
234            .map(Amount::new),
235        quote_volume: parse_decimal(data, "volCcy24h").map(Amount::new),
236        funding_rate: None,
237        open_interest: None,
238        index_price: None,
239        mark_price: None,
240        info: value_to_hashmap(data),
241    })
242}
243
244/// Parse orderbook data from OKX depth response.
245///
246/// # Arguments
247///
248/// * `data` - OKX orderbook data JSON object
249/// * `symbol` - Trading pair symbol
250///
251/// # Returns
252///
253/// Returns a CCXT [`OrderBook`] structure with bids sorted in descending order
254/// and asks sorted in ascending order.
255pub fn parse_orderbook(data: &Value, symbol: String) -> Result<OrderBook> {
256    let timestamp =
257        parse_timestamp(data, "ts").unwrap_or_else(|| chrono::Utc::now().timestamp_millis());
258
259    let mut bids = parse_orderbook_side(&data["bids"])?;
260    let mut asks = parse_orderbook_side(&data["asks"])?;
261
262    // Sort bids in descending order (highest price first)
263    bids.sort_by(|a, b| b.price.cmp(&a.price));
264
265    // Sort asks in ascending order (lowest price first)
266    asks.sort_by(|a, b| a.price.cmp(&b.price));
267
268    Ok(OrderBook {
269        symbol,
270        timestamp,
271        datetime: timestamp_to_datetime(timestamp),
272        nonce: None,
273        bids,
274        asks,
275        buffered_deltas: std::collections::VecDeque::new(),
276        bids_map: std::collections::BTreeMap::new(),
277        asks_map: std::collections::BTreeMap::new(),
278        is_synced: false,
279        needs_resync: false,
280        last_resync_time: 0,
281        info: value_to_hashmap(data),
282    })
283}
284
285/// Parse one side (bids or asks) of orderbook data.
286fn parse_orderbook_side(data: &Value) -> Result<Vec<OrderBookEntry>> {
287    let Some(array) = data.as_array() else {
288        return Ok(Vec::new());
289    };
290
291    let mut result = Vec::new();
292
293    for item in array {
294        if let Some(arr) = item.as_array() {
295            // OKX format: [price, size, liquidated_orders, num_orders]
296            if arr.len() >= 2 {
297                let price = arr[0]
298                    .as_str()
299                    .and_then(|s| Decimal::from_str(s).ok())
300                    .or_else(|| arr[0].as_f64().and_then(Decimal::from_f64))
301                    .ok_or_else(|| Error::from(ParseError::invalid_value("data", "price")))?;
302
303                let amount = arr[1]
304                    .as_str()
305                    .and_then(|s| Decimal::from_str(s).ok())
306                    .or_else(|| arr[1].as_f64().and_then(Decimal::from_f64))
307                    .ok_or_else(|| Error::from(ParseError::invalid_value("data", "amount")))?;
308
309                result.push(OrderBookEntry {
310                    price: Price::new(price),
311                    amount: Amount::new(amount),
312                });
313            }
314        }
315    }
316
317    Ok(result)
318}
319
320/// Parse trade data from OKX trade response.
321///
322/// # Arguments
323///
324/// * `data` - OKX trade data JSON object
325/// * `market` - Optional market information for symbol resolution
326///
327/// # Returns
328///
329/// Returns a CCXT [`Trade`] structure.
330pub fn parse_trade(data: &Value, market: Option<&Market>) -> Result<Trade> {
331    let symbol = if let Some(m) = market {
332        m.symbol.clone()
333    } else {
334        data["instId"]
335            .as_str()
336            .map(|s| s.replace('-', "/"))
337            .unwrap_or_default()
338    };
339
340    let id = data["tradeId"].as_str().map(ToString::to_string);
341
342    let timestamp = parse_timestamp(data, "ts").unwrap_or(0);
343
344    // OKX uses "side" field with "buy" or "sell" values
345    let side = match data["side"].as_str() {
346        Some("sell" | "Sell" | "SELL") => OrderSide::Sell,
347        _ => OrderSide::Buy, // Default to buy if not specified
348    };
349
350    let price = parse_decimal(data, "px").or_else(|| parse_decimal(data, "fillPx"));
351    let amount = parse_decimal(data, "sz").or_else(|| parse_decimal(data, "fillSz"));
352
353    let cost = match (price, amount) {
354        (Some(p), Some(a)) => Some(p * a),
355        _ => None,
356    };
357
358    Ok(Trade {
359        id,
360        order: data["ordId"].as_str().map(ToString::to_string),
361        timestamp,
362        datetime: timestamp_to_datetime(timestamp),
363        symbol,
364        trade_type: None,
365        side,
366        taker_or_maker: None,
367        price: Price::new(price.unwrap_or(Decimal::ZERO)),
368        amount: Amount::new(amount.unwrap_or(Decimal::ZERO)),
369        cost: cost.map(Cost::new),
370        fee: None,
371        info: value_to_hashmap(data),
372    })
373}
374
375/// Parse OHLCV (candlestick) data from OKX kline response.
376///
377/// # Arguments
378///
379/// * `data` - OKX OHLCV data JSON array
380///
381/// # Returns
382///
383/// Returns a CCXT [`OHLCV`] structure.
384pub fn parse_ohlcv(data: &Value) -> Result<OHLCV> {
385    // OKX returns OHLCV as array: [ts, o, h, l, c, vol, volCcy, volCcyQuote, confirm]
386    let arr = data
387        .as_array()
388        .ok_or_else(|| Error::from(ParseError::invalid_format("data", "OHLCV array")))?;
389
390    if arr.len() < 6 {
391        return Err(Error::from(ParseError::invalid_format(
392            "data",
393            "OHLCV array with at least 6 elements",
394        )));
395    }
396
397    let timestamp = arr[0]
398        .as_str()
399        .and_then(|s| s.parse::<i64>().ok())
400        .or_else(|| arr[0].as_i64())
401        .ok_or_else(|| Error::from(ParseError::invalid_value("data", "timestamp")))?;
402
403    let open = arr[1]
404        .as_str()
405        .and_then(|s| s.parse::<f64>().ok())
406        .or_else(|| arr[1].as_f64())
407        .ok_or_else(|| Error::from(ParseError::invalid_value("data", "open")))?;
408
409    let high = arr[2]
410        .as_str()
411        .and_then(|s| s.parse::<f64>().ok())
412        .or_else(|| arr[2].as_f64())
413        .ok_or_else(|| Error::from(ParseError::invalid_value("data", "high")))?;
414
415    let low = arr[3]
416        .as_str()
417        .and_then(|s| s.parse::<f64>().ok())
418        .or_else(|| arr[3].as_f64())
419        .ok_or_else(|| Error::from(ParseError::invalid_value("data", "low")))?;
420
421    let close = arr[4]
422        .as_str()
423        .and_then(|s| s.parse::<f64>().ok())
424        .or_else(|| arr[4].as_f64())
425        .ok_or_else(|| Error::from(ParseError::invalid_value("data", "close")))?;
426
427    let volume = arr[5]
428        .as_str()
429        .and_then(|s| s.parse::<f64>().ok())
430        .or_else(|| arr[5].as_f64())
431        .ok_or_else(|| Error::from(ParseError::invalid_value("data", "volume")))?;
432
433    Ok(OHLCV {
434        timestamp,
435        open,
436        high,
437        low,
438        close,
439        volume,
440    })
441}
442
443// ============================================================================
444// Order and Balance Parser Functions
445// ============================================================================
446
447/// Map OKX order status to CCXT OrderStatus.
448///
449/// OKX order states:
450/// - live: Order is active
451/// - partially_filled: Order is partially filled
452/// - filled: Order is completely filled
453/// - canceled: Order is canceled
454/// - mmp_canceled: Order is canceled by MMP
455///
456/// # Arguments
457///
458/// * `status` - OKX order status string
459///
460/// # Returns
461///
462/// Returns the corresponding CCXT [`OrderStatus`].
463pub fn parse_order_status(status: &str) -> OrderStatus {
464    match status.to_lowercase().as_str() {
465        "filled" => OrderStatus::Closed,
466        "canceled" | "cancelled" | "mmp_canceled" => OrderStatus::Cancelled,
467        "expired" => OrderStatus::Expired,
468        "rejected" => OrderStatus::Rejected,
469        _ => OrderStatus::Open, // Default to Open for unknown statuses
470    }
471}
472
473/// Parse order data from OKX order response.
474///
475/// # Arguments
476///
477/// * `data` - OKX order data JSON object
478/// * `market` - Optional market information for symbol resolution
479///
480/// # Returns
481///
482/// Returns a CCXT [`Order`] structure.
483pub fn parse_order(data: &Value, market: Option<&Market>) -> Result<Order> {
484    let symbol = if let Some(m) = market {
485        m.symbol.clone()
486    } else {
487        data["instId"]
488            .as_str()
489            .map(|s| s.replace('-', "/"))
490            .unwrap_or_default()
491    };
492
493    let id = data["ordId"]
494        .as_str()
495        .ok_or_else(|| Error::from(ParseError::missing_field("ordId")))?
496        .to_string();
497
498    let timestamp = parse_timestamp(data, "cTime").or_else(|| parse_timestamp(data, "ts"));
499
500    let status_str = data["state"].as_str().unwrap_or("live");
501    let status = parse_order_status(status_str);
502
503    // Parse order side
504    let side = match data["side"].as_str() {
505        Some("buy" | "Buy" | "BUY") => OrderSide::Buy,
506        Some("sell" | "Sell" | "SELL") => OrderSide::Sell,
507        _ => return Err(Error::from(ParseError::invalid_format("data", "side"))),
508    };
509
510    // Parse order type
511    let order_type = match data["ordType"].as_str() {
512        Some("market" | "Market" | "MARKET") => OrderType::Market,
513        Some("post_only") => OrderType::LimitMaker,
514        _ => OrderType::Limit, // Default to limit (covers limit, fok, ioc)
515    };
516
517    let price = parse_decimal(data, "px");
518    let amount =
519        parse_decimal(data, "sz").ok_or_else(|| Error::from(ParseError::missing_field("sz")))?;
520    let filled = parse_decimal(data, "accFillSz").or_else(|| parse_decimal(data, "fillSz"));
521    let remaining = match filled {
522        Some(f) => Some(amount - f),
523        None => Some(amount),
524    };
525
526    let average = parse_decimal(data, "avgPx").or_else(|| parse_decimal(data, "fillPx"));
527
528    // Calculate cost from filled amount and average price
529    let cost = match (filled, average) {
530        (Some(f), Some(avg)) => Some(f * avg),
531        _ => None,
532    };
533
534    Ok(Order {
535        id,
536        client_order_id: data["clOrdId"].as_str().map(ToString::to_string),
537        timestamp,
538        datetime: timestamp.and_then(timestamp_to_datetime),
539        last_trade_timestamp: parse_timestamp(data, "uTime"),
540        status,
541        symbol,
542        order_type,
543        time_in_force: data["ordType"].as_str().map(|s| match s {
544            "fok" => "FOK".to_string(),
545            "ioc" => "IOC".to_string(),
546            "post_only" => "PO".to_string(),
547            _ => "GTC".to_string(),
548        }),
549        side,
550        price,
551        average,
552        amount,
553        filled,
554        remaining,
555        cost,
556        trades: None,
557        fee: None,
558        post_only: Some(data["ordType"].as_str() == Some("post_only")),
559        reduce_only: data["reduceOnly"].as_bool(),
560        trigger_price: parse_decimal(data, "triggerPx"),
561        stop_price: parse_decimal(data, "slTriggerPx"),
562        take_profit_price: parse_decimal(data, "tpTriggerPx"),
563        stop_loss_price: parse_decimal(data, "slTriggerPx"),
564        trailing_delta: None,
565        trailing_percent: None,
566        activation_price: None,
567        callback_rate: None,
568        working_type: None,
569        fees: Some(Vec::new()),
570        info: value_to_hashmap(data),
571    })
572}
573
574/// Parse balance data from OKX account info.
575///
576/// # Arguments
577///
578/// * `data` - OKX account data JSON object
579///
580/// # Returns
581///
582/// Returns a CCXT [`Balance`] structure with all non-zero balances.
583pub fn parse_balance(data: &Value) -> Result<Balance> {
584    let mut balances = HashMap::new();
585
586    // OKX returns balance in details array
587    if let Some(details) = data["details"].as_array() {
588        for detail in details {
589            parse_balance_entry(detail, &mut balances);
590        }
591    } else if let Some(balances_array) = data.as_array() {
592        // Handle array of balance objects
593        for balance in balances_array {
594            if let Some(details) = balance["details"].as_array() {
595                for detail in details {
596                    parse_balance_entry(detail, &mut balances);
597                }
598            } else {
599                parse_balance_entry(balance, &mut balances);
600            }
601        }
602    } else {
603        // Handle single balance object
604        parse_balance_entry(data, &mut balances);
605    }
606
607    Ok(Balance {
608        balances,
609        info: value_to_hashmap(data),
610    })
611}
612
613/// Parse a single balance entry from OKX response.
614fn parse_balance_entry(data: &Value, balances: &mut HashMap<String, BalanceEntry>) {
615    let currency = data["ccy"]
616        .as_str()
617        .or_else(|| data["currency"].as_str())
618        .map(ToString::to_string);
619
620    if let Some(currency) = currency {
621        // OKX uses different field names depending on account type
622        let available = parse_decimal(data, "availBal")
623            .or_else(|| parse_decimal(data, "availEq"))
624            .or_else(|| parse_decimal(data, "cashBal"))
625            .unwrap_or(Decimal::ZERO);
626
627        let frozen = parse_decimal(data, "frozenBal")
628            .or_else(|| parse_decimal(data, "ordFrozen"))
629            .unwrap_or(Decimal::ZERO);
630
631        let total = parse_decimal(data, "eq")
632            .or_else(|| parse_decimal(data, "bal"))
633            .unwrap_or(available + frozen);
634
635        // Only include non-zero balances
636        if total > Decimal::ZERO {
637            balances.insert(
638                currency,
639                BalanceEntry {
640                    free: available,
641                    used: frozen,
642                    total,
643                },
644            );
645        }
646    }
647}
648
649// ============================================================================
650// Unit Tests
651// ============================================================================
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use rust_decimal_macros::dec;
657    use serde_json::json;
658
659    #[test]
660    fn test_parse_market() {
661        let data = json!({
662            "instId": "BTC-USDT",
663            "instType": "SPOT",
664            "baseCcy": "BTC",
665            "quoteCcy": "USDT",
666            "state": "live",
667            "tickSz": "0.01",
668            "lotSz": "0.0001",
669            "minSz": "0.0001"
670        });
671
672        let market = parse_market(&data).unwrap();
673        assert_eq!(market.id, "BTC-USDT");
674        assert_eq!(market.symbol, "BTC/USDT");
675        assert_eq!(market.base, "BTC");
676        assert_eq!(market.quote, "USDT");
677        assert!(market.active);
678        assert_eq!(market.market_type, MarketType::Spot);
679    }
680
681    #[test]
682    fn test_parse_ticker() {
683        let data = json!({
684            "instId": "BTC-USDT",
685            "last": "50000.00",
686            "high24h": "51000.00",
687            "low24h": "49000.00",
688            "bidPx": "49999.00",
689            "askPx": "50001.00",
690            "vol24h": "1000.5",
691            "ts": "1700000000000"
692        });
693
694        let ticker = parse_ticker(&data, None).unwrap();
695        assert_eq!(ticker.symbol, "BTC/USDT");
696        assert_eq!(ticker.last, Some(Price::new(dec!(50000.00))));
697        assert_eq!(ticker.high, Some(Price::new(dec!(51000.00))));
698        assert_eq!(ticker.low, Some(Price::new(dec!(49000.00))));
699        assert_eq!(ticker.timestamp, 1700000000000);
700    }
701
702    #[test]
703    fn test_parse_orderbook() {
704        let data = json!({
705            "bids": [
706                ["50000.00", "1.5", "0", "1"],
707                ["49999.00", "2.0", "0", "2"]
708            ],
709            "asks": [
710                ["50001.00", "1.0", "0", "1"],
711                ["50002.00", "3.0", "0", "2"]
712            ],
713            "ts": "1700000000000"
714        });
715
716        let orderbook = parse_orderbook(&data, "BTC/USDT".to_string()).unwrap();
717        assert_eq!(orderbook.symbol, "BTC/USDT");
718        assert_eq!(orderbook.bids.len(), 2);
719        assert_eq!(orderbook.asks.len(), 2);
720        assert_eq!(orderbook.bids[0].price, Price::new(dec!(50000.00)));
721        assert_eq!(orderbook.asks[0].price, Price::new(dec!(50001.00)));
722    }
723
724    #[test]
725    fn test_parse_trade() {
726        let data = json!({
727            "tradeId": "123456",
728            "instId": "BTC-USDT",
729            "side": "buy",
730            "px": "50000.00",
731            "sz": "0.5",
732            "ts": "1700000000000"
733        });
734
735        let trade = parse_trade(&data, None).unwrap();
736        assert_eq!(trade.id, Some("123456".to_string()));
737        assert_eq!(trade.side, OrderSide::Buy);
738        assert_eq!(trade.price, Price::new(dec!(50000.00)));
739        assert_eq!(trade.amount, Amount::new(dec!(0.5)));
740    }
741
742    #[test]
743    fn test_parse_ohlcv() {
744        let data = json!([
745            "1700000000000",
746            "50000.00",
747            "51000.00",
748            "49000.00",
749            "50500.00",
750            "1000.5"
751        ]);
752
753        let ohlcv = parse_ohlcv(&data).unwrap();
754        assert_eq!(ohlcv.timestamp, 1700000000000);
755        assert_eq!(ohlcv.open, 50000.00);
756        assert_eq!(ohlcv.high, 51000.00);
757        assert_eq!(ohlcv.low, 49000.00);
758        assert_eq!(ohlcv.close, 50500.00);
759        assert_eq!(ohlcv.volume, 1000.5);
760    }
761
762    #[test]
763    fn test_parse_order_status() {
764        assert_eq!(parse_order_status("live"), OrderStatus::Open);
765        assert_eq!(parse_order_status("partially_filled"), OrderStatus::Open);
766        assert_eq!(parse_order_status("filled"), OrderStatus::Closed);
767        assert_eq!(parse_order_status("canceled"), OrderStatus::Cancelled);
768        assert_eq!(parse_order_status("mmp_canceled"), OrderStatus::Cancelled);
769        assert_eq!(parse_order_status("expired"), OrderStatus::Expired);
770        assert_eq!(parse_order_status("rejected"), OrderStatus::Rejected);
771    }
772
773    #[test]
774    fn test_parse_order() {
775        let data = json!({
776            "ordId": "123456789",
777            "instId": "BTC-USDT",
778            "side": "buy",
779            "ordType": "limit",
780            "px": "50000.00",
781            "sz": "0.5",
782            "state": "live",
783            "cTime": "1700000000000"
784        });
785
786        let order = parse_order(&data, None).unwrap();
787        assert_eq!(order.id, "123456789");
788        assert_eq!(order.side, OrderSide::Buy);
789        assert_eq!(order.order_type, OrderType::Limit);
790        assert_eq!(order.price, Some(dec!(50000.00)));
791        assert_eq!(order.amount, dec!(0.5));
792        assert_eq!(order.status, OrderStatus::Open);
793    }
794
795    #[test]
796    fn test_parse_balance() {
797        let data = json!({
798            "details": [
799                {
800                    "ccy": "BTC",
801                    "availBal": "1.5",
802                    "frozenBal": "0.5",
803                    "eq": "2.0"
804                },
805                {
806                    "ccy": "USDT",
807                    "availBal": "10000.00",
808                    "frozenBal": "0",
809                    "eq": "10000.00"
810                }
811            ]
812        });
813
814        let balance = parse_balance(&data).unwrap();
815        let btc = balance.get("BTC").unwrap();
816        assert_eq!(btc.free, dec!(1.5));
817        assert_eq!(btc.used, dec!(0.5));
818        assert_eq!(btc.total, dec!(2.0));
819
820        let usdt = balance.get("USDT").unwrap();
821        assert_eq!(usdt.free, dec!(10000.00));
822        assert_eq!(usdt.total, dec!(10000.00));
823    }
824
825    #[test]
826    fn test_timestamp_to_datetime() {
827        let ts = 1700000000000i64;
828        let dt = timestamp_to_datetime(ts).unwrap();
829        assert!(dt.contains("2023-11-14"));
830    }
831}