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, OrderBookDelta, OrderBookDeltas, QuoteTick,
25 TradeTick,
26 },
27 enums::{
28 AccountType, AggressorSide, BookAction, LiquiditySide, OrderSide, OrderStatus, OrderType,
29 PositionSideSpecified, RecordFlag, TimeInForce, TriggerType,
30 },
31 events::account::state::AccountState,
32 identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
33 instruments::{Instrument, any::InstrumentAny},
34 reports::{FillReport, OrderStatusReport, PositionStatusReport},
35 types::{AccountBalance, Money, Price, Quantity},
36};
37use rust_decimal::Decimal;
38
39use super::messages::{
40 BybitWsAccountExecution, BybitWsAccountOrder, BybitWsAccountPosition, BybitWsAccountWallet,
41 BybitWsKline, BybitWsOrderbookDepthMsg, BybitWsTickerLinear, BybitWsTickerLinearMsg,
42 BybitWsTickerOptionMsg, BybitWsTrade,
43};
44use crate::common::{
45 consts::BYBIT_TOPIC_KLINE,
46 enums::{
47 BybitOrderStatus, BybitOrderType, BybitStopOrderType, BybitTimeInForce,
48 BybitTriggerDirection,
49 },
50 parse::{
51 get_currency, parse_millis_timestamp, parse_price_with_precision,
52 parse_quantity_with_precision,
53 },
54};
55
56pub fn parse_topic(topic: &str) -> anyhow::Result<Vec<&str>> {
62 let parts: Vec<&str> = topic.split('.').collect();
63 if parts.is_empty() {
64 anyhow::bail!("Invalid topic format: empty topic");
65 }
66 Ok(parts)
67}
68
69pub fn parse_kline_topic(topic: &str) -> anyhow::Result<(&str, &str)> {
77 let parts = parse_topic(topic)?;
78 if parts.len() != 3 || parts[0] != BYBIT_TOPIC_KLINE {
79 anyhow::bail!(
80 "Invalid kline topic format: expected '{BYBIT_TOPIC_KLINE}.{{interval}}.{{symbol}}', was '{topic}'"
81 );
82 }
83 Ok((parts[1], parts[2]))
84}
85
86pub fn parse_ws_trade_tick(
88 trade: &BybitWsTrade,
89 instrument: &InstrumentAny,
90 ts_init: UnixNanos,
91) -> anyhow::Result<TradeTick> {
92 let price = parse_price_with_precision(&trade.p, instrument.price_precision(), "trade.p")?;
93 let size = parse_quantity_with_precision(&trade.v, instrument.size_precision(), "trade.v")?;
94 let aggressor: AggressorSide = trade.taker_side.into();
95 let trade_id = TradeId::new_checked(trade.i.as_str())
96 .context("invalid trade identifier in Bybit trade message")?;
97 let ts_event = parse_millis_i64(trade.t, "trade.T")?;
98
99 TradeTick::new_checked(
100 instrument.id(),
101 price,
102 size,
103 aggressor,
104 trade_id,
105 ts_event,
106 ts_init,
107 )
108 .context("failed to construct TradeTick from Bybit trade message")
109}
110
111pub fn parse_orderbook_deltas(
113 msg: &BybitWsOrderbookDepthMsg,
114 instrument: &InstrumentAny,
115 ts_init: UnixNanos,
116) -> anyhow::Result<OrderBookDeltas> {
117 let is_snapshot = msg.msg_type.eq_ignore_ascii_case("snapshot");
118 let ts_event = parse_millis_i64(msg.ts, "orderbook.ts")?;
119 let ts_init = if ts_init.is_zero() { ts_event } else { ts_init };
120
121 let depth = &msg.data;
122 let instrument_id = instrument.id();
123 let price_precision = instrument.price_precision();
124 let size_precision = instrument.size_precision();
125 let update_id = u64::try_from(depth.u)
126 .context("received negative update id in Bybit order book message")?;
127 let sequence = u64::try_from(depth.seq)
128 .context("received negative sequence in Bybit order book message")?;
129
130 let total_levels = depth.b.len() + depth.a.len();
131 let capacity = if is_snapshot {
132 total_levels + 1
133 } else {
134 total_levels
135 };
136 let mut deltas = Vec::with_capacity(capacity);
137
138 if is_snapshot {
139 deltas.push(OrderBookDelta::clear(
140 instrument_id,
141 sequence,
142 ts_event,
143 ts_init,
144 ));
145 }
146 let mut processed = 0_usize;
147
148 let mut push_level = |values: &[String], side: OrderSide| -> anyhow::Result<()> {
149 let (price, size) = parse_book_level(values, price_precision, size_precision, "orderbook")?;
150 let action = if size.is_zero() {
151 BookAction::Delete
152 } else if is_snapshot {
153 BookAction::Add
154 } else {
155 BookAction::Update
156 };
157
158 processed += 1;
159 let mut flags = RecordFlag::F_MBP as u8;
160 if processed == total_levels {
161 flags |= RecordFlag::F_LAST as u8;
162 }
163
164 let order = BookOrder::new(side, price, size, update_id);
165 let delta = OrderBookDelta::new_checked(
166 instrument_id,
167 action,
168 order,
169 flags,
170 sequence,
171 ts_event,
172 ts_init,
173 )
174 .context("failed to construct OrderBookDelta from Bybit book level")?;
175 deltas.push(delta);
176 Ok(())
177 };
178
179 for level in &depth.b {
180 push_level(level, OrderSide::Buy)?;
181 }
182 for level in &depth.a {
183 push_level(level, OrderSide::Sell)?;
184 }
185
186 if total_levels == 0
187 && let Some(last) = deltas.last_mut()
188 {
189 last.flags |= RecordFlag::F_LAST as u8;
190 }
191
192 OrderBookDeltas::new_checked(instrument_id, deltas)
193 .context("failed to assemble OrderBookDeltas from Bybit message")
194}
195
196pub fn parse_orderbook_quote(
198 msg: &BybitWsOrderbookDepthMsg,
199 instrument: &InstrumentAny,
200 last_quote: Option<&QuoteTick>,
201 ts_init: UnixNanos,
202) -> anyhow::Result<QuoteTick> {
203 let ts_event = parse_millis_i64(msg.ts, "orderbook.ts")?;
204 let ts_init = if ts_init.is_zero() { ts_event } else { ts_init };
205 let price_precision = instrument.price_precision();
206 let size_precision = instrument.size_precision();
207
208 let get_best =
209 |levels: &[Vec<String>], label: &str| -> anyhow::Result<Option<(Price, Quantity)>> {
210 if let Some(values) = levels.first() {
211 parse_book_level(values, price_precision, size_precision, label).map(Some)
212 } else {
213 Ok(None)
214 }
215 };
216
217 let bids = get_best(&msg.data.b, "bid")?;
218 let asks = get_best(&msg.data.a, "ask")?;
219
220 let (bid_price, bid_size) = match (bids, last_quote) {
221 (Some(level), _) => level,
222 (None, Some(prev)) => (prev.bid_price, prev.bid_size),
223 (None, None) => {
224 anyhow::bail!(
225 "Bybit order book update missing bid levels and no previous quote provided"
226 );
227 }
228 };
229
230 let (ask_price, ask_size) = match (asks, last_quote) {
231 (Some(level), _) => level,
232 (None, Some(prev)) => (prev.ask_price, prev.ask_size),
233 (None, None) => {
234 anyhow::bail!(
235 "Bybit order book update missing ask levels and no previous quote provided"
236 );
237 }
238 };
239
240 QuoteTick::new_checked(
241 instrument.id(),
242 bid_price,
243 ask_price,
244 bid_size,
245 ask_size,
246 ts_event,
247 ts_init,
248 )
249 .context("failed to construct QuoteTick from Bybit order book message")
250}
251
252pub fn parse_ticker_linear_quote(
254 msg: &BybitWsTickerLinearMsg,
255 instrument: &InstrumentAny,
256 ts_init: UnixNanos,
257) -> anyhow::Result<QuoteTick> {
258 let ts_event = parse_millis_i64(msg.ts, "ticker.ts")?;
259 let ts_init = if ts_init.is_zero() { ts_event } else { ts_init };
260 let price_precision = instrument.price_precision();
261 let size_precision = instrument.size_precision();
262
263 let data = &msg.data;
264 let bid_price = data
265 .bid1_price
266 .as_ref()
267 .context("Bybit ticker message missing bid1Price")?
268 .as_str();
269 let ask_price = data
270 .ask1_price
271 .as_ref()
272 .context("Bybit ticker message missing ask1Price")?
273 .as_str();
274
275 let bid_price = parse_price_with_precision(bid_price, price_precision, "ticker.bid1Price")?;
276 let ask_price = parse_price_with_precision(ask_price, price_precision, "ticker.ask1Price")?;
277
278 let bid_size_str = data.bid1_size.as_deref().unwrap_or("0");
279 let ask_size_str = data.ask1_size.as_deref().unwrap_or("0");
280
281 let bid_size = parse_quantity_with_precision(bid_size_str, size_precision, "ticker.bid1Size")?;
282 let ask_size = parse_quantity_with_precision(ask_size_str, size_precision, "ticker.ask1Size")?;
283
284 QuoteTick::new_checked(
285 instrument.id(),
286 bid_price,
287 ask_price,
288 bid_size,
289 ask_size,
290 ts_event,
291 ts_init,
292 )
293 .context("failed to construct QuoteTick from Bybit linear ticker message")
294}
295
296pub fn parse_ticker_option_quote(
298 msg: &BybitWsTickerOptionMsg,
299 instrument: &InstrumentAny,
300 ts_init: UnixNanos,
301) -> anyhow::Result<QuoteTick> {
302 let ts_event = parse_millis_i64(msg.ts, "ticker.ts")?;
303 let ts_init = if ts_init.is_zero() { ts_event } else { ts_init };
304 let price_precision = instrument.price_precision();
305 let size_precision = instrument.size_precision();
306
307 let data = &msg.data;
308 let bid_price =
309 parse_price_with_precision(&data.bid_price, price_precision, "ticker.bidPrice")?;
310 let ask_price =
311 parse_price_with_precision(&data.ask_price, price_precision, "ticker.askPrice")?;
312 let bid_size = parse_quantity_with_precision(&data.bid_size, size_precision, "ticker.bidSize")?;
313 let ask_size = parse_quantity_with_precision(&data.ask_size, size_precision, "ticker.askSize")?;
314
315 QuoteTick::new_checked(
316 instrument.id(),
317 bid_price,
318 ask_price,
319 bid_size,
320 ask_size,
321 ts_event,
322 ts_init,
323 )
324 .context("failed to construct QuoteTick from Bybit option ticker message")
325}
326
327pub fn parse_ticker_linear_funding(
333 data: &BybitWsTickerLinear,
334 instrument_id: InstrumentId,
335 ts_event: UnixNanos,
336 ts_init: UnixNanos,
337) -> anyhow::Result<FundingRateUpdate> {
338 let funding_rate_str = data
339 .funding_rate
340 .as_ref()
341 .context("Bybit ticker missing funding_rate")?;
342
343 let funding_rate = funding_rate_str
344 .as_str()
345 .parse::<Decimal>()
346 .context("invalid funding_rate value")?
347 .normalize();
348
349 let next_funding_ns = if let Some(next_funding_time) = &data.next_funding_time {
350 let next_funding_millis = next_funding_time
351 .as_str()
352 .parse::<i64>()
353 .context("invalid next_funding_time value")?;
354 Some(parse_millis_i64(next_funding_millis, "next_funding_time")?)
355 } else {
356 None
357 };
358
359 Ok(FundingRateUpdate::new(
360 instrument_id,
361 funding_rate,
362 next_funding_ns,
363 ts_event,
364 ts_init,
365 ))
366}
367
368pub(crate) fn parse_millis_i64(value: i64, field: &str) -> anyhow::Result<UnixNanos> {
369 if value < 0 {
370 Err(anyhow::anyhow!("{field} must be non-negative, was {value}"))
371 } else {
372 let nanos = (value as u64)
373 .checked_mul(NANOSECONDS_IN_MILLISECOND)
374 .ok_or_else(|| anyhow::anyhow!("millisecond timestamp overflowed"))?;
375 Ok(UnixNanos::from(nanos))
376 }
377}
378
379fn parse_book_level(
380 level: &[String],
381 price_precision: u8,
382 size_precision: u8,
383 label: &str,
384) -> anyhow::Result<(Price, Quantity)> {
385 let price_str = level
386 .first()
387 .ok_or_else(|| anyhow::anyhow!("missing price component in {label} level"))?;
388 let size_str = level
389 .get(1)
390 .ok_or_else(|| anyhow::anyhow!("missing size component in {label} level"))?;
391 let price = parse_price_with_precision(price_str, price_precision, label)?;
392 let size = parse_quantity_with_precision(size_str, size_precision, label)?;
393 Ok((price, size))
394}
395
396pub fn parse_ws_kline_bar(
402 kline: &BybitWsKline,
403 instrument: &InstrumentAny,
404 bar_type: BarType,
405 timestamp_on_close: bool,
406 ts_init: UnixNanos,
407) -> anyhow::Result<Bar> {
408 let price_precision = instrument.price_precision();
409 let size_precision = instrument.size_precision();
410
411 let open = parse_price_with_precision(&kline.open, price_precision, "kline.open")?;
412 let high = parse_price_with_precision(&kline.high, price_precision, "kline.high")?;
413 let low = parse_price_with_precision(&kline.low, price_precision, "kline.low")?;
414 let close = parse_price_with_precision(&kline.close, price_precision, "kline.close")?;
415 let volume = parse_quantity_with_precision(&kline.volume, size_precision, "kline.volume")?;
416
417 let mut ts_event = parse_millis_i64(kline.start, "kline.start")?;
418 if timestamp_on_close {
419 let interval_ns = bar_type
420 .spec()
421 .timedelta()
422 .num_nanoseconds()
423 .context("bar specification produced non-integer interval")?;
424 let interval_ns = u64::try_from(interval_ns)
425 .context("bar interval overflowed the u64 range for nanoseconds")?;
426 let updated = ts_event
427 .as_u64()
428 .checked_add(interval_ns)
429 .context("bar timestamp overflowed when adjusting to close time")?;
430 ts_event = UnixNanos::from(updated);
431 }
432 let ts_init = if ts_init.is_zero() { ts_event } else { ts_init };
433
434 Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
435 .context("failed to construct Bar from Bybit WebSocket kline")
436}
437
438pub fn parse_ws_order_status_report(
444 order: &BybitWsAccountOrder,
445 instrument: &InstrumentAny,
446 account_id: AccountId,
447 ts_init: UnixNanos,
448) -> anyhow::Result<OrderStatusReport> {
449 use crate::common::enums::BybitOrderSide;
450
451 let instrument_id = instrument.id();
452 let venue_order_id = VenueOrderId::new(order.order_id.as_str());
453 let order_side: OrderSide = order.side.into();
454
455 let order_type: OrderType = match (
457 order.order_type,
458 order.stop_order_type,
459 order.trigger_direction,
460 order.side,
461 ) {
462 (BybitOrderType::Market, BybitStopOrderType::None | BybitStopOrderType::Unknown, _, _) => {
463 OrderType::Market
464 }
465 (BybitOrderType::Limit, BybitStopOrderType::None | BybitStopOrderType::Unknown, _, _) => {
466 OrderType::Limit
467 }
468
469 (
470 BybitOrderType::Market,
471 BybitStopOrderType::Stop,
472 BybitTriggerDirection::RisesTo,
473 BybitOrderSide::Buy,
474 ) => OrderType::StopMarket,
475 (
476 BybitOrderType::Market,
477 BybitStopOrderType::Stop,
478 BybitTriggerDirection::FallsTo,
479 BybitOrderSide::Buy,
480 ) => OrderType::MarketIfTouched,
481
482 (
483 BybitOrderType::Market,
484 BybitStopOrderType::Stop,
485 BybitTriggerDirection::FallsTo,
486 BybitOrderSide::Sell,
487 ) => OrderType::StopMarket,
488 (
489 BybitOrderType::Market,
490 BybitStopOrderType::Stop,
491 BybitTriggerDirection::RisesTo,
492 BybitOrderSide::Sell,
493 ) => OrderType::MarketIfTouched,
494
495 (
496 BybitOrderType::Limit,
497 BybitStopOrderType::Stop,
498 BybitTriggerDirection::RisesTo,
499 BybitOrderSide::Buy,
500 ) => OrderType::StopLimit,
501 (
502 BybitOrderType::Limit,
503 BybitStopOrderType::Stop,
504 BybitTriggerDirection::FallsTo,
505 BybitOrderSide::Buy,
506 ) => OrderType::LimitIfTouched,
507
508 (
509 BybitOrderType::Limit,
510 BybitStopOrderType::Stop,
511 BybitTriggerDirection::FallsTo,
512 BybitOrderSide::Sell,
513 ) => OrderType::StopLimit,
514 (
515 BybitOrderType::Limit,
516 BybitStopOrderType::Stop,
517 BybitTriggerDirection::RisesTo,
518 BybitOrderSide::Sell,
519 ) => OrderType::LimitIfTouched,
520
521 (BybitOrderType::Market, BybitStopOrderType::Stop, BybitTriggerDirection::None, _) => {
523 OrderType::Market
524 }
525 (BybitOrderType::Limit, BybitStopOrderType::Stop, BybitTriggerDirection::None, _) => {
526 OrderType::Limit
527 }
528
529 (BybitOrderType::Market, _, _, _) => OrderType::Market,
531 (BybitOrderType::Limit, _, _, _) => OrderType::Limit,
532
533 (BybitOrderType::Unknown, _, _, _) => OrderType::Limit,
534 };
535
536 let time_in_force: TimeInForce = match order.time_in_force {
537 BybitTimeInForce::Gtc => TimeInForce::Gtc,
538 BybitTimeInForce::Ioc => TimeInForce::Ioc,
539 BybitTimeInForce::Fok => TimeInForce::Fok,
540 BybitTimeInForce::PostOnly => TimeInForce::Gtc,
541 };
542
543 let quantity =
544 parse_quantity_with_precision(&order.qty, instrument.size_precision(), "order.qty")?;
545
546 let filled_qty = parse_quantity_with_precision(
547 &order.cum_exec_qty,
548 instrument.size_precision(),
549 "order.cumExecQty",
550 )?;
551
552 let order_status: OrderStatus = match order.order_status {
558 BybitOrderStatus::Created | BybitOrderStatus::New | BybitOrderStatus::Untriggered => {
559 OrderStatus::Accepted
560 }
561 BybitOrderStatus::Rejected => {
562 if filled_qty.is_positive() {
563 OrderStatus::Canceled
564 } else {
565 OrderStatus::Rejected
566 }
567 }
568 BybitOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
569 BybitOrderStatus::Filled => OrderStatus::Filled,
570 BybitOrderStatus::Canceled | BybitOrderStatus::PartiallyFilledCanceled => {
571 OrderStatus::Canceled
572 }
573 BybitOrderStatus::Triggered => OrderStatus::Triggered,
574 BybitOrderStatus::Deactivated => OrderStatus::Canceled,
575 };
576
577 let ts_accepted = parse_millis_timestamp(&order.created_time, "order.createdTime")?;
578 let ts_last = parse_millis_timestamp(&order.updated_time, "order.updatedTime")?;
579
580 let mut report = OrderStatusReport::new(
581 account_id,
582 instrument_id,
583 None,
584 venue_order_id,
585 order_side,
586 order_type,
587 time_in_force,
588 order_status,
589 quantity,
590 filled_qty,
591 ts_accepted,
592 ts_last,
593 ts_init,
594 Some(UUID4::new()),
595 );
596
597 if !order.order_link_id.is_empty() {
598 report = report.with_client_order_id(ClientOrderId::new(order.order_link_id.as_str()));
599 }
600
601 if !order.price.is_empty() && order.price != "0" {
602 let price =
603 parse_price_with_precision(&order.price, instrument.price_precision(), "order.price")?;
604 report = report.with_price(price);
605 }
606
607 if !order.avg_price.is_empty() && order.avg_price != "0" {
608 let avg_px = order
609 .avg_price
610 .parse::<f64>()
611 .with_context(|| format!("Failed to parse avg_price='{}' as f64", order.avg_price))?;
612 report = report.with_avg_px(avg_px)?;
613 }
614
615 if !order.trigger_price.is_empty() && order.trigger_price != "0" {
616 let trigger_price = parse_price_with_precision(
617 &order.trigger_price,
618 instrument.price_precision(),
619 "order.triggerPrice",
620 )?;
621 report = report.with_trigger_price(trigger_price);
622
623 let trigger_type: TriggerType = order.trigger_by.into();
625 report = report.with_trigger_type(trigger_type);
626 }
627
628 if order.reduce_only {
629 report = report.with_reduce_only(true);
630 }
631
632 if order.time_in_force == BybitTimeInForce::PostOnly {
633 report = report.with_post_only(true);
634 }
635
636 if !order.reject_reason.is_empty() {
637 report = report.with_cancel_reason(order.reject_reason.to_string());
638 }
639
640 Ok(report)
641}
642
643pub fn parse_ws_fill_report(
649 execution: &BybitWsAccountExecution,
650 account_id: AccountId,
651 instrument: &InstrumentAny,
652 ts_init: UnixNanos,
653) -> anyhow::Result<FillReport> {
654 let instrument_id = instrument.id();
655 let venue_order_id = VenueOrderId::new(execution.order_id.as_str());
656 let trade_id = TradeId::new_checked(execution.exec_id.as_str())
657 .context("invalid execId in Bybit WebSocket execution payload")?;
658
659 let order_side: OrderSide = execution.side.into();
660 let last_qty = parse_quantity_with_precision(
661 &execution.exec_qty,
662 instrument.size_precision(),
663 "execution.execQty",
664 )?;
665 let last_px = parse_price_with_precision(
666 &execution.exec_price,
667 instrument.price_precision(),
668 "execution.execPrice",
669 )?;
670
671 let liquidity_side = if execution.is_maker {
672 LiquiditySide::Maker
673 } else {
674 LiquiditySide::Taker
675 };
676
677 let commission_str = execution.exec_fee.trim_start_matches('-');
678 let commission_amount = commission_str
679 .parse::<f64>()
680 .with_context(|| format!("Failed to parse execFee='{}' as f64", execution.exec_fee))?
681 .abs();
682
683 let commission_currency = instrument.quote_currency();
685 let commission = Money::new(commission_amount, commission_currency);
686 let ts_event = parse_millis_timestamp(&execution.exec_time, "execution.execTime")?;
687
688 let client_order_id = if execution.order_link_id.is_empty() {
689 None
690 } else {
691 Some(ClientOrderId::new(execution.order_link_id.as_str()))
692 };
693
694 Ok(FillReport::new(
695 account_id,
696 instrument_id,
697 venue_order_id,
698 trade_id,
699 order_side,
700 last_qty,
701 last_px,
702 commission,
703 liquidity_side,
704 client_order_id,
705 None, ts_event,
707 ts_init,
708 None, ))
710}
711
712pub fn parse_ws_position_status_report(
718 position: &BybitWsAccountPosition,
719 account_id: AccountId,
720 instrument: &InstrumentAny,
721 ts_init: UnixNanos,
722) -> anyhow::Result<PositionStatusReport> {
723 let instrument_id = instrument.id();
724
725 let quantity = parse_quantity_with_precision(
727 &position.size,
728 instrument.size_precision(),
729 "position.size",
730 )?;
731
732 let position_side = if position.side.eq_ignore_ascii_case("buy") {
734 PositionSideSpecified::Long
735 } else if position.side.eq_ignore_ascii_case("sell") {
736 PositionSideSpecified::Short
737 } else {
738 PositionSideSpecified::Flat
739 };
740
741 let ts_last = parse_millis_timestamp(&position.updated_time, "position.updatedTime")?;
742
743 Ok(PositionStatusReport::new(
744 account_id,
745 instrument_id,
746 position_side,
747 quantity,
748 ts_last,
749 ts_init,
750 None, None, position.entry_price, ))
754}
755
756pub fn parse_ws_account_state(
762 wallet: &BybitWsAccountWallet,
763 account_id: AccountId,
764 ts_event: UnixNanos,
765 ts_init: UnixNanos,
766) -> anyhow::Result<AccountState> {
767 let mut balances = Vec::new();
768
769 for coin_data in &wallet.coin {
770 let currency = get_currency(coin_data.coin.as_str());
771 let total_dec = coin_data.wallet_balance - coin_data.spot_borrow;
772 let locked_dec = coin_data.total_order_im + coin_data.total_position_im;
773
774 let total = Money::from_decimal(total_dec, currency)?;
775 let locked = Money::from_decimal(locked_dec, currency)?;
776 let free = Money::from_raw(total.raw - locked.raw, currency);
777
778 let balance = AccountBalance::new(total, locked, free);
779 balances.push(balance);
780 }
781
782 Ok(AccountState::new(
783 account_id,
784 AccountType::Margin, balances,
786 vec![], true, UUID4::new(),
789 ts_event,
790 ts_init,
791 None, ))
793}
794
795#[cfg(test)]
796mod tests {
797 use nautilus_model::{
798 data::BarSpecification,
799 enums::{AggregationSource, BarAggregation, PositionSide, PriceType},
800 };
801 use rstest::rstest;
802 use rust_decimal_macros::dec;
803
804 use super::*;
805 use crate::{
806 common::{
807 parse::{parse_linear_instrument, parse_option_instrument},
808 testing::load_test_json,
809 },
810 http::models::{BybitInstrumentLinearResponse, BybitInstrumentOptionResponse},
811 websocket::messages::{
812 BybitWsOrderbookDepthMsg, BybitWsTickerLinearMsg, BybitWsTickerOptionMsg,
813 BybitWsTradeMsg,
814 },
815 };
816
817 const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
818
819 use ustr::Ustr;
820
821 use crate::http::models::BybitFeeRate;
822
823 fn sample_fee_rate(
824 symbol: &str,
825 taker: &str,
826 maker: &str,
827 base_coin: Option<&str>,
828 ) -> BybitFeeRate {
829 BybitFeeRate {
830 symbol: Ustr::from(symbol),
831 taker_fee_rate: taker.to_string(),
832 maker_fee_rate: maker.to_string(),
833 base_coin: base_coin.map(Ustr::from),
834 }
835 }
836
837 fn linear_instrument() -> InstrumentAny {
838 let json = load_test_json("http_get_instruments_linear.json");
839 let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
840 let instrument = &response.result.list[0];
841 let fee_rate = sample_fee_rate("BTCUSDT", "0.00055", "0.0001", Some("BTC"));
842 parse_linear_instrument(instrument, &fee_rate, TS, TS).unwrap()
843 }
844
845 fn option_instrument() -> InstrumentAny {
846 let json = load_test_json("http_get_instruments_option.json");
847 let response: BybitInstrumentOptionResponse = serde_json::from_str(&json).unwrap();
848 let instrument = &response.result.list[0];
849 parse_option_instrument(instrument, TS, TS).unwrap()
850 }
851
852 #[rstest]
853 fn parse_ws_trade_into_trade_tick() {
854 let instrument = linear_instrument();
855 let json = load_test_json("ws_public_trade.json");
856 let msg: BybitWsTradeMsg = serde_json::from_str(&json).unwrap();
857 let trade = &msg.data[0];
858
859 let tick = parse_ws_trade_tick(trade, &instrument, TS).unwrap();
860
861 assert_eq!(tick.instrument_id, instrument.id());
862 assert_eq!(tick.price, instrument.make_price(27451.00));
863 assert_eq!(tick.size, instrument.make_qty(0.010, None));
864 assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
865 assert_eq!(
866 tick.trade_id.to_string(),
867 "9dc75fca-4bdd-4773-9f78-6f5d7ab2a110"
868 );
869 assert_eq!(tick.ts_event, UnixNanos::new(1_709_891_679_000_000_000));
870 }
871
872 #[rstest]
873 fn parse_orderbook_snapshot_into_deltas() {
874 let instrument = linear_instrument();
875 let json = load_test_json("ws_orderbook_snapshot.json");
876 let msg: BybitWsOrderbookDepthMsg = serde_json::from_str(&json).unwrap();
877
878 let deltas = parse_orderbook_deltas(&msg, &instrument, TS).unwrap();
879
880 assert_eq!(deltas.instrument_id, instrument.id());
881 assert_eq!(deltas.deltas.len(), 5);
882 assert_eq!(deltas.deltas[0].action, BookAction::Clear);
883 assert_eq!(
884 deltas.deltas[1].order.price,
885 instrument.make_price(27450.00)
886 );
887 assert_eq!(
888 deltas.deltas[1].order.size,
889 instrument.make_qty(0.500, None)
890 );
891 let last = deltas.deltas.last().unwrap();
892 assert_eq!(last.order.side, OrderSide::Sell);
893 assert_eq!(last.order.price, instrument.make_price(27451.50));
894 assert_eq!(
895 last.flags & RecordFlag::F_LAST as u8,
896 RecordFlag::F_LAST as u8
897 );
898 }
899
900 #[rstest]
901 fn parse_orderbook_delta_marks_actions() {
902 let instrument = linear_instrument();
903 let json = load_test_json("ws_orderbook_delta.json");
904 let msg: BybitWsOrderbookDepthMsg = serde_json::from_str(&json).unwrap();
905
906 let deltas = parse_orderbook_deltas(&msg, &instrument, TS).unwrap();
907
908 assert_eq!(deltas.deltas.len(), 2);
909 let bid = &deltas.deltas[0];
910 assert_eq!(bid.action, BookAction::Update);
911 assert_eq!(bid.order.side, OrderSide::Buy);
912 assert_eq!(bid.order.size, instrument.make_qty(0.400, None));
913
914 let ask = &deltas.deltas[1];
915 assert_eq!(ask.action, BookAction::Delete);
916 assert_eq!(ask.order.side, OrderSide::Sell);
917 assert_eq!(ask.order.size, instrument.make_qty(0.0, None));
918 assert_eq!(
919 ask.flags & RecordFlag::F_LAST as u8,
920 RecordFlag::F_LAST as u8
921 );
922 }
923
924 #[rstest]
925 fn parse_orderbook_quote_produces_top_of_book() {
926 let instrument = linear_instrument();
927 let json = load_test_json("ws_orderbook_snapshot.json");
928 let msg: BybitWsOrderbookDepthMsg = serde_json::from_str(&json).unwrap();
929
930 let quote = parse_orderbook_quote(&msg, &instrument, None, TS).unwrap();
931
932 assert_eq!(quote.instrument_id, instrument.id());
933 assert_eq!(quote.bid_price, instrument.make_price(27450.00));
934 assert_eq!(quote.bid_size, instrument.make_qty(0.500, None));
935 assert_eq!(quote.ask_price, instrument.make_price(27451.00));
936 assert_eq!(quote.ask_size, instrument.make_qty(0.750, None));
937 }
938
939 #[rstest]
940 fn parse_orderbook_quote_with_delta_updates_sizes() {
941 let instrument = linear_instrument();
942 let snapshot: BybitWsOrderbookDepthMsg =
943 serde_json::from_str(&load_test_json("ws_orderbook_snapshot.json")).unwrap();
944 let base_quote = parse_orderbook_quote(&snapshot, &instrument, None, TS).unwrap();
945
946 let delta: BybitWsOrderbookDepthMsg =
947 serde_json::from_str(&load_test_json("ws_orderbook_delta.json")).unwrap();
948 let updated = parse_orderbook_quote(&delta, &instrument, Some(&base_quote), TS).unwrap();
949
950 assert_eq!(updated.bid_price, instrument.make_price(27450.00));
951 assert_eq!(updated.bid_size, instrument.make_qty(0.400, None));
952 assert_eq!(updated.ask_price, instrument.make_price(27451.00));
953 assert_eq!(updated.ask_size, instrument.make_qty(0.0, None));
954 }
955
956 #[rstest]
957 fn parse_linear_ticker_quote_to_quote_tick() {
958 let instrument = linear_instrument();
959 let json = load_test_json("ws_ticker_linear.json");
960 let msg: BybitWsTickerLinearMsg = serde_json::from_str(&json).unwrap();
961
962 let quote = parse_ticker_linear_quote(&msg, &instrument, TS).unwrap();
963
964 assert_eq!(quote.instrument_id, instrument.id());
965 assert_eq!(quote.bid_price, instrument.make_price(17215.50));
966 assert_eq!(quote.ask_price, instrument.make_price(17216.00));
967 assert_eq!(quote.bid_size, instrument.make_qty(84.489, None));
968 assert_eq!(quote.ask_size, instrument.make_qty(83.020, None));
969 assert_eq!(quote.ts_event, UnixNanos::new(1_673_272_861_686_000_000));
970 assert_eq!(quote.ts_init, TS);
971 }
972
973 #[rstest]
974 fn parse_option_ticker_quote_to_quote_tick() {
975 let instrument = option_instrument();
976 let json = load_test_json("ws_ticker_option.json");
977 let msg: BybitWsTickerOptionMsg = serde_json::from_str(&json).unwrap();
978
979 let quote = parse_ticker_option_quote(&msg, &instrument, TS).unwrap();
980
981 assert_eq!(quote.instrument_id, instrument.id());
982 assert_eq!(quote.bid_price, instrument.make_price(0.0));
983 assert_eq!(quote.ask_price, instrument.make_price(10.0));
984 assert_eq!(quote.bid_size, instrument.make_qty(0.0, None));
985 assert_eq!(quote.ask_size, instrument.make_qty(5.1, None));
986 assert_eq!(quote.ts_event, UnixNanos::new(1_672_917_511_074_000_000));
987 assert_eq!(quote.ts_init, TS);
988 }
989
990 #[rstest]
991 #[case::timestamp_on_open(false, 1_672_324_800_000_000_000)]
992 #[case::timestamp_on_close(true, 1_672_325_100_000_000_000)]
993 fn parse_ws_kline_into_bar(#[case] timestamp_on_close: bool, #[case] expected_ts_event: u64) {
994 use std::num::NonZero;
995
996 let instrument = linear_instrument();
997 let json = load_test_json("ws_kline.json");
998 let msg: crate::websocket::messages::BybitWsKlineMsg = serde_json::from_str(&json).unwrap();
999 let kline = &msg.data[0];
1000
1001 let bar_spec = BarSpecification {
1002 step: NonZero::new(5).unwrap(),
1003 aggregation: BarAggregation::Minute,
1004 price_type: PriceType::Last,
1005 };
1006 let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::External);
1007
1008 let bar = parse_ws_kline_bar(kline, &instrument, bar_type, timestamp_on_close, TS).unwrap();
1009
1010 assert_eq!(bar.bar_type, bar_type);
1011 assert_eq!(bar.open, instrument.make_price(16649.5));
1012 assert_eq!(bar.high, instrument.make_price(16677.0));
1013 assert_eq!(bar.low, instrument.make_price(16608.0));
1014 assert_eq!(bar.close, instrument.make_price(16677.0));
1015 assert_eq!(bar.volume, instrument.make_qty(2.081, None));
1016 assert_eq!(bar.ts_event, UnixNanos::new(expected_ts_event));
1017 assert_eq!(bar.ts_init, TS);
1018 }
1019
1020 #[rstest]
1021 fn parse_ws_order_into_order_status_report() {
1022 let instrument = linear_instrument();
1023 let json = load_test_json("ws_account_order_filled.json");
1024 let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1025 serde_json::from_str(&json).unwrap();
1026 let order = &msg.data[0];
1027 let account_id = AccountId::new("BYBIT-001");
1028
1029 let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1030
1031 assert_eq!(report.account_id, account_id);
1032 assert_eq!(report.instrument_id, instrument.id());
1033 assert_eq!(report.order_side, OrderSide::Buy);
1034 assert_eq!(report.order_type, OrderType::Limit);
1035 assert_eq!(report.time_in_force, TimeInForce::Gtc);
1036 assert_eq!(report.order_status, OrderStatus::Filled);
1037 assert_eq!(report.quantity, instrument.make_qty(0.100, None));
1038 assert_eq!(report.filled_qty, instrument.make_qty(0.100, None));
1039 assert_eq!(report.price, Some(instrument.make_price(30000.50)));
1040 assert_eq!(report.avg_px, Some(dec!(30000.50)));
1041 assert_eq!(
1042 report.client_order_id.as_ref().unwrap().to_string(),
1043 "test-client-order-001"
1044 );
1045 assert_eq!(
1046 report.ts_accepted,
1047 UnixNanos::new(1_672_364_262_444_000_000)
1048 );
1049 assert_eq!(report.ts_last, UnixNanos::new(1_672_364_262_457_000_000));
1050 }
1051
1052 #[rstest]
1053 fn parse_ws_order_partially_filled_rejected_maps_to_canceled() {
1054 let instrument = linear_instrument();
1055 let json = load_test_json("ws_account_order_partially_filled_rejected.json");
1056 let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1057 serde_json::from_str(&json).unwrap();
1058 let order = &msg.data[0];
1059 let account_id = AccountId::new("BYBIT-001");
1060
1061 let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1062
1063 assert_eq!(report.order_status, OrderStatus::Canceled);
1065 assert_eq!(report.filled_qty, instrument.make_qty(50.0, None));
1066 assert_eq!(
1067 report.client_order_id.as_ref().unwrap().to_string(),
1068 "O-20251001-164609-APEX-000-49"
1069 );
1070 assert_eq!(report.cancel_reason, Some("UNKNOWN".to_string()));
1071 }
1072
1073 #[rstest]
1074 fn parse_ws_execution_into_fill_report() {
1075 let instrument = linear_instrument();
1076 let json = load_test_json("ws_account_execution.json");
1077 let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1078 serde_json::from_str(&json).unwrap();
1079 let execution = &msg.data[0];
1080 let account_id = AccountId::new("BYBIT-001");
1081
1082 let report = parse_ws_fill_report(execution, account_id, &instrument, TS).unwrap();
1083
1084 assert_eq!(report.account_id, account_id);
1085 assert_eq!(report.instrument_id, instrument.id());
1086 assert_eq!(
1087 report.venue_order_id.to_string(),
1088 "9aac161b-8ed6-450d-9cab-c5cc67c21784"
1089 );
1090 assert_eq!(
1091 report.trade_id.to_string(),
1092 "0ab1bdf7-4219-438b-b30a-32ec863018f7"
1093 );
1094 assert_eq!(report.order_side, OrderSide::Sell);
1095 assert_eq!(report.last_qty, instrument.make_qty(0.5, None));
1096 assert_eq!(report.last_px, instrument.make_price(95900.1));
1097 assert_eq!(report.commission.as_f64(), 26.3725275);
1098 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1099 assert_eq!(
1100 report.client_order_id.as_ref().unwrap().to_string(),
1101 "test-order-link-001"
1102 );
1103 assert_eq!(report.ts_event, UnixNanos::new(1_746_270_400_353_000_000));
1104 }
1105
1106 #[rstest]
1107 fn parse_ws_position_into_position_status_report() {
1108 let instrument = linear_instrument();
1109 let json = load_test_json("ws_account_position.json");
1110 let msg: crate::websocket::messages::BybitWsAccountPositionMsg =
1111 serde_json::from_str(&json).unwrap();
1112 let position = &msg.data[0];
1113 let account_id = AccountId::new("BYBIT-001");
1114
1115 let report =
1116 parse_ws_position_status_report(position, account_id, &instrument, TS).unwrap();
1117
1118 assert_eq!(report.account_id, account_id);
1119 assert_eq!(report.instrument_id, instrument.id());
1120 assert_eq!(report.position_side.as_position_side(), PositionSide::Short);
1121 assert_eq!(report.quantity, instrument.make_qty(0.01, None));
1122 assert_eq!(
1123 report.avg_px_open,
1124 Some(Decimal::try_from(3641.075).unwrap())
1125 );
1126 assert_eq!(report.ts_last, UnixNanos::new(1_762_199_125_472_000_000));
1127 assert_eq!(report.ts_init, TS);
1128 }
1129
1130 #[rstest]
1131 fn parse_ws_position_short_into_position_status_report() {
1132 let instruments_json = load_test_json("http_get_instruments_linear.json");
1134 let instruments_response: crate::http::models::BybitInstrumentLinearResponse =
1135 serde_json::from_str(&instruments_json).unwrap();
1136 let eth_def = &instruments_response.result.list[1]; let fee_rate = crate::http::models::BybitFeeRate {
1138 symbol: Ustr::from("ETHUSDT"),
1139 taker_fee_rate: "0.00055".to_string(),
1140 maker_fee_rate: "0.0001".to_string(),
1141 base_coin: Some(Ustr::from("ETH")),
1142 };
1143 let instrument =
1144 crate::common::parse::parse_linear_instrument(eth_def, &fee_rate, TS, TS).unwrap();
1145
1146 let json = load_test_json("ws_account_position_short.json");
1147 let msg: crate::websocket::messages::BybitWsAccountPositionMsg =
1148 serde_json::from_str(&json).unwrap();
1149 let position = &msg.data[0];
1150 let account_id = AccountId::new("BYBIT-001");
1151
1152 let report =
1153 parse_ws_position_status_report(position, account_id, &instrument, TS).unwrap();
1154
1155 assert_eq!(report.account_id, account_id);
1156 assert_eq!(report.instrument_id.symbol.as_str(), "ETHUSDT-LINEAR");
1157 assert_eq!(report.position_side.as_position_side(), PositionSide::Short);
1158 assert_eq!(report.quantity, instrument.make_qty(0.01, None));
1159 assert_eq!(
1160 report.avg_px_open,
1161 Some(Decimal::try_from(3641.075).unwrap())
1162 );
1163 assert_eq!(report.ts_last, UnixNanos::new(1_762_199_125_472_000_000));
1164 assert_eq!(report.ts_init, TS);
1165 }
1166
1167 #[rstest]
1168 fn parse_ws_wallet_into_account_state() {
1169 let json = load_test_json("ws_account_wallet.json");
1170 let msg: crate::websocket::messages::BybitWsAccountWalletMsg =
1171 serde_json::from_str(&json).unwrap();
1172 let wallet = &msg.data[0];
1173 let account_id = AccountId::new("BYBIT-001");
1174 let ts_event = UnixNanos::new(1_700_034_722_104_000_000);
1175
1176 let state = parse_ws_account_state(wallet, account_id, ts_event, TS).unwrap();
1177
1178 assert_eq!(state.account_id, account_id);
1179 assert_eq!(state.account_type, AccountType::Margin);
1180 assert_eq!(state.balances.len(), 2);
1181 assert!(state.is_reported);
1182
1183 let btc_balance = &state.balances[0];
1185 assert_eq!(btc_balance.currency.code.as_str(), "BTC");
1186 assert!((btc_balance.total.as_f64() - 0.00102964).abs() < 1e-8);
1187 assert!((btc_balance.free.as_f64() - 0.00092964).abs() < 1e-8);
1188 assert!((btc_balance.locked.as_f64() - 0.0001).abs() < 1e-8);
1189
1190 let usdt_balance = &state.balances[1];
1192 assert_eq!(usdt_balance.currency.code.as_str(), "USDT");
1193 assert!((usdt_balance.total.as_f64() - 9647.75537647).abs() < 1e-6);
1194 assert!((usdt_balance.free.as_f64() - 9519.89806037).abs() < 1e-6);
1195 assert!((usdt_balance.locked.as_f64() - 127.8573161).abs() < 1e-6);
1196
1197 assert_eq!(state.ts_event, ts_event);
1198 assert_eq!(state.ts_init, TS);
1199 }
1200
1201 #[rstest]
1202 fn parse_ws_wallet_with_small_order_calculates_free_correctly() {
1203 let json = load_test_json("ws_account_wallet_small_order.json");
1207 let msg: crate::websocket::messages::BybitWsAccountWalletMsg =
1208 serde_json::from_str(&json).unwrap();
1209 let wallet = &msg.data[0];
1210 let account_id = AccountId::new("BYBIT-UNIFIED");
1211 let ts_event = UnixNanos::new(1_762_960_669_000_000_000);
1212
1213 let state = parse_ws_account_state(wallet, account_id, ts_event, TS).unwrap();
1214
1215 assert_eq!(state.account_id, account_id);
1216 assert_eq!(state.balances.len(), 1);
1217
1218 let usdt_balance = &state.balances[0];
1220 assert_eq!(usdt_balance.currency.code.as_str(), "USDT");
1221
1222 assert!((usdt_balance.total.as_f64() - 51333.82543837).abs() < 1e-6);
1224
1225 assert!((usdt_balance.locked.as_f64() - 50.028).abs() < 1e-6);
1227
1228 assert!((usdt_balance.free.as_f64() - 51283.79743837).abs() < 1e-6);
1230
1231 }
1234
1235 #[rstest]
1236 fn parse_ticker_linear_into_funding_rate() {
1237 let instrument = linear_instrument();
1238 let json = load_test_json("ws_ticker_linear.json");
1239 let msg: BybitWsTickerLinearMsg = serde_json::from_str(&json).unwrap();
1240
1241 let ts_event = UnixNanos::new(1_673_272_861_686_000_000);
1242
1243 let funding =
1244 parse_ticker_linear_funding(&msg.data, instrument.id(), ts_event, TS).unwrap();
1245
1246 assert_eq!(funding.instrument_id, instrument.id());
1247 assert_eq!(funding.rate, dec!(-0.000212)); assert_eq!(
1249 funding.next_funding_ns,
1250 Some(UnixNanos::new(1_673_280_000_000_000_000))
1251 );
1252 assert_eq!(funding.ts_event, ts_event);
1253 assert_eq!(funding.ts_init, TS);
1254 }
1255
1256 #[rstest]
1257 fn parse_ws_order_stop_market_sell_preserves_type() {
1258 let instrument = linear_instrument();
1259 let json = load_test_json("ws_account_order_stop_market.json");
1260 let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1261 serde_json::from_str(&json).unwrap();
1262 let order = &msg.data[0];
1263 let account_id = AccountId::new("BYBIT-001");
1264
1265 let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1266
1267 assert_eq!(report.order_type, OrderType::StopMarket);
1269 assert_eq!(report.order_side, OrderSide::Sell);
1270 assert_eq!(report.order_status, OrderStatus::Accepted); assert_eq!(report.trigger_price, Some(instrument.make_price(45000.00)));
1272 assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1273 assert_eq!(
1274 report.client_order_id.as_ref().unwrap().to_string(),
1275 "test-client-stop-market-001"
1276 );
1277 }
1278
1279 #[rstest]
1280 fn parse_ws_order_stop_market_buy_preserves_type() {
1281 let instrument = linear_instrument();
1282 let json = load_test_json("ws_account_order_buy_stop_market.json");
1283 let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1284 serde_json::from_str(&json).unwrap();
1285 let order = &msg.data[0];
1286 let account_id = AccountId::new("BYBIT-001");
1287
1288 let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1289
1290 assert_eq!(report.order_type, OrderType::StopMarket);
1292 assert_eq!(report.order_side, OrderSide::Buy);
1293 assert_eq!(report.order_status, OrderStatus::Accepted);
1294 assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
1295 assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1296 assert_eq!(
1297 report.client_order_id.as_ref().unwrap().to_string(),
1298 "test-client-buy-stop-market-001"
1299 );
1300 }
1301
1302 #[rstest]
1303 fn parse_ws_order_market_if_touched_buy_preserves_type() {
1304 let instrument = linear_instrument();
1305 let json = load_test_json("ws_account_order_market_if_touched.json");
1306 let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1307 serde_json::from_str(&json).unwrap();
1308 let order = &msg.data[0];
1309 let account_id = AccountId::new("BYBIT-001");
1310
1311 let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1312
1313 assert_eq!(report.order_type, OrderType::MarketIfTouched);
1315 assert_eq!(report.order_side, OrderSide::Buy);
1316 assert_eq!(report.order_status, OrderStatus::Accepted); assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
1318 assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1319 assert_eq!(
1320 report.client_order_id.as_ref().unwrap().to_string(),
1321 "test-client-mit-001"
1322 );
1323 }
1324
1325 #[rstest]
1326 fn parse_ws_order_market_if_touched_sell_preserves_type() {
1327 let instrument = linear_instrument();
1328 let json = load_test_json("ws_account_order_sell_market_if_touched.json");
1329 let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1330 serde_json::from_str(&json).unwrap();
1331 let order = &msg.data[0];
1332 let account_id = AccountId::new("BYBIT-001");
1333
1334 let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1335
1336 assert_eq!(report.order_type, OrderType::MarketIfTouched);
1338 assert_eq!(report.order_side, OrderSide::Sell);
1339 assert_eq!(report.order_status, OrderStatus::Accepted);
1340 assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
1341 assert_eq!(
1342 report.client_order_id.as_ref().unwrap().to_string(),
1343 "test-client-sell-mit-001"
1344 );
1345 }
1346
1347 #[rstest]
1348 fn parse_ws_order_stop_limit_preserves_type() {
1349 let instrument = linear_instrument();
1350 let json = load_test_json("ws_account_order_stop_limit.json");
1351 let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1352 serde_json::from_str(&json).unwrap();
1353 let order = &msg.data[0];
1354 let account_id = AccountId::new("BYBIT-001");
1355
1356 let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1357
1358 assert_eq!(report.order_type, OrderType::StopLimit);
1361 assert_eq!(report.order_side, OrderSide::Sell);
1362 assert_eq!(report.order_status, OrderStatus::Accepted); assert_eq!(report.price, Some(instrument.make_price(44500.00)));
1364 assert_eq!(report.trigger_price, Some(instrument.make_price(45000.00)));
1365 assert_eq!(
1366 report.client_order_id.as_ref().unwrap().to_string(),
1367 "test-client-stop-limit-001"
1368 );
1369 }
1370
1371 #[rstest]
1372 fn parse_ws_order_limit_if_touched_preserves_type() {
1373 let instrument = linear_instrument();
1374 let json = load_test_json("ws_account_order_limit_if_touched.json");
1375 let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1376 serde_json::from_str(&json).unwrap();
1377 let order = &msg.data[0];
1378 let account_id = AccountId::new("BYBIT-001");
1379
1380 let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1381
1382 assert_eq!(report.order_type, OrderType::LimitIfTouched);
1385 assert_eq!(report.order_side, OrderSide::Buy);
1386 assert_eq!(report.order_status, OrderStatus::Accepted); assert_eq!(report.price, Some(instrument.make_price(55500.00)));
1388 assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
1389 assert_eq!(
1390 report.client_order_id.as_ref().unwrap().to_string(),
1391 "test-client-lit-001"
1392 );
1393 }
1394}