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