nautilus_bybit/websocket/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parsing helpers for Bybit WebSocket payloads.
17
18use std::convert::TryFrom;
19
20use anyhow::Context;
21use nautilus_core::{datetime::NANOSECONDS_IN_MILLISECOND, nanos::UnixNanos, uuid::UUID4};
22use nautilus_model::{
23    data::{
24        Bar, BarType, BookOrder, FundingRateUpdate, OrderBookDelta, OrderBookDeltas, QuoteTick,
25        TradeTick,
26    },
27    enums::{
28        AccountType, AggressorSide, BookAction, LiquiditySide, OrderSide, OrderStatus, OrderType,
29        PositionSideSpecified, RecordFlag, TimeInForce, TriggerType,
30    },
31    events::account::state::AccountState,
32    identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
33    instruments::{Instrument, any::InstrumentAny},
34    reports::{FillReport, OrderStatusReport, PositionStatusReport},
35    types::{AccountBalance, Money, Price, Quantity},
36};
37use rust_decimal::Decimal;
38
39use super::messages::{
40    BybitWsAccountExecution, BybitWsAccountOrder, BybitWsAccountPosition, BybitWsAccountWallet,
41    BybitWsKline, BybitWsOrderbookDepthMsg, BybitWsTickerLinear, BybitWsTickerLinearMsg,
42    BybitWsTickerOptionMsg, BybitWsTrade,
43};
44use crate::common::{
45    consts::BYBIT_TOPIC_KLINE,
46    enums::{
47        BybitOrderStatus, BybitOrderType, BybitStopOrderType, BybitTimeInForce,
48        BybitTriggerDirection,
49    },
50    parse::{
51        get_currency, parse_millis_timestamp, parse_price_with_precision,
52        parse_quantity_with_precision,
53    },
54};
55
56/// Parses a Bybit WebSocket topic string into its components.
57///
58/// # Errors
59///
60/// Returns an error if the topic format is invalid.
61pub fn parse_topic(topic: &str) -> anyhow::Result<Vec<&str>> {
62    let parts: Vec<&str> = topic.split('.').collect();
63    if parts.is_empty() {
64        anyhow::bail!("Invalid topic format: empty topic");
65    }
66    Ok(parts)
67}
68
69/// Parses a Bybit kline topic into (interval, symbol).
70///
71/// Topic format: "kline.{interval}.{symbol}" (e.g., "kline.5.BTCUSDT")
72///
73/// # Errors
74///
75/// Returns an error if the topic format is invalid.
76pub fn parse_kline_topic(topic: &str) -> anyhow::Result<(&str, &str)> {
77    let parts = parse_topic(topic)?;
78    if parts.len() != 3 || parts[0] != BYBIT_TOPIC_KLINE {
79        anyhow::bail!(
80            "Invalid kline topic format: expected '{BYBIT_TOPIC_KLINE}.{{interval}}.{{symbol}}', was '{topic}'"
81        );
82    }
83    Ok((parts[1], parts[2]))
84}
85
86/// Parses a WebSocket trade frame into a [`TradeTick`].
87pub fn parse_ws_trade_tick(
88    trade: &BybitWsTrade,
89    instrument: &InstrumentAny,
90    ts_init: UnixNanos,
91) -> anyhow::Result<TradeTick> {
92    let price = parse_price_with_precision(&trade.p, instrument.price_precision(), "trade.p")?;
93    let size = parse_quantity_with_precision(&trade.v, instrument.size_precision(), "trade.v")?;
94    let aggressor: AggressorSide = trade.taker_side.into();
95    let trade_id = TradeId::new_checked(trade.i.as_str())
96        .context("invalid trade identifier in Bybit trade message")?;
97    let ts_event = parse_millis_i64(trade.t, "trade.T")?;
98
99    TradeTick::new_checked(
100        instrument.id(),
101        price,
102        size,
103        aggressor,
104        trade_id,
105        ts_event,
106        ts_init,
107    )
108    .context("failed to construct TradeTick from Bybit trade message")
109}
110
111/// Parses an order book depth message into [`OrderBookDeltas`].
112pub fn parse_orderbook_deltas(
113    msg: &BybitWsOrderbookDepthMsg,
114    instrument: &InstrumentAny,
115    ts_init: UnixNanos,
116) -> anyhow::Result<OrderBookDeltas> {
117    let is_snapshot = msg.msg_type.eq_ignore_ascii_case("snapshot");
118    let ts_event = parse_millis_i64(msg.ts, "orderbook.ts")?;
119    let ts_init = if ts_init.is_zero() { ts_event } else { ts_init };
120
121    let depth = &msg.data;
122    let instrument_id = instrument.id();
123    let price_precision = instrument.price_precision();
124    let size_precision = instrument.size_precision();
125    let update_id = u64::try_from(depth.u)
126        .context("received negative update id in Bybit order book message")?;
127    let sequence = u64::try_from(depth.seq)
128        .context("received negative sequence in Bybit order book message")?;
129
130    let total_levels = depth.b.len() + depth.a.len();
131    let capacity = if is_snapshot {
132        total_levels + 1
133    } else {
134        total_levels
135    };
136    let mut deltas = Vec::with_capacity(capacity);
137
138    if is_snapshot {
139        deltas.push(OrderBookDelta::clear(
140            instrument_id,
141            sequence,
142            ts_event,
143            ts_init,
144        ));
145    }
146    let mut processed = 0_usize;
147
148    let mut push_level = |values: &[String], side: OrderSide| -> anyhow::Result<()> {
149        let (price, size) = parse_book_level(values, price_precision, size_precision, "orderbook")?;
150        let action = if size.is_zero() {
151            BookAction::Delete
152        } else if is_snapshot {
153            BookAction::Add
154        } else {
155            BookAction::Update
156        };
157
158        processed += 1;
159        let mut flags = RecordFlag::F_MBP as u8;
160        if processed == total_levels {
161            flags |= RecordFlag::F_LAST as u8;
162        }
163
164        let order = BookOrder::new(side, price, size, update_id);
165        let delta = OrderBookDelta::new_checked(
166            instrument_id,
167            action,
168            order,
169            flags,
170            sequence,
171            ts_event,
172            ts_init,
173        )
174        .context("failed to construct OrderBookDelta from Bybit book level")?;
175        deltas.push(delta);
176        Ok(())
177    };
178
179    for level in &depth.b {
180        push_level(level, OrderSide::Buy)?;
181    }
182    for level in &depth.a {
183        push_level(level, OrderSide::Sell)?;
184    }
185
186    if total_levels == 0
187        && let Some(last) = deltas.last_mut()
188    {
189        last.flags |= RecordFlag::F_LAST as u8;
190    }
191
192    OrderBookDeltas::new_checked(instrument_id, deltas)
193        .context("failed to assemble OrderBookDeltas from Bybit message")
194}
195
196/// Parses an order book snapshot or delta into a [`QuoteTick`].
197pub fn parse_orderbook_quote(
198    msg: &BybitWsOrderbookDepthMsg,
199    instrument: &InstrumentAny,
200    last_quote: Option<&QuoteTick>,
201    ts_init: UnixNanos,
202) -> anyhow::Result<QuoteTick> {
203    let ts_event = parse_millis_i64(msg.ts, "orderbook.ts")?;
204    let ts_init = if ts_init.is_zero() { ts_event } else { ts_init };
205    let price_precision = instrument.price_precision();
206    let size_precision = instrument.size_precision();
207
208    let get_best =
209        |levels: &[Vec<String>], label: &str| -> anyhow::Result<Option<(Price, Quantity)>> {
210            if let Some(values) = levels.first() {
211                parse_book_level(values, price_precision, size_precision, label).map(Some)
212            } else {
213                Ok(None)
214            }
215        };
216
217    let bids = get_best(&msg.data.b, "bid")?;
218    let asks = get_best(&msg.data.a, "ask")?;
219
220    let (bid_price, bid_size) = match (bids, last_quote) {
221        (Some(level), _) => level,
222        (None, Some(prev)) => (prev.bid_price, prev.bid_size),
223        (None, None) => {
224            anyhow::bail!(
225                "Bybit order book update missing bid levels and no previous quote provided"
226            );
227        }
228    };
229
230    let (ask_price, ask_size) = match (asks, last_quote) {
231        (Some(level), _) => level,
232        (None, Some(prev)) => (prev.ask_price, prev.ask_size),
233        (None, None) => {
234            anyhow::bail!(
235                "Bybit order book update missing ask levels and no previous quote provided"
236            );
237        }
238    };
239
240    QuoteTick::new_checked(
241        instrument.id(),
242        bid_price,
243        ask_price,
244        bid_size,
245        ask_size,
246        ts_event,
247        ts_init,
248    )
249    .context("failed to construct QuoteTick from Bybit order book message")
250}
251
252/// Parses a linear or inverse ticker payload into a [`QuoteTick`].
253pub fn parse_ticker_linear_quote(
254    msg: &BybitWsTickerLinearMsg,
255    instrument: &InstrumentAny,
256    ts_init: UnixNanos,
257) -> anyhow::Result<QuoteTick> {
258    let ts_event = parse_millis_i64(msg.ts, "ticker.ts")?;
259    let ts_init = if ts_init.is_zero() { ts_event } else { ts_init };
260    let price_precision = instrument.price_precision();
261    let size_precision = instrument.size_precision();
262
263    let data = &msg.data;
264    let bid_price = data
265        .bid1_price
266        .as_ref()
267        .context("Bybit ticker message missing bid1Price")?
268        .as_str();
269    let ask_price = data
270        .ask1_price
271        .as_ref()
272        .context("Bybit ticker message missing ask1Price")?
273        .as_str();
274
275    let bid_price = parse_price_with_precision(bid_price, price_precision, "ticker.bid1Price")?;
276    let ask_price = parse_price_with_precision(ask_price, price_precision, "ticker.ask1Price")?;
277
278    let bid_size_str = data.bid1_size.as_deref().unwrap_or("0");
279    let ask_size_str = data.ask1_size.as_deref().unwrap_or("0");
280
281    let bid_size = parse_quantity_with_precision(bid_size_str, size_precision, "ticker.bid1Size")?;
282    let ask_size = parse_quantity_with_precision(ask_size_str, size_precision, "ticker.ask1Size")?;
283
284    QuoteTick::new_checked(
285        instrument.id(),
286        bid_price,
287        ask_price,
288        bid_size,
289        ask_size,
290        ts_event,
291        ts_init,
292    )
293    .context("failed to construct QuoteTick from Bybit linear ticker message")
294}
295
296/// Parses an option ticker payload into a [`QuoteTick`].
297pub fn parse_ticker_option_quote(
298    msg: &BybitWsTickerOptionMsg,
299    instrument: &InstrumentAny,
300    ts_init: UnixNanos,
301) -> anyhow::Result<QuoteTick> {
302    let ts_event = parse_millis_i64(msg.ts, "ticker.ts")?;
303    let ts_init = if ts_init.is_zero() { ts_event } else { ts_init };
304    let price_precision = instrument.price_precision();
305    let size_precision = instrument.size_precision();
306
307    let data = &msg.data;
308    let bid_price =
309        parse_price_with_precision(&data.bid_price, price_precision, "ticker.bidPrice")?;
310    let ask_price =
311        parse_price_with_precision(&data.ask_price, price_precision, "ticker.askPrice")?;
312    let bid_size = parse_quantity_with_precision(&data.bid_size, size_precision, "ticker.bidSize")?;
313    let ask_size = parse_quantity_with_precision(&data.ask_size, size_precision, "ticker.askSize")?;
314
315    QuoteTick::new_checked(
316        instrument.id(),
317        bid_price,
318        ask_price,
319        bid_size,
320        ask_size,
321        ts_event,
322        ts_init,
323    )
324    .context("failed to construct QuoteTick from Bybit option ticker message")
325}
326
327/// Parses a linear ticker payload into a [`FundingRateUpdate`].
328///
329/// # Errors
330///
331/// Returns an error if funding rate or next funding time fields are missing or cannot be parsed.
332pub fn parse_ticker_linear_funding(
333    data: &BybitWsTickerLinear,
334    instrument_id: InstrumentId,
335    ts_event: UnixNanos,
336    ts_init: UnixNanos,
337) -> anyhow::Result<FundingRateUpdate> {
338    let funding_rate_str = data
339        .funding_rate
340        .as_ref()
341        .context("Bybit ticker missing funding_rate")?;
342
343    let funding_rate = funding_rate_str
344        .as_str()
345        .parse::<Decimal>()
346        .context("invalid funding_rate value")?
347        .normalize();
348
349    let next_funding_ns = if let Some(next_funding_time) = &data.next_funding_time {
350        let next_funding_millis = next_funding_time
351            .as_str()
352            .parse::<i64>()
353            .context("invalid next_funding_time value")?;
354        Some(parse_millis_i64(next_funding_millis, "next_funding_time")?)
355    } else {
356        None
357    };
358
359    Ok(FundingRateUpdate::new(
360        instrument_id,
361        funding_rate,
362        next_funding_ns,
363        ts_event,
364        ts_init,
365    ))
366}
367
368pub(crate) fn parse_millis_i64(value: i64, field: &str) -> anyhow::Result<UnixNanos> {
369    if value < 0 {
370        Err(anyhow::anyhow!("{field} must be non-negative, was {value}"))
371    } else {
372        let nanos = (value as u64)
373            .checked_mul(NANOSECONDS_IN_MILLISECOND)
374            .ok_or_else(|| anyhow::anyhow!("millisecond timestamp overflowed"))?;
375        Ok(UnixNanos::from(nanos))
376    }
377}
378
379fn parse_book_level(
380    level: &[String],
381    price_precision: u8,
382    size_precision: u8,
383    label: &str,
384) -> anyhow::Result<(Price, Quantity)> {
385    let price_str = level
386        .first()
387        .ok_or_else(|| anyhow::anyhow!("missing price component in {label} level"))?;
388    let size_str = level
389        .get(1)
390        .ok_or_else(|| anyhow::anyhow!("missing size component in {label} level"))?;
391    let price = parse_price_with_precision(price_str, price_precision, label)?;
392    let size = parse_quantity_with_precision(size_str, size_precision, label)?;
393    Ok((price, size))
394}
395
396/// Parses a WebSocket kline payload into a [`Bar`].
397///
398/// # Errors
399///
400/// Returns an error if price or volume fields cannot be parsed or if the bar cannot be constructed.
401pub fn parse_ws_kline_bar(
402    kline: &BybitWsKline,
403    instrument: &InstrumentAny,
404    bar_type: BarType,
405    timestamp_on_close: bool,
406    ts_init: UnixNanos,
407) -> anyhow::Result<Bar> {
408    let price_precision = instrument.price_precision();
409    let size_precision = instrument.size_precision();
410
411    let open = parse_price_with_precision(&kline.open, price_precision, "kline.open")?;
412    let high = parse_price_with_precision(&kline.high, price_precision, "kline.high")?;
413    let low = parse_price_with_precision(&kline.low, price_precision, "kline.low")?;
414    let close = parse_price_with_precision(&kline.close, price_precision, "kline.close")?;
415    let volume = parse_quantity_with_precision(&kline.volume, size_precision, "kline.volume")?;
416
417    let mut ts_event = parse_millis_i64(kline.start, "kline.start")?;
418    if timestamp_on_close {
419        let interval_ns = bar_type
420            .spec()
421            .timedelta()
422            .num_nanoseconds()
423            .context("bar specification produced non-integer interval")?;
424        let interval_ns = u64::try_from(interval_ns)
425            .context("bar interval overflowed the u64 range for nanoseconds")?;
426        let updated = ts_event
427            .as_u64()
428            .checked_add(interval_ns)
429            .context("bar timestamp overflowed when adjusting to close time")?;
430        ts_event = UnixNanos::from(updated);
431    }
432    let ts_init = if ts_init.is_zero() { ts_event } else { ts_init };
433
434    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
435        .context("failed to construct Bar from Bybit WebSocket kline")
436}
437
438/// Parses a WebSocket account order payload into an [`OrderStatusReport`].
439///
440/// # Errors
441///
442/// Returns an error if price or quantity fields cannot be parsed or timestamps are invalid.
443pub fn parse_ws_order_status_report(
444    order: &BybitWsAccountOrder,
445    instrument: &InstrumentAny,
446    account_id: AccountId,
447    ts_init: UnixNanos,
448) -> anyhow::Result<OrderStatusReport> {
449    let instrument_id = instrument.id();
450    let venue_order_id = VenueOrderId::new(order.order_id.as_str());
451    let order_side: OrderSide = order.side.into();
452
453    // Bybit represents conditional orders using orderType + stopOrderType + triggerDirection + side
454    use crate::common::enums::BybitOrderSide;
455    let order_type: OrderType = match (
456        order.order_type,
457        order.stop_order_type,
458        order.trigger_direction,
459        order.side,
460    ) {
461        (BybitOrderType::Market, BybitStopOrderType::None | BybitStopOrderType::Unknown, _, _) => {
462            OrderType::Market
463        }
464        (BybitOrderType::Limit, BybitStopOrderType::None | BybitStopOrderType::Unknown, _, _) => {
465            OrderType::Limit
466        }
467
468        (
469            BybitOrderType::Market,
470            BybitStopOrderType::Stop,
471            BybitTriggerDirection::RisesTo,
472            BybitOrderSide::Buy,
473        ) => OrderType::StopMarket,
474        (
475            BybitOrderType::Market,
476            BybitStopOrderType::Stop,
477            BybitTriggerDirection::FallsTo,
478            BybitOrderSide::Buy,
479        ) => OrderType::MarketIfTouched,
480
481        (
482            BybitOrderType::Market,
483            BybitStopOrderType::Stop,
484            BybitTriggerDirection::FallsTo,
485            BybitOrderSide::Sell,
486        ) => OrderType::StopMarket,
487        (
488            BybitOrderType::Market,
489            BybitStopOrderType::Stop,
490            BybitTriggerDirection::RisesTo,
491            BybitOrderSide::Sell,
492        ) => OrderType::MarketIfTouched,
493
494        (
495            BybitOrderType::Limit,
496            BybitStopOrderType::Stop,
497            BybitTriggerDirection::RisesTo,
498            BybitOrderSide::Buy,
499        ) => OrderType::StopLimit,
500        (
501            BybitOrderType::Limit,
502            BybitStopOrderType::Stop,
503            BybitTriggerDirection::FallsTo,
504            BybitOrderSide::Buy,
505        ) => OrderType::LimitIfTouched,
506
507        (
508            BybitOrderType::Limit,
509            BybitStopOrderType::Stop,
510            BybitTriggerDirection::FallsTo,
511            BybitOrderSide::Sell,
512        ) => OrderType::StopLimit,
513        (
514            BybitOrderType::Limit,
515            BybitStopOrderType::Stop,
516            BybitTriggerDirection::RisesTo,
517            BybitOrderSide::Sell,
518        ) => OrderType::LimitIfTouched,
519
520        // triggerDirection=None means regular order with TP/SL attached, not a standalone conditional order
521        (BybitOrderType::Market, BybitStopOrderType::Stop, BybitTriggerDirection::None, _) => {
522            OrderType::Market
523        }
524        (BybitOrderType::Limit, BybitStopOrderType::Stop, BybitTriggerDirection::None, _) => {
525            OrderType::Limit
526        }
527
528        // TP/SL stopOrderTypes are attached to positions, not standalone conditional orders
529        (BybitOrderType::Market, _, _, _) => OrderType::Market,
530        (BybitOrderType::Limit, _, _, _) => OrderType::Limit,
531
532        (BybitOrderType::Unknown, _, _, _) => OrderType::Limit,
533    };
534
535    let time_in_force: TimeInForce = match order.time_in_force {
536        BybitTimeInForce::Gtc => TimeInForce::Gtc,
537        BybitTimeInForce::Ioc => TimeInForce::Ioc,
538        BybitTimeInForce::Fok => TimeInForce::Fok,
539        BybitTimeInForce::PostOnly => TimeInForce::Gtc,
540    };
541
542    let quantity =
543        parse_quantity_with_precision(&order.qty, instrument.size_precision(), "order.qty")?;
544
545    let filled_qty = parse_quantity_with_precision(
546        &order.cum_exec_qty,
547        instrument.size_precision(),
548        "order.cumExecQty",
549    )?;
550
551    // Map Bybit order status to Nautilus order status
552    // Special case: if Bybit reports "Rejected" but the order has fills, treat it as Canceled.
553    // This handles the case where the exchange partially fills an order then rejects the
554    // remaining quantity (e.g., due to margin, risk limits, or liquidity constraints).
555    // The state machine does not allow PARTIALLY_FILLED -> REJECTED transitions.
556    let order_status: OrderStatus = match order.order_status {
557        BybitOrderStatus::Created | BybitOrderStatus::New | BybitOrderStatus::Untriggered => {
558            OrderStatus::Accepted
559        }
560        BybitOrderStatus::Rejected => {
561            if filled_qty.is_positive() {
562                OrderStatus::Canceled
563            } else {
564                OrderStatus::Rejected
565            }
566        }
567        BybitOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
568        BybitOrderStatus::Filled => OrderStatus::Filled,
569        BybitOrderStatus::Canceled | BybitOrderStatus::PartiallyFilledCanceled => {
570            OrderStatus::Canceled
571        }
572        BybitOrderStatus::Triggered => OrderStatus::Triggered,
573        BybitOrderStatus::Deactivated => OrderStatus::Canceled,
574    };
575
576    let ts_accepted = parse_millis_timestamp(&order.created_time, "order.createdTime")?;
577    let ts_last = parse_millis_timestamp(&order.updated_time, "order.updatedTime")?;
578
579    let mut report = OrderStatusReport::new(
580        account_id,
581        instrument_id,
582        None,
583        venue_order_id,
584        order_side,
585        order_type,
586        time_in_force,
587        order_status,
588        quantity,
589        filled_qty,
590        ts_accepted,
591        ts_last,
592        ts_init,
593        Some(UUID4::new()),
594    );
595
596    if !order.order_link_id.is_empty() {
597        report = report.with_client_order_id(ClientOrderId::new(order.order_link_id.as_str()));
598    }
599
600    if !order.price.is_empty() && order.price != "0" {
601        let price =
602            parse_price_with_precision(&order.price, instrument.price_precision(), "order.price")?;
603        report = report.with_price(price);
604    }
605
606    if !order.avg_price.is_empty() && order.avg_price != "0" {
607        let avg_px = order
608            .avg_price
609            .parse::<f64>()
610            .with_context(|| format!("Failed to parse avg_price='{}' as f64", order.avg_price))?;
611        report = report.with_avg_px(avg_px)?;
612    }
613
614    if !order.trigger_price.is_empty() && order.trigger_price != "0" {
615        let trigger_price = parse_price_with_precision(
616            &order.trigger_price,
617            instrument.price_precision(),
618            "order.triggerPrice",
619        )?;
620        report = report.with_trigger_price(trigger_price);
621
622        // Set trigger_type for conditional orders
623        let trigger_type: TriggerType = order.trigger_by.into();
624        report = report.with_trigger_type(trigger_type);
625    }
626
627    if order.reduce_only {
628        report = report.with_reduce_only(true);
629    }
630
631    if order.time_in_force == BybitTimeInForce::PostOnly {
632        report = report.with_post_only(true);
633    }
634
635    if !order.reject_reason.is_empty() {
636        report = report.with_cancel_reason(order.reject_reason.to_string());
637    }
638
639    Ok(report)
640}
641
642/// Parses a WebSocket account execution payload into a [`FillReport`].
643///
644/// # Errors
645///
646/// Returns an error if price or quantity fields cannot be parsed or timestamps are invalid.
647pub fn parse_ws_fill_report(
648    execution: &BybitWsAccountExecution,
649    account_id: AccountId,
650    instrument: &InstrumentAny,
651    ts_init: UnixNanos,
652) -> anyhow::Result<FillReport> {
653    let instrument_id = instrument.id();
654    let venue_order_id = VenueOrderId::new(execution.order_id.as_str());
655    let trade_id = TradeId::new_checked(execution.exec_id.as_str())
656        .context("invalid execId in Bybit WebSocket execution payload")?;
657
658    let order_side: OrderSide = execution.side.into();
659    let last_qty = parse_quantity_with_precision(
660        &execution.exec_qty,
661        instrument.size_precision(),
662        "execution.execQty",
663    )?;
664    let last_px = parse_price_with_precision(
665        &execution.exec_price,
666        instrument.price_precision(),
667        "execution.execPrice",
668    )?;
669
670    let liquidity_side = if execution.is_maker {
671        LiquiditySide::Maker
672    } else {
673        LiquiditySide::Taker
674    };
675
676    let commission_str = execution.exec_fee.trim_start_matches('-');
677    let commission_amount = commission_str
678        .parse::<f64>()
679        .with_context(|| format!("Failed to parse execFee='{}' as f64", execution.exec_fee))?
680        .abs();
681
682    // Use instrument quote currency for commission
683    let commission_currency = instrument.quote_currency();
684    let commission = Money::new(commission_amount, commission_currency);
685    let ts_event = parse_millis_timestamp(&execution.exec_time, "execution.execTime")?;
686
687    let client_order_id = if !execution.order_link_id.is_empty() {
688        Some(ClientOrderId::new(execution.order_link_id.as_str()))
689    } else {
690        None
691    };
692
693    Ok(FillReport::new(
694        account_id,
695        instrument_id,
696        venue_order_id,
697        trade_id,
698        order_side,
699        last_qty,
700        last_px,
701        commission,
702        liquidity_side,
703        client_order_id,
704        None, // venue_position_id
705        ts_event,
706        ts_init,
707        None, // report_id
708    ))
709}
710
711/// Parses a WebSocket account position payload into a [`PositionStatusReport`].
712///
713/// # Errors
714///
715/// Returns an error if position size or prices cannot be parsed.
716pub fn parse_ws_position_status_report(
717    position: &BybitWsAccountPosition,
718    account_id: AccountId,
719    instrument: &InstrumentAny,
720    ts_init: UnixNanos,
721) -> anyhow::Result<PositionStatusReport> {
722    let instrument_id = instrument.id();
723
724    // Parse absolute size as unsigned Quantity
725    let quantity = parse_quantity_with_precision(
726        &position.size,
727        instrument.size_precision(),
728        "position.size",
729    )?;
730
731    // Derive position side from the side field
732    let position_side = if position.side.eq_ignore_ascii_case("buy") {
733        PositionSideSpecified::Long
734    } else if position.side.eq_ignore_ascii_case("sell") {
735        PositionSideSpecified::Short
736    } else {
737        PositionSideSpecified::Flat
738    };
739
740    let ts_last = parse_millis_timestamp(&position.updated_time, "position.updatedTime")?;
741
742    Ok(PositionStatusReport::new(
743        account_id,
744        instrument_id,
745        position_side,
746        quantity,
747        ts_last,
748        ts_init,
749        None,                 // report_id
750        None,                 // venue_position_id
751        position.entry_price, // avg_px_open
752    ))
753}
754
755/// Parses a WebSocket account wallet payload into an [`AccountState`].
756///
757/// # Errors
758///
759/// Returns an error if balance fields cannot be parsed.
760pub fn parse_ws_account_state(
761    wallet: &BybitWsAccountWallet,
762    account_id: AccountId,
763    ts_event: UnixNanos,
764    ts_init: UnixNanos,
765) -> anyhow::Result<AccountState> {
766    let mut balances = Vec::new();
767
768    for coin_data in &wallet.coin {
769        let currency = get_currency(coin_data.coin.as_str());
770        let total_dec = coin_data.wallet_balance - coin_data.spot_borrow;
771        let locked_dec = coin_data.total_order_im + coin_data.total_position_im;
772
773        let total = Money::from_decimal(total_dec, currency)?;
774        let locked = Money::from_decimal(locked_dec, currency)?;
775        let free = Money::from_raw(total.raw - locked.raw, currency);
776
777        let balance = AccountBalance::new(total, locked, free);
778        balances.push(balance);
779    }
780
781    Ok(AccountState::new(
782        account_id,
783        AccountType::Margin, // Bybit unified account
784        balances,
785        vec![], // margins - Bybit doesn't provide per-instrument margin in wallet updates
786        true,   // is_reported
787        UUID4::new(),
788        ts_event,
789        ts_init,
790        None, // base_currency
791    ))
792}
793
794#[cfg(test)]
795mod tests {
796    use nautilus_model::{
797        data::BarSpecification,
798        enums::{AggregationSource, BarAggregation, PositionSide, PriceType},
799    };
800    use rstest::rstest;
801    use rust_decimal_macros::dec;
802
803    use super::*;
804    use crate::{
805        common::{
806            parse::{parse_linear_instrument, parse_option_instrument},
807            testing::load_test_json,
808        },
809        http::models::{BybitInstrumentLinearResponse, BybitInstrumentOptionResponse},
810        websocket::messages::{
811            BybitWsOrderbookDepthMsg, BybitWsTickerLinearMsg, BybitWsTickerOptionMsg,
812            BybitWsTradeMsg,
813        },
814    };
815
816    const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
817
818    use ustr::Ustr;
819
820    use crate::http::models::BybitFeeRate;
821
822    fn sample_fee_rate(
823        symbol: &str,
824        taker: &str,
825        maker: &str,
826        base_coin: Option<&str>,
827    ) -> BybitFeeRate {
828        BybitFeeRate {
829            symbol: Ustr::from(symbol),
830            taker_fee_rate: taker.to_string(),
831            maker_fee_rate: maker.to_string(),
832            base_coin: base_coin.map(Ustr::from),
833        }
834    }
835
836    fn linear_instrument() -> InstrumentAny {
837        let json = load_test_json("http_get_instruments_linear.json");
838        let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
839        let instrument = &response.result.list[0];
840        let fee_rate = sample_fee_rate("BTCUSDT", "0.00055", "0.0001", Some("BTC"));
841        parse_linear_instrument(instrument, &fee_rate, TS, TS).unwrap()
842    }
843
844    fn option_instrument() -> InstrumentAny {
845        let json = load_test_json("http_get_instruments_option.json");
846        let response: BybitInstrumentOptionResponse = serde_json::from_str(&json).unwrap();
847        let instrument = &response.result.list[0];
848        parse_option_instrument(instrument, TS, TS).unwrap()
849    }
850
851    #[rstest]
852    fn parse_ws_trade_into_trade_tick() {
853        let instrument = linear_instrument();
854        let json = load_test_json("ws_public_trade.json");
855        let msg: BybitWsTradeMsg = serde_json::from_str(&json).unwrap();
856        let trade = &msg.data[0];
857
858        let tick = parse_ws_trade_tick(trade, &instrument, TS).unwrap();
859
860        assert_eq!(tick.instrument_id, instrument.id());
861        assert_eq!(tick.price, instrument.make_price(27451.00));
862        assert_eq!(tick.size, instrument.make_qty(0.010, None));
863        assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
864        assert_eq!(
865            tick.trade_id.to_string(),
866            "9dc75fca-4bdd-4773-9f78-6f5d7ab2a110"
867        );
868        assert_eq!(tick.ts_event, UnixNanos::new(1_709_891_679_000_000_000));
869    }
870
871    #[rstest]
872    fn parse_orderbook_snapshot_into_deltas() {
873        let instrument = linear_instrument();
874        let json = load_test_json("ws_orderbook_snapshot.json");
875        let msg: BybitWsOrderbookDepthMsg = serde_json::from_str(&json).unwrap();
876
877        let deltas = parse_orderbook_deltas(&msg, &instrument, TS).unwrap();
878
879        assert_eq!(deltas.instrument_id, instrument.id());
880        assert_eq!(deltas.deltas.len(), 5);
881        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
882        assert_eq!(
883            deltas.deltas[1].order.price,
884            instrument.make_price(27450.00)
885        );
886        assert_eq!(
887            deltas.deltas[1].order.size,
888            instrument.make_qty(0.500, None)
889        );
890        let last = deltas.deltas.last().unwrap();
891        assert_eq!(last.order.side, OrderSide::Sell);
892        assert_eq!(last.order.price, instrument.make_price(27451.50));
893        assert_eq!(
894            last.flags & RecordFlag::F_LAST as u8,
895            RecordFlag::F_LAST as u8
896        );
897    }
898
899    #[rstest]
900    fn parse_orderbook_delta_marks_actions() {
901        let instrument = linear_instrument();
902        let json = load_test_json("ws_orderbook_delta.json");
903        let msg: BybitWsOrderbookDepthMsg = serde_json::from_str(&json).unwrap();
904
905        let deltas = parse_orderbook_deltas(&msg, &instrument, TS).unwrap();
906
907        assert_eq!(deltas.deltas.len(), 2);
908        let bid = &deltas.deltas[0];
909        assert_eq!(bid.action, BookAction::Update);
910        assert_eq!(bid.order.side, OrderSide::Buy);
911        assert_eq!(bid.order.size, instrument.make_qty(0.400, None));
912
913        let ask = &deltas.deltas[1];
914        assert_eq!(ask.action, BookAction::Delete);
915        assert_eq!(ask.order.side, OrderSide::Sell);
916        assert_eq!(ask.order.size, instrument.make_qty(0.0, None));
917        assert_eq!(
918            ask.flags & RecordFlag::F_LAST as u8,
919            RecordFlag::F_LAST as u8
920        );
921    }
922
923    #[rstest]
924    fn parse_orderbook_quote_produces_top_of_book() {
925        let instrument = linear_instrument();
926        let json = load_test_json("ws_orderbook_snapshot.json");
927        let msg: BybitWsOrderbookDepthMsg = serde_json::from_str(&json).unwrap();
928
929        let quote = parse_orderbook_quote(&msg, &instrument, None, TS).unwrap();
930
931        assert_eq!(quote.instrument_id, instrument.id());
932        assert_eq!(quote.bid_price, instrument.make_price(27450.00));
933        assert_eq!(quote.bid_size, instrument.make_qty(0.500, None));
934        assert_eq!(quote.ask_price, instrument.make_price(27451.00));
935        assert_eq!(quote.ask_size, instrument.make_qty(0.750, None));
936    }
937
938    #[rstest]
939    fn parse_orderbook_quote_with_delta_updates_sizes() {
940        let instrument = linear_instrument();
941        let snapshot: BybitWsOrderbookDepthMsg =
942            serde_json::from_str(&load_test_json("ws_orderbook_snapshot.json")).unwrap();
943        let base_quote = parse_orderbook_quote(&snapshot, &instrument, None, TS).unwrap();
944
945        let delta: BybitWsOrderbookDepthMsg =
946            serde_json::from_str(&load_test_json("ws_orderbook_delta.json")).unwrap();
947        let updated = parse_orderbook_quote(&delta, &instrument, Some(&base_quote), TS).unwrap();
948
949        assert_eq!(updated.bid_price, instrument.make_price(27450.00));
950        assert_eq!(updated.bid_size, instrument.make_qty(0.400, None));
951        assert_eq!(updated.ask_price, instrument.make_price(27451.00));
952        assert_eq!(updated.ask_size, instrument.make_qty(0.0, None));
953    }
954
955    #[rstest]
956    fn parse_linear_ticker_quote_to_quote_tick() {
957        let instrument = linear_instrument();
958        let json = load_test_json("ws_ticker_linear.json");
959        let msg: BybitWsTickerLinearMsg = serde_json::from_str(&json).unwrap();
960
961        let quote = parse_ticker_linear_quote(&msg, &instrument, TS).unwrap();
962
963        assert_eq!(quote.instrument_id, instrument.id());
964        assert_eq!(quote.bid_price, instrument.make_price(17215.50));
965        assert_eq!(quote.ask_price, instrument.make_price(17216.00));
966        assert_eq!(quote.bid_size, instrument.make_qty(84.489, None));
967        assert_eq!(quote.ask_size, instrument.make_qty(83.020, None));
968        assert_eq!(quote.ts_event, UnixNanos::new(1_673_272_861_686_000_000));
969        assert_eq!(quote.ts_init, TS);
970    }
971
972    #[rstest]
973    fn parse_option_ticker_quote_to_quote_tick() {
974        let instrument = option_instrument();
975        let json = load_test_json("ws_ticker_option.json");
976        let msg: BybitWsTickerOptionMsg = serde_json::from_str(&json).unwrap();
977
978        let quote = parse_ticker_option_quote(&msg, &instrument, TS).unwrap();
979
980        assert_eq!(quote.instrument_id, instrument.id());
981        assert_eq!(quote.bid_price, instrument.make_price(0.0));
982        assert_eq!(quote.ask_price, instrument.make_price(10.0));
983        assert_eq!(quote.bid_size, instrument.make_qty(0.0, None));
984        assert_eq!(quote.ask_size, instrument.make_qty(5.1, None));
985        assert_eq!(quote.ts_event, UnixNanos::new(1_672_917_511_074_000_000));
986        assert_eq!(quote.ts_init, TS);
987    }
988
989    #[rstest]
990    fn parse_ws_kline_into_bar() {
991        use std::num::NonZero;
992
993        let instrument = linear_instrument();
994        let json = load_test_json("ws_kline.json");
995        let msg: crate::websocket::messages::BybitWsKlineMsg = serde_json::from_str(&json).unwrap();
996        let kline = &msg.data[0];
997
998        let bar_spec = BarSpecification {
999            step: NonZero::new(5).unwrap(),
1000            aggregation: BarAggregation::Minute,
1001            price_type: PriceType::Last,
1002        };
1003        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::External);
1004
1005        let bar = parse_ws_kline_bar(kline, &instrument, bar_type, false, TS).unwrap();
1006
1007        assert_eq!(bar.bar_type, bar_type);
1008        assert_eq!(bar.open, instrument.make_price(16649.5));
1009        assert_eq!(bar.high, instrument.make_price(16677.0));
1010        assert_eq!(bar.low, instrument.make_price(16608.0));
1011        assert_eq!(bar.close, instrument.make_price(16677.0));
1012        assert_eq!(bar.volume, instrument.make_qty(2.081, None));
1013        assert_eq!(bar.ts_event, UnixNanos::new(1_672_324_800_000_000_000));
1014        assert_eq!(bar.ts_init, TS);
1015    }
1016
1017    #[rstest]
1018    fn parse_ws_order_into_order_status_report() {
1019        let instrument = linear_instrument();
1020        let json = load_test_json("ws_account_order_filled.json");
1021        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1022            serde_json::from_str(&json).unwrap();
1023        let order = &msg.data[0];
1024        let account_id = AccountId::new("BYBIT-001");
1025
1026        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1027
1028        assert_eq!(report.account_id, account_id);
1029        assert_eq!(report.instrument_id, instrument.id());
1030        assert_eq!(report.order_side, OrderSide::Buy);
1031        assert_eq!(report.order_type, OrderType::Limit);
1032        assert_eq!(report.time_in_force, TimeInForce::Gtc);
1033        assert_eq!(report.order_status, OrderStatus::Filled);
1034        assert_eq!(report.quantity, instrument.make_qty(0.100, None));
1035        assert_eq!(report.filled_qty, instrument.make_qty(0.100, None));
1036        assert_eq!(report.price, Some(instrument.make_price(30000.50)));
1037        assert_eq!(report.avg_px, Some(dec!(30000.50)));
1038        assert_eq!(
1039            report.client_order_id.as_ref().unwrap().to_string(),
1040            "test-client-order-001"
1041        );
1042        assert_eq!(
1043            report.ts_accepted,
1044            UnixNanos::new(1_672_364_262_444_000_000)
1045        );
1046        assert_eq!(report.ts_last, UnixNanos::new(1_672_364_262_457_000_000));
1047    }
1048
1049    #[rstest]
1050    fn parse_ws_order_partially_filled_rejected_maps_to_canceled() {
1051        let instrument = linear_instrument();
1052        let json = load_test_json("ws_account_order_partially_filled_rejected.json");
1053        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1054            serde_json::from_str(&json).unwrap();
1055        let order = &msg.data[0];
1056        let account_id = AccountId::new("BYBIT-001");
1057
1058        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1059
1060        // Verify that Bybit "Rejected" status with fills is mapped to Canceled, not Rejected
1061        assert_eq!(report.order_status, OrderStatus::Canceled);
1062        assert_eq!(report.filled_qty, instrument.make_qty(50.0, None));
1063        assert_eq!(
1064            report.client_order_id.as_ref().unwrap().to_string(),
1065            "O-20251001-164609-APEX-000-49"
1066        );
1067        assert_eq!(report.cancel_reason, Some("UNKNOWN".to_string()));
1068    }
1069
1070    #[rstest]
1071    fn parse_ws_execution_into_fill_report() {
1072        let instrument = linear_instrument();
1073        let json = load_test_json("ws_account_execution.json");
1074        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1075            serde_json::from_str(&json).unwrap();
1076        let execution = &msg.data[0];
1077        let account_id = AccountId::new("BYBIT-001");
1078
1079        let report = parse_ws_fill_report(execution, account_id, &instrument, TS).unwrap();
1080
1081        assert_eq!(report.account_id, account_id);
1082        assert_eq!(report.instrument_id, instrument.id());
1083        assert_eq!(
1084            report.venue_order_id.to_string(),
1085            "9aac161b-8ed6-450d-9cab-c5cc67c21784"
1086        );
1087        assert_eq!(
1088            report.trade_id.to_string(),
1089            "0ab1bdf7-4219-438b-b30a-32ec863018f7"
1090        );
1091        assert_eq!(report.order_side, OrderSide::Sell);
1092        assert_eq!(report.last_qty, instrument.make_qty(0.5, None));
1093        assert_eq!(report.last_px, instrument.make_price(95900.1));
1094        assert_eq!(report.commission.as_f64(), 26.3725275);
1095        assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1096        assert_eq!(
1097            report.client_order_id.as_ref().unwrap().to_string(),
1098            "test-order-link-001"
1099        );
1100        assert_eq!(report.ts_event, UnixNanos::new(1_746_270_400_353_000_000));
1101    }
1102
1103    #[rstest]
1104    fn parse_ws_position_into_position_status_report() {
1105        let instrument = linear_instrument();
1106        let json = load_test_json("ws_account_position.json");
1107        let msg: crate::websocket::messages::BybitWsAccountPositionMsg =
1108            serde_json::from_str(&json).unwrap();
1109        let position = &msg.data[0];
1110        let account_id = AccountId::new("BYBIT-001");
1111
1112        let report =
1113            parse_ws_position_status_report(position, account_id, &instrument, TS).unwrap();
1114
1115        assert_eq!(report.account_id, account_id);
1116        assert_eq!(report.instrument_id, instrument.id());
1117        assert_eq!(report.position_side.as_position_side(), PositionSide::Short);
1118        assert_eq!(report.quantity, instrument.make_qty(0.01, None));
1119        assert_eq!(
1120            report.avg_px_open,
1121            Some(Decimal::try_from(3641.075).unwrap())
1122        );
1123        assert_eq!(report.ts_last, UnixNanos::new(1_762_199_125_472_000_000));
1124        assert_eq!(report.ts_init, TS);
1125    }
1126
1127    #[rstest]
1128    fn parse_ws_position_short_into_position_status_report() {
1129        // Create ETHUSDT instrument
1130        let instruments_json = load_test_json("http_get_instruments_linear.json");
1131        let instruments_response: crate::http::models::BybitInstrumentLinearResponse =
1132            serde_json::from_str(&instruments_json).unwrap();
1133        let eth_def = &instruments_response.result.list[1]; // ETHUSDT is second in the list
1134        let fee_rate = crate::http::models::BybitFeeRate {
1135            symbol: ustr::Ustr::from("ETHUSDT"),
1136            taker_fee_rate: "0.00055".to_string(),
1137            maker_fee_rate: "0.0001".to_string(),
1138            base_coin: Some(ustr::Ustr::from("ETH")),
1139        };
1140        let instrument =
1141            crate::common::parse::parse_linear_instrument(eth_def, &fee_rate, TS, TS).unwrap();
1142
1143        let json = load_test_json("ws_account_position_short.json");
1144        let msg: crate::websocket::messages::BybitWsAccountPositionMsg =
1145            serde_json::from_str(&json).unwrap();
1146        let position = &msg.data[0];
1147        let account_id = AccountId::new("BYBIT-001");
1148
1149        let report =
1150            parse_ws_position_status_report(position, account_id, &instrument, TS).unwrap();
1151
1152        assert_eq!(report.account_id, account_id);
1153        assert_eq!(report.instrument_id.symbol.as_str(), "ETHUSDT-LINEAR");
1154        assert_eq!(report.position_side.as_position_side(), PositionSide::Short);
1155        assert_eq!(report.quantity, instrument.make_qty(0.01, None));
1156        assert_eq!(
1157            report.avg_px_open,
1158            Some(Decimal::try_from(3641.075).unwrap())
1159        );
1160        assert_eq!(report.ts_last, UnixNanos::new(1_762_199_125_472_000_000));
1161        assert_eq!(report.ts_init, TS);
1162    }
1163
1164    #[rstest]
1165    fn parse_ws_wallet_into_account_state() {
1166        let json = load_test_json("ws_account_wallet.json");
1167        let msg: crate::websocket::messages::BybitWsAccountWalletMsg =
1168            serde_json::from_str(&json).unwrap();
1169        let wallet = &msg.data[0];
1170        let account_id = AccountId::new("BYBIT-001");
1171        let ts_event = UnixNanos::new(1_700_034_722_104_000_000);
1172
1173        let state = parse_ws_account_state(wallet, account_id, ts_event, TS).unwrap();
1174
1175        assert_eq!(state.account_id, account_id);
1176        assert_eq!(state.account_type, AccountType::Margin);
1177        assert_eq!(state.balances.len(), 2);
1178        assert!(state.is_reported);
1179
1180        // Check BTC balance
1181        let btc_balance = &state.balances[0];
1182        assert_eq!(btc_balance.currency.code.as_str(), "BTC");
1183        assert!((btc_balance.total.as_f64() - 0.00102964).abs() < 1e-8);
1184        assert!((btc_balance.free.as_f64() - 0.00092964).abs() < 1e-8);
1185        assert!((btc_balance.locked.as_f64() - 0.0001).abs() < 1e-8);
1186
1187        // Check USDT balance
1188        let usdt_balance = &state.balances[1];
1189        assert_eq!(usdt_balance.currency.code.as_str(), "USDT");
1190        assert!((usdt_balance.total.as_f64() - 9647.75537647).abs() < 1e-6);
1191        assert!((usdt_balance.free.as_f64() - 9519.89806037).abs() < 1e-6);
1192        assert!((usdt_balance.locked.as_f64() - 127.8573161).abs() < 1e-6);
1193
1194        assert_eq!(state.ts_event, ts_event);
1195        assert_eq!(state.ts_init, TS);
1196    }
1197
1198    #[rstest]
1199    fn parse_ws_wallet_with_small_order_calculates_free_correctly() {
1200        // Regression test for issue where availableToWithdraw=0 caused all funds to appear locked
1201        // When a small order is placed, Bybit may report availableToWithdraw=0 due to margin calculations,
1202        // but totalOrderIM correctly shows only the margin locked for the order
1203        let json = load_test_json("ws_account_wallet_small_order.json");
1204        let msg: crate::websocket::messages::BybitWsAccountWalletMsg =
1205            serde_json::from_str(&json).unwrap();
1206        let wallet = &msg.data[0];
1207        let account_id = AccountId::new("BYBIT-UNIFIED");
1208        let ts_event = UnixNanos::new(1_762_960_669_000_000_000);
1209
1210        let state = parse_ws_account_state(wallet, account_id, ts_event, TS).unwrap();
1211
1212        assert_eq!(state.account_id, account_id);
1213        assert_eq!(state.balances.len(), 1);
1214
1215        // Check USDT balance
1216        let usdt_balance = &state.balances[0];
1217        assert_eq!(usdt_balance.currency.code.as_str(), "USDT");
1218
1219        // Wallet has 51,333.82 USDT total
1220        assert!((usdt_balance.total.as_f64() - 51333.82543837).abs() < 1e-6);
1221
1222        // Only 50.028 USDT should be locked (for the order), not all funds
1223        assert!((usdt_balance.locked.as_f64() - 50.028).abs() < 1e-6);
1224
1225        // Free should be total - locked = 51,333.82 - 50.028 = 51,283.79
1226        assert!((usdt_balance.free.as_f64() - 51283.79743837).abs() < 1e-6);
1227
1228        // The bug would have calculated: locked = total - availableToWithdraw = 51,333.82 - 0 = 51,333.82 (all locked!)
1229        // This test verifies that we now correctly use totalOrderIM instead of deriving from availableToWithdraw
1230    }
1231
1232    #[rstest]
1233    fn parse_ticker_linear_into_funding_rate() {
1234        let instrument = linear_instrument();
1235        let json = load_test_json("ws_ticker_linear.json");
1236        let msg: BybitWsTickerLinearMsg = serde_json::from_str(&json).unwrap();
1237
1238        let ts_event = UnixNanos::new(1_673_272_861_686_000_000);
1239
1240        let funding =
1241            parse_ticker_linear_funding(&msg.data, instrument.id(), ts_event, TS).unwrap();
1242
1243        assert_eq!(funding.instrument_id, instrument.id());
1244        assert_eq!(funding.rate, dec!(-0.000212)); // -0.000212
1245        assert_eq!(
1246            funding.next_funding_ns,
1247            Some(UnixNanos::new(1_673_280_000_000_000_000))
1248        );
1249        assert_eq!(funding.ts_event, ts_event);
1250        assert_eq!(funding.ts_init, TS);
1251    }
1252
1253    #[rstest]
1254    fn parse_ws_order_stop_market_sell_preserves_type() {
1255        let instrument = linear_instrument();
1256        let json = load_test_json("ws_account_order_stop_market.json");
1257        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1258            serde_json::from_str(&json).unwrap();
1259        let order = &msg.data[0];
1260        let account_id = AccountId::new("BYBIT-001");
1261
1262        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1263
1264        // Verify sell StopMarket: orderType=Market + stopOrderType=Stop + triggerDirection=2 (falls to)
1265        assert_eq!(report.order_type, OrderType::StopMarket);
1266        assert_eq!(report.order_side, OrderSide::Sell);
1267        assert_eq!(report.order_status, OrderStatus::Accepted); // Untriggered maps to Accepted
1268        assert_eq!(report.trigger_price, Some(instrument.make_price(45000.00)));
1269        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1270        assert_eq!(
1271            report.client_order_id.as_ref().unwrap().to_string(),
1272            "test-client-stop-market-001"
1273        );
1274    }
1275
1276    #[rstest]
1277    fn parse_ws_order_stop_market_buy_preserves_type() {
1278        let instrument = linear_instrument();
1279        let json = load_test_json("ws_account_order_buy_stop_market.json");
1280        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1281            serde_json::from_str(&json).unwrap();
1282        let order = &msg.data[0];
1283        let account_id = AccountId::new("BYBIT-001");
1284
1285        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1286
1287        // Verify buy StopMarket: orderType=Market + stopOrderType=Stop + triggerDirection=1 (rises to)
1288        assert_eq!(report.order_type, OrderType::StopMarket);
1289        assert_eq!(report.order_side, OrderSide::Buy);
1290        assert_eq!(report.order_status, OrderStatus::Accepted);
1291        assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
1292        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1293        assert_eq!(
1294            report.client_order_id.as_ref().unwrap().to_string(),
1295            "test-client-buy-stop-market-001"
1296        );
1297    }
1298
1299    #[rstest]
1300    fn parse_ws_order_market_if_touched_buy_preserves_type() {
1301        let instrument = linear_instrument();
1302        let json = load_test_json("ws_account_order_market_if_touched.json");
1303        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1304            serde_json::from_str(&json).unwrap();
1305        let order = &msg.data[0];
1306        let account_id = AccountId::new("BYBIT-001");
1307
1308        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1309
1310        // Verify buy MIT: orderType=Market + stopOrderType=Stop + triggerDirection=2 (falls to)
1311        assert_eq!(report.order_type, OrderType::MarketIfTouched);
1312        assert_eq!(report.order_side, OrderSide::Buy);
1313        assert_eq!(report.order_status, OrderStatus::Accepted); // Untriggered maps to Accepted
1314        assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
1315        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1316        assert_eq!(
1317            report.client_order_id.as_ref().unwrap().to_string(),
1318            "test-client-mit-001"
1319        );
1320    }
1321
1322    #[rstest]
1323    fn parse_ws_order_market_if_touched_sell_preserves_type() {
1324        let instrument = linear_instrument();
1325        let json = load_test_json("ws_account_order_sell_market_if_touched.json");
1326        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1327            serde_json::from_str(&json).unwrap();
1328        let order = &msg.data[0];
1329        let account_id = AccountId::new("BYBIT-001");
1330
1331        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1332
1333        // Verify sell MIT: orderType=Market + stopOrderType=Stop + triggerDirection=1 (rises to)
1334        assert_eq!(report.order_type, OrderType::MarketIfTouched);
1335        assert_eq!(report.order_side, OrderSide::Sell);
1336        assert_eq!(report.order_status, OrderStatus::Accepted);
1337        assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
1338        assert_eq!(
1339            report.client_order_id.as_ref().unwrap().to_string(),
1340            "test-client-sell-mit-001"
1341        );
1342    }
1343
1344    #[rstest]
1345    fn parse_ws_order_stop_limit_preserves_type() {
1346        let instrument = linear_instrument();
1347        let json = load_test_json("ws_account_order_stop_limit.json");
1348        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1349            serde_json::from_str(&json).unwrap();
1350        let order = &msg.data[0];
1351        let account_id = AccountId::new("BYBIT-001");
1352
1353        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1354
1355        // Verify StopLimit order type is correctly parsed
1356        // orderType=Limit + stopOrderType=Stop + triggerDirection=2 (falls to)
1357        assert_eq!(report.order_type, OrderType::StopLimit);
1358        assert_eq!(report.order_side, OrderSide::Sell);
1359        assert_eq!(report.order_status, OrderStatus::Accepted); // Untriggered maps to Accepted
1360        assert_eq!(report.price, Some(instrument.make_price(44500.00)));
1361        assert_eq!(report.trigger_price, Some(instrument.make_price(45000.00)));
1362        assert_eq!(
1363            report.client_order_id.as_ref().unwrap().to_string(),
1364            "test-client-stop-limit-001"
1365        );
1366    }
1367
1368    #[rstest]
1369    fn parse_ws_order_limit_if_touched_preserves_type() {
1370        let instrument = linear_instrument();
1371        let json = load_test_json("ws_account_order_limit_if_touched.json");
1372        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1373            serde_json::from_str(&json).unwrap();
1374        let order = &msg.data[0];
1375        let account_id = AccountId::new("BYBIT-001");
1376
1377        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1378
1379        // Verify LimitIfTouched order type is correctly parsed
1380        // orderType=Limit + stopOrderType=Stop + triggerDirection=1 (rises to)
1381        assert_eq!(report.order_type, OrderType::LimitIfTouched);
1382        assert_eq!(report.order_side, OrderSide::Buy);
1383        assert_eq!(report.order_status, OrderStatus::Accepted); // Untriggered maps to Accepted
1384        assert_eq!(report.price, Some(instrument.make_price(55500.00)));
1385        assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
1386        assert_eq!(
1387            report.client_order_id.as_ref().unwrap().to_string(),
1388            "test-client-lit-001"
1389        );
1390    }
1391}