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.get_config(&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.get_config(&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 config = self.get_config(&instrument_id.symbol.inner());
378        let mut deltas = Vec::new();
379        let mut order_id = sequence * 1000; // Ensure unique order IDs
380
381        // Process bid removals
382        for price_str in bid_removals {
383            let price = parse_price(price_str, &config)?;
384            let order = BookOrder::new(OrderSide::Buy, price, Quantity::from("0"), order_id);
385            deltas.push(OrderBookDelta::new(
386                instrument_id,
387                BookAction::Delete,
388                order,
389                0, // flags
390                sequence,
391                ts_event,
392                ts_init,
393            ));
394            order_id += 1;
395        }
396
397        // Process ask removals
398        for price_str in ask_removals {
399            let price = parse_price(price_str, &config)?;
400            let order = BookOrder::new(OrderSide::Sell, price, Quantity::from("0"), order_id);
401            deltas.push(OrderBookDelta::new(
402                instrument_id,
403                BookAction::Delete,
404                order,
405                0, // flags
406                sequence,
407                ts_event,
408                ts_init,
409            ));
410            order_id += 1;
411        }
412
413        // Process bid updates/additions
414        for (price_str, size_str) in bid_updates {
415            let price = parse_price(price_str, &config)?;
416            let size = parse_size(size_str, &config)?;
417
418            if size.is_positive() {
419                let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
420                deltas.push(OrderBookDelta::new(
421                    instrument_id,
422                    BookAction::Update, // Could be Add or Update - we use Update as safer default
423                    order,
424                    0, // flags
425                    sequence,
426                    ts_event,
427                    ts_init,
428                ));
429            } else {
430                // Size 0 means removal
431                let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
432                deltas.push(OrderBookDelta::new(
433                    instrument_id,
434                    BookAction::Delete,
435                    order,
436                    0, // flags
437                    sequence,
438                    ts_event,
439                    ts_init,
440                ));
441            }
442            order_id += 1;
443        }
444
445        // Process ask updates/additions
446        for (price_str, size_str) in ask_updates {
447            let price = parse_price(price_str, &config)?;
448            let size = parse_size(size_str, &config)?;
449
450            if size.is_positive() {
451                let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
452                deltas.push(OrderBookDelta::new(
453                    instrument_id,
454                    BookAction::Update, // Could be Add or Update - we use Update as safer default
455                    order,
456                    0, // flags
457                    sequence,
458                    ts_event,
459                    ts_init,
460                ));
461            } else {
462                // Size 0 means removal
463                let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
464                deltas.push(OrderBookDelta::new(
465                    instrument_id,
466                    BookAction::Delete,
467                    order,
468                    0, // flags
469                    sequence,
470                    ts_event,
471                    ts_init,
472                ));
473            }
474            order_id += 1;
475        }
476
477        Ok(OrderBookDeltas::new(instrument_id, deltas))
478    }
479}
480
481/// Convert HTTP level to price and size
482fn parse_level(
483    level: &HyperliquidLevel,
484    _inst_info: &HyperliquidInstrumentInfo,
485) -> Result<(Price, Quantity), ConversionError> {
486    let price = price_from_decimal(level.px)?;
487    let size = size_from_decimal(level.sz)?;
488    Ok((price, size))
489}
490
491/// Convert WebSocket level to price and size
492fn parse_ws_level(
493    level: &WsLevelData,
494    _config: &HyperliquidInstrumentInfo,
495) -> Result<(Price, Quantity), ConversionError> {
496    let price = price_from_decimal(level.px)?;
497    let size = size_from_decimal(level.sz)?;
498    Ok((price, size))
499}
500
501/// Parse price string to Price with proper precision
502fn parse_price(
503    price_str: &str,
504    _config: &HyperliquidInstrumentInfo,
505) -> Result<Price, ConversionError> {
506    let decimal = Decimal::from_str(price_str).map_err(|_| ConversionError::InvalidPrice {
507        value: price_str.to_string(),
508    })?;
509
510    Price::from_decimal(decimal).map_err(|_| ConversionError::InvalidPrice {
511        value: price_str.to_string(),
512    })
513}
514
515/// Parse size string to Quantity with proper precision
516fn parse_size(
517    size_str: &str,
518    _config: &HyperliquidInstrumentInfo,
519) -> Result<Quantity, ConversionError> {
520    let decimal = Decimal::from_str(size_str).map_err(|_| ConversionError::InvalidSize {
521        value: size_str.to_string(),
522    })?;
523
524    Quantity::from_decimal(decimal).map_err(|_| ConversionError::InvalidSize {
525        value: size_str.to_string(),
526    })
527}
528
529/// Convert a decimal price to a `Price`, inferring precision from its scale.
530fn price_from_decimal(value: Decimal) -> Result<Price, ConversionError> {
531    Price::from_decimal(value).map_err(|_| ConversionError::InvalidPrice {
532        value: value.to_string(),
533    })
534}
535
536/// Convert a decimal size to a `Quantity`, inferring precision from its scale.
537fn size_from_decimal(value: Decimal) -> Result<Quantity, ConversionError> {
538    Quantity::from_decimal(value).map_err(|_| ConversionError::InvalidSize {
539        value: value.to_string(),
540    })
541}
542
543/// Error conditions from Hyperliquid data conversion.
544#[derive(Debug, Clone, PartialEq, Eq)]
545pub enum ConversionError {
546    /// Invalid price string format.
547    InvalidPrice { value: String },
548    /// Invalid size string format.
549    InvalidSize { value: String },
550    /// Error creating OrderBookDeltas
551    OrderBookDeltasError(String),
552}
553
554impl From<anyhow::Error> for ConversionError {
555    fn from(err: anyhow::Error) -> Self {
556        Self::OrderBookDeltasError(err.to_string())
557    }
558}
559
560impl Display for ConversionError {
561    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
562        match self {
563            Self::InvalidPrice { value } => write!(f, "Invalid price: {value}"),
564            Self::InvalidSize { value } => write!(f, "Invalid size: {value}"),
565            Self::OrderBookDeltasError(msg) => {
566                write!(f, "OrderBookDeltas error: {msg}")
567            }
568        }
569    }
570}
571
572impl std::error::Error for ConversionError {}
573
574/// Raw position data from Hyperliquid API for parsing position status reports.
575///
576/// This struct is used only for parsing API responses and converting to Nautilus
577/// PositionStatusReport events. The actual position tracking is handled by the
578/// Nautilus platform, not the adapter.
579///
580/// See Hyperliquid API documentation:
581/// - [User State Info](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals#retrieve-users-perpetuals-account-summary)
582#[derive(Clone, Debug)]
583pub struct HyperliquidPositionData {
584    pub asset: String,
585    pub position: Decimal, // signed: positive = long, negative = short
586    pub entry_px: Option<Decimal>,
587    pub unrealized_pnl: Decimal,
588    pub cumulative_funding: Option<Decimal>,
589    pub position_value: Decimal,
590}
591
592impl HyperliquidPositionData {
593    /// Check if position is flat (no quantity)
594    pub fn is_flat(&self) -> bool {
595        self.position.is_zero()
596    }
597
598    /// Check if position is long
599    pub fn is_long(&self) -> bool {
600        self.position > Decimal::ZERO
601    }
602
603    /// Check if position is short
604    pub fn is_short(&self) -> bool {
605        self.position < Decimal::ZERO
606    }
607}
608
609/// Balance information from Hyperliquid API.
610///
611/// Represents account balance for a specific asset (currency) as returned by Hyperliquid.
612/// Used for converting to Nautilus AccountBalance and AccountState events.
613///
614/// See Hyperliquid API documentation:
615/// - [Perpetuals Account Summary](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals#retrieve-users-perpetuals-account-summary)
616#[derive(Clone, Debug)]
617pub struct HyperliquidBalance {
618    pub asset: String,
619    pub total: Decimal,
620    pub available: Decimal,
621    pub sequence: u64,
622    pub ts_event: UnixNanos,
623}
624
625impl HyperliquidBalance {
626    pub fn new(
627        asset: String,
628        total: Decimal,
629        available: Decimal,
630        sequence: u64,
631        ts_event: UnixNanos,
632    ) -> Self {
633        Self {
634            asset,
635            total,
636            available,
637            sequence,
638            ts_event,
639        }
640    }
641
642    /// Calculate locked (reserved) balance
643    pub fn locked(&self) -> Decimal {
644        (self.total - self.available).max(Decimal::ZERO)
645    }
646}
647
648/// Simplified account state for Hyperliquid adapter.
649///
650/// This tracks only the essential state needed for generating Nautilus AccountState events.
651/// Position tracking is handled by the Nautilus platform, not the adapter.
652///
653/// See Hyperliquid API documentation:
654/// - [User State Info](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals#retrieve-users-perpetuals-account-summary)
655#[derive(Default, Debug)]
656pub struct HyperliquidAccountState {
657    pub balances: AHashMap<String, HyperliquidBalance>,
658    pub last_sequence: u64,
659}
660
661impl HyperliquidAccountState {
662    pub fn new() -> Self {
663        Self::default()
664    }
665
666    /// Get balance for an asset, returns zero balance if not found
667    pub fn get_balance(&self, asset: &str) -> HyperliquidBalance {
668        self.balances.get(asset).cloned().unwrap_or_else(|| {
669            HyperliquidBalance::new(
670                asset.to_string(),
671                Decimal::ZERO,
672                Decimal::ZERO,
673                0,
674                UnixNanos::default(),
675            )
676        })
677    }
678
679    /// Calculate total account value from balances only.
680    /// Note: This doesn't include unrealized PnL from positions as those are
681    /// tracked by the Nautilus platform, not the adapter.
682    pub fn account_value(&self) -> Decimal {
683        self.balances.values().map(|balance| balance.total).sum()
684    }
685
686    /// Convert HyperliquidAccountState to Nautilus AccountState event.
687    ///
688    /// This creates a standard Nautilus AccountState from the Hyperliquid-specific account state,
689    /// converting balances and handling the margin account type since Hyperliquid supports leverage.
690    ///
691    /// # Returns
692    ///
693    /// A Nautilus AccountState event that can be processed by the platform
694    pub fn to_account_state(
695        &self,
696        account_id: AccountId,
697        ts_event: UnixNanos,
698        ts_init: UnixNanos,
699    ) -> anyhow::Result<AccountState> {
700        // Convert HyperliquidBalance to AccountBalance
701        let balances: Vec<AccountBalance> = self
702            .balances
703            .values()
704            .map(|balance| {
705                // Create currency - Hyperliquid primarily uses USD/USDC
706                let currency = get_currency(&balance.asset);
707                AccountBalance::from_total_and_free(balance.total, balance.available, currency)
708                    .map_err(anyhow::Error::from)
709            })
710            .collect::<anyhow::Result<Vec<_>>>()?;
711
712        // Hyperliquid uses cross-margin so we don't map individual position margins
713        let margins = Vec::new();
714
715        let account_type = AccountType::Margin;
716        let is_reported = true;
717        let event_id = UUID4::new();
718
719        Ok(AccountState::new(
720            account_id,
721            account_type,
722            balances,
723            margins,
724            is_reported,
725            event_id,
726            ts_event,
727            ts_init,
728            None, // base_currency: None for multi-currency support
729        ))
730    }
731}
732
733/// Account balance update events from Hyperliquid exchange.
734///
735/// This enum represents balance update events that can be received from Hyperliquid
736/// via WebSocket streams or HTTP responses. Position tracking is handled by the
737/// Nautilus platform, so this only processes balance changes.
738///
739/// See Hyperliquid documentation:
740/// - [WebSocket API](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket)
741/// - [User State Updates](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket#user-data)
742#[derive(Debug, Clone)]
743pub enum HyperliquidAccountEvent {
744    /// Complete snapshot of balances
745    BalanceSnapshot {
746        balances: Vec<HyperliquidBalance>,
747        sequence: u64,
748    },
749    /// Delta update for a single balance
750    BalanceDelta { balance: HyperliquidBalance },
751}
752
753impl HyperliquidAccountState {
754    /// Apply a balance event to update the account state
755    pub fn apply(&mut self, event: HyperliquidAccountEvent) {
756        match event {
757            HyperliquidAccountEvent::BalanceSnapshot { balances, sequence } => {
758                self.balances.clear();
759
760                for balance in balances {
761                    self.balances.insert(balance.asset.clone(), balance);
762                }
763
764                self.last_sequence = sequence;
765            }
766            HyperliquidAccountEvent::BalanceDelta { balance } => {
767                let sequence = balance.sequence;
768                let entry = self
769                    .balances
770                    .entry(balance.asset.clone())
771                    .or_insert_with(|| balance.clone());
772
773                // Only update if sequence is newer
774                if sequence > entry.sequence {
775                    *entry = balance;
776                    self.last_sequence = self.last_sequence.max(sequence);
777                }
778            }
779        }
780    }
781}
782
783/// Parse Hyperliquid position data into a Nautilus PositionStatusReport.
784///
785/// This function converts raw position data from Hyperliquid API responses into
786/// the standardized Nautilus PositionStatusReport format. The actual position
787/// tracking and management is handled by the Nautilus platform.
788///
789/// See Hyperliquid API documentation:
790/// - [User State Info](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals#retrieve-users-perpetuals-account-summary)
791/// - [Position Data Format](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals#retrieve-users-perpetuals-account-summary)
792pub fn parse_position_status_report(
793    position_data: &HyperliquidPositionData,
794    account_id: AccountId,
795    instrument_id: InstrumentId,
796    ts_init: UnixNanos,
797) -> anyhow::Result<PositionStatusReport> {
798    // Determine position side
799    let position_side = if position_data.is_flat() {
800        PositionSide::Flat
801    } else if position_data.is_long() {
802        PositionSide::Long
803    } else {
804        PositionSide::Short
805    };
806
807    // Convert position size to Quantity
808    let quantity = Quantity::from_decimal(position_data.position.abs())?;
809
810    let ts_last = ts_init;
811    let avg_px_open = position_data.entry_px;
812
813    Ok(PositionStatusReport::new(
814        account_id,
815        instrument_id,
816        position_side.as_specified(),
817        quantity,
818        ts_last,
819        ts_init,
820        None, // report_id: auto-generated
821        None, // venue_position_id: Hyperliquid doesn't use position IDs
822        avg_px_open,
823    ))
824}
825
826#[cfg(test)]
827#[allow(dead_code)]
828mod tests {
829    use rstest::rstest;
830    use rust_decimal_macros::dec;
831
832    use super::*;
833    use crate::common::testing::load_test_data;
834
835    fn test_instrument_id() -> InstrumentId {
836        InstrumentId::from("BTC.HYPER")
837    }
838
839    fn sample_http_book() -> HyperliquidL2Book {
840        load_test_data("http_l2_book_snapshot.json")
841    }
842
843    fn sample_ws_book() -> WsBookData {
844        load_test_data("ws_book_data.json")
845    }
846
847    #[rstest]
848    fn test_http_snapshot_conversion() {
849        let converter = HyperliquidDataConverter::new();
850        let book_data = sample_http_book();
851        let instrument_id = test_instrument_id();
852        let ts_init = UnixNanos::default();
853
854        let deltas = converter
855            .convert_http_snapshot(&book_data, instrument_id, ts_init)
856            .unwrap();
857
858        assert_eq!(deltas.instrument_id, instrument_id);
859        assert_eq!(deltas.deltas.len(), 11); // 1 clear + 5 bids + 5 asks
860
861        // First delta should be Clear - assert all fields
862        let clear_delta = &deltas.deltas[0];
863        assert_eq!(clear_delta.instrument_id, instrument_id);
864        assert_eq!(clear_delta.action, BookAction::Clear);
865        assert_eq!(clear_delta.order.side, OrderSide::NoOrderSide);
866        assert_eq!(clear_delta.order.price.raw, 0);
867        assert_eq!(clear_delta.order.price.precision, 0);
868        assert_eq!(clear_delta.order.size.raw, 0);
869        assert_eq!(clear_delta.order.size.precision, 0);
870        assert_eq!(clear_delta.order.order_id, 0);
871        assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
872        assert_eq!(clear_delta.sequence, 0);
873        assert_eq!(
874            clear_delta.ts_event,
875            UnixNanos::from(book_data.time * 1_000_000)
876        );
877        assert_eq!(clear_delta.ts_init, ts_init);
878
879        // Second delta should be first bid Add - assert all fields
880        let first_bid_delta = &deltas.deltas[1];
881        assert_eq!(first_bid_delta.instrument_id, instrument_id);
882        assert_eq!(first_bid_delta.action, BookAction::Add);
883        assert_eq!(first_bid_delta.order.side, OrderSide::Buy);
884        assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
885        assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
886        assert_eq!(first_bid_delta.order.order_id, 1);
887        assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
888        assert_eq!(first_bid_delta.sequence, 1);
889        assert_eq!(
890            first_bid_delta.ts_event,
891            UnixNanos::from(book_data.time * 1_000_000)
892        );
893        assert_eq!(first_bid_delta.ts_init, ts_init);
894
895        // Verify remaining deltas are Add actions with positive sizes
896        for delta in &deltas.deltas[1..] {
897            assert_eq!(delta.action, BookAction::Add);
898            assert!(delta.order.size.is_positive());
899        }
900    }
901
902    #[rstest]
903    fn test_ws_snapshot_conversion() {
904        let converter = HyperliquidDataConverter::new();
905        let book_data = sample_ws_book();
906        let instrument_id = test_instrument_id();
907        let ts_init = UnixNanos::default();
908
909        let deltas = converter
910            .convert_ws_snapshot(&book_data, instrument_id, ts_init)
911            .unwrap();
912
913        assert_eq!(deltas.instrument_id, instrument_id);
914        assert_eq!(deltas.deltas.len(), 11); // 1 clear + 5 bids + 5 asks
915
916        // First delta should be Clear - assert all fields
917        let clear_delta = &deltas.deltas[0];
918        assert_eq!(clear_delta.instrument_id, instrument_id);
919        assert_eq!(clear_delta.action, BookAction::Clear);
920        assert_eq!(clear_delta.order.side, OrderSide::NoOrderSide);
921        assert_eq!(clear_delta.order.price.raw, 0);
922        assert_eq!(clear_delta.order.price.precision, 0);
923        assert_eq!(clear_delta.order.size.raw, 0);
924        assert_eq!(clear_delta.order.size.precision, 0);
925        assert_eq!(clear_delta.order.order_id, 0);
926        assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
927        assert_eq!(clear_delta.sequence, 0);
928        assert_eq!(
929            clear_delta.ts_event,
930            UnixNanos::from(book_data.time * 1_000_000)
931        );
932        assert_eq!(clear_delta.ts_init, ts_init);
933
934        // Second delta should be first bid Add - assert all fields
935        let first_bid_delta = &deltas.deltas[1];
936        assert_eq!(first_bid_delta.instrument_id, instrument_id);
937        assert_eq!(first_bid_delta.action, BookAction::Add);
938        assert_eq!(first_bid_delta.order.side, OrderSide::Buy);
939        assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
940        assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
941        assert_eq!(first_bid_delta.order.order_id, 1);
942        assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
943        assert_eq!(first_bid_delta.sequence, 1);
944        assert_eq!(
945            first_bid_delta.ts_event,
946            UnixNanos::from(book_data.time * 1_000_000)
947        );
948        assert_eq!(first_bid_delta.ts_init, ts_init);
949    }
950
951    #[rstest]
952    fn test_delta_update_conversion() {
953        let converter = HyperliquidDataConverter::new();
954        let instrument_id = test_instrument_id();
955        let ts_event = UnixNanos::default();
956        let ts_init = UnixNanos::default();
957
958        let bid_updates = vec![("98450.00".to_string(), "1.5".to_string())];
959        let ask_updates = vec![("98451.00".to_string(), "2.0".to_string())];
960        let bid_removals = vec!["98449.00".to_string()];
961        let ask_removals = vec!["98452.00".to_string()];
962
963        let deltas = converter
964            .convert_delta_update(
965                instrument_id,
966                123,
967                ts_event,
968                ts_init,
969                &bid_updates,
970                &ask_updates,
971                &bid_removals,
972                &ask_removals,
973            )
974            .unwrap();
975
976        assert_eq!(deltas.instrument_id, instrument_id);
977        assert_eq!(deltas.deltas.len(), 4); // 2 removals + 2 updates
978        assert_eq!(deltas.sequence, 123);
979
980        // First delta should be bid removal - assert all fields
981        let first_delta = &deltas.deltas[0];
982        assert_eq!(first_delta.instrument_id, instrument_id);
983        assert_eq!(first_delta.action, BookAction::Delete);
984        assert_eq!(first_delta.order.side, OrderSide::Buy);
985        assert_eq!(first_delta.order.price, Price::from("98449.00"));
986        assert_eq!(first_delta.order.size, Quantity::from("0"));
987        assert_eq!(first_delta.order.order_id, 123000);
988        assert_eq!(first_delta.flags, 0);
989        assert_eq!(first_delta.sequence, 123);
990        assert_eq!(first_delta.ts_event, ts_event);
991        assert_eq!(first_delta.ts_init, ts_init);
992    }
993
994    #[rstest]
995    fn test_price_size_parsing() {
996        let instrument_id = test_instrument_id();
997        let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
998
999        let price = parse_price("98450.50", &config).unwrap();
1000        assert_eq!(price.to_string(), "98450.50");
1001
1002        let size = parse_size("2.5", &config).unwrap();
1003        assert_eq!(size.to_string(), "2.5");
1004    }
1005
1006    #[rstest]
1007    fn test_hyperliquid_instrument_mini_info() {
1008        let instrument_id = test_instrument_id();
1009
1010        // Test constructor with all fields
1011        let config = HyperliquidInstrumentInfo::new(instrument_id, 4, 6);
1012        assert_eq!(config.instrument_id, instrument_id);
1013        assert_eq!(config.price_decimals, 4);
1014        assert_eq!(config.size_decimals, 6);
1015
1016        // Test default crypto configuration - assert all fields
1017        let default_config = HyperliquidInstrumentInfo::default_crypto(instrument_id);
1018        assert_eq!(default_config.instrument_id, instrument_id);
1019        assert_eq!(default_config.price_decimals, 2);
1020        assert_eq!(default_config.size_decimals, 5);
1021    }
1022
1023    #[rstest]
1024    fn test_invalid_price_parsing() {
1025        let instrument_id = test_instrument_id();
1026        let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
1027
1028        // Test invalid price parsing
1029        let result = parse_price("invalid", &config);
1030        assert!(result.is_err());
1031
1032        match result.unwrap_err() {
1033            ConversionError::InvalidPrice { value } => {
1034                assert_eq!(value, "invalid");
1035                // Verify the error displays correctly
1036                assert!(value.contains("invalid"));
1037            }
1038            _ => panic!("Expected InvalidPrice error"),
1039        }
1040
1041        // Test invalid size parsing
1042        let size_result = parse_size("not_a_number", &config);
1043        assert!(size_result.is_err());
1044
1045        match size_result.unwrap_err() {
1046            ConversionError::InvalidSize { value } => {
1047                assert_eq!(value, "not_a_number");
1048                // Verify the error displays correctly
1049                assert!(value.contains("not_a_number"));
1050            }
1051            _ => panic!("Expected InvalidSize error"),
1052        }
1053    }
1054
1055    #[rstest]
1056    fn test_configuration() {
1057        let mut converter = HyperliquidDataConverter::new();
1058        let eth_id = InstrumentId::from("ETH.HYPER");
1059        let config = HyperliquidInstrumentInfo::new(eth_id, 4, 8);
1060
1061        let asset = Ustr::from("ETH");
1062
1063        converter.configure_instrument(asset.as_str(), config.clone());
1064
1065        // Assert all fields of the retrieved config
1066        let retrieved_config = converter.get_config(&asset);
1067        assert_eq!(retrieved_config.instrument_id, eth_id);
1068        assert_eq!(retrieved_config.price_decimals, 4);
1069        assert_eq!(retrieved_config.size_decimals, 8);
1070
1071        // Assert all fields of the default config for unknown symbol
1072        let default_config = converter.get_config(&Ustr::from("UNKNOWN"));
1073        assert_eq!(
1074            default_config.instrument_id,
1075            InstrumentId::from("UNKNOWN.HYPER")
1076        );
1077        assert_eq!(default_config.price_decimals, 2);
1078        assert_eq!(default_config.size_decimals, 5);
1079
1080        // Verify the original config object has expected values
1081        assert_eq!(config.instrument_id, eth_id);
1082        assert_eq!(config.price_decimals, 4);
1083        assert_eq!(config.size_decimals, 8);
1084    }
1085
1086    #[rstest]
1087    fn test_instrument_info_creation() {
1088        let instrument_id = InstrumentId::from("BTC.HYPER");
1089        let info = HyperliquidInstrumentInfo::with_metadata(
1090            instrument_id,
1091            2,
1092            5,
1093            dec!(0.01),
1094            dec!(0.00001),
1095            dec!(10),
1096        );
1097
1098        assert_eq!(info.instrument_id, instrument_id);
1099        assert_eq!(info.price_decimals, 2);
1100        assert_eq!(info.size_decimals, 5);
1101        assert_eq!(info.tick_size, Some(dec!(0.01)));
1102        assert_eq!(info.step_size, Some(dec!(0.00001)));
1103        assert_eq!(info.min_notional, Some(dec!(10)));
1104    }
1105
1106    #[rstest]
1107    fn test_instrument_info_with_precision() {
1108        let instrument_id = test_instrument_id();
1109        let info = HyperliquidInstrumentInfo::with_precision(instrument_id, 3, 4);
1110        assert_eq!(info.instrument_id, instrument_id);
1111        assert_eq!(info.price_decimals, 3);
1112        assert_eq!(info.size_decimals, 4);
1113        assert_eq!(info.tick_size, Some(dec!(0.001))); // 0.001
1114        assert_eq!(info.step_size, Some(dec!(0.0001))); // 0.0001
1115    }
1116
1117    #[tokio::test]
1118    async fn test_instrument_cache_basic_operations() {
1119        let btc_info = HyperliquidInstrumentInfo::with_metadata(
1120            InstrumentId::from("BTC.HYPER"),
1121            2,
1122            5,
1123            dec!(0.01),
1124            dec!(0.00001),
1125            dec!(10),
1126        );
1127
1128        let eth_info = HyperliquidInstrumentInfo::with_metadata(
1129            InstrumentId::from("ETH.HYPER"),
1130            2,
1131            4,
1132            dec!(0.01),
1133            dec!(0.0001),
1134            dec!(10),
1135        );
1136
1137        let mut cache = HyperliquidInstrumentCache::new();
1138
1139        // Insert instruments manually
1140        cache.insert("BTC", btc_info.clone());
1141        cache.insert("ETH", eth_info.clone());
1142
1143        // Get BTC instrument
1144        let retrieved_btc = cache.get("BTC").unwrap();
1145        assert_eq!(retrieved_btc.instrument_id, btc_info.instrument_id);
1146        assert_eq!(retrieved_btc.size_decimals, 5);
1147
1148        // Get ETH instrument
1149        let retrieved_eth = cache.get("ETH").unwrap();
1150        assert_eq!(retrieved_eth.instrument_id, eth_info.instrument_id);
1151        assert_eq!(retrieved_eth.size_decimals, 4);
1152
1153        // Test cache methods
1154        assert_eq!(cache.len(), 2);
1155        assert!(!cache.is_empty());
1156
1157        // Test contains
1158        assert!(cache.contains("BTC"));
1159        assert!(cache.contains("ETH"));
1160        assert!(!cache.contains("UNKNOWN"));
1161
1162        // Test get_all
1163        let all_instruments = cache.get_all();
1164        assert_eq!(all_instruments.len(), 2);
1165    }
1166
1167    #[rstest]
1168    fn test_instrument_cache_empty() {
1169        let cache = HyperliquidInstrumentCache::new();
1170        let result = cache.get("UNKNOWN");
1171        assert!(result.is_none());
1172        assert!(cache.is_empty());
1173        assert_eq!(cache.len(), 0);
1174    }
1175
1176    #[rstest]
1177    fn test_normalize_order_for_symbol() {
1178        use rust_decimal_macros::dec;
1179
1180        let mut converter = HyperliquidDataConverter::new();
1181
1182        // Configure BTC with specific instrument info
1183        let btc_info = HyperliquidInstrumentInfo::with_metadata(
1184            InstrumentId::from("BTC.HYPER"),
1185            2,
1186            5,
1187            dec!(0.01),    // tick_size
1188            dec!(0.00001), // step_size
1189            dec!(10.0),    // min_notional
1190        );
1191        converter.configure_instrument("BTC", btc_info);
1192
1193        // Test successful normalization
1194        let result = converter.normalize_order_for_symbol(
1195            "BTC",
1196            dec!(50123.456789), // price
1197            dec!(0.123456789),  // qty
1198        );
1199
1200        assert!(result.is_ok());
1201        let (price, qty) = result.unwrap();
1202        // Price is first rounded to 5 sig figs (50123), then to tick size
1203        assert_eq!(price, dec!(50123.00));
1204        assert_eq!(qty, dec!(0.12345)); // rounded down to step size
1205
1206        // Test with symbol not configured (should use defaults)
1207        let result_eth = converter.normalize_order_for_symbol("ETH", dec!(3000.123), dec!(1.23456));
1208        assert!(result_eth.is_ok());
1209
1210        // Test minimum notional failure
1211        let result_fail = converter.normalize_order_for_symbol(
1212            "BTC",
1213            dec!(1.0),   // low price
1214            dec!(0.001), // small qty
1215        );
1216        assert!(result_fail.is_err());
1217        assert!(result_fail.unwrap_err().contains("Notional value"));
1218    }
1219
1220    #[rstest]
1221    fn test_hyperliquid_balance_creation_and_properties() {
1222        use rust_decimal_macros::dec;
1223
1224        let asset = "USD".to_string();
1225        let total = dec!(1000.0);
1226        let available = dec!(750.0);
1227        let sequence = 42;
1228        let ts_event = UnixNanos::default();
1229
1230        let balance = HyperliquidBalance::new(asset.clone(), total, available, sequence, ts_event);
1231
1232        assert_eq!(balance.asset, asset);
1233        assert_eq!(balance.total, total);
1234        assert_eq!(balance.available, available);
1235        assert_eq!(balance.sequence, sequence);
1236        assert_eq!(balance.ts_event, ts_event);
1237        assert_eq!(balance.locked(), dec!(250.0)); // 1000 - 750
1238
1239        // Test balance with all available
1240        let full_balance = HyperliquidBalance::new(
1241            "ETH".to_string(),
1242            dec!(100.0),
1243            dec!(100.0),
1244            1,
1245            UnixNanos::default(),
1246        );
1247        assert_eq!(full_balance.locked(), dec!(0.0));
1248
1249        // Test edge case where available > total (should return 0 locked)
1250        let weird_balance = HyperliquidBalance::new(
1251            "WEIRD".to_string(),
1252            dec!(50.0),
1253            dec!(60.0),
1254            1,
1255            UnixNanos::default(),
1256        );
1257        assert_eq!(weird_balance.locked(), dec!(0.0));
1258    }
1259
1260    #[rstest]
1261    fn test_hyperliquid_account_state_creation() {
1262        let state = HyperliquidAccountState::new();
1263        assert!(state.balances.is_empty());
1264        assert_eq!(state.last_sequence, 0);
1265
1266        let default_state = HyperliquidAccountState::default();
1267        assert!(default_state.balances.is_empty());
1268        assert_eq!(default_state.last_sequence, 0);
1269    }
1270
1271    #[rstest]
1272    fn test_hyperliquid_account_state_getters() {
1273        use rust_decimal_macros::dec;
1274
1275        let mut state = HyperliquidAccountState::new();
1276
1277        // Test get_balance for non-existent asset (should return zero balance)
1278        let balance = state.get_balance("USD");
1279        assert_eq!(balance.asset, "USD");
1280        assert_eq!(balance.total, dec!(0.0));
1281        assert_eq!(balance.available, dec!(0.0));
1282
1283        // Add actual balance
1284        let real_balance = HyperliquidBalance::new(
1285            "USD".to_string(),
1286            dec!(1000.0),
1287            dec!(750.0),
1288            1,
1289            UnixNanos::default(),
1290        );
1291        state.balances.insert("USD".to_string(), real_balance);
1292
1293        // Test retrieving real data
1294        let retrieved_balance = state.get_balance("USD");
1295        assert_eq!(retrieved_balance.total, dec!(1000.0));
1296    }
1297
1298    #[rstest]
1299    fn test_hyperliquid_account_state_account_value() {
1300        use rust_decimal_macros::dec;
1301
1302        let mut state = HyperliquidAccountState::new();
1303
1304        // Add USD balance
1305        state.balances.insert(
1306            "USD".to_string(),
1307            HyperliquidBalance::new(
1308                "USD".to_string(),
1309                dec!(10000.0),
1310                dec!(5000.0),
1311                1,
1312                UnixNanos::default(),
1313            ),
1314        );
1315
1316        let total_value = state.account_value();
1317        assert_eq!(total_value, dec!(10000.0));
1318
1319        // Test with no balance
1320        state.balances.clear();
1321        let no_balance_value = state.account_value();
1322        assert_eq!(no_balance_value, dec!(0.0));
1323    }
1324
1325    #[rstest]
1326    fn test_hyperliquid_account_event_balance_snapshot() {
1327        use rust_decimal_macros::dec;
1328
1329        let mut state = HyperliquidAccountState::new();
1330
1331        let balance = HyperliquidBalance::new(
1332            "USD".to_string(),
1333            dec!(1000.0),
1334            dec!(750.0),
1335            10,
1336            UnixNanos::default(),
1337        );
1338
1339        let snapshot_event = HyperliquidAccountEvent::BalanceSnapshot {
1340            balances: vec![balance],
1341            sequence: 10,
1342        };
1343
1344        state.apply(snapshot_event);
1345
1346        assert_eq!(state.balances.len(), 1);
1347        assert_eq!(state.last_sequence, 10);
1348        assert_eq!(state.get_balance("USD").total, dec!(1000.0));
1349    }
1350
1351    #[rstest]
1352    fn test_hyperliquid_account_event_balance_delta() {
1353        use rust_decimal_macros::dec;
1354
1355        let mut state = HyperliquidAccountState::new();
1356
1357        // Add initial balance
1358        let initial_balance = HyperliquidBalance::new(
1359            "USD".to_string(),
1360            dec!(1000.0),
1361            dec!(750.0),
1362            5,
1363            UnixNanos::default(),
1364        );
1365        state.balances.insert("USD".to_string(), initial_balance);
1366        state.last_sequence = 5;
1367
1368        // Apply balance delta with newer sequence
1369        let updated_balance = HyperliquidBalance::new(
1370            "USD".to_string(),
1371            dec!(1200.0),
1372            dec!(900.0),
1373            10,
1374            UnixNanos::default(),
1375        );
1376
1377        let delta_event = HyperliquidAccountEvent::BalanceDelta {
1378            balance: updated_balance,
1379        };
1380
1381        state.apply(delta_event);
1382
1383        let balance = state.get_balance("USD");
1384        assert_eq!(balance.total, dec!(1200.0));
1385        assert_eq!(balance.available, dec!(900.0));
1386        assert_eq!(balance.sequence, 10);
1387        assert_eq!(state.last_sequence, 10);
1388
1389        // Try to apply older sequence (should be ignored)
1390        let old_balance = HyperliquidBalance::new(
1391            "USD".to_string(),
1392            dec!(800.0),
1393            dec!(600.0),
1394            8,
1395            UnixNanos::default(),
1396        );
1397
1398        let old_delta_event = HyperliquidAccountEvent::BalanceDelta {
1399            balance: old_balance,
1400        };
1401
1402        state.apply(old_delta_event);
1403
1404        // Balance should remain unchanged
1405        let balance = state.get_balance("USD");
1406        assert_eq!(balance.total, dec!(1200.0)); // Still the newer value
1407        assert_eq!(balance.sequence, 10); // Still the newer sequence
1408        assert_eq!(state.last_sequence, 10); // Global sequence unchanged
1409    }
1410
1411    #[rstest]
1412    fn test_hyperliquid_account_state_to_account_state_uses_from_total_and_free() {
1413        use nautilus_model::identifiers::AccountId;
1414
1415        let mut state = HyperliquidAccountState::new();
1416        state.balances.insert(
1417            "USDC".to_string(),
1418            HyperliquidBalance::new(
1419                "USDC".to_string(),
1420                dec!(10_000),
1421                dec!(7_500),
1422                1,
1423                UnixNanos::default(),
1424            ),
1425        );
1426        state.balances.insert(
1427            "BTC".to_string(),
1428            HyperliquidBalance::new(
1429                "BTC".to_string(),
1430                dec!(1.25),
1431                dec!(1.0),
1432                2,
1433                UnixNanos::default(),
1434            ),
1435        );
1436
1437        let account_id = AccountId::new("HYPERLIQUID-001");
1438        let ts = UnixNanos::default();
1439        let account_state = state.to_account_state(account_id, ts, ts).unwrap();
1440
1441        assert_eq!(account_state.account_id, account_id);
1442        assert_eq!(account_state.balances.len(), 2);
1443
1444        let usdc = account_state
1445            .balances
1446            .iter()
1447            .find(|b| b.currency.code.as_str() == "USDC")
1448            .expect("USDC balance emitted");
1449        assert_eq!(usdc.total.as_decimal(), dec!(10_000));
1450        assert_eq!(usdc.free.as_decimal(), dec!(7_500));
1451        assert_eq!(usdc.locked.as_decimal(), dec!(2_500));
1452
1453        let btc = account_state
1454            .balances
1455            .iter()
1456            .find(|b| b.currency.code.as_str() == "BTC")
1457            .expect("BTC balance emitted");
1458        assert_eq!(btc.total.as_decimal(), dec!(1.25));
1459        assert_eq!(btc.free.as_decimal(), dec!(1.0));
1460        assert_eq!(btc.locked.as_decimal(), dec!(0.25));
1461    }
1462}