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