nautilus_bybit/common/
parse.rs

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