Skip to main content

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, IndexPriceUpdate, MarkPriceUpdate,
25        OrderBookDelta, OrderBookDeltas, QuoteTick, TradeTick, greeks::OptionGreekValues,
26        option_chain::OptionGreeks,
27    },
28    enums::{
29        AccountType, AggressorSide, BookAction, GreeksConvention, LiquiditySide, OrderSide,
30        OrderStatus, PositionSide, RecordFlag, TimeInForce, TriggerType,
31    },
32    events::account::state::AccountState,
33    identifiers::{AccountId, ClientOrderId, InstrumentId, PositionId, TradeId, VenueOrderId},
34    instruments::{Instrument, any::InstrumentAny},
35    reports::{FillReport, OrderStatusReport, PositionStatusReport},
36    types::{AccountBalance, MarginBalance, Money, Price, Quantity},
37};
38use rust_decimal::Decimal;
39
40use super::{
41    enums::{BybitWsOperation, BybitWsPrivateChannel, BybitWsPublicChannel},
42    messages::{
43        BybitWsAccountExecution, BybitWsAccountExecutionFast, BybitWsAccountOrder,
44        BybitWsAccountPosition, BybitWsAccountWallet, BybitWsAuthResponse, BybitWsFrame,
45        BybitWsKline, BybitWsOrderResponse, BybitWsOrderbookDepthMsg, BybitWsResponse,
46        BybitWsSubscriptionMsg, BybitWsTickerLinear, BybitWsTickerLinearMsg,
47        BybitWsTickerOptionMsg, BybitWsTrade,
48    },
49};
50use crate::common::{
51    enums::{BybitOrderStatus, BybitPositionSide, BybitTimeInForce},
52    parse::{
53        bybit_rejection_due_post_only, get_currency, make_hedge_venue_position_id,
54        parse_book_level, parse_bybit_order_type, parse_millis_timestamp,
55        parse_price_with_precision, parse_quantity_with_precision,
56    },
57};
58
59/// Classifies a parsed JSON value into a typed Bybit WebSocket frame.
60///
61/// Returns `Unknown(value)` if no specific type matches.
62pub fn parse_bybit_ws_frame(value: serde_json::Value) -> BybitWsFrame {
63    if let Some(op_val) = value.get("op") {
64        if let Ok(op) = serde_json::from_value::<BybitWsOperation>(op_val.clone())
65            && op == BybitWsOperation::Auth
66            && let Ok(auth) = serde_json::from_value::<BybitWsAuthResponse>(value.clone())
67        {
68            let is_success = auth.success.unwrap_or(false) || auth.ret_code.unwrap_or(-1) == 0;
69            if is_success {
70                return BybitWsFrame::Auth(auth);
71            }
72            let resp = BybitWsResponse {
73                op: Some(auth.op.clone()),
74                topic: None,
75                success: auth.success,
76                conn_id: auth.conn_id.clone(),
77                req_id: None,
78                ret_code: auth.ret_code,
79                ret_msg: auth.ret_msg,
80            };
81            return BybitWsFrame::ErrorResponse(resp);
82        }
83
84        if let Some(op_str) = op_val.as_str()
85            && op_str.starts_with("order.")
86        {
87            return serde_json::from_value::<BybitWsOrderResponse>(value.clone()).map_or_else(
88                |_| BybitWsFrame::Unknown(value),
89                BybitWsFrame::OrderResponse,
90            );
91        }
92    }
93
94    if let Some(success) = value.get("success").and_then(serde_json::Value::as_bool) {
95        if success {
96            return serde_json::from_value::<BybitWsSubscriptionMsg>(value.clone())
97                .map_or_else(|_| BybitWsFrame::Unknown(value), BybitWsFrame::Subscription);
98        }
99        return serde_json::from_value::<BybitWsResponse>(value.clone()).map_or_else(
100            |_| BybitWsFrame::Unknown(value),
101            BybitWsFrame::ErrorResponse,
102        );
103    }
104
105    if let Some(topic) = value.get("topic").and_then(serde_json::Value::as_str) {
106        if topic.starts_with(BybitWsPublicChannel::OrderBook.as_ref()) {
107            return serde_json::from_value(value.clone())
108                .map_or_else(|_| BybitWsFrame::Unknown(value), BybitWsFrame::Orderbook);
109        }
110
111        if topic.contains(BybitWsPublicChannel::PublicTrade.as_ref())
112            || topic.starts_with(BybitWsPublicChannel::Trade.as_ref())
113        {
114            return serde_json::from_value(value.clone())
115                .map_or_else(|_| BybitWsFrame::Unknown(value), BybitWsFrame::Trade);
116        }
117
118        if topic.starts_with(BybitWsPublicChannel::Kline.as_ref()) {
119            return serde_json::from_value(value.clone())
120                .map_or_else(|_| BybitWsFrame::Unknown(value), BybitWsFrame::Kline);
121        }
122
123        if topic.starts_with(BybitWsPublicChannel::Tickers.as_ref()) {
124            // Option symbols have 3+ hyphens: BTC-6JAN23-17500-C
125            let is_option = value
126                .get("data")
127                .and_then(|d| d.get("symbol"))
128                .and_then(|s| s.as_str())
129                .is_some_and(|symbol| symbol.contains('-') && symbol.matches('-').count() >= 3);
130
131            if is_option {
132                return serde_json::from_value(value.clone())
133                    .map_or_else(|_| BybitWsFrame::Unknown(value), BybitWsFrame::TickerOption);
134            }
135            return serde_json::from_value(value.clone())
136                .map_or_else(|_| BybitWsFrame::Unknown(value), BybitWsFrame::TickerLinear);
137        }
138
139        if topic.starts_with(BybitWsPrivateChannel::Order.as_ref()) {
140            return serde_json::from_value(value.clone())
141                .map_or_else(|_| BybitWsFrame::Unknown(value), BybitWsFrame::AccountOrder);
142        }
143
144        if topic.starts_with(BybitWsPrivateChannel::ExecutionFast.as_ref()) {
145            return serde_json::from_value(value.clone()).map_or_else(
146                |_| BybitWsFrame::Unknown(value),
147                BybitWsFrame::AccountExecutionFast,
148            );
149        }
150
151        if topic.starts_with(BybitWsPrivateChannel::Execution.as_ref()) {
152            return serde_json::from_value(value.clone()).map_or_else(
153                |_| BybitWsFrame::Unknown(value),
154                BybitWsFrame::AccountExecution,
155            );
156        }
157
158        if topic.starts_with(BybitWsPrivateChannel::Wallet.as_ref()) {
159            return serde_json::from_value(value.clone()).map_or_else(
160                |_| BybitWsFrame::Unknown(value),
161                BybitWsFrame::AccountWallet,
162            );
163        }
164
165        if topic.starts_with(BybitWsPrivateChannel::Position.as_ref()) {
166            return serde_json::from_value(value.clone()).map_or_else(
167                |_| BybitWsFrame::Unknown(value),
168                BybitWsFrame::AccountPosition,
169            );
170        }
171    }
172
173    BybitWsFrame::Unknown(value)
174}
175
176/// Parses a Bybit WebSocket topic string into its components.
177///
178/// # Errors
179///
180/// Returns an error if the topic format is invalid.
181pub fn parse_topic(topic: &str) -> anyhow::Result<Vec<&str>> {
182    let parts: Vec<&str> = topic.split('.').collect();
183    if parts.is_empty() {
184        anyhow::bail!("Invalid topic format: empty topic");
185    }
186    Ok(parts)
187}
188
189/// Parses a Bybit kline topic into (interval, symbol).
190///
191/// Topic format: "kline.{interval}.{symbol}" (e.g., "kline.5.BTCUSDT")
192///
193/// # Errors
194///
195/// Returns an error if the topic format is invalid.
196pub fn parse_kline_topic(topic: &str) -> anyhow::Result<(&str, &str)> {
197    let kline = BybitWsPublicChannel::Kline.as_ref();
198    let parts = parse_topic(topic)?;
199    if parts.len() != 3 || parts[0] != kline {
200        anyhow::bail!(
201            "Invalid kline topic format: expected '{kline}.{{interval}}.{{symbol}}', was '{topic}'"
202        );
203    }
204    Ok((parts[1], parts[2]))
205}
206
207/// Parses a WebSocket trade frame into a [`TradeTick`].
208pub fn parse_ws_trade_tick(
209    trade: &BybitWsTrade,
210    instrument: &InstrumentAny,
211    ts_init: UnixNanos,
212) -> anyhow::Result<TradeTick> {
213    let price = parse_price_with_precision(&trade.p, instrument.price_precision(), "trade.p")?;
214    let size = parse_quantity_with_precision(&trade.v, instrument.size_precision(), "trade.v")?;
215    let aggressor: AggressorSide = trade.taker_side.into();
216    let trade_id = TradeId::new_checked(trade.i.as_str())
217        .context("invalid trade identifier in Bybit trade message")?;
218    let ts_event = parse_millis_i64(trade.t, "trade.T")?;
219
220    TradeTick::new_checked(
221        instrument.id(),
222        price,
223        size,
224        aggressor,
225        trade_id,
226        ts_event,
227        ts_init,
228    )
229    .context("failed to construct TradeTick from Bybit trade message")
230}
231
232/// Parses an order book depth message into [`OrderBookDeltas`].
233pub fn parse_orderbook_deltas(
234    msg: &BybitWsOrderbookDepthMsg,
235    instrument: &InstrumentAny,
236    ts_init: UnixNanos,
237) -> anyhow::Result<OrderBookDeltas> {
238    let is_snapshot = msg.msg_type.eq_ignore_ascii_case("snapshot");
239    let ts_event = parse_millis_i64(msg.ts, "orderbook.ts")?;
240    let ts_init = if ts_init.is_zero() { ts_event } else { ts_init };
241
242    let depth = &msg.data;
243    let instrument_id = instrument.id();
244    let price_precision = instrument.price_precision();
245    let size_precision = instrument.size_precision();
246    let update_id = u64::try_from(depth.u)
247        .context("received negative update id in Bybit order book message")?;
248    let sequence = u64::try_from(depth.seq)
249        .context("received negative sequence in Bybit order book message")?;
250
251    let total_levels = depth.b.len() + depth.a.len();
252    let capacity = if is_snapshot {
253        total_levels + 1
254    } else {
255        total_levels
256    };
257    let mut deltas = Vec::with_capacity(capacity);
258
259    if is_snapshot {
260        deltas.push(OrderBookDelta::clear(
261            instrument_id,
262            sequence,
263            ts_event,
264            ts_init,
265        ));
266    }
267    let mut processed = 0_usize;
268
269    let mut push_level = |values: &[String], side: OrderSide| -> anyhow::Result<()> {
270        let (price, size) = parse_book_level(values, price_precision, size_precision, "orderbook")?;
271        let action = if size.is_zero() {
272            BookAction::Delete
273        } else if is_snapshot {
274            BookAction::Add
275        } else {
276            BookAction::Update
277        };
278
279        processed += 1;
280        let mut flags = RecordFlag::F_MBP as u8;
281
282        if processed == total_levels {
283            flags |= RecordFlag::F_LAST as u8;
284        }
285
286        let order = BookOrder::new(side, price, size, update_id);
287        let delta = OrderBookDelta::new_checked(
288            instrument_id,
289            action,
290            order,
291            flags,
292            sequence,
293            ts_event,
294            ts_init,
295        )
296        .context("failed to construct OrderBookDelta from Bybit book level")?;
297        deltas.push(delta);
298        Ok(())
299    };
300
301    for level in &depth.b {
302        push_level(level, OrderSide::Buy)?;
303    }
304
305    for level in &depth.a {
306        push_level(level, OrderSide::Sell)?;
307    }
308
309    if total_levels == 0
310        && let Some(last) = deltas.last_mut()
311    {
312        last.flags |= RecordFlag::F_LAST as u8;
313    }
314
315    OrderBookDeltas::new_checked(instrument_id, deltas)
316        .context("failed to assemble OrderBookDeltas from Bybit message")
317}
318
319/// Parses an order book snapshot or delta into a [`QuoteTick`].
320pub fn parse_orderbook_quote(
321    msg: &BybitWsOrderbookDepthMsg,
322    instrument: &InstrumentAny,
323    last_quote: Option<&QuoteTick>,
324    ts_init: UnixNanos,
325) -> anyhow::Result<QuoteTick> {
326    let ts_event = parse_millis_i64(msg.ts, "orderbook.ts")?;
327    let ts_init = if ts_init.is_zero() { ts_event } else { ts_init };
328    let price_precision = instrument.price_precision();
329    let size_precision = instrument.size_precision();
330
331    let get_best =
332        |levels: &[Vec<String>], label: &str| -> anyhow::Result<Option<(Price, Quantity)>> {
333            if let Some(values) = levels.first() {
334                parse_book_level(values, price_precision, size_precision, label).map(Some)
335            } else {
336                Ok(None)
337            }
338        };
339
340    let bids = get_best(&msg.data.b, "bid")?;
341    let asks = get_best(&msg.data.a, "ask")?;
342
343    let (bid_price, bid_size) = match (bids, last_quote) {
344        (Some(level), _) => level,
345        (None, Some(prev)) => (prev.bid_price, prev.bid_size),
346        (None, None) => {
347            anyhow::bail!(
348                "Bybit order book update missing bid levels and no previous quote provided"
349            );
350        }
351    };
352
353    let (ask_price, ask_size) = match (asks, last_quote) {
354        (Some(level), _) => level,
355        (None, Some(prev)) => (prev.ask_price, prev.ask_size),
356        (None, None) => {
357            anyhow::bail!(
358                "Bybit order book update missing ask levels and no previous quote provided"
359            );
360        }
361    };
362
363    QuoteTick::new_checked(
364        instrument.id(),
365        bid_price,
366        ask_price,
367        bid_size,
368        ask_size,
369        ts_event,
370        ts_init,
371    )
372    .context("failed to construct QuoteTick from Bybit order book message")
373}
374
375/// Parses a linear or inverse ticker payload into a [`QuoteTick`].
376pub fn parse_ticker_linear_quote(
377    msg: &BybitWsTickerLinearMsg,
378    instrument: &InstrumentAny,
379    ts_init: UnixNanos,
380) -> anyhow::Result<QuoteTick> {
381    let ts_event = parse_millis_i64(msg.ts, "ticker.ts")?;
382    let ts_init = if ts_init.is_zero() { ts_event } else { ts_init };
383    let price_precision = instrument.price_precision();
384    let size_precision = instrument.size_precision();
385
386    let data = &msg.data;
387    let bid_price = data
388        .bid1_price
389        .as_ref()
390        .context("Bybit ticker message missing bid1Price")?
391        .as_str();
392    let ask_price = data
393        .ask1_price
394        .as_ref()
395        .context("Bybit ticker message missing ask1Price")?
396        .as_str();
397
398    let bid_price = parse_price_with_precision(bid_price, price_precision, "ticker.bid1Price")?;
399    let ask_price = parse_price_with_precision(ask_price, price_precision, "ticker.ask1Price")?;
400
401    let bid_size_str = data.bid1_size.as_deref().unwrap_or("0");
402    let ask_size_str = data.ask1_size.as_deref().unwrap_or("0");
403
404    let bid_size = parse_quantity_with_precision(bid_size_str, size_precision, "ticker.bid1Size")?;
405    let ask_size = parse_quantity_with_precision(ask_size_str, size_precision, "ticker.ask1Size")?;
406
407    QuoteTick::new_checked(
408        instrument.id(),
409        bid_price,
410        ask_price,
411        bid_size,
412        ask_size,
413        ts_event,
414        ts_init,
415    )
416    .context("failed to construct QuoteTick from Bybit linear ticker message")
417}
418
419/// Parses an option ticker payload into a [`QuoteTick`].
420pub fn parse_ticker_option_quote(
421    msg: &BybitWsTickerOptionMsg,
422    instrument: &InstrumentAny,
423    ts_init: UnixNanos,
424) -> anyhow::Result<QuoteTick> {
425    let ts_event = parse_millis_i64(msg.ts, "ticker.ts")?;
426    let ts_init = if ts_init.is_zero() { ts_event } else { ts_init };
427    let price_precision = instrument.price_precision();
428    let size_precision = instrument.size_precision();
429
430    let data = &msg.data;
431    let bid_price =
432        parse_price_with_precision(&data.bid_price, price_precision, "ticker.bidPrice")?;
433    let ask_price =
434        parse_price_with_precision(&data.ask_price, price_precision, "ticker.askPrice")?;
435    let bid_size = parse_quantity_with_precision(&data.bid_size, size_precision, "ticker.bidSize")?;
436    let ask_size = parse_quantity_with_precision(&data.ask_size, size_precision, "ticker.askSize")?;
437
438    QuoteTick::new_checked(
439        instrument.id(),
440        bid_price,
441        ask_price,
442        bid_size,
443        ask_size,
444        ts_event,
445        ts_init,
446    )
447    .context("failed to construct QuoteTick from Bybit option ticker message")
448}
449
450/// Parses a linear ticker payload into a [`FundingRateUpdate`].
451///
452/// # Errors
453///
454/// Returns an error if funding rate, funding interval or next funding time fields are missing or cannot be parsed.
455pub fn parse_ticker_linear_funding(
456    data: &BybitWsTickerLinear,
457    instrument_id: InstrumentId,
458    ts_event: UnixNanos,
459    ts_init: UnixNanos,
460) -> anyhow::Result<FundingRateUpdate> {
461    let funding_rate_str = data
462        .funding_rate
463        .as_ref()
464        .context("Bybit ticker missing funding_rate")?;
465
466    if funding_rate_str.is_empty() {
467        anyhow::bail!(
468            "empty funding_rate for {instrument_id} (dated futures do not have funding rates)"
469        );
470    }
471
472    let funding_rate = funding_rate_str
473        .as_str()
474        .parse::<Decimal>()
475        .with_context(|| {
476            format!("invalid funding_rate value '{funding_rate_str}' for {instrument_id}")
477        })?;
478
479    let funding_interval = if let Some(funding_interval_hour) = &data.funding_interval_hour {
480        let funding_interval_hour = funding_interval_hour
481            .as_str()
482            .parse::<u16>()
483            .context("invalid funding_interval_hour value")?;
484        Some(
485            funding_interval_hour
486                .checked_mul(60)
487                .ok_or_else(|| anyhow::anyhow!("funding_interval_hour out of bounds"))?,
488        )
489    } else {
490        None
491    };
492
493    let next_funding_ns = if let Some(next_funding_time) = &data.next_funding_time {
494        let next_funding_millis = next_funding_time
495            .as_str()
496            .parse::<i64>()
497            .context("invalid next_funding_time value")?;
498        Some(parse_millis_i64(next_funding_millis, "next_funding_time")?)
499    } else {
500        None
501    };
502
503    Ok(FundingRateUpdate::new(
504        instrument_id,
505        funding_rate,
506        funding_interval,
507        next_funding_ns,
508        ts_event,
509        ts_init,
510    ))
511}
512
513/// Parses a linear/inverse ticker payload into a [`MarkPriceUpdate`].
514///
515/// # Errors
516///
517/// Returns an error if the mark_price field is missing or cannot be parsed.
518pub fn parse_ticker_linear_mark_price(
519    data: &BybitWsTickerLinear,
520    instrument: &InstrumentAny,
521    ts_event: UnixNanos,
522    ts_init: UnixNanos,
523) -> anyhow::Result<MarkPriceUpdate> {
524    let mark_price_str = data
525        .mark_price
526        .as_ref()
527        .context("Bybit ticker missing mark_price")?;
528
529    let price =
530        parse_price_with_precision(mark_price_str, instrument.price_precision(), "mark_price")?;
531
532    Ok(MarkPriceUpdate::new(
533        instrument.id(),
534        price,
535        ts_event,
536        ts_init,
537    ))
538}
539
540/// Parses a linear/inverse ticker payload into an [`IndexPriceUpdate`].
541///
542/// # Errors
543///
544/// Returns an error if the index_price field is missing or cannot be parsed.
545pub fn parse_ticker_linear_index_price(
546    data: &BybitWsTickerLinear,
547    instrument: &InstrumentAny,
548    ts_event: UnixNanos,
549    ts_init: UnixNanos,
550) -> anyhow::Result<IndexPriceUpdate> {
551    let index_price_str = data
552        .index_price
553        .as_ref()
554        .context("Bybit ticker missing index_price")?;
555
556    let price =
557        parse_price_with_precision(index_price_str, instrument.price_precision(), "index_price")?;
558
559    Ok(IndexPriceUpdate::new(
560        instrument.id(),
561        price,
562        ts_event,
563        ts_init,
564    ))
565}
566
567/// Parses an option ticker payload into a [`MarkPriceUpdate`].
568///
569/// # Errors
570///
571/// Returns an error if the mark_price field cannot be parsed.
572pub fn parse_ticker_option_mark_price(
573    msg: &BybitWsTickerOptionMsg,
574    instrument: &InstrumentAny,
575    ts_init: UnixNanos,
576) -> anyhow::Result<MarkPriceUpdate> {
577    let ts_event = parse_millis_i64(msg.ts, "ticker.ts")?;
578
579    let price = parse_price_with_precision(
580        &msg.data.mark_price,
581        instrument.price_precision(),
582        "mark_price",
583    )?;
584
585    Ok(MarkPriceUpdate::new(
586        instrument.id(),
587        price,
588        ts_event,
589        ts_init,
590    ))
591}
592
593/// Parses an option ticker payload into an [`IndexPriceUpdate`].
594///
595/// # Errors
596///
597/// Returns an error if the index_price field cannot be parsed.
598pub fn parse_ticker_option_index_price(
599    msg: &BybitWsTickerOptionMsg,
600    instrument: &InstrumentAny,
601    ts_init: UnixNanos,
602) -> anyhow::Result<IndexPriceUpdate> {
603    let ts_event = parse_millis_i64(msg.ts, "ticker.ts")?;
604
605    let price = parse_price_with_precision(
606        &msg.data.index_price,
607        instrument.price_precision(),
608        "index_price",
609    )?;
610
611    Ok(IndexPriceUpdate::new(
612        instrument.id(),
613        price,
614        ts_event,
615        ts_init,
616    ))
617}
618
619/// Parses an option ticker payload into [`OptionGreeks`].
620///
621/// # Errors
622///
623/// Returns an error if any of the greek fields cannot be parsed as f64.
624pub fn parse_ticker_option_greeks(
625    msg: &BybitWsTickerOptionMsg,
626    instrument: &InstrumentAny,
627    ts_init: UnixNanos,
628) -> anyhow::Result<OptionGreeks> {
629    let ts_event = parse_millis_i64(msg.ts, "ticker.ts")?;
630
631    let delta: f64 = msg.data.delta.parse().context("invalid delta")?;
632    let gamma: f64 = msg.data.gamma.parse().context("invalid gamma")?;
633    let vega: f64 = msg.data.vega.parse().context("invalid vega")?;
634    let theta: f64 = msg.data.theta.parse().context("invalid theta")?;
635
636    let bid_iv: f64 = msg.data.bid_iv.parse().context("invalid bid_iv")?;
637    let ask_iv: f64 = msg.data.ask_iv.parse().context("invalid ask_iv")?;
638    let mark_iv: f64 = msg
639        .data
640        .mark_price_iv
641        .parse()
642        .context("invalid mark_price_iv")?;
643    let underlying_price: f64 = msg
644        .data
645        .underlying_price
646        .parse()
647        .context("invalid underlying_price")?;
648    let open_interest: f64 = msg
649        .data
650        .open_interest
651        .parse()
652        .context("invalid open_interest")?;
653
654    Ok(OptionGreeks {
655        instrument_id: instrument.id(),
656        convention: GreeksConvention::BlackScholes,
657        greeks: OptionGreekValues {
658            delta,
659            gamma,
660            vega,
661            theta,
662            rho: 0.0, // Bybit doesn't provide rho
663        },
664        mark_iv: Some(mark_iv),
665        bid_iv: Some(bid_iv),
666        ask_iv: Some(ask_iv),
667        underlying_price: Some(underlying_price),
668        open_interest: Some(open_interest),
669        ts_event,
670        ts_init,
671    })
672}
673
674pub(crate) fn parse_millis_i64(value: i64, field: &str) -> anyhow::Result<UnixNanos> {
675    if value < 0 {
676        Err(anyhow::anyhow!("{field} must be non-negative, was {value}"))
677    } else {
678        let nanos = (value as u64)
679            .checked_mul(NANOSECONDS_IN_MILLISECOND)
680            .ok_or_else(|| anyhow::anyhow!("millisecond timestamp overflowed"))?;
681        Ok(UnixNanos::from(nanos))
682    }
683}
684
685/// Parses a WebSocket kline payload into a [`Bar`].
686///
687/// # Errors
688///
689/// Returns an error if price or volume fields cannot be parsed or if the bar cannot be constructed.
690pub fn parse_ws_kline_bar(
691    kline: &BybitWsKline,
692    instrument: &InstrumentAny,
693    bar_type: BarType,
694    timestamp_on_close: bool,
695    ts_init: UnixNanos,
696) -> anyhow::Result<Bar> {
697    let price_precision = instrument.price_precision();
698    let size_precision = instrument.size_precision();
699
700    let open = parse_price_with_precision(&kline.open, price_precision, "kline.open")?;
701    let high = parse_price_with_precision(&kline.high, price_precision, "kline.high")?;
702    let low = parse_price_with_precision(&kline.low, price_precision, "kline.low")?;
703    let close = parse_price_with_precision(&kline.close, price_precision, "kline.close")?;
704    let volume = parse_quantity_with_precision(&kline.volume, size_precision, "kline.volume")?;
705
706    let mut ts_event = parse_millis_i64(kline.start, "kline.start")?;
707
708    if timestamp_on_close {
709        let interval_ns = bar_type.spec().timedelta().as_nanos();
710        let interval_ns = u64::try_from(interval_ns)
711            .context("bar interval overflowed the u64 range for nanoseconds")?;
712        let updated = ts_event
713            .as_u64()
714            .checked_add(interval_ns)
715            .context("bar timestamp overflowed when adjusting to close time")?;
716        ts_event = UnixNanos::from(updated);
717    }
718    let ts_init = if ts_init.is_zero() { ts_event } else { ts_init };
719
720    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
721        .context("failed to construct Bar from Bybit WebSocket kline")
722}
723
724/// Parses a WebSocket account order payload into an [`OrderStatusReport`].
725///
726/// # Errors
727///
728/// Returns an error if price or quantity fields cannot be parsed or timestamps are invalid.
729pub fn parse_ws_order_status_report(
730    order: &BybitWsAccountOrder,
731    instrument: &InstrumentAny,
732    account_id: AccountId,
733    ts_init: UnixNanos,
734) -> anyhow::Result<OrderStatusReport> {
735    let instrument_id = instrument.id();
736    let venue_order_id = VenueOrderId::new(order.order_id.as_str());
737    let order_side: Option<OrderSide> = order.side.into();
738
739    let order_type = parse_bybit_order_type(
740        order.order_type,
741        order.stop_order_type,
742        order.trigger_direction,
743        order.side,
744    );
745
746    let time_in_force: TimeInForce = match order.time_in_force {
747        BybitTimeInForce::Gtc => TimeInForce::Gtc,
748        BybitTimeInForce::Ioc => TimeInForce::Ioc,
749        BybitTimeInForce::Fok => TimeInForce::Fok,
750        BybitTimeInForce::PostOnly => TimeInForce::Gtc,
751    };
752
753    let quantity =
754        parse_quantity_with_precision(&order.qty, instrument.size_precision(), "order.qty")?;
755
756    let filled_qty = parse_quantity_with_precision(
757        &order.cum_exec_qty,
758        instrument.size_precision(),
759        "order.cumExecQty",
760    )?;
761
762    // Map Bybit order status to Nautilus order status
763    // Special case: if Bybit reports "Rejected" but the order has fills, treat it as Canceled.
764    // This handles the case where the exchange partially fills an order then rejects the
765    // remaining quantity (e.g., due to margin, risk limits, or liquidity constraints).
766    // The state machine does not allow PARTIALLY_FILLED -> REJECTED transitions.
767    let order_status: OrderStatus = match order.order_status {
768        BybitOrderStatus::Created | BybitOrderStatus::New | BybitOrderStatus::Untriggered => {
769            OrderStatus::Accepted
770        }
771        BybitOrderStatus::Rejected => {
772            if filled_qty.is_positive() {
773                OrderStatus::Canceled
774            } else {
775                OrderStatus::Rejected
776            }
777        }
778        BybitOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
779        BybitOrderStatus::Filled => OrderStatus::Filled,
780        // A post-only order that would take liquidity is reported as Cancelled with
781        // rejectReason=EC_PostOnlyWillTakeLiquidity (not Rejected). Surface it as Rejected
782        // for consistency with the tracked-order event path.
783        BybitOrderStatus::Canceled
784            if filled_qty.is_zero()
785                && bybit_rejection_due_post_only(order.reject_reason.as_str()) =>
786        {
787            OrderStatus::Rejected
788        }
789        BybitOrderStatus::Canceled | BybitOrderStatus::PartiallyFilledCanceled => {
790            OrderStatus::Canceled
791        }
792        BybitOrderStatus::Triggered => OrderStatus::Triggered,
793        BybitOrderStatus::Deactivated => OrderStatus::Canceled,
794    };
795
796    let ts_accepted = parse_millis_timestamp(&order.created_time, "order.createdTime")?;
797    let ts_last = parse_millis_timestamp(&order.updated_time, "order.updatedTime")?;
798
799    let mut report = OrderStatusReport::new(
800        account_id,
801        instrument_id,
802        None,
803        venue_order_id,
804        order_side,
805        order_type,
806        time_in_force,
807        order_status,
808        quantity,
809        filled_qty,
810        ts_accepted,
811        ts_last,
812        ts_init,
813        Some(UUID4::new()),
814    );
815
816    if !order.order_link_id.is_empty() {
817        report = report.with_client_order_id(ClientOrderId::new(order.order_link_id.as_str()));
818    }
819
820    if !order.price.is_empty() && order.price != "0" {
821        let price =
822            parse_price_with_precision(&order.price, instrument.price_precision(), "order.price")?;
823        report = report.with_price(price);
824    }
825
826    if !order.avg_price.is_empty() && order.avg_price != "0" {
827        let avg_px = order.avg_price.parse::<Decimal>().with_context(|| {
828            format!("Failed to parse avg_price='{}' as Decimal", order.avg_price)
829        })?;
830        report = report.with_avg_px(avg_px);
831    }
832
833    if !order.trigger_price.is_empty() && order.trigger_price != "0" {
834        let trigger_price = parse_price_with_precision(
835            &order.trigger_price,
836            instrument.price_precision(),
837            "order.triggerPrice",
838        )?;
839        report = report.with_trigger_price(trigger_price);
840
841        // Set trigger_type for conditional orders
842        let trigger_type: TriggerType = order.trigger_by.into();
843        report = report.with_trigger_type(trigger_type);
844    }
845
846    if let Some(venue_position_id) = make_hedge_venue_position_id(instrument_id, order.position_idx)
847    {
848        report = report.with_venue_position_id(venue_position_id);
849    }
850
851    if order.reduce_only {
852        report = report.with_reduce_only(true);
853    }
854
855    if order.time_in_force == BybitTimeInForce::PostOnly {
856        report = report.with_post_only(true);
857    }
858
859    if !order.reject_reason.is_empty() {
860        report = report.with_cancel_reason(order.reject_reason.to_string());
861    }
862
863    Ok(report)
864}
865
866/// Parses a WebSocket account execution payload into a [`FillReport`].
867///
868/// # Errors
869///
870/// Returns an error if price or quantity fields cannot be parsed or timestamps are invalid.
871pub fn parse_ws_fill_report(
872    execution: &BybitWsAccountExecution,
873    account_id: AccountId,
874    instrument: &InstrumentAny,
875    ts_init: UnixNanos,
876) -> anyhow::Result<FillReport> {
877    let instrument_id = instrument.id();
878    let venue_order_id = VenueOrderId::new(execution.order_id.as_str());
879    let trade_id = TradeId::new_checked(execution.exec_id.as_str())
880        .context("invalid execId in Bybit WebSocket execution payload")?;
881
882    let order_side = OrderSide::try_from(execution.side)?;
883    let last_qty = parse_quantity_with_precision(
884        &execution.exec_qty,
885        instrument.size_precision(),
886        "execution.execQty",
887    )?;
888    let last_px = parse_price_with_precision(
889        &execution.exec_price,
890        instrument.price_precision(),
891        "execution.execPrice",
892    )?;
893
894    let liquidity_side = if execution.is_maker {
895        LiquiditySide::Maker
896    } else {
897        LiquiditySide::Taker
898    };
899
900    let fee_decimal: Decimal = execution
901        .exec_fee
902        .parse()
903        .with_context(|| format!("Failed to parse execFee='{}'", execution.exec_fee))?;
904
905    let commission_currency = get_currency(&execution.fee_currency);
906    let commission = Money::from_decimal(fee_decimal, commission_currency).with_context(|| {
907        format!(
908            "Failed to create commission from execFee='{}'",
909            execution.exec_fee
910        )
911    })?;
912    let ts_event = parse_millis_timestamp(&execution.exec_time, "execution.execTime")?;
913
914    let client_order_id = if execution.order_link_id.is_empty() {
915        None
916    } else {
917        Some(ClientOrderId::new(execution.order_link_id.as_str()))
918    };
919
920    Ok(FillReport::new(
921        account_id,
922        instrument_id,
923        venue_order_id,
924        trade_id,
925        order_side,
926        last_qty,
927        last_px,
928        commission,
929        liquidity_side,
930        client_order_id,
931        None, // venue_position_id: execution data lacks position_idx
932        ts_event,
933        ts_init,
934        None, // report_id
935    ))
936}
937
938/// Parses a fast-stream WebSocket execution payload into a [`FillReport`].
939///
940/// The `execution.fast` channel omits fee and exec-type fields, so the resulting
941/// report carries zero commission. Liquidity side is derived from the payload's
942/// `isMaker` flag. Pair with the standard `execution` channel if exact fee data
943/// is required.
944///
945/// `venue_position_id` should be supplied for tracked hedge-mode orders so the
946/// emitted report carries the long/short position identity that the standard
947/// channel preserves via `OrderFilled`.
948///
949/// # Errors
950///
951/// Returns an error if price or quantity fields cannot be parsed or timestamps are invalid.
952pub fn parse_ws_fill_report_fast(
953    execution: &BybitWsAccountExecutionFast,
954    account_id: AccountId,
955    instrument: &InstrumentAny,
956    venue_position_id: Option<PositionId>,
957    ts_init: UnixNanos,
958) -> anyhow::Result<FillReport> {
959    let instrument_id = instrument.id();
960    let venue_order_id = VenueOrderId::new(execution.order_id.as_str());
961    let trade_id = TradeId::new_checked(execution.exec_id.as_str())
962        .context("invalid execId in Bybit WebSocket fast-execution payload")?;
963
964    let order_side = OrderSide::try_from(execution.side)?;
965    let last_qty = parse_quantity_with_precision(
966        &execution.exec_qty,
967        instrument.size_precision(),
968        "execution.execQty",
969    )?;
970    let last_px = parse_price_with_precision(
971        &execution.exec_price,
972        instrument.price_precision(),
973        "execution.execPrice",
974    )?;
975
976    let liquidity_side = if execution.is_maker {
977        LiquiditySide::Maker
978    } else {
979        LiquiditySide::Taker
980    };
981
982    // execution.fast carries no fee data (no rate or currency)
983    let commission_currency = instrument.quote_currency();
984    let commission = Money::from_decimal(Decimal::ZERO, commission_currency)
985        .with_context(|| format!("Failed to create zero commission for {commission_currency}"))?;
986    let ts_event = parse_millis_timestamp(&execution.exec_time, "execution.execTime")?;
987
988    let client_order_id = if execution.order_link_id.is_empty() {
989        None
990    } else {
991        Some(ClientOrderId::new(execution.order_link_id.as_str()))
992    };
993
994    Ok(FillReport::new(
995        account_id,
996        instrument_id,
997        venue_order_id,
998        trade_id,
999        order_side,
1000        last_qty,
1001        last_px,
1002        commission,
1003        liquidity_side,
1004        client_order_id,
1005        venue_position_id,
1006        ts_event,
1007        ts_init,
1008        None,
1009    ))
1010}
1011
1012/// Parses a WebSocket account position payload into a [`PositionStatusReport`].
1013///
1014/// # Errors
1015///
1016/// Returns an error if position size or prices cannot be parsed.
1017pub fn parse_ws_position_status_report(
1018    position: &BybitWsAccountPosition,
1019    account_id: AccountId,
1020    instrument: &InstrumentAny,
1021    ts_init: UnixNanos,
1022) -> anyhow::Result<PositionStatusReport> {
1023    let instrument_id = instrument.id();
1024
1025    // Parse absolute size as unsigned Quantity
1026    let quantity = parse_quantity_with_precision(
1027        &position.size,
1028        instrument.size_precision(),
1029        "position.size",
1030    )?;
1031
1032    let position_side = match position.side {
1033        BybitPositionSide::Buy => PositionSide::Long,
1034        BybitPositionSide::Sell => PositionSide::Short,
1035        BybitPositionSide::Flat => PositionSide::Flat,
1036    };
1037
1038    // Bybit ranks open positions 1-5 by ADL priority (5 = next to be deleveraged);
1039    // 0 means the account has no open position or is flat. Warn when approaching the
1040    // top tier so operators can react before the venue force-closes.
1041    if position.adl_rank_indicator >= 4 {
1042        log::warn!(
1043            "Elevated ADL risk: {} position size={} adl_rank={}",
1044            instrument_id,
1045            position.size,
1046            position.adl_rank_indicator,
1047        );
1048    }
1049
1050    let ts_last = parse_millis_timestamp(&position.updated_time, "position.updatedTime")?;
1051
1052    let venue_position_id = make_hedge_venue_position_id(instrument_id, position.position_idx);
1053
1054    Ok(PositionStatusReport::new(
1055        account_id,
1056        instrument_id,
1057        position_side,
1058        quantity,
1059        ts_last,
1060        ts_init,
1061        None, // report_id
1062        venue_position_id,
1063        position.entry_price, // avg_px_open
1064    ))
1065}
1066
1067/// Parses a WebSocket account wallet payload into an [`AccountState`].
1068///
1069/// # Errors
1070///
1071/// Returns an error if balance fields cannot be parsed.
1072pub fn parse_ws_account_state(
1073    wallet: &BybitWsAccountWallet,
1074    account_id: AccountId,
1075    ts_event: UnixNanos,
1076    ts_init: UnixNanos,
1077) -> anyhow::Result<AccountState> {
1078    let mut balances = Vec::new();
1079    let mut margins = Vec::new();
1080
1081    for coin_data in &wallet.coin {
1082        let currency = get_currency(coin_data.coin.as_str());
1083        let total_dec = coin_data.wallet_balance - coin_data.spot_borrow;
1084        let locked_dec = coin_data.total_order_im + coin_data.total_position_im;
1085
1086        balances.push(AccountBalance::from_total_and_locked(
1087            total_dec, locked_dec, currency,
1088        )?);
1089
1090        // Sum position IM (reserved by open positions) and order IM (reserved by
1091        // pending orders) so the reported initial margin reflects either source.
1092        let initial_margin_dec = coin_data.total_position_im + coin_data.total_order_im;
1093        let maintenance_margin_dec = match &coin_data.total_position_mm {
1094            Some(mm) if !mm.is_empty() => mm.parse::<Decimal>()?,
1095            _ => Decimal::ZERO,
1096        };
1097
1098        if !initial_margin_dec.is_zero() || !maintenance_margin_dec.is_zero() {
1099            margins.push(MarginBalance::new(
1100                Money::from_decimal(initial_margin_dec, currency)?,
1101                Money::from_decimal(maintenance_margin_dec, currency)?,
1102                None,
1103            ));
1104        }
1105    }
1106
1107    Ok(AccountState::new(
1108        account_id,
1109        AccountType::Margin, // Bybit unified account
1110        balances,
1111        margins,
1112        true, // is_reported
1113        UUID4::new(),
1114        ts_event,
1115        ts_init,
1116        None, // base_currency
1117    ))
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122    use std::str::FromStr;
1123
1124    use nautilus_model::{
1125        data::BarSpecification,
1126        enums::{
1127            AggregationSource, BarAggregation, OrderType, PositionSide, PriceType, TriggerType,
1128        },
1129        identifiers::PositionId,
1130    };
1131    use rstest::rstest;
1132    use rust_decimal_macros::dec;
1133
1134    use super::*;
1135    use crate::{
1136        common::{
1137            enums::{BybitExecType, BybitOrderSide, BybitProductType},
1138            parse::{parse_linear_instrument, parse_option_instrument},
1139            testing::load_test_json,
1140        },
1141        http::models::{BybitInstrumentLinearResponse, BybitInstrumentOptionResponse},
1142        websocket::messages::{
1143            BybitWsAccountExecutionMsg, BybitWsOrderbookDepthMsg, BybitWsTickerLinearMsg,
1144            BybitWsTickerOptionMsg, BybitWsTradeMsg,
1145        },
1146    };
1147
1148    const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
1149
1150    use ustr::Ustr;
1151
1152    use crate::http::models::BybitFeeRate;
1153
1154    fn sample_fee_rate(
1155        symbol: &str,
1156        taker: &str,
1157        maker: &str,
1158        base_coin: Option<&str>,
1159    ) -> BybitFeeRate {
1160        BybitFeeRate {
1161            symbol: Ustr::from(symbol),
1162            taker_fee_rate: taker.to_string(),
1163            maker_fee_rate: maker.to_string(),
1164            base_coin: base_coin.map(Ustr::from),
1165        }
1166    }
1167
1168    fn linear_instrument() -> InstrumentAny {
1169        let json = load_test_json("http_get_instruments_linear.json");
1170        let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
1171        let instrument = &response.result.list[0];
1172        let fee_rate = sample_fee_rate("BTCUSDT", "0.00055", "0.0001", Some("BTC"));
1173        parse_linear_instrument(instrument, &fee_rate, TS, TS).unwrap()
1174    }
1175
1176    fn option_instrument() -> InstrumentAny {
1177        let json = load_test_json("http_get_instruments_option.json");
1178        let response: BybitInstrumentOptionResponse = serde_json::from_str(&json).unwrap();
1179        let instrument = &response.result.list[0];
1180        parse_option_instrument(instrument, None, TS, TS).unwrap()
1181    }
1182
1183    #[rstest]
1184    fn parse_ws_trade_into_trade_tick() {
1185        let instrument = linear_instrument();
1186        let json = load_test_json("ws_public_trade.json");
1187        let msg: BybitWsTradeMsg = serde_json::from_str(&json).unwrap();
1188        let trade = &msg.data[0];
1189
1190        let tick = parse_ws_trade_tick(trade, &instrument, TS).unwrap();
1191
1192        assert_eq!(tick.instrument_id, instrument.id());
1193        assert_eq!(tick.price, instrument.make_price(27451.00));
1194        assert_eq!(tick.size, instrument.make_qty(0.010, None));
1195        assert_eq!(tick.aggressor_side, AggressorSide::Buy);
1196        assert_eq!(
1197            tick.trade_id.to_string(),
1198            "9dc75fca-4bdd-4773-9f78-6f5d7ab2a110"
1199        );
1200        assert_eq!(tick.ts_event, UnixNanos::new(1_709_891_679_000_000_000));
1201    }
1202
1203    #[rstest]
1204    fn parse_orderbook_snapshot_into_deltas() {
1205        let instrument = linear_instrument();
1206        let json = load_test_json("ws_orderbook_snapshot.json");
1207        let msg: BybitWsOrderbookDepthMsg = serde_json::from_str(&json).unwrap();
1208
1209        let deltas = parse_orderbook_deltas(&msg, &instrument, TS).unwrap();
1210
1211        assert_eq!(deltas.instrument_id, instrument.id());
1212        assert_eq!(deltas.deltas.len(), 5);
1213        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1214        assert_eq!(
1215            deltas.deltas[1].order.price,
1216            instrument.make_price(27450.00)
1217        );
1218        assert_eq!(
1219            deltas.deltas[1].order.size,
1220            instrument.make_qty(0.500, None)
1221        );
1222        let last = deltas.deltas.last().unwrap();
1223        assert_eq!(last.order.side, OrderSide::Sell.into());
1224        assert_eq!(last.order.price, instrument.make_price(27451.50));
1225        assert_eq!(
1226            last.flags & RecordFlag::F_LAST as u8,
1227            RecordFlag::F_LAST as u8
1228        );
1229    }
1230
1231    #[rstest]
1232    fn parse_orderbook_delta_marks_actions() {
1233        let instrument = linear_instrument();
1234        let json = load_test_json("ws_orderbook_delta.json");
1235        let msg: BybitWsOrderbookDepthMsg = serde_json::from_str(&json).unwrap();
1236
1237        let deltas = parse_orderbook_deltas(&msg, &instrument, TS).unwrap();
1238
1239        assert_eq!(deltas.deltas.len(), 2);
1240        let bid = &deltas.deltas[0];
1241        assert_eq!(bid.action, BookAction::Update);
1242        assert_eq!(bid.order.side, OrderSide::Buy.into());
1243        assert_eq!(bid.order.size, instrument.make_qty(0.400, None));
1244
1245        let ask = &deltas.deltas[1];
1246        assert_eq!(ask.action, BookAction::Delete);
1247        assert_eq!(ask.order.side, OrderSide::Sell.into());
1248        assert_eq!(ask.order.size, instrument.make_qty(0.0, None));
1249        assert_eq!(
1250            ask.flags & RecordFlag::F_LAST as u8,
1251            RecordFlag::F_LAST as u8
1252        );
1253    }
1254
1255    #[rstest]
1256    fn parse_orderbook_quote_produces_top_of_book() {
1257        let instrument = linear_instrument();
1258        let json = load_test_json("ws_orderbook_snapshot.json");
1259        let msg: BybitWsOrderbookDepthMsg = serde_json::from_str(&json).unwrap();
1260
1261        let quote = parse_orderbook_quote(&msg, &instrument, None, TS).unwrap();
1262
1263        assert_eq!(quote.instrument_id, instrument.id());
1264        assert_eq!(quote.bid_price, instrument.make_price(27450.00));
1265        assert_eq!(quote.bid_size, instrument.make_qty(0.500, None));
1266        assert_eq!(quote.ask_price, instrument.make_price(27451.00));
1267        assert_eq!(quote.ask_size, instrument.make_qty(0.750, None));
1268    }
1269
1270    #[rstest]
1271    fn parse_orderbook_quote_with_delta_updates_sizes() {
1272        let instrument = linear_instrument();
1273        let snapshot: BybitWsOrderbookDepthMsg =
1274            serde_json::from_str(&load_test_json("ws_orderbook_snapshot.json")).unwrap();
1275        let base_quote = parse_orderbook_quote(&snapshot, &instrument, None, TS).unwrap();
1276
1277        let delta: BybitWsOrderbookDepthMsg =
1278            serde_json::from_str(&load_test_json("ws_orderbook_delta.json")).unwrap();
1279        let updated = parse_orderbook_quote(&delta, &instrument, Some(&base_quote), TS).unwrap();
1280
1281        assert_eq!(updated.bid_price, instrument.make_price(27450.00));
1282        assert_eq!(updated.bid_size, instrument.make_qty(0.400, None));
1283        assert_eq!(updated.ask_price, instrument.make_price(27451.00));
1284        assert_eq!(updated.ask_size, instrument.make_qty(0.0, None));
1285    }
1286
1287    #[rstest]
1288    fn parse_linear_ticker_quote_to_quote_tick() {
1289        let instrument = linear_instrument();
1290        let json = load_test_json("ws_ticker_linear.json");
1291        let msg: BybitWsTickerLinearMsg = serde_json::from_str(&json).unwrap();
1292
1293        let quote = parse_ticker_linear_quote(&msg, &instrument, TS).unwrap();
1294
1295        assert_eq!(quote.instrument_id, instrument.id());
1296        assert_eq!(quote.bid_price, instrument.make_price(17215.50));
1297        assert_eq!(quote.ask_price, instrument.make_price(17216.00));
1298        assert_eq!(quote.bid_size, instrument.make_qty(84.489, None));
1299        assert_eq!(quote.ask_size, instrument.make_qty(83.020, None));
1300        assert_eq!(quote.ts_event, UnixNanos::new(1_673_272_861_686_000_000));
1301        assert_eq!(quote.ts_init, TS);
1302    }
1303
1304    #[rstest]
1305    fn parse_option_ticker_quote_to_quote_tick() {
1306        let instrument = option_instrument();
1307        let json = load_test_json("ws_ticker_option.json");
1308        let msg: BybitWsTickerOptionMsg = serde_json::from_str(&json).unwrap();
1309
1310        let quote = parse_ticker_option_quote(&msg, &instrument, TS).unwrap();
1311
1312        assert_eq!(quote.instrument_id, instrument.id());
1313        assert_eq!(quote.bid_price, instrument.make_price(0.0));
1314        assert_eq!(quote.ask_price, instrument.make_price(10.0));
1315        assert_eq!(quote.bid_size, instrument.make_qty(0.0, None));
1316        assert_eq!(quote.ask_size, instrument.make_qty(5.1, None));
1317        assert_eq!(quote.ts_event, UnixNanos::new(1_672_917_511_074_000_000));
1318        assert_eq!(quote.ts_init, TS);
1319    }
1320
1321    #[rstest]
1322    #[case::timestamp_on_open(false, 1_672_324_800_000_000_000)]
1323    #[case::timestamp_on_close(true, 1_672_325_100_000_000_000)]
1324    fn parse_ws_kline_into_bar(#[case] timestamp_on_close: bool, #[case] expected_ts_event: u64) {
1325        use std::num::NonZero;
1326
1327        let instrument = linear_instrument();
1328        let json = load_test_json("ws_kline.json");
1329        let msg: crate::websocket::messages::BybitWsKlineMsg = serde_json::from_str(&json).unwrap();
1330        let kline = &msg.data[0];
1331
1332        let bar_spec = BarSpecification {
1333            step: NonZero::new(5).unwrap(),
1334            aggregation: BarAggregation::Minute,
1335            price_type: PriceType::Last,
1336        };
1337        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::External);
1338
1339        let bar = parse_ws_kline_bar(kline, &instrument, bar_type, timestamp_on_close, TS).unwrap();
1340
1341        assert_eq!(bar.bar_type, bar_type);
1342        assert_eq!(bar.open, instrument.make_price(16649.5));
1343        assert_eq!(bar.high, instrument.make_price(16677.0));
1344        assert_eq!(bar.low, instrument.make_price(16608.0));
1345        assert_eq!(bar.close, instrument.make_price(16677.0));
1346        assert_eq!(bar.volume, instrument.make_qty(2.081, None));
1347        assert_eq!(bar.ts_event, UnixNanos::new(expected_ts_event));
1348        assert_eq!(bar.ts_init, TS);
1349    }
1350
1351    #[rstest]
1352    fn parse_ws_order_into_order_status_report() {
1353        let instrument = linear_instrument();
1354        let json = load_test_json("ws_account_order_filled.json");
1355        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1356            serde_json::from_str(&json).unwrap();
1357        let order = &msg.data[0];
1358        let account_id = AccountId::new("BYBIT-001");
1359
1360        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1361
1362        assert_eq!(report.account_id, account_id);
1363        assert_eq!(report.instrument_id, instrument.id());
1364        assert_eq!(report.order_side, OrderSide::Buy.into());
1365        assert_eq!(report.order_type, OrderType::Limit);
1366        assert_eq!(report.time_in_force, TimeInForce::Gtc);
1367        assert_eq!(report.order_status, OrderStatus::Filled);
1368        assert_eq!(report.quantity, instrument.make_qty(0.100, None));
1369        assert_eq!(report.filled_qty, instrument.make_qty(0.100, None));
1370        assert_eq!(report.price, Some(instrument.make_price(30000.50)));
1371        assert_eq!(report.avg_px, Some(dec!(30000.50)));
1372        assert_eq!(
1373            report.client_order_id.as_ref().unwrap().to_string(),
1374            "test-client-order-001"
1375        );
1376        assert_eq!(
1377            report.ts_accepted,
1378            UnixNanos::new(1_672_364_262_444_000_000)
1379        );
1380        assert_eq!(report.ts_last, UnixNanos::new(1_672_364_262_457_000_000));
1381    }
1382
1383    #[rstest]
1384    fn parse_ws_order_avg_price_keeps_every_digit_the_venue_sent() {
1385        // 28 significant digits is exactly what `Decimal` holds, and more than `f64` can:
1386        // routing the same string through `f64` first collapses it to 30000.500000000004.
1387        let raw = "30000.50000000000372529029846";
1388        let instrument = linear_instrument();
1389        let json = load_test_json("ws_account_order_filled.json");
1390        let mut msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1391            serde_json::from_str(&json).unwrap();
1392        msg.data[0].avg_price = raw.to_string();
1393
1394        let report = parse_ws_order_status_report(
1395            &msg.data[0],
1396            &instrument,
1397            AccountId::new("BYBIT-001"),
1398            TS,
1399        )
1400        .unwrap();
1401
1402        let via_f64: Decimal = raw.parse::<f64>().unwrap().to_string().parse().unwrap();
1403        assert_eq!(report.avg_px, Some(Decimal::from_str(raw).unwrap()));
1404        assert_ne!(report.avg_px, Some(via_f64));
1405    }
1406
1407    #[rstest]
1408    fn parse_ws_order_partially_filled_rejected_maps_to_canceled() {
1409        let instrument = linear_instrument();
1410        let json = load_test_json("ws_account_order_partially_filled_rejected.json");
1411        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1412            serde_json::from_str(&json).unwrap();
1413        let order = &msg.data[0];
1414        let account_id = AccountId::new("BYBIT-001");
1415
1416        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1417
1418        // Verify that Bybit "Rejected" status with fills is mapped to Canceled, not Rejected
1419        assert_eq!(report.order_status, OrderStatus::Canceled);
1420        assert_eq!(report.filled_qty, instrument.make_qty(50.0, None));
1421        assert_eq!(
1422            report.client_order_id.as_ref().unwrap().to_string(),
1423            "O-20251001-164609-APEX-000-49"
1424        );
1425        assert_eq!(report.cancel_reason, Some("UNKNOWN".to_string()));
1426    }
1427
1428    #[rstest]
1429    fn parse_ws_order_post_only_cancel_maps_to_rejected() {
1430        let instrument = linear_instrument();
1431        let json = load_test_json("ws_account_order.json");
1432        let mut msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1433            serde_json::from_str(&json).unwrap();
1434
1435        let order = msg.data.first_mut().unwrap();
1436        order.reject_reason = Ustr::from("EC_PostOnlyWillTakeLiquidity");
1437        order.cum_exec_qty = "0".to_string();
1438        let account_id = AccountId::new("BYBIT-001");
1439
1440        let report =
1441            parse_ws_order_status_report(&msg.data[0], &instrument, account_id, TS).unwrap();
1442
1443        assert_eq!(report.order_status, OrderStatus::Rejected);
1444        assert_eq!(
1445            report.cancel_reason,
1446            Some("EC_PostOnlyWillTakeLiquidity".to_string())
1447        );
1448    }
1449
1450    #[rstest]
1451    fn parse_ws_execution_into_fill_report() {
1452        let instrument = linear_instrument();
1453        let json = load_test_json("ws_account_execution.json");
1454        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1455            serde_json::from_str(&json).unwrap();
1456        let execution = &msg.data[0];
1457        let account_id = AccountId::new("BYBIT-001");
1458
1459        let report = parse_ws_fill_report(execution, account_id, &instrument, TS).unwrap();
1460
1461        assert_eq!(report.account_id, account_id);
1462        assert_eq!(report.instrument_id, instrument.id());
1463        assert_eq!(
1464            report.venue_order_id.to_string(),
1465            "9aac161b-8ed6-450d-9cab-c5cc67c21784"
1466        );
1467        assert_eq!(
1468            report.trade_id.to_string(),
1469            "0ab1bdf7-4219-438b-b30a-32ec863018f7"
1470        );
1471        assert_eq!(report.order_side, OrderSide::Sell);
1472        assert_eq!(report.last_qty, instrument.make_qty(0.5, None));
1473        assert_eq!(report.last_px, instrument.make_price(95900.1));
1474        assert_eq!(report.commission.as_f64(), 26.3725275);
1475        assert_eq!(report.commission.currency.code.as_str(), "USDT");
1476        assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1477        assert_eq!(
1478            report.client_order_id.as_ref().unwrap().to_string(),
1479            "test-order-link-001"
1480        );
1481        assert_eq!(report.ts_event, UnixNanos::new(1_746_270_400_353_000_000));
1482    }
1483
1484    #[rstest]
1485    fn parse_ws_adl_execution_into_fill_report() {
1486        let instrument = linear_instrument();
1487        let json = load_test_json("ws_account_execution_adl.json");
1488        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1489            serde_json::from_str(&json).unwrap();
1490        let execution = &msg.data[0];
1491        let account_id = AccountId::new("BYBIT-001");
1492
1493        assert_eq!(execution.exec_type, BybitExecType::AdlTrade);
1494        assert!(execution.exec_type.is_exchange_generated());
1495        assert!(execution.order_link_id.is_empty());
1496
1497        let report = parse_ws_fill_report(execution, account_id, &instrument, TS).unwrap();
1498
1499        // ADL fills carry an empty orderLinkId; client_order_id is None so the engine
1500        // creates the order as external from the accompanying order status report.
1501        assert_eq!(report.client_order_id, None);
1502        assert_eq!(
1503            report.venue_order_id.to_string(),
1504            "9aac161b-8ed6-450d-9cab-c5cc67c21785"
1505        );
1506        assert_eq!(report.order_side, OrderSide::Sell);
1507        assert_eq!(report.last_qty, instrument.make_qty(0.5, None));
1508        assert_eq!(report.last_px, instrument.make_price(95850.0));
1509        assert_eq!(report.commission.as_f64(), 0.0);
1510        assert_eq!(report.commission.currency.code.as_str(), "USDT");
1511    }
1512
1513    #[rstest]
1514    fn parse_ws_fill_report_venue_position_id_is_none() {
1515        let instrument = linear_instrument();
1516        let json = load_test_json("ws_account_execution.json");
1517        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1518            serde_json::from_str(&json).unwrap();
1519        let execution = &msg.data[0];
1520        let account_id = AccountId::new("BYBIT-001");
1521
1522        let report = parse_ws_fill_report(execution, account_id, &instrument, TS).unwrap();
1523
1524        assert_eq!(report.venue_position_id, None);
1525    }
1526
1527    #[rstest]
1528    fn parse_ws_fill_report_uses_payload_fee_currency() {
1529        let instrument = linear_instrument();
1530        let json = load_test_json("ws_account_execution.json");
1531        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1532            serde_json::from_str(&json).unwrap();
1533
1534        let mut execution = msg.data[0].clone();
1535        execution.fee_currency = Ustr::from("BTC");
1536        let account_id = AccountId::new("BYBIT-001");
1537
1538        let report = parse_ws_fill_report(&execution, account_id, &instrument, TS).unwrap();
1539
1540        assert_eq!(report.commission.currency.code.as_str(), "BTC");
1541    }
1542
1543    fn fast_execution(is_maker: bool, order_link_id: &str) -> BybitWsAccountExecutionFast {
1544        BybitWsAccountExecutionFast {
1545            category: BybitProductType::Linear,
1546            symbol: Ustr::from("BTCUSDT"),
1547            exec_id: "abc-123".to_string(),
1548            exec_price: "50000.0".to_string(),
1549            exec_qty: "0.5".to_string(),
1550            order_id: Ustr::from("ord-1"),
1551            order_link_id: Ustr::from(order_link_id),
1552            side: BybitOrderSide::Buy,
1553            exec_time: "1716800399334".to_string(),
1554            is_maker,
1555            seq: 42,
1556        }
1557    }
1558
1559    #[rstest]
1560    // Maker fast fill: docs say orderLinkId is always empty -> client_order_id is None.
1561    #[case(true, "", LiquiditySide::Maker, None)]
1562    // Taker fast fill: orderLinkId is populated -> client_order_id is set.
1563    #[case(false, "link-1", LiquiditySide::Taker, Some("link-1"))]
1564    fn parse_ws_fill_report_fast_maps_is_maker_and_link_id(
1565        #[case] is_maker: bool,
1566        #[case] order_link_id: &str,
1567        #[case] expected_liquidity: LiquiditySide,
1568        #[case] expected_cid: Option<&str>,
1569    ) {
1570        let instrument = linear_instrument();
1571        let exec = fast_execution(is_maker, order_link_id);
1572        let account_id = AccountId::new("BYBIT-001");
1573
1574        let report = parse_ws_fill_report_fast(&exec, account_id, &instrument, None, TS).unwrap();
1575
1576        assert_eq!(report.account_id, account_id);
1577        assert_eq!(report.instrument_id, instrument.id());
1578        assert_eq!(report.venue_order_id.to_string(), "ord-1");
1579        assert_eq!(report.trade_id.to_string(), "abc-123");
1580        assert_eq!(report.order_side, OrderSide::Buy);
1581        assert_eq!(report.last_qty, instrument.make_qty(0.5, None));
1582        assert_eq!(report.last_px, instrument.make_price(50000.0));
1583        assert_eq!(report.commission.as_f64(), 0.0);
1584        assert_eq!(report.liquidity_side, expected_liquidity);
1585        assert_eq!(
1586            report.client_order_id.map(|c| c.to_string()),
1587            expected_cid.map(str::to_string),
1588        );
1589        assert_eq!(report.venue_position_id, None);
1590        assert_eq!(report.ts_event, UnixNanos::new(1_716_800_399_334_000_000));
1591    }
1592
1593    #[rstest]
1594    fn parse_ws_fill_report_fast_preserves_venue_position_id() {
1595        let instrument = linear_instrument();
1596        let exec = fast_execution(false, "link-hedge");
1597        let account_id = AccountId::new("BYBIT-001");
1598        let venue_pid = PositionId::from("BTCUSDT-LINEAR.BYBIT-LONG");
1599
1600        let report =
1601            parse_ws_fill_report_fast(&exec, account_id, &instrument, Some(venue_pid), TS).unwrap();
1602
1603        assert_eq!(report.venue_position_id, Some(venue_pid));
1604    }
1605
1606    #[rstest]
1607    fn parse_bybit_ws_frame_routes_execution_fast_topic() {
1608        // The fixture topic is `execution.fast` (matches the venue-doc sample),
1609        // which the prefix check at parse_bybit_ws_frame routes to the fast frame.
1610        let value: serde_json::Value =
1611            serde_json::from_str(&load_test_json("ws_account_execution_fast.json")).unwrap();
1612        let frame = parse_bybit_ws_frame(value);
1613        assert!(
1614            matches!(frame, BybitWsFrame::AccountExecutionFast(_)),
1615            "expected AccountExecutionFast, found {frame:?}",
1616        );
1617    }
1618
1619    #[rstest]
1620    fn parse_bybit_ws_frame_routes_standard_execution_topic() {
1621        // Sanity: with the fast prefix checked first, a plain `execution.<cat>` topic
1622        // must still route to the standard variant (not the fast one).
1623        let envelope = BybitWsAccountExecutionMsg {
1624            topic: Ustr::from("execution.linear"),
1625            id: "std-1".to_string(),
1626            creation_time: 1_716_800_399_338,
1627            data: vec![],
1628        };
1629        let value = serde_json::to_value(envelope).unwrap();
1630        let frame = parse_bybit_ws_frame(value);
1631        assert!(
1632            matches!(frame, BybitWsFrame::AccountExecution(_)),
1633            "expected AccountExecution, found {frame:?}",
1634        );
1635    }
1636
1637    #[rstest]
1638    fn parse_ws_order_status_report_venue_position_id_is_none_for_tp() {
1639        let instrument = linear_instrument();
1640        let json = load_test_json("ws_account_order_take_profit.json");
1641        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1642            serde_json::from_str(&json).unwrap();
1643        let order = &msg.data[0]; // positionIdx=0
1644        let account_id = AccountId::new("BYBIT-001");
1645
1646        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1647
1648        assert_eq!(report.venue_position_id, None);
1649    }
1650
1651    #[rstest]
1652    fn parse_ws_order_status_report_venue_position_id_for_hedge() {
1653        let instrument = linear_instrument();
1654        let json = load_test_json("ws_account_order_take_profit.json");
1655        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1656            serde_json::from_str(&json).unwrap();
1657        let mut order = msg.data[0].clone();
1658        order.position_idx = 1;
1659        let account_id = AccountId::new("BYBIT-001");
1660
1661        let report = parse_ws_order_status_report(&order, &instrument, account_id, TS).unwrap();
1662
1663        assert_eq!(
1664            report.venue_position_id,
1665            Some(PositionId::from("BTCUSDT-LINEAR.BYBIT-LONG"))
1666        );
1667    }
1668
1669    #[rstest]
1670    fn parse_ws_position_into_position_status_report() {
1671        let instrument = linear_instrument();
1672        let json = load_test_json("ws_account_position.json");
1673        let msg: crate::websocket::messages::BybitWsAccountPositionMsg =
1674            serde_json::from_str(&json).unwrap();
1675        let position = &msg.data[0];
1676        let account_id = AccountId::new("BYBIT-001");
1677
1678        let report =
1679            parse_ws_position_status_report(position, account_id, &instrument, TS).unwrap();
1680
1681        assert_eq!(report.account_id, account_id);
1682        assert_eq!(report.instrument_id, instrument.id());
1683        assert_eq!(report.position_side, PositionSide::Short);
1684        assert_eq!(report.quantity, instrument.make_qty(0.01, None));
1685        assert_eq!(
1686            report.avg_px_open,
1687            Some(Decimal::try_from(3641.075).unwrap())
1688        );
1689        assert_eq!(report.ts_last, UnixNanos::new(1_762_199_125_472_000_000));
1690        assert_eq!(report.ts_init, TS);
1691    }
1692
1693    #[rstest]
1694    fn parse_ws_position_status_report_venue_position_id_for_hedge() {
1695        let instrument = linear_instrument();
1696        let json = load_test_json("ws_account_position.json");
1697        let msg: crate::websocket::messages::BybitWsAccountPositionMsg =
1698            serde_json::from_str(&json).unwrap();
1699        let mut position = msg.data[0].clone();
1700        position.position_idx = 2;
1701        let account_id = AccountId::new("BYBIT-001");
1702
1703        let report =
1704            parse_ws_position_status_report(&position, account_id, &instrument, TS).unwrap();
1705
1706        assert_eq!(
1707            report.venue_position_id,
1708            Some(PositionId::from("BTCUSDT-LINEAR.BYBIT-SHORT"))
1709        );
1710    }
1711
1712    #[rstest]
1713    fn parse_ws_position_short_into_position_status_report() {
1714        // Create ETHUSDT instrument
1715        let instruments_json = load_test_json("http_get_instruments_linear.json");
1716        let instruments_response: crate::http::models::BybitInstrumentLinearResponse =
1717            serde_json::from_str(&instruments_json).unwrap();
1718        let eth_def = &instruments_response.result.list[1]; // ETHUSDT is second in the list
1719        let fee_rate = crate::http::models::BybitFeeRate {
1720            symbol: Ustr::from("ETHUSDT"),
1721            taker_fee_rate: "0.00055".to_string(),
1722            maker_fee_rate: "0.0001".to_string(),
1723            base_coin: Some(Ustr::from("ETH")),
1724        };
1725        let instrument =
1726            crate::common::parse::parse_linear_instrument(eth_def, &fee_rate, TS, TS).unwrap();
1727
1728        let json = load_test_json("ws_account_position_short.json");
1729        let msg: crate::websocket::messages::BybitWsAccountPositionMsg =
1730            serde_json::from_str(&json).unwrap();
1731        let position = &msg.data[0];
1732        let account_id = AccountId::new("BYBIT-001");
1733
1734        let report =
1735            parse_ws_position_status_report(position, account_id, &instrument, TS).unwrap();
1736
1737        assert_eq!(report.account_id, account_id);
1738        assert_eq!(report.instrument_id.symbol.as_str(), "ETHUSDT-LINEAR");
1739        assert_eq!(report.position_side, PositionSide::Short);
1740        assert_eq!(report.quantity, instrument.make_qty(0.01, None));
1741        assert_eq!(
1742            report.avg_px_open,
1743            Some(Decimal::try_from(3641.075).unwrap())
1744        );
1745        assert_eq!(report.ts_last, UnixNanos::new(1_762_199_125_472_000_000));
1746        assert_eq!(report.ts_init, TS);
1747    }
1748
1749    #[rstest]
1750    fn parse_ws_wallet_into_account_state() {
1751        let json = load_test_json("ws_account_wallet.json");
1752        let msg: crate::websocket::messages::BybitWsAccountWalletMsg =
1753            serde_json::from_str(&json).unwrap();
1754        let wallet = &msg.data[0];
1755        let account_id = AccountId::new("BYBIT-001");
1756        let ts_event = UnixNanos::new(1_700_034_722_104_000_000);
1757
1758        let state = parse_ws_account_state(wallet, account_id, ts_event, TS).unwrap();
1759
1760        assert_eq!(state.account_id, account_id);
1761        assert_eq!(state.account_type, AccountType::Margin);
1762        assert_eq!(state.balances.len(), 2);
1763        assert!(state.is_reported);
1764
1765        // Check BTC balance
1766        let btc_balance = &state.balances[0];
1767        assert_eq!(btc_balance.currency.code.as_str(), "BTC");
1768        assert!((btc_balance.total.as_f64() - 0.00102964).abs() < 1e-8);
1769        assert!((btc_balance.free.as_f64() - 0.00092964).abs() < 1e-8);
1770        assert!((btc_balance.locked.as_f64() - 0.0001).abs() < 1e-8);
1771
1772        // Check USDT balance
1773        let usdt_balance = &state.balances[1];
1774        assert_eq!(usdt_balance.currency.code.as_str(), "USDT");
1775        assert!((usdt_balance.total.as_f64() - 9647.75537647).abs() < 1e-6);
1776        assert!((usdt_balance.free.as_f64() - 9519.89806037).abs() < 1e-6);
1777        assert!((usdt_balance.locked.as_f64() - 127.8573161).abs() < 1e-6);
1778
1779        // BTC has order IM only (no position), USDT has position IM+MM (no orders).
1780        assert_eq!(state.margins.len(), 2);
1781        assert!(state.margins.iter().all(|m| m.instrument_id.is_none()));
1782
1783        let btc_margin = state
1784            .margins
1785            .iter()
1786            .find(|m| m.currency.code.as_str() == "BTC")
1787            .expect("BTC margin missing");
1788        assert!((btc_margin.initial.as_f64() - 0.0001).abs() < 1e-8);
1789        assert!(btc_margin.maintenance.as_f64().abs() < 1e-9);
1790
1791        let usdt_margin = state
1792            .margins
1793            .iter()
1794            .find(|m| m.currency.code.as_str() == "USDT")
1795            .expect("USDT margin missing");
1796        assert!((usdt_margin.initial.as_f64() - 127.8573161).abs() < 1e-6);
1797        assert!((usdt_margin.maintenance.as_f64() - 12.78573161).abs() < 1e-6);
1798
1799        assert_eq!(state.ts_event, ts_event);
1800        assert_eq!(state.ts_init, TS);
1801    }
1802
1803    #[rstest]
1804    fn parse_ws_wallet_with_small_order_calculates_free_correctly() {
1805        // Regression test for issue where availableToWithdraw=0 caused all funds to appear locked
1806        // When a small order is placed, Bybit may report availableToWithdraw=0 due to margin calculations,
1807        // but totalOrderIM correctly shows only the margin locked for the order
1808        let json = load_test_json("ws_account_wallet_small_order.json");
1809        let msg: crate::websocket::messages::BybitWsAccountWalletMsg =
1810            serde_json::from_str(&json).unwrap();
1811        let wallet = &msg.data[0];
1812        let account_id = AccountId::new("BYBIT-UNIFIED");
1813        let ts_event = UnixNanos::new(1_762_960_669_000_000_000);
1814
1815        let state = parse_ws_account_state(wallet, account_id, ts_event, TS).unwrap();
1816
1817        assert_eq!(state.account_id, account_id);
1818        assert_eq!(state.balances.len(), 1);
1819
1820        // Check USDT balance
1821        let usdt_balance = &state.balances[0];
1822        assert_eq!(usdt_balance.currency.code.as_str(), "USDT");
1823
1824        // Wallet has 51,333.82 USDT total
1825        assert!((usdt_balance.total.as_f64() - 51333.82543837).abs() < 1e-6);
1826
1827        // Only 50.028 USDT should be locked (for the order), not all funds
1828        assert!((usdt_balance.locked.as_f64() - 50.028).abs() < 1e-6);
1829
1830        // Free should be total - locked = 51,333.82 - 50.028 = 51,283.79
1831        assert!((usdt_balance.free.as_f64() - 51283.79743837).abs() < 1e-6);
1832
1833        // The bug would have calculated: locked = total - availableToWithdraw = 51,333.82 - 0 = 51,333.82 (all locked!)
1834        // This test verifies that we now correctly use totalOrderIM instead of deriving from availableToWithdraw
1835
1836        // The small order reserves 50.028 USDT of initial margin via `totalOrderIM`,
1837        // so the account-wide USDT margin must be populated even with no open position.
1838        assert_eq!(state.margins.len(), 1);
1839        let usdt_margin = &state.margins[0];
1840        assert!(usdt_margin.instrument_id.is_none());
1841        assert_eq!(usdt_margin.currency.code.as_str(), "USDT");
1842        assert!((usdt_margin.initial.as_f64() - 50.028).abs() < 1e-6);
1843        assert!(usdt_margin.maintenance.as_f64().abs() < 1e-9);
1844    }
1845
1846    #[rstest]
1847    fn parse_ticker_linear_into_funding_rate() {
1848        let instrument = linear_instrument();
1849        let json = load_test_json("ws_ticker_linear.json");
1850        let msg: BybitWsTickerLinearMsg = serde_json::from_str(&json).unwrap();
1851
1852        let ts_event = UnixNanos::new(1_673_272_861_686_000_000);
1853
1854        let funding =
1855            parse_ticker_linear_funding(&msg.data, instrument.id(), ts_event, TS).unwrap();
1856
1857        assert_eq!(funding.instrument_id, instrument.id());
1858        assert_eq!(funding.rate, dec!(-0.000212)); // -0.000212
1859        assert_eq!(funding.interval, Some(8 * 60));
1860        assert_eq!(
1861            funding.next_funding_ns,
1862            Some(UnixNanos::new(1_673_280_000_000_000_000))
1863        );
1864        assert_eq!(funding.ts_event, ts_event);
1865        assert_eq!(funding.ts_init, TS);
1866    }
1867
1868    #[rstest]
1869    fn parse_ticker_linear_into_mark_price() {
1870        let instrument = linear_instrument();
1871        let json = load_test_json("ws_ticker_linear.json");
1872        let msg: BybitWsTickerLinearMsg = serde_json::from_str(&json).unwrap();
1873
1874        let ts_event = UnixNanos::new(1_673_272_861_686_000_000);
1875
1876        let mark_price =
1877            parse_ticker_linear_mark_price(&msg.data, &instrument, ts_event, TS).unwrap();
1878
1879        assert_eq!(mark_price.instrument_id, instrument.id());
1880        assert_eq!(mark_price.value, instrument.make_price(17217.33));
1881        assert_eq!(mark_price.ts_event, ts_event);
1882        assert_eq!(mark_price.ts_init, TS);
1883    }
1884
1885    #[rstest]
1886    fn parse_ticker_linear_into_index_price() {
1887        let instrument = linear_instrument();
1888        let json = load_test_json("ws_ticker_linear.json");
1889        let msg: BybitWsTickerLinearMsg = serde_json::from_str(&json).unwrap();
1890
1891        let ts_event = UnixNanos::new(1_673_272_861_686_000_000);
1892
1893        let index_price =
1894            parse_ticker_linear_index_price(&msg.data, &instrument, ts_event, TS).unwrap();
1895
1896        assert_eq!(index_price.instrument_id, instrument.id());
1897        assert_eq!(index_price.value, instrument.make_price(17227.36));
1898        assert_eq!(index_price.ts_event, ts_event);
1899        assert_eq!(index_price.ts_init, TS);
1900    }
1901
1902    #[rstest]
1903    fn parse_ticker_option_into_mark_price() {
1904        let instrument = option_instrument();
1905        let json = load_test_json("ws_ticker_option.json");
1906        let msg: BybitWsTickerOptionMsg = serde_json::from_str(&json).unwrap();
1907
1908        let mark_price = parse_ticker_option_mark_price(&msg, &instrument, TS).unwrap();
1909
1910        assert_eq!(mark_price.instrument_id, instrument.id());
1911        assert_eq!(mark_price.value, instrument.make_price(7.86976724));
1912        assert_eq!(mark_price.ts_init, TS);
1913    }
1914
1915    #[rstest]
1916    fn parse_ticker_option_into_index_price() {
1917        let instrument = option_instrument();
1918        let json = load_test_json("ws_ticker_option.json");
1919        let msg: BybitWsTickerOptionMsg = serde_json::from_str(&json).unwrap();
1920
1921        let index_price = parse_ticker_option_index_price(&msg, &instrument, TS).unwrap();
1922
1923        assert_eq!(index_price.instrument_id, instrument.id());
1924        assert_eq!(index_price.value, instrument.make_price(16823.73));
1925        assert_eq!(index_price.ts_init, TS);
1926    }
1927
1928    #[rstest]
1929    fn parse_ws_order_stop_market_sell_preserves_type() {
1930        let instrument = linear_instrument();
1931        let json = load_test_json("ws_account_order_stop_market.json");
1932        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1933            serde_json::from_str(&json).unwrap();
1934        let order = &msg.data[0];
1935        let account_id = AccountId::new("BYBIT-001");
1936
1937        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1938
1939        // Verify sell StopMarket: orderType=Market + stopOrderType=Stop + triggerDirection=2 (falls to)
1940        assert_eq!(report.order_type, OrderType::StopMarket);
1941        assert_eq!(report.order_side, OrderSide::Sell.into());
1942        assert_eq!(report.order_status, OrderStatus::Accepted); // Untriggered maps to Accepted
1943        assert_eq!(report.trigger_price, Some(instrument.make_price(45000.00)));
1944        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1945        assert_eq!(
1946            report.client_order_id.as_ref().unwrap().to_string(),
1947            "test-client-stop-market-001"
1948        );
1949    }
1950
1951    #[rstest]
1952    fn parse_ws_order_stop_market_buy_preserves_type() {
1953        let instrument = linear_instrument();
1954        let json = load_test_json("ws_account_order_buy_stop_market.json");
1955        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1956            serde_json::from_str(&json).unwrap();
1957        let order = &msg.data[0];
1958        let account_id = AccountId::new("BYBIT-001");
1959
1960        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1961
1962        // Verify buy StopMarket: orderType=Market + stopOrderType=Stop + triggerDirection=1 (rises to)
1963        assert_eq!(report.order_type, OrderType::StopMarket);
1964        assert_eq!(report.order_side, OrderSide::Buy.into());
1965        assert_eq!(report.order_status, OrderStatus::Accepted);
1966        assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
1967        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1968        assert_eq!(
1969            report.client_order_id.as_ref().unwrap().to_string(),
1970            "test-client-buy-stop-market-001"
1971        );
1972    }
1973
1974    #[rstest]
1975    fn parse_ws_order_market_if_touched_buy_preserves_type() {
1976        let instrument = linear_instrument();
1977        let json = load_test_json("ws_account_order_market_if_touched.json");
1978        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1979            serde_json::from_str(&json).unwrap();
1980        let order = &msg.data[0];
1981        let account_id = AccountId::new("BYBIT-001");
1982
1983        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1984
1985        // Verify buy MIT: orderType=Market + stopOrderType=Stop + triggerDirection=2 (falls to)
1986        assert_eq!(report.order_type, OrderType::MarketIfTouched);
1987        assert_eq!(report.order_side, OrderSide::Buy.into());
1988        assert_eq!(report.order_status, OrderStatus::Accepted); // Untriggered maps to Accepted
1989        assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
1990        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1991        assert_eq!(
1992            report.client_order_id.as_ref().unwrap().to_string(),
1993            "test-client-mit-001"
1994        );
1995    }
1996
1997    #[rstest]
1998    fn parse_ws_order_market_if_touched_sell_preserves_type() {
1999        let instrument = linear_instrument();
2000        let json = load_test_json("ws_account_order_sell_market_if_touched.json");
2001        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
2002            serde_json::from_str(&json).unwrap();
2003        let order = &msg.data[0];
2004        let account_id = AccountId::new("BYBIT-001");
2005
2006        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
2007
2008        // Verify sell MIT: orderType=Market + stopOrderType=Stop + triggerDirection=1 (rises to)
2009        assert_eq!(report.order_type, OrderType::MarketIfTouched);
2010        assert_eq!(report.order_side, OrderSide::Sell.into());
2011        assert_eq!(report.order_status, OrderStatus::Accepted);
2012        assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
2013        assert_eq!(
2014            report.client_order_id.as_ref().unwrap().to_string(),
2015            "test-client-sell-mit-001"
2016        );
2017    }
2018
2019    #[rstest]
2020    fn parse_ws_order_stop_limit_preserves_type() {
2021        let instrument = linear_instrument();
2022        let json = load_test_json("ws_account_order_stop_limit.json");
2023        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
2024            serde_json::from_str(&json).unwrap();
2025        let order = &msg.data[0];
2026        let account_id = AccountId::new("BYBIT-001");
2027
2028        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
2029
2030        // Verify StopLimit order type is correctly parsed
2031        // orderType=Limit + stopOrderType=Stop + triggerDirection=2 (falls to)
2032        assert_eq!(report.order_type, OrderType::StopLimit);
2033        assert_eq!(report.order_side, OrderSide::Sell.into());
2034        assert_eq!(report.order_status, OrderStatus::Accepted); // Untriggered maps to Accepted
2035        assert_eq!(report.price, Some(instrument.make_price(44500.00)));
2036        assert_eq!(report.trigger_price, Some(instrument.make_price(45000.00)));
2037        assert_eq!(
2038            report.client_order_id.as_ref().unwrap().to_string(),
2039            "test-client-stop-limit-001"
2040        );
2041    }
2042
2043    #[rstest]
2044    fn parse_ws_order_limit_if_touched_preserves_type() {
2045        let instrument = linear_instrument();
2046        let json = load_test_json("ws_account_order_limit_if_touched.json");
2047        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
2048            serde_json::from_str(&json).unwrap();
2049        let order = &msg.data[0];
2050        let account_id = AccountId::new("BYBIT-001");
2051
2052        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
2053
2054        // Verify LimitIfTouched order type is correctly parsed
2055        // orderType=Limit + stopOrderType=Stop + triggerDirection=1 (rises to)
2056        assert_eq!(report.order_type, OrderType::LimitIfTouched);
2057        assert_eq!(report.order_side, OrderSide::Buy.into());
2058        assert_eq!(report.order_status, OrderStatus::Accepted); // Untriggered maps to Accepted
2059        assert_eq!(report.price, Some(instrument.make_price(55500.00)));
2060        assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
2061        assert_eq!(
2062            report.client_order_id.as_ref().unwrap().to_string(),
2063            "test-client-lit-001"
2064        );
2065    }
2066
2067    #[rstest]
2068    fn parse_ws_wallet_clamps_free_to_zero_when_locked_exceeds_total() {
2069        // totalOrderIM (80) + totalPositionIM (40) = 120, which exceeds
2070        // walletBalance (100). Free balance should clamp to zero, not underflow.
2071        let json = load_test_json("ws_account_wallet_locked_exceeds_total.json");
2072        let msg: crate::websocket::messages::BybitWsAccountWalletMsg =
2073            serde_json::from_str(&json).unwrap();
2074        let wallet = &msg.data[0];
2075        let account_id = AccountId::new("BYBIT-UNIFIED");
2076        let ts_event = UnixNanos::new(1_762_960_669_000_000_000);
2077
2078        let state = parse_ws_account_state(wallet, account_id, ts_event, TS).unwrap();
2079
2080        let usdt_balance = &state.balances[0];
2081        assert_eq!(usdt_balance.currency.code.as_str(), "USDT");
2082        assert!((usdt_balance.total.as_f64() - 100.0).abs() < 1e-6);
2083        // Locked is capped at total to prevent negative free balance
2084        assert!((usdt_balance.locked.as_f64() - 100.0).abs() < 1e-6);
2085        assert_eq!(usdt_balance.free.as_f64(), 0.0);
2086    }
2087
2088    #[rstest]
2089    fn parse_ws_order_take_profit_maps_to_market_if_touched() {
2090        let instrument = linear_instrument();
2091        let json = load_test_json("ws_account_order_take_profit.json");
2092        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
2093            serde_json::from_str(&json).unwrap();
2094        let order = &msg.data[0];
2095        let account_id = AccountId::new("BYBIT-001");
2096
2097        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
2098
2099        assert_eq!(report.order_type, OrderType::MarketIfTouched);
2100        assert_eq!(report.order_side, OrderSide::Sell.into());
2101        assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
2102        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
2103        assert!(report.reduce_only);
2104    }
2105
2106    #[rstest]
2107    fn parse_ws_order_stop_loss_maps_to_stop_market() {
2108        let instrument = linear_instrument();
2109        let json = load_test_json("ws_account_order_stop_loss.json");
2110        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
2111            serde_json::from_str(&json).unwrap();
2112        let order = &msg.data[0];
2113        let account_id = AccountId::new("BYBIT-001");
2114
2115        let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
2116
2117        assert_eq!(report.order_type, OrderType::StopMarket);
2118        assert_eq!(report.order_side, OrderSide::Sell.into());
2119        assert_eq!(report.trigger_price, Some(instrument.make_price(48000.00)));
2120        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
2121        assert!(report.reduce_only);
2122    }
2123}