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