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