1use 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
20pub use ccxt_core::parser_utils::{datetime_to_timestamp, timestamp_to_datetime};
22
23pub fn parse_market(data: &Value) -> Result<Market> {
41 let id = data["symbol"]
43 .as_str()
44 .ok_or_else(|| Error::from(ParseError::missing_field("symbol")))?
45 .to_string();
46
47 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 let symbol = format!("{}/{}", base, quote);
60
61 let status = data["status"].as_str().unwrap_or("online");
63 let active = status == "online";
64
65 let price_precision = parse_decimal(data, "pricePrecision")
67 .or_else(|| parse_decimal(data, "pricePlace"))
68 .map(|p| {
69 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 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 let maker_fee = parse_decimal(data, "makerFeeRate");
98 let taker_fee = parse_decimal(data, "takerFeeRate");
99
100 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
151pub 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 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 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
235pub 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 bids.sort_by(|a, b| b.price.cmp(&a.price));
256
257 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
277fn 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
311pub 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 let side = match data["side"].as_str() {
345 Some("sell" | "Sell" | "SELL") => OrderSide::Sell,
346 _ => OrderSide::Buy, };
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
376pub fn parse_ohlcv(data: &Value) -> Result<OHLCV> {
386 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
444pub 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, }
465}
466
467pub 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 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 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, };
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
578pub fn parse_balance(data: &Value) -> Result<Balance> {
588 let mut balances = HashMap::new();
589
590 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 for balance in balances_array {
598 parse_balance_entry(balance, &mut balances);
599 }
600 } else {
601 parse_balance_entry(data, &mut balances);
603 }
604
605 Ok(Balance {
606 balances,
607 info: value_to_hashmap(data),
608 })
609}
610
611fn 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 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#[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}