nautilus_hyperliquid/common/
models.rs

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