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