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