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