1use 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
19fn 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
36fn 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
44fn 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
51pub 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
57pub 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
64pub fn parse_market(data: &Value) -> Result<Market> {
82 let id = data["symbol"]
84 .as_str()
85 .ok_or_else(|| Error::from(ParseError::missing_field("symbol")))?
86 .to_string();
87
88 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 let symbol = format!("{}/{}", base, quote);
101
102 let status = data["status"].as_str().unwrap_or("online");
104 let active = status == "online";
105
106 let price_precision = parse_decimal(data, "pricePrecision")
108 .or_else(|| parse_decimal(data, "pricePlace"))
109 .map(|p| {
110 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 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 let maker_fee = parse_decimal(data, "makerFeeRate");
139 let taker_fee = parse_decimal(data, "takerFeeRate");
140
141 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
192pub 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 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 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
269pub 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 bids.sort_by(|a, b| b.price.cmp(&a.price));
290
291 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
311fn 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
345pub 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 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, };
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
408pub fn parse_ohlcv(data: &Value) -> Result<OHLCV> {
418 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
476pub 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, }
499}
500
501pub 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 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 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, };
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
610pub fn parse_balance(data: &Value) -> Result<Balance> {
620 let mut balances = HashMap::new();
621
622 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 for balance in balances_array {
630 parse_balance_entry(balance, &mut balances)?;
631 }
632 } else {
633 parse_balance_entry(data, &mut balances)?;
635 }
636
637 Ok(Balance {
638 balances,
639 info: value_to_hashmap(data),
640 })
641}
642
643fn 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 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#[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}