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