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