Skip to main content

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