Skip to main content

ccxt_exchanges/okx/
parser.rs

1//! OKX data parser module.
2//!
3//! Converts OKX 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 OKX exchange info.
28///
29/// OKX uses `instId` for instrument ID (e.g., "BTC-USDT").
30///
31/// # Arguments
32///
33/// * `data` - OKX market data JSON object
34///
35/// # Returns
36///
37/// Returns a CCXT [`Market`] structure.
38pub fn parse_market(data: &Value) -> Result<Market> {
39    // OKX uses "instId" for the exchange-specific ID (e.g., "BTC-USDT")
40    let id = data["instId"]
41        .as_str()
42        .ok_or_else(|| Error::from(ParseError::missing_field("instId")))?
43        .to_string();
44
45    // Parse instId parts for fallback
46    let parts: Vec<&str> = id.split('-').collect();
47    let (base_from_id, quote_from_id) = if parts.len() >= 2 {
48        (Some(parts[0]), Some(parts[1]))
49    } else {
50        (None, None)
51    };
52
53    // Base and quote currencies
54    let base = data["baseCcy"]
55        .as_str()
56        .filter(|s| !s.is_empty())
57        .map(ToString::to_string)
58        .or_else(|| base_from_id.map(ToString::to_string))
59        .ok_or_else(|| Error::from(ParseError::missing_field("baseCcy")))?;
60
61    let quote = data["quoteCcy"]
62        .as_str()
63        .filter(|s| !s.is_empty())
64        .map(ToString::to_string)
65        .or_else(|| quote_from_id.map(ToString::to_string))
66        .ok_or_else(|| Error::from(ParseError::missing_field("quoteCcy")))?;
67
68    // Instrument type
69    let inst_type = data["instType"].as_str().unwrap_or("SPOT");
70    let market_type = match inst_type {
71        "SWAP" => MarketType::Swap,
72        "FUTURES" => MarketType::Futures,
73        "OPTION" => MarketType::Option,
74        _ => MarketType::Spot,
75    };
76
77    // Market status
78    let state = data["state"].as_str().unwrap_or("live");
79    let active = state == "live";
80
81    // Parse precision - OKX uses tickSz and lotSz
82    let price_precision = parse_decimal(data, "tickSz");
83    let amount_precision = parse_decimal(data, "lotSz");
84
85    // Parse limits
86    let min_amount = parse_decimal(data, "minSz");
87    let max_amount = parse_decimal(data, "maxLmtSz");
88
89    // Contract-specific fields
90    let contract = inst_type != "SPOT";
91    let linear = if contract {
92        Some(data["ctType"].as_str() == Some("linear"))
93    } else {
94        None
95    };
96    let inverse = if contract {
97        Some(data["ctType"].as_str() == Some("inverse"))
98    } else {
99        None
100    };
101    let contract_size = parse_decimal(data, "ctVal");
102
103    // Settlement currency for derivatives
104    let settle = data["settleCcy"].as_str().map(ToString::to_string);
105    let settle_id = settle.clone();
106
107    // Expiry for futures/options
108    let expiry = parse_timestamp(data, "expTime");
109    let expiry_datetime = expiry.and_then(timestamp_to_datetime);
110
111    // Build unified symbol format based on market type:
112    // - Spot: BASE/QUOTE (e.g., "BTC/USDT")
113    // - Swap: BASE/QUOTE:SETTLE (e.g., "BTC/USDT:USDT")
114    // - Futures: BASE/QUOTE:SETTLE-YYMMDD (e.g., "BTC/USDT:USDT-241231")
115    let symbol = match market_type {
116        MarketType::Spot => format!("{}/{}", base, quote),
117        MarketType::Swap => {
118            if let Some(ref s) = settle {
119                format!("{}/{}:{}", base, quote, s)
120            } else {
121                // Fallback: use quote as settle for linear
122                format!("{}/{}:{}", base, quote, quote)
123            }
124        }
125        MarketType::Futures | MarketType::Option => {
126            if let (Some(s), Some(exp_ts)) = (&settle, expiry) {
127                // Convert timestamp to YYMMDD format
128                if let Some(dt) = chrono::DateTime::from_timestamp_millis(exp_ts) {
129                    let year = (dt.format("%y").to_string().parse::<u8>()).unwrap_or(0);
130                    let month = (dt.format("%m").to_string().parse::<u8>()).unwrap_or(1);
131                    let day = (dt.format("%d").to_string().parse::<u8>()).unwrap_or(1);
132                    format!("{}/{}:{}-{:02}{:02}{:02}", base, quote, s, year, month, day)
133                } else {
134                    format!("{}/{}:{}", base, quote, s)
135                }
136            } else if let Some(ref s) = settle {
137                format!("{}/{}:{}", base, quote, s)
138            } else {
139                format!("{}/{}", base, quote)
140            }
141        }
142    };
143
144    // Parse the symbol to get structured representation
145    let parsed_symbol = ccxt_core::symbol::SymbolParser::parse(&symbol).ok();
146
147    Ok(Market {
148        id,
149        symbol,
150        parsed_symbol,
151        base: base.clone(),
152        quote: quote.clone(),
153        settle,
154        base_id: Some(base),
155        quote_id: Some(quote),
156        settle_id,
157        market_type,
158        active,
159        margin: inst_type == "MARGIN",
160        contract: Some(contract),
161        linear,
162        inverse,
163        contract_size,
164        expiry,
165        expiry_datetime,
166        strike: parse_decimal(data, "stk"),
167        option_type: data["optType"].as_str().map(ToString::to_string),
168        precision: MarketPrecision {
169            price: price_precision,
170            amount: amount_precision,
171            base: None,
172            quote: None,
173        },
174        limits: MarketLimits {
175            amount: Some(MinMax {
176                min: min_amount,
177                max: max_amount,
178            }),
179            price: None,
180            cost: None,
181            leverage: None,
182        },
183        maker: parse_decimal(data, "makerFee"),
184        taker: parse_decimal(data, "takerFee"),
185        percentage: Some(true),
186        tier_based: Some(false),
187        fee_side: Some("quote".to_string()),
188        info: value_to_hashmap(data),
189    })
190}
191
192/// Parse ticker data from OKX ticker response.
193///
194/// # Arguments
195///
196/// * `data` - OKX ticker data JSON object
197/// * `market` - Optional market information for symbol resolution
198///
199/// # Returns
200///
201/// Returns a CCXT [`Ticker`] structure.
202pub fn parse_ticker(data: &Value, market: Option<&Market>) -> Result<Ticker> {
203    let symbol = if let Some(m) = market {
204        m.symbol.clone()
205    } else {
206        // Try to construct symbol from instId
207        data["instId"]
208            .as_str()
209            .map(|s| s.replace('-', "/"))
210            .unwrap_or_default()
211    };
212
213    // OKX uses "ts" for timestamp
214    let timestamp = parse_timestamp(data, "ts").unwrap_or(0);
215
216    Ok(Ticker {
217        symbol,
218        timestamp,
219        datetime: timestamp_to_datetime(timestamp),
220        high: parse_decimal(data, "high24h").map(Price::new),
221        low: parse_decimal(data, "low24h").map(Price::new),
222        bid: parse_decimal(data, "bidPx").map(Price::new),
223        bid_volume: parse_decimal(data, "bidSz").map(Amount::new),
224        ask: parse_decimal(data, "askPx").map(Price::new),
225        ask_volume: parse_decimal(data, "askSz").map(Amount::new),
226        vwap: None,
227        open: parse_decimal(data, "open24h")
228            .or_else(|| parse_decimal(data, "sodUtc0"))
229            .map(Price::new),
230        close: parse_decimal(data, "last").map(Price::new),
231        last: parse_decimal(data, "last").map(Price::new),
232        previous_close: None,
233        change: None, // OKX doesn't provide direct change value
234        percentage: parse_decimal(data, "sodUtc0").and_then(|open| {
235            parse_decimal(data, "last").map(|last| {
236                if open.is_zero() {
237                    Decimal::ZERO
238                } else {
239                    ((last - open) / open) * Decimal::from(100)
240                }
241            })
242        }),
243        average: None,
244        base_volume: parse_decimal(data, "vol24h")
245            .or_else(|| parse_decimal(data, "volCcy24h"))
246            .map(Amount::new),
247        quote_volume: parse_decimal(data, "volCcy24h").map(Amount::new),
248        funding_rate: None,
249        open_interest: None,
250        index_price: None,
251        mark_price: None,
252        info: value_to_hashmap(data),
253    })
254}
255
256/// Parse orderbook data from OKX depth response.
257///
258/// # Arguments
259///
260/// * `data` - OKX orderbook data JSON object
261/// * `symbol` - Trading pair symbol
262///
263/// # Returns
264///
265/// Returns a CCXT [`OrderBook`] structure with bids sorted in descending order
266/// and asks sorted in ascending order.
267pub fn parse_orderbook(data: &Value, symbol: String) -> Result<OrderBook> {
268    let timestamp =
269        parse_timestamp(data, "ts").unwrap_or_else(|| chrono::Utc::now().timestamp_millis());
270
271    let mut bids = parse_orderbook_side(&data["bids"])?;
272    let mut asks = parse_orderbook_side(&data["asks"])?;
273
274    // Sort bids in descending order (highest price first)
275    bids.sort_by(|a, b| b.price.cmp(&a.price));
276
277    // Sort asks in ascending order (lowest price first)
278    asks.sort_by(|a, b| a.price.cmp(&b.price));
279
280    Ok(OrderBook {
281        symbol,
282        timestamp,
283        datetime: timestamp_to_datetime(timestamp),
284        nonce: None,
285        bids,
286        asks,
287        buffered_deltas: std::collections::VecDeque::new(),
288        bids_map: std::collections::BTreeMap::new(),
289        asks_map: std::collections::BTreeMap::new(),
290        is_synced: false,
291        needs_resync: false,
292        last_resync_time: 0,
293        info: value_to_hashmap(data),
294    })
295}
296
297/// Parse one side (bids or asks) of orderbook data.
298fn parse_orderbook_side(data: &Value) -> Result<Vec<OrderBookEntry>> {
299    let Some(array) = data.as_array() else {
300        return Ok(Vec::new());
301    };
302
303    let mut result = Vec::new();
304
305    for item in array {
306        if let Some(arr) = item.as_array() {
307            // OKX format: [price, size, liquidated_orders, num_orders]
308            if arr.len() >= 2 {
309                let price = arr[0]
310                    .as_str()
311                    .and_then(|s| Decimal::from_str(s).ok())
312                    .or_else(|| arr[0].as_f64().and_then(Decimal::from_f64))
313                    .ok_or_else(|| Error::from(ParseError::invalid_value("data", "price")))?;
314
315                let amount = arr[1]
316                    .as_str()
317                    .and_then(|s| Decimal::from_str(s).ok())
318                    .or_else(|| arr[1].as_f64().and_then(Decimal::from_f64))
319                    .ok_or_else(|| Error::from(ParseError::invalid_value("data", "amount")))?;
320
321                result.push(OrderBookEntry {
322                    price: Price::new(price),
323                    amount: Amount::new(amount),
324                });
325            }
326        }
327    }
328
329    Ok(result)
330}
331
332/// Parse trade data from OKX trade response.
333///
334/// # Arguments
335///
336/// * `data` - OKX trade data JSON object
337/// * `market` - Optional market information for symbol resolution
338///
339/// # Returns
340///
341/// Returns a CCXT [`Trade`] structure.
342pub fn parse_trade(data: &Value, market: Option<&Market>) -> Result<Trade> {
343    let symbol = if let Some(m) = market {
344        m.symbol.clone()
345    } else {
346        data["instId"]
347            .as_str()
348            .map(|s| s.replace('-', "/"))
349            .unwrap_or_default()
350    };
351
352    let id = data["tradeId"].as_str().map(ToString::to_string);
353
354    let timestamp = parse_timestamp(data, "ts").unwrap_or(0);
355
356    // OKX uses "side" field with "buy" or "sell" values
357    let side = match data["side"].as_str() {
358        Some("sell" | "Sell" | "SELL") => OrderSide::Sell,
359        _ => OrderSide::Buy, // Default to buy if not specified
360    };
361
362    let price = parse_decimal(data, "px").or_else(|| parse_decimal(data, "fillPx"));
363    let amount = parse_decimal(data, "sz").or_else(|| parse_decimal(data, "fillSz"));
364
365    let cost = match (price, amount) {
366        (Some(p), Some(a)) => Some(p * a),
367        _ => None,
368    };
369
370    Ok(Trade {
371        id,
372        order: data["ordId"].as_str().map(ToString::to_string),
373        timestamp,
374        datetime: timestamp_to_datetime(timestamp),
375        symbol,
376        trade_type: None,
377        side,
378        taker_or_maker: None,
379        price: Price::new(price.unwrap_or(Decimal::ZERO)),
380        amount: Amount::new(amount.unwrap_or(Decimal::ZERO)),
381        cost: cost.map(Cost::new),
382        fee: None,
383        info: value_to_hashmap(data),
384    })
385}
386
387/// Parse OHLCV (candlestick) data from OKX kline response.
388///
389/// # Arguments
390///
391/// * `data` - OKX OHLCV data JSON array
392///
393/// # Returns
394///
395/// Returns a CCXT [`OHLCV`] structure.
396pub fn parse_ohlcv(data: &Value) -> Result<OHLCV> {
397    // OKX returns OHLCV as array: [ts, o, h, l, c, vol, volCcy, volCcyQuote, confirm]
398    let arr = data
399        .as_array()
400        .ok_or_else(|| Error::from(ParseError::invalid_format("data", "OHLCV array")))?;
401
402    if arr.len() < 6 {
403        return Err(Error::from(ParseError::invalid_format(
404            "data",
405            "OHLCV array with at least 6 elements",
406        )));
407    }
408
409    let timestamp = arr[0]
410        .as_str()
411        .and_then(|s| s.parse::<i64>().ok())
412        .or_else(|| arr[0].as_i64())
413        .ok_or_else(|| Error::from(ParseError::invalid_value("data", "timestamp")))?;
414
415    let open = arr[1]
416        .as_str()
417        .and_then(|s| s.parse::<f64>().ok())
418        .or_else(|| arr[1].as_f64())
419        .ok_or_else(|| Error::from(ParseError::invalid_value("data", "open")))?;
420
421    let high = arr[2]
422        .as_str()
423        .and_then(|s| s.parse::<f64>().ok())
424        .or_else(|| arr[2].as_f64())
425        .ok_or_else(|| Error::from(ParseError::invalid_value("data", "high")))?;
426
427    let low = arr[3]
428        .as_str()
429        .and_then(|s| s.parse::<f64>().ok())
430        .or_else(|| arr[3].as_f64())
431        .ok_or_else(|| Error::from(ParseError::invalid_value("data", "low")))?;
432
433    let close = arr[4]
434        .as_str()
435        .and_then(|s| s.parse::<f64>().ok())
436        .or_else(|| arr[4].as_f64())
437        .ok_or_else(|| Error::from(ParseError::invalid_value("data", "close")))?;
438
439    let volume = arr[5]
440        .as_str()
441        .and_then(|s| s.parse::<f64>().ok())
442        .or_else(|| arr[5].as_f64())
443        .ok_or_else(|| Error::from(ParseError::invalid_value("data", "volume")))?;
444
445    Ok(OHLCV {
446        timestamp,
447        open,
448        high,
449        low,
450        close,
451        volume,
452    })
453}
454
455// ============================================================================
456// Order and Balance Parser Functions
457// ============================================================================
458
459/// Map OKX order status to CCXT OrderStatus.
460///
461/// OKX order states:
462/// - live: Order is active
463/// - partially_filled: Order is partially filled
464/// - filled: Order is completely filled
465/// - canceled: Order is canceled
466/// - mmp_canceled: Order is canceled by MMP
467///
468/// # Arguments
469///
470/// * `status` - OKX order status string
471///
472/// # Returns
473///
474/// Returns the corresponding CCXT [`OrderStatus`].
475pub fn parse_order_status(status: &str) -> OrderStatus {
476    match status.to_lowercase().as_str() {
477        "filled" => OrderStatus::Closed,
478        "canceled" | "cancelled" | "mmp_canceled" => OrderStatus::Cancelled,
479        "expired" => OrderStatus::Expired,
480        "rejected" => OrderStatus::Rejected,
481        _ => OrderStatus::Open, // Default to Open for unknown statuses
482    }
483}
484
485/// Parse order data from OKX order response.
486///
487/// # Arguments
488///
489/// * `data` - OKX order data JSON object
490/// * `market` - Optional market information for symbol resolution
491///
492/// # Returns
493///
494/// Returns a CCXT [`Order`] structure.
495pub fn parse_order(data: &Value, market: Option<&Market>) -> Result<Order> {
496    let symbol = if let Some(m) = market {
497        m.symbol.clone()
498    } else {
499        data["instId"]
500            .as_str()
501            .map(|s| s.replace('-', "/"))
502            .unwrap_or_default()
503    };
504
505    let id = data["ordId"]
506        .as_str()
507        .ok_or_else(|| Error::from(ParseError::missing_field("ordId")))?
508        .to_string();
509
510    let timestamp = parse_timestamp(data, "cTime").or_else(|| parse_timestamp(data, "ts"));
511
512    let status_str = data["state"].as_str().unwrap_or("live");
513    let status = parse_order_status(status_str);
514
515    // Parse order side
516    let side = match data["side"].as_str() {
517        Some("buy" | "Buy" | "BUY") => OrderSide::Buy,
518        Some("sell" | "Sell" | "SELL") => OrderSide::Sell,
519        _ => return Err(Error::from(ParseError::invalid_format("data", "side"))),
520    };
521
522    // Parse order type
523    let order_type = match data["ordType"].as_str() {
524        Some("market" | "Market" | "MARKET") => OrderType::Market,
525        Some("post_only") => OrderType::LimitMaker,
526        _ => OrderType::Limit, // Default to limit (covers limit, fok, ioc)
527    };
528
529    let price = parse_decimal(data, "px");
530    let amount =
531        parse_decimal(data, "sz").ok_or_else(|| Error::from(ParseError::missing_field("sz")))?;
532    let filled = parse_decimal(data, "accFillSz").or_else(|| parse_decimal(data, "fillSz"));
533    let remaining = match filled {
534        Some(f) => Some(amount - f),
535        None => Some(amount),
536    };
537
538    let average = parse_decimal(data, "avgPx").or_else(|| parse_decimal(data, "fillPx"));
539
540    // Calculate cost from filled amount and average price
541    let cost = match (filled, average) {
542        (Some(f), Some(avg)) => Some(f * avg),
543        _ => None,
544    };
545
546    Ok(Order {
547        id,
548        client_order_id: data["clOrdId"].as_str().map(ToString::to_string),
549        timestamp,
550        datetime: timestamp.and_then(timestamp_to_datetime),
551        last_trade_timestamp: parse_timestamp(data, "uTime"),
552        status,
553        symbol,
554        order_type,
555        time_in_force: data["ordType"].as_str().map(|s| match s {
556            "fok" => "FOK".to_string(),
557            "ioc" => "IOC".to_string(),
558            "post_only" => "PO".to_string(),
559            _ => "GTC".to_string(),
560        }),
561        side,
562        price,
563        average,
564        amount,
565        filled,
566        remaining,
567        cost,
568        trades: None,
569        fee: None,
570        post_only: Some(data["ordType"].as_str() == Some("post_only")),
571        reduce_only: data["reduceOnly"].as_bool(),
572        trigger_price: parse_decimal(data, "triggerPx"),
573        stop_price: parse_decimal(data, "slTriggerPx"),
574        take_profit_price: parse_decimal(data, "tpTriggerPx"),
575        stop_loss_price: parse_decimal(data, "slTriggerPx"),
576        trailing_delta: None,
577        trailing_percent: None,
578        activation_price: None,
579        callback_rate: None,
580        working_type: None,
581        fees: Some(Vec::new()),
582        info: value_to_hashmap(data),
583    })
584}
585
586/// Parse balance data from OKX account info.
587///
588/// # Arguments
589///
590/// * `data` - OKX account data JSON object
591///
592/// # Returns
593///
594/// Returns a CCXT [`Balance`] structure with all non-zero balances.
595pub fn parse_balance(data: &Value) -> Result<Balance> {
596    let mut balances = HashMap::new();
597
598    // OKX returns balance in details array
599    if let Some(details) = data["details"].as_array() {
600        for detail in details {
601            parse_balance_entry(detail, &mut balances);
602        }
603    } else if let Some(balances_array) = data.as_array() {
604        // Handle array of balance objects
605        for balance in balances_array {
606            if let Some(details) = balance["details"].as_array() {
607                for detail in details {
608                    parse_balance_entry(detail, &mut balances);
609                }
610            } else {
611                parse_balance_entry(balance, &mut balances);
612            }
613        }
614    } else {
615        // Handle single balance object
616        parse_balance_entry(data, &mut balances);
617    }
618
619    Ok(Balance {
620        balances,
621        info: value_to_hashmap(data),
622    })
623}
624
625/// Parse a single balance entry from OKX response.
626fn parse_balance_entry(data: &Value, balances: &mut HashMap<String, BalanceEntry>) {
627    let currency = data["ccy"]
628        .as_str()
629        .or_else(|| data["currency"].as_str())
630        .map(ToString::to_string);
631
632    if let Some(currency) = currency {
633        // OKX uses different field names depending on account type
634        let available = parse_decimal(data, "availBal")
635            .or_else(|| parse_decimal(data, "availEq"))
636            .or_else(|| parse_decimal(data, "cashBal"))
637            .unwrap_or(Decimal::ZERO);
638
639        let frozen = parse_decimal(data, "frozenBal")
640            .or_else(|| parse_decimal(data, "ordFrozen"))
641            .unwrap_or(Decimal::ZERO);
642
643        let total = parse_decimal(data, "eq")
644            .or_else(|| parse_decimal(data, "bal"))
645            .unwrap_or(available + frozen);
646
647        // Only include non-zero balances
648        if total > Decimal::ZERO {
649            balances.insert(
650                currency,
651                BalanceEntry {
652                    free: available,
653                    used: frozen,
654                    total,
655                },
656            );
657        }
658    }
659}
660
661// ============================================================================
662// Position and Funding Rate Parser Functions
663// ============================================================================
664
665/// Parse position data from OKX account positions response.
666///
667/// OKX position fields:
668/// - instId: instrument ID
669/// - posSide: position side (long/short/net)
670/// - pos: position quantity
671/// - avgPx: average entry price
672/// - markPx: mark price
673/// - upl: unrealized PnL
674/// - lever: leverage
675/// - liqPx: liquidation price
676/// - mgnMode: margin mode (cross/isolated)
677/// - imr: initial margin requirement
678/// - mmr: maintenance margin requirement
679/// - cTime: creation time
680/// - uTime: update time
681///
682/// # Arguments
683///
684/// * `data` - OKX position data JSON object
685/// * `symbol` - Unified symbol string
686///
687/// # Returns
688///
689/// Returns a CCXT [`Position`] structure.
690pub fn parse_position(data: &Value, symbol: &str) -> Result<ccxt_core::types::Position> {
691    use ccxt_core::types::position::PositionSide;
692
693    let pos_side_str = data["posSide"].as_str().unwrap_or("net");
694    let position_side = match pos_side_str.to_lowercase().as_str() {
695        "long" => PositionSide::Long,
696        "short" => PositionSide::Short,
697        _ => PositionSide::Both,
698    };
699
700    let pos = parse_f64_field(data, "pos").unwrap_or(0.0);
701    let avg_px = parse_f64_field(data, "avgPx");
702    let mark_px = parse_f64_field(data, "markPx");
703    let upl = parse_f64_field(data, "upl");
704    let lever = parse_f64_field(data, "lever");
705    let liq_px = parse_f64_field(data, "liqPx");
706    let imr = parse_f64_field(data, "imr");
707    let mmr = parse_f64_field(data, "mmr");
708    let notional_usd = parse_f64_field(data, "notionalUsd");
709    let margin = parse_f64_field(data, "margin");
710    let realized_pnl = parse_f64_field(data, "realizedPnl");
711
712    let mgn_mode = data["mgnMode"].as_str().unwrap_or("cross");
713    let margin_mode = Some(mgn_mode.to_string());
714
715    let timestamp = parse_timestamp(data, "uTime").or_else(|| parse_timestamp(data, "cTime"));
716    let datetime = timestamp.and_then(timestamp_to_datetime);
717
718    // Determine side from position quantity or posSide
719    let side = match position_side {
720        PositionSide::Long => Some("long".to_string()),
721        PositionSide::Short => Some("short".to_string()),
722        PositionSide::Both => {
723            if pos > 0.0 {
724                Some("long".to_string())
725            } else if pos < 0.0 {
726                Some("short".to_string())
727            } else {
728                None
729            }
730        }
731    };
732
733    let contracts = Some(pos.abs());
734
735    // Calculate initial margin percentage from leverage
736    let initial_margin_percentage = lever.map(|l| if l > 0.0 { 1.0 / l } else { 0.0 });
737
738    // Calculate notional value
739    let notional = notional_usd.or(match (avg_px, contracts) {
740        (Some(price), Some(qty)) => Some(price * qty),
741        _ => None,
742    });
743
744    // Calculate percentage PnL
745    let percentage = match (upl, margin.or(imr)) {
746        (Some(pnl), Some(m)) if m > 0.0 => Some((pnl / m) * 100.0),
747        _ => None,
748    };
749
750    let hedged = match position_side {
751        PositionSide::Both => Some(false),
752        _ => Some(true),
753    };
754
755    Ok(ccxt_core::types::Position {
756        info: data.clone(),
757        id: data["posId"].as_str().map(ToString::to_string),
758        symbol: symbol.to_string(),
759        side,
760        position_side: Some(position_side),
761        dual_side_position: hedged,
762        contracts,
763        contract_size: parse_f64_field(data, "ctVal"),
764        entry_price: avg_px,
765        mark_price: mark_px,
766        notional,
767        leverage: lever,
768        collateral: margin,
769        initial_margin: imr,
770        initial_margin_percentage,
771        maintenance_margin: mmr,
772        maintenance_margin_percentage: None,
773        unrealized_pnl: upl,
774        realized_pnl,
775        liquidation_price: liq_px,
776        margin_ratio: None,
777        margin_mode,
778        hedged,
779        percentage,
780        timestamp,
781        datetime,
782    })
783}
784
785/// Parse funding rate data from OKX public funding-rate response.
786///
787/// OKX funding rate fields:
788/// - instId: instrument ID
789/// - fundingRate: current funding rate
790/// - fundingTime: next funding time
791/// - nextFundingRate: estimated next funding rate
792/// - nextFundingTime: next funding settlement time
793///
794/// # Arguments
795///
796/// * `data` - OKX funding rate data JSON object
797/// * `symbol` - Unified symbol string
798///
799/// # Returns
800///
801/// Returns a CCXT [`FundingRate`] structure.
802pub fn parse_funding_rate(data: &Value, symbol: &str) -> Result<ccxt_core::types::FundingRate> {
803    let funding_rate = parse_f64_field(data, "fundingRate");
804    let funding_time = parse_timestamp(data, "fundingTime");
805    let next_funding_time = parse_timestamp(data, "nextFundingTime");
806
807    let timestamp = funding_time.or_else(|| parse_timestamp(data, "ts"));
808    let datetime = timestamp.and_then(timestamp_to_datetime);
809
810    let funding_datetime = next_funding_time.and_then(timestamp_to_datetime);
811
812    Ok(ccxt_core::types::FundingRate {
813        info: data.clone(),
814        symbol: symbol.to_string(),
815        mark_price: parse_f64_field(data, "markPx"),
816        index_price: parse_f64_field(data, "idxPx"),
817        interest_rate: None,
818        estimated_settle_price: None,
819        funding_rate,
820        funding_timestamp: next_funding_time,
821        funding_datetime,
822        previous_funding_rate: None,
823        previous_funding_timestamp: None,
824        previous_funding_datetime: None,
825        timestamp,
826        datetime,
827    })
828}
829
830/// Parse funding rate history data from OKX public funding-rate-history response.
831///
832/// OKX funding rate history fields:
833/// - instId: instrument ID
834/// - fundingRate: historical funding rate
835/// - fundingTime: funding settlement time
836/// - realizedRate: realized funding rate
837///
838/// # Arguments
839///
840/// * `data` - OKX funding rate history data JSON object
841/// * `symbol` - Unified symbol string
842///
843/// # Returns
844///
845/// Returns a CCXT [`FundingRateHistory`] structure.
846pub fn parse_funding_rate_history(
847    data: &Value,
848    symbol: &str,
849) -> Result<ccxt_core::types::FundingRateHistory> {
850    let funding_rate =
851        parse_f64_field(data, "fundingRate").or_else(|| parse_f64_field(data, "realizedRate"));
852    let timestamp = parse_timestamp(data, "fundingTime");
853    let datetime = timestamp.and_then(timestamp_to_datetime);
854
855    Ok(ccxt_core::types::FundingRateHistory {
856        info: data.clone(),
857        symbol: symbol.to_string(),
858        funding_rate,
859        timestamp,
860        datetime,
861    })
862}
863
864/// Helper to parse a string field as f64.
865fn parse_f64_field(data: &Value, field: &str) -> Option<f64> {
866    data[field]
867        .as_str()
868        .and_then(|s| {
869            if s.is_empty() {
870                None
871            } else {
872                s.parse::<f64>().ok()
873            }
874        })
875        .or_else(|| data[field].as_f64())
876}
877
878// ============================================================================
879// Unit Tests
880// ============================================================================
881
882#[cfg(test)]
883mod tests {
884    use super::*;
885    use rust_decimal_macros::dec;
886    use serde_json::json;
887
888    #[test]
889    fn test_parse_market_swap_empty_base_quote() {
890        let data = json!({
891            "instId": "BTC-USDT-SWAP",
892            "instType": "SWAP",
893            "baseCcy": "",
894            "quoteCcy": "",
895            "settleCcy": "USDT",
896            "state": "live",
897            "tickSz": "0.1",
898            "lotSz": "1",
899            "minSz": "1",
900            "ctVal": "100"
901        });
902
903        // This should now correctly infer base/quote from instId
904        let market = parse_market(&data).unwrap();
905
906        // Assertions for FIXED behavior
907        assert_eq!(market.base, "BTC");
908        assert_eq!(market.quote, "USDT");
909        assert_eq!(market.symbol, "BTC/USDT:USDT");
910    }
911
912    #[test]
913    fn test_parse_market() {
914        let data = json!({
915            "instId": "BTC-USDT",
916            "instType": "SPOT",
917            "baseCcy": "BTC",
918            "quoteCcy": "USDT",
919            "state": "live",
920            "tickSz": "0.01",
921            "lotSz": "0.0001",
922            "minSz": "0.0001"
923        });
924
925        let market = parse_market(&data).unwrap();
926        assert_eq!(market.id, "BTC-USDT");
927        assert_eq!(market.symbol, "BTC/USDT");
928        assert_eq!(market.base, "BTC");
929        assert_eq!(market.quote, "USDT");
930        assert!(market.active);
931        assert_eq!(market.market_type, MarketType::Spot);
932    }
933
934    #[test]
935    fn test_parse_ticker() {
936        let data = json!({
937            "instId": "BTC-USDT",
938            "last": "50000.00",
939            "high24h": "51000.00",
940            "low24h": "49000.00",
941            "bidPx": "49999.00",
942            "askPx": "50001.00",
943            "vol24h": "1000.5",
944            "ts": "1700000000000"
945        });
946
947        let ticker = parse_ticker(&data, None).unwrap();
948        assert_eq!(ticker.symbol, "BTC/USDT");
949        assert_eq!(ticker.last, Some(Price::new(dec!(50000.00))));
950        assert_eq!(ticker.high, Some(Price::new(dec!(51000.00))));
951        assert_eq!(ticker.low, Some(Price::new(dec!(49000.00))));
952        assert_eq!(ticker.timestamp, 1700000000000);
953    }
954
955    #[test]
956    fn test_parse_orderbook() {
957        let data = json!({
958            "bids": [
959                ["50000.00", "1.5", "0", "1"],
960                ["49999.00", "2.0", "0", "2"]
961            ],
962            "asks": [
963                ["50001.00", "1.0", "0", "1"],
964                ["50002.00", "3.0", "0", "2"]
965            ],
966            "ts": "1700000000000"
967        });
968
969        let orderbook = parse_orderbook(&data, "BTC/USDT".to_string()).unwrap();
970        assert_eq!(orderbook.symbol, "BTC/USDT");
971        assert_eq!(orderbook.bids.len(), 2);
972        assert_eq!(orderbook.asks.len(), 2);
973        assert_eq!(orderbook.bids[0].price, Price::new(dec!(50000.00)));
974        assert_eq!(orderbook.asks[0].price, Price::new(dec!(50001.00)));
975    }
976
977    #[test]
978    fn test_parse_trade() {
979        let data = json!({
980            "tradeId": "123456",
981            "instId": "BTC-USDT",
982            "side": "buy",
983            "px": "50000.00",
984            "sz": "0.5",
985            "ts": "1700000000000"
986        });
987
988        let trade = parse_trade(&data, None).unwrap();
989        assert_eq!(trade.id, Some("123456".to_string()));
990        assert_eq!(trade.side, OrderSide::Buy);
991        assert_eq!(trade.price, Price::new(dec!(50000.00)));
992        assert_eq!(trade.amount, Amount::new(dec!(0.5)));
993    }
994
995    #[test]
996    fn test_parse_ohlcv() {
997        let data = json!([
998            "1700000000000",
999            "50000.00",
1000            "51000.00",
1001            "49000.00",
1002            "50500.00",
1003            "1000.5"
1004        ]);
1005
1006        let ohlcv = parse_ohlcv(&data).unwrap();
1007        assert_eq!(ohlcv.timestamp, 1700000000000);
1008        assert_eq!(ohlcv.open, 50000.00);
1009        assert_eq!(ohlcv.high, 51000.00);
1010        assert_eq!(ohlcv.low, 49000.00);
1011        assert_eq!(ohlcv.close, 50500.00);
1012        assert_eq!(ohlcv.volume, 1000.5);
1013    }
1014
1015    #[test]
1016    fn test_parse_order_status() {
1017        assert_eq!(parse_order_status("live"), OrderStatus::Open);
1018        assert_eq!(parse_order_status("partially_filled"), OrderStatus::Open);
1019        assert_eq!(parse_order_status("filled"), OrderStatus::Closed);
1020        assert_eq!(parse_order_status("canceled"), OrderStatus::Cancelled);
1021        assert_eq!(parse_order_status("mmp_canceled"), OrderStatus::Cancelled);
1022        assert_eq!(parse_order_status("expired"), OrderStatus::Expired);
1023        assert_eq!(parse_order_status("rejected"), OrderStatus::Rejected);
1024    }
1025
1026    #[test]
1027    fn test_parse_order() {
1028        let data = json!({
1029            "ordId": "123456789",
1030            "instId": "BTC-USDT",
1031            "side": "buy",
1032            "ordType": "limit",
1033            "px": "50000.00",
1034            "sz": "0.5",
1035            "state": "live",
1036            "cTime": "1700000000000"
1037        });
1038
1039        let order = parse_order(&data, None).unwrap();
1040        assert_eq!(order.id, "123456789");
1041        assert_eq!(order.side, OrderSide::Buy);
1042        assert_eq!(order.order_type, OrderType::Limit);
1043        assert_eq!(order.price, Some(dec!(50000.00)));
1044        assert_eq!(order.amount, dec!(0.5));
1045        assert_eq!(order.status, OrderStatus::Open);
1046    }
1047
1048    #[test]
1049    fn test_parse_balance() {
1050        let data = json!({
1051            "details": [
1052                {
1053                    "ccy": "BTC",
1054                    "availBal": "1.5",
1055                    "frozenBal": "0.5",
1056                    "eq": "2.0"
1057                },
1058                {
1059                    "ccy": "USDT",
1060                    "availBal": "10000.00",
1061                    "frozenBal": "0",
1062                    "eq": "10000.00"
1063                }
1064            ]
1065        });
1066
1067        let balance = parse_balance(&data).unwrap();
1068        let btc = balance.get("BTC").unwrap();
1069        assert_eq!(btc.free, dec!(1.5));
1070        assert_eq!(btc.used, dec!(0.5));
1071        assert_eq!(btc.total, dec!(2.0));
1072
1073        let usdt = balance.get("USDT").unwrap();
1074        assert_eq!(usdt.free, dec!(10000.00));
1075        assert_eq!(usdt.total, dec!(10000.00));
1076    }
1077
1078    #[test]
1079    fn test_timestamp_to_datetime() {
1080        let ts = 1700000000000i64;
1081        let dt = timestamp_to_datetime(ts).unwrap();
1082        assert!(dt.contains("2023-11-14"));
1083    }
1084
1085    // ========================================================================
1086    // Position Parser Tests
1087    // ========================================================================
1088
1089    #[test]
1090    fn test_parse_position_long() {
1091        let data = json!({
1092            "instId": "BTC-USDT-SWAP",
1093            "posId": "12345",
1094            "posSide": "long",
1095            "pos": "1.5",
1096            "avgPx": "50000.00",
1097            "markPx": "51000.00",
1098            "upl": "1500.00",
1099            "lever": "10",
1100            "liqPx": "45000.00",
1101            "mgnMode": "cross",
1102            "imr": "5000.00",
1103            "mmr": "500.00",
1104            "notionalUsd": "76500.00",
1105            "margin": "5000.00",
1106            "uTime": "1700000000000"
1107        });
1108
1109        let position = parse_position(&data, "BTC/USDT:USDT").unwrap();
1110        assert_eq!(position.symbol, "BTC/USDT:USDT");
1111        assert_eq!(position.side, Some("long".to_string()));
1112        assert_eq!(position.contracts, Some(1.5));
1113        assert_eq!(position.entry_price, Some(50000.00));
1114        assert_eq!(position.mark_price, Some(51000.00));
1115        assert_eq!(position.unrealized_pnl, Some(1500.00));
1116        assert_eq!(position.leverage, Some(10.0));
1117        assert_eq!(position.liquidation_price, Some(45000.00));
1118        assert_eq!(position.margin_mode, Some("cross".to_string()));
1119        assert_eq!(position.initial_margin, Some(5000.00));
1120        assert_eq!(position.maintenance_margin, Some(500.00));
1121        assert_eq!(position.hedged, Some(true));
1122    }
1123
1124    #[test]
1125    fn test_parse_position_short() {
1126        let data = json!({
1127            "instId": "ETH-USDT-SWAP",
1128            "posSide": "short",
1129            "pos": "-10",
1130            "avgPx": "3000.00",
1131            "markPx": "2900.00",
1132            "upl": "1000.00",
1133            "lever": "5",
1134            "mgnMode": "isolated",
1135            "uTime": "1700000000000"
1136        });
1137
1138        let position = parse_position(&data, "ETH/USDT:USDT").unwrap();
1139        assert_eq!(position.side, Some("short".to_string()));
1140        assert_eq!(position.contracts, Some(10.0));
1141        assert_eq!(position.margin_mode, Some("isolated".to_string()));
1142        assert_eq!(position.hedged, Some(true));
1143    }
1144
1145    #[test]
1146    fn test_parse_position_net_mode() {
1147        let data = json!({
1148            "instId": "BTC-USDT-SWAP",
1149            "posSide": "net",
1150            "pos": "2",
1151            "avgPx": "50000.00",
1152            "lever": "10",
1153            "mgnMode": "cross",
1154            "uTime": "1700000000000"
1155        });
1156
1157        let position = parse_position(&data, "BTC/USDT:USDT").unwrap();
1158        assert_eq!(position.side, Some("long".to_string()));
1159        assert_eq!(position.contracts, Some(2.0));
1160        assert_eq!(position.hedged, Some(false));
1161    }
1162
1163    #[test]
1164    fn test_parse_position_empty_fields() {
1165        let data = json!({
1166            "instId": "BTC-USDT-SWAP",
1167            "posSide": "net",
1168            "pos": "0",
1169            "avgPx": "",
1170            "markPx": "",
1171            "upl": "",
1172            "lever": "10",
1173            "mgnMode": "cross"
1174        });
1175
1176        let position = parse_position(&data, "BTC/USDT:USDT").unwrap();
1177        assert_eq!(position.contracts, Some(0.0));
1178        assert_eq!(position.entry_price, None);
1179        assert_eq!(position.mark_price, None);
1180        assert_eq!(position.unrealized_pnl, None);
1181    }
1182
1183    // ========================================================================
1184    // Funding Rate Parser Tests
1185    // ========================================================================
1186
1187    #[test]
1188    fn test_parse_funding_rate() {
1189        let data = json!({
1190            "instId": "BTC-USDT-SWAP",
1191            "fundingRate": "0.0001",
1192            "fundingTime": "1700000000000",
1193            "nextFundingRate": "0.00015",
1194            "nextFundingTime": "1700028800000"
1195        });
1196
1197        let rate = parse_funding_rate(&data, "BTC/USDT:USDT").unwrap();
1198        assert_eq!(rate.symbol, "BTC/USDT:USDT");
1199        assert_eq!(rate.funding_rate, Some(0.0001));
1200        assert_eq!(rate.funding_timestamp, Some(1700028800000));
1201        assert_eq!(rate.timestamp, Some(1700000000000));
1202    }
1203
1204    #[test]
1205    fn test_parse_funding_rate_history() {
1206        let data = json!({
1207            "instId": "BTC-USDT-SWAP",
1208            "fundingRate": "0.0001",
1209            "fundingTime": "1700000000000",
1210            "realizedRate": "0.00009"
1211        });
1212
1213        let history = parse_funding_rate_history(&data, "BTC/USDT:USDT").unwrap();
1214        assert_eq!(history.symbol, "BTC/USDT:USDT");
1215        assert_eq!(history.funding_rate, Some(0.0001));
1216        assert_eq!(history.timestamp, Some(1700000000000));
1217    }
1218
1219    #[test]
1220    fn test_parse_f64_field() {
1221        let data = json!({
1222            "a": "123.45",
1223            "b": "",
1224            "c": 67.89,
1225            "d": null
1226        });
1227
1228        assert_eq!(parse_f64_field(&data, "a"), Some(123.45));
1229        assert_eq!(parse_f64_field(&data, "b"), None);
1230        assert_eq!(parse_f64_field(&data, "c"), Some(67.89));
1231        assert_eq!(parse_f64_field(&data, "d"), None);
1232        assert_eq!(parse_f64_field(&data, "missing"), None);
1233    }
1234}