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(ToString::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(ToString::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(ToString::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("sell" | "Sell" | "SELL") => OrderSide::Sell,
377        _ => OrderSide::Buy, // Default to buy if not specified
378    };
379
380    let price = parse_decimal(data, "price").or_else(|| parse_decimal(data, "fillPrice"));
381    let amount = parse_decimal(data, "size")
382        .or_else(|| parse_decimal(data, "amount"))
383        .or_else(|| parse_decimal(data, "fillSize"));
384
385    let cost = match (price, amount) {
386        (Some(p), Some(a)) => Some(p * a),
387        _ => None,
388    };
389
390    Ok(Trade {
391        id,
392        order: data["orderId"].as_str().map(ToString::to_string),
393        timestamp,
394        datetime: timestamp_to_datetime(timestamp),
395        symbol,
396        trade_type: None,
397        side,
398        taker_or_maker: None,
399        price: Price::new(price.unwrap_or(Decimal::ZERO)),
400        amount: Amount::new(amount.unwrap_or(Decimal::ZERO)),
401        cost: cost.map(Cost::new),
402        fee: None,
403        info: value_to_hashmap(data),
404    })
405}
406
407/// Parse OHLCV (candlestick) data from Bitget kline response.
408///
409/// # Arguments
410///
411/// * `data` - Bitget OHLCV data JSON array
412///
413/// # Returns
414///
415/// Returns a CCXT [`OHLCV`] structure.
416pub fn parse_ohlcv(data: &Value) -> Result<OHLCV> {
417    // Bitget returns OHLCV as array: [timestamp, open, high, low, close, volume, quoteVolume]
418    let arr = data
419        .as_array()
420        .ok_or_else(|| Error::from(ParseError::invalid_format("data", "OHLCV array")))?;
421
422    if arr.len() < 6 {
423        return Err(Error::from(ParseError::invalid_format(
424            "data",
425            "OHLCV array with at least 6 elements",
426        )));
427    }
428
429    let timestamp = arr[0]
430        .as_str()
431        .and_then(|s| s.parse::<i64>().ok())
432        .or_else(|| arr[0].as_i64())
433        .ok_or_else(|| Error::from(ParseError::invalid_value("data", "timestamp")))?;
434
435    let open = arr[1]
436        .as_str()
437        .and_then(|s| s.parse::<f64>().ok())
438        .or_else(|| arr[1].as_f64())
439        .ok_or_else(|| Error::from(ParseError::invalid_value("data", "open")))?;
440
441    let high = arr[2]
442        .as_str()
443        .and_then(|s| s.parse::<f64>().ok())
444        .or_else(|| arr[2].as_f64())
445        .ok_or_else(|| Error::from(ParseError::invalid_value("data", "high")))?;
446
447    let low = arr[3]
448        .as_str()
449        .and_then(|s| s.parse::<f64>().ok())
450        .or_else(|| arr[3].as_f64())
451        .ok_or_else(|| Error::from(ParseError::invalid_value("data", "low")))?;
452
453    let close = arr[4]
454        .as_str()
455        .and_then(|s| s.parse::<f64>().ok())
456        .or_else(|| arr[4].as_f64())
457        .ok_or_else(|| Error::from(ParseError::invalid_value("data", "close")))?;
458
459    let volume = arr[5]
460        .as_str()
461        .and_then(|s| s.parse::<f64>().ok())
462        .or_else(|| arr[5].as_f64())
463        .ok_or_else(|| Error::from(ParseError::invalid_value("data", "volume")))?;
464
465    Ok(OHLCV {
466        timestamp,
467        open,
468        high,
469        low,
470        close,
471        volume,
472    })
473}
474
475// ============================================================================
476// Order and Balance Parser Functions
477// ============================================================================
478
479/// Map Bitget order status to CCXT OrderStatus.
480///
481/// # Arguments
482///
483/// * `status` - Bitget order status string
484///
485/// # Returns
486///
487/// Returns the corresponding CCXT [`OrderStatus`].
488pub fn parse_order_status(status: &str) -> OrderStatus {
489    match status.to_lowercase().as_str() {
490        "filled" | "full_fill" | "full-fill" => OrderStatus::Closed,
491        "cancelled" | "canceled" | "cancel" => OrderStatus::Cancelled,
492        "expired" | "expire" => OrderStatus::Expired,
493        "rejected" | "reject" => OrderStatus::Rejected,
494        _ => OrderStatus::Open, // Default to Open for unknown statuses
495    }
496}
497
498/// Parse order data from Bitget order response.
499///
500/// # Arguments
501///
502/// * `data` - Bitget order data JSON object
503/// * `market` - Optional market information for symbol resolution
504///
505/// # Returns
506///
507/// Returns a CCXT [`Order`] structure.
508pub fn parse_order(data: &Value, market: Option<&Market>) -> Result<Order> {
509    let symbol = if let Some(m) = market {
510        m.symbol.clone()
511    } else {
512        data["symbol"]
513            .as_str()
514            .or_else(|| data["instId"].as_str())
515            .map(ToString::to_string)
516            .unwrap_or_default()
517    };
518
519    let id = data["orderId"]
520        .as_str()
521        .ok_or_else(|| Error::from(ParseError::missing_field("orderId")))?
522        .to_string();
523
524    let timestamp = parse_timestamp(data, "cTime")
525        .or_else(|| parse_timestamp(data, "createTime"))
526        .or_else(|| parse_timestamp(data, "ts"));
527
528    let status_str = data["status"]
529        .as_str()
530        .or_else(|| data["state"].as_str())
531        .unwrap_or("live");
532    let status = parse_order_status(status_str);
533
534    // Parse order side
535    let side = match data["side"].as_str() {
536        Some("buy" | "Buy" | "BUY") => OrderSide::Buy,
537        Some("sell" | "Sell" | "SELL") => OrderSide::Sell,
538        _ => return Err(Error::from(ParseError::invalid_format("data", "side"))),
539    };
540
541    // Parse order type
542    let order_type = match data["orderType"].as_str().or_else(|| data["type"].as_str()) {
543        Some("market" | "Market" | "MARKET") => OrderType::Market,
544        Some("limit_maker" | "post_only") => OrderType::LimitMaker,
545        _ => OrderType::Limit, // Default to limit
546    };
547
548    let price = parse_decimal(data, "price").or_else(|| parse_decimal(data, "priceAvg"));
549    let amount = parse_decimal(data, "size")
550        .or_else(|| parse_decimal(data, "baseVolume"))
551        .ok_or_else(|| Error::from(ParseError::missing_field("size")))?;
552    let filled = parse_decimal(data, "fillSize").or_else(|| parse_decimal(data, "baseVolume"));
553    let remaining = match filled {
554        Some(f) => Some(amount - f),
555        None => Some(amount),
556    };
557
558    let cost =
559        parse_decimal(data, "fillNotionalUsd").or_else(|| parse_decimal(data, "quoteVolume"));
560
561    let average = parse_decimal(data, "priceAvg").or_else(|| parse_decimal(data, "fillPrice"));
562
563    Ok(Order {
564        id,
565        client_order_id: data["clientOid"]
566            .as_str()
567            .or_else(|| data["clientOrderId"].as_str())
568            .map(ToString::to_string),
569        timestamp,
570        datetime: timestamp.and_then(timestamp_to_datetime),
571        last_trade_timestamp: parse_timestamp(data, "uTime")
572            .or_else(|| parse_timestamp(data, "updateTime")),
573        status,
574        symbol,
575        order_type,
576        time_in_force: data["timeInForce"]
577            .as_str()
578            .or_else(|| data["force"].as_str())
579            .map(str::to_uppercase),
580        side,
581        price,
582        average,
583        amount,
584        filled,
585        remaining,
586        cost,
587        trades: None,
588        fee: None,
589        post_only: None,
590        reduce_only: data["reduceOnly"].as_bool(),
591        trigger_price: parse_decimal(data, "triggerPrice"),
592        stop_price: parse_decimal(data, "stopPrice")
593            .or_else(|| parse_decimal(data, "presetStopLossPrice")),
594        take_profit_price: parse_decimal(data, "presetTakeProfitPrice"),
595        stop_loss_price: parse_decimal(data, "presetStopLossPrice"),
596        trailing_delta: None,
597        trailing_percent: None,
598        activation_price: None,
599        callback_rate: None,
600        working_type: None,
601        fees: Some(Vec::new()),
602        info: value_to_hashmap(data),
603    })
604}
605
606/// Parse balance data from Bitget account info.
607///
608/// # Arguments
609///
610/// * `data` - Bitget account data JSON object
611///
612/// # Returns
613///
614/// Returns a CCXT [`Balance`] structure with all non-zero balances.
615pub fn parse_balance(data: &Value) -> Result<Balance> {
616    let mut balances = HashMap::new();
617
618    // Handle array of balances (common Bitget response format)
619    if let Some(balances_array) = data.as_array() {
620        for balance in balances_array {
621            parse_balance_entry(balance, &mut balances);
622        }
623    } else if let Some(balances_array) = data["data"].as_array() {
624        // Handle wrapped response
625        for balance in balances_array {
626            parse_balance_entry(balance, &mut balances);
627        }
628    } else {
629        // Handle single balance object
630        parse_balance_entry(data, &mut balances);
631    }
632
633    Ok(Balance {
634        balances,
635        info: value_to_hashmap(data),
636    })
637}
638
639/// Parse a single balance entry from Bitget response.
640fn parse_balance_entry(data: &Value, balances: &mut HashMap<String, BalanceEntry>) {
641    let currency = data["coin"]
642        .as_str()
643        .or_else(|| data["coinName"].as_str())
644        .or_else(|| data["asset"].as_str())
645        .map(ToString::to_string);
646
647    if let Some(currency) = currency {
648        let available = parse_decimal(data, "available")
649            .or_else(|| parse_decimal(data, "free"))
650            .unwrap_or(Decimal::ZERO);
651
652        let frozen = parse_decimal(data, "frozen")
653            .or_else(|| parse_decimal(data, "locked"))
654            .or_else(|| parse_decimal(data, "lock"))
655            .unwrap_or(Decimal::ZERO);
656
657        let total = available + frozen;
658
659        // Only include non-zero balances
660        if total > Decimal::ZERO {
661            balances.insert(
662                currency,
663                BalanceEntry {
664                    free: available,
665                    used: frozen,
666                    total,
667                },
668            );
669        }
670    }
671}
672
673// ============================================================================
674// Unit Tests
675// ============================================================================
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use rust_decimal_macros::dec;
681    use serde_json::json;
682
683    #[test]
684    fn test_parse_market() {
685        let data = json!({
686            "symbol": "BTCUSDT",
687            "baseCoin": "BTC",
688            "quoteCoin": "USDT",
689            "status": "online",
690            "pricePrecision": "2",
691            "quantityPrecision": "4",
692            "minTradeNum": "0.0001",
693            "makerFeeRate": "0.001",
694            "takerFeeRate": "0.001"
695        });
696
697        let market = parse_market(&data).unwrap();
698        assert_eq!(market.id, "BTCUSDT");
699        assert_eq!(market.symbol, "BTC/USDT");
700        assert_eq!(market.base, "BTC");
701        assert_eq!(market.quote, "USDT");
702        assert!(market.active);
703    }
704
705    #[test]
706    fn test_parse_ticker() {
707        let data = json!({
708            "symbol": "BTCUSDT",
709            "lastPr": "50000.00",
710            "high24h": "51000.00",
711            "low24h": "49000.00",
712            "bidPr": "49999.00",
713            "askPr": "50001.00",
714            "baseVolume": "1000.5",
715            "ts": "1700000000000"
716        });
717
718        let ticker = parse_ticker(&data, None).unwrap();
719        assert_eq!(ticker.symbol, "BTCUSDT");
720        assert_eq!(ticker.last, Some(Price::new(dec!(50000.00))));
721        assert_eq!(ticker.high, Some(Price::new(dec!(51000.00))));
722        assert_eq!(ticker.low, Some(Price::new(dec!(49000.00))));
723        assert_eq!(ticker.timestamp, 1700000000000);
724    }
725
726    #[test]
727    fn test_parse_orderbook() {
728        let data = json!({
729            "bids": [
730                ["50000.00", "1.5"],
731                ["49999.00", "2.0"]
732            ],
733            "asks": [
734                ["50001.00", "1.0"],
735                ["50002.00", "3.0"]
736            ],
737            "ts": "1700000000000"
738        });
739
740        let orderbook = parse_orderbook(&data, "BTC/USDT".to_string()).unwrap();
741        assert_eq!(orderbook.symbol, "BTC/USDT");
742        assert_eq!(orderbook.bids.len(), 2);
743        assert_eq!(orderbook.asks.len(), 2);
744        assert_eq!(orderbook.bids[0].price, Price::new(dec!(50000.00)));
745        assert_eq!(orderbook.asks[0].price, Price::new(dec!(50001.00)));
746    }
747
748    #[test]
749    fn test_parse_trade() {
750        let data = json!({
751            "tradeId": "123456",
752            "symbol": "BTCUSDT",
753            "side": "buy",
754            "price": "50000.00",
755            "size": "0.5",
756            "ts": "1700000000000"
757        });
758
759        let trade = parse_trade(&data, None).unwrap();
760        assert_eq!(trade.id, Some("123456".to_string()));
761        assert_eq!(trade.side, OrderSide::Buy);
762        assert_eq!(trade.price, Price::new(dec!(50000.00)));
763        assert_eq!(trade.amount, Amount::new(dec!(0.5)));
764    }
765
766    #[test]
767    fn test_parse_ohlcv() {
768        let data = json!([
769            "1700000000000",
770            "50000.00",
771            "51000.00",
772            "49000.00",
773            "50500.00",
774            "1000.5"
775        ]);
776
777        let ohlcv = parse_ohlcv(&data).unwrap();
778        assert_eq!(ohlcv.timestamp, 1700000000000);
779        assert_eq!(ohlcv.open, 50000.00);
780        assert_eq!(ohlcv.high, 51000.00);
781        assert_eq!(ohlcv.low, 49000.00);
782        assert_eq!(ohlcv.close, 50500.00);
783        assert_eq!(ohlcv.volume, 1000.5);
784    }
785
786    #[test]
787    fn test_parse_order_status() {
788        assert_eq!(parse_order_status("live"), OrderStatus::Open);
789        assert_eq!(parse_order_status("partially_filled"), OrderStatus::Open);
790        assert_eq!(parse_order_status("filled"), OrderStatus::Closed);
791        assert_eq!(parse_order_status("cancelled"), OrderStatus::Cancelled);
792        assert_eq!(parse_order_status("expired"), OrderStatus::Expired);
793        assert_eq!(parse_order_status("rejected"), OrderStatus::Rejected);
794    }
795
796    #[test]
797    fn test_parse_order() {
798        let data = json!({
799            "orderId": "123456789",
800            "symbol": "BTCUSDT",
801            "side": "buy",
802            "orderType": "limit",
803            "price": "50000.00",
804            "size": "0.5",
805            "status": "live",
806            "cTime": "1700000000000"
807        });
808
809        let order = parse_order(&data, None).unwrap();
810        assert_eq!(order.id, "123456789");
811        assert_eq!(order.side, OrderSide::Buy);
812        assert_eq!(order.order_type, OrderType::Limit);
813        assert_eq!(order.price, Some(dec!(50000.00)));
814        assert_eq!(order.amount, dec!(0.5));
815        assert_eq!(order.status, OrderStatus::Open);
816    }
817
818    #[test]
819    fn test_parse_balance() {
820        let data = json!([
821            {
822                "coin": "BTC",
823                "available": "1.5",
824                "frozen": "0.5"
825            },
826            {
827                "coin": "USDT",
828                "available": "10000.00",
829                "frozen": "0"
830            }
831        ]);
832
833        let balance = parse_balance(&data).unwrap();
834        let btc = balance.get("BTC").unwrap();
835        assert_eq!(btc.free, dec!(1.5));
836        assert_eq!(btc.used, dec!(0.5));
837        assert_eq!(btc.total, dec!(2.0));
838
839        let usdt = balance.get("USDT").unwrap();
840        assert_eq!(usdt.free, dec!(10000.00));
841        assert_eq!(usdt.total, dec!(10000.00));
842    }
843
844    #[test]
845    fn test_timestamp_to_datetime() {
846        let ts = 1700000000000i64;
847        let dt = timestamp_to_datetime(ts).unwrap();
848        assert!(dt.contains("2023-11-14"));
849    }
850}