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