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