1use 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
63pub 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 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
185pub 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
198pub 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
216pub 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
241pub 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
285pub 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
374pub 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
433pub 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
464pub 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
527pub 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
554pub 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
581pub 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
607pub 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
633pub 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, },
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
699pub 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
741pub 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 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 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 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
886pub 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, ts_event,
953 ts_init,
954 None, ))
956}
957
958pub 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 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
1032pub 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 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 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, venue_position_id,
1083 position.entry_price, ))
1085}
1086
1087pub 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 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, balances,
1131 margins,
1132 true, UUID4::new(),
1134 ts_event,
1135 ts_init,
1136 None, ))
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 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 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 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 #[case(true, "", LiquiditySide::Maker, None)]
1733 #[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 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 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]; 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 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]; 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 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 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 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 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 let usdt_balance = &state.balances[0];
2002 assert_eq!(usdt_balance.currency.code, "USDT");
2003
2004 assert!((usdt_balance.total.as_f64() - 51333.82543837).abs() < 1e-6);
2006
2007 assert!((usdt_balance.locked.as_f64() - 50.028).abs() < 1e-6);
2009
2010 assert!((usdt_balance.free.as_f64() - 51283.79743837).abs() < 1e-6);
2012
2013 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)); 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 assert_eq!(report.order_type, OrderType::StopMarket);
2121 assert_eq!(report.order_side, OrderSide::Sell.into());
2122 assert_eq!(report.order_status, OrderStatus::Accepted); 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 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 assert_eq!(report.order_type, OrderType::MarketIfTouched);
2167 assert_eq!(report.order_side, OrderSide::Buy.into());
2168 assert_eq!(report.order_status, OrderStatus::Accepted); 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 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 assert_eq!(report.order_type, OrderType::StopLimit);
2213 assert_eq!(report.order_side, OrderSide::Sell.into());
2214 assert_eq!(report.order_status, OrderStatus::Accepted); 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 assert_eq!(report.order_type, OrderType::LimitIfTouched);
2237 assert_eq!(report.order_side, OrderSide::Buy.into());
2238 assert_eq!(report.order_status, OrderStatus::Accepted); 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 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 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}