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, parsing::precision_from_str,
23    uuid::UUID4,
24};
25use nautilus_model::{
26    data::{Bar, BarType, TradeTick},
27    enums::{
28        AggressorSide, BarAggregation, ContingencyType, LiquiditySide, OrderStatus, OrderType,
29        PositionSideSpecified, TimeInForce, TrailingOffsetType, TriggerType,
30    },
31    identifiers::{AccountId, InstrumentId, Symbol, TradeId, VenueOrderId},
32    instruments::{
33        Instrument, any::InstrumentAny, crypto_perpetual::CryptoPerpetual,
34        currency_pair::CurrencyPair,
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, KrakenInstrumentType, KrakenPositionSide, KrakenSpotTrigger,
47            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/// Normalizes a Kraken spot symbol to use BTC instead of XBT.
84///
85/// Kraken's REST API returns `XBT` for Bitcoin (following ISO 4217 conventions), but their
86/// WebSocket v2 API uses the more common `BTC` format. This function normalizes symbols
87/// so that instruments and subscriptions use consistent, industry-standard symbols.
88/// Handles XBT in both base position (XBT/USD -> BTC/USD) and quote position (ETH/XBT -> ETH/BTC).
89#[inline]
90pub fn normalize_spot_symbol(symbol: &str) -> String {
91    let normalized = if symbol.starts_with("XBT/") {
92        symbol.replacen("XBT/", "BTC/", 1)
93    } else {
94        symbol.to_string()
95    };
96
97    if normalized.ends_with("/XBT") {
98        normalized.replacen("/XBT", "/BTC", 1)
99    } else {
100        normalized
101    }
102}
103
104/// Parse an optional decimal string.
105pub fn parse_decimal_opt(value: Option<&str>) -> anyhow::Result<Option<Decimal>> {
106    match value {
107        Some(s) if !s.is_empty() && s != "0" => Ok(Some(parse_decimal(s)?)),
108        _ => Ok(None),
109    }
110}
111
112/// Parse Kraken spot trigger to Nautilus TriggerType.
113fn parse_trigger_type(
114    order_type: OrderType,
115    trigger: Option<KrakenSpotTrigger>,
116) -> Option<TriggerType> {
117    let is_conditional = matches!(
118        order_type,
119        OrderType::StopMarket
120            | OrderType::StopLimit
121            | OrderType::MarketIfTouched
122            | OrderType::LimitIfTouched
123    );
124
125    if !is_conditional {
126        return None;
127    }
128
129    match trigger {
130        Some(KrakenSpotTrigger::Last) => Some(TriggerType::LastPrice),
131        Some(KrakenSpotTrigger::Index) => Some(TriggerType::IndexPrice),
132        None => Some(TriggerType::Default),
133    }
134}
135
136/// Parse Kraken futures trigger signal to Nautilus TriggerType.
137fn parse_futures_trigger_type(
138    order_type: OrderType,
139    trigger_signal: Option<KrakenTriggerSignal>,
140) -> Option<TriggerType> {
141    let is_conditional = matches!(
142        order_type,
143        OrderType::StopMarket
144            | OrderType::StopLimit
145            | OrderType::MarketIfTouched
146            | OrderType::LimitIfTouched
147    );
148
149    if !is_conditional {
150        return None;
151    }
152
153    match trigger_signal {
154        Some(KrakenTriggerSignal::Last) => Some(TriggerType::LastPrice),
155        Some(KrakenTriggerSignal::Mark) => Some(TriggerType::MarkPrice),
156        Some(KrakenTriggerSignal::Index) => Some(TriggerType::IndexPrice),
157        None => Some(TriggerType::Default),
158    }
159}
160
161/// Parses a Kraken asset pair definition into a Nautilus currency pair instrument.
162///
163/// # Errors
164///
165/// Returns an error if:
166/// - Tick size, order minimum, or cost minimum cannot be parsed.
167/// - Price or quantity precision is invalid.
168/// - Currency codes are invalid.
169pub fn parse_spot_instrument(
170    pair_name: &str,
171    definition: &AssetPairInfo,
172    ts_event: UnixNanos,
173    ts_init: UnixNanos,
174) -> anyhow::Result<InstrumentAny> {
175    let symbol_str = definition.wsname.as_ref().unwrap_or(&definition.altname);
176    let normalized_symbol = normalize_spot_symbol(symbol_str);
177    let instrument_id = InstrumentId::new(Symbol::new(&normalized_symbol), *KRAKEN_VENUE);
178    let raw_symbol = Symbol::new(pair_name);
179
180    let base_currency = get_currency(definition.base.as_str());
181    let quote_currency = get_currency(definition.quote.as_str());
182
183    let price_increment = parse_price(
184        definition
185            .tick_size
186            .as_ref()
187            .context("tick_size is required")?,
188        "tick_size",
189    )?;
190
191    // lot_decimals specifies the decimal precision for the lot size
192    let size_precision = definition.lot_decimals;
193    let size_increment = Quantity::new(10.0_f64.powi(-(size_precision as i32)), size_precision);
194
195    let min_quantity = definition
196        .ordermin
197        .as_ref()
198        .map(|s| parse_quantity(s, "ordermin"))
199        .transpose()?;
200
201    // Use base tier fees, convert from percentage
202    let taker_fee = definition
203        .fees
204        .first()
205        .map(|(_, fee)| Decimal::try_from(*fee))
206        .transpose()
207        .context("Failed to parse taker fee")?
208        .map(|f| f / dec!(100));
209
210    let maker_fee = definition
211        .fees_maker
212        .first()
213        .map(|(_, fee)| Decimal::try_from(*fee))
214        .transpose()
215        .context("Failed to parse maker fee")?
216        .map(|f| f / dec!(100));
217
218    let instrument = CurrencyPair::new(
219        instrument_id,
220        raw_symbol,
221        base_currency,
222        quote_currency,
223        price_increment.precision,
224        size_increment.precision,
225        price_increment,
226        size_increment,
227        None,
228        None,
229        None,
230        min_quantity,
231        None,
232        None,
233        None,
234        None,
235        None,
236        None,
237        maker_fee,
238        taker_fee,
239        ts_event,
240        ts_init,
241    );
242
243    Ok(InstrumentAny::CurrencyPair(instrument))
244}
245
246/// Parses a Kraken futures instrument definition into a Nautilus crypto perpetual instrument.
247///
248/// # Errors
249///
250/// Returns an error if:
251/// - Tick size cannot be parsed as a valid price.
252/// - Contract size cannot be parsed as a valid quantity.
253/// - Currency codes are invalid.
254pub fn parse_futures_instrument(
255    instrument: &FuturesInstrument,
256    ts_event: UnixNanos,
257    ts_init: UnixNanos,
258) -> anyhow::Result<InstrumentAny> {
259    let instrument_id = InstrumentId::new(Symbol::new(&instrument.symbol), *KRAKEN_VENUE);
260    let raw_symbol = Symbol::new(&instrument.symbol);
261
262    let base_currency = get_currency(&instrument.base);
263    let quote_currency = get_currency(&instrument.quote);
264
265    let is_inverse = instrument.instrument_type == KrakenInstrumentType::FuturesInverse;
266    let settlement_currency = if is_inverse {
267        base_currency
268    } else {
269        quote_currency
270    };
271
272    // Derive precision from tick_size string representation to handle non-power-of-10
273    // tick sizes correctly (e.g., 0.25, 2.5)
274    let tick_size = instrument.tick_size;
275    let price_precision = precision_from_str(&tick_size.to_string());
276    if price_precision > FIXED_PRECISION {
277        anyhow::bail!(
278            "Cannot parse instrument '{}': tick_size {tick_size} requires precision {price_precision} \
279             which exceeds FIXED_PRECISION ({FIXED_PRECISION})",
280            instrument.symbol
281        );
282    }
283    let price_increment = Price::new(tick_size, price_precision);
284
285    // Use contract_value_trade_precision for the tradeable size increment
286    // Positive values (e.g., 3) mean fractional sizes (0.001)
287    // Negative values (e.g., -3) mean multiples of powers of 10 (1000) - used for meme coins
288    // Zero means whole number increments (1)
289    let (_size_precision, size_increment) = if instrument.contract_value_trade_precision >= 0 {
290        let precision = instrument.contract_value_trade_precision as u8;
291        let increment = Quantity::new(10.0_f64.powi(-(precision as i32)), precision);
292        (precision, increment)
293    } else {
294        // Negative precision: increment is 10^abs(precision), e.g., -3 → 1000
295        let increment_value = 10.0_f64.powi(-instrument.contract_value_trade_precision);
296        (0, Quantity::new(increment_value, 0))
297    };
298
299    let multiplier_precision = if instrument.contract_size.fract() == 0.0 {
300        0
301    } else {
302        instrument
303            .contract_size
304            .to_string()
305            .split('.')
306            .nth(1)
307            .map_or(0, |s| s.len() as u8)
308    };
309    let multiplier = Some(Quantity::new(
310        instrument.contract_size,
311        multiplier_precision,
312    ));
313
314    // Use first margin level if available
315    let (margin_init, margin_maint) = instrument
316        .margin_levels
317        .first()
318        .and_then(|level| {
319            let init = Decimal::try_from(level.initial_margin).ok()?;
320            let maint = Decimal::try_from(level.maintenance_margin).ok()?;
321            Some((Some(init), Some(maint)))
322        })
323        .unwrap_or((None, None));
324
325    let instrument = CryptoPerpetual::new(
326        instrument_id,
327        raw_symbol,
328        base_currency,
329        quote_currency,
330        settlement_currency,
331        is_inverse,
332        price_increment.precision,
333        size_increment.precision,
334        price_increment,
335        size_increment,
336        multiplier,
337        None, // lot_size
338        None, // max_quantity
339        None, // min_quantity
340        None, // max_notional
341        None, // min_notional
342        None, // max_price
343        None, // min_price
344        margin_init,
345        margin_maint,
346        None, // maker_fee
347        None, // taker_fee
348        ts_event,
349        ts_init,
350    );
351
352    Ok(InstrumentAny::CryptoPerpetual(instrument))
353}
354
355fn parse_price(value: &str, field: &str) -> anyhow::Result<Price> {
356    Price::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
357}
358
359fn parse_quantity(value: &str, field: &str) -> anyhow::Result<Quantity> {
360    Quantity::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
361}
362
363/// Returns a currency from the internal map or creates a new crypto currency.
364///
365/// Uses [`Currency::get_or_create_crypto`] to handle unknown currency codes,
366/// which automatically registers newly listed Kraken assets.
367pub fn get_currency(code: &str) -> Currency {
368    Currency::get_or_create_crypto(code)
369}
370
371/// Parses a Kraken trade array into a Nautilus trade tick.
372///
373/// The Kraken API returns trades as arrays: [price, volume, time, side, type, misc, trade_id]
374///
375/// # Errors
376///
377/// Returns an error if:
378/// - Price or volume cannot be parsed.
379/// - Timestamp is invalid.
380/// - Trade ID is invalid.
381pub fn parse_trade_tick_from_array(
382    trade_array: &[serde_json::Value],
383    instrument: &InstrumentAny,
384    ts_init: UnixNanos,
385) -> anyhow::Result<TradeTick> {
386    let price_str = trade_array
387        .first()
388        .and_then(|v| v.as_str())
389        .context("Missing or invalid price")?;
390    let price = parse_price_with_precision(price_str, instrument.price_precision(), "trade.price")?;
391
392    let size_str = trade_array
393        .get(1)
394        .and_then(|v| v.as_str())
395        .context("Missing or invalid volume")?;
396    let size = parse_quantity_with_precision(size_str, instrument.size_precision(), "trade.size")?;
397
398    let time = trade_array
399        .get(2)
400        .and_then(|v| v.as_f64())
401        .context("Missing or invalid timestamp")?;
402    let ts_event = parse_millis_timestamp(time, "trade.time")?;
403
404    let side_str = trade_array
405        .get(3)
406        .and_then(|v| v.as_str())
407        .context("Missing or invalid side")?;
408    let aggressor = match side_str {
409        "b" => AggressorSide::Buyer,
410        "s" => AggressorSide::Seller,
411        _ => AggressorSide::NoAggressor,
412    };
413
414    let trade_id_value = trade_array.get(6).context("Missing trade_id")?;
415    let trade_id = if let Some(id) = trade_id_value.as_i64() {
416        TradeId::new_checked(id.to_string())?
417    } else if let Some(id_str) = trade_id_value.as_str() {
418        TradeId::new_checked(id_str)?
419    } else {
420        anyhow::bail!("Invalid trade_id format");
421    };
422
423    TradeTick::new_checked(
424        instrument.id(),
425        price,
426        size,
427        aggressor,
428        trade_id,
429        ts_event,
430        ts_init,
431    )
432    .context("Failed to construct TradeTick from Kraken trade")
433}
434
435/// Parses a Kraken Futures public execution into a Nautilus trade tick.
436///
437/// # Errors
438///
439/// Returns an error if:
440/// - Price or quantity cannot be parsed.
441/// - Trade ID is invalid.
442pub fn parse_futures_public_execution(
443    execution: &FuturesPublicExecution,
444    instrument: &InstrumentAny,
445    ts_init: UnixNanos,
446) -> anyhow::Result<TradeTick> {
447    let price =
448        parse_price_with_precision(&execution.price, instrument.price_precision(), "price")?;
449    let size = parse_quantity_with_precision(
450        &execution.quantity,
451        instrument.size_precision(),
452        "quantity",
453    )?;
454
455    // Timestamp is in milliseconds
456    let ts_event = UnixNanos::from((execution.timestamp as u64) * 1_000_000);
457
458    // Aggressor side is determined by the taker's direction
459    let aggressor = match execution.taker_order.direction.to_lowercase().as_str() {
460        "buy" => AggressorSide::Buyer,
461        "sell" => AggressorSide::Seller,
462        _ => AggressorSide::NoAggressor,
463    };
464
465    let trade_id = TradeId::new_checked(&execution.uid)?;
466
467    TradeTick::new_checked(
468        instrument.id(),
469        price,
470        size,
471        aggressor,
472        trade_id,
473        ts_event,
474        ts_init,
475    )
476    .context("Failed to construct TradeTick from Kraken futures execution")
477}
478
479/// Parses a Kraken OHLC entry into a Nautilus bar.
480///
481/// # Errors
482///
483/// Returns an error if:
484/// - OHLC values cannot be parsed.
485/// - Timestamp is invalid.
486pub fn parse_bar(
487    ohlc: &OhlcData,
488    instrument: &InstrumentAny,
489    bar_type: BarType,
490    ts_init: UnixNanos,
491) -> anyhow::Result<Bar> {
492    let price_precision = instrument.price_precision();
493    let size_precision = instrument.size_precision();
494
495    let open = parse_price_with_precision(&ohlc.open, price_precision, "ohlc.open")?;
496    let high = parse_price_with_precision(&ohlc.high, price_precision, "ohlc.high")?;
497    let low = parse_price_with_precision(&ohlc.low, price_precision, "ohlc.low")?;
498    let close = parse_price_with_precision(&ohlc.close, price_precision, "ohlc.close")?;
499    let volume = parse_quantity_with_precision(&ohlc.volume, size_precision, "ohlc.volume")?;
500
501    let ts_event = UnixNanos::from((ohlc.time as u64) * 1_000_000_000);
502
503    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
504        .context("Failed to construct Bar from Kraken OHLC")
505}
506
507fn parse_price_with_precision(value: &str, precision: u8, field: &str) -> anyhow::Result<Price> {
508    let parsed = value
509        .parse::<f64>()
510        .with_context(|| format!("Failed to parse {field}='{value}' as f64"))?;
511    Price::new_checked(parsed, precision).with_context(|| {
512        format!("Failed to construct Price for {field} with precision {precision}")
513    })
514}
515
516fn parse_quantity_with_precision(
517    value: &str,
518    precision: u8,
519    field: &str,
520) -> anyhow::Result<Quantity> {
521    let parsed = value
522        .parse::<f64>()
523        .with_context(|| format!("Failed to parse {field}='{value}' as f64"))?;
524    Quantity::new_checked(parsed, precision).with_context(|| {
525        format!("Failed to construct Quantity for {field} with precision {precision}")
526    })
527}
528
529pub fn parse_millis_timestamp(value: f64, field: &str) -> anyhow::Result<UnixNanos> {
530    let millis = (value * 1000.0) as u64;
531    let nanos = millis
532        .checked_mul(NANOSECONDS_IN_MILLISECOND)
533        .with_context(|| format!("{field} timestamp overflowed when converting to nanoseconds"))?;
534    Ok(UnixNanos::from(nanos))
535}
536
537/// Parses a Kraken spot order into a Nautilus OrderStatusReport.
538///
539/// # Errors
540///
541/// Returns an error if:
542/// - Order ID, quantities, or prices cannot be parsed.
543/// - Order status mapping fails.
544pub fn parse_order_status_report(
545    order_id: &str,
546    order: &SpotOrder,
547    instrument: &InstrumentAny,
548    account_id: AccountId,
549    ts_init: UnixNanos,
550) -> anyhow::Result<OrderStatusReport> {
551    let instrument_id = instrument.id();
552    let venue_order_id = VenueOrderId::new(order_id);
553
554    let order_side = order.descr.order_side.into();
555    let order_type = order.descr.ordertype.into();
556    let order_status = order.status.into();
557
558    // Kraken returns expiretm=0 for GTC orders, so check for actual expiration value
559    let has_expiration = order.expiretm.is_some_and(|t| t > 0.0);
560    let time_in_force = if has_expiration {
561        TimeInForce::Gtd
562    } else if order.oflags.contains("ioc") {
563        TimeInForce::Ioc
564    } else {
565        TimeInForce::Gtc
566    };
567
568    let quantity =
569        parse_quantity_with_precision(&order.vol, instrument.size_precision(), "order.vol")?;
570
571    let filled_qty = parse_quantity_with_precision(
572        &order.vol_exec,
573        instrument.size_precision(),
574        "order.vol_exec",
575    )?;
576
577    let ts_accepted = parse_millis_timestamp(order.opentm, "order.opentm")?;
578
579    let ts_last = order
580        .closetm
581        .map(|t| parse_millis_timestamp(t, "order.closetm"))
582        .transpose()?
583        .unwrap_or(ts_accepted);
584
585    let price = if !order.price.is_empty() && order.price != "0" {
586        Some(parse_price_with_precision(
587            &order.price,
588            instrument.price_precision(),
589            "order.price",
590        )?)
591    } else {
592        None
593    };
594
595    let trigger_price = order
596        .stopprice
597        .as_ref()
598        .and_then(|p| {
599            if !p.is_empty() && p != "0" {
600                Some(parse_price_with_precision(
601                    p,
602                    instrument.price_precision(),
603                    "order.stopprice",
604                ))
605            } else {
606                None
607            }
608        })
609        .transpose()?;
610
611    let expire_time = if has_expiration {
612        order
613            .expiretm
614            .map(|t| parse_millis_timestamp(t, "order.expiretm"))
615            .transpose()?
616    } else {
617        None
618    };
619
620    let trigger_type = parse_trigger_type(order_type, order.trigger);
621
622    Ok(OrderStatusReport {
623        account_id,
624        instrument_id,
625        client_order_id: None,
626        venue_order_id,
627        order_side,
628        order_type,
629        time_in_force,
630        order_status,
631        quantity,
632        filled_qty,
633        report_id: UUID4::new(),
634        ts_accepted,
635        ts_last,
636        ts_init,
637        order_list_id: None,
638        venue_position_id: None,
639        linked_order_ids: None,
640        parent_order_id: None,
641        contingency_type: ContingencyType::NoContingency,
642        expire_time,
643        price,
644        trigger_price,
645        trigger_type,
646        limit_offset: None,
647        trailing_offset: None,
648        trailing_offset_type: TrailingOffsetType::NoTrailingOffset,
649        display_qty: None,
650        avg_px: compute_avg_px(order),
651        post_only: order.oflags.contains("post"),
652        reduce_only: false,
653        cancel_reason: order.reason.clone(),
654        ts_triggered: None,
655    })
656}
657
658/// Computes the average price for a Kraken spot order.
659///
660/// Prefers the direct `avg_price` field if available, otherwise calculates from `cost / vol_exec`.
661fn compute_avg_px(order: &SpotOrder) -> Option<Decimal> {
662    if let Some(ref avg) = order.avg_price
663        && let Ok(v) = parse_decimal(avg)
664        && v > dec!(0)
665    {
666        return Some(v);
667    }
668
669    let cost = parse_decimal(&order.cost);
670    let vol_exec = parse_decimal(&order.vol_exec);
671    match (&cost, &vol_exec) {
672        (Ok(c), Ok(v)) if *v > dec!(0) => Some(*c / *v),
673        _ => {
674            if let Ok(v) = &vol_exec
675                && *v > dec!(0)
676            {
677                log::warn!("Cannot compute avg_px: cost={cost:?}, vol_exec={vol_exec:?}");
678            }
679            None
680        }
681    }
682}
683
684/// Parses a Kraken spot trade into a Nautilus FillReport.
685///
686/// # Errors
687///
688/// Returns an error if:
689/// - Trade ID, quantities, or prices cannot be parsed.
690pub fn parse_fill_report(
691    trade_id: &str,
692    trade: &SpotTrade,
693    instrument: &InstrumentAny,
694    account_id: AccountId,
695    ts_init: UnixNanos,
696) -> anyhow::Result<FillReport> {
697    let instrument_id = instrument.id();
698    let venue_order_id = VenueOrderId::new(&trade.ordertxid);
699    let trade_id_obj = TradeId::new(trade_id);
700
701    let order_side = trade.trade_type.into();
702
703    let last_qty =
704        parse_quantity_with_precision(&trade.vol, instrument.size_precision(), "trade.vol")?;
705
706    let last_px =
707        parse_price_with_precision(&trade.price, instrument.price_precision(), "trade.price")?;
708
709    let fee_decimal = parse_decimal(&trade.fee)?;
710    let quote_currency = match instrument {
711        InstrumentAny::CurrencyPair(pair) => pair.quote_currency,
712        InstrumentAny::CryptoPerpetual(perp) => perp.quote_currency,
713        _ => anyhow::bail!("Unsupported instrument type for fill report"),
714    };
715
716    let fee_f64 = fee_decimal
717        .try_into()
718        .context("Failed to convert fee to f64")?;
719    let commission = Money::new(fee_f64, quote_currency);
720
721    let liquidity_side = match trade.maker {
722        Some(true) => LiquiditySide::Maker,
723        Some(false) => LiquiditySide::Taker,
724        None => LiquiditySide::NoLiquiditySide,
725    };
726
727    let ts_event = parse_millis_timestamp(trade.time, "trade.time")?;
728
729    Ok(FillReport {
730        account_id,
731        instrument_id,
732        venue_order_id,
733        trade_id: trade_id_obj,
734        order_side,
735        last_qty,
736        last_px,
737        commission,
738        liquidity_side,
739        report_id: UUID4::new(),
740        ts_event,
741        ts_init,
742        client_order_id: None,
743        venue_position_id: None,
744    })
745}
746
747/// Parses a Kraken futures open order into a Nautilus OrderStatusReport.
748///
749/// # Errors
750///
751/// Returns an error if order ID, quantities, or prices cannot be parsed.
752pub fn parse_futures_order_status_report(
753    order: &FuturesOpenOrder,
754    instrument: &InstrumentAny,
755    account_id: AccountId,
756    ts_init: UnixNanos,
757) -> anyhow::Result<OrderStatusReport> {
758    let instrument_id = instrument.id();
759    let venue_order_id = VenueOrderId::new(&order.order_id);
760
761    let order_side = order.side.into();
762    let order_type = order.order_type.into();
763    let order_status = order.status.into();
764
765    let quantity = Quantity::new(
766        order.unfilled_size + order.filled_size,
767        instrument.size_precision(),
768    );
769
770    let filled_qty = Quantity::new(order.filled_size, instrument.size_precision());
771
772    let ts_accepted = parse_rfc3339_timestamp(&order.received_time, "order.received_time")?;
773    let ts_last = parse_rfc3339_timestamp(&order.last_update_time, "order.last_update_time")?;
774
775    let price = order
776        .limit_price
777        .map(|p| Price::new(p, instrument.price_precision()));
778
779    let trigger_price = order
780        .stop_price
781        .map(|p| Price::new(p, instrument.price_precision()));
782
783    let trigger_type = parse_futures_trigger_type(order_type, order.trigger_signal);
784
785    Ok(OrderStatusReport {
786        account_id,
787        instrument_id,
788        client_order_id: order.cli_ord_id.as_ref().map(|s| s.as_str().into()),
789        venue_order_id,
790        order_side,
791        order_type,
792        time_in_force: TimeInForce::Gtc,
793        order_status,
794        quantity,
795        filled_qty,
796        report_id: UUID4::new(),
797        ts_accepted,
798        ts_last,
799        ts_init,
800        order_list_id: None,
801        venue_position_id: None,
802        linked_order_ids: None,
803        parent_order_id: None,
804        contingency_type: ContingencyType::NoContingency,
805        expire_time: None,
806        price,
807        trigger_price,
808        trigger_type,
809        limit_offset: None,
810        trailing_offset: None,
811        trailing_offset_type: TrailingOffsetType::NoTrailingOffset,
812        display_qty: None,
813        avg_px: None,
814        post_only: false,
815        reduce_only: order.reduce_only.unwrap_or(false),
816        cancel_reason: None,
817        ts_triggered: None,
818    })
819}
820
821/// Parses a Kraken futures order event (historical order) into a Nautilus OrderStatusReport.
822///
823/// # Errors
824///
825/// Returns an error if order ID, quantities, or prices cannot be parsed.
826pub fn parse_futures_order_event_status_report(
827    event: &FuturesOrderEvent,
828    instrument: &InstrumentAny,
829    account_id: AccountId,
830    ts_init: UnixNanos,
831) -> anyhow::Result<OrderStatusReport> {
832    let instrument_id = instrument.id();
833    let venue_order_id = VenueOrderId::new(&event.order_id);
834
835    let order_side = event.side.into();
836    let order_type = event.order_type.into();
837
838    // Infer status from filled quantity since historical events don't include explicit status
839    let order_status = if event.filled >= event.quantity {
840        OrderStatus::Filled
841    } else if event.filled > 0.0 {
842        OrderStatus::PartiallyFilled
843    } else {
844        OrderStatus::Canceled
845    };
846
847    let quantity = Quantity::new(event.quantity, instrument.size_precision());
848    let filled_qty = Quantity::new(event.filled, instrument.size_precision());
849
850    let ts_accepted = parse_rfc3339_timestamp(&event.timestamp, "event.timestamp")?;
851    let ts_last =
852        parse_rfc3339_timestamp(&event.last_update_timestamp, "event.last_update_timestamp")?;
853
854    let price = event
855        .limit_price
856        .map(|p| Price::new(p, instrument.price_precision()));
857
858    let trigger_price = event
859        .stop_price
860        .map(|p| Price::new(p, instrument.price_precision()));
861
862    // FuturesOrderEvent doesn't have trigger_signal, so we pass None
863    // This will default to TriggerType::Default for conditional orders
864    let trigger_type = parse_futures_trigger_type(order_type, None);
865
866    Ok(OrderStatusReport {
867        account_id,
868        instrument_id,
869        client_order_id: event.cli_ord_id.as_ref().map(|s| s.as_str().into()),
870        venue_order_id,
871        order_side,
872        order_type,
873        time_in_force: TimeInForce::Gtc,
874        order_status,
875        quantity,
876        filled_qty,
877        report_id: UUID4::new(),
878        ts_accepted,
879        ts_last,
880        ts_init,
881        order_list_id: None,
882        venue_position_id: None,
883        linked_order_ids: None,
884        parent_order_id: None,
885        contingency_type: ContingencyType::NoContingency,
886        expire_time: None,
887        price,
888        trigger_price,
889        trigger_type,
890        limit_offset: None,
891        trailing_offset: None,
892        trailing_offset_type: TrailingOffsetType::NoTrailingOffset,
893        display_qty: None,
894        avg_px: None,
895        post_only: false,
896        reduce_only: event.reduce_only,
897        cancel_reason: None,
898        ts_triggered: None,
899    })
900}
901
902/// Parses a Kraken futures fill into a Nautilus FillReport.
903///
904/// # Errors
905///
906/// Returns an error if fill ID, quantities, or prices cannot be parsed.
907pub fn parse_futures_fill_report(
908    fill: &FuturesFill,
909    instrument: &InstrumentAny,
910    account_id: AccountId,
911    ts_init: UnixNanos,
912) -> anyhow::Result<FillReport> {
913    let instrument_id = instrument.id();
914    let venue_order_id = VenueOrderId::new(&fill.order_id);
915    let trade_id = TradeId::new(&fill.fill_id);
916
917    let order_side = fill.side.into();
918
919    let last_qty = Quantity::new(fill.size, instrument.size_precision());
920    let last_px = Price::new(fill.price, instrument.price_precision());
921
922    let quote_currency = match instrument {
923        InstrumentAny::CryptoPerpetual(perp) => perp.quote_currency,
924        InstrumentAny::CryptoFuture(future) => future.quote_currency,
925        _ => anyhow::bail!("Unsupported instrument type for futures fill report"),
926    };
927
928    let fee_f64 = fill.fee_paid.unwrap_or(0.0);
929    let commission = Money::new(fee_f64, quote_currency);
930
931    let liquidity_side = match fill.fill_type {
932        KrakenFillType::Maker => LiquiditySide::Maker,
933        KrakenFillType::Taker => LiquiditySide::Taker,
934    };
935
936    let ts_event = parse_rfc3339_timestamp(&fill.fill_time, "fill.fill_time")?;
937
938    Ok(FillReport {
939        account_id,
940        instrument_id,
941        venue_order_id,
942        trade_id,
943        order_side,
944        last_qty,
945        last_px,
946        commission,
947        liquidity_side,
948        report_id: UUID4::new(),
949        ts_event,
950        ts_init,
951        client_order_id: fill.cli_ord_id.as_ref().map(|s| s.as_str().into()),
952        venue_position_id: None,
953    })
954}
955
956/// Parses a Kraken futures position into a Nautilus PositionStatusReport.
957///
958/// # Errors
959///
960/// Returns an error if position quantities or prices cannot be parsed.
961pub fn parse_futures_position_status_report(
962    position: &FuturesPosition,
963    instrument: &InstrumentAny,
964    account_id: AccountId,
965    ts_init: UnixNanos,
966) -> anyhow::Result<PositionStatusReport> {
967    let instrument_id = instrument.id();
968
969    let position_side = match position.side {
970        KrakenPositionSide::Long => PositionSideSpecified::Long,
971        KrakenPositionSide::Short => PositionSideSpecified::Short,
972    };
973
974    let quantity = Quantity::new(position.size, instrument.size_precision());
975    let size_decimal = Decimal::from_str(&position.size.to_string()).unwrap_or(dec!(0));
976    let signed_decimal_qty = match position_side {
977        PositionSideSpecified::Long => size_decimal,
978        PositionSideSpecified::Short => -size_decimal,
979        PositionSideSpecified::Flat => dec!(0),
980    };
981
982    let avg_px_open = Decimal::from_str(&position.price.to_string()).ok();
983
984    Ok(PositionStatusReport {
985        account_id,
986        instrument_id,
987        position_side,
988        quantity,
989        signed_decimal_qty,
990        report_id: UUID4::new(),
991        ts_last: ts_init,
992        ts_init,
993        venue_position_id: None,
994        avg_px_open,
995    })
996}
997
998/// Converts a Nautilus BarType to Kraken Spot API interval (in minutes).
999///
1000/// # Errors
1001///
1002/// Returns an error if:
1003/// - Bar aggregation type is not supported (only Minute, Hour, Day are valid).
1004/// - Bar step is not supported for the aggregation type.
1005pub fn bar_type_to_spot_interval(bar_type: BarType) -> anyhow::Result<u32> {
1006    let step = bar_type.spec().step.get() as u32;
1007    let base_interval = match bar_type.spec().aggregation {
1008        BarAggregation::Minute => 1,
1009        BarAggregation::Hour => 60,
1010        BarAggregation::Day => 1440,
1011        other => {
1012            anyhow::bail!("Unsupported bar aggregation for Kraken Spot: {other:?}");
1013        }
1014    };
1015    Ok(base_interval * step)
1016}
1017
1018/// Converts a Nautilus BarType to Kraken Futures API resolution string.
1019///
1020/// Supported resolutions: 1m, 5m, 15m, 1h, 4h, 12h, 1d, 1w
1021///
1022/// # Errors
1023///
1024/// Returns an error if:
1025/// - Bar aggregation type is not supported.
1026/// - Bar step is not supported for the aggregation type.
1027pub fn bar_type_to_futures_resolution(bar_type: BarType) -> anyhow::Result<&'static str> {
1028    let step = bar_type.spec().step.get() as u32;
1029    match bar_type.spec().aggregation {
1030        BarAggregation::Minute => match step {
1031            1 => Ok("1m"),
1032            5 => Ok("5m"),
1033            15 => Ok("15m"),
1034            _ => anyhow::bail!("Unsupported minute step for Kraken Futures: {step}"),
1035        },
1036        BarAggregation::Hour => match step {
1037            1 => Ok("1h"),
1038            4 => Ok("4h"),
1039            12 => Ok("12h"),
1040            _ => anyhow::bail!("Unsupported hour step for Kraken Futures: {step}"),
1041        },
1042        BarAggregation::Day => {
1043            if step == 1 {
1044                Ok("1d")
1045            } else {
1046                anyhow::bail!("Unsupported day step for Kraken Futures: {step}")
1047            }
1048        }
1049        BarAggregation::Week => {
1050            if step == 1 {
1051                Ok("1w")
1052            } else {
1053                anyhow::bail!("Unsupported week step for Kraken Futures: {step}")
1054            }
1055        }
1056        other => {
1057            anyhow::bail!("Unsupported bar aggregation for Kraken Futures: {other:?}");
1058        }
1059    }
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064    use indexmap::IndexMap;
1065    use nautilus_model::{
1066        data::BarSpecification,
1067        enums::{AggregationSource, BarAggregation, OrderStatus, PriceType},
1068    };
1069    use rstest::rstest;
1070
1071    use super::*;
1072    use crate::http::models::AssetPairsResponse;
1073
1074    const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
1075
1076    fn load_test_json(filename: &str) -> String {
1077        let path = format!("test_data/{filename}");
1078        std::fs::read_to_string(&path)
1079            .unwrap_or_else(|e| panic!("Failed to load test data from {path}: {e}"))
1080    }
1081
1082    #[rstest]
1083    fn test_parse_decimal() {
1084        assert_eq!(parse_decimal("123.45").unwrap(), dec!(123.45));
1085        assert_eq!(parse_decimal("0").unwrap(), dec!(0));
1086        assert_eq!(parse_decimal("").unwrap(), dec!(0));
1087    }
1088
1089    #[rstest]
1090    fn test_parse_decimal_opt() {
1091        assert_eq!(
1092            parse_decimal_opt(Some("123.45")).unwrap(),
1093            Some(dec!(123.45))
1094        );
1095        assert_eq!(parse_decimal_opt(Some("0")).unwrap(), None);
1096        assert_eq!(parse_decimal_opt(Some("")).unwrap(), None);
1097        assert_eq!(parse_decimal_opt(None).unwrap(), None);
1098    }
1099
1100    #[rstest]
1101    fn test_parse_spot_instrument() {
1102        let json = load_test_json("http_asset_pairs.json");
1103        let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1104        let result = wrapper.get("result").unwrap();
1105        let pairs: AssetPairsResponse = serde_json::from_value(result.clone()).unwrap();
1106
1107        let (pair_name, definition) = pairs.iter().next().unwrap();
1108
1109        let instrument = parse_spot_instrument(pair_name, definition, TS, TS).unwrap();
1110
1111        match instrument {
1112            InstrumentAny::CurrencyPair(pair) => {
1113                assert_eq!(pair.id.venue.as_str(), "KRAKEN");
1114                assert_eq!(pair.base_currency.code.as_str(), "XXBT");
1115                assert_eq!(pair.quote_currency.code.as_str(), "USDT");
1116                assert!(pair.price_increment.as_f64() > 0.0);
1117                assert!(pair.size_increment.as_f64() > 0.0);
1118                assert!(pair.min_quantity.is_some());
1119                assert_eq!(pair.maker_fee, dec!(0.0025));
1120                assert_eq!(pair.taker_fee, dec!(0.004));
1121                assert_eq!(pair.margin_init, dec!(0));
1122                assert_eq!(pair.margin_maint, dec!(0));
1123            }
1124            _ => panic!("Expected CurrencyPair"),
1125        }
1126    }
1127
1128    #[rstest]
1129    fn test_parse_futures_instrument_inverse() {
1130        let json = load_test_json("http_futures_instruments.json");
1131        let response: crate::http::models::FuturesInstrumentsResponse =
1132            serde_json::from_str(&json).unwrap();
1133
1134        let fut_instrument = &response.instruments[0];
1135
1136        let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1137
1138        match instrument {
1139            InstrumentAny::CryptoPerpetual(perp) => {
1140                assert_eq!(perp.id.venue.as_str(), "KRAKEN");
1141                assert_eq!(perp.id.symbol.as_str(), "PI_XBTUSD");
1142                assert_eq!(perp.raw_symbol.as_str(), "PI_XBTUSD");
1143                assert_eq!(perp.base_currency.code.as_str(), "BTC");
1144                assert_eq!(perp.quote_currency.code.as_str(), "USD");
1145                assert_eq!(perp.settlement_currency.code.as_str(), "BTC");
1146                assert!(perp.is_inverse);
1147                assert_eq!(perp.price_increment.as_f64(), 0.5);
1148                assert_eq!(perp.size_increment.as_f64(), 1.0);
1149                assert_eq!(perp.size_precision(), 0);
1150                assert_eq!(perp.margin_init, dec!(0.02));
1151                assert_eq!(perp.margin_maint, dec!(0.01));
1152            }
1153            _ => panic!("Expected CryptoPerpetual"),
1154        }
1155    }
1156
1157    #[rstest]
1158    fn test_parse_futures_instrument_flexible() {
1159        let json = load_test_json("http_futures_instruments.json");
1160        let response: crate::http::models::FuturesInstrumentsResponse =
1161            serde_json::from_str(&json).unwrap();
1162
1163        let fut_instrument = &response.instruments[1];
1164
1165        let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1166
1167        match instrument {
1168            InstrumentAny::CryptoPerpetual(perp) => {
1169                assert_eq!(perp.id.venue.as_str(), "KRAKEN");
1170                assert_eq!(perp.id.symbol.as_str(), "PF_ETHUSD");
1171                assert_eq!(perp.raw_symbol.as_str(), "PF_ETHUSD");
1172                assert_eq!(perp.base_currency.code.as_str(), "ETH");
1173                assert_eq!(perp.quote_currency.code.as_str(), "USD");
1174                assert_eq!(perp.settlement_currency.code.as_str(), "USD");
1175                assert!(!perp.is_inverse);
1176                assert_eq!(perp.price_increment.as_f64(), 0.1);
1177                assert_eq!(perp.size_increment.as_f64(), 0.001);
1178                assert_eq!(perp.size_precision(), 3);
1179                assert_eq!(perp.margin_init, dec!(0.02));
1180                assert_eq!(perp.margin_maint, dec!(0.01));
1181            }
1182            _ => panic!("Expected CryptoPerpetual"),
1183        }
1184    }
1185
1186    // PF_PEPEUSD has tickSize: 1e-10 which requires precision 10
1187    // This test requires high-precision mode (FIXED_PRECISION=16) which is the default build
1188    #[rstest]
1189    fn test_parse_futures_instrument_negative_precision() {
1190        let json = load_test_json("http_futures_instruments.json");
1191        let response: crate::http::models::FuturesInstrumentsResponse =
1192            serde_json::from_str(&json).unwrap();
1193
1194        // PF_PEPEUSD has contractValueTradePrecision: -3 (trades in multiples of 1000)
1195        let fut_instrument = &response.instruments[2];
1196
1197        let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1198
1199        match instrument {
1200            InstrumentAny::CryptoPerpetual(perp) => {
1201                assert_eq!(perp.id.symbol.as_str(), "PF_PEPEUSD");
1202                assert_eq!(perp.base_currency.code.as_str(), "PEPE");
1203                assert!(!perp.is_inverse);
1204                assert_eq!(perp.size_increment.as_f64(), 1000.0);
1205                assert_eq!(perp.size_precision(), 0);
1206            }
1207            _ => panic!("Expected CryptoPerpetual"),
1208        }
1209    }
1210
1211    #[rstest]
1212    fn test_parse_trade_tick_from_array() {
1213        let json = load_test_json("http_trades.json");
1214        let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1215        let result = wrapper.get("result").unwrap();
1216        let trades_map = result.as_object().unwrap();
1217
1218        // Get first pair's trades
1219        let (_pair, trades_value) = trades_map.iter().find(|(k, _)| *k != "last").unwrap();
1220        let trades = trades_value.as_array().unwrap();
1221        let trade_array = trades[0].as_array().unwrap();
1222
1223        // Create a mock instrument for testing
1224        let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1225        let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1226            instrument_id,
1227            Symbol::new("XBTUSDT"),
1228            Currency::BTC(),
1229            Currency::USDT(),
1230            1, // price_precision
1231            8, // size_precision
1232            Price::from("0.1"),
1233            Quantity::from("0.00000001"),
1234            None,
1235            None,
1236            None,
1237            None,
1238            None,
1239            None,
1240            None,
1241            None,
1242            None,
1243            None,
1244            None,
1245            None,
1246            TS,
1247            TS,
1248        ));
1249
1250        let trade_tick = parse_trade_tick_from_array(trade_array, &instrument, TS).unwrap();
1251
1252        assert_eq!(trade_tick.instrument_id, instrument_id);
1253        assert!(trade_tick.price.as_f64() > 0.0);
1254        assert!(trade_tick.size.as_f64() > 0.0);
1255    }
1256
1257    #[rstest]
1258    fn test_parse_bar() {
1259        let json = load_test_json("http_ohlc.json");
1260        let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1261        let result = wrapper.get("result").unwrap();
1262        let ohlc_map = result.as_object().unwrap();
1263
1264        // Get first pair's OHLC data
1265        let (_pair, ohlc_value) = ohlc_map.iter().find(|(k, _)| *k != "last").unwrap();
1266        let ohlcs = ohlc_value.as_array().unwrap();
1267
1268        // Parse first OHLC array into OhlcData
1269        let ohlc_array = ohlcs[0].as_array().unwrap();
1270        let ohlc = OhlcData {
1271            time: ohlc_array[0].as_i64().unwrap(),
1272            open: ohlc_array[1].as_str().unwrap().to_string(),
1273            high: ohlc_array[2].as_str().unwrap().to_string(),
1274            low: ohlc_array[3].as_str().unwrap().to_string(),
1275            close: ohlc_array[4].as_str().unwrap().to_string(),
1276            vwap: ohlc_array[5].as_str().unwrap().to_string(),
1277            volume: ohlc_array[6].as_str().unwrap().to_string(),
1278            count: ohlc_array[7].as_i64().unwrap(),
1279        };
1280
1281        // Create a mock instrument
1282        let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1283        let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1284            instrument_id,
1285            Symbol::new("XBTUSDT"),
1286            Currency::BTC(),
1287            Currency::USDT(),
1288            1, // price_precision
1289            8, // size_precision
1290            Price::from("0.1"),
1291            Quantity::from("0.00000001"),
1292            None,
1293            None,
1294            None,
1295            None,
1296            None,
1297            None,
1298            None,
1299            None,
1300            None,
1301            None,
1302            None,
1303            None,
1304            TS,
1305            TS,
1306        ));
1307
1308        let bar_type = BarType::new(
1309            instrument_id,
1310            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
1311            AggregationSource::External,
1312        );
1313
1314        let bar = parse_bar(&ohlc, &instrument, bar_type, TS).unwrap();
1315
1316        assert_eq!(bar.bar_type, bar_type);
1317        assert!(bar.open.as_f64() > 0.0);
1318        assert!(bar.high.as_f64() > 0.0);
1319        assert!(bar.low.as_f64() > 0.0);
1320        assert!(bar.close.as_f64() > 0.0);
1321        assert!(bar.volume.as_f64() >= 0.0);
1322    }
1323
1324    #[rstest]
1325    fn test_parse_millis_timestamp() {
1326        let timestamp = 1762795433.9717445;
1327        let result = parse_millis_timestamp(timestamp, "test").unwrap();
1328        assert!(result.as_u64() > 0);
1329    }
1330
1331    #[rstest]
1332    #[case(1, BarAggregation::Minute, 1)]
1333    #[case(5, BarAggregation::Minute, 5)]
1334    #[case(15, BarAggregation::Minute, 15)]
1335    #[case(1, BarAggregation::Hour, 60)]
1336    #[case(4, BarAggregation::Hour, 240)]
1337    #[case(1, BarAggregation::Day, 1440)]
1338    fn test_bar_type_to_spot_interval(
1339        #[case] step: usize,
1340        #[case] aggregation: BarAggregation,
1341        #[case] expected: u32,
1342    ) {
1343        let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1344        let bar_type = BarType::new(
1345            instrument_id,
1346            BarSpecification::new(step, aggregation, PriceType::Last),
1347            AggregationSource::External,
1348        );
1349
1350        let result = bar_type_to_spot_interval(bar_type).unwrap();
1351        assert_eq!(result, expected);
1352    }
1353
1354    #[rstest]
1355    fn test_bar_type_to_spot_interval_unsupported() {
1356        let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1357        let bar_type = BarType::new(
1358            instrument_id,
1359            BarSpecification::new(1, BarAggregation::Second, PriceType::Last),
1360            AggregationSource::External,
1361        );
1362
1363        let result = bar_type_to_spot_interval(bar_type);
1364        assert!(result.is_err());
1365        assert!(result.unwrap_err().to_string().contains("Unsupported"));
1366    }
1367
1368    #[rstest]
1369    #[case(1, BarAggregation::Minute, "1m")]
1370    #[case(5, BarAggregation::Minute, "5m")]
1371    #[case(15, BarAggregation::Minute, "15m")]
1372    #[case(1, BarAggregation::Hour, "1h")]
1373    #[case(4, BarAggregation::Hour, "4h")]
1374    #[case(12, BarAggregation::Hour, "12h")]
1375    #[case(1, BarAggregation::Day, "1d")]
1376    #[case(1, BarAggregation::Week, "1w")]
1377    fn test_bar_type_to_futures_resolution(
1378        #[case] step: usize,
1379        #[case] aggregation: BarAggregation,
1380        #[case] expected: &str,
1381    ) {
1382        let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
1383        let bar_type = BarType::new(
1384            instrument_id,
1385            BarSpecification::new(step, aggregation, PriceType::Last),
1386            AggregationSource::External,
1387        );
1388
1389        let result = bar_type_to_futures_resolution(bar_type).unwrap();
1390        assert_eq!(result, expected);
1391    }
1392
1393    #[rstest]
1394    #[case(30, BarAggregation::Minute)] // Unsupported minute step
1395    #[case(2, BarAggregation::Hour)] // Unsupported hour step
1396    #[case(2, BarAggregation::Day)] // Unsupported day step
1397    #[case(1, BarAggregation::Second)] // Unsupported aggregation
1398    fn test_bar_type_to_futures_resolution_unsupported(
1399        #[case] step: usize,
1400        #[case] aggregation: BarAggregation,
1401    ) {
1402        let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
1403        let bar_type = BarType::new(
1404            instrument_id,
1405            BarSpecification::new(step, aggregation, PriceType::Last),
1406            AggregationSource::External,
1407        );
1408
1409        let result = bar_type_to_futures_resolution(bar_type);
1410        assert!(result.is_err());
1411        assert!(result.unwrap_err().to_string().contains("Unsupported"));
1412    }
1413
1414    #[rstest]
1415    fn test_parse_order_status_report() {
1416        let json = load_test_json("http_open_orders.json");
1417        let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1418        let result = wrapper.get("result").unwrap();
1419        let open_map = result.get("open").unwrap();
1420        let orders: IndexMap<String, SpotOrder> = serde_json::from_value(open_map.clone()).unwrap();
1421
1422        let account_id = AccountId::new("KRAKEN-001");
1423        let instrument_id = InstrumentId::new(Symbol::new("BTC/USDT"), *KRAKEN_VENUE);
1424        let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1425            instrument_id,
1426            Symbol::new("XBTUSDT"),
1427            Currency::BTC(),
1428            Currency::USDT(),
1429            2,
1430            8,
1431            Price::from("0.01"),
1432            Quantity::from("0.00000001"),
1433            None,
1434            None,
1435            None,
1436            None,
1437            None,
1438            None,
1439            None,
1440            None,
1441            None,
1442            None,
1443            None,
1444            None,
1445            TS,
1446            TS,
1447        ));
1448
1449        let (order_id, order) = orders.iter().next().unwrap();
1450
1451        let report =
1452            parse_order_status_report(order_id, order, &instrument, account_id, TS).unwrap();
1453
1454        assert_eq!(report.account_id, account_id);
1455        assert_eq!(report.instrument_id, instrument_id);
1456        assert_eq!(report.venue_order_id.as_str(), order_id);
1457        assert_eq!(report.order_status, OrderStatus::Accepted);
1458        assert!(report.quantity.as_f64() > 0.0);
1459    }
1460
1461    #[rstest]
1462    fn test_parse_fill_report() {
1463        let json = load_test_json("http_trades_history.json");
1464        let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1465        let result = wrapper.get("result").unwrap();
1466        let trades_map = result.get("trades").unwrap();
1467        let trades: IndexMap<String, SpotTrade> =
1468            serde_json::from_value(trades_map.clone()).unwrap();
1469
1470        let account_id = AccountId::new("KRAKEN-001");
1471        let instrument_id = InstrumentId::new(Symbol::new("BTC/USDT"), *KRAKEN_VENUE);
1472        let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1473            instrument_id,
1474            Symbol::new("XBTUSDT"),
1475            Currency::BTC(),
1476            Currency::USDT(),
1477            2,
1478            8,
1479            Price::from("0.01"),
1480            Quantity::from("0.00000001"),
1481            None,
1482            None,
1483            None,
1484            None,
1485            None,
1486            None,
1487            None,
1488            None,
1489            None,
1490            None,
1491            None,
1492            None,
1493            TS,
1494            TS,
1495        ));
1496
1497        let (trade_id, trade) = trades.iter().next().unwrap();
1498
1499        let report = parse_fill_report(trade_id, trade, &instrument, account_id, TS).unwrap();
1500
1501        assert_eq!(report.account_id, account_id);
1502        assert_eq!(report.instrument_id, instrument_id);
1503        assert_eq!(report.trade_id.to_string(), *trade_id);
1504        assert!(report.last_qty.as_f64() > 0.0);
1505        assert!(report.last_px.as_f64() > 0.0);
1506        assert!(report.commission.as_f64() > 0.0);
1507    }
1508
1509    #[rstest]
1510    #[case("XXBT", "XBT")]
1511    #[case("XETH", "ETH")]
1512    #[case("ZUSD", "USD")]
1513    #[case("ZEUR", "EUR")]
1514    #[case("BTC", "BTC")]
1515    #[case("ETH", "ETH")]
1516    #[case("USDT", "USDT")]
1517    #[case("SOL", "SOL")]
1518    fn test_normalize_currency_code(#[case] input: &str, #[case] expected: &str) {
1519        assert_eq!(normalize_currency_code(input), expected);
1520    }
1521
1522    #[rstest]
1523    #[case("XBT/EUR", "BTC/EUR")]
1524    #[case("XBT/USD", "BTC/USD")]
1525    #[case("XBT/USDT", "BTC/USDT")]
1526    #[case("ETH/USD", "ETH/USD")]
1527    #[case("ETH/XBT", "ETH/BTC")]
1528    #[case("SOL/XBT", "SOL/BTC")]
1529    #[case("SOL/USD", "SOL/USD")]
1530    #[case("BTC/USD", "BTC/USD")]
1531    #[case("ETH/BTC", "ETH/BTC")]
1532    fn test_normalize_spot_symbol(#[case] input: &str, #[case] expected: &str) {
1533        assert_eq!(normalize_spot_symbol(input), expected);
1534    }
1535}