nautilus_bitmex/common/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use chrono::{DateTime, Utc};
17use nautilus_core::{nanos::UnixNanos, uuid::UUID4};
18use nautilus_model::{
19    enums::{AccountType, AggressorSide, LiquiditySide, PositionSide},
20    events::AccountState,
21    identifiers::{AccountId, InstrumentId, Symbol},
22    types::{AccountBalance, Currency, Money, QUANTITY_MAX, Quantity},
23};
24use ustr::Ustr;
25
26use crate::{
27    common::{
28        consts::BITMEX_VENUE,
29        enums::{BitmexLiquidityIndicator, BitmexSide},
30    },
31    websocket::messages::BitmexMarginMsg,
32};
33
34/// Parses a Nautilus instrument ID from the given BitMEX `symbol` value.
35#[must_use]
36pub fn parse_instrument_id(symbol: Ustr) -> InstrumentId {
37    InstrumentId::new(Symbol::from_ustr_unchecked(symbol), *BITMEX_VENUE)
38}
39
40/// Safely converts a Quantity to u32 for BitMEX API.
41///
42/// Logs a warning if truncation occurs.
43#[must_use]
44pub fn quantity_to_u32(quantity: &Quantity) -> u32 {
45    let value = quantity.as_f64();
46    if value > u32::MAX as f64 {
47        tracing::warn!(
48            "Quantity {value} exceeds u32::MAX, clamping to {}",
49            u32::MAX
50        );
51        u32::MAX
52    } else if value < 0.0 {
53        tracing::warn!("Quantity {value} is negative, using 0");
54        0
55    } else {
56        value as u32
57    }
58}
59
60#[must_use]
61pub fn parse_contracts_quantity(value: u64) -> Quantity {
62    let size_workaround = std::cmp::min(QUANTITY_MAX as u64, value);
63    // TODO: Log with more visibility for now
64    if value > QUANTITY_MAX as u64 {
65        tracing::warn!(
66            "Quantity value {value} exceeds QUANTITY_MAX {QUANTITY_MAX}, clamping to maximum",
67        );
68    }
69    Quantity::new(size_workaround as f64, 0)
70}
71
72#[must_use]
73pub fn parse_frac_quantity(value: f64, size_precision: u8) -> Quantity {
74    let value_u64 = value as u64;
75    let size_workaround = std::cmp::min(QUANTITY_MAX as u64, value as u64);
76    // TODO: Log with more visibility for now
77    if value_u64 > QUANTITY_MAX as u64 {
78        tracing::warn!(
79            "Quantity value {value} exceeds QUANTITY_MAX {QUANTITY_MAX}, clamping to maximum",
80        );
81    }
82    Quantity::new(size_workaround as f64, size_precision)
83}
84
85/// Parses the given datetime (UTC) into a `UnixNanos` timestamp.
86/// If `value` is `None`, then defaults to the UNIX epoch (0 nanoseconds).
87///
88/// Returns epoch (0) for invalid timestamps that cannot be converted to nanoseconds.
89#[must_use]
90pub fn parse_optional_datetime_to_unix_nanos(
91    value: &Option<DateTime<Utc>>,
92    field: &str,
93) -> UnixNanos {
94    value
95        .map(|dt| {
96            UnixNanos::from(dt.timestamp_nanos_opt().unwrap_or_else(|| {
97                tracing::error!(field = field, timestamp = ?dt, "Invalid timestamp - out of range");
98                0
99            }) as u64)
100        })
101        .unwrap_or_default()
102}
103
104#[must_use]
105pub const fn parse_aggressor_side(side: &Option<BitmexSide>) -> AggressorSide {
106    match side {
107        Some(BitmexSide::Buy) => AggressorSide::Buyer,
108        Some(BitmexSide::Sell) => AggressorSide::Seller,
109        None => AggressorSide::NoAggressor,
110    }
111}
112
113#[must_use]
114pub fn parse_liquidity_side(liquidity: &Option<BitmexLiquidityIndicator>) -> LiquiditySide {
115    liquidity
116        .map(std::convert::Into::into)
117        .unwrap_or(LiquiditySide::NoLiquiditySide)
118}
119
120#[must_use]
121pub const fn parse_position_side(current_qty: Option<i64>) -> PositionSide {
122    match current_qty {
123        Some(qty) if qty > 0 => PositionSide::Long,
124        Some(qty) if qty < 0 => PositionSide::Short,
125        _ => PositionSide::Flat,
126    }
127}
128
129/// Maps BitMEX currency codes to standard Nautilus currency codes.
130///
131/// BitMEX uses some non-standard currency codes:
132/// - "XBt" -> "XBT" (Bitcoin)
133/// - "USDt" -> "USDT" (Tether)
134/// - "LAMp" -> "USDT" (Test currency, mapped to USDT)
135///
136/// For other currencies, converts to uppercase.
137#[must_use]
138pub fn map_bitmex_currency(bitmex_currency: &str) -> String {
139    match bitmex_currency {
140        "XBt" => "XBT".to_string(),
141        "USDt" => "USDT".to_string(),
142        "LAMp" => "USDT".to_string(), // Map test currency to USDT
143        other => other.to_uppercase(),
144    }
145}
146
147/// Parses a BitMEX margin message into a Nautilus account state.
148///
149/// # Errors
150///
151/// Returns an error if the margin data cannot be parsed into valid balance values.
152pub fn parse_account_state(
153    margin: &BitmexMarginMsg,
154    account_id: AccountId,
155    ts_init: UnixNanos,
156) -> anyhow::Result<AccountState> {
157    // Map BitMEX currency to standard currency code
158    let currency_str = map_bitmex_currency(&margin.currency);
159    let currency = Currency::from(currency_str.as_str());
160
161    // BitMEX returns values in satoshis for BTC (XBt) or microunits for USDT/LAMp
162    // We need to convert to the actual value
163    let divisor = if margin.currency == "XBt" {
164        100_000_000.0 // Satoshis to BTC
165    } else if margin.currency == "USDt" || margin.currency == "LAMp" {
166        1_000_000.0 // Microunits to units
167    } else {
168        1.0
169    };
170
171    // Calculate total balance from wallet balance
172    let total = if let Some(wallet_balance) = margin.wallet_balance {
173        Money::new(wallet_balance as f64 / divisor, currency)
174    } else {
175        Money::new(0.0, currency)
176    };
177
178    // Calculate free balance from available margin
179    let free = if let Some(available_margin) = margin.available_margin {
180        Money::new(available_margin as f64 / divisor, currency)
181    } else {
182        Money::new(0.0, currency)
183    };
184
185    // Calculate locked balance as the difference
186    let locked = total - free;
187
188    let balance = AccountBalance::new(total, locked, free);
189    let balances = vec![balance];
190    let margins = vec![]; // BitMEX margin info is already in the balances
191
192    let account_type = AccountType::Margin;
193    let is_reported = true;
194    let event_id = UUID4::new();
195    let ts_event =
196        UnixNanos::from(margin.timestamp.timestamp_nanos_opt().unwrap_or_default() as u64);
197
198    Ok(AccountState::new(
199        account_id,
200        account_type,
201        balances,
202        margins,
203        is_reported,
204        event_id,
205        ts_event,
206        ts_init,
207        None,
208    ))
209}
210
211////////////////////////////////////////////////////////////////////////////////
212// Tests
213////////////////////////////////////////////////////////////////////////////////
214
215#[cfg(test)]
216mod tests {
217    use chrono::TimeZone;
218    use nautilus_model::enums::AccountType;
219    use rstest::rstest;
220    use ustr::Ustr;
221
222    use super::*;
223
224    #[rstest]
225    fn test_parse_account_state() {
226        let margin_msg = BitmexMarginMsg {
227            account: 123456,
228            currency: Ustr::from("XBt"),
229            risk_limit: Some(1000000000),
230            amount: Some(5000000),
231            prev_realised_pnl: Some(100000),
232            gross_comm: Some(1000),
233            gross_open_cost: Some(200000),
234            gross_open_premium: None,
235            gross_exec_cost: None,
236            gross_mark_value: Some(210000),
237            risk_value: Some(50000),
238            init_margin: Some(20000),
239            maint_margin: Some(10000),
240            target_excess_margin: Some(5000),
241            realised_pnl: Some(100000),
242            unrealised_pnl: Some(10000),
243            wallet_balance: Some(5000000),
244            margin_balance: Some(5010000),
245            margin_leverage: Some(2.5),
246            margin_used_pcnt: Some(0.25),
247            excess_margin: Some(4990000),
248            available_margin: Some(4980000),
249            withdrawable_margin: Some(4900000),
250            maker_fee_discount: Some(0.1),
251            taker_fee_discount: Some(0.05),
252            timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
253            foreign_margin_balance: None,
254            foreign_requirement: None,
255        };
256
257        let account_id = AccountId::new("BITMEX-001");
258        let ts_init = UnixNanos::from(1_000_000_000);
259
260        let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
261
262        assert_eq!(account_state.account_id, account_id);
263        assert_eq!(account_state.account_type, AccountType::Margin);
264        assert_eq!(account_state.balances.len(), 1);
265        assert_eq!(account_state.margins.len(), 0);
266        assert!(account_state.is_reported);
267
268        // Check XBT balance (converted from satoshis)
269        let xbt_balance = &account_state.balances[0];
270        assert_eq!(xbt_balance.currency, Currency::from("XBT"));
271        assert_eq!(xbt_balance.total.as_f64(), 0.05); // 5000000 satoshis = 0.05 XBT
272        assert_eq!(xbt_balance.free.as_f64(), 0.0498); // 4980000 satoshis = 0.0498 XBT
273        assert_eq!(xbt_balance.locked.as_f64(), 0.0002); // difference
274    }
275
276    #[rstest]
277    fn test_parse_account_state_usdt() {
278        let margin_msg = BitmexMarginMsg {
279            account: 123456,
280            currency: Ustr::from("USDt"),
281            risk_limit: Some(1000000000),
282            amount: Some(10000000000), // 10000 USDT in microunits
283            prev_realised_pnl: None,
284            gross_comm: None,
285            gross_open_cost: None,
286            gross_open_premium: None,
287            gross_exec_cost: None,
288            gross_mark_value: None,
289            risk_value: None,
290            init_margin: None,
291            maint_margin: None,
292            target_excess_margin: None,
293            realised_pnl: None,
294            unrealised_pnl: None,
295            wallet_balance: Some(10000000000),
296            margin_balance: Some(10000000000),
297            margin_leverage: None,
298            margin_used_pcnt: None,
299            excess_margin: None,
300            available_margin: Some(9500000000), // 9500 USDT available
301            withdrawable_margin: None,
302            maker_fee_discount: None,
303            taker_fee_discount: None,
304            timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
305            foreign_margin_balance: None,
306            foreign_requirement: None,
307        };
308
309        let account_id = AccountId::new("BITMEX-001");
310        let ts_init = UnixNanos::from(1_000_000_000);
311
312        let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
313
314        // Check USDT balance (converted from microunits)
315        let usdt_balance = &account_state.balances[0];
316        assert_eq!(usdt_balance.currency, Currency::USDT());
317        assert_eq!(usdt_balance.total.as_f64(), 10000.0);
318        assert_eq!(usdt_balance.free.as_f64(), 9500.0);
319        assert_eq!(usdt_balance.locked.as_f64(), 500.0);
320    }
321}