Skip to main content

nautilus_bybit/common/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Conversion functions that translate Bybit API schemas into Nautilus instruments.
17
18use std::{convert::TryFrom, str::FromStr};
19
20use anyhow::Context;
21pub use nautilus_core::serialization::{
22    deserialize_decimal_or_zero, deserialize_optional_decimal_or_zero,
23    deserialize_optional_decimal_str, deserialize_string_to_u8,
24};
25use nautilus_core::{UUID4, datetime::NANOSECONDS_IN_MILLISECOND, nanos::UnixNanos};
26use nautilus_model::{
27    data::{
28        Bar, BarType, BookOrder, FundingRateUpdate, OrderBookDelta, OrderBookDeltas, TradeTick,
29    },
30    enums::{
31        AccountType, AggressorSide, BarAggregation, BookAction, LiquiditySide, OptionKind,
32        OrderSide, OrderStatus, OrderType, PositionSideSpecified, RecordFlag, TimeInForce,
33        TriggerType,
34    },
35    events::account::state::AccountState,
36    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, Venue, VenueOrderId},
37    instruments::{
38        Instrument, any::InstrumentAny, crypto_future::CryptoFuture, crypto_option::CryptoOption,
39        crypto_perpetual::CryptoPerpetual, currency_pair::CurrencyPair,
40    },
41    reports::{FillReport, OrderStatusReport, PositionStatusReport},
42    types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
43};
44use rust_decimal::Decimal;
45use ustr::Ustr;
46
47use crate::{
48    common::{
49        enums::{
50            BybitContractType, BybitKlineInterval, BybitOptionType, BybitOrderSide,
51            BybitOrderStatus, BybitOrderType, BybitPositionSide, BybitProductType,
52            BybitStopOrderType, BybitTimeInForce, BybitTriggerDirection,
53        },
54        symbol::BybitSymbol,
55    },
56    http::models::{
57        BybitExecution, BybitFeeRate, BybitFunding, BybitInstrumentInverse, BybitInstrumentLinear,
58        BybitInstrumentOption, BybitInstrumentSpot, BybitKline, BybitOrderbookResult,
59        BybitPosition, BybitTrade, BybitWalletBalance,
60    },
61    websocket::parse::parse_millis_i64,
62};
63
64const BYBIT_HOUR_INTERVALS: &[u64] = &[1, 2, 4, 6, 12];
65
66/// Extracts the raw symbol from a Bybit symbol by removing the product type suffix.
67#[must_use]
68pub fn extract_raw_symbol(symbol: &str) -> &str {
69    symbol.rsplit_once('-').map_or(symbol, |(prefix, _)| prefix)
70}
71
72/// Constructs a full Bybit symbol from a raw symbol and product type.
73///
74/// Returns a `Ustr` for efficient string interning and comparisons.
75#[must_use]
76pub fn make_bybit_symbol<S: AsRef<str>>(raw_symbol: S, product_type: BybitProductType) -> Ustr {
77    let raw = raw_symbol.as_ref();
78    let suffix = match product_type {
79        BybitProductType::Spot => "-SPOT",
80        BybitProductType::Linear => "-LINEAR",
81        BybitProductType::Inverse => "-INVERSE",
82        BybitProductType::Option => "-OPTION",
83    };
84    Ustr::from(&format!("{raw}{suffix}"))
85}
86
87/// Converts a Bybit kline interval string to a Nautilus bar aggregation and step.
88///
89/// Bybit interval strings: 1, 3, 5, 15, 30, 60, 120, 240, 360, 720 (minutes/hours), D, W, M
90#[must_use]
91pub fn bybit_interval_to_bar_spec(interval: &str) -> Option<(usize, BarAggregation)> {
92    match interval {
93        "1" => Some((1, BarAggregation::Minute)),
94        "3" => Some((3, BarAggregation::Minute)),
95        "5" => Some((5, BarAggregation::Minute)),
96        "15" => Some((15, BarAggregation::Minute)),
97        "30" => Some((30, BarAggregation::Minute)),
98        "60" => Some((1, BarAggregation::Hour)),
99        "120" => Some((2, BarAggregation::Hour)),
100        "240" => Some((4, BarAggregation::Hour)),
101        "360" => Some((6, BarAggregation::Hour)),
102        "720" => Some((12, BarAggregation::Hour)),
103        "D" => Some((1, BarAggregation::Day)),
104        "W" => Some((1, BarAggregation::Week)),
105        "M" => Some((1, BarAggregation::Month)),
106        _ => None,
107    }
108}
109
110/// Converts a Nautilus bar aggregation and step to a Bybit kline interval.
111///
112/// Bybit supported intervals: 1, 3, 5, 15, 30, 60, 120, 240, 360, 720 (minutes), D, W, M
113///
114/// # Errors
115///
116/// Returns an error if the aggregation type or step is not supported by Bybit.
117pub fn bar_spec_to_bybit_interval(
118    aggregation: BarAggregation,
119    step: u64,
120) -> anyhow::Result<BybitKlineInterval> {
121    match aggregation {
122        BarAggregation::Minute => match step {
123            1 => Ok(BybitKlineInterval::Minute1),
124            3 => Ok(BybitKlineInterval::Minute3),
125            5 => Ok(BybitKlineInterval::Minute5),
126            15 => Ok(BybitKlineInterval::Minute15),
127            30 => Ok(BybitKlineInterval::Minute30),
128            _ => anyhow::bail!(
129                "Bybit only supports minute intervals 1, 3, 5, 15, 30 (use HOUR for >= 60)"
130            ),
131        },
132        BarAggregation::Hour => match step {
133            1 => Ok(BybitKlineInterval::Hour1),
134            2 => Ok(BybitKlineInterval::Hour2),
135            4 => Ok(BybitKlineInterval::Hour4),
136            6 => Ok(BybitKlineInterval::Hour6),
137            12 => Ok(BybitKlineInterval::Hour12),
138            _ => anyhow::bail!(
139                "Bybit only supports the following hour intervals: {BYBIT_HOUR_INTERVALS:?}"
140            ),
141        },
142        BarAggregation::Day => {
143            if step != 1 {
144                anyhow::bail!("Bybit only supports 1 DAY interval bars");
145            }
146            Ok(BybitKlineInterval::Day1)
147        }
148        BarAggregation::Week => {
149            if step != 1 {
150                anyhow::bail!("Bybit only supports 1 WEEK interval bars");
151            }
152            Ok(BybitKlineInterval::Week1)
153        }
154        BarAggregation::Month => {
155            if step != 1 {
156                anyhow::bail!("Bybit only supports 1 MONTH interval bars");
157            }
158            Ok(BybitKlineInterval::Month1)
159        }
160        _ => {
161            anyhow::bail!("Bybit does not support {aggregation:?} bars");
162        }
163    }
164}
165
166fn default_margin() -> Decimal {
167    Decimal::new(1, 1)
168}
169
170/// Parses a spot instrument definition returned by Bybit into a Nautilus currency pair.
171pub fn parse_spot_instrument(
172    definition: &BybitInstrumentSpot,
173    fee_rate: &BybitFeeRate,
174    ts_event: UnixNanos,
175    ts_init: UnixNanos,
176) -> anyhow::Result<InstrumentAny> {
177    let base_currency = get_currency(definition.base_coin.as_str());
178    let quote_currency = get_currency(definition.quote_coin.as_str());
179
180    let symbol = BybitSymbol::new(format!("{}-SPOT", definition.symbol))?;
181    let instrument_id = symbol.to_instrument_id();
182    let raw_symbol = Symbol::new(symbol.raw_symbol());
183
184    let price_increment = parse_price(&definition.price_filter.tick_size, "priceFilter.tickSize")?;
185    let size_increment = parse_quantity(
186        &definition.lot_size_filter.base_precision,
187        "lotSizeFilter.basePrecision",
188    )?;
189    let lot_size = Some(size_increment);
190    let max_quantity = Some(parse_quantity(
191        &definition.lot_size_filter.max_order_qty,
192        "lotSizeFilter.maxOrderQty",
193    )?);
194    let min_quantity = Some(parse_quantity(
195        &definition.lot_size_filter.min_order_qty,
196        "lotSizeFilter.minOrderQty",
197    )?);
198
199    let maker_fee = parse_decimal(&fee_rate.maker_fee_rate, "makerFeeRate")?;
200    let taker_fee = parse_decimal(&fee_rate.taker_fee_rate, "takerFeeRate")?;
201
202    let instrument = CurrencyPair::new(
203        instrument_id,
204        raw_symbol,
205        base_currency,
206        quote_currency,
207        price_increment.precision,
208        size_increment.precision,
209        price_increment,
210        size_increment,
211        None,
212        lot_size,
213        max_quantity,
214        min_quantity,
215        None,
216        None,
217        None,
218        None,
219        Some(default_margin()),
220        Some(default_margin()),
221        Some(maker_fee),
222        Some(taker_fee),
223        ts_event,
224        ts_init,
225    );
226
227    Ok(InstrumentAny::CurrencyPair(instrument))
228}
229
230/// Parses a linear contract definition (perpetual or dated future) into a Nautilus instrument.
231pub fn parse_linear_instrument(
232    definition: &BybitInstrumentLinear,
233    fee_rate: &BybitFeeRate,
234    ts_event: UnixNanos,
235    ts_init: UnixNanos,
236) -> anyhow::Result<InstrumentAny> {
237    // Validate required fields
238    anyhow::ensure!(
239        !definition.base_coin.is_empty(),
240        "base_coin is empty for symbol '{}'",
241        definition.symbol
242    );
243    anyhow::ensure!(
244        !definition.quote_coin.is_empty(),
245        "quote_coin is empty for symbol '{}'",
246        definition.symbol
247    );
248
249    let base_currency = get_currency(definition.base_coin.as_str());
250    let quote_currency = get_currency(definition.quote_coin.as_str());
251    let settlement_currency = resolve_settlement_currency(
252        definition.settle_coin.as_str(),
253        base_currency,
254        quote_currency,
255    )?;
256
257    let symbol = BybitSymbol::new(format!("{}-LINEAR", definition.symbol))?;
258    let instrument_id = symbol.to_instrument_id();
259    let raw_symbol = Symbol::new(symbol.raw_symbol());
260
261    let price_increment = parse_price(&definition.price_filter.tick_size, "priceFilter.tickSize")?;
262    let size_increment = parse_quantity(
263        &definition.lot_size_filter.qty_step,
264        "lotSizeFilter.qtyStep",
265    )?;
266    let lot_size = Some(size_increment);
267    let max_quantity = Some(parse_quantity(
268        &definition.lot_size_filter.max_order_qty,
269        "lotSizeFilter.maxOrderQty",
270    )?);
271    let min_quantity = Some(parse_quantity(
272        &definition.lot_size_filter.min_order_qty,
273        "lotSizeFilter.minOrderQty",
274    )?);
275    let max_price = Some(parse_price(
276        &definition.price_filter.max_price,
277        "priceFilter.maxPrice",
278    )?);
279    let min_price = Some(parse_price(
280        &definition.price_filter.min_price,
281        "priceFilter.minPrice",
282    )?);
283
284    let maker_fee = parse_decimal(&fee_rate.maker_fee_rate, "makerFeeRate")?;
285    let taker_fee = parse_decimal(&fee_rate.taker_fee_rate, "takerFeeRate")?;
286
287    match definition.contract_type {
288        BybitContractType::LinearPerpetual => {
289            let instrument = CryptoPerpetual::new(
290                instrument_id,
291                raw_symbol,
292                base_currency,
293                quote_currency,
294                settlement_currency,
295                false,
296                price_increment.precision,
297                size_increment.precision,
298                price_increment,
299                size_increment,
300                None,
301                lot_size,
302                max_quantity,
303                min_quantity,
304                None,
305                None,
306                max_price,
307                min_price,
308                Some(default_margin()),
309                Some(default_margin()),
310                Some(maker_fee),
311                Some(taker_fee),
312                ts_event,
313                ts_init,
314            );
315            Ok(InstrumentAny::CryptoPerpetual(instrument))
316        }
317        BybitContractType::LinearFutures => {
318            let activation_ns = parse_millis_timestamp(&definition.launch_time, "launchTime")?;
319            let expiration_ns = parse_millis_timestamp(&definition.delivery_time, "deliveryTime")?;
320            let instrument = CryptoFuture::new(
321                instrument_id,
322                raw_symbol,
323                base_currency,
324                quote_currency,
325                settlement_currency,
326                false,
327                activation_ns,
328                expiration_ns,
329                price_increment.precision,
330                size_increment.precision,
331                price_increment,
332                size_increment,
333                None,
334                lot_size,
335                max_quantity,
336                min_quantity,
337                None,
338                None,
339                max_price,
340                min_price,
341                Some(default_margin()),
342                Some(default_margin()),
343                Some(maker_fee),
344                Some(taker_fee),
345                ts_event,
346                ts_init,
347            );
348            Ok(InstrumentAny::CryptoFuture(instrument))
349        }
350        other => Err(anyhow::anyhow!(
351            "unsupported linear contract variant: {other:?}"
352        )),
353    }
354}
355
356/// Parses an inverse contract definition into a Nautilus instrument.
357pub fn parse_inverse_instrument(
358    definition: &BybitInstrumentInverse,
359    fee_rate: &BybitFeeRate,
360    ts_event: UnixNanos,
361    ts_init: UnixNanos,
362) -> anyhow::Result<InstrumentAny> {
363    // Validate required fields
364    anyhow::ensure!(
365        !definition.base_coin.is_empty(),
366        "base_coin is empty for symbol '{}'",
367        definition.symbol
368    );
369    anyhow::ensure!(
370        !definition.quote_coin.is_empty(),
371        "quote_coin is empty for symbol '{}'",
372        definition.symbol
373    );
374
375    let base_currency = get_currency(definition.base_coin.as_str());
376    let quote_currency = get_currency(definition.quote_coin.as_str());
377    let settlement_currency = resolve_settlement_currency(
378        definition.settle_coin.as_str(),
379        base_currency,
380        quote_currency,
381    )?;
382
383    let symbol = BybitSymbol::new(format!("{}-INVERSE", definition.symbol))?;
384    let instrument_id = symbol.to_instrument_id();
385    let raw_symbol = Symbol::new(symbol.raw_symbol());
386
387    let price_increment = parse_price(&definition.price_filter.tick_size, "priceFilter.tickSize")?;
388    let size_increment = parse_quantity(
389        &definition.lot_size_filter.qty_step,
390        "lotSizeFilter.qtyStep",
391    )?;
392    let lot_size = Some(size_increment);
393    let max_quantity = Some(parse_quantity(
394        &definition.lot_size_filter.max_order_qty,
395        "lotSizeFilter.maxOrderQty",
396    )?);
397    let min_quantity = Some(parse_quantity(
398        &definition.lot_size_filter.min_order_qty,
399        "lotSizeFilter.minOrderQty",
400    )?);
401    let max_price = Some(parse_price(
402        &definition.price_filter.max_price,
403        "priceFilter.maxPrice",
404    )?);
405    let min_price = Some(parse_price(
406        &definition.price_filter.min_price,
407        "priceFilter.minPrice",
408    )?);
409
410    let maker_fee = parse_decimal(&fee_rate.maker_fee_rate, "makerFeeRate")?;
411    let taker_fee = parse_decimal(&fee_rate.taker_fee_rate, "takerFeeRate")?;
412
413    match definition.contract_type {
414        BybitContractType::InversePerpetual => {
415            let instrument = CryptoPerpetual::new(
416                instrument_id,
417                raw_symbol,
418                base_currency,
419                quote_currency,
420                settlement_currency,
421                true,
422                price_increment.precision,
423                size_increment.precision,
424                price_increment,
425                size_increment,
426                None,
427                lot_size,
428                max_quantity,
429                min_quantity,
430                None,
431                None,
432                max_price,
433                min_price,
434                Some(default_margin()),
435                Some(default_margin()),
436                Some(maker_fee),
437                Some(taker_fee),
438                ts_event,
439                ts_init,
440            );
441            Ok(InstrumentAny::CryptoPerpetual(instrument))
442        }
443        BybitContractType::InverseFutures => {
444            let activation_ns = parse_millis_timestamp(&definition.launch_time, "launchTime")?;
445            let expiration_ns = parse_millis_timestamp(&definition.delivery_time, "deliveryTime")?;
446            let instrument = CryptoFuture::new(
447                instrument_id,
448                raw_symbol,
449                base_currency,
450                quote_currency,
451                settlement_currency,
452                true,
453                activation_ns,
454                expiration_ns,
455                price_increment.precision,
456                size_increment.precision,
457                price_increment,
458                size_increment,
459                None,
460                lot_size,
461                max_quantity,
462                min_quantity,
463                None,
464                None,
465                max_price,
466                min_price,
467                Some(default_margin()),
468                Some(default_margin()),
469                Some(maker_fee),
470                Some(taker_fee),
471                ts_event,
472                ts_init,
473            );
474            Ok(InstrumentAny::CryptoFuture(instrument))
475        }
476        other => Err(anyhow::anyhow!(
477            "unsupported inverse contract variant: {other:?}"
478        )),
479    }
480}
481
482/// Parses a Bybit option contract definition into a Nautilus [`CryptoOption`].
483pub fn parse_option_instrument(
484    definition: &BybitInstrumentOption,
485    ts_event: UnixNanos,
486    ts_init: UnixNanos,
487) -> anyhow::Result<InstrumentAny> {
488    let symbol = BybitSymbol::new(format!("{}-OPTION", definition.symbol))?;
489    let instrument_id = symbol.to_instrument_id();
490    let raw_symbol = Symbol::new(symbol.raw_symbol());
491    let underlying = get_currency(definition.base_coin.as_str());
492    let quote_currency = get_currency(definition.quote_coin.as_str());
493    let settlement_currency = get_currency(definition.settle_coin.as_str());
494    // Bybit Options are linear contracts — they are margined and settled in stablecoins
495    let is_inverse = false;
496
497    let price_increment = parse_price(&definition.price_filter.tick_size, "priceFilter.tickSize")?;
498    let max_price = Some(parse_price(
499        &definition.price_filter.max_price,
500        "priceFilter.maxPrice",
501    )?);
502    let min_price = Some(parse_price(
503        &definition.price_filter.min_price,
504        "priceFilter.minPrice",
505    )?);
506    let lot_size = parse_quantity(
507        &definition.lot_size_filter.qty_step,
508        "lotSizeFilter.qtyStep",
509    )?;
510    let max_quantity = Some(parse_quantity(
511        &definition.lot_size_filter.max_order_qty,
512        "lotSizeFilter.maxOrderQty",
513    )?);
514    let min_quantity = Some(parse_quantity(
515        &definition.lot_size_filter.min_order_qty,
516        "lotSizeFilter.minOrderQty",
517    )?);
518
519    let option_kind = match definition.options_type {
520        BybitOptionType::Call => OptionKind::Call,
521        BybitOptionType::Put => OptionKind::Put,
522    };
523
524    let strike_price = extract_strike_from_symbol(&definition.symbol)?;
525    let activation_ns = parse_millis_timestamp(&definition.launch_time, "launchTime")?;
526    let expiration_ns = parse_millis_timestamp(&definition.delivery_time, "deliveryTime")?;
527
528    let instrument = CryptoOption::new(
529        instrument_id,
530        raw_symbol,
531        underlying,
532        quote_currency,
533        settlement_currency,
534        is_inverse,
535        option_kind,
536        strike_price,
537        activation_ns,
538        expiration_ns,
539        price_increment.precision,
540        lot_size.precision,
541        price_increment,
542        lot_size,                    // Lot size represents size increment.
543        Some(Quantity::from(1_u32)), // multiplier
544        Some(lot_size),
545        max_quantity,
546        min_quantity,
547        None,
548        None,
549        max_price,
550        min_price,
551        Some(Decimal::ZERO),
552        Some(Decimal::ZERO),
553        Some(Decimal::ZERO),
554        Some(Decimal::ZERO),
555        ts_event,
556        ts_init,
557    );
558
559    Ok(InstrumentAny::CryptoOption(instrument))
560}
561
562/// Parses a REST trade payload into a [`TradeTick`].
563pub fn parse_trade_tick(
564    trade: &BybitTrade,
565    instrument: &InstrumentAny,
566    ts_init: Option<UnixNanos>,
567) -> anyhow::Result<TradeTick> {
568    let price =
569        parse_price_with_precision(&trade.price, instrument.price_precision(), "trade.price")?;
570    let size =
571        parse_quantity_with_precision(&trade.size, instrument.size_precision(), "trade.size")?;
572    let aggressor: AggressorSide = trade.side.into();
573    let trade_id = TradeId::new_checked(trade.exec_id.as_str())
574        .context("invalid exec_id in Bybit trade payload")?;
575    let ts_event = parse_millis_timestamp(&trade.time, "trade.time")?;
576    let ts_init = ts_init.unwrap_or(ts_event);
577
578    TradeTick::new_checked(
579        instrument.id(),
580        price,
581        size,
582        aggressor,
583        trade_id,
584        ts_event,
585        ts_init,
586    )
587    .context("failed to construct TradeTick from Bybit trade payload")
588}
589
590/// Parses a REST funding payload into a [`FundingRateUpdate`].
591pub fn parse_funding_rate(
592    funding: &BybitFunding,
593    instrument: &InstrumentAny,
594) -> anyhow::Result<FundingRateUpdate> {
595    let rate = parse_decimal(&funding.funding_rate, "funding.rate")?;
596    let ts_event = parse_millis_timestamp(&funding.funding_rate_timestamp, "funding.timestamp")?;
597
598    Ok(FundingRateUpdate::new(
599        instrument.id(),
600        rate,
601        None, // next_funding_ns not provided with historical funding rates
602        ts_event,
603        ts_event,
604    ))
605}
606
607/// Parses an order book response into [`OrderBookDeltas`].
608pub fn parse_orderbook(
609    result: &BybitOrderbookResult,
610    instrument: &InstrumentAny,
611    ts_init: Option<UnixNanos>,
612) -> anyhow::Result<OrderBookDeltas> {
613    let ts_event = parse_millis_i64(result.ts, "orderbook.timestamp")?;
614    let ts_init = ts_init.unwrap_or(ts_event);
615
616    let instrument_id = instrument.id();
617    let price_precision = instrument.price_precision();
618    let size_precision = instrument.size_precision();
619    let update_id = u64::try_from(result.u)
620        .context("received negative update id in Bybit order book message")?;
621    let sequence = u64::try_from(result.seq)
622        .context("received negative sequence in Bybit order book message")?;
623
624    let total_levels = result.b.len() + result.a.len();
625    let mut deltas = Vec::with_capacity(total_levels + 1);
626
627    let mut clear = OrderBookDelta::clear(instrument_id, sequence, ts_event, ts_init);
628    if total_levels == 0 {
629        clear.flags |= RecordFlag::F_LAST as u8;
630    }
631    deltas.push(clear);
632
633    let mut processed = 0_usize;
634
635    let mut push_level = |values: &[String], side: OrderSide| -> anyhow::Result<()> {
636        let (price, size) = parse_book_level(values, price_precision, size_precision, "orderbook")?;
637
638        processed += 1;
639        let mut flags = RecordFlag::F_MBP as u8;
640        if processed == total_levels {
641            flags |= RecordFlag::F_LAST as u8;
642        }
643
644        let order = BookOrder::new(side, price, size, update_id);
645        let delta = OrderBookDelta::new_checked(
646            instrument_id,
647            BookAction::Add,
648            order,
649            flags,
650            sequence,
651            ts_event,
652            ts_init,
653        )
654        .context("failed to construct OrderBookDelta from Bybit book level")?;
655        deltas.push(delta);
656        Ok(())
657    };
658
659    for level in &result.b {
660        push_level(level, OrderSide::Buy)?;
661    }
662    for level in &result.a {
663        push_level(level, OrderSide::Sell)?;
664    }
665
666    OrderBookDeltas::new_checked(instrument_id, deltas)
667        .context("failed to assemble OrderBookDeltas from Bybit message")
668}
669
670pub fn parse_book_level(
671    level: &[String],
672    price_precision: u8,
673    size_precision: u8,
674    label: &str,
675) -> anyhow::Result<(Price, Quantity)> {
676    let price_str = level
677        .first()
678        .ok_or_else(|| anyhow::anyhow!("missing price component in {label} level"))?;
679    let size_str = level
680        .get(1)
681        .ok_or_else(|| anyhow::anyhow!("missing size component in {label} level"))?;
682    let price = parse_price_with_precision(price_str, price_precision, label)?;
683    let size = parse_quantity_with_precision(size_str, size_precision, label)?;
684    Ok((price, size))
685}
686
687/// Parses a kline entry into a [`Bar`].
688pub fn parse_kline_bar(
689    kline: &BybitKline,
690    instrument: &InstrumentAny,
691    bar_type: BarType,
692    timestamp_on_close: bool,
693    ts_init: Option<UnixNanos>,
694) -> anyhow::Result<Bar> {
695    let price_precision = instrument.price_precision();
696    let size_precision = instrument.size_precision();
697
698    let open = parse_price_with_precision(&kline.open, price_precision, "kline.open")?;
699    let high = parse_price_with_precision(&kline.high, price_precision, "kline.high")?;
700    let low = parse_price_with_precision(&kline.low, price_precision, "kline.low")?;
701    let close = parse_price_with_precision(&kline.close, price_precision, "kline.close")?;
702    let volume = parse_quantity_with_precision(&kline.volume, size_precision, "kline.volume")?;
703
704    let mut ts_event = parse_millis_timestamp(&kline.start, "kline.start")?;
705    if timestamp_on_close {
706        let interval_ns = bar_type
707            .spec()
708            .timedelta()
709            .num_nanoseconds()
710            .context("bar specification produced non-integer interval")?;
711        let interval_ns = u64::try_from(interval_ns)
712            .context("bar interval overflowed the u64 range for nanoseconds")?;
713        let updated = ts_event
714            .as_u64()
715            .checked_add(interval_ns)
716            .context("bar timestamp overflowed when adjusting to close time")?;
717        ts_event = UnixNanos::from(updated);
718    }
719    let ts_init = ts_init.unwrap_or(ts_event);
720
721    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
722        .context("failed to construct Bar from Bybit kline entry")
723}
724
725/// Parses a Bybit execution into a Nautilus FillReport.
726///
727/// # Errors
728///
729/// This function returns an error if:
730/// - Required price or quantity fields cannot be parsed.
731/// - The execution timestamp cannot be parsed.
732/// - Numeric conversions fail.
733pub fn parse_fill_report(
734    execution: &BybitExecution,
735    account_id: AccountId,
736    instrument: &InstrumentAny,
737    ts_init: UnixNanos,
738) -> anyhow::Result<FillReport> {
739    let instrument_id = instrument.id();
740    let venue_order_id = VenueOrderId::new(execution.order_id.as_str());
741    let trade_id = TradeId::new_checked(execution.exec_id.as_str())
742        .context("invalid execId in Bybit execution payload")?;
743
744    let order_side: OrderSide = execution.side.into();
745
746    let last_px = parse_price_with_precision(
747        &execution.exec_price,
748        instrument.price_precision(),
749        "execution.execPrice",
750    )?;
751
752    let last_qty = parse_quantity_with_precision(
753        &execution.exec_qty,
754        instrument.size_precision(),
755        "execution.execQty",
756    )?;
757
758    let fee_decimal: Decimal = execution
759        .exec_fee
760        .parse()
761        .with_context(|| format!("Failed to parse execFee='{}'", execution.exec_fee))?;
762    let currency = get_currency(&execution.fee_currency);
763    let commission = Money::from_decimal(fee_decimal, currency).with_context(|| {
764        format!(
765            "Failed to create commission from execFee='{}'",
766            execution.exec_fee
767        )
768    })?;
769
770    // Determine liquidity side from is_maker flag
771    let liquidity_side = if execution.is_maker {
772        LiquiditySide::Maker
773    } else {
774        LiquiditySide::Taker
775    };
776
777    let ts_event = parse_millis_timestamp(&execution.exec_time, "execution.execTime")?;
778
779    // Parse client_order_id if present
780    let client_order_id = if execution.order_link_id.is_empty() {
781        None
782    } else {
783        Some(ClientOrderId::new(execution.order_link_id.as_str()))
784    };
785
786    Ok(FillReport::new(
787        account_id,
788        instrument_id,
789        venue_order_id,
790        trade_id,
791        order_side,
792        last_qty,
793        last_px,
794        commission,
795        liquidity_side,
796        client_order_id,
797        None, // venue_position_id not provided by Bybit executions
798        ts_event,
799        ts_init,
800        None, // Will generate a new UUID4
801    ))
802}
803
804/// Parses a Bybit position into a Nautilus PositionStatusReport.
805///
806/// # Errors
807///
808/// This function returns an error if:
809/// - Position quantity or price fields cannot be parsed.
810/// - The position timestamp cannot be parsed.
811/// - Numeric conversions fail.
812pub fn parse_position_status_report(
813    position: &BybitPosition,
814    account_id: AccountId,
815    instrument: &InstrumentAny,
816    ts_init: UnixNanos,
817) -> anyhow::Result<PositionStatusReport> {
818    let instrument_id = instrument.id();
819
820    // Parse position size
821    let size_f64 = position
822        .size
823        .parse::<f64>()
824        .with_context(|| format!("Failed to parse position size '{}'", position.size))?;
825
826    // Determine position side and quantity
827    let (position_side, quantity) = match position.side {
828        BybitPositionSide::Buy => {
829            let qty = Quantity::new(size_f64, instrument.size_precision());
830            (PositionSideSpecified::Long, qty)
831        }
832        BybitPositionSide::Sell => {
833            let qty = Quantity::new(size_f64, instrument.size_precision());
834            (PositionSideSpecified::Short, qty)
835        }
836        BybitPositionSide::Flat => {
837            let qty = Quantity::new(0.0, instrument.size_precision());
838            (PositionSideSpecified::Flat, qty)
839        }
840    };
841
842    // Parse average entry price
843    let avg_px_open = if position.avg_price.is_empty() || position.avg_price == "0" {
844        None
845    } else {
846        Some(Decimal::from_str(&position.avg_price)?)
847    };
848
849    // Use ts_init if updatedTime is empty (initial/flat positions)
850    let ts_last = if position.updated_time.is_empty() {
851        ts_init
852    } else {
853        parse_millis_timestamp(&position.updated_time, "position.updatedTime")?
854    };
855
856    Ok(PositionStatusReport::new(
857        account_id,
858        instrument_id,
859        position_side,
860        quantity,
861        ts_last,
862        ts_init,
863        None, // Will generate a new UUID4
864        None, // venue_position_id not used for now
865        avg_px_open,
866    ))
867}
868
869/// Parses a Bybit wallet balance into a Nautilus account state.
870///
871/// # Errors
872///
873/// Returns an error if:
874/// - Balance data cannot be parsed.
875/// - Currency is invalid.
876pub fn parse_account_state(
877    wallet_balance: &BybitWalletBalance,
878    account_id: AccountId,
879    ts_init: UnixNanos,
880) -> anyhow::Result<AccountState> {
881    let mut balances = Vec::new();
882
883    for coin in &wallet_balance.coin {
884        let total_dec = coin.wallet_balance - coin.spot_borrow;
885        let locked_dec = coin.locked;
886
887        let currency = get_currency(&coin.coin);
888        let total = Money::from_decimal(total_dec, currency)?;
889        let locked = Money::from_decimal(locked_dec, currency)?;
890        let free = Money::from_raw(total.raw - locked.raw, currency);
891
892        balances.push(AccountBalance::new(total, locked, free));
893    }
894
895    let mut margins = Vec::new();
896
897    for coin in &wallet_balance.coin {
898        let initial_margin_f64 = match &coin.total_position_im {
899            Some(im) if !im.is_empty() => im.parse::<f64>()?,
900            _ => 0.0,
901        };
902
903        let maintenance_margin_f64 = match &coin.total_position_mm {
904            Some(mm) if !mm.is_empty() => mm.parse::<f64>()?,
905            _ => 0.0,
906        };
907
908        let currency = get_currency(&coin.coin);
909
910        // Only create margin balance if there are actual margin requirements
911        if initial_margin_f64 > 0.0 || maintenance_margin_f64 > 0.0 {
912            let initial_margin = Money::new(initial_margin_f64, currency);
913            let maintenance_margin = Money::new(maintenance_margin_f64, currency);
914
915            // Create a synthetic instrument_id for account-level margins
916            let margin_instrument_id = InstrumentId::new(
917                Symbol::from_str_unchecked(format!("ACCOUNT-{}", coin.coin)),
918                Venue::new("BYBIT"),
919            );
920
921            margins.push(MarginBalance::new(
922                initial_margin,
923                maintenance_margin,
924                margin_instrument_id,
925            ));
926        }
927    }
928
929    let account_type = AccountType::Margin;
930    let is_reported = true;
931    let event_id = UUID4::new();
932
933    // Use current time as ts_event since Bybit doesn't provide this in wallet balance
934    let ts_event = ts_init;
935
936    Ok(AccountState::new(
937        account_id,
938        account_type,
939        balances,
940        margins,
941        is_reported,
942        event_id,
943        ts_event,
944        ts_init,
945        None,
946    ))
947}
948
949pub(crate) fn parse_price_with_precision(
950    value: &str,
951    precision: u8,
952    field: &str,
953) -> anyhow::Result<Price> {
954    let parsed = value
955        .parse::<f64>()
956        .with_context(|| format!("Failed to parse {field}='{value}' as f64"))?;
957    Price::new_checked(parsed, precision).with_context(|| {
958        format!("Failed to construct Price for {field} with precision {precision}")
959    })
960}
961
962pub(crate) fn parse_quantity_with_precision(
963    value: &str,
964    precision: u8,
965    field: &str,
966) -> anyhow::Result<Quantity> {
967    let parsed = value
968        .parse::<f64>()
969        .with_context(|| format!("Failed to parse {field}='{value}' as f64"))?;
970    Quantity::new_checked(parsed, precision).with_context(|| {
971        format!("Failed to construct Quantity for {field} with precision {precision}")
972    })
973}
974
975pub(crate) fn parse_price(value: &str, field: &str) -> anyhow::Result<Price> {
976    Price::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
977}
978
979pub(crate) fn parse_quantity(value: &str, field: &str) -> anyhow::Result<Quantity> {
980    Quantity::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
981}
982
983pub(crate) fn parse_decimal(value: &str, field: &str) -> anyhow::Result<Decimal> {
984    Decimal::from_str(value)
985        .map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}' as Decimal: {e}"))
986}
987
988pub(crate) fn parse_millis_timestamp(value: &str, field: &str) -> anyhow::Result<UnixNanos> {
989    let millis: u64 = value
990        .parse()
991        .with_context(|| format!("Failed to parse {field}='{value}' as u64 millis"))?;
992    let nanos = millis
993        .checked_mul(NANOSECONDS_IN_MILLISECOND)
994        .context("millisecond timestamp overflowed when converting to nanoseconds")?;
995    Ok(UnixNanos::from(nanos))
996}
997
998fn resolve_settlement_currency(
999    settle_coin: &str,
1000    base_currency: Currency,
1001    quote_currency: Currency,
1002) -> anyhow::Result<Currency> {
1003    if settle_coin.eq_ignore_ascii_case(base_currency.code.as_str()) {
1004        Ok(base_currency)
1005    } else if settle_coin.eq_ignore_ascii_case(quote_currency.code.as_str()) {
1006        Ok(quote_currency)
1007    } else {
1008        Err(anyhow::anyhow!(
1009            "unrecognised settlement currency '{settle_coin}'"
1010        ))
1011    }
1012}
1013
1014/// Returns a currency from the internal map or creates a new crypto currency.
1015///
1016/// Uses [`Currency::get_or_create_crypto`] to handle unknown currency codes,
1017/// which automatically registers newly listed Bybit assets.
1018pub fn get_currency(code: &str) -> Currency {
1019    Currency::get_or_create_crypto(code)
1020}
1021
1022fn extract_strike_from_symbol(symbol: &str) -> anyhow::Result<Price> {
1023    let parts: Vec<&str> = symbol.split('-').collect();
1024    let strike = parts
1025        .get(2)
1026        .ok_or_else(|| anyhow::anyhow!("invalid option symbol '{symbol}'"))?;
1027    parse_price(strike, "option strike")
1028}
1029
1030/// Parses a Bybit order into a Nautilus OrderStatusReport.
1031pub fn parse_order_status_report(
1032    order: &crate::http::models::BybitOrder,
1033    instrument: &InstrumentAny,
1034    account_id: AccountId,
1035    ts_init: UnixNanos,
1036) -> anyhow::Result<OrderStatusReport> {
1037    let instrument_id = instrument.id();
1038    let venue_order_id = VenueOrderId::new(order.order_id);
1039
1040    let order_side: OrderSide = order.side.into();
1041
1042    // Bybit represents conditional orders using orderType + stopOrderType + triggerDirection + side
1043    let order_type: OrderType = match (
1044        order.order_type,
1045        order.stop_order_type,
1046        order.trigger_direction,
1047        order.side,
1048    ) {
1049        (BybitOrderType::Market, BybitStopOrderType::None | BybitStopOrderType::Unknown, _, _) => {
1050            OrderType::Market
1051        }
1052        (BybitOrderType::Limit, BybitStopOrderType::None | BybitStopOrderType::Unknown, _, _) => {
1053            OrderType::Limit
1054        }
1055
1056        (
1057            BybitOrderType::Market,
1058            BybitStopOrderType::Stop,
1059            BybitTriggerDirection::RisesTo,
1060            BybitOrderSide::Buy,
1061        ) => OrderType::StopMarket,
1062        (
1063            BybitOrderType::Market,
1064            BybitStopOrderType::Stop,
1065            BybitTriggerDirection::FallsTo,
1066            BybitOrderSide::Buy,
1067        ) => OrderType::MarketIfTouched,
1068
1069        (
1070            BybitOrderType::Market,
1071            BybitStopOrderType::Stop,
1072            BybitTriggerDirection::FallsTo,
1073            BybitOrderSide::Sell,
1074        ) => OrderType::StopMarket,
1075        (
1076            BybitOrderType::Market,
1077            BybitStopOrderType::Stop,
1078            BybitTriggerDirection::RisesTo,
1079            BybitOrderSide::Sell,
1080        ) => OrderType::MarketIfTouched,
1081
1082        (
1083            BybitOrderType::Limit,
1084            BybitStopOrderType::Stop,
1085            BybitTriggerDirection::RisesTo,
1086            BybitOrderSide::Buy,
1087        ) => OrderType::StopLimit,
1088        (
1089            BybitOrderType::Limit,
1090            BybitStopOrderType::Stop,
1091            BybitTriggerDirection::FallsTo,
1092            BybitOrderSide::Buy,
1093        ) => OrderType::LimitIfTouched,
1094
1095        (
1096            BybitOrderType::Limit,
1097            BybitStopOrderType::Stop,
1098            BybitTriggerDirection::FallsTo,
1099            BybitOrderSide::Sell,
1100        ) => OrderType::StopLimit,
1101        (
1102            BybitOrderType::Limit,
1103            BybitStopOrderType::Stop,
1104            BybitTriggerDirection::RisesTo,
1105            BybitOrderSide::Sell,
1106        ) => OrderType::LimitIfTouched,
1107
1108        // triggerDirection=None means regular order with TP/SL attached, not a standalone conditional order
1109        (BybitOrderType::Market, BybitStopOrderType::Stop, BybitTriggerDirection::None, _) => {
1110            OrderType::Market
1111        }
1112        (BybitOrderType::Limit, BybitStopOrderType::Stop, BybitTriggerDirection::None, _) => {
1113            OrderType::Limit
1114        }
1115
1116        // TP/SL stopOrderTypes are attached to positions, not standalone conditional orders
1117        (BybitOrderType::Market, _, _, _) => OrderType::Market,
1118        (BybitOrderType::Limit, _, _, _) => OrderType::Limit,
1119
1120        (BybitOrderType::Unknown, _, _, _) => OrderType::Limit,
1121    };
1122
1123    let time_in_force: TimeInForce = match order.time_in_force {
1124        BybitTimeInForce::Gtc => TimeInForce::Gtc,
1125        BybitTimeInForce::Ioc => TimeInForce::Ioc,
1126        BybitTimeInForce::Fok => TimeInForce::Fok,
1127        BybitTimeInForce::PostOnly => TimeInForce::Gtc,
1128    };
1129
1130    let quantity =
1131        parse_quantity_with_precision(&order.qty, instrument.size_precision(), "order.qty")?;
1132
1133    let filled_qty = parse_quantity_with_precision(
1134        &order.cum_exec_qty,
1135        instrument.size_precision(),
1136        "order.cumExecQty",
1137    )?;
1138
1139    // Map Bybit order status to Nautilus order status
1140    // Special case: if Bybit reports "Rejected" but the order has fills, treat it as Canceled.
1141    // This handles the case where the exchange partially fills an order then rejects the
1142    // remaining quantity (e.g., due to margin, risk limits, or liquidity constraints).
1143    // The state machine does not allow PARTIALLY_FILLED -> REJECTED transitions.
1144    let order_status: OrderStatus = match order.order_status {
1145        BybitOrderStatus::Created | BybitOrderStatus::New | BybitOrderStatus::Untriggered => {
1146            OrderStatus::Accepted
1147        }
1148        BybitOrderStatus::Rejected => {
1149            if filled_qty.is_positive() {
1150                OrderStatus::Canceled
1151            } else {
1152                OrderStatus::Rejected
1153            }
1154        }
1155        BybitOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
1156        BybitOrderStatus::Filled => OrderStatus::Filled,
1157        BybitOrderStatus::Canceled | BybitOrderStatus::PartiallyFilledCanceled => {
1158            OrderStatus::Canceled
1159        }
1160        BybitOrderStatus::Triggered => OrderStatus::Triggered,
1161        BybitOrderStatus::Deactivated => OrderStatus::Canceled,
1162    };
1163
1164    let ts_accepted = parse_millis_timestamp(&order.created_time, "order.createdTime")?;
1165    let ts_last = parse_millis_timestamp(&order.updated_time, "order.updatedTime")?;
1166
1167    let mut report = OrderStatusReport::new(
1168        account_id,
1169        instrument_id,
1170        None,
1171        venue_order_id,
1172        order_side,
1173        order_type,
1174        time_in_force,
1175        order_status,
1176        quantity,
1177        filled_qty,
1178        ts_accepted,
1179        ts_last,
1180        ts_init,
1181        Some(UUID4::new()),
1182    );
1183
1184    if !order.order_link_id.is_empty() {
1185        report = report.with_client_order_id(ClientOrderId::new(order.order_link_id.as_str()));
1186    }
1187
1188    if !order.price.is_empty() && order.price != "0" {
1189        let price =
1190            parse_price_with_precision(&order.price, instrument.price_precision(), "order.price")?;
1191        report = report.with_price(price);
1192    }
1193
1194    if let Some(avg_price) = &order.avg_price
1195        && !avg_price.is_empty()
1196        && avg_price != "0"
1197    {
1198        let avg_px = avg_price
1199            .parse::<f64>()
1200            .with_context(|| format!("Failed to parse avg_price='{avg_price}' as f64"))?;
1201        report = report.with_avg_px(avg_px)?;
1202    }
1203
1204    if !order.trigger_price.is_empty() && order.trigger_price != "0" {
1205        let trigger_price = parse_price_with_precision(
1206            &order.trigger_price,
1207            instrument.price_precision(),
1208            "order.triggerPrice",
1209        )?;
1210        report = report.with_trigger_price(trigger_price);
1211
1212        // Set trigger_type for conditional orders
1213        let trigger_type: TriggerType = order.trigger_by.into();
1214        report = report.with_trigger_type(trigger_type);
1215    }
1216
1217    Ok(report)
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222    use nautilus_model::{
1223        data::BarSpecification,
1224        enums::{AggregationSource, BarAggregation, PositionSide, PriceType},
1225    };
1226    use rstest::rstest;
1227
1228    use super::*;
1229    use crate::{
1230        common::testing::load_test_json,
1231        http::models::{
1232            BybitInstrumentInverseResponse, BybitInstrumentLinearResponse,
1233            BybitInstrumentOptionResponse, BybitInstrumentSpotResponse, BybitKlinesResponse,
1234            BybitTradesResponse,
1235        },
1236    };
1237
1238    const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
1239
1240    fn sample_fee_rate(
1241        symbol: &str,
1242        taker: &str,
1243        maker: &str,
1244        base_coin: Option<&str>,
1245    ) -> BybitFeeRate {
1246        BybitFeeRate {
1247            symbol: Ustr::from(symbol),
1248            taker_fee_rate: taker.to_string(),
1249            maker_fee_rate: maker.to_string(),
1250            base_coin: base_coin.map(Ustr::from),
1251        }
1252    }
1253
1254    fn linear_instrument() -> InstrumentAny {
1255        let json = load_test_json("http_get_instruments_linear.json");
1256        let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
1257        let instrument = &response.result.list[0];
1258        let fee_rate = sample_fee_rate("BTCUSDT", "0.00055", "0.0001", Some("BTC"));
1259        parse_linear_instrument(instrument, &fee_rate, TS, TS).unwrap()
1260    }
1261
1262    #[rstest]
1263    fn parse_spot_instrument_builds_currency_pair() {
1264        let json = load_test_json("http_get_instruments_spot.json");
1265        let response: BybitInstrumentSpotResponse = serde_json::from_str(&json).unwrap();
1266        let instrument = &response.result.list[0];
1267        let fee_rate = sample_fee_rate("BTCUSDT", "0.0006", "0.0001", Some("BTC"));
1268
1269        let parsed = parse_spot_instrument(instrument, &fee_rate, TS, TS).unwrap();
1270        match parsed {
1271            InstrumentAny::CurrencyPair(pair) => {
1272                assert_eq!(pair.id.to_string(), "BTCUSDT-SPOT.BYBIT");
1273                assert_eq!(pair.price_increment, Price::from_str("0.1").unwrap());
1274                assert_eq!(pair.size_increment, Quantity::from_str("0.0001").unwrap());
1275                assert_eq!(pair.base_currency.code.as_str(), "BTC");
1276                assert_eq!(pair.quote_currency.code.as_str(), "USDT");
1277            }
1278            _ => panic!("expected CurrencyPair"),
1279        }
1280    }
1281
1282    #[rstest]
1283    fn parse_linear_perpetual_instrument_builds_crypto_perpetual() {
1284        let json = load_test_json("http_get_instruments_linear.json");
1285        let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
1286        let instrument = &response.result.list[0];
1287        let fee_rate = sample_fee_rate("BTCUSDT", "0.00055", "0.0001", Some("BTC"));
1288
1289        let parsed = parse_linear_instrument(instrument, &fee_rate, TS, TS).unwrap();
1290        match parsed {
1291            InstrumentAny::CryptoPerpetual(perp) => {
1292                assert_eq!(perp.id.to_string(), "BTCUSDT-LINEAR.BYBIT");
1293                assert!(!perp.is_inverse);
1294                assert_eq!(perp.price_increment, Price::from_str("0.5").unwrap());
1295                assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
1296            }
1297            other => panic!("unexpected instrument variant: {other:?}"),
1298        }
1299    }
1300
1301    #[rstest]
1302    fn parse_inverse_perpetual_instrument_builds_inverse_perpetual() {
1303        let json = load_test_json("http_get_instruments_inverse.json");
1304        let response: BybitInstrumentInverseResponse = serde_json::from_str(&json).unwrap();
1305        let instrument = &response.result.list[0];
1306        let fee_rate = sample_fee_rate("BTCUSD", "0.00075", "0.00025", Some("BTC"));
1307
1308        let parsed = parse_inverse_instrument(instrument, &fee_rate, TS, TS).unwrap();
1309        match parsed {
1310            InstrumentAny::CryptoPerpetual(perp) => {
1311                assert_eq!(perp.id.to_string(), "BTCUSD-INVERSE.BYBIT");
1312                assert!(perp.is_inverse);
1313                assert_eq!(perp.price_increment, Price::from_str("0.5").unwrap());
1314                assert_eq!(perp.size_increment, Quantity::from_str("1").unwrap());
1315            }
1316            other => panic!("unexpected instrument variant: {other:?}"),
1317        }
1318    }
1319
1320    #[rstest]
1321    fn parse_option_instrument_builds_crypto_option() {
1322        let json = load_test_json("http_get_instruments_option.json");
1323        let response: BybitInstrumentOptionResponse = serde_json::from_str(&json).unwrap();
1324        let instrument = &response.result.list[0];
1325
1326        let parsed = parse_option_instrument(instrument, TS, TS).unwrap();
1327        match parsed {
1328            InstrumentAny::CryptoOption(option) => {
1329                assert_eq!(option.id.to_string(), "ETH-26JUN26-16000-P-OPTION.BYBIT");
1330                assert_eq!(option.underlying.code.as_str(), "ETH");
1331                assert_eq!(option.quote_currency.code.as_str(), "USDC");
1332                assert_eq!(option.settlement_currency.code.as_str(), "USDC");
1333                assert!(!option.is_inverse);
1334                assert_eq!(option.option_kind, OptionKind::Put);
1335                assert_eq!(option.price_precision, 1);
1336                assert_eq!(option.price_increment, Price::from_str("0.1").unwrap());
1337                assert_eq!(option.size_precision, 0);
1338                assert_eq!(option.size_increment, Quantity::from_str("1").unwrap());
1339                assert_eq!(option.lot_size, Quantity::from_str("1").unwrap());
1340            }
1341            other => panic!("unexpected instrument variant: {other:?}"),
1342        }
1343    }
1344
1345    #[rstest]
1346    fn parse_http_trade_into_trade_tick() {
1347        let instrument = linear_instrument();
1348        let json = load_test_json("http_get_trades_recent.json");
1349        let response: BybitTradesResponse = serde_json::from_str(&json).unwrap();
1350        let trade = &response.result.list[0];
1351
1352        let tick = parse_trade_tick(trade, &instrument, Some(TS)).unwrap();
1353
1354        assert_eq!(tick.instrument_id, instrument.id());
1355        assert_eq!(tick.price, instrument.make_price(27450.50));
1356        assert_eq!(tick.size, instrument.make_qty(0.005, None));
1357        assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
1358        assert_eq!(
1359            tick.trade_id.to_string(),
1360            "a905d5c3-1ed0-4f37-83e4-9c73a2fe2f01"
1361        );
1362        assert_eq!(tick.ts_event, UnixNanos::new(1_709_891_679_000_000_000));
1363    }
1364
1365    #[rstest]
1366    fn parse_kline_into_bar() {
1367        let instrument = linear_instrument();
1368        let json = load_test_json("http_get_klines_linear.json");
1369        let response: BybitKlinesResponse = serde_json::from_str(&json).unwrap();
1370        let kline = &response.result.list[0];
1371
1372        let bar_type = BarType::new(
1373            instrument.id(),
1374            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
1375            AggregationSource::External,
1376        );
1377
1378        let bar = parse_kline_bar(kline, &instrument, bar_type, false, Some(TS)).unwrap();
1379
1380        assert_eq!(bar.bar_type.to_string(), bar_type.to_string());
1381        assert_eq!(bar.open, instrument.make_price(27450.0));
1382        assert_eq!(bar.high, instrument.make_price(27460.0));
1383        assert_eq!(bar.low, instrument.make_price(27440.0));
1384        assert_eq!(bar.close, instrument.make_price(27455.0));
1385        assert_eq!(bar.volume, instrument.make_qty(123.45, None));
1386        assert_eq!(bar.ts_event, UnixNanos::new(1_709_891_679_000_000_000));
1387    }
1388
1389    #[rstest]
1390    fn parse_http_position_short_into_position_status_report() {
1391        use crate::http::models::BybitPositionListResponse;
1392
1393        let json = load_test_json("http_get_positions.json");
1394        let response: BybitPositionListResponse = serde_json::from_str(&json).unwrap();
1395
1396        // Get the short position (ETHUSDT, side="Sell", size="5.0")
1397        let short_position = &response.result.list[1];
1398        assert_eq!(short_position.symbol.as_str(), "ETHUSDT");
1399        assert_eq!(short_position.side, BybitPositionSide::Sell);
1400
1401        // Create ETHUSDT instrument for parsing
1402        let eth_json = load_test_json("http_get_instruments_linear.json");
1403        let eth_response: BybitInstrumentLinearResponse = serde_json::from_str(&eth_json).unwrap();
1404        let eth_def = &eth_response.result.list[1]; // ETHUSDT is second in the list
1405        let fee_rate = sample_fee_rate("ETHUSDT", "0.00055", "0.0001", Some("ETH"));
1406        let eth_instrument = parse_linear_instrument(eth_def, &fee_rate, TS, TS).unwrap();
1407
1408        let account_id = AccountId::new("BYBIT-001");
1409        let report =
1410            parse_position_status_report(short_position, account_id, &eth_instrument, TS).unwrap();
1411
1412        // Verify short position is correctly parsed
1413        assert_eq!(report.account_id, account_id);
1414        assert_eq!(report.instrument_id.symbol.as_str(), "ETHUSDT-LINEAR");
1415        assert_eq!(report.position_side.as_position_side(), PositionSide::Short);
1416        assert_eq!(report.quantity, eth_instrument.make_qty(5.0, None));
1417        assert_eq!(
1418            report.avg_px_open,
1419            Some(Decimal::try_from(3000.00).unwrap())
1420        );
1421        assert_eq!(report.ts_last, UnixNanos::new(1_697_673_700_112_000_000));
1422    }
1423
1424    #[rstest]
1425    fn parse_http_order_partially_filled_rejected_maps_to_canceled() {
1426        use crate::http::models::BybitOrderHistoryResponse;
1427
1428        let instrument = linear_instrument();
1429        let json = load_test_json("http_get_order_partially_filled_rejected.json");
1430        let response: BybitOrderHistoryResponse = serde_json::from_str(&json).unwrap();
1431        let order = &response.result.list[0];
1432        let account_id = AccountId::new("BYBIT-001");
1433
1434        let report = parse_order_status_report(order, &instrument, account_id, TS).unwrap();
1435
1436        // Verify that Bybit "Rejected" status with fills is mapped to Canceled, not Rejected
1437        assert_eq!(report.order_status, OrderStatus::Canceled);
1438        assert_eq!(report.filled_qty, instrument.make_qty(0.005, None));
1439        assert_eq!(
1440            report.client_order_id.as_ref().unwrap().to_string(),
1441            "O-20251001-164609-APEX-000-49"
1442        );
1443    }
1444
1445    #[rstest]
1446    #[case(BarAggregation::Minute, 1, BybitKlineInterval::Minute1)]
1447    #[case(BarAggregation::Minute, 3, BybitKlineInterval::Minute3)]
1448    #[case(BarAggregation::Minute, 5, BybitKlineInterval::Minute5)]
1449    #[case(BarAggregation::Minute, 15, BybitKlineInterval::Minute15)]
1450    #[case(BarAggregation::Minute, 30, BybitKlineInterval::Minute30)]
1451    fn test_bar_spec_to_bybit_interval_minutes(
1452        #[case] aggregation: BarAggregation,
1453        #[case] step: u64,
1454        #[case] expected: BybitKlineInterval,
1455    ) {
1456        let result = bar_spec_to_bybit_interval(aggregation, step).unwrap();
1457        assert_eq!(result, expected);
1458    }
1459
1460    #[rstest]
1461    #[case(BarAggregation::Hour, 1, BybitKlineInterval::Hour1)]
1462    #[case(BarAggregation::Hour, 2, BybitKlineInterval::Hour2)]
1463    #[case(BarAggregation::Hour, 4, BybitKlineInterval::Hour4)]
1464    #[case(BarAggregation::Hour, 6, BybitKlineInterval::Hour6)]
1465    #[case(BarAggregation::Hour, 12, BybitKlineInterval::Hour12)]
1466    fn test_bar_spec_to_bybit_interval_hours(
1467        #[case] aggregation: BarAggregation,
1468        #[case] step: u64,
1469        #[case] expected: BybitKlineInterval,
1470    ) {
1471        let result = bar_spec_to_bybit_interval(aggregation, step).unwrap();
1472        assert_eq!(result, expected);
1473    }
1474
1475    #[rstest]
1476    #[case(BarAggregation::Day, 1, BybitKlineInterval::Day1)]
1477    #[case(BarAggregation::Week, 1, BybitKlineInterval::Week1)]
1478    #[case(BarAggregation::Month, 1, BybitKlineInterval::Month1)]
1479    fn test_bar_spec_to_bybit_interval_day_week_month(
1480        #[case] aggregation: BarAggregation,
1481        #[case] step: u64,
1482        #[case] expected: BybitKlineInterval,
1483    ) {
1484        let result = bar_spec_to_bybit_interval(aggregation, step).unwrap();
1485        assert_eq!(result, expected);
1486    }
1487
1488    #[rstest]
1489    #[case(BarAggregation::Minute, 2)]
1490    #[case(BarAggregation::Minute, 10)]
1491    #[case(BarAggregation::Hour, 3)]
1492    #[case(BarAggregation::Hour, 24)]
1493    #[case(BarAggregation::Day, 2)]
1494    #[case(BarAggregation::Week, 2)]
1495    #[case(BarAggregation::Month, 2)]
1496    fn test_bar_spec_to_bybit_interval_unsupported_steps(
1497        #[case] aggregation: BarAggregation,
1498        #[case] step: u64,
1499    ) {
1500        let result = bar_spec_to_bybit_interval(aggregation, step);
1501        assert!(result.is_err());
1502    }
1503
1504    #[rstest]
1505    fn test_bar_spec_to_bybit_interval_unsupported_aggregation() {
1506        let result = bar_spec_to_bybit_interval(BarAggregation::Second, 1);
1507        assert!(result.is_err());
1508    }
1509
1510    #[rstest]
1511    #[case("1", 1, BarAggregation::Minute)]
1512    #[case("3", 3, BarAggregation::Minute)]
1513    #[case("5", 5, BarAggregation::Minute)]
1514    #[case("15", 15, BarAggregation::Minute)]
1515    #[case("30", 30, BarAggregation::Minute)]
1516    fn test_bybit_interval_to_bar_spec_minutes(
1517        #[case] interval: &str,
1518        #[case] expected_step: usize,
1519        #[case] expected_aggregation: BarAggregation,
1520    ) {
1521        let result = bybit_interval_to_bar_spec(interval).unwrap();
1522        assert_eq!(result, (expected_step, expected_aggregation));
1523    }
1524
1525    #[rstest]
1526    #[case("60", 1, BarAggregation::Hour)]
1527    #[case("120", 2, BarAggregation::Hour)]
1528    #[case("240", 4, BarAggregation::Hour)]
1529    #[case("360", 6, BarAggregation::Hour)]
1530    #[case("720", 12, BarAggregation::Hour)]
1531    fn test_bybit_interval_to_bar_spec_hours(
1532        #[case] interval: &str,
1533        #[case] expected_step: usize,
1534        #[case] expected_aggregation: BarAggregation,
1535    ) {
1536        let result = bybit_interval_to_bar_spec(interval).unwrap();
1537        assert_eq!(result, (expected_step, expected_aggregation));
1538    }
1539
1540    #[rstest]
1541    #[case("D", 1, BarAggregation::Day)]
1542    #[case("W", 1, BarAggregation::Week)]
1543    #[case("M", 1, BarAggregation::Month)]
1544    fn test_bybit_interval_to_bar_spec_day_week_month(
1545        #[case] interval: &str,
1546        #[case] expected_step: usize,
1547        #[case] expected_aggregation: BarAggregation,
1548    ) {
1549        let result = bybit_interval_to_bar_spec(interval).unwrap();
1550        assert_eq!(result, (expected_step, expected_aggregation));
1551    }
1552
1553    #[rstest]
1554    #[case("2")]
1555    #[case("10")]
1556    #[case("100")]
1557    #[case("invalid")]
1558    #[case("")]
1559    fn test_bybit_interval_to_bar_spec_unsupported(#[case] interval: &str) {
1560        let result = bybit_interval_to_bar_spec(interval);
1561        assert!(result.is_none());
1562    }
1563}