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