Skip to main content

nautilus_bybit/common/
parse.rs

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