Skip to main content

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