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