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