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