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