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