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