1use std::{convert::TryFrom, str::FromStr};
19
20use anyhow::Context;
21pub use nautilus_core::serialization::{
22 deserialize_decimal_or_zero, deserialize_optional_decimal_or_zero,
23 deserialize_optional_decimal_str, deserialize_string_to_u8,
24};
25use nautilus_core::{UUID4, datetime::NANOSECONDS_IN_MILLISECOND, nanos::UnixNanos};
26use nautilus_model::{
27 data::{
28 Bar, BarType, BookOrder, FundingRateUpdate, OrderBookDelta, OrderBookDeltas, TradeTick,
29 },
30 enums::{
31 AccountType, AggressorSide, BarAggregation, BookAction, LiquiditySide, OptionKind,
32 OrderSide, OrderStatus, OrderType, PositionSideSpecified, RecordFlag, TimeInForce,
33 TriggerType,
34 },
35 events::account::state::AccountState,
36 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, Venue, VenueOrderId},
37 instruments::{
38 Instrument, any::InstrumentAny, crypto_future::CryptoFuture, crypto_option::CryptoOption,
39 crypto_perpetual::CryptoPerpetual, currency_pair::CurrencyPair,
40 },
41 reports::{FillReport, OrderStatusReport, PositionStatusReport},
42 types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
43};
44use rust_decimal::Decimal;
45use ustr::Ustr;
46
47use crate::{
48 common::{
49 enums::{
50 BybitContractType, BybitKlineInterval, BybitOptionType, BybitOrderSide,
51 BybitOrderStatus, BybitOrderType, BybitPositionSide, BybitProductType,
52 BybitStopOrderType, BybitTimeInForce, BybitTriggerDirection,
53 },
54 symbol::BybitSymbol,
55 },
56 http::models::{
57 BybitExecution, BybitFeeRate, BybitFunding, BybitInstrumentInverse, BybitInstrumentLinear,
58 BybitInstrumentOption, BybitInstrumentSpot, BybitKline, BybitOrderbookResult,
59 BybitPosition, BybitTrade, BybitWalletBalance,
60 },
61 websocket::parse::parse_millis_i64,
62};
63
64const BYBIT_HOUR_INTERVALS: &[u64] = &[1, 2, 4, 6, 12];
65
66#[must_use]
68pub fn extract_raw_symbol(symbol: &str) -> &str {
69 symbol.rsplit_once('-').map_or(symbol, |(prefix, _)| prefix)
70}
71
72#[must_use]
76pub fn make_bybit_symbol<S: AsRef<str>>(raw_symbol: S, product_type: BybitProductType) -> Ustr {
77 let raw = raw_symbol.as_ref();
78 let suffix = match product_type {
79 BybitProductType::Spot => "-SPOT",
80 BybitProductType::Linear => "-LINEAR",
81 BybitProductType::Inverse => "-INVERSE",
82 BybitProductType::Option => "-OPTION",
83 };
84 Ustr::from(&format!("{raw}{suffix}"))
85}
86
87#[must_use]
91pub fn bybit_interval_to_bar_spec(interval: &str) -> Option<(usize, BarAggregation)> {
92 match interval {
93 "1" => Some((1, BarAggregation::Minute)),
94 "3" => Some((3, BarAggregation::Minute)),
95 "5" => Some((5, BarAggregation::Minute)),
96 "15" => Some((15, BarAggregation::Minute)),
97 "30" => Some((30, BarAggregation::Minute)),
98 "60" => Some((1, BarAggregation::Hour)),
99 "120" => Some((2, BarAggregation::Hour)),
100 "240" => Some((4, BarAggregation::Hour)),
101 "360" => Some((6, BarAggregation::Hour)),
102 "720" => Some((12, BarAggregation::Hour)),
103 "D" => Some((1, BarAggregation::Day)),
104 "W" => Some((1, BarAggregation::Week)),
105 "M" => Some((1, BarAggregation::Month)),
106 _ => None,
107 }
108}
109
110pub fn bar_spec_to_bybit_interval(
118 aggregation: BarAggregation,
119 step: u64,
120) -> anyhow::Result<BybitKlineInterval> {
121 match aggregation {
122 BarAggregation::Minute => match step {
123 1 => Ok(BybitKlineInterval::Minute1),
124 3 => Ok(BybitKlineInterval::Minute3),
125 5 => Ok(BybitKlineInterval::Minute5),
126 15 => Ok(BybitKlineInterval::Minute15),
127 30 => Ok(BybitKlineInterval::Minute30),
128 _ => anyhow::bail!(
129 "Bybit only supports minute intervals 1, 3, 5, 15, 30 (use HOUR for >= 60)"
130 ),
131 },
132 BarAggregation::Hour => match step {
133 1 => Ok(BybitKlineInterval::Hour1),
134 2 => Ok(BybitKlineInterval::Hour2),
135 4 => Ok(BybitKlineInterval::Hour4),
136 6 => Ok(BybitKlineInterval::Hour6),
137 12 => Ok(BybitKlineInterval::Hour12),
138 _ => anyhow::bail!(
139 "Bybit only supports the following hour intervals: {BYBIT_HOUR_INTERVALS:?}"
140 ),
141 },
142 BarAggregation::Day => {
143 if step != 1 {
144 anyhow::bail!("Bybit only supports 1 DAY interval bars");
145 }
146 Ok(BybitKlineInterval::Day1)
147 }
148 BarAggregation::Week => {
149 if step != 1 {
150 anyhow::bail!("Bybit only supports 1 WEEK interval bars");
151 }
152 Ok(BybitKlineInterval::Week1)
153 }
154 BarAggregation::Month => {
155 if step != 1 {
156 anyhow::bail!("Bybit only supports 1 MONTH interval bars");
157 }
158 Ok(BybitKlineInterval::Month1)
159 }
160 _ => {
161 anyhow::bail!("Bybit does not support {aggregation:?} bars");
162 }
163 }
164}
165
166fn default_margin() -> Decimal {
167 Decimal::new(1, 1)
168}
169
170pub fn parse_spot_instrument(
172 definition: &BybitInstrumentSpot,
173 fee_rate: &BybitFeeRate,
174 ts_event: UnixNanos,
175 ts_init: UnixNanos,
176) -> anyhow::Result<InstrumentAny> {
177 let base_currency = get_currency(definition.base_coin.as_str());
178 let quote_currency = get_currency(definition.quote_coin.as_str());
179
180 let symbol = BybitSymbol::new(format!("{}-SPOT", definition.symbol))?;
181 let instrument_id = symbol.to_instrument_id();
182 let raw_symbol = Symbol::new(symbol.raw_symbol());
183
184 let price_increment = parse_price(&definition.price_filter.tick_size, "priceFilter.tickSize")?;
185 let size_increment = parse_quantity(
186 &definition.lot_size_filter.base_precision,
187 "lotSizeFilter.basePrecision",
188 )?;
189 let lot_size = Some(size_increment);
190 let max_quantity = Some(parse_quantity(
191 &definition.lot_size_filter.max_order_qty,
192 "lotSizeFilter.maxOrderQty",
193 )?);
194 let min_quantity = Some(parse_quantity(
195 &definition.lot_size_filter.min_order_qty,
196 "lotSizeFilter.minOrderQty",
197 )?);
198
199 let maker_fee = parse_decimal(&fee_rate.maker_fee_rate, "makerFeeRate")?;
200 let taker_fee = parse_decimal(&fee_rate.taker_fee_rate, "takerFeeRate")?;
201
202 let instrument = CurrencyPair::new(
203 instrument_id,
204 raw_symbol,
205 base_currency,
206 quote_currency,
207 price_increment.precision,
208 size_increment.precision,
209 price_increment,
210 size_increment,
211 None,
212 lot_size,
213 max_quantity,
214 min_quantity,
215 None,
216 None,
217 None,
218 None,
219 Some(default_margin()),
220 Some(default_margin()),
221 Some(maker_fee),
222 Some(taker_fee),
223 ts_event,
224 ts_init,
225 );
226
227 Ok(InstrumentAny::CurrencyPair(instrument))
228}
229
230pub fn parse_linear_instrument(
232 definition: &BybitInstrumentLinear,
233 fee_rate: &BybitFeeRate,
234 ts_event: UnixNanos,
235 ts_init: UnixNanos,
236) -> anyhow::Result<InstrumentAny> {
237 anyhow::ensure!(
239 !definition.base_coin.is_empty(),
240 "base_coin is empty for symbol '{}'",
241 definition.symbol
242 );
243 anyhow::ensure!(
244 !definition.quote_coin.is_empty(),
245 "quote_coin is empty for symbol '{}'",
246 definition.symbol
247 );
248
249 let base_currency = get_currency(definition.base_coin.as_str());
250 let quote_currency = get_currency(definition.quote_coin.as_str());
251 let settlement_currency = resolve_settlement_currency(
252 definition.settle_coin.as_str(),
253 base_currency,
254 quote_currency,
255 )?;
256
257 let symbol = BybitSymbol::new(format!("{}-LINEAR", definition.symbol))?;
258 let instrument_id = symbol.to_instrument_id();
259 let raw_symbol = Symbol::new(symbol.raw_symbol());
260
261 let price_increment = parse_price(&definition.price_filter.tick_size, "priceFilter.tickSize")?;
262 let size_increment = parse_quantity(
263 &definition.lot_size_filter.qty_step,
264 "lotSizeFilter.qtyStep",
265 )?;
266 let lot_size = Some(size_increment);
267 let max_quantity = Some(parse_quantity(
268 &definition.lot_size_filter.max_order_qty,
269 "lotSizeFilter.maxOrderQty",
270 )?);
271 let min_quantity = Some(parse_quantity(
272 &definition.lot_size_filter.min_order_qty,
273 "lotSizeFilter.minOrderQty",
274 )?);
275 let max_price = Some(parse_price(
276 &definition.price_filter.max_price,
277 "priceFilter.maxPrice",
278 )?);
279 let min_price = Some(parse_price(
280 &definition.price_filter.min_price,
281 "priceFilter.minPrice",
282 )?);
283
284 let maker_fee = parse_decimal(&fee_rate.maker_fee_rate, "makerFeeRate")?;
285 let taker_fee = parse_decimal(&fee_rate.taker_fee_rate, "takerFeeRate")?;
286
287 match definition.contract_type {
288 BybitContractType::LinearPerpetual => {
289 let instrument = CryptoPerpetual::new(
290 instrument_id,
291 raw_symbol,
292 base_currency,
293 quote_currency,
294 settlement_currency,
295 false,
296 price_increment.precision,
297 size_increment.precision,
298 price_increment,
299 size_increment,
300 None,
301 lot_size,
302 max_quantity,
303 min_quantity,
304 None,
305 None,
306 max_price,
307 min_price,
308 Some(default_margin()),
309 Some(default_margin()),
310 Some(maker_fee),
311 Some(taker_fee),
312 ts_event,
313 ts_init,
314 );
315 Ok(InstrumentAny::CryptoPerpetual(instrument))
316 }
317 BybitContractType::LinearFutures => {
318 let activation_ns = parse_millis_timestamp(&definition.launch_time, "launchTime")?;
319 let expiration_ns = parse_millis_timestamp(&definition.delivery_time, "deliveryTime")?;
320 let instrument = CryptoFuture::new(
321 instrument_id,
322 raw_symbol,
323 base_currency,
324 quote_currency,
325 settlement_currency,
326 false,
327 activation_ns,
328 expiration_ns,
329 price_increment.precision,
330 size_increment.precision,
331 price_increment,
332 size_increment,
333 None,
334 lot_size,
335 max_quantity,
336 min_quantity,
337 None,
338 None,
339 max_price,
340 min_price,
341 Some(default_margin()),
342 Some(default_margin()),
343 Some(maker_fee),
344 Some(taker_fee),
345 ts_event,
346 ts_init,
347 );
348 Ok(InstrumentAny::CryptoFuture(instrument))
349 }
350 other => Err(anyhow::anyhow!(
351 "unsupported linear contract variant: {other:?}"
352 )),
353 }
354}
355
356pub fn parse_inverse_instrument(
358 definition: &BybitInstrumentInverse,
359 fee_rate: &BybitFeeRate,
360 ts_event: UnixNanos,
361 ts_init: UnixNanos,
362) -> anyhow::Result<InstrumentAny> {
363 anyhow::ensure!(
365 !definition.base_coin.is_empty(),
366 "base_coin is empty for symbol '{}'",
367 definition.symbol
368 );
369 anyhow::ensure!(
370 !definition.quote_coin.is_empty(),
371 "quote_coin is empty for symbol '{}'",
372 definition.symbol
373 );
374
375 let base_currency = get_currency(definition.base_coin.as_str());
376 let quote_currency = get_currency(definition.quote_coin.as_str());
377 let settlement_currency = resolve_settlement_currency(
378 definition.settle_coin.as_str(),
379 base_currency,
380 quote_currency,
381 )?;
382
383 let symbol = BybitSymbol::new(format!("{}-INVERSE", definition.symbol))?;
384 let instrument_id = symbol.to_instrument_id();
385 let raw_symbol = Symbol::new(symbol.raw_symbol());
386
387 let price_increment = parse_price(&definition.price_filter.tick_size, "priceFilter.tickSize")?;
388 let size_increment = parse_quantity(
389 &definition.lot_size_filter.qty_step,
390 "lotSizeFilter.qtyStep",
391 )?;
392 let lot_size = Some(size_increment);
393 let max_quantity = Some(parse_quantity(
394 &definition.lot_size_filter.max_order_qty,
395 "lotSizeFilter.maxOrderQty",
396 )?);
397 let min_quantity = Some(parse_quantity(
398 &definition.lot_size_filter.min_order_qty,
399 "lotSizeFilter.minOrderQty",
400 )?);
401 let max_price = Some(parse_price(
402 &definition.price_filter.max_price,
403 "priceFilter.maxPrice",
404 )?);
405 let min_price = Some(parse_price(
406 &definition.price_filter.min_price,
407 "priceFilter.minPrice",
408 )?);
409
410 let maker_fee = parse_decimal(&fee_rate.maker_fee_rate, "makerFeeRate")?;
411 let taker_fee = parse_decimal(&fee_rate.taker_fee_rate, "takerFeeRate")?;
412
413 match definition.contract_type {
414 BybitContractType::InversePerpetual => {
415 let instrument = CryptoPerpetual::new(
416 instrument_id,
417 raw_symbol,
418 base_currency,
419 quote_currency,
420 settlement_currency,
421 true,
422 price_increment.precision,
423 size_increment.precision,
424 price_increment,
425 size_increment,
426 None,
427 lot_size,
428 max_quantity,
429 min_quantity,
430 None,
431 None,
432 max_price,
433 min_price,
434 Some(default_margin()),
435 Some(default_margin()),
436 Some(maker_fee),
437 Some(taker_fee),
438 ts_event,
439 ts_init,
440 );
441 Ok(InstrumentAny::CryptoPerpetual(instrument))
442 }
443 BybitContractType::InverseFutures => {
444 let activation_ns = parse_millis_timestamp(&definition.launch_time, "launchTime")?;
445 let expiration_ns = parse_millis_timestamp(&definition.delivery_time, "deliveryTime")?;
446 let instrument = CryptoFuture::new(
447 instrument_id,
448 raw_symbol,
449 base_currency,
450 quote_currency,
451 settlement_currency,
452 true,
453 activation_ns,
454 expiration_ns,
455 price_increment.precision,
456 size_increment.precision,
457 price_increment,
458 size_increment,
459 None,
460 lot_size,
461 max_quantity,
462 min_quantity,
463 None,
464 None,
465 max_price,
466 min_price,
467 Some(default_margin()),
468 Some(default_margin()),
469 Some(maker_fee),
470 Some(taker_fee),
471 ts_event,
472 ts_init,
473 );
474 Ok(InstrumentAny::CryptoFuture(instrument))
475 }
476 other => Err(anyhow::anyhow!(
477 "unsupported inverse contract variant: {other:?}"
478 )),
479 }
480}
481
482pub fn parse_option_instrument(
484 definition: &BybitInstrumentOption,
485 ts_event: UnixNanos,
486 ts_init: UnixNanos,
487) -> anyhow::Result<InstrumentAny> {
488 let symbol = BybitSymbol::new(format!("{}-OPTION", definition.symbol))?;
489 let instrument_id = symbol.to_instrument_id();
490 let raw_symbol = Symbol::new(symbol.raw_symbol());
491 let underlying = get_currency(definition.base_coin.as_str());
492 let quote_currency = get_currency(definition.quote_coin.as_str());
493 let settlement_currency = get_currency(definition.settle_coin.as_str());
494 let is_inverse = false;
496
497 let price_increment = parse_price(&definition.price_filter.tick_size, "priceFilter.tickSize")?;
498 let max_price = Some(parse_price(
499 &definition.price_filter.max_price,
500 "priceFilter.maxPrice",
501 )?);
502 let min_price = Some(parse_price(
503 &definition.price_filter.min_price,
504 "priceFilter.minPrice",
505 )?);
506 let lot_size = parse_quantity(
507 &definition.lot_size_filter.qty_step,
508 "lotSizeFilter.qtyStep",
509 )?;
510 let max_quantity = Some(parse_quantity(
511 &definition.lot_size_filter.max_order_qty,
512 "lotSizeFilter.maxOrderQty",
513 )?);
514 let min_quantity = Some(parse_quantity(
515 &definition.lot_size_filter.min_order_qty,
516 "lotSizeFilter.minOrderQty",
517 )?);
518
519 let option_kind = match definition.options_type {
520 BybitOptionType::Call => OptionKind::Call,
521 BybitOptionType::Put => OptionKind::Put,
522 };
523
524 let strike_price = extract_strike_from_symbol(&definition.symbol)?;
525 let activation_ns = parse_millis_timestamp(&definition.launch_time, "launchTime")?;
526 let expiration_ns = parse_millis_timestamp(&definition.delivery_time, "deliveryTime")?;
527
528 let instrument = CryptoOption::new(
529 instrument_id,
530 raw_symbol,
531 underlying,
532 quote_currency,
533 settlement_currency,
534 is_inverse,
535 option_kind,
536 strike_price,
537 activation_ns,
538 expiration_ns,
539 price_increment.precision,
540 lot_size.precision,
541 price_increment,
542 lot_size, Some(Quantity::from(1_u32)), Some(lot_size),
545 max_quantity,
546 min_quantity,
547 None,
548 None,
549 max_price,
550 min_price,
551 Some(Decimal::ZERO),
552 Some(Decimal::ZERO),
553 Some(Decimal::ZERO),
554 Some(Decimal::ZERO),
555 ts_event,
556 ts_init,
557 );
558
559 Ok(InstrumentAny::CryptoOption(instrument))
560}
561
562pub fn parse_trade_tick(
564 trade: &BybitTrade,
565 instrument: &InstrumentAny,
566 ts_init: Option<UnixNanos>,
567) -> anyhow::Result<TradeTick> {
568 let price =
569 parse_price_with_precision(&trade.price, instrument.price_precision(), "trade.price")?;
570 let size =
571 parse_quantity_with_precision(&trade.size, instrument.size_precision(), "trade.size")?;
572 let aggressor: AggressorSide = trade.side.into();
573 let trade_id = TradeId::new_checked(trade.exec_id.as_str())
574 .context("invalid exec_id in Bybit trade payload")?;
575 let ts_event = parse_millis_timestamp(&trade.time, "trade.time")?;
576 let ts_init = ts_init.unwrap_or(ts_event);
577
578 TradeTick::new_checked(
579 instrument.id(),
580 price,
581 size,
582 aggressor,
583 trade_id,
584 ts_event,
585 ts_init,
586 )
587 .context("failed to construct TradeTick from Bybit trade payload")
588}
589
590pub fn parse_funding_rate(
592 funding: &BybitFunding,
593 instrument: &InstrumentAny,
594) -> anyhow::Result<FundingRateUpdate> {
595 let rate = parse_decimal(&funding.funding_rate, "funding.rate")?;
596 let ts_event = parse_millis_timestamp(&funding.funding_rate_timestamp, "funding.timestamp")?;
597
598 Ok(FundingRateUpdate::new(
599 instrument.id(),
600 rate,
601 None, ts_event,
603 ts_event,
604 ))
605}
606
607pub fn parse_orderbook(
609 result: &BybitOrderbookResult,
610 instrument: &InstrumentAny,
611 ts_init: Option<UnixNanos>,
612) -> anyhow::Result<OrderBookDeltas> {
613 let ts_event = parse_millis_i64(result.ts, "orderbook.timestamp")?;
614 let ts_init = ts_init.unwrap_or(ts_event);
615
616 let instrument_id = instrument.id();
617 let price_precision = instrument.price_precision();
618 let size_precision = instrument.size_precision();
619 let update_id = u64::try_from(result.u)
620 .context("received negative update id in Bybit order book message")?;
621 let sequence = u64::try_from(result.seq)
622 .context("received negative sequence in Bybit order book message")?;
623
624 let total_levels = result.b.len() + result.a.len();
625 let mut deltas = Vec::with_capacity(total_levels + 1);
626
627 let mut clear = OrderBookDelta::clear(instrument_id, sequence, ts_event, ts_init);
628 if total_levels == 0 {
629 clear.flags |= RecordFlag::F_LAST as u8;
630 }
631 deltas.push(clear);
632
633 let mut processed = 0_usize;
634
635 let mut push_level = |values: &[String], side: OrderSide| -> anyhow::Result<()> {
636 let (price, size) = parse_book_level(values, price_precision, size_precision, "orderbook")?;
637
638 processed += 1;
639 let mut flags = RecordFlag::F_MBP as u8;
640 if processed == total_levels {
641 flags |= RecordFlag::F_LAST as u8;
642 }
643
644 let order = BookOrder::new(side, price, size, update_id);
645 let delta = OrderBookDelta::new_checked(
646 instrument_id,
647 BookAction::Add,
648 order,
649 flags,
650 sequence,
651 ts_event,
652 ts_init,
653 )
654 .context("failed to construct OrderBookDelta from Bybit book level")?;
655 deltas.push(delta);
656 Ok(())
657 };
658
659 for level in &result.b {
660 push_level(level, OrderSide::Buy)?;
661 }
662 for level in &result.a {
663 push_level(level, OrderSide::Sell)?;
664 }
665
666 OrderBookDeltas::new_checked(instrument_id, deltas)
667 .context("failed to assemble OrderBookDeltas from Bybit message")
668}
669
670pub fn parse_book_level(
671 level: &[String],
672 price_precision: u8,
673 size_precision: u8,
674 label: &str,
675) -> anyhow::Result<(Price, Quantity)> {
676 let price_str = level
677 .first()
678 .ok_or_else(|| anyhow::anyhow!("missing price component in {label} level"))?;
679 let size_str = level
680 .get(1)
681 .ok_or_else(|| anyhow::anyhow!("missing size component in {label} level"))?;
682 let price = parse_price_with_precision(price_str, price_precision, label)?;
683 let size = parse_quantity_with_precision(size_str, size_precision, label)?;
684 Ok((price, size))
685}
686
687pub fn parse_kline_bar(
689 kline: &BybitKline,
690 instrument: &InstrumentAny,
691 bar_type: BarType,
692 timestamp_on_close: bool,
693 ts_init: Option<UnixNanos>,
694) -> anyhow::Result<Bar> {
695 let price_precision = instrument.price_precision();
696 let size_precision = instrument.size_precision();
697
698 let open = parse_price_with_precision(&kline.open, price_precision, "kline.open")?;
699 let high = parse_price_with_precision(&kline.high, price_precision, "kline.high")?;
700 let low = parse_price_with_precision(&kline.low, price_precision, "kline.low")?;
701 let close = parse_price_with_precision(&kline.close, price_precision, "kline.close")?;
702 let volume = parse_quantity_with_precision(&kline.volume, size_precision, "kline.volume")?;
703
704 let mut ts_event = parse_millis_timestamp(&kline.start, "kline.start")?;
705 if timestamp_on_close {
706 let interval_ns = bar_type
707 .spec()
708 .timedelta()
709 .num_nanoseconds()
710 .context("bar specification produced non-integer interval")?;
711 let interval_ns = u64::try_from(interval_ns)
712 .context("bar interval overflowed the u64 range for nanoseconds")?;
713 let updated = ts_event
714 .as_u64()
715 .checked_add(interval_ns)
716 .context("bar timestamp overflowed when adjusting to close time")?;
717 ts_event = UnixNanos::from(updated);
718 }
719 let ts_init = ts_init.unwrap_or(ts_event);
720
721 Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
722 .context("failed to construct Bar from Bybit kline entry")
723}
724
725pub fn parse_fill_report(
734 execution: &BybitExecution,
735 account_id: AccountId,
736 instrument: &InstrumentAny,
737 ts_init: UnixNanos,
738) -> anyhow::Result<FillReport> {
739 let instrument_id = instrument.id();
740 let venue_order_id = VenueOrderId::new(execution.order_id.as_str());
741 let trade_id = TradeId::new_checked(execution.exec_id.as_str())
742 .context("invalid execId in Bybit execution payload")?;
743
744 let order_side: OrderSide = execution.side.into();
745
746 let last_px = parse_price_with_precision(
747 &execution.exec_price,
748 instrument.price_precision(),
749 "execution.execPrice",
750 )?;
751
752 let last_qty = parse_quantity_with_precision(
753 &execution.exec_qty,
754 instrument.size_precision(),
755 "execution.execQty",
756 )?;
757
758 let fee_decimal: Decimal = execution
759 .exec_fee
760 .parse()
761 .with_context(|| format!("Failed to parse execFee='{}'", execution.exec_fee))?;
762 let currency = get_currency(&execution.fee_currency);
763 let commission = Money::from_decimal(fee_decimal, currency).with_context(|| {
764 format!(
765 "Failed to create commission from execFee='{}'",
766 execution.exec_fee
767 )
768 })?;
769
770 let liquidity_side = if execution.is_maker {
772 LiquiditySide::Maker
773 } else {
774 LiquiditySide::Taker
775 };
776
777 let ts_event = parse_millis_timestamp(&execution.exec_time, "execution.execTime")?;
778
779 let client_order_id = if execution.order_link_id.is_empty() {
781 None
782 } else {
783 Some(ClientOrderId::new(execution.order_link_id.as_str()))
784 };
785
786 Ok(FillReport::new(
787 account_id,
788 instrument_id,
789 venue_order_id,
790 trade_id,
791 order_side,
792 last_qty,
793 last_px,
794 commission,
795 liquidity_side,
796 client_order_id,
797 None, ts_event,
799 ts_init,
800 None, ))
802}
803
804pub fn parse_position_status_report(
813 position: &BybitPosition,
814 account_id: AccountId,
815 instrument: &InstrumentAny,
816 ts_init: UnixNanos,
817) -> anyhow::Result<PositionStatusReport> {
818 let instrument_id = instrument.id();
819
820 let size_f64 = position
822 .size
823 .parse::<f64>()
824 .with_context(|| format!("Failed to parse position size '{}'", position.size))?;
825
826 let (position_side, quantity) = match position.side {
828 BybitPositionSide::Buy => {
829 let qty = Quantity::new(size_f64, instrument.size_precision());
830 (PositionSideSpecified::Long, qty)
831 }
832 BybitPositionSide::Sell => {
833 let qty = Quantity::new(size_f64, instrument.size_precision());
834 (PositionSideSpecified::Short, qty)
835 }
836 BybitPositionSide::Flat => {
837 let qty = Quantity::new(0.0, instrument.size_precision());
838 (PositionSideSpecified::Flat, qty)
839 }
840 };
841
842 let avg_px_open = if position.avg_price.is_empty() || position.avg_price == "0" {
844 None
845 } else {
846 Some(Decimal::from_str(&position.avg_price)?)
847 };
848
849 let ts_last = if position.updated_time.is_empty() {
851 ts_init
852 } else {
853 parse_millis_timestamp(&position.updated_time, "position.updatedTime")?
854 };
855
856 Ok(PositionStatusReport::new(
857 account_id,
858 instrument_id,
859 position_side,
860 quantity,
861 ts_last,
862 ts_init,
863 None, None, avg_px_open,
866 ))
867}
868
869pub fn parse_account_state(
877 wallet_balance: &BybitWalletBalance,
878 account_id: AccountId,
879 ts_init: UnixNanos,
880) -> anyhow::Result<AccountState> {
881 let mut balances = Vec::new();
882
883 for coin in &wallet_balance.coin {
884 let total_dec = coin.wallet_balance - coin.spot_borrow;
885 let locked_dec = coin.locked;
886
887 let currency = get_currency(&coin.coin);
888 let total = Money::from_decimal(total_dec, currency)?;
889 let locked = Money::from_decimal(locked_dec, currency)?;
890 let free = Money::from_raw(total.raw - locked.raw, currency);
891
892 balances.push(AccountBalance::new(total, locked, free));
893 }
894
895 let mut margins = Vec::new();
896
897 for coin in &wallet_balance.coin {
898 let initial_margin_f64 = match &coin.total_position_im {
899 Some(im) if !im.is_empty() => im.parse::<f64>()?,
900 _ => 0.0,
901 };
902
903 let maintenance_margin_f64 = match &coin.total_position_mm {
904 Some(mm) if !mm.is_empty() => mm.parse::<f64>()?,
905 _ => 0.0,
906 };
907
908 let currency = get_currency(&coin.coin);
909
910 if initial_margin_f64 > 0.0 || maintenance_margin_f64 > 0.0 {
912 let initial_margin = Money::new(initial_margin_f64, currency);
913 let maintenance_margin = Money::new(maintenance_margin_f64, currency);
914
915 let margin_instrument_id = InstrumentId::new(
917 Symbol::from_str_unchecked(format!("ACCOUNT-{}", coin.coin)),
918 Venue::new("BYBIT"),
919 );
920
921 margins.push(MarginBalance::new(
922 initial_margin,
923 maintenance_margin,
924 margin_instrument_id,
925 ));
926 }
927 }
928
929 let account_type = AccountType::Margin;
930 let is_reported = true;
931 let event_id = UUID4::new();
932
933 let ts_event = ts_init;
935
936 Ok(AccountState::new(
937 account_id,
938 account_type,
939 balances,
940 margins,
941 is_reported,
942 event_id,
943 ts_event,
944 ts_init,
945 None,
946 ))
947}
948
949pub(crate) fn parse_price_with_precision(
950 value: &str,
951 precision: u8,
952 field: &str,
953) -> anyhow::Result<Price> {
954 let parsed = value
955 .parse::<f64>()
956 .with_context(|| format!("Failed to parse {field}='{value}' as f64"))?;
957 Price::new_checked(parsed, precision).with_context(|| {
958 format!("Failed to construct Price for {field} with precision {precision}")
959 })
960}
961
962pub(crate) fn parse_quantity_with_precision(
963 value: &str,
964 precision: u8,
965 field: &str,
966) -> anyhow::Result<Quantity> {
967 let parsed = value
968 .parse::<f64>()
969 .with_context(|| format!("Failed to parse {field}='{value}' as f64"))?;
970 Quantity::new_checked(parsed, precision).with_context(|| {
971 format!("Failed to construct Quantity for {field} with precision {precision}")
972 })
973}
974
975pub(crate) fn parse_price(value: &str, field: &str) -> anyhow::Result<Price> {
976 Price::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
977}
978
979pub(crate) fn parse_quantity(value: &str, field: &str) -> anyhow::Result<Quantity> {
980 Quantity::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
981}
982
983pub(crate) fn parse_decimal(value: &str, field: &str) -> anyhow::Result<Decimal> {
984 Decimal::from_str(value)
985 .map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}' as Decimal: {e}"))
986}
987
988pub(crate) fn parse_millis_timestamp(value: &str, field: &str) -> anyhow::Result<UnixNanos> {
989 let millis: u64 = value
990 .parse()
991 .with_context(|| format!("Failed to parse {field}='{value}' as u64 millis"))?;
992 let nanos = millis
993 .checked_mul(NANOSECONDS_IN_MILLISECOND)
994 .context("millisecond timestamp overflowed when converting to nanoseconds")?;
995 Ok(UnixNanos::from(nanos))
996}
997
998fn resolve_settlement_currency(
999 settle_coin: &str,
1000 base_currency: Currency,
1001 quote_currency: Currency,
1002) -> anyhow::Result<Currency> {
1003 if settle_coin.eq_ignore_ascii_case(base_currency.code.as_str()) {
1004 Ok(base_currency)
1005 } else if settle_coin.eq_ignore_ascii_case(quote_currency.code.as_str()) {
1006 Ok(quote_currency)
1007 } else {
1008 Err(anyhow::anyhow!(
1009 "unrecognised settlement currency '{settle_coin}'"
1010 ))
1011 }
1012}
1013
1014pub fn get_currency(code: &str) -> Currency {
1019 Currency::get_or_create_crypto(code)
1020}
1021
1022fn extract_strike_from_symbol(symbol: &str) -> anyhow::Result<Price> {
1023 let parts: Vec<&str> = symbol.split('-').collect();
1024 let strike = parts
1025 .get(2)
1026 .ok_or_else(|| anyhow::anyhow!("invalid option symbol '{symbol}'"))?;
1027 parse_price(strike, "option strike")
1028}
1029
1030pub fn parse_order_status_report(
1032 order: &crate::http::models::BybitOrder,
1033 instrument: &InstrumentAny,
1034 account_id: AccountId,
1035 ts_init: UnixNanos,
1036) -> anyhow::Result<OrderStatusReport> {
1037 let instrument_id = instrument.id();
1038 let venue_order_id = VenueOrderId::new(order.order_id);
1039
1040 let order_side: OrderSide = order.side.into();
1041
1042 let order_type: OrderType = match (
1044 order.order_type,
1045 order.stop_order_type,
1046 order.trigger_direction,
1047 order.side,
1048 ) {
1049 (BybitOrderType::Market, BybitStopOrderType::None | BybitStopOrderType::Unknown, _, _) => {
1050 OrderType::Market
1051 }
1052 (BybitOrderType::Limit, BybitStopOrderType::None | BybitStopOrderType::Unknown, _, _) => {
1053 OrderType::Limit
1054 }
1055
1056 (
1057 BybitOrderType::Market,
1058 BybitStopOrderType::Stop,
1059 BybitTriggerDirection::RisesTo,
1060 BybitOrderSide::Buy,
1061 ) => OrderType::StopMarket,
1062 (
1063 BybitOrderType::Market,
1064 BybitStopOrderType::Stop,
1065 BybitTriggerDirection::FallsTo,
1066 BybitOrderSide::Buy,
1067 ) => OrderType::MarketIfTouched,
1068
1069 (
1070 BybitOrderType::Market,
1071 BybitStopOrderType::Stop,
1072 BybitTriggerDirection::FallsTo,
1073 BybitOrderSide::Sell,
1074 ) => OrderType::StopMarket,
1075 (
1076 BybitOrderType::Market,
1077 BybitStopOrderType::Stop,
1078 BybitTriggerDirection::RisesTo,
1079 BybitOrderSide::Sell,
1080 ) => OrderType::MarketIfTouched,
1081
1082 (
1083 BybitOrderType::Limit,
1084 BybitStopOrderType::Stop,
1085 BybitTriggerDirection::RisesTo,
1086 BybitOrderSide::Buy,
1087 ) => OrderType::StopLimit,
1088 (
1089 BybitOrderType::Limit,
1090 BybitStopOrderType::Stop,
1091 BybitTriggerDirection::FallsTo,
1092 BybitOrderSide::Buy,
1093 ) => OrderType::LimitIfTouched,
1094
1095 (
1096 BybitOrderType::Limit,
1097 BybitStopOrderType::Stop,
1098 BybitTriggerDirection::FallsTo,
1099 BybitOrderSide::Sell,
1100 ) => OrderType::StopLimit,
1101 (
1102 BybitOrderType::Limit,
1103 BybitStopOrderType::Stop,
1104 BybitTriggerDirection::RisesTo,
1105 BybitOrderSide::Sell,
1106 ) => OrderType::LimitIfTouched,
1107
1108 (BybitOrderType::Market, BybitStopOrderType::Stop, BybitTriggerDirection::None, _) => {
1110 OrderType::Market
1111 }
1112 (BybitOrderType::Limit, BybitStopOrderType::Stop, BybitTriggerDirection::None, _) => {
1113 OrderType::Limit
1114 }
1115
1116 (BybitOrderType::Market, _, _, _) => OrderType::Market,
1118 (BybitOrderType::Limit, _, _, _) => OrderType::Limit,
1119
1120 (BybitOrderType::Unknown, _, _, _) => OrderType::Limit,
1121 };
1122
1123 let time_in_force: TimeInForce = match order.time_in_force {
1124 BybitTimeInForce::Gtc => TimeInForce::Gtc,
1125 BybitTimeInForce::Ioc => TimeInForce::Ioc,
1126 BybitTimeInForce::Fok => TimeInForce::Fok,
1127 BybitTimeInForce::PostOnly => TimeInForce::Gtc,
1128 };
1129
1130 let quantity =
1131 parse_quantity_with_precision(&order.qty, instrument.size_precision(), "order.qty")?;
1132
1133 let filled_qty = parse_quantity_with_precision(
1134 &order.cum_exec_qty,
1135 instrument.size_precision(),
1136 "order.cumExecQty",
1137 )?;
1138
1139 let order_status: OrderStatus = match order.order_status {
1145 BybitOrderStatus::Created | BybitOrderStatus::New | BybitOrderStatus::Untriggered => {
1146 OrderStatus::Accepted
1147 }
1148 BybitOrderStatus::Rejected => {
1149 if filled_qty.is_positive() {
1150 OrderStatus::Canceled
1151 } else {
1152 OrderStatus::Rejected
1153 }
1154 }
1155 BybitOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
1156 BybitOrderStatus::Filled => OrderStatus::Filled,
1157 BybitOrderStatus::Canceled | BybitOrderStatus::PartiallyFilledCanceled => {
1158 OrderStatus::Canceled
1159 }
1160 BybitOrderStatus::Triggered => OrderStatus::Triggered,
1161 BybitOrderStatus::Deactivated => OrderStatus::Canceled,
1162 };
1163
1164 let ts_accepted = parse_millis_timestamp(&order.created_time, "order.createdTime")?;
1165 let ts_last = parse_millis_timestamp(&order.updated_time, "order.updatedTime")?;
1166
1167 let mut report = OrderStatusReport::new(
1168 account_id,
1169 instrument_id,
1170 None,
1171 venue_order_id,
1172 order_side,
1173 order_type,
1174 time_in_force,
1175 order_status,
1176 quantity,
1177 filled_qty,
1178 ts_accepted,
1179 ts_last,
1180 ts_init,
1181 Some(UUID4::new()),
1182 );
1183
1184 if !order.order_link_id.is_empty() {
1185 report = report.with_client_order_id(ClientOrderId::new(order.order_link_id.as_str()));
1186 }
1187
1188 if !order.price.is_empty() && order.price != "0" {
1189 let price =
1190 parse_price_with_precision(&order.price, instrument.price_precision(), "order.price")?;
1191 report = report.with_price(price);
1192 }
1193
1194 if let Some(avg_price) = &order.avg_price
1195 && !avg_price.is_empty()
1196 && avg_price != "0"
1197 {
1198 let avg_px = avg_price
1199 .parse::<f64>()
1200 .with_context(|| format!("Failed to parse avg_price='{avg_price}' as f64"))?;
1201 report = report.with_avg_px(avg_px)?;
1202 }
1203
1204 if !order.trigger_price.is_empty() && order.trigger_price != "0" {
1205 let trigger_price = parse_price_with_precision(
1206 &order.trigger_price,
1207 instrument.price_precision(),
1208 "order.triggerPrice",
1209 )?;
1210 report = report.with_trigger_price(trigger_price);
1211
1212 let trigger_type: TriggerType = order.trigger_by.into();
1214 report = report.with_trigger_type(trigger_type);
1215 }
1216
1217 Ok(report)
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222 use nautilus_model::{
1223 data::BarSpecification,
1224 enums::{AggregationSource, BarAggregation, PositionSide, PriceType},
1225 };
1226 use rstest::rstest;
1227
1228 use super::*;
1229 use crate::{
1230 common::testing::load_test_json,
1231 http::models::{
1232 BybitInstrumentInverseResponse, BybitInstrumentLinearResponse,
1233 BybitInstrumentOptionResponse, BybitInstrumentSpotResponse, BybitKlinesResponse,
1234 BybitTradesResponse,
1235 },
1236 };
1237
1238 const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
1239
1240 fn sample_fee_rate(
1241 symbol: &str,
1242 taker: &str,
1243 maker: &str,
1244 base_coin: Option<&str>,
1245 ) -> BybitFeeRate {
1246 BybitFeeRate {
1247 symbol: Ustr::from(symbol),
1248 taker_fee_rate: taker.to_string(),
1249 maker_fee_rate: maker.to_string(),
1250 base_coin: base_coin.map(Ustr::from),
1251 }
1252 }
1253
1254 fn linear_instrument() -> InstrumentAny {
1255 let json = load_test_json("http_get_instruments_linear.json");
1256 let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
1257 let instrument = &response.result.list[0];
1258 let fee_rate = sample_fee_rate("BTCUSDT", "0.00055", "0.0001", Some("BTC"));
1259 parse_linear_instrument(instrument, &fee_rate, TS, TS).unwrap()
1260 }
1261
1262 #[rstest]
1263 fn parse_spot_instrument_builds_currency_pair() {
1264 let json = load_test_json("http_get_instruments_spot.json");
1265 let response: BybitInstrumentSpotResponse = serde_json::from_str(&json).unwrap();
1266 let instrument = &response.result.list[0];
1267 let fee_rate = sample_fee_rate("BTCUSDT", "0.0006", "0.0001", Some("BTC"));
1268
1269 let parsed = parse_spot_instrument(instrument, &fee_rate, TS, TS).unwrap();
1270 match parsed {
1271 InstrumentAny::CurrencyPair(pair) => {
1272 assert_eq!(pair.id.to_string(), "BTCUSDT-SPOT.BYBIT");
1273 assert_eq!(pair.price_increment, Price::from_str("0.1").unwrap());
1274 assert_eq!(pair.size_increment, Quantity::from_str("0.0001").unwrap());
1275 assert_eq!(pair.base_currency.code.as_str(), "BTC");
1276 assert_eq!(pair.quote_currency.code.as_str(), "USDT");
1277 }
1278 _ => panic!("expected CurrencyPair"),
1279 }
1280 }
1281
1282 #[rstest]
1283 fn parse_linear_perpetual_instrument_builds_crypto_perpetual() {
1284 let json = load_test_json("http_get_instruments_linear.json");
1285 let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
1286 let instrument = &response.result.list[0];
1287 let fee_rate = sample_fee_rate("BTCUSDT", "0.00055", "0.0001", Some("BTC"));
1288
1289 let parsed = parse_linear_instrument(instrument, &fee_rate, TS, TS).unwrap();
1290 match parsed {
1291 InstrumentAny::CryptoPerpetual(perp) => {
1292 assert_eq!(perp.id.to_string(), "BTCUSDT-LINEAR.BYBIT");
1293 assert!(!perp.is_inverse);
1294 assert_eq!(perp.price_increment, Price::from_str("0.5").unwrap());
1295 assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
1296 }
1297 other => panic!("unexpected instrument variant: {other:?}"),
1298 }
1299 }
1300
1301 #[rstest]
1302 fn parse_inverse_perpetual_instrument_builds_inverse_perpetual() {
1303 let json = load_test_json("http_get_instruments_inverse.json");
1304 let response: BybitInstrumentInverseResponse = serde_json::from_str(&json).unwrap();
1305 let instrument = &response.result.list[0];
1306 let fee_rate = sample_fee_rate("BTCUSD", "0.00075", "0.00025", Some("BTC"));
1307
1308 let parsed = parse_inverse_instrument(instrument, &fee_rate, TS, TS).unwrap();
1309 match parsed {
1310 InstrumentAny::CryptoPerpetual(perp) => {
1311 assert_eq!(perp.id.to_string(), "BTCUSD-INVERSE.BYBIT");
1312 assert!(perp.is_inverse);
1313 assert_eq!(perp.price_increment, Price::from_str("0.5").unwrap());
1314 assert_eq!(perp.size_increment, Quantity::from_str("1").unwrap());
1315 }
1316 other => panic!("unexpected instrument variant: {other:?}"),
1317 }
1318 }
1319
1320 #[rstest]
1321 fn parse_option_instrument_builds_crypto_option() {
1322 let json = load_test_json("http_get_instruments_option.json");
1323 let response: BybitInstrumentOptionResponse = serde_json::from_str(&json).unwrap();
1324 let instrument = &response.result.list[0];
1325
1326 let parsed = parse_option_instrument(instrument, TS, TS).unwrap();
1327 match parsed {
1328 InstrumentAny::CryptoOption(option) => {
1329 assert_eq!(option.id.to_string(), "ETH-26JUN26-16000-P-OPTION.BYBIT");
1330 assert_eq!(option.underlying.code.as_str(), "ETH");
1331 assert_eq!(option.quote_currency.code.as_str(), "USDC");
1332 assert_eq!(option.settlement_currency.code.as_str(), "USDC");
1333 assert!(!option.is_inverse);
1334 assert_eq!(option.option_kind, OptionKind::Put);
1335 assert_eq!(option.price_precision, 1);
1336 assert_eq!(option.price_increment, Price::from_str("0.1").unwrap());
1337 assert_eq!(option.size_precision, 0);
1338 assert_eq!(option.size_increment, Quantity::from_str("1").unwrap());
1339 assert_eq!(option.lot_size, Quantity::from_str("1").unwrap());
1340 }
1341 other => panic!("unexpected instrument variant: {other:?}"),
1342 }
1343 }
1344
1345 #[rstest]
1346 fn parse_http_trade_into_trade_tick() {
1347 let instrument = linear_instrument();
1348 let json = load_test_json("http_get_trades_recent.json");
1349 let response: BybitTradesResponse = serde_json::from_str(&json).unwrap();
1350 let trade = &response.result.list[0];
1351
1352 let tick = parse_trade_tick(trade, &instrument, Some(TS)).unwrap();
1353
1354 assert_eq!(tick.instrument_id, instrument.id());
1355 assert_eq!(tick.price, instrument.make_price(27450.50));
1356 assert_eq!(tick.size, instrument.make_qty(0.005, None));
1357 assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
1358 assert_eq!(
1359 tick.trade_id.to_string(),
1360 "a905d5c3-1ed0-4f37-83e4-9c73a2fe2f01"
1361 );
1362 assert_eq!(tick.ts_event, UnixNanos::new(1_709_891_679_000_000_000));
1363 }
1364
1365 #[rstest]
1366 fn parse_kline_into_bar() {
1367 let instrument = linear_instrument();
1368 let json = load_test_json("http_get_klines_linear.json");
1369 let response: BybitKlinesResponse = serde_json::from_str(&json).unwrap();
1370 let kline = &response.result.list[0];
1371
1372 let bar_type = BarType::new(
1373 instrument.id(),
1374 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
1375 AggregationSource::External,
1376 );
1377
1378 let bar = parse_kline_bar(kline, &instrument, bar_type, false, Some(TS)).unwrap();
1379
1380 assert_eq!(bar.bar_type.to_string(), bar_type.to_string());
1381 assert_eq!(bar.open, instrument.make_price(27450.0));
1382 assert_eq!(bar.high, instrument.make_price(27460.0));
1383 assert_eq!(bar.low, instrument.make_price(27440.0));
1384 assert_eq!(bar.close, instrument.make_price(27455.0));
1385 assert_eq!(bar.volume, instrument.make_qty(123.45, None));
1386 assert_eq!(bar.ts_event, UnixNanos::new(1_709_891_679_000_000_000));
1387 }
1388
1389 #[rstest]
1390 fn parse_http_position_short_into_position_status_report() {
1391 use crate::http::models::BybitPositionListResponse;
1392
1393 let json = load_test_json("http_get_positions.json");
1394 let response: BybitPositionListResponse = serde_json::from_str(&json).unwrap();
1395
1396 let short_position = &response.result.list[1];
1398 assert_eq!(short_position.symbol.as_str(), "ETHUSDT");
1399 assert_eq!(short_position.side, BybitPositionSide::Sell);
1400
1401 let eth_json = load_test_json("http_get_instruments_linear.json");
1403 let eth_response: BybitInstrumentLinearResponse = serde_json::from_str(ð_json).unwrap();
1404 let eth_def = ð_response.result.list[1]; let fee_rate = sample_fee_rate("ETHUSDT", "0.00055", "0.0001", Some("ETH"));
1406 let eth_instrument = parse_linear_instrument(eth_def, &fee_rate, TS, TS).unwrap();
1407
1408 let account_id = AccountId::new("BYBIT-001");
1409 let report =
1410 parse_position_status_report(short_position, account_id, ð_instrument, TS).unwrap();
1411
1412 assert_eq!(report.account_id, account_id);
1414 assert_eq!(report.instrument_id.symbol.as_str(), "ETHUSDT-LINEAR");
1415 assert_eq!(report.position_side.as_position_side(), PositionSide::Short);
1416 assert_eq!(report.quantity, eth_instrument.make_qty(5.0, None));
1417 assert_eq!(
1418 report.avg_px_open,
1419 Some(Decimal::try_from(3000.00).unwrap())
1420 );
1421 assert_eq!(report.ts_last, UnixNanos::new(1_697_673_700_112_000_000));
1422 }
1423
1424 #[rstest]
1425 fn parse_http_order_partially_filled_rejected_maps_to_canceled() {
1426 use crate::http::models::BybitOrderHistoryResponse;
1427
1428 let instrument = linear_instrument();
1429 let json = load_test_json("http_get_order_partially_filled_rejected.json");
1430 let response: BybitOrderHistoryResponse = serde_json::from_str(&json).unwrap();
1431 let order = &response.result.list[0];
1432 let account_id = AccountId::new("BYBIT-001");
1433
1434 let report = parse_order_status_report(order, &instrument, account_id, TS).unwrap();
1435
1436 assert_eq!(report.order_status, OrderStatus::Canceled);
1438 assert_eq!(report.filled_qty, instrument.make_qty(0.005, None));
1439 assert_eq!(
1440 report.client_order_id.as_ref().unwrap().to_string(),
1441 "O-20251001-164609-APEX-000-49"
1442 );
1443 }
1444
1445 #[rstest]
1446 #[case(BarAggregation::Minute, 1, BybitKlineInterval::Minute1)]
1447 #[case(BarAggregation::Minute, 3, BybitKlineInterval::Minute3)]
1448 #[case(BarAggregation::Minute, 5, BybitKlineInterval::Minute5)]
1449 #[case(BarAggregation::Minute, 15, BybitKlineInterval::Minute15)]
1450 #[case(BarAggregation::Minute, 30, BybitKlineInterval::Minute30)]
1451 fn test_bar_spec_to_bybit_interval_minutes(
1452 #[case] aggregation: BarAggregation,
1453 #[case] step: u64,
1454 #[case] expected: BybitKlineInterval,
1455 ) {
1456 let result = bar_spec_to_bybit_interval(aggregation, step).unwrap();
1457 assert_eq!(result, expected);
1458 }
1459
1460 #[rstest]
1461 #[case(BarAggregation::Hour, 1, BybitKlineInterval::Hour1)]
1462 #[case(BarAggregation::Hour, 2, BybitKlineInterval::Hour2)]
1463 #[case(BarAggregation::Hour, 4, BybitKlineInterval::Hour4)]
1464 #[case(BarAggregation::Hour, 6, BybitKlineInterval::Hour6)]
1465 #[case(BarAggregation::Hour, 12, BybitKlineInterval::Hour12)]
1466 fn test_bar_spec_to_bybit_interval_hours(
1467 #[case] aggregation: BarAggregation,
1468 #[case] step: u64,
1469 #[case] expected: BybitKlineInterval,
1470 ) {
1471 let result = bar_spec_to_bybit_interval(aggregation, step).unwrap();
1472 assert_eq!(result, expected);
1473 }
1474
1475 #[rstest]
1476 #[case(BarAggregation::Day, 1, BybitKlineInterval::Day1)]
1477 #[case(BarAggregation::Week, 1, BybitKlineInterval::Week1)]
1478 #[case(BarAggregation::Month, 1, BybitKlineInterval::Month1)]
1479 fn test_bar_spec_to_bybit_interval_day_week_month(
1480 #[case] aggregation: BarAggregation,
1481 #[case] step: u64,
1482 #[case] expected: BybitKlineInterval,
1483 ) {
1484 let result = bar_spec_to_bybit_interval(aggregation, step).unwrap();
1485 assert_eq!(result, expected);
1486 }
1487
1488 #[rstest]
1489 #[case(BarAggregation::Minute, 2)]
1490 #[case(BarAggregation::Minute, 10)]
1491 #[case(BarAggregation::Hour, 3)]
1492 #[case(BarAggregation::Hour, 24)]
1493 #[case(BarAggregation::Day, 2)]
1494 #[case(BarAggregation::Week, 2)]
1495 #[case(BarAggregation::Month, 2)]
1496 fn test_bar_spec_to_bybit_interval_unsupported_steps(
1497 #[case] aggregation: BarAggregation,
1498 #[case] step: u64,
1499 ) {
1500 let result = bar_spec_to_bybit_interval(aggregation, step);
1501 assert!(result.is_err());
1502 }
1503
1504 #[rstest]
1505 fn test_bar_spec_to_bybit_interval_unsupported_aggregation() {
1506 let result = bar_spec_to_bybit_interval(BarAggregation::Second, 1);
1507 assert!(result.is_err());
1508 }
1509
1510 #[rstest]
1511 #[case("1", 1, BarAggregation::Minute)]
1512 #[case("3", 3, BarAggregation::Minute)]
1513 #[case("5", 5, BarAggregation::Minute)]
1514 #[case("15", 15, BarAggregation::Minute)]
1515 #[case("30", 30, BarAggregation::Minute)]
1516 fn test_bybit_interval_to_bar_spec_minutes(
1517 #[case] interval: &str,
1518 #[case] expected_step: usize,
1519 #[case] expected_aggregation: BarAggregation,
1520 ) {
1521 let result = bybit_interval_to_bar_spec(interval).unwrap();
1522 assert_eq!(result, (expected_step, expected_aggregation));
1523 }
1524
1525 #[rstest]
1526 #[case("60", 1, BarAggregation::Hour)]
1527 #[case("120", 2, BarAggregation::Hour)]
1528 #[case("240", 4, BarAggregation::Hour)]
1529 #[case("360", 6, BarAggregation::Hour)]
1530 #[case("720", 12, BarAggregation::Hour)]
1531 fn test_bybit_interval_to_bar_spec_hours(
1532 #[case] interval: &str,
1533 #[case] expected_step: usize,
1534 #[case] expected_aggregation: BarAggregation,
1535 ) {
1536 let result = bybit_interval_to_bar_spec(interval).unwrap();
1537 assert_eq!(result, (expected_step, expected_aggregation));
1538 }
1539
1540 #[rstest]
1541 #[case("D", 1, BarAggregation::Day)]
1542 #[case("W", 1, BarAggregation::Week)]
1543 #[case("M", 1, BarAggregation::Month)]
1544 fn test_bybit_interval_to_bar_spec_day_week_month(
1545 #[case] interval: &str,
1546 #[case] expected_step: usize,
1547 #[case] expected_aggregation: BarAggregation,
1548 ) {
1549 let result = bybit_interval_to_bar_spec(interval).unwrap();
1550 assert_eq!(result, (expected_step, expected_aggregation));
1551 }
1552
1553 #[rstest]
1554 #[case("2")]
1555 #[case("10")]
1556 #[case("100")]
1557 #[case("invalid")]
1558 #[case("")]
1559 fn test_bybit_interval_to_bar_spec_unsupported(#[case] interval: &str) {
1560 let result = bybit_interval_to_bar_spec(interval);
1561 assert!(result.is_none());
1562 }
1563}