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