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