nautilus_hyperliquid/common/
models.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 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 std::{collections::HashMap, fmt::Display, str::FromStr};
17
18use nautilus_core::{UUID4, UnixNanos};
19use nautilus_model::{
20    data::{delta::OrderBookDelta, deltas::OrderBookDeltas, order::BookOrder},
21    enums::{AccountType, BookAction, OrderSide, PositionSide, RecordFlag},
22    events::AccountState,
23    identifiers::{AccountId, InstrumentId},
24    reports::PositionStatusReport,
25    types::{AccountBalance, Money, Price, Quantity},
26};
27use rust_decimal::{Decimal, prelude::ToPrimitive};
28use ustr::Ustr;
29
30use crate::{
31    http::{
32        models::{HyperliquidL2Book, HyperliquidLevel},
33        parse::get_currency,
34    },
35    websocket::messages::{WsBookData, WsLevelData},
36};
37
38/// Configuration for price/size precision.
39#[derive(Debug, Clone)]
40pub struct HyperliquidInstrumentInfo {
41    pub instrument_id: InstrumentId,
42    pub price_decimals: u8,
43    pub size_decimals: u8,
44    /// Minimum tick size for price (optional)
45    pub tick_size: Option<Decimal>,
46    /// Minimum step size for quantity (optional)
47    pub step_size: Option<Decimal>,
48    /// Minimum notional value for orders (optional)
49    pub min_notional: Option<Decimal>,
50}
51
52impl HyperliquidInstrumentInfo {
53    /// Create config with specific precision
54    pub fn new(instrument_id: InstrumentId, price_decimals: u8, size_decimals: u8) -> Self {
55        Self {
56            instrument_id,
57            price_decimals,
58            size_decimals,
59            tick_size: None,
60            step_size: None,
61            min_notional: None,
62        }
63    }
64
65    /// Create config with full metadata
66    pub fn with_metadata(
67        instrument_id: InstrumentId,
68        price_decimals: u8,
69        size_decimals: u8,
70        tick_size: Decimal,
71        step_size: Decimal,
72        min_notional: Decimal,
73    ) -> Self {
74        Self {
75            instrument_id,
76            price_decimals,
77            size_decimals,
78            tick_size: Some(tick_size),
79            step_size: Some(step_size),
80            min_notional: Some(min_notional),
81        }
82    }
83
84    /// Create with basic precision config and calculated tick/step sizes
85    pub fn with_precision(
86        instrument_id: InstrumentId,
87        price_decimals: u8,
88        size_decimals: u8,
89    ) -> Self {
90        let tick_size = Decimal::new(1, price_decimals as u32);
91        let step_size = Decimal::new(1, size_decimals as u32);
92        Self {
93            instrument_id,
94            price_decimals,
95            size_decimals,
96            tick_size: Some(tick_size),
97            step_size: Some(step_size),
98            min_notional: None,
99        }
100    }
101
102    /// Default configuration for most crypto assets
103    pub fn default_crypto(instrument_id: InstrumentId) -> Self {
104        Self::with_precision(instrument_id, 2, 5) // 0.01 price precision, 0.00001 size precision
105    }
106}
107
108/// Simple instrument cache for parsing messages and responses
109#[derive(Debug, Default)]
110pub struct HyperliquidInstrumentCache {
111    instruments_by_symbol: HashMap<Ustr, HyperliquidInstrumentInfo>,
112}
113
114impl HyperliquidInstrumentCache {
115    /// Create a new empty cache
116    pub fn new() -> Self {
117        Self {
118            instruments_by_symbol: HashMap::new(),
119        }
120    }
121
122    /// Add or update an instrument in the cache
123    pub fn insert(&mut self, symbol: &str, info: HyperliquidInstrumentInfo) {
124        self.instruments_by_symbol.insert(Ustr::from(symbol), info);
125    }
126
127    /// Get instrument metadata for a symbol
128    pub fn get(&self, symbol: &str) -> Option<&HyperliquidInstrumentInfo> {
129        self.instruments_by_symbol.get(&Ustr::from(symbol))
130    }
131
132    /// Get all cached instruments
133    pub fn get_all(&self) -> Vec<&HyperliquidInstrumentInfo> {
134        self.instruments_by_symbol.values().collect()
135    }
136
137    /// Check if symbol exists in cache
138    pub fn contains(&self, symbol: &str) -> bool {
139        self.instruments_by_symbol.contains_key(&Ustr::from(symbol))
140    }
141
142    /// Get the number of cached instruments
143    pub fn len(&self) -> usize {
144        self.instruments_by_symbol.len()
145    }
146
147    /// Check if the cache is empty
148    pub fn is_empty(&self) -> bool {
149        self.instruments_by_symbol.is_empty()
150    }
151
152    /// Clear all cached instruments
153    pub fn clear(&mut self) {
154        self.instruments_by_symbol.clear();
155    }
156}
157
158/// Key for identifying unique trades/tickers
159#[derive(Clone, Debug, PartialEq, Eq, Hash)]
160pub enum HyperliquidTradeKey {
161    /// Preferred: exchange-provided unique identifier
162    Id(String),
163    /// Fallback: exchange sequence number
164    Seq(u64),
165}
166
167/// Manages precision configuration and converts Hyperliquid data to standard Nautilus formats
168#[derive(Debug)]
169pub struct HyperliquidDataConverter {
170    /// Configuration by instrument symbol
171    configs: HashMap<Ustr, HyperliquidInstrumentInfo>,
172}
173
174impl Default for HyperliquidDataConverter {
175    fn default() -> Self {
176        Self::new()
177    }
178}
179
180impl HyperliquidDataConverter {
181    /// Create a new converter
182    pub fn new() -> Self {
183        Self {
184            configs: HashMap::new(),
185        }
186    }
187
188    /// Normalize an order's price and quantity for Hyperliquid
189    ///
190    /// This is a convenience method that uses the instrument configuration
191    /// to apply proper normalization and validation.
192    pub fn normalize_order_for_symbol(
193        &mut self,
194        symbol: &str,
195        price: Decimal,
196        qty: Decimal,
197    ) -> Result<(Decimal, Decimal), String> {
198        let config = self.get_config(&Ustr::from(symbol));
199
200        // Use default values if instrument metadata is not available
201        let tick_size = config.tick_size.unwrap_or_else(|| Decimal::new(1, 2)); // 0.01
202        let step_size = config.step_size.unwrap_or_else(|| {
203            // Calculate step size from decimals if not provided
204            match config.size_decimals {
205                0 => Decimal::ONE,
206                1 => Decimal::new(1, 1), // 0.1
207                2 => Decimal::new(1, 2), // 0.01
208                3 => Decimal::new(1, 3), // 0.001
209                4 => Decimal::new(1, 4), // 0.0001
210                5 => Decimal::new(1, 5), // 0.00001
211                _ => Decimal::new(1, 6), // 0.000001
212            }
213        });
214        let min_notional = config.min_notional.unwrap_or_else(|| Decimal::from(10)); // $10 minimum
215
216        crate::common::parse::normalize_order(
217            price,
218            qty,
219            tick_size,
220            step_size,
221            min_notional,
222            config.price_decimals,
223            config.size_decimals,
224        )
225    }
226
227    /// Configure precision for an instrument
228    pub fn configure_instrument(&mut self, symbol: &str, config: HyperliquidInstrumentInfo) {
229        self.configs.insert(Ustr::from(symbol), config);
230    }
231
232    /// Get configuration for an instrument, using default if not configured
233    fn get_config(&self, symbol: &Ustr) -> HyperliquidInstrumentInfo {
234        self.configs.get(symbol).cloned().unwrap_or_else(|| {
235            // Create default config with a placeholder instrument_id based on symbol
236            let instrument_id = InstrumentId::from(format!("{symbol}.HYPER").as_str());
237            HyperliquidInstrumentInfo::default_crypto(instrument_id)
238        })
239    }
240
241    /// Convert Hyperliquid HTTP L2Book snapshot to OrderBookDeltas
242    pub fn convert_http_snapshot(
243        &self,
244        data: &HyperliquidL2Book,
245        instrument_id: InstrumentId,
246        ts_init: UnixNanos,
247    ) -> Result<OrderBookDeltas, ConversionError> {
248        let config = self.get_config(&data.coin);
249        let mut deltas = Vec::new();
250
251        // Add a clear delta first to reset the book
252        deltas.push(OrderBookDelta::clear(
253            instrument_id,
254            0,                                      // sequence starts at 0 for snapshots
255            UnixNanos::from(data.time * 1_000_000), // Convert millis to nanos
256            ts_init,
257        ));
258
259        let mut order_id = 1u64; // Sequential order IDs for snapshot
260
261        // Convert bid levels
262        for level in &data.levels[0] {
263            let (price, size) = parse_level(level, &config)?;
264            if size.is_positive() {
265                let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
266                deltas.push(OrderBookDelta::new(
267                    instrument_id,
268                    BookAction::Add,
269                    order,
270                    RecordFlag::F_LAST as u8, // Mark as last for snapshot
271                    order_id,
272                    UnixNanos::from(data.time * 1_000_000),
273                    ts_init,
274                ));
275                order_id += 1;
276            }
277        }
278
279        // Convert ask levels
280        for level in &data.levels[1] {
281            let (price, size) = parse_level(level, &config)?;
282            if size.is_positive() {
283                let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
284                deltas.push(OrderBookDelta::new(
285                    instrument_id,
286                    BookAction::Add,
287                    order,
288                    RecordFlag::F_LAST as u8, // Mark as last for snapshot
289                    order_id,
290                    UnixNanos::from(data.time * 1_000_000),
291                    ts_init,
292                ));
293                order_id += 1;
294            }
295        }
296
297        Ok(OrderBookDeltas::new(instrument_id, deltas))
298    }
299
300    /// Convert Hyperliquid WebSocket book data to OrderBookDeltas
301    pub fn convert_ws_snapshot(
302        &self,
303        data: &WsBookData,
304        instrument_id: InstrumentId,
305        ts_init: UnixNanos,
306    ) -> Result<OrderBookDeltas, ConversionError> {
307        let config = self.get_config(&data.coin);
308        let mut deltas = Vec::new();
309
310        // Add a clear delta first to reset the book
311        deltas.push(OrderBookDelta::clear(
312            instrument_id,
313            0,                                      // sequence starts at 0 for snapshots
314            UnixNanos::from(data.time * 1_000_000), // Convert millis to nanos
315            ts_init,
316        ));
317
318        let mut order_id = 1u64; // Sequential order IDs for snapshot
319
320        // Convert bid levels
321        for level in &data.levels[0] {
322            let (price, size) = parse_ws_level(level, &config)?;
323            if size.is_positive() {
324                let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
325                deltas.push(OrderBookDelta::new(
326                    instrument_id,
327                    BookAction::Add,
328                    order,
329                    RecordFlag::F_LAST as u8,
330                    order_id,
331                    UnixNanos::from(data.time * 1_000_000),
332                    ts_init,
333                ));
334                order_id += 1;
335            }
336        }
337
338        // Convert ask levels
339        for level in &data.levels[1] {
340            let (price, size) = parse_ws_level(level, &config)?;
341            if size.is_positive() {
342                let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
343                deltas.push(OrderBookDelta::new(
344                    instrument_id,
345                    BookAction::Add,
346                    order,
347                    RecordFlag::F_LAST as u8,
348                    order_id,
349                    UnixNanos::from(data.time * 1_000_000),
350                    ts_init,
351                ));
352                order_id += 1;
353            }
354        }
355
356        Ok(OrderBookDeltas::new(instrument_id, deltas))
357    }
358
359    /// Convert price/size changes to OrderBookDeltas
360    /// This would be used for incremental WebSocket updates if Hyperliquid provided them
361    #[allow(clippy::too_many_arguments)]
362    pub fn convert_delta_update(
363        &self,
364        instrument_id: InstrumentId,
365        sequence: u64,
366        ts_event: UnixNanos,
367        ts_init: UnixNanos,
368        bid_updates: &[(String, String)], // (price, size) pairs
369        ask_updates: &[(String, String)], // (price, size) pairs
370        bid_removals: &[String],          // prices to remove
371        ask_removals: &[String],          // prices to remove
372    ) -> Result<OrderBookDeltas, ConversionError> {
373        let config = self.get_config(&instrument_id.symbol.inner());
374        let mut deltas = Vec::new();
375        let mut order_id = sequence * 1000; // Ensure unique order IDs
376
377        // Process bid removals
378        for price_str in bid_removals {
379            let price = parse_price(price_str, &config)?;
380            let order = BookOrder::new(OrderSide::Buy, price, Quantity::from("0"), order_id);
381            deltas.push(OrderBookDelta::new(
382                instrument_id,
383                BookAction::Delete,
384                order,
385                0, // flags
386                sequence,
387                ts_event,
388                ts_init,
389            ));
390            order_id += 1;
391        }
392
393        // Process ask removals
394        for price_str in ask_removals {
395            let price = parse_price(price_str, &config)?;
396            let order = BookOrder::new(OrderSide::Sell, price, Quantity::from("0"), order_id);
397            deltas.push(OrderBookDelta::new(
398                instrument_id,
399                BookAction::Delete,
400                order,
401                0, // flags
402                sequence,
403                ts_event,
404                ts_init,
405            ));
406            order_id += 1;
407        }
408
409        // Process bid updates/additions
410        for (price_str, size_str) in bid_updates {
411            let price = parse_price(price_str, &config)?;
412            let size = parse_size(size_str, &config)?;
413
414            if size.is_positive() {
415                let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
416                deltas.push(OrderBookDelta::new(
417                    instrument_id,
418                    BookAction::Update, // Could be Add or Update - we use Update as safer default
419                    order,
420                    0, // flags
421                    sequence,
422                    ts_event,
423                    ts_init,
424                ));
425            } else {
426                // Size 0 means removal
427                let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
428                deltas.push(OrderBookDelta::new(
429                    instrument_id,
430                    BookAction::Delete,
431                    order,
432                    0, // flags
433                    sequence,
434                    ts_event,
435                    ts_init,
436                ));
437            }
438            order_id += 1;
439        }
440
441        // Process ask updates/additions
442        for (price_str, size_str) in ask_updates {
443            let price = parse_price(price_str, &config)?;
444            let size = parse_size(size_str, &config)?;
445
446            if size.is_positive() {
447                let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
448                deltas.push(OrderBookDelta::new(
449                    instrument_id,
450                    BookAction::Update, // Could be Add or Update - we use Update as safer default
451                    order,
452                    0, // flags
453                    sequence,
454                    ts_event,
455                    ts_init,
456                ));
457            } else {
458                // Size 0 means removal
459                let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
460                deltas.push(OrderBookDelta::new(
461                    instrument_id,
462                    BookAction::Delete,
463                    order,
464                    0, // flags
465                    sequence,
466                    ts_event,
467                    ts_init,
468                ));
469            }
470            order_id += 1;
471        }
472
473        Ok(OrderBookDeltas::new(instrument_id, deltas))
474    }
475}
476
477/// Convert HTTP level to price and size
478fn parse_level(
479    level: &HyperliquidLevel,
480    inst_info: &HyperliquidInstrumentInfo,
481) -> Result<(Price, Quantity), ConversionError> {
482    let price = parse_price(&level.px, inst_info)?;
483    let size = parse_size(&level.sz, inst_info)?;
484    Ok((price, size))
485}
486
487/// Convert WebSocket level to price and size
488fn parse_ws_level(
489    level: &WsLevelData,
490    config: &HyperliquidInstrumentInfo,
491) -> Result<(Price, Quantity), ConversionError> {
492    let price = parse_price(&level.px, config)?;
493    let size = parse_size(&level.sz, config)?;
494    Ok((price, size))
495}
496
497/// Parse price string to Price with proper precision
498fn parse_price(
499    price_str: &str,
500    _config: &HyperliquidInstrumentInfo,
501) -> Result<Price, ConversionError> {
502    let _decimal = Decimal::from_str(price_str).map_err(|_| ConversionError::InvalidPrice {
503        value: price_str.to_string(),
504    })?;
505
506    Price::from_str(price_str).map_err(|_| ConversionError::InvalidPrice {
507        value: price_str.to_string(),
508    })
509}
510
511/// Parse size string to Quantity with proper precision
512fn parse_size(
513    size_str: &str,
514    _config: &HyperliquidInstrumentInfo,
515) -> Result<Quantity, ConversionError> {
516    let _decimal = Decimal::from_str(size_str).map_err(|_| ConversionError::InvalidSize {
517        value: size_str.to_string(),
518    })?;
519
520    Quantity::from_str(size_str).map_err(|_| ConversionError::InvalidSize {
521        value: size_str.to_string(),
522    })
523}
524
525/// Error conditions from Hyperliquid data conversion.
526#[derive(Debug, Clone, PartialEq, Eq)]
527pub enum ConversionError {
528    /// Invalid price string format.
529    InvalidPrice { value: String },
530    /// Invalid size string format.
531    InvalidSize { value: String },
532    /// Error creating OrderBookDeltas
533    OrderBookDeltasError(String),
534}
535
536impl From<anyhow::Error> for ConversionError {
537    fn from(err: anyhow::Error) -> Self {
538        Self::OrderBookDeltasError(err.to_string())
539    }
540}
541
542impl Display for ConversionError {
543    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
544        match self {
545            Self::InvalidPrice { value } => write!(f, "Invalid price: {value}"),
546            Self::InvalidSize { value } => write!(f, "Invalid size: {value}"),
547            Self::OrderBookDeltasError(msg) => {
548                write!(f, "OrderBookDeltas error: {msg}")
549            }
550        }
551    }
552}
553
554impl std::error::Error for ConversionError {}
555
556////////////////////////////////////////////////////////////////////////////////
557// Position and Account State Management
558////////////////////////////////////////////////////////////////////////////////
559
560/// Raw position data from Hyperliquid API for parsing position status reports.
561///
562/// This struct is used only for parsing API responses and converting to Nautilus
563/// PositionStatusReport events. The actual position tracking is handled by the
564/// Nautilus platform, not the adapter.
565///
566/// See Hyperliquid API documentation:
567/// - [User State Info](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals#retrieve-users-perpetuals-account-summary)
568#[derive(Clone, Debug)]
569pub struct HyperliquidPositionData {
570    pub asset: String,
571    pub position: Decimal, // signed: positive = long, negative = short
572    pub entry_px: Option<Decimal>,
573    pub unrealized_pnl: Decimal,
574    pub cumulative_funding: Option<Decimal>,
575    pub position_value: Decimal,
576}
577
578impl HyperliquidPositionData {
579    /// Check if position is flat (no quantity)
580    pub fn is_flat(&self) -> bool {
581        self.position.is_zero()
582    }
583
584    /// Check if position is long
585    pub fn is_long(&self) -> bool {
586        self.position > Decimal::ZERO
587    }
588
589    /// Check if position is short
590    pub fn is_short(&self) -> bool {
591        self.position < Decimal::ZERO
592    }
593}
594
595/// Balance information from Hyperliquid API.
596///
597/// Represents account balance for a specific asset (currency) as returned by Hyperliquid.
598/// Used for converting to Nautilus AccountBalance and AccountState events.
599///
600/// See Hyperliquid API documentation:
601/// - [Perpetuals Account Summary](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals#retrieve-users-perpetuals-account-summary)
602#[derive(Clone, Debug)]
603pub struct HyperliquidBalance {
604    pub asset: String,
605    pub total: Decimal,
606    pub available: Decimal,
607    pub sequence: u64,
608    pub ts_event: UnixNanos,
609}
610
611impl HyperliquidBalance {
612    pub fn new(
613        asset: String,
614        total: Decimal,
615        available: Decimal,
616        sequence: u64,
617        ts_event: UnixNanos,
618    ) -> Self {
619        Self {
620            asset,
621            total,
622            available,
623            sequence,
624            ts_event,
625        }
626    }
627
628    /// Calculate locked (reserved) balance
629    pub fn locked(&self) -> Decimal {
630        (self.total - self.available).max(Decimal::ZERO)
631    }
632}
633
634/// Simplified account state for Hyperliquid adapter.
635///
636/// This tracks only the essential state needed for generating Nautilus AccountState events.
637/// Position tracking is handled by the Nautilus platform, not the adapter.
638///
639/// See Hyperliquid API documentation:
640/// - [User State Info](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals#retrieve-users-perpetuals-account-summary)
641#[derive(Default, Debug)]
642pub struct HyperliquidAccountState {
643    pub balances: HashMap<String, HyperliquidBalance>,
644    pub last_sequence: u64,
645}
646
647impl HyperliquidAccountState {
648    pub fn new() -> Self {
649        Default::default()
650    }
651
652    /// Get balance for an asset, returns zero balance if not found
653    pub fn get_balance(&self, asset: &str) -> HyperliquidBalance {
654        self.balances.get(asset).cloned().unwrap_or_else(|| {
655            HyperliquidBalance::new(
656                asset.to_string(),
657                Decimal::ZERO,
658                Decimal::ZERO,
659                0,
660                UnixNanos::default(),
661            )
662        })
663    }
664
665    /// Calculate total account value from balances only.
666    /// Note: This doesn't include unrealized PnL from positions as those are
667    /// tracked by the Nautilus platform, not the adapter.
668    pub fn account_value(&self) -> Decimal {
669        self.balances.values().map(|balance| balance.total).sum()
670    }
671
672    /// Convert HyperliquidAccountState to Nautilus AccountState event.
673    ///
674    /// This creates a standard Nautilus AccountState from the Hyperliquid-specific account state,
675    /// converting balances and handling the margin account type since Hyperliquid supports leverage.
676    ///
677    /// # Returns
678    ///
679    /// A Nautilus AccountState event that can be processed by the platform
680    pub fn to_account_state(
681        &self,
682        account_id: AccountId,
683        ts_event: UnixNanos,
684        ts_init: UnixNanos,
685    ) -> anyhow::Result<AccountState> {
686        // Convert HyperliquidBalance to AccountBalance
687        let balances: Vec<AccountBalance> = self
688            .balances
689            .values()
690            .map(|balance| {
691                // Create currency - Hyperliquid primarily uses USD/USDC
692                let currency = get_currency(&balance.asset);
693
694                // Convert Decimal to f64 and create Money with proper currency
695                let total = Money::new(balance.total.to_f64().unwrap_or(0.0), currency);
696                let free = Money::new(balance.available.to_f64().unwrap_or(0.0), currency);
697                let locked = total - free; // locked = total - available
698
699                AccountBalance::new(total, locked, free)
700            })
701            .collect();
702
703        // For now, we don't map individual position margins since Hyperliquid uses cross-margin
704        // The risk management happens at the exchange level
705        let margins = Vec::new();
706
707        // Hyperliquid is a margin exchange (supports leverage)
708        let account_type = AccountType::Margin;
709
710        // This state comes from the exchange
711        let is_reported = true;
712
713        // Generate event ID
714        let event_id = UUID4::new();
715
716        Ok(AccountState::new(
717            account_id,
718            account_type,
719            balances,
720            margins,
721            is_reported,
722            event_id,
723            ts_event,
724            ts_init,
725            None, // base_currency: None for multi-currency support
726        ))
727    }
728}
729
730/// Account balance update events from Hyperliquid exchange.
731///
732/// This enum represents balance update events that can be received from Hyperliquid
733/// via WebSocket streams or HTTP responses. Position tracking is handled by the
734/// Nautilus platform, so this only processes balance changes.
735///
736/// See Hyperliquid documentation:
737/// - [WebSocket API](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket)
738/// - [User State Updates](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket#user-data)
739#[derive(Debug, Clone)]
740pub enum HyperliquidAccountEvent {
741    /// Complete snapshot of balances
742    BalanceSnapshot {
743        balances: Vec<HyperliquidBalance>,
744        sequence: u64,
745    },
746    /// Delta update for a single balance
747    BalanceDelta { balance: HyperliquidBalance },
748}
749
750impl HyperliquidAccountState {
751    /// Apply a balance event to update the account state
752    pub fn apply(&mut self, event: HyperliquidAccountEvent) {
753        match event {
754            HyperliquidAccountEvent::BalanceSnapshot { balances, sequence } => {
755                self.balances.clear();
756
757                for balance in balances {
758                    self.balances.insert(balance.asset.clone(), balance);
759                }
760
761                self.last_sequence = sequence;
762            }
763            HyperliquidAccountEvent::BalanceDelta { balance } => {
764                let sequence = balance.sequence;
765                let entry = self
766                    .balances
767                    .entry(balance.asset.clone())
768                    .or_insert_with(|| balance.clone());
769
770                // Only update if sequence is newer
771                if sequence > entry.sequence {
772                    *entry = balance;
773                    self.last_sequence = self.last_sequence.max(sequence);
774                }
775            }
776        }
777    }
778}
779
780/// Parse Hyperliquid position data into a Nautilus PositionStatusReport.
781///
782/// This function converts raw position data from Hyperliquid API responses into
783/// the standardized Nautilus PositionStatusReport format. The actual position
784/// tracking and management is handled by the Nautilus platform.
785///
786/// See Hyperliquid API documentation:
787/// - [User State Info](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals#retrieve-users-perpetuals-account-summary)
788/// - [Position Data Format](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals#retrieve-users-perpetuals-account-summary)
789pub fn parse_position_status_report(
790    position_data: &HyperliquidPositionData,
791    account_id: AccountId,
792    instrument_id: InstrumentId,
793    ts_init: UnixNanos,
794) -> anyhow::Result<PositionStatusReport> {
795    // Determine position side
796    let position_side = if position_data.is_flat() {
797        PositionSide::Flat
798    } else if position_data.is_long() {
799        PositionSide::Long
800    } else {
801        PositionSide::Short
802    };
803
804    // Convert position size to Quantity
805    let quantity = Quantity::new(position_data.position.abs().to_f64().unwrap_or(0.0), 0);
806
807    // Use current timestamp as last update time
808    let ts_last = ts_init;
809
810    // Convert entry price to Decimal if available
811    let avg_px_open = position_data.entry_px;
812
813    Ok(PositionStatusReport::new(
814        account_id,
815        instrument_id,
816        position_side.as_specified(),
817        quantity,
818        ts_last,
819        ts_init,
820        None, // report_id: auto-generated
821        None, // venue_position_id: Hyperliquid doesn't use position IDs
822        avg_px_open,
823    ))
824}
825
826////////////////////////////////////////////////////////////////////////////////
827
828#[cfg(test)]
829#[allow(dead_code)]
830mod tests {
831    use rstest::rstest;
832    use rust_decimal_macros::dec;
833
834    use super::*;
835
836    fn load_test_data<T>(filename: &str) -> T
837    where
838        T: serde::de::DeserializeOwned,
839    {
840        let path = format!("test_data/{filename}");
841        let content = std::fs::read_to_string(path).expect("Failed to read test data");
842        serde_json::from_str(&content).expect("Failed to parse test data")
843    }
844
845    fn test_instrument_id() -> InstrumentId {
846        InstrumentId::from("BTC.HYPER")
847    }
848
849    fn sample_http_book() -> HyperliquidL2Book {
850        load_test_data("http_l2_book_snapshot.json")
851    }
852
853    fn sample_ws_book() -> WsBookData {
854        load_test_data("ws_book_data.json")
855    }
856
857    #[rstest]
858    fn test_http_snapshot_conversion() {
859        let converter = HyperliquidDataConverter::new();
860        let book_data = sample_http_book();
861        let instrument_id = test_instrument_id();
862        let ts_init = UnixNanos::default();
863
864        let deltas = converter
865            .convert_http_snapshot(&book_data, instrument_id, ts_init)
866            .unwrap();
867
868        assert_eq!(deltas.instrument_id, instrument_id);
869        assert_eq!(deltas.deltas.len(), 11); // 1 clear + 5 bids + 5 asks
870
871        // First delta should be Clear - assert all fields
872        let clear_delta = &deltas.deltas[0];
873        assert_eq!(clear_delta.instrument_id, instrument_id);
874        assert_eq!(clear_delta.action, BookAction::Clear);
875        assert_eq!(clear_delta.order.side, OrderSide::NoOrderSide);
876        assert_eq!(clear_delta.order.price.raw, 0);
877        assert_eq!(clear_delta.order.price.precision, 0);
878        assert_eq!(clear_delta.order.size.raw, 0);
879        assert_eq!(clear_delta.order.size.precision, 0);
880        assert_eq!(clear_delta.order.order_id, 0);
881        assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
882        assert_eq!(clear_delta.sequence, 0);
883        assert_eq!(
884            clear_delta.ts_event,
885            UnixNanos::from(book_data.time * 1_000_000)
886        );
887        assert_eq!(clear_delta.ts_init, ts_init);
888
889        // Second delta should be first bid Add - assert all fields
890        let first_bid_delta = &deltas.deltas[1];
891        assert_eq!(first_bid_delta.instrument_id, instrument_id);
892        assert_eq!(first_bid_delta.action, BookAction::Add);
893        assert_eq!(first_bid_delta.order.side, OrderSide::Buy);
894        assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
895        assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
896        assert_eq!(first_bid_delta.order.order_id, 1);
897        assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
898        assert_eq!(first_bid_delta.sequence, 1);
899        assert_eq!(
900            first_bid_delta.ts_event,
901            UnixNanos::from(book_data.time * 1_000_000)
902        );
903        assert_eq!(first_bid_delta.ts_init, ts_init);
904
905        // Verify remaining deltas are Add actions with positive sizes
906        for delta in &deltas.deltas[1..] {
907            assert_eq!(delta.action, BookAction::Add);
908            assert!(delta.order.size.is_positive());
909        }
910    }
911
912    #[rstest]
913    fn test_ws_snapshot_conversion() {
914        let converter = HyperliquidDataConverter::new();
915        let book_data = sample_ws_book();
916        let instrument_id = test_instrument_id();
917        let ts_init = UnixNanos::default();
918
919        let deltas = converter
920            .convert_ws_snapshot(&book_data, instrument_id, ts_init)
921            .unwrap();
922
923        assert_eq!(deltas.instrument_id, instrument_id);
924        assert_eq!(deltas.deltas.len(), 11); // 1 clear + 5 bids + 5 asks
925
926        // First delta should be Clear - assert all fields
927        let clear_delta = &deltas.deltas[0];
928        assert_eq!(clear_delta.instrument_id, instrument_id);
929        assert_eq!(clear_delta.action, BookAction::Clear);
930        assert_eq!(clear_delta.order.side, OrderSide::NoOrderSide);
931        assert_eq!(clear_delta.order.price.raw, 0);
932        assert_eq!(clear_delta.order.price.precision, 0);
933        assert_eq!(clear_delta.order.size.raw, 0);
934        assert_eq!(clear_delta.order.size.precision, 0);
935        assert_eq!(clear_delta.order.order_id, 0);
936        assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
937        assert_eq!(clear_delta.sequence, 0);
938        assert_eq!(
939            clear_delta.ts_event,
940            UnixNanos::from(book_data.time * 1_000_000)
941        );
942        assert_eq!(clear_delta.ts_init, ts_init);
943
944        // Second delta should be first bid Add - assert all fields
945        let first_bid_delta = &deltas.deltas[1];
946        assert_eq!(first_bid_delta.instrument_id, instrument_id);
947        assert_eq!(first_bid_delta.action, BookAction::Add);
948        assert_eq!(first_bid_delta.order.side, OrderSide::Buy);
949        assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
950        assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
951        assert_eq!(first_bid_delta.order.order_id, 1);
952        assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
953        assert_eq!(first_bid_delta.sequence, 1);
954        assert_eq!(
955            first_bid_delta.ts_event,
956            UnixNanos::from(book_data.time * 1_000_000)
957        );
958        assert_eq!(first_bid_delta.ts_init, ts_init);
959    }
960
961    #[rstest]
962    fn test_delta_update_conversion() {
963        let converter = HyperliquidDataConverter::new();
964        let instrument_id = test_instrument_id();
965        let ts_event = UnixNanos::default();
966        let ts_init = UnixNanos::default();
967
968        let bid_updates = vec![("98450.00".to_string(), "1.5".to_string())];
969        let ask_updates = vec![("98451.00".to_string(), "2.0".to_string())];
970        let bid_removals = vec!["98449.00".to_string()];
971        let ask_removals = vec!["98452.00".to_string()];
972
973        let deltas = converter
974            .convert_delta_update(
975                instrument_id,
976                123,
977                ts_event,
978                ts_init,
979                &bid_updates,
980                &ask_updates,
981                &bid_removals,
982                &ask_removals,
983            )
984            .unwrap();
985
986        assert_eq!(deltas.instrument_id, instrument_id);
987        assert_eq!(deltas.deltas.len(), 4); // 2 removals + 2 updates
988        assert_eq!(deltas.sequence, 123);
989
990        // First delta should be bid removal - assert all fields
991        let first_delta = &deltas.deltas[0];
992        assert_eq!(first_delta.instrument_id, instrument_id);
993        assert_eq!(first_delta.action, BookAction::Delete);
994        assert_eq!(first_delta.order.side, OrderSide::Buy);
995        assert_eq!(first_delta.order.price, Price::from("98449.00"));
996        assert_eq!(first_delta.order.size, Quantity::from("0"));
997        assert_eq!(first_delta.order.order_id, 123000);
998        assert_eq!(first_delta.flags, 0);
999        assert_eq!(first_delta.sequence, 123);
1000        assert_eq!(first_delta.ts_event, ts_event);
1001        assert_eq!(first_delta.ts_init, ts_init);
1002    }
1003
1004    #[rstest]
1005    fn test_price_size_parsing() {
1006        let instrument_id = test_instrument_id();
1007        let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
1008
1009        let price = parse_price("98450.50", &config).unwrap();
1010        assert_eq!(price.to_string(), "98450.50");
1011
1012        let size = parse_size("2.5", &config).unwrap();
1013        assert_eq!(size.to_string(), "2.5");
1014    }
1015
1016    #[rstest]
1017    fn test_hyperliquid_instrument_mini_info() {
1018        let instrument_id = test_instrument_id();
1019
1020        // Test constructor with all fields
1021        let config = HyperliquidInstrumentInfo::new(instrument_id, 4, 6);
1022        assert_eq!(config.instrument_id, instrument_id);
1023        assert_eq!(config.price_decimals, 4);
1024        assert_eq!(config.size_decimals, 6);
1025
1026        // Test default crypto configuration - assert all fields
1027        let default_config = HyperliquidInstrumentInfo::default_crypto(instrument_id);
1028        assert_eq!(default_config.instrument_id, instrument_id);
1029        assert_eq!(default_config.price_decimals, 2);
1030        assert_eq!(default_config.size_decimals, 5);
1031    }
1032
1033    #[rstest]
1034    fn test_invalid_price_parsing() {
1035        let instrument_id = test_instrument_id();
1036        let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
1037
1038        // Test invalid price parsing
1039        let result = parse_price("invalid", &config);
1040        assert!(result.is_err());
1041
1042        match result.unwrap_err() {
1043            ConversionError::InvalidPrice { value } => {
1044                assert_eq!(value, "invalid");
1045                // Verify the error displays correctly
1046                assert!(value.contains("invalid"));
1047            }
1048            _ => panic!("Expected InvalidPrice error"),
1049        }
1050
1051        // Test invalid size parsing
1052        let size_result = parse_size("not_a_number", &config);
1053        assert!(size_result.is_err());
1054
1055        match size_result.unwrap_err() {
1056            ConversionError::InvalidSize { value } => {
1057                assert_eq!(value, "not_a_number");
1058                // Verify the error displays correctly
1059                assert!(value.contains("not_a_number"));
1060            }
1061            _ => panic!("Expected InvalidSize error"),
1062        }
1063    }
1064
1065    #[rstest]
1066    fn test_configuration() {
1067        let mut converter = HyperliquidDataConverter::new();
1068        let eth_id = InstrumentId::from("ETH.HYPER");
1069        let config = HyperliquidInstrumentInfo::new(eth_id, 4, 8);
1070
1071        let asset = Ustr::from("ETH");
1072
1073        converter.configure_instrument(asset.as_str(), config.clone());
1074
1075        // Assert all fields of the retrieved config
1076        let retrieved_config = converter.get_config(&asset);
1077        assert_eq!(retrieved_config.instrument_id, eth_id);
1078        assert_eq!(retrieved_config.price_decimals, 4);
1079        assert_eq!(retrieved_config.size_decimals, 8);
1080
1081        // Assert all fields of the default config for unknown symbol
1082        let default_config = converter.get_config(&Ustr::from("UNKNOWN"));
1083        assert_eq!(
1084            default_config.instrument_id,
1085            InstrumentId::from("UNKNOWN.HYPER")
1086        );
1087        assert_eq!(default_config.price_decimals, 2);
1088        assert_eq!(default_config.size_decimals, 5);
1089
1090        // Verify the original config object has expected values
1091        assert_eq!(config.instrument_id, eth_id);
1092        assert_eq!(config.price_decimals, 4);
1093        assert_eq!(config.size_decimals, 8);
1094    }
1095
1096    #[rstest]
1097    fn test_instrument_info_creation() {
1098        let instrument_id = InstrumentId::from("BTC.HYPER");
1099        let info = HyperliquidInstrumentInfo::with_metadata(
1100            instrument_id,
1101            2,
1102            5,
1103            Decimal::from_f64_retain(0.01).unwrap(),
1104            Decimal::from_f64_retain(0.00001).unwrap(),
1105            Decimal::from_f64_retain(10.0).unwrap(),
1106        );
1107
1108        assert_eq!(info.instrument_id, instrument_id);
1109        assert_eq!(info.price_decimals, 2);
1110        assert_eq!(info.size_decimals, 5);
1111        assert_eq!(
1112            info.tick_size,
1113            Some(Decimal::from_f64_retain(0.01).unwrap())
1114        );
1115        assert_eq!(
1116            info.step_size,
1117            Some(Decimal::from_f64_retain(0.00001).unwrap())
1118        );
1119        assert_eq!(
1120            info.min_notional,
1121            Some(Decimal::from_f64_retain(10.0).unwrap())
1122        );
1123    }
1124
1125    #[rstest]
1126    fn test_instrument_info_with_precision() {
1127        let instrument_id = test_instrument_id();
1128        let info = HyperliquidInstrumentInfo::with_precision(instrument_id, 3, 4);
1129        assert_eq!(info.instrument_id, instrument_id);
1130        assert_eq!(info.price_decimals, 3);
1131        assert_eq!(info.size_decimals, 4);
1132        assert_eq!(info.tick_size, Some(dec!(0.001))); // 0.001
1133        assert_eq!(info.step_size, Some(dec!(0.0001))); // 0.0001
1134    }
1135
1136    #[tokio::test]
1137    async fn test_instrument_cache_basic_operations() {
1138        let btc_info = HyperliquidInstrumentInfo::with_metadata(
1139            InstrumentId::from("BTC.HYPER"),
1140            2,
1141            5,
1142            Decimal::from_f64_retain(0.01).unwrap(),
1143            Decimal::from_f64_retain(0.00001).unwrap(),
1144            Decimal::from_f64_retain(10.0).unwrap(),
1145        );
1146
1147        let eth_info = HyperliquidInstrumentInfo::with_metadata(
1148            InstrumentId::from("ETH.HYPER"),
1149            2,
1150            4,
1151            Decimal::from_f64_retain(0.01).unwrap(),
1152            Decimal::from_f64_retain(0.0001).unwrap(),
1153            Decimal::from_f64_retain(10.0).unwrap(),
1154        );
1155
1156        let mut cache = HyperliquidInstrumentCache::new();
1157
1158        // Insert instruments manually
1159        cache.insert("BTC", btc_info.clone());
1160        cache.insert("ETH", eth_info.clone());
1161
1162        // Get BTC instrument
1163        let retrieved_btc = cache.get("BTC").unwrap();
1164        assert_eq!(retrieved_btc.instrument_id, btc_info.instrument_id);
1165        assert_eq!(retrieved_btc.size_decimals, 5);
1166
1167        // Get ETH instrument
1168        let retrieved_eth = cache.get("ETH").unwrap();
1169        assert_eq!(retrieved_eth.instrument_id, eth_info.instrument_id);
1170        assert_eq!(retrieved_eth.size_decimals, 4);
1171
1172        // Test cache methods
1173        assert_eq!(cache.len(), 2);
1174        assert!(!cache.is_empty());
1175
1176        // Test contains
1177        assert!(cache.contains("BTC"));
1178        assert!(cache.contains("ETH"));
1179        assert!(!cache.contains("UNKNOWN"));
1180
1181        // Test get_all
1182        let all_instruments = cache.get_all();
1183        assert_eq!(all_instruments.len(), 2);
1184    }
1185
1186    #[rstest]
1187    fn test_instrument_cache_empty() {
1188        let cache = HyperliquidInstrumentCache::new();
1189        let result = cache.get("UNKNOWN");
1190        assert!(result.is_none());
1191        assert!(cache.is_empty());
1192        assert_eq!(cache.len(), 0);
1193    }
1194
1195    #[rstest]
1196    fn test_normalize_order_for_symbol() {
1197        use rust_decimal_macros::dec;
1198
1199        let mut converter = HyperliquidDataConverter::new();
1200
1201        // Configure BTC with specific instrument info
1202        let btc_info = HyperliquidInstrumentInfo::with_metadata(
1203            InstrumentId::from("BTC.HYPER"),
1204            2,
1205            5,
1206            dec!(0.01),    // tick_size
1207            dec!(0.00001), // step_size
1208            dec!(10.0),    // min_notional
1209        );
1210        converter.configure_instrument("BTC", btc_info);
1211
1212        // Test successful normalization
1213        let result = converter.normalize_order_for_symbol(
1214            "BTC",
1215            dec!(50123.456789), // price
1216            dec!(0.123456789),  // qty
1217        );
1218
1219        assert!(result.is_ok());
1220        let (price, qty) = result.unwrap();
1221        assert_eq!(price, dec!(50123.45)); // rounded down to tick size
1222        assert_eq!(qty, dec!(0.12345)); // rounded down to step size
1223
1224        // Test with symbol not configured (should use defaults)
1225        let result_eth = converter.normalize_order_for_symbol("ETH", dec!(3000.123), dec!(1.23456));
1226        assert!(result_eth.is_ok());
1227
1228        // Test minimum notional failure
1229        let result_fail = converter.normalize_order_for_symbol(
1230            "BTC",
1231            dec!(1.0),   // low price
1232            dec!(0.001), // small qty
1233        );
1234        assert!(result_fail.is_err());
1235        assert!(result_fail.unwrap_err().contains("Notional value"));
1236    }
1237
1238    #[rstest]
1239    fn test_hyperliquid_balance_creation_and_properties() {
1240        use rust_decimal_macros::dec;
1241
1242        let asset = "USD".to_string();
1243        let total = dec!(1000.0);
1244        let available = dec!(750.0);
1245        let sequence = 42;
1246        let ts_event = UnixNanos::default();
1247
1248        let balance = HyperliquidBalance::new(asset.clone(), total, available, sequence, ts_event);
1249
1250        assert_eq!(balance.asset, asset);
1251        assert_eq!(balance.total, total);
1252        assert_eq!(balance.available, available);
1253        assert_eq!(balance.sequence, sequence);
1254        assert_eq!(balance.ts_event, ts_event);
1255        assert_eq!(balance.locked(), dec!(250.0)); // 1000 - 750
1256
1257        // Test balance with all available
1258        let full_balance = HyperliquidBalance::new(
1259            "ETH".to_string(),
1260            dec!(100.0),
1261            dec!(100.0),
1262            1,
1263            UnixNanos::default(),
1264        );
1265        assert_eq!(full_balance.locked(), dec!(0.0));
1266
1267        // Test edge case where available > total (should return 0 locked)
1268        let weird_balance = HyperliquidBalance::new(
1269            "WEIRD".to_string(),
1270            dec!(50.0),
1271            dec!(60.0),
1272            1,
1273            UnixNanos::default(),
1274        );
1275        assert_eq!(weird_balance.locked(), dec!(0.0));
1276    }
1277
1278    #[rstest]
1279    fn test_hyperliquid_account_state_creation() {
1280        let state = HyperliquidAccountState::new();
1281        assert!(state.balances.is_empty());
1282        assert_eq!(state.last_sequence, 0);
1283
1284        let default_state = HyperliquidAccountState::default();
1285        assert!(default_state.balances.is_empty());
1286        assert_eq!(default_state.last_sequence, 0);
1287    }
1288
1289    #[rstest]
1290    fn test_hyperliquid_account_state_getters() {
1291        use rust_decimal_macros::dec;
1292
1293        let mut state = HyperliquidAccountState::new();
1294
1295        // Test get_balance for non-existent asset (should return zero balance)
1296        let balance = state.get_balance("USD");
1297        assert_eq!(balance.asset, "USD");
1298        assert_eq!(balance.total, dec!(0.0));
1299        assert_eq!(balance.available, dec!(0.0));
1300
1301        // Add actual balance
1302        let real_balance = HyperliquidBalance::new(
1303            "USD".to_string(),
1304            dec!(1000.0),
1305            dec!(750.0),
1306            1,
1307            UnixNanos::default(),
1308        );
1309        state.balances.insert("USD".to_string(), real_balance);
1310
1311        // Test retrieving real data
1312        let retrieved_balance = state.get_balance("USD");
1313        assert_eq!(retrieved_balance.total, dec!(1000.0));
1314    }
1315
1316    #[rstest]
1317    fn test_hyperliquid_account_state_account_value() {
1318        use rust_decimal_macros::dec;
1319
1320        let mut state = HyperliquidAccountState::new();
1321
1322        // Add USD balance
1323        state.balances.insert(
1324            "USD".to_string(),
1325            HyperliquidBalance::new(
1326                "USD".to_string(),
1327                dec!(10000.0),
1328                dec!(5000.0),
1329                1,
1330                UnixNanos::default(),
1331            ),
1332        );
1333
1334        let total_value = state.account_value();
1335        assert_eq!(total_value, dec!(10000.0));
1336
1337        // Test with no balance
1338        state.balances.clear();
1339        let no_balance_value = state.account_value();
1340        assert_eq!(no_balance_value, dec!(0.0));
1341    }
1342
1343    #[rstest]
1344    fn test_hyperliquid_account_event_balance_snapshot() {
1345        use rust_decimal_macros::dec;
1346
1347        let mut state = HyperliquidAccountState::new();
1348
1349        let balance = HyperliquidBalance::new(
1350            "USD".to_string(),
1351            dec!(1000.0),
1352            dec!(750.0),
1353            10,
1354            UnixNanos::default(),
1355        );
1356
1357        let snapshot_event = HyperliquidAccountEvent::BalanceSnapshot {
1358            balances: vec![balance],
1359            sequence: 10,
1360        };
1361
1362        state.apply(snapshot_event);
1363
1364        assert_eq!(state.balances.len(), 1);
1365        assert_eq!(state.last_sequence, 10);
1366        assert_eq!(state.get_balance("USD").total, dec!(1000.0));
1367    }
1368
1369    #[rstest]
1370    fn test_hyperliquid_account_event_balance_delta() {
1371        use rust_decimal_macros::dec;
1372
1373        let mut state = HyperliquidAccountState::new();
1374
1375        // Add initial balance
1376        let initial_balance = HyperliquidBalance::new(
1377            "USD".to_string(),
1378            dec!(1000.0),
1379            dec!(750.0),
1380            5,
1381            UnixNanos::default(),
1382        );
1383        state.balances.insert("USD".to_string(), initial_balance);
1384        state.last_sequence = 5;
1385
1386        // Apply balance delta with newer sequence
1387        let updated_balance = HyperliquidBalance::new(
1388            "USD".to_string(),
1389            dec!(1200.0),
1390            dec!(900.0),
1391            10,
1392            UnixNanos::default(),
1393        );
1394
1395        let delta_event = HyperliquidAccountEvent::BalanceDelta {
1396            balance: updated_balance,
1397        };
1398
1399        state.apply(delta_event);
1400
1401        let balance = state.get_balance("USD");
1402        assert_eq!(balance.total, dec!(1200.0));
1403        assert_eq!(balance.available, dec!(900.0));
1404        assert_eq!(balance.sequence, 10);
1405        assert_eq!(state.last_sequence, 10);
1406
1407        // Try to apply older sequence (should be ignored)
1408        let old_balance = HyperliquidBalance::new(
1409            "USD".to_string(),
1410            dec!(800.0),
1411            dec!(600.0),
1412            8,
1413            UnixNanos::default(),
1414        );
1415
1416        let old_delta_event = HyperliquidAccountEvent::BalanceDelta {
1417            balance: old_balance,
1418        };
1419
1420        state.apply(old_delta_event);
1421
1422        // Balance should remain unchanged
1423        let balance = state.get_balance("USD");
1424        assert_eq!(balance.total, dec!(1200.0)); // Still the newer value
1425        assert_eq!(balance.sequence, 10); // Still the newer sequence
1426        assert_eq!(state.last_sequence, 10); // Global sequence unchanged
1427    }
1428}