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 BybitContractType, BybitKlineInterval, BybitMarketUnit, BybitOptionType,
156 BybitOrderSide, BybitOrderStatus, BybitOrderType, BybitPositionIdx, BybitPositionSide,
157 BybitProductType, BybitStopOrderType, BybitTimeInForce, BybitTriggerDirection,
158 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]
873pub fn make_venue_position_id(instrument_id: InstrumentId, position_idx: i32) -> PositionId {
874 let side = match position_idx {
875 0 => "ONEWAY",
876 1 => "LONG",
877 2 => "SHORT",
878 _ => "UNKNOWN",
879 };
880 PositionId::new(format!("{instrument_id}-{side}"))
881}
882
883pub fn parse_fill_report(
892 execution: &BybitExecution,
893 account_id: AccountId,
894 instrument: &InstrumentAny,
895 ts_init: UnixNanos,
896) -> anyhow::Result<FillReport> {
897 let instrument_id = instrument.id();
898 let venue_order_id = VenueOrderId::new(execution.order_id.as_str());
899 let trade_id = TradeId::new_checked(execution.exec_id.as_str())
900 .context("invalid execId in Bybit execution payload")?;
901
902 let order_side: OrderSide = execution.side.into();
903
904 let last_px = parse_price_with_precision(
905 &execution.exec_price,
906 instrument.price_precision(),
907 "execution.execPrice",
908 )?;
909
910 let last_qty = parse_quantity_with_precision(
911 &execution.exec_qty,
912 instrument.size_precision(),
913 "execution.execQty",
914 )?;
915
916 let fee_decimal: Decimal = execution
917 .exec_fee
918 .parse()
919 .with_context(|| format!("Failed to parse execFee='{}'", execution.exec_fee))?;
920 let currency = get_currency(&execution.fee_currency);
921 let commission = Money::from_decimal(fee_decimal, currency).with_context(|| {
922 format!(
923 "Failed to create commission from execFee='{}'",
924 execution.exec_fee
925 )
926 })?;
927
928 let liquidity_side = if execution.is_maker {
930 LiquiditySide::Maker
931 } else {
932 LiquiditySide::Taker
933 };
934
935 let ts_event = parse_millis_timestamp(&execution.exec_time, "execution.execTime")?;
936
937 let client_order_id = if execution.order_link_id.is_empty() {
939 None
940 } else {
941 Some(ClientOrderId::new(execution.order_link_id.as_str()))
942 };
943
944 Ok(FillReport::new(
945 account_id,
946 instrument_id,
947 venue_order_id,
948 trade_id,
949 order_side,
950 last_qty,
951 last_px,
952 commission,
953 liquidity_side,
954 client_order_id,
955 None, ts_event,
957 ts_init,
958 None, ))
960}
961
962pub fn parse_position_status_report(
971 position: &BybitPosition,
972 account_id: AccountId,
973 instrument: &InstrumentAny,
974 ts_init: UnixNanos,
975) -> anyhow::Result<PositionStatusReport> {
976 let instrument_id = instrument.id();
977
978 let size_f64 = position
980 .size
981 .parse::<f64>()
982 .with_context(|| format!("Failed to parse position size '{}'", position.size))?;
983
984 let (position_side, quantity) = match position.side {
986 BybitPositionSide::Buy => {
987 let qty = Quantity::new(size_f64, instrument.size_precision());
988 (PositionSideSpecified::Long, qty)
989 }
990 BybitPositionSide::Sell => {
991 let qty = Quantity::new(size_f64, instrument.size_precision());
992 (PositionSideSpecified::Short, qty)
993 }
994 BybitPositionSide::Flat => {
995 let qty = Quantity::new(0.0, instrument.size_precision());
996 (PositionSideSpecified::Flat, qty)
997 }
998 };
999
1000 let avg_px_open = if position.avg_price.is_empty() || position.avg_price == "0" {
1002 None
1003 } else {
1004 Some(Decimal::from_str(&position.avg_price)?)
1005 };
1006
1007 let ts_last = if position.updated_time.is_empty() {
1009 ts_init
1010 } else {
1011 parse_millis_timestamp(&position.updated_time, "position.updatedTime")?
1012 };
1013
1014 if position.adl_rank_indicator >= 4 {
1017 log::warn!(
1018 "Elevated ADL risk: {} position size={} adl_rank={}",
1019 instrument_id,
1020 position.size,
1021 position.adl_rank_indicator,
1022 );
1023 }
1024
1025 Ok(PositionStatusReport::new(
1026 account_id,
1027 instrument_id,
1028 position_side,
1029 quantity,
1030 ts_last,
1031 ts_init,
1032 None, None, avg_px_open,
1035 ))
1036}
1037
1038pub fn parse_account_state(
1046 wallet_balance: &BybitWalletBalance,
1047 account_id: AccountId,
1048 ts_init: UnixNanos,
1049) -> anyhow::Result<AccountState> {
1050 let mut balances = Vec::new();
1051
1052 for coin in &wallet_balance.coin {
1053 let total_dec = coin.wallet_balance - coin.spot_borrow;
1054 let locked_dec = coin.locked;
1055
1056 let currency = get_currency(&coin.coin);
1057 balances.push(AccountBalance::from_total_and_locked(
1058 total_dec, locked_dec, currency,
1059 )?);
1060 }
1061
1062 let mut margins = Vec::new();
1063
1064 for coin in &wallet_balance.coin {
1065 let position_im_f64 = match &coin.total_position_im {
1069 Some(im) if !im.is_empty() => im.parse::<f64>()?,
1070 _ => 0.0,
1071 };
1072 let order_im_f64 = match &coin.total_order_im {
1073 Some(im) if !im.is_empty() => im.parse::<f64>()?,
1074 _ => 0.0,
1075 };
1076 let initial_margin_f64 = position_im_f64 + order_im_f64;
1077
1078 let maintenance_margin_f64 = match &coin.total_position_mm {
1079 Some(mm) if !mm.is_empty() => mm.parse::<f64>()?,
1080 _ => 0.0,
1081 };
1082
1083 if initial_margin_f64 == 0.0 && maintenance_margin_f64 == 0.0 {
1084 continue;
1085 }
1086
1087 let currency = get_currency(&coin.coin);
1088 let initial_margin = Money::new(initial_margin_f64, currency);
1089 let maintenance_margin = Money::new(maintenance_margin_f64, currency);
1090
1091 margins.push(MarginBalance::new(initial_margin, maintenance_margin, None));
1092 }
1093
1094 let account_type = AccountType::Margin;
1095 let is_reported = true;
1096 let event_id = UUID4::new();
1097
1098 let ts_event = ts_init;
1100
1101 Ok(AccountState::new(
1102 account_id,
1103 account_type,
1104 balances,
1105 margins,
1106 is_reported,
1107 event_id,
1108 ts_event,
1109 ts_init,
1110 None,
1111 ))
1112}
1113
1114pub(crate) fn parse_price_with_precision(
1115 value: &str,
1116 precision: u8,
1117 field: &str,
1118) -> anyhow::Result<Price> {
1119 let parsed = value
1120 .parse::<f64>()
1121 .with_context(|| format!("Failed to parse {field}='{value}' as f64"))?;
1122 Price::new_checked(parsed, precision).with_context(|| {
1123 format!("Failed to construct Price for {field} with precision {precision}")
1124 })
1125}
1126
1127pub(crate) fn parse_quantity_with_precision(
1128 value: &str,
1129 precision: u8,
1130 field: &str,
1131) -> anyhow::Result<Quantity> {
1132 let parsed = value
1133 .parse::<f64>()
1134 .with_context(|| format!("Failed to parse {field}='{value}' as f64"))?;
1135 Quantity::new_checked(parsed, precision).with_context(|| {
1136 format!("Failed to construct Quantity for {field} with precision {precision}")
1137 })
1138}
1139
1140pub(crate) fn parse_price(value: &str, field: &str) -> anyhow::Result<Price> {
1141 Price::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
1142}
1143
1144pub(crate) fn parse_quantity(value: &str, field: &str) -> anyhow::Result<Quantity> {
1145 Quantity::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
1146}
1147
1148pub(crate) fn parse_decimal(value: &str, field: &str) -> anyhow::Result<Decimal> {
1149 Decimal::from_str(value)
1150 .map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}' as Decimal: {e}"))
1151}
1152
1153pub(crate) fn parse_millis_timestamp(value: &str, field: &str) -> anyhow::Result<UnixNanos> {
1154 let millis: u64 = value
1155 .parse()
1156 .with_context(|| format!("Failed to parse {field}='{value}' as u64 millis"))?;
1157 let nanos = millis
1158 .checked_mul(NANOSECONDS_IN_MILLISECOND)
1159 .context("millisecond timestamp overflowed when converting to nanoseconds")?;
1160 Ok(UnixNanos::from(nanos))
1161}
1162
1163fn resolve_settlement_currency(
1164 settle_coin: &str,
1165 base_currency: Currency,
1166 quote_currency: Currency,
1167) -> anyhow::Result<Currency> {
1168 if settle_coin.eq_ignore_ascii_case(base_currency.code.as_str()) {
1169 Ok(base_currency)
1170 } else if settle_coin.eq_ignore_ascii_case(quote_currency.code.as_str()) {
1171 Ok(quote_currency)
1172 } else {
1173 Err(anyhow::anyhow!(
1174 "unrecognised settlement currency '{settle_coin}'"
1175 ))
1176 }
1177}
1178
1179pub fn get_currency(code: &str) -> Currency {
1184 Currency::get_or_create_crypto(code)
1185}
1186
1187fn extract_strike_from_symbol(symbol: &str) -> anyhow::Result<Price> {
1188 let parts: Vec<&str> = symbol.split('-').collect();
1189 let strike = parts
1190 .get(2)
1191 .ok_or_else(|| anyhow::anyhow!("invalid option symbol '{symbol}'"))?;
1192 parse_price(strike, "option strike")
1193}
1194
1195#[must_use]
1205pub fn parse_bybit_order_type(
1206 order_type: BybitOrderType,
1207 stop_order_type: BybitStopOrderType,
1208 trigger_direction: BybitTriggerDirection,
1209 side: BybitOrderSide,
1210) -> OrderType {
1211 if matches!(
1212 stop_order_type,
1213 BybitStopOrderType::None | BybitStopOrderType::Unknown
1214 ) {
1215 return match order_type {
1216 BybitOrderType::Market => OrderType::Market,
1217 BybitOrderType::Limit | BybitOrderType::Unknown => OrderType::Limit,
1218 };
1219 }
1220
1221 if trigger_direction == BybitTriggerDirection::None {
1224 return match order_type {
1225 BybitOrderType::Market => OrderType::Market,
1226 BybitOrderType::Limit | BybitOrderType::Unknown => OrderType::Limit,
1227 };
1228 }
1229
1230 match (order_type, trigger_direction, side) {
1233 (BybitOrderType::Market, BybitTriggerDirection::RisesTo, BybitOrderSide::Buy) => {
1234 OrderType::StopMarket
1235 }
1236 (BybitOrderType::Market, BybitTriggerDirection::FallsTo, BybitOrderSide::Buy) => {
1237 OrderType::MarketIfTouched
1238 }
1239 (BybitOrderType::Market, BybitTriggerDirection::FallsTo, BybitOrderSide::Sell) => {
1240 OrderType::StopMarket
1241 }
1242 (BybitOrderType::Market, BybitTriggerDirection::RisesTo, BybitOrderSide::Sell) => {
1243 OrderType::MarketIfTouched
1244 }
1245 (BybitOrderType::Limit, BybitTriggerDirection::RisesTo, BybitOrderSide::Buy) => {
1246 OrderType::StopLimit
1247 }
1248 (BybitOrderType::Limit, BybitTriggerDirection::FallsTo, BybitOrderSide::Buy) => {
1249 OrderType::LimitIfTouched
1250 }
1251 (BybitOrderType::Limit, BybitTriggerDirection::FallsTo, BybitOrderSide::Sell) => {
1252 OrderType::StopLimit
1253 }
1254 (BybitOrderType::Limit, BybitTriggerDirection::RisesTo, BybitOrderSide::Sell) => {
1255 OrderType::LimitIfTouched
1256 }
1257 _ => match order_type {
1258 BybitOrderType::Market => OrderType::Market,
1259 BybitOrderType::Limit | BybitOrderType::Unknown => OrderType::Limit,
1260 },
1261 }
1262}
1263
1264pub fn parse_order_status_report(
1266 order: &crate::http::models::BybitOrder,
1267 instrument: &InstrumentAny,
1268 account_id: AccountId,
1269 ts_init: UnixNanos,
1270) -> anyhow::Result<OrderStatusReport> {
1271 let instrument_id = instrument.id();
1272 let venue_order_id = VenueOrderId::new(order.order_id);
1273
1274 let order_side: OrderSide = order.side.into();
1275
1276 let order_type = parse_bybit_order_type(
1277 order.order_type,
1278 order.stop_order_type,
1279 order.trigger_direction,
1280 order.side,
1281 );
1282
1283 let time_in_force: TimeInForce = match order.time_in_force {
1284 BybitTimeInForce::Gtc => TimeInForce::Gtc,
1285 BybitTimeInForce::Ioc => TimeInForce::Ioc,
1286 BybitTimeInForce::Fok => TimeInForce::Fok,
1287 BybitTimeInForce::PostOnly => TimeInForce::Gtc,
1288 };
1289
1290 let quantity =
1291 parse_quantity_with_precision(&order.qty, instrument.size_precision(), "order.qty")?;
1292
1293 let filled_qty = parse_quantity_with_precision(
1294 &order.cum_exec_qty,
1295 instrument.size_precision(),
1296 "order.cumExecQty",
1297 )?;
1298
1299 let order_status: OrderStatus = match order.order_status {
1305 BybitOrderStatus::Created | BybitOrderStatus::New | BybitOrderStatus::Untriggered => {
1306 OrderStatus::Accepted
1307 }
1308 BybitOrderStatus::Rejected => {
1309 if filled_qty.is_positive() {
1310 OrderStatus::Canceled
1311 } else {
1312 OrderStatus::Rejected
1313 }
1314 }
1315 BybitOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
1316 BybitOrderStatus::Filled => OrderStatus::Filled,
1317 BybitOrderStatus::Canceled | BybitOrderStatus::PartiallyFilledCanceled => {
1318 OrderStatus::Canceled
1319 }
1320 BybitOrderStatus::Triggered => OrderStatus::Triggered,
1321 BybitOrderStatus::Deactivated => OrderStatus::Canceled,
1322 };
1323
1324 let ts_accepted = parse_millis_timestamp(&order.created_time, "order.createdTime")?;
1325 let ts_last = parse_millis_timestamp(&order.updated_time, "order.updatedTime")?;
1326
1327 let mut report = OrderStatusReport::new(
1328 account_id,
1329 instrument_id,
1330 None,
1331 venue_order_id,
1332 order_side,
1333 order_type,
1334 time_in_force,
1335 order_status,
1336 quantity,
1337 filled_qty,
1338 ts_accepted,
1339 ts_last,
1340 ts_init,
1341 Some(UUID4::new()),
1342 );
1343
1344 if !order.order_link_id.is_empty() {
1345 report = report.with_client_order_id(ClientOrderId::new(order.order_link_id.as_str()));
1346 }
1347
1348 if !order.price.is_empty() && order.price != "0" {
1349 let price =
1350 parse_price_with_precision(&order.price, instrument.price_precision(), "order.price")?;
1351 report = report.with_price(price);
1352 }
1353
1354 if let Some(avg_price) = &order.avg_price
1355 && !avg_price.is_empty()
1356 && avg_price != "0"
1357 {
1358 let avg_px = avg_price
1359 .parse::<f64>()
1360 .with_context(|| format!("Failed to parse avg_price='{avg_price}' as f64"))?;
1361 report = report.with_avg_px(avg_px)?;
1362 }
1363
1364 if !order.trigger_price.is_empty() && order.trigger_price != "0" {
1365 let trigger_price = parse_price_with_precision(
1366 &order.trigger_price,
1367 instrument.price_precision(),
1368 "order.triggerPrice",
1369 )?;
1370 report = report.with_trigger_price(trigger_price);
1371
1372 let trigger_type: TriggerType = order.trigger_by.into();
1374 report = report.with_trigger_type(trigger_type);
1375 }
1376
1377 if order.reduce_only {
1381 report = report.with_reduce_only(true);
1382 }
1383
1384 if order.time_in_force == BybitTimeInForce::PostOnly {
1385 report = report.with_post_only(true);
1386 }
1387
1388 Ok(report)
1389}
1390
1391#[must_use]
1393pub fn spot_market_unit(
1394 product_type: BybitProductType,
1395 order_type: BybitOrderType,
1396 is_quote_quantity: bool,
1397) -> Option<BybitMarketUnit> {
1398 if product_type == BybitProductType::Spot && order_type == BybitOrderType::Market {
1399 if is_quote_quantity {
1400 Some(BybitMarketUnit::QuoteCoin)
1401 } else {
1402 Some(BybitMarketUnit::BaseCoin)
1403 }
1404 } else {
1405 None
1406 }
1407}
1408
1409#[must_use]
1411pub fn spot_leverage(product_type: BybitProductType, is_leverage: bool) -> Option<i32> {
1412 if product_type == BybitProductType::Spot {
1413 Some(i32::from(is_leverage))
1414 } else {
1415 None
1416 }
1417}
1418
1419#[must_use]
1421pub fn trigger_direction(
1422 order_type: OrderType,
1423 order_side: OrderSide,
1424 is_stop_order: bool,
1425) -> Option<BybitTriggerDirection> {
1426 if !is_stop_order {
1427 return None;
1428 }
1429
1430 match (order_type, order_side) {
1431 (OrderType::StopMarket | OrderType::StopLimit, OrderSide::Buy) => {
1432 Some(BybitTriggerDirection::RisesTo)
1433 }
1434 (OrderType::StopMarket | OrderType::StopLimit, OrderSide::Sell) => {
1435 Some(BybitTriggerDirection::FallsTo)
1436 }
1437 (OrderType::MarketIfTouched | OrderType::LimitIfTouched, OrderSide::Buy) => {
1438 Some(BybitTriggerDirection::FallsTo)
1439 }
1440 (OrderType::MarketIfTouched | OrderType::LimitIfTouched, OrderSide::Sell) => {
1441 Some(BybitTriggerDirection::RisesTo)
1442 }
1443 _ => None,
1444 }
1445}
1446
1447pub fn map_time_in_force(
1451 order_type: BybitOrderType,
1452 time_in_force: Option<TimeInForce>,
1453 post_only: Option<bool>,
1454) -> Result<Option<BybitTimeInForce>, TimeInForce> {
1455 if order_type == BybitOrderType::Market {
1456 return Ok(None);
1457 }
1458
1459 if post_only == Some(true) {
1460 return Ok(Some(BybitTimeInForce::PostOnly));
1461 }
1462
1463 match time_in_force {
1464 Some(TimeInForce::Gtc) => Ok(Some(BybitTimeInForce::Gtc)),
1465 Some(TimeInForce::Ioc) => Ok(Some(BybitTimeInForce::Ioc)),
1466 Some(TimeInForce::Fok) => Ok(Some(BybitTimeInForce::Fok)),
1467 Some(tif) => Err(tif),
1468 None => Ok(None),
1469 }
1470}
1471
1472pub fn nanos_to_millis(value: Option<UnixNanos>) -> Option<i64> {
1474 value.map(|nanos| nanos_to_millis_u64(nanos.as_u64()) as i64)
1475}
1476
1477#[derive(Debug, Default)]
1479pub struct BybitTpSlParams {
1480 pub take_profit: Option<Price>,
1481 pub stop_loss: Option<Price>,
1482 pub tp_trigger_by: Option<BybitTriggerType>,
1483 pub sl_trigger_by: Option<BybitTriggerType>,
1484 pub tp_order_type: Option<BybitOrderType>,
1485 pub sl_order_type: Option<BybitOrderType>,
1486 pub tp_limit_price: Option<String>,
1487 pub sl_limit_price: Option<String>,
1488 pub tp_trigger_price: Option<String>,
1489 pub sl_trigger_price: Option<String>,
1490 pub close_on_trigger: Option<bool>,
1491 pub is_leverage: bool,
1492 pub order_iv: Option<String>,
1493 pub mmp: Option<bool>,
1494 pub position_idx: Option<BybitPositionIdx>,
1495}
1496
1497impl BybitTpSlParams {
1498 pub fn has_tp_sl(&self) -> bool {
1499 self.take_profit.is_some() || self.stop_loss.is_some()
1500 }
1501}
1502
1503pub fn get_price_str(params: &Params, key: &str) -> Option<String> {
1505 let value = params.get(key)?;
1506 if let Some(s) = value.as_str() {
1507 Some(s.to_string())
1508 } else if let Some(n) = value.as_f64() {
1509 Some(n.to_string())
1510 } else if let Some(n) = value.as_i64() {
1511 Some(n.to_string())
1512 } else {
1513 value.as_u64().map(|n| n.to_string())
1514 }
1515}
1516
1517pub fn parse_bybit_tp_sl_params(params: Option<&Params>) -> anyhow::Result<BybitTpSlParams> {
1519 let Some(params) = params else {
1520 return Ok(BybitTpSlParams::default());
1521 };
1522
1523 let mut result = BybitTpSlParams {
1524 is_leverage: params.get_bool("is_leverage").unwrap_or(false),
1525 ..Default::default()
1526 };
1527
1528 if let Some(s) = get_price_str(params, "take_profit") {
1529 let p =
1530 Price::from_str(&s).map_err(|e| anyhow::anyhow!("invalid 'take_profit' price: {e}"))?;
1531
1532 if p.as_f64() < 0.0 {
1533 anyhow::bail!("invalid 'take_profit' price: '{s}', expected a non-negative value");
1534 }
1535 result.take_profit = Some(p);
1536 }
1537
1538 if let Some(s) = get_price_str(params, "stop_loss") {
1539 let p =
1540 Price::from_str(&s).map_err(|e| anyhow::anyhow!("invalid 'stop_loss' price: {e}"))?;
1541
1542 if p.as_f64() < 0.0 {
1543 anyhow::bail!("invalid 'stop_loss' price: '{s}', expected a non-negative value");
1544 }
1545 result.stop_loss = Some(p);
1546 }
1547
1548 for (key, setter) in [
1549 (
1550 "tp_limit_price",
1551 &mut result.tp_limit_price as &mut Option<String>,
1552 ),
1553 ("sl_limit_price", &mut result.sl_limit_price),
1554 ("tp_trigger_price", &mut result.tp_trigger_price),
1555 ("sl_trigger_price", &mut result.sl_trigger_price),
1556 ] {
1557 if let Some(s) = get_price_str(params, key) {
1558 let v: f64 = s
1559 .parse()
1560 .map_err(|_| anyhow::anyhow!("invalid price for '{key}': '{s}'"))?;
1561
1562 if !v.is_finite() || v < 0.0 {
1563 anyhow::bail!(
1564 "invalid price for '{key}': '{s}', expected a finite non-negative number"
1565 );
1566 }
1567 *setter = Some(s);
1568 }
1569 }
1570
1571 if let Some(s) = params.get_str("tp_trigger_by") {
1572 result.tp_trigger_by = Some(parse_trigger_type(s)?);
1573 }
1574
1575 if let Some(s) = params.get_str("sl_trigger_by") {
1576 result.sl_trigger_by = Some(parse_trigger_type(s)?);
1577 }
1578
1579 if let Some(s) = params.get_str("tp_order_type") {
1580 result.tp_order_type = Some(parse_tp_sl_order_type(s)?);
1581 }
1582
1583 if let Some(s) = params.get_str("sl_order_type") {
1584 result.sl_order_type = Some(parse_tp_sl_order_type(s)?);
1585 }
1586
1587 let has_tp_fields = result.tp_trigger_by.is_some()
1588 || result.tp_order_type.is_some()
1589 || result.tp_limit_price.is_some()
1590 || result.tp_trigger_price.is_some();
1591
1592 let has_sl_fields = result.sl_trigger_by.is_some()
1593 || result.sl_order_type.is_some()
1594 || result.sl_limit_price.is_some()
1595 || result.sl_trigger_price.is_some();
1596
1597 if result.take_profit.is_none() && has_tp_fields {
1598 anyhow::bail!("TP override fields require 'take_profit' to be set");
1599 }
1600
1601 if result.stop_loss.is_none() && has_sl_fields {
1602 anyhow::bail!("SL override fields require 'stop_loss' to be set");
1603 }
1604
1605 if result.tp_order_type == Some(BybitOrderType::Limit) && result.tp_limit_price.is_none() {
1606 anyhow::bail!("'tp_order_type' is 'Limit' but 'tp_limit_price' was not provided");
1607 }
1608
1609 if result.sl_order_type == Some(BybitOrderType::Limit) && result.sl_limit_price.is_none() {
1610 anyhow::bail!("'sl_order_type' is 'Limit' but 'sl_limit_price' was not provided");
1611 }
1612
1613 if result.tp_limit_price.is_some() && result.tp_order_type != Some(BybitOrderType::Limit) {
1614 anyhow::bail!("'tp_limit_price' requires 'tp_order_type' to be 'Limit'");
1615 }
1616
1617 if result.sl_limit_price.is_some() && result.sl_order_type != Some(BybitOrderType::Limit) {
1618 anyhow::bail!("'sl_limit_price' requires 'sl_order_type' to be 'Limit'");
1619 }
1620
1621 result.close_on_trigger = params.get_bool("close_on_trigger");
1622
1623 if let Some(value) = params.get("order_iv") {
1624 match get_price_str(params, "order_iv") {
1625 Some(s) => result.order_iv = Some(s),
1626 None => {
1627 anyhow::bail!("invalid type for 'order_iv': {value}, expected string or number")
1628 }
1629 }
1630 }
1631
1632 if let Some(value) = params.get("mmp") {
1633 match value.as_bool() {
1634 Some(b) => result.mmp = Some(b),
1635 None => anyhow::bail!("invalid type for 'mmp': {value}, expected bool"),
1636 }
1637 }
1638
1639 if let Some(value) = params.get("position_idx") {
1640 let idx = value.as_i64().ok_or_else(|| {
1641 anyhow::anyhow!("invalid type for 'position_idx': {value}, expected integer")
1642 })?;
1643 result.position_idx = Some(match idx {
1644 0 => BybitPositionIdx::OneWay,
1645 1 => BybitPositionIdx::BuyHedge,
1646 2 => BybitPositionIdx::SellHedge,
1647 _ => anyhow::bail!("invalid 'position_idx': {idx}, expected 0, 1, or 2"),
1648 });
1649 }
1650
1651 Ok(result)
1652}
1653
1654fn parse_trigger_type(s: &str) -> anyhow::Result<BybitTriggerType> {
1655 match s {
1656 "LastPrice" => Ok(BybitTriggerType::LastPrice),
1657 "MarkPrice" => Ok(BybitTriggerType::MarkPrice),
1658 "IndexPrice" => Ok(BybitTriggerType::IndexPrice),
1659 _ => anyhow::bail!(
1660 "invalid Bybit trigger type: '{s}', expected LastPrice, MarkPrice, or IndexPrice"
1661 ),
1662 }
1663}
1664
1665fn parse_tp_sl_order_type(s: &str) -> anyhow::Result<BybitOrderType> {
1666 match s {
1667 "Market" => Ok(BybitOrderType::Market),
1668 "Limit" => Ok(BybitOrderType::Limit),
1669 _ => anyhow::bail!("invalid Bybit TP/SL order type: '{s}', expected Market or Limit"),
1670 }
1671}
1672
1673#[cfg(test)]
1674mod tests {
1675 use nautilus_model::{
1676 data::BarSpecification,
1677 enums::{AggregationSource, BarAggregation, PositionSide, PriceType},
1678 };
1679 use rstest::rstest;
1680 use serde_json::json;
1681
1682 use super::*;
1683 use crate::{
1684 common::{
1685 enums::{BybitOrderSide, BybitOrderType, BybitStopOrderType, BybitTriggerDirection},
1686 testing::load_test_json,
1687 },
1688 http::models::{
1689 BybitInstrumentInverseResponse, BybitInstrumentLinearResponse,
1690 BybitInstrumentOptionResponse, BybitInstrumentSpotResponse, BybitKlinesResponse,
1691 BybitOpenOrdersResponse, BybitTradeHistoryResponse, BybitTradesResponse,
1692 },
1693 };
1694
1695 const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
1696
1697 fn sample_fee_rate(
1698 symbol: &str,
1699 taker: &str,
1700 maker: &str,
1701 base_coin: Option<&str>,
1702 ) -> BybitFeeRate {
1703 BybitFeeRate {
1704 symbol: Ustr::from(symbol),
1705 taker_fee_rate: taker.to_string(),
1706 maker_fee_rate: maker.to_string(),
1707 base_coin: base_coin.map(Ustr::from),
1708 }
1709 }
1710
1711 fn linear_instrument() -> InstrumentAny {
1712 let json = load_test_json("http_get_instruments_linear.json");
1713 let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
1714 let instrument = &response.result.list[0];
1715 let fee_rate = sample_fee_rate("BTCUSDT", "0.00055", "0.0001", Some("BTC"));
1716 parse_linear_instrument(instrument, &fee_rate, TS, TS).unwrap()
1717 }
1718
1719 #[rstest]
1720 fn parse_spot_instrument_builds_currency_pair() {
1721 let json = load_test_json("http_get_instruments_spot.json");
1722 let response: BybitInstrumentSpotResponse = serde_json::from_str(&json).unwrap();
1723 let instrument = &response.result.list[0];
1724 let fee_rate = sample_fee_rate("BTCUSDT", "0.0006", "0.0001", Some("BTC"));
1725
1726 let parsed = parse_spot_instrument(instrument, &fee_rate, TS, TS).unwrap();
1727 match parsed {
1728 InstrumentAny::CurrencyPair(pair) => {
1729 assert_eq!(pair.id.to_string(), "BTCUSDT-SPOT.BYBIT");
1730 assert_eq!(pair.price_increment, Price::from_str("0.1").unwrap());
1731 assert_eq!(pair.size_increment, Quantity::from_str("0.0001").unwrap());
1732 assert_eq!(pair.base_currency.code.as_str(), "BTC");
1733 assert_eq!(pair.quote_currency.code.as_str(), "USDT");
1734 }
1735 _ => panic!("expected CurrencyPair"),
1736 }
1737 }
1738
1739 #[rstest]
1740 fn parse_linear_perpetual_instrument_builds_crypto_perpetual() {
1741 let json = load_test_json("http_get_instruments_linear.json");
1742 let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
1743 let instrument = &response.result.list[0];
1744 let fee_rate = sample_fee_rate("BTCUSDT", "0.00055", "0.0001", Some("BTC"));
1745
1746 let parsed = parse_linear_instrument(instrument, &fee_rate, TS, TS).unwrap();
1747 match parsed {
1748 InstrumentAny::CryptoPerpetual(perp) => {
1749 assert_eq!(perp.id.to_string(), "BTCUSDT-LINEAR.BYBIT");
1750 assert!(!perp.is_inverse);
1751 assert_eq!(perp.price_increment, Price::from_str("0.5").unwrap());
1752 assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
1753 }
1754 other => panic!("unexpected instrument variant: {other:?}"),
1755 }
1756 }
1757
1758 #[rstest]
1759 fn parse_inverse_perpetual_instrument_builds_inverse_perpetual() {
1760 let json = load_test_json("http_get_instruments_inverse.json");
1761 let response: BybitInstrumentInverseResponse = serde_json::from_str(&json).unwrap();
1762 let instrument = &response.result.list[0];
1763 let fee_rate = sample_fee_rate("BTCUSD", "0.00075", "0.00025", Some("BTC"));
1764
1765 let parsed = parse_inverse_instrument(instrument, &fee_rate, TS, TS).unwrap();
1766 match parsed {
1767 InstrumentAny::CryptoPerpetual(perp) => {
1768 assert_eq!(perp.id.to_string(), "BTCUSD-INVERSE.BYBIT");
1769 assert!(perp.is_inverse);
1770 assert_eq!(perp.price_increment, Price::from_str("0.5").unwrap());
1771 assert_eq!(perp.size_increment, Quantity::from_str("1").unwrap());
1772 }
1773 other => panic!("unexpected instrument variant: {other:?}"),
1774 }
1775 }
1776
1777 #[rstest]
1778 fn parse_option_instrument_builds_crypto_option() {
1779 let json = load_test_json("http_get_instruments_option.json");
1780 let response: BybitInstrumentOptionResponse = serde_json::from_str(&json).unwrap();
1781 let instrument = &response.result.list[0];
1782
1783 let parsed = parse_option_instrument(instrument, None, TS, TS).unwrap();
1784 match parsed {
1785 InstrumentAny::CryptoOption(option) => {
1786 assert_eq!(option.id.to_string(), "ETH-26JUN26-16000-P-OPTION.BYBIT");
1787 assert_eq!(option.underlying.code.as_str(), "ETH");
1788 assert_eq!(option.quote_currency.code.as_str(), "USDC");
1789 assert_eq!(option.settlement_currency.code.as_str(), "USDC");
1790 assert!(!option.is_inverse);
1791 assert_eq!(option.option_kind, OptionKind::Put);
1792 assert_eq!(option.price_precision, 1);
1793 assert_eq!(option.price_increment, Price::from_str("0.1").unwrap());
1794 assert_eq!(option.size_precision, 0);
1795 assert_eq!(option.size_increment, Quantity::from_str("1").unwrap());
1796 assert_eq!(option.lot_size, Quantity::from_str("1").unwrap());
1797 }
1798 other => panic!("unexpected instrument variant: {other:?}"),
1799 }
1800 }
1801
1802 #[rstest]
1803 fn test_extract_base_coin_from_option_symbol() {
1804 assert_eq!(extract_base_coin("BTC-27MAR26-70000-P"), "BTC");
1805 assert_eq!(extract_base_coin("ETH-26JUN26-16000-C"), "ETH");
1806 assert_eq!(extract_base_coin("SOL-30MAR26-200-P-USDT"), "SOL");
1807 assert_eq!(extract_base_coin("BTC"), "BTC");
1808 }
1809
1810 #[rstest]
1811 fn test_extract_base_coin_from_nautilus_option_symbol() {
1812 let raw = extract_raw_symbol("BTC-27MAR26-70000-P-USDT-OPTION");
1814 assert_eq!(extract_base_coin(raw), "BTC");
1815 }
1816
1817 #[rstest]
1818 fn parse_option_instrument_with_fee_rate() {
1819 let json = load_test_json("http_get_instruments_option.json");
1820 let response: BybitInstrumentOptionResponse = serde_json::from_str(&json).unwrap();
1821 let instrument = &response.result.list[0];
1822 let fee = sample_fee_rate("", "0.0006", "0.0001", Some("ETH"));
1823
1824 let parsed = parse_option_instrument(instrument, Some(&fee), TS, TS).unwrap();
1825 match parsed {
1826 InstrumentAny::CryptoOption(option) => {
1827 assert_eq!(option.taker_fee, Decimal::new(6, 4));
1828 assert_eq!(option.maker_fee, Decimal::new(1, 4));
1829 assert_eq!(option.margin_init, Decimal::ZERO);
1830 assert_eq!(option.margin_maint, Decimal::ZERO);
1831 }
1832 other => panic!("unexpected instrument variant: {other:?}"),
1833 }
1834 }
1835
1836 #[rstest]
1837 fn parse_option_instrument_without_fee_rate_defaults_to_zero() {
1838 let json = load_test_json("http_get_instruments_option.json");
1839 let response: BybitInstrumentOptionResponse = serde_json::from_str(&json).unwrap();
1840 let instrument = &response.result.list[0];
1841
1842 let parsed = parse_option_instrument(instrument, None, TS, TS).unwrap();
1843 match parsed {
1844 InstrumentAny::CryptoOption(option) => {
1845 assert_eq!(option.taker_fee, Decimal::ZERO);
1846 assert_eq!(option.maker_fee, Decimal::ZERO);
1847 }
1848 other => panic!("unexpected instrument variant: {other:?}"),
1849 }
1850 }
1851
1852 #[rstest]
1853 fn parse_http_trade_into_trade_tick() {
1854 let instrument = linear_instrument();
1855 let json = load_test_json("http_get_trades_recent.json");
1856 let response: BybitTradesResponse = serde_json::from_str(&json).unwrap();
1857 let trade = &response.result.list[0];
1858
1859 let tick = parse_trade_tick(trade, &instrument, Some(TS)).unwrap();
1860
1861 assert_eq!(tick.instrument_id, instrument.id());
1862 assert_eq!(tick.price, instrument.make_price(27450.50));
1863 assert_eq!(tick.size, instrument.make_qty(0.005, None));
1864 assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
1865 assert_eq!(
1866 tick.trade_id.to_string(),
1867 "a905d5c3-1ed0-4f37-83e4-9c73a2fe2f01"
1868 );
1869 assert_eq!(tick.ts_event, UnixNanos::new(1_709_891_679_000_000_000));
1870 }
1871
1872 #[rstest]
1873 fn parse_kline_into_bar() {
1874 let instrument = linear_instrument();
1875 let json = load_test_json("http_get_klines_linear.json");
1876 let response: BybitKlinesResponse = serde_json::from_str(&json).unwrap();
1877 let kline = &response.result.list[0];
1878
1879 let bar_type = BarType::new(
1880 instrument.id(),
1881 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
1882 AggregationSource::External,
1883 );
1884
1885 let bar = parse_kline_bar(kline, &instrument, bar_type, false, Some(TS)).unwrap();
1886
1887 assert_eq!(bar.bar_type.to_string(), bar_type.to_string());
1888 assert_eq!(bar.open, instrument.make_price(27450.0));
1889 assert_eq!(bar.high, instrument.make_price(27460.0));
1890 assert_eq!(bar.low, instrument.make_price(27440.0));
1891 assert_eq!(bar.close, instrument.make_price(27455.0));
1892 assert_eq!(bar.volume, instrument.make_qty(123.45, None));
1893 assert_eq!(bar.ts_event, UnixNanos::new(1_709_891_679_000_000_000));
1894 }
1895
1896 #[rstest]
1897 fn parse_http_position_short_into_position_status_report() {
1898 use crate::http::models::BybitPositionListResponse;
1899
1900 let json = load_test_json("http_get_positions.json");
1901 let response: BybitPositionListResponse = serde_json::from_str(&json).unwrap();
1902
1903 let short_position = &response.result.list[1];
1905 assert_eq!(short_position.symbol.as_str(), "ETHUSDT");
1906 assert_eq!(short_position.side, BybitPositionSide::Sell);
1907
1908 let eth_json = load_test_json("http_get_instruments_linear.json");
1910 let eth_response: BybitInstrumentLinearResponse = serde_json::from_str(ð_json).unwrap();
1911 let eth_def = ð_response.result.list[1]; let fee_rate = sample_fee_rate("ETHUSDT", "0.00055", "0.0001", Some("ETH"));
1913 let eth_instrument = parse_linear_instrument(eth_def, &fee_rate, TS, TS).unwrap();
1914
1915 let account_id = AccountId::new("BYBIT-001");
1916 let report =
1917 parse_position_status_report(short_position, account_id, ð_instrument, TS).unwrap();
1918
1919 assert_eq!(report.account_id, account_id);
1921 assert_eq!(report.instrument_id.symbol.as_str(), "ETHUSDT-LINEAR");
1922 assert_eq!(report.position_side.as_position_side(), PositionSide::Short);
1923 assert_eq!(report.quantity, eth_instrument.make_qty(5.0, None));
1924 assert_eq!(
1925 report.avg_px_open,
1926 Some(Decimal::try_from(3000.00).unwrap())
1927 );
1928 assert_eq!(report.ts_last, UnixNanos::new(1_697_673_700_112_000_000));
1929 }
1930
1931 #[rstest]
1932 fn parse_http_order_partially_filled_rejected_maps_to_canceled() {
1933 use crate::http::models::BybitOrderHistoryResponse;
1934
1935 let instrument = linear_instrument();
1936 let json = load_test_json("http_get_order_partially_filled_rejected.json");
1937 let response: BybitOrderHistoryResponse = serde_json::from_str(&json).unwrap();
1938 let order = &response.result.list[0];
1939 let account_id = AccountId::new("BYBIT-001");
1940
1941 let report = parse_order_status_report(order, &instrument, account_id, TS).unwrap();
1942
1943 assert_eq!(report.order_status, OrderStatus::Canceled);
1945 assert_eq!(report.filled_qty, instrument.make_qty(0.005, None));
1946 assert_eq!(
1947 report.client_order_id.as_ref().unwrap().to_string(),
1948 "O-20251001-164609-APEX-000-49"
1949 );
1950 }
1951
1952 #[rstest]
1953 #[case(BarAggregation::Minute, 1, BybitKlineInterval::Minute1)]
1954 #[case(BarAggregation::Minute, 3, BybitKlineInterval::Minute3)]
1955 #[case(BarAggregation::Minute, 5, BybitKlineInterval::Minute5)]
1956 #[case(BarAggregation::Minute, 15, BybitKlineInterval::Minute15)]
1957 #[case(BarAggregation::Minute, 30, BybitKlineInterval::Minute30)]
1958 fn test_bar_spec_to_bybit_interval_minutes(
1959 #[case] aggregation: BarAggregation,
1960 #[case] step: u64,
1961 #[case] expected: BybitKlineInterval,
1962 ) {
1963 let result = bar_spec_to_bybit_interval(aggregation, step).unwrap();
1964 assert_eq!(result, expected);
1965 }
1966
1967 #[rstest]
1968 #[case(BarAggregation::Hour, 1, BybitKlineInterval::Hour1)]
1969 #[case(BarAggregation::Hour, 2, BybitKlineInterval::Hour2)]
1970 #[case(BarAggregation::Hour, 4, BybitKlineInterval::Hour4)]
1971 #[case(BarAggregation::Hour, 6, BybitKlineInterval::Hour6)]
1972 #[case(BarAggregation::Hour, 12, BybitKlineInterval::Hour12)]
1973 fn test_bar_spec_to_bybit_interval_hours(
1974 #[case] aggregation: BarAggregation,
1975 #[case] step: u64,
1976 #[case] expected: BybitKlineInterval,
1977 ) {
1978 let result = bar_spec_to_bybit_interval(aggregation, step).unwrap();
1979 assert_eq!(result, expected);
1980 }
1981
1982 #[rstest]
1983 #[case(BarAggregation::Day, 1, BybitKlineInterval::Day1)]
1984 #[case(BarAggregation::Week, 1, BybitKlineInterval::Week1)]
1985 #[case(BarAggregation::Month, 1, BybitKlineInterval::Month1)]
1986 fn test_bar_spec_to_bybit_interval_day_week_month(
1987 #[case] aggregation: BarAggregation,
1988 #[case] step: u64,
1989 #[case] expected: BybitKlineInterval,
1990 ) {
1991 let result = bar_spec_to_bybit_interval(aggregation, step).unwrap();
1992 assert_eq!(result, expected);
1993 }
1994
1995 #[rstest]
1996 #[case(BarAggregation::Minute, 2)]
1997 #[case(BarAggregation::Minute, 10)]
1998 #[case(BarAggregation::Hour, 3)]
1999 #[case(BarAggregation::Hour, 24)]
2000 #[case(BarAggregation::Day, 2)]
2001 #[case(BarAggregation::Week, 2)]
2002 #[case(BarAggregation::Month, 2)]
2003 fn test_bar_spec_to_bybit_interval_unsupported_steps(
2004 #[case] aggregation: BarAggregation,
2005 #[case] step: u64,
2006 ) {
2007 let result = bar_spec_to_bybit_interval(aggregation, step);
2008 assert!(result.is_err());
2009 }
2010
2011 #[rstest]
2012 fn test_bar_spec_to_bybit_interval_unsupported_aggregation() {
2013 let result = bar_spec_to_bybit_interval(BarAggregation::Second, 1);
2014 assert!(result.is_err());
2015 }
2016
2017 #[rstest]
2018 #[case("1", 1, BarAggregation::Minute)]
2019 #[case("3", 3, BarAggregation::Minute)]
2020 #[case("5", 5, BarAggregation::Minute)]
2021 #[case("15", 15, BarAggregation::Minute)]
2022 #[case("30", 30, BarAggregation::Minute)]
2023 fn test_bybit_interval_to_bar_spec_minutes(
2024 #[case] interval: &str,
2025 #[case] expected_step: usize,
2026 #[case] expected_aggregation: BarAggregation,
2027 ) {
2028 let result = bybit_interval_to_bar_spec(interval).unwrap();
2029 assert_eq!(result, (expected_step, expected_aggregation));
2030 }
2031
2032 #[rstest]
2033 #[case("60", 1, BarAggregation::Hour)]
2034 #[case("120", 2, BarAggregation::Hour)]
2035 #[case("240", 4, BarAggregation::Hour)]
2036 #[case("360", 6, BarAggregation::Hour)]
2037 #[case("720", 12, BarAggregation::Hour)]
2038 fn test_bybit_interval_to_bar_spec_hours(
2039 #[case] interval: &str,
2040 #[case] expected_step: usize,
2041 #[case] expected_aggregation: BarAggregation,
2042 ) {
2043 let result = bybit_interval_to_bar_spec(interval).unwrap();
2044 assert_eq!(result, (expected_step, expected_aggregation));
2045 }
2046
2047 #[rstest]
2048 #[case("D", 1, BarAggregation::Day)]
2049 #[case("W", 1, BarAggregation::Week)]
2050 #[case("M", 1, BarAggregation::Month)]
2051 fn test_bybit_interval_to_bar_spec_day_week_month(
2052 #[case] interval: &str,
2053 #[case] expected_step: usize,
2054 #[case] expected_aggregation: BarAggregation,
2055 ) {
2056 let result = bybit_interval_to_bar_spec(interval).unwrap();
2057 assert_eq!(result, (expected_step, expected_aggregation));
2058 }
2059
2060 #[rstest]
2061 #[case("2")]
2062 #[case("10")]
2063 #[case("100")]
2064 #[case("invalid")]
2065 #[case("")]
2066 fn test_bybit_interval_to_bar_spec_unsupported(#[case] interval: &str) {
2067 let result = bybit_interval_to_bar_spec(interval);
2068 assert!(result.is_none());
2069 }
2070
2071 fn params_from(pairs: &[(&str, serde_json::Value)]) -> Params {
2072 let mut p = Params::new();
2073 for (k, v) in pairs {
2074 p.insert(k.to_string(), v.clone());
2075 }
2076 p
2077 }
2078
2079 #[rstest]
2080 fn test_parse_tp_sl_params_none_returns_defaults() {
2081 let result = parse_bybit_tp_sl_params(None).unwrap();
2082 assert!(!result.is_leverage);
2083 assert!(!result.has_tp_sl());
2084 assert!(result.order_iv.is_none());
2085 assert!(result.mmp.is_none());
2086 }
2087
2088 #[rstest]
2089 fn test_parse_tp_sl_params_empty_returns_defaults() {
2090 let p = Params::new();
2091 let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2092 assert!(!result.is_leverage);
2093 assert!(!result.has_tp_sl());
2094 assert!(result.order_iv.is_none());
2095 assert!(result.mmp.is_none());
2096 }
2097
2098 #[rstest]
2099 fn test_parse_tp_sl_params_valid_full() {
2100 let p = params_from(&[
2101 ("take_profit", json!("55000.00")),
2102 ("stop_loss", json!("47000.00")),
2103 ("tp_trigger_by", json!("MarkPrice")),
2104 ("sl_trigger_by", json!("IndexPrice")),
2105 ("tp_order_type", json!("Limit")),
2106 ("tp_limit_price", json!("54990.00")),
2107 ("sl_order_type", json!("Market")),
2108 ("close_on_trigger", json!(true)),
2109 ("is_leverage", json!(true)),
2110 ]);
2111 let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2112
2113 assert!(result.has_tp_sl());
2114 assert!(result.take_profit.is_some());
2115 assert!(result.stop_loss.is_some());
2116 assert_eq!(result.tp_trigger_by, Some(BybitTriggerType::MarkPrice));
2117 assert_eq!(result.sl_trigger_by, Some(BybitTriggerType::IndexPrice));
2118 assert_eq!(result.tp_order_type, Some(BybitOrderType::Limit));
2119 assert_eq!(result.sl_order_type, Some(BybitOrderType::Market));
2120 assert_eq!(result.tp_limit_price.as_deref(), Some("54990.00"));
2121 assert_eq!(result.close_on_trigger, Some(true));
2122 assert!(result.is_leverage);
2123 }
2124
2125 #[rstest]
2126 #[case("abc")]
2127 #[case("nan")]
2128 #[case("inf")]
2129 #[case("-1.0")]
2130 fn test_parse_tp_sl_params_rejects_invalid_take_profit(#[case] price: &str) {
2131 let p = params_from(&[("take_profit", json!(price))]);
2132 assert!(parse_bybit_tp_sl_params(Some(&p)).is_err());
2133 }
2134
2135 #[rstest]
2136 #[case("abc")]
2137 #[case("nan")]
2138 #[case("inf")]
2139 fn test_parse_tp_sl_params_rejects_invalid_stop_loss(#[case] price: &str) {
2140 let p = params_from(&[("stop_loss", json!(price))]);
2141 assert!(parse_bybit_tp_sl_params(Some(&p)).is_err());
2142 }
2143
2144 #[rstest]
2145 #[case("nan")]
2146 #[case("inf")]
2147 #[case("-5.0")]
2148 #[case("not_a_number")]
2149 fn test_parse_tp_sl_params_rejects_invalid_limit_price(#[case] price: &str) {
2150 let p = params_from(&[
2151 ("take_profit", json!("55000.00")),
2152 ("tp_order_type", json!("Limit")),
2153 ("tp_limit_price", json!(price)),
2154 ]);
2155 assert!(parse_bybit_tp_sl_params(Some(&p)).is_err());
2156 }
2157
2158 #[rstest]
2159 fn test_parse_tp_sl_params_rejects_invalid_trigger_type() {
2160 let p = params_from(&[
2161 ("take_profit", json!("55000.00")),
2162 ("tp_trigger_by", json!("InvalidType")),
2163 ]);
2164 assert!(parse_bybit_tp_sl_params(Some(&p)).is_err());
2165 }
2166
2167 #[rstest]
2168 fn test_parse_tp_sl_params_rejects_invalid_order_type() {
2169 let p = params_from(&[
2170 ("stop_loss", json!("47000.00")),
2171 ("sl_order_type", json!("Stop")),
2172 ]);
2173 assert!(parse_bybit_tp_sl_params(Some(&p)).is_err());
2174 }
2175
2176 #[rstest]
2177 fn test_parse_tp_sl_params_rejects_limit_without_limit_price() {
2178 let p = params_from(&[
2179 ("take_profit", json!("55000.00")),
2180 ("tp_order_type", json!("Limit")),
2181 ]);
2182 let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2183 assert!(err.to_string().contains("tp_limit_price"));
2184 }
2185
2186 #[rstest]
2187 fn test_parse_tp_sl_params_rejects_limit_price_without_limit_type() {
2188 let p = params_from(&[
2189 ("take_profit", json!("55000.00")),
2190 ("tp_limit_price", json!("54990.00")),
2191 ]);
2192 let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2193 assert!(err.to_string().contains("tp_order_type"));
2194 }
2195
2196 #[rstest]
2197 fn test_parse_tp_sl_params_rejects_orphaned_tp_fields() {
2198 let p = params_from(&[("tp_trigger_by", json!("MarkPrice"))]);
2199 let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2200 assert!(err.to_string().contains("TP override fields require"));
2201 }
2202
2203 #[rstest]
2204 fn test_parse_tp_sl_params_accepts_numeric_prices() {
2205 let p = params_from(&[("take_profit", json!(55000.0)), ("stop_loss", json!(47000))]);
2206 let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2207 assert!(result.take_profit.is_some());
2208 assert!(result.stop_loss.is_some());
2209 }
2210
2211 #[rstest]
2212 fn test_parse_tp_sl_params_rejects_orphaned_sl_fields() {
2213 let p = params_from(&[("sl_trigger_by", json!("IndexPrice"))]);
2214 let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2215 assert!(err.to_string().contains("SL override fields require"));
2216 }
2217
2218 #[rstest]
2219 fn test_parse_tp_sl_params_rejects_bool_order_iv() {
2220 let p = params_from(&[("order_iv", json!(true))]);
2221 let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2222 assert!(err.to_string().contains("order_iv"));
2223 }
2224
2225 #[rstest]
2226 fn test_parse_tp_sl_params_rejects_string_mmp() {
2227 let p = params_from(&[("mmp", json!("true"))]);
2228 let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2229 assert!(err.to_string().contains("mmp"));
2230 }
2231
2232 #[rstest]
2233 fn test_parse_tp_sl_params_order_iv_string() {
2234 let p = params_from(&[("order_iv", json!("0.75"))]);
2235 let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2236 assert_eq!(result.order_iv.as_deref(), Some("0.75"));
2237 }
2238
2239 #[rstest]
2240 fn test_parse_tp_sl_params_order_iv_numeric() {
2241 let p = params_from(&[("order_iv", json!(0.75))]);
2242 let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2243 assert_eq!(result.order_iv.as_deref(), Some("0.75"));
2244 }
2245
2246 #[rstest]
2247 fn test_parse_tp_sl_params_mmp() {
2248 let p = params_from(&[("mmp", json!(true))]);
2249 let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2250 assert_eq!(result.mmp, Some(true));
2251 }
2252
2253 #[rstest]
2254 #[case(0, BybitPositionIdx::OneWay)]
2255 #[case(1, BybitPositionIdx::BuyHedge)]
2256 #[case(2, BybitPositionIdx::SellHedge)]
2257 fn test_parse_tp_sl_params_position_idx_valid(
2258 #[case] idx: i64,
2259 #[case] expected: BybitPositionIdx,
2260 ) {
2261 let p = params_from(&[("position_idx", json!(idx))]);
2262 let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2263 assert_eq!(result.position_idx, Some(expected));
2264 }
2265
2266 #[rstest]
2267 #[case(json!(3))]
2268 #[case(json!(-1))]
2269 #[case(json!("1"))]
2270 #[case(json!(true))]
2271 fn test_parse_tp_sl_params_position_idx_invalid(#[case] value: serde_json::Value) {
2272 let p = params_from(&[("position_idx", value)]);
2273 let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2274 assert!(err.to_string().contains("position_idx"));
2275 }
2276
2277 #[rstest]
2278 #[case(
2279 BybitOrderType::Market,
2280 BybitStopOrderType::TakeProfit,
2281 BybitTriggerDirection::RisesTo,
2282 BybitOrderSide::Sell,
2283 OrderType::MarketIfTouched
2284 )]
2285 #[case(
2286 BybitOrderType::Market,
2287 BybitStopOrderType::StopLoss,
2288 BybitTriggerDirection::FallsTo,
2289 BybitOrderSide::Sell,
2290 OrderType::StopMarket
2291 )]
2292 #[case(
2293 BybitOrderType::Market,
2294 BybitStopOrderType::TakeProfit,
2295 BybitTriggerDirection::FallsTo,
2296 BybitOrderSide::Buy,
2297 OrderType::MarketIfTouched
2298 )]
2299 #[case(
2300 BybitOrderType::Market,
2301 BybitStopOrderType::StopLoss,
2302 BybitTriggerDirection::RisesTo,
2303 BybitOrderSide::Buy,
2304 OrderType::StopMarket
2305 )]
2306 #[case(
2307 BybitOrderType::Limit,
2308 BybitStopOrderType::TakeProfit,
2309 BybitTriggerDirection::RisesTo,
2310 BybitOrderSide::Sell,
2311 OrderType::LimitIfTouched
2312 )]
2313 #[case(
2314 BybitOrderType::Limit,
2315 BybitStopOrderType::StopLoss,
2316 BybitTriggerDirection::FallsTo,
2317 BybitOrderSide::Sell,
2318 OrderType::StopLimit
2319 )]
2320 #[case(
2321 BybitOrderType::Limit,
2322 BybitStopOrderType::PartialTakeProfit,
2323 BybitTriggerDirection::FallsTo,
2324 BybitOrderSide::Buy,
2325 OrderType::LimitIfTouched
2326 )]
2327 #[case(
2328 BybitOrderType::Limit,
2329 BybitStopOrderType::PartialStopLoss,
2330 BybitTriggerDirection::RisesTo,
2331 BybitOrderSide::Buy,
2332 OrderType::StopLimit
2333 )]
2334 #[case(
2335 BybitOrderType::Market,
2336 BybitStopOrderType::TpslOrder,
2337 BybitTriggerDirection::FallsTo,
2338 BybitOrderSide::Sell,
2339 OrderType::StopMarket
2340 )]
2341 #[case(
2342 BybitOrderType::Market,
2343 BybitStopOrderType::Stop,
2344 BybitTriggerDirection::RisesTo,
2345 BybitOrderSide::Buy,
2346 OrderType::StopMarket
2347 )]
2348 #[case(
2349 BybitOrderType::Market,
2350 BybitStopOrderType::Stop,
2351 BybitTriggerDirection::FallsTo,
2352 BybitOrderSide::Sell,
2353 OrderType::StopMarket
2354 )]
2355 #[case(
2356 BybitOrderType::Market,
2357 BybitStopOrderType::TrailingStop,
2358 BybitTriggerDirection::FallsTo,
2359 BybitOrderSide::Sell,
2360 OrderType::StopMarket
2361 )]
2362 #[case(
2363 BybitOrderType::Limit,
2364 BybitStopOrderType::TrailingStop,
2365 BybitTriggerDirection::RisesTo,
2366 BybitOrderSide::Buy,
2367 OrderType::StopLimit
2368 )]
2369 fn test_parse_bybit_order_type_conditional(
2370 #[case] order_type: BybitOrderType,
2371 #[case] stop_order_type: BybitStopOrderType,
2372 #[case] trigger_direction: BybitTriggerDirection,
2373 #[case] side: BybitOrderSide,
2374 #[case] expected: OrderType,
2375 ) {
2376 let result = parse_bybit_order_type(order_type, stop_order_type, trigger_direction, side);
2377 assert_eq!(result, expected);
2378 }
2379
2380 #[rstest]
2381 #[case(
2382 BybitOrderType::Market,
2383 BybitStopOrderType::None,
2384 BybitTriggerDirection::None,
2385 BybitOrderSide::Buy,
2386 OrderType::Market
2387 )]
2388 #[case(
2389 BybitOrderType::Limit,
2390 BybitStopOrderType::Unknown,
2391 BybitTriggerDirection::None,
2392 BybitOrderSide::Sell,
2393 OrderType::Limit
2394 )]
2395 #[case(
2396 BybitOrderType::Market,
2397 BybitStopOrderType::TakeProfit,
2398 BybitTriggerDirection::None,
2399 BybitOrderSide::Sell,
2400 OrderType::Market
2401 )]
2402 #[case(
2403 BybitOrderType::Limit,
2404 BybitStopOrderType::StopLoss,
2405 BybitTriggerDirection::None,
2406 BybitOrderSide::Buy,
2407 OrderType::Limit
2408 )]
2409 fn test_parse_bybit_order_type_plain(
2410 #[case] order_type: BybitOrderType,
2411 #[case] stop_order_type: BybitStopOrderType,
2412 #[case] trigger_direction: BybitTriggerDirection,
2413 #[case] side: BybitOrderSide,
2414 #[case] expected: OrderType,
2415 ) {
2416 let result = parse_bybit_order_type(order_type, stop_order_type, trigger_direction, side);
2417 assert_eq!(result, expected);
2418 }
2419
2420 #[rstest]
2421 fn test_parse_order_status_report_take_profit() {
2422 let instrument = linear_instrument();
2423 let json = load_test_json("http_get_orders_realtime_tp_sl.json");
2424 let response: BybitOpenOrdersResponse = serde_json::from_str(&json).unwrap();
2425 let order = &response.result.list[0];
2426 let account_id = AccountId::new("BYBIT-001");
2427
2428 let report = parse_order_status_report(order, &instrument, account_id, TS).unwrap();
2429
2430 assert_eq!(report.order_type, OrderType::MarketIfTouched);
2431 assert_eq!(report.order_side, OrderSide::Sell);
2432 assert_eq!(report.order_status, OrderStatus::Accepted);
2433 assert!(report.trigger_price.is_some());
2434 assert_eq!(
2435 report.trigger_price.unwrap(),
2436 Price::from_str("55000.0").unwrap()
2437 );
2438 assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
2439 assert!(report.reduce_only);
2440 }
2441
2442 #[rstest]
2443 fn test_parse_order_status_report_stop_loss_limit() {
2444 let instrument = linear_instrument();
2445 let json = load_test_json("http_get_orders_realtime_tp_sl.json");
2446 let response: BybitOpenOrdersResponse = serde_json::from_str(&json).unwrap();
2447 let order = &response.result.list[1];
2448 let account_id = AccountId::new("BYBIT-001");
2449
2450 let report = parse_order_status_report(order, &instrument, account_id, TS).unwrap();
2451
2452 assert_eq!(report.order_type, OrderType::StopLimit);
2453 assert_eq!(report.order_side, OrderSide::Sell);
2454 assert_eq!(report.order_status, OrderStatus::Accepted);
2455 assert!(report.trigger_price.is_some());
2456 assert_eq!(
2457 report.trigger_price.unwrap(),
2458 Price::from_str("48000.0").unwrap()
2459 );
2460 assert!(report.price.is_some());
2461 assert_eq!(report.price.unwrap(), Price::from_str("47500.0").unwrap());
2462 assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
2463 assert!(report.reduce_only);
2464 }
2465
2466 #[rstest]
2467 #[case::oneway(0, "BTCUSDT-LINEAR.BYBIT-ONEWAY")]
2468 #[case::long(1, "BTCUSDT-LINEAR.BYBIT-LONG")]
2469 #[case::short(2, "BTCUSDT-LINEAR.BYBIT-SHORT")]
2470 #[case::unknown(99, "BTCUSDT-LINEAR.BYBIT-UNKNOWN")]
2471 fn test_make_venue_position_id(#[case] position_idx: i32, #[case] expected: &str) {
2472 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2473 let result = make_venue_position_id(instrument_id, position_idx);
2474 assert_eq!(result, PositionId::from(expected));
2475 }
2476
2477 #[rstest]
2478 fn test_parse_fill_report_venue_position_id_is_none() {
2479 let instrument = linear_instrument();
2480 let json = load_test_json("http_get_executions.json");
2481 let response: BybitTradeHistoryResponse = serde_json::from_str(&json).unwrap();
2482 let execution = &response.result.list[0];
2483 let account_id = AccountId::new("BYBIT-001");
2484
2485 let report = parse_fill_report(execution, account_id, &instrument, TS).unwrap();
2486
2487 assert_eq!(report.venue_position_id, None);
2488 }
2489
2490 #[rstest]
2491 fn test_parse_order_status_report_venue_position_id_is_none() {
2492 let instrument = linear_instrument();
2493 let json = load_test_json("http_get_orders_realtime_tp_sl.json");
2494 let response: BybitOpenOrdersResponse = serde_json::from_str(&json).unwrap();
2495 let order = &response.result.list[0]; let account_id = AccountId::new("BYBIT-001");
2497
2498 let report = parse_order_status_report(order, &instrument, account_id, TS).unwrap();
2499
2500 assert_eq!(report.venue_position_id, None);
2501 }
2502}