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(ToString::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(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 let side = match data["side"].as_str() {
376 Some("sell" | "Sell" | "SELL") => OrderSide::Sell,
377 _ => OrderSide::Buy, };
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
407pub fn parse_ohlcv(data: &Value) -> Result<OHLCV> {
417 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
475pub 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, }
496}
497
498pub 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 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 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, };
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
606pub fn parse_balance(data: &Value) -> Result<Balance> {
616 let mut balances = HashMap::new();
617
618 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 for balance in balances_array {
626 parse_balance_entry(balance, &mut balances);
627 }
628 } else {
629 parse_balance_entry(data, &mut balances);
631 }
632
633 Ok(Balance {
634 balances,
635 info: value_to_hashmap(data),
636 })
637}
638
639fn 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 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#[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}