Skip to main content

nautilus_hyperliquid/common/
models.rs

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