1use ccxt_core::{
6 Result,
7 error::{Error, ParseError},
8 types::{
9 Balance, BalanceEntry, Market, MarketLimits, MarketPrecision, MarketType, MinMax, Order,
10 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
19pub fn 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
36pub fn 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
44pub fn timestamp_to_datetime(timestamp: i64) -> Option<String> {
46 chrono::DateTime::from_timestamp_millis(timestamp)
47 .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string())
48}
49
50fn value_to_hashmap(data: &Value) -> HashMap<String, Value> {
52 data.as_object()
53 .map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
54 .unwrap_or_default()
55}
56
57pub fn parse_market(data: &Value, index: usize) -> Result<Market> {
63 let name = data["name"]
64 .as_str()
65 .ok_or_else(|| Error::from(ParseError::missing_field("name")))?;
66
67 let symbol = format!("{}/USDC:USDC", name);
70 let id = index.to_string();
71
72 let sz_decimals = data["szDecimals"].as_u64().unwrap_or(4) as u32;
74 let amount_precision = Decimal::new(1, sz_decimals);
75
76 let price_precision = Decimal::new(1, 5);
78
79 let parsed_symbol = ccxt_core::symbol::SymbolParser::parse(&symbol).ok();
81
82 Ok(Market {
83 id,
84 symbol: symbol.clone(),
85 parsed_symbol,
86 base: name.to_string(),
87 quote: "USDC".to_string(),
88 settle: Some("USDC".to_string()),
89 base_id: Some(name.to_string()),
90 quote_id: Some("USDC".to_string()),
91 settle_id: Some("USDC".to_string()),
92 market_type: MarketType::Swap,
93 active: true,
94 margin: true,
95 contract: Some(true),
96 linear: Some(true),
97 inverse: Some(false),
98 contract_size: Some(Decimal::ONE),
99 expiry: None,
100 expiry_datetime: None,
101 strike: None,
102 option_type: None,
103 precision: MarketPrecision {
104 price: Some(price_precision),
105 amount: Some(amount_precision),
106 base: None,
107 quote: None,
108 },
109 limits: MarketLimits {
110 amount: Some(MinMax {
111 min: Some(amount_precision),
112 max: None,
113 }),
114 price: None,
115 cost: Some(MinMax {
116 min: Some(Decimal::new(10, 0)), max: None,
118 }),
119 leverage: Some(MinMax {
120 min: Some(Decimal::ONE),
121 max: Some(Decimal::new(50, 0)),
122 }),
123 },
124 maker: Some(Decimal::new(2, 4)), taker: Some(Decimal::new(5, 4)), percentage: Some(true),
127 tier_based: Some(true),
128 fee_side: Some("quote".to_string()),
129 info: value_to_hashmap(data),
130 })
131}
132
133pub fn parse_ticker(symbol: &str, mid_price: Decimal, _market: Option<&Market>) -> Result<Ticker> {
139 let timestamp = chrono::Utc::now().timestamp_millis();
140
141 Ok(Ticker {
142 symbol: symbol.to_string(),
143 timestamp,
144 datetime: timestamp_to_datetime(timestamp),
145 high: None,
146 low: None,
147 bid: Some(Price::new(mid_price)),
148 bid_volume: None,
149 ask: Some(Price::new(mid_price)),
150 ask_volume: None,
151 vwap: None,
152 open: None,
153 close: Some(Price::new(mid_price)),
154 last: Some(Price::new(mid_price)),
155 previous_close: None,
156 change: None,
157 percentage: None,
158 average: None,
159 base_volume: None,
160 quote_volume: None,
161 info: HashMap::new(),
162 })
163}
164
165pub fn parse_orderbook(data: &Value, symbol: String) -> Result<OrderBook> {
171 let timestamp = chrono::Utc::now().timestamp_millis();
172
173 let mut bids = Vec::new();
174 let mut asks = Vec::new();
175
176 if let Some(levels) = data["levels"].as_array() {
178 if levels.len() >= 2 {
179 if let Some(bid_levels) = levels[0].as_array() {
181 for level in bid_levels {
182 if let (Some(px), Some(sz)) = (
183 level["px"].as_str().and_then(|s| Decimal::from_str(s).ok()),
184 level["sz"].as_str().and_then(|s| Decimal::from_str(s).ok()),
185 ) {
186 bids.push(OrderBookEntry {
187 price: Price::new(px),
188 amount: Amount::new(sz),
189 });
190 }
191 }
192 }
193
194 if let Some(ask_levels) = levels[1].as_array() {
195 for level in ask_levels {
196 if let (Some(px), Some(sz)) = (
197 level["px"].as_str().and_then(|s| Decimal::from_str(s).ok()),
198 level["sz"].as_str().and_then(|s| Decimal::from_str(s).ok()),
199 ) {
200 asks.push(OrderBookEntry {
201 price: Price::new(px),
202 amount: Amount::new(sz),
203 });
204 }
205 }
206 }
207 }
208 }
209
210 bids.sort_by(|a, b| b.price.cmp(&a.price));
212 asks.sort_by(|a, b| a.price.cmp(&b.price));
213
214 Ok(OrderBook {
215 symbol,
216 timestamp,
217 datetime: timestamp_to_datetime(timestamp),
218 nonce: None,
219 bids,
220 asks,
221 buffered_deltas: std::collections::VecDeque::new(),
222 bids_map: std::collections::BTreeMap::new(),
223 asks_map: std::collections::BTreeMap::new(),
224 is_synced: false,
225 needs_resync: false,
226 last_resync_time: 0,
227 info: value_to_hashmap(data),
228 })
229}
230
231pub fn parse_trade(data: &Value, market: Option<&Market>) -> Result<Trade> {
237 let symbol = market
238 .map(|m| m.symbol.clone())
239 .unwrap_or_else(|| data["coin"].as_str().unwrap_or("").to_string());
240
241 let timestamp = parse_timestamp(data, "time").unwrap_or(0);
242
243 let side = match data["side"].as_str() {
244 Some("B") | Some("buy") | Some("Buy") => OrderSide::Buy,
245 Some("A") | Some("sell") | Some("Sell") => OrderSide::Sell,
246 _ => OrderSide::Buy,
247 };
248
249 let price = parse_decimal(data, "px").unwrap_or(Decimal::ZERO);
250 let amount = parse_decimal(data, "sz").unwrap_or(Decimal::ZERO);
251 let cost = price * amount;
252
253 Ok(Trade {
254 id: data["tid"]
255 .as_str()
256 .or(data["hash"].as_str())
257 .map(|s| s.to_string()),
258 order: data["oid"].as_str().map(|s| s.to_string()),
259 timestamp,
260 datetime: timestamp_to_datetime(timestamp),
261 symbol,
262 trade_type: None,
263 side,
264 taker_or_maker: None,
265 price: Price::new(price),
266 amount: Amount::new(amount),
267 cost: Some(Cost::new(cost)),
268 fee: None,
269 info: value_to_hashmap(data),
270 })
271}
272
273pub fn parse_order_status(status: &str) -> OrderStatus {
279 match status.to_lowercase().as_str() {
280 "open" | "resting" => OrderStatus::Open,
281 "filled" => OrderStatus::Closed,
282 "canceled" | "cancelled" => OrderStatus::Cancelled,
283 "rejected" => OrderStatus::Rejected,
284 _ => OrderStatus::Open,
285 }
286}
287
288pub fn parse_order(data: &Value, market: Option<&Market>) -> Result<Order> {
290 let symbol = market.map(|m| m.symbol.clone()).unwrap_or_else(|| {
291 data["coin"]
292 .as_str()
293 .map(|c| format!("{}/USDC:USDC", c))
294 .unwrap_or_default()
295 });
296
297 let id = data["oid"]
298 .as_u64()
299 .map(|n| n.to_string())
300 .or_else(|| data["oid"].as_str().map(|s| s.to_string()))
301 .unwrap_or_default();
302
303 let timestamp = parse_timestamp(data, "timestamp");
304
305 let status_str = data["status"].as_str().unwrap_or("open");
306 let status = parse_order_status(status_str);
307
308 let side = match data["side"].as_str() {
309 Some("B") | Some("buy") => OrderSide::Buy,
310 Some("A") | Some("sell") => OrderSide::Sell,
311 _ => {
312 if data["isBuy"].as_bool().unwrap_or(true) {
314 OrderSide::Buy
315 } else {
316 OrderSide::Sell
317 }
318 }
319 };
320
321 let order_type = match data["orderType"].as_str() {
322 Some("Limit") | Some("limit") => OrderType::Limit,
323 Some("Market") | Some("market") => OrderType::Market,
324 _ => OrderType::Limit,
325 };
326
327 let price = parse_decimal(data, "limitPx").or_else(|| parse_decimal(data, "px"));
328 let amount = parse_decimal(data, "sz")
329 .or_else(|| parse_decimal(data, "origSz"))
330 .unwrap_or(Decimal::ZERO);
331 let filled = parse_decimal(data, "filledSz");
332 let remaining = filled.map(|f| amount - f);
333
334 Ok(Order {
335 id,
336 client_order_id: data["cloid"].as_str().map(|s| s.to_string()),
337 timestamp,
338 datetime: timestamp.and_then(timestamp_to_datetime),
339 last_trade_timestamp: None,
340 status,
341 symbol,
342 order_type,
343 time_in_force: data["tif"].as_str().map(|s| s.to_uppercase()),
344 side,
345 price,
346 average: parse_decimal(data, "avgPx"),
347 amount,
348 filled,
349 remaining,
350 cost: None,
351 trades: None,
352 fee: None,
353 post_only: None,
354 reduce_only: data["reduceOnly"].as_bool(),
355 trigger_price: parse_decimal(data, "triggerPx"),
356 stop_price: None,
357 take_profit_price: None,
358 stop_loss_price: None,
359 trailing_delta: None,
360 trailing_percent: None,
361 activation_price: None,
362 callback_rate: None,
363 working_type: None,
364 fees: Some(Vec::new()),
365 info: value_to_hashmap(data),
366 })
367}
368
369pub fn parse_ohlcv(data: &Value) -> Result<ccxt_core::types::Ohlcv> {
377 if let Some(arr) = data.as_array() {
379 if arr.len() >= 6 {
380 let timestamp = arr[0]
381 .as_i64()
382 .or_else(|| arr[0].as_str().and_then(|s| s.parse().ok()))
383 .ok_or_else(|| Error::from(ParseError::missing_field("timestamp")))?;
384
385 let open = parse_decimal_from_value(&arr[1])
386 .ok_or_else(|| Error::from(ParseError::missing_field("open")))?;
387 let high = parse_decimal_from_value(&arr[2])
388 .ok_or_else(|| Error::from(ParseError::missing_field("high")))?;
389 let low = parse_decimal_from_value(&arr[3])
390 .ok_or_else(|| Error::from(ParseError::missing_field("low")))?;
391 let close = parse_decimal_from_value(&arr[4])
392 .ok_or_else(|| Error::from(ParseError::missing_field("close")))?;
393 let volume = parse_decimal_from_value(&arr[5])
394 .ok_or_else(|| Error::from(ParseError::missing_field("volume")))?;
395
396 return Ok(ccxt_core::types::Ohlcv {
397 timestamp,
398 open: ccxt_core::types::financial::Price::new(open),
399 high: ccxt_core::types::financial::Price::new(high),
400 low: ccxt_core::types::financial::Price::new(low),
401 close: ccxt_core::types::financial::Price::new(close),
402 volume: ccxt_core::types::financial::Amount::new(volume),
403 });
404 }
405 }
406
407 let timestamp = parse_timestamp(data, "t")
409 .or_else(|| parse_timestamp(data, "timestamp"))
410 .ok_or_else(|| Error::from(ParseError::missing_field("timestamp")))?;
411
412 let open = parse_decimal(data, "o")
413 .or_else(|| parse_decimal(data, "open"))
414 .ok_or_else(|| Error::from(ParseError::missing_field("open")))?;
415 let high = parse_decimal(data, "h")
416 .or_else(|| parse_decimal(data, "high"))
417 .ok_or_else(|| Error::from(ParseError::missing_field("high")))?;
418 let low = parse_decimal(data, "l")
419 .or_else(|| parse_decimal(data, "low"))
420 .ok_or_else(|| Error::from(ParseError::missing_field("low")))?;
421 let close = parse_decimal(data, "c")
422 .or_else(|| parse_decimal(data, "close"))
423 .ok_or_else(|| Error::from(ParseError::missing_field("close")))?;
424 let volume = parse_decimal(data, "v")
425 .or_else(|| parse_decimal(data, "volume"))
426 .ok_or_else(|| Error::from(ParseError::missing_field("volume")))?;
427
428 Ok(ccxt_core::types::Ohlcv {
429 timestamp,
430 open: ccxt_core::types::financial::Price::new(open),
431 high: ccxt_core::types::financial::Price::new(high),
432 low: ccxt_core::types::financial::Price::new(low),
433 close: ccxt_core::types::financial::Price::new(close),
434 volume: ccxt_core::types::financial::Amount::new(volume),
435 })
436}
437
438fn parse_decimal_from_value(v: &Value) -> Option<Decimal> {
440 if let Some(num) = v.as_f64() {
441 Decimal::from_f64(num)
442 } else if let Some(s) = v.as_str() {
443 Decimal::from_str(s).ok()
444 } else {
445 None
446 }
447}
448
449pub fn parse_balance(data: &Value) -> Result<Balance> {
455 let mut balances = HashMap::new();
456
457 if let Some(margin) = data.get("marginSummary") {
459 let account_value = parse_decimal(margin, "accountValue").unwrap_or(Decimal::ZERO);
460 let total_margin_used = parse_decimal(margin, "totalMarginUsed").unwrap_or(Decimal::ZERO);
461 let available = account_value - total_margin_used;
462
463 balances.insert(
464 "USDC".to_string(),
465 BalanceEntry {
466 free: available,
467 used: total_margin_used,
468 total: account_value,
469 },
470 );
471 }
472
473 if let Some(withdrawable) = data.get("withdrawable") {
475 if let Some(w) = withdrawable
476 .as_str()
477 .and_then(|s| Decimal::from_str(s).ok())
478 {
479 if let Some(entry) = balances.get_mut("USDC") {
480 entry.free = w;
481 }
482 }
483 }
484
485 Ok(Balance {
486 balances,
487 info: value_to_hashmap(data),
488 })
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use rust_decimal_macros::dec;
495 use serde_json::json;
496
497 #[test]
498 fn test_parse_market() {
499 let data = json!({
500 "name": "BTC",
501 "szDecimals": 4
502 });
503
504 let market = parse_market(&data, 0).unwrap();
505 assert_eq!(market.symbol, "BTC/USDC:USDC");
506 assert_eq!(market.base, "BTC");
507 assert_eq!(market.quote, "USDC");
508 assert!(market.active);
509 }
510
511 #[test]
512 fn test_parse_ticker() {
513 let ticker = parse_ticker("BTC/USDC:USDC", dec!(50000), None).unwrap();
514 assert_eq!(ticker.symbol, "BTC/USDC:USDC");
515 assert_eq!(ticker.last, Some(Price::new(dec!(50000))));
516 }
517
518 #[test]
519 fn test_parse_orderbook() {
520 let data = json!({
521 "levels": [
522 [{"px": "50000", "sz": "1.5"}, {"px": "49999", "sz": "2.0"}],
523 [{"px": "50001", "sz": "1.0"}, {"px": "50002", "sz": "3.0"}]
524 ]
525 });
526
527 let orderbook = parse_orderbook(&data, "BTC/USDC:USDC".to_string()).unwrap();
528 assert_eq!(orderbook.bids.len(), 2);
529 assert_eq!(orderbook.asks.len(), 2);
530 assert!(orderbook.bids[0].price >= orderbook.bids[1].price);
532 assert!(orderbook.asks[0].price <= orderbook.asks[1].price);
534 }
535
536 #[test]
537 fn test_parse_trade() {
538 let data = json!({
539 "coin": "BTC",
540 "side": "B",
541 "px": "50000",
542 "sz": "0.5",
543 "time": 1700000000000i64,
544 "tid": "123456"
545 });
546
547 let trade = parse_trade(&data, None).unwrap();
548 assert_eq!(trade.side, OrderSide::Buy);
549 assert_eq!(trade.price, Price::new(dec!(50000)));
550 assert_eq!(trade.amount, Amount::new(dec!(0.5)));
551 }
552
553 #[test]
554 fn test_parse_order_status() {
555 assert_eq!(parse_order_status("open"), OrderStatus::Open);
556 assert_eq!(parse_order_status("filled"), OrderStatus::Closed);
557 assert_eq!(parse_order_status("canceled"), OrderStatus::Cancelled);
558 }
559
560 #[test]
561 fn test_parse_balance() {
562 let data = json!({
563 "marginSummary": {
564 "accountValue": "10000",
565 "totalMarginUsed": "2000"
566 },
567 "withdrawable": "8000"
568 });
569
570 let balance = parse_balance(&data).unwrap();
571 let usdc = balance.get("USDC").unwrap();
572 assert_eq!(usdc.total, dec!(10000));
573 assert_eq!(usdc.free, dec!(8000));
574 }
575}