1use std::{borrow::Cow, str::FromStr};
19
20use chrono::{DateTime, Utc};
21use nautilus_core::{Params, nanos::UnixNanos, uuid::UUID4};
22use nautilus_model::{
23 data::bar::BarType,
24 enums::{AccountType, AggressorSide, CurrencyType, LiquiditySide, PositionSide, TriggerType},
25 events::AccountState,
26 identifiers::{AccountId, InstrumentId, Symbol, TradeId},
27 instruments::{Instrument, InstrumentAny},
28 types::{
29 AccountBalance, Currency, MarginBalance, Money, Price, Quantity,
30 quantity::{QUANTITY_RAW_MAX, QuantityRaw},
31 },
32};
33use rust_decimal::{Decimal, RoundingStrategy, prelude::ToPrimitive};
34use ustr::Ustr;
35
36use crate::{
37 common::{
38 consts::BITMEX_VENUE,
39 enums::{BitmexExecInstruction, BitmexLiquidityIndicator, BitmexPegPriceType, BitmexSide},
40 },
41 websocket::messages::BitmexMarginMsg,
42};
43
44const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
46const FNV_PRIME: u64 = 0x0100_0000_01b3;
47
48#[must_use]
52pub fn clean_reason(reason: &str) -> String {
53 reason.replace("\nNautilusTrader", "").trim().to_string()
54}
55
56#[must_use]
58pub fn extract_trigger_type(exec_inst: Option<&Vec<BitmexExecInstruction>>) -> TriggerType {
59 if let Some(exec_insts) = exec_inst {
60 if exec_insts.contains(&BitmexExecInstruction::MarkPrice) {
61 TriggerType::MarkPrice
62 } else if exec_insts.contains(&BitmexExecInstruction::IndexPrice) {
63 TriggerType::IndexPrice
64 } else if exec_insts.contains(&BitmexExecInstruction::LastPrice) {
65 TriggerType::LastPrice
66 } else {
67 TriggerType::Default
68 }
69 } else {
70 TriggerType::Default
71 }
72}
73
74#[must_use]
76pub fn parse_instrument_id(symbol: Ustr) -> InstrumentId {
77 InstrumentId::new(Symbol::from_ustr_unchecked(symbol), *BITMEX_VENUE)
78}
79
80#[must_use]
88pub fn quantity_to_u32(quantity: &Quantity, instrument: &InstrumentAny) -> u32 {
89 let size_increment = instrument.size_increment();
90 let step_decimal = size_increment.as_decimal();
91
92 if step_decimal.is_zero() {
93 let value = quantity.as_f64();
94 if value > u32::MAX as f64 {
95 log::warn!("Quantity {value} exceeds u32::MAX without instrument increment, clamping",);
96 return u32::MAX;
97 }
98 return value.max(0.0) as u32;
99 }
100
101 let units_decimal = quantity.as_decimal() / step_decimal;
102 let rounded_units =
103 units_decimal.round_dp_with_strategy(0, RoundingStrategy::MidpointAwayFromZero);
104
105 match rounded_units.to_u128() {
106 Some(units) if units <= u32::MAX as u128 => units as u32,
107 Some(units) => {
108 log::warn!(
109 "Quantity {} converts to {units} contracts which exceeds u32::MAX, clamping",
110 quantity.as_f64(),
111 );
112 u32::MAX
113 }
114 None => {
115 log::warn!(
116 "Failed to convert quantity {} to venue units, defaulting to 0",
117 quantity.as_f64(),
118 );
119 0
120 }
121 }
122}
123
124#[must_use]
126pub fn parse_contracts_quantity(value: u64, instrument: &InstrumentAny) -> Quantity {
127 let size_increment = instrument.size_increment();
128 let precision = instrument.size_precision();
129
130 let increment_raw: QuantityRaw = (&size_increment).into();
131 let value_raw = QuantityRaw::from(value);
132
133 let mut raw = increment_raw.saturating_mul(value_raw);
134 if raw > QUANTITY_RAW_MAX {
135 log::warn!("Quantity value {value} exceeds QUANTITY_RAW_MAX {QUANTITY_RAW_MAX}, clamping",);
136 raw = QUANTITY_RAW_MAX;
137 }
138
139 Quantity::from_raw(raw, precision)
140}
141
142pub fn derive_contract_decimal_and_increment(
152 multiplier: Option<f64>,
153 max_scale: u32,
154) -> anyhow::Result<(Decimal, Quantity)> {
155 let raw_multiplier = multiplier.unwrap_or(1.0);
156 let contract_size = if raw_multiplier > 0.0 {
157 1.0 / raw_multiplier
158 } else {
159 1.0
160 };
161
162 let mut contract_decimal = Decimal::from_str(&contract_size.to_string())
163 .map_err(|_| anyhow::anyhow!("Invalid contract size {contract_size}"))?;
164
165 if contract_decimal.scale() > max_scale {
166 contract_decimal = contract_decimal
167 .round_dp_with_strategy(max_scale, RoundingStrategy::MidpointAwayFromZero);
168 }
169 contract_decimal = contract_decimal.normalize();
170 let contract_precision = contract_decimal.scale() as u8;
171 let size_increment = Quantity::from_decimal_dp(contract_decimal, contract_precision)?;
172
173 Ok((contract_decimal, size_increment))
174}
175
176pub fn convert_contract_quantity(
183 value: Option<f64>,
184 contract_decimal: Decimal,
185 max_scale: u32,
186 field_name: &str,
187) -> anyhow::Result<Option<Quantity>> {
188 value
189 .map(|raw| {
190 let mut decimal = Decimal::from_str(&raw.to_string())
191 .map_err(|_| anyhow::anyhow!("Invalid {field_name} value"))?
192 * contract_decimal;
193 let scale = decimal.scale();
194 if scale > max_scale {
195 decimal = decimal
196 .round_dp_with_strategy(max_scale, RoundingStrategy::MidpointAwayFromZero);
197 }
198 let decimal = decimal.normalize();
199 let precision = decimal.scale() as u8;
200 Quantity::from_decimal_dp(decimal, precision).map_err(anyhow::Error::from)
201 })
202 .transpose()
203}
204
205#[must_use]
207pub fn parse_signed_contracts_quantity(value: i64, instrument: &InstrumentAny) -> Quantity {
208 let abs_value = value.checked_abs().unwrap_or_else(|| {
209 log::warn!("Quantity value {value} overflowed when taking absolute value");
210 i64::MAX
211 }) as u64;
212 parse_contracts_quantity(abs_value, instrument)
213}
214
215#[must_use]
217pub fn parse_fractional_quantity(value: f64, instrument: &InstrumentAny) -> Quantity {
218 if value < 0.0 {
219 log::warn!("Received negative fractional quantity {value}, defaulting to 0.0");
220 return instrument.make_qty(0.0, None);
221 }
222
223 instrument.try_make_qty(value, None).unwrap_or_else(|e| {
224 log::warn!(
225 "Failed to convert fractional quantity {value} with precision {}: {e}",
226 instrument.size_precision(),
227 );
228 instrument.make_qty(0.0, None)
229 })
230}
231
232#[must_use]
240pub fn normalize_trade_bin_prices(
241 open: Price,
242 mut high: Price,
243 mut low: Price,
244 close: Price,
245 symbol: &Ustr,
246 bar_type: Option<&BarType>,
247) -> (Price, Price, Price, Price) {
248 let price_extremes = [open, high, low, close];
249 let max_price = *price_extremes
250 .iter()
251 .max()
252 .expect("Price array contains values");
253 let min_price = *price_extremes
254 .iter()
255 .min()
256 .expect("Price array contains values");
257
258 if high < max_price || low > min_price {
259 match bar_type {
260 Some(bt) => {
261 log::warn!("Adjusting BitMEX trade bin extremes: symbol={symbol}, bar_type={bt:?}");
262 }
263 None => log::warn!("Adjusting BitMEX trade bin extremes: symbol={symbol}"),
264 }
265 high = max_price;
266 low = min_price;
267 }
268
269 (open, high, low, close)
270}
271
272#[must_use]
275pub fn normalize_trade_bin_volume(volume: Option<i64>, symbol: &Ustr) -> u64 {
276 match volume {
277 Some(v) if v >= 0 => v as u64,
278 Some(v) => {
279 log::warn!("Received negative volume in BitMEX trade bin: symbol={symbol}, volume={v}");
280 0
281 }
282 None => {
283 log::warn!("Trade bin missing volume, defaulting to 0: symbol={symbol}");
284 0
285 }
286 }
287}
288
289#[must_use]
294pub fn parse_optional_datetime_to_unix_nanos(
295 value: &Option<DateTime<Utc>>,
296 field: &str,
297) -> UnixNanos {
298 value
299 .map(|dt| {
300 UnixNanos::from(dt.timestamp_nanos_opt().unwrap_or_else(|| {
301 log::error!("Invalid timestamp - out of range: field={field}, timestamp={dt:?}");
302 0
303 }) as u64)
304 })
305 .unwrap_or_default()
306}
307
308#[must_use]
310pub const fn parse_aggressor_side(side: &Option<BitmexSide>) -> AggressorSide {
311 match side {
312 Some(BitmexSide::Buy) => AggressorSide::Buyer,
313 Some(BitmexSide::Sell) => AggressorSide::Seller,
314 None => AggressorSide::NoAggressor,
315 }
316}
317
318#[must_use]
320pub fn parse_liquidity_side(liquidity: &Option<BitmexLiquidityIndicator>) -> LiquiditySide {
321 liquidity.map_or(LiquiditySide::NoLiquiditySide, std::convert::Into::into)
322}
323
324#[must_use]
326pub const fn parse_position_side(current_qty: Option<i64>) -> PositionSide {
327 match current_qty {
328 Some(qty) if qty > 0 => PositionSide::Long,
329 Some(qty) if qty < 0 => PositionSide::Short,
330 _ => PositionSide::Flat,
331 }
332}
333
334#[must_use]
346pub fn map_bitmex_currency(bitmex_currency: &str) -> Cow<'static, str> {
347 match bitmex_currency {
348 "XBt" => Cow::Borrowed("XBT"),
349 "USDt" | "LAMp" => Cow::Borrowed("USDT"), "RLUSd" => Cow::Borrowed("RLUSD"),
351 "MAMUSd" => Cow::Borrowed("MAMUSD"),
352 other => Cow::Owned(other.to_uppercase()),
353 }
354}
355
356#[must_use]
358pub fn bitmex_currency_divisor(bitmex_currency: &str) -> Decimal {
359 match bitmex_currency {
360 "XBt" => Decimal::from(100_000_000),
361 "USDt" | "LAMp" | "MAMUSd" | "RLUSd" | "USYc" | "USYC" => Decimal::from(1_000_000),
362 _ => Decimal::ONE,
363 }
364}
365
366#[must_use]
368pub fn bitmex_account_id(account: i64) -> AccountId {
369 AccountId::new(format!("BITMEX-{account}"))
370}
371
372pub fn parse_account_balance(margin: &BitmexMarginMsg) -> AccountBalance {
374 log::debug!(
375 "Parsing margin: currency={}, wallet_balance={:?}, available_margin={:?}, init_margin={:?}, maint_margin={:?}",
376 margin.currency,
377 margin.wallet_balance,
378 margin.available_margin,
379 margin.init_margin,
380 margin.maint_margin,
381 );
382
383 let currency_str = map_bitmex_currency(&margin.currency);
384 let currency = parse_bitmex_margin_currency(¤cy_str);
385
386 let divisor = bitmex_currency_divisor(margin.currency.as_str());
388 let to_dec = |raw: i64| Decimal::from(raw) / divisor;
389
390 let total_dec = margin
392 .wallet_balance
393 .map(to_dec)
394 .or_else(|| margin.margin_balance.map(to_dec))
395 .or_else(|| margin.available_margin.map(to_dec))
396 .unwrap_or(Decimal::ZERO);
397
398 let free_dec = if let Some(withdrawable) = margin.withdrawable_margin {
402 to_dec(withdrawable)
403 } else if let Some(available) = margin.available_margin {
404 to_dec(available)
405 } else {
406 let margin_used = margin.init_margin.map_or(Decimal::ZERO, to_dec);
407 total_dec - margin_used
408 };
409
410 AccountBalance::from_total_and_free(total_dec, free_dec, currency).unwrap_or_else(|e| {
411 log::error!("Failed to build BitMEX account balance: {e}");
412 let zero = Money::zero(currency);
413 AccountBalance::new(zero, zero, zero)
414 })
415}
416
417fn parse_bitmex_margin_currency(currency_str: &str) -> Currency {
418 if let Some(currency) = known_bitmex_margin_currency(currency_str) {
419 return currency;
420 }
421
422 match Currency::try_from_str(currency_str) {
423 Some(c) => c,
424 None => {
425 log::warn!(
426 "Unknown currency '{currency_str}' in margin message, creating default crypto currency"
427 );
428 let currency = Currency::new(currency_str, 8, 0, currency_str, CurrencyType::Crypto);
429 if let Err(e) = Currency::register(currency, false) {
430 log::error!("Failed to register currency '{currency_str}': {e}");
431 }
432 currency
433 }
434 }
435}
436
437fn known_bitmex_margin_currency(currency_str: &str) -> Option<Currency> {
438 match currency_str {
439 "USYC" => {
440 let currency = Currency::new("USYC", 6, 0, "USYC", CurrencyType::Crypto);
441 if let Err(e) = Currency::register(currency, true) {
442 log::error!("Failed to register currency '{currency_str}': {e}");
443 }
444 Some(currency)
445 }
446 _ => None,
447 }
448}
449
450pub fn parse_account_state(
456 margin: &BitmexMarginMsg,
457 account_id: AccountId,
458 ts_init: UnixNanos,
459) -> anyhow::Result<AccountState> {
460 let balance = parse_account_balance(margin);
461 let balances = vec![balance];
462
463 let currency = balance.total.currency;
464 let mut margins = Vec::new();
465
466 let divisor = bitmex_currency_divisor(margin.currency.as_str());
467 let initial_dec = Decimal::from(margin.init_margin.unwrap_or(0).max(0)) / divisor;
468 let maintenance_dec = Decimal::from(margin.maint_margin.unwrap_or(0).max(0)) / divisor;
469
470 if !initial_dec.is_zero() || !maintenance_dec.is_zero() {
471 margins.push(MarginBalance::new(
473 Money::from_decimal(initial_dec, currency).unwrap_or_else(|_| Money::zero(currency)),
474 Money::from_decimal(maintenance_dec, currency)
475 .unwrap_or_else(|_| Money::zero(currency)),
476 None,
477 ));
478 }
479
480 let account_type = AccountType::Margin;
481 let is_reported = true;
482 let event_id = UUID4::new();
483 let ts_event =
484 UnixNanos::from(margin.timestamp.timestamp_nanos_opt().unwrap_or_default() as u64);
485
486 Ok(AccountState::new(
487 account_id,
488 account_type,
489 balances,
490 margins,
491 is_reported,
492 event_id,
493 ts_event,
494 ts_init,
495 None,
496 ))
497}
498
499pub fn parse_peg_price_type(params: Option<&Params>) -> anyhow::Result<Option<BitmexPegPriceType>> {
505 let value = params.and_then(|p| p.get_str("peg_price_type"));
506 match value {
507 Some(s) => BitmexPegPriceType::from_str(s)
508 .map(Some)
509 .map_err(|_| anyhow::anyhow!("Invalid peg_price_type: {s}")),
510 None => Ok(None),
511 }
512}
513
514pub fn parse_peg_offset_value(params: Option<&Params>) -> anyhow::Result<Option<f64>> {
520 let value = params.and_then(|p| p.get_str("peg_offset_value"));
521 match value {
522 Some(s) => s
523 .parse::<f64>()
524 .map(Some)
525 .map_err(|_| anyhow::anyhow!("Invalid peg_offset_value: {s}")),
526 None => Ok(None),
527 }
528}
529
530#[must_use]
538pub fn derive_trade_id(
539 symbol: Ustr,
540 ts_event_ns: u64,
541 price: f64,
542 size: i64,
543 side: Option<BitmexSide>,
544) -> TradeId {
545 let side_tag: &[u8] = match side {
546 Some(BitmexSide::Buy) => b"B",
547 Some(BitmexSide::Sell) => b"S",
548 None => b"N",
549 };
550
551 let mut hash: u64 = FNV_OFFSET_BASIS;
552
553 for bytes in [
554 symbol.as_str().as_bytes(),
555 b"\x1f",
556 &ts_event_ns.to_le_bytes(),
557 b"\x1f",
558 &price.to_bits().to_le_bytes(),
559 b"\x1f",
560 &size.to_le_bytes(),
561 b"\x1f",
562 side_tag,
563 ] {
564 for &byte in bytes {
565 hash ^= u64::from(byte);
566 hash = hash.wrapping_mul(FNV_PRIME);
567 }
568 }
569 TradeId::new(format!("{hash:016x}"))
570}
571
572#[cfg(test)]
573mod tests {
574 use chrono::TimeZone;
575 use nautilus_model::{instruments::CurrencyPair, types::fixed::FIXED_PRECISION};
576 use rstest::rstest;
577 use ustr::Ustr;
578
579 use super::*;
580
581 #[rstest]
582 fn test_clean_reason_strips_nautilus_trader() {
583 assert_eq!(
584 clean_reason(
585 "Canceled: Order had execInst of ParticipateDoNotInitiate\nNautilusTrader"
586 ),
587 "Canceled: Order had execInst of ParticipateDoNotInitiate"
588 );
589
590 assert_eq!(clean_reason("Some error\nNautilusTrader"), "Some error");
591 assert_eq!(
592 clean_reason("Multiple lines\nSome content\nNautilusTrader"),
593 "Multiple lines\nSome content"
594 );
595 assert_eq!(clean_reason("No identifier here"), "No identifier here");
596 assert_eq!(clean_reason(" \nNautilusTrader "), "");
597 }
598
599 #[rstest]
600 fn test_derive_trade_id_is_deterministic_and_16_hex_chars() {
601 let first = derive_trade_id(
602 Ustr::from("XBTUSD"),
603 1_700_000_000_000_000_000,
604 98_570.9,
605 100,
606 Some(BitmexSide::Buy),
607 );
608 let second = derive_trade_id(
609 Ustr::from("XBTUSD"),
610 1_700_000_000_000_000_000,
611 98_570.9,
612 100,
613 Some(BitmexSide::Buy),
614 );
615 assert_eq!(first, second);
616 assert_eq!(first.as_str().len(), 16);
617 }
618
619 #[rstest]
620 #[case::symbol_changed(derive_trade_id(
621 Ustr::from("ETHUSD"),
622 1,
623 100.0,
624 1,
625 Some(BitmexSide::Buy)
626 ))]
627 #[case::ts_changed(derive_trade_id(Ustr::from("XBTUSD"), 2, 100.0, 1, Some(BitmexSide::Buy)))]
628 #[case::price_changed(derive_trade_id(
629 Ustr::from("XBTUSD"),
630 1,
631 101.0,
632 1,
633 Some(BitmexSide::Buy)
634 ))]
635 #[case::size_changed(derive_trade_id(
636 Ustr::from("XBTUSD"),
637 1,
638 100.0,
639 2,
640 Some(BitmexSide::Buy)
641 ))]
642 #[case::side_changed(derive_trade_id(
643 Ustr::from("XBTUSD"),
644 1,
645 100.0,
646 1,
647 Some(BitmexSide::Sell)
648 ))]
649 #[case::side_missing(derive_trade_id(Ustr::from("XBTUSD"), 1, 100.0, 1, None))]
650 fn test_derive_trade_id_each_field_affects_output(#[case] altered: TradeId) {
651 let baseline = derive_trade_id(Ustr::from("XBTUSD"), 1, 100.0, 1, Some(BitmexSide::Buy));
652 assert_ne!(baseline, altered);
653 }
654
655 #[rstest]
656 fn test_derive_trade_id_field_delimiter_prevents_collision() {
657 let a = derive_trade_id(Ustr::from("A"), 1, 0.0, 0, Some(BitmexSide::Buy));
661 let b = derive_trade_id(Ustr::from("A\0"), 256, 0.0, 0, Some(BitmexSide::Buy));
662 assert_ne!(a, b);
663 }
664
665 fn make_test_spot_instrument(size_increment: f64, size_precision: u8) -> InstrumentAny {
666 let instrument_id = InstrumentId::from("SOLUSDT.BITMEX");
667 let raw_symbol = Symbol::from("SOLUSDT");
668 let base_currency = Currency::from("SOL");
669 let quote_currency = Currency::from("USDT");
670 let price_precision = 2;
671 let price_increment = Price::new(0.01, price_precision);
672 let size_increment = Quantity::new(size_increment, size_precision);
673 let instrument = CurrencyPair::new(
674 instrument_id,
675 raw_symbol,
676 base_currency,
677 quote_currency,
678 price_precision,
679 size_precision,
680 price_increment,
681 size_increment,
682 None, None, None, None, None, None, None, None, None, None, None, None, None, None, UnixNanos::from(0),
697 UnixNanos::from(0),
698 );
699 InstrumentAny::CurrencyPair(instrument)
700 }
701
702 #[rstest]
703 fn test_quantity_to_u32_scaled() {
704 let instrument = make_test_spot_instrument(0.0001, 4);
705 let qty = Quantity::new(0.1, 4);
706 assert_eq!(quantity_to_u32(&qty, &instrument), 1_000);
707 }
708
709 #[rstest]
710 fn test_parse_contracts_quantity_scaled() {
711 let instrument = make_test_spot_instrument(0.0001, 4);
712 let qty = parse_contracts_quantity(1_000, &instrument);
713 assert!((qty.as_f64() - 0.1).abs() < 1e-9);
714 assert_eq!(qty.precision, 4);
715 }
716
717 #[rstest]
718 fn test_convert_contract_quantity_scaling() {
719 let max_scale = FIXED_PRECISION as u32;
720 let (contract_decimal, size_increment) =
721 derive_contract_decimal_and_increment(Some(10_000.0), max_scale).unwrap();
722 assert!((size_increment.as_f64() - 0.0001).abs() < 1e-12);
723
724 let lot_qty =
725 convert_contract_quantity(Some(1_000.0), contract_decimal, max_scale, "lot size")
726 .unwrap()
727 .unwrap();
728 assert!((lot_qty.as_f64() - 0.1).abs() < 1e-9);
729 assert_eq!(lot_qty.precision, 1);
730 }
731
732 #[rstest]
733 fn test_derive_contract_decimal_defaults_to_one() {
734 let max_scale = FIXED_PRECISION as u32;
735 let (contract_decimal, size_increment) =
736 derive_contract_decimal_and_increment(Some(0.0), max_scale).unwrap();
737 assert_eq!(contract_decimal, Decimal::ONE);
738 assert_eq!(size_increment.as_f64(), 1.0);
739 }
740
741 #[rstest]
742 fn test_parse_account_state() {
743 let margin_msg = BitmexMarginMsg {
744 account: 123456,
745 currency: Ustr::from("XBt"),
746 risk_limit: Some(1000000000),
747 amount: Some(5000000),
748 prev_realised_pnl: Some(100000),
749 gross_comm: Some(1000),
750 gross_open_cost: Some(200000),
751 gross_open_premium: None,
752 gross_exec_cost: None,
753 gross_mark_value: Some(210000),
754 risk_value: Some(50000),
755 init_margin: Some(20000),
756 maint_margin: Some(10000),
757 target_excess_margin: Some(5000),
758 realised_pnl: Some(100000),
759 unrealised_pnl: Some(10000),
760 wallet_balance: Some(5000000),
761 margin_balance: Some(5010000),
762 margin_leverage: Some(2.5),
763 margin_used_pcnt: Some(0.25),
764 excess_margin: Some(4990000),
765 available_margin: Some(4980000),
766 withdrawable_margin: Some(4900000),
767 maker_fee_discount: Some(0.1),
768 taker_fee_discount: Some(0.05),
769 timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
770 foreign_margin_balance: None,
771 foreign_requirement: None,
772 };
773
774 let account_id = AccountId::new("BITMEX-001");
775 let ts_init = UnixNanos::from(1_000_000_000);
776
777 let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
778
779 assert_eq!(account_state.account_id, account_id);
780 assert_eq!(account_state.account_type, AccountType::Margin);
781 assert_eq!(account_state.balances.len(), 1);
782 assert_eq!(account_state.margins.len(), 1);
783 assert!(account_state.is_reported);
784
785 let xbt_balance = &account_state.balances[0];
786 assert_eq!(xbt_balance.currency, Currency::from("XBT"));
787 assert_eq!(xbt_balance.total.as_f64(), 0.05); assert_eq!(xbt_balance.free.as_f64(), 0.049); assert_eq!(xbt_balance.locked.as_f64(), 0.001); let xbt_margin = &account_state.margins[0];
792 assert_eq!(xbt_margin.initial.as_f64(), 0.0002); assert_eq!(xbt_margin.maintenance.as_f64(), 0.0001); }
795
796 #[rstest]
797 fn test_parse_account_state_usdt() {
798 let margin_msg = BitmexMarginMsg {
799 account: 123456,
800 currency: Ustr::from("USDt"),
801 risk_limit: Some(1000000000),
802 amount: Some(10000000000), prev_realised_pnl: None,
804 gross_comm: None,
805 gross_open_cost: None,
806 gross_open_premium: None,
807 gross_exec_cost: None,
808 gross_mark_value: None,
809 risk_value: None,
810 init_margin: Some(500000), maint_margin: Some(250000), target_excess_margin: None,
813 realised_pnl: None,
814 unrealised_pnl: None,
815 wallet_balance: Some(10000000000),
816 margin_balance: Some(10000000000),
817 margin_leverage: None,
818 margin_used_pcnt: None,
819 excess_margin: None,
820 available_margin: Some(9500000000), withdrawable_margin: None,
822 maker_fee_discount: None,
823 taker_fee_discount: None,
824 timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
825 foreign_margin_balance: None,
826 foreign_requirement: None,
827 };
828
829 let account_id = AccountId::new("BITMEX-001");
830 let ts_init = UnixNanos::from(1_000_000_000);
831
832 let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
833
834 let usdt_balance = &account_state.balances[0];
835 assert_eq!(usdt_balance.currency, Currency::USDT());
836 assert_eq!(usdt_balance.total.as_f64(), 10000.0);
837 assert_eq!(usdt_balance.free.as_f64(), 9500.0);
838 assert_eq!(usdt_balance.locked.as_f64(), 500.0);
839
840 assert_eq!(account_state.margins.len(), 1);
841 let usdt_margin = &account_state.margins[0];
842 assert_eq!(usdt_margin.initial.as_f64(), 0.5); assert_eq!(usdt_margin.maintenance.as_f64(), 0.25); }
845
846 #[rstest]
847 fn test_parse_account_state_usyc_margin_currency() {
848 let margin_msg = BitmexMarginMsg {
849 account: 123456,
850 currency: Ustr::from("USYc"),
851 risk_limit: Some(1000000000),
852 amount: Some(100000000),
853 prev_realised_pnl: None,
854 gross_comm: None,
855 gross_open_cost: None,
856 gross_open_premium: None,
857 gross_exec_cost: None,
858 gross_mark_value: None,
859 risk_value: None,
860 init_margin: Some(500000),
861 maint_margin: Some(250000),
862 target_excess_margin: None,
863 realised_pnl: None,
864 unrealised_pnl: None,
865 wallet_balance: Some(100000000),
866 margin_balance: Some(100000000),
867 margin_leverage: None,
868 margin_used_pcnt: None,
869 excess_margin: None,
870 available_margin: Some(99000000),
871 withdrawable_margin: None,
872 maker_fee_discount: None,
873 taker_fee_discount: None,
874 timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
875 foreign_margin_balance: None,
876 foreign_requirement: None,
877 };
878
879 let account_id = AccountId::new("BITMEX-001");
880 let ts_init = UnixNanos::from(1_000_000_000);
881
882 let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
883
884 let balance = &account_state.balances[0];
885 assert_eq!(balance.currency.code.as_str(), "USYC");
886 assert_eq!(balance.currency.precision, 6);
887 assert_eq!(balance.total.as_f64(), 100.0);
888 assert_eq!(balance.free.as_f64(), 99.0);
889 assert_eq!(balance.locked.as_f64(), 1.0);
890
891 assert_eq!(account_state.margins.len(), 1);
892 let margin = &account_state.margins[0];
893 assert_eq!(margin.currency.code.as_str(), "USYC");
894 assert_eq!(margin.initial.as_f64(), 0.5);
895 assert_eq!(margin.maintenance.as_f64(), 0.25);
896 }
897
898 #[rstest]
899 fn test_parse_account_balance_falls_back_to_margin_balance_when_wallet_absent() {
900 let margin_msg = BitmexMarginMsg {
904 account: 123456,
905 currency: Ustr::from("XBt"),
906 risk_limit: None,
907 amount: None,
908 prev_realised_pnl: None,
909 gross_comm: None,
910 gross_open_cost: None,
911 gross_open_premium: None,
912 gross_exec_cost: None,
913 gross_mark_value: None,
914 risk_value: None,
915 init_margin: Some(20000),
916 maint_margin: Some(10000),
917 target_excess_margin: None,
918 realised_pnl: None,
919 unrealised_pnl: None,
920 wallet_balance: None,
921 margin_balance: Some(5_010_000),
922 margin_leverage: None,
923 margin_used_pcnt: None,
924 excess_margin: None,
925 available_margin: Some(4_980_000),
926 withdrawable_margin: Some(4_900_000),
927 maker_fee_discount: None,
928 taker_fee_discount: None,
929 timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
930 foreign_margin_balance: None,
931 foreign_requirement: None,
932 };
933
934 let balance = parse_account_balance(&margin_msg);
935
936 assert_eq!(balance.currency, Currency::from("XBT"));
937 assert!((balance.total.as_f64() - 0.0501).abs() < 1e-9);
939 assert!((balance.free.as_f64() - 0.049).abs() < 1e-9);
941 assert!((balance.locked.as_f64() - 0.0011).abs() < 1e-9);
943 }
944
945 #[rstest]
946 fn test_parse_margin_message_with_missing_fields() {
947 let margin_msg = BitmexMarginMsg {
949 account: 123456,
950 currency: Ustr::from("XBt"),
951 risk_limit: None,
952 amount: None,
953 prev_realised_pnl: None,
954 gross_comm: None,
955 gross_open_cost: None,
956 gross_open_premium: None,
957 gross_exec_cost: None,
958 gross_mark_value: None,
959 risk_value: None,
960 init_margin: None, maint_margin: None, target_excess_margin: None,
963 realised_pnl: None,
964 unrealised_pnl: None,
965 wallet_balance: Some(100000),
966 margin_balance: None,
967 margin_leverage: None,
968 margin_used_pcnt: None,
969 excess_margin: None,
970 available_margin: Some(95000),
971 withdrawable_margin: None,
972 maker_fee_discount: None,
973 taker_fee_discount: None,
974 timestamp: chrono::Utc::now(),
975 foreign_margin_balance: None,
976 foreign_requirement: None,
977 };
978
979 let account_id = AccountId::new("BITMEX-123456");
980 let ts_init = UnixNanos::from(1_000_000_000);
981
982 let account_state = parse_account_state(&margin_msg, account_id, ts_init)
983 .expect("Should parse even with missing margin fields");
984
985 assert_eq!(account_state.balances.len(), 1);
987 assert_eq!(account_state.margins.len(), 0); }
989
990 #[rstest]
991 fn test_parse_margin_message_with_only_available_margin() {
992 let margin_msg = BitmexMarginMsg {
994 account: 1667725,
995 currency: Ustr::from("USDt"),
996 risk_limit: None,
997 amount: None,
998 prev_realised_pnl: None,
999 gross_comm: None,
1000 gross_open_cost: None,
1001 gross_open_premium: None,
1002 gross_exec_cost: None,
1003 gross_mark_value: None,
1004 risk_value: None,
1005 init_margin: None,
1006 maint_margin: None,
1007 target_excess_margin: None,
1008 realised_pnl: None,
1009 unrealised_pnl: None,
1010 wallet_balance: None, margin_balance: None, margin_leverage: None,
1013 margin_used_pcnt: None,
1014 excess_margin: None,
1015 available_margin: Some(107859036), withdrawable_margin: None,
1017 maker_fee_discount: None,
1018 taker_fee_discount: None,
1019 timestamp: chrono::Utc::now(),
1020 foreign_margin_balance: None,
1021 foreign_requirement: None,
1022 };
1023
1024 let account_id = AccountId::new("BITMEX-1667725");
1025 let ts_init = UnixNanos::from(1_000_000_000);
1026
1027 let account_state = parse_account_state(&margin_msg, account_id, ts_init)
1028 .expect("Should handle case with only available_margin");
1029
1030 let balance = &account_state.balances[0];
1032 assert_eq!(balance.currency, Currency::USDT());
1033 assert_eq!(balance.total.as_f64(), 107.859036); assert_eq!(balance.free.as_f64(), 107.859036);
1035 assert_eq!(balance.locked.as_f64(), 0.0);
1036
1037 assert_eq!(balance.total, balance.locked + balance.free);
1039 }
1040
1041 #[rstest]
1042 fn test_parse_margin_available_exceeds_wallet() {
1043 let margin_msg = BitmexMarginMsg {
1045 account: 123456,
1046 currency: Ustr::from("XBt"),
1047 risk_limit: None,
1048 amount: Some(70772),
1049 prev_realised_pnl: None,
1050 gross_comm: None,
1051 gross_open_cost: None,
1052 gross_open_premium: None,
1053 gross_exec_cost: None,
1054 gross_mark_value: None,
1055 risk_value: None,
1056 init_margin: Some(0),
1057 maint_margin: Some(0),
1058 target_excess_margin: None,
1059 realised_pnl: None,
1060 unrealised_pnl: None,
1061 wallet_balance: Some(70772), margin_balance: None,
1063 margin_leverage: None,
1064 margin_used_pcnt: None,
1065 excess_margin: None,
1066 available_margin: Some(94381), withdrawable_margin: None,
1068 maker_fee_discount: None,
1069 taker_fee_discount: None,
1070 timestamp: chrono::Utc::now(),
1071 foreign_margin_balance: None,
1072 foreign_requirement: None,
1073 };
1074
1075 let account_id = AccountId::new("BITMEX-123456");
1076 let ts_init = UnixNanos::from(1_000_000_000);
1077
1078 let account_state = parse_account_state(&margin_msg, account_id, ts_init)
1079 .expect("Should handle available > wallet case");
1080
1081 let balance = &account_state.balances[0];
1083 assert_eq!(balance.currency, Currency::from("XBT"));
1084 assert_eq!(balance.total.as_f64(), 0.00070772); assert_eq!(balance.free.as_f64(), 0.00070772); assert_eq!(balance.locked.as_f64(), 0.0);
1087
1088 assert_eq!(balance.total, balance.locked + balance.free);
1090 }
1091
1092 #[rstest]
1093 fn test_parse_margin_message_with_foreign_requirements() {
1094 let margin_msg = BitmexMarginMsg {
1096 account: 123456,
1097 currency: Ustr::from("XBt"),
1098 risk_limit: Some(1000000000),
1099 amount: Some(100000000), prev_realised_pnl: None,
1101 gross_comm: None,
1102 gross_open_cost: None,
1103 gross_open_premium: None,
1104 gross_exec_cost: None,
1105 gross_mark_value: None,
1106 risk_value: None,
1107 init_margin: None, maint_margin: None, target_excess_margin: None,
1110 realised_pnl: None,
1111 unrealised_pnl: None,
1112 wallet_balance: Some(100000000),
1113 margin_balance: Some(100000000),
1114 margin_leverage: None,
1115 margin_used_pcnt: None,
1116 excess_margin: None,
1117 available_margin: Some(95000000), withdrawable_margin: None,
1119 maker_fee_discount: None,
1120 taker_fee_discount: None,
1121 timestamp: chrono::Utc::now(),
1122 foreign_margin_balance: Some(100000000), foreign_requirement: Some(5000000), };
1125
1126 let account_id = AccountId::new("BITMEX-123456");
1127 let ts_init = UnixNanos::from(1_000_000_000);
1128
1129 let account_state = parse_account_state(&margin_msg, account_id, ts_init)
1130 .expect("Failed to parse account state with foreign requirements");
1131
1132 let balance = &account_state.balances[0];
1134 assert_eq!(balance.currency, Currency::from("XBT"));
1135 assert_eq!(balance.total.as_f64(), 1.0);
1136 assert_eq!(balance.free.as_f64(), 0.95);
1137 assert_eq!(balance.locked.as_f64(), 0.05);
1138
1139 assert_eq!(account_state.margins.len(), 0);
1141 }
1142
1143 #[rstest]
1144 fn test_parse_margin_message_with_both_standard_and_foreign() {
1145 let margin_msg = BitmexMarginMsg {
1147 account: 123456,
1148 currency: Ustr::from("XBt"),
1149 risk_limit: Some(1000000000),
1150 amount: Some(100000000), prev_realised_pnl: None,
1152 gross_comm: None,
1153 gross_open_cost: None,
1154 gross_open_premium: None,
1155 gross_exec_cost: None,
1156 gross_mark_value: None,
1157 risk_value: None,
1158 init_margin: Some(2000000), maint_margin: Some(1000000), target_excess_margin: None,
1161 realised_pnl: None,
1162 unrealised_pnl: None,
1163 wallet_balance: Some(100000000),
1164 margin_balance: Some(100000000),
1165 margin_leverage: None,
1166 margin_used_pcnt: None,
1167 excess_margin: None,
1168 available_margin: Some(93000000), withdrawable_margin: None,
1170 maker_fee_discount: None,
1171 taker_fee_discount: None,
1172 timestamp: chrono::Utc::now(),
1173 foreign_margin_balance: Some(100000000),
1174 foreign_requirement: Some(5000000), };
1176
1177 let account_id = AccountId::new("BITMEX-123456");
1178 let ts_init = UnixNanos::from(1_000_000_000);
1179
1180 let account_state = parse_account_state(&margin_msg, account_id, ts_init)
1181 .expect("Failed to parse account state with both margins");
1182
1183 let balance = &account_state.balances[0];
1185 assert_eq!(balance.currency, Currency::from("XBT"));
1186 assert_eq!(balance.total.as_f64(), 1.0);
1187 assert_eq!(balance.free.as_f64(), 0.93);
1188 assert_eq!(balance.locked.as_f64(), 0.07); assert_eq!(account_state.margins.len(), 1);
1191 let xbt_margin = &account_state.margins[0];
1192 assert_eq!(xbt_margin.initial.as_f64(), 0.02); assert_eq!(xbt_margin.maintenance.as_f64(), 0.01); }
1195}