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