Skip to main content

nautilus_kraken/common/
parse.rs

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