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