Skip to main content

nautilus_kraken/common/
parse.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
16//! Conversion helpers that translate Kraken API schemas into Nautilus domain models.
17
18use std::str::FromStr;
19
20use anyhow::Context;
21use nautilus_core::{
22    datetime::NANOSECONDS_IN_MILLISECOND, nanos::UnixNanos, string::parsing::precision_from_str,
23    uuid::UUID4,
24};
25use nautilus_model::{
26    data::{Bar, BarType, TradeTick},
27    enums::{
28        AggressorSide, AssetClass, BarAggregation, ContingencyType, LiquiditySide, OrderStatus,
29        OrderType, PositionSideSpecified, TimeInForce, TrailingOffsetType, TriggerType,
30    },
31    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
32    instruments::{
33        Instrument, any::InstrumentAny, crypto_perpetual::CryptoPerpetual,
34        currency_pair::CurrencyPair, tokenized_asset::TokenizedAsset,
35    },
36    reports::{FillReport, OrderStatusReport, PositionStatusReport},
37    types::{Currency, Money, Price, Quantity, fixed::FIXED_PRECISION},
38};
39use rust_decimal::Decimal;
40use rust_decimal_macros::dec;
41
42use crate::{
43    common::{
44        consts::KRAKEN_VENUE,
45        enums::{
46            KrakenFillType, KrakenFuturesOrderEventType, KrakenInstrumentType, KrakenPositionSide,
47            KrakenSpotTrigger, KrakenTriggerSignal,
48        },
49    },
50    http::models::{
51        AssetPairInfo, FuturesFill, FuturesInstrument, FuturesOpenOrder, FuturesOrderEvent,
52        FuturesPosition, FuturesPublicExecution, OhlcData, SpotOrder, SpotTrade,
53    },
54};
55
56/// Parse a decimal string, handling empty strings and "0" values.
57pub fn parse_decimal(value: &str) -> anyhow::Result<Decimal> {
58    if value.is_empty() || value == "0" {
59        return Ok(dec!(0));
60    }
61    value
62        .parse::<Decimal>()
63        .map_err(|e| anyhow::anyhow!("Failed to parse decimal '{value}': {e}"))
64}
65
66fn parse_rfc3339_timestamp(value: &str, field: &str) -> anyhow::Result<UnixNanos> {
67    value
68        .parse::<UnixNanos>()
69        .map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
70}
71
72/// Normalizes a Kraken currency code by stripping the legacy X/Z prefix.
73///
74/// Kraken uses legacy prefixes for some currencies (e.g., XXBT for Bitcoin, XETH for Ethereum,
75/// ZUSD for USD). This function strips those prefixes for consistent lookups.
76#[inline]
77pub fn normalize_currency_code(code: &str) -> &str {
78    code.strip_prefix("X")
79        .or_else(|| code.strip_prefix("Z"))
80        .unwrap_or(code)
81}
82
83/// Normalizes a Kraken spot symbol to use BTC instead of XBT.
84///
85/// Kraken's REST API returns `XBT` for Bitcoin (following ISO 4217 conventions), but their
86/// WebSocket v2 API uses the more common `BTC` format. This function normalizes symbols
87/// so that instruments and subscriptions use consistent, industry-standard symbols.
88/// Handles XBT in both base position (XBT/USD -> BTC/USD) and quote position (ETH/XBT -> ETH/BTC).
89#[inline]
90pub fn normalize_spot_symbol(symbol: &str) -> String {
91    let normalized = if symbol.starts_with("XBT/") {
92        symbol.replacen("XBT/", "BTC/", 1)
93    } else {
94        symbol.to_string()
95    };
96
97    if normalized.ends_with("/XBT") {
98        normalized.replacen("/XBT", "/BTC", 1)
99    } else {
100        normalized
101    }
102}
103
104/// Parse an optional decimal string.
105pub fn parse_decimal_opt(value: Option<&str>) -> anyhow::Result<Option<Decimal>> {
106    match value {
107        Some(s) if !s.is_empty() && s != "0" => Ok(Some(parse_decimal(s)?)),
108        _ => Ok(None),
109    }
110}
111
112/// Parse Kraken spot trigger to Nautilus TriggerType.
113fn parse_trigger_type(
114    order_type: OrderType,
115    trigger: Option<KrakenSpotTrigger>,
116) -> Option<TriggerType> {
117    let is_conditional = matches!(
118        order_type,
119        OrderType::StopMarket
120            | OrderType::StopLimit
121            | OrderType::MarketIfTouched
122            | OrderType::LimitIfTouched
123    );
124
125    if !is_conditional {
126        return None;
127    }
128
129    match trigger {
130        Some(KrakenSpotTrigger::Last) => Some(TriggerType::LastPrice),
131        Some(KrakenSpotTrigger::Index) => Some(TriggerType::IndexPrice),
132        None => Some(TriggerType::Default),
133    }
134}
135
136/// Parse Kraken futures trigger signal to Nautilus TriggerType.
137fn parse_futures_trigger_type(
138    order_type: OrderType,
139    trigger_signal: Option<KrakenTriggerSignal>,
140) -> Option<TriggerType> {
141    let is_conditional = matches!(
142        order_type,
143        OrderType::StopMarket
144            | OrderType::StopLimit
145            | OrderType::MarketIfTouched
146            | OrderType::LimitIfTouched
147    );
148
149    if !is_conditional {
150        return None;
151    }
152
153    match trigger_signal {
154        Some(KrakenTriggerSignal::Last) => Some(TriggerType::LastPrice),
155        Some(KrakenTriggerSignal::Mark) => Some(TriggerType::MarkPrice),
156        Some(KrakenTriggerSignal::Index) => Some(TriggerType::IndexPrice),
157        None => Some(TriggerType::Default),
158    }
159}
160
161/// Parses a Kraken asset pair definition into a Nautilus currency pair instrument.
162///
163/// # Errors
164///
165/// Returns an error if:
166/// - Tick size, order minimum, or cost minimum cannot be parsed.
167/// - Price or quantity precision is invalid.
168/// - Currency codes are invalid.
169pub fn parse_spot_instrument(
170    pair_name: &str,
171    definition: &AssetPairInfo,
172    ts_event: UnixNanos,
173    ts_init: UnixNanos,
174) -> anyhow::Result<InstrumentAny> {
175    let symbol_str = definition.wsname.as_ref().unwrap_or(&definition.altname);
176    let normalized_symbol = normalize_spot_symbol(symbol_str);
177    let instrument_id = InstrumentId::new(Symbol::new(&normalized_symbol), *KRAKEN_VENUE);
178    let raw_symbol = Symbol::new(pair_name);
179
180    let base_currency = get_currency(definition.base.as_str());
181    let quote_currency = get_currency(definition.quote.as_str());
182
183    let price_increment = parse_price(
184        definition
185            .tick_size
186            .as_ref()
187            .context("tick_size is required")?,
188        "tick_size",
189    )?;
190
191    // lot_decimals specifies the decimal precision for the lot size
192    let size_precision = definition.lot_decimals;
193    let size_increment = Quantity::new(10.0_f64.powi(-(size_precision as i32)), size_precision);
194
195    let min_quantity = definition
196        .ordermin
197        .as_ref()
198        .map(|s| parse_quantity(s, "ordermin"))
199        .transpose()?;
200
201    // Use base tier fees, convert from percentage
202    let taker_fee = definition
203        .fees
204        .first()
205        .map(|(_, fee)| Decimal::try_from(*fee))
206        .transpose()
207        .context("Failed to parse taker fee")?
208        .map(|f| f / dec!(100));
209
210    let maker_fee = definition
211        .fees_maker
212        .first()
213        .map(|(_, fee)| Decimal::try_from(*fee))
214        .transpose()
215        .context("Failed to parse maker fee")?
216        .map(|f| f / dec!(100));
217
218    let instrument = CurrencyPair::new(
219        instrument_id,
220        raw_symbol,
221        base_currency,
222        quote_currency,
223        price_increment.precision,
224        size_increment.precision,
225        price_increment,
226        size_increment,
227        None,
228        None,
229        None,
230        min_quantity,
231        None,
232        None,
233        None,
234        None,
235        None,
236        None,
237        maker_fee,
238        taker_fee,
239        None,
240        ts_event,
241        ts_init,
242    );
243
244    Ok(InstrumentAny::CurrencyPair(instrument))
245}
246
247/// Parses a Kraken tokenized asset pair into a Nautilus tokenized asset instrument.
248///
249/// Tokenized assets (xStocks) use the same API schema as spot pairs but represent
250/// real-world equities, ETFs, or other tokenized securities.
251///
252/// # Errors
253///
254/// Returns an error if tick size, order minimum, or fee fields cannot be parsed.
255pub fn parse_tokenized_instrument(
256    pair_name: &str,
257    definition: &AssetPairInfo,
258    ts_event: UnixNanos,
259    ts_init: UnixNanos,
260) -> anyhow::Result<InstrumentAny> {
261    let symbol_str = definition.wsname.as_ref().unwrap_or(&definition.altname);
262    let normalized_symbol = normalize_spot_symbol(symbol_str);
263    let instrument_id = InstrumentId::new(Symbol::new(&normalized_symbol), *KRAKEN_VENUE);
264    let raw_symbol = Symbol::new(pair_name);
265
266    let base_currency = get_currency(definition.base.as_str());
267    let quote_currency = get_currency(definition.quote.as_str());
268
269    let price_increment = parse_price(
270        definition
271            .tick_size
272            .as_ref()
273            .context("tick_size is required")?,
274        "tick_size",
275    )?;
276
277    let size_precision = definition.lot_decimals;
278    let size_increment = Quantity::new(10.0_f64.powi(-(size_precision as i32)), size_precision);
279
280    let min_quantity = definition
281        .ordermin
282        .as_ref()
283        .map(|s| parse_quantity(s, "ordermin"))
284        .transpose()?;
285
286    let taker_fee = definition
287        .fees
288        .first()
289        .map(|(_, fee)| Decimal::try_from(*fee))
290        .transpose()
291        .context("failed to parse taker fee")?
292        .map(|f| f / dec!(100));
293
294    let maker_fee = definition
295        .fees_maker
296        .first()
297        .map(|(_, fee)| Decimal::try_from(*fee))
298        .transpose()
299        .context("failed to parse maker fee")?
300        .map(|f| f / dec!(100));
301
302    let instrument = TokenizedAsset::new(
303        instrument_id,
304        raw_symbol,
305        AssetClass::Equity,
306        base_currency,
307        quote_currency,
308        None, // isin
309        price_increment.precision,
310        size_increment.precision,
311        price_increment,
312        size_increment,
313        None,
314        None,
315        None,
316        min_quantity,
317        None,
318        None,
319        None,
320        None,
321        None,
322        None,
323        maker_fee,
324        taker_fee,
325        None,
326        ts_event,
327        ts_init,
328    );
329
330    Ok(InstrumentAny::TokenizedAsset(instrument))
331}
332
333/// Parses a Kraken futures instrument definition into a Nautilus crypto perpetual instrument.
334///
335/// # Errors
336///
337/// Returns an error if:
338/// - Tick size cannot be parsed as a valid price.
339/// - Contract size cannot be parsed as a valid quantity.
340/// - Currency codes are invalid.
341pub fn parse_futures_instrument(
342    instrument: &FuturesInstrument,
343    ts_event: UnixNanos,
344    ts_init: UnixNanos,
345) -> anyhow::Result<InstrumentAny> {
346    let instrument_id = InstrumentId::new(Symbol::new(&instrument.symbol), *KRAKEN_VENUE);
347    let raw_symbol = Symbol::new(&instrument.symbol);
348
349    let base_currency = get_currency(&instrument.base);
350    let quote_currency = get_currency(&instrument.quote);
351
352    let is_inverse = instrument.instrument_type == KrakenInstrumentType::FuturesInverse;
353    let settlement_currency = if is_inverse {
354        base_currency
355    } else {
356        quote_currency
357    };
358
359    // Derive precision from tick_size string representation to handle non-power-of-10
360    // tick sizes correctly (e.g., 0.25, 2.5)
361    let tick_size = instrument.tick_size;
362    let price_precision = precision_from_str(&tick_size.to_string());
363    if price_precision > FIXED_PRECISION {
364        anyhow::bail!(
365            "Cannot parse instrument '{}': tick_size {tick_size} requires precision {price_precision} \
366             which exceeds FIXED_PRECISION ({FIXED_PRECISION})",
367            instrument.symbol
368        );
369    }
370    let price_increment = Price::new(tick_size, price_precision);
371
372    // Use contract_value_trade_precision for the tradeable size increment
373    // Positive values (e.g., 3) mean fractional sizes (0.001)
374    // Negative values (e.g., -3) mean multiples of powers of 10 (1000) - used for meme coins
375    // Zero means whole number increments (1)
376    let (_size_precision, size_increment) = if instrument.contract_value_trade_precision >= 0 {
377        let precision = instrument.contract_value_trade_precision as u8;
378        let increment = Quantity::new(10.0_f64.powi(-(precision as i32)), precision);
379        (precision, increment)
380    } else {
381        // Negative precision: increment is 10^abs(precision), e.g., -3 → 1000
382        let increment_value = 10.0_f64.powi(-instrument.contract_value_trade_precision);
383        (0, Quantity::new(increment_value, 0))
384    };
385
386    let multiplier_precision = if instrument.contract_size.fract() == 0.0 {
387        0
388    } else {
389        instrument
390            .contract_size
391            .to_string()
392            .split('.')
393            .nth(1)
394            .map_or(0, |s| s.len() as u8)
395    };
396    let multiplier = Some(Quantity::new(
397        instrument.contract_size,
398        multiplier_precision,
399    ));
400
401    // Use first margin level if available
402    let (margin_init, margin_maint) = instrument
403        .margin_levels
404        .first()
405        .and_then(|level| {
406            let init = Decimal::try_from(level.initial_margin).ok()?;
407            let maint = Decimal::try_from(level.maintenance_margin).ok()?;
408            Some((Some(init), Some(maint)))
409        })
410        .unwrap_or((None, None));
411
412    let instrument = CryptoPerpetual::new(
413        instrument_id,
414        raw_symbol,
415        base_currency,
416        quote_currency,
417        settlement_currency,
418        is_inverse,
419        price_increment.precision,
420        size_increment.precision,
421        price_increment,
422        size_increment,
423        multiplier,
424        None, // lot_size
425        None, // max_quantity
426        None, // min_quantity
427        None, // max_notional
428        None, // min_notional
429        None, // max_price
430        None, // min_price
431        margin_init,
432        margin_maint,
433        None, // maker_fee
434        None, // taker_fee
435        None,
436        ts_event,
437        ts_init,
438    );
439
440    Ok(InstrumentAny::CryptoPerpetual(instrument))
441}
442
443fn parse_price(value: &str, field: &str) -> anyhow::Result<Price> {
444    Price::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
445}
446
447fn parse_quantity(value: &str, field: &str) -> anyhow::Result<Quantity> {
448    Quantity::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
449}
450
451/// Returns a currency from the internal map or creates a new crypto currency.
452///
453/// Uses [`Currency::get_or_create_crypto`] to handle unknown currency codes,
454/// which automatically registers newly listed Kraken assets.
455pub fn get_currency(code: &str) -> Currency {
456    Currency::get_or_create_crypto(code)
457}
458
459/// Parses a Kraken trade array into a Nautilus trade tick.
460///
461/// The Kraken API returns trades as arrays: [price, volume, time, side, type, misc, trade_id]
462///
463/// # Errors
464///
465/// Returns an error if:
466/// - Price or volume cannot be parsed.
467/// - Timestamp is invalid.
468/// - Trade ID is invalid.
469pub fn parse_trade_tick_from_array(
470    trade_array: &[serde_json::Value],
471    instrument: &InstrumentAny,
472    ts_init: UnixNanos,
473) -> anyhow::Result<TradeTick> {
474    let price_str = trade_array
475        .first()
476        .and_then(|v| v.as_str())
477        .context("Missing or invalid price")?;
478    let price = parse_price_with_precision(price_str, instrument.price_precision(), "trade.price")?;
479
480    let size_str = trade_array
481        .get(1)
482        .and_then(|v| v.as_str())
483        .context("Missing or invalid volume")?;
484    let size = parse_quantity_with_precision(size_str, instrument.size_precision(), "trade.size")?;
485
486    let time = trade_array
487        .get(2)
488        .and_then(|v| v.as_f64())
489        .context("Missing or invalid timestamp")?;
490    let ts_event = parse_millis_timestamp(time, "trade.time")?;
491
492    let side_str = trade_array
493        .get(3)
494        .and_then(|v| v.as_str())
495        .context("Missing or invalid side")?;
496    let aggressor = match side_str {
497        "b" => AggressorSide::Buyer,
498        "s" => AggressorSide::Seller,
499        _ => AggressorSide::NoAggressor,
500    };
501
502    let trade_id_value = trade_array.get(6).context("Missing trade_id")?;
503    let trade_id = if let Some(id) = trade_id_value.as_i64() {
504        TradeId::new_checked(id.to_string())?
505    } else if let Some(id_str) = trade_id_value.as_str() {
506        TradeId::new_checked(id_str)?
507    } else {
508        anyhow::bail!("Invalid trade_id format");
509    };
510
511    TradeTick::new_checked(
512        instrument.id(),
513        price,
514        size,
515        aggressor,
516        trade_id,
517        ts_event,
518        ts_init,
519    )
520    .context("Failed to construct TradeTick from Kraken trade")
521}
522
523/// Parses a Kraken Futures public execution into a Nautilus trade tick.
524///
525/// # Errors
526///
527/// Returns an error if:
528/// - Price or quantity cannot be parsed.
529/// - Trade ID is invalid.
530pub fn parse_futures_public_execution(
531    execution: &FuturesPublicExecution,
532    instrument: &InstrumentAny,
533    ts_init: UnixNanos,
534) -> anyhow::Result<TradeTick> {
535    let price =
536        parse_price_with_precision(&execution.price, instrument.price_precision(), "price")?;
537    let size = parse_quantity_with_precision(
538        &execution.quantity,
539        instrument.size_precision(),
540        "quantity",
541    )?;
542
543    // Timestamp is in milliseconds
544    let ts_event = UnixNanos::from((execution.timestamp as u64) * 1_000_000);
545
546    // Aggressor side is determined by the taker's direction
547    let aggressor = match execution.taker_order.direction.to_lowercase().as_str() {
548        "buy" => AggressorSide::Buyer,
549        "sell" => AggressorSide::Seller,
550        _ => AggressorSide::NoAggressor,
551    };
552
553    let trade_id = TradeId::new_checked(&execution.uid)?;
554
555    TradeTick::new_checked(
556        instrument.id(),
557        price,
558        size,
559        aggressor,
560        trade_id,
561        ts_event,
562        ts_init,
563    )
564    .context("Failed to construct TradeTick from Kraken futures execution")
565}
566
567/// Parses a Kraken OHLC entry into a Nautilus bar.
568///
569/// # Errors
570///
571/// Returns an error if:
572/// - OHLC values cannot be parsed.
573/// - Timestamp is invalid.
574pub fn parse_bar(
575    ohlc: &OhlcData,
576    instrument: &InstrumentAny,
577    bar_type: BarType,
578    ts_init: UnixNanos,
579) -> anyhow::Result<Bar> {
580    let price_precision = instrument.price_precision();
581    let size_precision = instrument.size_precision();
582
583    let open = parse_price_with_precision(&ohlc.open, price_precision, "ohlc.open")?;
584    let high = parse_price_with_precision(&ohlc.high, price_precision, "ohlc.high")?;
585    let low = parse_price_with_precision(&ohlc.low, price_precision, "ohlc.low")?;
586    let close = parse_price_with_precision(&ohlc.close, price_precision, "ohlc.close")?;
587    let volume = parse_quantity_with_precision(&ohlc.volume, size_precision, "ohlc.volume")?;
588
589    let ts_event = UnixNanos::from((ohlc.time as u64) * 1_000_000_000);
590
591    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
592        .context("Failed to construct Bar from Kraken OHLC")
593}
594
595fn parse_price_with_precision(value: &str, precision: u8, field: &str) -> anyhow::Result<Price> {
596    let parsed = value
597        .parse::<f64>()
598        .with_context(|| format!("Failed to parse {field}='{value}' as f64"))?;
599    Price::new_checked(parsed, precision).with_context(|| {
600        format!("Failed to construct Price for {field} with precision {precision}")
601    })
602}
603
604fn parse_quantity_with_precision(
605    value: &str,
606    precision: u8,
607    field: &str,
608) -> anyhow::Result<Quantity> {
609    let parsed = value
610        .parse::<f64>()
611        .with_context(|| format!("Failed to parse {field}='{value}' as f64"))?;
612    Quantity::new_checked(parsed, precision).with_context(|| {
613        format!("Failed to construct Quantity for {field} with precision {precision}")
614    })
615}
616
617pub fn parse_millis_timestamp(value: f64, field: &str) -> anyhow::Result<UnixNanos> {
618    let millis = (value * 1000.0) as u64;
619    let nanos = millis
620        .checked_mul(NANOSECONDS_IN_MILLISECOND)
621        .with_context(|| format!("{field} timestamp overflowed when converting to nanoseconds"))?;
622    Ok(UnixNanos::from(nanos))
623}
624
625/// Parses a Kraken spot order into a Nautilus OrderStatusReport.
626///
627/// # Errors
628///
629/// Returns an error if:
630/// - Order ID, quantities, or prices cannot be parsed.
631/// - Order status mapping fails.
632pub fn parse_order_status_report(
633    order_id: &str,
634    order: &SpotOrder,
635    instrument: &InstrumentAny,
636    account_id: AccountId,
637    ts_init: UnixNanos,
638) -> anyhow::Result<OrderStatusReport> {
639    let instrument_id = instrument.id();
640    let venue_order_id = VenueOrderId::new(order_id);
641
642    let order_side = order.descr.order_side.into();
643    let order_type = order.descr.ordertype.into();
644    let order_status = order.status.into();
645
646    // Kraken returns expiretm=0 for GTC orders, so check for actual expiration value
647    let has_expiration = order.expiretm.is_some_and(|t| t > 0.0);
648    let time_in_force = if has_expiration {
649        TimeInForce::Gtd
650    } else if order.oflags.contains("ioc") {
651        TimeInForce::Ioc
652    } else {
653        TimeInForce::Gtc
654    };
655
656    let quantity =
657        parse_quantity_with_precision(&order.vol, instrument.size_precision(), "order.vol")?;
658
659    let filled_qty = parse_quantity_with_precision(
660        &order.vol_exec,
661        instrument.size_precision(),
662        "order.vol_exec",
663    )?;
664
665    let ts_accepted = parse_millis_timestamp(order.opentm, "order.opentm")?;
666
667    let ts_last = order
668        .closetm
669        .map(|t| parse_millis_timestamp(t, "order.closetm"))
670        .transpose()?
671        .unwrap_or(ts_accepted);
672
673    let price = if !order.price.is_empty() && order.price != "0" {
674        Some(parse_price_with_precision(
675            &order.price,
676            instrument.price_precision(),
677            "order.price",
678        )?)
679    } else {
680        None
681    };
682
683    let trigger_price = order
684        .stopprice
685        .as_ref()
686        .and_then(|p| {
687            if !p.is_empty() && p != "0" {
688                Some(parse_price_with_precision(
689                    p,
690                    instrument.price_precision(),
691                    "order.stopprice",
692                ))
693            } else {
694                None
695            }
696        })
697        .transpose()?;
698
699    let expire_time = if has_expiration {
700        order
701            .expiretm
702            .map(|t| parse_millis_timestamp(t, "order.expiretm"))
703            .transpose()?
704    } else {
705        None
706    };
707
708    let trigger_type = parse_trigger_type(order_type, order.trigger);
709
710    Ok(OrderStatusReport {
711        account_id,
712        instrument_id,
713        client_order_id: None,
714        venue_order_id,
715        order_side,
716        order_type,
717        time_in_force,
718        order_status,
719        quantity,
720        filled_qty,
721        report_id: UUID4::new(),
722        ts_accepted,
723        ts_last,
724        ts_init,
725        order_list_id: None,
726        venue_position_id: None,
727        linked_order_ids: None,
728        parent_order_id: None,
729        contingency_type: ContingencyType::NoContingency,
730        expire_time,
731        price,
732        trigger_price,
733        trigger_type,
734        limit_offset: None,
735        trailing_offset: None,
736        trailing_offset_type: TrailingOffsetType::NoTrailingOffset,
737        display_qty: None,
738        avg_px: compute_avg_px(order),
739        post_only: order.oflags.contains("post"),
740        reduce_only: false,
741        cancel_reason: order.reason.clone(),
742        ts_triggered: None,
743    })
744}
745
746/// Computes the average price for a Kraken spot order.
747///
748/// Prefers the direct `avg_price` field if available, otherwise calculates from `cost / vol_exec`.
749fn compute_avg_px(order: &SpotOrder) -> Option<Decimal> {
750    if let Some(ref avg) = order.avg_price
751        && let Ok(v) = parse_decimal(avg)
752        && v > dec!(0)
753    {
754        return Some(v);
755    }
756
757    let cost = parse_decimal(&order.cost);
758    let vol_exec = parse_decimal(&order.vol_exec);
759    match (&cost, &vol_exec) {
760        (Ok(c), Ok(v)) if *v > dec!(0) => Some(*c / *v),
761        _ => {
762            if let Ok(v) = &vol_exec
763                && *v > dec!(0)
764            {
765                log::warn!("Cannot compute avg_px: cost={cost:?}, vol_exec={vol_exec:?}");
766            }
767            None
768        }
769    }
770}
771
772/// Parses a Kraken spot trade into a Nautilus FillReport.
773///
774/// # Errors
775///
776/// Returns an error if:
777/// - Trade ID, quantities, or prices cannot be parsed.
778pub fn parse_fill_report(
779    trade_id: &str,
780    trade: &SpotTrade,
781    instrument: &InstrumentAny,
782    account_id: AccountId,
783    ts_init: UnixNanos,
784) -> anyhow::Result<FillReport> {
785    let instrument_id = instrument.id();
786    let venue_order_id = VenueOrderId::new(&trade.ordertxid);
787    let trade_id_obj = TradeId::new(trade_id);
788
789    let order_side = trade.trade_type.into();
790
791    let last_qty =
792        parse_quantity_with_precision(&trade.vol, instrument.size_precision(), "trade.vol")?;
793
794    let last_px =
795        parse_price_with_precision(&trade.price, instrument.price_precision(), "trade.price")?;
796
797    let fee_decimal = parse_decimal(&trade.fee)?;
798    let quote_currency = match instrument {
799        InstrumentAny::CurrencyPair(pair) => pair.quote_currency,
800        InstrumentAny::CryptoPerpetual(perp) => perp.quote_currency,
801        InstrumentAny::TokenizedAsset(ta) => ta.quote_currency,
802        _ => anyhow::bail!("Unsupported instrument type for fill report"),
803    };
804
805    let fee_f64 = fee_decimal
806        .try_into()
807        .context("Failed to convert fee to f64")?;
808    let commission = Money::new(fee_f64, quote_currency);
809
810    let liquidity_side = match trade.maker {
811        Some(true) => LiquiditySide::Maker,
812        Some(false) => LiquiditySide::Taker,
813        None => LiquiditySide::NoLiquiditySide,
814    };
815
816    let ts_event = parse_millis_timestamp(trade.time, "trade.time")?;
817
818    Ok(FillReport {
819        account_id,
820        instrument_id,
821        venue_order_id,
822        trade_id: trade_id_obj,
823        order_side,
824        last_qty,
825        last_px,
826        commission,
827        liquidity_side,
828        avg_px: None,
829        report_id: UUID4::new(),
830        ts_event,
831        ts_init,
832        client_order_id: None,
833        venue_position_id: None,
834    })
835}
836
837/// Parses a Kraken futures open order into a Nautilus OrderStatusReport.
838///
839/// # Errors
840///
841/// Returns an error if order ID, quantities, or prices cannot be parsed.
842pub fn parse_futures_order_status_report(
843    order: &FuturesOpenOrder,
844    instrument: &InstrumentAny,
845    account_id: AccountId,
846    ts_init: UnixNanos,
847) -> anyhow::Result<OrderStatusReport> {
848    let instrument_id = instrument.id();
849    let venue_order_id = VenueOrderId::new(&order.order_id);
850
851    let order_side = order.side.into();
852    let order_type: OrderType = order.order_type.into();
853    let order_type = if order_type == OrderType::MarketIfTouched && order.limit_price.is_some() {
854        OrderType::LimitIfTouched
855    } else {
856        order_type
857    };
858    let order_status = order.status.into();
859
860    let quantity = Quantity::new(
861        order.unfilled_size + order.filled_size,
862        instrument.size_precision(),
863    );
864
865    let filled_qty = Quantity::new(order.filled_size, instrument.size_precision());
866
867    let ts_accepted = parse_rfc3339_timestamp(&order.received_time, "order.received_time")?;
868    let ts_last = parse_rfc3339_timestamp(&order.last_update_time, "order.last_update_time")?;
869
870    let price = order
871        .limit_price
872        .map(|p| Price::new(p, instrument.price_precision()));
873
874    let trigger_price = order
875        .stop_price
876        .map(|p| Price::new(p, instrument.price_precision()));
877
878    let trigger_type = parse_futures_trigger_type(order_type, order.trigger_signal);
879
880    Ok(OrderStatusReport {
881        account_id,
882        instrument_id,
883        client_order_id: order.cli_ord_id.as_ref().map(|s| s.as_str().into()),
884        venue_order_id,
885        order_side,
886        order_type,
887        time_in_force: TimeInForce::Gtc,
888        order_status,
889        quantity,
890        filled_qty,
891        report_id: UUID4::new(),
892        ts_accepted,
893        ts_last,
894        ts_init,
895        order_list_id: None,
896        venue_position_id: None,
897        linked_order_ids: None,
898        parent_order_id: None,
899        contingency_type: ContingencyType::NoContingency,
900        expire_time: None,
901        price,
902        trigger_price,
903        trigger_type,
904        limit_offset: None,
905        trailing_offset: None,
906        trailing_offset_type: TrailingOffsetType::NoTrailingOffset,
907        display_qty: None,
908        avg_px: None,
909        post_only: false,
910        reduce_only: order.reduce_only.unwrap_or(false),
911        cancel_reason: None,
912        ts_triggered: None,
913    })
914}
915
916/// Parses a Kraken futures order event (historical order) into a Nautilus OrderStatusReport.
917///
918/// # Errors
919///
920/// Returns an error if order ID, quantities, or prices cannot be parsed.
921pub fn parse_futures_order_event_status_report(
922    event: &FuturesOrderEvent,
923    event_type: Option<KrakenFuturesOrderEventType>,
924    instrument: &InstrumentAny,
925    account_id: AccountId,
926    ts_init: UnixNanos,
927) -> anyhow::Result<OrderStatusReport> {
928    let instrument_id = instrument.id();
929    let venue_order_id = VenueOrderId::new(&event.order_id);
930
931    let order_side = event.side.into();
932    let order_type: OrderType = event.order_type.into();
933    let order_type = if order_type == OrderType::MarketIfTouched && event.limit_price.is_some() {
934        OrderType::LimitIfTouched
935    } else {
936        order_type
937    };
938
939    let order_status = parse_futures_order_event_status(event_type, event.filled, event.quantity);
940
941    let quantity = Quantity::new(event.quantity, instrument.size_precision());
942    let filled_qty = Quantity::new(event.filled, instrument.size_precision());
943
944    let ts_accepted = parse_rfc3339_timestamp(&event.timestamp, "event.timestamp")?;
945    let ts_last =
946        parse_rfc3339_timestamp(&event.last_update_timestamp, "event.last_update_timestamp")?;
947
948    let price = event
949        .limit_price
950        .map(|p| Price::new(p, instrument.price_precision()));
951
952    let trigger_price = event
953        .stop_price
954        .map(|p| Price::new(p, instrument.price_precision()));
955
956    let trigger_type = parse_futures_trigger_type(order_type, None);
957
958    Ok(OrderStatusReport {
959        account_id,
960        instrument_id,
961        client_order_id: event.cli_ord_id.as_ref().map(|s| s.as_str().into()),
962        venue_order_id,
963        order_side,
964        order_type,
965        time_in_force: TimeInForce::Gtc,
966        order_status,
967        quantity,
968        filled_qty,
969        report_id: UUID4::new(),
970        ts_accepted,
971        ts_last,
972        ts_init,
973        order_list_id: None,
974        venue_position_id: None,
975        linked_order_ids: None,
976        parent_order_id: None,
977        contingency_type: ContingencyType::NoContingency,
978        expire_time: None,
979        price,
980        trigger_price,
981        trigger_type,
982        limit_offset: None,
983        trailing_offset: None,
984        trailing_offset_type: TrailingOffsetType::NoTrailingOffset,
985        display_qty: None,
986        avg_px: None,
987        post_only: false,
988        reduce_only: event.reduce_only,
989        cancel_reason: None,
990        ts_triggered: None,
991    })
992}
993
994fn parse_futures_order_event_status(
995    event_type: Option<KrakenFuturesOrderEventType>,
996    filled: f64,
997    quantity: f64,
998) -> OrderStatus {
999    match event_type {
1000        Some(KrakenFuturesOrderEventType::Cancel) => OrderStatus::Canceled,
1001        Some(KrakenFuturesOrderEventType::Reject) => OrderStatus::Rejected,
1002        Some(KrakenFuturesOrderEventType::Expire) => OrderStatus::Expired,
1003        Some(
1004            KrakenFuturesOrderEventType::Fill
1005            | KrakenFuturesOrderEventType::Execution
1006            | KrakenFuturesOrderEventType::Place
1007            | KrakenFuturesOrderEventType::Edit,
1008        ) => {
1009            if filled >= quantity {
1010                OrderStatus::Filled
1011            } else if filled > 0.0 {
1012                OrderStatus::PartiallyFilled
1013            } else {
1014                OrderStatus::Accepted
1015            }
1016        }
1017        _ => {
1018            if filled >= quantity {
1019                OrderStatus::Filled
1020            } else if filled > 0.0 {
1021                OrderStatus::PartiallyFilled
1022            } else {
1023                OrderStatus::Canceled
1024            }
1025        }
1026    }
1027}
1028
1029/// Parses a Kraken futures fill into a Nautilus FillReport.
1030///
1031/// # Errors
1032///
1033/// Returns an error if fill ID, quantities, or prices cannot be parsed.
1034pub fn parse_futures_fill_report(
1035    fill: &FuturesFill,
1036    instrument: &InstrumentAny,
1037    account_id: AccountId,
1038    ts_init: UnixNanos,
1039) -> anyhow::Result<FillReport> {
1040    let instrument_id = instrument.id();
1041    let venue_order_id = VenueOrderId::new(&fill.order_id);
1042    let trade_id = TradeId::new(&fill.fill_id);
1043
1044    let order_side = fill.side.into();
1045
1046    let last_qty = Quantity::new(fill.size, instrument.size_precision());
1047    let last_px = Price::new(fill.price, instrument.price_precision());
1048
1049    let quote_currency = match instrument {
1050        InstrumentAny::CryptoPerpetual(perp) => perp.quote_currency,
1051        InstrumentAny::CryptoFuture(future) => future.quote_currency,
1052        _ => anyhow::bail!("Unsupported instrument type for futures fill report"),
1053    };
1054
1055    let fee_f64 = fill.fee_paid.unwrap_or(0.0);
1056    let commission = Money::new(fee_f64, quote_currency);
1057
1058    let liquidity_side = match fill.fill_type {
1059        KrakenFillType::Maker => LiquiditySide::Maker,
1060        KrakenFillType::Taker => LiquiditySide::Taker,
1061    };
1062
1063    let ts_event = parse_rfc3339_timestamp(&fill.fill_time, "fill.fill_time")?;
1064
1065    Ok(FillReport {
1066        account_id,
1067        instrument_id,
1068        venue_order_id,
1069        trade_id,
1070        order_side,
1071        last_qty,
1072        last_px,
1073        commission,
1074        liquidity_side,
1075        avg_px: None,
1076        report_id: UUID4::new(),
1077        ts_event,
1078        ts_init,
1079        client_order_id: fill.cli_ord_id.as_ref().map(|s| s.as_str().into()),
1080        venue_position_id: None,
1081    })
1082}
1083
1084/// Parses a Kraken futures position into a Nautilus PositionStatusReport.
1085///
1086/// # Errors
1087///
1088/// Returns an error if position quantities or prices cannot be parsed.
1089pub fn parse_futures_position_status_report(
1090    position: &FuturesPosition,
1091    instrument: &InstrumentAny,
1092    account_id: AccountId,
1093    ts_init: UnixNanos,
1094) -> anyhow::Result<PositionStatusReport> {
1095    let instrument_id = instrument.id();
1096
1097    let position_side = match position.side {
1098        KrakenPositionSide::Long => PositionSideSpecified::Long,
1099        KrakenPositionSide::Short => PositionSideSpecified::Short,
1100    };
1101
1102    let quantity = Quantity::new(position.size, instrument.size_precision());
1103    let size_decimal = Decimal::from_str(&position.size.to_string()).unwrap_or(dec!(0));
1104    let signed_decimal_qty = match position_side {
1105        PositionSideSpecified::Long => size_decimal,
1106        PositionSideSpecified::Short => -size_decimal,
1107        PositionSideSpecified::Flat => dec!(0),
1108    };
1109
1110    let avg_px_open = Decimal::from_str(&position.price.to_string()).ok();
1111
1112    Ok(PositionStatusReport {
1113        account_id,
1114        instrument_id,
1115        position_side,
1116        quantity,
1117        signed_decimal_qty,
1118        report_id: UUID4::new(),
1119        ts_last: ts_init,
1120        ts_init,
1121        venue_position_id: None,
1122        avg_px_open,
1123    })
1124}
1125
1126/// Converts a Nautilus BarType to Kraken Spot API interval (in minutes).
1127///
1128/// # Errors
1129///
1130/// Returns an error if:
1131/// - Bar aggregation type is not supported (only Minute, Hour, Day are valid).
1132/// - Bar step is not supported for the aggregation type.
1133pub fn bar_type_to_spot_interval(bar_type: BarType) -> anyhow::Result<u32> {
1134    let step = bar_type.spec().step.get() as u32;
1135    let base_interval = match bar_type.spec().aggregation {
1136        BarAggregation::Minute => 1,
1137        BarAggregation::Hour => 60,
1138        BarAggregation::Day => 1440,
1139        other => {
1140            anyhow::bail!("Unsupported bar aggregation for Kraken Spot: {other:?}");
1141        }
1142    };
1143    Ok(base_interval * step)
1144}
1145
1146/// Converts a Nautilus BarType to Kraken Futures API resolution string.
1147///
1148/// Supported resolutions: 1m, 5m, 15m, 1h, 4h, 12h, 1d, 1w
1149///
1150/// # Errors
1151///
1152/// Returns an error if:
1153/// - Bar aggregation type is not supported.
1154/// - Bar step is not supported for the aggregation type.
1155pub fn bar_type_to_futures_resolution(bar_type: BarType) -> anyhow::Result<&'static str> {
1156    let step = bar_type.spec().step.get() as u32;
1157    match bar_type.spec().aggregation {
1158        BarAggregation::Minute => match step {
1159            1 => Ok("1m"),
1160            5 => Ok("5m"),
1161            15 => Ok("15m"),
1162            _ => anyhow::bail!("Unsupported minute step for Kraken Futures: {step}"),
1163        },
1164        BarAggregation::Hour => match step {
1165            1 => Ok("1h"),
1166            4 => Ok("4h"),
1167            12 => Ok("12h"),
1168            _ => anyhow::bail!("Unsupported hour step for Kraken Futures: {step}"),
1169        },
1170        BarAggregation::Day => {
1171            if step == 1 {
1172                Ok("1d")
1173            } else {
1174                anyhow::bail!("Unsupported day step for Kraken Futures: {step}")
1175            }
1176        }
1177        BarAggregation::Week => {
1178            if step == 1 {
1179                Ok("1w")
1180            } else {
1181                anyhow::bail!("Unsupported week step for Kraken Futures: {step}")
1182            }
1183        }
1184        other => {
1185            anyhow::bail!("Unsupported bar aggregation for Kraken Futures: {other:?}");
1186        }
1187    }
1188}
1189
1190/// Truncates a `ClientOrderId` for Kraken's `cl_ord_id` field.
1191///
1192/// Kraken accepts three formats:
1193/// - Long UUID (36 chars with hyphens): passed through
1194/// - Short UUID (32 hex chars): passed through
1195/// - Free text: max 18 chars
1196///
1197/// Sequential NautilusTrader IDs (e.g. `O202602270023210040011`) exceed the
1198/// 18-char free-text limit. These are truncated to 'O' + last 17 chars,
1199/// preserving the counter portion for maximum entropy.
1200pub fn truncate_cl_ord_id(client_order_id: &ClientOrderId) -> String {
1201    let id = client_order_id.as_str();
1202
1203    if id.len() <= 18 {
1204        return id.to_string();
1205    }
1206
1207    if id.len() == 36 && id.bytes().filter(|b| *b == b'-').count() == 4 {
1208        return id.to_string();
1209    }
1210
1211    if id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit()) {
1212        return id.to_string();
1213    }
1214
1215    format!("O{}", &id[id.len() - 17..])
1216}
1217
1218#[cfg(test)]
1219mod tests {
1220    use indexmap::IndexMap;
1221    use nautilus_model::{
1222        data::BarSpecification,
1223        enums::{AggregationSource, BarAggregation, OrderSide, OrderStatus, PriceType},
1224        instruments::crypto_perpetual::CryptoPerpetual,
1225    };
1226    use rstest::rstest;
1227
1228    use super::*;
1229    use crate::{
1230        common::enums::{
1231            KrakenFuturesOrderEventType, KrakenFuturesOrderStatus, KrakenFuturesOrderType,
1232            KrakenOrderSide,
1233        },
1234        http::{
1235            futures::models::{FuturesOpenOrder, FuturesOrderEvent},
1236            models::AssetPairsResponse,
1237        },
1238    };
1239
1240    const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
1241
1242    fn load_test_json(filename: &str) -> String {
1243        let path = format!("test_data/{filename}");
1244        std::fs::read_to_string(&path)
1245            .unwrap_or_else(|e| panic!("Failed to load test data from {path}: {e}"))
1246    }
1247
1248    #[rstest]
1249    fn test_parse_decimal() {
1250        assert_eq!(parse_decimal("123.45").unwrap(), dec!(123.45));
1251        assert_eq!(parse_decimal("0").unwrap(), dec!(0));
1252        assert_eq!(parse_decimal("").unwrap(), dec!(0));
1253    }
1254
1255    #[rstest]
1256    fn test_parse_decimal_opt() {
1257        assert_eq!(
1258            parse_decimal_opt(Some("123.45")).unwrap(),
1259            Some(dec!(123.45))
1260        );
1261        assert_eq!(parse_decimal_opt(Some("0")).unwrap(), None);
1262        assert_eq!(parse_decimal_opt(Some("")).unwrap(), None);
1263        assert_eq!(parse_decimal_opt(None).unwrap(), None);
1264    }
1265
1266    #[rstest]
1267    fn test_parse_spot_instrument() {
1268        let json = load_test_json("http_asset_pairs.json");
1269        let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1270        let result = wrapper.get("result").unwrap();
1271        let pairs: AssetPairsResponse = serde_json::from_value(result.clone()).unwrap();
1272
1273        let (pair_name, definition) = pairs.iter().next().unwrap();
1274
1275        let instrument = parse_spot_instrument(pair_name, definition, TS, TS).unwrap();
1276
1277        match instrument {
1278            InstrumentAny::CurrencyPair(pair) => {
1279                assert_eq!(pair.id.venue.as_str(), "KRAKEN");
1280                assert_eq!(pair.base_currency.code.as_str(), "XXBT");
1281                assert_eq!(pair.quote_currency.code.as_str(), "USDT");
1282                assert!(pair.price_increment.as_f64() > 0.0);
1283                assert!(pair.size_increment.as_f64() > 0.0);
1284                assert!(pair.min_quantity.is_some());
1285                assert_eq!(pair.maker_fee, dec!(0.0025));
1286                assert_eq!(pair.taker_fee, dec!(0.004));
1287                assert_eq!(pair.margin_init, dec!(0));
1288                assert_eq!(pair.margin_maint, dec!(0));
1289            }
1290            _ => panic!("Expected CurrencyPair"),
1291        }
1292    }
1293
1294    #[rstest]
1295    fn test_parse_futures_instrument_inverse() {
1296        let json = load_test_json("http_futures_instruments.json");
1297        let response: crate::http::models::FuturesInstrumentsResponse =
1298            serde_json::from_str(&json).unwrap();
1299
1300        let fut_instrument = &response.instruments[0];
1301
1302        let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1303
1304        match instrument {
1305            InstrumentAny::CryptoPerpetual(perp) => {
1306                assert_eq!(perp.id.venue.as_str(), "KRAKEN");
1307                assert_eq!(perp.id.symbol.as_str(), "PI_XBTUSD");
1308                assert_eq!(perp.raw_symbol.as_str(), "PI_XBTUSD");
1309                assert_eq!(perp.base_currency.code.as_str(), "BTC");
1310                assert_eq!(perp.quote_currency.code.as_str(), "USD");
1311                assert_eq!(perp.settlement_currency.code.as_str(), "BTC");
1312                assert!(perp.is_inverse);
1313                assert_eq!(perp.price_increment.as_f64(), 0.5);
1314                assert_eq!(perp.size_increment.as_f64(), 1.0);
1315                assert_eq!(perp.size_precision(), 0);
1316                assert_eq!(perp.margin_init, dec!(0.02));
1317                assert_eq!(perp.margin_maint, dec!(0.01));
1318            }
1319            _ => panic!("Expected CryptoPerpetual"),
1320        }
1321    }
1322
1323    #[rstest]
1324    fn test_parse_futures_instrument_flexible() {
1325        let json = load_test_json("http_futures_instruments.json");
1326        let response: crate::http::models::FuturesInstrumentsResponse =
1327            serde_json::from_str(&json).unwrap();
1328
1329        let fut_instrument = &response.instruments[1];
1330
1331        let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1332
1333        match instrument {
1334            InstrumentAny::CryptoPerpetual(perp) => {
1335                assert_eq!(perp.id.venue.as_str(), "KRAKEN");
1336                assert_eq!(perp.id.symbol.as_str(), "PF_ETHUSD");
1337                assert_eq!(perp.raw_symbol.as_str(), "PF_ETHUSD");
1338                assert_eq!(perp.base_currency.code.as_str(), "ETH");
1339                assert_eq!(perp.quote_currency.code.as_str(), "USD");
1340                assert_eq!(perp.settlement_currency.code.as_str(), "USD");
1341                assert!(!perp.is_inverse);
1342                assert_eq!(perp.price_increment.as_f64(), 0.1);
1343                assert_eq!(perp.size_increment.as_f64(), 0.001);
1344                assert_eq!(perp.size_precision(), 3);
1345                assert_eq!(perp.margin_init, dec!(0.02));
1346                assert_eq!(perp.margin_maint, dec!(0.01));
1347            }
1348            _ => panic!("Expected CryptoPerpetual"),
1349        }
1350    }
1351
1352    // PF_PEPEUSD has tickSize: 1e-10 which requires precision 10
1353    // This test requires high-precision mode (FIXED_PRECISION=16) which is the default build
1354    #[rstest]
1355    fn test_parse_futures_instrument_negative_precision() {
1356        let json = load_test_json("http_futures_instruments.json");
1357        let response: crate::http::models::FuturesInstrumentsResponse =
1358            serde_json::from_str(&json).unwrap();
1359
1360        // PF_PEPEUSD has contractValueTradePrecision: -3 (trades in multiples of 1000)
1361        let fut_instrument = &response.instruments[2];
1362
1363        let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1364
1365        match instrument {
1366            InstrumentAny::CryptoPerpetual(perp) => {
1367                assert_eq!(perp.id.symbol.as_str(), "PF_PEPEUSD");
1368                assert_eq!(perp.base_currency.code.as_str(), "PEPE");
1369                assert!(!perp.is_inverse);
1370                assert_eq!(perp.size_increment.as_f64(), 1000.0);
1371                assert_eq!(perp.size_precision(), 0);
1372            }
1373            _ => panic!("Expected CryptoPerpetual"),
1374        }
1375    }
1376
1377    #[rstest]
1378    fn test_parse_futures_instrument_tokenized_underlying() {
1379        let json = load_test_json("http_futures_instruments.json");
1380        let response: crate::http::models::FuturesInstrumentsResponse =
1381            serde_json::from_str(&json).unwrap();
1382
1383        let fut_instrument = &response.instruments[3];
1384
1385        let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1386
1387        match instrument {
1388            InstrumentAny::CryptoPerpetual(perp) => {
1389                assert_eq!(perp.id.symbol.as_str(), "PF_AAPLxUSD");
1390                assert_eq!(perp.raw_symbol.as_str(), "PF_AAPLxUSD");
1391                assert_eq!(perp.base_currency.code.as_str(), "AAPLx");
1392                assert_eq!(perp.quote_currency.code.as_str(), "USD");
1393                assert_eq!(perp.settlement_currency.code.as_str(), "USD");
1394                assert!(!perp.is_inverse);
1395                assert_eq!(perp.price_increment.as_f64(), 0.01);
1396                assert_eq!(perp.size_increment.as_f64(), 0.01);
1397                assert_eq!(perp.size_precision(), 2);
1398                assert_eq!(perp.margin_init, dec!(0.2));
1399                assert_eq!(perp.margin_maint, dec!(0.1));
1400            }
1401            _ => panic!("Expected CryptoPerpetual"),
1402        }
1403    }
1404
1405    #[rstest]
1406    fn test_parse_trade_tick_from_array() {
1407        let json = load_test_json("http_trades.json");
1408        let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1409        let result = wrapper.get("result").unwrap();
1410        let trades_map = result.as_object().unwrap();
1411
1412        // Get first pair's trades
1413        let (_pair, trades_value) = trades_map.iter().find(|(k, _)| *k != "last").unwrap();
1414        let trades = trades_value.as_array().unwrap();
1415        let trade_array = trades[0].as_array().unwrap();
1416
1417        // Create a mock instrument for testing
1418        let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1419        let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1420            instrument_id,
1421            Symbol::new("XBTUSDT"),
1422            Currency::BTC(),
1423            Currency::USDT(),
1424            1, // price_precision
1425            8, // size_precision
1426            Price::from("0.1"),
1427            Quantity::from("0.00000001"),
1428            None,
1429            None,
1430            None,
1431            None,
1432            None,
1433            None,
1434            None,
1435            None,
1436            None,
1437            None,
1438            None,
1439            None,
1440            None,
1441            TS,
1442            TS,
1443        ));
1444
1445        let trade_tick = parse_trade_tick_from_array(trade_array, &instrument, TS).unwrap();
1446
1447        assert_eq!(trade_tick.instrument_id, instrument_id);
1448        assert!(trade_tick.price.as_f64() > 0.0);
1449        assert!(trade_tick.size.as_f64() > 0.0);
1450    }
1451
1452    #[rstest]
1453    fn test_parse_bar() {
1454        let json = load_test_json("http_ohlc.json");
1455        let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1456        let result = wrapper.get("result").unwrap();
1457        let ohlc_map = result.as_object().unwrap();
1458
1459        // Get first pair's OHLC data
1460        let (_pair, ohlc_value) = ohlc_map.iter().find(|(k, _)| *k != "last").unwrap();
1461        let ohlcs = ohlc_value.as_array().unwrap();
1462
1463        // Parse first OHLC array into OhlcData
1464        let ohlc_array = ohlcs[0].as_array().unwrap();
1465        let ohlc = OhlcData {
1466            time: ohlc_array[0].as_i64().unwrap(),
1467            open: ohlc_array[1].as_str().unwrap().to_string(),
1468            high: ohlc_array[2].as_str().unwrap().to_string(),
1469            low: ohlc_array[3].as_str().unwrap().to_string(),
1470            close: ohlc_array[4].as_str().unwrap().to_string(),
1471            vwap: ohlc_array[5].as_str().unwrap().to_string(),
1472            volume: ohlc_array[6].as_str().unwrap().to_string(),
1473            count: ohlc_array[7].as_i64().unwrap(),
1474        };
1475
1476        // Create a mock instrument
1477        let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1478        let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1479            instrument_id,
1480            Symbol::new("XBTUSDT"),
1481            Currency::BTC(),
1482            Currency::USDT(),
1483            1, // price_precision
1484            8, // size_precision
1485            Price::from("0.1"),
1486            Quantity::from("0.00000001"),
1487            None,
1488            None,
1489            None,
1490            None,
1491            None,
1492            None,
1493            None,
1494            None,
1495            None,
1496            None,
1497            None,
1498            None,
1499            None,
1500            TS,
1501            TS,
1502        ));
1503
1504        let bar_type = BarType::new(
1505            instrument_id,
1506            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
1507            AggregationSource::External,
1508        );
1509
1510        let bar = parse_bar(&ohlc, &instrument, bar_type, TS).unwrap();
1511
1512        assert_eq!(bar.bar_type, bar_type);
1513        assert!(bar.open.as_f64() > 0.0);
1514        assert!(bar.high.as_f64() > 0.0);
1515        assert!(bar.low.as_f64() > 0.0);
1516        assert!(bar.close.as_f64() > 0.0);
1517        assert!(bar.volume.as_f64() >= 0.0);
1518    }
1519
1520    #[rstest]
1521    fn test_parse_millis_timestamp() {
1522        let timestamp = 1762795433.9717445;
1523        let result = parse_millis_timestamp(timestamp, "test").unwrap();
1524        assert!(result.as_u64() > 0);
1525    }
1526
1527    #[rstest]
1528    #[case(1, BarAggregation::Minute, 1)]
1529    #[case(5, BarAggregation::Minute, 5)]
1530    #[case(15, BarAggregation::Minute, 15)]
1531    #[case(1, BarAggregation::Hour, 60)]
1532    #[case(4, BarAggregation::Hour, 240)]
1533    #[case(1, BarAggregation::Day, 1440)]
1534    fn test_bar_type_to_spot_interval(
1535        #[case] step: usize,
1536        #[case] aggregation: BarAggregation,
1537        #[case] expected: u32,
1538    ) {
1539        let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1540        let bar_type = BarType::new(
1541            instrument_id,
1542            BarSpecification::new(step, aggregation, PriceType::Last),
1543            AggregationSource::External,
1544        );
1545
1546        let result = bar_type_to_spot_interval(bar_type).unwrap();
1547        assert_eq!(result, expected);
1548    }
1549
1550    #[rstest]
1551    fn test_bar_type_to_spot_interval_unsupported() {
1552        let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1553        let bar_type = BarType::new(
1554            instrument_id,
1555            BarSpecification::new(1, BarAggregation::Second, PriceType::Last),
1556            AggregationSource::External,
1557        );
1558
1559        let result = bar_type_to_spot_interval(bar_type);
1560        assert!(result.is_err());
1561        assert!(result.unwrap_err().to_string().contains("Unsupported"));
1562    }
1563
1564    #[rstest]
1565    #[case(1, BarAggregation::Minute, "1m")]
1566    #[case(5, BarAggregation::Minute, "5m")]
1567    #[case(15, BarAggregation::Minute, "15m")]
1568    #[case(1, BarAggregation::Hour, "1h")]
1569    #[case(4, BarAggregation::Hour, "4h")]
1570    #[case(12, BarAggregation::Hour, "12h")]
1571    #[case(1, BarAggregation::Day, "1d")]
1572    #[case(1, BarAggregation::Week, "1w")]
1573    fn test_bar_type_to_futures_resolution(
1574        #[case] step: usize,
1575        #[case] aggregation: BarAggregation,
1576        #[case] expected: &str,
1577    ) {
1578        let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
1579        let bar_type = BarType::new(
1580            instrument_id,
1581            BarSpecification::new(step, aggregation, PriceType::Last),
1582            AggregationSource::External,
1583        );
1584
1585        let result = bar_type_to_futures_resolution(bar_type).unwrap();
1586        assert_eq!(result, expected);
1587    }
1588
1589    #[rstest]
1590    #[case(30, BarAggregation::Minute)] // Unsupported minute step
1591    #[case(2, BarAggregation::Hour)] // Unsupported hour step
1592    #[case(2, BarAggregation::Day)] // Unsupported day step
1593    #[case(1, BarAggregation::Second)] // Unsupported aggregation
1594    fn test_bar_type_to_futures_resolution_unsupported(
1595        #[case] step: usize,
1596        #[case] aggregation: BarAggregation,
1597    ) {
1598        let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
1599        let bar_type = BarType::new(
1600            instrument_id,
1601            BarSpecification::new(step, aggregation, PriceType::Last),
1602            AggregationSource::External,
1603        );
1604
1605        let result = bar_type_to_futures_resolution(bar_type);
1606        assert!(result.is_err());
1607        assert!(result.unwrap_err().to_string().contains("Unsupported"));
1608    }
1609
1610    #[rstest]
1611    fn test_parse_order_status_report() {
1612        let json = load_test_json("http_open_orders.json");
1613        let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1614        let result = wrapper.get("result").unwrap();
1615        let open_map = result.get("open").unwrap();
1616        let orders: IndexMap<String, SpotOrder> = serde_json::from_value(open_map.clone()).unwrap();
1617
1618        let account_id = AccountId::new("KRAKEN-001");
1619        let instrument_id = InstrumentId::new(Symbol::new("BTC/USDT"), *KRAKEN_VENUE);
1620        let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1621            instrument_id,
1622            Symbol::new("XBTUSDT"),
1623            Currency::BTC(),
1624            Currency::USDT(),
1625            2,
1626            8,
1627            Price::from("0.01"),
1628            Quantity::from("0.00000001"),
1629            None,
1630            None,
1631            None,
1632            None,
1633            None,
1634            None,
1635            None,
1636            None,
1637            None,
1638            None,
1639            None,
1640            None,
1641            None,
1642            TS,
1643            TS,
1644        ));
1645
1646        let (order_id, order) = orders.iter().next().unwrap();
1647
1648        let report =
1649            parse_order_status_report(order_id, order, &instrument, account_id, TS).unwrap();
1650
1651        assert_eq!(report.account_id, account_id);
1652        assert_eq!(report.instrument_id, instrument_id);
1653        assert_eq!(report.venue_order_id.as_str(), order_id);
1654        assert_eq!(report.order_status, OrderStatus::Accepted);
1655        assert!(report.quantity.as_f64() > 0.0);
1656    }
1657
1658    fn create_mock_perp() -> InstrumentAny {
1659        let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
1660        InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
1661            instrument_id,
1662            Symbol::new("PI_XBTUSD"),
1663            Currency::BTC(),
1664            Currency::USD(),
1665            Currency::USD(),
1666            false,
1667            1,
1668            0,
1669            Price::from("0.5"),
1670            Quantity::from("1"),
1671            None,
1672            None,
1673            None,
1674            None,
1675            None,
1676            None,
1677            None,
1678            None,
1679            None,
1680            None,
1681            None,
1682            None,
1683            None,
1684            TS,
1685            TS,
1686        ))
1687    }
1688
1689    #[rstest]
1690    fn test_parse_futures_order_status_report_market_if_touched() {
1691        let order = FuturesOpenOrder {
1692            order_id: "tp-001".to_string(),
1693            symbol: "PI_XBTUSD".to_string(),
1694            side: KrakenOrderSide::Buy,
1695            order_type: KrakenFuturesOrderType::TakeProfit,
1696            limit_price: None,
1697            stop_price: Some(36000.0),
1698            unfilled_size: 500.0,
1699            received_time: "2023-11-14T22:13:20.000Z".to_string(),
1700            status: KrakenFuturesOrderStatus::Untouched,
1701            filled_size: 0.0,
1702            reduce_only: Some(true),
1703            last_update_time: "2023-11-14T22:13:20.000Z".to_string(),
1704            trigger_signal: None,
1705            cli_ord_id: Some("my-tp-1".to_string()),
1706        };
1707        let instrument = create_mock_perp();
1708        let account_id = AccountId::new("KRAKEN-001");
1709
1710        let report =
1711            parse_futures_order_status_report(&order, &instrument, account_id, TS).unwrap();
1712
1713        assert_eq!(report.order_type, OrderType::MarketIfTouched);
1714        assert_eq!(report.trigger_price.unwrap().as_f64(), 36000.0);
1715        assert!(report.price.is_none());
1716        assert!(report.reduce_only);
1717        assert_eq!(report.order_status, OrderStatus::Accepted);
1718    }
1719
1720    #[rstest]
1721    fn test_parse_futures_order_status_report_limit_if_touched() {
1722        let order = FuturesOpenOrder {
1723            order_id: "tpl-001".to_string(),
1724            symbol: "PI_XBTUSD".to_string(),
1725            side: KrakenOrderSide::Sell,
1726            order_type: KrakenFuturesOrderType::TakeProfit,
1727            limit_price: Some(35500.0),
1728            stop_price: Some(36000.0),
1729            unfilled_size: 500.0,
1730            received_time: "2023-11-14T22:13:20.000Z".to_string(),
1731            status: KrakenFuturesOrderStatus::Untouched,
1732            filled_size: 0.0,
1733            reduce_only: None,
1734            last_update_time: "2023-11-14T22:13:20.000Z".to_string(),
1735            trigger_signal: None,
1736            cli_ord_id: Some("my-tpl-1".to_string()),
1737        };
1738        let instrument = create_mock_perp();
1739        let account_id = AccountId::new("KRAKEN-001");
1740
1741        let report =
1742            parse_futures_order_status_report(&order, &instrument, account_id, TS).unwrap();
1743
1744        assert_eq!(report.order_type, OrderType::LimitIfTouched);
1745        assert_eq!(report.trigger_price.unwrap().as_f64(), 36000.0);
1746        assert_eq!(report.price.unwrap().as_f64(), 35500.0);
1747        assert_eq!(report.order_side, OrderSide::Sell);
1748        assert!(!report.reduce_only);
1749    }
1750
1751    #[rstest]
1752    fn test_parse_futures_order_event_market_if_touched() {
1753        let event = FuturesOrderEvent {
1754            order_id: "tp-evt-001".to_string(),
1755            cli_ord_id: None,
1756            order_type: KrakenFuturesOrderType::TakeProfit,
1757            symbol: "PI_XBTUSD".to_string(),
1758            side: KrakenOrderSide::Buy,
1759            quantity: 100.0,
1760            filled: 100.0,
1761            limit_price: None,
1762            stop_price: Some(40000.0),
1763            timestamp: "2023-11-14T22:13:20.000Z".to_string(),
1764            last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
1765            reduce_only: false,
1766        };
1767        let instrument = create_mock_perp();
1768        let account_id = AccountId::new("KRAKEN-001");
1769
1770        let report = parse_futures_order_event_status_report(
1771            &event,
1772            Some(KrakenFuturesOrderEventType::Fill),
1773            &instrument,
1774            account_id,
1775            TS,
1776        )
1777        .unwrap();
1778
1779        assert_eq!(report.order_type, OrderType::MarketIfTouched);
1780        assert_eq!(report.trigger_price.unwrap().as_f64(), 40000.0);
1781        assert!(report.price.is_none());
1782        assert_eq!(report.order_status, OrderStatus::Filled);
1783    }
1784
1785    #[rstest]
1786    fn test_parse_futures_order_event_limit_if_touched() {
1787        let event = FuturesOrderEvent {
1788            order_id: "tpl-evt-001".to_string(),
1789            cli_ord_id: Some("my-tpl-evt".to_string()),
1790            order_type: KrakenFuturesOrderType::TakeProfit,
1791            symbol: "PI_XBTUSD".to_string(),
1792            side: KrakenOrderSide::Sell,
1793            quantity: 200.0,
1794            filled: 0.0,
1795            limit_price: Some(39500.0),
1796            stop_price: Some(40000.0),
1797            timestamp: "2023-11-14T22:13:20.000Z".to_string(),
1798            last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
1799            reduce_only: true,
1800        };
1801        let instrument = create_mock_perp();
1802        let account_id = AccountId::new("KRAKEN-001");
1803
1804        let report = parse_futures_order_event_status_report(
1805            &event,
1806            Some(KrakenFuturesOrderEventType::Place),
1807            &instrument,
1808            account_id,
1809            TS,
1810        )
1811        .unwrap();
1812
1813        assert_eq!(report.order_type, OrderType::LimitIfTouched);
1814        assert_eq!(report.trigger_price.unwrap().as_f64(), 40000.0);
1815        assert_eq!(report.price.unwrap().as_f64(), 39500.0);
1816        assert_eq!(report.order_side, OrderSide::Sell);
1817        assert_eq!(report.order_status, OrderStatus::Accepted);
1818        assert!(report.reduce_only);
1819    }
1820
1821    #[rstest]
1822    fn test_parse_futures_order_event_cancel_status() {
1823        let event = FuturesOrderEvent {
1824            order_id: "cancel-evt-001".to_string(),
1825            cli_ord_id: Some("cancel-evt".to_string()),
1826            order_type: KrakenFuturesOrderType::Stop,
1827            symbol: "PI_XBTUSD".to_string(),
1828            side: KrakenOrderSide::Sell,
1829            quantity: 200.0,
1830            filled: 0.0,
1831            limit_price: None,
1832            stop_price: Some(39000.0),
1833            timestamp: "2023-11-14T22:13:20.000Z".to_string(),
1834            last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
1835            reduce_only: true,
1836        };
1837        let instrument = create_mock_perp();
1838        let account_id = AccountId::new("KRAKEN-001");
1839
1840        let report = parse_futures_order_event_status_report(
1841            &event,
1842            Some(KrakenFuturesOrderEventType::Cancel),
1843            &instrument,
1844            account_id,
1845            TS,
1846        )
1847        .unwrap();
1848
1849        assert_eq!(report.order_status, OrderStatus::Canceled);
1850        assert!(report.reduce_only);
1851    }
1852
1853    #[rstest]
1854    fn test_parse_futures_order_event_reject_status() {
1855        let event = FuturesOrderEvent {
1856            order_id: "reject-evt-001".to_string(),
1857            cli_ord_id: Some("reject-evt".to_string()),
1858            order_type: KrakenFuturesOrderType::Limit,
1859            symbol: "PI_XBTUSD".to_string(),
1860            side: KrakenOrderSide::Buy,
1861            quantity: 200.0,
1862            filled: 0.0,
1863            limit_price: Some(35000.0),
1864            stop_price: None,
1865            timestamp: "2023-11-14T22:13:20.000Z".to_string(),
1866            last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
1867            reduce_only: false,
1868        };
1869        let instrument = create_mock_perp();
1870        let account_id = AccountId::new("KRAKEN-001");
1871
1872        let report = parse_futures_order_event_status_report(
1873            &event,
1874            Some(KrakenFuturesOrderEventType::Reject),
1875            &instrument,
1876            account_id,
1877            TS,
1878        )
1879        .unwrap();
1880
1881        assert_eq!(report.order_status, OrderStatus::Rejected);
1882    }
1883
1884    #[rstest]
1885    fn test_parse_futures_order_event_expire_status() {
1886        let event = FuturesOrderEvent {
1887            order_id: "expire-evt-001".to_string(),
1888            cli_ord_id: Some("expire-evt".to_string()),
1889            order_type: KrakenFuturesOrderType::Limit,
1890            symbol: "PI_XBTUSD".to_string(),
1891            side: KrakenOrderSide::Buy,
1892            quantity: 200.0,
1893            filled: 0.0,
1894            limit_price: Some(35000.0),
1895            stop_price: None,
1896            timestamp: "2023-11-14T22:13:20.000Z".to_string(),
1897            last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
1898            reduce_only: false,
1899        };
1900        let instrument = create_mock_perp();
1901        let account_id = AccountId::new("KRAKEN-001");
1902
1903        let report = parse_futures_order_event_status_report(
1904            &event,
1905            Some(KrakenFuturesOrderEventType::Expire),
1906            &instrument,
1907            account_id,
1908            TS,
1909        )
1910        .unwrap();
1911
1912        assert_eq!(report.order_status, OrderStatus::Expired);
1913    }
1914
1915    #[rstest]
1916    fn test_parse_futures_order_event_execution_status() {
1917        let event = FuturesOrderEvent {
1918            order_id: "execution-evt-001".to_string(),
1919            cli_ord_id: Some("execution-evt".to_string()),
1920            order_type: KrakenFuturesOrderType::Limit,
1921            symbol: "PI_XBTUSD".to_string(),
1922            side: KrakenOrderSide::Buy,
1923            quantity: 200.0,
1924            filled: 50.0,
1925            limit_price: Some(35000.0),
1926            stop_price: None,
1927            timestamp: "2023-11-14T22:13:20.000Z".to_string(),
1928            last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
1929            reduce_only: false,
1930        };
1931        let instrument = create_mock_perp();
1932        let account_id = AccountId::new("KRAKEN-001");
1933
1934        let report = parse_futures_order_event_status_report(
1935            &event,
1936            Some(KrakenFuturesOrderEventType::Execution),
1937            &instrument,
1938            account_id,
1939            TS,
1940        )
1941        .unwrap();
1942
1943        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1944    }
1945
1946    #[rstest]
1947    fn test_parse_fill_report() {
1948        let json = load_test_json("http_trades_history.json");
1949        let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1950        let result = wrapper.get("result").unwrap();
1951        let trades_map = result.get("trades").unwrap();
1952        let trades: IndexMap<String, SpotTrade> =
1953            serde_json::from_value(trades_map.clone()).unwrap();
1954
1955        let account_id = AccountId::new("KRAKEN-001");
1956        let instrument_id = InstrumentId::new(Symbol::new("BTC/USDT"), *KRAKEN_VENUE);
1957        let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1958            instrument_id,
1959            Symbol::new("XBTUSDT"),
1960            Currency::BTC(),
1961            Currency::USDT(),
1962            2,
1963            8,
1964            Price::from("0.01"),
1965            Quantity::from("0.00000001"),
1966            None,
1967            None,
1968            None,
1969            None,
1970            None,
1971            None,
1972            None,
1973            None,
1974            None,
1975            None,
1976            None,
1977            None,
1978            None,
1979            TS,
1980            TS,
1981        ));
1982
1983        let (trade_id, trade) = trades.iter().next().unwrap();
1984
1985        let report = parse_fill_report(trade_id, trade, &instrument, account_id, TS).unwrap();
1986
1987        assert_eq!(report.account_id, account_id);
1988        assert_eq!(report.instrument_id, instrument_id);
1989        assert_eq!(report.trade_id.to_string(), *trade_id);
1990        assert!(report.last_qty.as_f64() > 0.0);
1991        assert!(report.last_px.as_f64() > 0.0);
1992        assert!(report.commission.as_f64() > 0.0);
1993    }
1994
1995    #[rstest]
1996    #[case("XXBT", "XBT")]
1997    #[case("XETH", "ETH")]
1998    #[case("ZUSD", "USD")]
1999    #[case("ZEUR", "EUR")]
2000    #[case("BTC", "BTC")]
2001    #[case("ETH", "ETH")]
2002    #[case("USDT", "USDT")]
2003    #[case("SOL", "SOL")]
2004    fn test_normalize_currency_code(#[case] input: &str, #[case] expected: &str) {
2005        assert_eq!(normalize_currency_code(input), expected);
2006    }
2007
2008    #[rstest]
2009    #[case("XBT/EUR", "BTC/EUR")]
2010    #[case("XBT/USD", "BTC/USD")]
2011    #[case("XBT/USDT", "BTC/USDT")]
2012    #[case("ETH/USD", "ETH/USD")]
2013    #[case("ETH/XBT", "ETH/BTC")]
2014    #[case("SOL/XBT", "SOL/BTC")]
2015    #[case("SOL/USD", "SOL/USD")]
2016    #[case("BTC/USD", "BTC/USD")]
2017    #[case("ETH/BTC", "ETH/BTC")]
2018    fn test_normalize_spot_symbol(#[case] input: &str, #[case] expected: &str) {
2019        assert_eq!(normalize_spot_symbol(input), expected);
2020    }
2021
2022    #[rstest]
2023    #[case("A", "A")] // 1 char, minimum
2024    #[case("O2026022700232", "O2026022700232")] // 14 chars, typical short
2025    #[case("ABCDEFGHIJKLMNOPQR", "ABCDEFGHIJKLMNOPQR")] // 18 chars, at limit
2026    fn test_truncate_cl_ord_id_short_passthrough(#[case] input: &str, #[case] expected: &str) {
2027        let id = ClientOrderId::new(input);
2028        assert_eq!(truncate_cl_ord_id(&id), expected);
2029    }
2030
2031    #[rstest]
2032    #[case("6d47a5f0-6fd4-4b84-b56e-c23f0f689c20")] // lowercase hex
2033    #[case("6D47A5F0-6FD4-4B84-B56E-C23F0F689C20")] // uppercase hex
2034    #[case("00000000-0000-0000-0000-000000000000")] // nil UUID
2035    #[case("ffffffff-ffff-ffff-ffff-ffffffffffff")] // max UUID
2036    fn test_truncate_cl_ord_id_uuid_hyphenated_passthrough(#[case] input: &str) {
2037        let id = ClientOrderId::new(input);
2038        assert_eq!(truncate_cl_ord_id(&id), input);
2039    }
2040
2041    #[rstest]
2042    #[case("6d47a5f06fd44b84b56ec23f0f689c20")] // lowercase
2043    #[case("6D47A5F06FD44B84B56EC23F0F689C20")] // uppercase
2044    #[case("00000000000000000000000000000000")] // all zeros
2045    #[case("aAbBcCdDeEfF00112233445566778899")] // mixed case
2046    fn test_truncate_cl_ord_id_uuid_compact_passthrough(#[case] input: &str) {
2047        let id = ClientOrderId::new(input);
2048        assert_eq!(truncate_cl_ord_id(&id), input);
2049    }
2050
2051    #[rstest]
2052    #[case("O2026022700232100400", "O26022700232100400")] // 20 chars → O + last 17
2053    #[case("O202602270023210040011", "O02270023210040011")] // 22 chars, typical sequential
2054    #[case("O20260227002321004001100", "O27002321004001100")] // 24 chars
2055    fn test_truncate_cl_ord_id_sequential_truncated(#[case] input: &str, #[case] expected: &str) {
2056        let id = ClientOrderId::new(input);
2057        let result = truncate_cl_ord_id(&id);
2058        assert_eq!(result, expected);
2059        assert_eq!(result.len(), 18);
2060        assert!(result.starts_with('O'));
2061    }
2062
2063    #[rstest]
2064    fn test_truncate_cl_ord_id_32_chars_non_hex_truncated() {
2065        let input = "0123456789abcdef0123456789abcdeg";
2066        let id = ClientOrderId::new(input);
2067        let result = truncate_cl_ord_id(&id);
2068        assert_eq!(result.len(), 18);
2069        assert!(result.starts_with('O'));
2070        assert_eq!(result, "Of0123456789abcdeg");
2071    }
2072
2073    #[rstest]
2074    fn test_truncate_cl_ord_id_36_chars_wrong_hyphens_truncated() {
2075        let input = "6d47a5f0-6fd4-4b84-b56ec23f0f689c200";
2076        let id = ClientOrderId::new(input);
2077        let result = truncate_cl_ord_id(&id);
2078        assert_eq!(result.len(), 18);
2079        assert!(result.starts_with('O'));
2080    }
2081
2082    #[rstest]
2083    fn test_parse_tokenized_instrument() {
2084        let json = load_test_json("http_asset_pairs_tokenized.json");
2085        let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
2086        let result = wrapper.get("result").unwrap();
2087        let pairs: AssetPairsResponse = serde_json::from_value(result.clone()).unwrap();
2088
2089        let (pair_name, definition) = pairs.iter().next().unwrap();
2090
2091        let instrument = parse_tokenized_instrument(pair_name, definition, TS, TS).unwrap();
2092
2093        match instrument {
2094            InstrumentAny::TokenizedAsset(ta) => {
2095                assert_eq!(ta.id.symbol.as_str(), "AAPLx/USD");
2096                assert_eq!(ta.id.venue.as_str(), "KRAKEN");
2097                assert_eq!(ta.raw_symbol.as_str(), "AAPLxUSD");
2098                assert_eq!(ta.asset_class, AssetClass::Equity);
2099                assert_eq!(ta.base_currency.code.as_str(), "AAPLx");
2100                assert_eq!(ta.quote_currency.code.as_str(), "ZUSD");
2101                assert_eq!(ta.price_precision, 2);
2102                assert_eq!(ta.size_precision, 8);
2103                assert!(ta.price_increment.as_f64() > 0.0);
2104                assert!(ta.size_increment.as_f64() > 0.0);
2105                assert!(ta.min_quantity.is_some());
2106                assert_eq!(ta.maker_fee, dec!(-0.0002));
2107                assert_eq!(ta.taker_fee, dec!(0.001));
2108            }
2109            _ => panic!("Expected TokenizedAsset, received {instrument:?}"),
2110        }
2111    }
2112
2113    #[rstest]
2114    fn test_parse_fill_report_tokenized_asset() {
2115        let json = load_test_json("http_trades_history.json");
2116        let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
2117        let result = wrapper.get("result").unwrap();
2118        let trades_map = result.get("trades").unwrap();
2119        let trades: IndexMap<String, SpotTrade> =
2120            serde_json::from_value(trades_map.clone()).unwrap();
2121
2122        let account_id = AccountId::new("KRAKEN-001");
2123        let instrument_id = InstrumentId::new(Symbol::new("AAPLx/USD"), *KRAKEN_VENUE);
2124        let instrument = InstrumentAny::TokenizedAsset(TokenizedAsset::new(
2125            instrument_id,
2126            Symbol::new("AAPLxUSD"),
2127            AssetClass::Equity,
2128            Currency::get_or_create_crypto("AAPLx"),
2129            Currency::USD(),
2130            None,
2131            2,
2132            8,
2133            Price::from("0.01"),
2134            Quantity::from("0.00000001"),
2135            None,
2136            None,
2137            None,
2138            None,
2139            None,
2140            None,
2141            None,
2142            None,
2143            None,
2144            None,
2145            None,
2146            None,
2147            None,
2148            TS,
2149            TS,
2150        ));
2151
2152        let (trade_id, trade) = trades.iter().next().unwrap();
2153
2154        let report = parse_fill_report(trade_id, trade, &instrument, account_id, TS).unwrap();
2155
2156        assert_eq!(report.account_id, account_id);
2157        assert_eq!(report.instrument_id, instrument_id);
2158        assert_eq!(report.trade_id.to_string(), *trade_id);
2159        assert!(report.last_qty.as_f64() > 0.0);
2160        assert!(report.last_px.as_f64() > 0.0);
2161        assert_eq!(report.commission.currency, Currency::USD());
2162    }
2163
2164    #[rstest]
2165    fn test_truncate_cl_ord_id_19_chars_truncated() {
2166        let input = "O202602270023210040";
2167        assert_eq!(input.len(), 19);
2168        let id = ClientOrderId::new(input);
2169        let result = truncate_cl_ord_id(&id);
2170        assert_eq!(result.len(), 18);
2171        assert_eq!(result, "O02602270023210040");
2172    }
2173
2174    #[rstest]
2175    fn test_truncate_cl_ord_id_preserves_tail() {
2176        let input = "O20260227002321004001100";
2177        let id = ClientOrderId::new(input);
2178        let result = truncate_cl_ord_id(&id);
2179        assert_eq!(&result[1..], &input[input.len() - 17..]);
2180    }
2181}