Skip to main content

nautilus_bybit/common/
parse.rs

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