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