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!("{symbol}.HYPER").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#[cfg(test)]
860#[allow(dead_code)]
861mod tests {
862    use rstest::rstest;
863    use rust_decimal_macros::dec;
864
865    use super::*;
866
867    fn load_test_data<T>(filename: &str) -> T
868    where
869        T: serde::de::DeserializeOwned,
870    {
871        let path = format!("test_data/{filename}");
872        let content = std::fs::read_to_string(path).expect("Failed to read test data");
873        serde_json::from_str(&content).expect("Failed to parse test data")
874    }
875
876    fn test_instrument_id() -> InstrumentId {
877        InstrumentId::from("BTC.HYPER")
878    }
879
880    fn sample_http_book() -> HyperliquidL2Book {
881        load_test_data("http_l2_book_snapshot.json")
882    }
883
884    fn sample_ws_book() -> WsBookData {
885        load_test_data("ws_book_data.json")
886    }
887
888    #[rstest]
889    fn test_http_snapshot_conversion() {
890        let converter = HyperliquidDataConverter::new();
891        let book_data = sample_http_book();
892        let instrument_id = test_instrument_id();
893        let ts_init = UnixNanos::default();
894
895        let deltas = converter
896            .convert_http_snapshot(&book_data, instrument_id, ts_init)
897            .unwrap();
898
899        assert_eq!(deltas.instrument_id, instrument_id);
900        assert_eq!(deltas.deltas.len(), 11); // 1 clear + 5 bids + 5 asks
901
902        // First delta should be Clear - assert all fields
903        let clear_delta = &deltas.deltas[0];
904        assert_eq!(clear_delta.instrument_id, instrument_id);
905        assert_eq!(clear_delta.action, BookAction::Clear);
906        assert_eq!(clear_delta.order.side, OrderSide::NoOrderSide);
907        assert_eq!(clear_delta.order.price.raw, 0);
908        assert_eq!(clear_delta.order.price.precision, 0);
909        assert_eq!(clear_delta.order.size.raw, 0);
910        assert_eq!(clear_delta.order.size.precision, 0);
911        assert_eq!(clear_delta.order.order_id, 0);
912        assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
913        assert_eq!(clear_delta.sequence, 0);
914        assert_eq!(
915            clear_delta.ts_event,
916            UnixNanos::from(book_data.time * 1_000_000)
917        );
918        assert_eq!(clear_delta.ts_init, ts_init);
919
920        // Second delta should be first bid Add - assert all fields
921        let first_bid_delta = &deltas.deltas[1];
922        assert_eq!(first_bid_delta.instrument_id, instrument_id);
923        assert_eq!(first_bid_delta.action, BookAction::Add);
924        assert_eq!(first_bid_delta.order.side, OrderSide::Buy);
925        assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
926        assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
927        assert_eq!(first_bid_delta.order.order_id, 1);
928        assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
929        assert_eq!(first_bid_delta.sequence, 1);
930        assert_eq!(
931            first_bid_delta.ts_event,
932            UnixNanos::from(book_data.time * 1_000_000)
933        );
934        assert_eq!(first_bid_delta.ts_init, ts_init);
935
936        // Verify remaining deltas are Add actions with positive sizes
937        for delta in &deltas.deltas[1..] {
938            assert_eq!(delta.action, BookAction::Add);
939            assert!(delta.order.size.is_positive());
940        }
941    }
942
943    #[rstest]
944    fn test_ws_snapshot_conversion() {
945        let converter = HyperliquidDataConverter::new();
946        let book_data = sample_ws_book();
947        let instrument_id = test_instrument_id();
948        let ts_init = UnixNanos::default();
949
950        let deltas = converter
951            .convert_ws_snapshot(&book_data, instrument_id, ts_init)
952            .unwrap();
953
954        assert_eq!(deltas.instrument_id, instrument_id);
955        assert_eq!(deltas.deltas.len(), 11); // 1 clear + 5 bids + 5 asks
956
957        // First delta should be Clear - assert all fields
958        let clear_delta = &deltas.deltas[0];
959        assert_eq!(clear_delta.instrument_id, instrument_id);
960        assert_eq!(clear_delta.action, BookAction::Clear);
961        assert_eq!(clear_delta.order.side, OrderSide::NoOrderSide);
962        assert_eq!(clear_delta.order.price.raw, 0);
963        assert_eq!(clear_delta.order.price.precision, 0);
964        assert_eq!(clear_delta.order.size.raw, 0);
965        assert_eq!(clear_delta.order.size.precision, 0);
966        assert_eq!(clear_delta.order.order_id, 0);
967        assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
968        assert_eq!(clear_delta.sequence, 0);
969        assert_eq!(
970            clear_delta.ts_event,
971            UnixNanos::from(book_data.time * 1_000_000)
972        );
973        assert_eq!(clear_delta.ts_init, ts_init);
974
975        // Second delta should be first bid Add - assert all fields
976        let first_bid_delta = &deltas.deltas[1];
977        assert_eq!(first_bid_delta.instrument_id, instrument_id);
978        assert_eq!(first_bid_delta.action, BookAction::Add);
979        assert_eq!(first_bid_delta.order.side, OrderSide::Buy);
980        assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
981        assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
982        assert_eq!(first_bid_delta.order.order_id, 1);
983        assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
984        assert_eq!(first_bid_delta.sequence, 1);
985        assert_eq!(
986            first_bid_delta.ts_event,
987            UnixNanos::from(book_data.time * 1_000_000)
988        );
989        assert_eq!(first_bid_delta.ts_init, ts_init);
990    }
991
992    #[rstest]
993    fn test_delta_update_conversion() {
994        let converter = HyperliquidDataConverter::new();
995        let instrument_id = test_instrument_id();
996        let ts_event = UnixNanos::default();
997        let ts_init = UnixNanos::default();
998
999        let bid_updates = vec![("98450.00".to_string(), "1.5".to_string())];
1000        let ask_updates = vec![("98451.00".to_string(), "2.0".to_string())];
1001        let bid_removals = vec!["98449.00".to_string()];
1002        let ask_removals = vec!["98452.00".to_string()];
1003
1004        let deltas = converter
1005            .convert_delta_update(
1006                instrument_id,
1007                123,
1008                ts_event,
1009                ts_init,
1010                &bid_updates,
1011                &ask_updates,
1012                &bid_removals,
1013                &ask_removals,
1014            )
1015            .unwrap();
1016
1017        assert_eq!(deltas.instrument_id, instrument_id);
1018        assert_eq!(deltas.deltas.len(), 4); // 2 removals + 2 updates
1019        assert_eq!(deltas.sequence, 123);
1020
1021        // First delta should be bid removal - assert all fields
1022        let first_delta = &deltas.deltas[0];
1023        assert_eq!(first_delta.instrument_id, instrument_id);
1024        assert_eq!(first_delta.action, BookAction::Delete);
1025        assert_eq!(first_delta.order.side, OrderSide::Buy);
1026        assert_eq!(first_delta.order.price, Price::from("98449.00"));
1027        assert_eq!(first_delta.order.size, Quantity::from("0"));
1028        assert_eq!(first_delta.order.order_id, 123000);
1029        assert_eq!(first_delta.flags, 0);
1030        assert_eq!(first_delta.sequence, 123);
1031        assert_eq!(first_delta.ts_event, ts_event);
1032        assert_eq!(first_delta.ts_init, ts_init);
1033    }
1034
1035    #[rstest]
1036    fn test_price_size_parsing() {
1037        let instrument_id = test_instrument_id();
1038        let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
1039
1040        let price = parse_price("98450.50", &config).unwrap();
1041        assert_eq!(price.to_string(), "98450.50");
1042
1043        let size = parse_size("2.5", &config).unwrap();
1044        assert_eq!(size.to_string(), "2.5");
1045    }
1046
1047    #[rstest]
1048    fn test_hyperliquid_instrument_mini_info() {
1049        let instrument_id = test_instrument_id();
1050
1051        // Test constructor with all fields
1052        let config = HyperliquidInstrumentInfo::new(instrument_id, 4, 6);
1053        assert_eq!(config.instrument_id, instrument_id);
1054        assert_eq!(config.price_decimals, 4);
1055        assert_eq!(config.size_decimals, 6);
1056
1057        // Test default crypto configuration - assert all fields
1058        let default_config = HyperliquidInstrumentInfo::default_crypto(instrument_id);
1059        assert_eq!(default_config.instrument_id, instrument_id);
1060        assert_eq!(default_config.price_decimals, 2);
1061        assert_eq!(default_config.size_decimals, 5);
1062    }
1063
1064    #[rstest]
1065    fn test_invalid_price_parsing() {
1066        let instrument_id = test_instrument_id();
1067        let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
1068
1069        // Test invalid price parsing
1070        let result = parse_price("invalid", &config);
1071        assert!(result.is_err());
1072
1073        match result.unwrap_err() {
1074            ConversionError::InvalidPrice { value } => {
1075                assert_eq!(value, "invalid");
1076                // Verify the error displays correctly
1077                assert!(value.contains("invalid"));
1078            }
1079            _ => panic!("Expected InvalidPrice error"),
1080        }
1081
1082        // Test invalid size parsing
1083        let size_result = parse_size("not_a_number", &config);
1084        assert!(size_result.is_err());
1085
1086        match size_result.unwrap_err() {
1087            ConversionError::InvalidSize { value } => {
1088                assert_eq!(value, "not_a_number");
1089                // Verify the error displays correctly
1090                assert!(value.contains("not_a_number"));
1091            }
1092            _ => panic!("Expected InvalidSize error"),
1093        }
1094    }
1095
1096    #[rstest]
1097    fn test_configuration() {
1098        let mut converter = HyperliquidDataConverter::new();
1099        let eth_id = InstrumentId::from("ETH.HYPER");
1100        let config = HyperliquidInstrumentInfo::new(eth_id, 4, 8);
1101
1102        let asset = Ustr::from("ETH");
1103
1104        converter.configure_instrument(asset.as_str(), config.clone());
1105
1106        // Assert all fields of the retrieved config
1107        let retrieved_config = converter.get_config(&asset);
1108        assert_eq!(retrieved_config.instrument_id, eth_id);
1109        assert_eq!(retrieved_config.price_decimals, 4);
1110        assert_eq!(retrieved_config.size_decimals, 8);
1111
1112        // Assert all fields of the default config for unknown symbol
1113        let default_config = converter.get_config(&Ustr::from("UNKNOWN"));
1114        assert_eq!(
1115            default_config.instrument_id,
1116            InstrumentId::from("UNKNOWN.HYPER")
1117        );
1118        assert_eq!(default_config.price_decimals, 2);
1119        assert_eq!(default_config.size_decimals, 5);
1120
1121        // Verify the original config object has expected values
1122        assert_eq!(config.instrument_id, eth_id);
1123        assert_eq!(config.price_decimals, 4);
1124        assert_eq!(config.size_decimals, 8);
1125    }
1126
1127    #[rstest]
1128    fn test_instrument_info_creation() {
1129        let instrument_id = InstrumentId::from("BTC.HYPER");
1130        let info = HyperliquidInstrumentInfo::with_metadata(
1131            instrument_id,
1132            2,
1133            5,
1134            Decimal::from_f64_retain(0.01).unwrap(),
1135            Decimal::from_f64_retain(0.00001).unwrap(),
1136            Decimal::from_f64_retain(10.0).unwrap(),
1137        );
1138
1139        assert_eq!(info.instrument_id, instrument_id);
1140        assert_eq!(info.price_decimals, 2);
1141        assert_eq!(info.size_decimals, 5);
1142        assert_eq!(
1143            info.tick_size,
1144            Some(Decimal::from_f64_retain(0.01).unwrap())
1145        );
1146        assert_eq!(
1147            info.step_size,
1148            Some(Decimal::from_f64_retain(0.00001).unwrap())
1149        );
1150        assert_eq!(
1151            info.min_notional,
1152            Some(Decimal::from_f64_retain(10.0).unwrap())
1153        );
1154    }
1155
1156    #[rstest]
1157    fn test_instrument_info_with_precision() {
1158        let instrument_id = test_instrument_id();
1159        let info = HyperliquidInstrumentInfo::with_precision(instrument_id, 3, 4);
1160        assert_eq!(info.instrument_id, instrument_id);
1161        assert_eq!(info.price_decimals, 3);
1162        assert_eq!(info.size_decimals, 4);
1163        assert_eq!(info.tick_size, Some(dec!(0.001))); // 0.001
1164        assert_eq!(info.step_size, Some(dec!(0.0001))); // 0.0001
1165    }
1166
1167    #[tokio::test]
1168    async fn test_instrument_cache_basic_operations() {
1169        let btc_info = HyperliquidInstrumentInfo::with_metadata(
1170            InstrumentId::from("BTC.HYPER"),
1171            2,
1172            5,
1173            Decimal::from_f64_retain(0.01).unwrap(),
1174            Decimal::from_f64_retain(0.00001).unwrap(),
1175            Decimal::from_f64_retain(10.0).unwrap(),
1176        );
1177
1178        let eth_info = HyperliquidInstrumentInfo::with_metadata(
1179            InstrumentId::from("ETH.HYPER"),
1180            2,
1181            4,
1182            Decimal::from_f64_retain(0.01).unwrap(),
1183            Decimal::from_f64_retain(0.0001).unwrap(),
1184            Decimal::from_f64_retain(10.0).unwrap(),
1185        );
1186
1187        let mut cache = HyperliquidInstrumentCache::new();
1188
1189        // Insert instruments manually
1190        cache.insert("BTC", btc_info.clone());
1191        cache.insert("ETH", eth_info.clone());
1192
1193        // Get BTC instrument
1194        let retrieved_btc = cache.get("BTC").unwrap();
1195        assert_eq!(retrieved_btc.instrument_id, btc_info.instrument_id);
1196        assert_eq!(retrieved_btc.size_decimals, 5);
1197
1198        // Get ETH instrument
1199        let retrieved_eth = cache.get("ETH").unwrap();
1200        assert_eq!(retrieved_eth.instrument_id, eth_info.instrument_id);
1201        assert_eq!(retrieved_eth.size_decimals, 4);
1202
1203        // Test cache methods
1204        assert_eq!(cache.len(), 2);
1205        assert!(!cache.is_empty());
1206
1207        // Test contains
1208        assert!(cache.contains("BTC"));
1209        assert!(cache.contains("ETH"));
1210        assert!(!cache.contains("UNKNOWN"));
1211
1212        // Test get_all
1213        let all_instruments = cache.get_all();
1214        assert_eq!(all_instruments.len(), 2);
1215    }
1216
1217    #[rstest]
1218    fn test_instrument_cache_empty() {
1219        let cache = HyperliquidInstrumentCache::new();
1220        let result = cache.get("UNKNOWN");
1221        assert!(result.is_none());
1222        assert!(cache.is_empty());
1223        assert_eq!(cache.len(), 0);
1224    }
1225
1226    #[rstest]
1227    fn test_latency_model_creation() {
1228        let converter = HyperliquidDataConverter::new();
1229
1230        // Test custom latency model
1231        let latency_model = converter.create_latency_model(
1232            100_000_000, // 100ms base
1233            20_000_000,  // 20ms insert
1234            10_000_000,  // 10ms update
1235            10_000_000,  // 10ms delete
1236        );
1237
1238        assert_eq!(latency_model.base_latency_nanos.as_u64(), 100_000_000);
1239        assert_eq!(latency_model.insert_latency_nanos.as_u64(), 20_000_000);
1240        assert_eq!(latency_model.update_latency_nanos.as_u64(), 10_000_000);
1241        assert_eq!(latency_model.delete_latency_nanos.as_u64(), 10_000_000);
1242
1243        // Test default latency model
1244        let default_model = converter.create_default_latency_model();
1245        assert_eq!(default_model.base_latency_nanos.as_u64(), 50_000_000);
1246        assert_eq!(default_model.insert_latency_nanos.as_u64(), 10_000_000);
1247        assert_eq!(default_model.update_latency_nanos.as_u64(), 5_000_000);
1248        assert_eq!(default_model.delete_latency_nanos.as_u64(), 5_000_000);
1249
1250        // Test that Display trait works
1251        let display_str = format!("{default_model}");
1252        assert_eq!(display_str, "LatencyModel()");
1253    }
1254
1255    #[rstest]
1256    fn test_normalize_order_for_symbol() {
1257        use rust_decimal_macros::dec;
1258
1259        let mut converter = HyperliquidDataConverter::new();
1260
1261        // Configure BTC with specific instrument info
1262        let btc_info = HyperliquidInstrumentInfo::with_metadata(
1263            InstrumentId::from("BTC.HYPER"),
1264            2,
1265            5,
1266            dec!(0.01),    // tick_size
1267            dec!(0.00001), // step_size
1268            dec!(10.0),    // min_notional
1269        );
1270        converter.configure_instrument("BTC", btc_info);
1271
1272        // Test successful normalization
1273        let result = converter.normalize_order_for_symbol(
1274            "BTC",
1275            dec!(50123.456789), // price
1276            dec!(0.123456789),  // qty
1277        );
1278
1279        assert!(result.is_ok());
1280        let (price, qty) = result.unwrap();
1281        assert_eq!(price, dec!(50123.45)); // rounded down to tick size
1282        assert_eq!(qty, dec!(0.12345)); // rounded down to step size
1283
1284        // Test with symbol not configured (should use defaults)
1285        let result_eth = converter.normalize_order_for_symbol("ETH", dec!(3000.123), dec!(1.23456));
1286        assert!(result_eth.is_ok());
1287
1288        // Test minimum notional failure
1289        let result_fail = converter.normalize_order_for_symbol(
1290            "BTC",
1291            dec!(1.0),   // low price
1292            dec!(0.001), // small qty
1293        );
1294        assert!(result_fail.is_err());
1295        assert!(result_fail.unwrap_err().contains("Notional value"));
1296    }
1297
1298    #[rstest]
1299    fn test_hyperliquid_balance_creation_and_properties() {
1300        use rust_decimal_macros::dec;
1301
1302        let asset = "USD".to_string();
1303        let total = dec!(1000.0);
1304        let available = dec!(750.0);
1305        let sequence = 42;
1306        let ts_event = UnixNanos::default();
1307
1308        let balance = HyperliquidBalance::new(asset.clone(), total, available, sequence, ts_event);
1309
1310        assert_eq!(balance.asset, asset);
1311        assert_eq!(balance.total, total);
1312        assert_eq!(balance.available, available);
1313        assert_eq!(balance.sequence, sequence);
1314        assert_eq!(balance.ts_event, ts_event);
1315        assert_eq!(balance.locked(), dec!(250.0)); // 1000 - 750
1316
1317        // Test balance with all available
1318        let full_balance = HyperliquidBalance::new(
1319            "ETH".to_string(),
1320            dec!(100.0),
1321            dec!(100.0),
1322            1,
1323            UnixNanos::default(),
1324        );
1325        assert_eq!(full_balance.locked(), dec!(0.0));
1326
1327        // Test edge case where available > total (should return 0 locked)
1328        let weird_balance = HyperliquidBalance::new(
1329            "WEIRD".to_string(),
1330            dec!(50.0),
1331            dec!(60.0),
1332            1,
1333            UnixNanos::default(),
1334        );
1335        assert_eq!(weird_balance.locked(), dec!(0.0));
1336    }
1337
1338    #[rstest]
1339    fn test_hyperliquid_account_state_creation() {
1340        let state = HyperliquidAccountState::new();
1341        assert!(state.balances.is_empty());
1342        assert_eq!(state.last_sequence, 0);
1343
1344        let default_state = HyperliquidAccountState::default();
1345        assert!(default_state.balances.is_empty());
1346        assert_eq!(default_state.last_sequence, 0);
1347    }
1348
1349    #[rstest]
1350    fn test_hyperliquid_account_state_getters() {
1351        use rust_decimal_macros::dec;
1352
1353        let mut state = HyperliquidAccountState::new();
1354
1355        // Test get_balance for non-existent asset (should return zero balance)
1356        let balance = state.get_balance("USD");
1357        assert_eq!(balance.asset, "USD");
1358        assert_eq!(balance.total, dec!(0.0));
1359        assert_eq!(balance.available, dec!(0.0));
1360
1361        // Add actual balance
1362        let real_balance = HyperliquidBalance::new(
1363            "USD".to_string(),
1364            dec!(1000.0),
1365            dec!(750.0),
1366            1,
1367            UnixNanos::default(),
1368        );
1369        state.balances.insert("USD".to_string(), real_balance);
1370
1371        // Test retrieving real data
1372        let retrieved_balance = state.get_balance("USD");
1373        assert_eq!(retrieved_balance.total, dec!(1000.0));
1374    }
1375
1376    #[rstest]
1377    fn test_hyperliquid_account_state_account_value() {
1378        use rust_decimal_macros::dec;
1379
1380        let mut state = HyperliquidAccountState::new();
1381
1382        // Add USD balance
1383        state.balances.insert(
1384            "USD".to_string(),
1385            HyperliquidBalance::new(
1386                "USD".to_string(),
1387                dec!(10000.0),
1388                dec!(5000.0),
1389                1,
1390                UnixNanos::default(),
1391            ),
1392        );
1393
1394        let total_value = state.account_value();
1395        assert_eq!(total_value, dec!(10000.0));
1396
1397        // Test with no balance
1398        state.balances.clear();
1399        let no_balance_value = state.account_value();
1400        assert_eq!(no_balance_value, dec!(0.0));
1401    }
1402
1403    #[rstest]
1404    fn test_hyperliquid_account_event_balance_snapshot() {
1405        use rust_decimal_macros::dec;
1406
1407        let mut state = HyperliquidAccountState::new();
1408
1409        let balance = HyperliquidBalance::new(
1410            "USD".to_string(),
1411            dec!(1000.0),
1412            dec!(750.0),
1413            10,
1414            UnixNanos::default(),
1415        );
1416
1417        let snapshot_event = HyperliquidAccountEvent::BalanceSnapshot {
1418            balances: vec![balance],
1419            sequence: 10,
1420        };
1421
1422        state.apply(snapshot_event);
1423
1424        assert_eq!(state.balances.len(), 1);
1425        assert_eq!(state.last_sequence, 10);
1426        assert_eq!(state.get_balance("USD").total, dec!(1000.0));
1427    }
1428
1429    #[rstest]
1430    fn test_hyperliquid_account_event_balance_delta() {
1431        use rust_decimal_macros::dec;
1432
1433        let mut state = HyperliquidAccountState::new();
1434
1435        // Add initial balance
1436        let initial_balance = HyperliquidBalance::new(
1437            "USD".to_string(),
1438            dec!(1000.0),
1439            dec!(750.0),
1440            5,
1441            UnixNanos::default(),
1442        );
1443        state.balances.insert("USD".to_string(), initial_balance);
1444        state.last_sequence = 5;
1445
1446        // Apply balance delta with newer sequence
1447        let updated_balance = HyperliquidBalance::new(
1448            "USD".to_string(),
1449            dec!(1200.0),
1450            dec!(900.0),
1451            10,
1452            UnixNanos::default(),
1453        );
1454
1455        let delta_event = HyperliquidAccountEvent::BalanceDelta {
1456            balance: updated_balance,
1457        };
1458
1459        state.apply(delta_event);
1460
1461        let balance = state.get_balance("USD");
1462        assert_eq!(balance.total, dec!(1200.0));
1463        assert_eq!(balance.available, dec!(900.0));
1464        assert_eq!(balance.sequence, 10);
1465        assert_eq!(state.last_sequence, 10);
1466
1467        // Try to apply older sequence (should be ignored)
1468        let old_balance = HyperliquidBalance::new(
1469            "USD".to_string(),
1470            dec!(800.0),
1471            dec!(600.0),
1472            8,
1473            UnixNanos::default(),
1474        );
1475
1476        let old_delta_event = HyperliquidAccountEvent::BalanceDelta {
1477            balance: old_balance,
1478        };
1479
1480        state.apply(old_delta_event);
1481
1482        // Balance should remain unchanged
1483        let balance = state.get_balance("USD");
1484        assert_eq!(balance.total, dec!(1200.0)); // Still the newer value
1485        assert_eq!(balance.sequence, 10); // Still the newer sequence
1486        assert_eq!(state.last_sequence, 10); // Global sequence unchanged
1487    }
1488}