Skip to main content

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