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