Skip to main content

nautilus_deribit/common/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parsing functions for Deribit API responses into Nautilus domain types.
17
18use std::str::FromStr;
19
20use anyhow::Context;
21use nautilus_core::{
22    datetime::{NANOSECONDS_IN_MICROSECOND, NANOSECONDS_IN_MILLISECOND},
23    nanos::UnixNanos,
24    uuid::UUID4,
25};
26use nautilus_model::{
27    data::{Bar, BarType, BookOrder, TradeTick},
28    enums::{AccountType, AggressorSide, BookType, OptionKind, OrderSide},
29    events::AccountState,
30    identifiers::{AccountId, InstrumentId, Symbol, TradeId},
31    instruments::{CryptoFuture, CryptoOption, CryptoPerpetual, CurrencyPair, any::InstrumentAny},
32    orderbook::OrderBook,
33    types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
34};
35use rust_decimal::Decimal;
36
37use crate::{
38    common::{
39        consts::DERIBIT_VENUE,
40        enums::{DeribitOptionType, DeribitProductType},
41    },
42    http::models::{
43        DeribitAccountSummary, DeribitInstrument, DeribitOrderBook, DeribitPublicTrade,
44        DeribitTradingViewChartData,
45    },
46    websocket::messages::DeribitPortfolioMsg,
47};
48
49/// Parses a Deribit instrument ID into kind and currency for WebSocket channel subscription.
50///
51/// Deribit instrument naming conventions (per Deribit docs):
52/// - **Future**: `{CURRENCY}-{DMMMYY}` (e.g., "BTC-25MAR23", "BTC-5AUG23")
53/// - **Perpetual**: `{CURRENCY}-PERPETUAL` (e.g., "BTC-PERPETUAL")
54/// - **Option**: `{CURRENCY}-{DMMMYY}-{STRIKE}-{C|P}` (e.g., "BTC-25MAR23-420-C", "BTC-5AUG23-580-P")
55/// - **Linear Option**: `{BASE}_{QUOTE}-{DMMMYY}-{STRIKE}-{C|P}` (e.g., "XRP_USDC-30JUN23-0d625-C")
56///   - Note: `d` is used as decimal point for decimal strikes (0d625 = 0.625)
57/// - **Spot**: `{BASE}_{QUOTE}` (e.g., "BTC_USDC")
58///
59/// Returns `(kind, currency)` tuple for `instrument.state.{kind}.{currency}` channel.
60///
61/// Valid kinds: `future`, `option`, `spot`, `future_combo`, `option_combo`, `any`
62/// Valid currencies: `BTC`, `ETH`, `USDC`, `USDT`, `EURR`, `any`
63#[must_use]
64pub fn parse_instrument_kind_currency(instrument_id: &InstrumentId) -> (String, String) {
65    let symbol = instrument_id.symbol.as_str();
66
67    // Determine kind from instrument name pattern
68    // Order matters: check most specific patterns first
69    let kind = if symbol.contains("PERPETUAL") {
70        "future" // Perpetuals are treated as futures in Deribit API
71    } else if symbol.ends_with("-C") || symbol.ends_with("-P") {
72        // Options end with -C (call) or -P (put)
73        "option"
74    } else if symbol.contains('_') && !symbol.contains('-') {
75        // Spot pairs have underscore but no dash (e.g., "BTC_USDC")
76        "spot"
77    } else {
78        // Default to future for expiry dates like "BTC-25MAR23"
79        "future"
80    };
81
82    // Extract currency (first part before '-' or '_')
83    // For most instruments, currency is the first segment
84    let currency = if let Some(idx) = symbol.find('-') {
85        // Futures, perpetuals, options: "BTC-..." → "BTC"
86        // Linear options: "XRP_USDC-..." → extract base currency "XRP"
87        let first_part = &symbol[..idx];
88        if let Some(underscore_idx) = first_part.find('_') {
89            first_part[..underscore_idx].to_string()
90        } else {
91            first_part.to_string()
92        }
93    } else if let Some(idx) = symbol.find('_') {
94        // Spot: "BTC_USDC" → "BTC"
95        symbol[..idx].to_string()
96    } else {
97        "any".to_string()
98    };
99
100    (kind.to_string(), currency)
101}
102
103/// Extracts server timestamp from response and converts to UnixNanos.
104///
105/// # Errors
106///
107/// Returns an error if the server timestamp (us_out) is missing from the response.
108pub fn extract_server_timestamp(us_out: Option<u64>) -> anyhow::Result<UnixNanos> {
109    let us_out =
110        us_out.ok_or_else(|| anyhow::anyhow!("Missing server timestamp (us_out) in response"))?;
111    Ok(UnixNanos::from(us_out * NANOSECONDS_IN_MICROSECOND))
112}
113
114/// Parses a Deribit instrument into a Nautilus [`InstrumentAny`].
115///
116/// Returns `Ok(None)` for unsupported instrument types (e.g., combos).
117///
118/// # Errors
119///
120/// Returns an error if:
121/// - Required fields are missing (e.g., strike price for options)
122/// - Timestamp conversion fails
123/// - Decimal conversion fails for fees
124pub fn parse_deribit_instrument_any(
125    instrument: &DeribitInstrument,
126    ts_init: UnixNanos,
127    ts_event: UnixNanos,
128) -> anyhow::Result<Option<InstrumentAny>> {
129    match instrument.kind {
130        DeribitProductType::Spot => parse_spot_instrument(instrument, ts_init, ts_event).map(Some),
131        DeribitProductType::Future => {
132            // Check if it's a perpetual
133            if instrument.instrument_name.as_str().contains("PERPETUAL") {
134                parse_perpetual_instrument(instrument, ts_init, ts_event).map(Some)
135            } else {
136                parse_future_instrument(instrument, ts_init, ts_event).map(Some)
137            }
138        }
139        DeribitProductType::Option => {
140            parse_option_instrument(instrument, ts_init, ts_event).map(Some)
141        }
142        DeribitProductType::FutureCombo | DeribitProductType::OptionCombo => {
143            log::debug!(
144                "Skipping combo instrument: {} (kind={:?})",
145                instrument.instrument_name,
146                instrument.kind
147            );
148            Ok(None)
149        }
150    }
151}
152
153/// Parses a spot instrument into a [`CurrencyPair`].
154fn parse_spot_instrument(
155    instrument: &DeribitInstrument,
156    ts_init: UnixNanos,
157    ts_event: UnixNanos,
158) -> anyhow::Result<InstrumentAny> {
159    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
160
161    let base_currency = Currency::get_or_create_crypto(instrument.base_currency);
162    let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
163
164    let price_increment = Price::from_decimal(instrument.tick_size)?;
165    let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
166    let min_quantity = Quantity::from_decimal(instrument.min_trade_amount)?;
167
168    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
169        .context("Failed to parse maker_commission")?;
170    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
171        .context("Failed to parse taker_commission")?;
172
173    let currency_pair = CurrencyPair::new(
174        instrument_id,
175        instrument.instrument_name.into(),
176        base_currency,
177        quote_currency,
178        price_increment.precision,
179        size_increment.precision,
180        price_increment,
181        size_increment,
182        None, // multiplier
183        None, // lot_size
184        None, // max_quantity
185        Some(min_quantity),
186        None, // max_notional
187        None, // min_notional
188        None, // max_price
189        None, // min_price
190        None, // margin_init
191        None, // margin_maint
192        Some(maker_fee),
193        Some(taker_fee),
194        None,
195        ts_event,
196        ts_init,
197    );
198
199    Ok(InstrumentAny::CurrencyPair(currency_pair))
200}
201
202/// Parses a perpetual swap instrument into a [`CryptoPerpetual`].
203fn parse_perpetual_instrument(
204    instrument: &DeribitInstrument,
205    ts_init: UnixNanos,
206    ts_event: UnixNanos,
207) -> anyhow::Result<InstrumentAny> {
208    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
209
210    let base_currency = Currency::get_or_create_crypto(instrument.base_currency);
211    let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
212    let settlement_currency = instrument
213        .settlement_currency
214        .map_or(base_currency, Currency::get_or_create_crypto);
215
216    let is_inverse = instrument
217        .instrument_type
218        .as_ref()
219        .is_some_and(|t| t == "reversed");
220
221    let price_increment = Price::from_decimal(instrument.tick_size)?;
222    let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
223    let min_quantity = Quantity::from_decimal(instrument.min_trade_amount)?;
224
225    // Contract size represents the multiplier (e.g., 10 USD per contract for BTC-PERPETUAL)
226    let multiplier = Some(Quantity::from_decimal(instrument.contract_size)?);
227    let lot_size = Some(size_increment);
228
229    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
230        .context("Failed to parse maker_commission")?;
231    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
232        .context("Failed to parse taker_commission")?;
233
234    let perpetual = CryptoPerpetual::new(
235        instrument_id,
236        instrument.instrument_name.into(),
237        base_currency,
238        quote_currency,
239        settlement_currency,
240        is_inverse,
241        price_increment.precision,
242        size_increment.precision,
243        price_increment,
244        size_increment,
245        multiplier,
246        lot_size,
247        None, // max_quantity - Deribit doesn't specify a hard max
248        Some(min_quantity),
249        None, // max_notional
250        None, // min_notional
251        None, // max_price
252        None, // min_price
253        None, // margin_init
254        None, // margin_maint
255        Some(maker_fee),
256        Some(taker_fee),
257        None,
258        ts_event,
259        ts_init,
260    );
261
262    Ok(InstrumentAny::CryptoPerpetual(perpetual))
263}
264
265/// Parses a futures instrument into a [`CryptoFuture`].
266fn parse_future_instrument(
267    instrument: &DeribitInstrument,
268    ts_init: UnixNanos,
269    ts_event: UnixNanos,
270) -> anyhow::Result<InstrumentAny> {
271    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
272
273    let underlying = Currency::get_or_create_crypto(instrument.base_currency);
274    let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
275    let settlement_currency = instrument
276        .settlement_currency
277        .map_or(underlying, Currency::get_or_create_crypto);
278
279    let is_inverse = instrument
280        .instrument_type
281        .as_ref()
282        .is_some_and(|t| t == "reversed");
283
284    // Convert timestamps from milliseconds to nanoseconds
285    let activation_ns = (instrument.creation_timestamp as u64) * 1_000_000;
286    let expiration_ns = instrument
287        .expiration_timestamp
288        .context("Missing expiration_timestamp for future")? as u64
289        * 1_000_000; // milliseconds to nanoseconds
290
291    let price_increment = Price::from_decimal(instrument.tick_size)?;
292    let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
293    let min_quantity = Quantity::from_decimal(instrument.min_trade_amount)?;
294
295    // Contract size represents the multiplier
296    let multiplier = Some(Quantity::from_decimal(instrument.contract_size)?);
297    let lot_size = Some(size_increment); // Use min_trade_amount as lot size
298
299    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
300        .context("Failed to parse maker_commission")?;
301    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
302        .context("Failed to parse taker_commission")?;
303
304    let future = CryptoFuture::new(
305        instrument_id,
306        instrument.instrument_name.into(),
307        underlying,
308        quote_currency,
309        settlement_currency,
310        is_inverse,
311        UnixNanos::from(activation_ns),
312        UnixNanos::from(expiration_ns),
313        price_increment.precision,
314        size_increment.precision,
315        price_increment,
316        size_increment,
317        multiplier,
318        lot_size,
319        None, // max_quantity - Deribit doesn't specify a hard max
320        Some(min_quantity),
321        None, // max_notional
322        None, // min_notional
323        None, // max_price
324        None, // min_price
325        None, // margin_init
326        None, // margin_maint
327        Some(maker_fee),
328        Some(taker_fee),
329        None,
330        ts_event,
331        ts_init,
332    );
333
334    Ok(InstrumentAny::CryptoFuture(future))
335}
336
337/// Parses an options instrument into a [`CryptoOption`].
338fn parse_option_instrument(
339    instrument: &DeribitInstrument,
340    ts_init: UnixNanos,
341    ts_event: UnixNanos,
342) -> anyhow::Result<InstrumentAny> {
343    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
344    let underlying = Currency::get_or_create_crypto(instrument.base_currency);
345    let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
346    let settlement = instrument
347        .settlement_currency
348        .unwrap_or(instrument.base_currency);
349    let settlement_currency = Currency::get_or_create_crypto(settlement);
350
351    // Determine if inverse (settled in base currency) or linear (settled in quote/USDC)
352    let is_inverse = instrument
353        .instrument_type
354        .as_ref()
355        .is_some_and(|t| t == "reversed");
356
357    // Determine option kind
358    let option_kind = match instrument.option_type {
359        Some(DeribitOptionType::Call) => OptionKind::Call,
360        Some(DeribitOptionType::Put) => OptionKind::Put,
361        None => anyhow::bail!("Missing option_type for option instrument"),
362    };
363
364    // Parse strike price
365    let strike = instrument.strike.context("Missing strike for option")?;
366    let strike_price = Price::from_decimal(strike)?;
367
368    // Convert timestamps from milliseconds to nanoseconds
369    let activation_ns = (instrument.creation_timestamp as u64) * 1_000_000;
370    let expiration_ns = instrument
371        .expiration_timestamp
372        .context("Missing expiration_timestamp for option")? as u64
373        * 1_000_000;
374
375    let price_increment = Price::from_decimal(instrument.tick_size)?;
376
377    // Contract size is the multiplier (e.g., 1.0 for BTC options)
378    let multiplier = Quantity::from_decimal(instrument.contract_size)?;
379    let lot_size = Quantity::from_decimal(instrument.min_trade_amount)?;
380    let min_trade_amount = Quantity::from_decimal(instrument.min_trade_amount)?;
381
382    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
383        .context("Failed to parse maker_commission")?;
384    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
385        .context("Failed to parse taker_commission")?;
386
387    let option = CryptoOption::new(
388        instrument_id,
389        instrument.instrument_name.into(),
390        underlying,
391        quote_currency,
392        settlement_currency,
393        is_inverse,
394        option_kind,
395        strike_price,
396        UnixNanos::from(activation_ns),
397        UnixNanos::from(expiration_ns),
398        price_increment.precision,
399        lot_size.precision,
400        price_increment,
401        lot_size,
402        Some(multiplier),
403        Some(lot_size),
404        None,
405        Some(min_trade_amount),
406        None,
407        None,
408        None,
409        None,
410        None,
411        None,
412        Some(maker_fee),
413        Some(taker_fee),
414        None,
415        ts_event,
416        ts_init,
417    );
418
419    Ok(InstrumentAny::CryptoOption(option))
420}
421
422/// Parses Deribit account summaries into a Nautilus [`AccountState`].
423///
424/// Processes multiple currency summaries and creates balance entries for each currency.
425///
426/// # Errors
427///
428/// Returns an error if:
429/// - Money conversion fails for any balance field
430/// - Decimal conversion fails for margin values
431pub fn parse_account_state(
432    summaries: &[DeribitAccountSummary],
433    account_id: AccountId,
434    ts_init: UnixNanos,
435    ts_event: UnixNanos,
436) -> anyhow::Result<AccountState> {
437    let mut balances = Vec::new();
438    let mut margins = Vec::new();
439
440    // Parse each currency summary
441    for summary in summaries {
442        let ccy_str = summary.currency.as_str().trim();
443
444        // Skip balances with empty currency codes
445        if ccy_str.is_empty() {
446            log::debug!("Skipping balance detail with empty currency code | raw_data={summary:?}");
447            continue;
448        }
449
450        let currency = Currency::get_or_create_crypto_with_context(
451            ccy_str,
452            Some("DERIBIT - Parsing account state"),
453        );
454
455        // Parse balance using margin_balance (not equity):
456        // - total: margin_balance (equity minus fee reserves)
457        // - free: available_funds
458        // - locked derived centrally at currency precision; for Deribit it equals
459        //   `initial_margin` since `available_funds = margin_balance - initial_margin`.
460        let balance = AccountBalance::from_total_and_free(
461            summary.margin_balance,
462            summary.available_funds,
463            currency,
464        )?;
465        balances.push(balance);
466
467        // Parse margin balances if present
468        if let (Some(initial_margin), Some(maintenance_margin)) =
469            (summary.initial_margin, summary.maintenance_margin)
470            && (!initial_margin.is_zero() || !maintenance_margin.is_zero())
471        {
472            let initial = Money::from_decimal(initial_margin, currency)?;
473            let maintenance = Money::from_decimal(maintenance_margin, currency)?;
474            // Deribit reports cross-margin per collateral currency; emit as an
475            // account-wide entry keyed by that currency.
476            margins.push(MarginBalance::new(initial, maintenance, None));
477        }
478    }
479
480    // Ensure at least one balance exists (Nautilus requires non-empty balances)
481    if balances.is_empty() {
482        let zero_currency = Currency::USD();
483        let zero_money = Money::new(0.0, zero_currency);
484        let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
485        balances.push(zero_balance);
486    }
487
488    let account_type = AccountType::Margin;
489    let is_reported = true;
490
491    Ok(AccountState::new(
492        account_id,
493        account_type,
494        balances,
495        margins,
496        is_reported,
497        UUID4::new(),
498        ts_event,
499        ts_init,
500        None,
501    ))
502}
503
504/// Parses a Deribit WebSocket portfolio message into a Nautilus [`AccountState`].
505///
506/// This function converts real-time portfolio updates from the `user.portfolio.{currency}`
507/// subscription channel into Nautilus account state events.
508///
509/// # Returns
510///
511/// An `AccountState` containing balances and margin information.
512///
513/// # Errors
514///
515/// Returns an error if Money conversion fails for any balance field.
516pub fn parse_portfolio_to_account_state(
517    portfolio: &DeribitPortfolioMsg,
518    account_id: AccountId,
519    ts_init: UnixNanos,
520) -> anyhow::Result<AccountState> {
521    let ccy_str = portfolio.currency.trim();
522
523    // Skip empty currency codes
524    if ccy_str.is_empty() {
525        anyhow::bail!("Portfolio message has empty currency code");
526    }
527
528    let currency = Currency::get_or_create_crypto_with_context(
529        ccy_str,
530        Some("DERIBIT - Parsing portfolio update"),
531    );
532
533    // Parse balance using margin_balance (not equity):
534    // - total: margin_balance (equity minus fee reserves, used for margin calculations)
535    // - free: available_funds (what can be used for new orders)
536    // - locked derived centrally at currency precision; for Deribit it equals
537    //   `initial_margin` since `available_funds = margin_balance - initial_margin`.
538    let balance = AccountBalance::from_total_and_free(
539        portfolio.margin_balance,
540        portfolio.available_funds,
541        currency,
542    )?;
543    let balances = vec![balance];
544
545    // Parse margin balances
546    let mut margins = Vec::new();
547    let initial_margin = portfolio.initial_margin;
548    let maintenance_margin = portfolio.maintenance_margin;
549
550    // Only create margin balance if there are actual margin requirements
551    if !initial_margin.is_zero() || !maintenance_margin.is_zero() {
552        let initial = Money::from_decimal(initial_margin, currency)?;
553        let maintenance = Money::from_decimal(maintenance_margin, currency)?;
554        // Deribit reports cross-margin per collateral currency; emit as an
555        // account-wide entry keyed by that currency.
556        margins.push(MarginBalance::new(initial, maintenance, None));
557    }
558
559    let account_type = AccountType::Margin;
560    let is_reported = true;
561
562    Ok(AccountState::new(
563        account_id,
564        account_type,
565        balances,
566        margins,
567        is_reported,
568        UUID4::new(),
569        ts_init, // Use ts_init for both since we don't have server timestamp in portfolio msg
570        ts_init,
571        None,
572    ))
573}
574
575// Parses a Deribit public trade into a Nautilus [`TradeTick`].
576///
577/// # Errors
578///
579/// Returns an error if:
580/// - The direction is not "buy" or "sell"
581/// - Decimal conversion fails for price or size
582pub fn parse_trade_tick(
583    trade: &DeribitPublicTrade,
584    instrument_id: InstrumentId,
585    price_precision: u8,
586    size_precision: u8,
587    ts_init: UnixNanos,
588) -> anyhow::Result<TradeTick> {
589    // Parse aggressor side from direction
590    let aggressor_side = match trade.direction.as_str() {
591        "buy" => AggressorSide::Buyer,
592        "sell" => AggressorSide::Seller,
593        other => anyhow::bail!("Invalid trade direction: {other}"),
594    };
595    let price = Price::from_decimal_dp(trade.price, price_precision)?;
596    let size = Quantity::from_decimal_dp(trade.amount, size_precision)?;
597    let ts_event = UnixNanos::from((trade.timestamp as u64) * NANOSECONDS_IN_MILLISECOND);
598    let trade_id = TradeId::new(&trade.trade_id);
599
600    Ok(TradeTick::new(
601        instrument_id,
602        price,
603        size,
604        aggressor_side,
605        trade_id,
606        ts_event,
607        ts_init,
608    ))
609}
610
611/// Parses Deribit TradingView chart data into Nautilus [`Bar`]s.
612///
613/// Converts OHLCV arrays from the `public/get_tradingview_chart_data` endpoint
614/// into a vector of [`Bar`] objects.
615///
616/// # Errors
617///
618/// Returns an error if:
619/// - The status is not "ok"
620/// - Array lengths are inconsistent
621/// - No data points are present
622pub fn parse_bars(
623    chart_data: &DeribitTradingViewChartData,
624    bar_type: BarType,
625    price_precision: u8,
626    size_precision: u8,
627    ts_init: UnixNanos,
628) -> anyhow::Result<Vec<Bar>> {
629    // Check status
630    if chart_data.status != "ok" {
631        anyhow::bail!(
632            "Chart data status is '{}', expected 'ok'",
633            chart_data.status
634        );
635    }
636
637    let num_bars = chart_data.ticks.len();
638
639    // Verify array lengths match
640    anyhow::ensure!(
641        chart_data.open.len() == num_bars
642            && chart_data.high.len() == num_bars
643            && chart_data.low.len() == num_bars
644            && chart_data.close.len() == num_bars
645            && chart_data.volume.len() == num_bars,
646        "Inconsistent array lengths in chart data"
647    );
648
649    if num_bars == 0 {
650        return Ok(Vec::new());
651    }
652
653    let mut bars = Vec::with_capacity(num_bars);
654
655    for i in 0..num_bars {
656        let open = Price::new_checked(chart_data.open[i], price_precision)
657            .with_context(|| format!("Invalid open price at index {i}"))?;
658        let high = Price::new_checked(chart_data.high[i], price_precision)
659            .with_context(|| format!("Invalid high price at index {i}"))?;
660        let low = Price::new_checked(chart_data.low[i], price_precision)
661            .with_context(|| format!("Invalid low price at index {i}"))?;
662        let close = Price::new_checked(chart_data.close[i], price_precision)
663            .with_context(|| format!("Invalid close price at index {i}"))?;
664        let volume = Quantity::new_checked(chart_data.volume[i], size_precision)
665            .with_context(|| format!("Invalid volume at index {i}"))?;
666
667        // Convert timestamp from milliseconds to nanoseconds
668        let ts_event = UnixNanos::from((chart_data.ticks[i] as u64) * NANOSECONDS_IN_MILLISECOND);
669
670        let bar = Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
671            .with_context(|| format!("Invalid OHLC bar at index {i}"))?;
672        bars.push(bar);
673    }
674
675    Ok(bars)
676}
677
678/// Parses Deribit order book data into a Nautilus [`OrderBook`].
679///
680/// Converts bids and asks from the `public/get_order_book` endpoint
681/// into an L2_MBP order book.
682///
683/// # Errors
684///
685/// Returns an error if order book creation fails.
686pub fn parse_order_book(
687    order_book_data: &DeribitOrderBook,
688    instrument_id: InstrumentId,
689    price_precision: u8,
690    size_precision: u8,
691    ts_init: UnixNanos,
692) -> anyhow::Result<OrderBook> {
693    let ts_event = UnixNanos::from((order_book_data.timestamp as u64) * NANOSECONDS_IN_MILLISECOND);
694    let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
695
696    for (idx, [price, amount]) in order_book_data.bids.iter().enumerate() {
697        let order = BookOrder::new(
698            OrderSide::Buy,
699            Price::new(*price, price_precision),
700            Quantity::new(*amount, size_precision),
701            idx as u64,
702        );
703        book.add(order, 0, idx as u64, ts_event);
704    }
705
706    let bids_len = order_book_data.bids.len();
707    for (idx, [price, amount]) in order_book_data.asks.iter().enumerate() {
708        let order = BookOrder::new(
709            OrderSide::Sell,
710            Price::new(*price, price_precision),
711            Quantity::new(*amount, size_precision),
712            (bids_len + idx) as u64,
713        );
714        book.add(order, 0, (bids_len + idx) as u64, ts_event);
715    }
716
717    book.ts_last = ts_init;
718
719    Ok(book)
720}
721
722/// Converts a Nautilus BarType to a Deribit chart resolution.
723///
724/// Deribit resolutions: "1", "3", "5", "10", "15", "30", "60", "120", "180", "360", "720", "1D"
725pub fn bar_spec_to_resolution(bar_type: &BarType) -> String {
726    use nautilus_model::enums::BarAggregation;
727
728    let spec = bar_type.spec();
729    match spec.aggregation {
730        BarAggregation::Minute => {
731            let step = spec.step.get();
732            // Map to nearest Deribit resolution
733            match step {
734                1 => "1".to_string(),
735                2..=3 => "3".to_string(),
736                4..=5 => "5".to_string(),
737                6..=10 => "10".to_string(),
738                11..=15 => "15".to_string(),
739                16..=30 => "30".to_string(),
740                31..=60 => "60".to_string(),
741                61..=120 => "120".to_string(),
742                121..=180 => "180".to_string(),
743                181..=360 => "360".to_string(),
744                361..=720 => "720".to_string(),
745                _ => "1D".to_string(),
746            }
747        }
748        BarAggregation::Hour => {
749            let step = spec.step.get();
750            match step {
751                1 => "60".to_string(),
752                2 => "120".to_string(),
753                3 => "180".to_string(),
754                4..=6 => "360".to_string(),
755                7..=12 => "720".to_string(),
756                _ => "1D".to_string(),
757            }
758        }
759        BarAggregation::Day => "1D".to_string(),
760        _ => {
761            log::warn!(
762                "Unsupported bar aggregation {:?}, defaulting to 1 minute",
763                spec.aggregation
764            );
765            "1".to_string()
766        }
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use nautilus_model::{identifiers::Venue, instruments::Instrument};
773    use rstest::rstest;
774    use rust_decimal_macros::dec;
775
776    use super::*;
777    use crate::{
778        common::testing::load_test_json,
779        http::models::{
780            DeribitAccountSummariesResponse, DeribitJsonRpcResponse, DeribitTradesResponse,
781        },
782    };
783
784    #[rstest]
785    fn test_parse_perpetual_instrument() {
786        let json_data = load_test_json("http_get_instrument.json");
787        let response: DeribitJsonRpcResponse<DeribitInstrument> =
788            serde_json::from_str(&json_data).unwrap();
789        let deribit_inst = response.result.expect("Test data must have result");
790
791        let instrument_any =
792            parse_deribit_instrument_any(&deribit_inst, UnixNanos::default(), UnixNanos::default())
793                .unwrap();
794        let instrument = instrument_any.expect("Should parse perpetual instrument");
795
796        let InstrumentAny::CryptoPerpetual(perpetual) = instrument else {
797            panic!("Expected CryptoPerpetual, was {instrument:?}");
798        };
799        assert_eq!(perpetual.id(), InstrumentId::from("BTC-PERPETUAL.DERIBIT"));
800        assert_eq!(perpetual.raw_symbol(), Symbol::from("BTC-PERPETUAL"));
801        assert_eq!(perpetual.base_currency().unwrap().code, "BTC");
802        assert_eq!(perpetual.quote_currency().code, "USD");
803        assert_eq!(perpetual.settlement_currency().code, "BTC");
804        assert!(perpetual.is_inverse());
805        assert_eq!(perpetual.price_precision(), 1);
806        assert_eq!(perpetual.size_precision(), 0);
807        assert_eq!(perpetual.price_increment(), Price::from("0.5"));
808        assert_eq!(perpetual.size_increment(), Quantity::from("10"));
809        assert_eq!(perpetual.multiplier(), Quantity::from("10"));
810        assert_eq!(perpetual.lot_size(), Some(Quantity::from("10")));
811        assert_eq!(perpetual.maker_fee(), dec!(0));
812        assert_eq!(perpetual.taker_fee(), dec!(0.0005));
813        assert_eq!(perpetual.max_quantity(), None);
814        assert_eq!(perpetual.min_quantity(), Some(Quantity::from("10")));
815    }
816
817    #[rstest]
818    fn test_parse_future_instrument() {
819        let json_data = load_test_json("http_get_instruments.json");
820        let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
821            serde_json::from_str(&json_data).unwrap();
822        let instruments = response.result.expect("Test data must have result");
823        let deribit_inst = instruments
824            .iter()
825            .find(|i| i.instrument_name.as_str() == "BTC-27DEC24")
826            .expect("Test data must contain BTC-27DEC24");
827
828        let instrument_any =
829            parse_deribit_instrument_any(deribit_inst, UnixNanos::default(), UnixNanos::default())
830                .unwrap();
831        let instrument = instrument_any.expect("Should parse future instrument");
832
833        let InstrumentAny::CryptoFuture(future) = instrument else {
834            panic!("Expected CryptoFuture, was {instrument:?}");
835        };
836        assert_eq!(future.id(), InstrumentId::from("BTC-27DEC24.DERIBIT"));
837        assert_eq!(future.raw_symbol(), Symbol::from("BTC-27DEC24"));
838        assert_eq!(future.underlying().unwrap(), "BTC");
839        assert_eq!(future.quote_currency().code, "USD");
840        assert_eq!(future.settlement_currency().code, "BTC");
841        assert!(future.is_inverse());
842
843        // Verify timestamps
844        assert_eq!(
845            future.activation_ns(),
846            Some(UnixNanos::from(1719561600000_u64 * 1_000_000))
847        );
848        assert_eq!(
849            future.expiration_ns(),
850            Some(UnixNanos::from(1735300800000_u64 * 1_000_000))
851        );
852        assert_eq!(future.price_precision(), 1);
853        assert_eq!(future.size_precision(), 0);
854        assert_eq!(future.price_increment(), Price::from("0.5"));
855        assert_eq!(future.size_increment(), Quantity::from("10"));
856        assert_eq!(future.multiplier(), Quantity::from("10"));
857        assert_eq!(future.lot_size(), Some(Quantity::from("10")));
858        assert_eq!(future.maker_fee, dec!(0));
859        assert_eq!(future.taker_fee, dec!(0.0005));
860    }
861
862    #[rstest]
863    fn test_parse_option_instrument() {
864        let json_data = load_test_json("http_get_instruments.json");
865        let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
866            serde_json::from_str(&json_data).unwrap();
867        let instruments = response.result.expect("Test data must have result");
868        let deribit_inst = instruments
869            .iter()
870            .find(|i| i.instrument_name.as_str() == "BTC-27DEC24-100000-C")
871            .expect("Test data must contain BTC-27DEC24-100000-C");
872
873        let instrument_any =
874            parse_deribit_instrument_any(deribit_inst, UnixNanos::default(), UnixNanos::default())
875                .unwrap();
876        let instrument = instrument_any.expect("Should parse option instrument");
877
878        // Verify it's a CryptoOption
879        let InstrumentAny::CryptoOption(option) = instrument else {
880            panic!("Expected CryptoOption, was {instrument:?}");
881        };
882
883        assert_eq!(
884            option.id(),
885            InstrumentId::from("BTC-27DEC24-100000-C.DERIBIT")
886        );
887        assert_eq!(option.raw_symbol(), Symbol::from("BTC-27DEC24-100000-C"));
888        assert_eq!(option.underlying.code.as_str(), "BTC");
889        assert_eq!(option.quote_currency.code.as_str(), "BTC");
890        assert_eq!(option.settlement_currency.code.as_str(), "BTC");
891        assert!(option.is_inverse);
892        assert_eq!(option.option_kind, OptionKind::Call);
893        assert_eq!(option.strike_price, Price::from("100000"));
894        assert_eq!(
895            option.activation_ns,
896            UnixNanos::from(1719561600000_u64 * 1_000_000)
897        );
898        assert_eq!(
899            option.expiration_ns,
900            UnixNanos::from(1735300800000_u64 * 1_000_000)
901        );
902        assert_eq!(option.price_precision, 4);
903        assert_eq!(option.price_increment, Price::from("0.0005"));
904        assert_eq!(option.size_precision, 1);
905        assert_eq!(option.size_increment, Quantity::from("0.1"));
906        assert_eq!(option.multiplier, Quantity::from("1"));
907        assert_eq!(option.lot_size, Quantity::from("0.1"));
908        assert_eq!(option.maker_fee, dec!(0.0003));
909        assert_eq!(option.taker_fee, dec!(0.0003));
910    }
911
912    #[rstest]
913    fn test_parse_account_state_with_positions() {
914        let json_data = load_test_json("http_get_account_summaries.json");
915        let response: DeribitJsonRpcResponse<DeribitAccountSummariesResponse> =
916            serde_json::from_str(&json_data).unwrap();
917        let result = response.result.expect("Test data must have result");
918
919        let account_id = AccountId::from("DERIBIT-001");
920
921        // Extract server timestamp from response
922        let ts_event =
923            extract_server_timestamp(response.us_out).expect("Test data must have us_out");
924        let ts_init = UnixNanos::default();
925
926        let account_state = parse_account_state(&result.summaries, account_id, ts_init, ts_event)
927            .expect("Should parse account state");
928
929        // Verify we got 2 currencies (BTC and ETH)
930        assert_eq!(account_state.balances.len(), 2);
931
932        // Test BTC balance (has open positions with unrealized PnL)
933        let btc_balance = account_state
934            .balances
935            .iter()
936            .find(|b| b.currency.code == "BTC")
937            .expect("BTC balance should exist");
938
939        // From test data:
940        // margin_balance: 302.62729214, available_funds: 301.38059622
941        // initial_margin: 1.24669592
942        //
943        // Using margin_balance:
944        // total = margin_balance = 302.62729214
945        // free = available_funds = 301.38059622
946        // locked = total - free = 302.62729214 - 301.38059622 = 1.24669592 (exactly initial_margin!)
947        assert_eq!(btc_balance.total.as_f64(), 302.62729214);
948        assert_eq!(btc_balance.free.as_f64(), 301.38059622);
949
950        // Verify locked equals initial_margin exactly
951        let locked = btc_balance.locked.as_f64();
952        assert!(
953            locked > 0.0,
954            "Locked should be positive when positions exist"
955        );
956        assert!(
957            (locked - 1.24669592).abs() < 0.0001,
958            "Locked ({locked}) should equal initial_margin (1.24669592)"
959        );
960
961        // Test ETH balance (no positions)
962        let eth_balance = account_state
963            .balances
964            .iter()
965            .find(|b| b.currency.code == "ETH")
966            .expect("ETH balance should exist");
967
968        // From test data: margin_balance: 100, available_funds: 99.999598, initial_margin: 0.000402
969        // total = margin_balance = 100
970        // free = available_funds = 99.999598
971        // locked = 100 - 99.999598 = 0.000402 (equals initial_margin)
972        assert_eq!(eth_balance.total.as_f64(), 100.0);
973        assert_eq!(eth_balance.free.as_f64(), 99.999598);
974        assert_eq!(eth_balance.locked.as_f64(), 0.000402);
975
976        // Verify account metadata
977        assert_eq!(account_state.account_id, account_id);
978        assert_eq!(account_state.account_type, AccountType::Margin);
979        assert!(account_state.is_reported);
980
981        // Verify ts_event matches server timestamp (us_out = 1687352432005000 microseconds)
982        let expected_ts_event = UnixNanos::from(1687352432005000_u64 * NANOSECONDS_IN_MICROSECOND);
983        assert_eq!(
984            account_state.ts_event, expected_ts_event,
985            "ts_event should match server timestamp from response"
986        );
987    }
988
989    #[rstest]
990    fn test_parse_trade_tick_sell() {
991        let json_data = load_test_json("http_get_last_trades.json");
992        let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
993            serde_json::from_str(&json_data).unwrap();
994        let result = response.result.expect("Test data must have result");
995
996        assert!(result.has_more, "has_more should be true");
997        assert_eq!(result.trades.len(), 10, "Should have 10 trades");
998
999        let raw_trade = &result.trades[0];
1000        let instrument_id = InstrumentId::from("ETH-PERPETUAL.DERIBIT");
1001        let ts_init = UnixNanos::from(1766335632425576_u64 * 1000); // from usOut
1002
1003        let trade = parse_trade_tick(raw_trade, instrument_id, 1, 0, ts_init)
1004            .expect("Should parse trade tick");
1005
1006        assert_eq!(trade.instrument_id, instrument_id);
1007        assert_eq!(trade.price, Price::from("2968.3"));
1008        assert_eq!(trade.size, Quantity::from("1"));
1009        assert_eq!(trade.aggressor_side, AggressorSide::Seller);
1010        assert_eq!(trade.trade_id, TradeId::new("ETH-284830839"));
1011        // timestamp 1766332040636 ms -> ns
1012        assert_eq!(
1013            trade.ts_event,
1014            UnixNanos::from(1766332040636_u64 * 1_000_000)
1015        );
1016        assert_eq!(trade.ts_init, ts_init);
1017    }
1018
1019    #[rstest]
1020    fn test_parse_trade_tick_buy() {
1021        let json_data = load_test_json("http_get_last_trades.json");
1022        let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1023            serde_json::from_str(&json_data).unwrap();
1024        let result = response.result.expect("Test data must have result");
1025
1026        // Last trade is a buy with amount 106
1027        let raw_trade = &result.trades[9];
1028        let instrument_id = InstrumentId::from("ETH-PERPETUAL.DERIBIT");
1029        let ts_init = UnixNanos::default();
1030
1031        let trade = parse_trade_tick(raw_trade, instrument_id, 1, 0, ts_init)
1032            .expect("Should parse trade tick");
1033
1034        assert_eq!(trade.instrument_id, instrument_id);
1035        assert_eq!(trade.price, Price::from("2968.3"));
1036        assert_eq!(trade.size, Quantity::from("106"));
1037        assert_eq!(trade.aggressor_side, AggressorSide::Buyer);
1038        assert_eq!(trade.trade_id, TradeId::new("ETH-284830854"));
1039    }
1040
1041    #[rstest]
1042    fn test_parse_bars() {
1043        let json_data = load_test_json("http_get_tradingview_chart_data.json");
1044        let response: DeribitJsonRpcResponse<DeribitTradingViewChartData> =
1045            serde_json::from_str(&json_data).unwrap();
1046        let chart_data = response.result.expect("Test data must have result");
1047
1048        let bar_type = BarType::from("BTC-PERPETUAL.DERIBIT-1-MINUTE-LAST-EXTERNAL");
1049        let ts_init = UnixNanos::from(1766487086146245_u64 * NANOSECONDS_IN_MICROSECOND);
1050
1051        let bars = parse_bars(&chart_data, bar_type, 1, 8, ts_init).expect("Should parse bars");
1052
1053        assert_eq!(bars.len(), 5, "Should parse 5 bars");
1054
1055        // Verify first bar
1056        let first_bar = &bars[0];
1057        assert_eq!(first_bar.bar_type, bar_type);
1058        assert_eq!(first_bar.open, Price::from("87451.0"));
1059        assert_eq!(first_bar.high, Price::from("87456.5"));
1060        assert_eq!(first_bar.low, Price::from("87451.0"));
1061        assert_eq!(first_bar.close, Price::from("87456.5"));
1062        assert_eq!(first_bar.volume, Quantity::from("2.94375216"));
1063        assert_eq!(
1064            first_bar.ts_event,
1065            UnixNanos::from(1766483460000_u64 * NANOSECONDS_IN_MILLISECOND)
1066        );
1067        assert_eq!(first_bar.ts_init, ts_init);
1068
1069        // Verify last bar
1070        let last_bar = &bars[4];
1071        assert_eq!(last_bar.open, Price::from("87456.0"));
1072        assert_eq!(last_bar.high, Price::from("87456.5"));
1073        assert_eq!(last_bar.low, Price::from("87456.0"));
1074        assert_eq!(last_bar.close, Price::from("87456.0"));
1075        assert_eq!(last_bar.volume, Quantity::from("0.1018798"));
1076        assert_eq!(
1077            last_bar.ts_event,
1078            UnixNanos::from(1766483700000_u64 * NANOSECONDS_IN_MILLISECOND)
1079        );
1080    }
1081
1082    #[rstest]
1083    fn test_parse_order_book() {
1084        let json_data = load_test_json("http_get_order_book.json");
1085        let response: DeribitJsonRpcResponse<DeribitOrderBook> =
1086            serde_json::from_str(&json_data).unwrap();
1087        let order_book_data = response.result.expect("Test data must have result");
1088
1089        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1090        let ts_init = UnixNanos::from(1766554855146274_u64 * NANOSECONDS_IN_MICROSECOND);
1091
1092        let book = parse_order_book(&order_book_data, instrument_id, 1, 0, ts_init)
1093            .expect("Should parse order book");
1094
1095        // Verify book metadata
1096        assert_eq!(book.instrument_id, instrument_id);
1097        assert_eq!(book.book_type, BookType::L2_MBP);
1098        assert_eq!(book.ts_last, ts_init);
1099
1100        // Verify book has both sides
1101        assert!(book.has_bid(), "Book should have bids");
1102        assert!(book.has_ask(), "Book should have asks");
1103
1104        // Verify best bid using OrderBook methods
1105        assert_eq!(
1106            book.best_bid_price(),
1107            Some(Price::from("87002.5")),
1108            "Best bid price should match"
1109        );
1110        assert_eq!(
1111            book.best_bid_size(),
1112            Some(Quantity::from("199190")),
1113            "Best bid size should match"
1114        );
1115
1116        // Verify best ask using OrderBook methods
1117        assert_eq!(
1118            book.best_ask_price(),
1119            Some(Price::from("87003.0")),
1120            "Best ask price should match"
1121        );
1122        assert_eq!(
1123            book.best_ask_size(),
1124            Some(Quantity::from("125090")),
1125            "Best ask size should match"
1126        );
1127
1128        // Verify spread (best_ask - best_bid = 87003.0 - 87002.5 = 0.5)
1129        let spread = book.spread().expect("Spread should exist");
1130        assert!(
1131            (spread - 0.5).abs() < 0.0001,
1132            "Spread should be 0.5, was {spread}"
1133        );
1134
1135        // Verify midpoint ((87003.0 + 87002.5) / 2 = 87002.75)
1136        let midpoint = book.midpoint().expect("Midpoint should exist");
1137        assert!(
1138            (midpoint - 87002.75).abs() < 0.0001,
1139            "Midpoint should be 87002.75, was {midpoint}"
1140        );
1141
1142        // Verify level counts match input data
1143        let bid_count = book.bids(None).count();
1144        let ask_count = book.asks(None).count();
1145        assert_eq!(
1146            bid_count,
1147            order_book_data.bids.len(),
1148            "Bid levels count should match input data"
1149        );
1150        assert_eq!(
1151            ask_count,
1152            order_book_data.asks.len(),
1153            "Ask levels count should match input data"
1154        );
1155        assert_eq!(bid_count, 20, "Should have 20 bid levels");
1156        assert_eq!(ask_count, 20, "Should have 20 ask levels");
1157
1158        // Verify depth limiting works (get top 5 levels)
1159        assert_eq!(
1160            book.bids(Some(5)).count(),
1161            5,
1162            "Should limit to 5 bid levels"
1163        );
1164        assert_eq!(
1165            book.asks(Some(5)).count(),
1166            5,
1167            "Should limit to 5 ask levels"
1168        );
1169
1170        // Verify bids_as_map and asks_as_map
1171        let bids_map = book.bids_as_map(None);
1172        let asks_map = book.asks_as_map(None);
1173        assert_eq!(bids_map.len(), 20, "Bids map should have 20 entries");
1174        assert_eq!(asks_map.len(), 20, "Asks map should have 20 entries");
1175
1176        // Verify specific prices exist in maps
1177        assert!(
1178            bids_map.contains_key(&dec!(87002.5)),
1179            "Bids map should contain best bid price"
1180        );
1181        assert!(
1182            asks_map.contains_key(&dec!(87003.0)),
1183            "Asks map should contain best ask price"
1184        );
1185
1186        // Verify worst levels exist
1187        assert!(
1188            bids_map.contains_key(&dec!(86980.0)),
1189            "Bids map should contain worst bid price"
1190        );
1191        assert!(
1192            asks_map.contains_key(&dec!(87031.5)),
1193            "Asks map should contain worst ask price"
1194        );
1195    }
1196
1197    fn make_instrument_id(symbol: &str) -> InstrumentId {
1198        InstrumentId::new(Symbol::from(symbol), Venue::from("DERIBIT"))
1199    }
1200
1201    #[rstest]
1202    fn test_parse_futures_and_perpetuals() {
1203        // Perpetuals are classified as "future" in Deribit API
1204        let cases = [
1205            ("BTC-PERPETUAL", "future", "BTC"),
1206            ("ETH-PERPETUAL", "future", "ETH"),
1207            ("SOL-PERPETUAL", "future", "SOL"),
1208            // Futures with expiry dates
1209            ("BTC-25MAR23", "future", "BTC"),
1210            ("BTC-5AUG23", "future", "BTC"), // Single digit day
1211            ("ETH-28MAR25", "future", "ETH"),
1212        ];
1213
1214        for (symbol, expected_kind, expected_currency) in cases {
1215            let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1216            assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1217            assert_eq!(
1218                currency, expected_currency,
1219                "currency mismatch for {symbol}"
1220            );
1221        }
1222    }
1223
1224    #[rstest]
1225    fn test_parse_options() {
1226        let cases = [
1227            // Standard options: {CURRENCY}-{DMMMYY}-{STRIKE}-{C|P}
1228            ("BTC-25MAR23-420-C", "option", "BTC"),
1229            ("BTC-5AUG23-580-P", "option", "BTC"),
1230            ("ETH-28MAR25-4000-C", "option", "ETH"),
1231            // Linear option with decimal strike (d = decimal point)
1232            ("XRP_USDC-30JUN23-0d625-C", "option", "XRP"),
1233        ];
1234
1235        for (symbol, expected_kind, expected_currency) in cases {
1236            let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1237            assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1238            assert_eq!(
1239                currency, expected_currency,
1240                "currency mismatch for {symbol}"
1241            );
1242        }
1243    }
1244
1245    #[rstest]
1246    fn test_parse_spot() {
1247        let cases = [
1248            ("BTC_USDC", "spot", "BTC"),
1249            ("ETH_USDT", "spot", "ETH"),
1250            ("SOL_USDC", "spot", "SOL"),
1251        ];
1252
1253        for (symbol, expected_kind, expected_currency) in cases {
1254            let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1255            assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1256            assert_eq!(
1257                currency, expected_currency,
1258                "currency mismatch for {symbol}"
1259            );
1260        }
1261    }
1262
1263    #[rstest]
1264    fn test_parse_portfolio_to_account_state() {
1265        let json_data = load_test_json("ws_portfolio.json");
1266        let notification: serde_json::Value = serde_json::from_str(&json_data).unwrap();
1267
1268        // Extract the data field from the notification
1269        let data = notification
1270            .get("params")
1271            .and_then(|p| p.get("data"))
1272            .expect("Test data must have params.data");
1273
1274        let portfolio: DeribitPortfolioMsg =
1275            serde_json::from_value(data.clone()).expect("Should deserialize portfolio message");
1276
1277        // Verify deserialization
1278        assert_eq!(portfolio.currency, "USDT");
1279        assert_eq!(portfolio.equity, dec!(55.00055));
1280        assert_eq!(portfolio.balance, dec!(55.00055));
1281        assert_eq!(portfolio.available_funds, dec!(53.868247));
1282        assert_eq!(portfolio.margin_balance, dec!(54.968258));
1283        assert_eq!(portfolio.initial_margin, dec!(1.100011));
1284        assert_eq!(portfolio.maintenance_margin, dec!(0.0));
1285
1286        // Test parsing to AccountState
1287        let account_id = AccountId::new("DERIBIT-master");
1288        let ts_init = UnixNanos::from(1700000000000000000_u64);
1289
1290        let account_state =
1291            parse_portfolio_to_account_state(&portfolio, account_id, ts_init).unwrap();
1292
1293        // Verify account state
1294        assert_eq!(account_state.account_id, account_id);
1295        assert_eq!(account_state.account_type, AccountType::Margin);
1296        assert!(account_state.is_reported);
1297
1298        // Verify balances (should have 1 balance for USDT)
1299        assert_eq!(account_state.balances.len(), 1);
1300        let balance = &account_state.balances[0];
1301        assert_eq!(balance.currency.code, "USDT");
1302        assert_eq!(balance.total.as_f64(), 54.968258); // margin_balance
1303        assert_eq!(balance.free.as_f64(), 53.868247); // available_funds
1304
1305        // locked = total - free = 54.968258 - 53.868247 = 1.100011 (equals initial_margin)
1306        let locked = balance.locked.as_f64();
1307        assert!(
1308            (locked - 1.100011).abs() < 0.0001,
1309            "Locked ({locked}) should be close to 1.100011 (initial_margin)"
1310        );
1311
1312        // Verify margins (should have 1 margin since initial_margin > 0)
1313        assert_eq!(account_state.margins.len(), 1);
1314        let margin = &account_state.margins[0];
1315        assert_eq!(margin.initial.as_f64(), 1.100011);
1316        assert_eq!(margin.maintenance.as_f64(), 0.0);
1317        assert!(margin.instrument_id.is_none());
1318        assert_eq!(margin.currency.code.as_str(), "USDT");
1319    }
1320
1321    #[rstest]
1322    #[case::minute_1(1, "MINUTE", "1")]
1323    #[case::minute_2(2, "MINUTE", "3")]
1324    #[case::minute_3(3, "MINUTE", "3")]
1325    #[case::minute_4(4, "MINUTE", "5")]
1326    #[case::minute_5(5, "MINUTE", "5")]
1327    #[case::minute_6(6, "MINUTE", "10")]
1328    #[case::minute_10(10, "MINUTE", "10")]
1329    #[case::minute_11(11, "MINUTE", "15")]
1330    #[case::minute_15(15, "MINUTE", "15")]
1331    #[case::minute_16(16, "MINUTE", "30")]
1332    #[case::minute_30(30, "MINUTE", "30")]
1333    #[case::minute_31(31, "MINUTE", "60")]
1334    #[case::minute_60(60, "MINUTE", "60")]
1335    #[case::minute_61(61, "MINUTE", "120")]
1336    #[case::minute_120(120, "MINUTE", "120")]
1337    #[case::minute_121(121, "MINUTE", "180")]
1338    #[case::minute_180(180, "MINUTE", "180")]
1339    #[case::minute_181(181, "MINUTE", "360")]
1340    #[case::minute_360(360, "MINUTE", "360")]
1341    #[case::minute_361(361, "MINUTE", "720")]
1342    #[case::minute_720(720, "MINUTE", "720")]
1343    #[case::minute_721(721, "MINUTE", "1D")]
1344    #[case::hour_1(1, "HOUR", "60")]
1345    #[case::hour_2(2, "HOUR", "120")]
1346    #[case::hour_3(3, "HOUR", "180")]
1347    #[case::hour_4(4, "HOUR", "360")]
1348    #[case::hour_6(6, "HOUR", "360")]
1349    #[case::hour_7(7, "HOUR", "720")]
1350    #[case::hour_12(12, "HOUR", "720")]
1351    #[case::hour_13(13, "HOUR", "1D")]
1352    #[case::day_1(1, "DAY", "1D")]
1353    fn test_bar_spec_to_resolution(
1354        #[case] step: u64,
1355        #[case] aggregation: &str,
1356        #[case] expected: &str,
1357    ) {
1358        let bar_type_str = format!("BTC-PERPETUAL.DERIBIT-{step}-{aggregation}-LAST-EXTERNAL");
1359        let bar_type = BarType::from(bar_type_str.as_str());
1360        let resolution = bar_spec_to_resolution(&bar_type);
1361        assert_eq!(resolution, expected);
1362    }
1363}