1use std::{convert::TryFrom, str::FromStr};
19
20use anyhow::Context;
21pub use nautilus_core::serialization::{
22 deserialize_decimal_or_zero, deserialize_optional_decimal_or_zero,
23 deserialize_optional_decimal_str, deserialize_string_to_u8,
24};
25use serde::{Deserialize, de::Error};
26
27pub mod on_off_bool {
33 use serde::{Deserialize, Deserializer, Serializer, de::Error};
34
35 pub fn serialize<S: Serializer>(value: &bool, s: S) -> Result<S::Ok, S::Error> {
36 s.serialize_str(if *value { "ON" } else { "OFF" })
37 }
38
39 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
40 let raw = String::deserialize(d)?;
41 match raw.as_str() {
42 "ON" => Ok(true),
43 "OFF" => Ok(false),
44 other => Err(D::Error::custom(format!(
45 "expected 'ON' or 'OFF', received {other:?}"
46 ))),
47 }
48 }
49}
50
51pub mod bool_or_int {
57 use serde::{Deserialize, Deserializer, Serializer, de::Error};
58
59 pub fn serialize<S: Serializer>(value: &bool, s: S) -> Result<S::Ok, S::Error> {
60 s.serialize_bool(*value)
61 }
62
63 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
64 #[derive(Deserialize)]
65 #[serde(untagged)]
66 enum BoolOrInt {
67 Bool(bool),
68 Int(i64),
69 }
70
71 match BoolOrInt::deserialize(d)? {
72 BoolOrInt::Bool(b) => Ok(b),
73 BoolOrInt::Int(0) => Ok(false),
74 BoolOrInt::Int(1) => Ok(true),
75 BoolOrInt::Int(n) => Err(D::Error::custom(format!(
76 "expected bool or 0/1, received {n}"
77 ))),
78 }
79 }
80}
81
82pub(crate) fn deserialize_int_or_string<'de, T, D>(d: D) -> Result<T, D::Error>
86where
87 T: serde::Deserialize<'de> + std::str::FromStr,
88 T::Err: std::fmt::Display,
89 D: serde::Deserializer<'de>,
90{
91 #[derive(serde::Deserialize)]
92 #[serde(untagged)]
93 enum IntOrString<T> {
94 Int(T),
95 Str(String),
96 }
97
98 match IntOrString::<T>::deserialize(d)? {
99 IntOrString::Int(value) => Ok(value),
100 IntOrString::Str(value) => value.parse().map_err(|e| {
101 D::Error::custom(format!(
102 "expected {}, received {value:?}: {e}",
103 std::any::type_name::<T>()
104 ))
105 }),
106 }
107}
108
109pub(crate) fn deserialize_i32_or_string<'de, D: serde::Deserializer<'de>>(
113 d: D,
114) -> Result<i32, D::Error> {
115 deserialize_int_or_string(d)
116}
117
118pub(crate) fn deserialize_i64_or_string<'de, D: serde::Deserializer<'de>>(
122 d: D,
123) -> Result<i64, D::Error> {
124 deserialize_int_or_string(d)
125}
126
127pub mod opt_bool_as_int {
130 use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error};
131
132 pub fn serialize<S: Serializer>(value: &Option<bool>, s: S) -> Result<S::Ok, S::Error> {
133 value.map(i32::from).serialize(s)
134 }
135
136 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
137 match Option::<i32>::deserialize(d)? {
138 None => Ok(None),
139 Some(0) => Ok(Some(false)),
140 Some(1) => Ok(Some(true)),
141 Some(n) => Err(D::Error::custom(format!("expected 0 or 1, received {n}"))),
142 }
143 }
144}
145
146pub mod masked_secret {
153 use nautilus_core::string::secret::SecretString;
154 use serde::{Deserialize, Deserializer, Serialize, Serializer};
155
156 pub fn serialize<S: Serializer>(value: &Option<SecretString>, s: S) -> Result<S::Ok, S::Error> {
157 match value {
158 Some(v) => v.serialize(s),
159 None => "".serialize(s),
160 }
161 }
162
163 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<SecretString>, D::Error> {
164 let raw = Option::<String>::deserialize(d)?;
165 Ok(match raw.as_deref() {
166 None | Some("" | "******") => None,
167 Some(_) => raw.map(SecretString::from),
168 })
169 }
170}
171use nautilus_core::{
172 Params, UUID4,
173 datetime::{NANOSECONDS_IN_MILLISECOND, nanos_to_millis as nanos_to_millis_u64},
174 nanos::UnixNanos,
175};
176use nautilus_model::{
177 data::{
178 Bar, BarType, BookOrder, FundingRateUpdate, OrderBookDelta, OrderBookDeltas, TradeTick,
179 },
180 enums::{
181 AccountType, AggressorSide, BarAggregation, BookAction, LiquiditySide, OptionKind,
182 OrderSide, OrderStatus, OrderType, PositionSide, RecordFlag, TimeInForce, TriggerType,
183 },
184 events::account::state::AccountState,
185 identifiers::{
186 AccountId, ClientOrderId, InstrumentId, PositionId, Symbol, TradeId, VenueOrderId,
187 },
188 instruments::{
189 Instrument, any::InstrumentAny, crypto_future::CryptoFuture, crypto_option::CryptoOption,
190 crypto_perpetual::CryptoPerpetual, currency_pair::CurrencyPair,
191 },
192 reports::{FillReport, OrderStatusReport, PositionStatusReport},
193 types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
194};
195use rust_decimal::Decimal;
196use ustr::Ustr;
197
198use crate::{
199 common::{
200 enums::{
201 BybitBboSideType, BybitContractType, BybitKlineInterval, BybitMarginTrading,
202 BybitMarketUnit, BybitOptionType, BybitOrderSide, BybitOrderSmpType, BybitOrderStatus,
203 BybitOrderType, BybitPositionIdx, BybitPositionMode, BybitPositionSide,
204 BybitProductType, BybitStopOrderType, BybitSymbolType, BybitTimeInForce, BybitTpSlMode,
205 BybitTriggerDirection, BybitTriggerType,
206 },
207 symbol::BybitSymbol,
208 },
209 http::{
210 models::{
211 BybitExecution, BybitFunding, BybitInstrumentInverse, BybitInstrumentLinear,
212 BybitInstrumentOption, BybitInstrumentSpot, BybitKline, BybitOrderbookResult,
213 BybitPosition, BybitTrade, BybitWalletBalance,
214 },
215 query::BybitNativeTpSlParams,
216 },
217 websocket::parse::parse_millis_i64,
218};
219
220const BYBIT_HOUR_INTERVALS: &[u64] = &[1, 2, 4, 6, 12];
221
222const BYBIT_POST_ONLY_REJECT_REASON: &str = "EC_PostOnlyWillTakeLiquidity";
223
224#[must_use]
227pub fn bybit_rejection_due_post_only(reason: &str) -> bool {
228 reason.contains(BYBIT_POST_ONLY_REJECT_REASON)
229}
230
231#[must_use]
233pub fn extract_raw_symbol(symbol: &str) -> &str {
234 symbol.rsplit_once('-').map_or(symbol, |(prefix, _)| prefix)
235}
236
237#[must_use]
241pub fn extract_base_coin(symbol: &str) -> &str {
242 symbol.split_once('-').map_or(symbol, |(base, _)| base)
243}
244
245#[must_use]
249pub fn make_bybit_symbol<S: AsRef<str>>(raw_symbol: S, product_type: BybitProductType) -> Ustr {
250 let raw = raw_symbol.as_ref();
251 Ustr::from(&format!("{raw}{}", product_type.suffix()))
252}
253
254#[must_use]
258pub fn bybit_interval_to_bar_spec(interval: &str) -> Option<(usize, BarAggregation)> {
259 match interval {
260 "1" => Some((1, BarAggregation::Minute)),
261 "3" => Some((3, BarAggregation::Minute)),
262 "5" => Some((5, BarAggregation::Minute)),
263 "15" => Some((15, BarAggregation::Minute)),
264 "30" => Some((30, BarAggregation::Minute)),
265 "60" => Some((1, BarAggregation::Hour)),
266 "120" => Some((2, BarAggregation::Hour)),
267 "240" => Some((4, BarAggregation::Hour)),
268 "360" => Some((6, BarAggregation::Hour)),
269 "720" => Some((12, BarAggregation::Hour)),
270 "D" => Some((1, BarAggregation::Day)),
271 "W" => Some((1, BarAggregation::Week)),
272 "M" => Some((1, BarAggregation::Month)),
273 _ => None,
274 }
275}
276
277pub fn bar_spec_to_bybit_interval(
285 aggregation: BarAggregation,
286 step: u64,
287) -> anyhow::Result<BybitKlineInterval> {
288 match aggregation {
289 BarAggregation::Minute => match step {
290 1 => Ok(BybitKlineInterval::Minute1),
291 3 => Ok(BybitKlineInterval::Minute3),
292 5 => Ok(BybitKlineInterval::Minute5),
293 15 => Ok(BybitKlineInterval::Minute15),
294 30 => Ok(BybitKlineInterval::Minute30),
295 _ => anyhow::bail!(
296 "Bybit only supports minute intervals 1, 3, 5, 15, 30 (use HOUR for >= 60)"
297 ),
298 },
299 BarAggregation::Hour => match step {
300 1 => Ok(BybitKlineInterval::Hour1),
301 2 => Ok(BybitKlineInterval::Hour2),
302 4 => Ok(BybitKlineInterval::Hour4),
303 6 => Ok(BybitKlineInterval::Hour6),
304 12 => Ok(BybitKlineInterval::Hour12),
305 _ => anyhow::bail!(
306 "Bybit only supports the following hour intervals: {BYBIT_HOUR_INTERVALS:?}"
307 ),
308 },
309 BarAggregation::Day => {
310 if step != 1 {
311 anyhow::bail!("Bybit only supports 1 DAY interval bars");
312 }
313 Ok(BybitKlineInterval::Day1)
314 }
315 BarAggregation::Week => {
316 if step != 1 {
317 anyhow::bail!("Bybit only supports 1 WEEK interval bars");
318 }
319 Ok(BybitKlineInterval::Week1)
320 }
321 BarAggregation::Month => {
322 if step != 1 {
323 anyhow::bail!("Bybit only supports 1 MONTH interval bars");
324 }
325 Ok(BybitKlineInterval::Month1)
326 }
327 _ => {
328 anyhow::bail!("Bybit does not support {aggregation:?} bars");
329 }
330 }
331}
332
333fn default_margin() -> Decimal {
334 Decimal::new(1, 1)
335}
336
337pub fn parse_spot_instrument(
343 definition: &BybitInstrumentSpot,
344 ts_event: UnixNanos,
345 ts_init: UnixNanos,
346) -> anyhow::Result<InstrumentAny> {
347 let base_currency = get_currency(definition.base_coin.as_str());
348 let quote_currency = get_currency(definition.quote_coin.as_str());
349
350 let symbol = BybitSymbol::new(format!("{}-SPOT", definition.symbol))?;
351 let instrument_id = symbol.to_instrument_id();
352 let raw_symbol = Symbol::new(symbol.raw_symbol());
353
354 let price_increment = parse_price(&definition.price_filter.tick_size, "priceFilter.tickSize")?;
355 let size_increment = parse_quantity(
356 &definition.lot_size_filter.base_precision,
357 "lotSizeFilter.basePrecision",
358 )?;
359 let lot_size = Some(size_increment);
360 let max_quantity = Some(parse_quantity(
361 &definition.lot_size_filter.max_order_qty,
362 "lotSizeFilter.maxOrderQty",
363 )?);
364 let min_quantity = Some(parse_quantity(
365 &definition.lot_size_filter.min_order_qty,
366 "lotSizeFilter.minOrderQty",
367 )?);
368 let min_notional = Some(Money::from_decimal(
369 parse_decimal(
370 &definition.lot_size_filter.min_order_amt,
371 "lotSizeFilter.minOrderAmt",
372 )?,
373 quote_currency,
374 )?);
375
376 let margin_trading_supported = matches!(
377 definition.margin_trading,
378 BybitMarginTrading::Both | BybitMarginTrading::UtaOnly
379 );
380
381 let mut info = build_instrument_info(
382 definition.symbol_type,
383 definition.xstock_multiplier.as_deref(),
384 )
385 .unwrap_or_default();
386 info.insert(
387 "margin_trading".to_string(),
388 serde_json::Value::Bool(margin_trading_supported),
389 );
390
391 let instrument = CurrencyPair::builder()
392 .instrument_id(instrument_id)
393 .raw_symbol(raw_symbol)
394 .base_currency(base_currency)
395 .quote_currency(quote_currency)
396 .price_precision(price_increment.precision)
397 .size_precision(size_increment.precision)
398 .price_increment(price_increment)
399 .size_increment(size_increment)
400 .maybe_lot_size(lot_size)
401 .maybe_max_quantity(max_quantity)
402 .maybe_min_quantity(min_quantity)
403 .maybe_min_notional(min_notional)
404 .margin_init(default_margin())
405 .margin_maint(default_margin())
406 .info(info)
407 .ts_event(ts_event)
408 .ts_init(ts_init)
409 .build()
410 .unwrap();
411
412 Ok(InstrumentAny::CurrencyPair(instrument))
413}
414
415pub fn parse_linear_instrument(
421 definition: &BybitInstrumentLinear,
422 ts_event: UnixNanos,
423 ts_init: UnixNanos,
424) -> anyhow::Result<InstrumentAny> {
425 anyhow::ensure!(
427 !definition.base_coin.is_empty(),
428 "base_coin is empty for symbol '{}'",
429 definition.symbol
430 );
431 anyhow::ensure!(
432 !definition.quote_coin.is_empty(),
433 "quote_coin is empty for symbol '{}'",
434 definition.symbol
435 );
436
437 let base_currency = get_currency(definition.base_coin.as_str());
438 let quote_currency = get_currency(definition.quote_coin.as_str());
439 let settlement_currency = resolve_settlement_currency(
440 definition.settle_coin.as_str(),
441 base_currency,
442 quote_currency,
443 )?;
444
445 let symbol = BybitSymbol::new(format!("{}-LINEAR", definition.symbol))?;
446 let instrument_id = symbol.to_instrument_id();
447 let raw_symbol = Symbol::new(symbol.raw_symbol());
448
449 let price_increment = parse_price(&definition.price_filter.tick_size, "priceFilter.tickSize")?;
450 let size_increment = parse_quantity(
451 &definition.lot_size_filter.qty_step,
452 "lotSizeFilter.qtyStep",
453 )?;
454 let lot_size = Some(size_increment);
455 let max_quantity = Some(parse_quantity(
456 &definition.lot_size_filter.max_order_qty,
457 "lotSizeFilter.maxOrderQty",
458 )?);
459 let min_quantity = Some(parse_quantity(
460 &definition.lot_size_filter.min_order_qty,
461 "lotSizeFilter.minOrderQty",
462 )?);
463 let max_price = Some(parse_price(
464 &definition.price_filter.max_price,
465 "priceFilter.maxPrice",
466 )?);
467 let min_price = Some(parse_price(
468 &definition.price_filter.min_price,
469 "priceFilter.minPrice",
470 )?);
471 let min_notional = parse_optional_notional(
472 definition.lot_size_filter.min_notional_value.as_deref(),
473 quote_currency,
474 "lotSizeFilter.minNotionalValue",
475 )?;
476
477 let info = build_instrument_info(definition.symbol_type, None);
478
479 match definition.contract_type {
480 BybitContractType::LinearPerpetual => {
481 let instrument = CryptoPerpetual::builder()
482 .instrument_id(instrument_id)
483 .raw_symbol(raw_symbol)
484 .base_currency(base_currency)
485 .quote_currency(quote_currency)
486 .settlement_currency(settlement_currency)
487 .is_inverse(false)
488 .price_precision(price_increment.precision)
489 .size_precision(size_increment.precision)
490 .price_increment(price_increment)
491 .size_increment(size_increment)
492 .maybe_lot_size(lot_size)
493 .maybe_max_quantity(max_quantity)
494 .maybe_min_quantity(min_quantity)
495 .maybe_min_notional(min_notional)
496 .maybe_max_price(max_price)
497 .maybe_min_price(min_price)
498 .margin_init(default_margin())
499 .margin_maint(default_margin())
500 .maybe_info(info)
501 .ts_event(ts_event)
502 .ts_init(ts_init)
503 .build()
504 .unwrap();
505 Ok(InstrumentAny::CryptoPerpetual(instrument))
506 }
507 BybitContractType::LinearFutures => {
508 let activation_ns = parse_millis_timestamp(&definition.launch_time, "launchTime")?;
509 let expiration_ns = parse_millis_timestamp(&definition.delivery_time, "deliveryTime")?;
510 let instrument = CryptoFuture::builder()
511 .instrument_id(instrument_id)
512 .raw_symbol(raw_symbol)
513 .underlying(base_currency)
514 .quote_currency(quote_currency)
515 .settlement_currency(settlement_currency)
516 .is_inverse(false)
517 .activation_ns(activation_ns)
518 .expiration_ns(expiration_ns)
519 .price_precision(price_increment.precision)
520 .size_precision(size_increment.precision)
521 .price_increment(price_increment)
522 .size_increment(size_increment)
523 .maybe_lot_size(lot_size)
524 .maybe_max_quantity(max_quantity)
525 .maybe_min_quantity(min_quantity)
526 .maybe_min_notional(min_notional)
527 .maybe_max_price(max_price)
528 .maybe_min_price(min_price)
529 .margin_init(default_margin())
530 .margin_maint(default_margin())
531 .maybe_info(info)
532 .ts_event(ts_event)
533 .ts_init(ts_init)
534 .build()
535 .unwrap();
536 Ok(InstrumentAny::CryptoFuture(instrument))
537 }
538 other => Err(anyhow::anyhow!(
539 "unsupported linear contract variant: {other:?}"
540 )),
541 }
542}
543
544fn parse_optional_notional(
548 raw: Option<&str>,
549 currency: Currency,
550 field: &str,
551) -> anyhow::Result<Option<Money>> {
552 let Some(s) = raw.map(str::trim).filter(|s| !s.is_empty()) else {
553 return Ok(None);
554 };
555 let amount: f64 = s
556 .parse()
557 .with_context(|| format!("invalid f64 for {field}: {s:?}"))?;
558
559 if !amount.is_finite() || amount <= 0.0 {
560 return Ok(None);
561 }
562 Ok(Some(Money::new(amount, currency)))
563}
564
565pub fn parse_inverse_instrument(
571 definition: &BybitInstrumentInverse,
572 ts_event: UnixNanos,
573 ts_init: UnixNanos,
574) -> anyhow::Result<InstrumentAny> {
575 anyhow::ensure!(
577 !definition.base_coin.is_empty(),
578 "base_coin is empty for symbol '{}'",
579 definition.symbol
580 );
581 anyhow::ensure!(
582 !definition.quote_coin.is_empty(),
583 "quote_coin is empty for symbol '{}'",
584 definition.symbol
585 );
586
587 let base_currency = get_currency(definition.base_coin.as_str());
588 let quote_currency = get_currency(definition.quote_coin.as_str());
589 let settlement_currency = resolve_settlement_currency(
590 definition.settle_coin.as_str(),
591 base_currency,
592 quote_currency,
593 )?;
594
595 let symbol = BybitSymbol::new(format!("{}-INVERSE", definition.symbol))?;
596 let instrument_id = symbol.to_instrument_id();
597 let raw_symbol = Symbol::new(symbol.raw_symbol());
598
599 let price_increment = parse_price(&definition.price_filter.tick_size, "priceFilter.tickSize")?;
600 let size_increment = parse_quantity(
601 &definition.lot_size_filter.qty_step,
602 "lotSizeFilter.qtyStep",
603 )?;
604 let lot_size = Some(size_increment);
605 let max_quantity = Some(parse_quantity(
606 &definition.lot_size_filter.max_order_qty,
607 "lotSizeFilter.maxOrderQty",
608 )?);
609 let min_quantity = Some(parse_quantity(
610 &definition.lot_size_filter.min_order_qty,
611 "lotSizeFilter.minOrderQty",
612 )?);
613 let max_price = Some(parse_price(
614 &definition.price_filter.max_price,
615 "priceFilter.maxPrice",
616 )?);
617 let min_price = Some(parse_price(
618 &definition.price_filter.min_price,
619 "priceFilter.minPrice",
620 )?);
621 let min_notional = parse_optional_notional(
622 definition.lot_size_filter.min_notional_value.as_deref(),
623 quote_currency,
624 "lotSizeFilter.minNotionalValue",
625 )?;
626
627 let info = build_instrument_info(definition.symbol_type, None);
628
629 match definition.contract_type {
630 BybitContractType::InversePerpetual => {
631 let instrument = CryptoPerpetual::builder()
632 .instrument_id(instrument_id)
633 .raw_symbol(raw_symbol)
634 .base_currency(base_currency)
635 .quote_currency(quote_currency)
636 .settlement_currency(settlement_currency)
637 .is_inverse(true)
638 .price_precision(price_increment.precision)
639 .size_precision(size_increment.precision)
640 .price_increment(price_increment)
641 .size_increment(size_increment)
642 .maybe_lot_size(lot_size)
643 .maybe_max_quantity(max_quantity)
644 .maybe_min_quantity(min_quantity)
645 .maybe_min_notional(min_notional)
646 .maybe_max_price(max_price)
647 .maybe_min_price(min_price)
648 .margin_init(default_margin())
649 .margin_maint(default_margin())
650 .maybe_info(info)
651 .ts_event(ts_event)
652 .ts_init(ts_init)
653 .build()
654 .unwrap();
655 Ok(InstrumentAny::CryptoPerpetual(instrument))
656 }
657 BybitContractType::InverseFutures => {
658 let activation_ns = parse_millis_timestamp(&definition.launch_time, "launchTime")?;
659 let expiration_ns = parse_millis_timestamp(&definition.delivery_time, "deliveryTime")?;
660 let instrument = CryptoFuture::builder()
661 .instrument_id(instrument_id)
662 .raw_symbol(raw_symbol)
663 .underlying(base_currency)
664 .quote_currency(quote_currency)
665 .settlement_currency(settlement_currency)
666 .is_inverse(true)
667 .activation_ns(activation_ns)
668 .expiration_ns(expiration_ns)
669 .price_precision(price_increment.precision)
670 .size_precision(size_increment.precision)
671 .price_increment(price_increment)
672 .size_increment(size_increment)
673 .maybe_lot_size(lot_size)
674 .maybe_max_quantity(max_quantity)
675 .maybe_min_quantity(min_quantity)
676 .maybe_min_notional(min_notional)
677 .maybe_max_price(max_price)
678 .maybe_min_price(min_price)
679 .margin_init(default_margin())
680 .margin_maint(default_margin())
681 .maybe_info(info)
682 .ts_event(ts_event)
683 .ts_init(ts_init)
684 .build()
685 .unwrap();
686 Ok(InstrumentAny::CryptoFuture(instrument))
687 }
688 other => Err(anyhow::anyhow!(
689 "unsupported inverse contract variant: {other:?}"
690 )),
691 }
692}
693
694fn build_instrument_info(
695 symbol_type: Option<BybitSymbolType>,
696 xstock_multiplier: Option<&str>,
697) -> Option<Params> {
698 let symbol_type = symbol_type?;
699 let value = symbol_type.as_str()?;
700 let mut info = Params::new();
701 info.insert(
702 "symbol_type".to_string(),
703 serde_json::Value::String(value.to_string()),
704 );
705
706 if symbol_type == BybitSymbolType::Xstocks
707 && let Some(multiplier) = xstock_multiplier.filter(|value| !value.is_empty())
708 {
709 info.insert(
710 "xstock_multiplier".to_string(),
711 serde_json::Value::String(multiplier.to_string()),
712 );
713 }
714
715 Some(info)
716}
717
718pub fn parse_option_instrument(
724 definition: &BybitInstrumentOption,
725 ts_event: UnixNanos,
726 ts_init: UnixNanos,
727) -> anyhow::Result<InstrumentAny> {
728 let symbol = BybitSymbol::new(format!("{}-OPTION", definition.symbol))?;
729 let instrument_id = symbol.to_instrument_id();
730 let raw_symbol = Symbol::new(symbol.raw_symbol());
731 let underlying = get_currency(definition.base_coin.as_str());
732 let quote_currency = get_currency(definition.quote_coin.as_str());
733 let settlement_currency = get_currency(definition.settle_coin.as_str());
734 let is_inverse = false;
736
737 let price_increment = parse_price(&definition.price_filter.tick_size, "priceFilter.tickSize")?;
738 let max_price = Some(parse_price(
739 &definition.price_filter.max_price,
740 "priceFilter.maxPrice",
741 )?);
742 let min_price = Some(parse_price(
743 &definition.price_filter.min_price,
744 "priceFilter.minPrice",
745 )?);
746 let lot_size = parse_quantity(
747 &definition.lot_size_filter.qty_step,
748 "lotSizeFilter.qtyStep",
749 )?;
750 let max_quantity = Some(parse_quantity(
751 &definition.lot_size_filter.max_order_qty,
752 "lotSizeFilter.maxOrderQty",
753 )?);
754 let min_quantity = Some(parse_quantity(
755 &definition.lot_size_filter.min_order_qty,
756 "lotSizeFilter.minOrderQty",
757 )?);
758
759 let option_kind = match definition.options_type {
760 BybitOptionType::Call => OptionKind::Call,
761 BybitOptionType::Put => OptionKind::Put,
762 };
763
764 let strike_price = extract_strike_from_symbol(&definition.symbol)?;
765 let activation_ns = parse_millis_timestamp(&definition.launch_time, "launchTime")?;
766 let expiration_ns = parse_millis_timestamp(&definition.delivery_time, "deliveryTime")?;
767
768 let instrument = CryptoOption::builder()
769 .instrument_id(instrument_id)
770 .raw_symbol(raw_symbol)
771 .underlying(underlying)
772 .quote_currency(quote_currency)
773 .settlement_currency(settlement_currency)
774 .is_inverse(is_inverse)
775 .option_kind(option_kind)
776 .strike_price(strike_price)
777 .activation_ns(activation_ns)
778 .expiration_ns(expiration_ns)
779 .price_precision(price_increment.precision)
780 .size_precision(lot_size.precision)
781 .price_increment(price_increment)
782 .size_increment(lot_size)
784 .multiplier(Quantity::from(1_u32))
785 .lot_size(lot_size)
786 .maybe_max_quantity(max_quantity)
787 .maybe_min_quantity(min_quantity)
788 .maybe_max_price(max_price)
789 .maybe_min_price(min_price)
790 .ts_event(ts_event)
791 .ts_init(ts_init)
792 .build()
793 .unwrap();
794
795 Ok(InstrumentAny::CryptoOption(instrument))
796}
797
798pub fn parse_trade_tick(
800 trade: &BybitTrade,
801 instrument: &InstrumentAny,
802 ts_init: Option<UnixNanos>,
803) -> anyhow::Result<TradeTick> {
804 let price =
805 parse_price_with_precision(&trade.price, instrument.price_precision(), "trade.price")?;
806 let size =
807 parse_quantity_with_precision(&trade.size, instrument.size_precision(), "trade.size")?;
808 let aggressor: AggressorSide = trade.side.into();
809 let trade_id = TradeId::new_checked(trade.exec_id.as_str())
810 .context("invalid exec_id in Bybit trade payload")?;
811 let ts_event = parse_millis_timestamp(&trade.time, "trade.time")?;
812 let ts_init = ts_init.unwrap_or(ts_event);
813
814 TradeTick::new_checked(
815 instrument.id(),
816 price,
817 size,
818 aggressor,
819 trade_id,
820 ts_event,
821 ts_init,
822 )
823 .context("failed to construct TradeTick from Bybit trade payload")
824}
825
826pub fn parse_funding_rate(
828 funding: &BybitFunding,
829 instrument: &InstrumentAny,
830 interval_millis: Option<i64>,
831) -> anyhow::Result<FundingRateUpdate> {
832 let rate = parse_decimal(&funding.funding_rate, "funding.rate")?;
833 let ts_event = parse_millis_timestamp(&funding.funding_rate_timestamp, "funding.timestamp")?;
834 let interval = interval_millis
835 .map(|ms| u16::try_from(ms / 60_000).context("interval milliseconds out of bounds"))
836 .transpose()?;
837
838 Ok(FundingRateUpdate::new(
839 instrument.id(),
840 rate,
841 interval,
842 None, ts_event,
844 ts_event,
845 ))
846}
847
848pub fn parse_orderbook(
850 result: &BybitOrderbookResult,
851 instrument: &InstrumentAny,
852 ts_init: Option<UnixNanos>,
853) -> anyhow::Result<OrderBookDeltas> {
854 let ts_event = parse_millis_i64(result.ts, "orderbook.timestamp")?;
855 let ts_init = ts_init.unwrap_or(ts_event);
856
857 let instrument_id = instrument.id();
858 let price_precision = instrument.price_precision();
859 let size_precision = instrument.size_precision();
860 let update_id = u64::try_from(result.u)
861 .context("received negative update id in Bybit order book message")?;
862 let sequence = u64::try_from(result.seq)
863 .context("received negative sequence in Bybit order book message")?;
864
865 let total_levels = result.b.len() + result.a.len();
866 let mut deltas = Vec::with_capacity(total_levels + 1);
867
868 let mut clear = OrderBookDelta::clear(instrument_id, sequence, ts_event, ts_init);
869
870 if total_levels == 0 {
871 clear.flags |= RecordFlag::F_LAST as u8;
872 }
873 deltas.push(clear);
874
875 let mut processed = 0_usize;
876
877 let mut push_level = |values: &[String], side: OrderSide| -> anyhow::Result<()> {
878 let (price, size) = parse_book_level(values, price_precision, size_precision, "orderbook")?;
879
880 processed += 1;
881 let mut flags = RecordFlag::F_MBP as u8;
882
883 if processed == total_levels {
884 flags |= RecordFlag::F_LAST as u8;
885 }
886
887 let order = BookOrder::new(side, price, size, update_id);
888 let delta = OrderBookDelta::new_checked(
889 instrument_id,
890 BookAction::Add,
891 order,
892 flags,
893 sequence,
894 ts_event,
895 ts_init,
896 )
897 .context("failed to construct OrderBookDelta from Bybit book level")?;
898 deltas.push(delta);
899 Ok(())
900 };
901
902 for level in &result.b {
903 push_level(level, OrderSide::Buy)?;
904 }
905
906 for level in &result.a {
907 push_level(level, OrderSide::Sell)?;
908 }
909
910 OrderBookDeltas::new_checked(instrument_id, deltas)
911 .context("failed to assemble OrderBookDeltas from Bybit message")
912}
913
914pub fn parse_book_level(
915 level: &[String],
916 price_precision: u8,
917 size_precision: u8,
918 label: &str,
919) -> anyhow::Result<(Price, Quantity)> {
920 let price_str = level
921 .first()
922 .ok_or_else(|| anyhow::anyhow!("missing price component in {label} level"))?;
923 let size_str = level
924 .get(1)
925 .ok_or_else(|| anyhow::anyhow!("missing size component in {label} level"))?;
926 let price = parse_price_with_precision(price_str, price_precision, label)?;
927 let size = parse_quantity_with_precision(size_str, size_precision, label)?;
928 Ok((price, size))
929}
930
931pub fn parse_kline_bar(
933 kline: &BybitKline,
934 instrument: &InstrumentAny,
935 bar_type: BarType,
936 timestamp_on_close: bool,
937 ts_init: Option<UnixNanos>,
938) -> anyhow::Result<Bar> {
939 let price_precision = instrument.price_precision();
940 let size_precision = instrument.size_precision();
941
942 let open = parse_price_with_precision(&kline.open, price_precision, "kline.open")?;
943 let high = parse_price_with_precision(&kline.high, price_precision, "kline.high")?;
944 let low = parse_price_with_precision(&kline.low, price_precision, "kline.low")?;
945 let close = parse_price_with_precision(&kline.close, price_precision, "kline.close")?;
946 let volume = parse_quantity_with_precision(&kline.volume, size_precision, "kline.volume")?;
947
948 let ts_open = parse_millis_timestamp(&kline.start, "kline.start")?;
949 let interval_ns = bar_type.spec().timedelta().as_nanos();
950 let interval_ns = u64::try_from(interval_ns)
951 .context("bar interval overflowed the u64 range for nanoseconds")?;
952 let ts_close = ts_open
953 .as_u64()
954 .checked_add(interval_ns)
955 .map(UnixNanos::from)
956 .context("bar timestamp overflowed when adjusting to close time")?;
957
958 let ts_event = if timestamp_on_close {
959 ts_close
960 } else {
961 ts_open
962 };
963 let ts_init = ts_init.unwrap_or(ts_close); Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
966 .context("failed to construct Bar from Bybit kline entry")
967}
968
969#[must_use]
973pub fn make_venue_position_id(instrument_id: InstrumentId, position_idx: i32) -> PositionId {
974 let side = match position_idx {
975 0 => "ONEWAY",
976 1 => "LONG",
977 2 => "SHORT",
978 _ => "UNKNOWN",
979 };
980 PositionId::new(format!("{instrument_id}-{side}"))
981}
982
983#[must_use]
985pub fn make_hedge_venue_position_id(
986 instrument_id: InstrumentId,
987 position_idx: i32,
988) -> Option<PositionId> {
989 match position_idx {
990 1 | 2 => Some(make_venue_position_id(instrument_id, position_idx)),
991 _ => None,
992 }
993}
994
995#[must_use]
1001pub fn resolve_position_idx(
1002 position_mode: Option<BybitPositionMode>,
1003 order_side: BybitOrderSide,
1004 is_reduce_only: bool,
1005 manual_override: Option<BybitPositionIdx>,
1006) -> Option<BybitPositionIdx> {
1007 if manual_override.is_some() {
1008 return manual_override;
1009 }
1010
1011 let mode = position_mode?;
1012 match mode {
1013 BybitPositionMode::BothSides => Some(match (order_side, is_reduce_only) {
1014 (BybitOrderSide::Buy, false) | (BybitOrderSide::Sell, true) => {
1015 BybitPositionIdx::BuyHedge
1016 }
1017 (BybitOrderSide::Sell, false) | (BybitOrderSide::Buy, true) => {
1018 BybitPositionIdx::SellHedge
1019 }
1020 (BybitOrderSide::Unknown, _) => BybitPositionIdx::OneWay,
1021 }),
1022 BybitPositionMode::MergedSingle => Some(BybitPositionIdx::OneWay),
1023 }
1024}
1025
1026pub fn parse_fill_report(
1035 execution: &BybitExecution,
1036 account_id: AccountId,
1037 instrument: &InstrumentAny,
1038 ts_init: UnixNanos,
1039) -> anyhow::Result<FillReport> {
1040 let instrument_id = instrument.id();
1041 let venue_order_id = VenueOrderId::new(execution.order_id);
1042 let trade_id = TradeId::new_checked(execution.exec_id.as_str())
1043 .context("invalid execId in Bybit execution payload")?;
1044
1045 let order_side = OrderSide::try_from(execution.side)?;
1046
1047 let last_px = parse_price_with_precision(
1048 &execution.exec_price,
1049 instrument.price_precision(),
1050 "execution.execPrice",
1051 )?;
1052
1053 let last_qty = parse_quantity_with_precision(
1054 &execution.exec_qty,
1055 instrument.size_precision(),
1056 "execution.execQty",
1057 )?;
1058
1059 let fee_decimal: Decimal = execution
1060 .exec_fee
1061 .parse()
1062 .with_context(|| format!("Failed to parse execFee='{}'", execution.exec_fee))?;
1063 let currency = get_currency(&execution.fee_currency);
1064 let commission = Money::from_decimal(fee_decimal, currency).with_context(|| {
1065 format!(
1066 "Failed to create commission from execFee='{}'",
1067 execution.exec_fee
1068 )
1069 })?;
1070
1071 let liquidity_side = if execution.is_maker {
1073 LiquiditySide::Maker
1074 } else {
1075 LiquiditySide::Taker
1076 };
1077
1078 let ts_event = parse_millis_timestamp(&execution.exec_time, "execution.execTime")?;
1079
1080 let client_order_id = if execution.order_link_id.is_empty() {
1082 None
1083 } else {
1084 Some(ClientOrderId::new(execution.order_link_id))
1085 };
1086
1087 Ok(FillReport::new(
1088 account_id,
1089 instrument_id,
1090 venue_order_id,
1091 trade_id,
1092 order_side,
1093 last_qty,
1094 last_px,
1095 commission,
1096 liquidity_side,
1097 client_order_id,
1098 None, ts_event,
1100 ts_init,
1101 None, ))
1103}
1104
1105pub fn parse_position_status_report(
1114 position: &BybitPosition,
1115 account_id: AccountId,
1116 instrument: &InstrumentAny,
1117 ts_init: UnixNanos,
1118) -> anyhow::Result<PositionStatusReport> {
1119 let instrument_id = instrument.id();
1120
1121 let size = parse_quantity_with_precision(
1122 &position.size,
1123 instrument.size_precision(),
1124 "position.size",
1125 )?;
1126
1127 let (position_side, quantity) = match position.side {
1129 BybitPositionSide::Buy => (PositionSide::Long, size),
1130 BybitPositionSide::Sell => (PositionSide::Short, size),
1131 BybitPositionSide::Flat => {
1132 let qty = Quantity::zero(instrument.size_precision());
1133 (PositionSide::Flat, qty)
1134 }
1135 };
1136
1137 let avg_px_open = if position.avg_price.is_empty() || position.avg_price == "0" {
1139 None
1140 } else {
1141 Some(Decimal::from_str(&position.avg_price)?)
1142 };
1143
1144 let ts_last = if position.updated_time.is_empty() {
1146 ts_init
1147 } else {
1148 parse_millis_timestamp(&position.updated_time, "position.updatedTime")?
1149 };
1150
1151 if position.adl_rank_indicator >= 4 {
1154 log::warn!(
1155 "Elevated ADL risk: {} position size={} adl_rank={}",
1156 instrument_id,
1157 position.size,
1158 position.adl_rank_indicator,
1159 );
1160 }
1161
1162 let venue_position_id =
1163 make_hedge_venue_position_id(instrument_id, position.position_idx as i32);
1164
1165 Ok(PositionStatusReport::new(
1166 account_id,
1167 instrument_id,
1168 position_side,
1169 quantity,
1170 ts_last,
1171 ts_init,
1172 None, venue_position_id,
1174 avg_px_open,
1175 ))
1176}
1177
1178pub fn parse_account_state(
1186 wallet_balance: &BybitWalletBalance,
1187 account_id: AccountId,
1188 ts_init: UnixNanos,
1189) -> anyhow::Result<AccountState> {
1190 let mut balances = Vec::new();
1191
1192 for coin in &wallet_balance.coin {
1193 let total_dec = coin.wallet_balance - coin.spot_borrow;
1194 let locked_dec = coin.locked;
1195
1196 let currency = get_currency(&coin.coin);
1197 balances.push(AccountBalance::from_total_and_locked(
1198 total_dec, locked_dec, currency,
1199 )?);
1200 }
1201
1202 let mut margins = Vec::new();
1203
1204 for coin in &wallet_balance.coin {
1205 let position_im_f64 = match &coin.total_position_im {
1209 Some(im) if !im.is_empty() => im.parse::<f64>()?,
1210 _ => 0.0,
1211 };
1212 let order_im_f64 = match &coin.total_order_im {
1213 Some(im) if !im.is_empty() => im.parse::<f64>()?,
1214 _ => 0.0,
1215 };
1216 let initial_margin_f64 = position_im_f64 + order_im_f64;
1217
1218 let maintenance_margin_f64 = match &coin.total_position_mm {
1219 Some(mm) if !mm.is_empty() => mm.parse::<f64>()?,
1220 _ => 0.0,
1221 };
1222
1223 if initial_margin_f64 == 0.0 && maintenance_margin_f64 == 0.0 {
1224 continue;
1225 }
1226
1227 let currency = get_currency(&coin.coin);
1228 let initial_margin = Money::new(initial_margin_f64, currency);
1229 let maintenance_margin = Money::new(maintenance_margin_f64, currency);
1230
1231 margins.push(MarginBalance::new(initial_margin, maintenance_margin, None));
1232 }
1233
1234 let account_type = AccountType::Margin;
1235 let is_reported = true;
1236 let event_id = UUID4::new();
1237
1238 let ts_event = ts_init;
1240
1241 Ok(AccountState::new(
1242 account_id,
1243 account_type,
1244 balances,
1245 margins,
1246 is_reported,
1247 event_id,
1248 ts_event,
1249 ts_init,
1250 None,
1251 ))
1252}
1253
1254pub(crate) fn parse_price_with_precision(
1255 value: &str,
1256 precision: u8,
1257 field: &str,
1258) -> anyhow::Result<Price> {
1259 let parsed = parse_decimal(value, field)?;
1260 Price::from_decimal_dp(parsed, precision).with_context(|| {
1261 format!("Failed to construct Price for {field} with precision {precision}")
1262 })
1263}
1264
1265pub(crate) fn parse_quantity_with_precision(
1266 value: &str,
1267 precision: u8,
1268 field: &str,
1269) -> anyhow::Result<Quantity> {
1270 let parsed = parse_decimal(value, field)?;
1271 Quantity::from_decimal_dp(parsed, precision).with_context(|| {
1272 format!("Failed to construct Quantity for {field} with precision {precision}")
1273 })
1274}
1275
1276pub(crate) fn parse_price(value: &str, field: &str) -> anyhow::Result<Price> {
1277 Price::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
1278}
1279
1280pub(crate) fn parse_quantity(value: &str, field: &str) -> anyhow::Result<Quantity> {
1281 Quantity::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
1282}
1283
1284pub(crate) fn parse_decimal(value: &str, field: &str) -> anyhow::Result<Decimal> {
1285 Decimal::from_str(value)
1286 .map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}' as Decimal: {e}"))
1287}
1288
1289pub(crate) fn parse_millis_timestamp(value: &str, field: &str) -> anyhow::Result<UnixNanos> {
1290 let millis: u64 = value
1291 .parse()
1292 .with_context(|| format!("Failed to parse {field}='{value}' as u64 millis"))?;
1293 let nanos = millis
1294 .checked_mul(NANOSECONDS_IN_MILLISECOND)
1295 .context("millisecond timestamp overflowed when converting to nanoseconds")?;
1296 Ok(UnixNanos::from(nanos))
1297}
1298
1299fn resolve_settlement_currency(
1300 settle_coin: &str,
1301 base_currency: Currency,
1302 quote_currency: Currency,
1303) -> anyhow::Result<Currency> {
1304 if settle_coin.eq_ignore_ascii_case(base_currency.code.as_str()) {
1305 Ok(base_currency)
1306 } else if settle_coin.eq_ignore_ascii_case(quote_currency.code.as_str()) {
1307 Ok(quote_currency)
1308 } else {
1309 Err(anyhow::anyhow!(
1310 "unrecognized settlement currency '{settle_coin}'"
1311 ))
1312 }
1313}
1314
1315pub fn get_currency(code: &str) -> Currency {
1320 Currency::get_or_create_crypto(code)
1321}
1322
1323fn extract_strike_from_symbol(symbol: &str) -> anyhow::Result<Price> {
1324 let parts: Vec<&str> = symbol.split('-').collect();
1325 let strike = parts
1326 .get(2)
1327 .ok_or_else(|| anyhow::anyhow!("invalid option symbol '{symbol}'"))?;
1328 parse_price(strike, "option strike")
1329}
1330
1331#[must_use]
1341pub fn parse_bybit_order_type(
1342 order_type: BybitOrderType,
1343 stop_order_type: BybitStopOrderType,
1344 trigger_direction: BybitTriggerDirection,
1345 side: BybitOrderSide,
1346) -> OrderType {
1347 if matches!(
1348 stop_order_type,
1349 BybitStopOrderType::None | BybitStopOrderType::Unknown
1350 ) {
1351 return match order_type {
1352 BybitOrderType::Market => OrderType::Market,
1353 BybitOrderType::Limit | BybitOrderType::Unknown => OrderType::Limit,
1354 };
1355 }
1356
1357 if trigger_direction == BybitTriggerDirection::None {
1360 return match order_type {
1361 BybitOrderType::Market => OrderType::Market,
1362 BybitOrderType::Limit | BybitOrderType::Unknown => OrderType::Limit,
1363 };
1364 }
1365
1366 match (order_type, trigger_direction, side) {
1369 (BybitOrderType::Market, BybitTriggerDirection::RisesTo, BybitOrderSide::Buy) => {
1370 OrderType::StopMarket
1371 }
1372 (BybitOrderType::Market, BybitTriggerDirection::FallsTo, BybitOrderSide::Buy) => {
1373 OrderType::MarketIfTouched
1374 }
1375 (BybitOrderType::Market, BybitTriggerDirection::FallsTo, BybitOrderSide::Sell) => {
1376 OrderType::StopMarket
1377 }
1378 (BybitOrderType::Market, BybitTriggerDirection::RisesTo, BybitOrderSide::Sell) => {
1379 OrderType::MarketIfTouched
1380 }
1381 (BybitOrderType::Limit, BybitTriggerDirection::RisesTo, BybitOrderSide::Buy) => {
1382 OrderType::StopLimit
1383 }
1384 (BybitOrderType::Limit, BybitTriggerDirection::FallsTo, BybitOrderSide::Buy) => {
1385 OrderType::LimitIfTouched
1386 }
1387 (BybitOrderType::Limit, BybitTriggerDirection::FallsTo, BybitOrderSide::Sell) => {
1388 OrderType::StopLimit
1389 }
1390 (BybitOrderType::Limit, BybitTriggerDirection::RisesTo, BybitOrderSide::Sell) => {
1391 OrderType::LimitIfTouched
1392 }
1393 _ => match order_type {
1394 BybitOrderType::Market => OrderType::Market,
1395 BybitOrderType::Limit | BybitOrderType::Unknown => OrderType::Limit,
1396 },
1397 }
1398}
1399
1400pub fn parse_order_status_report(
1402 order: &crate::http::models::BybitOrder,
1403 instrument: &InstrumentAny,
1404 account_id: AccountId,
1405 ts_init: UnixNanos,
1406) -> anyhow::Result<OrderStatusReport> {
1407 let instrument_id = instrument.id();
1408 let venue_order_id = VenueOrderId::new(order.order_id);
1409
1410 let order_side: Option<OrderSide> = order.side.into();
1411
1412 let order_type = parse_bybit_order_type(
1413 order.order_type,
1414 order.stop_order_type,
1415 order.trigger_direction,
1416 order.side,
1417 );
1418
1419 let time_in_force: TimeInForce = match order.time_in_force {
1420 BybitTimeInForce::Gtc => TimeInForce::Gtc,
1421 BybitTimeInForce::Ioc => TimeInForce::Ioc,
1422 BybitTimeInForce::Fok => TimeInForce::Fok,
1423 BybitTimeInForce::PostOnly | BybitTimeInForce::Rpi => TimeInForce::Gtc,
1424 };
1425
1426 let quantity =
1427 parse_quantity_with_precision(&order.qty, instrument.size_precision(), "order.qty")?;
1428
1429 let filled_qty = parse_quantity_with_precision(
1430 &order.cum_exec_qty,
1431 instrument.size_precision(),
1432 "order.cumExecQty",
1433 )?;
1434
1435 let order_status: OrderStatus = match order.order_status {
1441 BybitOrderStatus::Created | BybitOrderStatus::New | BybitOrderStatus::Untriggered => {
1442 OrderStatus::Accepted
1443 }
1444 BybitOrderStatus::Rejected => {
1445 if filled_qty.is_positive() {
1446 OrderStatus::Canceled
1447 } else {
1448 OrderStatus::Rejected
1449 }
1450 }
1451 BybitOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
1452 BybitOrderStatus::Filled => OrderStatus::Filled,
1453 BybitOrderStatus::Canceled
1457 if filled_qty.is_zero()
1458 && bybit_rejection_due_post_only(order.reject_reason.as_str()) =>
1459 {
1460 OrderStatus::Rejected
1461 }
1462 BybitOrderStatus::Canceled | BybitOrderStatus::PartiallyFilledCanceled => {
1463 OrderStatus::Canceled
1464 }
1465 BybitOrderStatus::Triggered => OrderStatus::Triggered,
1466 BybitOrderStatus::Deactivated => OrderStatus::Canceled,
1467 };
1468
1469 let ts_accepted = parse_millis_timestamp(&order.created_time, "order.createdTime")?;
1470 let ts_last = parse_millis_timestamp(&order.updated_time, "order.updatedTime")?;
1471
1472 let mut report = OrderStatusReport::new(
1473 account_id,
1474 instrument_id,
1475 None,
1476 venue_order_id,
1477 order_side,
1478 order_type,
1479 time_in_force,
1480 order_status,
1481 quantity,
1482 filled_qty,
1483 ts_accepted,
1484 ts_last,
1485 ts_init,
1486 Some(UUID4::new()),
1487 );
1488
1489 if !order.order_link_id.is_empty() {
1490 report = report.with_client_order_id(ClientOrderId::new(order.order_link_id));
1491 }
1492
1493 if !order.price.is_empty() && order.price != "0" {
1494 let price =
1495 parse_price_with_precision(&order.price, instrument.price_precision(), "order.price")?;
1496 report = report.with_price(price);
1497 }
1498
1499 if let Some(avg_price) = &order.avg_price
1500 && !avg_price.is_empty()
1501 && avg_price != "0"
1502 {
1503 let avg_px = avg_price
1504 .parse::<Decimal>()
1505 .with_context(|| format!("Failed to parse avg_price='{avg_price}' as Decimal"))?;
1506 report = report.with_avg_px(avg_px);
1507 }
1508
1509 if !order.trigger_price.is_empty() && order.trigger_price != "0" {
1510 let trigger_price = parse_price_with_precision(
1511 &order.trigger_price,
1512 instrument.price_precision(),
1513 "order.triggerPrice",
1514 )?;
1515 report = report.with_trigger_price(trigger_price);
1516
1517 let trigger_type: TriggerType = order.trigger_by.into();
1519 report = report.with_trigger_type(trigger_type);
1520 }
1521
1522 if let Some(venue_position_id) = make_hedge_venue_position_id(instrument_id, order.position_idx)
1523 {
1524 report = report.with_venue_position_id(venue_position_id);
1525 }
1526
1527 if order.reduce_only {
1528 report = report.with_reduce_only(true);
1529 }
1530
1531 if matches!(
1532 order.time_in_force,
1533 BybitTimeInForce::PostOnly | BybitTimeInForce::Rpi
1534 ) {
1535 report = report.with_post_only(true);
1536 }
1537
1538 Ok(report)
1539}
1540
1541#[must_use]
1543pub fn spot_market_unit(
1544 product_type: BybitProductType,
1545 order_type: BybitOrderType,
1546 is_quote_quantity: bool,
1547) -> Option<BybitMarketUnit> {
1548 if product_type == BybitProductType::Spot && order_type == BybitOrderType::Market {
1549 if is_quote_quantity {
1550 Some(BybitMarketUnit::QuoteCoin)
1551 } else {
1552 Some(BybitMarketUnit::BaseCoin)
1553 }
1554 } else {
1555 None
1556 }
1557}
1558
1559#[must_use]
1561pub fn spot_leverage(product_type: BybitProductType, is_leverage: bool) -> Option<i32> {
1562 if product_type == BybitProductType::Spot {
1563 Some(i32::from(is_leverage))
1564 } else {
1565 None
1566 }
1567}
1568
1569#[must_use]
1571pub fn trigger_direction(
1572 order_type: OrderType,
1573 order_side: OrderSide,
1574 is_stop_order: bool,
1575) -> Option<BybitTriggerDirection> {
1576 if !is_stop_order {
1577 return None;
1578 }
1579
1580 match (order_type, order_side) {
1581 (OrderType::StopMarket | OrderType::StopLimit, OrderSide::Buy) => {
1582 Some(BybitTriggerDirection::RisesTo)
1583 }
1584 (OrderType::StopMarket | OrderType::StopLimit, OrderSide::Sell) => {
1585 Some(BybitTriggerDirection::FallsTo)
1586 }
1587 (OrderType::MarketIfTouched | OrderType::LimitIfTouched, OrderSide::Buy) => {
1588 Some(BybitTriggerDirection::FallsTo)
1589 }
1590 (OrderType::MarketIfTouched | OrderType::LimitIfTouched, OrderSide::Sell) => {
1591 Some(BybitTriggerDirection::RisesTo)
1592 }
1593 _ => None,
1594 }
1595}
1596
1597pub fn map_time_in_force(
1601 order_type: BybitOrderType,
1602 time_in_force: Option<TimeInForce>,
1603 post_only: Option<bool>,
1604) -> Result<Option<BybitTimeInForce>, TimeInForce> {
1605 if order_type == BybitOrderType::Market {
1606 return Ok(None);
1607 }
1608
1609 if post_only == Some(true) {
1610 return Ok(Some(BybitTimeInForce::PostOnly));
1611 }
1612
1613 match time_in_force {
1614 Some(TimeInForce::Gtc) => Ok(Some(BybitTimeInForce::Gtc)),
1615 Some(TimeInForce::Ioc) => Ok(Some(BybitTimeInForce::Ioc)),
1616 Some(TimeInForce::Fok) => Ok(Some(BybitTimeInForce::Fok)),
1617 Some(tif) => Err(tif),
1618 None => Ok(None),
1619 }
1620}
1621
1622pub fn nanos_to_millis(value: Option<UnixNanos>) -> Option<i64> {
1624 value.map(|nanos| nanos_to_millis_u64(nanos.as_u64()) as i64)
1625}
1626
1627#[derive(Debug, Default)]
1629pub struct BybitTpSlParams {
1630 pub take_profit: Option<Price>,
1631 pub stop_loss: Option<Price>,
1632 pub tp_trigger_by: Option<BybitTriggerType>,
1633 pub sl_trigger_by: Option<BybitTriggerType>,
1634 pub tp_order_type: Option<BybitOrderType>,
1635 pub sl_order_type: Option<BybitOrderType>,
1636 pub tp_limit_price: Option<String>,
1637 pub sl_limit_price: Option<String>,
1638 pub tp_trigger_price: Option<String>,
1639 pub sl_trigger_price: Option<String>,
1640 pub tpsl_mode: Option<BybitTpSlMode>,
1641 pub close_on_trigger: Option<bool>,
1642 pub is_leverage: bool,
1643 pub order_iv: Option<String>,
1644 pub mmp: Option<bool>,
1645 pub smp_type: Option<BybitOrderSmpType>,
1646 pub position_idx: Option<BybitPositionIdx>,
1647 pub bbo_side_type: Option<BybitBboSideType>,
1648 pub bbo_level: Option<String>,
1649}
1650
1651impl BybitTpSlParams {
1652 pub fn has_tp_sl(&self) -> bool {
1653 self.take_profit.is_some() || self.stop_loss.is_some()
1654 }
1655
1656 pub fn has_bbo(&self) -> bool {
1657 self.bbo_side_type.is_some()
1658 }
1659
1660 #[must_use]
1664 pub fn to_native_tp_sl(&self) -> BybitNativeTpSlParams {
1665 BybitNativeTpSlParams {
1666 take_profit: self.take_profit.map(|p| p.to_string()),
1667 stop_loss: self.stop_loss.map(|p| p.to_string()),
1668 tp_trigger_by: self.tp_trigger_by,
1669 sl_trigger_by: self.sl_trigger_by,
1670 tp_order_type: self.tp_order_type,
1671 sl_order_type: self.sl_order_type,
1672 tp_limit_price: self.tp_limit_price.clone(),
1673 sl_limit_price: self.sl_limit_price.clone(),
1674 tpsl_mode: self.tpsl_mode,
1675 close_on_trigger: self.close_on_trigger,
1676 order_iv: self.order_iv.clone(),
1677 mmp: self.mmp,
1678 }
1679 }
1680}
1681
1682pub fn get_price_str(params: &Params, key: &str) -> Option<String> {
1684 let value = params.get(key)?;
1685 if let Some(s) = value.as_str() {
1686 Some(s.to_string())
1687 } else if let Some(n) = value.as_f64() {
1688 Some(n.to_string())
1689 } else if let Some(n) = value.as_i64() {
1690 Some(n.to_string())
1691 } else {
1692 value.as_u64().map(|n| n.to_string())
1693 }
1694}
1695
1696pub fn parse_smp_type(s: &str) -> anyhow::Result<BybitOrderSmpType> {
1702 match s.to_ascii_lowercase().as_str() {
1703 "none" => Ok(BybitOrderSmpType::None),
1704 "cancelmaker" => Ok(BybitOrderSmpType::CancelMaker),
1705 "canceltaker" => Ok(BybitOrderSmpType::CancelTaker),
1706 "cancelboth" => Ok(BybitOrderSmpType::CancelBoth),
1707 _ => anyhow::bail!(
1708 "invalid Bybit smp_type: '{s}', expected None, CancelMaker, CancelTaker, or CancelBoth"
1709 ),
1710 }
1711}
1712
1713pub fn deserialize_optional_smp_type<'de, D: serde::Deserializer<'de>>(
1722 d: D,
1723) -> Result<Option<BybitOrderSmpType>, D::Error> {
1724 let Some(value) = Option::<String>::deserialize(d)? else {
1725 return Ok(None);
1726 };
1727
1728 parse_smp_type(&value).map(Some).map_err(D::Error::custom)
1729}
1730
1731pub fn parse_bbo_side_type(s: &str) -> anyhow::Result<BybitBboSideType> {
1732 match s.to_ascii_lowercase().as_str() {
1733 "queue" => Ok(BybitBboSideType::Queue),
1734 "counterparty" => Ok(BybitBboSideType::Counterparty),
1735 _ => anyhow::bail!("invalid Bybit bbo_side_type: '{s}', expected Queue or Counterparty"),
1736 }
1737}
1738
1739pub fn parse_bbo_level(s: String) -> anyhow::Result<String> {
1740 match s.as_str() {
1741 "1" | "2" | "3" | "4" | "5" => Ok(s),
1742 _ => anyhow::bail!("invalid 'bbo_level': '{s}', expected 1, 2, 3, 4, or 5"),
1743 }
1744}
1745
1746pub fn parse_bybit_tp_sl_params(params: Option<&Params>) -> anyhow::Result<BybitTpSlParams> {
1748 let Some(params) = params else {
1749 return Ok(BybitTpSlParams::default());
1750 };
1751
1752 let mut result = BybitTpSlParams {
1753 is_leverage: params.get_bool("is_leverage").unwrap_or(false),
1754 ..Default::default()
1755 };
1756
1757 if let Some(s) = get_price_str(params, "take_profit") {
1758 let p =
1759 Price::from_str(&s).map_err(|e| anyhow::anyhow!("invalid 'take_profit' price: {e}"))?;
1760
1761 if p.as_f64() < 0.0 {
1762 anyhow::bail!("invalid 'take_profit' price: '{s}', expected a non-negative value");
1763 }
1764 result.take_profit = Some(p);
1765 }
1766
1767 if let Some(s) = get_price_str(params, "stop_loss") {
1768 let p =
1769 Price::from_str(&s).map_err(|e| anyhow::anyhow!("invalid 'stop_loss' price: {e}"))?;
1770
1771 if p.as_f64() < 0.0 {
1772 anyhow::bail!("invalid 'stop_loss' price: '{s}', expected a non-negative value");
1773 }
1774 result.stop_loss = Some(p);
1775 }
1776
1777 for (key, setter) in [
1778 (
1779 "tp_limit_price",
1780 &mut result.tp_limit_price as &mut Option<String>,
1781 ),
1782 ("sl_limit_price", &mut result.sl_limit_price),
1783 ("tp_trigger_price", &mut result.tp_trigger_price),
1784 ("sl_trigger_price", &mut result.sl_trigger_price),
1785 ] {
1786 if let Some(s) = get_price_str(params, key) {
1787 let v: f64 = s
1788 .parse()
1789 .map_err(|_| anyhow::anyhow!("invalid price for '{key}': '{s}'"))?;
1790
1791 if !v.is_finite() || v < 0.0 {
1792 anyhow::bail!(
1793 "invalid price for '{key}': '{s}', expected a finite non-negative number"
1794 );
1795 }
1796 *setter = Some(s);
1797 }
1798 }
1799
1800 if let Some(s) = params.get_str("tp_trigger_by") {
1801 result.tp_trigger_by = Some(parse_trigger_type(s)?);
1802 }
1803
1804 if let Some(s) = params.get_str("sl_trigger_by") {
1805 result.sl_trigger_by = Some(parse_trigger_type(s)?);
1806 }
1807
1808 if let Some(s) = params.get_str("tp_order_type") {
1809 result.tp_order_type = Some(parse_tp_sl_order_type(s)?);
1810 }
1811
1812 if let Some(s) = params.get_str("sl_order_type") {
1813 result.sl_order_type = Some(parse_tp_sl_order_type(s)?);
1814 }
1815
1816 if let Some(s) = params.get_str("tpsl_mode") {
1817 result.tpsl_mode = Some(parse_tpsl_mode(s)?);
1818 }
1819
1820 let has_tp_fields = result.tp_trigger_by.is_some()
1821 || result.tp_order_type.is_some()
1822 || result.tp_limit_price.is_some()
1823 || result.tp_trigger_price.is_some();
1824
1825 let has_sl_fields = result.sl_trigger_by.is_some()
1826 || result.sl_order_type.is_some()
1827 || result.sl_limit_price.is_some()
1828 || result.sl_trigger_price.is_some();
1829
1830 if result.take_profit.is_none() && has_tp_fields {
1831 anyhow::bail!("TP override fields require 'take_profit' to be set");
1832 }
1833
1834 if result.stop_loss.is_none() && has_sl_fields {
1835 anyhow::bail!("SL override fields require 'stop_loss' to be set");
1836 }
1837
1838 if result.tp_order_type == Some(BybitOrderType::Limit) && result.tp_limit_price.is_none() {
1839 anyhow::bail!("'tp_order_type' is 'Limit' but 'tp_limit_price' was not provided");
1840 }
1841
1842 if result.sl_order_type == Some(BybitOrderType::Limit) && result.sl_limit_price.is_none() {
1843 anyhow::bail!("'sl_order_type' is 'Limit' but 'sl_limit_price' was not provided");
1844 }
1845
1846 if result.tp_limit_price.is_some() && result.tp_order_type != Some(BybitOrderType::Limit) {
1847 anyhow::bail!("'tp_limit_price' requires 'tp_order_type' to be 'Limit'");
1848 }
1849
1850 if result.sl_limit_price.is_some() && result.sl_order_type != Some(BybitOrderType::Limit) {
1851 anyhow::bail!("'sl_limit_price' requires 'sl_order_type' to be 'Limit'");
1852 }
1853
1854 result.close_on_trigger = params.get_bool("close_on_trigger");
1855
1856 if let Some(value) = params.get("order_iv") {
1857 match get_price_str(params, "order_iv") {
1858 Some(s) => result.order_iv = Some(s),
1859 None => {
1860 anyhow::bail!("invalid type for 'order_iv': {value}, expected string or number")
1861 }
1862 }
1863 }
1864
1865 if let Some(value) = params.get("mmp") {
1866 match value.as_bool() {
1867 Some(b) => result.mmp = Some(b),
1868 None => anyhow::bail!("invalid type for 'mmp': {value}, expected bool"),
1869 }
1870 }
1871
1872 if let Some(value) = params.get("smp_type") {
1873 let smp_type = value.as_str().ok_or_else(|| {
1874 anyhow::anyhow!("invalid type for 'smp_type': {value}, expected string")
1875 })?;
1876 result.smp_type = Some(parse_smp_type(smp_type)?);
1877 }
1878
1879 if let Some(value) = params.get("position_idx") {
1880 let idx = value.as_i64().ok_or_else(|| {
1881 anyhow::anyhow!("invalid type for 'position_idx': {value}, expected integer")
1882 })?;
1883 result.position_idx = Some(match idx {
1884 0 => BybitPositionIdx::OneWay,
1885 1 => BybitPositionIdx::BuyHedge,
1886 2 => BybitPositionIdx::SellHedge,
1887 _ => anyhow::bail!("invalid 'position_idx': {idx}, expected 0, 1, or 2"),
1888 });
1889 }
1890
1891 let has_bbo_side_type = params.get("bbo_side_type").is_some();
1892 let has_bbo_level = params.get("bbo_level").is_some();
1893
1894 if has_bbo_side_type != has_bbo_level {
1895 anyhow::bail!("'bbo_side_type' and 'bbo_level' must be provided together");
1896 }
1897
1898 if let Some(value) = params.get("bbo_side_type") {
1899 let side_type = value.as_str().ok_or_else(|| {
1900 anyhow::anyhow!("invalid type for 'bbo_side_type': {value}, expected string")
1901 })?;
1902 result.bbo_side_type = Some(parse_bbo_side_type(side_type)?);
1903 }
1904
1905 if let Some(value) = params.get("bbo_level") {
1906 let level = if let Some(s) = value.as_str() {
1907 s.to_string()
1908 } else if let Some(i) = value.as_i64() {
1909 i.to_string()
1910 } else if let Some(u) = value.as_u64() {
1911 u.to_string()
1912 } else {
1913 anyhow::bail!("invalid type for 'bbo_level': {value}, expected string or integer");
1914 };
1915 result.bbo_level = Some(parse_bbo_level(level)?);
1916 }
1917
1918 Ok(result)
1919}
1920
1921pub(crate) fn parse_trigger_type(s: &str) -> anyhow::Result<BybitTriggerType> {
1922 match s {
1923 "LastPrice" => Ok(BybitTriggerType::LastPrice),
1924 "MarkPrice" => Ok(BybitTriggerType::MarkPrice),
1925 "IndexPrice" => Ok(BybitTriggerType::IndexPrice),
1926 _ => anyhow::bail!(
1927 "invalid Bybit trigger type: '{s}', expected LastPrice, MarkPrice, or IndexPrice"
1928 ),
1929 }
1930}
1931
1932pub(crate) fn parse_tp_sl_order_type(s: &str) -> anyhow::Result<BybitOrderType> {
1933 match s {
1934 "Market" => Ok(BybitOrderType::Market),
1935 "Limit" => Ok(BybitOrderType::Limit),
1936 _ => anyhow::bail!("invalid Bybit TP/SL order type: '{s}', expected Market or Limit"),
1937 }
1938}
1939
1940pub(crate) fn parse_tpsl_mode(s: &str) -> anyhow::Result<BybitTpSlMode> {
1943 match s {
1944 "Full" => Ok(BybitTpSlMode::Full),
1945 "Partial" => Ok(BybitTpSlMode::Partial),
1946 _ => anyhow::bail!("invalid Bybit TP/SL mode: '{s}', expected Full or Partial"),
1947 }
1948}
1949
1950#[cfg(test)]
1951mod tests {
1952 use nautilus_model::{
1953 data::BarSpecification,
1954 enums::{AggregationSource, BarAggregation, PositionSide, PriceType},
1955 };
1956 use rstest::rstest;
1957 use serde_json::json;
1958
1959 use super::*;
1960 use crate::{
1961 common::{
1962 enums::{
1963 BybitExecType, BybitOrderSide, BybitOrderType, BybitStopOrderType,
1964 BybitTriggerDirection,
1965 },
1966 testing::load_test_json,
1967 },
1968 http::models::{
1969 BybitInstrumentInverseResponse, BybitInstrumentLinearResponse,
1970 BybitInstrumentOptionResponse, BybitInstrumentSpotResponse, BybitKlinesResponse,
1971 BybitOpenOrdersResponse, BybitPositionListResponse, BybitTradeHistoryResponse,
1972 BybitTradesResponse,
1973 },
1974 };
1975
1976 const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
1977
1978 fn linear_instrument() -> InstrumentAny {
1979 let json = load_test_json("http_get_instruments_linear.json");
1980 let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
1981 let instrument = &response.result.list[0];
1982 parse_linear_instrument(instrument, TS, TS).unwrap()
1983 }
1984
1985 #[rstest]
1986 fn test_parse_price_with_precision_preserves_decimal_value() {
1987 let price = parse_price_with_precision("9000000.000000001", 9, "test.price").unwrap();
1988
1989 assert_eq!(price, Price::from("9000000.000000001"));
1990 assert_eq!(price.precision, 9);
1991 }
1992
1993 #[rstest]
1994 #[case("25.000", 2, "25.00")]
1995 #[case("9000000.000000001", 9, "9000000.000000001")]
1996 fn test_parse_quantity_with_precision_preserves_decimal_value(
1997 #[case] value: &str,
1998 #[case] precision: u8,
1999 #[case] expected: &str,
2000 ) {
2001 let quantity = parse_quantity_with_precision(value, precision, "test.quantity").unwrap();
2002
2003 assert_eq!(quantity, Quantity::from(expected));
2004 assert_eq!(quantity.precision, precision);
2005 }
2006
2007 #[rstest]
2008 #[case::post_only_cross("EC_PostOnlyWillTakeLiquidity", true)]
2009 #[case::post_only_cross_with_prefix("Order rejected: EC_PostOnlyWillTakeLiquidity", true)]
2010 #[case::other_reason("EC_OrigClOrdIDDoesNotExist", false)]
2011 #[case::generic("Order rejected by venue", false)]
2012 #[case::empty("", false)]
2013 fn test_bybit_rejection_due_post_only(#[case] reason: &str, #[case] expected: bool) {
2014 assert_eq!(bybit_rejection_due_post_only(reason), expected);
2015 }
2016
2017 #[rstest]
2018 fn parse_spot_instrument_builds_currency_pair() {
2019 let json = load_test_json("http_get_instruments_spot.json");
2020 let response: BybitInstrumentSpotResponse = serde_json::from_str(&json).unwrap();
2021 let instrument = &response.result.list[0];
2022
2023 let parsed = parse_spot_instrument(instrument, TS, TS).unwrap();
2024 match parsed {
2025 InstrumentAny::CurrencyPair(pair) => {
2026 assert_eq!(pair.id.to_string(), "BTCUSDT-SPOT.BYBIT");
2027 assert_eq!(pair.price_increment, Price::from_str("0.1").unwrap());
2028 assert_eq!(pair.size_increment, Quantity::from_str("0.0001").unwrap());
2029 assert_eq!(pair.base_currency.code, "BTC");
2030 assert_eq!(pair.quote_currency.code, "USDT");
2031 assert_eq!(
2032 pair.min_notional,
2033 Some(Money::from_decimal(Decimal::new(10, 0), Currency::USDT()).unwrap()),
2034 );
2035 assert_eq!(
2036 pair.info.as_ref().unwrap().get_bool("margin_trading"),
2037 Some(true)
2038 );
2039 }
2040 _ => panic!("expected CurrencyPair"),
2041 }
2042 }
2043
2044 #[rstest]
2045 fn parse_spot_instrument_forwards_symbol_info() {
2046 let json = load_test_json("http_get_instruments_spot_xstocks.json");
2047 let response: BybitInstrumentSpotResponse = serde_json::from_str(&json).unwrap();
2048 let instrument = &response.result.list[0];
2049
2050 let parsed = parse_spot_instrument(instrument, TS, TS).unwrap();
2051 match parsed {
2052 InstrumentAny::CurrencyPair(pair) => {
2053 let info = pair.info.as_ref().unwrap();
2054 assert_eq!(info.get_bool("margin_trading"), Some(false));
2055 assert_eq!(info.get_str("symbol_type"), Some("xstocks"));
2056 assert_eq!(info.get_str("xstock_multiplier"), Some("0.1"));
2057 assert_eq!(info.len(), 3);
2058 }
2059 other => panic!("unexpected instrument variant: {other:?}"),
2060 }
2061 }
2062
2063 #[rstest]
2064 #[case::unknown(BybitSymbolType::Other, "1", None, 1)]
2065 #[case::non_xstock(BybitSymbolType::Adventure, "1", Some("adventure"), 2)]
2066 #[case::empty_xstock_multiplier(BybitSymbolType::Xstocks, "", Some("xstocks"), 2)]
2067 fn parse_spot_instrument_omits_inapplicable_symbol_info(
2068 #[case] symbol_type: BybitSymbolType,
2069 #[case] xstock_multiplier: &str,
2070 #[case] expected_symbol_type: Option<&str>,
2071 #[case] expected_len: usize,
2072 ) {
2073 let json = load_test_json("http_get_instruments_spot.json");
2074 let response: BybitInstrumentSpotResponse = serde_json::from_str(&json).unwrap();
2075 let mut instrument = response.result.list[0].clone();
2076 instrument.symbol_type = Some(symbol_type);
2077 instrument.xstock_multiplier = Some(xstock_multiplier.to_string());
2078
2079 let parsed = parse_spot_instrument(&instrument, TS, TS).unwrap();
2080 match parsed {
2081 InstrumentAny::CurrencyPair(pair) => {
2082 let info = pair.info.as_ref().unwrap();
2083 assert_eq!(info.get_bool("margin_trading"), Some(true));
2084 assert_eq!(info.get_str("symbol_type"), expected_symbol_type);
2085 assert_eq!(info.get("xstock_multiplier"), None);
2086 assert_eq!(info.len(), expected_len);
2087 }
2088 other => panic!("unexpected instrument variant: {other:?}"),
2089 }
2090 }
2091
2092 #[rstest]
2093 #[case(BybitMarginTrading::Both, true)]
2094 #[case(BybitMarginTrading::UtaOnly, true)]
2095 #[case(BybitMarginTrading::None, false)]
2096 #[case(BybitMarginTrading::Other, false)]
2097 fn parse_spot_instrument_maps_margin_trading(
2098 #[case] margin_trading: BybitMarginTrading,
2099 #[case] expected: bool,
2100 ) {
2101 let json = load_test_json("http_get_instruments_spot.json");
2102 let response: BybitInstrumentSpotResponse = serde_json::from_str(&json).unwrap();
2103 let mut instrument = response.result.list[0].clone();
2104 instrument.margin_trading = margin_trading;
2105
2106 let parsed = parse_spot_instrument(&instrument, TS, TS).unwrap();
2107 match parsed {
2108 InstrumentAny::CurrencyPair(pair) => {
2109 assert_eq!(
2110 pair.info.as_ref().unwrap().get_bool("margin_trading"),
2111 Some(expected)
2112 );
2113 }
2114 _ => panic!("expected CurrencyPair"),
2115 }
2116 }
2117
2118 #[rstest]
2119 fn parse_linear_perpetual_instrument_builds_crypto_perpetual() {
2120 let json = load_test_json("http_get_instruments_linear.json");
2121 let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
2122 let instrument = &response.result.list[0];
2123
2124 let parsed = parse_linear_instrument(instrument, TS, TS).unwrap();
2125 match parsed {
2126 InstrumentAny::CryptoPerpetual(perp) => {
2127 assert_eq!(perp.id.to_string(), "BTCUSDT-LINEAR.BYBIT");
2128 assert!(!perp.is_inverse);
2129 assert_eq!(perp.price_increment, Price::from_str("0.5").unwrap());
2130 assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
2131 assert_eq!(perp.min_notional, Some(Money::new(5.0, Currency::USDT())),);
2132 assert!(perp.info.is_none());
2133 }
2134 other => panic!("unexpected instrument variant: {other:?}"),
2135 }
2136 }
2137
2138 #[rstest]
2139 #[case::perpetual(BybitContractType::LinearPerpetual)]
2140 #[case::future(BybitContractType::LinearFutures)]
2141 fn parse_linear_instrument_forwards_symbol_info(#[case] contract_type: BybitContractType) {
2142 let json = load_test_json("http_get_instruments_linear_symbol_type.json");
2143 let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
2144 let mut instrument = response.result.list[0].clone();
2145 instrument.contract_type = contract_type;
2146
2147 let parsed = parse_linear_instrument(&instrument, TS, TS).unwrap();
2148 let info = match (contract_type, parsed) {
2149 (BybitContractType::LinearPerpetual, InstrumentAny::CryptoPerpetual(perp)) => {
2150 perp.info.unwrap()
2151 }
2152 (BybitContractType::LinearFutures, InstrumentAny::CryptoFuture(future)) => {
2153 future.info.unwrap()
2154 }
2155 (_, other) => panic!("unexpected instrument variant: {other:?}"),
2156 };
2157
2158 assert_eq!(info.get_str("symbol_type"), Some("stock"));
2159 assert_eq!(info.len(), 1);
2160 }
2161
2162 #[rstest]
2163 fn parse_inverse_perpetual_instrument_builds_inverse_perpetual() {
2164 let json = load_test_json("http_get_instruments_inverse.json");
2165 let response: BybitInstrumentInverseResponse = serde_json::from_str(&json).unwrap();
2166 let instrument = &response.result.list[0];
2167
2168 let parsed = parse_inverse_instrument(instrument, TS, TS).unwrap();
2169 match parsed {
2170 InstrumentAny::CryptoPerpetual(perp) => {
2171 assert_eq!(perp.id.to_string(), "BTCUSD-INVERSE.BYBIT");
2172 assert!(perp.is_inverse);
2173 assert_eq!(perp.price_increment, Price::from_str("0.5").unwrap());
2174 assert_eq!(perp.size_increment, Quantity::from_str("1").unwrap());
2175 assert!(perp.min_notional.is_none());
2176 assert!(perp.info.is_none());
2177 }
2178 other => panic!("unexpected instrument variant: {other:?}"),
2179 }
2180 }
2181
2182 #[rstest]
2183 #[case::perpetual(BybitContractType::InversePerpetual)]
2184 #[case::future(BybitContractType::InverseFutures)]
2185 fn parse_inverse_instrument_forwards_symbol_info(#[case] contract_type: BybitContractType) {
2186 let json = load_test_json("http_get_instruments_inverse_symbol_type.json");
2187 let response: BybitInstrumentInverseResponse = serde_json::from_str(&json).unwrap();
2188 let mut instrument = response.result.list[0].clone();
2189 instrument.contract_type = contract_type;
2190
2191 let parsed = parse_inverse_instrument(&instrument, TS, TS).unwrap();
2192 let info = match (contract_type, parsed) {
2193 (BybitContractType::InversePerpetual, InstrumentAny::CryptoPerpetual(perp)) => {
2194 perp.info.unwrap()
2195 }
2196 (BybitContractType::InverseFutures, InstrumentAny::CryptoFuture(future)) => {
2197 future.info.unwrap()
2198 }
2199 (_, other) => panic!("unexpected instrument variant: {other:?}"),
2200 };
2201
2202 assert_eq!(info.get_str("symbol_type"), Some("commodity"));
2203 assert_eq!(info.len(), 1);
2204 }
2205
2206 #[rstest]
2207 fn parse_option_instrument_builds_crypto_option() {
2208 let json = load_test_json("http_get_instruments_option.json");
2209 let response: BybitInstrumentOptionResponse = serde_json::from_str(&json).unwrap();
2210 let instrument = &response.result.list[0];
2211
2212 let parsed = parse_option_instrument(instrument, TS, TS).unwrap();
2213 match parsed {
2214 InstrumentAny::CryptoOption(option) => {
2215 assert_eq!(option.id.to_string(), "ETH-26JUN26-16000-P-OPTION.BYBIT");
2216 assert_eq!(option.underlying.code, "ETH");
2217 assert_eq!(option.quote_currency.code, "USDC");
2218 assert_eq!(option.settlement_currency.code, "USDC");
2219 assert!(!option.is_inverse);
2220 assert_eq!(option.option_kind, OptionKind::Put);
2221 assert_eq!(option.price_precision, 1);
2222 assert_eq!(option.price_increment, Price::from_str("0.1").unwrap());
2223 assert_eq!(option.size_precision, 0);
2224 assert_eq!(option.size_increment, Quantity::from_str("1").unwrap());
2225 assert_eq!(option.lot_size, Quantity::from_str("1").unwrap());
2226 }
2227 other => panic!("unexpected instrument variant: {other:?}"),
2228 }
2229 }
2230
2231 #[rstest]
2232 fn test_extract_base_coin_from_option_symbol() {
2233 assert_eq!(extract_base_coin("BTC-27MAR26-70000-P"), "BTC");
2234 assert_eq!(extract_base_coin("ETH-26JUN26-16000-C"), "ETH");
2235 assert_eq!(extract_base_coin("SOL-30MAR26-200-P-USDT"), "SOL");
2236 assert_eq!(extract_base_coin("BTC"), "BTC");
2237 }
2238
2239 #[rstest]
2240 fn test_extract_base_coin_from_nautilus_option_symbol() {
2241 let raw = extract_raw_symbol("BTC-27MAR26-70000-P-USDT-OPTION");
2243 assert_eq!(extract_base_coin(raw), "BTC");
2244 }
2245
2246 #[rstest]
2247 fn parse_http_trade_into_trade_tick() {
2248 let instrument = linear_instrument();
2249 let json = load_test_json("http_get_trades_recent.json");
2250 let response: BybitTradesResponse = serde_json::from_str(&json).unwrap();
2251 let trade = &response.result.list[0];
2252
2253 let tick = parse_trade_tick(trade, &instrument, Some(TS)).unwrap();
2254
2255 assert_eq!(tick.instrument_id, instrument.id());
2256 assert_eq!(tick.price, instrument.make_price(27450.50));
2257 assert_eq!(tick.size, instrument.make_qty(0.005, None));
2258 assert_eq!(tick.aggressor_side, AggressorSide::Buy);
2259 assert_eq!(
2260 tick.trade_id.to_string(),
2261 "a905d5c3-1ed0-4f37-83e4-9c73a2fe2f01"
2262 );
2263 assert_eq!(tick.ts_event, UnixNanos::new(1_709_891_679_000_000_000));
2264 }
2265
2266 #[rstest]
2267 fn parse_kline_into_bar() {
2268 let instrument = linear_instrument();
2269 let json = load_test_json("http_get_klines_linear.json");
2270 let response: BybitKlinesResponse = serde_json::from_str(&json).unwrap();
2271 let kline = &response.result.list[0];
2272
2273 let bar_type = BarType::new(
2274 instrument.id(),
2275 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
2276 AggregationSource::External,
2277 );
2278
2279 let bar = parse_kline_bar(kline, &instrument, bar_type, false, Some(TS)).unwrap();
2280
2281 assert_eq!(bar.bar_type.to_string(), bar_type.to_string());
2282 assert_eq!(bar.open, instrument.make_price(27450.0));
2283 assert_eq!(bar.high, instrument.make_price(27460.0));
2284 assert_eq!(bar.low, instrument.make_price(27440.0));
2285 assert_eq!(bar.close, instrument.make_price(27455.0));
2286 assert_eq!(bar.volume, instrument.make_qty(123.45, None));
2287 assert_eq!(bar.ts_event, UnixNanos::new(1_709_891_679_000_000_000));
2288 }
2289
2290 #[rstest]
2291 fn parse_http_position_short_into_position_status_report() {
2292 use crate::http::models::BybitPositionListResponse;
2293
2294 let json = load_test_json("http_get_positions.json");
2295 let response: BybitPositionListResponse = serde_json::from_str(&json).unwrap();
2296
2297 let short_position = &response.result.list[1];
2299 assert_eq!(short_position.symbol, "ETHUSDT");
2300 assert_eq!(short_position.side, BybitPositionSide::Sell);
2301
2302 let eth_json = load_test_json("http_get_instruments_linear.json");
2304 let eth_response: BybitInstrumentLinearResponse = serde_json::from_str(ð_json).unwrap();
2305 let eth_def = ð_response.result.list[1]; let eth_instrument = parse_linear_instrument(eth_def, TS, TS).unwrap();
2307
2308 let account_id = AccountId::new("BYBIT-001");
2309 let report =
2310 parse_position_status_report(short_position, account_id, ð_instrument, TS).unwrap();
2311
2312 assert_eq!(report.account_id, account_id);
2314 assert_eq!(report.instrument_id.symbol.as_str(), "ETHUSDT-LINEAR");
2315 assert_eq!(report.position_side, PositionSide::Short);
2316 assert_eq!(report.quantity, eth_instrument.make_qty(5.0, None));
2317 assert_eq!(
2318 report.avg_px_open,
2319 Some(Decimal::try_from(3000.00).unwrap())
2320 );
2321 assert_eq!(report.ts_last, UnixNanos::new(1_697_673_700_112_000_000));
2322 }
2323
2324 #[rstest]
2325 fn parse_http_position_preserves_decimal_quantity() {
2326 use crate::http::models::BybitPositionListResponse;
2327
2328 let json = load_test_json("http_get_positions.json");
2329 let response: BybitPositionListResponse = serde_json::from_str(&json).unwrap();
2330 let mut position = response.result.list[0].clone();
2331 position.size = "9000000.000000001".to_string();
2332
2333 let json = load_test_json("http_get_instruments_linear.json");
2334 let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
2335 let mut definition = response.result.list[0].clone();
2336 definition.lot_size_filter.qty_step = "0.000000001".to_string();
2337 let instrument = parse_linear_instrument(&definition, TS, TS).unwrap();
2338
2339 let report =
2340 parse_position_status_report(&position, AccountId::new("BYBIT-001"), &instrument, TS)
2341 .unwrap();
2342
2343 assert_eq!(report.quantity, Quantity::from("9000000.000000001"));
2344 assert_eq!(report.quantity.precision, 9);
2345 }
2346
2347 #[rstest]
2348 fn parse_http_order_partially_filled_rejected_maps_to_canceled() {
2349 use crate::http::models::BybitOrderHistoryResponse;
2350
2351 let instrument = linear_instrument();
2352 let json = load_test_json("http_get_order_partially_filled_rejected.json");
2353 let response: BybitOrderHistoryResponse = serde_json::from_str(&json).unwrap();
2354 let order = &response.result.list[0];
2355 let account_id = AccountId::new("BYBIT-001");
2356
2357 let report = parse_order_status_report(order, &instrument, account_id, TS).unwrap();
2358
2359 assert_eq!(report.order_status, OrderStatus::Canceled);
2361 assert_eq!(report.filled_qty, instrument.make_qty(0.005, None));
2362 assert_eq!(
2363 report.client_order_id.as_ref().unwrap().to_string(),
2364 "O-20251001-164609-APEX-000-49"
2365 );
2366 }
2367
2368 #[rstest]
2369 #[case(BarAggregation::Minute, 1, BybitKlineInterval::Minute1)]
2370 #[case(BarAggregation::Minute, 3, BybitKlineInterval::Minute3)]
2371 #[case(BarAggregation::Minute, 5, BybitKlineInterval::Minute5)]
2372 #[case(BarAggregation::Minute, 15, BybitKlineInterval::Minute15)]
2373 #[case(BarAggregation::Minute, 30, BybitKlineInterval::Minute30)]
2374 fn test_bar_spec_to_bybit_interval_minutes(
2375 #[case] aggregation: BarAggregation,
2376 #[case] step: u64,
2377 #[case] expected: BybitKlineInterval,
2378 ) {
2379 let result = bar_spec_to_bybit_interval(aggregation, step).unwrap();
2380 assert_eq!(result, expected);
2381 }
2382
2383 #[rstest]
2384 #[case(BarAggregation::Hour, 1, BybitKlineInterval::Hour1)]
2385 #[case(BarAggregation::Hour, 2, BybitKlineInterval::Hour2)]
2386 #[case(BarAggregation::Hour, 4, BybitKlineInterval::Hour4)]
2387 #[case(BarAggregation::Hour, 6, BybitKlineInterval::Hour6)]
2388 #[case(BarAggregation::Hour, 12, BybitKlineInterval::Hour12)]
2389 fn test_bar_spec_to_bybit_interval_hours(
2390 #[case] aggregation: BarAggregation,
2391 #[case] step: u64,
2392 #[case] expected: BybitKlineInterval,
2393 ) {
2394 let result = bar_spec_to_bybit_interval(aggregation, step).unwrap();
2395 assert_eq!(result, expected);
2396 }
2397
2398 #[rstest]
2399 #[case(BarAggregation::Day, 1, BybitKlineInterval::Day1)]
2400 #[case(BarAggregation::Week, 1, BybitKlineInterval::Week1)]
2401 #[case(BarAggregation::Month, 1, BybitKlineInterval::Month1)]
2402 fn test_bar_spec_to_bybit_interval_day_week_month(
2403 #[case] aggregation: BarAggregation,
2404 #[case] step: u64,
2405 #[case] expected: BybitKlineInterval,
2406 ) {
2407 let result = bar_spec_to_bybit_interval(aggregation, step).unwrap();
2408 assert_eq!(result, expected);
2409 }
2410
2411 #[rstest]
2412 #[case(BarAggregation::Minute, 2)]
2413 #[case(BarAggregation::Minute, 10)]
2414 #[case(BarAggregation::Hour, 3)]
2415 #[case(BarAggregation::Hour, 24)]
2416 #[case(BarAggregation::Day, 2)]
2417 #[case(BarAggregation::Week, 2)]
2418 #[case(BarAggregation::Month, 2)]
2419 fn test_bar_spec_to_bybit_interval_unsupported_steps(
2420 #[case] aggregation: BarAggregation,
2421 #[case] step: u64,
2422 ) {
2423 let result = bar_spec_to_bybit_interval(aggregation, step);
2424 result.unwrap_err();
2425 }
2426
2427 #[rstest]
2428 fn test_bar_spec_to_bybit_interval_unsupported_aggregation() {
2429 let result = bar_spec_to_bybit_interval(BarAggregation::Second, 1);
2430 result.unwrap_err();
2431 }
2432
2433 #[rstest]
2434 #[case("1", 1, BarAggregation::Minute)]
2435 #[case("3", 3, BarAggregation::Minute)]
2436 #[case("5", 5, BarAggregation::Minute)]
2437 #[case("15", 15, BarAggregation::Minute)]
2438 #[case("30", 30, BarAggregation::Minute)]
2439 fn test_bybit_interval_to_bar_spec_minutes(
2440 #[case] interval: &str,
2441 #[case] expected_step: usize,
2442 #[case] expected_aggregation: BarAggregation,
2443 ) {
2444 let result = bybit_interval_to_bar_spec(interval).unwrap();
2445 assert_eq!(result, (expected_step, expected_aggregation));
2446 }
2447
2448 #[rstest]
2449 #[case("60", 1, BarAggregation::Hour)]
2450 #[case("120", 2, BarAggregation::Hour)]
2451 #[case("240", 4, BarAggregation::Hour)]
2452 #[case("360", 6, BarAggregation::Hour)]
2453 #[case("720", 12, BarAggregation::Hour)]
2454 fn test_bybit_interval_to_bar_spec_hours(
2455 #[case] interval: &str,
2456 #[case] expected_step: usize,
2457 #[case] expected_aggregation: BarAggregation,
2458 ) {
2459 let result = bybit_interval_to_bar_spec(interval).unwrap();
2460 assert_eq!(result, (expected_step, expected_aggregation));
2461 }
2462
2463 #[rstest]
2464 #[case("D", 1, BarAggregation::Day)]
2465 #[case("W", 1, BarAggregation::Week)]
2466 #[case("M", 1, BarAggregation::Month)]
2467 fn test_bybit_interval_to_bar_spec_day_week_month(
2468 #[case] interval: &str,
2469 #[case] expected_step: usize,
2470 #[case] expected_aggregation: BarAggregation,
2471 ) {
2472 let result = bybit_interval_to_bar_spec(interval).unwrap();
2473 assert_eq!(result, (expected_step, expected_aggregation));
2474 }
2475
2476 #[rstest]
2477 #[case("2")]
2478 #[case("10")]
2479 #[case("100")]
2480 #[case("invalid")]
2481 #[case("")]
2482 fn test_bybit_interval_to_bar_spec_unsupported(#[case] interval: &str) {
2483 let result = bybit_interval_to_bar_spec(interval);
2484 assert!(result.is_none());
2485 }
2486
2487 fn params_from(pairs: &[(&str, serde_json::Value)]) -> Params {
2488 let mut p = Params::new();
2489 for (k, v) in pairs {
2490 p.insert(k.to_string(), v.clone());
2491 }
2492 p
2493 }
2494
2495 #[rstest]
2496 fn test_parse_tp_sl_params_none_returns_defaults() {
2497 let result = parse_bybit_tp_sl_params(None).unwrap();
2498 assert!(!result.is_leverage);
2499 assert!(!result.has_tp_sl());
2500 assert!(!result.has_bbo());
2501 assert!(result.order_iv.is_none());
2502 assert!(result.mmp.is_none());
2503 }
2504
2505 #[rstest]
2506 fn test_parse_tp_sl_params_empty_returns_defaults() {
2507 let p = Params::new();
2508 let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2509 assert!(!result.is_leverage);
2510 assert!(!result.has_tp_sl());
2511 assert!(!result.has_bbo());
2512 assert!(result.order_iv.is_none());
2513 assert!(result.mmp.is_none());
2514 }
2515
2516 #[rstest]
2517 fn test_parse_tp_sl_params_valid_full() {
2518 let p = params_from(&[
2519 ("take_profit", json!("55000.00")),
2520 ("stop_loss", json!("47000.00")),
2521 ("tp_trigger_by", json!("MarkPrice")),
2522 ("sl_trigger_by", json!("IndexPrice")),
2523 ("tp_order_type", json!("Limit")),
2524 ("tp_limit_price", json!("54990.00")),
2525 ("sl_order_type", json!("Market")),
2526 ("close_on_trigger", json!(true)),
2527 ("is_leverage", json!(true)),
2528 ]);
2529 let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2530
2531 assert!(result.has_tp_sl());
2532 assert!(result.take_profit.is_some());
2533 assert!(result.stop_loss.is_some());
2534 assert_eq!(result.tp_trigger_by, Some(BybitTriggerType::MarkPrice));
2535 assert_eq!(result.sl_trigger_by, Some(BybitTriggerType::IndexPrice));
2536 assert_eq!(result.tp_order_type, Some(BybitOrderType::Limit));
2537 assert_eq!(result.sl_order_type, Some(BybitOrderType::Market));
2538 assert_eq!(result.tp_limit_price.as_deref(), Some("54990.00"));
2539 assert_eq!(result.close_on_trigger, Some(true));
2540 assert!(result.is_leverage);
2541 }
2542
2543 #[rstest]
2544 fn test_parse_tp_sl_params_preserves_tpsl_mode() {
2545 let p = params_from(&[
2546 ("take_profit", json!("55000.00")),
2547 ("tpsl_mode", json!("Partial")),
2548 ]);
2549 let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2550
2551 assert_eq!(result.tpsl_mode, Some(BybitTpSlMode::Partial));
2552 }
2553
2554 #[rstest]
2555 #[case("Unknown")]
2556 #[case("partial")]
2557 #[case("garbage")]
2558 fn test_parse_tp_sl_params_rejects_invalid_tpsl_mode(#[case] mode: &str) {
2559 let p = params_from(&[("tpsl_mode", json!(mode))]);
2560 let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2561
2562 assert!(err.to_string().contains("invalid Bybit TP/SL mode"));
2563 }
2564
2565 #[rstest]
2566 #[case("None", BybitOrderSmpType::None)]
2567 #[case("CancelMaker", BybitOrderSmpType::CancelMaker)]
2568 #[case("canceltaker", BybitOrderSmpType::CancelTaker)]
2569 #[case("CANCELBOTH", BybitOrderSmpType::CancelBoth)]
2570 fn test_parse_tp_sl_params_valid_smp_type(
2571 #[case] value: &str,
2572 #[case] expected: BybitOrderSmpType,
2573 ) {
2574 let p = params_from(&[("smp_type", json!(value))]);
2575 let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2576
2577 assert_eq!(result.smp_type, Some(expected));
2578 }
2579
2580 #[rstest]
2581 fn test_parse_tp_sl_params_smp_type_absent_stays_none() {
2582 let p = params_from(&[("mmp", json!(true))]);
2583 let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2584
2585 assert_eq!(result.smp_type, None);
2586 }
2587
2588 #[rstest]
2589 #[case(json!("Other"), "invalid Bybit smp_type: 'Other'")]
2590 #[case(json!("cancel_maker"), "invalid Bybit smp_type: 'cancel_maker'")]
2591 #[case(json!(""), "invalid Bybit smp_type: ''")]
2592 #[case(json!(1), "invalid type for 'smp_type'")]
2593 #[case(json!(true), "invalid type for 'smp_type'")]
2594 fn test_parse_tp_sl_params_rejects_invalid_smp_type(
2595 #[case] value: serde_json::Value,
2596 #[case] expected: &str,
2597 ) {
2598 let p = params_from(&[("smp_type", value)]);
2599 let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2600
2601 assert!(
2602 err.to_string().contains(expected),
2603 "expected '{expected}', was '{err}'"
2604 );
2605 }
2606
2607 #[rstest]
2608 fn test_parse_tp_sl_params_valid_bbo() {
2609 let p = params_from(&[("bbo_side_type", json!("queue")), ("bbo_level", json!(3))]);
2610 let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2611
2612 assert!(result.has_bbo());
2613 assert_eq!(result.bbo_side_type, Some(BybitBboSideType::Queue));
2614 assert_eq!(result.bbo_level.as_deref(), Some("3"));
2615 }
2616
2617 #[rstest]
2618 fn test_parse_tp_sl_params_rejects_invalid_bbo() {
2619 let cases = vec![
2620 (
2621 params_from(&[("bbo_side_type", json!("Queue"))]),
2622 "must be provided together",
2623 ),
2624 (
2625 params_from(&[("bbo_level", json!("1"))]),
2626 "must be provided together",
2627 ),
2628 (
2629 params_from(&[
2630 ("bbo_side_type", json!("invalid")),
2631 ("bbo_level", json!("1")),
2632 ]),
2633 "invalid Bybit bbo_side_type",
2634 ),
2635 (
2636 params_from(&[("bbo_side_type", json!("Queue")), ("bbo_level", json!("6"))]),
2637 "invalid 'bbo_level'",
2638 ),
2639 (
2640 params_from(&[("bbo_side_type", json!(1)), ("bbo_level", json!("1"))]),
2641 "invalid type for 'bbo_side_type'",
2642 ),
2643 (
2644 params_from(&[
2645 ("bbo_side_type", json!("Queue")),
2646 ("bbo_level", json!(true)),
2647 ]),
2648 "invalid type for 'bbo_level'",
2649 ),
2650 ];
2651
2652 for (p, expected) in cases {
2653 let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2654 assert!(err.to_string().contains(expected));
2655 }
2656 }
2657
2658 #[rstest]
2659 #[case("abc")]
2660 #[case("nan")]
2661 #[case("inf")]
2662 #[case("-1.0")]
2663 fn test_parse_tp_sl_params_rejects_invalid_take_profit(#[case] price: &str) {
2664 let p = params_from(&[("take_profit", json!(price))]);
2665 parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2666 }
2667
2668 #[rstest]
2669 #[case("abc")]
2670 #[case("nan")]
2671 #[case("inf")]
2672 fn test_parse_tp_sl_params_rejects_invalid_stop_loss(#[case] price: &str) {
2673 let p = params_from(&[("stop_loss", json!(price))]);
2674 parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2675 }
2676
2677 #[rstest]
2678 #[case("nan")]
2679 #[case("inf")]
2680 #[case("-5.0")]
2681 #[case("not_a_number")]
2682 fn test_parse_tp_sl_params_rejects_invalid_limit_price(#[case] price: &str) {
2683 let p = params_from(&[
2684 ("take_profit", json!("55000.00")),
2685 ("tp_order_type", json!("Limit")),
2686 ("tp_limit_price", json!(price)),
2687 ]);
2688 parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2689 }
2690
2691 #[rstest]
2692 fn test_parse_tp_sl_params_rejects_invalid_trigger_type() {
2693 let p = params_from(&[
2694 ("take_profit", json!("55000.00")),
2695 ("tp_trigger_by", json!("InvalidType")),
2696 ]);
2697 parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2698 }
2699
2700 #[rstest]
2701 fn test_parse_tp_sl_params_rejects_invalid_order_type() {
2702 let p = params_from(&[
2703 ("stop_loss", json!("47000.00")),
2704 ("sl_order_type", json!("Stop")),
2705 ]);
2706 parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2707 }
2708
2709 #[rstest]
2710 fn test_parse_tp_sl_params_rejects_limit_without_limit_price() {
2711 let p = params_from(&[
2712 ("take_profit", json!("55000.00")),
2713 ("tp_order_type", json!("Limit")),
2714 ]);
2715 let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2716 assert!(err.to_string().contains("tp_limit_price"));
2717 }
2718
2719 #[rstest]
2720 fn test_parse_tp_sl_params_rejects_limit_price_without_limit_type() {
2721 let p = params_from(&[
2722 ("take_profit", json!("55000.00")),
2723 ("tp_limit_price", json!("54990.00")),
2724 ]);
2725 let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2726 assert!(err.to_string().contains("tp_order_type"));
2727 }
2728
2729 #[rstest]
2730 fn test_parse_tp_sl_params_rejects_orphaned_tp_fields() {
2731 let p = params_from(&[("tp_trigger_by", json!("MarkPrice"))]);
2732 let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2733 assert!(err.to_string().contains("TP override fields require"));
2734 }
2735
2736 #[rstest]
2737 fn test_parse_tp_sl_params_accepts_numeric_prices() {
2738 let p = params_from(&[("take_profit", json!(55000.0)), ("stop_loss", json!(47000))]);
2739 let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2740 assert!(result.take_profit.is_some());
2741 assert!(result.stop_loss.is_some());
2742 }
2743
2744 #[rstest]
2745 fn test_parse_tp_sl_params_rejects_orphaned_sl_fields() {
2746 let p = params_from(&[("sl_trigger_by", json!("IndexPrice"))]);
2747 let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2748 assert!(err.to_string().contains("SL override fields require"));
2749 }
2750
2751 #[rstest]
2752 fn test_parse_tp_sl_params_rejects_bool_order_iv() {
2753 let p = params_from(&[("order_iv", json!(true))]);
2754 let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2755 assert!(err.to_string().contains("order_iv"));
2756 }
2757
2758 #[rstest]
2759 fn test_parse_tp_sl_params_rejects_string_mmp() {
2760 let p = params_from(&[("mmp", json!("true"))]);
2761 let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2762 assert!(err.to_string().contains("mmp"));
2763 }
2764
2765 #[rstest]
2766 fn test_parse_tp_sl_params_order_iv_string() {
2767 let p = params_from(&[("order_iv", json!("0.75"))]);
2768 let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2769 assert_eq!(result.order_iv.as_deref(), Some("0.75"));
2770 }
2771
2772 #[rstest]
2773 fn test_parse_tp_sl_params_order_iv_numeric() {
2774 let p = params_from(&[("order_iv", json!(0.75))]);
2775 let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2776 assert_eq!(result.order_iv.as_deref(), Some("0.75"));
2777 }
2778
2779 #[rstest]
2780 fn test_parse_tp_sl_params_mmp() {
2781 let p = params_from(&[("mmp", json!(true))]);
2782 let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2783 assert_eq!(result.mmp, Some(true));
2784 }
2785
2786 #[rstest]
2787 #[case(0, BybitPositionIdx::OneWay)]
2788 #[case(1, BybitPositionIdx::BuyHedge)]
2789 #[case(2, BybitPositionIdx::SellHedge)]
2790 fn test_parse_tp_sl_params_position_idx_valid(
2791 #[case] idx: i64,
2792 #[case] expected: BybitPositionIdx,
2793 ) {
2794 let p = params_from(&[("position_idx", json!(idx))]);
2795 let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2796 assert_eq!(result.position_idx, Some(expected));
2797 }
2798
2799 #[rstest]
2800 #[case(json!(3))]
2801 #[case(json!(-1))]
2802 #[case(json!("1"))]
2803 #[case(json!(true))]
2804 fn test_parse_tp_sl_params_position_idx_invalid(#[case] value: serde_json::Value) {
2805 let p = params_from(&[("position_idx", value)]);
2806 let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2807 assert!(err.to_string().contains("position_idx"));
2808 }
2809
2810 #[rstest]
2811 #[case(
2812 BybitOrderType::Market,
2813 BybitStopOrderType::TakeProfit,
2814 BybitTriggerDirection::RisesTo,
2815 BybitOrderSide::Sell,
2816 OrderType::MarketIfTouched
2817 )]
2818 #[case(
2819 BybitOrderType::Market,
2820 BybitStopOrderType::StopLoss,
2821 BybitTriggerDirection::FallsTo,
2822 BybitOrderSide::Sell,
2823 OrderType::StopMarket
2824 )]
2825 #[case(
2826 BybitOrderType::Market,
2827 BybitStopOrderType::TakeProfit,
2828 BybitTriggerDirection::FallsTo,
2829 BybitOrderSide::Buy,
2830 OrderType::MarketIfTouched
2831 )]
2832 #[case(
2833 BybitOrderType::Market,
2834 BybitStopOrderType::StopLoss,
2835 BybitTriggerDirection::RisesTo,
2836 BybitOrderSide::Buy,
2837 OrderType::StopMarket
2838 )]
2839 #[case(
2840 BybitOrderType::Limit,
2841 BybitStopOrderType::TakeProfit,
2842 BybitTriggerDirection::RisesTo,
2843 BybitOrderSide::Sell,
2844 OrderType::LimitIfTouched
2845 )]
2846 #[case(
2847 BybitOrderType::Limit,
2848 BybitStopOrderType::StopLoss,
2849 BybitTriggerDirection::FallsTo,
2850 BybitOrderSide::Sell,
2851 OrderType::StopLimit
2852 )]
2853 #[case(
2854 BybitOrderType::Limit,
2855 BybitStopOrderType::PartialTakeProfit,
2856 BybitTriggerDirection::FallsTo,
2857 BybitOrderSide::Buy,
2858 OrderType::LimitIfTouched
2859 )]
2860 #[case(
2861 BybitOrderType::Limit,
2862 BybitStopOrderType::PartialStopLoss,
2863 BybitTriggerDirection::RisesTo,
2864 BybitOrderSide::Buy,
2865 OrderType::StopLimit
2866 )]
2867 #[case(
2868 BybitOrderType::Market,
2869 BybitStopOrderType::TpslOrder,
2870 BybitTriggerDirection::FallsTo,
2871 BybitOrderSide::Sell,
2872 OrderType::StopMarket
2873 )]
2874 #[case(
2875 BybitOrderType::Market,
2876 BybitStopOrderType::Stop,
2877 BybitTriggerDirection::RisesTo,
2878 BybitOrderSide::Buy,
2879 OrderType::StopMarket
2880 )]
2881 #[case(
2882 BybitOrderType::Market,
2883 BybitStopOrderType::Stop,
2884 BybitTriggerDirection::FallsTo,
2885 BybitOrderSide::Sell,
2886 OrderType::StopMarket
2887 )]
2888 #[case(
2889 BybitOrderType::Market,
2890 BybitStopOrderType::TrailingStop,
2891 BybitTriggerDirection::FallsTo,
2892 BybitOrderSide::Sell,
2893 OrderType::StopMarket
2894 )]
2895 #[case(
2896 BybitOrderType::Limit,
2897 BybitStopOrderType::TrailingStop,
2898 BybitTriggerDirection::RisesTo,
2899 BybitOrderSide::Buy,
2900 OrderType::StopLimit
2901 )]
2902 fn test_parse_bybit_order_type_conditional(
2903 #[case] order_type: BybitOrderType,
2904 #[case] stop_order_type: BybitStopOrderType,
2905 #[case] trigger_direction: BybitTriggerDirection,
2906 #[case] side: BybitOrderSide,
2907 #[case] expected: OrderType,
2908 ) {
2909 let result = parse_bybit_order_type(order_type, stop_order_type, trigger_direction, side);
2910 assert_eq!(result, expected);
2911 }
2912
2913 #[rstest]
2914 #[case(
2915 BybitOrderType::Market,
2916 BybitStopOrderType::None,
2917 BybitTriggerDirection::None,
2918 BybitOrderSide::Buy,
2919 OrderType::Market
2920 )]
2921 #[case(
2922 BybitOrderType::Limit,
2923 BybitStopOrderType::Unknown,
2924 BybitTriggerDirection::None,
2925 BybitOrderSide::Sell,
2926 OrderType::Limit
2927 )]
2928 #[case(
2929 BybitOrderType::Market,
2930 BybitStopOrderType::TakeProfit,
2931 BybitTriggerDirection::None,
2932 BybitOrderSide::Sell,
2933 OrderType::Market
2934 )]
2935 #[case(
2936 BybitOrderType::Limit,
2937 BybitStopOrderType::StopLoss,
2938 BybitTriggerDirection::None,
2939 BybitOrderSide::Buy,
2940 OrderType::Limit
2941 )]
2942 fn test_parse_bybit_order_type_plain(
2943 #[case] order_type: BybitOrderType,
2944 #[case] stop_order_type: BybitStopOrderType,
2945 #[case] trigger_direction: BybitTriggerDirection,
2946 #[case] side: BybitOrderSide,
2947 #[case] expected: OrderType,
2948 ) {
2949 let result = parse_bybit_order_type(order_type, stop_order_type, trigger_direction, side);
2950 assert_eq!(result, expected);
2951 }
2952
2953 #[rstest]
2954 fn test_parse_order_status_report_take_profit() {
2955 let instrument = linear_instrument();
2956 let json = load_test_json("http_get_orders_realtime_tp_sl.json");
2957 let response: BybitOpenOrdersResponse = serde_json::from_str(&json).unwrap();
2958 let order = &response.result.list[0];
2959 let account_id = AccountId::new("BYBIT-001");
2960
2961 let report = parse_order_status_report(order, &instrument, account_id, TS).unwrap();
2962
2963 assert_eq!(report.order_type, OrderType::MarketIfTouched);
2964 assert_eq!(report.order_side, OrderSide::Sell.into());
2965 assert_eq!(report.order_status, OrderStatus::Accepted);
2966 assert!(report.trigger_price.is_some());
2967 assert_eq!(
2968 report.trigger_price.unwrap(),
2969 Price::from_str("55000.0").unwrap()
2970 );
2971 assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
2972 assert!(report.reduce_only);
2973 }
2974
2975 #[rstest]
2976 fn test_parse_order_status_report_stop_loss_limit() {
2977 let instrument = linear_instrument();
2978 let json = load_test_json("http_get_orders_realtime_tp_sl.json");
2979 let response: BybitOpenOrdersResponse = serde_json::from_str(&json).unwrap();
2980 let order = &response.result.list[1];
2981 let account_id = AccountId::new("BYBIT-001");
2982
2983 let report = parse_order_status_report(order, &instrument, account_id, TS).unwrap();
2984
2985 assert_eq!(report.order_type, OrderType::StopLimit);
2986 assert_eq!(report.order_side, OrderSide::Sell.into());
2987 assert_eq!(report.order_status, OrderStatus::Accepted);
2988 assert!(report.trigger_price.is_some());
2989 assert_eq!(
2990 report.trigger_price.unwrap(),
2991 Price::from_str("48000.0").unwrap()
2992 );
2993 assert!(report.price.is_some());
2994 assert_eq!(report.price.unwrap(), Price::from_str("47500.0").unwrap());
2995 assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
2996 assert!(report.reduce_only);
2997 }
2998
2999 #[rstest]
3000 #[case::oneway(0, "BTCUSDT-LINEAR.BYBIT-ONEWAY")]
3001 #[case::long(1, "BTCUSDT-LINEAR.BYBIT-LONG")]
3002 #[case::short(2, "BTCUSDT-LINEAR.BYBIT-SHORT")]
3003 #[case::unknown(99, "BTCUSDT-LINEAR.BYBIT-UNKNOWN")]
3004 fn test_make_venue_position_id(#[case] position_idx: i32, #[case] expected: &str) {
3005 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
3006 let result = make_venue_position_id(instrument_id, position_idx);
3007 assert_eq!(result, PositionId::from(expected));
3008 }
3009
3010 #[rstest]
3011 #[case::oneway(0, None)]
3012 #[case::long(1, Some("BTCUSDT-LINEAR.BYBIT-LONG"))]
3013 #[case::short(2, Some("BTCUSDT-LINEAR.BYBIT-SHORT"))]
3014 #[case::unknown(99, None)]
3015 fn test_make_hedge_venue_position_id(
3016 #[case] position_idx: i32,
3017 #[case] expected: Option<&str>,
3018 ) {
3019 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
3020 let result = make_hedge_venue_position_id(instrument_id, position_idx);
3021 assert_eq!(result, expected.map(PositionId::from));
3022 }
3023
3024 #[rstest]
3025 #[case::buy_open(BybitOrderSide::Buy, false, BybitPositionIdx::BuyHedge)]
3026 #[case::sell_open(BybitOrderSide::Sell, false, BybitPositionIdx::SellHedge)]
3027 #[case::sell_close_long(BybitOrderSide::Sell, true, BybitPositionIdx::BuyHedge)]
3028 #[case::buy_close_short(BybitOrderSide::Buy, true, BybitPositionIdx::SellHedge)]
3029 fn test_resolve_position_idx_hedge_mode(
3030 #[case] side: BybitOrderSide,
3031 #[case] is_reduce_only: bool,
3032 #[case] expected: BybitPositionIdx,
3033 ) {
3034 let idx = resolve_position_idx(
3035 Some(BybitPositionMode::BothSides),
3036 side,
3037 is_reduce_only,
3038 None,
3039 );
3040 assert_eq!(idx, Some(expected));
3041 }
3042
3043 #[rstest]
3044 fn test_resolve_position_idx_one_way_mode() {
3045 let idx = resolve_position_idx(
3046 Some(BybitPositionMode::MergedSingle),
3047 BybitOrderSide::Buy,
3048 false,
3049 None,
3050 );
3051 assert_eq!(idx, Some(BybitPositionIdx::OneWay));
3052 }
3053
3054 #[rstest]
3055 fn test_resolve_position_idx_manual_override_wins() {
3056 let idx = resolve_position_idx(
3057 Some(BybitPositionMode::BothSides),
3058 BybitOrderSide::Buy,
3059 false,
3060 Some(BybitPositionIdx::SellHedge),
3061 );
3062 assert_eq!(idx, Some(BybitPositionIdx::SellHedge));
3063 }
3064
3065 #[rstest]
3066 fn test_resolve_position_idx_returns_none_when_unconfigured() {
3067 let idx = resolve_position_idx(None, BybitOrderSide::Buy, false, None);
3068 assert!(idx.is_none());
3069 }
3070
3071 #[rstest]
3072 fn test_parse_fill_report_venue_position_id_is_none() {
3073 let instrument = linear_instrument();
3074 let json = load_test_json("http_get_executions.json");
3075 let response: BybitTradeHistoryResponse = serde_json::from_str(&json).unwrap();
3076 let execution = &response.result.list[0];
3077 let account_id = AccountId::new("BYBIT-001");
3078
3079 let report = parse_fill_report(execution, account_id, &instrument, TS).unwrap();
3080
3081 assert_eq!(report.venue_position_id, None);
3082 }
3083
3084 #[rstest]
3085 #[case::corporate_action("CorporateAction", BybitExecType::CorporateAction, true)]
3086 #[case::forward_split_settle("ForwardSplitSettle", BybitExecType::ForwardSplitSettle, true)]
3087 #[case::reverse_split_settle("ReverseSplitSettle", BybitExecType::ReverseSplitSettle, true)]
3088 #[case::dividend("Dividend", BybitExecType::Dividend, true)]
3089 #[case::unknown_literal("UNKNOWN", BybitExecType::Unknown, false)]
3090 #[case::unrecognized("StockMerger", BybitExecType::Unknown, false)]
3091 fn test_parse_http_exec_type_fill_report(
3092 #[case] exec_type: &str,
3093 #[case] expected: BybitExecType,
3094 #[case] exchange_generated: bool,
3095 ) {
3096 let instrument = linear_instrument();
3097 let json = load_test_json("http_get_executions.json");
3098 let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
3099 value["result"]["list"][0]["execType"] = json!(exec_type);
3100 let response: BybitTradeHistoryResponse = serde_json::from_value(value).unwrap();
3101 let execution = &response.result.list[0];
3102 let account_id = AccountId::new("BYBIT-001");
3103
3104 assert_eq!(execution.exec_type, expected);
3105 assert_eq!(
3106 execution.exec_type.is_exchange_generated(),
3107 exchange_generated
3108 );
3109
3110 let report = parse_fill_report(execution, account_id, &instrument, TS).unwrap();
3111
3112 assert_eq!(
3113 report.venue_order_id,
3114 VenueOrderId::from("8c065341-7b52-4ca9-ac2c-37e31ac55c94")
3115 );
3116 }
3117
3118 #[rstest]
3119 fn test_parse_order_status_report_venue_position_id_for_hedge() {
3120 let instrument = linear_instrument();
3121 let json = load_test_json("http_get_orders_realtime_tp_sl.json");
3122 let response: BybitOpenOrdersResponse = serde_json::from_str(&json).unwrap();
3123 let mut order = response.result.list[0].clone();
3124 order.position_idx = 2;
3125 let account_id = AccountId::new("BYBIT-001");
3126
3127 let report = parse_order_status_report(&order, &instrument, account_id, TS).unwrap();
3128
3129 assert_eq!(
3130 report.venue_position_id,
3131 Some(PositionId::from("BTCUSDT-LINEAR.BYBIT-SHORT"))
3132 );
3133 }
3134
3135 #[rstest]
3136 fn test_parse_position_status_report_venue_position_id_for_hedge() {
3137 let json = load_test_json("http_get_positions.json");
3138 let response: BybitPositionListResponse = serde_json::from_str(&json).unwrap();
3139 let mut position = response.result.list[0].clone();
3140 position.position_idx = BybitPositionIdx::BuyHedge;
3141 let instrument = linear_instrument();
3142 let account_id = AccountId::new("BYBIT-001");
3143
3144 let report = parse_position_status_report(&position, account_id, &instrument, TS).unwrap();
3145
3146 assert_eq!(
3147 report.venue_position_id,
3148 Some(PositionId::from("BTCUSDT-LINEAR.BYBIT-LONG"))
3149 );
3150 }
3151
3152 #[rstest]
3153 fn test_parse_order_status_report_venue_position_id_is_none() {
3154 let instrument = linear_instrument();
3155 let json = load_test_json("http_get_orders_realtime_tp_sl.json");
3156 let response: BybitOpenOrdersResponse = serde_json::from_str(&json).unwrap();
3157 let order = &response.result.list[0]; let account_id = AccountId::new("BYBIT-001");
3159
3160 let report = parse_order_status_report(order, &instrument, account_id, TS).unwrap();
3161
3162 assert_eq!(report.venue_position_id, None);
3163 }
3164}