1use std::{borrow::Cow, str::FromStr};
19
20use jiff::Timestamp;
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(value: &Option<Timestamp>, field: &str) -> UnixNanos {
295 value
296 .map(|dt| {
297 UnixNanos::from(u64::try_from(dt.as_nanosecond()).unwrap_or_else(|_| {
298 log::error!("Invalid timestamp - out of range: field={field}, timestamp={dt:?}");
299 0
300 }))
301 })
302 .unwrap_or_default()
303}
304
305#[must_use]
307pub const fn parse_aggressor_side(side: &Option<BitmexSide>) -> AggressorSide {
308 match side {
309 Some(BitmexSide::Buy) => AggressorSide::Buy,
310 Some(BitmexSide::Sell) => AggressorSide::Sell,
311 None => AggressorSide::NoAggressor,
312 }
313}
314
315#[must_use]
317pub fn parse_liquidity_side(liquidity: &Option<BitmexLiquidityIndicator>) -> LiquiditySide {
318 liquidity.map_or(LiquiditySide::NoLiquiditySide, std::convert::Into::into)
319}
320
321#[must_use]
323pub const fn parse_position_side(current_qty: Option<i64>) -> PositionSide {
324 match current_qty {
325 Some(qty) if qty > 0 => PositionSide::Long,
326 Some(qty) if qty < 0 => PositionSide::Short,
327 _ => PositionSide::Flat,
328 }
329}
330
331#[must_use]
343pub fn map_bitmex_currency(bitmex_currency: &str) -> Cow<'static, str> {
344 match bitmex_currency {
345 "XBt" => Cow::Borrowed("XBT"),
346 "USDt" | "LAMp" => Cow::Borrowed("USDT"), "RLUSd" => Cow::Borrowed("RLUSD"),
348 "MAMUSd" => Cow::Borrowed("MAMUSD"),
349 other => Cow::Owned(other.to_uppercase()),
350 }
351}
352
353#[must_use]
355pub fn bitmex_currency_divisor(bitmex_currency: &str) -> Decimal {
356 match bitmex_currency {
357 "XBt" => Decimal::from(100_000_000),
358 "USDt" | "LAMp" | "MAMUSd" | "RLUSd" | "USYc" | "USYC" => Decimal::from(1_000_000),
359 _ => Decimal::ONE,
360 }
361}
362
363#[must_use]
365pub fn bitmex_account_id(account: i64) -> AccountId {
366 AccountId::new(format!("BITMEX-{account}"))
367}
368
369pub fn parse_account_balance(margin: &BitmexMarginMsg) -> AccountBalance {
371 log::debug!(
372 "Parsing margin: currency={}, wallet_balance={:?}, available_margin={:?}, init_margin={:?}, maint_margin={:?}",
373 margin.currency,
374 margin.wallet_balance,
375 margin.available_margin,
376 margin.init_margin,
377 margin.maint_margin,
378 );
379
380 let currency_str = map_bitmex_currency(&margin.currency);
381 let currency = parse_bitmex_margin_currency(¤cy_str);
382
383 let divisor = bitmex_currency_divisor(margin.currency.as_str());
385 let to_dec = |raw: i64| Decimal::from(raw) / divisor;
386
387 let total_dec = margin
389 .wallet_balance
390 .map(to_dec)
391 .or_else(|| margin.margin_balance.map(to_dec))
392 .or_else(|| margin.available_margin.map(to_dec))
393 .unwrap_or(Decimal::ZERO);
394
395 let free_dec = if let Some(withdrawable) = margin.withdrawable_margin {
399 to_dec(withdrawable)
400 } else if let Some(available) = margin.available_margin {
401 to_dec(available)
402 } else {
403 let margin_used = margin.init_margin.map_or(Decimal::ZERO, to_dec);
404 total_dec - margin_used
405 };
406
407 AccountBalance::from_total_and_free(total_dec, free_dec, currency).unwrap_or_else(|e| {
408 log::error!("Failed to build BitMEX account balance: {e}");
409 let zero = Money::zero(currency);
410 AccountBalance::new(zero, zero, zero)
411 })
412}
413
414fn parse_bitmex_margin_currency(currency_str: &str) -> Currency {
415 if let Some(currency) = known_bitmex_margin_currency(currency_str) {
416 return currency;
417 }
418
419 match Currency::try_from_str(currency_str) {
420 Some(c) => c,
421 None => {
422 log::warn!(
423 "Unknown currency '{currency_str}' in margin message, creating default crypto currency"
424 );
425 let currency = Currency::new(currency_str, 8, 0, currency_str, CurrencyType::Crypto);
426 if let Err(e) = Currency::register(currency, false) {
427 log::error!("Failed to register currency '{currency_str}': {e}");
428 }
429 currency
430 }
431 }
432}
433
434fn known_bitmex_margin_currency(currency_str: &str) -> Option<Currency> {
435 match currency_str {
436 "USYC" => {
437 let currency = Currency::new("USYC", 6, 0, "USYC", CurrencyType::Crypto);
438 if let Err(e) = Currency::register(currency, true) {
439 log::error!("Failed to register currency '{currency_str}': {e}");
440 }
441 Some(currency)
442 }
443 _ => None,
444 }
445}
446
447pub fn parse_account_state(
453 margin: &BitmexMarginMsg,
454 account_id: AccountId,
455 ts_init: UnixNanos,
456) -> anyhow::Result<AccountState> {
457 let balance = parse_account_balance(margin);
458 let balances = vec![balance];
459
460 let currency = balance.total.currency;
461 let mut margins = Vec::new();
462
463 let divisor = bitmex_currency_divisor(margin.currency.as_str());
464 let initial_dec = Decimal::from(margin.init_margin.unwrap_or(0).max(0)) / divisor;
465 let maintenance_dec = Decimal::from(margin.maint_margin.unwrap_or(0).max(0)) / divisor;
466
467 if !initial_dec.is_zero() || !maintenance_dec.is_zero() {
468 margins.push(MarginBalance::new(
470 Money::from_decimal(initial_dec, currency).unwrap_or_else(|_| Money::zero(currency)),
471 Money::from_decimal(maintenance_dec, currency)
472 .unwrap_or_else(|_| Money::zero(currency)),
473 None,
474 ));
475 }
476
477 let account_type = AccountType::Margin;
478 let is_reported = true;
479 let event_id = UUID4::new();
480 let ts_event =
481 UnixNanos::from(u64::try_from(margin.timestamp.as_nanosecond()).unwrap_or_default());
482
483 Ok(AccountState::new(
484 account_id,
485 account_type,
486 balances,
487 margins,
488 is_reported,
489 event_id,
490 ts_event,
491 ts_init,
492 None,
493 ))
494}
495
496pub fn parse_peg_price_type(params: Option<&Params>) -> anyhow::Result<Option<BitmexPegPriceType>> {
502 let value = params.and_then(|p| p.get_str("peg_price_type"));
503 match value {
504 Some(s) => BitmexPegPriceType::from_str(s)
505 .map(Some)
506 .map_err(|_| anyhow::anyhow!("Invalid peg_price_type: {s}")),
507 None => Ok(None),
508 }
509}
510
511pub fn parse_peg_offset_value(params: Option<&Params>) -> anyhow::Result<Option<f64>> {
517 let value = params.and_then(|p| p.get_str("peg_offset_value"));
518 match value {
519 Some(s) => s
520 .parse::<f64>()
521 .map(Some)
522 .map_err(|_| anyhow::anyhow!("Invalid peg_offset_value: {s}")),
523 None => Ok(None),
524 }
525}
526
527#[must_use]
535pub fn derive_trade_id(
536 symbol: Ustr,
537 ts_event_ns: u64,
538 price: f64,
539 size: i64,
540 side: Option<BitmexSide>,
541) -> TradeId {
542 let side_tag: &[u8] = match side {
543 Some(BitmexSide::Buy) => b"B",
544 Some(BitmexSide::Sell) => b"S",
545 None => b"N",
546 };
547
548 let mut hash: u64 = FNV_OFFSET_BASIS;
549
550 for bytes in [
551 symbol.as_str().as_bytes(),
552 b"\x1f",
553 &ts_event_ns.to_le_bytes(),
554 b"\x1f",
555 &price.to_bits().to_le_bytes(),
556 b"\x1f",
557 &size.to_le_bytes(),
558 b"\x1f",
559 side_tag,
560 ] {
561 for &byte in bytes {
562 hash ^= u64::from(byte);
563 hash = hash.wrapping_mul(FNV_PRIME);
564 }
565 }
566 TradeId::new(format!("{hash:016x}"))
567}
568
569#[cfg(test)]
570mod tests {
571 use jiff::{Timestamp, civil::Date, tz::Offset};
572 use nautilus_model::{instruments::CurrencyPair, types::fixed::FIXED_PRECISION};
573 use rstest::rstest;
574 use ustr::Ustr;
575
576 use super::*;
577
578 fn utc_timestamp(year: i16, month: i8, day: i8, hour: i8, minute: i8, second: i8) -> Timestamp {
579 Offset::UTC
580 .to_timestamp(
581 Date::new(year, month, day)
582 .unwrap()
583 .at(hour, minute, second, 0),
584 )
585 .unwrap()
586 }
587
588 #[rstest]
589 fn test_clean_reason_strips_nautilus_trader() {
590 assert_eq!(
591 clean_reason(
592 "Canceled: Order had execInst of ParticipateDoNotInitiate\nNautilusTrader"
593 ),
594 "Canceled: Order had execInst of ParticipateDoNotInitiate"
595 );
596
597 assert_eq!(clean_reason("Some error\nNautilusTrader"), "Some error");
598 assert_eq!(
599 clean_reason("Multiple lines\nSome content\nNautilusTrader"),
600 "Multiple lines\nSome content"
601 );
602 assert_eq!(clean_reason("No identifier here"), "No identifier here");
603 assert_eq!(clean_reason(" \nNautilusTrader "), "");
604 }
605
606 #[rstest]
607 fn test_derive_trade_id_is_deterministic_and_16_hex_chars() {
608 let first = 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 let second = derive_trade_id(
616 Ustr::from("XBTUSD"),
617 1_700_000_000_000_000_000,
618 98_570.9,
619 100,
620 Some(BitmexSide::Buy),
621 );
622 assert_eq!(first, second);
623 assert_eq!(first.as_str().len(), 16);
624 }
625
626 #[rstest]
627 #[case::symbol_changed(derive_trade_id(
628 Ustr::from("ETHUSD"),
629 1,
630 100.0,
631 1,
632 Some(BitmexSide::Buy)
633 ))]
634 #[case::ts_changed(derive_trade_id(Ustr::from("XBTUSD"), 2, 100.0, 1, Some(BitmexSide::Buy)))]
635 #[case::price_changed(derive_trade_id(
636 Ustr::from("XBTUSD"),
637 1,
638 101.0,
639 1,
640 Some(BitmexSide::Buy)
641 ))]
642 #[case::size_changed(derive_trade_id(
643 Ustr::from("XBTUSD"),
644 1,
645 100.0,
646 2,
647 Some(BitmexSide::Buy)
648 ))]
649 #[case::side_changed(derive_trade_id(
650 Ustr::from("XBTUSD"),
651 1,
652 100.0,
653 1,
654 Some(BitmexSide::Sell)
655 ))]
656 #[case::side_missing(derive_trade_id(Ustr::from("XBTUSD"), 1, 100.0, 1, None))]
657 fn test_derive_trade_id_each_field_affects_output(#[case] altered: TradeId) {
658 let baseline = derive_trade_id(Ustr::from("XBTUSD"), 1, 100.0, 1, Some(BitmexSide::Buy));
659 assert_ne!(baseline, altered);
660 }
661
662 #[rstest]
663 fn test_derive_trade_id_field_delimiter_prevents_collision() {
664 let a = derive_trade_id(Ustr::from("A"), 1, 0.0, 0, Some(BitmexSide::Buy));
668 let b = derive_trade_id(Ustr::from("A\0"), 256, 0.0, 0, Some(BitmexSide::Buy));
669 assert_ne!(a, b);
670 }
671
672 fn make_test_spot_instrument(size_increment: f64, size_precision: u8) -> InstrumentAny {
673 let instrument_id = InstrumentId::from("SOLUSDT.BITMEX");
674 let raw_symbol = Symbol::from("SOLUSDT");
675 let base_currency = Currency::from("SOL");
676 let quote_currency = Currency::from("USDT");
677 let price_precision = 2;
678 let price_increment = Price::new(0.01, price_precision);
679 let size_increment = Quantity::new(size_increment, size_precision);
680 let instrument = CurrencyPair::builder()
681 .instrument_id(instrument_id)
682 .raw_symbol(raw_symbol)
683 .base_currency(base_currency)
684 .quote_currency(quote_currency)
685 .price_precision(price_precision)
686 .size_precision(size_precision)
687 .price_increment(price_increment)
688 .size_increment(size_increment)
689 .ts_event(UnixNanos::from(0))
690 .ts_init(UnixNanos::from(0))
691 .build()
692 .unwrap();
693 InstrumentAny::CurrencyPair(instrument)
694 }
695
696 #[rstest]
697 fn test_quantity_to_u32_scaled() {
698 let instrument = make_test_spot_instrument(0.0001, 4);
699 let qty = Quantity::new(0.1, 4);
700 assert_eq!(quantity_to_u32(&qty, &instrument), 1_000);
701 }
702
703 #[rstest]
704 fn test_parse_contracts_quantity_scaled() {
705 let instrument = make_test_spot_instrument(0.0001, 4);
706 let qty = parse_contracts_quantity(1_000, &instrument);
707 assert!((qty.as_f64() - 0.1).abs() < 1e-9);
708 assert_eq!(qty.precision, 4);
709 }
710
711 #[rstest]
712 fn test_convert_contract_quantity_scaling() {
713 let max_scale = FIXED_PRECISION as u32;
714 let (contract_decimal, size_increment) =
715 derive_contract_decimal_and_increment(Some(10_000.0), max_scale).unwrap();
716 assert!((size_increment.as_f64() - 0.0001).abs() < 1e-12);
717
718 let lot_qty =
719 convert_contract_quantity(Some(1_000.0), contract_decimal, max_scale, "lot size")
720 .unwrap()
721 .unwrap();
722 assert!((lot_qty.as_f64() - 0.1).abs() < 1e-9);
723 assert_eq!(lot_qty.precision, 1);
724 }
725
726 #[rstest]
727 fn test_derive_contract_decimal_defaults_to_one() {
728 let max_scale = FIXED_PRECISION as u32;
729 let (contract_decimal, size_increment) =
730 derive_contract_decimal_and_increment(Some(0.0), max_scale).unwrap();
731 assert_eq!(contract_decimal, Decimal::ONE);
732 assert_eq!(size_increment.as_f64(), 1.0);
733 }
734
735 #[rstest]
736 fn test_parse_account_state() {
737 let margin_msg = BitmexMarginMsg {
738 account: 123456,
739 currency: Ustr::from("XBt"),
740 risk_limit: Some(1000000000),
741 amount: Some(5000000),
742 prev_realised_pnl: Some(100000),
743 gross_comm: Some(1000),
744 gross_open_cost: Some(200000),
745 gross_open_premium: None,
746 gross_exec_cost: None,
747 gross_mark_value: Some(210000),
748 risk_value: Some(50000),
749 init_margin: Some(20000),
750 maint_margin: Some(10000),
751 target_excess_margin: Some(5000),
752 realised_pnl: Some(100000),
753 unrealised_pnl: Some(10000),
754 wallet_balance: Some(5000000),
755 margin_balance: Some(5010000),
756 margin_leverage: Some(2.5),
757 margin_used_pcnt: Some(0.25),
758 excess_margin: Some(4990000),
759 available_margin: Some(4980000),
760 withdrawable_margin: Some(4900000),
761 maker_fee_discount: Some(0.1),
762 taker_fee_discount: Some(0.05),
763 timestamp: utc_timestamp(2024, 1, 1, 12, 0, 0),
764 foreign_margin_balance: None,
765 foreign_requirement: None,
766 };
767
768 let account_id = AccountId::new("BITMEX-001");
769 let ts_init = UnixNanos::from(1_000_000_000);
770
771 let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
772
773 assert_eq!(account_state.account_id, account_id);
774 assert_eq!(account_state.account_type, AccountType::Margin);
775 assert_eq!(account_state.balances.len(), 1);
776 assert_eq!(account_state.margins.len(), 1);
777 assert!(account_state.is_reported);
778
779 let xbt_balance = &account_state.balances[0];
780 assert_eq!(xbt_balance.currency, Currency::from("XBT"));
781 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];
786 assert_eq!(xbt_margin.initial.as_f64(), 0.0002); assert_eq!(xbt_margin.maintenance.as_f64(), 0.0001); }
789
790 #[rstest]
791 fn test_parse_account_state_usdt() {
792 let margin_msg = BitmexMarginMsg {
793 account: 123456,
794 currency: Ustr::from("USDt"),
795 risk_limit: Some(1000000000),
796 amount: Some(10000000000), prev_realised_pnl: None,
798 gross_comm: None,
799 gross_open_cost: None,
800 gross_open_premium: None,
801 gross_exec_cost: None,
802 gross_mark_value: None,
803 risk_value: None,
804 init_margin: Some(500000), maint_margin: Some(250000), target_excess_margin: None,
807 realised_pnl: None,
808 unrealised_pnl: None,
809 wallet_balance: Some(10000000000),
810 margin_balance: Some(10000000000),
811 margin_leverage: None,
812 margin_used_pcnt: None,
813 excess_margin: None,
814 available_margin: Some(9500000000), withdrawable_margin: None,
816 maker_fee_discount: None,
817 taker_fee_discount: None,
818 timestamp: utc_timestamp(2024, 1, 1, 12, 0, 0),
819 foreign_margin_balance: None,
820 foreign_requirement: None,
821 };
822
823 let account_id = AccountId::new("BITMEX-001");
824 let ts_init = UnixNanos::from(1_000_000_000);
825
826 let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
827
828 let usdt_balance = &account_state.balances[0];
829 assert_eq!(usdt_balance.currency, Currency::USDT());
830 assert_eq!(usdt_balance.total.as_f64(), 10000.0);
831 assert_eq!(usdt_balance.free.as_f64(), 9500.0);
832 assert_eq!(usdt_balance.locked.as_f64(), 500.0);
833
834 assert_eq!(account_state.margins.len(), 1);
835 let usdt_margin = &account_state.margins[0];
836 assert_eq!(usdt_margin.initial.as_f64(), 0.5); assert_eq!(usdt_margin.maintenance.as_f64(), 0.25); }
839
840 #[rstest]
841 fn test_parse_account_state_usyc_margin_currency() {
842 let margin_msg = BitmexMarginMsg {
843 account: 123456,
844 currency: Ustr::from("USYc"),
845 risk_limit: Some(1000000000),
846 amount: Some(100000000),
847 prev_realised_pnl: None,
848 gross_comm: None,
849 gross_open_cost: None,
850 gross_open_premium: None,
851 gross_exec_cost: None,
852 gross_mark_value: None,
853 risk_value: None,
854 init_margin: Some(500000),
855 maint_margin: Some(250000),
856 target_excess_margin: None,
857 realised_pnl: None,
858 unrealised_pnl: None,
859 wallet_balance: Some(100000000),
860 margin_balance: Some(100000000),
861 margin_leverage: None,
862 margin_used_pcnt: None,
863 excess_margin: None,
864 available_margin: Some(99000000),
865 withdrawable_margin: None,
866 maker_fee_discount: None,
867 taker_fee_discount: None,
868 timestamp: utc_timestamp(2024, 1, 1, 12, 0, 0),
869 foreign_margin_balance: None,
870 foreign_requirement: None,
871 };
872
873 let account_id = AccountId::new("BITMEX-001");
874 let ts_init = UnixNanos::from(1_000_000_000);
875
876 let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
877
878 let balance = &account_state.balances[0];
879 assert_eq!(balance.currency.code.as_str(), "USYC");
880 assert_eq!(balance.currency.precision, 6);
881 assert_eq!(balance.total.as_f64(), 100.0);
882 assert_eq!(balance.free.as_f64(), 99.0);
883 assert_eq!(balance.locked.as_f64(), 1.0);
884
885 assert_eq!(account_state.margins.len(), 1);
886 let margin = &account_state.margins[0];
887 assert_eq!(margin.currency.code.as_str(), "USYC");
888 assert_eq!(margin.initial.as_f64(), 0.5);
889 assert_eq!(margin.maintenance.as_f64(), 0.25);
890 }
891
892 #[rstest]
893 fn test_parse_account_balance_falls_back_to_margin_balance_when_wallet_absent() {
894 let margin_msg = BitmexMarginMsg {
898 account: 123456,
899 currency: Ustr::from("XBt"),
900 risk_limit: None,
901 amount: None,
902 prev_realised_pnl: None,
903 gross_comm: None,
904 gross_open_cost: None,
905 gross_open_premium: None,
906 gross_exec_cost: None,
907 gross_mark_value: None,
908 risk_value: None,
909 init_margin: Some(20000),
910 maint_margin: Some(10000),
911 target_excess_margin: None,
912 realised_pnl: None,
913 unrealised_pnl: None,
914 wallet_balance: None,
915 margin_balance: Some(5_010_000),
916 margin_leverage: None,
917 margin_used_pcnt: None,
918 excess_margin: None,
919 available_margin: Some(4_980_000),
920 withdrawable_margin: Some(4_900_000),
921 maker_fee_discount: None,
922 taker_fee_discount: None,
923 timestamp: utc_timestamp(2024, 1, 1, 12, 0, 0),
924 foreign_margin_balance: None,
925 foreign_requirement: None,
926 };
927
928 let balance = parse_account_balance(&margin_msg);
929
930 assert_eq!(balance.currency, Currency::from("XBT"));
931 assert!((balance.total.as_f64() - 0.0501).abs() < 1e-9);
933 assert!((balance.free.as_f64() - 0.049).abs() < 1e-9);
935 assert!((balance.locked.as_f64() - 0.0011).abs() < 1e-9);
937 }
938
939 #[rstest]
940 fn test_parse_margin_message_with_missing_fields() {
941 let margin_msg = BitmexMarginMsg {
943 account: 123456,
944 currency: Ustr::from("XBt"),
945 risk_limit: None,
946 amount: None,
947 prev_realised_pnl: None,
948 gross_comm: None,
949 gross_open_cost: None,
950 gross_open_premium: None,
951 gross_exec_cost: None,
952 gross_mark_value: None,
953 risk_value: None,
954 init_margin: None, maint_margin: None, target_excess_margin: None,
957 realised_pnl: None,
958 unrealised_pnl: None,
959 wallet_balance: Some(100000),
960 margin_balance: None,
961 margin_leverage: None,
962 margin_used_pcnt: None,
963 excess_margin: None,
964 available_margin: Some(95000),
965 withdrawable_margin: None,
966 maker_fee_discount: None,
967 taker_fee_discount: None,
968 timestamp: jiff::Timestamp::now(),
969 foreign_margin_balance: None,
970 foreign_requirement: None,
971 };
972
973 let account_id = AccountId::new("BITMEX-123456");
974 let ts_init = UnixNanos::from(1_000_000_000);
975
976 let account_state = parse_account_state(&margin_msg, account_id, ts_init)
977 .expect("Should parse even with missing margin fields");
978
979 assert_eq!(account_state.balances.len(), 1);
981 assert_eq!(account_state.margins.len(), 0); }
983
984 #[rstest]
985 fn test_parse_margin_message_with_only_available_margin() {
986 let margin_msg = BitmexMarginMsg {
988 account: 1667725,
989 currency: Ustr::from("USDt"),
990 risk_limit: None,
991 amount: None,
992 prev_realised_pnl: None,
993 gross_comm: None,
994 gross_open_cost: None,
995 gross_open_premium: None,
996 gross_exec_cost: None,
997 gross_mark_value: None,
998 risk_value: None,
999 init_margin: None,
1000 maint_margin: None,
1001 target_excess_margin: None,
1002 realised_pnl: None,
1003 unrealised_pnl: None,
1004 wallet_balance: None, margin_balance: None, margin_leverage: None,
1007 margin_used_pcnt: None,
1008 excess_margin: None,
1009 available_margin: Some(107859036), withdrawable_margin: None,
1011 maker_fee_discount: None,
1012 taker_fee_discount: None,
1013 timestamp: jiff::Timestamp::now(),
1014 foreign_margin_balance: None,
1015 foreign_requirement: None,
1016 };
1017
1018 let account_id = AccountId::new("BITMEX-1667725");
1019 let ts_init = UnixNanos::from(1_000_000_000);
1020
1021 let account_state = parse_account_state(&margin_msg, account_id, ts_init)
1022 .expect("Should handle case with only available_margin");
1023
1024 let balance = &account_state.balances[0];
1026 assert_eq!(balance.currency, Currency::USDT());
1027 assert_eq!(balance.total.as_f64(), 107.859036); assert_eq!(balance.free.as_f64(), 107.859036);
1029 assert_eq!(balance.locked.as_f64(), 0.0);
1030
1031 assert_eq!(balance.total, balance.locked + balance.free);
1033 }
1034
1035 #[rstest]
1036 fn test_parse_margin_available_exceeds_wallet() {
1037 let margin_msg = BitmexMarginMsg {
1039 account: 123456,
1040 currency: Ustr::from("XBt"),
1041 risk_limit: None,
1042 amount: Some(70772),
1043 prev_realised_pnl: None,
1044 gross_comm: None,
1045 gross_open_cost: None,
1046 gross_open_premium: None,
1047 gross_exec_cost: None,
1048 gross_mark_value: None,
1049 risk_value: None,
1050 init_margin: Some(0),
1051 maint_margin: Some(0),
1052 target_excess_margin: None,
1053 realised_pnl: None,
1054 unrealised_pnl: None,
1055 wallet_balance: Some(70772), margin_balance: None,
1057 margin_leverage: None,
1058 margin_used_pcnt: None,
1059 excess_margin: None,
1060 available_margin: Some(94381), withdrawable_margin: None,
1062 maker_fee_discount: None,
1063 taker_fee_discount: None,
1064 timestamp: jiff::Timestamp::now(),
1065 foreign_margin_balance: None,
1066 foreign_requirement: None,
1067 };
1068
1069 let account_id = AccountId::new("BITMEX-123456");
1070 let ts_init = UnixNanos::from(1_000_000_000);
1071
1072 let account_state = parse_account_state(&margin_msg, account_id, ts_init)
1073 .expect("Should handle available > wallet case");
1074
1075 let balance = &account_state.balances[0];
1077 assert_eq!(balance.currency, Currency::from("XBT"));
1078 assert_eq!(balance.total.as_f64(), 0.00070772); assert_eq!(balance.free.as_f64(), 0.00070772); assert_eq!(balance.locked.as_f64(), 0.0);
1081
1082 assert_eq!(balance.total, balance.locked + balance.free);
1084 }
1085
1086 #[rstest]
1087 fn test_parse_margin_message_with_foreign_requirements() {
1088 let margin_msg = BitmexMarginMsg {
1090 account: 123456,
1091 currency: Ustr::from("XBt"),
1092 risk_limit: Some(1000000000),
1093 amount: Some(100000000), prev_realised_pnl: None,
1095 gross_comm: None,
1096 gross_open_cost: None,
1097 gross_open_premium: None,
1098 gross_exec_cost: None,
1099 gross_mark_value: None,
1100 risk_value: None,
1101 init_margin: None, maint_margin: None, target_excess_margin: None,
1104 realised_pnl: None,
1105 unrealised_pnl: None,
1106 wallet_balance: Some(100000000),
1107 margin_balance: Some(100000000),
1108 margin_leverage: None,
1109 margin_used_pcnt: None,
1110 excess_margin: None,
1111 available_margin: Some(95000000), withdrawable_margin: None,
1113 maker_fee_discount: None,
1114 taker_fee_discount: None,
1115 timestamp: jiff::Timestamp::now(),
1116 foreign_margin_balance: Some(100000000), foreign_requirement: Some(5000000), };
1119
1120 let account_id = AccountId::new("BITMEX-123456");
1121 let ts_init = UnixNanos::from(1_000_000_000);
1122
1123 let account_state = parse_account_state(&margin_msg, account_id, ts_init)
1124 .expect("Failed to parse account state with foreign requirements");
1125
1126 let balance = &account_state.balances[0];
1128 assert_eq!(balance.currency, Currency::from("XBT"));
1129 assert_eq!(balance.total.as_f64(), 1.0);
1130 assert_eq!(balance.free.as_f64(), 0.95);
1131 assert_eq!(balance.locked.as_f64(), 0.05);
1132
1133 assert_eq!(account_state.margins.len(), 0);
1135 }
1136
1137 #[rstest]
1138 fn test_parse_margin_message_with_both_standard_and_foreign() {
1139 let margin_msg = BitmexMarginMsg {
1141 account: 123456,
1142 currency: Ustr::from("XBt"),
1143 risk_limit: Some(1000000000),
1144 amount: Some(100000000), prev_realised_pnl: None,
1146 gross_comm: None,
1147 gross_open_cost: None,
1148 gross_open_premium: None,
1149 gross_exec_cost: None,
1150 gross_mark_value: None,
1151 risk_value: None,
1152 init_margin: Some(2000000), maint_margin: Some(1000000), target_excess_margin: None,
1155 realised_pnl: None,
1156 unrealised_pnl: None,
1157 wallet_balance: Some(100000000),
1158 margin_balance: Some(100000000),
1159 margin_leverage: None,
1160 margin_used_pcnt: None,
1161 excess_margin: None,
1162 available_margin: Some(93000000), withdrawable_margin: None,
1164 maker_fee_discount: None,
1165 taker_fee_discount: None,
1166 timestamp: jiff::Timestamp::now(),
1167 foreign_margin_balance: Some(100000000),
1168 foreign_requirement: Some(5000000), };
1170
1171 let account_id = AccountId::new("BITMEX-123456");
1172 let ts_init = UnixNanos::from(1_000_000_000);
1173
1174 let account_state = parse_account_state(&margin_msg, account_id, ts_init)
1175 .expect("Failed to parse account state with both margins");
1176
1177 let balance = &account_state.balances[0];
1179 assert_eq!(balance.currency, Currency::from("XBT"));
1180 assert_eq!(balance.total.as_f64(), 1.0);
1181 assert_eq!(balance.free.as_f64(), 0.93);
1182 assert_eq!(balance.locked.as_f64(), 0.07); assert_eq!(account_state.margins.len(), 1);
1185 let xbt_margin = &account_state.margins[0];
1186 assert_eq!(xbt_margin.initial.as_f64(), 0.02); assert_eq!(xbt_margin.maintenance.as_f64(), 0.01); }
1189}