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