nautilus_bybit/websocket/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 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    use crate::common::enums::BybitOrderSide;
450
451    let instrument_id = instrument.id();
452    let venue_order_id = VenueOrderId::new(order.order_id.as_str());
453    let order_side: OrderSide = order.side.into();
454
455    // Bybit represents conditional orders using orderType + stopOrderType + triggerDirection + side
456    let order_type: OrderType = match (
457        order.order_type,
458        order.stop_order_type,
459        order.trigger_direction,
460        order.side,
461    ) {
462        (BybitOrderType::Market, BybitStopOrderType::None | BybitStopOrderType::Unknown, _, _) => {
463            OrderType::Market
464        }
465        (BybitOrderType::Limit, BybitStopOrderType::None | BybitStopOrderType::Unknown, _, _) => {
466            OrderType::Limit
467        }
468
469        (
470            BybitOrderType::Market,
471            BybitStopOrderType::Stop,
472            BybitTriggerDirection::RisesTo,
473            BybitOrderSide::Buy,
474        ) => OrderType::StopMarket,
475        (
476            BybitOrderType::Market,
477            BybitStopOrderType::Stop,
478            BybitTriggerDirection::FallsTo,
479            BybitOrderSide::Buy,
480        ) => OrderType::MarketIfTouched,
481
482        (
483            BybitOrderType::Market,
484            BybitStopOrderType::Stop,
485            BybitTriggerDirection::FallsTo,
486            BybitOrderSide::Sell,
487        ) => OrderType::StopMarket,
488        (
489            BybitOrderType::Market,
490            BybitStopOrderType::Stop,
491            BybitTriggerDirection::RisesTo,
492            BybitOrderSide::Sell,
493        ) => OrderType::MarketIfTouched,
494
495        (
496            BybitOrderType::Limit,
497            BybitStopOrderType::Stop,
498            BybitTriggerDirection::RisesTo,
499            BybitOrderSide::Buy,
500        ) => OrderType::StopLimit,
501        (
502            BybitOrderType::Limit,
503            BybitStopOrderType::Stop,
504            BybitTriggerDirection::FallsTo,
505            BybitOrderSide::Buy,
506        ) => OrderType::LimitIfTouched,
507
508        (
509            BybitOrderType::Limit,
510            BybitStopOrderType::Stop,
511            BybitTriggerDirection::FallsTo,
512            BybitOrderSide::Sell,
513        ) => OrderType::StopLimit,
514        (
515            BybitOrderType::Limit,
516            BybitStopOrderType::Stop,
517            BybitTriggerDirection::RisesTo,
518            BybitOrderSide::Sell,
519        ) => OrderType::LimitIfTouched,
520
521        // triggerDirection=None means regular order with TP/SL attached, not a standalone conditional order
522        (BybitOrderType::Market, BybitStopOrderType::Stop, BybitTriggerDirection::None, _) => {
523            OrderType::Market
524        }
525        (BybitOrderType::Limit, BybitStopOrderType::Stop, BybitTriggerDirection::None, _) => {
526            OrderType::Limit
527        }
528
529        // TP/SL stopOrderTypes are attached to positions, not standalone conditional orders
530        (BybitOrderType::Market, _, _, _) => OrderType::Market,
531        (BybitOrderType::Limit, _, _, _) => OrderType::Limit,
532
533        (BybitOrderType::Unknown, _, _, _) => OrderType::Limit,
534    };
535
536    let time_in_force: TimeInForce = match order.time_in_force {
537        BybitTimeInForce::Gtc => TimeInForce::Gtc,
538        BybitTimeInForce::Ioc => TimeInForce::Ioc,
539        BybitTimeInForce::Fok => TimeInForce::Fok,
540        BybitTimeInForce::PostOnly => TimeInForce::Gtc,
541    };
542
543    let quantity =
544        parse_quantity_with_precision(&order.qty, instrument.size_precision(), "order.qty")?;
545
546    let filled_qty = parse_quantity_with_precision(
547        &order.cum_exec_qty,
548        instrument.size_precision(),
549        "order.cumExecQty",
550    )?;
551
552    // Map Bybit order status to Nautilus order status
553    // Special case: if Bybit reports "Rejected" but the order has fills, treat it as Canceled.
554    // This handles the case where the exchange partially fills an order then rejects the
555    // remaining quantity (e.g., due to margin, risk limits, or liquidity constraints).
556    // The state machine does not allow PARTIALLY_FILLED -> REJECTED transitions.
557    let order_status: OrderStatus = match order.order_status {
558        BybitOrderStatus::Created | BybitOrderStatus::New | BybitOrderStatus::Untriggered => {
559            OrderStatus::Accepted
560        }
561        BybitOrderStatus::Rejected => {
562            if filled_qty.is_positive() {
563                OrderStatus::Canceled
564            } else {
565                OrderStatus::Rejected
566            }
567        }
568        BybitOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
569        BybitOrderStatus::Filled => OrderStatus::Filled,
570        BybitOrderStatus::Canceled | BybitOrderStatus::PartiallyFilledCanceled => {
571            OrderStatus::Canceled
572        }
573        BybitOrderStatus::Triggered => OrderStatus::Triggered,
574        BybitOrderStatus::Deactivated => OrderStatus::Canceled,
575    };
576
577    let ts_accepted = parse_millis_timestamp(&order.created_time, "order.createdTime")?;
578    let ts_last = parse_millis_timestamp(&order.updated_time, "order.updatedTime")?;
579
580    let mut report = OrderStatusReport::new(
581        account_id,
582        instrument_id,
583        None,
584        venue_order_id,
585        order_side,
586        order_type,
587        time_in_force,
588        order_status,
589        quantity,
590        filled_qty,
591        ts_accepted,
592        ts_last,
593        ts_init,
594        Some(UUID4::new()),
595    );
596
597    if !order.order_link_id.is_empty() {
598        report = report.with_client_order_id(ClientOrderId::new(order.order_link_id.as_str()));
599    }
600
601    if !order.price.is_empty() && order.price != "0" {
602        let price =
603            parse_price_with_precision(&order.price, instrument.price_precision(), "order.price")?;
604        report = report.with_price(price);
605    }
606
607    if !order.avg_price.is_empty() && order.avg_price != "0" {
608        let avg_px = order
609            .avg_price
610            .parse::<f64>()
611            .with_context(|| format!("Failed to parse avg_price='{}' as f64", order.avg_price))?;
612        report = report.with_avg_px(avg_px)?;
613    }
614
615    if !order.trigger_price.is_empty() && order.trigger_price != "0" {
616        let trigger_price = parse_price_with_precision(
617            &order.trigger_price,
618            instrument.price_precision(),
619            "order.triggerPrice",
620        )?;
621        report = report.with_trigger_price(trigger_price);
622
623        // Set trigger_type for conditional orders
624        let trigger_type: TriggerType = order.trigger_by.into();
625        report = report.with_trigger_type(trigger_type);
626    }
627
628    if order.reduce_only {
629        report = report.with_reduce_only(true);
630    }
631
632    if order.time_in_force == BybitTimeInForce::PostOnly {
633        report = report.with_post_only(true);
634    }
635
636    if !order.reject_reason.is_empty() {
637        report = report.with_cancel_reason(order.reject_reason.to_string());
638    }
639
640    Ok(report)
641}
642
643/// Parses a WebSocket account execution payload into a [`FillReport`].
644///
645/// # Errors
646///
647/// Returns an error if price or quantity fields cannot be parsed or timestamps are invalid.
648pub fn parse_ws_fill_report(
649    execution: &BybitWsAccountExecution,
650    account_id: AccountId,
651    instrument: &InstrumentAny,
652    ts_init: UnixNanos,
653) -> anyhow::Result<FillReport> {
654    let instrument_id = instrument.id();
655    let venue_order_id = VenueOrderId::new(execution.order_id.as_str());
656    let trade_id = TradeId::new_checked(execution.exec_id.as_str())
657        .context("invalid execId in Bybit WebSocket execution payload")?;
658
659    let order_side: OrderSide = execution.side.into();
660    let last_qty = parse_quantity_with_precision(
661        &execution.exec_qty,
662        instrument.size_precision(),
663        "execution.execQty",
664    )?;
665    let last_px = parse_price_with_precision(
666        &execution.exec_price,
667        instrument.price_precision(),
668        "execution.execPrice",
669    )?;
670
671    let liquidity_side = if execution.is_maker {
672        LiquiditySide::Maker
673    } else {
674        LiquiditySide::Taker
675    };
676
677    let commission_str = execution.exec_fee.trim_start_matches('-');
678    let commission_amount = commission_str
679        .parse::<f64>()
680        .with_context(|| format!("Failed to parse execFee='{}' as f64", execution.exec_fee))?
681        .abs();
682
683    // Use instrument quote currency for commission
684    let commission_currency = instrument.quote_currency();
685    let commission = Money::new(commission_amount, commission_currency);
686    let ts_event = parse_millis_timestamp(&execution.exec_time, "execution.execTime")?;
687
688    let client_order_id = if execution.order_link_id.is_empty() {
689        None
690    } else {
691        Some(ClientOrderId::new(execution.order_link_id.as_str()))
692    };
693
694    Ok(FillReport::new(
695        account_id,
696        instrument_id,
697        venue_order_id,
698        trade_id,
699        order_side,
700        last_qty,
701        last_px,
702        commission,
703        liquidity_side,
704        client_order_id,
705        None, // venue_position_id
706        ts_event,
707        ts_init,
708        None, // report_id
709    ))
710}
711
712/// Parses a WebSocket account position payload into a [`PositionStatusReport`].
713///
714/// # Errors
715///
716/// Returns an error if position size or prices cannot be parsed.
717pub fn parse_ws_position_status_report(
718    position: &BybitWsAccountPosition,
719    account_id: AccountId,
720    instrument: &InstrumentAny,
721    ts_init: UnixNanos,
722) -> anyhow::Result<PositionStatusReport> {
723    let instrument_id = instrument.id();
724
725    // Parse absolute size as unsigned Quantity
726    let quantity = parse_quantity_with_precision(
727        &position.size,
728        instrument.size_precision(),
729        "position.size",
730    )?;
731
732    // Derive position side from the side field
733    let position_side = if position.side.eq_ignore_ascii_case("buy") {
734        PositionSideSpecified::Long
735    } else if position.side.eq_ignore_ascii_case("sell") {
736        PositionSideSpecified::Short
737    } else {
738        PositionSideSpecified::Flat
739    };
740
741    let ts_last = parse_millis_timestamp(&position.updated_time, "position.updatedTime")?;
742
743    Ok(PositionStatusReport::new(
744        account_id,
745        instrument_id,
746        position_side,
747        quantity,
748        ts_last,
749        ts_init,
750        None,                 // report_id
751        None,                 // venue_position_id
752        position.entry_price, // avg_px_open
753    ))
754}
755
756/// Parses a WebSocket account wallet payload into an [`AccountState`].
757///
758/// # Errors
759///
760/// Returns an error if balance fields cannot be parsed.
761pub fn parse_ws_account_state(
762    wallet: &BybitWsAccountWallet,
763    account_id: AccountId,
764    ts_event: UnixNanos,
765    ts_init: UnixNanos,
766) -> anyhow::Result<AccountState> {
767    let mut balances = Vec::new();
768
769    for coin_data in &wallet.coin {
770        let currency = get_currency(coin_data.coin.as_str());
771        let total_dec = coin_data.wallet_balance - coin_data.spot_borrow;
772        let locked_dec = coin_data.total_order_im + coin_data.total_position_im;
773
774        let total = Money::from_decimal(total_dec, currency)?;
775        let locked = Money::from_decimal(locked_dec, currency)?;
776        let free = Money::from_raw(total.raw - locked.raw, currency);
777
778        let balance = AccountBalance::new(total, locked, free);
779        balances.push(balance);
780    }
781
782    Ok(AccountState::new(
783        account_id,
784        AccountType::Margin, // Bybit unified account
785        balances,
786        vec![], // margins - Bybit doesn't provide per-instrument margin in wallet updates
787        true,   // is_reported
788        UUID4::new(),
789        ts_event,
790        ts_init,
791        None, // base_currency
792    ))
793}
794
795#[cfg(test)]
796mod tests {
797    use nautilus_model::{
798        data::BarSpecification,
799        enums::{AggregationSource, BarAggregation, PositionSide, PriceType},
800    };
801    use rstest::rstest;
802    use rust_decimal_macros::dec;
803
804    use super::*;
805    use crate::{
806        common::{
807            parse::{parse_linear_instrument, parse_option_instrument},
808            testing::load_test_json,
809        },
810        http::models::{BybitInstrumentLinearResponse, BybitInstrumentOptionResponse},
811        websocket::messages::{
812            BybitWsOrderbookDepthMsg, BybitWsTickerLinearMsg, BybitWsTickerOptionMsg,
813            BybitWsTradeMsg,
814        },
815    };
816
817    const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
818
819    use ustr::Ustr;
820
821    use crate::http::models::BybitFeeRate;
822
823    fn sample_fee_rate(
824        symbol: &str,
825        taker: &str,
826        maker: &str,
827        base_coin: Option<&str>,
828    ) -> BybitFeeRate {
829        BybitFeeRate {
830            symbol: Ustr::from(symbol),
831            taker_fee_rate: taker.to_string(),
832            maker_fee_rate: maker.to_string(),
833            base_coin: base_coin.map(Ustr::from),
834        }
835    }
836
837    fn linear_instrument() -> InstrumentAny {
838        let json = load_test_json("http_get_instruments_linear.json");
839        let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
840        let instrument = &response.result.list[0];
841        let fee_rate = sample_fee_rate("BTCUSDT", "0.00055", "0.0001", Some("BTC"));
842        parse_linear_instrument(instrument, &fee_rate, TS, TS).unwrap()
843    }
844
845    fn option_instrument() -> InstrumentAny {
846        let json = load_test_json("http_get_instruments_option.json");
847        let response: BybitInstrumentOptionResponse = serde_json::from_str(&json).unwrap();
848        let instrument = &response.result.list[0];
849        parse_option_instrument(instrument, TS, TS).unwrap()
850    }
851
852    #[rstest]
853    fn parse_ws_trade_into_trade_tick() {
854        let instrument = linear_instrument();
855        let json = load_test_json("ws_public_trade.json");
856        let msg: BybitWsTradeMsg = serde_json::from_str(&json).unwrap();
857        let trade = &msg.data[0];
858
859        let tick = parse_ws_trade_tick(trade, &instrument, TS).unwrap();
860
861        assert_eq!(tick.instrument_id, instrument.id());
862        assert_eq!(tick.price, instrument.make_price(27451.00));
863        assert_eq!(tick.size, instrument.make_qty(0.010, None));
864        assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
865        assert_eq!(
866            tick.trade_id.to_string(),
867            "9dc75fca-4bdd-4773-9f78-6f5d7ab2a110"
868        );
869        assert_eq!(tick.ts_event, UnixNanos::new(1_709_891_679_000_000_000));
870    }
871
872    #[rstest]
873    fn parse_orderbook_snapshot_into_deltas() {
874        let instrument = linear_instrument();
875        let json = load_test_json("ws_orderbook_snapshot.json");
876        let msg: BybitWsOrderbookDepthMsg = serde_json::from_str(&json).unwrap();
877
878        let deltas = parse_orderbook_deltas(&msg, &instrument, TS).unwrap();
879
880        assert_eq!(deltas.instrument_id, instrument.id());
881        assert_eq!(deltas.deltas.len(), 5);
882        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
883        assert_eq!(
884            deltas.deltas[1].order.price,
885            instrument.make_price(27450.00)
886        );
887        assert_eq!(
888            deltas.deltas[1].order.size,
889            instrument.make_qty(0.500, None)
890        );
891        let last = deltas.deltas.last().unwrap();
892        assert_eq!(last.order.side, OrderSide::Sell);
893        assert_eq!(last.order.price, instrument.make_price(27451.50));
894        assert_eq!(
895            last.flags & RecordFlag::F_LAST as u8,
896            RecordFlag::F_LAST as u8
897        );
898    }
899
900    #[rstest]
901    fn parse_orderbook_delta_marks_actions() {
902        let instrument = linear_instrument();
903        let json = load_test_json("ws_orderbook_delta.json");
904        let msg: BybitWsOrderbookDepthMsg = serde_json::from_str(&json).unwrap();
905
906        let deltas = parse_orderbook_deltas(&msg, &instrument, TS).unwrap();
907
908        assert_eq!(deltas.deltas.len(), 2);
909        let bid = &deltas.deltas[0];
910        assert_eq!(bid.action, BookAction::Update);
911        assert_eq!(bid.order.side, OrderSide::Buy);
912        assert_eq!(bid.order.size, instrument.make_qty(0.400, None));
913
914        let ask = &deltas.deltas[1];
915        assert_eq!(ask.action, BookAction::Delete);
916        assert_eq!(ask.order.side, OrderSide::Sell);
917        assert_eq!(ask.order.size, instrument.make_qty(0.0, None));
918        assert_eq!(
919            ask.flags & RecordFlag::F_LAST as u8,
920            RecordFlag::F_LAST as u8
921        );
922    }
923
924    #[rstest]
925    fn parse_orderbook_quote_produces_top_of_book() {
926        let instrument = linear_instrument();
927        let json = load_test_json("ws_orderbook_snapshot.json");
928        let msg: BybitWsOrderbookDepthMsg = serde_json::from_str(&json).unwrap();
929
930        let quote = parse_orderbook_quote(&msg, &instrument, None, TS).unwrap();
931
932        assert_eq!(quote.instrument_id, instrument.id());
933        assert_eq!(quote.bid_price, instrument.make_price(27450.00));
934        assert_eq!(quote.bid_size, instrument.make_qty(0.500, None));
935        assert_eq!(quote.ask_price, instrument.make_price(27451.00));
936        assert_eq!(quote.ask_size, instrument.make_qty(0.750, None));
937    }
938
939    #[rstest]
940    fn parse_orderbook_quote_with_delta_updates_sizes() {
941        let instrument = linear_instrument();
942        let snapshot: BybitWsOrderbookDepthMsg =
943            serde_json::from_str(&load_test_json("ws_orderbook_snapshot.json")).unwrap();
944        let base_quote = parse_orderbook_quote(&snapshot, &instrument, None, TS).unwrap();
945
946        let delta: BybitWsOrderbookDepthMsg =
947            serde_json::from_str(&load_test_json("ws_orderbook_delta.json")).unwrap();
948        let updated = parse_orderbook_quote(&delta, &instrument, Some(&base_quote), TS).unwrap();
949
950        assert_eq!(updated.bid_price, instrument.make_price(27450.00));
951        assert_eq!(updated.bid_size, instrument.make_qty(0.400, None));
952        assert_eq!(updated.ask_price, instrument.make_price(27451.00));
953        assert_eq!(updated.ask_size, instrument.make_qty(0.0, None));
954    }
955
956    #[rstest]
957    fn parse_linear_ticker_quote_to_quote_tick() {
958        let instrument = linear_instrument();
959        let json = load_test_json("ws_ticker_linear.json");
960        let msg: BybitWsTickerLinearMsg = serde_json::from_str(&json).unwrap();
961
962        let quote = parse_ticker_linear_quote(&msg, &instrument, TS).unwrap();
963
964        assert_eq!(quote.instrument_id, instrument.id());
965        assert_eq!(quote.bid_price, instrument.make_price(17215.50));
966        assert_eq!(quote.ask_price, instrument.make_price(17216.00));
967        assert_eq!(quote.bid_size, instrument.make_qty(84.489, None));
968        assert_eq!(quote.ask_size, instrument.make_qty(83.020, None));
969        assert_eq!(quote.ts_event, UnixNanos::new(1_673_272_861_686_000_000));
970        assert_eq!(quote.ts_init, TS);
971    }
972
973    #[rstest]
974    fn parse_option_ticker_quote_to_quote_tick() {
975        let instrument = option_instrument();
976        let json = load_test_json("ws_ticker_option.json");
977        let msg: BybitWsTickerOptionMsg = serde_json::from_str(&json).unwrap();
978
979        let quote = parse_ticker_option_quote(&msg, &instrument, TS).unwrap();
980
981        assert_eq!(quote.instrument_id, instrument.id());
982        assert_eq!(quote.bid_price, instrument.make_price(0.0));
983        assert_eq!(quote.ask_price, instrument.make_price(10.0));
984        assert_eq!(quote.bid_size, instrument.make_qty(0.0, None));
985        assert_eq!(quote.ask_size, instrument.make_qty(5.1, None));
986        assert_eq!(quote.ts_event, UnixNanos::new(1_672_917_511_074_000_000));
987        assert_eq!(quote.ts_init, TS);
988    }
989
990    #[rstest]
991    #[case::timestamp_on_open(false, 1_672_324_800_000_000_000)]
992    #[case::timestamp_on_close(true, 1_672_325_100_000_000_000)]
993    fn parse_ws_kline_into_bar(#[case] timestamp_on_close: bool, #[case] expected_ts_event: u64) {
994        use std::num::NonZero;
995
996        let instrument = linear_instrument();
997        let json = load_test_json("ws_kline.json");
998        let msg: crate::websocket::messages::BybitWsKlineMsg = serde_json::from_str(&json).unwrap();
999        let kline = &msg.data[0];
1000
1001        let bar_spec = BarSpecification {
1002            step: NonZero::new(5).unwrap(),
1003            aggregation: BarAggregation::Minute,
1004            price_type: PriceType::Last,
1005        };
1006        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::External);
1007
1008        let bar = parse_ws_kline_bar(kline, &instrument, bar_type, timestamp_on_close, TS).unwrap();
1009
1010        assert_eq!(bar.bar_type, bar_type);
1011        assert_eq!(bar.open, instrument.make_price(16649.5));
1012        assert_eq!(bar.high, instrument.make_price(16677.0));
1013        assert_eq!(bar.low, instrument.make_price(16608.0));
1014        assert_eq!(bar.close, instrument.make_price(16677.0));
1015        assert_eq!(bar.volume, instrument.make_qty(2.081, None));
1016        assert_eq!(bar.ts_event, UnixNanos::new(expected_ts_event));
1017        assert_eq!(bar.ts_init, TS);
1018    }
1019
1020    #[rstest]
1021    fn parse_ws_order_into_order_status_report() {
1022        let instrument = linear_instrument();
1023        let json = load_test_json("ws_account_order_filled.json");
1024        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1025            serde_json::from_str(&json).unwrap();
1026        let order = &msg.data[0];
1027        let account_id = AccountId::new("BYBIT-001");
1028
1029        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1030
1031        assert_eq!(report.account_id, account_id);
1032        assert_eq!(report.instrument_id, instrument.id());
1033        assert_eq!(report.order_side, OrderSide::Buy);
1034        assert_eq!(report.order_type, OrderType::Limit);
1035        assert_eq!(report.time_in_force, TimeInForce::Gtc);
1036        assert_eq!(report.order_status, OrderStatus::Filled);
1037        assert_eq!(report.quantity, instrument.make_qty(0.100, None));
1038        assert_eq!(report.filled_qty, instrument.make_qty(0.100, None));
1039        assert_eq!(report.price, Some(instrument.make_price(30000.50)));
1040        assert_eq!(report.avg_px, Some(dec!(30000.50)));
1041        assert_eq!(
1042            report.client_order_id.as_ref().unwrap().to_string(),
1043            "test-client-order-001"
1044        );
1045        assert_eq!(
1046            report.ts_accepted,
1047            UnixNanos::new(1_672_364_262_444_000_000)
1048        );
1049        assert_eq!(report.ts_last, UnixNanos::new(1_672_364_262_457_000_000));
1050    }
1051
1052    #[rstest]
1053    fn parse_ws_order_partially_filled_rejected_maps_to_canceled() {
1054        let instrument = linear_instrument();
1055        let json = load_test_json("ws_account_order_partially_filled_rejected.json");
1056        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1057            serde_json::from_str(&json).unwrap();
1058        let order = &msg.data[0];
1059        let account_id = AccountId::new("BYBIT-001");
1060
1061        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1062
1063        // Verify that Bybit "Rejected" status with fills is mapped to Canceled, not Rejected
1064        assert_eq!(report.order_status, OrderStatus::Canceled);
1065        assert_eq!(report.filled_qty, instrument.make_qty(50.0, None));
1066        assert_eq!(
1067            report.client_order_id.as_ref().unwrap().to_string(),
1068            "O-20251001-164609-APEX-000-49"
1069        );
1070        assert_eq!(report.cancel_reason, Some("UNKNOWN".to_string()));
1071    }
1072
1073    #[rstest]
1074    fn parse_ws_execution_into_fill_report() {
1075        let instrument = linear_instrument();
1076        let json = load_test_json("ws_account_execution.json");
1077        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1078            serde_json::from_str(&json).unwrap();
1079        let execution = &msg.data[0];
1080        let account_id = AccountId::new("BYBIT-001");
1081
1082        let report = parse_ws_fill_report(execution, account_id, &instrument, TS).unwrap();
1083
1084        assert_eq!(report.account_id, account_id);
1085        assert_eq!(report.instrument_id, instrument.id());
1086        assert_eq!(
1087            report.venue_order_id.to_string(),
1088            "9aac161b-8ed6-450d-9cab-c5cc67c21784"
1089        );
1090        assert_eq!(
1091            report.trade_id.to_string(),
1092            "0ab1bdf7-4219-438b-b30a-32ec863018f7"
1093        );
1094        assert_eq!(report.order_side, OrderSide::Sell);
1095        assert_eq!(report.last_qty, instrument.make_qty(0.5, None));
1096        assert_eq!(report.last_px, instrument.make_price(95900.1));
1097        assert_eq!(report.commission.as_f64(), 26.3725275);
1098        assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1099        assert_eq!(
1100            report.client_order_id.as_ref().unwrap().to_string(),
1101            "test-order-link-001"
1102        );
1103        assert_eq!(report.ts_event, UnixNanos::new(1_746_270_400_353_000_000));
1104    }
1105
1106    #[rstest]
1107    fn parse_ws_position_into_position_status_report() {
1108        let instrument = linear_instrument();
1109        let json = load_test_json("ws_account_position.json");
1110        let msg: crate::websocket::messages::BybitWsAccountPositionMsg =
1111            serde_json::from_str(&json).unwrap();
1112        let position = &msg.data[0];
1113        let account_id = AccountId::new("BYBIT-001");
1114
1115        let report =
1116            parse_ws_position_status_report(position, account_id, &instrument, TS).unwrap();
1117
1118        assert_eq!(report.account_id, account_id);
1119        assert_eq!(report.instrument_id, instrument.id());
1120        assert_eq!(report.position_side.as_position_side(), PositionSide::Short);
1121        assert_eq!(report.quantity, instrument.make_qty(0.01, None));
1122        assert_eq!(
1123            report.avg_px_open,
1124            Some(Decimal::try_from(3641.075).unwrap())
1125        );
1126        assert_eq!(report.ts_last, UnixNanos::new(1_762_199_125_472_000_000));
1127        assert_eq!(report.ts_init, TS);
1128    }
1129
1130    #[rstest]
1131    fn parse_ws_position_short_into_position_status_report() {
1132        // Create ETHUSDT instrument
1133        let instruments_json = load_test_json("http_get_instruments_linear.json");
1134        let instruments_response: crate::http::models::BybitInstrumentLinearResponse =
1135            serde_json::from_str(&instruments_json).unwrap();
1136        let eth_def = &instruments_response.result.list[1]; // ETHUSDT is second in the list
1137        let fee_rate = crate::http::models::BybitFeeRate {
1138            symbol: Ustr::from("ETHUSDT"),
1139            taker_fee_rate: "0.00055".to_string(),
1140            maker_fee_rate: "0.0001".to_string(),
1141            base_coin: Some(Ustr::from("ETH")),
1142        };
1143        let instrument =
1144            crate::common::parse::parse_linear_instrument(eth_def, &fee_rate, TS, TS).unwrap();
1145
1146        let json = load_test_json("ws_account_position_short.json");
1147        let msg: crate::websocket::messages::BybitWsAccountPositionMsg =
1148            serde_json::from_str(&json).unwrap();
1149        let position = &msg.data[0];
1150        let account_id = AccountId::new("BYBIT-001");
1151
1152        let report =
1153            parse_ws_position_status_report(position, account_id, &instrument, TS).unwrap();
1154
1155        assert_eq!(report.account_id, account_id);
1156        assert_eq!(report.instrument_id.symbol.as_str(), "ETHUSDT-LINEAR");
1157        assert_eq!(report.position_side.as_position_side(), PositionSide::Short);
1158        assert_eq!(report.quantity, instrument.make_qty(0.01, None));
1159        assert_eq!(
1160            report.avg_px_open,
1161            Some(Decimal::try_from(3641.075).unwrap())
1162        );
1163        assert_eq!(report.ts_last, UnixNanos::new(1_762_199_125_472_000_000));
1164        assert_eq!(report.ts_init, TS);
1165    }
1166
1167    #[rstest]
1168    fn parse_ws_wallet_into_account_state() {
1169        let json = load_test_json("ws_account_wallet.json");
1170        let msg: crate::websocket::messages::BybitWsAccountWalletMsg =
1171            serde_json::from_str(&json).unwrap();
1172        let wallet = &msg.data[0];
1173        let account_id = AccountId::new("BYBIT-001");
1174        let ts_event = UnixNanos::new(1_700_034_722_104_000_000);
1175
1176        let state = parse_ws_account_state(wallet, account_id, ts_event, TS).unwrap();
1177
1178        assert_eq!(state.account_id, account_id);
1179        assert_eq!(state.account_type, AccountType::Margin);
1180        assert_eq!(state.balances.len(), 2);
1181        assert!(state.is_reported);
1182
1183        // Check BTC balance
1184        let btc_balance = &state.balances[0];
1185        assert_eq!(btc_balance.currency.code.as_str(), "BTC");
1186        assert!((btc_balance.total.as_f64() - 0.00102964).abs() < 1e-8);
1187        assert!((btc_balance.free.as_f64() - 0.00092964).abs() < 1e-8);
1188        assert!((btc_balance.locked.as_f64() - 0.0001).abs() < 1e-8);
1189
1190        // Check USDT balance
1191        let usdt_balance = &state.balances[1];
1192        assert_eq!(usdt_balance.currency.code.as_str(), "USDT");
1193        assert!((usdt_balance.total.as_f64() - 9647.75537647).abs() < 1e-6);
1194        assert!((usdt_balance.free.as_f64() - 9519.89806037).abs() < 1e-6);
1195        assert!((usdt_balance.locked.as_f64() - 127.8573161).abs() < 1e-6);
1196
1197        assert_eq!(state.ts_event, ts_event);
1198        assert_eq!(state.ts_init, TS);
1199    }
1200
1201    #[rstest]
1202    fn parse_ws_wallet_with_small_order_calculates_free_correctly() {
1203        // Regression test for issue where availableToWithdraw=0 caused all funds to appear locked
1204        // When a small order is placed, Bybit may report availableToWithdraw=0 due to margin calculations,
1205        // but totalOrderIM correctly shows only the margin locked for the order
1206        let json = load_test_json("ws_account_wallet_small_order.json");
1207        let msg: crate::websocket::messages::BybitWsAccountWalletMsg =
1208            serde_json::from_str(&json).unwrap();
1209        let wallet = &msg.data[0];
1210        let account_id = AccountId::new("BYBIT-UNIFIED");
1211        let ts_event = UnixNanos::new(1_762_960_669_000_000_000);
1212
1213        let state = parse_ws_account_state(wallet, account_id, ts_event, TS).unwrap();
1214
1215        assert_eq!(state.account_id, account_id);
1216        assert_eq!(state.balances.len(), 1);
1217
1218        // Check USDT balance
1219        let usdt_balance = &state.balances[0];
1220        assert_eq!(usdt_balance.currency.code.as_str(), "USDT");
1221
1222        // Wallet has 51,333.82 USDT total
1223        assert!((usdt_balance.total.as_f64() - 51333.82543837).abs() < 1e-6);
1224
1225        // Only 50.028 USDT should be locked (for the order), not all funds
1226        assert!((usdt_balance.locked.as_f64() - 50.028).abs() < 1e-6);
1227
1228        // Free should be total - locked = 51,333.82 - 50.028 = 51,283.79
1229        assert!((usdt_balance.free.as_f64() - 51283.79743837).abs() < 1e-6);
1230
1231        // The bug would have calculated: locked = total - availableToWithdraw = 51,333.82 - 0 = 51,333.82 (all locked!)
1232        // This test verifies that we now correctly use totalOrderIM instead of deriving from availableToWithdraw
1233    }
1234
1235    #[rstest]
1236    fn parse_ticker_linear_into_funding_rate() {
1237        let instrument = linear_instrument();
1238        let json = load_test_json("ws_ticker_linear.json");
1239        let msg: BybitWsTickerLinearMsg = serde_json::from_str(&json).unwrap();
1240
1241        let ts_event = UnixNanos::new(1_673_272_861_686_000_000);
1242
1243        let funding =
1244            parse_ticker_linear_funding(&msg.data, instrument.id(), ts_event, TS).unwrap();
1245
1246        assert_eq!(funding.instrument_id, instrument.id());
1247        assert_eq!(funding.rate, dec!(-0.000212)); // -0.000212
1248        assert_eq!(
1249            funding.next_funding_ns,
1250            Some(UnixNanos::new(1_673_280_000_000_000_000))
1251        );
1252        assert_eq!(funding.ts_event, ts_event);
1253        assert_eq!(funding.ts_init, TS);
1254    }
1255
1256    #[rstest]
1257    fn parse_ws_order_stop_market_sell_preserves_type() {
1258        let instrument = linear_instrument();
1259        let json = load_test_json("ws_account_order_stop_market.json");
1260        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1261            serde_json::from_str(&json).unwrap();
1262        let order = &msg.data[0];
1263        let account_id = AccountId::new("BYBIT-001");
1264
1265        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1266
1267        // Verify sell StopMarket: orderType=Market + stopOrderType=Stop + triggerDirection=2 (falls to)
1268        assert_eq!(report.order_type, OrderType::StopMarket);
1269        assert_eq!(report.order_side, OrderSide::Sell);
1270        assert_eq!(report.order_status, OrderStatus::Accepted); // Untriggered maps to Accepted
1271        assert_eq!(report.trigger_price, Some(instrument.make_price(45000.00)));
1272        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1273        assert_eq!(
1274            report.client_order_id.as_ref().unwrap().to_string(),
1275            "test-client-stop-market-001"
1276        );
1277    }
1278
1279    #[rstest]
1280    fn parse_ws_order_stop_market_buy_preserves_type() {
1281        let instrument = linear_instrument();
1282        let json = load_test_json("ws_account_order_buy_stop_market.json");
1283        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1284            serde_json::from_str(&json).unwrap();
1285        let order = &msg.data[0];
1286        let account_id = AccountId::new("BYBIT-001");
1287
1288        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1289
1290        // Verify buy StopMarket: orderType=Market + stopOrderType=Stop + triggerDirection=1 (rises to)
1291        assert_eq!(report.order_type, OrderType::StopMarket);
1292        assert_eq!(report.order_side, OrderSide::Buy);
1293        assert_eq!(report.order_status, OrderStatus::Accepted);
1294        assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
1295        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1296        assert_eq!(
1297            report.client_order_id.as_ref().unwrap().to_string(),
1298            "test-client-buy-stop-market-001"
1299        );
1300    }
1301
1302    #[rstest]
1303    fn parse_ws_order_market_if_touched_buy_preserves_type() {
1304        let instrument = linear_instrument();
1305        let json = load_test_json("ws_account_order_market_if_touched.json");
1306        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1307            serde_json::from_str(&json).unwrap();
1308        let order = &msg.data[0];
1309        let account_id = AccountId::new("BYBIT-001");
1310
1311        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1312
1313        // Verify buy MIT: orderType=Market + stopOrderType=Stop + triggerDirection=2 (falls to)
1314        assert_eq!(report.order_type, OrderType::MarketIfTouched);
1315        assert_eq!(report.order_side, OrderSide::Buy);
1316        assert_eq!(report.order_status, OrderStatus::Accepted); // Untriggered maps to Accepted
1317        assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
1318        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1319        assert_eq!(
1320            report.client_order_id.as_ref().unwrap().to_string(),
1321            "test-client-mit-001"
1322        );
1323    }
1324
1325    #[rstest]
1326    fn parse_ws_order_market_if_touched_sell_preserves_type() {
1327        let instrument = linear_instrument();
1328        let json = load_test_json("ws_account_order_sell_market_if_touched.json");
1329        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1330            serde_json::from_str(&json).unwrap();
1331        let order = &msg.data[0];
1332        let account_id = AccountId::new("BYBIT-001");
1333
1334        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1335
1336        // Verify sell MIT: orderType=Market + stopOrderType=Stop + triggerDirection=1 (rises to)
1337        assert_eq!(report.order_type, OrderType::MarketIfTouched);
1338        assert_eq!(report.order_side, OrderSide::Sell);
1339        assert_eq!(report.order_status, OrderStatus::Accepted);
1340        assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
1341        assert_eq!(
1342            report.client_order_id.as_ref().unwrap().to_string(),
1343            "test-client-sell-mit-001"
1344        );
1345    }
1346
1347    #[rstest]
1348    fn parse_ws_order_stop_limit_preserves_type() {
1349        let instrument = linear_instrument();
1350        let json = load_test_json("ws_account_order_stop_limit.json");
1351        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1352            serde_json::from_str(&json).unwrap();
1353        let order = &msg.data[0];
1354        let account_id = AccountId::new("BYBIT-001");
1355
1356        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1357
1358        // Verify StopLimit order type is correctly parsed
1359        // orderType=Limit + stopOrderType=Stop + triggerDirection=2 (falls to)
1360        assert_eq!(report.order_type, OrderType::StopLimit);
1361        assert_eq!(report.order_side, OrderSide::Sell);
1362        assert_eq!(report.order_status, OrderStatus::Accepted); // Untriggered maps to Accepted
1363        assert_eq!(report.price, Some(instrument.make_price(44500.00)));
1364        assert_eq!(report.trigger_price, Some(instrument.make_price(45000.00)));
1365        assert_eq!(
1366            report.client_order_id.as_ref().unwrap().to_string(),
1367            "test-client-stop-limit-001"
1368        );
1369    }
1370
1371    #[rstest]
1372    fn parse_ws_order_limit_if_touched_preserves_type() {
1373        let instrument = linear_instrument();
1374        let json = load_test_json("ws_account_order_limit_if_touched.json");
1375        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1376            serde_json::from_str(&json).unwrap();
1377        let order = &msg.data[0];
1378        let account_id = AccountId::new("BYBIT-001");
1379
1380        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1381
1382        // Verify LimitIfTouched order type is correctly parsed
1383        // orderType=Limit + stopOrderType=Stop + triggerDirection=1 (rises to)
1384        assert_eq!(report.order_type, OrderType::LimitIfTouched);
1385        assert_eq!(report.order_side, OrderSide::Buy);
1386        assert_eq!(report.order_status, OrderStatus::Accepted); // Untriggered maps to Accepted
1387        assert_eq!(report.price, Some(instrument.make_price(55500.00)));
1388        assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
1389        assert_eq!(
1390            report.client_order_id.as_ref().unwrap().to_string(),
1391            "test-client-lit-001"
1392        );
1393    }
1394}