ccxt_exchanges/bybit/
parser.rs

1//! Bybit data parser module.
2//!
3//! Converts Bybit API response data into standardized CCXT format structures.
4
5use ccxt_core::{
6    Result,
7    error::{Error, ParseError},
8    types::{
9        Balance, BalanceEntry, Market, MarketLimits, MarketPrecision, MarketType, MinMax, OHLCV,
10        Order, 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
19// ============================================================================
20// Helper Functions - Type Conversion
21// ============================================================================
22
23/// Parse a `Decimal` value from JSON (supports both string and number formats).
24fn 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            if s.is_empty() {
30                None
31            } else {
32                Decimal::from_str(s).ok()
33            }
34        } else {
35            None
36        }
37    })
38}
39
40/// Parse a timestamp from JSON (supports both string and number formats).
41fn parse_timestamp(data: &Value, key: &str) -> Option<i64> {
42    data.get(key).and_then(|v| {
43        v.as_i64()
44            .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
45    })
46}
47
48/// Convert a JSON `Value` into a `HashMap<String, Value>`.
49fn value_to_hashmap(data: &Value) -> HashMap<String, Value> {
50    data.as_object()
51        .map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
52        .unwrap_or_default()
53}
54
55/// Convert millisecond timestamp to ISO8601 datetime string.
56pub fn timestamp_to_datetime(timestamp: i64) -> Option<String> {
57    chrono::DateTime::from_timestamp_millis(timestamp)
58        .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string())
59}
60
61/// Convert ISO8601 datetime string to millisecond timestamp.
62pub fn datetime_to_timestamp(datetime: &str) -> Option<i64> {
63    chrono::DateTime::parse_from_rfc3339(datetime)
64        .ok()
65        .map(|dt| dt.timestamp_millis())
66}
67
68// ============================================================================
69// Market Data Parser Functions
70// ============================================================================
71
72/// Parse market data from Bybit exchange info.
73///
74/// Bybit uses `symbol` for instrument ID (e.g., "BTCUSDT").
75///
76/// # Arguments
77///
78/// * `data` - Bybit market data JSON object
79///
80/// # Returns
81///
82/// Returns a CCXT [`Market`] structure.
83pub fn parse_market(data: &Value) -> Result<Market> {
84    // Bybit uses "symbol" for the exchange-specific ID (e.g., "BTCUSDT")
85    let id = data["symbol"]
86        .as_str()
87        .ok_or_else(|| Error::from(ParseError::missing_field("symbol")))?
88        .to_string();
89
90    // Base and quote currencies
91    let base = data["baseCoin"]
92        .as_str()
93        .ok_or_else(|| Error::from(ParseError::missing_field("baseCoin")))?
94        .to_string();
95
96    let quote = data["quoteCoin"]
97        .as_str()
98        .ok_or_else(|| Error::from(ParseError::missing_field("quoteCoin")))?
99        .to_string();
100
101    // Contract type - Bybit uses "contractType" for derivatives
102    let contract_type = data["contractType"].as_str();
103    let market_type = match contract_type {
104        Some("LinearPerpetual" | "InversePerpetual") => MarketType::Swap,
105        Some("LinearFutures" | "InverseFutures") => MarketType::Futures,
106        _ => MarketType::Spot,
107    };
108
109    // Market status
110    let status = data["status"].as_str().unwrap_or("Trading");
111    let active = status == "Trading";
112
113    // Parse precision - Bybit uses tickSize and basePrecision
114    let price_precision = parse_decimal(data, "priceFilter").or_else(|| {
115        data.get("priceFilter")
116            .and_then(|pf| parse_decimal(pf, "tickSize"))
117    });
118    let amount_precision = parse_decimal(data, "lotSizeFilter").or_else(|| {
119        data.get("lotSizeFilter")
120            .and_then(|lf| parse_decimal(lf, "basePrecision"))
121    });
122
123    // Parse limits from lotSizeFilter
124    let (min_amount, max_amount) = if let Some(lot_filter) = data.get("lotSizeFilter") {
125        (
126            parse_decimal(lot_filter, "minOrderQty"),
127            parse_decimal(lot_filter, "maxOrderQty"),
128        )
129    } else {
130        (None, None)
131    };
132
133    // Contract-specific fields
134    let contract = market_type != MarketType::Spot;
135    let linear = if contract {
136        Some(contract_type == Some("LinearPerpetual") || contract_type == Some("LinearFutures"))
137    } else {
138        None
139    };
140    let inverse = if contract {
141        Some(contract_type == Some("InversePerpetual") || contract_type == Some("InverseFutures"))
142    } else {
143        None
144    };
145    let contract_size = parse_decimal(data, "contractSize");
146
147    // Settlement currency for derivatives
148    let settle = data["settleCoin"].as_str().map(ToString::to_string);
149    let settle_id = settle.clone();
150
151    // Expiry for futures
152    let expiry = parse_timestamp(data, "deliveryTime");
153    let expiry_datetime = expiry.and_then(timestamp_to_datetime);
154
155    // Build unified symbol format based on market type:
156    // - Spot: BASE/QUOTE (e.g., "BTC/USDT")
157    // - Swap: BASE/QUOTE:SETTLE (e.g., "BTC/USDT:USDT")
158    // - Futures: BASE/QUOTE:SETTLE-YYMMDD (e.g., "BTC/USDT:USDT-241231")
159    let symbol = match market_type {
160        MarketType::Swap => {
161            if let Some(ref s) = settle {
162                format!("{}/{}:{}", base, quote, s)
163            } else if linear == Some(true) {
164                // Linear swaps settle in quote currency
165                format!("{}/{}:{}", base, quote, quote)
166            } else {
167                // Inverse swaps settle in base currency
168                format!("{}/{}:{}", base, quote, base)
169            }
170        }
171        MarketType::Futures => {
172            let settle_ccy = settle.clone().unwrap_or_else(|| {
173                if linear == Some(true) {
174                    quote.clone()
175                } else {
176                    base.clone()
177                }
178            });
179            if let Some(exp_ts) = expiry {
180                // Convert timestamp to YYMMDD format
181                if let Some(dt) = chrono::DateTime::from_timestamp_millis(exp_ts) {
182                    let year = (dt.format("%y").to_string().parse::<u8>()).unwrap_or(0);
183                    let month = (dt.format("%m").to_string().parse::<u8>()).unwrap_or(1);
184                    let day = (dt.format("%d").to_string().parse::<u8>()).unwrap_or(1);
185                    format!(
186                        "{}/{}:{}-{:02}{:02}{:02}",
187                        base, quote, settle_ccy, year, month, day
188                    )
189                } else {
190                    format!("{}/{}:{}", base, quote, settle_ccy)
191                }
192            } else {
193                format!("{}/{}:{}", base, quote, settle_ccy)
194            }
195        }
196        _ => format!("{}/{}", base, quote), // Spot and Option
197    };
198
199    // Parse the symbol to get structured representation
200    let parsed_symbol = ccxt_core::symbol::SymbolParser::parse(&symbol).ok();
201
202    Ok(Market {
203        id,
204        symbol,
205        parsed_symbol,
206        base: base.clone(),
207        quote: quote.clone(),
208        settle,
209        base_id: Some(base),
210        quote_id: Some(quote),
211        settle_id,
212        market_type,
213        active,
214        margin: contract,
215        contract: Some(contract),
216        linear,
217        inverse,
218        contract_size,
219        expiry,
220        expiry_datetime,
221        strike: None,
222        option_type: None,
223        precision: MarketPrecision {
224            price: price_precision,
225            amount: amount_precision,
226            base: None,
227            quote: None,
228        },
229        limits: MarketLimits {
230            amount: Some(MinMax {
231                min: min_amount,
232                max: max_amount,
233            }),
234            price: None,
235            cost: None,
236            leverage: None,
237        },
238        maker: parse_decimal(data, "makerFeeRate"),
239        taker: parse_decimal(data, "takerFeeRate"),
240        percentage: Some(true),
241        tier_based: Some(false),
242        fee_side: Some("quote".to_string()),
243        info: value_to_hashmap(data),
244    })
245}
246
247/// Parse ticker data from Bybit ticker response.
248///
249/// # Arguments
250///
251/// * `data` - Bybit ticker data JSON object
252/// * `market` - Optional market information for symbol resolution
253///
254/// # Returns
255///
256/// Returns a CCXT [`Ticker`] structure.
257pub fn parse_ticker(data: &Value, market: Option<&Market>) -> Result<Ticker> {
258    let symbol = if let Some(m) = market {
259        m.symbol.clone()
260    } else {
261        // Try to construct symbol from symbol field
262        // Bybit uses concatenated format like "BTCUSDT"
263        data["symbol"]
264            .as_str()
265            .map(ToString::to_string)
266            .unwrap_or_default()
267    };
268
269    // Bybit uses different timestamp fields
270    let timestamp = parse_timestamp(data, "time")
271        .or_else(|| parse_timestamp(data, "timestamp"))
272        .unwrap_or(0);
273
274    Ok(Ticker {
275        symbol,
276        timestamp,
277        datetime: timestamp_to_datetime(timestamp),
278        high: parse_decimal(data, "highPrice24h").map(Price::new),
279        low: parse_decimal(data, "lowPrice24h").map(Price::new),
280        bid: parse_decimal(data, "bid1Price").map(Price::new),
281        bid_volume: parse_decimal(data, "bid1Size").map(Amount::new),
282        ask: parse_decimal(data, "ask1Price").map(Price::new),
283        ask_volume: parse_decimal(data, "ask1Size").map(Amount::new),
284        vwap: None,
285        open: parse_decimal(data, "prevPrice24h").map(Price::new),
286        close: parse_decimal(data, "lastPrice").map(Price::new),
287        last: parse_decimal(data, "lastPrice").map(Price::new),
288        previous_close: parse_decimal(data, "prevPrice24h").map(Price::new),
289        change: parse_decimal(data, "price24hPcnt")
290            .and_then(|pct| parse_decimal(data, "prevPrice24h").map(|prev| Price::new(prev * pct))),
291        percentage: parse_decimal(data, "price24hPcnt").map(|p| p * Decimal::from(100)),
292        average: None,
293        base_volume: parse_decimal(data, "volume24h").map(Amount::new),
294        quote_volume: parse_decimal(data, "turnover24h").map(Amount::new),
295        info: value_to_hashmap(data),
296    })
297}
298
299/// Parse orderbook data from Bybit depth response.
300///
301/// # Arguments
302///
303/// * `data` - Bybit orderbook data JSON object
304/// * `symbol` - Trading pair symbol
305///
306/// # Returns
307///
308/// Returns a CCXT [`OrderBook`] structure with bids sorted in descending order
309/// and asks sorted in ascending order.
310pub fn parse_orderbook(data: &Value, symbol: String) -> Result<OrderBook> {
311    let timestamp = parse_timestamp(data, "ts")
312        .or_else(|| parse_timestamp(data, "time"))
313        .unwrap_or_else(|| chrono::Utc::now().timestamp_millis());
314
315    let mut bids = parse_orderbook_side(&data["b"])?;
316    let mut asks = parse_orderbook_side(&data["a"])?;
317
318    // Sort bids in descending order (highest price first)
319    bids.sort_by(|a, b| b.price.cmp(&a.price));
320
321    // Sort asks in ascending order (lowest price first)
322    asks.sort_by(|a, b| a.price.cmp(&b.price));
323
324    Ok(OrderBook {
325        symbol,
326        timestamp,
327        datetime: timestamp_to_datetime(timestamp),
328        nonce: None,
329        bids,
330        asks,
331        buffered_deltas: std::collections::VecDeque::new(),
332        bids_map: std::collections::BTreeMap::new(),
333        asks_map: std::collections::BTreeMap::new(),
334        is_synced: false,
335        needs_resync: false,
336        last_resync_time: 0,
337        info: value_to_hashmap(data),
338    })
339}
340
341/// Parse one side (bids or asks) of orderbook data.
342fn parse_orderbook_side(data: &Value) -> Result<Vec<OrderBookEntry>> {
343    let Some(array) = data.as_array() else {
344        return Ok(Vec::new());
345    };
346
347    let mut result = Vec::new();
348
349    for item in array {
350        if let Some(arr) = item.as_array() {
351            // Bybit format: [price, size]
352            if arr.len() >= 2 {
353                let price = arr[0]
354                    .as_str()
355                    .and_then(|s| Decimal::from_str(s).ok())
356                    .or_else(|| arr[0].as_f64().and_then(Decimal::from_f64))
357                    .ok_or_else(|| Error::from(ParseError::invalid_value("data", "price")))?;
358
359                let amount = arr[1]
360                    .as_str()
361                    .and_then(|s| Decimal::from_str(s).ok())
362                    .or_else(|| arr[1].as_f64().and_then(Decimal::from_f64))
363                    .ok_or_else(|| Error::from(ParseError::invalid_value("data", "amount")))?;
364
365                result.push(OrderBookEntry {
366                    price: Price::new(price),
367                    amount: Amount::new(amount),
368                });
369            }
370        }
371    }
372
373    Ok(result)
374}
375
376/// Parse trade data from Bybit trade response.
377///
378/// # Arguments
379///
380/// * `data` - Bybit trade data JSON object
381/// * `market` - Optional market information for symbol resolution
382///
383/// # Returns
384///
385/// Returns a CCXT [`Trade`] structure.
386pub fn parse_trade(data: &Value, market: Option<&Market>) -> Result<Trade> {
387    let symbol = if let Some(m) = market {
388        m.symbol.clone()
389    } else {
390        data["symbol"]
391            .as_str()
392            .map(ToString::to_string)
393            .unwrap_or_default()
394    };
395
396    let id = data["execId"]
397        .as_str()
398        .or_else(|| data["id"].as_str())
399        .map(ToString::to_string);
400
401    let timestamp = parse_timestamp(data, "time")
402        .or_else(|| parse_timestamp(data, "T"))
403        .unwrap_or(0);
404
405    // Bybit uses "side" field with "Buy" or "Sell" values
406    let side = match data["side"].as_str() {
407        Some("Sell" | "sell" | "SELL") => OrderSide::Sell,
408        _ => OrderSide::Buy, // Default to buy if not specified
409    };
410
411    let price = parse_decimal(data, "price").or_else(|| parse_decimal(data, "execPrice"));
412    let amount = parse_decimal(data, "size")
413        .or_else(|| parse_decimal(data, "execQty"))
414        .or_else(|| parse_decimal(data, "qty"));
415
416    let cost = match (price, amount) {
417        (Some(p), Some(a)) => Some(p * a),
418        _ => None,
419    };
420
421    Ok(Trade {
422        id,
423        order: data["orderId"].as_str().map(ToString::to_string),
424        timestamp,
425        datetime: timestamp_to_datetime(timestamp),
426        symbol,
427        trade_type: None,
428        side,
429        taker_or_maker: None,
430        price: Price::new(price.unwrap_or(Decimal::ZERO)),
431        amount: Amount::new(amount.unwrap_or(Decimal::ZERO)),
432        cost: cost.map(Cost::new),
433        fee: None,
434        info: value_to_hashmap(data),
435    })
436}
437
438/// Parse OHLCV (candlestick) data from Bybit kline response.
439///
440/// # Arguments
441///
442/// * `data` - Bybit OHLCV data JSON array or object
443///
444/// # Returns
445///
446/// Returns a CCXT [`OHLCV`] structure.
447pub fn parse_ohlcv(data: &Value) -> Result<OHLCV> {
448    // Bybit returns OHLCV as array: [startTime, openPrice, highPrice, lowPrice, closePrice, volume, turnover]
449    // or as object with named fields
450    if let Some(arr) = data.as_array() {
451        if arr.len() < 6 {
452            return Err(Error::from(ParseError::invalid_format(
453                "data",
454                "OHLCV array with at least 6 elements",
455            )));
456        }
457
458        let timestamp = arr[0]
459            .as_str()
460            .and_then(|s| s.parse::<i64>().ok())
461            .or_else(|| arr[0].as_i64())
462            .ok_or_else(|| Error::from(ParseError::invalid_value("data", "timestamp")))?;
463
464        let open = arr[1]
465            .as_str()
466            .and_then(|s| s.parse::<f64>().ok())
467            .or_else(|| arr[1].as_f64())
468            .ok_or_else(|| Error::from(ParseError::invalid_value("data", "open")))?;
469
470        let high = arr[2]
471            .as_str()
472            .and_then(|s| s.parse::<f64>().ok())
473            .or_else(|| arr[2].as_f64())
474            .ok_or_else(|| Error::from(ParseError::invalid_value("data", "high")))?;
475
476        let low = arr[3]
477            .as_str()
478            .and_then(|s| s.parse::<f64>().ok())
479            .or_else(|| arr[3].as_f64())
480            .ok_or_else(|| Error::from(ParseError::invalid_value("data", "low")))?;
481
482        let close = arr[4]
483            .as_str()
484            .and_then(|s| s.parse::<f64>().ok())
485            .or_else(|| arr[4].as_f64())
486            .ok_or_else(|| Error::from(ParseError::invalid_value("data", "close")))?;
487
488        let volume = arr[5]
489            .as_str()
490            .and_then(|s| s.parse::<f64>().ok())
491            .or_else(|| arr[5].as_f64())
492            .ok_or_else(|| Error::from(ParseError::invalid_value("data", "volume")))?;
493
494        Ok(OHLCV {
495            timestamp,
496            open,
497            high,
498            low,
499            close,
500            volume,
501        })
502    } else {
503        // Handle object format
504        let timestamp = parse_timestamp(data, "startTime")
505            .or_else(|| parse_timestamp(data, "openTime"))
506            .ok_or_else(|| Error::from(ParseError::missing_field("startTime")))?;
507
508        let open = data["openPrice"]
509            .as_str()
510            .and_then(|s| s.parse::<f64>().ok())
511            .or_else(|| data["openPrice"].as_f64())
512            .or_else(|| data["open"].as_str().and_then(|s| s.parse::<f64>().ok()))
513            .or_else(|| data["open"].as_f64())
514            .ok_or_else(|| Error::from(ParseError::missing_field("openPrice")))?;
515
516        let high = data["highPrice"]
517            .as_str()
518            .and_then(|s| s.parse::<f64>().ok())
519            .or_else(|| data["highPrice"].as_f64())
520            .or_else(|| data["high"].as_str().and_then(|s| s.parse::<f64>().ok()))
521            .or_else(|| data["high"].as_f64())
522            .ok_or_else(|| Error::from(ParseError::missing_field("highPrice")))?;
523
524        let low = data["lowPrice"]
525            .as_str()
526            .and_then(|s| s.parse::<f64>().ok())
527            .or_else(|| data["lowPrice"].as_f64())
528            .or_else(|| data["low"].as_str().and_then(|s| s.parse::<f64>().ok()))
529            .or_else(|| data["low"].as_f64())
530            .ok_or_else(|| Error::from(ParseError::missing_field("lowPrice")))?;
531
532        let close = data["closePrice"]
533            .as_str()
534            .and_then(|s| s.parse::<f64>().ok())
535            .or_else(|| data["closePrice"].as_f64())
536            .or_else(|| data["close"].as_str().and_then(|s| s.parse::<f64>().ok()))
537            .or_else(|| data["close"].as_f64())
538            .ok_or_else(|| Error::from(ParseError::missing_field("closePrice")))?;
539
540        let volume = data["volume"]
541            .as_str()
542            .and_then(|s| s.parse::<f64>().ok())
543            .or_else(|| data["volume"].as_f64())
544            .ok_or_else(|| Error::from(ParseError::missing_field("volume")))?;
545
546        Ok(OHLCV {
547            timestamp,
548            open,
549            high,
550            low,
551            close,
552            volume,
553        })
554    }
555}
556
557// ============================================================================
558// Order and Balance Parser Functions
559// ============================================================================
560
561/// Map Bybit order status to CCXT OrderStatus.
562///
563/// Bybit order states:
564/// - New: Order is active
565/// - PartiallyFilled: Order is partially filled
566/// - Filled: Order is completely filled
567/// - Cancelled: Order is canceled
568/// - Rejected: Order is rejected
569/// - PartiallyFilledCanceled: Partially filled then canceled
570///
571/// # Arguments
572///
573/// * `status` - Bybit order status string
574///
575/// # Returns
576///
577/// Returns the corresponding CCXT [`OrderStatus`].
578pub fn parse_order_status(status: &str) -> OrderStatus {
579    match status {
580        "Filled" => OrderStatus::Closed,
581        "Cancelled" | "Canceled" | "PartiallyFilledCanceled" | "Deactivated" => {
582            OrderStatus::Cancelled
583        }
584        "Rejected" => OrderStatus::Rejected,
585        "Expired" => OrderStatus::Expired,
586        _ => OrderStatus::Open, // Default to Open for unknown statuses
587    }
588}
589
590/// Parse order data from Bybit order response.
591///
592/// # Arguments
593///
594/// * `data` - Bybit order data JSON object
595/// * `market` - Optional market information for symbol resolution
596///
597/// # Returns
598///
599/// Returns a CCXT [`Order`] structure.
600pub fn parse_order(data: &Value, market: Option<&Market>) -> Result<Order> {
601    let symbol = if let Some(m) = market {
602        m.symbol.clone()
603    } else {
604        data["symbol"]
605            .as_str()
606            .map(ToString::to_string)
607            .unwrap_or_default()
608    };
609
610    let id = data["orderId"]
611        .as_str()
612        .ok_or_else(|| Error::from(ParseError::missing_field("orderId")))?
613        .to_string();
614
615    let timestamp =
616        parse_timestamp(data, "createdTime").or_else(|| parse_timestamp(data, "createTime"));
617
618    let status_str = data["orderStatus"].as_str().unwrap_or("New");
619    let status = parse_order_status(status_str);
620
621    // Parse order side
622    let side = match data["side"].as_str() {
623        Some("Buy" | "buy" | "BUY") => OrderSide::Buy,
624        Some("Sell" | "sell" | "SELL") => OrderSide::Sell,
625        _ => return Err(Error::from(ParseError::invalid_format("data", "side"))),
626    };
627
628    // Parse order type
629    let order_type = match data["orderType"].as_str() {
630        Some("Market" | "MARKET") => OrderType::Market,
631        _ => OrderType::Limit, // Default to limit
632    };
633
634    let price = parse_decimal(data, "price");
635    let amount =
636        parse_decimal(data, "qty").ok_or_else(|| Error::from(ParseError::missing_field("qty")))?;
637    let filled = parse_decimal(data, "cumExecQty");
638    let remaining = match filled {
639        Some(f) => Some(amount - f),
640        None => Some(amount),
641    };
642
643    let average = parse_decimal(data, "avgPrice");
644
645    // Calculate cost from filled amount and average price
646    let cost = parse_decimal(data, "cumExecValue").or_else(|| match (filled, average) {
647        (Some(f), Some(avg)) => Some(f * avg),
648        _ => None,
649    });
650
651    Ok(Order {
652        id,
653        client_order_id: data["orderLinkId"].as_str().map(ToString::to_string),
654        timestamp,
655        datetime: timestamp.and_then(timestamp_to_datetime),
656        last_trade_timestamp: parse_timestamp(data, "updatedTime"),
657        status,
658        symbol,
659        order_type,
660        time_in_force: data["timeInForce"].as_str().map(ToString::to_string),
661        side,
662        price,
663        average,
664        amount,
665        filled,
666        remaining,
667        cost,
668        trades: None,
669        fee: None,
670        post_only: data["timeInForce"].as_str().map(|s| s == "PostOnly"),
671        reduce_only: data["reduceOnly"].as_bool(),
672        trigger_price: parse_decimal(data, "triggerPrice"),
673        stop_price: parse_decimal(data, "stopLoss"),
674        take_profit_price: parse_decimal(data, "takeProfit"),
675        stop_loss_price: parse_decimal(data, "stopLoss"),
676        trailing_delta: None,
677        trailing_percent: None,
678        activation_price: None,
679        callback_rate: None,
680        working_type: None,
681        fees: Some(Vec::new()),
682        info: value_to_hashmap(data),
683    })
684}
685
686/// Parse balance data from Bybit account info.
687///
688/// # Arguments
689///
690/// * `data` - Bybit account data JSON object
691///
692/// # Returns
693///
694/// Returns a CCXT [`Balance`] structure with all non-zero balances.
695pub fn parse_balance(data: &Value) -> Result<Balance> {
696    let mut balances = HashMap::new();
697
698    // Bybit returns balance in coin array
699    if let Some(coins) = data["coin"].as_array() {
700        for coin in coins {
701            parse_balance_entry(coin, &mut balances);
702        }
703    } else if let Some(list) = data["list"].as_array() {
704        // Handle list format from wallet balance endpoint
705        for item in list {
706            if let Some(coins) = item["coin"].as_array() {
707                for coin in coins {
708                    parse_balance_entry(coin, &mut balances);
709                }
710            }
711        }
712    } else {
713        // Handle single balance object
714        parse_balance_entry(data, &mut balances);
715    }
716
717    Ok(Balance {
718        balances,
719        info: value_to_hashmap(data),
720    })
721}
722
723/// Parse a single balance entry from Bybit response.
724fn parse_balance_entry(data: &Value, balances: &mut HashMap<String, BalanceEntry>) {
725    let currency = data["coin"]
726        .as_str()
727        .or_else(|| data["currency"].as_str())
728        .map(ToString::to_string);
729
730    if let Some(currency) = currency {
731        // Bybit uses different field names depending on account type
732        let available = parse_decimal(data, "availableToWithdraw")
733            .or_else(|| parse_decimal(data, "free"))
734            .or_else(|| parse_decimal(data, "walletBalance"))
735            .unwrap_or(Decimal::ZERO);
736
737        let frozen = parse_decimal(data, "locked")
738            .or_else(|| parse_decimal(data, "frozen"))
739            .unwrap_or(Decimal::ZERO);
740
741        let total = parse_decimal(data, "walletBalance")
742            .or_else(|| parse_decimal(data, "equity"))
743            .unwrap_or(available + frozen);
744
745        // Only include non-zero balances
746        if total > Decimal::ZERO {
747            balances.insert(
748                currency,
749                BalanceEntry {
750                    free: available,
751                    used: frozen,
752                    total,
753                },
754            );
755        }
756    }
757}
758
759// ============================================================================
760// Unit Tests
761// ============================================================================
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766    use rust_decimal_macros::dec;
767    use serde_json::json;
768
769    #[test]
770    fn test_parse_market() {
771        let data = json!({
772            "symbol": "BTCUSDT",
773            "baseCoin": "BTC",
774            "quoteCoin": "USDT",
775            "status": "Trading",
776            "lotSizeFilter": {
777                "basePrecision": "0.0001",
778                "minOrderQty": "0.0001",
779                "maxOrderQty": "100"
780            },
781            "priceFilter": {
782                "tickSize": "0.01"
783            }
784        });
785
786        let market = parse_market(&data).unwrap();
787        assert_eq!(market.id, "BTCUSDT");
788        assert_eq!(market.symbol, "BTC/USDT");
789        assert_eq!(market.base, "BTC");
790        assert_eq!(market.quote, "USDT");
791        assert!(market.active);
792        assert_eq!(market.market_type, MarketType::Spot);
793    }
794
795    #[test]
796    fn test_parse_ticker() {
797        let data = json!({
798            "symbol": "BTCUSDT",
799            "lastPrice": "50000.00",
800            "highPrice24h": "51000.00",
801            "lowPrice24h": "49000.00",
802            "bid1Price": "49999.00",
803            "ask1Price": "50001.00",
804            "volume24h": "1000.5",
805            "time": "1700000000000"
806        });
807
808        let ticker = parse_ticker(&data, None).unwrap();
809        assert_eq!(ticker.symbol, "BTCUSDT");
810        assert_eq!(ticker.last, Some(Price::new(dec!(50000.00))));
811        assert_eq!(ticker.high, Some(Price::new(dec!(51000.00))));
812        assert_eq!(ticker.low, Some(Price::new(dec!(49000.00))));
813        assert_eq!(ticker.timestamp, 1700000000000);
814    }
815
816    #[test]
817    fn test_parse_orderbook() {
818        let data = json!({
819            "b": [
820                ["50000.00", "1.5"],
821                ["49999.00", "2.0"]
822            ],
823            "a": [
824                ["50001.00", "1.0"],
825                ["50002.00", "3.0"]
826            ],
827            "ts": "1700000000000"
828        });
829
830        let orderbook = parse_orderbook(&data, "BTC/USDT".to_string()).unwrap();
831        assert_eq!(orderbook.symbol, "BTC/USDT");
832        assert_eq!(orderbook.bids.len(), 2);
833        assert_eq!(orderbook.asks.len(), 2);
834        assert_eq!(orderbook.bids[0].price, Price::new(dec!(50000.00)));
835        assert_eq!(orderbook.asks[0].price, Price::new(dec!(50001.00)));
836    }
837
838    #[test]
839    fn test_parse_trade() {
840        let data = json!({
841            "execId": "123456",
842            "symbol": "BTCUSDT",
843            "side": "Buy",
844            "price": "50000.00",
845            "size": "0.5",
846            "time": "1700000000000"
847        });
848
849        let trade = parse_trade(&data, None).unwrap();
850        assert_eq!(trade.id, Some("123456".to_string()));
851        assert_eq!(trade.side, OrderSide::Buy);
852        assert_eq!(trade.price, Price::new(dec!(50000.00)));
853        assert_eq!(trade.amount, Amount::new(dec!(0.5)));
854    }
855
856    #[test]
857    fn test_parse_ohlcv_array() {
858        let data = json!([
859            "1700000000000",
860            "50000.00",
861            "51000.00",
862            "49000.00",
863            "50500.00",
864            "1000.5"
865        ]);
866
867        let ohlcv = parse_ohlcv(&data).unwrap();
868        assert_eq!(ohlcv.timestamp, 1700000000000);
869        assert_eq!(ohlcv.open, 50000.00);
870        assert_eq!(ohlcv.high, 51000.00);
871        assert_eq!(ohlcv.low, 49000.00);
872        assert_eq!(ohlcv.close, 50500.00);
873        assert_eq!(ohlcv.volume, 1000.5);
874    }
875
876    #[test]
877    fn test_parse_ohlcv_object() {
878        let data = json!({
879            "startTime": "1700000000000",
880            "openPrice": "50000.00",
881            "highPrice": "51000.00",
882            "lowPrice": "49000.00",
883            "closePrice": "50500.00",
884            "volume": "1000.5"
885        });
886
887        let ohlcv = parse_ohlcv(&data).unwrap();
888        assert_eq!(ohlcv.timestamp, 1700000000000);
889        assert_eq!(ohlcv.open, 50000.00);
890        assert_eq!(ohlcv.high, 51000.00);
891        assert_eq!(ohlcv.low, 49000.00);
892        assert_eq!(ohlcv.close, 50500.00);
893        assert_eq!(ohlcv.volume, 1000.5);
894    }
895
896    #[test]
897    fn test_parse_order_status() {
898        assert_eq!(parse_order_status("New"), OrderStatus::Open);
899        assert_eq!(parse_order_status("PartiallyFilled"), OrderStatus::Open);
900        assert_eq!(parse_order_status("Filled"), OrderStatus::Closed);
901        assert_eq!(parse_order_status("Cancelled"), OrderStatus::Cancelled);
902        assert_eq!(parse_order_status("Rejected"), OrderStatus::Rejected);
903        assert_eq!(parse_order_status("Expired"), OrderStatus::Expired);
904    }
905
906    #[test]
907    fn test_parse_order() {
908        let data = json!({
909            "orderId": "123456789",
910            "symbol": "BTCUSDT",
911            "side": "Buy",
912            "orderType": "Limit",
913            "price": "50000.00",
914            "qty": "0.5",
915            "orderStatus": "New",
916            "createdTime": "1700000000000"
917        });
918
919        let order = parse_order(&data, None).unwrap();
920        assert_eq!(order.id, "123456789");
921        assert_eq!(order.side, OrderSide::Buy);
922        assert_eq!(order.order_type, OrderType::Limit);
923        assert_eq!(order.price, Some(dec!(50000.00)));
924        assert_eq!(order.amount, dec!(0.5));
925        assert_eq!(order.status, OrderStatus::Open);
926    }
927
928    #[test]
929    fn test_parse_balance() {
930        let data = json!({
931            "coin": [
932                {
933                    "coin": "BTC",
934                    "walletBalance": "2.0",
935                    "availableToWithdraw": "1.5",
936                    "locked": "0.5"
937                },
938                {
939                    "coin": "USDT",
940                    "walletBalance": "10000.00",
941                    "availableToWithdraw": "10000.00",
942                    "locked": "0"
943                }
944            ]
945        });
946
947        let balance = parse_balance(&data).unwrap();
948        let btc = balance.get("BTC").unwrap();
949        assert_eq!(btc.free, dec!(1.5));
950        assert_eq!(btc.used, dec!(0.5));
951        assert_eq!(btc.total, dec!(2.0));
952
953        let usdt = balance.get("USDT").unwrap();
954        assert_eq!(usdt.free, dec!(10000.00));
955        assert_eq!(usdt.total, dec!(10000.00));
956    }
957
958    #[test]
959    fn test_timestamp_to_datetime() {
960        let ts = 1700000000000i64;
961        let dt = timestamp_to_datetime(ts).unwrap();
962        assert!(dt.contains("2023-11-14"));
963    }
964}