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