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 parts: Vec<&str> = id.split('-').collect();
47 let (base_from_id, quote_from_id) = if parts.len() >= 2 {
48 (Some(parts[0]), Some(parts[1]))
49 } else {
50 (None, None)
51 };
52
53 let base = data["baseCcy"]
55 .as_str()
56 .filter(|s| !s.is_empty())
57 .map(ToString::to_string)
58 .or_else(|| base_from_id.map(ToString::to_string))
59 .ok_or_else(|| Error::from(ParseError::missing_field("baseCcy")))?;
60
61 let quote = data["quoteCcy"]
62 .as_str()
63 .filter(|s| !s.is_empty())
64 .map(ToString::to_string)
65 .or_else(|| quote_from_id.map(ToString::to_string))
66 .ok_or_else(|| Error::from(ParseError::missing_field("quoteCcy")))?;
67
68 let inst_type = data["instType"].as_str().unwrap_or("SPOT");
70 let market_type = match inst_type {
71 "SWAP" => MarketType::Swap,
72 "FUTURES" => MarketType::Futures,
73 "OPTION" => MarketType::Option,
74 _ => MarketType::Spot,
75 };
76
77 let state = data["state"].as_str().unwrap_or("live");
79 let active = state == "live";
80
81 let price_precision = parse_decimal(data, "tickSz");
83 let amount_precision = parse_decimal(data, "lotSz");
84
85 let min_amount = parse_decimal(data, "minSz");
87 let max_amount = parse_decimal(data, "maxLmtSz");
88
89 let contract = inst_type != "SPOT";
91 let linear = if contract {
92 Some(data["ctType"].as_str() == Some("linear"))
93 } else {
94 None
95 };
96 let inverse = if contract {
97 Some(data["ctType"].as_str() == Some("inverse"))
98 } else {
99 None
100 };
101 let contract_size = parse_decimal(data, "ctVal");
102
103 let settle = data["settleCcy"].as_str().map(ToString::to_string);
105 let settle_id = settle.clone();
106
107 let expiry = parse_timestamp(data, "expTime");
109 let expiry_datetime = expiry.and_then(timestamp_to_datetime);
110
111 let symbol = match market_type {
116 MarketType::Spot => format!("{}/{}", base, quote),
117 MarketType::Swap => {
118 if let Some(ref s) = settle {
119 format!("{}/{}:{}", base, quote, s)
120 } else {
121 format!("{}/{}:{}", base, quote, quote)
123 }
124 }
125 MarketType::Futures | MarketType::Option => {
126 if let (Some(s), Some(exp_ts)) = (&settle, expiry) {
127 if let Some(dt) = chrono::DateTime::from_timestamp_millis(exp_ts) {
129 let year = (dt.format("%y").to_string().parse::<u8>()).unwrap_or(0);
130 let month = (dt.format("%m").to_string().parse::<u8>()).unwrap_or(1);
131 let day = (dt.format("%d").to_string().parse::<u8>()).unwrap_or(1);
132 format!("{}/{}:{}-{:02}{:02}{:02}", base, quote, s, year, month, day)
133 } else {
134 format!("{}/{}:{}", base, quote, s)
135 }
136 } else if let Some(ref s) = settle {
137 format!("{}/{}:{}", base, quote, s)
138 } else {
139 format!("{}/{}", base, quote)
140 }
141 }
142 };
143
144 let parsed_symbol = ccxt_core::symbol::SymbolParser::parse(&symbol).ok();
146
147 Ok(Market {
148 id,
149 symbol,
150 parsed_symbol,
151 base: base.clone(),
152 quote: quote.clone(),
153 settle,
154 base_id: Some(base),
155 quote_id: Some(quote),
156 settle_id,
157 market_type,
158 active,
159 margin: inst_type == "MARGIN",
160 contract: Some(contract),
161 linear,
162 inverse,
163 contract_size,
164 expiry,
165 expiry_datetime,
166 strike: parse_decimal(data, "stk"),
167 option_type: data["optType"].as_str().map(ToString::to_string),
168 precision: MarketPrecision {
169 price: price_precision,
170 amount: amount_precision,
171 base: None,
172 quote: None,
173 },
174 limits: MarketLimits {
175 amount: Some(MinMax {
176 min: min_amount,
177 max: max_amount,
178 }),
179 price: None,
180 cost: None,
181 leverage: None,
182 },
183 maker: parse_decimal(data, "makerFee"),
184 taker: parse_decimal(data, "takerFee"),
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["instId"]
208 .as_str()
209 .map(|s| s.replace('-', "/"))
210 .unwrap_or_default()
211 };
212
213 let timestamp = parse_timestamp(data, "ts").unwrap_or(0);
215
216 Ok(Ticker {
217 symbol,
218 timestamp,
219 datetime: timestamp_to_datetime(timestamp),
220 high: parse_decimal(data, "high24h").map(Price::new),
221 low: parse_decimal(data, "low24h").map(Price::new),
222 bid: parse_decimal(data, "bidPx").map(Price::new),
223 bid_volume: parse_decimal(data, "bidSz").map(Amount::new),
224 ask: parse_decimal(data, "askPx").map(Price::new),
225 ask_volume: parse_decimal(data, "askSz").map(Amount::new),
226 vwap: None,
227 open: parse_decimal(data, "open24h")
228 .or_else(|| parse_decimal(data, "sodUtc0"))
229 .map(Price::new),
230 close: parse_decimal(data, "last").map(Price::new),
231 last: parse_decimal(data, "last").map(Price::new),
232 previous_close: None,
233 change: None, percentage: parse_decimal(data, "sodUtc0").and_then(|open| {
235 parse_decimal(data, "last").map(|last| {
236 if open.is_zero() {
237 Decimal::ZERO
238 } else {
239 ((last - open) / open) * Decimal::from(100)
240 }
241 })
242 }),
243 average: None,
244 base_volume: parse_decimal(data, "vol24h")
245 .or_else(|| parse_decimal(data, "volCcy24h"))
246 .map(Amount::new),
247 quote_volume: parse_decimal(data, "volCcy24h").map(Amount::new),
248 funding_rate: None,
249 open_interest: None,
250 index_price: None,
251 mark_price: None,
252 info: value_to_hashmap(data),
253 })
254}
255
256pub fn parse_orderbook(data: &Value, symbol: String) -> Result<OrderBook> {
268 let timestamp =
269 parse_timestamp(data, "ts").unwrap_or_else(|| chrono::Utc::now().timestamp_millis());
270
271 let mut bids = parse_orderbook_side(&data["bids"])?;
272 let mut asks = parse_orderbook_side(&data["asks"])?;
273
274 bids.sort_by(|a, b| b.price.cmp(&a.price));
276
277 asks.sort_by(|a, b| a.price.cmp(&b.price));
279
280 Ok(OrderBook {
281 symbol,
282 timestamp,
283 datetime: timestamp_to_datetime(timestamp),
284 nonce: None,
285 bids,
286 asks,
287 buffered_deltas: std::collections::VecDeque::new(),
288 bids_map: std::collections::BTreeMap::new(),
289 asks_map: std::collections::BTreeMap::new(),
290 is_synced: false,
291 needs_resync: false,
292 last_resync_time: 0,
293 info: value_to_hashmap(data),
294 })
295}
296
297fn parse_orderbook_side(data: &Value) -> Result<Vec<OrderBookEntry>> {
299 let Some(array) = data.as_array() else {
300 return Ok(Vec::new());
301 };
302
303 let mut result = Vec::new();
304
305 for item in array {
306 if let Some(arr) = item.as_array() {
307 if arr.len() >= 2 {
309 let price = arr[0]
310 .as_str()
311 .and_then(|s| Decimal::from_str(s).ok())
312 .or_else(|| arr[0].as_f64().and_then(Decimal::from_f64))
313 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "price")))?;
314
315 let amount = arr[1]
316 .as_str()
317 .and_then(|s| Decimal::from_str(s).ok())
318 .or_else(|| arr[1].as_f64().and_then(Decimal::from_f64))
319 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "amount")))?;
320
321 result.push(OrderBookEntry {
322 price: Price::new(price),
323 amount: Amount::new(amount),
324 });
325 }
326 }
327 }
328
329 Ok(result)
330}
331
332pub fn parse_trade(data: &Value, market: Option<&Market>) -> Result<Trade> {
343 let symbol = if let Some(m) = market {
344 m.symbol.clone()
345 } else {
346 data["instId"]
347 .as_str()
348 .map(|s| s.replace('-', "/"))
349 .unwrap_or_default()
350 };
351
352 let id = data["tradeId"].as_str().map(ToString::to_string);
353
354 let timestamp = parse_timestamp(data, "ts").unwrap_or(0);
355
356 let side = match data["side"].as_str() {
358 Some("sell" | "Sell" | "SELL") => OrderSide::Sell,
359 _ => OrderSide::Buy, };
361
362 let price = parse_decimal(data, "px").or_else(|| parse_decimal(data, "fillPx"));
363 let amount = parse_decimal(data, "sz").or_else(|| parse_decimal(data, "fillSz"));
364
365 let cost = match (price, amount) {
366 (Some(p), Some(a)) => Some(p * a),
367 _ => None,
368 };
369
370 Ok(Trade {
371 id,
372 order: data["ordId"].as_str().map(ToString::to_string),
373 timestamp,
374 datetime: timestamp_to_datetime(timestamp),
375 symbol,
376 trade_type: None,
377 side,
378 taker_or_maker: None,
379 price: Price::new(price.unwrap_or(Decimal::ZERO)),
380 amount: Amount::new(amount.unwrap_or(Decimal::ZERO)),
381 cost: cost.map(Cost::new),
382 fee: None,
383 info: value_to_hashmap(data),
384 })
385}
386
387pub fn parse_ohlcv(data: &Value) -> Result<OHLCV> {
397 let arr = data
399 .as_array()
400 .ok_or_else(|| Error::from(ParseError::invalid_format("data", "OHLCV array")))?;
401
402 if arr.len() < 6 {
403 return Err(Error::from(ParseError::invalid_format(
404 "data",
405 "OHLCV array with at least 6 elements",
406 )));
407 }
408
409 let timestamp = arr[0]
410 .as_str()
411 .and_then(|s| s.parse::<i64>().ok())
412 .or_else(|| arr[0].as_i64())
413 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "timestamp")))?;
414
415 let open = arr[1]
416 .as_str()
417 .and_then(|s| s.parse::<f64>().ok())
418 .or_else(|| arr[1].as_f64())
419 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "open")))?;
420
421 let high = arr[2]
422 .as_str()
423 .and_then(|s| s.parse::<f64>().ok())
424 .or_else(|| arr[2].as_f64())
425 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "high")))?;
426
427 let low = arr[3]
428 .as_str()
429 .and_then(|s| s.parse::<f64>().ok())
430 .or_else(|| arr[3].as_f64())
431 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "low")))?;
432
433 let close = arr[4]
434 .as_str()
435 .and_then(|s| s.parse::<f64>().ok())
436 .or_else(|| arr[4].as_f64())
437 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "close")))?;
438
439 let volume = arr[5]
440 .as_str()
441 .and_then(|s| s.parse::<f64>().ok())
442 .or_else(|| arr[5].as_f64())
443 .ok_or_else(|| Error::from(ParseError::invalid_value("data", "volume")))?;
444
445 Ok(OHLCV {
446 timestamp,
447 open,
448 high,
449 low,
450 close,
451 volume,
452 })
453}
454
455pub fn parse_order_status(status: &str) -> OrderStatus {
476 match status.to_lowercase().as_str() {
477 "filled" => OrderStatus::Closed,
478 "canceled" | "cancelled" | "mmp_canceled" => OrderStatus::Cancelled,
479 "expired" => OrderStatus::Expired,
480 "rejected" => OrderStatus::Rejected,
481 _ => OrderStatus::Open, }
483}
484
485pub fn parse_order(data: &Value, market: Option<&Market>) -> Result<Order> {
496 let symbol = if let Some(m) = market {
497 m.symbol.clone()
498 } else {
499 data["instId"]
500 .as_str()
501 .map(|s| s.replace('-', "/"))
502 .unwrap_or_default()
503 };
504
505 let id = data["ordId"]
506 .as_str()
507 .ok_or_else(|| Error::from(ParseError::missing_field("ordId")))?
508 .to_string();
509
510 let timestamp = parse_timestamp(data, "cTime").or_else(|| parse_timestamp(data, "ts"));
511
512 let status_str = data["state"].as_str().unwrap_or("live");
513 let status = parse_order_status(status_str);
514
515 let side = match data["side"].as_str() {
517 Some("buy" | "Buy" | "BUY") => OrderSide::Buy,
518 Some("sell" | "Sell" | "SELL") => OrderSide::Sell,
519 _ => return Err(Error::from(ParseError::invalid_format("data", "side"))),
520 };
521
522 let order_type = match data["ordType"].as_str() {
524 Some("market" | "Market" | "MARKET") => OrderType::Market,
525 Some("post_only") => OrderType::LimitMaker,
526 _ => OrderType::Limit, };
528
529 let price = parse_decimal(data, "px");
530 let amount =
531 parse_decimal(data, "sz").ok_or_else(|| Error::from(ParseError::missing_field("sz")))?;
532 let filled = parse_decimal(data, "accFillSz").or_else(|| parse_decimal(data, "fillSz"));
533 let remaining = match filled {
534 Some(f) => Some(amount - f),
535 None => Some(amount),
536 };
537
538 let average = parse_decimal(data, "avgPx").or_else(|| parse_decimal(data, "fillPx"));
539
540 let cost = match (filled, average) {
542 (Some(f), Some(avg)) => Some(f * avg),
543 _ => None,
544 };
545
546 Ok(Order {
547 id,
548 client_order_id: data["clOrdId"].as_str().map(ToString::to_string),
549 timestamp,
550 datetime: timestamp.and_then(timestamp_to_datetime),
551 last_trade_timestamp: parse_timestamp(data, "uTime"),
552 status,
553 symbol,
554 order_type,
555 time_in_force: data["ordType"].as_str().map(|s| match s {
556 "fok" => "FOK".to_string(),
557 "ioc" => "IOC".to_string(),
558 "post_only" => "PO".to_string(),
559 _ => "GTC".to_string(),
560 }),
561 side,
562 price,
563 average,
564 amount,
565 filled,
566 remaining,
567 cost,
568 trades: None,
569 fee: None,
570 post_only: Some(data["ordType"].as_str() == Some("post_only")),
571 reduce_only: data["reduceOnly"].as_bool(),
572 trigger_price: parse_decimal(data, "triggerPx"),
573 stop_price: parse_decimal(data, "slTriggerPx"),
574 take_profit_price: parse_decimal(data, "tpTriggerPx"),
575 stop_loss_price: parse_decimal(data, "slTriggerPx"),
576 trailing_delta: None,
577 trailing_percent: None,
578 activation_price: None,
579 callback_rate: None,
580 working_type: None,
581 fees: Some(Vec::new()),
582 info: value_to_hashmap(data),
583 })
584}
585
586pub fn parse_balance(data: &Value) -> Result<Balance> {
596 let mut balances = HashMap::new();
597
598 if let Some(details) = data["details"].as_array() {
600 for detail in details {
601 parse_balance_entry(detail, &mut balances);
602 }
603 } else if let Some(balances_array) = data.as_array() {
604 for balance in balances_array {
606 if let Some(details) = balance["details"].as_array() {
607 for detail in details {
608 parse_balance_entry(detail, &mut balances);
609 }
610 } else {
611 parse_balance_entry(balance, &mut balances);
612 }
613 }
614 } else {
615 parse_balance_entry(data, &mut balances);
617 }
618
619 Ok(Balance {
620 balances,
621 info: value_to_hashmap(data),
622 })
623}
624
625fn parse_balance_entry(data: &Value, balances: &mut HashMap<String, BalanceEntry>) {
627 let currency = data["ccy"]
628 .as_str()
629 .or_else(|| data["currency"].as_str())
630 .map(ToString::to_string);
631
632 if let Some(currency) = currency {
633 let available = parse_decimal(data, "availBal")
635 .or_else(|| parse_decimal(data, "availEq"))
636 .or_else(|| parse_decimal(data, "cashBal"))
637 .unwrap_or(Decimal::ZERO);
638
639 let frozen = parse_decimal(data, "frozenBal")
640 .or_else(|| parse_decimal(data, "ordFrozen"))
641 .unwrap_or(Decimal::ZERO);
642
643 let total = parse_decimal(data, "eq")
644 .or_else(|| parse_decimal(data, "bal"))
645 .unwrap_or(available + frozen);
646
647 if total > Decimal::ZERO {
649 balances.insert(
650 currency,
651 BalanceEntry {
652 free: available,
653 used: frozen,
654 total,
655 },
656 );
657 }
658 }
659}
660
661pub fn parse_position(data: &Value, symbol: &str) -> Result<ccxt_core::types::Position> {
691 use ccxt_core::types::position::PositionSide;
692
693 let pos_side_str = data["posSide"].as_str().unwrap_or("net");
694 let position_side = match pos_side_str.to_lowercase().as_str() {
695 "long" => PositionSide::Long,
696 "short" => PositionSide::Short,
697 _ => PositionSide::Both,
698 };
699
700 let pos = parse_f64_field(data, "pos").unwrap_or(0.0);
701 let avg_px = parse_f64_field(data, "avgPx");
702 let mark_px = parse_f64_field(data, "markPx");
703 let upl = parse_f64_field(data, "upl");
704 let lever = parse_f64_field(data, "lever");
705 let liq_px = parse_f64_field(data, "liqPx");
706 let imr = parse_f64_field(data, "imr");
707 let mmr = parse_f64_field(data, "mmr");
708 let notional_usd = parse_f64_field(data, "notionalUsd");
709 let margin = parse_f64_field(data, "margin");
710 let realized_pnl = parse_f64_field(data, "realizedPnl");
711
712 let mgn_mode = data["mgnMode"].as_str().unwrap_or("cross");
713 let margin_mode = Some(mgn_mode.to_string());
714
715 let timestamp = parse_timestamp(data, "uTime").or_else(|| parse_timestamp(data, "cTime"));
716 let datetime = timestamp.and_then(timestamp_to_datetime);
717
718 let side = match position_side {
720 PositionSide::Long => Some("long".to_string()),
721 PositionSide::Short => Some("short".to_string()),
722 PositionSide::Both => {
723 if pos > 0.0 {
724 Some("long".to_string())
725 } else if pos < 0.0 {
726 Some("short".to_string())
727 } else {
728 None
729 }
730 }
731 };
732
733 let contracts = Some(pos.abs());
734
735 let initial_margin_percentage = lever.map(|l| if l > 0.0 { 1.0 / l } else { 0.0 });
737
738 let notional = notional_usd.or(match (avg_px, contracts) {
740 (Some(price), Some(qty)) => Some(price * qty),
741 _ => None,
742 });
743
744 let percentage = match (upl, margin.or(imr)) {
746 (Some(pnl), Some(m)) if m > 0.0 => Some((pnl / m) * 100.0),
747 _ => None,
748 };
749
750 let hedged = match position_side {
751 PositionSide::Both => Some(false),
752 _ => Some(true),
753 };
754
755 Ok(ccxt_core::types::Position {
756 info: data.clone(),
757 id: data["posId"].as_str().map(ToString::to_string),
758 symbol: symbol.to_string(),
759 side,
760 position_side: Some(position_side),
761 dual_side_position: hedged,
762 contracts,
763 contract_size: parse_f64_field(data, "ctVal"),
764 entry_price: avg_px,
765 mark_price: mark_px,
766 notional,
767 leverage: lever,
768 collateral: margin,
769 initial_margin: imr,
770 initial_margin_percentage,
771 maintenance_margin: mmr,
772 maintenance_margin_percentage: None,
773 unrealized_pnl: upl,
774 realized_pnl,
775 liquidation_price: liq_px,
776 margin_ratio: None,
777 margin_mode,
778 hedged,
779 percentage,
780 timestamp,
781 datetime,
782 })
783}
784
785pub fn parse_funding_rate(data: &Value, symbol: &str) -> Result<ccxt_core::types::FundingRate> {
803 let funding_rate = parse_f64_field(data, "fundingRate");
804 let funding_time = parse_timestamp(data, "fundingTime");
805 let next_funding_time = parse_timestamp(data, "nextFundingTime");
806
807 let timestamp = funding_time.or_else(|| parse_timestamp(data, "ts"));
808 let datetime = timestamp.and_then(timestamp_to_datetime);
809
810 let funding_datetime = next_funding_time.and_then(timestamp_to_datetime);
811
812 Ok(ccxt_core::types::FundingRate {
813 info: data.clone(),
814 symbol: symbol.to_string(),
815 mark_price: parse_f64_field(data, "markPx"),
816 index_price: parse_f64_field(data, "idxPx"),
817 interest_rate: None,
818 estimated_settle_price: None,
819 funding_rate,
820 funding_timestamp: next_funding_time,
821 funding_datetime,
822 previous_funding_rate: None,
823 previous_funding_timestamp: None,
824 previous_funding_datetime: None,
825 timestamp,
826 datetime,
827 })
828}
829
830pub fn parse_funding_rate_history(
847 data: &Value,
848 symbol: &str,
849) -> Result<ccxt_core::types::FundingRateHistory> {
850 let funding_rate =
851 parse_f64_field(data, "fundingRate").or_else(|| parse_f64_field(data, "realizedRate"));
852 let timestamp = parse_timestamp(data, "fundingTime");
853 let datetime = timestamp.and_then(timestamp_to_datetime);
854
855 Ok(ccxt_core::types::FundingRateHistory {
856 info: data.clone(),
857 symbol: symbol.to_string(),
858 funding_rate,
859 timestamp,
860 datetime,
861 })
862}
863
864fn parse_f64_field(data: &Value, field: &str) -> Option<f64> {
866 data[field]
867 .as_str()
868 .and_then(|s| {
869 if s.is_empty() {
870 None
871 } else {
872 s.parse::<f64>().ok()
873 }
874 })
875 .or_else(|| data[field].as_f64())
876}
877
878#[cfg(test)]
883mod tests {
884 use super::*;
885 use rust_decimal_macros::dec;
886 use serde_json::json;
887
888 #[test]
889 fn test_parse_market_swap_empty_base_quote() {
890 let data = json!({
891 "instId": "BTC-USDT-SWAP",
892 "instType": "SWAP",
893 "baseCcy": "",
894 "quoteCcy": "",
895 "settleCcy": "USDT",
896 "state": "live",
897 "tickSz": "0.1",
898 "lotSz": "1",
899 "minSz": "1",
900 "ctVal": "100"
901 });
902
903 let market = parse_market(&data).unwrap();
905
906 assert_eq!(market.base, "BTC");
908 assert_eq!(market.quote, "USDT");
909 assert_eq!(market.symbol, "BTC/USDT:USDT");
910 }
911
912 #[test]
913 fn test_parse_market() {
914 let data = json!({
915 "instId": "BTC-USDT",
916 "instType": "SPOT",
917 "baseCcy": "BTC",
918 "quoteCcy": "USDT",
919 "state": "live",
920 "tickSz": "0.01",
921 "lotSz": "0.0001",
922 "minSz": "0.0001"
923 });
924
925 let market = parse_market(&data).unwrap();
926 assert_eq!(market.id, "BTC-USDT");
927 assert_eq!(market.symbol, "BTC/USDT");
928 assert_eq!(market.base, "BTC");
929 assert_eq!(market.quote, "USDT");
930 assert!(market.active);
931 assert_eq!(market.market_type, MarketType::Spot);
932 }
933
934 #[test]
935 fn test_parse_ticker() {
936 let data = json!({
937 "instId": "BTC-USDT",
938 "last": "50000.00",
939 "high24h": "51000.00",
940 "low24h": "49000.00",
941 "bidPx": "49999.00",
942 "askPx": "50001.00",
943 "vol24h": "1000.5",
944 "ts": "1700000000000"
945 });
946
947 let ticker = parse_ticker(&data, None).unwrap();
948 assert_eq!(ticker.symbol, "BTC/USDT");
949 assert_eq!(ticker.last, Some(Price::new(dec!(50000.00))));
950 assert_eq!(ticker.high, Some(Price::new(dec!(51000.00))));
951 assert_eq!(ticker.low, Some(Price::new(dec!(49000.00))));
952 assert_eq!(ticker.timestamp, 1700000000000);
953 }
954
955 #[test]
956 fn test_parse_orderbook() {
957 let data = json!({
958 "bids": [
959 ["50000.00", "1.5", "0", "1"],
960 ["49999.00", "2.0", "0", "2"]
961 ],
962 "asks": [
963 ["50001.00", "1.0", "0", "1"],
964 ["50002.00", "3.0", "0", "2"]
965 ],
966 "ts": "1700000000000"
967 });
968
969 let orderbook = parse_orderbook(&data, "BTC/USDT".to_string()).unwrap();
970 assert_eq!(orderbook.symbol, "BTC/USDT");
971 assert_eq!(orderbook.bids.len(), 2);
972 assert_eq!(orderbook.asks.len(), 2);
973 assert_eq!(orderbook.bids[0].price, Price::new(dec!(50000.00)));
974 assert_eq!(orderbook.asks[0].price, Price::new(dec!(50001.00)));
975 }
976
977 #[test]
978 fn test_parse_trade() {
979 let data = json!({
980 "tradeId": "123456",
981 "instId": "BTC-USDT",
982 "side": "buy",
983 "px": "50000.00",
984 "sz": "0.5",
985 "ts": "1700000000000"
986 });
987
988 let trade = parse_trade(&data, None).unwrap();
989 assert_eq!(trade.id, Some("123456".to_string()));
990 assert_eq!(trade.side, OrderSide::Buy);
991 assert_eq!(trade.price, Price::new(dec!(50000.00)));
992 assert_eq!(trade.amount, Amount::new(dec!(0.5)));
993 }
994
995 #[test]
996 fn test_parse_ohlcv() {
997 let data = json!([
998 "1700000000000",
999 "50000.00",
1000 "51000.00",
1001 "49000.00",
1002 "50500.00",
1003 "1000.5"
1004 ]);
1005
1006 let ohlcv = parse_ohlcv(&data).unwrap();
1007 assert_eq!(ohlcv.timestamp, 1700000000000);
1008 assert_eq!(ohlcv.open, 50000.00);
1009 assert_eq!(ohlcv.high, 51000.00);
1010 assert_eq!(ohlcv.low, 49000.00);
1011 assert_eq!(ohlcv.close, 50500.00);
1012 assert_eq!(ohlcv.volume, 1000.5);
1013 }
1014
1015 #[test]
1016 fn test_parse_order_status() {
1017 assert_eq!(parse_order_status("live"), OrderStatus::Open);
1018 assert_eq!(parse_order_status("partially_filled"), OrderStatus::Open);
1019 assert_eq!(parse_order_status("filled"), OrderStatus::Closed);
1020 assert_eq!(parse_order_status("canceled"), OrderStatus::Cancelled);
1021 assert_eq!(parse_order_status("mmp_canceled"), OrderStatus::Cancelled);
1022 assert_eq!(parse_order_status("expired"), OrderStatus::Expired);
1023 assert_eq!(parse_order_status("rejected"), OrderStatus::Rejected);
1024 }
1025
1026 #[test]
1027 fn test_parse_order() {
1028 let data = json!({
1029 "ordId": "123456789",
1030 "instId": "BTC-USDT",
1031 "side": "buy",
1032 "ordType": "limit",
1033 "px": "50000.00",
1034 "sz": "0.5",
1035 "state": "live",
1036 "cTime": "1700000000000"
1037 });
1038
1039 let order = parse_order(&data, None).unwrap();
1040 assert_eq!(order.id, "123456789");
1041 assert_eq!(order.side, OrderSide::Buy);
1042 assert_eq!(order.order_type, OrderType::Limit);
1043 assert_eq!(order.price, Some(dec!(50000.00)));
1044 assert_eq!(order.amount, dec!(0.5));
1045 assert_eq!(order.status, OrderStatus::Open);
1046 }
1047
1048 #[test]
1049 fn test_parse_balance() {
1050 let data = json!({
1051 "details": [
1052 {
1053 "ccy": "BTC",
1054 "availBal": "1.5",
1055 "frozenBal": "0.5",
1056 "eq": "2.0"
1057 },
1058 {
1059 "ccy": "USDT",
1060 "availBal": "10000.00",
1061 "frozenBal": "0",
1062 "eq": "10000.00"
1063 }
1064 ]
1065 });
1066
1067 let balance = parse_balance(&data).unwrap();
1068 let btc = balance.get("BTC").unwrap();
1069 assert_eq!(btc.free, dec!(1.5));
1070 assert_eq!(btc.used, dec!(0.5));
1071 assert_eq!(btc.total, dec!(2.0));
1072
1073 let usdt = balance.get("USDT").unwrap();
1074 assert_eq!(usdt.free, dec!(10000.00));
1075 assert_eq!(usdt.total, dec!(10000.00));
1076 }
1077
1078 #[test]
1079 fn test_timestamp_to_datetime() {
1080 let ts = 1700000000000i64;
1081 let dt = timestamp_to_datetime(ts).unwrap();
1082 assert!(dt.contains("2023-11-14"));
1083 }
1084
1085 #[test]
1090 fn test_parse_position_long() {
1091 let data = json!({
1092 "instId": "BTC-USDT-SWAP",
1093 "posId": "12345",
1094 "posSide": "long",
1095 "pos": "1.5",
1096 "avgPx": "50000.00",
1097 "markPx": "51000.00",
1098 "upl": "1500.00",
1099 "lever": "10",
1100 "liqPx": "45000.00",
1101 "mgnMode": "cross",
1102 "imr": "5000.00",
1103 "mmr": "500.00",
1104 "notionalUsd": "76500.00",
1105 "margin": "5000.00",
1106 "uTime": "1700000000000"
1107 });
1108
1109 let position = parse_position(&data, "BTC/USDT:USDT").unwrap();
1110 assert_eq!(position.symbol, "BTC/USDT:USDT");
1111 assert_eq!(position.side, Some("long".to_string()));
1112 assert_eq!(position.contracts, Some(1.5));
1113 assert_eq!(position.entry_price, Some(50000.00));
1114 assert_eq!(position.mark_price, Some(51000.00));
1115 assert_eq!(position.unrealized_pnl, Some(1500.00));
1116 assert_eq!(position.leverage, Some(10.0));
1117 assert_eq!(position.liquidation_price, Some(45000.00));
1118 assert_eq!(position.margin_mode, Some("cross".to_string()));
1119 assert_eq!(position.initial_margin, Some(5000.00));
1120 assert_eq!(position.maintenance_margin, Some(500.00));
1121 assert_eq!(position.hedged, Some(true));
1122 }
1123
1124 #[test]
1125 fn test_parse_position_short() {
1126 let data = json!({
1127 "instId": "ETH-USDT-SWAP",
1128 "posSide": "short",
1129 "pos": "-10",
1130 "avgPx": "3000.00",
1131 "markPx": "2900.00",
1132 "upl": "1000.00",
1133 "lever": "5",
1134 "mgnMode": "isolated",
1135 "uTime": "1700000000000"
1136 });
1137
1138 let position = parse_position(&data, "ETH/USDT:USDT").unwrap();
1139 assert_eq!(position.side, Some("short".to_string()));
1140 assert_eq!(position.contracts, Some(10.0));
1141 assert_eq!(position.margin_mode, Some("isolated".to_string()));
1142 assert_eq!(position.hedged, Some(true));
1143 }
1144
1145 #[test]
1146 fn test_parse_position_net_mode() {
1147 let data = json!({
1148 "instId": "BTC-USDT-SWAP",
1149 "posSide": "net",
1150 "pos": "2",
1151 "avgPx": "50000.00",
1152 "lever": "10",
1153 "mgnMode": "cross",
1154 "uTime": "1700000000000"
1155 });
1156
1157 let position = parse_position(&data, "BTC/USDT:USDT").unwrap();
1158 assert_eq!(position.side, Some("long".to_string()));
1159 assert_eq!(position.contracts, Some(2.0));
1160 assert_eq!(position.hedged, Some(false));
1161 }
1162
1163 #[test]
1164 fn test_parse_position_empty_fields() {
1165 let data = json!({
1166 "instId": "BTC-USDT-SWAP",
1167 "posSide": "net",
1168 "pos": "0",
1169 "avgPx": "",
1170 "markPx": "",
1171 "upl": "",
1172 "lever": "10",
1173 "mgnMode": "cross"
1174 });
1175
1176 let position = parse_position(&data, "BTC/USDT:USDT").unwrap();
1177 assert_eq!(position.contracts, Some(0.0));
1178 assert_eq!(position.entry_price, None);
1179 assert_eq!(position.mark_price, None);
1180 assert_eq!(position.unrealized_pnl, None);
1181 }
1182
1183 #[test]
1188 fn test_parse_funding_rate() {
1189 let data = json!({
1190 "instId": "BTC-USDT-SWAP",
1191 "fundingRate": "0.0001",
1192 "fundingTime": "1700000000000",
1193 "nextFundingRate": "0.00015",
1194 "nextFundingTime": "1700028800000"
1195 });
1196
1197 let rate = parse_funding_rate(&data, "BTC/USDT:USDT").unwrap();
1198 assert_eq!(rate.symbol, "BTC/USDT:USDT");
1199 assert_eq!(rate.funding_rate, Some(0.0001));
1200 assert_eq!(rate.funding_timestamp, Some(1700028800000));
1201 assert_eq!(rate.timestamp, Some(1700000000000));
1202 }
1203
1204 #[test]
1205 fn test_parse_funding_rate_history() {
1206 let data = json!({
1207 "instId": "BTC-USDT-SWAP",
1208 "fundingRate": "0.0001",
1209 "fundingTime": "1700000000000",
1210 "realizedRate": "0.00009"
1211 });
1212
1213 let history = parse_funding_rate_history(&data, "BTC/USDT:USDT").unwrap();
1214 assert_eq!(history.symbol, "BTC/USDT:USDT");
1215 assert_eq!(history.funding_rate, Some(0.0001));
1216 assert_eq!(history.timestamp, Some(1700000000000));
1217 }
1218
1219 #[test]
1220 fn test_parse_f64_field() {
1221 let data = json!({
1222 "a": "123.45",
1223 "b": "",
1224 "c": 67.89,
1225 "d": null
1226 });
1227
1228 assert_eq!(parse_f64_field(&data, "a"), Some(123.45));
1229 assert_eq!(parse_f64_field(&data, "b"), None);
1230 assert_eq!(parse_f64_field(&data, "c"), Some(67.89));
1231 assert_eq!(parse_f64_field(&data, "d"), None);
1232 assert_eq!(parse_f64_field(&data, "missing"), None);
1233 }
1234}