nautilus_deribit/common/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parsing functions for Deribit API responses into Nautilus domain types.
17
18use std::str::FromStr;
19
20use anyhow::Context;
21use nautilus_core::{datetime::NANOSECONDS_IN_MICROSECOND, nanos::UnixNanos, uuid::UUID4};
22use nautilus_model::{
23    enums::{AccountType, AssetClass, CurrencyType, OptionKind},
24    events::AccountState,
25    identifiers::{AccountId, InstrumentId, Symbol, Venue},
26    instruments::{
27        CryptoFuture, CryptoPerpetual, CurrencyPair, OptionContract, any::InstrumentAny,
28    },
29    types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
30};
31use rust_decimal::Decimal;
32
33use crate::{
34    common::consts::DERIBIT_VENUE,
35    http::models::{
36        DeribitAccountSummary, DeribitInstrument, DeribitInstrumentKind, DeribitOptionType,
37    },
38};
39
40/// Extracts server timestamp from response and converts to UnixNanos.
41///
42/// # Errors
43///
44/// Returns an error if the server timestamp (us_out) is missing from the response.
45pub fn extract_server_timestamp(us_out: Option<u64>) -> anyhow::Result<UnixNanos> {
46    let us_out =
47        us_out.ok_or_else(|| anyhow::anyhow!("Missing server timestamp (us_out) in response"))?;
48    Ok(UnixNanos::from(us_out * NANOSECONDS_IN_MICROSECOND))
49}
50
51/// Parses a Deribit instrument into a Nautilus [`InstrumentAny`].
52///
53/// Returns `Ok(None)` for unsupported instrument types (e.g., combos).
54///
55/// # Errors
56///
57/// Returns an error if:
58/// - Required fields are missing (e.g., strike price for options)
59/// - Timestamp conversion fails
60/// - Decimal conversion fails for fees
61pub fn parse_deribit_instrument_any(
62    instrument: &DeribitInstrument,
63    ts_init: UnixNanos,
64    ts_event: UnixNanos,
65) -> anyhow::Result<Option<InstrumentAny>> {
66    match instrument.kind {
67        DeribitInstrumentKind::Spot => {
68            parse_spot_instrument(instrument, ts_init, ts_event).map(Some)
69        }
70        DeribitInstrumentKind::Future => {
71            // Check if it's a perpetual
72            if instrument.instrument_name.as_str().contains("PERPETUAL") {
73                parse_perpetual_instrument(instrument, ts_init, ts_event).map(Some)
74            } else {
75                parse_future_instrument(instrument, ts_init, ts_event).map(Some)
76            }
77        }
78        DeribitInstrumentKind::Option => {
79            parse_option_instrument(instrument, ts_init, ts_event).map(Some)
80        }
81        DeribitInstrumentKind::FutureCombo | DeribitInstrumentKind::OptionCombo => {
82            // Skip combos for initial implementation
83            Ok(None)
84        }
85    }
86}
87
88/// Parses a spot instrument into a [`CurrencyPair`].
89fn parse_spot_instrument(
90    instrument: &DeribitInstrument,
91    ts_init: UnixNanos,
92    ts_event: UnixNanos,
93) -> anyhow::Result<InstrumentAny> {
94    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
95
96    let base_currency = Currency::new(
97        instrument.base_currency,
98        8,
99        0,
100        instrument.base_currency,
101        CurrencyType::Crypto,
102    );
103    let quote_currency = Currency::new(
104        instrument.quote_currency,
105        8,
106        0,
107        instrument.quote_currency,
108        CurrencyType::Crypto,
109    );
110
111    let price_increment = Price::from(instrument.tick_size.to_string().as_str());
112    let size_increment = Quantity::from(instrument.min_trade_amount.to_string().as_str());
113
114    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
115        .context("Failed to parse maker_commission")?;
116    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
117        .context("Failed to parse taker_commission")?;
118
119    let currency_pair = CurrencyPair::new(
120        instrument_id,
121        instrument.instrument_name.into(),
122        base_currency,
123        quote_currency,
124        price_increment.precision,
125        size_increment.precision,
126        price_increment,
127        size_increment,
128        None, // multiplier
129        None, // lot_size
130        None, // max_quantity
131        None, // min_quantity
132        None, // max_notional
133        None, // min_notional
134        None, // max_price
135        None, // min_price
136        None, // margin_init
137        None, // margin_maint
138        Some(maker_fee),
139        Some(taker_fee),
140        ts_event,
141        ts_init,
142    );
143
144    Ok(InstrumentAny::CurrencyPair(currency_pair))
145}
146
147/// Parses a perpetual swap instrument into a [`CryptoPerpetual`].
148fn parse_perpetual_instrument(
149    instrument: &DeribitInstrument,
150    ts_init: UnixNanos,
151    ts_event: UnixNanos,
152) -> anyhow::Result<InstrumentAny> {
153    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
154
155    let base_currency = Currency::new(
156        instrument.base_currency,
157        8,
158        0,
159        instrument.base_currency,
160        CurrencyType::Crypto,
161    );
162    let quote_currency = Currency::new(
163        instrument.quote_currency,
164        8,
165        0,
166        instrument.quote_currency,
167        CurrencyType::Crypto,
168    );
169    let settlement_currency = instrument.settlement_currency.map_or(base_currency, |c| {
170        Currency::new(c, 8, 0, c, CurrencyType::Crypto)
171    });
172
173    let is_inverse = instrument
174        .instrument_type
175        .as_ref()
176        .is_some_and(|t| t == "reversed");
177
178    let price_increment = Price::from(instrument.tick_size.to_string().as_str());
179    let size_increment = Quantity::from(instrument.min_trade_amount.to_string().as_str());
180
181    // Contract size represents the multiplier (e.g., 10 USD per contract for BTC-PERPETUAL)
182    let multiplier = Some(Quantity::from(
183        instrument.contract_size.to_string().as_str(),
184    ));
185    let lot_size = Some(size_increment);
186
187    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
188        .context("Failed to parse maker_commission")?;
189    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
190        .context("Failed to parse taker_commission")?;
191
192    let perpetual = CryptoPerpetual::new(
193        instrument_id,
194        instrument.instrument_name.into(),
195        base_currency,
196        quote_currency,
197        settlement_currency,
198        is_inverse,
199        price_increment.precision,
200        size_increment.precision,
201        price_increment,
202        size_increment,
203        multiplier,
204        lot_size,
205        None, // max_quantity - Deribit doesn't specify a hard max
206        None, // min_quantity
207        None, // max_notional
208        None, // min_notional
209        None, // max_price
210        None, // min_price
211        None, // margin_init
212        None, // margin_maint
213        Some(maker_fee),
214        Some(taker_fee),
215        ts_event,
216        ts_init,
217    );
218
219    Ok(InstrumentAny::CryptoPerpetual(perpetual))
220}
221
222/// Parses a futures instrument into a [`CryptoFuture`].
223fn parse_future_instrument(
224    instrument: &DeribitInstrument,
225    ts_init: UnixNanos,
226    ts_event: UnixNanos,
227) -> anyhow::Result<InstrumentAny> {
228    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
229
230    let underlying = Currency::new(
231        instrument.base_currency,
232        8,
233        0,
234        instrument.base_currency,
235        CurrencyType::Crypto,
236    );
237    let quote_currency = Currency::new(
238        instrument.quote_currency,
239        8,
240        0,
241        instrument.quote_currency,
242        CurrencyType::Crypto,
243    );
244    let settlement_currency = instrument.settlement_currency.map_or(underlying, |c| {
245        Currency::new(c, 8, 0, c, CurrencyType::Crypto)
246    });
247
248    let is_inverse = instrument
249        .instrument_type
250        .as_ref()
251        .is_some_and(|t| t == "reversed");
252
253    // Convert timestamps from milliseconds to nanoseconds
254    let activation_ns = (instrument.creation_timestamp as u64) * 1_000_000;
255    let expiration_ns = instrument
256        .expiration_timestamp
257        .context("Missing expiration_timestamp for future")? as u64
258        * 1_000_000; // milliseconds to nanoseconds
259
260    let price_increment = Price::from(instrument.tick_size.to_string().as_str());
261    let size_increment = Quantity::from(instrument.min_trade_amount.to_string().as_str());
262
263    // Contract size represents the multiplier
264    let multiplier = Some(Quantity::from(
265        instrument.contract_size.to_string().as_str(),
266    ));
267    let lot_size = Some(size_increment); // Use min_trade_amount as lot size
268
269    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
270        .context("Failed to parse maker_commission")?;
271    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
272        .context("Failed to parse taker_commission")?;
273
274    let future = CryptoFuture::new(
275        instrument_id,
276        instrument.instrument_name.into(),
277        underlying,
278        quote_currency,
279        settlement_currency,
280        is_inverse,
281        UnixNanos::from(activation_ns),
282        UnixNanos::from(expiration_ns),
283        price_increment.precision,
284        size_increment.precision,
285        price_increment,
286        size_increment,
287        multiplier,
288        lot_size,
289        None, // max_quantity - Deribit doesn't specify a hard max
290        None, // min_quantity
291        None, // max_notional
292        None, // min_notional
293        None, // max_price
294        None, // min_price
295        None, // margin_init
296        None, // margin_maint
297        Some(maker_fee),
298        Some(taker_fee),
299        ts_event,
300        ts_init,
301    );
302
303    Ok(InstrumentAny::CryptoFuture(future))
304}
305
306/// Parses an options instrument into an [`OptionContract`].
307fn parse_option_instrument(
308    instrument: &DeribitInstrument,
309    ts_init: UnixNanos,
310    ts_event: UnixNanos,
311) -> anyhow::Result<InstrumentAny> {
312    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
313
314    // Underlying is the base currency symbol (e.g., "BTC")
315    let underlying = instrument.base_currency;
316
317    // Settlement currency for Deribit options
318    let settlement = instrument
319        .settlement_currency
320        .unwrap_or(instrument.base_currency);
321    let currency = Currency::new(settlement, 8, 0, settlement, CurrencyType::Crypto);
322
323    // Determine option kind
324    let option_kind = match instrument.option_type {
325        Some(DeribitOptionType::Call) => OptionKind::Call,
326        Some(DeribitOptionType::Put) => OptionKind::Put,
327        None => anyhow::bail!("Missing option_type for option instrument"),
328    };
329
330    // Parse strike price
331    let strike = instrument.strike.context("Missing strike for option")?;
332    let strike_price = Price::from(strike.to_string().as_str());
333
334    // Convert timestamps from milliseconds to nanoseconds
335    let activation_ns = (instrument.creation_timestamp as u64) * 1_000_000;
336    let expiration_ns = instrument
337        .expiration_timestamp
338        .context("Missing expiration_timestamp for option")? as u64
339        * 1_000_000;
340
341    let price_increment = Price::from(instrument.tick_size.to_string().as_str());
342
343    // Contract size is the multiplier (e.g., 1.0 for BTC options)
344    let multiplier = Quantity::from(instrument.contract_size.to_string().as_str());
345    let lot_size = Quantity::from(instrument.min_trade_amount.to_string().as_str());
346
347    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
348        .context("Failed to parse maker_commission")?;
349    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
350        .context("Failed to parse taker_commission")?;
351
352    let option = OptionContract::new(
353        instrument_id,
354        instrument.instrument_name.into(),
355        AssetClass::Cryptocurrency,
356        None, // exchange - Deribit doesn't provide separate exchange field
357        underlying,
358        option_kind,
359        strike_price,
360        currency,
361        UnixNanos::from(activation_ns),
362        UnixNanos::from(expiration_ns),
363        price_increment.precision,
364        price_increment,
365        multiplier,
366        lot_size,
367        None, // max_quantity
368        None, // min_quantity
369        None, // max_price
370        None, // min_price
371        None, // margin_init
372        None, // margin_maint
373        Some(maker_fee),
374        Some(taker_fee),
375        ts_event,
376        ts_init,
377    );
378
379    Ok(InstrumentAny::OptionContract(option))
380}
381
382/// Parses Deribit account summaries into a Nautilus [`AccountState`].
383///
384/// Processes multiple currency summaries and creates balance entries for each currency.
385///
386/// # Errors
387///
388/// Returns an error if:
389/// - Money conversion fails for any balance field
390/// - Decimal conversion fails for margin values
391pub fn parse_account_state(
392    summaries: &[DeribitAccountSummary],
393    account_id: AccountId,
394    ts_init: UnixNanos,
395    ts_event: UnixNanos,
396) -> anyhow::Result<AccountState> {
397    let mut balances = Vec::new();
398    let mut margins = Vec::new();
399
400    // Parse each currency summary
401    for summary in summaries {
402        let ccy_str = summary.currency.as_str().trim();
403
404        // Skip balances with empty currency codes
405        if ccy_str.is_empty() {
406            tracing::debug!(
407                "Skipping balance detail with empty currency code | raw_data={:?}",
408                summary
409            );
410            continue;
411        }
412
413        let currency = Currency::get_or_create_crypto_with_context(
414            ccy_str,
415            Some("DERIBIT - Parsing account state"),
416        );
417
418        // Parse balance: total (equity includes unrealized PnL), locked, free
419        // Note: Deribit's available_funds = equity - initial_margin, so we must use equity for total
420        let total = Money::new(summary.equity, currency);
421        let free = Money::new(summary.available_funds, currency);
422        let locked = Money::from_raw(total.raw - free.raw, currency);
423
424        let balance = AccountBalance::new(total, locked, free);
425        balances.push(balance);
426
427        // Parse margin balances if present
428        if let (Some(initial_margin), Some(maintenance_margin)) =
429            (summary.initial_margin, summary.maintenance_margin)
430        {
431            // Only create margin balance if there are actual margin requirements
432            if initial_margin > 0.0 || maintenance_margin > 0.0 {
433                let initial = Money::new(initial_margin, currency);
434                let maintenance = Money::new(maintenance_margin, currency);
435
436                // Create a synthetic instrument_id for account-level margins
437                let margin_instrument_id = InstrumentId::new(
438                    Symbol::from_str_unchecked(format!("ACCOUNT-{}", summary.currency)),
439                    Venue::new("DERIBIT"),
440                );
441
442                margins.push(MarginBalance::new(
443                    initial,
444                    maintenance,
445                    margin_instrument_id,
446                ));
447            }
448        }
449    }
450
451    // Ensure at least one balance exists (Nautilus requires non-empty balances)
452    if balances.is_empty() {
453        let zero_currency = Currency::USD();
454        let zero_money = Money::new(0.0, zero_currency);
455        let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
456        balances.push(zero_balance);
457    }
458
459    let account_type = AccountType::Margin;
460    let is_reported = true;
461
462    Ok(AccountState::new(
463        account_id,
464        account_type,
465        balances,
466        margins,
467        is_reported,
468        UUID4::new(),
469        ts_event,
470        ts_init,
471        None,
472    ))
473}
474
475#[cfg(test)]
476mod tests {
477    use nautilus_model::instruments::Instrument;
478    use rstest::rstest;
479    use rust_decimal_macros::dec;
480
481    use super::*;
482    use crate::{
483        common::testing::load_test_json,
484        http::models::{DeribitAccountSummariesResponse, DeribitJsonRpcResponse},
485    };
486
487    #[rstest]
488    fn test_parse_perpetual_instrument() {
489        let json_data = load_test_json("http_get_instrument.json");
490        let response: DeribitJsonRpcResponse<DeribitInstrument> =
491            serde_json::from_str(&json_data).unwrap();
492        let deribit_inst = response.result.expect("Test data must have result");
493
494        let instrument_any =
495            parse_deribit_instrument_any(&deribit_inst, UnixNanos::default(), UnixNanos::default())
496                .unwrap();
497        let instrument = instrument_any.expect("Should parse perpetual instrument");
498
499        let InstrumentAny::CryptoPerpetual(perpetual) = instrument else {
500            panic!("Expected CryptoPerpetual, got {instrument:?}");
501        };
502        assert_eq!(perpetual.id(), InstrumentId::from("BTC-PERPETUAL.DERIBIT"));
503        assert_eq!(perpetual.raw_symbol(), Symbol::from("BTC-PERPETUAL"));
504        assert_eq!(perpetual.base_currency().unwrap().code, "BTC");
505        assert_eq!(perpetual.quote_currency().code, "USD");
506        assert_eq!(perpetual.settlement_currency().code, "BTC");
507        assert!(perpetual.is_inverse());
508        assert_eq!(perpetual.price_precision(), 1);
509        assert_eq!(perpetual.size_precision(), 0);
510        assert_eq!(perpetual.price_increment(), Price::from("0.5"));
511        assert_eq!(perpetual.size_increment(), Quantity::from("10"));
512        assert_eq!(perpetual.multiplier(), Quantity::from("10"));
513        assert_eq!(perpetual.lot_size(), Some(Quantity::from("10")));
514        assert_eq!(perpetual.maker_fee(), dec!(0));
515        assert_eq!(perpetual.taker_fee(), dec!(0.0005));
516        assert_eq!(perpetual.max_quantity(), None);
517        assert_eq!(perpetual.min_quantity(), None);
518    }
519
520    #[rstest]
521    fn test_parse_future_instrument() {
522        let json_data = load_test_json("http_get_instruments.json");
523        let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
524            serde_json::from_str(&json_data).unwrap();
525        let instruments = response.result.expect("Test data must have result");
526        let deribit_inst = instruments
527            .iter()
528            .find(|i| i.instrument_name.as_str() == "BTC-27DEC24")
529            .expect("Test data must contain BTC-27DEC24");
530
531        let instrument_any =
532            parse_deribit_instrument_any(deribit_inst, UnixNanos::default(), UnixNanos::default())
533                .unwrap();
534        let instrument = instrument_any.expect("Should parse future instrument");
535
536        let InstrumentAny::CryptoFuture(future) = instrument else {
537            panic!("Expected CryptoFuture, got {instrument:?}");
538        };
539        assert_eq!(future.id(), InstrumentId::from("BTC-27DEC24.DERIBIT"));
540        assert_eq!(future.raw_symbol(), Symbol::from("BTC-27DEC24"));
541        assert_eq!(future.underlying().unwrap(), "BTC");
542        assert_eq!(future.quote_currency().code, "USD");
543        assert_eq!(future.settlement_currency().code, "BTC");
544        assert!(future.is_inverse());
545
546        // Verify timestamps
547        assert_eq!(
548            future.activation_ns(),
549            Some(UnixNanos::from(1719561600000_u64 * 1_000_000))
550        );
551        assert_eq!(
552            future.expiration_ns(),
553            Some(UnixNanos::from(1735300800000_u64 * 1_000_000))
554        );
555        assert_eq!(future.price_precision(), 1);
556        assert_eq!(future.size_precision(), 0);
557        assert_eq!(future.price_increment(), Price::from("0.5"));
558        assert_eq!(future.size_increment(), Quantity::from("10"));
559        assert_eq!(future.multiplier(), Quantity::from("10"));
560        assert_eq!(future.lot_size(), Some(Quantity::from("10")));
561        assert_eq!(future.maker_fee, dec!(0));
562        assert_eq!(future.taker_fee, dec!(0.0005));
563    }
564
565    #[rstest]
566    fn test_parse_option_instrument() {
567        let json_data = load_test_json("http_get_instruments.json");
568        let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
569            serde_json::from_str(&json_data).unwrap();
570        let instruments = response.result.expect("Test data must have result");
571        let deribit_inst = instruments
572            .iter()
573            .find(|i| i.instrument_name.as_str() == "BTC-27DEC24-100000-C")
574            .expect("Test data must contain BTC-27DEC24-100000-C");
575
576        let instrument_any =
577            parse_deribit_instrument_any(deribit_inst, UnixNanos::default(), UnixNanos::default())
578                .unwrap();
579        let instrument = instrument_any.expect("Should parse option instrument");
580
581        // Verify it's an OptionContract
582        let InstrumentAny::OptionContract(option) = instrument else {
583            panic!("Expected OptionContract, got {instrument:?}");
584        };
585
586        assert_eq!(
587            option.id(),
588            InstrumentId::from("BTC-27DEC24-100000-C.DERIBIT")
589        );
590        assert_eq!(option.raw_symbol(), Symbol::from("BTC-27DEC24-100000-C"));
591        assert_eq!(option.underlying(), Some("BTC".into()));
592        assert_eq!(option.asset_class(), AssetClass::Cryptocurrency);
593        assert_eq!(option.option_kind(), Some(OptionKind::Call));
594        assert_eq!(option.strike_price(), Some(Price::from("100000")));
595        assert_eq!(option.currency.code, "BTC");
596        assert_eq!(
597            option.activation_ns(),
598            Some(UnixNanos::from(1719561600000_u64 * 1_000_000))
599        );
600        assert_eq!(
601            option.expiration_ns(),
602            Some(UnixNanos::from(1735300800000_u64 * 1_000_000))
603        );
604        assert_eq!(option.price_precision(), 4);
605        assert_eq!(option.price_increment(), Price::from("0.0005"));
606        assert_eq!(option.multiplier(), Quantity::from("1"));
607        assert_eq!(option.lot_size(), Some(Quantity::from("0.1")));
608        assert_eq!(option.maker_fee, dec!(0.0003));
609        assert_eq!(option.taker_fee, dec!(0.0003));
610    }
611
612    #[rstest]
613    fn test_parse_account_state_with_positions() {
614        let json_data = load_test_json("http_get_account_summaries.json");
615        let response: DeribitJsonRpcResponse<DeribitAccountSummariesResponse> =
616            serde_json::from_str(&json_data).unwrap();
617        let result = response.result.expect("Test data must have result");
618
619        let account_id = AccountId::from("DERIBIT-001");
620
621        // Extract server timestamp from response
622        let ts_event =
623            extract_server_timestamp(response.us_out).expect("Test data must have us_out");
624        let ts_init = UnixNanos::default();
625
626        let account_state = parse_account_state(&result.summaries, account_id, ts_init, ts_event)
627            .expect("Should parse account state");
628
629        // Verify we got 2 currencies (BTC and ETH)
630        assert_eq!(account_state.balances.len(), 2);
631
632        // Test BTC balance (has open positions with unrealized PnL)
633        let btc_balance = account_state
634            .balances
635            .iter()
636            .find(|b| b.currency.code == "BTC")
637            .expect("BTC balance should exist");
638
639        // From test data:
640        // balance: 302.60065765, equity: 302.61869214, available_funds: 301.38059622
641        // initial_margin: 1.24669592, session_upl: 0.05271555
642        //
643        // Using equity (correct):
644        // total = equity = 302.61869214
645        // free = available_funds = 301.38059622
646        // locked = total - free = 302.61869214 - 301.38059622 = 1.23809592
647        //
648        // This is close to initial_margin (1.24669592), small difference due to other factors
649        assert_eq!(btc_balance.total.as_f64(), 302.61869214);
650        assert_eq!(btc_balance.free.as_f64(), 301.38059622);
651
652        // Verify locked is positive and close to initial_margin
653        let locked = btc_balance.locked.as_f64();
654        assert!(
655            locked > 0.0,
656            "Locked should be positive when positions exist"
657        );
658        assert!(
659            (locked - 1.24669592).abs() < 0.01,
660            "Locked ({locked}) should be close to initial_margin (1.24669592)"
661        );
662
663        // Test ETH balance (no positions)
664        let eth_balance = account_state
665            .balances
666            .iter()
667            .find(|b| b.currency.code == "ETH")
668            .expect("ETH balance should exist");
669
670        // From test data: balance: 100, equity: 100, available_funds: 99.999598
671        // total = equity = 100
672        // free = available_funds = 99.999598
673        // locked = 100 - 99.999598 = 0.000402 (matches initial_margin)
674        assert_eq!(eth_balance.total.as_f64(), 100.0);
675        assert_eq!(eth_balance.free.as_f64(), 99.999598);
676        assert_eq!(eth_balance.locked.as_f64(), 0.000402);
677
678        // Verify account metadata
679        assert_eq!(account_state.account_id, account_id);
680        assert_eq!(account_state.account_type, AccountType::Margin);
681        assert!(account_state.is_reported);
682
683        // Verify ts_event matches server timestamp (us_out = 1687352432005000 microseconds)
684        let expected_ts_event = UnixNanos::from(1687352432005000_u64 * NANOSECONDS_IN_MICROSECOND);
685        assert_eq!(
686            account_state.ts_event, expected_ts_event,
687            "ts_event should match server timestamp from response"
688        );
689    }
690}