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 if s.is_empty() {
30 None
31 } else {
32 Decimal::from_str(s).ok()
33 }
34 } else {
35 None
36 }
37 })
38}
39
40fn parse_timestamp(data: &Value, key: &str) -> Option<i64> {
42 data.get(key).and_then(|v| {
43 v.as_i64()
44 .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
45 })
46}
47
48fn value_to_hashmap(data: &Value) -> HashMap<String, Value> {
50 data.as_object()
51 .map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
52 .unwrap_or_default()
53}
54
55pub fn timestamp_to_datetime(timestamp: i64) -> Option<String> {
57 chrono::DateTime::from_timestamp_millis(timestamp)
58 .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string())
59}
60
61pub fn datetime_to_timestamp(datetime: &str) -> Option<i64> {
63 chrono::DateTime::parse_from_rfc3339(datetime)
64 .ok()
65 .map(|dt| dt.timestamp_millis())
66}
67
68pub fn parse_market(data: &Value) -> Result<Market> {
84 let id = data["instId"]
86 .as_str()
87 .ok_or_else(|| Error::from(ParseError::missing_field("instId")))?
88 .to_string();
89
90 let base = data["baseCcy"]
92 .as_str()
93 .ok_or_else(|| Error::from(ParseError::missing_field("baseCcy")))?
94 .to_string();
95
96 let quote = data["quoteCcy"]
97 .as_str()
98 .ok_or_else(|| Error::from(ParseError::missing_field("quoteCcy")))?
99 .to_string();
100
101 let inst_type = data["instType"].as_str().unwrap_or("SPOT");
103 let market_type = match inst_type {
104 "SPOT" => MarketType::Spot,
105 "SWAP" => MarketType::Swap,
106 "FUTURES" => MarketType::Futures,
107 "OPTION" => MarketType::Option,
108 _ => MarketType::Spot,
109 };
110
111 let state = data["state"].as_str().unwrap_or("live");
113 let active = state == "live";
114
115 let price_precision = parse_decimal(data, "tickSz");
117 let amount_precision = parse_decimal(data, "lotSz");
118
119 let min_amount = parse_decimal(data, "minSz");
121 let max_amount = parse_decimal(data, "maxLmtSz");
122
123 let contract = inst_type != "SPOT";
125 let linear = if contract {
126 Some(data["ctType"].as_str() == Some("linear"))
127 } else {
128 None
129 };
130 let inverse = if contract {
131 Some(data["ctType"].as_str() == Some("inverse"))
132 } else {
133 None
134 };
135 let contract_size = parse_decimal(data, "ctVal");
136
137 let settle = data["settleCcy"].as_str().map(|s| s.to_string());
139 let settle_id = settle.clone();
140
141 let expiry = parse_timestamp(data, "expTime");
143 let expiry_datetime = expiry.and_then(timestamp_to_datetime);
144
145 let symbol = match market_type {
150 MarketType::Spot => format!("{}/{}", base, quote),
151 MarketType::Swap => {
152 if let Some(ref s) = settle {
153 format!("{}/{}:{}", base, quote, s)
154 } else {
155 format!("{}/{}:{}", base, quote, quote)
157 }
158 }
159 MarketType::Futures | MarketType::Option => {
160 if let (Some(s), Some(exp_ts)) = (&settle, expiry) {
161 if let Some(dt) = chrono::DateTime::from_timestamp_millis(exp_ts) {
163 let year = (dt.format("%y").to_string().parse::<u8>()).unwrap_or(0);
164 let month = (dt.format("%m").to_string().parse::<u8>()).unwrap_or(1);
165 let day = (dt.format("%d").to_string().parse::<u8>()).unwrap_or(1);
166 format!("{}/{}:{}-{:02}{:02}{:02}", base, quote, s, year, month, day)
167 } else {
168 format!("{}/{}:{}", base, quote, s)
169 }
170 } else if let Some(ref s) = settle {
171 format!("{}/{}:{}", base, quote, s)
172 } else {
173 format!("{}/{}", base, quote)
174 }
175 }
176 };
177
178 let parsed_symbol = ccxt_core::symbol::SymbolParser::parse(&symbol).ok();
180
181 Ok(Market {
182 id,
183 symbol,
184 parsed_symbol,
185 base: base.clone(),
186 quote: quote.clone(),
187 settle,
188 base_id: Some(base),
189 quote_id: Some(quote),
190 settle_id,
191 market_type,
192 active,
193 margin: inst_type == "MARGIN",
194 contract: Some(contract),
195 linear,
196 inverse,
197 contract_size,
198 expiry,
199 expiry_datetime,
200 strike: parse_decimal(data, "stk"),
201 option_type: data["optType"].as_str().map(|s| s.to_string()),
202 precision: MarketPrecision {
203 price: price_precision,
204 amount: amount_precision,
205 base: None,
206 quote: None,
207 },
208 limits: MarketLimits {
209 amount: Some(MinMax {
210 min: min_amount,
211 max: max_amount,
212 }),
213 price: None,
214 cost: None,
215 leverage: None,
216 },
217 maker: parse_decimal(data, "makerFee"),
218 taker: parse_decimal(data, "takerFee"),
219 percentage: Some(true),
220 tier_based: Some(false),
221 fee_side: Some("quote".to_string()),
222 info: value_to_hashmap(data),
223 })
224}
225
226pub fn parse_ticker(data: &Value, market: Option<&Market>) -> Result<Ticker> {
237 let symbol = if let Some(m) = market {
238 m.symbol.clone()
239 } else {
240 data["instId"]
242 .as_str()
243 .map(|s| s.replace('-', "/"))
244 .unwrap_or_default()
245 };
246
247 let timestamp = parse_timestamp(data, "ts").unwrap_or(0);
249
250 Ok(Ticker {
251 symbol,
252 timestamp,
253 datetime: timestamp_to_datetime(timestamp),
254 high: parse_decimal(data, "high24h").map(Price::new),
255 low: parse_decimal(data, "low24h").map(Price::new),
256 bid: parse_decimal(data, "bidPx").map(Price::new),
257 bid_volume: parse_decimal(data, "bidSz").map(Amount::new),
258 ask: parse_decimal(data, "askPx").map(Price::new),
259 ask_volume: parse_decimal(data, "askSz").map(Amount::new),
260 vwap: None,
261 open: parse_decimal(data, "open24h")
262 .or_else(|| parse_decimal(data, "sodUtc0"))
263 .map(Price::new),
264 close: parse_decimal(data, "last").map(Price::new),
265 last: parse_decimal(data, "last").map(Price::new),
266 previous_close: None,
267 change: None, percentage: parse_decimal(data, "sodUtc0").and_then(|open| {
269 parse_decimal(data, "last").map(|last| {
270 if open.is_zero() {
271 Decimal::ZERO
272 } else {
273 ((last - open) / open) * Decimal::from(100)
274 }
275 })
276 }),
277 average: None,
278 base_volume: parse_decimal(data, "vol24h")
279 .or_else(|| parse_decimal(data, "volCcy24h"))
280 .map(Amount::new),
281 quote_volume: parse_decimal(data, "volCcy24h").map(Amount::new),
282 info: value_to_hashmap(data),
283 })
284}
285
286pub fn parse_orderbook(data: &Value, symbol: String) -> Result<OrderBook> {
298 let timestamp =
299 parse_timestamp(data, "ts").unwrap_or_else(|| chrono::Utc::now().timestamp_millis());
300
301 let mut bids = parse_orderbook_side(&data["bids"])?;
302 let mut asks = parse_orderbook_side(&data["asks"])?;
303
304 bids.sort_by(|a, b| b.price.cmp(&a.price));
306
307 asks.sort_by(|a, b| a.price.cmp(&b.price));
309
310 Ok(OrderBook {
311 symbol,
312 timestamp,
313 datetime: timestamp_to_datetime(timestamp),
314 nonce: None,
315 bids,
316 asks,
317 buffered_deltas: std::collections::VecDeque::new(),
318 bids_map: std::collections::BTreeMap::new(),
319 asks_map: std::collections::BTreeMap::new(),
320 is_synced: false,
321 needs_resync: false,
322 last_resync_time: 0,
323 info: value_to_hashmap(data),
324 })
325}
326
327fn parse_orderbook_side(data: &Value) -> Result<Vec<OrderBookEntry>> {
329 let Some(array) = data.as_array() else {
330 return Ok(Vec::new());
331 };
332
333 let mut result = Vec::new();
334
335 for item in array {
336 if let Some(arr) = item.as_array() {
337 if arr.len() >= 2 {
339 let price = arr[0]
340 .as_str()
341 .and_then(|s| Decimal::from_str(s).ok())
342 .or_else(|| arr[0].as_f64().and_then(Decimal::from_f64))
343 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "price")))?;
344
345 let amount = arr[1]
346 .as_str()
347 .and_then(|s| Decimal::from_str(s).ok())
348 .or_else(|| arr[1].as_f64().and_then(Decimal::from_f64))
349 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "amount")))?;
350
351 result.push(OrderBookEntry {
352 price: Price::new(price),
353 amount: Amount::new(amount),
354 });
355 }
356 }
357 }
358
359 Ok(result)
360}
361
362pub fn parse_trade(data: &Value, market: Option<&Market>) -> Result<Trade> {
373 let symbol = if let Some(m) = market {
374 m.symbol.clone()
375 } else {
376 data["instId"]
377 .as_str()
378 .map(|s| s.replace('-', "/"))
379 .unwrap_or_default()
380 };
381
382 let id = data["tradeId"].as_str().map(|s| s.to_string());
383
384 let timestamp = parse_timestamp(data, "ts").unwrap_or(0);
385
386 let side = match data["side"].as_str() {
388 Some("buy") | Some("Buy") | Some("BUY") => OrderSide::Buy,
389 Some("sell") | Some("Sell") | Some("SELL") => OrderSide::Sell,
390 _ => OrderSide::Buy, };
392
393 let price = parse_decimal(data, "px").or_else(|| parse_decimal(data, "fillPx"));
394 let amount = parse_decimal(data, "sz").or_else(|| parse_decimal(data, "fillSz"));
395
396 let cost = match (price, amount) {
397 (Some(p), Some(a)) => Some(p * a),
398 _ => None,
399 };
400
401 Ok(Trade {
402 id,
403 order: data["ordId"].as_str().map(|s| s.to_string()),
404 timestamp,
405 datetime: timestamp_to_datetime(timestamp),
406 symbol,
407 trade_type: None,
408 side,
409 taker_or_maker: None,
410 price: Price::new(price.unwrap_or(Decimal::ZERO)),
411 amount: Amount::new(amount.unwrap_or(Decimal::ZERO)),
412 cost: cost.map(Cost::new),
413 fee: None,
414 info: value_to_hashmap(data),
415 })
416}
417
418pub fn parse_ohlcv(data: &Value) -> Result<OHLCV> {
428 let arr = data
430 .as_array()
431 .ok_or_else(|| Error::from(ParseError::invalid_format("data", "OHLCV array")))?;
432
433 if arr.len() < 6 {
434 return Err(Error::from(ParseError::invalid_format(
435 "data",
436 "OHLCV array with at least 6 elements",
437 )));
438 }
439
440 let timestamp = arr[0]
441 .as_str()
442 .and_then(|s| s.parse::<i64>().ok())
443 .or_else(|| arr[0].as_i64())
444 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "timestamp")))?;
445
446 let open = arr[1]
447 .as_str()
448 .and_then(|s| s.parse::<f64>().ok())
449 .or_else(|| arr[1].as_f64())
450 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "open")))?;
451
452 let high = arr[2]
453 .as_str()
454 .and_then(|s| s.parse::<f64>().ok())
455 .or_else(|| arr[2].as_f64())
456 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "high")))?;
457
458 let low = arr[3]
459 .as_str()
460 .and_then(|s| s.parse::<f64>().ok())
461 .or_else(|| arr[3].as_f64())
462 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "low")))?;
463
464 let close = arr[4]
465 .as_str()
466 .and_then(|s| s.parse::<f64>().ok())
467 .or_else(|| arr[4].as_f64())
468 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "close")))?;
469
470 let volume = arr[5]
471 .as_str()
472 .and_then(|s| s.parse::<f64>().ok())
473 .or_else(|| arr[5].as_f64())
474 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "volume")))?;
475
476 Ok(OHLCV {
477 timestamp,
478 open,
479 high,
480 low,
481 close,
482 volume,
483 })
484}
485
486pub fn parse_order_status(status: &str) -> OrderStatus {
507 match status.to_lowercase().as_str() {
508 "live" => OrderStatus::Open,
509 "partially_filled" => OrderStatus::Open,
510 "filled" => OrderStatus::Closed,
511 "canceled" | "cancelled" => OrderStatus::Canceled,
512 "mmp_canceled" => OrderStatus::Canceled,
513 "expired" => OrderStatus::Expired,
514 "rejected" => OrderStatus::Rejected,
515 _ => OrderStatus::Open, }
517}
518
519pub fn parse_order(data: &Value, market: Option<&Market>) -> Result<Order> {
530 let symbol = if let Some(m) = market {
531 m.symbol.clone()
532 } else {
533 data["instId"]
534 .as_str()
535 .map(|s| s.replace('-', "/"))
536 .unwrap_or_default()
537 };
538
539 let id = data["ordId"]
540 .as_str()
541 .ok_or_else(|| Error::from(ParseError::missing_field("ordId")))?
542 .to_string();
543
544 let timestamp = parse_timestamp(data, "cTime").or_else(|| parse_timestamp(data, "ts"));
545
546 let status_str = data["state"].as_str().unwrap_or("live");
547 let status = parse_order_status(status_str);
548
549 let side = match data["side"].as_str() {
551 Some("buy") | Some("Buy") | Some("BUY") => OrderSide::Buy,
552 Some("sell") | Some("Sell") | Some("SELL") => OrderSide::Sell,
553 _ => return Err(Error::from(ParseError::invalid_format("data", "side"))),
554 };
555
556 let order_type = match data["ordType"].as_str() {
558 Some("market") | Some("Market") | Some("MARKET") => OrderType::Market,
559 Some("limit") | Some("Limit") | Some("LIMIT") => OrderType::Limit,
560 Some("post_only") => OrderType::LimitMaker,
561 Some("fok") => OrderType::Limit, Some("ioc") => OrderType::Limit, _ => OrderType::Limit, };
565
566 let price = parse_decimal(data, "px");
567 let amount =
568 parse_decimal(data, "sz").ok_or_else(|| Error::from(ParseError::missing_field("sz")))?;
569 let filled = parse_decimal(data, "accFillSz").or_else(|| parse_decimal(data, "fillSz"));
570 let remaining = match filled {
571 Some(f) => Some(amount - f),
572 None => Some(amount),
573 };
574
575 let average = parse_decimal(data, "avgPx").or_else(|| parse_decimal(data, "fillPx"));
576
577 let cost = match (filled, average) {
579 (Some(f), Some(avg)) => Some(f * avg),
580 _ => None,
581 };
582
583 Ok(Order {
584 id,
585 client_order_id: data["clOrdId"].as_str().map(|s| s.to_string()),
586 timestamp,
587 datetime: timestamp.and_then(timestamp_to_datetime),
588 last_trade_timestamp: parse_timestamp(data, "uTime"),
589 status,
590 symbol,
591 order_type,
592 time_in_force: data["ordType"].as_str().map(|s| match s {
593 "fok" => "FOK".to_string(),
594 "ioc" => "IOC".to_string(),
595 "post_only" => "PO".to_string(),
596 _ => "GTC".to_string(),
597 }),
598 side,
599 price,
600 average,
601 amount,
602 filled,
603 remaining,
604 cost,
605 trades: None,
606 fee: None,
607 post_only: Some(data["ordType"].as_str() == Some("post_only")),
608 reduce_only: data["reduceOnly"].as_bool(),
609 trigger_price: parse_decimal(data, "triggerPx"),
610 stop_price: parse_decimal(data, "slTriggerPx"),
611 take_profit_price: parse_decimal(data, "tpTriggerPx"),
612 stop_loss_price: parse_decimal(data, "slTriggerPx"),
613 trailing_delta: None,
614 trailing_percent: None,
615 activation_price: None,
616 callback_rate: None,
617 working_type: None,
618 fees: Some(Vec::new()),
619 info: value_to_hashmap(data),
620 })
621}
622
623pub fn parse_balance(data: &Value) -> Result<Balance> {
633 let mut balances = HashMap::new();
634
635 if let Some(details) = data["details"].as_array() {
637 for detail in details {
638 parse_balance_entry(detail, &mut balances)?;
639 }
640 } else if let Some(balances_array) = data.as_array() {
641 for balance in balances_array {
643 if let Some(details) = balance["details"].as_array() {
644 for detail in details {
645 parse_balance_entry(detail, &mut balances)?;
646 }
647 } else {
648 parse_balance_entry(balance, &mut balances)?;
649 }
650 }
651 } else {
652 parse_balance_entry(data, &mut balances)?;
654 }
655
656 Ok(Balance {
657 balances,
658 info: value_to_hashmap(data),
659 })
660}
661
662fn parse_balance_entry(data: &Value, balances: &mut HashMap<String, BalanceEntry>) -> Result<()> {
664 let currency = data["ccy"]
665 .as_str()
666 .or_else(|| data["currency"].as_str())
667 .map(|s| s.to_string());
668
669 if let Some(currency) = currency {
670 let available = parse_decimal(data, "availBal")
672 .or_else(|| parse_decimal(data, "availEq"))
673 .or_else(|| parse_decimal(data, "cashBal"))
674 .unwrap_or(Decimal::ZERO);
675
676 let frozen = parse_decimal(data, "frozenBal")
677 .or_else(|| parse_decimal(data, "ordFrozen"))
678 .unwrap_or(Decimal::ZERO);
679
680 let total = parse_decimal(data, "eq")
681 .or_else(|| parse_decimal(data, "bal"))
682 .unwrap_or(available + frozen);
683
684 if total > Decimal::ZERO {
686 balances.insert(
687 currency,
688 BalanceEntry {
689 free: available,
690 used: frozen,
691 total,
692 },
693 );
694 }
695 }
696
697 Ok(())
698}
699
700#[cfg(test)]
705mod tests {
706 use super::*;
707 use rust_decimal_macros::dec;
708 use serde_json::json;
709
710 #[test]
711 fn test_parse_market() {
712 let data = json!({
713 "instId": "BTC-USDT",
714 "instType": "SPOT",
715 "baseCcy": "BTC",
716 "quoteCcy": "USDT",
717 "state": "live",
718 "tickSz": "0.01",
719 "lotSz": "0.0001",
720 "minSz": "0.0001"
721 });
722
723 let market = parse_market(&data).unwrap();
724 assert_eq!(market.id, "BTC-USDT");
725 assert_eq!(market.symbol, "BTC/USDT");
726 assert_eq!(market.base, "BTC");
727 assert_eq!(market.quote, "USDT");
728 assert!(market.active);
729 assert_eq!(market.market_type, MarketType::Spot);
730 }
731
732 #[test]
733 fn test_parse_ticker() {
734 let data = json!({
735 "instId": "BTC-USDT",
736 "last": "50000.00",
737 "high24h": "51000.00",
738 "low24h": "49000.00",
739 "bidPx": "49999.00",
740 "askPx": "50001.00",
741 "vol24h": "1000.5",
742 "ts": "1700000000000"
743 });
744
745 let ticker = parse_ticker(&data, None).unwrap();
746 assert_eq!(ticker.symbol, "BTC/USDT");
747 assert_eq!(ticker.last, Some(Price::new(dec!(50000.00))));
748 assert_eq!(ticker.high, Some(Price::new(dec!(51000.00))));
749 assert_eq!(ticker.low, Some(Price::new(dec!(49000.00))));
750 assert_eq!(ticker.timestamp, 1700000000000);
751 }
752
753 #[test]
754 fn test_parse_orderbook() {
755 let data = json!({
756 "bids": [
757 ["50000.00", "1.5", "0", "1"],
758 ["49999.00", "2.0", "0", "2"]
759 ],
760 "asks": [
761 ["50001.00", "1.0", "0", "1"],
762 ["50002.00", "3.0", "0", "2"]
763 ],
764 "ts": "1700000000000"
765 });
766
767 let orderbook = parse_orderbook(&data, "BTC/USDT".to_string()).unwrap();
768 assert_eq!(orderbook.symbol, "BTC/USDT");
769 assert_eq!(orderbook.bids.len(), 2);
770 assert_eq!(orderbook.asks.len(), 2);
771 assert_eq!(orderbook.bids[0].price, Price::new(dec!(50000.00)));
772 assert_eq!(orderbook.asks[0].price, Price::new(dec!(50001.00)));
773 }
774
775 #[test]
776 fn test_parse_trade() {
777 let data = json!({
778 "tradeId": "123456",
779 "instId": "BTC-USDT",
780 "side": "buy",
781 "px": "50000.00",
782 "sz": "0.5",
783 "ts": "1700000000000"
784 });
785
786 let trade = parse_trade(&data, None).unwrap();
787 assert_eq!(trade.id, Some("123456".to_string()));
788 assert_eq!(trade.side, OrderSide::Buy);
789 assert_eq!(trade.price, Price::new(dec!(50000.00)));
790 assert_eq!(trade.amount, Amount::new(dec!(0.5)));
791 }
792
793 #[test]
794 fn test_parse_ohlcv() {
795 let data = json!([
796 "1700000000000",
797 "50000.00",
798 "51000.00",
799 "49000.00",
800 "50500.00",
801 "1000.5"
802 ]);
803
804 let ohlcv = parse_ohlcv(&data).unwrap();
805 assert_eq!(ohlcv.timestamp, 1700000000000);
806 assert_eq!(ohlcv.open, 50000.00);
807 assert_eq!(ohlcv.high, 51000.00);
808 assert_eq!(ohlcv.low, 49000.00);
809 assert_eq!(ohlcv.close, 50500.00);
810 assert_eq!(ohlcv.volume, 1000.5);
811 }
812
813 #[test]
814 fn test_parse_order_status() {
815 assert_eq!(parse_order_status("live"), OrderStatus::Open);
816 assert_eq!(parse_order_status("partially_filled"), OrderStatus::Open);
817 assert_eq!(parse_order_status("filled"), OrderStatus::Closed);
818 assert_eq!(parse_order_status("canceled"), OrderStatus::Canceled);
819 assert_eq!(parse_order_status("mmp_canceled"), OrderStatus::Canceled);
820 assert_eq!(parse_order_status("expired"), OrderStatus::Expired);
821 assert_eq!(parse_order_status("rejected"), OrderStatus::Rejected);
822 }
823
824 #[test]
825 fn test_parse_order() {
826 let data = json!({
827 "ordId": "123456789",
828 "instId": "BTC-USDT",
829 "side": "buy",
830 "ordType": "limit",
831 "px": "50000.00",
832 "sz": "0.5",
833 "state": "live",
834 "cTime": "1700000000000"
835 });
836
837 let order = parse_order(&data, None).unwrap();
838 assert_eq!(order.id, "123456789");
839 assert_eq!(order.side, OrderSide::Buy);
840 assert_eq!(order.order_type, OrderType::Limit);
841 assert_eq!(order.price, Some(dec!(50000.00)));
842 assert_eq!(order.amount, dec!(0.5));
843 assert_eq!(order.status, OrderStatus::Open);
844 }
845
846 #[test]
847 fn test_parse_balance() {
848 let data = json!({
849 "details": [
850 {
851 "ccy": "BTC",
852 "availBal": "1.5",
853 "frozenBal": "0.5",
854 "eq": "2.0"
855 },
856 {
857 "ccy": "USDT",
858 "availBal": "10000.00",
859 "frozenBal": "0",
860 "eq": "10000.00"
861 }
862 ]
863 });
864
865 let balance = parse_balance(&data).unwrap();
866 let btc = balance.get("BTC").unwrap();
867 assert_eq!(btc.free, dec!(1.5));
868 assert_eq!(btc.used, dec!(0.5));
869 assert_eq!(btc.total, dec!(2.0));
870
871 let usdt = balance.get("USDT").unwrap();
872 assert_eq!(usdt.free, dec!(10000.00));
873 assert_eq!(usdt.total, dec!(10000.00));
874 }
875
876 #[test]
877 fn test_timestamp_to_datetime() {
878 let ts = 1700000000000i64;
879 let dt = timestamp_to_datetime(ts).unwrap();
880 assert!(dt.contains("2023-11-14"));
881 }
882}