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