Skip to main content

nautilus_kraken/common/
parse.rs

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