ccxt_exchanges/bitget/
parser.rs

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