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]
345pub fn map_bitmex_currency(bitmex_currency: &str) -> Cow<'static, str> {
346 match bitmex_currency {
347 "XBt" => Cow::Borrowed("XBT"),
348 "USDt" | "LAMp" => Cow::Borrowed("USDT"), "RLUSd" => Cow::Borrowed("RLUSD"),
350 "MAMUSd" => Cow::Borrowed("MAMUSD"),
351 other => Cow::Owned(other.to_uppercase()),
352 }
353}
354
355#[must_use]
357pub fn bitmex_currency_divisor(bitmex_currency: &str) -> Decimal {
358 match bitmex_currency {
359 "XBt" => Decimal::from(100_000_000),
360 "USDt" | "LAMp" | "MAMUSd" | "RLUSd" => Decimal::from(1_000_000),
361 _ => Decimal::ONE,
362 }
363}
364
365pub fn parse_account_balance(margin: &BitmexMarginMsg) -> AccountBalance {
367 log::debug!(
368 "Parsing margin: currency={}, wallet_balance={:?}, available_margin={:?}, init_margin={:?}, maint_margin={:?}",
369 margin.currency,
370 margin.wallet_balance,
371 margin.available_margin,
372 margin.init_margin,
373 margin.maint_margin,
374 );
375
376 let currency_str = map_bitmex_currency(&margin.currency);
377
378 let currency = match Currency::try_from_str(¤cy_str) {
379 Some(c) => c,
380 None => {
381 log::warn!(
383 "Unknown currency '{currency_str}' in margin message, creating default crypto currency"
384 );
385 let currency = Currency::new(¤cy_str, 8, 0, ¤cy_str, CurrencyType::Crypto);
386 if let Err(e) = Currency::register(currency, false) {
387 log::error!("Failed to register currency '{currency_str}': {e}");
388 }
389 currency
390 }
391 };
392
393 let divisor = bitmex_currency_divisor(margin.currency.as_str());
395 let to_dec = |raw: i64| Decimal::from(raw) / divisor;
396
397 let total_dec = margin
399 .wallet_balance
400 .map(to_dec)
401 .or_else(|| margin.margin_balance.map(to_dec))
402 .or_else(|| margin.available_margin.map(to_dec))
403 .unwrap_or(Decimal::ZERO);
404
405 let free_dec = if let Some(withdrawable) = margin.withdrawable_margin {
409 to_dec(withdrawable)
410 } else if let Some(available) = margin.available_margin {
411 to_dec(available)
412 } else {
413 let margin_used = margin.init_margin.map_or(Decimal::ZERO, to_dec);
414 total_dec - margin_used
415 };
416
417 AccountBalance::from_total_and_free(total_dec, free_dec, currency).unwrap_or_else(|e| {
418 log::error!("Failed to build BitMEX account balance: {e}");
419 let zero = Money::zero(currency);
420 AccountBalance::new(zero, zero, zero)
421 })
422}
423
424pub fn parse_account_state(
430 margin: &BitmexMarginMsg,
431 account_id: AccountId,
432 ts_init: UnixNanos,
433) -> anyhow::Result<AccountState> {
434 let balance = parse_account_balance(margin);
435 let balances = vec![balance];
436
437 let currency = balance.total.currency;
438 let mut margins = Vec::new();
439
440 let divisor = bitmex_currency_divisor(margin.currency.as_str());
441 let initial_dec = Decimal::from(margin.init_margin.unwrap_or(0).max(0)) / divisor;
442 let maintenance_dec = Decimal::from(margin.maint_margin.unwrap_or(0).max(0)) / divisor;
443
444 if !initial_dec.is_zero() || !maintenance_dec.is_zero() {
445 margins.push(MarginBalance::new(
447 Money::from_decimal(initial_dec, currency).unwrap_or_else(|_| Money::zero(currency)),
448 Money::from_decimal(maintenance_dec, currency)
449 .unwrap_or_else(|_| Money::zero(currency)),
450 None,
451 ));
452 }
453
454 let account_type = AccountType::Margin;
455 let is_reported = true;
456 let event_id = UUID4::new();
457 let ts_event =
458 UnixNanos::from(margin.timestamp.timestamp_nanos_opt().unwrap_or_default() as u64);
459
460 Ok(AccountState::new(
461 account_id,
462 account_type,
463 balances,
464 margins,
465 is_reported,
466 event_id,
467 ts_event,
468 ts_init,
469 None,
470 ))
471}
472
473pub fn parse_peg_price_type(params: Option<&Params>) -> anyhow::Result<Option<BitmexPegPriceType>> {
479 let value = params.and_then(|p| p.get_str("peg_price_type"));
480 match value {
481 Some(s) => BitmexPegPriceType::from_str(s)
482 .map(Some)
483 .map_err(|_| anyhow::anyhow!("Invalid peg_price_type: {s}")),
484 None => Ok(None),
485 }
486}
487
488pub fn parse_peg_offset_value(params: Option<&Params>) -> anyhow::Result<Option<f64>> {
494 let value = params.and_then(|p| p.get_str("peg_offset_value"));
495 match value {
496 Some(s) => s
497 .parse::<f64>()
498 .map(Some)
499 .map_err(|_| anyhow::anyhow!("Invalid peg_offset_value: {s}")),
500 None => Ok(None),
501 }
502}
503
504#[must_use]
512pub fn derive_trade_id(
513 symbol: Ustr,
514 ts_event_ns: u64,
515 price: f64,
516 size: i64,
517 side: Option<BitmexSide>,
518) -> TradeId {
519 let side_tag: &[u8] = match side {
520 Some(BitmexSide::Buy) => b"B",
521 Some(BitmexSide::Sell) => b"S",
522 None => b"N",
523 };
524
525 let mut hash: u64 = FNV_OFFSET_BASIS;
526
527 for bytes in [
528 symbol.as_str().as_bytes(),
529 b"\x1f",
530 &ts_event_ns.to_le_bytes(),
531 b"\x1f",
532 &price.to_bits().to_le_bytes(),
533 b"\x1f",
534 &size.to_le_bytes(),
535 b"\x1f",
536 side_tag,
537 ] {
538 for &byte in bytes {
539 hash ^= u64::from(byte);
540 hash = hash.wrapping_mul(FNV_PRIME);
541 }
542 }
543 TradeId::new(format!("{hash:016x}"))
544}
545
546#[cfg(test)]
547mod tests {
548 use chrono::TimeZone;
549 use nautilus_model::{instruments::CurrencyPair, types::fixed::FIXED_PRECISION};
550 use rstest::rstest;
551 use ustr::Ustr;
552
553 use super::*;
554
555 #[rstest]
556 fn test_clean_reason_strips_nautilus_trader() {
557 assert_eq!(
558 clean_reason(
559 "Canceled: Order had execInst of ParticipateDoNotInitiate\nNautilusTrader"
560 ),
561 "Canceled: Order had execInst of ParticipateDoNotInitiate"
562 );
563
564 assert_eq!(clean_reason("Some error\nNautilusTrader"), "Some error");
565 assert_eq!(
566 clean_reason("Multiple lines\nSome content\nNautilusTrader"),
567 "Multiple lines\nSome content"
568 );
569 assert_eq!(clean_reason("No identifier here"), "No identifier here");
570 assert_eq!(clean_reason(" \nNautilusTrader "), "");
571 }
572
573 #[rstest]
574 fn test_derive_trade_id_is_deterministic_and_16_hex_chars() {
575 let first = derive_trade_id(
576 Ustr::from("XBTUSD"),
577 1_700_000_000_000_000_000,
578 98_570.9,
579 100,
580 Some(BitmexSide::Buy),
581 );
582 let second = derive_trade_id(
583 Ustr::from("XBTUSD"),
584 1_700_000_000_000_000_000,
585 98_570.9,
586 100,
587 Some(BitmexSide::Buy),
588 );
589 assert_eq!(first, second);
590 assert_eq!(first.as_str().len(), 16);
591 }
592
593 #[rstest]
594 #[case::symbol_changed(derive_trade_id(
595 Ustr::from("ETHUSD"),
596 1,
597 100.0,
598 1,
599 Some(BitmexSide::Buy)
600 ))]
601 #[case::ts_changed(derive_trade_id(Ustr::from("XBTUSD"), 2, 100.0, 1, Some(BitmexSide::Buy)))]
602 #[case::price_changed(derive_trade_id(
603 Ustr::from("XBTUSD"),
604 1,
605 101.0,
606 1,
607 Some(BitmexSide::Buy)
608 ))]
609 #[case::size_changed(derive_trade_id(
610 Ustr::from("XBTUSD"),
611 1,
612 100.0,
613 2,
614 Some(BitmexSide::Buy)
615 ))]
616 #[case::side_changed(derive_trade_id(
617 Ustr::from("XBTUSD"),
618 1,
619 100.0,
620 1,
621 Some(BitmexSide::Sell)
622 ))]
623 #[case::side_missing(derive_trade_id(Ustr::from("XBTUSD"), 1, 100.0, 1, None))]
624 fn test_derive_trade_id_each_field_affects_output(#[case] altered: TradeId) {
625 let baseline = derive_trade_id(Ustr::from("XBTUSD"), 1, 100.0, 1, Some(BitmexSide::Buy));
626 assert_ne!(baseline, altered);
627 }
628
629 #[rstest]
630 fn test_derive_trade_id_field_delimiter_prevents_collision() {
631 let a = derive_trade_id(Ustr::from("A"), 1, 0.0, 0, Some(BitmexSide::Buy));
635 let b = derive_trade_id(Ustr::from("A\0"), 256, 0.0, 0, Some(BitmexSide::Buy));
636 assert_ne!(a, b);
637 }
638
639 fn make_test_spot_instrument(size_increment: f64, size_precision: u8) -> InstrumentAny {
640 let instrument_id = InstrumentId::from("SOLUSDT.BITMEX");
641 let raw_symbol = Symbol::from("SOLUSDT");
642 let base_currency = Currency::from("SOL");
643 let quote_currency = Currency::from("USDT");
644 let price_precision = 2;
645 let price_increment = Price::new(0.01, price_precision);
646 let size_increment = Quantity::new(size_increment, size_precision);
647 let instrument = CurrencyPair::new(
648 instrument_id,
649 raw_symbol,
650 base_currency,
651 quote_currency,
652 price_precision,
653 size_precision,
654 price_increment,
655 size_increment,
656 None, None, None, None, None, None, None, None, None, None, None, None, None, UnixNanos::from(0),
670 UnixNanos::from(0),
671 );
672 InstrumentAny::CurrencyPair(instrument)
673 }
674
675 #[rstest]
676 fn test_quantity_to_u32_scaled() {
677 let instrument = make_test_spot_instrument(0.0001, 4);
678 let qty = Quantity::new(0.1, 4);
679 assert_eq!(quantity_to_u32(&qty, &instrument), 1_000);
680 }
681
682 #[rstest]
683 fn test_parse_contracts_quantity_scaled() {
684 let instrument = make_test_spot_instrument(0.0001, 4);
685 let qty = parse_contracts_quantity(1_000, &instrument);
686 assert!((qty.as_f64() - 0.1).abs() < 1e-9);
687 assert_eq!(qty.precision, 4);
688 }
689
690 #[rstest]
691 fn test_convert_contract_quantity_scaling() {
692 let max_scale = FIXED_PRECISION as u32;
693 let (contract_decimal, size_increment) =
694 derive_contract_decimal_and_increment(Some(10_000.0), max_scale).unwrap();
695 assert!((size_increment.as_f64() - 0.0001).abs() < 1e-12);
696
697 let lot_qty =
698 convert_contract_quantity(Some(1_000.0), contract_decimal, max_scale, "lot size")
699 .unwrap()
700 .unwrap();
701 assert!((lot_qty.as_f64() - 0.1).abs() < 1e-9);
702 assert_eq!(lot_qty.precision, 1);
703 }
704
705 #[rstest]
706 fn test_derive_contract_decimal_defaults_to_one() {
707 let max_scale = FIXED_PRECISION as u32;
708 let (contract_decimal, size_increment) =
709 derive_contract_decimal_and_increment(Some(0.0), max_scale).unwrap();
710 assert_eq!(contract_decimal, Decimal::ONE);
711 assert_eq!(size_increment.as_f64(), 1.0);
712 }
713
714 #[rstest]
715 fn test_parse_account_state() {
716 let margin_msg = BitmexMarginMsg {
717 account: 123456,
718 currency: Ustr::from("XBt"),
719 risk_limit: Some(1000000000),
720 amount: Some(5000000),
721 prev_realised_pnl: Some(100000),
722 gross_comm: Some(1000),
723 gross_open_cost: Some(200000),
724 gross_open_premium: None,
725 gross_exec_cost: None,
726 gross_mark_value: Some(210000),
727 risk_value: Some(50000),
728 init_margin: Some(20000),
729 maint_margin: Some(10000),
730 target_excess_margin: Some(5000),
731 realised_pnl: Some(100000),
732 unrealised_pnl: Some(10000),
733 wallet_balance: Some(5000000),
734 margin_balance: Some(5010000),
735 margin_leverage: Some(2.5),
736 margin_used_pcnt: Some(0.25),
737 excess_margin: Some(4990000),
738 available_margin: Some(4980000),
739 withdrawable_margin: Some(4900000),
740 maker_fee_discount: Some(0.1),
741 taker_fee_discount: Some(0.05),
742 timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
743 foreign_margin_balance: None,
744 foreign_requirement: None,
745 };
746
747 let account_id = AccountId::new("BITMEX-001");
748 let ts_init = UnixNanos::from(1_000_000_000);
749
750 let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
751
752 assert_eq!(account_state.account_id, account_id);
753 assert_eq!(account_state.account_type, AccountType::Margin);
754 assert_eq!(account_state.balances.len(), 1);
755 assert_eq!(account_state.margins.len(), 1);
756 assert!(account_state.is_reported);
757
758 let xbt_balance = &account_state.balances[0];
759 assert_eq!(xbt_balance.currency, Currency::from("XBT"));
760 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];
765 assert_eq!(xbt_margin.initial.as_f64(), 0.0002); assert_eq!(xbt_margin.maintenance.as_f64(), 0.0001); }
768
769 #[rstest]
770 fn test_parse_account_state_usdt() {
771 let margin_msg = BitmexMarginMsg {
772 account: 123456,
773 currency: Ustr::from("USDt"),
774 risk_limit: Some(1000000000),
775 amount: Some(10000000000), prev_realised_pnl: None,
777 gross_comm: None,
778 gross_open_cost: None,
779 gross_open_premium: None,
780 gross_exec_cost: None,
781 gross_mark_value: None,
782 risk_value: None,
783 init_margin: Some(500000), maint_margin: Some(250000), target_excess_margin: None,
786 realised_pnl: None,
787 unrealised_pnl: None,
788 wallet_balance: Some(10000000000),
789 margin_balance: Some(10000000000),
790 margin_leverage: None,
791 margin_used_pcnt: None,
792 excess_margin: None,
793 available_margin: Some(9500000000), withdrawable_margin: None,
795 maker_fee_discount: None,
796 taker_fee_discount: None,
797 timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
798 foreign_margin_balance: None,
799 foreign_requirement: None,
800 };
801
802 let account_id = AccountId::new("BITMEX-001");
803 let ts_init = UnixNanos::from(1_000_000_000);
804
805 let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
806
807 let usdt_balance = &account_state.balances[0];
808 assert_eq!(usdt_balance.currency, Currency::USDT());
809 assert_eq!(usdt_balance.total.as_f64(), 10000.0);
810 assert_eq!(usdt_balance.free.as_f64(), 9500.0);
811 assert_eq!(usdt_balance.locked.as_f64(), 500.0);
812
813 assert_eq!(account_state.margins.len(), 1);
814 let usdt_margin = &account_state.margins[0];
815 assert_eq!(usdt_margin.initial.as_f64(), 0.5); assert_eq!(usdt_margin.maintenance.as_f64(), 0.25); }
818
819 #[rstest]
820 fn test_parse_account_balance_falls_back_to_margin_balance_when_wallet_absent() {
821 let margin_msg = BitmexMarginMsg {
825 account: 123456,
826 currency: Ustr::from("XBt"),
827 risk_limit: None,
828 amount: None,
829 prev_realised_pnl: None,
830 gross_comm: None,
831 gross_open_cost: None,
832 gross_open_premium: None,
833 gross_exec_cost: None,
834 gross_mark_value: None,
835 risk_value: None,
836 init_margin: Some(20000),
837 maint_margin: Some(10000),
838 target_excess_margin: None,
839 realised_pnl: None,
840 unrealised_pnl: None,
841 wallet_balance: None,
842 margin_balance: Some(5_010_000),
843 margin_leverage: None,
844 margin_used_pcnt: None,
845 excess_margin: None,
846 available_margin: Some(4_980_000),
847 withdrawable_margin: Some(4_900_000),
848 maker_fee_discount: None,
849 taker_fee_discount: None,
850 timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
851 foreign_margin_balance: None,
852 foreign_requirement: None,
853 };
854
855 let balance = parse_account_balance(&margin_msg);
856
857 assert_eq!(balance.currency, Currency::from("XBT"));
858 assert!((balance.total.as_f64() - 0.0501).abs() < 1e-9);
860 assert!((balance.free.as_f64() - 0.049).abs() < 1e-9);
862 assert!((balance.locked.as_f64() - 0.0011).abs() < 1e-9);
864 }
865
866 #[rstest]
867 fn test_parse_margin_message_with_missing_fields() {
868 let margin_msg = BitmexMarginMsg {
870 account: 123456,
871 currency: Ustr::from("XBt"),
872 risk_limit: None,
873 amount: None,
874 prev_realised_pnl: None,
875 gross_comm: None,
876 gross_open_cost: None,
877 gross_open_premium: None,
878 gross_exec_cost: None,
879 gross_mark_value: None,
880 risk_value: None,
881 init_margin: None, maint_margin: None, target_excess_margin: None,
884 realised_pnl: None,
885 unrealised_pnl: None,
886 wallet_balance: Some(100000),
887 margin_balance: None,
888 margin_leverage: None,
889 margin_used_pcnt: None,
890 excess_margin: None,
891 available_margin: Some(95000),
892 withdrawable_margin: None,
893 maker_fee_discount: None,
894 taker_fee_discount: None,
895 timestamp: chrono::Utc::now(),
896 foreign_margin_balance: None,
897 foreign_requirement: None,
898 };
899
900 let account_id = AccountId::new("BITMEX-123456");
901 let ts_init = UnixNanos::from(1_000_000_000);
902
903 let account_state = parse_account_state(&margin_msg, account_id, ts_init)
904 .expect("Should parse even with missing margin fields");
905
906 assert_eq!(account_state.balances.len(), 1);
908 assert_eq!(account_state.margins.len(), 0); }
910
911 #[rstest]
912 fn test_parse_margin_message_with_only_available_margin() {
913 let margin_msg = BitmexMarginMsg {
915 account: 1667725,
916 currency: Ustr::from("USDt"),
917 risk_limit: None,
918 amount: None,
919 prev_realised_pnl: None,
920 gross_comm: None,
921 gross_open_cost: None,
922 gross_open_premium: None,
923 gross_exec_cost: None,
924 gross_mark_value: None,
925 risk_value: None,
926 init_margin: None,
927 maint_margin: None,
928 target_excess_margin: None,
929 realised_pnl: None,
930 unrealised_pnl: None,
931 wallet_balance: None, margin_balance: None, margin_leverage: None,
934 margin_used_pcnt: None,
935 excess_margin: None,
936 available_margin: Some(107859036), withdrawable_margin: None,
938 maker_fee_discount: None,
939 taker_fee_discount: None,
940 timestamp: chrono::Utc::now(),
941 foreign_margin_balance: None,
942 foreign_requirement: None,
943 };
944
945 let account_id = AccountId::new("BITMEX-1667725");
946 let ts_init = UnixNanos::from(1_000_000_000);
947
948 let account_state = parse_account_state(&margin_msg, account_id, ts_init)
949 .expect("Should handle case with only available_margin");
950
951 let balance = &account_state.balances[0];
953 assert_eq!(balance.currency, Currency::USDT());
954 assert_eq!(balance.total.as_f64(), 107.859036); assert_eq!(balance.free.as_f64(), 107.859036);
956 assert_eq!(balance.locked.as_f64(), 0.0);
957
958 assert_eq!(balance.total, balance.locked + balance.free);
960 }
961
962 #[rstest]
963 fn test_parse_margin_available_exceeds_wallet() {
964 let margin_msg = BitmexMarginMsg {
966 account: 123456,
967 currency: Ustr::from("XBt"),
968 risk_limit: None,
969 amount: Some(70772),
970 prev_realised_pnl: None,
971 gross_comm: None,
972 gross_open_cost: None,
973 gross_open_premium: None,
974 gross_exec_cost: None,
975 gross_mark_value: None,
976 risk_value: None,
977 init_margin: Some(0),
978 maint_margin: Some(0),
979 target_excess_margin: None,
980 realised_pnl: None,
981 unrealised_pnl: None,
982 wallet_balance: Some(70772), margin_balance: None,
984 margin_leverage: None,
985 margin_used_pcnt: None,
986 excess_margin: None,
987 available_margin: Some(94381), withdrawable_margin: None,
989 maker_fee_discount: None,
990 taker_fee_discount: None,
991 timestamp: chrono::Utc::now(),
992 foreign_margin_balance: None,
993 foreign_requirement: None,
994 };
995
996 let account_id = AccountId::new("BITMEX-123456");
997 let ts_init = UnixNanos::from(1_000_000_000);
998
999 let account_state = parse_account_state(&margin_msg, account_id, ts_init)
1000 .expect("Should handle available > wallet case");
1001
1002 let balance = &account_state.balances[0];
1004 assert_eq!(balance.currency, Currency::from("XBT"));
1005 assert_eq!(balance.total.as_f64(), 0.00070772); assert_eq!(balance.free.as_f64(), 0.00070772); assert_eq!(balance.locked.as_f64(), 0.0);
1008
1009 assert_eq!(balance.total, balance.locked + balance.free);
1011 }
1012
1013 #[rstest]
1014 fn test_parse_margin_message_with_foreign_requirements() {
1015 let margin_msg = BitmexMarginMsg {
1017 account: 123456,
1018 currency: Ustr::from("XBt"),
1019 risk_limit: Some(1000000000),
1020 amount: Some(100000000), prev_realised_pnl: None,
1022 gross_comm: None,
1023 gross_open_cost: None,
1024 gross_open_premium: None,
1025 gross_exec_cost: None,
1026 gross_mark_value: None,
1027 risk_value: None,
1028 init_margin: None, maint_margin: None, target_excess_margin: None,
1031 realised_pnl: None,
1032 unrealised_pnl: None,
1033 wallet_balance: Some(100000000),
1034 margin_balance: Some(100000000),
1035 margin_leverage: None,
1036 margin_used_pcnt: None,
1037 excess_margin: None,
1038 available_margin: Some(95000000), withdrawable_margin: None,
1040 maker_fee_discount: None,
1041 taker_fee_discount: None,
1042 timestamp: chrono::Utc::now(),
1043 foreign_margin_balance: Some(100000000), foreign_requirement: Some(5000000), };
1046
1047 let account_id = AccountId::new("BITMEX-123456");
1048 let ts_init = UnixNanos::from(1_000_000_000);
1049
1050 let account_state = parse_account_state(&margin_msg, account_id, ts_init)
1051 .expect("Failed to parse account state with foreign requirements");
1052
1053 let balance = &account_state.balances[0];
1055 assert_eq!(balance.currency, Currency::from("XBT"));
1056 assert_eq!(balance.total.as_f64(), 1.0);
1057 assert_eq!(balance.free.as_f64(), 0.95);
1058 assert_eq!(balance.locked.as_f64(), 0.05);
1059
1060 assert_eq!(account_state.margins.len(), 0);
1062 }
1063
1064 #[rstest]
1065 fn test_parse_margin_message_with_both_standard_and_foreign() {
1066 let margin_msg = BitmexMarginMsg {
1068 account: 123456,
1069 currency: Ustr::from("XBt"),
1070 risk_limit: Some(1000000000),
1071 amount: Some(100000000), prev_realised_pnl: None,
1073 gross_comm: None,
1074 gross_open_cost: None,
1075 gross_open_premium: None,
1076 gross_exec_cost: None,
1077 gross_mark_value: None,
1078 risk_value: None,
1079 init_margin: Some(2000000), maint_margin: Some(1000000), target_excess_margin: None,
1082 realised_pnl: None,
1083 unrealised_pnl: None,
1084 wallet_balance: Some(100000000),
1085 margin_balance: Some(100000000),
1086 margin_leverage: None,
1087 margin_used_pcnt: None,
1088 excess_margin: None,
1089 available_margin: Some(93000000), withdrawable_margin: None,
1091 maker_fee_discount: None,
1092 taker_fee_discount: None,
1093 timestamp: chrono::Utc::now(),
1094 foreign_margin_balance: Some(100000000),
1095 foreign_requirement: Some(5000000), };
1097
1098 let account_id = AccountId::new("BITMEX-123456");
1099 let ts_init = UnixNanos::from(1_000_000_000);
1100
1101 let account_state = parse_account_state(&margin_msg, account_id, ts_init)
1102 .expect("Failed to parse account state with both margins");
1103
1104 let balance = &account_state.balances[0];
1106 assert_eq!(balance.currency, Currency::from("XBT"));
1107 assert_eq!(balance.total.as_f64(), 1.0);
1108 assert_eq!(balance.free.as_f64(), 0.93);
1109 assert_eq!(balance.locked.as_f64(), 0.07); assert_eq!(account_state.margins.len(), 1);
1112 let xbt_margin = &account_state.margins[0];
1113 assert_eq!(xbt_margin.initial.as_f64(), 0.02); assert_eq!(xbt_margin.maintenance.as_f64(), 0.01); }
1116}