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