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::{
29        AccountType, AggressorSide, AssetClass, BookType, CurrencyType, OptionKind, OrderSide,
30    },
31    events::AccountState,
32    identifiers::{AccountId, InstrumentId, Symbol, TradeId, Venue},
33    instruments::{
34        CryptoFuture, CryptoPerpetual, CurrencyPair, OptionContract, any::InstrumentAny,
35    },
36    orderbook::OrderBook,
37    types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
38};
39use rust_decimal::Decimal;
40
41use crate::{
42    common::consts::DERIBIT_VENUE,
43    http::models::{
44        DeribitAccountSummary, DeribitInstrument, DeribitInstrumentKind, DeribitOptionType,
45        DeribitOrderBook, DeribitPublicTrade, DeribitTradingViewChartData,
46    },
47};
48
49/// Extracts server timestamp from response and converts to UnixNanos.
50///
51/// # Errors
52///
53/// Returns an error if the server timestamp (us_out) is missing from the response.
54pub fn extract_server_timestamp(us_out: Option<u64>) -> anyhow::Result<UnixNanos> {
55    let us_out =
56        us_out.ok_or_else(|| anyhow::anyhow!("Missing server timestamp (us_out) in response"))?;
57    Ok(UnixNanos::from(us_out * NANOSECONDS_IN_MICROSECOND))
58}
59
60/// Parses a Deribit instrument into a Nautilus [`InstrumentAny`].
61///
62/// Returns `Ok(None)` for unsupported instrument types (e.g., combos).
63///
64/// # Errors
65///
66/// Returns an error if:
67/// - Required fields are missing (e.g., strike price for options)
68/// - Timestamp conversion fails
69/// - Decimal conversion fails for fees
70pub fn parse_deribit_instrument_any(
71    instrument: &DeribitInstrument,
72    ts_init: UnixNanos,
73    ts_event: UnixNanos,
74) -> anyhow::Result<Option<InstrumentAny>> {
75    match instrument.kind {
76        DeribitInstrumentKind::Spot => {
77            parse_spot_instrument(instrument, ts_init, ts_event).map(Some)
78        }
79        DeribitInstrumentKind::Future => {
80            // Check if it's a perpetual
81            if instrument.instrument_name.as_str().contains("PERPETUAL") {
82                parse_perpetual_instrument(instrument, ts_init, ts_event).map(Some)
83            } else {
84                parse_future_instrument(instrument, ts_init, ts_event).map(Some)
85            }
86        }
87        DeribitInstrumentKind::Option => {
88            parse_option_instrument(instrument, ts_init, ts_event).map(Some)
89        }
90        DeribitInstrumentKind::FutureCombo | DeribitInstrumentKind::OptionCombo => {
91            // Skip combos for initial implementation
92            Ok(None)
93        }
94    }
95}
96
97/// Parses a spot instrument into a [`CurrencyPair`].
98fn parse_spot_instrument(
99    instrument: &DeribitInstrument,
100    ts_init: UnixNanos,
101    ts_event: UnixNanos,
102) -> anyhow::Result<InstrumentAny> {
103    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
104
105    let base_currency = Currency::new(
106        instrument.base_currency,
107        8,
108        0,
109        instrument.base_currency,
110        CurrencyType::Crypto,
111    );
112    let quote_currency = Currency::new(
113        instrument.quote_currency,
114        8,
115        0,
116        instrument.quote_currency,
117        CurrencyType::Crypto,
118    );
119
120    let price_increment = Price::from(instrument.tick_size.to_string().as_str());
121    let size_increment = Quantity::from(instrument.min_trade_amount.to_string().as_str());
122    let min_quantity = Quantity::from(instrument.min_trade_amount.to_string().as_str());
123
124    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
125        .context("Failed to parse maker_commission")?;
126    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
127        .context("Failed to parse taker_commission")?;
128
129    let currency_pair = CurrencyPair::new(
130        instrument_id,
131        instrument.instrument_name.into(),
132        base_currency,
133        quote_currency,
134        price_increment.precision,
135        size_increment.precision,
136        price_increment,
137        size_increment,
138        None, // multiplier
139        None, // lot_size
140        None, // max_quantity
141        Some(min_quantity),
142        None, // max_notional
143        None, // min_notional
144        None, // max_price
145        None, // min_price
146        None, // margin_init
147        None, // margin_maint
148        Some(maker_fee),
149        Some(taker_fee),
150        ts_event,
151        ts_init,
152    );
153
154    Ok(InstrumentAny::CurrencyPair(currency_pair))
155}
156
157/// Parses a perpetual swap instrument into a [`CryptoPerpetual`].
158fn parse_perpetual_instrument(
159    instrument: &DeribitInstrument,
160    ts_init: UnixNanos,
161    ts_event: UnixNanos,
162) -> anyhow::Result<InstrumentAny> {
163    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
164
165    let base_currency = Currency::new(
166        instrument.base_currency,
167        8,
168        0,
169        instrument.base_currency,
170        CurrencyType::Crypto,
171    );
172    let quote_currency = Currency::new(
173        instrument.quote_currency,
174        8,
175        0,
176        instrument.quote_currency,
177        CurrencyType::Crypto,
178    );
179    let settlement_currency = instrument.settlement_currency.map_or(base_currency, |c| {
180        Currency::new(c, 8, 0, c, CurrencyType::Crypto)
181    });
182
183    let is_inverse = instrument
184        .instrument_type
185        .as_ref()
186        .is_some_and(|t| t == "reversed");
187
188    let price_increment = Price::from(instrument.tick_size.to_string().as_str());
189    let size_increment = Quantity::from(instrument.min_trade_amount.to_string().as_str());
190    let min_quantity = Quantity::from(instrument.min_trade_amount.to_string().as_str());
191
192    // Contract size represents the multiplier (e.g., 10 USD per contract for BTC-PERPETUAL)
193    let multiplier = Some(Quantity::from(
194        instrument.contract_size.to_string().as_str(),
195    ));
196    let lot_size = Some(size_increment);
197
198    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
199        .context("Failed to parse maker_commission")?;
200    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
201        .context("Failed to parse taker_commission")?;
202
203    let perpetual = CryptoPerpetual::new(
204        instrument_id,
205        instrument.instrument_name.into(),
206        base_currency,
207        quote_currency,
208        settlement_currency,
209        is_inverse,
210        price_increment.precision,
211        size_increment.precision,
212        price_increment,
213        size_increment,
214        multiplier,
215        lot_size,
216        None, // max_quantity - Deribit doesn't specify a hard max
217        Some(min_quantity),
218        None, // max_notional
219        None, // min_notional
220        None, // max_price
221        None, // min_price
222        None, // margin_init
223        None, // margin_maint
224        Some(maker_fee),
225        Some(taker_fee),
226        ts_event,
227        ts_init,
228    );
229
230    Ok(InstrumentAny::CryptoPerpetual(perpetual))
231}
232
233/// Parses a futures instrument into a [`CryptoFuture`].
234fn parse_future_instrument(
235    instrument: &DeribitInstrument,
236    ts_init: UnixNanos,
237    ts_event: UnixNanos,
238) -> anyhow::Result<InstrumentAny> {
239    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
240
241    let underlying = Currency::new(
242        instrument.base_currency,
243        8,
244        0,
245        instrument.base_currency,
246        CurrencyType::Crypto,
247    );
248    let quote_currency = Currency::new(
249        instrument.quote_currency,
250        8,
251        0,
252        instrument.quote_currency,
253        CurrencyType::Crypto,
254    );
255    let settlement_currency = instrument.settlement_currency.map_or(underlying, |c| {
256        Currency::new(c, 8, 0, c, CurrencyType::Crypto)
257    });
258
259    let is_inverse = instrument
260        .instrument_type
261        .as_ref()
262        .is_some_and(|t| t == "reversed");
263
264    // Convert timestamps from milliseconds to nanoseconds
265    let activation_ns = (instrument.creation_timestamp as u64) * 1_000_000;
266    let expiration_ns = instrument
267        .expiration_timestamp
268        .context("Missing expiration_timestamp for future")? as u64
269        * 1_000_000; // milliseconds to nanoseconds
270
271    let price_increment = Price::from(instrument.tick_size.to_string().as_str());
272    let size_increment = Quantity::from(instrument.min_trade_amount.to_string().as_str());
273    let min_quantity = Quantity::from(instrument.min_trade_amount.to_string().as_str());
274
275    // Contract size represents the multiplier
276    let multiplier = Some(Quantity::from(
277        instrument.contract_size.to_string().as_str(),
278    ));
279    let lot_size = Some(size_increment); // Use min_trade_amount as lot size
280
281    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
282        .context("Failed to parse maker_commission")?;
283    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
284        .context("Failed to parse taker_commission")?;
285
286    let future = CryptoFuture::new(
287        instrument_id,
288        instrument.instrument_name.into(),
289        underlying,
290        quote_currency,
291        settlement_currency,
292        is_inverse,
293        UnixNanos::from(activation_ns),
294        UnixNanos::from(expiration_ns),
295        price_increment.precision,
296        size_increment.precision,
297        price_increment,
298        size_increment,
299        multiplier,
300        lot_size,
301        None, // max_quantity - Deribit doesn't specify a hard max
302        Some(min_quantity),
303        None, // max_notional
304        None, // min_notional
305        None, // max_price
306        None, // min_price
307        None, // margin_init
308        None, // margin_maint
309        Some(maker_fee),
310        Some(taker_fee),
311        ts_event,
312        ts_init,
313    );
314
315    Ok(InstrumentAny::CryptoFuture(future))
316}
317
318/// Parses an options instrument into an [`OptionContract`].
319fn parse_option_instrument(
320    instrument: &DeribitInstrument,
321    ts_init: UnixNanos,
322    ts_event: UnixNanos,
323) -> anyhow::Result<InstrumentAny> {
324    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
325
326    // Underlying is the base currency symbol (e.g., "BTC")
327    let underlying = instrument.base_currency;
328
329    // Settlement currency for Deribit options
330    let settlement = instrument
331        .settlement_currency
332        .unwrap_or(instrument.base_currency);
333    let currency = Currency::new(settlement, 8, 0, settlement, CurrencyType::Crypto);
334
335    // Determine option kind
336    let option_kind = match instrument.option_type {
337        Some(DeribitOptionType::Call) => OptionKind::Call,
338        Some(DeribitOptionType::Put) => OptionKind::Put,
339        None => anyhow::bail!("Missing option_type for option instrument"),
340    };
341
342    // Parse strike price
343    let strike = instrument.strike.context("Missing strike for option")?;
344    let strike_price = Price::from(strike.to_string().as_str());
345
346    // Convert timestamps from milliseconds to nanoseconds
347    let activation_ns = (instrument.creation_timestamp as u64) * 1_000_000;
348    let expiration_ns = instrument
349        .expiration_timestamp
350        .context("Missing expiration_timestamp for option")? as u64
351        * 1_000_000;
352
353    let price_increment = Price::from(instrument.tick_size.to_string().as_str());
354
355    // Contract size is the multiplier (e.g., 1.0 for BTC options)
356    let multiplier = Quantity::from(instrument.contract_size.to_string().as_str());
357    let lot_size = Quantity::from(instrument.min_trade_amount.to_string().as_str());
358    let min_quantity = Quantity::from(instrument.min_trade_amount.to_string().as_str());
359
360    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
361        .context("Failed to parse maker_commission")?;
362    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
363        .context("Failed to parse taker_commission")?;
364
365    let option = OptionContract::new(
366        instrument_id,
367        instrument.instrument_name.into(),
368        AssetClass::Cryptocurrency,
369        None, // exchange - Deribit doesn't provide separate exchange field
370        underlying,
371        option_kind,
372        strike_price,
373        currency,
374        UnixNanos::from(activation_ns),
375        UnixNanos::from(expiration_ns),
376        price_increment.precision,
377        price_increment,
378        multiplier,
379        lot_size,
380        None, // max_quantity
381        Some(min_quantity),
382        None, // max_price
383        None, // min_price
384        None, // margin_init
385        None, // margin_maint
386        Some(maker_fee),
387        Some(taker_fee),
388        ts_event,
389        ts_init,
390    );
391
392    Ok(InstrumentAny::OptionContract(option))
393}
394
395/// Parses Deribit account summaries into a Nautilus [`AccountState`].
396///
397/// Processes multiple currency summaries and creates balance entries for each currency.
398///
399/// # Errors
400///
401/// Returns an error if:
402/// - Money conversion fails for any balance field
403/// - Decimal conversion fails for margin values
404pub fn parse_account_state(
405    summaries: &[DeribitAccountSummary],
406    account_id: AccountId,
407    ts_init: UnixNanos,
408    ts_event: UnixNanos,
409) -> anyhow::Result<AccountState> {
410    let mut balances = Vec::new();
411    let mut margins = Vec::new();
412
413    // Parse each currency summary
414    for summary in summaries {
415        let ccy_str = summary.currency.as_str().trim();
416
417        // Skip balances with empty currency codes
418        if ccy_str.is_empty() {
419            tracing::debug!(
420                "Skipping balance detail with empty currency code | raw_data={:?}",
421                summary
422            );
423            continue;
424        }
425
426        let currency = Currency::get_or_create_crypto_with_context(
427            ccy_str,
428            Some("DERIBIT - Parsing account state"),
429        );
430
431        // Parse balance: total (equity includes unrealized PnL), locked, free
432        // Note: Deribit's available_funds = equity - initial_margin, so we must use equity for total
433        let total = Money::new(summary.equity, currency);
434        let free = Money::new(summary.available_funds, currency);
435        let locked = Money::from_raw(total.raw - free.raw, currency);
436
437        let balance = AccountBalance::new(total, locked, free);
438        balances.push(balance);
439
440        // Parse margin balances if present
441        if let (Some(initial_margin), Some(maintenance_margin)) =
442            (summary.initial_margin, summary.maintenance_margin)
443        {
444            // Only create margin balance if there are actual margin requirements
445            if initial_margin > 0.0 || maintenance_margin > 0.0 {
446                let initial = Money::new(initial_margin, currency);
447                let maintenance = Money::new(maintenance_margin, currency);
448
449                // Create a synthetic instrument_id for account-level margins
450                let margin_instrument_id = InstrumentId::new(
451                    Symbol::from_str_unchecked(format!("ACCOUNT-{}", summary.currency)),
452                    Venue::new("DERIBIT"),
453                );
454
455                margins.push(MarginBalance::new(
456                    initial,
457                    maintenance,
458                    margin_instrument_id,
459                ));
460            }
461        }
462    }
463
464    // Ensure at least one balance exists (Nautilus requires non-empty balances)
465    if balances.is_empty() {
466        let zero_currency = Currency::USD();
467        let zero_money = Money::new(0.0, zero_currency);
468        let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
469        balances.push(zero_balance);
470    }
471
472    let account_type = AccountType::Margin;
473    let is_reported = true;
474
475    Ok(AccountState::new(
476        account_id,
477        account_type,
478        balances,
479        margins,
480        is_reported,
481        UUID4::new(),
482        ts_event,
483        ts_init,
484        None,
485    ))
486}
487
488// Parses a Deribit public trade into a Nautilus [`TradeTick`].
489///
490/// # Errors
491///
492/// Returns an error if:
493/// - The direction is not "buy" or "sell"
494/// - Decimal conversion fails for price or size
495pub fn parse_trade_tick(
496    trade: &DeribitPublicTrade,
497    instrument_id: InstrumentId,
498    price_precision: u8,
499    size_precision: u8,
500    ts_init: UnixNanos,
501) -> anyhow::Result<TradeTick> {
502    // Parse aggressor side from direction
503    let aggressor_side = match trade.direction.as_str() {
504        "buy" => AggressorSide::Buyer,
505        "sell" => AggressorSide::Seller,
506        other => anyhow::bail!("Invalid trade direction: {other}"),
507    };
508    let price = Price::new(trade.price, price_precision);
509    let size = Quantity::new(trade.amount, size_precision);
510    let ts_event = UnixNanos::from((trade.timestamp as u64) * NANOSECONDS_IN_MILLISECOND);
511    let trade_id = TradeId::new(&trade.trade_id);
512
513    Ok(TradeTick::new(
514        instrument_id,
515        price,
516        size,
517        aggressor_side,
518        trade_id,
519        ts_event,
520        ts_init,
521    ))
522}
523
524/// Parses Deribit TradingView chart data into Nautilus [`Bar`]s.
525///
526/// Converts OHLCV arrays from the `public/get_tradingview_chart_data` endpoint
527/// into a vector of [`Bar`] objects.
528///
529/// # Errors
530///
531/// Returns an error if:
532/// - The status is not "ok"
533/// - Array lengths are inconsistent
534/// - No data points are present
535pub fn parse_bars(
536    chart_data: &DeribitTradingViewChartData,
537    bar_type: BarType,
538    price_precision: u8,
539    size_precision: u8,
540    ts_init: UnixNanos,
541) -> anyhow::Result<Vec<Bar>> {
542    // Check status
543    if chart_data.status != "ok" {
544        anyhow::bail!(
545            "Chart data status is '{}', expected 'ok'",
546            chart_data.status
547        );
548    }
549
550    let num_bars = chart_data.ticks.len();
551
552    // Verify array lengths match
553    anyhow::ensure!(
554        chart_data.open.len() == num_bars
555            && chart_data.high.len() == num_bars
556            && chart_data.low.len() == num_bars
557            && chart_data.close.len() == num_bars
558            && chart_data.volume.len() == num_bars,
559        "Inconsistent array lengths in chart data"
560    );
561
562    if num_bars == 0 {
563        return Ok(Vec::new());
564    }
565
566    let mut bars = Vec::with_capacity(num_bars);
567
568    for i in 0..num_bars {
569        let open = Price::new_checked(chart_data.open[i], price_precision)
570            .with_context(|| format!("Invalid open price at index {i}"))?;
571        let high = Price::new_checked(chart_data.high[i], price_precision)
572            .with_context(|| format!("Invalid high price at index {i}"))?;
573        let low = Price::new_checked(chart_data.low[i], price_precision)
574            .with_context(|| format!("Invalid low price at index {i}"))?;
575        let close = Price::new_checked(chart_data.close[i], price_precision)
576            .with_context(|| format!("Invalid close price at index {i}"))?;
577        let volume = Quantity::new_checked(chart_data.volume[i], size_precision)
578            .with_context(|| format!("Invalid volume at index {i}"))?;
579
580        // Convert timestamp from milliseconds to nanoseconds
581        let ts_event = UnixNanos::from((chart_data.ticks[i] as u64) * NANOSECONDS_IN_MILLISECOND);
582
583        let bar = Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
584            .with_context(|| format!("Invalid OHLC bar at index {i}"))?;
585        bars.push(bar);
586    }
587
588    Ok(bars)
589}
590
591/// Parses Deribit order book data into a Nautilus [`OrderBook`].
592///
593/// Converts bids and asks from the `public/get_order_book` endpoint
594/// into an L2_MBP order book.
595///
596/// # Errors
597///
598/// Returns an error if order book creation fails.
599pub fn parse_order_book(
600    order_book_data: &DeribitOrderBook,
601    instrument_id: InstrumentId,
602    price_precision: u8,
603    size_precision: u8,
604    ts_init: UnixNanos,
605) -> anyhow::Result<OrderBook> {
606    let ts_event = UnixNanos::from((order_book_data.timestamp as u64) * NANOSECONDS_IN_MILLISECOND);
607    let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
608
609    for (idx, [price, amount]) in order_book_data.bids.iter().enumerate() {
610        let order = BookOrder::new(
611            OrderSide::Buy,
612            Price::new(*price, price_precision),
613            Quantity::new(*amount, size_precision),
614            idx as u64,
615        );
616        book.add(order, 0, idx as u64, ts_event);
617    }
618
619    let bids_len = order_book_data.bids.len();
620    for (idx, [price, amount]) in order_book_data.asks.iter().enumerate() {
621        let order = BookOrder::new(
622            OrderSide::Sell,
623            Price::new(*price, price_precision),
624            Quantity::new(*amount, size_precision),
625            (bids_len + idx) as u64,
626        );
627        book.add(order, 0, (bids_len + idx) as u64, ts_event);
628    }
629
630    book.ts_last = ts_init;
631
632    Ok(book)
633}
634
635#[cfg(test)]
636mod tests {
637    use nautilus_model::instruments::Instrument;
638    use rstest::rstest;
639    use rust_decimal_macros::dec;
640
641    use super::*;
642    use crate::{
643        common::testing::load_test_json,
644        http::models::{
645            DeribitAccountSummariesResponse, DeribitJsonRpcResponse, DeribitTradesResponse,
646        },
647    };
648
649    #[rstest]
650    fn test_parse_perpetual_instrument() {
651        let json_data = load_test_json("http_get_instrument.json");
652        let response: DeribitJsonRpcResponse<DeribitInstrument> =
653            serde_json::from_str(&json_data).unwrap();
654        let deribit_inst = response.result.expect("Test data must have result");
655
656        let instrument_any =
657            parse_deribit_instrument_any(&deribit_inst, UnixNanos::default(), UnixNanos::default())
658                .unwrap();
659        let instrument = instrument_any.expect("Should parse perpetual instrument");
660
661        let InstrumentAny::CryptoPerpetual(perpetual) = instrument else {
662            panic!("Expected CryptoPerpetual, got {instrument:?}");
663        };
664        assert_eq!(perpetual.id(), InstrumentId::from("BTC-PERPETUAL.DERIBIT"));
665        assert_eq!(perpetual.raw_symbol(), Symbol::from("BTC-PERPETUAL"));
666        assert_eq!(perpetual.base_currency().unwrap().code, "BTC");
667        assert_eq!(perpetual.quote_currency().code, "USD");
668        assert_eq!(perpetual.settlement_currency().code, "BTC");
669        assert!(perpetual.is_inverse());
670        assert_eq!(perpetual.price_precision(), 1);
671        assert_eq!(perpetual.size_precision(), 0);
672        assert_eq!(perpetual.price_increment(), Price::from("0.5"));
673        assert_eq!(perpetual.size_increment(), Quantity::from("10"));
674        assert_eq!(perpetual.multiplier(), Quantity::from("10"));
675        assert_eq!(perpetual.lot_size(), Some(Quantity::from("10")));
676        assert_eq!(perpetual.maker_fee(), dec!(0));
677        assert_eq!(perpetual.taker_fee(), dec!(0.0005));
678        assert_eq!(perpetual.max_quantity(), None);
679        assert_eq!(perpetual.min_quantity(), Some(Quantity::from("10")));
680    }
681
682    #[rstest]
683    fn test_parse_future_instrument() {
684        let json_data = load_test_json("http_get_instruments.json");
685        let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
686            serde_json::from_str(&json_data).unwrap();
687        let instruments = response.result.expect("Test data must have result");
688        let deribit_inst = instruments
689            .iter()
690            .find(|i| i.instrument_name.as_str() == "BTC-27DEC24")
691            .expect("Test data must contain BTC-27DEC24");
692
693        let instrument_any =
694            parse_deribit_instrument_any(deribit_inst, UnixNanos::default(), UnixNanos::default())
695                .unwrap();
696        let instrument = instrument_any.expect("Should parse future instrument");
697
698        let InstrumentAny::CryptoFuture(future) = instrument else {
699            panic!("Expected CryptoFuture, got {instrument:?}");
700        };
701        assert_eq!(future.id(), InstrumentId::from("BTC-27DEC24.DERIBIT"));
702        assert_eq!(future.raw_symbol(), Symbol::from("BTC-27DEC24"));
703        assert_eq!(future.underlying().unwrap(), "BTC");
704        assert_eq!(future.quote_currency().code, "USD");
705        assert_eq!(future.settlement_currency().code, "BTC");
706        assert!(future.is_inverse());
707
708        // Verify timestamps
709        assert_eq!(
710            future.activation_ns(),
711            Some(UnixNanos::from(1719561600000_u64 * 1_000_000))
712        );
713        assert_eq!(
714            future.expiration_ns(),
715            Some(UnixNanos::from(1735300800000_u64 * 1_000_000))
716        );
717        assert_eq!(future.price_precision(), 1);
718        assert_eq!(future.size_precision(), 0);
719        assert_eq!(future.price_increment(), Price::from("0.5"));
720        assert_eq!(future.size_increment(), Quantity::from("10"));
721        assert_eq!(future.multiplier(), Quantity::from("10"));
722        assert_eq!(future.lot_size(), Some(Quantity::from("10")));
723        assert_eq!(future.maker_fee, dec!(0));
724        assert_eq!(future.taker_fee, dec!(0.0005));
725    }
726
727    #[rstest]
728    fn test_parse_option_instrument() {
729        let json_data = load_test_json("http_get_instruments.json");
730        let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
731            serde_json::from_str(&json_data).unwrap();
732        let instruments = response.result.expect("Test data must have result");
733        let deribit_inst = instruments
734            .iter()
735            .find(|i| i.instrument_name.as_str() == "BTC-27DEC24-100000-C")
736            .expect("Test data must contain BTC-27DEC24-100000-C");
737
738        let instrument_any =
739            parse_deribit_instrument_any(deribit_inst, UnixNanos::default(), UnixNanos::default())
740                .unwrap();
741        let instrument = instrument_any.expect("Should parse option instrument");
742
743        // Verify it's an OptionContract
744        let InstrumentAny::OptionContract(option) = instrument else {
745            panic!("Expected OptionContract, got {instrument:?}");
746        };
747
748        assert_eq!(
749            option.id(),
750            InstrumentId::from("BTC-27DEC24-100000-C.DERIBIT")
751        );
752        assert_eq!(option.raw_symbol(), Symbol::from("BTC-27DEC24-100000-C"));
753        assert_eq!(option.underlying(), Some("BTC".into()));
754        assert_eq!(option.asset_class(), AssetClass::Cryptocurrency);
755        assert_eq!(option.option_kind(), Some(OptionKind::Call));
756        assert_eq!(option.strike_price(), Some(Price::from("100000")));
757        assert_eq!(option.currency.code, "BTC");
758        assert_eq!(
759            option.activation_ns(),
760            Some(UnixNanos::from(1719561600000_u64 * 1_000_000))
761        );
762        assert_eq!(
763            option.expiration_ns(),
764            Some(UnixNanos::from(1735300800000_u64 * 1_000_000))
765        );
766        assert_eq!(option.price_precision(), 4);
767        assert_eq!(option.price_increment(), Price::from("0.0005"));
768        assert_eq!(option.multiplier(), Quantity::from("1"));
769        assert_eq!(option.lot_size(), Some(Quantity::from("0.1")));
770        assert_eq!(option.maker_fee, dec!(0.0003));
771        assert_eq!(option.taker_fee, dec!(0.0003));
772    }
773
774    #[rstest]
775    fn test_parse_account_state_with_positions() {
776        let json_data = load_test_json("http_get_account_summaries.json");
777        let response: DeribitJsonRpcResponse<DeribitAccountSummariesResponse> =
778            serde_json::from_str(&json_data).unwrap();
779        let result = response.result.expect("Test data must have result");
780
781        let account_id = AccountId::from("DERIBIT-001");
782
783        // Extract server timestamp from response
784        let ts_event =
785            extract_server_timestamp(response.us_out).expect("Test data must have us_out");
786        let ts_init = UnixNanos::default();
787
788        let account_state = parse_account_state(&result.summaries, account_id, ts_init, ts_event)
789            .expect("Should parse account state");
790
791        // Verify we got 2 currencies (BTC and ETH)
792        assert_eq!(account_state.balances.len(), 2);
793
794        // Test BTC balance (has open positions with unrealized PnL)
795        let btc_balance = account_state
796            .balances
797            .iter()
798            .find(|b| b.currency.code == "BTC")
799            .expect("BTC balance should exist");
800
801        // From test data:
802        // balance: 302.60065765, equity: 302.61869214, available_funds: 301.38059622
803        // initial_margin: 1.24669592, session_upl: 0.05271555
804        //
805        // Using equity (correct):
806        // total = equity = 302.61869214
807        // free = available_funds = 301.38059622
808        // locked = total - free = 302.61869214 - 301.38059622 = 1.23809592
809        //
810        // This is close to initial_margin (1.24669592), small difference due to other factors
811        assert_eq!(btc_balance.total.as_f64(), 302.61869214);
812        assert_eq!(btc_balance.free.as_f64(), 301.38059622);
813
814        // Verify locked is positive and close to initial_margin
815        let locked = btc_balance.locked.as_f64();
816        assert!(
817            locked > 0.0,
818            "Locked should be positive when positions exist"
819        );
820        assert!(
821            (locked - 1.24669592).abs() < 0.01,
822            "Locked ({locked}) should be close to initial_margin (1.24669592)"
823        );
824
825        // Test ETH balance (no positions)
826        let eth_balance = account_state
827            .balances
828            .iter()
829            .find(|b| b.currency.code == "ETH")
830            .expect("ETH balance should exist");
831
832        // From test data: balance: 100, equity: 100, available_funds: 99.999598
833        // total = equity = 100
834        // free = available_funds = 99.999598
835        // locked = 100 - 99.999598 = 0.000402 (matches initial_margin)
836        assert_eq!(eth_balance.total.as_f64(), 100.0);
837        assert_eq!(eth_balance.free.as_f64(), 99.999598);
838        assert_eq!(eth_balance.locked.as_f64(), 0.000402);
839
840        // Verify account metadata
841        assert_eq!(account_state.account_id, account_id);
842        assert_eq!(account_state.account_type, AccountType::Margin);
843        assert!(account_state.is_reported);
844
845        // Verify ts_event matches server timestamp (us_out = 1687352432005000 microseconds)
846        let expected_ts_event = UnixNanos::from(1687352432005000_u64 * NANOSECONDS_IN_MICROSECOND);
847        assert_eq!(
848            account_state.ts_event, expected_ts_event,
849            "ts_event should match server timestamp from response"
850        );
851    }
852
853    #[rstest]
854    fn test_parse_trade_tick_sell() {
855        let json_data = load_test_json("http_get_last_trades.json");
856        let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
857            serde_json::from_str(&json_data).unwrap();
858        let result = response.result.expect("Test data must have result");
859
860        assert!(result.has_more, "has_more should be true");
861        assert_eq!(result.trades.len(), 10, "Should have 10 trades");
862
863        let raw_trade = &result.trades[0];
864        let instrument_id = InstrumentId::from("ETH-PERPETUAL.DERIBIT");
865        let ts_init = UnixNanos::from(1766335632425576_u64 * 1000); // from usOut
866
867        let trade = parse_trade_tick(raw_trade, instrument_id, 1, 0, ts_init)
868            .expect("Should parse trade tick");
869
870        assert_eq!(trade.instrument_id, instrument_id);
871        assert_eq!(trade.price, Price::from("2968.3"));
872        assert_eq!(trade.size, Quantity::from("1"));
873        assert_eq!(trade.aggressor_side, AggressorSide::Seller);
874        assert_eq!(trade.trade_id, TradeId::new("ETH-284830839"));
875        // timestamp 1766332040636 ms -> ns
876        assert_eq!(
877            trade.ts_event,
878            UnixNanos::from(1766332040636_u64 * 1_000_000)
879        );
880        assert_eq!(trade.ts_init, ts_init);
881    }
882
883    #[rstest]
884    fn test_parse_trade_tick_buy() {
885        let json_data = load_test_json("http_get_last_trades.json");
886        let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
887            serde_json::from_str(&json_data).unwrap();
888        let result = response.result.expect("Test data must have result");
889
890        // Last trade is a buy with amount 106
891        let raw_trade = &result.trades[9];
892        let instrument_id = InstrumentId::from("ETH-PERPETUAL.DERIBIT");
893        let ts_init = UnixNanos::default();
894
895        let trade = parse_trade_tick(raw_trade, instrument_id, 1, 0, ts_init)
896            .expect("Should parse trade tick");
897
898        assert_eq!(trade.instrument_id, instrument_id);
899        assert_eq!(trade.price, Price::from("2968.3"));
900        assert_eq!(trade.size, Quantity::from("106"));
901        assert_eq!(trade.aggressor_side, AggressorSide::Buyer);
902        assert_eq!(trade.trade_id, TradeId::new("ETH-284830854"));
903    }
904
905    #[rstest]
906    fn test_parse_bars() {
907        let json_data = load_test_json("http_get_tradingview_chart_data.json");
908        let response: DeribitJsonRpcResponse<DeribitTradingViewChartData> =
909            serde_json::from_str(&json_data).unwrap();
910        let chart_data = response.result.expect("Test data must have result");
911
912        let bar_type = BarType::from("BTC-PERPETUAL.DERIBIT-1-MINUTE-LAST-EXTERNAL");
913        let ts_init = UnixNanos::from(1766487086146245_u64 * NANOSECONDS_IN_MICROSECOND);
914
915        let bars = parse_bars(&chart_data, bar_type, 1, 8, ts_init).expect("Should parse bars");
916
917        assert_eq!(bars.len(), 5, "Should parse 5 bars");
918
919        // Verify first bar
920        let first_bar = &bars[0];
921        assert_eq!(first_bar.bar_type, bar_type);
922        assert_eq!(first_bar.open, Price::from("87451.0"));
923        assert_eq!(first_bar.high, Price::from("87456.5"));
924        assert_eq!(first_bar.low, Price::from("87451.0"));
925        assert_eq!(first_bar.close, Price::from("87456.5"));
926        assert_eq!(first_bar.volume, Quantity::from("2.94375216"));
927        assert_eq!(
928            first_bar.ts_event,
929            UnixNanos::from(1766483460000_u64 * NANOSECONDS_IN_MILLISECOND)
930        );
931        assert_eq!(first_bar.ts_init, ts_init);
932
933        // Verify last bar
934        let last_bar = &bars[4];
935        assert_eq!(last_bar.open, Price::from("87456.0"));
936        assert_eq!(last_bar.high, Price::from("87456.5"));
937        assert_eq!(last_bar.low, Price::from("87456.0"));
938        assert_eq!(last_bar.close, Price::from("87456.0"));
939        assert_eq!(last_bar.volume, Quantity::from("0.1018798"));
940        assert_eq!(
941            last_bar.ts_event,
942            UnixNanos::from(1766483700000_u64 * NANOSECONDS_IN_MILLISECOND)
943        );
944    }
945
946    #[rstest]
947    fn test_parse_order_book() {
948        let json_data = load_test_json("http_get_order_book.json");
949        let response: DeribitJsonRpcResponse<DeribitOrderBook> =
950            serde_json::from_str(&json_data).unwrap();
951        let order_book_data = response.result.expect("Test data must have result");
952
953        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
954        let ts_init = UnixNanos::from(1766554855146274_u64 * NANOSECONDS_IN_MICROSECOND);
955
956        let book = parse_order_book(&order_book_data, instrument_id, 1, 0, ts_init)
957            .expect("Should parse order book");
958
959        // Verify book metadata
960        assert_eq!(book.instrument_id, instrument_id);
961        assert_eq!(book.book_type, BookType::L2_MBP);
962        assert_eq!(book.ts_last, ts_init);
963
964        // Verify book has both sides
965        assert!(book.has_bid(), "Book should have bids");
966        assert!(book.has_ask(), "Book should have asks");
967
968        // Verify best bid using OrderBook methods
969        assert_eq!(
970            book.best_bid_price(),
971            Some(Price::from("87002.5")),
972            "Best bid price should match"
973        );
974        assert_eq!(
975            book.best_bid_size(),
976            Some(Quantity::from("199190")),
977            "Best bid size should match"
978        );
979
980        // Verify best ask using OrderBook methods
981        assert_eq!(
982            book.best_ask_price(),
983            Some(Price::from("87003.0")),
984            "Best ask price should match"
985        );
986        assert_eq!(
987            book.best_ask_size(),
988            Some(Quantity::from("125090")),
989            "Best ask size should match"
990        );
991
992        // Verify spread (best_ask - best_bid = 87003.0 - 87002.5 = 0.5)
993        let spread = book.spread().expect("Spread should exist");
994        assert!(
995            (spread - 0.5).abs() < 0.0001,
996            "Spread should be 0.5, got {spread}"
997        );
998
999        // Verify midpoint ((87003.0 + 87002.5) / 2 = 87002.75)
1000        let midpoint = book.midpoint().expect("Midpoint should exist");
1001        assert!(
1002            (midpoint - 87002.75).abs() < 0.0001,
1003            "Midpoint should be 87002.75, got {midpoint}"
1004        );
1005
1006        // Verify level counts match input data
1007        let bid_count = book.bids(None).count();
1008        let ask_count = book.asks(None).count();
1009        assert_eq!(
1010            bid_count,
1011            order_book_data.bids.len(),
1012            "Bid levels count should match input data"
1013        );
1014        assert_eq!(
1015            ask_count,
1016            order_book_data.asks.len(),
1017            "Ask levels count should match input data"
1018        );
1019        assert_eq!(bid_count, 20, "Should have 20 bid levels");
1020        assert_eq!(ask_count, 20, "Should have 20 ask levels");
1021
1022        // Verify depth limiting works (get top 5 levels)
1023        assert_eq!(
1024            book.bids(Some(5)).count(),
1025            5,
1026            "Should limit to 5 bid levels"
1027        );
1028        assert_eq!(
1029            book.asks(Some(5)).count(),
1030            5,
1031            "Should limit to 5 ask levels"
1032        );
1033
1034        // Verify bids_as_map and asks_as_map
1035        let bids_map = book.bids_as_map(None);
1036        let asks_map = book.asks_as_map(None);
1037        assert_eq!(bids_map.len(), 20, "Bids map should have 20 entries");
1038        assert_eq!(asks_map.len(), 20, "Asks map should have 20 entries");
1039
1040        // Verify specific prices exist in maps
1041        assert!(
1042            bids_map.contains_key(&dec!(87002.5)),
1043            "Bids map should contain best bid price"
1044        );
1045        assert!(
1046            asks_map.contains_key(&dec!(87003.0)),
1047            "Asks map should contain best ask price"
1048        );
1049
1050        // Verify worst levels exist
1051        assert!(
1052            bids_map.contains_key(&dec!(86980.0)),
1053            "Bids map should contain worst bid price"
1054        );
1055        assert!(
1056            asks_map.contains_key(&dec!(87031.5)),
1057            "Asks map should contain worst ask price"
1058        );
1059    }
1060}