nautilus_hyperliquid/http/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use anyhow::Context;
17use rust_decimal::Decimal;
18use serde::{Deserialize, Serialize};
19
20use super::models::{PerpMeta, SpotMeta};
21
22/// Market type enumeration for normalized instrument definitions.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24pub enum HyperliquidMarketType {
25    /// Perpetual futures contract.
26    Perp,
27    /// Spot trading pair.
28    Spot,
29}
30
31/// Normalized instrument definition produced by this parser.
32///
33/// This deliberately avoids any tight coupling to Nautilus' Cython types.
34/// The InstrumentProvider can later convert this into Nautilus `Instrument`s.
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36pub struct HyperliquidInstrumentDef {
37    /// Human-readable symbol (e.g., "BTC-USD-PERP", "PURR-USDC-SPOT").
38    pub symbol: String,
39    /// Base currency/asset (e.g., "BTC", "PURR").
40    pub base: String,
41    /// Quote currency (e.g., "USD" for perps, "USDC" for spot).
42    pub quote: String,
43    /// Market type (perpetual or spot).
44    pub market_type: HyperliquidMarketType,
45    /// Number of decimal places for price precision.
46    pub price_decimals: u32,
47    /// Number of decimal places for size precision.
48    pub size_decimals: u32,
49    /// Price tick size as decimal.
50    pub tick_size: Decimal,
51    /// Size lot increment as decimal.
52    pub lot_size: Decimal,
53    /// Maximum leverage (for perps).
54    pub max_leverage: Option<u32>,
55    /// Whether requires isolated margin only.
56    pub only_isolated: bool,
57    /// Whether the instrument is active/tradeable.
58    pub active: bool,
59    /// Raw upstream data for debugging.
60    pub raw_data: String,
61}
62
63/// Parse perpetual instrument definitions from Hyperliquid `meta` response.
64///
65/// Hyperliquid perps follow specific rules:
66/// - Quote is always USD (USDC settled)
67/// - Price decimals = max(0, 6 - sz_decimals) per venue docs
68/// - Active = !is_delisted
69///
70/// **Important:** Delisted instruments are included in the returned list but marked as inactive.
71/// This is necessary to support parsing historical data (orders, fills, positions) for instruments
72/// that have been delisted but may still have associated trading history.
73pub fn parse_perp_instruments(meta: &PerpMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
74    const PERP_MAX_DECIMALS: i32 = 6; // Hyperliquid perps price decimal limit
75
76    let mut defs = Vec::new();
77
78    for asset in meta.universe.iter() {
79        // Include delisted assets but mark them as inactive
80        // This allows parsing of historical data for delisted instruments
81        let is_delisted = asset.is_delisted.unwrap_or(false);
82
83        let price_decimals = (PERP_MAX_DECIMALS - asset.sz_decimals as i32).max(0) as u32;
84        let tick_size = pow10_neg(price_decimals)?;
85        let lot_size = pow10_neg(asset.sz_decimals)?;
86
87        let symbol = format!("{}-USD-PERP", asset.name);
88
89        let def = HyperliquidInstrumentDef {
90            symbol,
91            base: asset.name.clone(),
92            quote: "USD".to_string(), // Hyperliquid perps are USD-quoted (USDC settled)
93            market_type: HyperliquidMarketType::Perp,
94            price_decimals,
95            size_decimals: asset.sz_decimals,
96            tick_size,
97            lot_size,
98            max_leverage: asset.max_leverage,
99            only_isolated: asset.only_isolated.unwrap_or(false),
100            active: !is_delisted, // Mark delisted instruments as inactive
101            raw_data: serde_json::to_string(asset).unwrap_or_default(),
102        };
103
104        defs.push(def);
105    }
106
107    Ok(defs)
108}
109
110/// Parse spot instrument definitions from Hyperliquid `spotMeta` response.
111///
112/// Hyperliquid spot follows these rules:
113/// - Price decimals = max(0, 8 - base_sz_decimals) per venue docs
114/// - Size decimals from base token
115/// - All pairs are loaded (including non-canonical) to support parsing fills/positions
116///   for instruments that may have been traded
117pub fn parse_spot_instruments(meta: &SpotMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
118    const SPOT_MAX_DECIMALS: i32 = 8; // Hyperliquid spot price decimal limit
119
120    let mut defs = Vec::new();
121
122    // Build index -> token lookup
123    let mut tokens_by_index = std::collections::HashMap::new();
124    for token in &meta.tokens {
125        tokens_by_index.insert(token.index, token);
126    }
127
128    for pair in &meta.universe {
129        // Load all pairs (including non-canonical) to support parsing fills/positions
130        // for instruments that may have been traded but are not currently canonical
131
132        // Resolve base and quote tokens
133        let base_token = tokens_by_index
134            .get(&pair.tokens[0])
135            .ok_or_else(|| format!("Base token index {} not found", pair.tokens[0]))?;
136        let quote_token = tokens_by_index
137            .get(&pair.tokens[1])
138            .ok_or_else(|| format!("Quote token index {} not found", pair.tokens[1]))?;
139
140        let price_decimals = (SPOT_MAX_DECIMALS - base_token.sz_decimals as i32).max(0) as u32;
141        let tick_size = pow10_neg(price_decimals)?;
142        let lot_size = pow10_neg(base_token.sz_decimals)?;
143
144        let symbol = format!("{}-{}-SPOT", base_token.name, quote_token.name);
145
146        let def = HyperliquidInstrumentDef {
147            symbol,
148            base: base_token.name.clone(),
149            quote: quote_token.name.clone(),
150            market_type: HyperliquidMarketType::Spot,
151            price_decimals,
152            size_decimals: base_token.sz_decimals,
153            tick_size,
154            lot_size,
155            max_leverage: None,
156            only_isolated: false,
157            active: pair.is_canonical, // Use canonical status to indicate if pair is actively tradeable
158            raw_data: serde_json::to_string(pair).unwrap_or_default(),
159        };
160
161        defs.push(def);
162    }
163
164    Ok(defs)
165}
166
167/// Compute 10^(-decimals) as a Decimal.
168///
169/// This uses integer arithmetic to avoid floating-point precision issues.
170fn pow10_neg(decimals: u32) -> Result<Decimal, String> {
171    if decimals == 0 {
172        return Ok(Decimal::ONE);
173    }
174
175    // Build 1 / 10^decimals using integer arithmetic
176    Ok(Decimal::from_i128_with_scale(1, decimals))
177}
178
179// ================================================================================================
180// Instrument Conversion Functions
181// ================================================================================================
182
183use nautilus_core::time::get_atomic_clock_realtime;
184use nautilus_model::{
185    currencies::CURRENCY_MAP,
186    enums::CurrencyType,
187    identifiers::{InstrumentId, Symbol},
188    instruments::{CryptoPerpetual, CurrencyPair, InstrumentAny},
189    types::{Currency, Price, Quantity},
190};
191
192use crate::common::consts::HYPERLIQUID_VENUE;
193
194fn get_currency(code: &str) -> Currency {
195    CURRENCY_MAP
196        .lock()
197        .expect("Failed to acquire CURRENCY_MAP lock")
198        .get(code)
199        .copied()
200        .unwrap_or_else(|| Currency::new(code, 8, 0, code, CurrencyType::Crypto))
201}
202
203/// Converts a single Hyperliquid instrument definition into a Nautilus `InstrumentAny`.
204///
205/// Returns `None` if the conversion fails (e.g., unsupported market type).
206#[must_use]
207pub fn create_instrument_from_def(def: &HyperliquidInstrumentDef) -> Option<InstrumentAny> {
208    let clock = get_atomic_clock_realtime();
209    let ts_event = clock.get_time_ns();
210    let ts_init = ts_event;
211
212    let symbol = Symbol::new(&def.symbol);
213    let venue = *HYPERLIQUID_VENUE;
214    let instrument_id = InstrumentId::new(symbol, venue);
215
216    let raw_symbol = Symbol::new(&def.symbol);
217    let base_currency = get_currency(&def.base);
218    let quote_currency = get_currency(&def.quote);
219    let price_increment = Price::from(&def.tick_size.to_string());
220    let size_increment = Quantity::from(&def.lot_size.to_string());
221
222    match def.market_type {
223        HyperliquidMarketType::Spot => Some(InstrumentAny::CurrencyPair(CurrencyPair::new(
224            instrument_id,
225            raw_symbol,
226            base_currency,
227            quote_currency,
228            def.price_decimals as u8,
229            def.size_decimals as u8,
230            price_increment,
231            size_increment,
232            None,
233            None,
234            None,
235            None,
236            None,
237            None,
238            None,
239            None,
240            None,
241            None,
242            None,
243            None,
244            ts_event,
245            ts_init,
246        ))),
247        HyperliquidMarketType::Perp => {
248            let settlement_currency = get_currency("USDC");
249
250            Some(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
251                instrument_id,
252                raw_symbol,
253                base_currency,
254                quote_currency,
255                settlement_currency,
256                false,
257                def.price_decimals as u8,
258                def.size_decimals as u8,
259                price_increment,
260                size_increment,
261                None,
262                None,
263                None,
264                None,
265                None,
266                None,
267                None,
268                None,
269                None,
270                None,
271                None,
272                None,
273                ts_event,
274                ts_init,
275            )))
276        }
277    }
278}
279
280/// Convert a collection of Hyperliquid instrument definitions into Nautilus instruments,
281/// discarding any definitions that fail to convert.
282#[must_use]
283pub fn instruments_from_defs(defs: &[HyperliquidInstrumentDef]) -> Vec<InstrumentAny> {
284    defs.iter().filter_map(create_instrument_from_def).collect()
285}
286
287/// Convert owned definitions into Nautilus instruments, consuming the input vector.
288#[must_use]
289pub fn instruments_from_defs_owned(defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
290    defs.into_iter()
291        .filter_map(|def| create_instrument_from_def(&def))
292        .collect()
293}
294
295////////////////////////////////////////////////////////////////////////////////
296// Tests
297////////////////////////////////////////////////////////////////////////////////
298
299#[cfg(test)]
300mod tests {
301    use std::str::FromStr;
302
303    use rstest::rstest;
304
305    use super::{
306        super::models::{PerpAsset, SpotPair, SpotToken},
307        *,
308    };
309
310    #[rstest]
311    fn test_pow10_neg() {
312        assert_eq!(pow10_neg(0).unwrap(), Decimal::from(1));
313        assert_eq!(pow10_neg(1).unwrap(), Decimal::from_str("0.1").unwrap());
314        assert_eq!(pow10_neg(5).unwrap(), Decimal::from_str("0.00001").unwrap());
315    }
316
317    #[test]
318    fn test_parse_perp_instruments() {
319        let meta = PerpMeta {
320            universe: vec![
321                PerpAsset {
322                    name: "BTC".to_string(),
323                    sz_decimals: 5,
324                    max_leverage: Some(50),
325                    only_isolated: None,
326                    is_delisted: None,
327                },
328                PerpAsset {
329                    name: "DELIST".to_string(),
330                    sz_decimals: 3,
331                    max_leverage: Some(10),
332                    only_isolated: Some(true),
333                    is_delisted: Some(true), // Should be included but marked as inactive
334                },
335            ],
336            margin_tables: vec![],
337        };
338
339        let defs = parse_perp_instruments(&meta).unwrap();
340
341        // Should have both BTC and DELIST (delisted instruments are included for historical data)
342        assert_eq!(defs.len(), 2);
343
344        let btc = &defs[0];
345        assert_eq!(btc.symbol, "BTC-USD-PERP");
346        assert_eq!(btc.base, "BTC");
347        assert_eq!(btc.quote, "USD");
348        assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
349        assert_eq!(btc.price_decimals, 1); // 6 - 5 = 1
350        assert_eq!(btc.size_decimals, 5);
351        assert_eq!(btc.tick_size, Decimal::from_str("0.1").unwrap());
352        assert_eq!(btc.lot_size, Decimal::from_str("0.00001").unwrap());
353        assert_eq!(btc.max_leverage, Some(50));
354        assert!(!btc.only_isolated);
355        assert!(btc.active);
356
357        let delist = &defs[1];
358        assert_eq!(delist.symbol, "DELIST-USD-PERP");
359        assert_eq!(delist.base, "DELIST");
360        assert!(!delist.active); // Delisted instruments are marked as inactive
361    }
362
363    #[rstest]
364    fn test_parse_spot_instruments() {
365        let tokens = vec![
366            SpotToken {
367                name: "USDC".to_string(),
368                sz_decimals: 6,
369                wei_decimals: 6,
370                index: 0,
371                token_id: "0x1".to_string(),
372                is_canonical: true,
373                evm_contract: None,
374                full_name: None,
375                deployer_trading_fee_share: None,
376            },
377            SpotToken {
378                name: "PURR".to_string(),
379                sz_decimals: 0,
380                wei_decimals: 5,
381                index: 1,
382                token_id: "0x2".to_string(),
383                is_canonical: true,
384                evm_contract: None,
385                full_name: None,
386                deployer_trading_fee_share: None,
387            },
388        ];
389
390        let pairs = vec![
391            SpotPair {
392                name: "PURR/USDC".to_string(),
393                tokens: [1, 0], // PURR base, USDC quote
394                index: 0,
395                is_canonical: true,
396            },
397            SpotPair {
398                name: "ALIAS".to_string(),
399                tokens: [1, 0],
400                index: 1,
401                is_canonical: false, // Should be included but marked as inactive
402            },
403        ];
404
405        let meta = SpotMeta {
406            tokens,
407            universe: pairs,
408        };
409
410        let defs = parse_spot_instruments(&meta).unwrap();
411
412        // Should have both PURR/USDC and ALIAS (non-canonical pairs are included for historical data)
413        assert_eq!(defs.len(), 2);
414
415        let purr_usdc = &defs[0];
416        assert_eq!(purr_usdc.symbol, "PURR-USDC-SPOT");
417        assert_eq!(purr_usdc.base, "PURR");
418        assert_eq!(purr_usdc.quote, "USDC");
419        assert_eq!(purr_usdc.market_type, HyperliquidMarketType::Spot);
420        assert_eq!(purr_usdc.price_decimals, 8); // 8 - 0 = 8 (PURR sz_decimals = 0)
421        assert_eq!(purr_usdc.size_decimals, 0);
422        assert_eq!(
423            purr_usdc.tick_size,
424            Decimal::from_str("0.00000001").unwrap()
425        );
426        assert_eq!(purr_usdc.lot_size, Decimal::from(1));
427        assert_eq!(purr_usdc.max_leverage, None);
428        assert!(!purr_usdc.only_isolated);
429        assert!(purr_usdc.active);
430
431        let alias = &defs[1];
432        assert_eq!(alias.symbol, "PURR-USDC-SPOT");
433        assert_eq!(alias.base, "PURR");
434        assert!(!alias.active); // Non-canonical pairs are marked as inactive
435    }
436
437    #[rstest]
438    fn test_price_decimals_clamping() {
439        // Test that price decimals are clamped to >= 0
440        let meta = PerpMeta {
441            universe: vec![PerpAsset {
442                name: "HIGHPREC".to_string(),
443                sz_decimals: 10, // 6 - 10 = -4, should clamp to 0
444                max_leverage: Some(1),
445                only_isolated: None,
446                is_delisted: None,
447            }],
448            margin_tables: vec![],
449        };
450
451        let defs = parse_perp_instruments(&meta).unwrap();
452        assert_eq!(defs[0].price_decimals, 0);
453        assert_eq!(defs[0].tick_size, Decimal::from(1));
454    }
455}
456
457////////////////////////////////////////////////////////////////////////////////
458// Order, Fill, and Position Report Parsing
459////////////////////////////////////////////////////////////////////////////////
460
461use nautilus_core::{UUID4, UnixNanos};
462use nautilus_model::{
463    enums::{
464        LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSideSpecified, TimeInForce,
465        TriggerType,
466    },
467    identifiers::{AccountId, ClientOrderId, PositionId, TradeId, VenueOrderId},
468    instruments::Instrument,
469    reports::{FillReport, OrderStatusReport, PositionStatusReport},
470};
471use rust_decimal::prelude::ToPrimitive;
472
473use super::models::HyperliquidFill;
474use crate::{
475    common::enums::HyperliquidSide,
476    websocket::messages::{WsBasicOrderData, WsOrderData},
477};
478
479/// Map Hyperliquid order side to Nautilus OrderSide.
480fn parse_order_side(side: &str) -> OrderSide {
481    match side.to_lowercase().as_str() {
482        "a" | "buy" => OrderSide::Buy,
483        "b" | "sell" => OrderSide::Sell,
484        _ => OrderSide::NoOrderSide,
485    }
486}
487
488/// Map Hyperliquid fill side to Nautilus OrderSide.
489fn parse_fill_side(side: &HyperliquidSide) -> OrderSide {
490    match side {
491        HyperliquidSide::Buy => OrderSide::Buy,
492        HyperliquidSide::Sell => OrderSide::Sell,
493    }
494}
495
496/// Map Hyperliquid order status string to Nautilus OrderStatus.
497pub fn parse_order_status(status: &str) -> OrderStatus {
498    match status.to_lowercase().as_str() {
499        "open" => OrderStatus::Accepted,
500        "filled" => OrderStatus::Filled,
501        "canceled" | "cancelled" => OrderStatus::Canceled,
502        "rejected" => OrderStatus::Rejected,
503        "triggered" => OrderStatus::Triggered,
504        "partial_fill" | "partially_filled" => OrderStatus::PartiallyFilled,
505        _ => OrderStatus::Accepted, // Default to accepted for unknown statuses
506    }
507}
508
509/// Parse WebSocket order data to OrderStatusReport.
510///
511/// # Errors
512///
513/// Returns an error if required fields are missing or invalid.
514pub fn parse_order_status_report_from_ws(
515    order_data: &WsOrderData,
516    instrument: &dyn Instrument,
517    account_id: AccountId,
518    ts_init: UnixNanos,
519) -> anyhow::Result<OrderStatusReport> {
520    parse_order_status_report_from_basic(
521        &order_data.order,
522        &order_data.status,
523        instrument,
524        account_id,
525        ts_init,
526    )
527}
528
529/// Parse basic order data to OrderStatusReport.
530///
531/// # Errors
532///
533/// Returns an error if required fields are missing or invalid.
534pub fn parse_order_status_report_from_basic(
535    order: &WsBasicOrderData,
536    status_str: &str,
537    instrument: &dyn Instrument,
538    account_id: AccountId,
539    ts_init: UnixNanos,
540) -> anyhow::Result<OrderStatusReport> {
541    use nautilus_model::types::{Price, Quantity};
542    use rust_decimal::Decimal;
543
544    let instrument_id = instrument.id();
545    let venue_order_id = VenueOrderId::new(order.oid.to_string());
546    let order_side = parse_order_side(&order.side);
547
548    // Determine order type based on trigger parameters
549    let order_type = if order.trigger_px.is_some() {
550        if order.is_market == Some(true) {
551            // Check if it's stop-loss or take-profit based on tpsl field
552            match order.tpsl.as_deref() {
553                Some("tp") => OrderType::MarketIfTouched,
554                Some("sl") => OrderType::StopMarket,
555                _ => OrderType::StopMarket,
556            }
557        } else {
558            match order.tpsl.as_deref() {
559                Some("tp") => OrderType::LimitIfTouched,
560                Some("sl") => OrderType::StopLimit,
561                _ => OrderType::StopLimit,
562            }
563        }
564    } else {
565        OrderType::Limit
566    };
567
568    let time_in_force = TimeInForce::Gtc; // Hyperliquid uses GTC by default
569    let order_status = parse_order_status(status_str);
570
571    // Parse quantities
572    let price_precision = instrument.price_precision();
573    let size_precision = instrument.size_precision();
574
575    let orig_sz: Decimal = order
576        .orig_sz
577        .parse()
578        .map_err(|e| anyhow::anyhow!("Failed to parse orig_sz: {}", e))?;
579    let current_sz: Decimal = order
580        .sz
581        .parse()
582        .map_err(|e| anyhow::anyhow!("Failed to parse sz: {}", e))?;
583
584    let quantity = Quantity::new(orig_sz.abs().to_f64().unwrap_or(0.0), size_precision);
585    let filled_sz = orig_sz.abs() - current_sz.abs();
586    let filled_qty = Quantity::new(filled_sz.to_f64().unwrap_or(0.0), size_precision);
587
588    // Timestamps
589    let ts_accepted = UnixNanos::from(order.timestamp * 1_000_000); // Convert ms to ns
590    let ts_last = ts_accepted;
591
592    let report_id = UUID4::new();
593
594    let mut report = OrderStatusReport::new(
595        account_id,
596        instrument_id,
597        None, // client_order_id - will be set if present
598        venue_order_id,
599        order_side,
600        order_type,
601        time_in_force,
602        order_status,
603        quantity,
604        filled_qty,
605        ts_accepted,
606        ts_last,
607        ts_init,
608        Some(report_id),
609    );
610
611    // Add client order ID if present
612    if let Some(cloid) = &order.cloid {
613        report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
614    }
615
616    // Add price
617    let limit_px: Decimal = order
618        .limit_px
619        .parse()
620        .map_err(|e| anyhow::anyhow!("Failed to parse limit_px: {}", e))?;
621    report = report.with_price(Price::new(
622        limit_px.to_f64().unwrap_or(0.0),
623        price_precision,
624    ));
625
626    // Add trigger price if present
627    if let Some(trigger_px) = &order.trigger_px {
628        let trig_px: Decimal = trigger_px
629            .parse()
630            .map_err(|e| anyhow::anyhow!("Failed to parse trigger_px: {}", e))?;
631        report = report
632            .with_trigger_price(Price::new(trig_px.to_f64().unwrap_or(0.0), price_precision))
633            .with_trigger_type(TriggerType::Default);
634    }
635
636    Ok(report)
637}
638
639/// Parse Hyperliquid fill to FillReport.
640///
641/// # Errors
642///
643/// Returns an error if required fields are missing or invalid.
644pub fn parse_fill_report(
645    fill: &HyperliquidFill,
646    instrument: &dyn Instrument,
647    account_id: AccountId,
648    ts_init: UnixNanos,
649) -> anyhow::Result<FillReport> {
650    use nautilus_model::types::{Money, Price, Quantity};
651    use rust_decimal::Decimal;
652
653    let instrument_id = instrument.id();
654    let venue_order_id = VenueOrderId::new(fill.oid.to_string());
655    let trade_id = TradeId::new(format!("{}-{}", fill.hash, fill.time));
656    let order_side = parse_fill_side(&fill.side);
657
658    // Parse price and quantity
659    let price_precision = instrument.price_precision();
660    let size_precision = instrument.size_precision();
661
662    let px: Decimal = fill
663        .px
664        .parse()
665        .map_err(|e| anyhow::anyhow!("Failed to parse fill price: {}", e))?;
666    let sz: Decimal = fill
667        .sz
668        .parse()
669        .map_err(|e| anyhow::anyhow!("Failed to parse fill size: {}", e))?;
670
671    let last_px = Price::new(px.to_f64().unwrap_or(0.0), price_precision);
672    let last_qty = Quantity::new(sz.abs().to_f64().unwrap_or(0.0), size_precision);
673
674    // Parse fee - Hyperliquid fees are typically in USDC for perps
675    let fee_amount: Decimal = fill
676        .fee
677        .parse()
678        .map_err(|e| anyhow::anyhow!("Failed to parse fee: {}", e))?;
679
680    // Determine fee currency - Hyperliquid perp fees are in USDC
681    let fee_currency = Currency::from("USDC");
682    let commission = Money::new(fee_amount.abs().to_f64().unwrap_or(0.0), fee_currency);
683
684    // Determine liquidity side based on 'crossed' flag
685    let liquidity_side = if fill.crossed {
686        LiquiditySide::Taker
687    } else {
688        LiquiditySide::Maker
689    };
690
691    // Timestamp
692    let ts_event = UnixNanos::from(fill.time * 1_000_000); // Convert ms to ns
693
694    let report_id = UUID4::new();
695
696    let report = FillReport::new(
697        account_id,
698        instrument_id,
699        venue_order_id,
700        trade_id,
701        order_side,
702        last_qty,
703        last_px,
704        commission,
705        liquidity_side,
706        None, // client_order_id - to be linked by execution engine
707        None, // venue_position_id
708        ts_event,
709        ts_init,
710        Some(report_id),
711    );
712
713    Ok(report)
714}
715
716/// Parse position data from clearinghouse state to PositionStatusReport.
717///
718/// # Errors
719///
720/// Returns an error if required fields are missing or invalid.
721pub fn parse_position_status_report(
722    position_data: &serde_json::Value,
723    instrument: &dyn Instrument,
724    account_id: AccountId,
725    ts_init: UnixNanos,
726) -> anyhow::Result<PositionStatusReport> {
727    use nautilus_model::types::Quantity;
728
729    use super::models::AssetPosition;
730
731    // Deserialize the position data
732    let asset_position: AssetPosition = serde_json::from_value(position_data.clone())
733        .context("Failed to deserialize AssetPosition")?;
734
735    let position = &asset_position.position;
736    let instrument_id = instrument.id();
737
738    // Determine position side based on size (szi)
739    let (position_side, quantity_value) = if position.szi.is_zero() {
740        (PositionSideSpecified::Flat, Decimal::ZERO)
741    } else if position.szi.is_sign_positive() {
742        (PositionSideSpecified::Long, position.szi)
743    } else {
744        (PositionSideSpecified::Short, position.szi.abs())
745    };
746
747    // Create quantity
748    let quantity = Quantity::new(
749        quantity_value
750            .to_f64()
751            .context("Failed to convert quantity to f64")?,
752        instrument.size_precision(),
753    );
754
755    // Generate report ID
756    let report_id = UUID4::new();
757
758    // Use current time as ts_last (could be enhanced with actual last update time if available)
759    let ts_last = ts_init;
760
761    // Create position ID from coin symbol
762    let venue_position_id = Some(PositionId::new(format!("{}_{}", account_id, position.coin)));
763
764    // Entry price (if available)
765    let avg_px_open = position.entry_px;
766
767    Ok(PositionStatusReport::new(
768        account_id,
769        instrument_id,
770        position_side,
771        quantity,
772        ts_last,
773        ts_init,
774        Some(report_id),
775        venue_position_id,
776        avg_px_open,
777    ))
778}
779
780#[cfg(test)]
781mod reconciliation_tests {
782    use super::*;
783
784    #[test]
785    fn test_parse_order_side() {
786        assert_eq!(parse_order_side("A"), OrderSide::Buy);
787        assert_eq!(parse_order_side("buy"), OrderSide::Buy);
788        assert_eq!(parse_order_side("B"), OrderSide::Sell);
789        assert_eq!(parse_order_side("sell"), OrderSide::Sell);
790        assert_eq!(parse_order_side("unknown"), OrderSide::NoOrderSide);
791    }
792
793    #[test]
794    fn test_parse_order_status() {
795        assert_eq!(parse_order_status("open"), OrderStatus::Accepted);
796        assert_eq!(parse_order_status("filled"), OrderStatus::Filled);
797        assert_eq!(parse_order_status("canceled"), OrderStatus::Canceled);
798        assert_eq!(parse_order_status("cancelled"), OrderStatus::Canceled);
799        assert_eq!(parse_order_status("rejected"), OrderStatus::Rejected);
800        assert_eq!(parse_order_status("triggered"), OrderStatus::Triggered);
801    }
802
803    #[test]
804    fn test_parse_fill_side() {
805        assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy);
806        assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell);
807    }
808
809    #[test]
810    fn test_parse_order_side_case_insensitive() {
811        assert_eq!(parse_order_side("A"), OrderSide::Buy);
812        assert_eq!(parse_order_side("a"), OrderSide::Buy);
813        assert_eq!(parse_order_side("BUY"), OrderSide::Buy);
814        assert_eq!(parse_order_side("Buy"), OrderSide::Buy);
815        assert_eq!(parse_order_side("B"), OrderSide::Sell);
816        assert_eq!(parse_order_side("b"), OrderSide::Sell);
817        assert_eq!(parse_order_side("SELL"), OrderSide::Sell);
818        assert_eq!(parse_order_side("Sell"), OrderSide::Sell);
819    }
820
821    #[test]
822    fn test_parse_order_status_edge_cases() {
823        assert_eq!(parse_order_status("OPEN"), OrderStatus::Accepted);
824        assert_eq!(parse_order_status("FILLED"), OrderStatus::Filled);
825        assert_eq!(parse_order_status(""), OrderStatus::Accepted);
826        assert_eq!(parse_order_status("unknown_status"), OrderStatus::Accepted);
827    }
828}