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::from_index_map(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::from_index_map(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: order.cli_ord_id.as_ref().map(|s| s.as_str().into()),
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: event.cli_ord_id.as_ref().map(|s| s.as_str().into()),
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/// Parses a Kraken futures `/orders/status` entry into a Nautilus
1157/// OrderStatusReport.
1158///
1159/// The endpoint reports orders which are open or were filled/cancelled in the
1160/// last 5 seconds, so the entry's cumulative `filled` is authoritative for
1161/// reconciliation even when the order is absent from the open-orders snapshot.
1162///
1163/// # Errors
1164///
1165/// Returns an error if order ID, quantities, prices, or timestamps cannot be
1166/// parsed.
1167pub fn parse_futures_order_status_details_report(
1168    details: &FuturesOrderStatusDetails,
1169    instrument: &InstrumentAny,
1170    account_id: AccountId,
1171    ts_init: UnixNanos,
1172) -> anyhow::Result<OrderStatusReport> {
1173    let venue_order_id = VenueOrderId::new(&details.order.order_id);
1174
1175    let order_side = OrderSide::from(details.order.side).into();
1176
1177    let order_type = if details.order.limit_price.is_some() {
1178        OrderType::Limit
1179    } else {
1180        OrderType::Market
1181    };
1182
1183    let filled = details.order.filled.unwrap_or(Decimal::ZERO);
1184
1185    let order_status = match details.status {
1186        KrakenFuturesOrderLifecycleStatus::EnteredBook
1187        | KrakenFuturesOrderLifecycleStatus::TriggerPlaced => {
1188            if filled > Decimal::ZERO {
1189                OrderStatus::PartiallyFilled
1190            } else {
1191                OrderStatus::Accepted
1192            }
1193        }
1194        KrakenFuturesOrderLifecycleStatus::FullyExecuted => OrderStatus::Filled,
1195        KrakenFuturesOrderLifecycleStatus::Rejected
1196        | KrakenFuturesOrderLifecycleStatus::TriggerActivationFailure => OrderStatus::Rejected,
1197        KrakenFuturesOrderLifecycleStatus::Cancelled => OrderStatus::Canceled,
1198    };
1199
1200    let quantity_value = details
1201        .order
1202        .quantity
1203        .context("order status details missing quantity")?;
1204    let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())?;
1205    let filled_qty = Quantity::from_decimal_dp(filled, instrument.size_precision())?;
1206
1207    let ts_accepted = parse_rfc3339_timestamp(&details.order.timestamp, "order.timestamp")?;
1208    let ts_last = parse_rfc3339_timestamp(
1209        &details.order.last_update_timestamp,
1210        "order.last_update_timestamp",
1211    )?;
1212
1213    let price = details
1214        .order
1215        .limit_price
1216        .map(|p| Price::from_decimal_dp(p, instrument.price_precision()))
1217        .transpose()?;
1218
1219    let cancel_reason = match details.status {
1220        KrakenFuturesOrderLifecycleStatus::Cancelled
1221        | KrakenFuturesOrderLifecycleStatus::Rejected
1222        | KrakenFuturesOrderLifecycleStatus::TriggerActivationFailure => {
1223            details.update_reason.clone()
1224        }
1225        _ => None,
1226    };
1227
1228    Ok(OrderStatusReport {
1229        account_id,
1230        instrument_id: instrument.id(),
1231        client_order_id: details.order.cli_ord_id.as_ref().map(|s| s.as_str().into()),
1232        venue_order_id,
1233        order_side,
1234        order_type,
1235        time_in_force: TimeInForce::Gtc,
1236        order_status,
1237        quantity,
1238        filled_qty,
1239        report_id: UUID4::new(),
1240        ts_accepted,
1241        ts_last,
1242        ts_init,
1243        order_list_id: None,
1244        venue_position_id: None,
1245        linked_order_ids: None,
1246        parent_order_id: None,
1247        contingency_type: None,
1248        expire_time: None,
1249        price,
1250        activation_price: None,
1251        trigger_price: None,
1252        trigger_type: None,
1253        limit_offset: None,
1254        trailing_offset: None,
1255        trailing_offset_type: None,
1256        display_qty: None,
1257        avg_px: None,
1258        post_only: false,
1259        reduce_only: details.order.reduce_only,
1260        cancel_reason,
1261        ts_triggered: None,
1262    })
1263}
1264
1265fn parse_futures_order_event_status(
1266    event_type: Option<KrakenFuturesOrderEventType>,
1267    filled: Decimal,
1268    quantity: Decimal,
1269) -> OrderStatus {
1270    match event_type {
1271        Some(KrakenFuturesOrderEventType::Cancel) => OrderStatus::Canceled,
1272        Some(KrakenFuturesOrderEventType::Reject) => OrderStatus::Rejected,
1273        Some(KrakenFuturesOrderEventType::Expire) => OrderStatus::Expired,
1274        Some(
1275            KrakenFuturesOrderEventType::Fill
1276            | KrakenFuturesOrderEventType::Execution
1277            | KrakenFuturesOrderEventType::Place
1278            | KrakenFuturesOrderEventType::Edit,
1279        ) => {
1280            if filled >= quantity {
1281                OrderStatus::Filled
1282            } else if filled > Decimal::ZERO {
1283                OrderStatus::PartiallyFilled
1284            } else {
1285                OrderStatus::Accepted
1286            }
1287        }
1288        _ => {
1289            if filled >= quantity {
1290                OrderStatus::Filled
1291            } else if filled > Decimal::ZERO {
1292                OrderStatus::PartiallyFilled
1293            } else {
1294                OrderStatus::Canceled
1295            }
1296        }
1297    }
1298}
1299
1300/// Parses a Kraken futures fill into a Nautilus FillReport.
1301///
1302/// # Errors
1303///
1304/// Returns an error if fill ID, quantities, or prices cannot be parsed.
1305pub fn parse_futures_fill_report(
1306    fill: &FuturesFill,
1307    instrument: &InstrumentAny,
1308    account_id: AccountId,
1309    ts_init: UnixNanos,
1310) -> anyhow::Result<FillReport> {
1311    let instrument_id = instrument.id();
1312    let venue_order_id = VenueOrderId::new(&fill.order_id);
1313    let trade_id = TradeId::new(&fill.fill_id);
1314
1315    let order_side = fill.side.into();
1316
1317    let last_qty = Quantity::from_decimal_dp(fill.size, instrument.size_precision())?;
1318    let last_px = Price::from_decimal_dp(fill.price, instrument.price_precision())?;
1319
1320    let quote_currency = match instrument {
1321        InstrumentAny::CryptoPerpetual(perp) => perp.quote_currency,
1322        InstrumentAny::CryptoFuture(future) => future.quote_currency,
1323        _ => anyhow::bail!("Unsupported instrument type for futures fill report"),
1324    };
1325
1326    // The venue reports the fee currency, which on an inverse contract is not the quote.
1327    let commission_currency = fill
1328        .fee_currency
1329        .as_deref()
1330        .map_or(quote_currency, |reported| {
1331            fee_currency(reported, instrument)
1332        });
1333    let commission =
1334        Money::from_decimal(fill.fee_paid.unwrap_or(Decimal::ZERO), commission_currency)?;
1335
1336    let liquidity_side = fill.fill_type.into();
1337
1338    let ts_event = parse_rfc3339_timestamp(&fill.fill_time, "fill.fill_time")?;
1339
1340    Ok(FillReport {
1341        account_id,
1342        instrument_id,
1343        venue_order_id,
1344        trade_id,
1345        order_side,
1346        last_qty,
1347        last_px,
1348        commission,
1349        liquidity_side,
1350        avg_px: None,
1351        report_id: UUID4::new(),
1352        ts_event,
1353        ts_init,
1354        client_order_id: fill.cli_ord_id.as_ref().map(|s| s.as_str().into()),
1355        venue_position_id: None,
1356    })
1357}
1358
1359/// Parses a Kraken futures position into a Nautilus PositionStatusReport.
1360///
1361/// # Errors
1362///
1363/// Returns an error if position quantities or prices cannot be parsed.
1364pub fn parse_futures_position_status_report(
1365    position: &FuturesPosition,
1366    instrument: &InstrumentAny,
1367    account_id: AccountId,
1368    ts_init: UnixNanos,
1369) -> anyhow::Result<PositionStatusReport> {
1370    let instrument_id = instrument.id();
1371
1372    let position_side = match position.side {
1373        KrakenPositionSide::Long => PositionSide::Long,
1374        KrakenPositionSide::Short => PositionSide::Short,
1375    };
1376
1377    let quantity = Quantity::from_decimal_dp(position.size, instrument.size_precision())?;
1378    let signed_decimal_qty = match position_side {
1379        PositionSide::Long => position.size,
1380        PositionSide::Short => -position.size,
1381        PositionSide::Flat => dec!(0),
1382    };
1383
1384    let avg_px_open = Some(position.price);
1385
1386    Ok(PositionStatusReport {
1387        account_id,
1388        instrument_id,
1389        position_side,
1390        quantity,
1391        signed_decimal_qty,
1392        report_id: UUID4::new(),
1393        ts_last: ts_init,
1394        ts_init,
1395        venue_position_id: None,
1396        avg_px_open,
1397        avg_px_open_reconciliation: AvgPxReconciliation::default(),
1398        avg_px_open_precision: None,
1399    })
1400}
1401
1402/// Converts a Nautilus BarType to Kraken Spot API interval (in minutes).
1403///
1404/// # Errors
1405///
1406/// Returns an error if:
1407/// - Bar aggregation type is not supported (only Minute, Hour, Day are valid).
1408/// - Bar step is not supported for the aggregation type.
1409pub fn bar_type_to_spot_interval(bar_type: BarType) -> anyhow::Result<u32> {
1410    let step = bar_type.spec().step.get() as u32;
1411    let base_interval = match bar_type.spec().aggregation {
1412        BarAggregation::Minute => 1,
1413        BarAggregation::Hour => 60,
1414        BarAggregation::Day => 1440,
1415        other => {
1416            anyhow::bail!("Unsupported bar aggregation for Kraken Spot: {other:?}");
1417        }
1418    };
1419    Ok(base_interval * step)
1420}
1421
1422/// Converts a Nautilus BarType to Kraken Futures API resolution string.
1423///
1424/// Supported resolutions: 1m, 5m, 15m, 1h, 4h, 12h, 1d, 1w
1425///
1426/// # Errors
1427///
1428/// Returns an error if:
1429/// - Bar aggregation type is not supported.
1430/// - Bar step is not supported for the aggregation type.
1431pub fn bar_type_to_futures_resolution(bar_type: BarType) -> anyhow::Result<&'static str> {
1432    let step = bar_type.spec().step.get() as u32;
1433    match bar_type.spec().aggregation {
1434        BarAggregation::Minute => match step {
1435            1 => Ok("1m"),
1436            5 => Ok("5m"),
1437            15 => Ok("15m"),
1438            _ => anyhow::bail!("Unsupported minute step for Kraken Futures: {step}"),
1439        },
1440        BarAggregation::Hour => match step {
1441            1 => Ok("1h"),
1442            4 => Ok("4h"),
1443            12 => Ok("12h"),
1444            _ => anyhow::bail!("Unsupported hour step for Kraken Futures: {step}"),
1445        },
1446        BarAggregation::Day => {
1447            if step == 1 {
1448                Ok("1d")
1449            } else {
1450                anyhow::bail!("Unsupported day step for Kraken Futures: {step}")
1451            }
1452        }
1453        BarAggregation::Week => {
1454            if step == 1 {
1455                Ok("1w")
1456            } else {
1457                anyhow::bail!("Unsupported week step for Kraken Futures: {step}")
1458            }
1459        }
1460        other => {
1461            anyhow::bail!("Unsupported bar aggregation for Kraken Futures: {other:?}");
1462        }
1463    }
1464}
1465
1466/// Truncates a `ClientOrderId` for Kraken's `cl_ord_id` field.
1467///
1468/// Kraken accepts three formats:
1469/// - Long UUID (36 chars with hyphens): passed through
1470/// - Short UUID (32 hex chars): passed through
1471/// - Free text: max 18 chars
1472///
1473/// Sequential NautilusTrader IDs (e.g. `O202602270023210040011`) exceed the
1474/// 18-char free-text limit. These are truncated to 'O' + last 17 chars,
1475/// preserving the counter portion for maximum entropy.
1476pub fn truncate_cl_ord_id(client_order_id: &ClientOrderId) -> String {
1477    let id = client_order_id.as_str();
1478
1479    if id.len() <= 18 {
1480        return id.to_string();
1481    }
1482
1483    if id.len() == 36 && id.bytes().filter(|b| *b == b'-').count() == 4 {
1484        return id.to_string();
1485    }
1486
1487    if id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit()) {
1488        return id.to_string();
1489    }
1490
1491    format!("O{}", &id[id.len() - 17..])
1492}
1493
1494#[cfg(test)]
1495mod tests {
1496    use indexmap::IndexMap;
1497    use nautilus_model::{
1498        data::BarSpecification,
1499        enums::{AggregationSource, BarAggregation, OrderSide, OrderStatus, PriceType},
1500        instruments::crypto_perpetual::CryptoPerpetual,
1501    };
1502    use rstest::rstest;
1503
1504    use super::*;
1505    use crate::{
1506        common::enums::{
1507            KrakenFuturesOrderEventType, KrakenFuturesOrderStatus, KrakenFuturesOrderType,
1508            KrakenOrderSide,
1509        },
1510        http::{
1511            futures::models::{FuturesFillsResponse, FuturesOpenOrder, FuturesOrderEvent},
1512            models::{
1513                AssetPairsResponse, KrakenResponse, SpotClosedOrdersResult, SpotOpenOrdersResult,
1514            },
1515        },
1516    };
1517
1518    const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
1519
1520    fn load_test_json(filename: &str) -> String {
1521        let path = format!("test_data/{filename}");
1522        std::fs::read_to_string(&path)
1523            .unwrap_or_else(|e| panic!("Failed to load test data from {path}: {e}"))
1524    }
1525
1526    #[rstest]
1527    fn test_parse_decimal() {
1528        assert_eq!(parse_decimal("123.45").unwrap(), dec!(123.45));
1529        assert_eq!(parse_decimal("0").unwrap(), dec!(0));
1530        assert_eq!(parse_decimal("").unwrap(), dec!(0));
1531    }
1532
1533    #[rstest]
1534    fn test_parse_decimal_opt() {
1535        assert_eq!(
1536            parse_decimal_opt(Some("123.45")).unwrap(),
1537            Some(dec!(123.45))
1538        );
1539        assert_eq!(parse_decimal_opt(Some("0")).unwrap(), None);
1540        assert_eq!(parse_decimal_opt(Some("")).unwrap(), None);
1541        assert_eq!(parse_decimal_opt(None).unwrap(), None);
1542    }
1543
1544    #[rstest]
1545    fn test_parse_spot_instrument() {
1546        let json = load_test_json("http_asset_pairs.json");
1547        let response: KrakenResponse<AssetPairsResponse> = serde_json::from_str(&json).unwrap();
1548        let pairs = response.result.unwrap();
1549
1550        let (pair_name, definition) = pairs.iter().next().unwrap();
1551
1552        let instrument = parse_spot_instrument(pair_name, definition, TS, TS).unwrap();
1553
1554        match instrument {
1555            InstrumentAny::CurrencyPair(pair) => {
1556                assert_eq!(pair.id.venue.as_str(), "KRAKEN");
1557                assert_eq!(pair.base_currency.code, "BTC");
1558                assert_eq!(pair.quote_currency.code, "USDT");
1559                assert_eq!(pair.price_increment.as_decimal(), dec!(0.1));
1560                assert_eq!(pair.size_increment.as_decimal(), dec!(0.00000001));
1561                assert!(pair.min_quantity.is_some());
1562                assert_eq!(pair.margin_init, dec!(0));
1563                assert_eq!(pair.margin_maint, dec!(0));
1564            }
1565            _ => panic!("Expected CurrencyPair"),
1566        }
1567    }
1568
1569    #[rstest]
1570    fn test_parse_futures_instrument_inverse() {
1571        let json = load_test_json("http_futures_instruments.json");
1572        let response: crate::http::models::FuturesInstrumentsResponse =
1573            serde_json::from_str(&json).unwrap();
1574
1575        let fut_instrument = &response.instruments[0];
1576
1577        let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1578
1579        match instrument {
1580            InstrumentAny::CryptoPerpetual(perp) => {
1581                assert_eq!(perp.id.venue.as_str(), "KRAKEN");
1582                assert_eq!(perp.id.symbol.as_str(), "PI_XBTUSD");
1583                assert_eq!(perp.raw_symbol.as_str(), "PI_XBTUSD");
1584                assert_eq!(perp.base_currency.code, "BTC");
1585                assert_eq!(perp.quote_currency.code, "USD");
1586                assert_eq!(perp.settlement_currency.code, "BTC");
1587                assert!(perp.is_inverse);
1588                assert_eq!(perp.price_increment.as_decimal(), dec!(0.5));
1589                assert_eq!(perp.size_increment.as_decimal(), dec!(1));
1590                assert_eq!(perp.size_precision(), 0);
1591                assert_eq!(perp.margin_init, dec!(0.02));
1592                assert_eq!(perp.margin_maint, dec!(0.01));
1593            }
1594            _ => panic!("Expected CryptoPerpetual"),
1595        }
1596    }
1597
1598    #[rstest]
1599    fn test_parse_futures_instrument_flexible() {
1600        let json = load_test_json("http_futures_instruments.json");
1601        let response: crate::http::models::FuturesInstrumentsResponse =
1602            serde_json::from_str(&json).unwrap();
1603
1604        let fut_instrument = &response.instruments[1];
1605
1606        let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1607
1608        match instrument {
1609            InstrumentAny::CryptoPerpetual(perp) => {
1610                assert_eq!(perp.id.venue.as_str(), "KRAKEN");
1611                assert_eq!(perp.id.symbol.as_str(), "PF_ETHUSD");
1612                assert_eq!(perp.raw_symbol.as_str(), "PF_ETHUSD");
1613                assert_eq!(perp.base_currency.code, "ETH");
1614                assert_eq!(perp.quote_currency.code, "USD");
1615                assert_eq!(perp.settlement_currency.code, "USD");
1616                assert!(!perp.is_inverse);
1617                assert_eq!(perp.price_increment.as_decimal(), dec!(0.1));
1618                assert_eq!(perp.size_increment.as_decimal(), dec!(0.001));
1619                assert_eq!(perp.size_precision(), 3);
1620                assert_eq!(perp.margin_init, dec!(0.02));
1621                assert_eq!(perp.margin_maint, dec!(0.01));
1622            }
1623            _ => panic!("Expected CryptoPerpetual"),
1624        }
1625    }
1626
1627    #[rstest]
1628    fn test_parse_futures_instrument_accepts_max_precision() {
1629        let json = load_test_json("http_futures_instruments.json");
1630        let response: crate::http::models::FuturesInstrumentsResponse =
1631            serde_json::from_str(&json).unwrap();
1632        let mut fut_instrument = response.instruments[1].clone();
1633        let tick_size = Decimal::try_new(1, u32::from(FIXED_PRECISION)).unwrap();
1634        fut_instrument.tick_size = tick_size;
1635
1636        let instrument = parse_futures_instrument(&fut_instrument, TS, TS).unwrap();
1637
1638        match instrument {
1639            InstrumentAny::CryptoPerpetual(perp) => {
1640                assert_eq!(perp.price_precision(), FIXED_PRECISION);
1641                assert_eq!(perp.price_increment.as_decimal(), tick_size);
1642            }
1643            _ => panic!("Expected CryptoPerpetual"),
1644        }
1645    }
1646
1647    #[cfg(feature = "high-precision")]
1648    #[rstest]
1649    fn test_parse_futures_instrument_negative_precision() {
1650        let json = load_test_json("http_futures_instruments.json");
1651        let response: crate::http::models::FuturesInstrumentsResponse =
1652            serde_json::from_str(&json).unwrap();
1653
1654        // PF_PEPEUSD has contractValueTradePrecision: -3 (trades in multiples of 1000)
1655        let fut_instrument = &response.instruments[2];
1656
1657        let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1658
1659        match instrument {
1660            InstrumentAny::CryptoPerpetual(perp) => {
1661                assert_eq!(perp.id.symbol.as_str(), "PF_PEPEUSD");
1662                assert_eq!(perp.base_currency.code, "PEPE");
1663                assert!(!perp.is_inverse);
1664                assert_eq!(perp.size_increment.as_decimal(), dec!(1000));
1665                assert_eq!(perp.size_precision(), 0);
1666            }
1667            _ => panic!("Expected CryptoPerpetual"),
1668        }
1669    }
1670
1671    #[cfg(feature = "high-precision")]
1672    #[rstest]
1673    fn test_parse_futures_instrument_rejects_precision_above_high_max() {
1674        let json = load_test_json("http_futures_instruments.json");
1675        let response: crate::http::models::FuturesInstrumentsResponse =
1676            serde_json::from_str(&json).unwrap();
1677        let mut fut_instrument = response.instruments[1].clone();
1678        fut_instrument.tick_size = dec!(0.00000000000000001);
1679
1680        let error = parse_futures_instrument(&fut_instrument, TS, TS).unwrap_err();
1681
1682        assert_eq!(
1683            error.to_string(),
1684            "Cannot parse Kraken Futures instrument 'PF_ETHUSD': tick_size \
1685             0.00000000000000001 requires precision 17, but this build supports at most 16"
1686        );
1687    }
1688
1689    #[cfg(not(feature = "high-precision"))]
1690    #[rstest]
1691    fn test_parse_futures_instrument_rejects_unsupported_precision() {
1692        let json = load_test_json("http_futures_instruments.json");
1693        let response: crate::http::models::FuturesInstrumentsResponse =
1694            serde_json::from_str(&json).unwrap();
1695
1696        let tick_error = parse_futures_instrument(&response.instruments[2], TS, TS).unwrap_err();
1697
1698        let mut trade_precision_instrument = response.instruments[1].clone();
1699        trade_precision_instrument.contract_value_trade_precision = 256;
1700        let trade_precision_error =
1701            parse_futures_instrument(&trade_precision_instrument, TS, TS).unwrap_err();
1702
1703        let mut contract_size_instrument = response.instruments[1].clone();
1704        contract_size_instrument.contract_size = dec!(0.0000000001);
1705        let contract_size_error =
1706            parse_futures_instrument(&contract_size_instrument, TS, TS).unwrap_err();
1707
1708        assert_eq!(
1709            tick_error.to_string(),
1710            "Cannot parse Kraken Futures instrument 'PF_PEPEUSD': tick_size 0.0000000001 requires \
1711             precision 10, but this build supports at most 9; enable the 'high-precision' Cargo \
1712             feature and rebuild"
1713        );
1714        assert_eq!(
1715            trade_precision_error.to_string(),
1716            "Cannot parse Kraken Futures instrument 'PF_ETHUSD': contract_value_trade_precision 256 \
1717             requires precision 256, but this build supports at most 9; enable the 'high-precision' \
1718             Cargo feature and rebuild"
1719        );
1720        assert_eq!(
1721            contract_size_error.to_string(),
1722            "Cannot parse Kraken Futures instrument 'PF_ETHUSD': contract_size 0.0000000001 requires \
1723             precision 10, but this build supports at most 9; enable the 'high-precision' Cargo \
1724             feature and rebuild"
1725        );
1726    }
1727
1728    #[rstest]
1729    fn test_parse_futures_instrument_tokenized_underlying() {
1730        let json = load_test_json("http_futures_instruments.json");
1731        let response: crate::http::models::FuturesInstrumentsResponse =
1732            serde_json::from_str(&json).unwrap();
1733
1734        let fut_instrument = &response.instruments[3];
1735
1736        let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1737
1738        match instrument {
1739            InstrumentAny::CryptoPerpetual(perp) => {
1740                assert_eq!(perp.id.symbol.as_str(), "PF_AAPLxUSD");
1741                assert_eq!(perp.raw_symbol.as_str(), "PF_AAPLxUSD");
1742                assert_eq!(perp.base_currency.code, "AAPLx");
1743                assert_eq!(perp.quote_currency.code, "USD");
1744                assert_eq!(perp.settlement_currency.code, "USD");
1745                assert!(!perp.is_inverse);
1746                assert_eq!(perp.price_increment.as_decimal(), dec!(0.01));
1747                assert_eq!(perp.size_increment.as_decimal(), dec!(0.01));
1748                assert_eq!(perp.size_precision(), 2);
1749                assert_eq!(perp.margin_init, dec!(0.2));
1750                assert_eq!(perp.margin_maint, dec!(0.1));
1751            }
1752            _ => panic!("Expected CryptoPerpetual"),
1753        }
1754    }
1755
1756    #[rstest]
1757    fn test_parse_futures_instrument_without_fee_schedule_uid() {
1758        let json = load_test_json("http_futures_instrument_no_fee_schedule.json");
1759        let response: crate::http::models::FuturesInstrumentsResponse =
1760            serde_json::from_str(&json).unwrap();
1761
1762        let fut_instrument = &response.instruments[0];
1763        assert!(fut_instrument.fee_schedule_uid.is_none());
1764
1765        let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1766        match instrument {
1767            InstrumentAny::CryptoPerpetual(perp) => {
1768                assert_eq!(perp.id.symbol.as_str(), "PF_ETHUSD");
1769            }
1770            _ => panic!("Expected CryptoPerpetual"),
1771        }
1772    }
1773
1774    #[rstest]
1775    fn test_parse_trade_tick_from_array() {
1776        let json = load_test_json("http_trades.json");
1777        let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1778        let result = wrapper.get("result").unwrap();
1779        let trades_map = result.as_object().unwrap();
1780
1781        // Get first pair's trades
1782        let (_pair, trades_value) = trades_map.iter().find(|(k, _)| *k != "last").unwrap();
1783        let trades = trades_value.as_array().unwrap();
1784        let trade_array = trades[0].as_array().unwrap();
1785
1786        // Create a mock instrument for testing
1787        let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1788        let instrument = InstrumentAny::CurrencyPair(
1789            CurrencyPair::builder()
1790                .instrument_id(instrument_id)
1791                .raw_symbol(Symbol::new("XBTUSDT"))
1792                .base_currency(Currency::BTC())
1793                .quote_currency(Currency::USDT())
1794                .price_precision(1)
1795                .size_precision(8)
1796                .price_increment(Price::from("0.1"))
1797                .size_increment(Quantity::from("0.00000001"))
1798                .ts_event(TS)
1799                .ts_init(TS)
1800                .build()
1801                .unwrap(),
1802        );
1803
1804        let trade_tick = parse_trade_tick_from_array(trade_array, &instrument, TS).unwrap();
1805
1806        assert_eq!(trade_tick.instrument_id, instrument_id);
1807        assert_eq!(trade_tick.price, Price::from("105433.60000"));
1808        assert_eq!(trade_tick.size, Quantity::from("0.00027625"));
1809    }
1810
1811    #[rstest]
1812    fn test_parse_bar() {
1813        let json = load_test_json("http_ohlc.json");
1814        let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1815        let result = wrapper.get("result").unwrap();
1816        let ohlc_map = result.as_object().unwrap();
1817
1818        // Get first pair's OHLC data
1819        let (_pair, ohlc_value) = ohlc_map.iter().find(|(k, _)| *k != "last").unwrap();
1820        let ohlcs = ohlc_value.as_array().unwrap();
1821
1822        // Parse first OHLC array into OhlcData
1823        let ohlc_array = ohlcs[0].as_array().unwrap();
1824        let ohlc = OhlcData {
1825            time: ohlc_array[0].as_i64().unwrap(),
1826            open: ohlc_array[1].as_str().unwrap().to_string(),
1827            high: ohlc_array[2].as_str().unwrap().to_string(),
1828            low: ohlc_array[3].as_str().unwrap().to_string(),
1829            close: ohlc_array[4].as_str().unwrap().to_string(),
1830            vwap: ohlc_array[5].as_str().unwrap().to_string(),
1831            volume: ohlc_array[6].as_str().unwrap().to_string(),
1832            count: ohlc_array[7].as_i64().unwrap(),
1833        };
1834
1835        // Create a mock instrument
1836        let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1837        let instrument = InstrumentAny::CurrencyPair(
1838            CurrencyPair::builder()
1839                .instrument_id(instrument_id)
1840                .raw_symbol(Symbol::new("XBTUSDT"))
1841                .base_currency(Currency::BTC())
1842                .quote_currency(Currency::USDT())
1843                .price_precision(1)
1844                .size_precision(8)
1845                .price_increment(Price::from("0.1"))
1846                .size_increment(Quantity::from("0.00000001"))
1847                .ts_event(TS)
1848                .ts_init(TS)
1849                .build()
1850                .unwrap(),
1851        );
1852
1853        let bar_type = BarType::new(
1854            instrument_id,
1855            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
1856            AggregationSource::External,
1857        );
1858
1859        let bar = parse_bar(&ohlc, &instrument, bar_type, TS).unwrap();
1860
1861        assert_eq!(bar.bar_type, bar_type);
1862        assert_eq!(bar.open, Price::from("106038.2"));
1863        assert_eq!(bar.high, Price::from("106038.2"));
1864        assert_eq!(bar.low, Price::from("106038.2"));
1865        assert_eq!(bar.close, Price::from("106038.2"));
1866        assert_eq!(bar.volume, Quantity::from("0.00000000"));
1867    }
1868
1869    #[rstest]
1870    fn test_parse_millis_timestamp() {
1871        let timestamp = 1762795433.9717445;
1872        let result = parse_millis_timestamp(timestamp, "test").unwrap();
1873        assert!(result.as_u64() > 0);
1874    }
1875
1876    #[rstest]
1877    #[case(1, BarAggregation::Minute, 1)]
1878    #[case(5, BarAggregation::Minute, 5)]
1879    #[case(15, BarAggregation::Minute, 15)]
1880    #[case(1, BarAggregation::Hour, 60)]
1881    #[case(4, BarAggregation::Hour, 240)]
1882    #[case(1, BarAggregation::Day, 1440)]
1883    fn test_bar_type_to_spot_interval(
1884        #[case] step: usize,
1885        #[case] aggregation: BarAggregation,
1886        #[case] expected: u32,
1887    ) {
1888        let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1889        let bar_type = BarType::new(
1890            instrument_id,
1891            BarSpecification::new(step, aggregation, PriceType::Last),
1892            AggregationSource::External,
1893        );
1894
1895        let result = bar_type_to_spot_interval(bar_type).unwrap();
1896        assert_eq!(result, expected);
1897    }
1898
1899    #[rstest]
1900    fn test_bar_type_to_spot_interval_unsupported() {
1901        let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1902        let bar_type = BarType::new(
1903            instrument_id,
1904            BarSpecification::new(1, BarAggregation::Second, PriceType::Last),
1905            AggregationSource::External,
1906        );
1907
1908        let result = bar_type_to_spot_interval(bar_type);
1909        assert!(result.is_err());
1910        assert!(result.unwrap_err().to_string().contains("Unsupported"));
1911    }
1912
1913    #[rstest]
1914    #[case(1, BarAggregation::Minute, "1m")]
1915    #[case(5, BarAggregation::Minute, "5m")]
1916    #[case(15, BarAggregation::Minute, "15m")]
1917    #[case(1, BarAggregation::Hour, "1h")]
1918    #[case(4, BarAggregation::Hour, "4h")]
1919    #[case(12, BarAggregation::Hour, "12h")]
1920    #[case(1, BarAggregation::Day, "1d")]
1921    #[case(1, BarAggregation::Week, "1w")]
1922    fn test_bar_type_to_futures_resolution(
1923        #[case] step: usize,
1924        #[case] aggregation: BarAggregation,
1925        #[case] expected: &str,
1926    ) {
1927        let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
1928        let bar_type = BarType::new(
1929            instrument_id,
1930            BarSpecification::new(step, aggregation, PriceType::Last),
1931            AggregationSource::External,
1932        );
1933
1934        let result = bar_type_to_futures_resolution(bar_type).unwrap();
1935        assert_eq!(result, expected);
1936    }
1937
1938    #[rstest]
1939    #[case(30, BarAggregation::Minute)] // Unsupported minute step
1940    #[case(2, BarAggregation::Hour)] // Unsupported hour step
1941    #[case(2, BarAggregation::Day)] // Unsupported day step
1942    #[case(1, BarAggregation::Second)] // Unsupported aggregation
1943    fn test_bar_type_to_futures_resolution_unsupported(
1944        #[case] step: usize,
1945        #[case] aggregation: BarAggregation,
1946    ) {
1947        let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
1948        let bar_type = BarType::new(
1949            instrument_id,
1950            BarSpecification::new(step, aggregation, PriceType::Last),
1951            AggregationSource::External,
1952        );
1953
1954        let result = bar_type_to_futures_resolution(bar_type);
1955        assert!(result.is_err());
1956        assert!(result.unwrap_err().to_string().contains("Unsupported"));
1957    }
1958
1959    #[rstest]
1960    fn test_parse_order_status_report() {
1961        let json = load_test_json("http_open_orders.json");
1962        let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1963        let result = wrapper.get("result").unwrap();
1964        let open_map = result.get("open").unwrap();
1965        let orders: IndexMap<String, SpotOrder> = serde_json::from_value(open_map.clone()).unwrap();
1966
1967        let account_id = AccountId::new("KRAKEN-001");
1968        let instrument_id = InstrumentId::new(Symbol::new("BTC/USDT"), *KRAKEN_VENUE);
1969        let instrument = InstrumentAny::CurrencyPair(
1970            CurrencyPair::builder()
1971                .instrument_id(instrument_id)
1972                .raw_symbol(Symbol::new("XBTUSDT"))
1973                .base_currency(Currency::BTC())
1974                .quote_currency(Currency::USDT())
1975                .price_precision(2)
1976                .size_precision(8)
1977                .price_increment(Price::from("0.01"))
1978                .size_increment(Quantity::from("0.00000001"))
1979                .ts_event(TS)
1980                .ts_init(TS)
1981                .build()
1982                .unwrap(),
1983        );
1984
1985        let (order_id, order) = orders.iter().next().unwrap();
1986
1987        let report =
1988            parse_order_status_report(order_id, order, &instrument, account_id, TS).unwrap();
1989
1990        assert_eq!(report.account_id, account_id);
1991        assert_eq!(report.instrument_id, instrument_id);
1992        assert_eq!(report.venue_order_id.as_str(), order_id);
1993        assert_eq!(report.order_status, OrderStatus::Accepted);
1994        assert_eq!(report.quantity, Quantity::from("0.50000000"));
1995    }
1996
1997    /// An order ID with the price, trigger price and average fill price its report carries.
1998    type ExpectedPrices<'a> = (&'a str, Option<&'a str>, Option<&'a str>, Option<Decimal>);
1999
2000    /// A spot report carries the order's own prices, never the row's top-level `price`, which is
2001    /// the average fill price. The limit comes from `descr.price` for a limit order and from
2002    /// `descr.price2` for a stop-loss-limit or take-profit-limit order, or a trailing-stop-limit
2003    /// order once `misc` marks it `stopped`; a trailing-stop-limit's `limitprice`, the peak price,
2004    /// never stands in. The trigger comes from `stopprice`, which a non-trailing order fills only
2005    /// once triggered, so `descr.price` stands in before. Each row is a Kraken response listed in
2006    /// `test_data/README.md`.
2007    #[rstest]
2008    #[case::untriggered_open_orders(
2009        "http_open_orders_conditional.json",
2010        usdc_eur_instrument(),
2011        &[
2012            ("OCAP01-STOPL-000001", None, Some("1.3403"), None),
2013            ("OCAP02-STOPL-000002", None, Some("1.3402"), None),
2014            ("OCAP03-TAKEP-000003", None, Some("0.4467"), None),
2015            ("OCAP04-STLMT-000004", Some("1.3403"), Some("1.3403"), None),
2016            ("OCAP05-TPLMT-000005", Some("0.4467"), Some("0.4467"), None),
2017            ("OCAP06-TRAIL-000006", None, Some("1.3402"), None),
2018            ("OCAP07-TRLMT-000007", None, Some("1.3402"), None),
2019            ("OCAP08-LIMIT-000008", Some("0.4467"), None, None),
2020        ]
2021    )]
2022    #[case::untriggered_canceled_orders_one_amended(
2023        "http_closed_orders_conditional_canceled.json",
2024        usdc_eur_instrument(),
2025        &[
2026            ("OCAP01-STOPL-000001", None, Some("1.4296"), None),
2027            ("OCAP02-STOPL-000002", None, Some("1.3402"), None),
2028            ("OCAP03-TAKEP-000003", None, Some("0.4467"), None),
2029            ("OCAP04-STLMT-000004", Some("1.3403"), Some("1.3403"), None),
2030            ("OCAP05-TPLMT-000005", Some("0.4467"), Some("0.4467"), None),
2031            ("OCAP06-TRAIL-000006", None, Some("1.3401"), None),
2032            ("OCAP07-TRLMT-000007", None, Some("1.3401"), None),
2033            ("OCAP08-LIMIT-000008", Some("0.4467"), None, None),
2034        ]
2035    )]
2036    #[case::canceled_orders_three_triggered(
2037        "http_closed_orders_conditional_triggered.json",
2038        usdc_eur_instrument(),
2039        &[
2040            ("OCAP09-STLMT-000009", Some("0.4464"), Some("1.3394"), None),
2041            ("OCAP10-TPLMT-000010", Some("0.4455"), Some("0.4465"), None),
2042            ("OCAP11-STLMT-000011", Some("0.4464"), Some("0.8929"), None),
2043            ("OCAP12-TPLMT-000012", Some("0.4464"), Some("0.8929"), None),
2044            ("OCAP13-TRLMT-000013", Some("0.4465"), Some("0.8930"), None),
2045        ]
2046    )]
2047    #[case::documented_open_limit_orders(
2048        "http_open_orders_doc_example.json",
2049        xbt_usd_instrument(),
2050        &[
2051            ("OQCLML-BW3P3-BUCMWZ", Some("30010.0"), None, Some(dec!(11253.7) / dec!(0.375))),
2052            ("OB5VMB-B4U2U-DK2WRW", Some("14500.0"), None, None),
2053        ]
2054    )]
2055    #[case::documented_filled_market_order(
2056        "http_query_orders_doc_example.json",
2057        xbt_usd_instrument(),
2058        &[("OMMDB2-FSB6Z-7W3HPO", None, None, Some(dec!(30000)))]
2059    )]
2060    fn test_parse_order_status_report_prices_the_order_from_its_own_fields(
2061        #[case] filename: &str,
2062        #[case] instrument: InstrumentAny,
2063        #[case] expected: &[ExpectedPrices],
2064    ) {
2065        let rows = spot_order_rows(filename);
2066
2067        for &(order_id, expected_price, expected_trigger_price, expected_avg_px) in expected {
2068            let report = parse_order_status_report(
2069                order_id,
2070                &rows[order_id],
2071                &instrument,
2072                AccountId::new("KRAKEN-001"),
2073                TS,
2074            )
2075            .unwrap();
2076
2077            assert_eq!(report.price, expected_price.map(Price::from), "{order_id}");
2078            assert_eq!(
2079                report.trigger_price,
2080                expected_trigger_price.map(Price::from),
2081                "{order_id}"
2082            );
2083            assert_eq!(report.avg_px, expected_avg_px, "{order_id}");
2084        }
2085    }
2086
2087    /// A description price that is neither a price nor a relative expression fails the row. The
2088    /// input is synthetic: a captured limit row with its `descr.price` replaced.
2089    #[rstest]
2090    fn test_parse_order_status_report_fails_on_a_malformed_order_price() {
2091        let order_id = "OCAP08-LIMIT-000008";
2092        let mut order = spot_order_rows("http_open_orders_conditional.json")
2093            .swap_remove(order_id)
2094            .unwrap();
2095        order.descr.price = "abc".to_string();
2096
2097        let result = parse_order_status_report(
2098            order_id,
2099            &order,
2100            &usdc_eur_instrument(),
2101            AccountId::new("KRAKEN-001"),
2102            TS,
2103        );
2104
2105        assert!(result.is_err(), "{result:?}");
2106    }
2107
2108    /// An empty price or a relative expression reads as none rather than failing the row. The
2109    /// inputs are synthetic: a captured stop-loss row with its `stopprice` replaced, so
2110    /// `descr.price` stands in.
2111    #[rstest]
2112    #[case::empty("")]
2113    #[case::relative("+1.5%")]
2114    fn test_parse_order_status_report_reads_an_unpriced_stop_price_as_none(
2115        #[case] stop_price: &str,
2116    ) {
2117        let order_id = "OCAP02-STOPL-000002";
2118        let mut order = spot_order_rows("http_open_orders_conditional.json")
2119            .swap_remove(order_id)
2120            .unwrap();
2121        order.stopprice = Some(stop_price.to_string());
2122
2123        let report = parse_order_status_report(
2124            order_id,
2125            &order,
2126            &usdc_eur_instrument(),
2127            AccountId::new("KRAKEN-001"),
2128            TS,
2129        )
2130        .unwrap();
2131
2132        assert_eq!(report.trigger_price, Some(Price::from("1.3402")));
2133    }
2134
2135    /// An untriggered trailing-stop-limit carries no price whatever its `descr.price2` holds, so an
2136    /// unsigned offset, the form Nautilus submits, never reads as its limit. The input is
2137    /// synthetic: a captured untriggered row with its offset written unsigned.
2138    #[rstest]
2139    fn test_parse_order_status_report_prices_no_untriggered_trailing_stop_limit() {
2140        let order_id = "OCAP07-TRLMT-000007";
2141        let mut order = spot_order_rows("http_open_orders_conditional.json")
2142            .swap_remove(order_id)
2143            .unwrap();
2144        order.descr.price2 = "1.0000".to_string();
2145
2146        let report = parse_order_status_report(
2147            order_id,
2148            &order,
2149            &usdc_eur_instrument(),
2150            AccountId::new("KRAKEN-001"),
2151            TS,
2152        )
2153        .unwrap();
2154
2155        assert_eq!(report.price, None);
2156        assert_eq!(report.trigger_price, Some(Price::from("1.3402")));
2157    }
2158
2159    /// The rows of a spot OpenOrders, ClosedOrders or QueryOrders response in `test_data`.
2160    fn spot_order_rows(filename: &str) -> IndexMap<String, SpotOrder> {
2161        let json = load_test_json(filename);
2162        if filename.starts_with("http_open_orders") {
2163            let response: KrakenResponse<SpotOpenOrdersResult> =
2164                serde_json::from_str(&json).unwrap();
2165            response.result.unwrap().open
2166        } else if filename.starts_with("http_closed_orders") {
2167            let response: KrakenResponse<SpotClosedOrdersResult> =
2168                serde_json::from_str(&json).unwrap();
2169            response.result.unwrap().closed
2170        } else {
2171            let response: KrakenResponse<IndexMap<String, SpotOrder>> =
2172                serde_json::from_str(&json).unwrap();
2173            response.result.unwrap()
2174        }
2175    }
2176
2177    fn usdc_eur_instrument() -> InstrumentAny {
2178        InstrumentAny::CurrencyPair(
2179            CurrencyPair::builder()
2180                .instrument_id(InstrumentId::new(Symbol::new("USDC/EUR"), *KRAKEN_VENUE))
2181                .raw_symbol(Symbol::new("USDCEUR"))
2182                .base_currency(Currency::USDC())
2183                .quote_currency(Currency::EUR())
2184                .price_precision(4)
2185                .size_precision(8)
2186                .price_increment(Price::from("0.0001"))
2187                .size_increment(Quantity::from("0.00000001"))
2188                .ts_event(TS)
2189                .ts_init(TS)
2190                .build()
2191                .unwrap(),
2192        )
2193    }
2194
2195    fn xbt_usd_instrument() -> InstrumentAny {
2196        InstrumentAny::CurrencyPair(
2197            CurrencyPair::builder()
2198                .instrument_id(InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE))
2199                .raw_symbol(Symbol::new("XBTUSD"))
2200                .base_currency(Currency::BTC())
2201                .quote_currency(Currency::USD())
2202                .price_precision(1)
2203                .size_precision(8)
2204                .price_increment(Price::from("0.1"))
2205                .size_increment(Quantity::from("0.00000001"))
2206                .ts_event(TS)
2207                .ts_init(TS)
2208                .build()
2209                .unwrap(),
2210        )
2211    }
2212
2213    fn create_mock_perp() -> InstrumentAny {
2214        let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
2215        InstrumentAny::CryptoPerpetual(
2216            CryptoPerpetual::builder()
2217                .instrument_id(instrument_id)
2218                .raw_symbol(Symbol::new("PI_XBTUSD"))
2219                .base_currency(Currency::BTC())
2220                .quote_currency(Currency::USD())
2221                .settlement_currency(Currency::USD())
2222                .is_inverse(false)
2223                .price_precision(1)
2224                .size_precision(0)
2225                .price_increment(Price::from("0.5"))
2226                .size_increment(Quantity::from("1"))
2227                .ts_event(TS)
2228                .ts_init(TS)
2229                .build()
2230                .unwrap(),
2231        )
2232    }
2233
2234    #[rstest]
2235    fn test_parse_futures_assignee_fill_report() {
2236        let json = load_test_json("http_futures_fills.json");
2237        let response: FuturesFillsResponse = serde_json::from_str(&json).unwrap();
2238        let fill = &response.fills[2];
2239        let instrument = create_mock_perp();
2240        let account_id = AccountId::new("KRAKEN-001");
2241
2242        let report = parse_futures_fill_report(fill, &instrument, account_id, TS).unwrap();
2243
2244        assert_eq!(report.account_id, account_id);
2245        assert_eq!(report.instrument_id, instrument.id());
2246        assert_eq!(
2247            report.venue_order_id,
2248            VenueOrderId::new("f8a7b6c5-d4e3-2f1a-0b9c-8d7e6f5a4b3c")
2249        );
2250        assert_eq!(
2251            report.trade_id,
2252            TradeId::new("d3f4e5a6-b7c8-9d0e-1f2a-3b4c5d6e7f8a")
2253        );
2254        assert_eq!(report.order_side, OrderSide::Sell);
2255        assert_eq!(report.last_qty, Quantity::from("2500"));
2256        assert_eq!(report.last_px, Price::from("28050.0"));
2257        assert_eq!(report.commission, Money::zero(Currency::USD()));
2258        assert_eq!(report.liquidity_side, LiquiditySide::NoLiquiditySide);
2259        assert_eq!(report.avg_px, None);
2260        assert_eq!(
2261            report.ts_event,
2262            "2023-04-07T15:55:20.123Z".parse::<UnixNanos>().unwrap()
2263        );
2264        assert_eq!(report.ts_init, TS);
2265        assert_eq!(report.client_order_id, None);
2266        assert_eq!(report.venue_position_id, None);
2267    }
2268
2269    #[rstest]
2270    fn test_parse_futures_order_status_report_market_if_touched() {
2271        let order = FuturesOpenOrder {
2272            order_id: "tp-001".to_string(),
2273            symbol: "PI_XBTUSD".to_string(),
2274            side: KrakenOrderSide::Buy,
2275            order_type: KrakenFuturesOrderType::TakeProfit,
2276            limit_price: None,
2277            stop_price: Some(dec!(36000)),
2278            unfilled_size: Some(dec!(500)),
2279            received_time: "2023-11-14T22:13:20.000Z".to_string(),
2280            status: KrakenFuturesOrderStatus::PartiallyFilled,
2281            filled_size: dec!(25),
2282            reduce_only: Some(true),
2283            last_update_time: "2023-11-14T22:13:20.000Z".to_string(),
2284            trigger_signal: None,
2285            cli_ord_id: Some("my-tp-1".to_string()),
2286        };
2287        let instrument = create_mock_perp();
2288        let account_id = AccountId::new("KRAKEN-001");
2289
2290        let report =
2291            parse_futures_order_status_report(&order, &instrument, account_id, None, TS).unwrap();
2292
2293        assert_eq!(report.order_type, OrderType::MarketIfTouched);
2294        assert_eq!(report.quantity.as_decimal(), dec!(525));
2295        assert_eq!(report.filled_qty.as_decimal(), dec!(25));
2296        assert_eq!(report.trigger_price.unwrap().as_decimal(), dec!(36000));
2297        assert!(report.price.is_none());
2298        assert!(report.reduce_only);
2299        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
2300    }
2301
2302    #[rstest]
2303    fn test_parse_futures_order_status_report_limit_if_touched() {
2304        let order = FuturesOpenOrder {
2305            order_id: "tpl-001".to_string(),
2306            symbol: "PI_XBTUSD".to_string(),
2307            side: KrakenOrderSide::Sell,
2308            order_type: KrakenFuturesOrderType::TakeProfit,
2309            limit_price: Some(dec!(35500)),
2310            stop_price: Some(dec!(36000)),
2311            unfilled_size: Some(dec!(500)),
2312            received_time: "2023-11-14T22:13:20.000Z".to_string(),
2313            status: KrakenFuturesOrderStatus::Untouched,
2314            filled_size: dec!(0),
2315            reduce_only: None,
2316            last_update_time: "2023-11-14T22:13:20.000Z".to_string(),
2317            trigger_signal: None,
2318            cli_ord_id: Some("my-tpl-1".to_string()),
2319        };
2320        let instrument = create_mock_perp();
2321        let account_id = AccountId::new("KRAKEN-001");
2322
2323        let report =
2324            parse_futures_order_status_report(&order, &instrument, account_id, None, TS).unwrap();
2325
2326        assert_eq!(report.order_type, OrderType::LimitIfTouched);
2327        assert_eq!(report.trigger_price.unwrap().as_decimal(), dec!(36000));
2328        assert_eq!(report.price.unwrap().as_decimal(), dec!(35500));
2329        assert_eq!(report.order_side, OrderSide::Sell.into());
2330        assert!(!report.reduce_only);
2331    }
2332
2333    #[rstest]
2334    fn test_parse_futures_order_event_market_if_touched() {
2335        let event = FuturesOrderEvent {
2336            order_id: "tp-evt-001".to_string(),
2337            cli_ord_id: None,
2338            order_type: KrakenFuturesOrderType::TakeProfit,
2339            symbol: "PI_XBTUSD".to_string(),
2340            side: KrakenOrderSide::Buy,
2341            quantity: dec!(100),
2342            filled: dec!(100),
2343            limit_price: None,
2344            stop_price: Some(dec!(40000)),
2345            timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2346            last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2347            reduce_only: false,
2348        };
2349        let instrument = create_mock_perp();
2350        let account_id = AccountId::new("KRAKEN-001");
2351
2352        let report = parse_futures_order_event_status_report(
2353            &event,
2354            Some(KrakenFuturesOrderEventType::Fill),
2355            &instrument,
2356            account_id,
2357            TS,
2358        )
2359        .unwrap();
2360
2361        assert_eq!(report.order_type, OrderType::MarketIfTouched);
2362        assert_eq!(report.trigger_price.unwrap().as_decimal(), dec!(40000));
2363        assert!(report.price.is_none());
2364        assert_eq!(report.order_status, OrderStatus::Filled);
2365    }
2366
2367    #[rstest]
2368    fn test_parse_futures_order_event_limit_if_touched() {
2369        let event = FuturesOrderEvent {
2370            order_id: "tpl-evt-001".to_string(),
2371            cli_ord_id: Some("my-tpl-evt".to_string()),
2372            order_type: KrakenFuturesOrderType::TakeProfit,
2373            symbol: "PI_XBTUSD".to_string(),
2374            side: KrakenOrderSide::Sell,
2375            quantity: dec!(200),
2376            filled: Decimal::ZERO,
2377            limit_price: Some(dec!(39500)),
2378            stop_price: Some(dec!(40000)),
2379            timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2380            last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2381            reduce_only: true,
2382        };
2383        let instrument = create_mock_perp();
2384        let account_id = AccountId::new("KRAKEN-001");
2385
2386        let report = parse_futures_order_event_status_report(
2387            &event,
2388            Some(KrakenFuturesOrderEventType::Place),
2389            &instrument,
2390            account_id,
2391            TS,
2392        )
2393        .unwrap();
2394
2395        assert_eq!(report.order_type, OrderType::LimitIfTouched);
2396        assert_eq!(report.trigger_price.unwrap().as_decimal(), dec!(40000));
2397        assert_eq!(report.price.unwrap().as_decimal(), dec!(39500));
2398        assert_eq!(report.order_side, OrderSide::Sell.into());
2399        assert_eq!(report.order_status, OrderStatus::Accepted);
2400        assert!(report.reduce_only);
2401    }
2402
2403    #[rstest]
2404    fn test_parse_futures_order_event_cancel_status() {
2405        let event = FuturesOrderEvent {
2406            order_id: "cancel-evt-001".to_string(),
2407            cli_ord_id: Some("cancel-evt".to_string()),
2408            order_type: KrakenFuturesOrderType::Stop,
2409            symbol: "PI_XBTUSD".to_string(),
2410            side: KrakenOrderSide::Sell,
2411            quantity: dec!(200),
2412            filled: Decimal::ZERO,
2413            limit_price: None,
2414            stop_price: Some(dec!(39000)),
2415            timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2416            last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2417            reduce_only: true,
2418        };
2419        let instrument = create_mock_perp();
2420        let account_id = AccountId::new("KRAKEN-001");
2421
2422        let report = parse_futures_order_event_status_report(
2423            &event,
2424            Some(KrakenFuturesOrderEventType::Cancel),
2425            &instrument,
2426            account_id,
2427            TS,
2428        )
2429        .unwrap();
2430
2431        assert_eq!(report.order_status, OrderStatus::Canceled);
2432        assert!(report.reduce_only);
2433    }
2434
2435    #[rstest]
2436    fn test_parse_futures_order_event_reject_status() {
2437        let event = FuturesOrderEvent {
2438            order_id: "reject-evt-001".to_string(),
2439            cli_ord_id: Some("reject-evt".to_string()),
2440            order_type: KrakenFuturesOrderType::Limit,
2441            symbol: "PI_XBTUSD".to_string(),
2442            side: KrakenOrderSide::Buy,
2443            quantity: dec!(200),
2444            filled: Decimal::ZERO,
2445            limit_price: Some(dec!(35000)),
2446            stop_price: None,
2447            timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2448            last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2449            reduce_only: false,
2450        };
2451        let instrument = create_mock_perp();
2452        let account_id = AccountId::new("KRAKEN-001");
2453
2454        let report = parse_futures_order_event_status_report(
2455            &event,
2456            Some(KrakenFuturesOrderEventType::Reject),
2457            &instrument,
2458            account_id,
2459            TS,
2460        )
2461        .unwrap();
2462
2463        assert_eq!(report.order_status, OrderStatus::Rejected);
2464    }
2465
2466    #[rstest]
2467    fn test_parse_futures_order_event_expire_status() {
2468        let event = FuturesOrderEvent {
2469            order_id: "expire-evt-001".to_string(),
2470            cli_ord_id: Some("expire-evt".to_string()),
2471            order_type: KrakenFuturesOrderType::Limit,
2472            symbol: "PI_XBTUSD".to_string(),
2473            side: KrakenOrderSide::Buy,
2474            quantity: dec!(200),
2475            filled: Decimal::ZERO,
2476            limit_price: Some(dec!(35000)),
2477            stop_price: None,
2478            timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2479            last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2480            reduce_only: false,
2481        };
2482        let instrument = create_mock_perp();
2483        let account_id = AccountId::new("KRAKEN-001");
2484
2485        let report = parse_futures_order_event_status_report(
2486            &event,
2487            Some(KrakenFuturesOrderEventType::Expire),
2488            &instrument,
2489            account_id,
2490            TS,
2491        )
2492        .unwrap();
2493
2494        assert_eq!(report.order_status, OrderStatus::Expired);
2495    }
2496
2497    #[rstest]
2498    fn test_parse_futures_order_event_execution_status() {
2499        let event = FuturesOrderEvent {
2500            order_id: "execution-evt-001".to_string(),
2501            cli_ord_id: Some("execution-evt".to_string()),
2502            order_type: KrakenFuturesOrderType::Limit,
2503            symbol: "PI_XBTUSD".to_string(),
2504            side: KrakenOrderSide::Buy,
2505            quantity: dec!(200),
2506            filled: dec!(50),
2507            limit_price: Some(dec!(35000)),
2508            stop_price: None,
2509            timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2510            last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2511            reduce_only: false,
2512        };
2513        let instrument = create_mock_perp();
2514        let account_id = AccountId::new("KRAKEN-001");
2515
2516        let report = parse_futures_order_event_status_report(
2517            &event,
2518            Some(KrakenFuturesOrderEventType::Execution),
2519            &instrument,
2520            account_id,
2521            TS,
2522        )
2523        .unwrap();
2524
2525        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
2526    }
2527
2528    #[rstest]
2529    fn test_parse_fill_report() {
2530        let json = load_test_json("http_trades_history.json");
2531        let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
2532        let result = wrapper.get("result").unwrap();
2533        let trades_map = result.get("trades").unwrap();
2534        let trades: IndexMap<String, SpotTrade> =
2535            serde_json::from_value(trades_map.clone()).unwrap();
2536
2537        let account_id = AccountId::new("KRAKEN-001");
2538        let instrument_id = InstrumentId::new(Symbol::new("BTC/USDT"), *KRAKEN_VENUE);
2539        let instrument = InstrumentAny::CurrencyPair(
2540            CurrencyPair::builder()
2541                .instrument_id(instrument_id)
2542                .raw_symbol(Symbol::new("XBTUSDT"))
2543                .base_currency(Currency::BTC())
2544                .quote_currency(Currency::USDT())
2545                .price_precision(2)
2546                .size_precision(8)
2547                .price_increment(Price::from("0.01"))
2548                .size_increment(Quantity::from("0.00000001"))
2549                .ts_event(TS)
2550                .ts_init(TS)
2551                .build()
2552                .unwrap(),
2553        );
2554
2555        let (trade_id, trade) = trades.iter().next().unwrap();
2556
2557        let report = parse_fill_report(trade_id, trade, &instrument, account_id, TS).unwrap();
2558
2559        assert_eq!(report.account_id, account_id);
2560        assert_eq!(report.instrument_id, instrument_id);
2561        assert_eq!(report.trade_id.to_string(), *trade_id);
2562        assert_eq!(report.last_qty, Quantity::from("0.50000000"));
2563        assert_eq!(report.last_px, Price::from("29500.50"));
2564        assert_eq!(report.commission.as_decimal(), dec!(23.60));
2565    }
2566
2567    #[rstest]
2568    #[case("XXBT", "BTC")]
2569    #[case("XBT", "BTC")]
2570    #[case("XXDG", "DOGE")]
2571    #[case("XDG", "DOGE")]
2572    #[case("XETH", "ETH")]
2573    #[case("ZUSD", "USD")]
2574    #[case("ZEUR", "EUR")]
2575    #[case("KFEE", "FEE")]
2576    #[case("BTC", "BTC")]
2577    #[case("ETH", "ETH")]
2578    #[case("USDT", "USDT")]
2579    #[case("SOL", "SOL")]
2580    // Codes that legitimately begin with X or Z must survive: a prefix strip would corrupt them.
2581    #[case("XTZ", "XTZ")]
2582    #[case("XRP", "XRP")]
2583    #[case("XLM", "XLM")]
2584    #[case("XAUT", "XAUT")]
2585    #[case("XCN", "XCN")]
2586    #[case("ZRX", "ZRX")]
2587    #[case("ZEC", "ZEC")]
2588    #[case("ZK", "ZK")]
2589    fn test_normalize_currency_code(#[case] input: &str, #[case] expected: &str) {
2590        assert_eq!(normalize_currency_code(input), expected);
2591    }
2592
2593    /// Kraken Futures keys account balances by its own spelling, which is not upper case.
2594    #[rstest]
2595    #[case("xbt", "BTC")]
2596    #[case("XBT", "BTC")]
2597    #[case("xxbt", "BTC")]
2598    #[case("zeur", "EUR")]
2599    #[case("usd", "USD")]
2600    #[case("xtz", "XTZ")]
2601    #[case("flr", "FLR")]
2602    fn test_normalize_asset_key_is_case_tolerant(#[case] input: &str, #[case] expected: &str) {
2603        assert_eq!(normalize_asset_key(input), expected);
2604    }
2605
2606    #[rstest]
2607    #[case("XBT/EUR", "BTC/EUR")]
2608    #[case("XBT/USD", "BTC/USD")]
2609    #[case("XBT/USDT", "BTC/USDT")]
2610    #[case("ETH/USD", "ETH/USD")]
2611    #[case("ETH/XBT", "ETH/BTC")]
2612    #[case("SOL/XBT", "SOL/BTC")]
2613    #[case("SOL/USD", "SOL/USD")]
2614    #[case("BTC/USD", "BTC/USD")]
2615    #[case("ETH/BTC", "ETH/BTC")]
2616    #[case("XDG/USD", "DOGE/USD")]
2617    #[case("XDG/EUR", "DOGE/EUR")]
2618    #[case("XDG/BTC", "DOGE/BTC")]
2619    #[case("XDG/XBT", "DOGE/BTC")]
2620    fn test_normalize_spot_symbol(#[case] input: &str, #[case] expected: &str) {
2621        assert_eq!(normalize_spot_symbol(input), expected);
2622    }
2623
2624    #[rstest]
2625    #[case("A", "A")] // 1 char, minimum
2626    #[case("O2026022700232", "O2026022700232")] // 14 chars, typical short
2627    #[case("ABCDEFGHIJKLMNOPQR", "ABCDEFGHIJKLMNOPQR")] // 18 chars, at limit
2628    fn test_truncate_cl_ord_id_short_passthrough(#[case] input: &str, #[case] expected: &str) {
2629        let id = ClientOrderId::new(input);
2630        assert_eq!(truncate_cl_ord_id(&id), expected);
2631    }
2632
2633    #[rstest]
2634    #[case("6d47a5f0-6fd4-4b84-b56e-c23f0f689c20")] // lowercase hex
2635    #[case("6D47A5F0-6FD4-4B84-B56E-C23F0F689C20")] // uppercase hex
2636    #[case("00000000-0000-0000-0000-000000000000")] // nil UUID
2637    #[case("ffffffff-ffff-ffff-ffff-ffffffffffff")] // max UUID
2638    fn test_truncate_cl_ord_id_uuid_hyphenated_passthrough(#[case] input: &str) {
2639        let id = ClientOrderId::new(input);
2640        assert_eq!(truncate_cl_ord_id(&id), input);
2641    }
2642
2643    #[rstest]
2644    #[case("6d47a5f06fd44b84b56ec23f0f689c20")] // lowercase
2645    #[case("6D47A5F06FD44B84B56EC23F0F689C20")] // uppercase
2646    #[case("00000000000000000000000000000000")] // all zeros
2647    #[case("aAbBcCdDeEfF00112233445566778899")] // mixed case
2648    fn test_truncate_cl_ord_id_uuid_compact_passthrough(#[case] input: &str) {
2649        let id = ClientOrderId::new(input);
2650        assert_eq!(truncate_cl_ord_id(&id), input);
2651    }
2652
2653    #[rstest]
2654    #[case("O2026022700232100400", "O26022700232100400")] // 20 chars → O + last 17
2655    #[case("O202602270023210040011", "O02270023210040011")] // 22 chars, typical sequential
2656    #[case("O20260227002321004001100", "O27002321004001100")] // 24 chars
2657    fn test_truncate_cl_ord_id_sequential_truncated(#[case] input: &str, #[case] expected: &str) {
2658        let id = ClientOrderId::new(input);
2659        let result = truncate_cl_ord_id(&id);
2660        assert_eq!(result, expected);
2661        assert_eq!(result.len(), 18);
2662        assert!(result.starts_with('O'));
2663    }
2664
2665    #[rstest]
2666    fn test_truncate_cl_ord_id_32_chars_non_hex_truncated() {
2667        let input = "0123456789abcdef0123456789abcdeg";
2668        let id = ClientOrderId::new(input);
2669        let result = truncate_cl_ord_id(&id);
2670        assert_eq!(result.len(), 18);
2671        assert!(result.starts_with('O'));
2672        assert_eq!(result, "Of0123456789abcdeg");
2673    }
2674
2675    #[rstest]
2676    fn test_truncate_cl_ord_id_36_chars_wrong_hyphens_truncated() {
2677        let input = "6d47a5f0-6fd4-4b84-b56ec23f0f689c200";
2678        let id = ClientOrderId::new(input);
2679        let result = truncate_cl_ord_id(&id);
2680        assert_eq!(result.len(), 18);
2681        assert!(result.starts_with('O'));
2682    }
2683
2684    /// The wire price scale is recorded only when `pair_decimals` is finer than the tick
2685    /// precision: an equal or coarser scale leaves the key absent.
2686    #[rstest]
2687    fn test_pair_info_records_pair_decimals_only_when_finer_than_the_tick() {
2688        let json = load_test_json("http_asset_pairs_tokenized.json");
2689        let response: KrakenResponse<AssetPairsResponse> = serde_json::from_str(&json).unwrap();
2690        let pairs = response.result.unwrap();
2691        let (pair_name, definition) = pairs.iter().next().unwrap();
2692
2693        let same = parse_tokenized_instrument(pair_name, definition, TS, TS).unwrap();
2694        assert!(
2695            same.info()
2696                .is_none_or(|info| !info.contains_key(KRAKEN_PAIR_DECIMALS_KEY)),
2697            "a scale equal to the tick precision is not recorded"
2698        );
2699
2700        let mut coarser = definition.clone();
2701        coarser.pair_decimals = same.price_precision() - 1;
2702        let tokenized = parse_tokenized_instrument(pair_name, &coarser, TS, TS).unwrap();
2703        let spot = parse_spot_instrument(pair_name, &coarser, TS, TS).unwrap();
2704        for instrument in [tokenized, spot] {
2705            assert!(
2706                instrument
2707                    .info()
2708                    .is_none_or(|info| !info.contains_key(KRAKEN_PAIR_DECIMALS_KEY)),
2709                "a scale coarser than the tick precision is not recorded"
2710            );
2711        }
2712
2713        let mut finer = definition.clone();
2714        finer.pair_decimals = same.price_precision() + 1;
2715        let tokenized = parse_tokenized_instrument(pair_name, &finer, TS, TS).unwrap();
2716        let spot = parse_spot_instrument(pair_name, &finer, TS, TS).unwrap();
2717        for instrument in [tokenized, spot] {
2718            let recorded = instrument
2719                .info()
2720                .and_then(|info| info.get(KRAKEN_PAIR_DECIMALS_KEY))
2721                .and_then(serde_json::Value::as_u64);
2722            assert_eq!(recorded, Some(u64::from(finer.pair_decimals)));
2723        }
2724    }
2725
2726    #[rstest]
2727    fn test_parse_tokenized_instrument() {
2728        let json = load_test_json("http_asset_pairs_tokenized.json");
2729        let response: KrakenResponse<AssetPairsResponse> = serde_json::from_str(&json).unwrap();
2730        let pairs = response.result.unwrap();
2731
2732        let (pair_name, definition) = pairs.iter().next().unwrap();
2733
2734        let instrument = parse_tokenized_instrument(pair_name, definition, TS, TS).unwrap();
2735
2736        match instrument {
2737            InstrumentAny::TokenizedAsset(ta) => {
2738                assert_eq!(ta.id.symbol.as_str(), "AAPLx/USD");
2739                assert_eq!(ta.id.venue.as_str(), "KRAKEN");
2740                assert_eq!(ta.raw_symbol.as_str(), "AAPLxUSD");
2741                assert_eq!(ta.asset_class, AssetClass::Equity);
2742                assert_eq!(ta.base_currency.code, "AAPLx");
2743                assert_eq!(ta.quote_currency.code, "USD");
2744                assert_eq!(ta.price_precision, 2);
2745                assert_eq!(ta.size_precision, 8);
2746                assert_eq!(ta.price_increment.as_decimal(), dec!(0.01));
2747                assert_eq!(ta.size_increment.as_decimal(), dec!(0.00000001));
2748                assert!(ta.min_quantity.is_some());
2749            }
2750            _ => panic!("Expected TokenizedAsset, received {instrument:?}"),
2751        }
2752
2753        // A key spelled differently from the altname, at a wire scale finer than the tick: the
2754        // info carries the scale and nothing else.
2755        let mut finer = definition.clone();
2756        finer.pair_decimals = 3;
2757        let instrument = parse_tokenized_instrument("XAAPLxZUSD", &finer, TS, TS).unwrap();
2758        let info = instrument.info().expect("the wire scale is recorded");
2759        assert_eq!(
2760            info.get(KRAKEN_PAIR_DECIMALS_KEY)
2761                .and_then(serde_json::Value::as_u64),
2762            Some(3)
2763        );
2764        assert!(
2765            !info.contains_key(KRAKEN_ALTNAME_KEY),
2766            "a tokenized pair carries no altname alias"
2767        );
2768    }
2769
2770    #[rstest]
2771    fn test_parse_fill_report_tokenized_asset() {
2772        let json = load_test_json("http_trades_history.json");
2773        let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
2774        let result = wrapper.get("result").unwrap();
2775        let trades_map = result.get("trades").unwrap();
2776        let trades: IndexMap<String, SpotTrade> =
2777            serde_json::from_value(trades_map.clone()).unwrap();
2778
2779        let account_id = AccountId::new("KRAKEN-001");
2780        let instrument_id = InstrumentId::new(Symbol::new("AAPLx/USD"), *KRAKEN_VENUE);
2781        let instrument = InstrumentAny::TokenizedAsset(
2782            TokenizedAsset::builder()
2783                .instrument_id(instrument_id)
2784                .raw_symbol(Symbol::new("AAPLxUSD"))
2785                .asset_class(AssetClass::Equity)
2786                .base_currency(Currency::get_or_create_crypto("AAPLx"))
2787                .quote_currency(Currency::USD())
2788                .price_precision(2)
2789                .size_precision(8)
2790                .price_increment(Price::from("0.01"))
2791                .size_increment(Quantity::from("0.00000001"))
2792                .ts_event(TS)
2793                .ts_init(TS)
2794                .build()
2795                .unwrap(),
2796        );
2797
2798        let (trade_id, trade) = trades.iter().next().unwrap();
2799
2800        let report = parse_fill_report(trade_id, trade, &instrument, account_id, TS).unwrap();
2801
2802        assert_eq!(report.account_id, account_id);
2803        assert_eq!(report.instrument_id, instrument_id);
2804        assert_eq!(report.trade_id.to_string(), *trade_id);
2805        assert_eq!(report.last_qty, Quantity::from("0.50000000"));
2806        assert_eq!(report.last_px, Price::from("29500.50"));
2807        assert_eq!(report.commission.currency, Currency::USD());
2808    }
2809
2810    #[rstest]
2811    fn test_truncate_cl_ord_id_19_chars_truncated() {
2812        let input = "O202602270023210040";
2813        assert_eq!(input.len(), 19);
2814        let id = ClientOrderId::new(input);
2815        let result = truncate_cl_ord_id(&id);
2816        assert_eq!(result.len(), 18);
2817        assert_eq!(result, "O02602270023210040");
2818    }
2819
2820    #[rstest]
2821    fn test_truncate_cl_ord_id_preserves_tail() {
2822        let input = "O20260227002321004001100";
2823        let id = ClientOrderId::new(input);
2824        let result = truncate_cl_ord_id(&id);
2825        assert_eq!(&result[1..], &input[input.len() - 17..]);
2826    }
2827}