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            BybitContractType, BybitKlineInterval, BybitMarketUnit, BybitOptionType,
156            BybitOrderSide, BybitOrderStatus, BybitOrderType, BybitPositionIdx, BybitPositionSide,
157            BybitProductType, BybitStopOrderType, BybitTimeInForce, BybitTriggerDirection,
158            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///
869/// Not currently wired into reports because Bybit defaults to netting mode where
870/// non-None `venue_position_id` overrides the computed netting position ID.
871/// Ready to activate when hedge-mode support is added.
872#[must_use]
873pub fn make_venue_position_id(instrument_id: InstrumentId, position_idx: i32) -> PositionId {
874    let side = match position_idx {
875        0 => "ONEWAY",
876        1 => "LONG",
877        2 => "SHORT",
878        _ => "UNKNOWN",
879    };
880    PositionId::new(format!("{instrument_id}-{side}"))
881}
882
883/// Parses a Bybit execution into a Nautilus FillReport.
884///
885/// # Errors
886///
887/// This function returns an error if:
888/// - Required price or quantity fields cannot be parsed.
889/// - The execution timestamp cannot be parsed.
890/// - Numeric conversions fail.
891pub fn parse_fill_report(
892    execution: &BybitExecution,
893    account_id: AccountId,
894    instrument: &InstrumentAny,
895    ts_init: UnixNanos,
896) -> anyhow::Result<FillReport> {
897    let instrument_id = instrument.id();
898    let venue_order_id = VenueOrderId::new(execution.order_id.as_str());
899    let trade_id = TradeId::new_checked(execution.exec_id.as_str())
900        .context("invalid execId in Bybit execution payload")?;
901
902    let order_side: OrderSide = execution.side.into();
903
904    let last_px = parse_price_with_precision(
905        &execution.exec_price,
906        instrument.price_precision(),
907        "execution.execPrice",
908    )?;
909
910    let last_qty = parse_quantity_with_precision(
911        &execution.exec_qty,
912        instrument.size_precision(),
913        "execution.execQty",
914    )?;
915
916    let fee_decimal: Decimal = execution
917        .exec_fee
918        .parse()
919        .with_context(|| format!("Failed to parse execFee='{}'", execution.exec_fee))?;
920    let currency = get_currency(&execution.fee_currency);
921    let commission = Money::from_decimal(fee_decimal, currency).with_context(|| {
922        format!(
923            "Failed to create commission from execFee='{}'",
924            execution.exec_fee
925        )
926    })?;
927
928    // Determine liquidity side from is_maker flag
929    let liquidity_side = if execution.is_maker {
930        LiquiditySide::Maker
931    } else {
932        LiquiditySide::Taker
933    };
934
935    let ts_event = parse_millis_timestamp(&execution.exec_time, "execution.execTime")?;
936
937    // Parse client_order_id if present
938    let client_order_id = if execution.order_link_id.is_empty() {
939        None
940    } else {
941        Some(ClientOrderId::new(execution.order_link_id.as_str()))
942    };
943
944    Ok(FillReport::new(
945        account_id,
946        instrument_id,
947        venue_order_id,
948        trade_id,
949        order_side,
950        last_qty,
951        last_px,
952        commission,
953        liquidity_side,
954        client_order_id,
955        None, // venue_position_id: execution data lacks position_idx
956        ts_event,
957        ts_init,
958        None, // Will generate a new UUID4
959    ))
960}
961
962/// Parses a Bybit position into a Nautilus PositionStatusReport.
963///
964/// # Errors
965///
966/// This function returns an error if:
967/// - Position quantity or price fields cannot be parsed.
968/// - The position timestamp cannot be parsed.
969/// - Numeric conversions fail.
970pub fn parse_position_status_report(
971    position: &BybitPosition,
972    account_id: AccountId,
973    instrument: &InstrumentAny,
974    ts_init: UnixNanos,
975) -> anyhow::Result<PositionStatusReport> {
976    let instrument_id = instrument.id();
977
978    // Parse position size
979    let size_f64 = position
980        .size
981        .parse::<f64>()
982        .with_context(|| format!("Failed to parse position size '{}'", position.size))?;
983
984    // Determine position side and quantity
985    let (position_side, quantity) = match position.side {
986        BybitPositionSide::Buy => {
987            let qty = Quantity::new(size_f64, instrument.size_precision());
988            (PositionSideSpecified::Long, qty)
989        }
990        BybitPositionSide::Sell => {
991            let qty = Quantity::new(size_f64, instrument.size_precision());
992            (PositionSideSpecified::Short, qty)
993        }
994        BybitPositionSide::Flat => {
995            let qty = Quantity::new(0.0, instrument.size_precision());
996            (PositionSideSpecified::Flat, qty)
997        }
998    };
999
1000    // Parse average entry price
1001    let avg_px_open = if position.avg_price.is_empty() || position.avg_price == "0" {
1002        None
1003    } else {
1004        Some(Decimal::from_str(&position.avg_price)?)
1005    };
1006
1007    // Use ts_init if updatedTime is empty (initial/flat positions)
1008    let ts_last = if position.updated_time.is_empty() {
1009        ts_init
1010    } else {
1011        parse_millis_timestamp(&position.updated_time, "position.updatedTime")?
1012    };
1013
1014    // Bybit ranks open positions 1-5 by ADL priority (5 = next to be deleveraged);
1015    // 0 means the account has no open position or is flat.
1016    if position.adl_rank_indicator >= 4 {
1017        log::warn!(
1018            "Elevated ADL risk: {} position size={} adl_rank={}",
1019            instrument_id,
1020            position.size,
1021            position.adl_rank_indicator,
1022        );
1023    }
1024
1025    Ok(PositionStatusReport::new(
1026        account_id,
1027        instrument_id,
1028        position_side,
1029        quantity,
1030        ts_last,
1031        ts_init,
1032        None, // Will generate a new UUID4
1033        None, // venue_position_id omitted: non-None triggers hedge-mode reconciliation
1034        avg_px_open,
1035    ))
1036}
1037
1038/// Parses a Bybit wallet balance into a Nautilus account state.
1039///
1040/// # Errors
1041///
1042/// Returns an error if:
1043/// - Balance data cannot be parsed.
1044/// - Currency is invalid.
1045pub fn parse_account_state(
1046    wallet_balance: &BybitWalletBalance,
1047    account_id: AccountId,
1048    ts_init: UnixNanos,
1049) -> anyhow::Result<AccountState> {
1050    let mut balances = Vec::new();
1051
1052    for coin in &wallet_balance.coin {
1053        let total_dec = coin.wallet_balance - coin.spot_borrow;
1054        let locked_dec = coin.locked;
1055
1056        let currency = get_currency(&coin.coin);
1057        balances.push(AccountBalance::from_total_and_locked(
1058            total_dec, locked_dec, currency,
1059        )?);
1060    }
1061
1062    let mut margins = Vec::new();
1063
1064    for coin in &wallet_balance.coin {
1065        // Position IM is reserved against open positions; order IM is reserved against
1066        // pending orders. Sum both so an account that only has open orders still
1067        // reports a non-zero initial margin.
1068        let position_im_f64 = match &coin.total_position_im {
1069            Some(im) if !im.is_empty() => im.parse::<f64>()?,
1070            _ => 0.0,
1071        };
1072        let order_im_f64 = match &coin.total_order_im {
1073            Some(im) if !im.is_empty() => im.parse::<f64>()?,
1074            _ => 0.0,
1075        };
1076        let initial_margin_f64 = position_im_f64 + order_im_f64;
1077
1078        let maintenance_margin_f64 = match &coin.total_position_mm {
1079            Some(mm) if !mm.is_empty() => mm.parse::<f64>()?,
1080            _ => 0.0,
1081        };
1082
1083        if initial_margin_f64 == 0.0 && maintenance_margin_f64 == 0.0 {
1084            continue;
1085        }
1086
1087        let currency = get_currency(&coin.coin);
1088        let initial_margin = Money::new(initial_margin_f64, currency);
1089        let maintenance_margin = Money::new(maintenance_margin_f64, currency);
1090
1091        margins.push(MarginBalance::new(initial_margin, maintenance_margin, None));
1092    }
1093
1094    let account_type = AccountType::Margin;
1095    let is_reported = true;
1096    let event_id = UUID4::new();
1097
1098    // Use current time as ts_event since Bybit doesn't provide this in wallet balance
1099    let ts_event = ts_init;
1100
1101    Ok(AccountState::new(
1102        account_id,
1103        account_type,
1104        balances,
1105        margins,
1106        is_reported,
1107        event_id,
1108        ts_event,
1109        ts_init,
1110        None,
1111    ))
1112}
1113
1114pub(crate) fn parse_price_with_precision(
1115    value: &str,
1116    precision: u8,
1117    field: &str,
1118) -> anyhow::Result<Price> {
1119    let parsed = value
1120        .parse::<f64>()
1121        .with_context(|| format!("Failed to parse {field}='{value}' as f64"))?;
1122    Price::new_checked(parsed, precision).with_context(|| {
1123        format!("Failed to construct Price for {field} with precision {precision}")
1124    })
1125}
1126
1127pub(crate) fn parse_quantity_with_precision(
1128    value: &str,
1129    precision: u8,
1130    field: &str,
1131) -> anyhow::Result<Quantity> {
1132    let parsed = value
1133        .parse::<f64>()
1134        .with_context(|| format!("Failed to parse {field}='{value}' as f64"))?;
1135    Quantity::new_checked(parsed, precision).with_context(|| {
1136        format!("Failed to construct Quantity for {field} with precision {precision}")
1137    })
1138}
1139
1140pub(crate) fn parse_price(value: &str, field: &str) -> anyhow::Result<Price> {
1141    Price::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
1142}
1143
1144pub(crate) fn parse_quantity(value: &str, field: &str) -> anyhow::Result<Quantity> {
1145    Quantity::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
1146}
1147
1148pub(crate) fn parse_decimal(value: &str, field: &str) -> anyhow::Result<Decimal> {
1149    Decimal::from_str(value)
1150        .map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}' as Decimal: {e}"))
1151}
1152
1153pub(crate) fn parse_millis_timestamp(value: &str, field: &str) -> anyhow::Result<UnixNanos> {
1154    let millis: u64 = value
1155        .parse()
1156        .with_context(|| format!("Failed to parse {field}='{value}' as u64 millis"))?;
1157    let nanos = millis
1158        .checked_mul(NANOSECONDS_IN_MILLISECOND)
1159        .context("millisecond timestamp overflowed when converting to nanoseconds")?;
1160    Ok(UnixNanos::from(nanos))
1161}
1162
1163fn resolve_settlement_currency(
1164    settle_coin: &str,
1165    base_currency: Currency,
1166    quote_currency: Currency,
1167) -> anyhow::Result<Currency> {
1168    if settle_coin.eq_ignore_ascii_case(base_currency.code.as_str()) {
1169        Ok(base_currency)
1170    } else if settle_coin.eq_ignore_ascii_case(quote_currency.code.as_str()) {
1171        Ok(quote_currency)
1172    } else {
1173        Err(anyhow::anyhow!(
1174            "unrecognised settlement currency '{settle_coin}'"
1175        ))
1176    }
1177}
1178
1179/// Returns a currency from the internal map or creates a new crypto currency.
1180///
1181/// Uses [`Currency::get_or_create_crypto`] to handle unknown currency codes,
1182/// which automatically registers newly listed Bybit assets.
1183pub fn get_currency(code: &str) -> Currency {
1184    Currency::get_or_create_crypto(code)
1185}
1186
1187fn extract_strike_from_symbol(symbol: &str) -> anyhow::Result<Price> {
1188    let parts: Vec<&str> = symbol.split('-').collect();
1189    let strike = parts
1190        .get(2)
1191        .ok_or_else(|| anyhow::anyhow!("invalid option symbol '{symbol}'"))?;
1192    parse_price(strike, "option strike")
1193}
1194
1195/// Resolves a Nautilus [`OrderType`] from Bybit order classification fields.
1196///
1197/// Bybit represents conditional orders using a combination of `orderType` (Market/Limit),
1198/// `stopOrderType` (Stop, TakeProfit, StopLoss, etc.), `triggerDirection` (RisesTo/FallsTo),
1199/// and `side` (Buy/Sell). This function maps all combinations to the appropriate Nautilus
1200/// conditional order types.
1201///
1202/// When `triggerDirection` is `None`, the stop order type is informational only (a parent
1203/// order with TP/SL metadata attached), so the order is classified as plain Market/Limit.
1204#[must_use]
1205pub fn parse_bybit_order_type(
1206    order_type: BybitOrderType,
1207    stop_order_type: BybitStopOrderType,
1208    trigger_direction: BybitTriggerDirection,
1209    side: BybitOrderSide,
1210) -> OrderType {
1211    if matches!(
1212        stop_order_type,
1213        BybitStopOrderType::None | BybitStopOrderType::Unknown
1214    ) {
1215        return match order_type {
1216            BybitOrderType::Market => OrderType::Market,
1217            BybitOrderType::Limit | BybitOrderType::Unknown => OrderType::Limit,
1218        };
1219    }
1220
1221    // No trigger direction means TP/SL metadata on a parent order,
1222    // not a standalone conditional
1223    if trigger_direction == BybitTriggerDirection::None {
1224        return match order_type {
1225            BybitOrderType::Market => OrderType::Market,
1226            BybitOrderType::Limit | BybitOrderType::Unknown => OrderType::Limit,
1227        };
1228    }
1229
1230    // TrailingStop maps to StopMarket/StopLimit because Bybit does not
1231    // provide the trailing offset fields needed for the dedicated types.
1232    match (order_type, trigger_direction, side) {
1233        (BybitOrderType::Market, BybitTriggerDirection::RisesTo, BybitOrderSide::Buy) => {
1234            OrderType::StopMarket
1235        }
1236        (BybitOrderType::Market, BybitTriggerDirection::FallsTo, BybitOrderSide::Buy) => {
1237            OrderType::MarketIfTouched
1238        }
1239        (BybitOrderType::Market, BybitTriggerDirection::FallsTo, BybitOrderSide::Sell) => {
1240            OrderType::StopMarket
1241        }
1242        (BybitOrderType::Market, BybitTriggerDirection::RisesTo, BybitOrderSide::Sell) => {
1243            OrderType::MarketIfTouched
1244        }
1245        (BybitOrderType::Limit, BybitTriggerDirection::RisesTo, BybitOrderSide::Buy) => {
1246            OrderType::StopLimit
1247        }
1248        (BybitOrderType::Limit, BybitTriggerDirection::FallsTo, BybitOrderSide::Buy) => {
1249            OrderType::LimitIfTouched
1250        }
1251        (BybitOrderType::Limit, BybitTriggerDirection::FallsTo, BybitOrderSide::Sell) => {
1252            OrderType::StopLimit
1253        }
1254        (BybitOrderType::Limit, BybitTriggerDirection::RisesTo, BybitOrderSide::Sell) => {
1255            OrderType::LimitIfTouched
1256        }
1257        _ => match order_type {
1258            BybitOrderType::Market => OrderType::Market,
1259            BybitOrderType::Limit | BybitOrderType::Unknown => OrderType::Limit,
1260        },
1261    }
1262}
1263
1264/// Parses a Bybit order into a Nautilus OrderStatusReport.
1265pub fn parse_order_status_report(
1266    order: &crate::http::models::BybitOrder,
1267    instrument: &InstrumentAny,
1268    account_id: AccountId,
1269    ts_init: UnixNanos,
1270) -> anyhow::Result<OrderStatusReport> {
1271    let instrument_id = instrument.id();
1272    let venue_order_id = VenueOrderId::new(order.order_id);
1273
1274    let order_side: OrderSide = order.side.into();
1275
1276    let order_type = parse_bybit_order_type(
1277        order.order_type,
1278        order.stop_order_type,
1279        order.trigger_direction,
1280        order.side,
1281    );
1282
1283    let time_in_force: TimeInForce = match order.time_in_force {
1284        BybitTimeInForce::Gtc => TimeInForce::Gtc,
1285        BybitTimeInForce::Ioc => TimeInForce::Ioc,
1286        BybitTimeInForce::Fok => TimeInForce::Fok,
1287        BybitTimeInForce::PostOnly => TimeInForce::Gtc,
1288    };
1289
1290    let quantity =
1291        parse_quantity_with_precision(&order.qty, instrument.size_precision(), "order.qty")?;
1292
1293    let filled_qty = parse_quantity_with_precision(
1294        &order.cum_exec_qty,
1295        instrument.size_precision(),
1296        "order.cumExecQty",
1297    )?;
1298
1299    // Map Bybit order status to Nautilus order status
1300    // Special case: if Bybit reports "Rejected" but the order has fills, treat it as Canceled.
1301    // This handles the case where the exchange partially fills an order then rejects the
1302    // remaining quantity (e.g., due to margin, risk limits, or liquidity constraints).
1303    // The state machine does not allow PARTIALLY_FILLED -> REJECTED transitions.
1304    let order_status: OrderStatus = match order.order_status {
1305        BybitOrderStatus::Created | BybitOrderStatus::New | BybitOrderStatus::Untriggered => {
1306            OrderStatus::Accepted
1307        }
1308        BybitOrderStatus::Rejected => {
1309            if filled_qty.is_positive() {
1310                OrderStatus::Canceled
1311            } else {
1312                OrderStatus::Rejected
1313            }
1314        }
1315        BybitOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
1316        BybitOrderStatus::Filled => OrderStatus::Filled,
1317        BybitOrderStatus::Canceled | BybitOrderStatus::PartiallyFilledCanceled => {
1318            OrderStatus::Canceled
1319        }
1320        BybitOrderStatus::Triggered => OrderStatus::Triggered,
1321        BybitOrderStatus::Deactivated => OrderStatus::Canceled,
1322    };
1323
1324    let ts_accepted = parse_millis_timestamp(&order.created_time, "order.createdTime")?;
1325    let ts_last = parse_millis_timestamp(&order.updated_time, "order.updatedTime")?;
1326
1327    let mut report = OrderStatusReport::new(
1328        account_id,
1329        instrument_id,
1330        None,
1331        venue_order_id,
1332        order_side,
1333        order_type,
1334        time_in_force,
1335        order_status,
1336        quantity,
1337        filled_qty,
1338        ts_accepted,
1339        ts_last,
1340        ts_init,
1341        Some(UUID4::new()),
1342    );
1343
1344    if !order.order_link_id.is_empty() {
1345        report = report.with_client_order_id(ClientOrderId::new(order.order_link_id.as_str()));
1346    }
1347
1348    if !order.price.is_empty() && order.price != "0" {
1349        let price =
1350            parse_price_with_precision(&order.price, instrument.price_precision(), "order.price")?;
1351        report = report.with_price(price);
1352    }
1353
1354    if let Some(avg_price) = &order.avg_price
1355        && !avg_price.is_empty()
1356        && avg_price != "0"
1357    {
1358        let avg_px = avg_price
1359            .parse::<f64>()
1360            .with_context(|| format!("Failed to parse avg_price='{avg_price}' as f64"))?;
1361        report = report.with_avg_px(avg_px)?;
1362    }
1363
1364    if !order.trigger_price.is_empty() && order.trigger_price != "0" {
1365        let trigger_price = parse_price_with_precision(
1366            &order.trigger_price,
1367            instrument.price_precision(),
1368            "order.triggerPrice",
1369        )?;
1370        report = report.with_trigger_price(trigger_price);
1371
1372        // Set trigger_type for conditional orders
1373        let trigger_type: TriggerType = order.trigger_by.into();
1374        report = report.with_trigger_type(trigger_type);
1375    }
1376
1377    // venue_position_id omitted: in netting mode, non-None values override the
1378    // computed netting position ID and break position tracking.
1379
1380    if order.reduce_only {
1381        report = report.with_reduce_only(true);
1382    }
1383
1384    if order.time_in_force == BybitTimeInForce::PostOnly {
1385        report = report.with_post_only(true);
1386    }
1387
1388    Ok(report)
1389}
1390
1391/// Returns the `marketUnit` parameter for spot market orders.
1392#[must_use]
1393pub fn spot_market_unit(
1394    product_type: BybitProductType,
1395    order_type: BybitOrderType,
1396    is_quote_quantity: bool,
1397) -> Option<BybitMarketUnit> {
1398    if product_type == BybitProductType::Spot && order_type == BybitOrderType::Market {
1399        if is_quote_quantity {
1400            Some(BybitMarketUnit::QuoteCoin)
1401        } else {
1402            Some(BybitMarketUnit::BaseCoin)
1403        }
1404    } else {
1405        None
1406    }
1407}
1408
1409/// Returns the `isLeverage` parameter (spot-only).
1410#[must_use]
1411pub fn spot_leverage(product_type: BybitProductType, is_leverage: bool) -> Option<i32> {
1412    if product_type == BybitProductType::Spot {
1413        Some(i32::from(is_leverage))
1414    } else {
1415        None
1416    }
1417}
1418
1419/// Returns the trigger direction for stop and MIT orders.
1420#[must_use]
1421pub fn trigger_direction(
1422    order_type: OrderType,
1423    order_side: OrderSide,
1424    is_stop_order: bool,
1425) -> Option<BybitTriggerDirection> {
1426    if !is_stop_order {
1427        return None;
1428    }
1429
1430    match (order_type, order_side) {
1431        (OrderType::StopMarket | OrderType::StopLimit, OrderSide::Buy) => {
1432            Some(BybitTriggerDirection::RisesTo)
1433        }
1434        (OrderType::StopMarket | OrderType::StopLimit, OrderSide::Sell) => {
1435            Some(BybitTriggerDirection::FallsTo)
1436        }
1437        (OrderType::MarketIfTouched | OrderType::LimitIfTouched, OrderSide::Buy) => {
1438            Some(BybitTriggerDirection::FallsTo)
1439        }
1440        (OrderType::MarketIfTouched | OrderType::LimitIfTouched, OrderSide::Sell) => {
1441            Some(BybitTriggerDirection::RisesTo)
1442        }
1443        _ => None,
1444    }
1445}
1446
1447/// Maps Nautilus time-in-force to Bybit's TIF.
1448///
1449/// Returns `Err(tif)` with the unsupported value for caller-specific error wrapping.
1450pub fn map_time_in_force(
1451    order_type: BybitOrderType,
1452    time_in_force: Option<TimeInForce>,
1453    post_only: Option<bool>,
1454) -> Result<Option<BybitTimeInForce>, TimeInForce> {
1455    if order_type == BybitOrderType::Market {
1456        return Ok(None);
1457    }
1458
1459    if post_only == Some(true) {
1460        return Ok(Some(BybitTimeInForce::PostOnly));
1461    }
1462
1463    match time_in_force {
1464        Some(TimeInForce::Gtc) => Ok(Some(BybitTimeInForce::Gtc)),
1465        Some(TimeInForce::Ioc) => Ok(Some(BybitTimeInForce::Ioc)),
1466        Some(TimeInForce::Fok) => Ok(Some(BybitTimeInForce::Fok)),
1467        Some(tif) => Err(tif),
1468        None => Ok(None),
1469    }
1470}
1471
1472/// Converts an optional `UnixNanos` timestamp to optional milliseconds.
1473pub fn nanos_to_millis(value: Option<UnixNanos>) -> Option<i64> {
1474    value.map(|nanos| nanos_to_millis_u64(nanos.as_u64()) as i64)
1475}
1476
1477/// Parsed and validated Bybit TP/SL parameters from a `SubmitOrder.params` map.
1478#[derive(Debug, Default)]
1479pub struct BybitTpSlParams {
1480    pub take_profit: Option<Price>,
1481    pub stop_loss: Option<Price>,
1482    pub tp_trigger_by: Option<BybitTriggerType>,
1483    pub sl_trigger_by: Option<BybitTriggerType>,
1484    pub tp_order_type: Option<BybitOrderType>,
1485    pub sl_order_type: Option<BybitOrderType>,
1486    pub tp_limit_price: Option<String>,
1487    pub sl_limit_price: Option<String>,
1488    pub tp_trigger_price: Option<String>,
1489    pub sl_trigger_price: Option<String>,
1490    pub close_on_trigger: Option<bool>,
1491    pub is_leverage: bool,
1492    pub order_iv: Option<String>,
1493    pub mmp: Option<bool>,
1494    pub position_idx: Option<BybitPositionIdx>,
1495}
1496
1497impl BybitTpSlParams {
1498    pub fn has_tp_sl(&self) -> bool {
1499        self.take_profit.is_some() || self.stop_loss.is_some()
1500    }
1501}
1502
1503/// Extracts a string value from params, accepting both string and numeric JSON values.
1504pub fn get_price_str(params: &Params, key: &str) -> Option<String> {
1505    let value = params.get(key)?;
1506    if let Some(s) = value.as_str() {
1507        Some(s.to_string())
1508    } else if let Some(n) = value.as_f64() {
1509        Some(n.to_string())
1510    } else if let Some(n) = value.as_i64() {
1511        Some(n.to_string())
1512    } else {
1513        value.as_u64().map(|n| n.to_string())
1514    }
1515}
1516
1517/// Parses Bybit TP/SL parameters from an optional params map.
1518pub fn parse_bybit_tp_sl_params(params: Option<&Params>) -> anyhow::Result<BybitTpSlParams> {
1519    let Some(params) = params else {
1520        return Ok(BybitTpSlParams::default());
1521    };
1522
1523    let mut result = BybitTpSlParams {
1524        is_leverage: params.get_bool("is_leverage").unwrap_or(false),
1525        ..Default::default()
1526    };
1527
1528    if let Some(s) = get_price_str(params, "take_profit") {
1529        let p =
1530            Price::from_str(&s).map_err(|e| anyhow::anyhow!("invalid 'take_profit' price: {e}"))?;
1531
1532        if p.as_f64() < 0.0 {
1533            anyhow::bail!("invalid 'take_profit' price: '{s}', expected a non-negative value");
1534        }
1535        result.take_profit = Some(p);
1536    }
1537
1538    if let Some(s) = get_price_str(params, "stop_loss") {
1539        let p =
1540            Price::from_str(&s).map_err(|e| anyhow::anyhow!("invalid 'stop_loss' price: {e}"))?;
1541
1542        if p.as_f64() < 0.0 {
1543            anyhow::bail!("invalid 'stop_loss' price: '{s}', expected a non-negative value");
1544        }
1545        result.stop_loss = Some(p);
1546    }
1547
1548    for (key, setter) in [
1549        (
1550            "tp_limit_price",
1551            &mut result.tp_limit_price as &mut Option<String>,
1552        ),
1553        ("sl_limit_price", &mut result.sl_limit_price),
1554        ("tp_trigger_price", &mut result.tp_trigger_price),
1555        ("sl_trigger_price", &mut result.sl_trigger_price),
1556    ] {
1557        if let Some(s) = get_price_str(params, key) {
1558            let v: f64 = s
1559                .parse()
1560                .map_err(|_| anyhow::anyhow!("invalid price for '{key}': '{s}'"))?;
1561
1562            if !v.is_finite() || v < 0.0 {
1563                anyhow::bail!(
1564                    "invalid price for '{key}': '{s}', expected a finite non-negative number"
1565                );
1566            }
1567            *setter = Some(s);
1568        }
1569    }
1570
1571    if let Some(s) = params.get_str("tp_trigger_by") {
1572        result.tp_trigger_by = Some(parse_trigger_type(s)?);
1573    }
1574
1575    if let Some(s) = params.get_str("sl_trigger_by") {
1576        result.sl_trigger_by = Some(parse_trigger_type(s)?);
1577    }
1578
1579    if let Some(s) = params.get_str("tp_order_type") {
1580        result.tp_order_type = Some(parse_tp_sl_order_type(s)?);
1581    }
1582
1583    if let Some(s) = params.get_str("sl_order_type") {
1584        result.sl_order_type = Some(parse_tp_sl_order_type(s)?);
1585    }
1586
1587    let has_tp_fields = result.tp_trigger_by.is_some()
1588        || result.tp_order_type.is_some()
1589        || result.tp_limit_price.is_some()
1590        || result.tp_trigger_price.is_some();
1591
1592    let has_sl_fields = result.sl_trigger_by.is_some()
1593        || result.sl_order_type.is_some()
1594        || result.sl_limit_price.is_some()
1595        || result.sl_trigger_price.is_some();
1596
1597    if result.take_profit.is_none() && has_tp_fields {
1598        anyhow::bail!("TP override fields require 'take_profit' to be set");
1599    }
1600
1601    if result.stop_loss.is_none() && has_sl_fields {
1602        anyhow::bail!("SL override fields require 'stop_loss' to be set");
1603    }
1604
1605    if result.tp_order_type == Some(BybitOrderType::Limit) && result.tp_limit_price.is_none() {
1606        anyhow::bail!("'tp_order_type' is 'Limit' but 'tp_limit_price' was not provided");
1607    }
1608
1609    if result.sl_order_type == Some(BybitOrderType::Limit) && result.sl_limit_price.is_none() {
1610        anyhow::bail!("'sl_order_type' is 'Limit' but 'sl_limit_price' was not provided");
1611    }
1612
1613    if result.tp_limit_price.is_some() && result.tp_order_type != Some(BybitOrderType::Limit) {
1614        anyhow::bail!("'tp_limit_price' requires 'tp_order_type' to be 'Limit'");
1615    }
1616
1617    if result.sl_limit_price.is_some() && result.sl_order_type != Some(BybitOrderType::Limit) {
1618        anyhow::bail!("'sl_limit_price' requires 'sl_order_type' to be 'Limit'");
1619    }
1620
1621    result.close_on_trigger = params.get_bool("close_on_trigger");
1622
1623    if let Some(value) = params.get("order_iv") {
1624        match get_price_str(params, "order_iv") {
1625            Some(s) => result.order_iv = Some(s),
1626            None => {
1627                anyhow::bail!("invalid type for 'order_iv': {value}, expected string or number")
1628            }
1629        }
1630    }
1631
1632    if let Some(value) = params.get("mmp") {
1633        match value.as_bool() {
1634            Some(b) => result.mmp = Some(b),
1635            None => anyhow::bail!("invalid type for 'mmp': {value}, expected bool"),
1636        }
1637    }
1638
1639    if let Some(value) = params.get("position_idx") {
1640        let idx = value.as_i64().ok_or_else(|| {
1641            anyhow::anyhow!("invalid type for 'position_idx': {value}, expected integer")
1642        })?;
1643        result.position_idx = Some(match idx {
1644            0 => BybitPositionIdx::OneWay,
1645            1 => BybitPositionIdx::BuyHedge,
1646            2 => BybitPositionIdx::SellHedge,
1647            _ => anyhow::bail!("invalid 'position_idx': {idx}, expected 0, 1, or 2"),
1648        });
1649    }
1650
1651    Ok(result)
1652}
1653
1654fn parse_trigger_type(s: &str) -> anyhow::Result<BybitTriggerType> {
1655    match s {
1656        "LastPrice" => Ok(BybitTriggerType::LastPrice),
1657        "MarkPrice" => Ok(BybitTriggerType::MarkPrice),
1658        "IndexPrice" => Ok(BybitTriggerType::IndexPrice),
1659        _ => anyhow::bail!(
1660            "invalid Bybit trigger type: '{s}', expected LastPrice, MarkPrice, or IndexPrice"
1661        ),
1662    }
1663}
1664
1665fn parse_tp_sl_order_type(s: &str) -> anyhow::Result<BybitOrderType> {
1666    match s {
1667        "Market" => Ok(BybitOrderType::Market),
1668        "Limit" => Ok(BybitOrderType::Limit),
1669        _ => anyhow::bail!("invalid Bybit TP/SL order type: '{s}', expected Market or Limit"),
1670    }
1671}
1672
1673#[cfg(test)]
1674mod tests {
1675    use nautilus_model::{
1676        data::BarSpecification,
1677        enums::{AggregationSource, BarAggregation, PositionSide, PriceType},
1678    };
1679    use rstest::rstest;
1680    use serde_json::json;
1681
1682    use super::*;
1683    use crate::{
1684        common::{
1685            enums::{BybitOrderSide, BybitOrderType, BybitStopOrderType, BybitTriggerDirection},
1686            testing::load_test_json,
1687        },
1688        http::models::{
1689            BybitInstrumentInverseResponse, BybitInstrumentLinearResponse,
1690            BybitInstrumentOptionResponse, BybitInstrumentSpotResponse, BybitKlinesResponse,
1691            BybitOpenOrdersResponse, BybitTradeHistoryResponse, BybitTradesResponse,
1692        },
1693    };
1694
1695    const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
1696
1697    fn sample_fee_rate(
1698        symbol: &str,
1699        taker: &str,
1700        maker: &str,
1701        base_coin: Option<&str>,
1702    ) -> BybitFeeRate {
1703        BybitFeeRate {
1704            symbol: Ustr::from(symbol),
1705            taker_fee_rate: taker.to_string(),
1706            maker_fee_rate: maker.to_string(),
1707            base_coin: base_coin.map(Ustr::from),
1708        }
1709    }
1710
1711    fn linear_instrument() -> InstrumentAny {
1712        let json = load_test_json("http_get_instruments_linear.json");
1713        let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
1714        let instrument = &response.result.list[0];
1715        let fee_rate = sample_fee_rate("BTCUSDT", "0.00055", "0.0001", Some("BTC"));
1716        parse_linear_instrument(instrument, &fee_rate, TS, TS).unwrap()
1717    }
1718
1719    #[rstest]
1720    fn parse_spot_instrument_builds_currency_pair() {
1721        let json = load_test_json("http_get_instruments_spot.json");
1722        let response: BybitInstrumentSpotResponse = serde_json::from_str(&json).unwrap();
1723        let instrument = &response.result.list[0];
1724        let fee_rate = sample_fee_rate("BTCUSDT", "0.0006", "0.0001", Some("BTC"));
1725
1726        let parsed = parse_spot_instrument(instrument, &fee_rate, TS, TS).unwrap();
1727        match parsed {
1728            InstrumentAny::CurrencyPair(pair) => {
1729                assert_eq!(pair.id.to_string(), "BTCUSDT-SPOT.BYBIT");
1730                assert_eq!(pair.price_increment, Price::from_str("0.1").unwrap());
1731                assert_eq!(pair.size_increment, Quantity::from_str("0.0001").unwrap());
1732                assert_eq!(pair.base_currency.code.as_str(), "BTC");
1733                assert_eq!(pair.quote_currency.code.as_str(), "USDT");
1734            }
1735            _ => panic!("expected CurrencyPair"),
1736        }
1737    }
1738
1739    #[rstest]
1740    fn parse_linear_perpetual_instrument_builds_crypto_perpetual() {
1741        let json = load_test_json("http_get_instruments_linear.json");
1742        let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
1743        let instrument = &response.result.list[0];
1744        let fee_rate = sample_fee_rate("BTCUSDT", "0.00055", "0.0001", Some("BTC"));
1745
1746        let parsed = parse_linear_instrument(instrument, &fee_rate, TS, TS).unwrap();
1747        match parsed {
1748            InstrumentAny::CryptoPerpetual(perp) => {
1749                assert_eq!(perp.id.to_string(), "BTCUSDT-LINEAR.BYBIT");
1750                assert!(!perp.is_inverse);
1751                assert_eq!(perp.price_increment, Price::from_str("0.5").unwrap());
1752                assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
1753            }
1754            other => panic!("unexpected instrument variant: {other:?}"),
1755        }
1756    }
1757
1758    #[rstest]
1759    fn parse_inverse_perpetual_instrument_builds_inverse_perpetual() {
1760        let json = load_test_json("http_get_instruments_inverse.json");
1761        let response: BybitInstrumentInverseResponse = serde_json::from_str(&json).unwrap();
1762        let instrument = &response.result.list[0];
1763        let fee_rate = sample_fee_rate("BTCUSD", "0.00075", "0.00025", Some("BTC"));
1764
1765        let parsed = parse_inverse_instrument(instrument, &fee_rate, TS, TS).unwrap();
1766        match parsed {
1767            InstrumentAny::CryptoPerpetual(perp) => {
1768                assert_eq!(perp.id.to_string(), "BTCUSD-INVERSE.BYBIT");
1769                assert!(perp.is_inverse);
1770                assert_eq!(perp.price_increment, Price::from_str("0.5").unwrap());
1771                assert_eq!(perp.size_increment, Quantity::from_str("1").unwrap());
1772            }
1773            other => panic!("unexpected instrument variant: {other:?}"),
1774        }
1775    }
1776
1777    #[rstest]
1778    fn parse_option_instrument_builds_crypto_option() {
1779        let json = load_test_json("http_get_instruments_option.json");
1780        let response: BybitInstrumentOptionResponse = serde_json::from_str(&json).unwrap();
1781        let instrument = &response.result.list[0];
1782
1783        let parsed = parse_option_instrument(instrument, None, TS, TS).unwrap();
1784        match parsed {
1785            InstrumentAny::CryptoOption(option) => {
1786                assert_eq!(option.id.to_string(), "ETH-26JUN26-16000-P-OPTION.BYBIT");
1787                assert_eq!(option.underlying.code.as_str(), "ETH");
1788                assert_eq!(option.quote_currency.code.as_str(), "USDC");
1789                assert_eq!(option.settlement_currency.code.as_str(), "USDC");
1790                assert!(!option.is_inverse);
1791                assert_eq!(option.option_kind, OptionKind::Put);
1792                assert_eq!(option.price_precision, 1);
1793                assert_eq!(option.price_increment, Price::from_str("0.1").unwrap());
1794                assert_eq!(option.size_precision, 0);
1795                assert_eq!(option.size_increment, Quantity::from_str("1").unwrap());
1796                assert_eq!(option.lot_size, Quantity::from_str("1").unwrap());
1797            }
1798            other => panic!("unexpected instrument variant: {other:?}"),
1799        }
1800    }
1801
1802    #[rstest]
1803    fn test_extract_base_coin_from_option_symbol() {
1804        assert_eq!(extract_base_coin("BTC-27MAR26-70000-P"), "BTC");
1805        assert_eq!(extract_base_coin("ETH-26JUN26-16000-C"), "ETH");
1806        assert_eq!(extract_base_coin("SOL-30MAR26-200-P-USDT"), "SOL");
1807        assert_eq!(extract_base_coin("BTC"), "BTC");
1808    }
1809
1810    #[rstest]
1811    fn test_extract_base_coin_from_nautilus_option_symbol() {
1812        // After extract_raw_symbol strips the "-OPTION" suffix
1813        let raw = extract_raw_symbol("BTC-27MAR26-70000-P-USDT-OPTION");
1814        assert_eq!(extract_base_coin(raw), "BTC");
1815    }
1816
1817    #[rstest]
1818    fn parse_option_instrument_with_fee_rate() {
1819        let json = load_test_json("http_get_instruments_option.json");
1820        let response: BybitInstrumentOptionResponse = serde_json::from_str(&json).unwrap();
1821        let instrument = &response.result.list[0];
1822        let fee = sample_fee_rate("", "0.0006", "0.0001", Some("ETH"));
1823
1824        let parsed = parse_option_instrument(instrument, Some(&fee), TS, TS).unwrap();
1825        match parsed {
1826            InstrumentAny::CryptoOption(option) => {
1827                assert_eq!(option.taker_fee, Decimal::new(6, 4));
1828                assert_eq!(option.maker_fee, Decimal::new(1, 4));
1829                assert_eq!(option.margin_init, Decimal::ZERO);
1830                assert_eq!(option.margin_maint, Decimal::ZERO);
1831            }
1832            other => panic!("unexpected instrument variant: {other:?}"),
1833        }
1834    }
1835
1836    #[rstest]
1837    fn parse_option_instrument_without_fee_rate_defaults_to_zero() {
1838        let json = load_test_json("http_get_instruments_option.json");
1839        let response: BybitInstrumentOptionResponse = serde_json::from_str(&json).unwrap();
1840        let instrument = &response.result.list[0];
1841
1842        let parsed = parse_option_instrument(instrument, None, TS, TS).unwrap();
1843        match parsed {
1844            InstrumentAny::CryptoOption(option) => {
1845                assert_eq!(option.taker_fee, Decimal::ZERO);
1846                assert_eq!(option.maker_fee, Decimal::ZERO);
1847            }
1848            other => panic!("unexpected instrument variant: {other:?}"),
1849        }
1850    }
1851
1852    #[rstest]
1853    fn parse_http_trade_into_trade_tick() {
1854        let instrument = linear_instrument();
1855        let json = load_test_json("http_get_trades_recent.json");
1856        let response: BybitTradesResponse = serde_json::from_str(&json).unwrap();
1857        let trade = &response.result.list[0];
1858
1859        let tick = parse_trade_tick(trade, &instrument, Some(TS)).unwrap();
1860
1861        assert_eq!(tick.instrument_id, instrument.id());
1862        assert_eq!(tick.price, instrument.make_price(27450.50));
1863        assert_eq!(tick.size, instrument.make_qty(0.005, None));
1864        assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
1865        assert_eq!(
1866            tick.trade_id.to_string(),
1867            "a905d5c3-1ed0-4f37-83e4-9c73a2fe2f01"
1868        );
1869        assert_eq!(tick.ts_event, UnixNanos::new(1_709_891_679_000_000_000));
1870    }
1871
1872    #[rstest]
1873    fn parse_kline_into_bar() {
1874        let instrument = linear_instrument();
1875        let json = load_test_json("http_get_klines_linear.json");
1876        let response: BybitKlinesResponse = serde_json::from_str(&json).unwrap();
1877        let kline = &response.result.list[0];
1878
1879        let bar_type = BarType::new(
1880            instrument.id(),
1881            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
1882            AggregationSource::External,
1883        );
1884
1885        let bar = parse_kline_bar(kline, &instrument, bar_type, false, Some(TS)).unwrap();
1886
1887        assert_eq!(bar.bar_type.to_string(), bar_type.to_string());
1888        assert_eq!(bar.open, instrument.make_price(27450.0));
1889        assert_eq!(bar.high, instrument.make_price(27460.0));
1890        assert_eq!(bar.low, instrument.make_price(27440.0));
1891        assert_eq!(bar.close, instrument.make_price(27455.0));
1892        assert_eq!(bar.volume, instrument.make_qty(123.45, None));
1893        assert_eq!(bar.ts_event, UnixNanos::new(1_709_891_679_000_000_000));
1894    }
1895
1896    #[rstest]
1897    fn parse_http_position_short_into_position_status_report() {
1898        use crate::http::models::BybitPositionListResponse;
1899
1900        let json = load_test_json("http_get_positions.json");
1901        let response: BybitPositionListResponse = serde_json::from_str(&json).unwrap();
1902
1903        // Get the short position (ETHUSDT, side="Sell", size="5.0")
1904        let short_position = &response.result.list[1];
1905        assert_eq!(short_position.symbol.as_str(), "ETHUSDT");
1906        assert_eq!(short_position.side, BybitPositionSide::Sell);
1907
1908        // Create ETHUSDT instrument for parsing
1909        let eth_json = load_test_json("http_get_instruments_linear.json");
1910        let eth_response: BybitInstrumentLinearResponse = serde_json::from_str(&eth_json).unwrap();
1911        let eth_def = &eth_response.result.list[1]; // ETHUSDT is second in the list
1912        let fee_rate = sample_fee_rate("ETHUSDT", "0.00055", "0.0001", Some("ETH"));
1913        let eth_instrument = parse_linear_instrument(eth_def, &fee_rate, TS, TS).unwrap();
1914
1915        let account_id = AccountId::new("BYBIT-001");
1916        let report =
1917            parse_position_status_report(short_position, account_id, &eth_instrument, TS).unwrap();
1918
1919        // Verify short position is correctly parsed
1920        assert_eq!(report.account_id, account_id);
1921        assert_eq!(report.instrument_id.symbol.as_str(), "ETHUSDT-LINEAR");
1922        assert_eq!(report.position_side.as_position_side(), PositionSide::Short);
1923        assert_eq!(report.quantity, eth_instrument.make_qty(5.0, None));
1924        assert_eq!(
1925            report.avg_px_open,
1926            Some(Decimal::try_from(3000.00).unwrap())
1927        );
1928        assert_eq!(report.ts_last, UnixNanos::new(1_697_673_700_112_000_000));
1929    }
1930
1931    #[rstest]
1932    fn parse_http_order_partially_filled_rejected_maps_to_canceled() {
1933        use crate::http::models::BybitOrderHistoryResponse;
1934
1935        let instrument = linear_instrument();
1936        let json = load_test_json("http_get_order_partially_filled_rejected.json");
1937        let response: BybitOrderHistoryResponse = serde_json::from_str(&json).unwrap();
1938        let order = &response.result.list[0];
1939        let account_id = AccountId::new("BYBIT-001");
1940
1941        let report = parse_order_status_report(order, &instrument, account_id, TS).unwrap();
1942
1943        // Verify that Bybit "Rejected" status with fills is mapped to Canceled, not Rejected
1944        assert_eq!(report.order_status, OrderStatus::Canceled);
1945        assert_eq!(report.filled_qty, instrument.make_qty(0.005, None));
1946        assert_eq!(
1947            report.client_order_id.as_ref().unwrap().to_string(),
1948            "O-20251001-164609-APEX-000-49"
1949        );
1950    }
1951
1952    #[rstest]
1953    #[case(BarAggregation::Minute, 1, BybitKlineInterval::Minute1)]
1954    #[case(BarAggregation::Minute, 3, BybitKlineInterval::Minute3)]
1955    #[case(BarAggregation::Minute, 5, BybitKlineInterval::Minute5)]
1956    #[case(BarAggregation::Minute, 15, BybitKlineInterval::Minute15)]
1957    #[case(BarAggregation::Minute, 30, BybitKlineInterval::Minute30)]
1958    fn test_bar_spec_to_bybit_interval_minutes(
1959        #[case] aggregation: BarAggregation,
1960        #[case] step: u64,
1961        #[case] expected: BybitKlineInterval,
1962    ) {
1963        let result = bar_spec_to_bybit_interval(aggregation, step).unwrap();
1964        assert_eq!(result, expected);
1965    }
1966
1967    #[rstest]
1968    #[case(BarAggregation::Hour, 1, BybitKlineInterval::Hour1)]
1969    #[case(BarAggregation::Hour, 2, BybitKlineInterval::Hour2)]
1970    #[case(BarAggregation::Hour, 4, BybitKlineInterval::Hour4)]
1971    #[case(BarAggregation::Hour, 6, BybitKlineInterval::Hour6)]
1972    #[case(BarAggregation::Hour, 12, BybitKlineInterval::Hour12)]
1973    fn test_bar_spec_to_bybit_interval_hours(
1974        #[case] aggregation: BarAggregation,
1975        #[case] step: u64,
1976        #[case] expected: BybitKlineInterval,
1977    ) {
1978        let result = bar_spec_to_bybit_interval(aggregation, step).unwrap();
1979        assert_eq!(result, expected);
1980    }
1981
1982    #[rstest]
1983    #[case(BarAggregation::Day, 1, BybitKlineInterval::Day1)]
1984    #[case(BarAggregation::Week, 1, BybitKlineInterval::Week1)]
1985    #[case(BarAggregation::Month, 1, BybitKlineInterval::Month1)]
1986    fn test_bar_spec_to_bybit_interval_day_week_month(
1987        #[case] aggregation: BarAggregation,
1988        #[case] step: u64,
1989        #[case] expected: BybitKlineInterval,
1990    ) {
1991        let result = bar_spec_to_bybit_interval(aggregation, step).unwrap();
1992        assert_eq!(result, expected);
1993    }
1994
1995    #[rstest]
1996    #[case(BarAggregation::Minute, 2)]
1997    #[case(BarAggregation::Minute, 10)]
1998    #[case(BarAggregation::Hour, 3)]
1999    #[case(BarAggregation::Hour, 24)]
2000    #[case(BarAggregation::Day, 2)]
2001    #[case(BarAggregation::Week, 2)]
2002    #[case(BarAggregation::Month, 2)]
2003    fn test_bar_spec_to_bybit_interval_unsupported_steps(
2004        #[case] aggregation: BarAggregation,
2005        #[case] step: u64,
2006    ) {
2007        let result = bar_spec_to_bybit_interval(aggregation, step);
2008        assert!(result.is_err());
2009    }
2010
2011    #[rstest]
2012    fn test_bar_spec_to_bybit_interval_unsupported_aggregation() {
2013        let result = bar_spec_to_bybit_interval(BarAggregation::Second, 1);
2014        assert!(result.is_err());
2015    }
2016
2017    #[rstest]
2018    #[case("1", 1, BarAggregation::Minute)]
2019    #[case("3", 3, BarAggregation::Minute)]
2020    #[case("5", 5, BarAggregation::Minute)]
2021    #[case("15", 15, BarAggregation::Minute)]
2022    #[case("30", 30, BarAggregation::Minute)]
2023    fn test_bybit_interval_to_bar_spec_minutes(
2024        #[case] interval: &str,
2025        #[case] expected_step: usize,
2026        #[case] expected_aggregation: BarAggregation,
2027    ) {
2028        let result = bybit_interval_to_bar_spec(interval).unwrap();
2029        assert_eq!(result, (expected_step, expected_aggregation));
2030    }
2031
2032    #[rstest]
2033    #[case("60", 1, BarAggregation::Hour)]
2034    #[case("120", 2, BarAggregation::Hour)]
2035    #[case("240", 4, BarAggregation::Hour)]
2036    #[case("360", 6, BarAggregation::Hour)]
2037    #[case("720", 12, BarAggregation::Hour)]
2038    fn test_bybit_interval_to_bar_spec_hours(
2039        #[case] interval: &str,
2040        #[case] expected_step: usize,
2041        #[case] expected_aggregation: BarAggregation,
2042    ) {
2043        let result = bybit_interval_to_bar_spec(interval).unwrap();
2044        assert_eq!(result, (expected_step, expected_aggregation));
2045    }
2046
2047    #[rstest]
2048    #[case("D", 1, BarAggregation::Day)]
2049    #[case("W", 1, BarAggregation::Week)]
2050    #[case("M", 1, BarAggregation::Month)]
2051    fn test_bybit_interval_to_bar_spec_day_week_month(
2052        #[case] interval: &str,
2053        #[case] expected_step: usize,
2054        #[case] expected_aggregation: BarAggregation,
2055    ) {
2056        let result = bybit_interval_to_bar_spec(interval).unwrap();
2057        assert_eq!(result, (expected_step, expected_aggregation));
2058    }
2059
2060    #[rstest]
2061    #[case("2")]
2062    #[case("10")]
2063    #[case("100")]
2064    #[case("invalid")]
2065    #[case("")]
2066    fn test_bybit_interval_to_bar_spec_unsupported(#[case] interval: &str) {
2067        let result = bybit_interval_to_bar_spec(interval);
2068        assert!(result.is_none());
2069    }
2070
2071    fn params_from(pairs: &[(&str, serde_json::Value)]) -> Params {
2072        let mut p = Params::new();
2073        for (k, v) in pairs {
2074            p.insert(k.to_string(), v.clone());
2075        }
2076        p
2077    }
2078
2079    #[rstest]
2080    fn test_parse_tp_sl_params_none_returns_defaults() {
2081        let result = parse_bybit_tp_sl_params(None).unwrap();
2082        assert!(!result.is_leverage);
2083        assert!(!result.has_tp_sl());
2084        assert!(result.order_iv.is_none());
2085        assert!(result.mmp.is_none());
2086    }
2087
2088    #[rstest]
2089    fn test_parse_tp_sl_params_empty_returns_defaults() {
2090        let p = Params::new();
2091        let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2092        assert!(!result.is_leverage);
2093        assert!(!result.has_tp_sl());
2094        assert!(result.order_iv.is_none());
2095        assert!(result.mmp.is_none());
2096    }
2097
2098    #[rstest]
2099    fn test_parse_tp_sl_params_valid_full() {
2100        let p = params_from(&[
2101            ("take_profit", json!("55000.00")),
2102            ("stop_loss", json!("47000.00")),
2103            ("tp_trigger_by", json!("MarkPrice")),
2104            ("sl_trigger_by", json!("IndexPrice")),
2105            ("tp_order_type", json!("Limit")),
2106            ("tp_limit_price", json!("54990.00")),
2107            ("sl_order_type", json!("Market")),
2108            ("close_on_trigger", json!(true)),
2109            ("is_leverage", json!(true)),
2110        ]);
2111        let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2112
2113        assert!(result.has_tp_sl());
2114        assert!(result.take_profit.is_some());
2115        assert!(result.stop_loss.is_some());
2116        assert_eq!(result.tp_trigger_by, Some(BybitTriggerType::MarkPrice));
2117        assert_eq!(result.sl_trigger_by, Some(BybitTriggerType::IndexPrice));
2118        assert_eq!(result.tp_order_type, Some(BybitOrderType::Limit));
2119        assert_eq!(result.sl_order_type, Some(BybitOrderType::Market));
2120        assert_eq!(result.tp_limit_price.as_deref(), Some("54990.00"));
2121        assert_eq!(result.close_on_trigger, Some(true));
2122        assert!(result.is_leverage);
2123    }
2124
2125    #[rstest]
2126    #[case("abc")]
2127    #[case("nan")]
2128    #[case("inf")]
2129    #[case("-1.0")]
2130    fn test_parse_tp_sl_params_rejects_invalid_take_profit(#[case] price: &str) {
2131        let p = params_from(&[("take_profit", json!(price))]);
2132        assert!(parse_bybit_tp_sl_params(Some(&p)).is_err());
2133    }
2134
2135    #[rstest]
2136    #[case("abc")]
2137    #[case("nan")]
2138    #[case("inf")]
2139    fn test_parse_tp_sl_params_rejects_invalid_stop_loss(#[case] price: &str) {
2140        let p = params_from(&[("stop_loss", json!(price))]);
2141        assert!(parse_bybit_tp_sl_params(Some(&p)).is_err());
2142    }
2143
2144    #[rstest]
2145    #[case("nan")]
2146    #[case("inf")]
2147    #[case("-5.0")]
2148    #[case("not_a_number")]
2149    fn test_parse_tp_sl_params_rejects_invalid_limit_price(#[case] price: &str) {
2150        let p = params_from(&[
2151            ("take_profit", json!("55000.00")),
2152            ("tp_order_type", json!("Limit")),
2153            ("tp_limit_price", json!(price)),
2154        ]);
2155        assert!(parse_bybit_tp_sl_params(Some(&p)).is_err());
2156    }
2157
2158    #[rstest]
2159    fn test_parse_tp_sl_params_rejects_invalid_trigger_type() {
2160        let p = params_from(&[
2161            ("take_profit", json!("55000.00")),
2162            ("tp_trigger_by", json!("InvalidType")),
2163        ]);
2164        assert!(parse_bybit_tp_sl_params(Some(&p)).is_err());
2165    }
2166
2167    #[rstest]
2168    fn test_parse_tp_sl_params_rejects_invalid_order_type() {
2169        let p = params_from(&[
2170            ("stop_loss", json!("47000.00")),
2171            ("sl_order_type", json!("Stop")),
2172        ]);
2173        assert!(parse_bybit_tp_sl_params(Some(&p)).is_err());
2174    }
2175
2176    #[rstest]
2177    fn test_parse_tp_sl_params_rejects_limit_without_limit_price() {
2178        let p = params_from(&[
2179            ("take_profit", json!("55000.00")),
2180            ("tp_order_type", json!("Limit")),
2181        ]);
2182        let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2183        assert!(err.to_string().contains("tp_limit_price"));
2184    }
2185
2186    #[rstest]
2187    fn test_parse_tp_sl_params_rejects_limit_price_without_limit_type() {
2188        let p = params_from(&[
2189            ("take_profit", json!("55000.00")),
2190            ("tp_limit_price", json!("54990.00")),
2191        ]);
2192        let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2193        assert!(err.to_string().contains("tp_order_type"));
2194    }
2195
2196    #[rstest]
2197    fn test_parse_tp_sl_params_rejects_orphaned_tp_fields() {
2198        let p = params_from(&[("tp_trigger_by", json!("MarkPrice"))]);
2199        let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2200        assert!(err.to_string().contains("TP override fields require"));
2201    }
2202
2203    #[rstest]
2204    fn test_parse_tp_sl_params_accepts_numeric_prices() {
2205        let p = params_from(&[("take_profit", json!(55000.0)), ("stop_loss", json!(47000))]);
2206        let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2207        assert!(result.take_profit.is_some());
2208        assert!(result.stop_loss.is_some());
2209    }
2210
2211    #[rstest]
2212    fn test_parse_tp_sl_params_rejects_orphaned_sl_fields() {
2213        let p = params_from(&[("sl_trigger_by", json!("IndexPrice"))]);
2214        let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2215        assert!(err.to_string().contains("SL override fields require"));
2216    }
2217
2218    #[rstest]
2219    fn test_parse_tp_sl_params_rejects_bool_order_iv() {
2220        let p = params_from(&[("order_iv", json!(true))]);
2221        let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2222        assert!(err.to_string().contains("order_iv"));
2223    }
2224
2225    #[rstest]
2226    fn test_parse_tp_sl_params_rejects_string_mmp() {
2227        let p = params_from(&[("mmp", json!("true"))]);
2228        let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2229        assert!(err.to_string().contains("mmp"));
2230    }
2231
2232    #[rstest]
2233    fn test_parse_tp_sl_params_order_iv_string() {
2234        let p = params_from(&[("order_iv", json!("0.75"))]);
2235        let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2236        assert_eq!(result.order_iv.as_deref(), Some("0.75"));
2237    }
2238
2239    #[rstest]
2240    fn test_parse_tp_sl_params_order_iv_numeric() {
2241        let p = params_from(&[("order_iv", json!(0.75))]);
2242        let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2243        assert_eq!(result.order_iv.as_deref(), Some("0.75"));
2244    }
2245
2246    #[rstest]
2247    fn test_parse_tp_sl_params_mmp() {
2248        let p = params_from(&[("mmp", json!(true))]);
2249        let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2250        assert_eq!(result.mmp, Some(true));
2251    }
2252
2253    #[rstest]
2254    #[case(0, BybitPositionIdx::OneWay)]
2255    #[case(1, BybitPositionIdx::BuyHedge)]
2256    #[case(2, BybitPositionIdx::SellHedge)]
2257    fn test_parse_tp_sl_params_position_idx_valid(
2258        #[case] idx: i64,
2259        #[case] expected: BybitPositionIdx,
2260    ) {
2261        let p = params_from(&[("position_idx", json!(idx))]);
2262        let result = parse_bybit_tp_sl_params(Some(&p)).unwrap();
2263        assert_eq!(result.position_idx, Some(expected));
2264    }
2265
2266    #[rstest]
2267    #[case(json!(3))]
2268    #[case(json!(-1))]
2269    #[case(json!("1"))]
2270    #[case(json!(true))]
2271    fn test_parse_tp_sl_params_position_idx_invalid(#[case] value: serde_json::Value) {
2272        let p = params_from(&[("position_idx", value)]);
2273        let err = parse_bybit_tp_sl_params(Some(&p)).unwrap_err();
2274        assert!(err.to_string().contains("position_idx"));
2275    }
2276
2277    #[rstest]
2278    #[case(
2279        BybitOrderType::Market,
2280        BybitStopOrderType::TakeProfit,
2281        BybitTriggerDirection::RisesTo,
2282        BybitOrderSide::Sell,
2283        OrderType::MarketIfTouched
2284    )]
2285    #[case(
2286        BybitOrderType::Market,
2287        BybitStopOrderType::StopLoss,
2288        BybitTriggerDirection::FallsTo,
2289        BybitOrderSide::Sell,
2290        OrderType::StopMarket
2291    )]
2292    #[case(
2293        BybitOrderType::Market,
2294        BybitStopOrderType::TakeProfit,
2295        BybitTriggerDirection::FallsTo,
2296        BybitOrderSide::Buy,
2297        OrderType::MarketIfTouched
2298    )]
2299    #[case(
2300        BybitOrderType::Market,
2301        BybitStopOrderType::StopLoss,
2302        BybitTriggerDirection::RisesTo,
2303        BybitOrderSide::Buy,
2304        OrderType::StopMarket
2305    )]
2306    #[case(
2307        BybitOrderType::Limit,
2308        BybitStopOrderType::TakeProfit,
2309        BybitTriggerDirection::RisesTo,
2310        BybitOrderSide::Sell,
2311        OrderType::LimitIfTouched
2312    )]
2313    #[case(
2314        BybitOrderType::Limit,
2315        BybitStopOrderType::StopLoss,
2316        BybitTriggerDirection::FallsTo,
2317        BybitOrderSide::Sell,
2318        OrderType::StopLimit
2319    )]
2320    #[case(
2321        BybitOrderType::Limit,
2322        BybitStopOrderType::PartialTakeProfit,
2323        BybitTriggerDirection::FallsTo,
2324        BybitOrderSide::Buy,
2325        OrderType::LimitIfTouched
2326    )]
2327    #[case(
2328        BybitOrderType::Limit,
2329        BybitStopOrderType::PartialStopLoss,
2330        BybitTriggerDirection::RisesTo,
2331        BybitOrderSide::Buy,
2332        OrderType::StopLimit
2333    )]
2334    #[case(
2335        BybitOrderType::Market,
2336        BybitStopOrderType::TpslOrder,
2337        BybitTriggerDirection::FallsTo,
2338        BybitOrderSide::Sell,
2339        OrderType::StopMarket
2340    )]
2341    #[case(
2342        BybitOrderType::Market,
2343        BybitStopOrderType::Stop,
2344        BybitTriggerDirection::RisesTo,
2345        BybitOrderSide::Buy,
2346        OrderType::StopMarket
2347    )]
2348    #[case(
2349        BybitOrderType::Market,
2350        BybitStopOrderType::Stop,
2351        BybitTriggerDirection::FallsTo,
2352        BybitOrderSide::Sell,
2353        OrderType::StopMarket
2354    )]
2355    #[case(
2356        BybitOrderType::Market,
2357        BybitStopOrderType::TrailingStop,
2358        BybitTriggerDirection::FallsTo,
2359        BybitOrderSide::Sell,
2360        OrderType::StopMarket
2361    )]
2362    #[case(
2363        BybitOrderType::Limit,
2364        BybitStopOrderType::TrailingStop,
2365        BybitTriggerDirection::RisesTo,
2366        BybitOrderSide::Buy,
2367        OrderType::StopLimit
2368    )]
2369    fn test_parse_bybit_order_type_conditional(
2370        #[case] order_type: BybitOrderType,
2371        #[case] stop_order_type: BybitStopOrderType,
2372        #[case] trigger_direction: BybitTriggerDirection,
2373        #[case] side: BybitOrderSide,
2374        #[case] expected: OrderType,
2375    ) {
2376        let result = parse_bybit_order_type(order_type, stop_order_type, trigger_direction, side);
2377        assert_eq!(result, expected);
2378    }
2379
2380    #[rstest]
2381    #[case(
2382        BybitOrderType::Market,
2383        BybitStopOrderType::None,
2384        BybitTriggerDirection::None,
2385        BybitOrderSide::Buy,
2386        OrderType::Market
2387    )]
2388    #[case(
2389        BybitOrderType::Limit,
2390        BybitStopOrderType::Unknown,
2391        BybitTriggerDirection::None,
2392        BybitOrderSide::Sell,
2393        OrderType::Limit
2394    )]
2395    #[case(
2396        BybitOrderType::Market,
2397        BybitStopOrderType::TakeProfit,
2398        BybitTriggerDirection::None,
2399        BybitOrderSide::Sell,
2400        OrderType::Market
2401    )]
2402    #[case(
2403        BybitOrderType::Limit,
2404        BybitStopOrderType::StopLoss,
2405        BybitTriggerDirection::None,
2406        BybitOrderSide::Buy,
2407        OrderType::Limit
2408    )]
2409    fn test_parse_bybit_order_type_plain(
2410        #[case] order_type: BybitOrderType,
2411        #[case] stop_order_type: BybitStopOrderType,
2412        #[case] trigger_direction: BybitTriggerDirection,
2413        #[case] side: BybitOrderSide,
2414        #[case] expected: OrderType,
2415    ) {
2416        let result = parse_bybit_order_type(order_type, stop_order_type, trigger_direction, side);
2417        assert_eq!(result, expected);
2418    }
2419
2420    #[rstest]
2421    fn test_parse_order_status_report_take_profit() {
2422        let instrument = linear_instrument();
2423        let json = load_test_json("http_get_orders_realtime_tp_sl.json");
2424        let response: BybitOpenOrdersResponse = serde_json::from_str(&json).unwrap();
2425        let order = &response.result.list[0];
2426        let account_id = AccountId::new("BYBIT-001");
2427
2428        let report = parse_order_status_report(order, &instrument, account_id, TS).unwrap();
2429
2430        assert_eq!(report.order_type, OrderType::MarketIfTouched);
2431        assert_eq!(report.order_side, OrderSide::Sell);
2432        assert_eq!(report.order_status, OrderStatus::Accepted);
2433        assert!(report.trigger_price.is_some());
2434        assert_eq!(
2435            report.trigger_price.unwrap(),
2436            Price::from_str("55000.0").unwrap()
2437        );
2438        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
2439        assert!(report.reduce_only);
2440    }
2441
2442    #[rstest]
2443    fn test_parse_order_status_report_stop_loss_limit() {
2444        let instrument = linear_instrument();
2445        let json = load_test_json("http_get_orders_realtime_tp_sl.json");
2446        let response: BybitOpenOrdersResponse = serde_json::from_str(&json).unwrap();
2447        let order = &response.result.list[1];
2448        let account_id = AccountId::new("BYBIT-001");
2449
2450        let report = parse_order_status_report(order, &instrument, account_id, TS).unwrap();
2451
2452        assert_eq!(report.order_type, OrderType::StopLimit);
2453        assert_eq!(report.order_side, OrderSide::Sell);
2454        assert_eq!(report.order_status, OrderStatus::Accepted);
2455        assert!(report.trigger_price.is_some());
2456        assert_eq!(
2457            report.trigger_price.unwrap(),
2458            Price::from_str("48000.0").unwrap()
2459        );
2460        assert!(report.price.is_some());
2461        assert_eq!(report.price.unwrap(), Price::from_str("47500.0").unwrap());
2462        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
2463        assert!(report.reduce_only);
2464    }
2465
2466    #[rstest]
2467    #[case::oneway(0, "BTCUSDT-LINEAR.BYBIT-ONEWAY")]
2468    #[case::long(1, "BTCUSDT-LINEAR.BYBIT-LONG")]
2469    #[case::short(2, "BTCUSDT-LINEAR.BYBIT-SHORT")]
2470    #[case::unknown(99, "BTCUSDT-LINEAR.BYBIT-UNKNOWN")]
2471    fn test_make_venue_position_id(#[case] position_idx: i32, #[case] expected: &str) {
2472        let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2473        let result = make_venue_position_id(instrument_id, position_idx);
2474        assert_eq!(result, PositionId::from(expected));
2475    }
2476
2477    #[rstest]
2478    fn test_parse_fill_report_venue_position_id_is_none() {
2479        let instrument = linear_instrument();
2480        let json = load_test_json("http_get_executions.json");
2481        let response: BybitTradeHistoryResponse = serde_json::from_str(&json).unwrap();
2482        let execution = &response.result.list[0];
2483        let account_id = AccountId::new("BYBIT-001");
2484
2485        let report = parse_fill_report(execution, account_id, &instrument, TS).unwrap();
2486
2487        assert_eq!(report.venue_position_id, None);
2488    }
2489
2490    #[rstest]
2491    fn test_parse_order_status_report_venue_position_id_is_none() {
2492        let instrument = linear_instrument();
2493        let json = load_test_json("http_get_orders_realtime_tp_sl.json");
2494        let response: BybitOpenOrdersResponse = serde_json::from_str(&json).unwrap();
2495        let order = &response.result.list[0]; // TP order, positionIdx=0
2496        let account_id = AccountId::new("BYBIT-001");
2497
2498        let report = parse_order_status_report(order, &instrument, account_id, TS).unwrap();
2499
2500        assert_eq!(report.venue_position_id, None);
2501    }
2502}