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 let instrument_id = instrument.id();
450 let venue_order_id = VenueOrderId::new(order.order_id.as_str());
451 let order_side: OrderSide = order.side.into();
452
453 use crate::common::enums::BybitOrderSide;
455 let order_type: OrderType = match (
456 order.order_type,
457 order.stop_order_type,
458 order.trigger_direction,
459 order.side,
460 ) {
461 (BybitOrderType::Market, BybitStopOrderType::None | BybitStopOrderType::Unknown, _, _) => {
462 OrderType::Market
463 }
464 (BybitOrderType::Limit, BybitStopOrderType::None | BybitStopOrderType::Unknown, _, _) => {
465 OrderType::Limit
466 }
467
468 (
469 BybitOrderType::Market,
470 BybitStopOrderType::Stop,
471 BybitTriggerDirection::RisesTo,
472 BybitOrderSide::Buy,
473 ) => OrderType::StopMarket,
474 (
475 BybitOrderType::Market,
476 BybitStopOrderType::Stop,
477 BybitTriggerDirection::FallsTo,
478 BybitOrderSide::Buy,
479 ) => OrderType::MarketIfTouched,
480
481 (
482 BybitOrderType::Market,
483 BybitStopOrderType::Stop,
484 BybitTriggerDirection::FallsTo,
485 BybitOrderSide::Sell,
486 ) => OrderType::StopMarket,
487 (
488 BybitOrderType::Market,
489 BybitStopOrderType::Stop,
490 BybitTriggerDirection::RisesTo,
491 BybitOrderSide::Sell,
492 ) => OrderType::MarketIfTouched,
493
494 (
495 BybitOrderType::Limit,
496 BybitStopOrderType::Stop,
497 BybitTriggerDirection::RisesTo,
498 BybitOrderSide::Buy,
499 ) => OrderType::StopLimit,
500 (
501 BybitOrderType::Limit,
502 BybitStopOrderType::Stop,
503 BybitTriggerDirection::FallsTo,
504 BybitOrderSide::Buy,
505 ) => OrderType::LimitIfTouched,
506
507 (
508 BybitOrderType::Limit,
509 BybitStopOrderType::Stop,
510 BybitTriggerDirection::FallsTo,
511 BybitOrderSide::Sell,
512 ) => OrderType::StopLimit,
513 (
514 BybitOrderType::Limit,
515 BybitStopOrderType::Stop,
516 BybitTriggerDirection::RisesTo,
517 BybitOrderSide::Sell,
518 ) => OrderType::LimitIfTouched,
519
520 (BybitOrderType::Market, BybitStopOrderType::Stop, BybitTriggerDirection::None, _) => {
522 OrderType::Market
523 }
524 (BybitOrderType::Limit, BybitStopOrderType::Stop, BybitTriggerDirection::None, _) => {
525 OrderType::Limit
526 }
527
528 (BybitOrderType::Market, _, _, _) => OrderType::Market,
530 (BybitOrderType::Limit, _, _, _) => OrderType::Limit,
531
532 (BybitOrderType::Unknown, _, _, _) => OrderType::Limit,
533 };
534
535 let time_in_force: TimeInForce = match order.time_in_force {
536 BybitTimeInForce::Gtc => TimeInForce::Gtc,
537 BybitTimeInForce::Ioc => TimeInForce::Ioc,
538 BybitTimeInForce::Fok => TimeInForce::Fok,
539 BybitTimeInForce::PostOnly => TimeInForce::Gtc,
540 };
541
542 let quantity =
543 parse_quantity_with_precision(&order.qty, instrument.size_precision(), "order.qty")?;
544
545 let filled_qty = parse_quantity_with_precision(
546 &order.cum_exec_qty,
547 instrument.size_precision(),
548 "order.cumExecQty",
549 )?;
550
551 let order_status: OrderStatus = match order.order_status {
557 BybitOrderStatus::Created | BybitOrderStatus::New | BybitOrderStatus::Untriggered => {
558 OrderStatus::Accepted
559 }
560 BybitOrderStatus::Rejected => {
561 if filled_qty.is_positive() {
562 OrderStatus::Canceled
563 } else {
564 OrderStatus::Rejected
565 }
566 }
567 BybitOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
568 BybitOrderStatus::Filled => OrderStatus::Filled,
569 BybitOrderStatus::Canceled | BybitOrderStatus::PartiallyFilledCanceled => {
570 OrderStatus::Canceled
571 }
572 BybitOrderStatus::Triggered => OrderStatus::Triggered,
573 BybitOrderStatus::Deactivated => OrderStatus::Canceled,
574 };
575
576 let ts_accepted = parse_millis_timestamp(&order.created_time, "order.createdTime")?;
577 let ts_last = parse_millis_timestamp(&order.updated_time, "order.updatedTime")?;
578
579 let mut report = OrderStatusReport::new(
580 account_id,
581 instrument_id,
582 None,
583 venue_order_id,
584 order_side,
585 order_type,
586 time_in_force,
587 order_status,
588 quantity,
589 filled_qty,
590 ts_accepted,
591 ts_last,
592 ts_init,
593 Some(UUID4::new()),
594 );
595
596 if !order.order_link_id.is_empty() {
597 report = report.with_client_order_id(ClientOrderId::new(order.order_link_id.as_str()));
598 }
599
600 if !order.price.is_empty() && order.price != "0" {
601 let price =
602 parse_price_with_precision(&order.price, instrument.price_precision(), "order.price")?;
603 report = report.with_price(price);
604 }
605
606 if !order.avg_price.is_empty() && order.avg_price != "0" {
607 let avg_px = order
608 .avg_price
609 .parse::<f64>()
610 .with_context(|| format!("Failed to parse avg_price='{}' as f64", order.avg_price))?;
611 report = report.with_avg_px(avg_px)?;
612 }
613
614 if !order.trigger_price.is_empty() && order.trigger_price != "0" {
615 let trigger_price = parse_price_with_precision(
616 &order.trigger_price,
617 instrument.price_precision(),
618 "order.triggerPrice",
619 )?;
620 report = report.with_trigger_price(trigger_price);
621
622 let trigger_type: TriggerType = order.trigger_by.into();
624 report = report.with_trigger_type(trigger_type);
625 }
626
627 if order.reduce_only {
628 report = report.with_reduce_only(true);
629 }
630
631 if order.time_in_force == BybitTimeInForce::PostOnly {
632 report = report.with_post_only(true);
633 }
634
635 if !order.reject_reason.is_empty() {
636 report = report.with_cancel_reason(order.reject_reason.to_string());
637 }
638
639 Ok(report)
640}
641
642pub fn parse_ws_fill_report(
648 execution: &BybitWsAccountExecution,
649 account_id: AccountId,
650 instrument: &InstrumentAny,
651 ts_init: UnixNanos,
652) -> anyhow::Result<FillReport> {
653 let instrument_id = instrument.id();
654 let venue_order_id = VenueOrderId::new(execution.order_id.as_str());
655 let trade_id = TradeId::new_checked(execution.exec_id.as_str())
656 .context("invalid execId in Bybit WebSocket execution payload")?;
657
658 let order_side: OrderSide = execution.side.into();
659 let last_qty = parse_quantity_with_precision(
660 &execution.exec_qty,
661 instrument.size_precision(),
662 "execution.execQty",
663 )?;
664 let last_px = parse_price_with_precision(
665 &execution.exec_price,
666 instrument.price_precision(),
667 "execution.execPrice",
668 )?;
669
670 let liquidity_side = if execution.is_maker {
671 LiquiditySide::Maker
672 } else {
673 LiquiditySide::Taker
674 };
675
676 let commission_str = execution.exec_fee.trim_start_matches('-');
677 let commission_amount = commission_str
678 .parse::<f64>()
679 .with_context(|| format!("Failed to parse execFee='{}' as f64", execution.exec_fee))?
680 .abs();
681
682 let commission_currency = instrument.quote_currency();
684 let commission = Money::new(commission_amount, commission_currency);
685 let ts_event = parse_millis_timestamp(&execution.exec_time, "execution.execTime")?;
686
687 let client_order_id = if !execution.order_link_id.is_empty() {
688 Some(ClientOrderId::new(execution.order_link_id.as_str()))
689 } else {
690 None
691 };
692
693 Ok(FillReport::new(
694 account_id,
695 instrument_id,
696 venue_order_id,
697 trade_id,
698 order_side,
699 last_qty,
700 last_px,
701 commission,
702 liquidity_side,
703 client_order_id,
704 None, ts_event,
706 ts_init,
707 None, ))
709}
710
711pub fn parse_ws_position_status_report(
717 position: &BybitWsAccountPosition,
718 account_id: AccountId,
719 instrument: &InstrumentAny,
720 ts_init: UnixNanos,
721) -> anyhow::Result<PositionStatusReport> {
722 let instrument_id = instrument.id();
723
724 let quantity = parse_quantity_with_precision(
726 &position.size,
727 instrument.size_precision(),
728 "position.size",
729 )?;
730
731 let position_side = if position.side.eq_ignore_ascii_case("buy") {
733 PositionSideSpecified::Long
734 } else if position.side.eq_ignore_ascii_case("sell") {
735 PositionSideSpecified::Short
736 } else {
737 PositionSideSpecified::Flat
738 };
739
740 let ts_last = parse_millis_timestamp(&position.updated_time, "position.updatedTime")?;
741
742 Ok(PositionStatusReport::new(
743 account_id,
744 instrument_id,
745 position_side,
746 quantity,
747 ts_last,
748 ts_init,
749 None, None, position.entry_price, ))
753}
754
755pub fn parse_ws_account_state(
761 wallet: &BybitWsAccountWallet,
762 account_id: AccountId,
763 ts_event: UnixNanos,
764 ts_init: UnixNanos,
765) -> anyhow::Result<AccountState> {
766 let mut balances = Vec::new();
767
768 for coin_data in &wallet.coin {
769 let currency = get_currency(coin_data.coin.as_str());
770 let total_dec = coin_data.wallet_balance - coin_data.spot_borrow;
771 let locked_dec = coin_data.total_order_im + coin_data.total_position_im;
772
773 let total = Money::from_decimal(total_dec, currency)?;
774 let locked = Money::from_decimal(locked_dec, currency)?;
775 let free = Money::from_raw(total.raw - locked.raw, currency);
776
777 let balance = AccountBalance::new(total, locked, free);
778 balances.push(balance);
779 }
780
781 Ok(AccountState::new(
782 account_id,
783 AccountType::Margin, balances,
785 vec![], true, UUID4::new(),
788 ts_event,
789 ts_init,
790 None, ))
792}
793
794#[cfg(test)]
795mod tests {
796 use nautilus_model::{
797 data::BarSpecification,
798 enums::{AggregationSource, BarAggregation, PositionSide, PriceType},
799 };
800 use rstest::rstest;
801 use rust_decimal_macros::dec;
802
803 use super::*;
804 use crate::{
805 common::{
806 parse::{parse_linear_instrument, parse_option_instrument},
807 testing::load_test_json,
808 },
809 http::models::{BybitInstrumentLinearResponse, BybitInstrumentOptionResponse},
810 websocket::messages::{
811 BybitWsOrderbookDepthMsg, BybitWsTickerLinearMsg, BybitWsTickerOptionMsg,
812 BybitWsTradeMsg,
813 },
814 };
815
816 const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
817
818 use ustr::Ustr;
819
820 use crate::http::models::BybitFeeRate;
821
822 fn sample_fee_rate(
823 symbol: &str,
824 taker: &str,
825 maker: &str,
826 base_coin: Option<&str>,
827 ) -> BybitFeeRate {
828 BybitFeeRate {
829 symbol: Ustr::from(symbol),
830 taker_fee_rate: taker.to_string(),
831 maker_fee_rate: maker.to_string(),
832 base_coin: base_coin.map(Ustr::from),
833 }
834 }
835
836 fn linear_instrument() -> InstrumentAny {
837 let json = load_test_json("http_get_instruments_linear.json");
838 let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
839 let instrument = &response.result.list[0];
840 let fee_rate = sample_fee_rate("BTCUSDT", "0.00055", "0.0001", Some("BTC"));
841 parse_linear_instrument(instrument, &fee_rate, TS, TS).unwrap()
842 }
843
844 fn option_instrument() -> InstrumentAny {
845 let json = load_test_json("http_get_instruments_option.json");
846 let response: BybitInstrumentOptionResponse = serde_json::from_str(&json).unwrap();
847 let instrument = &response.result.list[0];
848 parse_option_instrument(instrument, TS, TS).unwrap()
849 }
850
851 #[rstest]
852 fn parse_ws_trade_into_trade_tick() {
853 let instrument = linear_instrument();
854 let json = load_test_json("ws_public_trade.json");
855 let msg: BybitWsTradeMsg = serde_json::from_str(&json).unwrap();
856 let trade = &msg.data[0];
857
858 let tick = parse_ws_trade_tick(trade, &instrument, TS).unwrap();
859
860 assert_eq!(tick.instrument_id, instrument.id());
861 assert_eq!(tick.price, instrument.make_price(27451.00));
862 assert_eq!(tick.size, instrument.make_qty(0.010, None));
863 assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
864 assert_eq!(
865 tick.trade_id.to_string(),
866 "9dc75fca-4bdd-4773-9f78-6f5d7ab2a110"
867 );
868 assert_eq!(tick.ts_event, UnixNanos::new(1_709_891_679_000_000_000));
869 }
870
871 #[rstest]
872 fn parse_orderbook_snapshot_into_deltas() {
873 let instrument = linear_instrument();
874 let json = load_test_json("ws_orderbook_snapshot.json");
875 let msg: BybitWsOrderbookDepthMsg = serde_json::from_str(&json).unwrap();
876
877 let deltas = parse_orderbook_deltas(&msg, &instrument, TS).unwrap();
878
879 assert_eq!(deltas.instrument_id, instrument.id());
880 assert_eq!(deltas.deltas.len(), 5);
881 assert_eq!(deltas.deltas[0].action, BookAction::Clear);
882 assert_eq!(
883 deltas.deltas[1].order.price,
884 instrument.make_price(27450.00)
885 );
886 assert_eq!(
887 deltas.deltas[1].order.size,
888 instrument.make_qty(0.500, None)
889 );
890 let last = deltas.deltas.last().unwrap();
891 assert_eq!(last.order.side, OrderSide::Sell);
892 assert_eq!(last.order.price, instrument.make_price(27451.50));
893 assert_eq!(
894 last.flags & RecordFlag::F_LAST as u8,
895 RecordFlag::F_LAST as u8
896 );
897 }
898
899 #[rstest]
900 fn parse_orderbook_delta_marks_actions() {
901 let instrument = linear_instrument();
902 let json = load_test_json("ws_orderbook_delta.json");
903 let msg: BybitWsOrderbookDepthMsg = serde_json::from_str(&json).unwrap();
904
905 let deltas = parse_orderbook_deltas(&msg, &instrument, TS).unwrap();
906
907 assert_eq!(deltas.deltas.len(), 2);
908 let bid = &deltas.deltas[0];
909 assert_eq!(bid.action, BookAction::Update);
910 assert_eq!(bid.order.side, OrderSide::Buy);
911 assert_eq!(bid.order.size, instrument.make_qty(0.400, None));
912
913 let ask = &deltas.deltas[1];
914 assert_eq!(ask.action, BookAction::Delete);
915 assert_eq!(ask.order.side, OrderSide::Sell);
916 assert_eq!(ask.order.size, instrument.make_qty(0.0, None));
917 assert_eq!(
918 ask.flags & RecordFlag::F_LAST as u8,
919 RecordFlag::F_LAST as u8
920 );
921 }
922
923 #[rstest]
924 fn parse_orderbook_quote_produces_top_of_book() {
925 let instrument = linear_instrument();
926 let json = load_test_json("ws_orderbook_snapshot.json");
927 let msg: BybitWsOrderbookDepthMsg = serde_json::from_str(&json).unwrap();
928
929 let quote = parse_orderbook_quote(&msg, &instrument, None, TS).unwrap();
930
931 assert_eq!(quote.instrument_id, instrument.id());
932 assert_eq!(quote.bid_price, instrument.make_price(27450.00));
933 assert_eq!(quote.bid_size, instrument.make_qty(0.500, None));
934 assert_eq!(quote.ask_price, instrument.make_price(27451.00));
935 assert_eq!(quote.ask_size, instrument.make_qty(0.750, None));
936 }
937
938 #[rstest]
939 fn parse_orderbook_quote_with_delta_updates_sizes() {
940 let instrument = linear_instrument();
941 let snapshot: BybitWsOrderbookDepthMsg =
942 serde_json::from_str(&load_test_json("ws_orderbook_snapshot.json")).unwrap();
943 let base_quote = parse_orderbook_quote(&snapshot, &instrument, None, TS).unwrap();
944
945 let delta: BybitWsOrderbookDepthMsg =
946 serde_json::from_str(&load_test_json("ws_orderbook_delta.json")).unwrap();
947 let updated = parse_orderbook_quote(&delta, &instrument, Some(&base_quote), TS).unwrap();
948
949 assert_eq!(updated.bid_price, instrument.make_price(27450.00));
950 assert_eq!(updated.bid_size, instrument.make_qty(0.400, None));
951 assert_eq!(updated.ask_price, instrument.make_price(27451.00));
952 assert_eq!(updated.ask_size, instrument.make_qty(0.0, None));
953 }
954
955 #[rstest]
956 fn parse_linear_ticker_quote_to_quote_tick() {
957 let instrument = linear_instrument();
958 let json = load_test_json("ws_ticker_linear.json");
959 let msg: BybitWsTickerLinearMsg = serde_json::from_str(&json).unwrap();
960
961 let quote = parse_ticker_linear_quote(&msg, &instrument, TS).unwrap();
962
963 assert_eq!(quote.instrument_id, instrument.id());
964 assert_eq!(quote.bid_price, instrument.make_price(17215.50));
965 assert_eq!(quote.ask_price, instrument.make_price(17216.00));
966 assert_eq!(quote.bid_size, instrument.make_qty(84.489, None));
967 assert_eq!(quote.ask_size, instrument.make_qty(83.020, None));
968 assert_eq!(quote.ts_event, UnixNanos::new(1_673_272_861_686_000_000));
969 assert_eq!(quote.ts_init, TS);
970 }
971
972 #[rstest]
973 fn parse_option_ticker_quote_to_quote_tick() {
974 let instrument = option_instrument();
975 let json = load_test_json("ws_ticker_option.json");
976 let msg: BybitWsTickerOptionMsg = serde_json::from_str(&json).unwrap();
977
978 let quote = parse_ticker_option_quote(&msg, &instrument, TS).unwrap();
979
980 assert_eq!(quote.instrument_id, instrument.id());
981 assert_eq!(quote.bid_price, instrument.make_price(0.0));
982 assert_eq!(quote.ask_price, instrument.make_price(10.0));
983 assert_eq!(quote.bid_size, instrument.make_qty(0.0, None));
984 assert_eq!(quote.ask_size, instrument.make_qty(5.1, None));
985 assert_eq!(quote.ts_event, UnixNanos::new(1_672_917_511_074_000_000));
986 assert_eq!(quote.ts_init, TS);
987 }
988
989 #[rstest]
990 #[case::timestamp_on_open(false, 1_672_324_800_000_000_000)]
991 #[case::timestamp_on_close(true, 1_672_325_100_000_000_000)]
992 fn parse_ws_kline_into_bar(#[case] timestamp_on_close: bool, #[case] expected_ts_event: u64) {
993 use std::num::NonZero;
994
995 let instrument = linear_instrument();
996 let json = load_test_json("ws_kline.json");
997 let msg: crate::websocket::messages::BybitWsKlineMsg = serde_json::from_str(&json).unwrap();
998 let kline = &msg.data[0];
999
1000 let bar_spec = BarSpecification {
1001 step: NonZero::new(5).unwrap(),
1002 aggregation: BarAggregation::Minute,
1003 price_type: PriceType::Last,
1004 };
1005 let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::External);
1006
1007 let bar = parse_ws_kline_bar(kline, &instrument, bar_type, timestamp_on_close, TS).unwrap();
1008
1009 assert_eq!(bar.bar_type, bar_type);
1010 assert_eq!(bar.open, instrument.make_price(16649.5));
1011 assert_eq!(bar.high, instrument.make_price(16677.0));
1012 assert_eq!(bar.low, instrument.make_price(16608.0));
1013 assert_eq!(bar.close, instrument.make_price(16677.0));
1014 assert_eq!(bar.volume, instrument.make_qty(2.081, None));
1015 assert_eq!(bar.ts_event, UnixNanos::new(expected_ts_event));
1016 assert_eq!(bar.ts_init, TS);
1017 }
1018
1019 #[rstest]
1020 fn parse_ws_order_into_order_status_report() {
1021 let instrument = linear_instrument();
1022 let json = load_test_json("ws_account_order_filled.json");
1023 let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1024 serde_json::from_str(&json).unwrap();
1025 let order = &msg.data[0];
1026 let account_id = AccountId::new("BYBIT-001");
1027
1028 let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1029
1030 assert_eq!(report.account_id, account_id);
1031 assert_eq!(report.instrument_id, instrument.id());
1032 assert_eq!(report.order_side, OrderSide::Buy);
1033 assert_eq!(report.order_type, OrderType::Limit);
1034 assert_eq!(report.time_in_force, TimeInForce::Gtc);
1035 assert_eq!(report.order_status, OrderStatus::Filled);
1036 assert_eq!(report.quantity, instrument.make_qty(0.100, None));
1037 assert_eq!(report.filled_qty, instrument.make_qty(0.100, None));
1038 assert_eq!(report.price, Some(instrument.make_price(30000.50)));
1039 assert_eq!(report.avg_px, Some(dec!(30000.50)));
1040 assert_eq!(
1041 report.client_order_id.as_ref().unwrap().to_string(),
1042 "test-client-order-001"
1043 );
1044 assert_eq!(
1045 report.ts_accepted,
1046 UnixNanos::new(1_672_364_262_444_000_000)
1047 );
1048 assert_eq!(report.ts_last, UnixNanos::new(1_672_364_262_457_000_000));
1049 }
1050
1051 #[rstest]
1052 fn parse_ws_order_partially_filled_rejected_maps_to_canceled() {
1053 let instrument = linear_instrument();
1054 let json = load_test_json("ws_account_order_partially_filled_rejected.json");
1055 let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1056 serde_json::from_str(&json).unwrap();
1057 let order = &msg.data[0];
1058 let account_id = AccountId::new("BYBIT-001");
1059
1060 let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1061
1062 assert_eq!(report.order_status, OrderStatus::Canceled);
1064 assert_eq!(report.filled_qty, instrument.make_qty(50.0, None));
1065 assert_eq!(
1066 report.client_order_id.as_ref().unwrap().to_string(),
1067 "O-20251001-164609-APEX-000-49"
1068 );
1069 assert_eq!(report.cancel_reason, Some("UNKNOWN".to_string()));
1070 }
1071
1072 #[rstest]
1073 fn parse_ws_execution_into_fill_report() {
1074 let instrument = linear_instrument();
1075 let json = load_test_json("ws_account_execution.json");
1076 let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1077 serde_json::from_str(&json).unwrap();
1078 let execution = &msg.data[0];
1079 let account_id = AccountId::new("BYBIT-001");
1080
1081 let report = parse_ws_fill_report(execution, account_id, &instrument, TS).unwrap();
1082
1083 assert_eq!(report.account_id, account_id);
1084 assert_eq!(report.instrument_id, instrument.id());
1085 assert_eq!(
1086 report.venue_order_id.to_string(),
1087 "9aac161b-8ed6-450d-9cab-c5cc67c21784"
1088 );
1089 assert_eq!(
1090 report.trade_id.to_string(),
1091 "0ab1bdf7-4219-438b-b30a-32ec863018f7"
1092 );
1093 assert_eq!(report.order_side, OrderSide::Sell);
1094 assert_eq!(report.last_qty, instrument.make_qty(0.5, None));
1095 assert_eq!(report.last_px, instrument.make_price(95900.1));
1096 assert_eq!(report.commission.as_f64(), 26.3725275);
1097 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1098 assert_eq!(
1099 report.client_order_id.as_ref().unwrap().to_string(),
1100 "test-order-link-001"
1101 );
1102 assert_eq!(report.ts_event, UnixNanos::new(1_746_270_400_353_000_000));
1103 }
1104
1105 #[rstest]
1106 fn parse_ws_position_into_position_status_report() {
1107 let instrument = linear_instrument();
1108 let json = load_test_json("ws_account_position.json");
1109 let msg: crate::websocket::messages::BybitWsAccountPositionMsg =
1110 serde_json::from_str(&json).unwrap();
1111 let position = &msg.data[0];
1112 let account_id = AccountId::new("BYBIT-001");
1113
1114 let report =
1115 parse_ws_position_status_report(position, account_id, &instrument, TS).unwrap();
1116
1117 assert_eq!(report.account_id, account_id);
1118 assert_eq!(report.instrument_id, instrument.id());
1119 assert_eq!(report.position_side.as_position_side(), PositionSide::Short);
1120 assert_eq!(report.quantity, instrument.make_qty(0.01, None));
1121 assert_eq!(
1122 report.avg_px_open,
1123 Some(Decimal::try_from(3641.075).unwrap())
1124 );
1125 assert_eq!(report.ts_last, UnixNanos::new(1_762_199_125_472_000_000));
1126 assert_eq!(report.ts_init, TS);
1127 }
1128
1129 #[rstest]
1130 fn parse_ws_position_short_into_position_status_report() {
1131 let instruments_json = load_test_json("http_get_instruments_linear.json");
1133 let instruments_response: crate::http::models::BybitInstrumentLinearResponse =
1134 serde_json::from_str(&instruments_json).unwrap();
1135 let eth_def = &instruments_response.result.list[1]; let fee_rate = crate::http::models::BybitFeeRate {
1137 symbol: Ustr::from("ETHUSDT"),
1138 taker_fee_rate: "0.00055".to_string(),
1139 maker_fee_rate: "0.0001".to_string(),
1140 base_coin: Some(Ustr::from("ETH")),
1141 };
1142 let instrument =
1143 crate::common::parse::parse_linear_instrument(eth_def, &fee_rate, TS, TS).unwrap();
1144
1145 let json = load_test_json("ws_account_position_short.json");
1146 let msg: crate::websocket::messages::BybitWsAccountPositionMsg =
1147 serde_json::from_str(&json).unwrap();
1148 let position = &msg.data[0];
1149 let account_id = AccountId::new("BYBIT-001");
1150
1151 let report =
1152 parse_ws_position_status_report(position, account_id, &instrument, TS).unwrap();
1153
1154 assert_eq!(report.account_id, account_id);
1155 assert_eq!(report.instrument_id.symbol.as_str(), "ETHUSDT-LINEAR");
1156 assert_eq!(report.position_side.as_position_side(), PositionSide::Short);
1157 assert_eq!(report.quantity, instrument.make_qty(0.01, None));
1158 assert_eq!(
1159 report.avg_px_open,
1160 Some(Decimal::try_from(3641.075).unwrap())
1161 );
1162 assert_eq!(report.ts_last, UnixNanos::new(1_762_199_125_472_000_000));
1163 assert_eq!(report.ts_init, TS);
1164 }
1165
1166 #[rstest]
1167 fn parse_ws_wallet_into_account_state() {
1168 let json = load_test_json("ws_account_wallet.json");
1169 let msg: crate::websocket::messages::BybitWsAccountWalletMsg =
1170 serde_json::from_str(&json).unwrap();
1171 let wallet = &msg.data[0];
1172 let account_id = AccountId::new("BYBIT-001");
1173 let ts_event = UnixNanos::new(1_700_034_722_104_000_000);
1174
1175 let state = parse_ws_account_state(wallet, account_id, ts_event, TS).unwrap();
1176
1177 assert_eq!(state.account_id, account_id);
1178 assert_eq!(state.account_type, AccountType::Margin);
1179 assert_eq!(state.balances.len(), 2);
1180 assert!(state.is_reported);
1181
1182 let btc_balance = &state.balances[0];
1184 assert_eq!(btc_balance.currency.code.as_str(), "BTC");
1185 assert!((btc_balance.total.as_f64() - 0.00102964).abs() < 1e-8);
1186 assert!((btc_balance.free.as_f64() - 0.00092964).abs() < 1e-8);
1187 assert!((btc_balance.locked.as_f64() - 0.0001).abs() < 1e-8);
1188
1189 let usdt_balance = &state.balances[1];
1191 assert_eq!(usdt_balance.currency.code.as_str(), "USDT");
1192 assert!((usdt_balance.total.as_f64() - 9647.75537647).abs() < 1e-6);
1193 assert!((usdt_balance.free.as_f64() - 9519.89806037).abs() < 1e-6);
1194 assert!((usdt_balance.locked.as_f64() - 127.8573161).abs() < 1e-6);
1195
1196 assert_eq!(state.ts_event, ts_event);
1197 assert_eq!(state.ts_init, TS);
1198 }
1199
1200 #[rstest]
1201 fn parse_ws_wallet_with_small_order_calculates_free_correctly() {
1202 let json = load_test_json("ws_account_wallet_small_order.json");
1206 let msg: crate::websocket::messages::BybitWsAccountWalletMsg =
1207 serde_json::from_str(&json).unwrap();
1208 let wallet = &msg.data[0];
1209 let account_id = AccountId::new("BYBIT-UNIFIED");
1210 let ts_event = UnixNanos::new(1_762_960_669_000_000_000);
1211
1212 let state = parse_ws_account_state(wallet, account_id, ts_event, TS).unwrap();
1213
1214 assert_eq!(state.account_id, account_id);
1215 assert_eq!(state.balances.len(), 1);
1216
1217 let usdt_balance = &state.balances[0];
1219 assert_eq!(usdt_balance.currency.code.as_str(), "USDT");
1220
1221 assert!((usdt_balance.total.as_f64() - 51333.82543837).abs() < 1e-6);
1223
1224 assert!((usdt_balance.locked.as_f64() - 50.028).abs() < 1e-6);
1226
1227 assert!((usdt_balance.free.as_f64() - 51283.79743837).abs() < 1e-6);
1229
1230 }
1233
1234 #[rstest]
1235 fn parse_ticker_linear_into_funding_rate() {
1236 let instrument = linear_instrument();
1237 let json = load_test_json("ws_ticker_linear.json");
1238 let msg: BybitWsTickerLinearMsg = serde_json::from_str(&json).unwrap();
1239
1240 let ts_event = UnixNanos::new(1_673_272_861_686_000_000);
1241
1242 let funding =
1243 parse_ticker_linear_funding(&msg.data, instrument.id(), ts_event, TS).unwrap();
1244
1245 assert_eq!(funding.instrument_id, instrument.id());
1246 assert_eq!(funding.rate, dec!(-0.000212)); assert_eq!(
1248 funding.next_funding_ns,
1249 Some(UnixNanos::new(1_673_280_000_000_000_000))
1250 );
1251 assert_eq!(funding.ts_event, ts_event);
1252 assert_eq!(funding.ts_init, TS);
1253 }
1254
1255 #[rstest]
1256 fn parse_ws_order_stop_market_sell_preserves_type() {
1257 let instrument = linear_instrument();
1258 let json = load_test_json("ws_account_order_stop_market.json");
1259 let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1260 serde_json::from_str(&json).unwrap();
1261 let order = &msg.data[0];
1262 let account_id = AccountId::new("BYBIT-001");
1263
1264 let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1265
1266 assert_eq!(report.order_type, OrderType::StopMarket);
1268 assert_eq!(report.order_side, OrderSide::Sell);
1269 assert_eq!(report.order_status, OrderStatus::Accepted); assert_eq!(report.trigger_price, Some(instrument.make_price(45000.00)));
1271 assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1272 assert_eq!(
1273 report.client_order_id.as_ref().unwrap().to_string(),
1274 "test-client-stop-market-001"
1275 );
1276 }
1277
1278 #[rstest]
1279 fn parse_ws_order_stop_market_buy_preserves_type() {
1280 let instrument = linear_instrument();
1281 let json = load_test_json("ws_account_order_buy_stop_market.json");
1282 let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1283 serde_json::from_str(&json).unwrap();
1284 let order = &msg.data[0];
1285 let account_id = AccountId::new("BYBIT-001");
1286
1287 let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1288
1289 assert_eq!(report.order_type, OrderType::StopMarket);
1291 assert_eq!(report.order_side, OrderSide::Buy);
1292 assert_eq!(report.order_status, OrderStatus::Accepted);
1293 assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
1294 assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1295 assert_eq!(
1296 report.client_order_id.as_ref().unwrap().to_string(),
1297 "test-client-buy-stop-market-001"
1298 );
1299 }
1300
1301 #[rstest]
1302 fn parse_ws_order_market_if_touched_buy_preserves_type() {
1303 let instrument = linear_instrument();
1304 let json = load_test_json("ws_account_order_market_if_touched.json");
1305 let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1306 serde_json::from_str(&json).unwrap();
1307 let order = &msg.data[0];
1308 let account_id = AccountId::new("BYBIT-001");
1309
1310 let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1311
1312 assert_eq!(report.order_type, OrderType::MarketIfTouched);
1314 assert_eq!(report.order_side, OrderSide::Buy);
1315 assert_eq!(report.order_status, OrderStatus::Accepted); assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
1317 assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1318 assert_eq!(
1319 report.client_order_id.as_ref().unwrap().to_string(),
1320 "test-client-mit-001"
1321 );
1322 }
1323
1324 #[rstest]
1325 fn parse_ws_order_market_if_touched_sell_preserves_type() {
1326 let instrument = linear_instrument();
1327 let json = load_test_json("ws_account_order_sell_market_if_touched.json");
1328 let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1329 serde_json::from_str(&json).unwrap();
1330 let order = &msg.data[0];
1331 let account_id = AccountId::new("BYBIT-001");
1332
1333 let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1334
1335 assert_eq!(report.order_type, OrderType::MarketIfTouched);
1337 assert_eq!(report.order_side, OrderSide::Sell);
1338 assert_eq!(report.order_status, OrderStatus::Accepted);
1339 assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
1340 assert_eq!(
1341 report.client_order_id.as_ref().unwrap().to_string(),
1342 "test-client-sell-mit-001"
1343 );
1344 }
1345
1346 #[rstest]
1347 fn parse_ws_order_stop_limit_preserves_type() {
1348 let instrument = linear_instrument();
1349 let json = load_test_json("ws_account_order_stop_limit.json");
1350 let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1351 serde_json::from_str(&json).unwrap();
1352 let order = &msg.data[0];
1353 let account_id = AccountId::new("BYBIT-001");
1354
1355 let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1356
1357 assert_eq!(report.order_type, OrderType::StopLimit);
1360 assert_eq!(report.order_side, OrderSide::Sell);
1361 assert_eq!(report.order_status, OrderStatus::Accepted); assert_eq!(report.price, Some(instrument.make_price(44500.00)));
1363 assert_eq!(report.trigger_price, Some(instrument.make_price(45000.00)));
1364 assert_eq!(
1365 report.client_order_id.as_ref().unwrap().to_string(),
1366 "test-client-stop-limit-001"
1367 );
1368 }
1369
1370 #[rstest]
1371 fn parse_ws_order_limit_if_touched_preserves_type() {
1372 let instrument = linear_instrument();
1373 let json = load_test_json("ws_account_order_limit_if_touched.json");
1374 let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1375 serde_json::from_str(&json).unwrap();
1376 let order = &msg.data[0];
1377 let account_id = AccountId::new("BYBIT-001");
1378
1379 let report = parse_ws_order_status_report(order, &instrument, account_id, TS).unwrap();
1380
1381 assert_eq!(report.order_type, OrderType::LimitIfTouched);
1384 assert_eq!(report.order_side, OrderSide::Buy);
1385 assert_eq!(report.order_status, OrderStatus::Accepted); assert_eq!(report.price, Some(instrument.make_price(55500.00)));
1387 assert_eq!(report.trigger_price, Some(instrument.make_price(55000.00)));
1388 assert_eq!(
1389 report.client_order_id.as_ref().unwrap().to_string(),
1390 "test-client-lit-001"
1391 );
1392 }
1393}