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> {
39 let id = data["instId"]
41 .as_str()
42 .ok_or_else(|| Error::from(ParseError::missing_field("instId")))?
43 .to_string();
44
45 let base = data["baseCcy"]
47 .as_str()
48 .ok_or_else(|| Error::from(ParseError::missing_field("baseCcy")))?
49 .to_string();
50
51 let quote = data["quoteCcy"]
52 .as_str()
53 .ok_or_else(|| Error::from(ParseError::missing_field("quoteCcy")))?
54 .to_string();
55
56 let inst_type = data["instType"].as_str().unwrap_or("SPOT");
58 let market_type = match inst_type {
59 "SWAP" => MarketType::Swap,
60 "FUTURES" => MarketType::Futures,
61 "OPTION" => MarketType::Option,
62 _ => MarketType::Spot,
63 };
64
65 let state = data["state"].as_str().unwrap_or("live");
67 let active = state == "live";
68
69 let price_precision = parse_decimal(data, "tickSz");
71 let amount_precision = parse_decimal(data, "lotSz");
72
73 let min_amount = parse_decimal(data, "minSz");
75 let max_amount = parse_decimal(data, "maxLmtSz");
76
77 let contract = inst_type != "SPOT";
79 let linear = if contract {
80 Some(data["ctType"].as_str() == Some("linear"))
81 } else {
82 None
83 };
84 let inverse = if contract {
85 Some(data["ctType"].as_str() == Some("inverse"))
86 } else {
87 None
88 };
89 let contract_size = parse_decimal(data, "ctVal");
90
91 let settle = data["settleCcy"].as_str().map(ToString::to_string);
93 let settle_id = settle.clone();
94
95 let expiry = parse_timestamp(data, "expTime");
97 let expiry_datetime = expiry.and_then(timestamp_to_datetime);
98
99 let symbol = match market_type {
104 MarketType::Spot => format!("{}/{}", base, quote),
105 MarketType::Swap => {
106 if let Some(ref s) = settle {
107 format!("{}/{}:{}", base, quote, s)
108 } else {
109 format!("{}/{}:{}", base, quote, quote)
111 }
112 }
113 MarketType::Futures | MarketType::Option => {
114 if let (Some(s), Some(exp_ts)) = (&settle, expiry) {
115 if let Some(dt) = chrono::DateTime::from_timestamp_millis(exp_ts) {
117 let year = (dt.format("%y").to_string().parse::<u8>()).unwrap_or(0);
118 let month = (dt.format("%m").to_string().parse::<u8>()).unwrap_or(1);
119 let day = (dt.format("%d").to_string().parse::<u8>()).unwrap_or(1);
120 format!("{}/{}:{}-{:02}{:02}{:02}", base, quote, s, year, month, day)
121 } else {
122 format!("{}/{}:{}", base, quote, s)
123 }
124 } else if let Some(ref s) = settle {
125 format!("{}/{}:{}", base, quote, s)
126 } else {
127 format!("{}/{}", base, quote)
128 }
129 }
130 };
131
132 let parsed_symbol = ccxt_core::symbol::SymbolParser::parse(&symbol).ok();
134
135 Ok(Market {
136 id,
137 symbol,
138 parsed_symbol,
139 base: base.clone(),
140 quote: quote.clone(),
141 settle,
142 base_id: Some(base),
143 quote_id: Some(quote),
144 settle_id,
145 market_type,
146 active,
147 margin: inst_type == "MARGIN",
148 contract: Some(contract),
149 linear,
150 inverse,
151 contract_size,
152 expiry,
153 expiry_datetime,
154 strike: parse_decimal(data, "stk"),
155 option_type: data["optType"].as_str().map(ToString::to_string),
156 precision: MarketPrecision {
157 price: price_precision,
158 amount: amount_precision,
159 base: None,
160 quote: None,
161 },
162 limits: MarketLimits {
163 amount: Some(MinMax {
164 min: min_amount,
165 max: max_amount,
166 }),
167 price: None,
168 cost: None,
169 leverage: None,
170 },
171 maker: parse_decimal(data, "makerFee"),
172 taker: parse_decimal(data, "takerFee"),
173 percentage: Some(true),
174 tier_based: Some(false),
175 fee_side: Some("quote".to_string()),
176 info: value_to_hashmap(data),
177 })
178}
179
180pub fn parse_ticker(data: &Value, market: Option<&Market>) -> Result<Ticker> {
191 let symbol = if let Some(m) = market {
192 m.symbol.clone()
193 } else {
194 data["instId"]
196 .as_str()
197 .map(|s| s.replace('-', "/"))
198 .unwrap_or_default()
199 };
200
201 let timestamp = parse_timestamp(data, "ts").unwrap_or(0);
203
204 Ok(Ticker {
205 symbol,
206 timestamp,
207 datetime: timestamp_to_datetime(timestamp),
208 high: parse_decimal(data, "high24h").map(Price::new),
209 low: parse_decimal(data, "low24h").map(Price::new),
210 bid: parse_decimal(data, "bidPx").map(Price::new),
211 bid_volume: parse_decimal(data, "bidSz").map(Amount::new),
212 ask: parse_decimal(data, "askPx").map(Price::new),
213 ask_volume: parse_decimal(data, "askSz").map(Amount::new),
214 vwap: None,
215 open: parse_decimal(data, "open24h")
216 .or_else(|| parse_decimal(data, "sodUtc0"))
217 .map(Price::new),
218 close: parse_decimal(data, "last").map(Price::new),
219 last: parse_decimal(data, "last").map(Price::new),
220 previous_close: None,
221 change: None, percentage: parse_decimal(data, "sodUtc0").and_then(|open| {
223 parse_decimal(data, "last").map(|last| {
224 if open.is_zero() {
225 Decimal::ZERO
226 } else {
227 ((last - open) / open) * Decimal::from(100)
228 }
229 })
230 }),
231 average: None,
232 base_volume: parse_decimal(data, "vol24h")
233 .or_else(|| parse_decimal(data, "volCcy24h"))
234 .map(Amount::new),
235 quote_volume: parse_decimal(data, "volCcy24h").map(Amount::new),
236 funding_rate: None,
237 open_interest: None,
238 index_price: None,
239 mark_price: None,
240 info: value_to_hashmap(data),
241 })
242}
243
244pub fn parse_orderbook(data: &Value, symbol: String) -> Result<OrderBook> {
256 let timestamp =
257 parse_timestamp(data, "ts").unwrap_or_else(|| chrono::Utc::now().timestamp_millis());
258
259 let mut bids = parse_orderbook_side(&data["bids"])?;
260 let mut asks = parse_orderbook_side(&data["asks"])?;
261
262 bids.sort_by(|a, b| b.price.cmp(&a.price));
264
265 asks.sort_by(|a, b| a.price.cmp(&b.price));
267
268 Ok(OrderBook {
269 symbol,
270 timestamp,
271 datetime: timestamp_to_datetime(timestamp),
272 nonce: None,
273 bids,
274 asks,
275 buffered_deltas: std::collections::VecDeque::new(),
276 bids_map: std::collections::BTreeMap::new(),
277 asks_map: std::collections::BTreeMap::new(),
278 is_synced: false,
279 needs_resync: false,
280 last_resync_time: 0,
281 info: value_to_hashmap(data),
282 })
283}
284
285fn parse_orderbook_side(data: &Value) -> Result<Vec<OrderBookEntry>> {
287 let Some(array) = data.as_array() else {
288 return Ok(Vec::new());
289 };
290
291 let mut result = Vec::new();
292
293 for item in array {
294 if let Some(arr) = item.as_array() {
295 if arr.len() >= 2 {
297 let price = arr[0]
298 .as_str()
299 .and_then(|s| Decimal::from_str(s).ok())
300 .or_else(|| arr[0].as_f64().and_then(Decimal::from_f64))
301 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "price")))?;
302
303 let amount = arr[1]
304 .as_str()
305 .and_then(|s| Decimal::from_str(s).ok())
306 .or_else(|| arr[1].as_f64().and_then(Decimal::from_f64))
307 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "amount")))?;
308
309 result.push(OrderBookEntry {
310 price: Price::new(price),
311 amount: Amount::new(amount),
312 });
313 }
314 }
315 }
316
317 Ok(result)
318}
319
320pub fn parse_trade(data: &Value, market: Option<&Market>) -> Result<Trade> {
331 let symbol = if let Some(m) = market {
332 m.symbol.clone()
333 } else {
334 data["instId"]
335 .as_str()
336 .map(|s| s.replace('-', "/"))
337 .unwrap_or_default()
338 };
339
340 let id = data["tradeId"].as_str().map(ToString::to_string);
341
342 let timestamp = parse_timestamp(data, "ts").unwrap_or(0);
343
344 let side = match data["side"].as_str() {
346 Some("sell" | "Sell" | "SELL") => OrderSide::Sell,
347 _ => OrderSide::Buy, };
349
350 let price = parse_decimal(data, "px").or_else(|| parse_decimal(data, "fillPx"));
351 let amount = parse_decimal(data, "sz").or_else(|| parse_decimal(data, "fillSz"));
352
353 let cost = match (price, amount) {
354 (Some(p), Some(a)) => Some(p * a),
355 _ => None,
356 };
357
358 Ok(Trade {
359 id,
360 order: data["ordId"].as_str().map(ToString::to_string),
361 timestamp,
362 datetime: timestamp_to_datetime(timestamp),
363 symbol,
364 trade_type: None,
365 side,
366 taker_or_maker: None,
367 price: Price::new(price.unwrap_or(Decimal::ZERO)),
368 amount: Amount::new(amount.unwrap_or(Decimal::ZERO)),
369 cost: cost.map(Cost::new),
370 fee: None,
371 info: value_to_hashmap(data),
372 })
373}
374
375pub fn parse_ohlcv(data: &Value) -> Result<OHLCV> {
385 let arr = data
387 .as_array()
388 .ok_or_else(|| Error::from(ParseError::invalid_format("data", "OHLCV array")))?;
389
390 if arr.len() < 6 {
391 return Err(Error::from(ParseError::invalid_format(
392 "data",
393 "OHLCV array with at least 6 elements",
394 )));
395 }
396
397 let timestamp = arr[0]
398 .as_str()
399 .and_then(|s| s.parse::<i64>().ok())
400 .or_else(|| arr[0].as_i64())
401 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "timestamp")))?;
402
403 let open = arr[1]
404 .as_str()
405 .and_then(|s| s.parse::<f64>().ok())
406 .or_else(|| arr[1].as_f64())
407 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "open")))?;
408
409 let high = arr[2]
410 .as_str()
411 .and_then(|s| s.parse::<f64>().ok())
412 .or_else(|| arr[2].as_f64())
413 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "high")))?;
414
415 let low = arr[3]
416 .as_str()
417 .and_then(|s| s.parse::<f64>().ok())
418 .or_else(|| arr[3].as_f64())
419 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "low")))?;
420
421 let close = arr[4]
422 .as_str()
423 .and_then(|s| s.parse::<f64>().ok())
424 .or_else(|| arr[4].as_f64())
425 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "close")))?;
426
427 let volume = arr[5]
428 .as_str()
429 .and_then(|s| s.parse::<f64>().ok())
430 .or_else(|| arr[5].as_f64())
431 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "volume")))?;
432
433 Ok(OHLCV {
434 timestamp,
435 open,
436 high,
437 low,
438 close,
439 volume,
440 })
441}
442
443pub fn parse_order_status(status: &str) -> OrderStatus {
464 match status.to_lowercase().as_str() {
465 "filled" => OrderStatus::Closed,
466 "canceled" | "cancelled" | "mmp_canceled" => OrderStatus::Cancelled,
467 "expired" => OrderStatus::Expired,
468 "rejected" => OrderStatus::Rejected,
469 _ => OrderStatus::Open, }
471}
472
473pub fn parse_order(data: &Value, market: Option<&Market>) -> Result<Order> {
484 let symbol = if let Some(m) = market {
485 m.symbol.clone()
486 } else {
487 data["instId"]
488 .as_str()
489 .map(|s| s.replace('-', "/"))
490 .unwrap_or_default()
491 };
492
493 let id = data["ordId"]
494 .as_str()
495 .ok_or_else(|| Error::from(ParseError::missing_field("ordId")))?
496 .to_string();
497
498 let timestamp = parse_timestamp(data, "cTime").or_else(|| parse_timestamp(data, "ts"));
499
500 let status_str = data["state"].as_str().unwrap_or("live");
501 let status = parse_order_status(status_str);
502
503 let side = match data["side"].as_str() {
505 Some("buy" | "Buy" | "BUY") => OrderSide::Buy,
506 Some("sell" | "Sell" | "SELL") => OrderSide::Sell,
507 _ => return Err(Error::from(ParseError::invalid_format("data", "side"))),
508 };
509
510 let order_type = match data["ordType"].as_str() {
512 Some("market" | "Market" | "MARKET") => OrderType::Market,
513 Some("post_only") => OrderType::LimitMaker,
514 _ => OrderType::Limit, };
516
517 let price = parse_decimal(data, "px");
518 let amount =
519 parse_decimal(data, "sz").ok_or_else(|| Error::from(ParseError::missing_field("sz")))?;
520 let filled = parse_decimal(data, "accFillSz").or_else(|| parse_decimal(data, "fillSz"));
521 let remaining = match filled {
522 Some(f) => Some(amount - f),
523 None => Some(amount),
524 };
525
526 let average = parse_decimal(data, "avgPx").or_else(|| parse_decimal(data, "fillPx"));
527
528 let cost = match (filled, average) {
530 (Some(f), Some(avg)) => Some(f * avg),
531 _ => None,
532 };
533
534 Ok(Order {
535 id,
536 client_order_id: data["clOrdId"].as_str().map(ToString::to_string),
537 timestamp,
538 datetime: timestamp.and_then(timestamp_to_datetime),
539 last_trade_timestamp: parse_timestamp(data, "uTime"),
540 status,
541 symbol,
542 order_type,
543 time_in_force: data["ordType"].as_str().map(|s| match s {
544 "fok" => "FOK".to_string(),
545 "ioc" => "IOC".to_string(),
546 "post_only" => "PO".to_string(),
547 _ => "GTC".to_string(),
548 }),
549 side,
550 price,
551 average,
552 amount,
553 filled,
554 remaining,
555 cost,
556 trades: None,
557 fee: None,
558 post_only: Some(data["ordType"].as_str() == Some("post_only")),
559 reduce_only: data["reduceOnly"].as_bool(),
560 trigger_price: parse_decimal(data, "triggerPx"),
561 stop_price: parse_decimal(data, "slTriggerPx"),
562 take_profit_price: parse_decimal(data, "tpTriggerPx"),
563 stop_loss_price: parse_decimal(data, "slTriggerPx"),
564 trailing_delta: None,
565 trailing_percent: None,
566 activation_price: None,
567 callback_rate: None,
568 working_type: None,
569 fees: Some(Vec::new()),
570 info: value_to_hashmap(data),
571 })
572}
573
574pub fn parse_balance(data: &Value) -> Result<Balance> {
584 let mut balances = HashMap::new();
585
586 if let Some(details) = data["details"].as_array() {
588 for detail in details {
589 parse_balance_entry(detail, &mut balances);
590 }
591 } else if let Some(balances_array) = data.as_array() {
592 for balance in balances_array {
594 if let Some(details) = balance["details"].as_array() {
595 for detail in details {
596 parse_balance_entry(detail, &mut balances);
597 }
598 } else {
599 parse_balance_entry(balance, &mut balances);
600 }
601 }
602 } else {
603 parse_balance_entry(data, &mut balances);
605 }
606
607 Ok(Balance {
608 balances,
609 info: value_to_hashmap(data),
610 })
611}
612
613fn parse_balance_entry(data: &Value, balances: &mut HashMap<String, BalanceEntry>) {
615 let currency = data["ccy"]
616 .as_str()
617 .or_else(|| data["currency"].as_str())
618 .map(ToString::to_string);
619
620 if let Some(currency) = currency {
621 let available = parse_decimal(data, "availBal")
623 .or_else(|| parse_decimal(data, "availEq"))
624 .or_else(|| parse_decimal(data, "cashBal"))
625 .unwrap_or(Decimal::ZERO);
626
627 let frozen = parse_decimal(data, "frozenBal")
628 .or_else(|| parse_decimal(data, "ordFrozen"))
629 .unwrap_or(Decimal::ZERO);
630
631 let total = parse_decimal(data, "eq")
632 .or_else(|| parse_decimal(data, "bal"))
633 .unwrap_or(available + frozen);
634
635 if total > Decimal::ZERO {
637 balances.insert(
638 currency,
639 BalanceEntry {
640 free: available,
641 used: frozen,
642 total,
643 },
644 );
645 }
646 }
647}
648
649#[cfg(test)]
654mod tests {
655 use super::*;
656 use rust_decimal_macros::dec;
657 use serde_json::json;
658
659 #[test]
660 fn test_parse_market() {
661 let data = json!({
662 "instId": "BTC-USDT",
663 "instType": "SPOT",
664 "baseCcy": "BTC",
665 "quoteCcy": "USDT",
666 "state": "live",
667 "tickSz": "0.01",
668 "lotSz": "0.0001",
669 "minSz": "0.0001"
670 });
671
672 let market = parse_market(&data).unwrap();
673 assert_eq!(market.id, "BTC-USDT");
674 assert_eq!(market.symbol, "BTC/USDT");
675 assert_eq!(market.base, "BTC");
676 assert_eq!(market.quote, "USDT");
677 assert!(market.active);
678 assert_eq!(market.market_type, MarketType::Spot);
679 }
680
681 #[test]
682 fn test_parse_ticker() {
683 let data = json!({
684 "instId": "BTC-USDT",
685 "last": "50000.00",
686 "high24h": "51000.00",
687 "low24h": "49000.00",
688 "bidPx": "49999.00",
689 "askPx": "50001.00",
690 "vol24h": "1000.5",
691 "ts": "1700000000000"
692 });
693
694 let ticker = parse_ticker(&data, None).unwrap();
695 assert_eq!(ticker.symbol, "BTC/USDT");
696 assert_eq!(ticker.last, Some(Price::new(dec!(50000.00))));
697 assert_eq!(ticker.high, Some(Price::new(dec!(51000.00))));
698 assert_eq!(ticker.low, Some(Price::new(dec!(49000.00))));
699 assert_eq!(ticker.timestamp, 1700000000000);
700 }
701
702 #[test]
703 fn test_parse_orderbook() {
704 let data = json!({
705 "bids": [
706 ["50000.00", "1.5", "0", "1"],
707 ["49999.00", "2.0", "0", "2"]
708 ],
709 "asks": [
710 ["50001.00", "1.0", "0", "1"],
711 ["50002.00", "3.0", "0", "2"]
712 ],
713 "ts": "1700000000000"
714 });
715
716 let orderbook = parse_orderbook(&data, "BTC/USDT".to_string()).unwrap();
717 assert_eq!(orderbook.symbol, "BTC/USDT");
718 assert_eq!(orderbook.bids.len(), 2);
719 assert_eq!(orderbook.asks.len(), 2);
720 assert_eq!(orderbook.bids[0].price, Price::new(dec!(50000.00)));
721 assert_eq!(orderbook.asks[0].price, Price::new(dec!(50001.00)));
722 }
723
724 #[test]
725 fn test_parse_trade() {
726 let data = json!({
727 "tradeId": "123456",
728 "instId": "BTC-USDT",
729 "side": "buy",
730 "px": "50000.00",
731 "sz": "0.5",
732 "ts": "1700000000000"
733 });
734
735 let trade = parse_trade(&data, None).unwrap();
736 assert_eq!(trade.id, Some("123456".to_string()));
737 assert_eq!(trade.side, OrderSide::Buy);
738 assert_eq!(trade.price, Price::new(dec!(50000.00)));
739 assert_eq!(trade.amount, Amount::new(dec!(0.5)));
740 }
741
742 #[test]
743 fn test_parse_ohlcv() {
744 let data = json!([
745 "1700000000000",
746 "50000.00",
747 "51000.00",
748 "49000.00",
749 "50500.00",
750 "1000.5"
751 ]);
752
753 let ohlcv = parse_ohlcv(&data).unwrap();
754 assert_eq!(ohlcv.timestamp, 1700000000000);
755 assert_eq!(ohlcv.open, 50000.00);
756 assert_eq!(ohlcv.high, 51000.00);
757 assert_eq!(ohlcv.low, 49000.00);
758 assert_eq!(ohlcv.close, 50500.00);
759 assert_eq!(ohlcv.volume, 1000.5);
760 }
761
762 #[test]
763 fn test_parse_order_status() {
764 assert_eq!(parse_order_status("live"), OrderStatus::Open);
765 assert_eq!(parse_order_status("partially_filled"), OrderStatus::Open);
766 assert_eq!(parse_order_status("filled"), OrderStatus::Closed);
767 assert_eq!(parse_order_status("canceled"), OrderStatus::Cancelled);
768 assert_eq!(parse_order_status("mmp_canceled"), OrderStatus::Cancelled);
769 assert_eq!(parse_order_status("expired"), OrderStatus::Expired);
770 assert_eq!(parse_order_status("rejected"), OrderStatus::Rejected);
771 }
772
773 #[test]
774 fn test_parse_order() {
775 let data = json!({
776 "ordId": "123456789",
777 "instId": "BTC-USDT",
778 "side": "buy",
779 "ordType": "limit",
780 "px": "50000.00",
781 "sz": "0.5",
782 "state": "live",
783 "cTime": "1700000000000"
784 });
785
786 let order = parse_order(&data, None).unwrap();
787 assert_eq!(order.id, "123456789");
788 assert_eq!(order.side, OrderSide::Buy);
789 assert_eq!(order.order_type, OrderType::Limit);
790 assert_eq!(order.price, Some(dec!(50000.00)));
791 assert_eq!(order.amount, dec!(0.5));
792 assert_eq!(order.status, OrderStatus::Open);
793 }
794
795 #[test]
796 fn test_parse_balance() {
797 let data = json!({
798 "details": [
799 {
800 "ccy": "BTC",
801 "availBal": "1.5",
802 "frozenBal": "0.5",
803 "eq": "2.0"
804 },
805 {
806 "ccy": "USDT",
807 "availBal": "10000.00",
808 "frozenBal": "0",
809 "eq": "10000.00"
810 }
811 ]
812 });
813
814 let balance = parse_balance(&data).unwrap();
815 let btc = balance.get("BTC").unwrap();
816 assert_eq!(btc.free, dec!(1.5));
817 assert_eq!(btc.used, dec!(0.5));
818 assert_eq!(btc.total, dec!(2.0));
819
820 let usdt = balance.get("USDT").unwrap();
821 assert_eq!(usdt.free, dec!(10000.00));
822 assert_eq!(usdt.total, dec!(10000.00));
823 }
824
825 #[test]
826 fn test_timestamp_to_datetime() {
827 let ts = 1700000000000i64;
828 let dt = timestamp_to_datetime(ts).unwrap();
829 assert!(dt.contains("2023-11-14"));
830 }
831}