Skip to main content

nautilus_bybit/common/
parse.rs

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