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