1use std::{convert::TryFrom, str::FromStr};
19
20use anyhow::Context;
21use nautilus_core::{datetime::NANOSECONDS_IN_MILLISECOND, nanos::UnixNanos};
22use nautilus_model::{
23 data::{Bar, BarType, TradeTick},
24 enums::{
25 AccountType, AggressorSide, AssetClass, BarAggregation, CurrencyType, LiquiditySide,
26 OptionKind, OrderSide, OrderStatus, OrderType, PositionSideSpecified, TimeInForce,
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 ustr::Ustr;
40
41use crate::{
42 common::{
43 enums::{BybitContractType, BybitOptionType, BybitProductType},
44 symbol::BybitSymbol,
45 },
46 http::models::{
47 BybitExecution, BybitFeeRate, BybitInstrumentInverse, BybitInstrumentLinear,
48 BybitInstrumentOption, BybitInstrumentSpot, BybitKline, BybitPosition, BybitTrade,
49 BybitWalletBalance,
50 },
51};
52
53const BYBIT_MINUTE_INTERVALS: &[u64] = &[1, 3, 5, 15, 30, 60, 120, 240, 360, 720];
54const BYBIT_HOUR_INTERVALS: &[u64] = &[1, 2, 4, 6, 12];
55
56#[must_use]
65pub fn extract_raw_symbol(symbol: &str) -> &str {
66 symbol.rsplit_once('-').map_or(symbol, |(prefix, _)| prefix)
67}
68
69#[must_use]
79pub fn make_bybit_symbol(raw_symbol: &str, product_type: BybitProductType) -> Ustr {
80 let suffix = match product_type {
81 BybitProductType::Spot => "-SPOT",
82 BybitProductType::Linear => "-LINEAR",
83 BybitProductType::Inverse => "-INVERSE",
84 BybitProductType::Option => "-OPTION",
85 };
86 Ustr::from(&format!("{raw_symbol}{suffix}"))
87}
88
89pub fn bar_spec_to_bybit_interval(
97 aggregation: BarAggregation,
98 step: u64,
99) -> anyhow::Result<String> {
100 match aggregation {
101 BarAggregation::Minute => {
102 if !BYBIT_MINUTE_INTERVALS.contains(&step) {
103 anyhow::bail!(
104 "Bybit only supports the following minute intervals: {:?}",
105 BYBIT_MINUTE_INTERVALS
106 );
107 }
108 Ok(step.to_string())
109 }
110 BarAggregation::Hour => {
111 if !BYBIT_HOUR_INTERVALS.contains(&step) {
112 anyhow::bail!(
113 "Bybit only supports the following hour intervals: {:?}",
114 BYBIT_HOUR_INTERVALS
115 );
116 }
117 Ok((step * 60).to_string())
118 }
119 BarAggregation::Day => {
120 if step != 1 {
121 anyhow::bail!("Bybit only supports 1 DAY interval bars");
122 }
123 Ok("D".to_string())
124 }
125 BarAggregation::Week => {
126 if step != 1 {
127 anyhow::bail!("Bybit only supports 1 WEEK interval bars");
128 }
129 Ok("W".to_string())
130 }
131 BarAggregation::Month => {
132 if step != 1 {
133 anyhow::bail!("Bybit only supports 1 MONTH interval bars");
134 }
135 Ok("M".to_string())
136 }
137 _ => {
138 anyhow::bail!("Bybit does not support {:?} bars", aggregation);
139 }
140 }
141}
142
143fn default_margin() -> Decimal {
144 Decimal::new(1, 1)
145}
146
147pub fn parse_spot_instrument(
149 definition: &BybitInstrumentSpot,
150 fee_rate: &BybitFeeRate,
151 ts_event: UnixNanos,
152 ts_init: UnixNanos,
153) -> anyhow::Result<InstrumentAny> {
154 let base_currency = get_currency(definition.base_coin.as_str());
155 let quote_currency = get_currency(definition.quote_coin.as_str());
156
157 let symbol = BybitSymbol::new(format!("{}-SPOT", definition.symbol))?;
158 let instrument_id = symbol.to_instrument_id();
159 let raw_symbol = Symbol::new(symbol.raw_symbol());
160
161 let price_increment = parse_price(&definition.price_filter.tick_size, "priceFilter.tickSize")?;
162 let size_increment = parse_quantity(
163 &definition.lot_size_filter.base_precision,
164 "lotSizeFilter.basePrecision",
165 )?;
166 let lot_size = Some(size_increment);
167 let max_quantity = Some(parse_quantity(
168 &definition.lot_size_filter.max_order_qty,
169 "lotSizeFilter.maxOrderQty",
170 )?);
171 let min_quantity = Some(parse_quantity(
172 &definition.lot_size_filter.min_order_qty,
173 "lotSizeFilter.minOrderQty",
174 )?);
175
176 let maker_fee = parse_decimal(&fee_rate.maker_fee_rate, "makerFeeRate")?;
177 let taker_fee = parse_decimal(&fee_rate.taker_fee_rate, "takerFeeRate")?;
178
179 let instrument = CurrencyPair::new(
180 instrument_id,
181 raw_symbol,
182 base_currency,
183 quote_currency,
184 price_increment.precision,
185 size_increment.precision,
186 price_increment,
187 size_increment,
188 None,
189 lot_size,
190 max_quantity,
191 min_quantity,
192 None,
193 None,
194 None,
195 None,
196 Some(default_margin()),
197 Some(default_margin()),
198 Some(maker_fee),
199 Some(taker_fee),
200 ts_event,
201 ts_init,
202 );
203
204 Ok(InstrumentAny::CurrencyPair(instrument))
205}
206
207pub fn parse_linear_instrument(
209 definition: &BybitInstrumentLinear,
210 fee_rate: &BybitFeeRate,
211 ts_event: UnixNanos,
212 ts_init: UnixNanos,
213) -> anyhow::Result<InstrumentAny> {
214 let base_currency = get_currency(definition.base_coin.as_str());
215 let quote_currency = get_currency(definition.quote_coin.as_str());
216 let settlement_currency = resolve_settlement_currency(
217 definition.settle_coin.as_str(),
218 base_currency,
219 quote_currency,
220 )?;
221
222 let symbol = BybitSymbol::new(format!("{}-LINEAR", definition.symbol))?;
223 let instrument_id = symbol.to_instrument_id();
224 let raw_symbol = Symbol::new(symbol.raw_symbol());
225
226 let price_increment = parse_price(&definition.price_filter.tick_size, "priceFilter.tickSize")?;
227 let size_increment = parse_quantity(
228 &definition.lot_size_filter.qty_step,
229 "lotSizeFilter.qtyStep",
230 )?;
231 let lot_size = Some(size_increment);
232 let max_quantity = Some(parse_quantity(
233 &definition.lot_size_filter.max_order_qty,
234 "lotSizeFilter.maxOrderQty",
235 )?);
236 let min_quantity = Some(parse_quantity(
237 &definition.lot_size_filter.min_order_qty,
238 "lotSizeFilter.minOrderQty",
239 )?);
240 let max_price = Some(parse_price(
241 &definition.price_filter.max_price,
242 "priceFilter.maxPrice",
243 )?);
244 let min_price = Some(parse_price(
245 &definition.price_filter.min_price,
246 "priceFilter.minPrice",
247 )?);
248
249 let maker_fee = parse_decimal(&fee_rate.maker_fee_rate, "makerFeeRate")?;
250 let taker_fee = parse_decimal(&fee_rate.taker_fee_rate, "takerFeeRate")?;
251
252 match definition.contract_type {
253 BybitContractType::LinearPerpetual => {
254 let instrument = CryptoPerpetual::new(
255 instrument_id,
256 raw_symbol,
257 base_currency,
258 quote_currency,
259 settlement_currency,
260 false,
261 price_increment.precision,
262 size_increment.precision,
263 price_increment,
264 size_increment,
265 None,
266 lot_size,
267 max_quantity,
268 min_quantity,
269 None,
270 None,
271 max_price,
272 min_price,
273 Some(default_margin()),
274 Some(default_margin()),
275 Some(maker_fee),
276 Some(taker_fee),
277 ts_event,
278 ts_init,
279 );
280 Ok(InstrumentAny::CryptoPerpetual(instrument))
281 }
282 BybitContractType::LinearFutures => {
283 let activation_ns = parse_millis_timestamp(&definition.launch_time, "launchTime")?;
284 let expiration_ns = parse_millis_timestamp(&definition.delivery_time, "deliveryTime")?;
285 let instrument = CryptoFuture::new(
286 instrument_id,
287 raw_symbol,
288 base_currency,
289 quote_currency,
290 settlement_currency,
291 false,
292 activation_ns,
293 expiration_ns,
294 price_increment.precision,
295 size_increment.precision,
296 price_increment,
297 size_increment,
298 None,
299 lot_size,
300 max_quantity,
301 min_quantity,
302 None,
303 None,
304 max_price,
305 min_price,
306 Some(default_margin()),
307 Some(default_margin()),
308 Some(maker_fee),
309 Some(taker_fee),
310 ts_event,
311 ts_init,
312 );
313 Ok(InstrumentAny::CryptoFuture(instrument))
314 }
315 other => Err(anyhow::anyhow!(
316 "unsupported linear contract variant: {other:?}"
317 )),
318 }
319}
320
321pub fn parse_inverse_instrument(
323 definition: &BybitInstrumentInverse,
324 fee_rate: &BybitFeeRate,
325 ts_event: UnixNanos,
326 ts_init: UnixNanos,
327) -> anyhow::Result<InstrumentAny> {
328 let base_currency = get_currency(definition.base_coin.as_str());
329 let quote_currency = get_currency(definition.quote_coin.as_str());
330 let settlement_currency = resolve_settlement_currency(
331 definition.settle_coin.as_str(),
332 base_currency,
333 quote_currency,
334 )?;
335
336 let symbol = BybitSymbol::new(format!("{}-INVERSE", definition.symbol))?;
337 let instrument_id = symbol.to_instrument_id();
338 let raw_symbol = Symbol::new(symbol.raw_symbol());
339
340 let price_increment = parse_price(&definition.price_filter.tick_size, "priceFilter.tickSize")?;
341 let size_increment = parse_quantity(
342 &definition.lot_size_filter.qty_step,
343 "lotSizeFilter.qtyStep",
344 )?;
345 let lot_size = Some(size_increment);
346 let max_quantity = Some(parse_quantity(
347 &definition.lot_size_filter.max_order_qty,
348 "lotSizeFilter.maxOrderQty",
349 )?);
350 let min_quantity = Some(parse_quantity(
351 &definition.lot_size_filter.min_order_qty,
352 "lotSizeFilter.minOrderQty",
353 )?);
354 let max_price = Some(parse_price(
355 &definition.price_filter.max_price,
356 "priceFilter.maxPrice",
357 )?);
358 let min_price = Some(parse_price(
359 &definition.price_filter.min_price,
360 "priceFilter.minPrice",
361 )?);
362
363 let maker_fee = parse_decimal(&fee_rate.maker_fee_rate, "makerFeeRate")?;
364 let taker_fee = parse_decimal(&fee_rate.taker_fee_rate, "takerFeeRate")?;
365
366 match definition.contract_type {
367 BybitContractType::InversePerpetual => {
368 let instrument = CryptoPerpetual::new(
369 instrument_id,
370 raw_symbol,
371 base_currency,
372 quote_currency,
373 settlement_currency,
374 true,
375 price_increment.precision,
376 size_increment.precision,
377 price_increment,
378 size_increment,
379 None,
380 lot_size,
381 max_quantity,
382 min_quantity,
383 None,
384 None,
385 max_price,
386 min_price,
387 Some(default_margin()),
388 Some(default_margin()),
389 Some(maker_fee),
390 Some(taker_fee),
391 ts_event,
392 ts_init,
393 );
394 Ok(InstrumentAny::CryptoPerpetual(instrument))
395 }
396 BybitContractType::InverseFutures => {
397 let activation_ns = parse_millis_timestamp(&definition.launch_time, "launchTime")?;
398 let expiration_ns = parse_millis_timestamp(&definition.delivery_time, "deliveryTime")?;
399 let instrument = CryptoFuture::new(
400 instrument_id,
401 raw_symbol,
402 base_currency,
403 quote_currency,
404 settlement_currency,
405 true,
406 activation_ns,
407 expiration_ns,
408 price_increment.precision,
409 size_increment.precision,
410 price_increment,
411 size_increment,
412 None,
413 lot_size,
414 max_quantity,
415 min_quantity,
416 None,
417 None,
418 max_price,
419 min_price,
420 Some(default_margin()),
421 Some(default_margin()),
422 Some(maker_fee),
423 Some(taker_fee),
424 ts_event,
425 ts_init,
426 );
427 Ok(InstrumentAny::CryptoFuture(instrument))
428 }
429 other => Err(anyhow::anyhow!(
430 "unsupported inverse contract variant: {other:?}"
431 )),
432 }
433}
434
435pub fn parse_option_instrument(
437 definition: &BybitInstrumentOption,
438 ts_event: UnixNanos,
439 ts_init: UnixNanos,
440) -> anyhow::Result<InstrumentAny> {
441 let quote_currency = get_currency(definition.quote_coin.as_str());
442
443 let symbol = BybitSymbol::new(format!("{}-OPTION", definition.symbol))?;
444 let instrument_id = symbol.to_instrument_id();
445 let raw_symbol = Symbol::new(symbol.raw_symbol());
446
447 let price_increment = parse_price(&definition.price_filter.tick_size, "priceFilter.tickSize")?;
448 let max_price = Some(parse_price(
449 &definition.price_filter.max_price,
450 "priceFilter.maxPrice",
451 )?);
452 let min_price = Some(parse_price(
453 &definition.price_filter.min_price,
454 "priceFilter.minPrice",
455 )?);
456 let lot_size = parse_quantity(
457 &definition.lot_size_filter.qty_step,
458 "lotSizeFilter.qtyStep",
459 )?;
460 let max_quantity = Some(parse_quantity(
461 &definition.lot_size_filter.max_order_qty,
462 "lotSizeFilter.maxOrderQty",
463 )?);
464 let min_quantity = Some(parse_quantity(
465 &definition.lot_size_filter.min_order_qty,
466 "lotSizeFilter.minOrderQty",
467 )?);
468
469 let option_kind = match definition.options_type {
470 BybitOptionType::Call => OptionKind::Call,
471 BybitOptionType::Put => OptionKind::Put,
472 };
473
474 let strike_price = extract_strike_from_symbol(&definition.symbol)?;
475 let activation_ns = parse_millis_timestamp(&definition.launch_time, "launchTime")?;
476 let expiration_ns = parse_millis_timestamp(&definition.delivery_time, "deliveryTime")?;
477
478 let instrument = OptionContract::new(
479 instrument_id,
480 raw_symbol,
481 AssetClass::Cryptocurrency,
482 None,
483 Ustr::from(definition.base_coin.as_str()),
484 option_kind,
485 strike_price,
486 quote_currency,
487 activation_ns,
488 expiration_ns,
489 price_increment.precision,
490 price_increment,
491 Quantity::from(1_u32),
492 lot_size,
493 max_quantity,
494 min_quantity,
495 max_price,
496 min_price,
497 Some(Decimal::ZERO),
498 Some(Decimal::ZERO),
499 Some(Decimal::ZERO),
500 Some(Decimal::ZERO),
501 ts_event,
502 ts_init,
503 );
504
505 Ok(InstrumentAny::OptionContract(instrument))
506}
507
508pub fn parse_trade_tick(
510 trade: &BybitTrade,
511 instrument: &InstrumentAny,
512 ts_init: UnixNanos,
513) -> anyhow::Result<TradeTick> {
514 let price =
515 parse_price_with_precision(&trade.price, instrument.price_precision(), "trade.price")?;
516 let size =
517 parse_quantity_with_precision(&trade.size, instrument.size_precision(), "trade.size")?;
518 let aggressor: AggressorSide = trade.side.into();
519 let trade_id = TradeId::new_checked(trade.exec_id.as_str())
520 .context("invalid exec_id in Bybit trade payload")?;
521 let ts_event = parse_millis_timestamp(&trade.time, "trade.time")?;
522
523 TradeTick::new_checked(
524 instrument.id(),
525 price,
526 size,
527 aggressor,
528 trade_id,
529 ts_event,
530 ts_init,
531 )
532 .context("failed to construct TradeTick from Bybit trade payload")
533}
534
535pub fn parse_kline_bar(
537 kline: &BybitKline,
538 instrument: &InstrumentAny,
539 bar_type: BarType,
540 timestamp_on_close: bool,
541 ts_init: UnixNanos,
542) -> anyhow::Result<Bar> {
543 let price_precision = instrument.price_precision();
544 let size_precision = instrument.size_precision();
545
546 let open = parse_price_with_precision(&kline.open, price_precision, "kline.open")?;
547 let high = parse_price_with_precision(&kline.high, price_precision, "kline.high")?;
548 let low = parse_price_with_precision(&kline.low, price_precision, "kline.low")?;
549 let close = parse_price_with_precision(&kline.close, price_precision, "kline.close")?;
550 let volume = parse_quantity_with_precision(&kline.volume, size_precision, "kline.volume")?;
551
552 let mut ts_event = parse_millis_timestamp(&kline.start, "kline.start")?;
553 if timestamp_on_close {
554 let interval_ns = bar_type
555 .spec()
556 .timedelta()
557 .num_nanoseconds()
558 .context("bar specification produced non-integer interval")?;
559 let interval_ns = u64::try_from(interval_ns)
560 .context("bar interval overflowed the u64 range for nanoseconds")?;
561 let updated = ts_event
562 .as_u64()
563 .checked_add(interval_ns)
564 .context("bar timestamp overflowed when adjusting to close time")?;
565 ts_event = UnixNanos::from(updated);
566 }
567 let ts_init = if ts_init.is_zero() { ts_event } else { ts_init };
568
569 Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
570 .context("failed to construct Bar from Bybit kline entry")
571}
572
573pub fn parse_fill_report(
582 execution: &BybitExecution,
583 account_id: AccountId,
584 instrument: &InstrumentAny,
585 ts_init: UnixNanos,
586) -> anyhow::Result<FillReport> {
587 let instrument_id = instrument.id();
588 let venue_order_id = VenueOrderId::new(execution.order_id.as_str());
589 let trade_id = TradeId::new_checked(execution.exec_id.as_str())
590 .context("invalid execId in Bybit execution payload")?;
591
592 let order_side: OrderSide = execution.side.into();
593
594 let last_px = parse_price_with_precision(
595 &execution.exec_price,
596 instrument.price_precision(),
597 "execution.execPrice",
598 )?;
599
600 let last_qty = parse_quantity_with_precision(
601 &execution.exec_qty,
602 instrument.size_precision(),
603 "execution.execQty",
604 )?;
605
606 let fee_f64 = execution
608 .exec_fee
609 .parse::<f64>()
610 .with_context(|| format!("Failed to parse execFee='{}'", execution.exec_fee))?;
611 let commission = Money::new(-fee_f64, Currency::from(execution.fee_currency.as_str()));
612
613 let liquidity_side = if execution.is_maker {
615 LiquiditySide::Maker
616 } else {
617 LiquiditySide::Taker
618 };
619
620 let ts_event = parse_millis_timestamp(&execution.exec_time, "execution.execTime")?;
621
622 let client_order_id = if execution.order_link_id.is_empty() {
624 None
625 } else {
626 Some(ClientOrderId::new(execution.order_link_id.as_str()))
627 };
628
629 Ok(FillReport::new(
630 account_id,
631 instrument_id,
632 venue_order_id,
633 trade_id,
634 order_side,
635 last_qty,
636 last_px,
637 commission,
638 liquidity_side,
639 client_order_id,
640 None, ts_event,
642 ts_init,
643 None, ))
645}
646
647pub fn parse_position_status_report(
656 position: &BybitPosition,
657 account_id: AccountId,
658 instrument: &InstrumentAny,
659 ts_init: UnixNanos,
660) -> anyhow::Result<PositionStatusReport> {
661 let instrument_id = instrument.id();
662
663 let size_f64 = position
665 .size
666 .parse::<f64>()
667 .with_context(|| format!("Failed to parse position size '{}'", position.size))?;
668
669 let (position_side, quantity) = match position.side {
671 crate::common::enums::BybitPositionSide::Buy => {
672 let qty = Quantity::new(size_f64, instrument.size_precision());
673 (PositionSideSpecified::Long, qty)
674 }
675 crate::common::enums::BybitPositionSide::Sell => {
676 let qty = Quantity::new(size_f64, instrument.size_precision());
677 (PositionSideSpecified::Short, qty)
678 }
679 crate::common::enums::BybitPositionSide::Flat => {
680 let qty = Quantity::new(0.0, instrument.size_precision());
681 (PositionSideSpecified::Flat, qty)
682 }
683 };
684
685 let avg_px_open = if position.avg_price.is_empty() || position.avg_price == "0" {
687 None
688 } else {
689 Some(Decimal::from_str(&position.avg_price)?)
690 };
691
692 let ts_last = parse_millis_timestamp(&position.updated_time, "position.updatedTime")?;
694
695 Ok(PositionStatusReport::new(
696 account_id,
697 instrument_id,
698 position_side,
699 quantity,
700 ts_last,
701 ts_init,
702 None, None, avg_px_open,
705 ))
706}
707
708pub fn parse_account_state(
716 wallet_balance: &BybitWalletBalance,
717 account_id: AccountId,
718 ts_init: UnixNanos,
719) -> anyhow::Result<AccountState> {
720 let mut balances = Vec::new();
721
722 for coin in &wallet_balance.coin {
724 let currency = Currency::from_str(&coin.coin)?;
725
726 let wallet_balance_f64 = if coin.wallet_balance.is_empty() {
727 0.0
728 } else {
729 coin.wallet_balance.parse::<f64>()?
730 };
731
732 let spot_borrow_f64 = if let Some(ref spot_borrow) = coin.spot_borrow {
734 if spot_borrow.is_empty() {
735 0.0
736 } else {
737 spot_borrow.parse::<f64>()?
738 }
739 } else {
740 0.0
741 };
742
743 let total_f64 = wallet_balance_f64 - spot_borrow_f64;
744
745 let locked_f64 = if coin.locked.is_empty() {
746 0.0
747 } else {
748 coin.locked.parse::<f64>()?
749 };
750
751 let total = Money::new(total_f64, currency);
752 let locked = Money::new(locked_f64, currency);
753
754 let free = if total.raw >= locked.raw {
756 Money::from_raw(total.raw - locked.raw, currency)
757 } else {
758 Money::new(0.0, currency)
759 };
760
761 balances.push(AccountBalance::new(total, locked, free));
762 }
763
764 let mut margins = Vec::new();
765
766 for coin in &wallet_balance.coin {
768 let currency = Currency::from_str(&coin.coin)?;
769
770 let initial_margin_f64 = match &coin.total_position_im {
771 Some(im) if !im.is_empty() => im.parse::<f64>()?,
772 _ => 0.0,
773 };
774
775 let maintenance_margin_f64 = match &coin.total_position_mm {
776 Some(mm) if !mm.is_empty() => mm.parse::<f64>()?,
777 _ => 0.0,
778 };
779
780 if initial_margin_f64 > 0.0 || maintenance_margin_f64 > 0.0 {
782 let initial_margin = Money::new(initial_margin_f64, currency);
783 let maintenance_margin = Money::new(maintenance_margin_f64, currency);
784
785 let margin_instrument_id = InstrumentId::new(
787 Symbol::from_str_unchecked(format!("ACCOUNT-{}", coin.coin)),
788 Venue::new("BYBIT"),
789 );
790
791 margins.push(MarginBalance::new(
792 initial_margin,
793 maintenance_margin,
794 margin_instrument_id,
795 ));
796 }
797 }
798
799 let account_type = AccountType::Margin;
800 let is_reported = true;
801 let event_id = nautilus_core::uuid::UUID4::new();
802
803 let ts_event = ts_init;
805
806 Ok(AccountState::new(
807 account_id,
808 account_type,
809 balances,
810 margins,
811 is_reported,
812 event_id,
813 ts_event,
814 ts_init,
815 None,
816 ))
817}
818
819pub(crate) fn parse_price_with_precision(
820 value: &str,
821 precision: u8,
822 field: &str,
823) -> anyhow::Result<Price> {
824 let parsed = value
825 .parse::<f64>()
826 .with_context(|| format!("Failed to parse {field}='{value}' as f64"))?;
827 Price::new_checked(parsed, precision).with_context(|| {
828 format!("Failed to construct Price for {field} with precision {precision}")
829 })
830}
831
832pub(crate) fn parse_quantity_with_precision(
833 value: &str,
834 precision: u8,
835 field: &str,
836) -> anyhow::Result<Quantity> {
837 let parsed = value
838 .parse::<f64>()
839 .with_context(|| format!("Failed to parse {field}='{value}' as f64"))?;
840 Quantity::new_checked(parsed, precision).with_context(|| {
841 format!("Failed to construct Quantity for {field} with precision {precision}")
842 })
843}
844
845pub(crate) fn parse_price(value: &str, field: &str) -> anyhow::Result<Price> {
846 Price::from_str(value)
847 .map_err(|err| anyhow::anyhow!("Failed to parse {field}='{value}': {err}"))
848}
849
850pub(crate) fn parse_quantity(value: &str, field: &str) -> anyhow::Result<Quantity> {
851 Quantity::from_str(value)
852 .map_err(|err| anyhow::anyhow!("Failed to parse {field}='{value}': {err}"))
853}
854
855pub(crate) fn parse_decimal(value: &str, field: &str) -> anyhow::Result<Decimal> {
856 Decimal::from_str(value)
857 .map_err(|err| anyhow::anyhow!("Failed to parse {field}='{value}' as Decimal: {err}"))
858}
859
860pub(crate) fn parse_millis_timestamp(value: &str, field: &str) -> anyhow::Result<UnixNanos> {
861 let millis: u64 = value
862 .parse()
863 .with_context(|| format!("Failed to parse {field}='{value}' as u64 millis"))?;
864 let nanos = millis
865 .checked_mul(NANOSECONDS_IN_MILLISECOND)
866 .context("millisecond timestamp overflowed when converting to nanoseconds")?;
867 Ok(UnixNanos::from(nanos))
868}
869
870fn resolve_settlement_currency(
871 settle_coin: &str,
872 base_currency: Currency,
873 quote_currency: Currency,
874) -> anyhow::Result<Currency> {
875 if settle_coin.eq_ignore_ascii_case(base_currency.code.as_str()) {
876 Ok(base_currency)
877 } else if settle_coin.eq_ignore_ascii_case(quote_currency.code.as_str()) {
878 Ok(quote_currency)
879 } else {
880 Err(anyhow::anyhow!(
881 "unrecognised settlement currency '{settle_coin}'"
882 ))
883 }
884}
885
886fn get_currency(code: &str) -> Currency {
887 Currency::try_from_str(code)
888 .unwrap_or_else(|| Currency::new(code, 8, 0, code, CurrencyType::Crypto))
889}
890
891fn extract_strike_from_symbol(symbol: &str) -> anyhow::Result<Price> {
892 let parts: Vec<&str> = symbol.split('-').collect();
893 let strike = parts
894 .get(2)
895 .ok_or_else(|| anyhow::anyhow!("invalid option symbol '{symbol}'"))?;
896 parse_price(strike, "option strike")
897}
898
899pub fn parse_order_status_report(
901 order: &crate::http::models::BybitOrder,
902 instrument: &InstrumentAny,
903 account_id: nautilus_model::identifiers::AccountId,
904 ts_init: UnixNanos,
905) -> anyhow::Result<nautilus_model::reports::OrderStatusReport> {
906 use crate::common::enums::{BybitOrderStatus, BybitOrderType, BybitTimeInForce};
907
908 let instrument_id = instrument.id();
909 let venue_order_id = VenueOrderId::new(order.order_id);
910
911 let order_side: OrderSide = order.side.into();
912
913 let order_type: OrderType = match order.order_type {
914 BybitOrderType::Market => OrderType::Market,
915 BybitOrderType::Limit => OrderType::Limit,
916 BybitOrderType::Unknown => OrderType::Limit,
917 };
918
919 let time_in_force: TimeInForce = match order.time_in_force {
920 BybitTimeInForce::Gtc => TimeInForce::Gtc,
921 BybitTimeInForce::Ioc => TimeInForce::Ioc,
922 BybitTimeInForce::Fok => TimeInForce::Fok,
923 BybitTimeInForce::PostOnly => TimeInForce::Gtc,
924 };
925
926 let quantity =
927 parse_quantity_with_precision(&order.qty, instrument.size_precision(), "order.qty")?;
928
929 let filled_qty = parse_quantity_with_precision(
930 &order.cum_exec_qty,
931 instrument.size_precision(),
932 "order.cumExecQty",
933 )?;
934
935 let order_status: OrderStatus = match order.order_status {
941 BybitOrderStatus::Created | BybitOrderStatus::New | BybitOrderStatus::Untriggered => {
942 OrderStatus::Accepted
943 }
944 BybitOrderStatus::Rejected => {
945 if filled_qty.is_positive() {
946 OrderStatus::Canceled
947 } else {
948 OrderStatus::Rejected
949 }
950 }
951 BybitOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
952 BybitOrderStatus::Filled => OrderStatus::Filled,
953 BybitOrderStatus::Canceled | BybitOrderStatus::PartiallyFilledCanceled => {
954 OrderStatus::Canceled
955 }
956 BybitOrderStatus::Triggered => OrderStatus::Triggered,
957 BybitOrderStatus::Deactivated => OrderStatus::Canceled,
958 };
959
960 let ts_accepted = parse_millis_timestamp(&order.created_time, "order.createdTime")?;
961 let ts_last = parse_millis_timestamp(&order.updated_time, "order.updatedTime")?;
962
963 let mut report = OrderStatusReport::new(
964 account_id,
965 instrument_id,
966 None,
967 venue_order_id,
968 order_side,
969 order_type,
970 time_in_force,
971 order_status,
972 quantity,
973 filled_qty,
974 ts_accepted,
975 ts_last,
976 ts_init,
977 Some(nautilus_core::uuid::UUID4::new()),
978 );
979
980 if !order.order_link_id.is_empty() {
981 report = report.with_client_order_id(ClientOrderId::new(order.order_link_id.as_str()));
982 }
983
984 if !order.price.is_empty() && order.price != "0" {
985 let price =
986 parse_price_with_precision(&order.price, instrument.price_precision(), "order.price")?;
987 report = report.with_price(price);
988 }
989
990 if let Some(avg_price) = &order.avg_price
991 && !avg_price.is_empty()
992 && avg_price != "0"
993 {
994 let avg_px = avg_price
995 .parse::<f64>()
996 .with_context(|| format!("Failed to parse avg_price='{avg_price}' as f64"))?;
997 report = report.with_avg_px(avg_px);
998 }
999
1000 if !order.trigger_price.is_empty() && order.trigger_price != "0" {
1001 let trigger_price = parse_price_with_precision(
1002 &order.trigger_price,
1003 instrument.price_precision(),
1004 "order.triggerPrice",
1005 )?;
1006 report = report.with_trigger_price(trigger_price);
1007 }
1008
1009 Ok(report)
1010}
1011
1012#[cfg(test)]
1017mod tests {
1018 use nautilus_model::{
1019 data::BarSpecification,
1020 enums::{AggregationSource, BarAggregation, PositionSide, PriceType},
1021 };
1022 use rstest::rstest;
1023
1024 use super::*;
1025 use crate::{
1026 common::testing::load_test_json,
1027 http::models::{
1028 BybitInstrumentInverseResponse, BybitInstrumentLinearResponse,
1029 BybitInstrumentOptionResponse, BybitInstrumentSpotResponse, BybitKlinesResponse,
1030 BybitTradesResponse,
1031 },
1032 };
1033
1034 const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
1035
1036 fn sample_fee_rate(
1037 symbol: &str,
1038 taker: &str,
1039 maker: &str,
1040 base_coin: Option<&str>,
1041 ) -> BybitFeeRate {
1042 BybitFeeRate {
1043 symbol: Ustr::from(symbol),
1044 taker_fee_rate: taker.to_string(),
1045 maker_fee_rate: maker.to_string(),
1046 base_coin: base_coin.map(Ustr::from),
1047 }
1048 }
1049
1050 fn linear_instrument() -> InstrumentAny {
1051 let json = load_test_json("http_get_instruments_linear.json");
1052 let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
1053 let instrument = &response.result.list[0];
1054 let fee_rate = sample_fee_rate("BTCUSDT", "0.00055", "0.0001", Some("BTC"));
1055 parse_linear_instrument(instrument, &fee_rate, TS, TS).unwrap()
1056 }
1057
1058 #[rstest]
1059 fn parse_spot_instrument_builds_currency_pair() {
1060 let json = load_test_json("http_get_instruments_spot.json");
1061 let response: BybitInstrumentSpotResponse = serde_json::from_str(&json).unwrap();
1062 let instrument = &response.result.list[0];
1063 let fee_rate = sample_fee_rate("BTCUSDT", "0.0006", "0.0001", Some("BTC"));
1064
1065 let parsed = parse_spot_instrument(instrument, &fee_rate, TS, TS).unwrap();
1066 match parsed {
1067 InstrumentAny::CurrencyPair(pair) => {
1068 assert_eq!(pair.id.to_string(), "BTCUSDT-SPOT.BYBIT");
1069 assert_eq!(pair.price_increment, Price::from_str("0.1").unwrap());
1070 assert_eq!(pair.size_increment, Quantity::from_str("0.0001").unwrap());
1071 assert_eq!(pair.base_currency.code.as_str(), "BTC");
1072 assert_eq!(pair.quote_currency.code.as_str(), "USDT");
1073 }
1074 _ => panic!("expected CurrencyPair"),
1075 }
1076 }
1077
1078 #[rstest]
1079 fn parse_linear_perpetual_instrument_builds_crypto_perpetual() {
1080 let json = load_test_json("http_get_instruments_linear.json");
1081 let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
1082 let instrument = &response.result.list[0];
1083 let fee_rate = sample_fee_rate("BTCUSDT", "0.00055", "0.0001", Some("BTC"));
1084
1085 let parsed = parse_linear_instrument(instrument, &fee_rate, TS, TS).unwrap();
1086 match parsed {
1087 InstrumentAny::CryptoPerpetual(perp) => {
1088 assert_eq!(perp.id.to_string(), "BTCUSDT-LINEAR.BYBIT");
1089 assert!(!perp.is_inverse);
1090 assert_eq!(perp.price_increment, Price::from_str("0.5").unwrap());
1091 assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
1092 }
1093 other => panic!("unexpected instrument variant: {other:?}"),
1094 }
1095 }
1096
1097 #[rstest]
1098 fn parse_inverse_perpetual_instrument_builds_inverse_perpetual() {
1099 let json = load_test_json("http_get_instruments_inverse.json");
1100 let response: BybitInstrumentInverseResponse = serde_json::from_str(&json).unwrap();
1101 let instrument = &response.result.list[0];
1102 let fee_rate = sample_fee_rate("BTCUSD", "0.00075", "0.00025", Some("BTC"));
1103
1104 let parsed = parse_inverse_instrument(instrument, &fee_rate, TS, TS).unwrap();
1105 match parsed {
1106 InstrumentAny::CryptoPerpetual(perp) => {
1107 assert_eq!(perp.id.to_string(), "BTCUSD-INVERSE.BYBIT");
1108 assert!(perp.is_inverse);
1109 assert_eq!(perp.price_increment, Price::from_str("0.5").unwrap());
1110 assert_eq!(perp.size_increment, Quantity::from_str("1").unwrap());
1111 }
1112 other => panic!("unexpected instrument variant: {other:?}"),
1113 }
1114 }
1115
1116 #[rstest]
1117 fn parse_option_instrument_builds_option_contract() {
1118 let json = load_test_json("http_get_instruments_option.json");
1119 let response: BybitInstrumentOptionResponse = serde_json::from_str(&json).unwrap();
1120 let instrument = &response.result.list[0];
1121
1122 let parsed = parse_option_instrument(instrument, TS, TS).unwrap();
1123 match parsed {
1124 InstrumentAny::OptionContract(option) => {
1125 assert_eq!(option.id.to_string(), "ETH-26JUN26-16000-P-OPTION.BYBIT");
1126 assert_eq!(option.option_kind, OptionKind::Put);
1127 assert_eq!(option.price_increment, Price::from_str("0.1").unwrap());
1128 assert_eq!(option.lot_size, Quantity::from_str("1").unwrap());
1129 }
1130 other => panic!("unexpected instrument variant: {other:?}"),
1131 }
1132 }
1133
1134 #[rstest]
1135 fn parse_http_trade_into_trade_tick() {
1136 let instrument = linear_instrument();
1137 let json = load_test_json("http_get_trades_recent.json");
1138 let response: BybitTradesResponse = serde_json::from_str(&json).unwrap();
1139 let trade = &response.result.list[0];
1140
1141 let tick = parse_trade_tick(trade, &instrument, TS).unwrap();
1142
1143 assert_eq!(tick.instrument_id, instrument.id());
1144 assert_eq!(tick.price, instrument.make_price(27450.50));
1145 assert_eq!(tick.size, instrument.make_qty(0.005, None));
1146 assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
1147 assert_eq!(
1148 tick.trade_id.to_string(),
1149 "a905d5c3-1ed0-4f37-83e4-9c73a2fe2f01"
1150 );
1151 assert_eq!(tick.ts_event, UnixNanos::new(1_709_891_679_000_000_000));
1152 }
1153
1154 #[rstest]
1155 fn parse_kline_into_bar() {
1156 let instrument = linear_instrument();
1157 let json = load_test_json("http_get_klines_linear.json");
1158 let response: BybitKlinesResponse = serde_json::from_str(&json).unwrap();
1159 let kline = &response.result.list[0];
1160
1161 let bar_type = BarType::new(
1162 instrument.id(),
1163 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
1164 AggregationSource::External,
1165 );
1166
1167 let bar = parse_kline_bar(kline, &instrument, bar_type, false, TS).unwrap();
1168
1169 assert_eq!(bar.bar_type.to_string(), bar_type.to_string());
1170 assert_eq!(bar.open, instrument.make_price(27450.0));
1171 assert_eq!(bar.high, instrument.make_price(27460.0));
1172 assert_eq!(bar.low, instrument.make_price(27440.0));
1173 assert_eq!(bar.close, instrument.make_price(27455.0));
1174 assert_eq!(bar.volume, instrument.make_qty(123.45, None));
1175 assert_eq!(bar.ts_event, UnixNanos::new(1_709_891_679_000_000_000));
1176 }
1177
1178 #[rstest]
1179 fn parse_http_position_short_into_position_status_report() {
1180 use crate::http::models::BybitPositionListResponse;
1181
1182 let json = load_test_json("http_get_positions.json");
1183 let response: BybitPositionListResponse = serde_json::from_str(&json).unwrap();
1184
1185 let short_position = &response.result.list[1];
1187 assert_eq!(short_position.symbol.as_str(), "ETHUSDT");
1188 assert_eq!(
1189 short_position.side,
1190 crate::common::enums::BybitPositionSide::Sell
1191 );
1192
1193 let eth_json = load_test_json("http_get_instruments_linear.json");
1195 let eth_response: BybitInstrumentLinearResponse = serde_json::from_str(ð_json).unwrap();
1196 let eth_def = ð_response.result.list[1]; let fee_rate = sample_fee_rate("ETHUSDT", "0.00055", "0.0001", Some("ETH"));
1198 let eth_instrument = parse_linear_instrument(eth_def, &fee_rate, TS, TS).unwrap();
1199
1200 let account_id = AccountId::new("BYBIT-001");
1201 let report =
1202 parse_position_status_report(short_position, account_id, ð_instrument, TS).unwrap();
1203
1204 assert_eq!(report.account_id, account_id);
1206 assert_eq!(report.instrument_id.symbol.as_str(), "ETHUSDT-LINEAR");
1207 assert_eq!(report.position_side.as_position_side(), PositionSide::Short);
1208 assert_eq!(report.quantity, eth_instrument.make_qty(5.0, None));
1209 assert_eq!(
1210 report.avg_px_open,
1211 Some(Decimal::try_from(3000.00).unwrap())
1212 );
1213 assert_eq!(report.ts_last, UnixNanos::new(1_697_673_700_112_000_000));
1214 }
1215
1216 #[rstest]
1217 fn parse_http_order_partially_filled_rejected_maps_to_canceled() {
1218 use crate::http::models::BybitOrderHistoryResponse;
1219
1220 let instrument = linear_instrument();
1221 let json = load_test_json("http_get_order_partially_filled_rejected.json");
1222 let response: BybitOrderHistoryResponse = serde_json::from_str(&json).unwrap();
1223 let order = &response.result.list[0];
1224 let account_id = AccountId::new("BYBIT-001");
1225
1226 let report = parse_order_status_report(order, &instrument, account_id, TS).unwrap();
1227
1228 assert_eq!(report.order_status, OrderStatus::Canceled);
1230 assert_eq!(report.filled_qty, instrument.make_qty(0.005, None));
1231 assert_eq!(
1232 report.client_order_id.as_ref().unwrap().to_string(),
1233 "O-20251001-164609-APEX-000-49"
1234 );
1235 }
1236}