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