1use std::{fmt::Display, str::FromStr};
19
20use anyhow::Context;
21use nautilus_core::{datetime::NANOSECONDS_IN_MILLISECOND, nanos::UnixNanos, uuid::UUID4};
22use nautilus_model::{
23 data::{Bar, BarType, TradeTick},
24 enums::{
25 AggressorSide, AssetClass, BarAggregation, LiquiditySide, OrderSide, OrderStatus,
26 OrderType, PositionSide, TimeInForce, TriggerType,
27 },
28 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
29 instruments::{
30 Instrument, any::InstrumentAny, crypto_perpetual::CryptoPerpetual,
31 currency_pair::CurrencyPair, tokenized_asset::TokenizedAsset,
32 },
33 reports::{FillReport, OrderStatusReport, PositionStatusReport},
34 types::{Currency, Money, Price, Quantity, fixed::FIXED_PRECISION},
35};
36use rust_decimal::Decimal;
37use rust_decimal_macros::dec;
38
39use crate::{
40 common::{
41 consts::KRAKEN_VENUE,
42 enums::{
43 KrakenFuturesOrderEventType, KrakenFuturesOrderLifecycleStatus, KrakenInstrumentType,
44 KrakenPositionSide, KrakenSpotTrigger, KrakenTriggerSignal,
45 },
46 },
47 http::models::{
48 AssetPairInfo, FuturesFill, FuturesInstrument, FuturesOpenOrder, FuturesOrderEvent,
49 FuturesOrderStatusDetails, FuturesPosition, FuturesPublicExecution, OhlcData, SpotOrder,
50 SpotTrade,
51 },
52};
53
54pub fn parse_decimal(value: &str) -> anyhow::Result<Decimal> {
56 if value.is_empty() || value == "0" {
57 return Ok(dec!(0));
58 }
59 value
60 .parse::<Decimal>()
61 .map_err(|e| anyhow::anyhow!("Failed to parse decimal '{value}': {e}"))
62}
63
64fn parse_rfc3339_timestamp(value: &str, field: &str) -> anyhow::Result<UnixNanos> {
65 value
66 .parse::<UnixNanos>()
67 .map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
68}
69
70#[inline]
75pub fn normalize_currency_code(code: &str) -> &str {
76 code.strip_prefix("X")
77 .or_else(|| code.strip_prefix("Z"))
78 .unwrap_or(code)
79}
80
81const KRAKEN_SYMBOL_RENAMES: &[(&str, &str)] = &[
86 ("XBT", "BTC"), ("XDG", "DOGE"), ];
89
90#[inline]
97pub fn normalize_spot_symbol(symbol: &str) -> String {
98 let Some((base, quote)) = symbol.split_once('/') else {
99 return symbol.to_string();
100 };
101 let base = KRAKEN_SYMBOL_RENAMES
102 .iter()
103 .find(|(old, _)| *old == base)
104 .map_or(base, |(_, new)| new);
105 let quote = KRAKEN_SYMBOL_RENAMES
106 .iter()
107 .find(|(old, _)| *old == quote)
108 .map_or(quote, |(_, new)| new);
109 format!("{base}/{quote}")
110}
111
112pub fn parse_decimal_opt(value: Option<&str>) -> anyhow::Result<Option<Decimal>> {
114 match value {
115 Some(s) if !s.is_empty() && s != "0" => Ok(Some(parse_decimal(s)?)),
116 _ => Ok(None),
117 }
118}
119
120fn parse_trigger_type(
122 order_type: OrderType,
123 trigger: Option<KrakenSpotTrigger>,
124) -> Option<TriggerType> {
125 let is_conditional = matches!(
126 order_type,
127 OrderType::StopMarket
128 | OrderType::StopLimit
129 | OrderType::MarketIfTouched
130 | OrderType::LimitIfTouched
131 );
132
133 if !is_conditional {
134 return None;
135 }
136
137 match trigger {
138 Some(KrakenSpotTrigger::Last) => Some(TriggerType::LastPrice),
139 Some(KrakenSpotTrigger::Index) => Some(TriggerType::IndexPrice),
140 None => Some(TriggerType::Default),
141 }
142}
143
144fn parse_futures_trigger_type(
146 order_type: OrderType,
147 trigger_signal: Option<KrakenTriggerSignal>,
148) -> Option<TriggerType> {
149 let is_conditional = matches!(
150 order_type,
151 OrderType::StopMarket
152 | OrderType::StopLimit
153 | OrderType::MarketIfTouched
154 | OrderType::LimitIfTouched
155 );
156
157 if !is_conditional {
158 return None;
159 }
160
161 match trigger_signal {
162 Some(KrakenTriggerSignal::Last) => Some(TriggerType::LastPrice),
163 Some(KrakenTriggerSignal::Mark) => Some(TriggerType::MarkPrice),
164 Some(KrakenTriggerSignal::Index) => Some(TriggerType::IndexPrice),
165 Some(KrakenTriggerSignal::Unknown) => {
166 log::warn!(
167 "KrakenTriggerSignal::Unknown received from venue, defaulting to Default trigger"
168 );
169 Some(TriggerType::Default)
170 }
171 None => Some(TriggerType::Default),
172 }
173}
174
175pub fn parse_spot_instrument(
184 pair_name: &str,
185 definition: &AssetPairInfo,
186 ts_event: UnixNanos,
187 ts_init: UnixNanos,
188) -> anyhow::Result<InstrumentAny> {
189 parse_spot_instrument_with_fee_rates(pair_name, definition, None, ts_event, ts_init)
190}
191
192pub(crate) fn parse_spot_instrument_with_fee_rates(
193 pair_name: &str,
194 definition: &AssetPairInfo,
195 fee_rates: Option<(Decimal, Decimal)>,
196 ts_event: UnixNanos,
197 ts_init: UnixNanos,
198) -> anyhow::Result<InstrumentAny> {
199 let symbol_str = definition.wsname.as_ref().unwrap_or(&definition.altname);
200 let normalized_symbol = normalize_spot_symbol(symbol_str);
201 let instrument_id = InstrumentId::new(Symbol::new(&normalized_symbol), *KRAKEN_VENUE);
202 let raw_symbol = Symbol::new(pair_name);
203
204 let base_currency = get_currency(definition.base.as_str());
205 let quote_currency = get_currency(definition.quote.as_str());
206
207 let price_increment = parse_price(
208 definition
209 .tick_size
210 .as_ref()
211 .context("tick_size is required")?,
212 "tick_size",
213 )?;
214
215 let size_precision = definition.lot_decimals;
217 let size_increment = Quantity::from_decimal_dp(
218 Decimal::try_new(1, u32::from(size_precision)).context("Invalid lot_decimals")?,
219 size_precision,
220 )?;
221
222 let min_quantity = definition
223 .ordermin
224 .as_ref()
225 .map(|s| parse_quantity(s, "ordermin"))
226 .transpose()?;
227
228 let (maker_fee, taker_fee) = resolve_fee_rates(definition, fee_rates);
229
230 let instrument = CurrencyPair::builder()
231 .instrument_id(instrument_id)
232 .raw_symbol(raw_symbol)
233 .base_currency(base_currency)
234 .quote_currency(quote_currency)
235 .price_precision(price_increment.precision)
236 .size_precision(size_increment.precision)
237 .price_increment(price_increment)
238 .size_increment(size_increment)
239 .maybe_min_quantity(min_quantity)
240 .maybe_maker_fee(maker_fee)
241 .maybe_taker_fee(taker_fee)
242 .ts_event(ts_event)
243 .ts_init(ts_init)
244 .build()
245 .unwrap();
246
247 Ok(InstrumentAny::CurrencyPair(instrument))
248}
249
250pub fn parse_tokenized_instrument(
259 pair_name: &str,
260 definition: &AssetPairInfo,
261 ts_event: UnixNanos,
262 ts_init: UnixNanos,
263) -> anyhow::Result<InstrumentAny> {
264 parse_tokenized_instrument_with_fee_rates(pair_name, definition, None, ts_event, ts_init)
265}
266
267pub(crate) fn parse_tokenized_instrument_with_fee_rates(
268 pair_name: &str,
269 definition: &AssetPairInfo,
270 fee_rates: Option<(Decimal, Decimal)>,
271 ts_event: UnixNanos,
272 ts_init: UnixNanos,
273) -> anyhow::Result<InstrumentAny> {
274 let symbol_str = definition.wsname.as_ref().unwrap_or(&definition.altname);
275 let normalized_symbol = normalize_spot_symbol(symbol_str);
276 let instrument_id = InstrumentId::new(Symbol::new(&normalized_symbol), *KRAKEN_VENUE);
277 let raw_symbol = Symbol::new(pair_name);
278
279 let base_currency = get_currency(definition.base.as_str());
280 let quote_currency = get_currency(definition.quote.as_str());
281
282 let price_increment = parse_price(
283 definition
284 .tick_size
285 .as_ref()
286 .context("tick_size is required")?,
287 "tick_size",
288 )?;
289
290 let size_precision = definition.lot_decimals;
291 let size_increment = Quantity::from_decimal_dp(
292 Decimal::try_new(1, u32::from(size_precision)).context("Invalid lot_decimals")?,
293 size_precision,
294 )?;
295
296 let min_quantity = definition
297 .ordermin
298 .as_ref()
299 .map(|s| parse_quantity(s, "ordermin"))
300 .transpose()?;
301
302 let (maker_fee, taker_fee) = resolve_fee_rates(definition, fee_rates);
303
304 let instrument = TokenizedAsset::builder()
305 .instrument_id(instrument_id)
306 .raw_symbol(raw_symbol)
307 .asset_class(AssetClass::Equity)
308 .base_currency(base_currency)
309 .quote_currency(quote_currency)
310 .price_precision(price_increment.precision)
311 .size_precision(size_increment.precision)
312 .price_increment(price_increment)
313 .size_increment(size_increment)
314 .maybe_min_quantity(min_quantity)
315 .maybe_maker_fee(maker_fee)
316 .maybe_taker_fee(taker_fee)
317 .ts_event(ts_event)
318 .ts_init(ts_init)
319 .build()
320 .unwrap();
321
322 Ok(InstrumentAny::TokenizedAsset(instrument))
323}
324
325fn resolve_fee_rates(
326 definition: &AssetPairInfo,
327 account_fee_rates: Option<(Decimal, Decimal)>,
328) -> (Option<Decimal>, Option<Decimal>) {
329 account_fee_rates.map_or_else(
330 || {
331 (
332 definition
333 .fees_maker
334 .first()
335 .map(|(_, fee)| *fee / dec!(100)),
336 definition.fees.first().map(|(_, fee)| *fee / dec!(100)),
337 )
338 },
339 |(maker, taker)| (Some(maker), Some(taker)),
340 )
341}
342
343pub fn parse_futures_instrument(
357 instrument: &FuturesInstrument,
358 ts_event: UnixNanos,
359 ts_init: UnixNanos,
360) -> anyhow::Result<InstrumentAny> {
361 let instrument_id = InstrumentId::new(Symbol::new(&instrument.symbol), *KRAKEN_VENUE);
362 let raw_symbol = Symbol::new(&instrument.symbol);
363
364 let base_currency = get_currency(&instrument.base);
365 let quote_currency = get_currency(&instrument.quote);
366
367 let is_inverse = instrument.instrument_type == KrakenInstrumentType::FuturesInverse;
368 let settlement_currency = if is_inverse {
369 base_currency
370 } else {
371 quote_currency
372 };
373
374 let tick_size = instrument.tick_size.normalize();
376 let price_precision = tick_size.scale();
377 check_futures_precision(&instrument.symbol, "tick_size", tick_size, price_precision)?;
378 let price_precision = u8::try_from(price_precision).context("Invalid tick_size precision")?;
379 let price_increment = Price::from_decimal_dp(tick_size, price_precision)?;
380
381 let size_increment = if instrument.contract_value_trade_precision >= 0 {
386 let precision = u32::try_from(instrument.contract_value_trade_precision)
387 .context("Invalid contract_value_trade_precision")?;
388 check_futures_precision(
389 &instrument.symbol,
390 "contract_value_trade_precision",
391 instrument.contract_value_trade_precision,
392 precision,
393 )?;
394 let precision =
395 u8::try_from(precision).context("Invalid contract_value_trade_precision")?;
396 Quantity::from_decimal_dp(
397 Decimal::try_new(1, u32::from(precision))
398 .context("Invalid contract_value_trade_precision")?,
399 precision,
400 )?
401 } else {
402 let exponent = instrument.contract_value_trade_precision.unsigned_abs();
404 let increment_value = 10_i64
405 .checked_pow(exponent)
406 .context("contract_value_trade_precision exceeds supported range")?;
407 Quantity::from_decimal_dp(Decimal::from(increment_value), 0)?
408 };
409
410 let contract_size = instrument.contract_size.normalize();
411 let multiplier_precision = contract_size.scale();
412 check_futures_precision(
413 &instrument.symbol,
414 "contract_size",
415 contract_size,
416 multiplier_precision,
417 )?;
418 let multiplier_precision =
419 u8::try_from(multiplier_precision).context("Invalid contract_size precision")?;
420 let multiplier = Some(Quantity::from_decimal_dp(
421 contract_size,
422 multiplier_precision,
423 )?);
424
425 let (margin_init, margin_maint) = instrument
427 .margin_levels
428 .first()
429 .map_or((None, None), |level| {
430 (Some(level.initial_margin), Some(level.maintenance_margin))
431 });
432
433 let instrument = CryptoPerpetual::builder()
434 .instrument_id(instrument_id)
435 .raw_symbol(raw_symbol)
436 .base_currency(base_currency)
437 .quote_currency(quote_currency)
438 .settlement_currency(settlement_currency)
439 .is_inverse(is_inverse)
440 .price_precision(price_increment.precision)
441 .size_precision(size_increment.precision)
442 .price_increment(price_increment)
443 .size_increment(size_increment)
444 .maybe_multiplier(multiplier)
445 .maybe_margin_init(margin_init)
446 .maybe_margin_maint(margin_maint)
447 .ts_event(ts_event)
448 .ts_init(ts_init)
449 .build()
450 .unwrap();
451
452 Ok(InstrumentAny::CryptoPerpetual(instrument))
453}
454
455fn check_futures_precision(
456 symbol: &str,
457 field: &str,
458 value: impl Display,
459 precision: u32,
460) -> anyhow::Result<()> {
461 if precision <= u32::from(FIXED_PRECISION) {
462 return Ok(());
463 }
464
465 #[cfg(feature = "high-precision")]
466 anyhow::bail!(
467 "Cannot parse Kraken Futures instrument '{symbol}': {field} {value} requires precision \
468 {precision}, but this build supports at most {FIXED_PRECISION}"
469 );
470
471 #[cfg(not(feature = "high-precision"))]
472 anyhow::bail!(
473 "Cannot parse Kraken Futures instrument '{symbol}': {field} {value} requires precision \
474 {precision}, but this build supports at most {FIXED_PRECISION}; enable the \
475 'high-precision' Cargo feature and rebuild"
476 );
477}
478
479fn parse_price(value: &str, field: &str) -> anyhow::Result<Price> {
480 Price::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
481}
482
483fn parse_quantity(value: &str, field: &str) -> anyhow::Result<Quantity> {
484 Quantity::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
485}
486
487pub fn get_currency(code: &str) -> Currency {
492 Currency::get_or_create_crypto(code)
493}
494
495pub fn parse_trade_tick_from_array(
506 trade_array: &[serde_json::Value],
507 instrument: &InstrumentAny,
508 ts_init: UnixNanos,
509) -> anyhow::Result<TradeTick> {
510 let price_str = trade_array
511 .first()
512 .and_then(|v| v.as_str())
513 .context("Missing or invalid price")?;
514 let price = parse_price_with_precision(price_str, instrument.price_precision(), "trade.price")?;
515
516 let size_str = trade_array
517 .get(1)
518 .and_then(|v| v.as_str())
519 .context("Missing or invalid volume")?;
520 let size = parse_quantity_with_precision(size_str, instrument.size_precision(), "trade.size")?;
521
522 let time = trade_array
523 .get(2)
524 .and_then(|v| v.as_f64())
525 .context("Missing or invalid timestamp")?;
526 let ts_event = parse_millis_timestamp(time, "trade.time")?;
527
528 let side_str = trade_array
529 .get(3)
530 .and_then(|v| v.as_str())
531 .context("Missing or invalid side")?;
532 let aggressor = match side_str {
533 "b" => AggressorSide::Buy,
534 "s" => AggressorSide::Sell,
535 _ => AggressorSide::NoAggressor,
536 };
537
538 let trade_id_value = trade_array.get(6).context("Missing trade_id")?;
539 let trade_id = if let Some(id) = trade_id_value.as_i64() {
540 TradeId::new_checked(id.to_string())?
541 } else if let Some(id_str) = trade_id_value.as_str() {
542 TradeId::new_checked(id_str)?
543 } else {
544 anyhow::bail!("Invalid trade_id format");
545 };
546
547 TradeTick::new_checked(
548 instrument.id(),
549 price,
550 size,
551 aggressor,
552 trade_id,
553 ts_event,
554 ts_init,
555 )
556 .context("Failed to construct TradeTick from Kraken trade")
557}
558
559pub fn parse_futures_public_execution(
567 execution: &FuturesPublicExecution,
568 instrument: &InstrumentAny,
569 ts_init: UnixNanos,
570) -> anyhow::Result<TradeTick> {
571 let price =
572 parse_price_with_precision(&execution.price, instrument.price_precision(), "price")?;
573 let size = parse_quantity_with_precision(
574 &execution.quantity,
575 instrument.size_precision(),
576 "quantity",
577 )?;
578
579 let ts_event = UnixNanos::from((execution.timestamp as u64) * 1_000_000);
581
582 let aggressor = match execution.taker_order.direction.to_lowercase().as_str() {
584 "buy" => AggressorSide::Buy,
585 "sell" => AggressorSide::Sell,
586 _ => AggressorSide::NoAggressor,
587 };
588
589 let trade_id = TradeId::new_checked(&execution.uid)?;
590
591 TradeTick::new_checked(
592 instrument.id(),
593 price,
594 size,
595 aggressor,
596 trade_id,
597 ts_event,
598 ts_init,
599 )
600 .context("Failed to construct TradeTick from Kraken futures execution")
601}
602
603pub fn parse_bar(
611 ohlc: &OhlcData,
612 instrument: &InstrumentAny,
613 bar_type: BarType,
614 ts_init: UnixNanos,
615) -> anyhow::Result<Bar> {
616 let price_precision = instrument.price_precision();
617 let size_precision = instrument.size_precision();
618
619 let open = parse_price_with_precision(&ohlc.open, price_precision, "ohlc.open")?;
620 let high = parse_price_with_precision(&ohlc.high, price_precision, "ohlc.high")?;
621 let low = parse_price_with_precision(&ohlc.low, price_precision, "ohlc.low")?;
622 let close = parse_price_with_precision(&ohlc.close, price_precision, "ohlc.close")?;
623 let volume = parse_quantity_with_precision(&ohlc.volume, size_precision, "ohlc.volume")?;
624
625 let ts_event = UnixNanos::from((ohlc.time as u64) * 1_000_000_000);
626
627 Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
628 .context("Failed to construct Bar from Kraken OHLC")
629}
630
631fn parse_price_with_precision(value: &str, precision: u8, field: &str) -> anyhow::Result<Price> {
632 let parsed = value
633 .parse::<Decimal>()
634 .with_context(|| format!("Failed to parse {field}='{value}' as Decimal"))?;
635 Price::from_decimal_dp(parsed, precision).with_context(|| {
636 format!("Failed to construct Price for {field} with precision {precision}")
637 })
638}
639
640fn parse_quantity_with_precision(
641 value: &str,
642 precision: u8,
643 field: &str,
644) -> anyhow::Result<Quantity> {
645 let parsed = value
646 .parse::<Decimal>()
647 .with_context(|| format!("Failed to parse {field}='{value}' as Decimal"))?;
648 Quantity::from_decimal_dp(parsed, precision).with_context(|| {
649 format!("Failed to construct Quantity for {field} with precision {precision}")
650 })
651}
652
653pub fn parse_millis_timestamp(value: f64, field: &str) -> anyhow::Result<UnixNanos> {
654 let millis = (value * 1000.0) as u64;
655 let nanos = millis
656 .checked_mul(NANOSECONDS_IN_MILLISECOND)
657 .with_context(|| format!("{field} timestamp overflowed when converting to nanoseconds"))?;
658 Ok(UnixNanos::from(nanos))
659}
660
661pub fn parse_order_status_report(
669 order_id: &str,
670 order: &SpotOrder,
671 instrument: &InstrumentAny,
672 account_id: AccountId,
673 ts_init: UnixNanos,
674) -> anyhow::Result<OrderStatusReport> {
675 let instrument_id = instrument.id();
676 let venue_order_id = VenueOrderId::new(order_id);
677
678 let order_side = OrderSide::from(order.descr.order_side).into();
679 let order_type = order.descr.ordertype.into();
680 let order_status = order.status.into();
681
682 let has_expiration = order.expiretm.is_some_and(|t| t > 0.0);
684 let time_in_force = if has_expiration {
685 TimeInForce::Gtd
686 } else if order.oflags.contains("ioc") {
687 TimeInForce::Ioc
688 } else {
689 TimeInForce::Gtc
690 };
691
692 let quantity =
693 parse_quantity_with_precision(&order.vol, instrument.size_precision(), "order.vol")?;
694
695 let filled_qty = parse_quantity_with_precision(
696 &order.vol_exec,
697 instrument.size_precision(),
698 "order.vol_exec",
699 )?;
700
701 let ts_accepted = parse_millis_timestamp(order.opentm, "order.opentm")?;
702
703 let ts_last = order
704 .closetm
705 .map(|t| parse_millis_timestamp(t, "order.closetm"))
706 .transpose()?
707 .unwrap_or(ts_accepted);
708
709 let price = if !order.price.is_empty() && order.price != "0" {
710 Some(parse_price_with_precision(
711 &order.price,
712 instrument.price_precision(),
713 "order.price",
714 )?)
715 } else {
716 None
717 };
718
719 let trigger_price = order
720 .stopprice
721 .as_ref()
722 .and_then(|p| {
723 if !p.is_empty() && p != "0" {
724 Some(parse_price_with_precision(
725 p,
726 instrument.price_precision(),
727 "order.stopprice",
728 ))
729 } else {
730 None
731 }
732 })
733 .transpose()?;
734
735 let expire_time = if has_expiration {
736 order
737 .expiretm
738 .map(|t| parse_millis_timestamp(t, "order.expiretm"))
739 .transpose()?
740 } else {
741 None
742 };
743
744 let trigger_type = parse_trigger_type(order_type, order.trigger);
745
746 Ok(OrderStatusReport {
747 account_id,
748 instrument_id,
749 client_order_id: None,
750 venue_order_id,
751 order_side,
752 order_type,
753 time_in_force,
754 order_status,
755 quantity,
756 filled_qty,
757 report_id: UUID4::new(),
758 ts_accepted,
759 ts_last,
760 ts_init,
761 order_list_id: None,
762 venue_position_id: None,
763 linked_order_ids: None,
764 parent_order_id: None,
765 contingency_type: None,
766 expire_time,
767 price,
768 activation_price: None,
769 trigger_price,
770 trigger_type,
771 limit_offset: None,
772 trailing_offset: None,
773 trailing_offset_type: None,
774 display_qty: None,
775 avg_px: compute_avg_px(order),
776 post_only: order.oflags.contains("post"),
777 reduce_only: false,
778 cancel_reason: order.reason.clone(),
779 ts_triggered: None,
780 })
781}
782
783fn compute_avg_px(order: &SpotOrder) -> Option<Decimal> {
787 if let Some(ref avg) = order.avg_price
788 && let Ok(v) = parse_decimal(avg)
789 && v > dec!(0)
790 {
791 return Some(v);
792 }
793
794 let cost = parse_decimal(&order.cost);
795 let vol_exec = parse_decimal(&order.vol_exec);
796 match (&cost, &vol_exec) {
797 (Ok(c), Ok(v)) if *v > dec!(0) => Some(*c / *v),
798 _ => {
799 if let Ok(v) = &vol_exec
800 && *v > dec!(0)
801 {
802 log::warn!("Cannot compute avg_px: cost={cost:?}, vol_exec={vol_exec:?}");
803 }
804 None
805 }
806 }
807}
808
809pub fn parse_fill_report(
816 trade_id: &str,
817 trade: &SpotTrade,
818 instrument: &InstrumentAny,
819 account_id: AccountId,
820 ts_init: UnixNanos,
821) -> anyhow::Result<FillReport> {
822 let instrument_id = instrument.id();
823 let venue_order_id = VenueOrderId::new(&trade.ordertxid);
824 let trade_id_obj = TradeId::new(trade_id);
825
826 let order_side = trade.trade_type.into();
827
828 let last_qty =
829 parse_quantity_with_precision(&trade.vol, instrument.size_precision(), "trade.vol")?;
830
831 let last_px =
832 parse_price_with_precision(&trade.price, instrument.price_precision(), "trade.price")?;
833
834 let fee_decimal = parse_decimal(&trade.fee)?;
835 let quote_currency = match instrument {
836 InstrumentAny::CurrencyPair(pair) => pair.quote_currency,
837 InstrumentAny::CryptoPerpetual(perp) => perp.quote_currency,
838 InstrumentAny::TokenizedAsset(ta) => ta.quote_currency,
839 _ => anyhow::bail!("Unsupported instrument type for fill report"),
840 };
841
842 let commission = Money::from_decimal(fee_decimal, quote_currency)?;
843
844 let liquidity_side = match trade.maker {
845 Some(true) => LiquiditySide::Maker,
846 Some(false) => LiquiditySide::Taker,
847 None => LiquiditySide::NoLiquiditySide,
848 };
849
850 let ts_event = parse_millis_timestamp(trade.time, "trade.time")?;
851
852 Ok(FillReport {
853 account_id,
854 instrument_id,
855 venue_order_id,
856 trade_id: trade_id_obj,
857 order_side,
858 last_qty,
859 last_px,
860 commission,
861 liquidity_side,
862 avg_px: None,
863 report_id: UUID4::new(),
864 ts_event,
865 ts_init,
866 client_order_id: None,
867 venue_position_id: None,
868 })
869}
870
871pub fn parse_futures_order_status_report(
877 order: &FuturesOpenOrder,
878 instrument: &InstrumentAny,
879 account_id: AccountId,
880 fallback_quantity: Option<Decimal>,
881 ts_init: UnixNanos,
882) -> anyhow::Result<OrderStatusReport> {
883 let instrument_id = instrument.id();
884 let venue_order_id = VenueOrderId::new(&order.order_id);
885
886 let order_side = OrderSide::from(order.side).into();
887 let order_type: OrderType = order.order_type.into();
888 let order_type = if order_type == OrderType::MarketIfTouched && order.limit_price.is_some() {
889 OrderType::LimitIfTouched
890 } else {
891 order_type
892 };
893 let order_status = order.status.into();
894
895 let quantity_value = order
896 .unfilled_size
897 .map(|unfilled_size| unfilled_size + order.filled_size)
898 .or(fallback_quantity)
899 .context("missing unfilled size and fallback quantity")?;
900 let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())?;
901
902 let filled_qty = Quantity::from_decimal_dp(order.filled_size, instrument.size_precision())?;
903
904 let ts_accepted = parse_rfc3339_timestamp(&order.received_time, "order.received_time")?;
905 let ts_last = parse_rfc3339_timestamp(&order.last_update_time, "order.last_update_time")?;
906
907 let price = order
908 .limit_price
909 .map(|p| Price::from_decimal_dp(p, instrument.price_precision()))
910 .transpose()?;
911
912 let trigger_price = order
913 .stop_price
914 .map(|p| Price::from_decimal_dp(p, instrument.price_precision()))
915 .transpose()?;
916
917 let trigger_type = parse_futures_trigger_type(order_type, order.trigger_signal);
918
919 Ok(OrderStatusReport {
920 account_id,
921 instrument_id,
922 client_order_id: order.cli_ord_id.as_ref().map(|s| s.as_str().into()),
923 venue_order_id,
924 order_side,
925 order_type,
926 time_in_force: TimeInForce::Gtc,
927 order_status,
928 quantity,
929 filled_qty,
930 report_id: UUID4::new(),
931 ts_accepted,
932 ts_last,
933 ts_init,
934 order_list_id: None,
935 venue_position_id: None,
936 linked_order_ids: None,
937 parent_order_id: None,
938 contingency_type: None,
939 expire_time: None,
940 price,
941 activation_price: None,
942 trigger_price,
943 trigger_type,
944 limit_offset: None,
945 trailing_offset: None,
946 trailing_offset_type: None,
947 display_qty: None,
948 avg_px: None,
949 post_only: false,
950 reduce_only: order.reduce_only.unwrap_or(false),
951 cancel_reason: None,
952 ts_triggered: None,
953 })
954}
955
956pub fn parse_futures_order_event_status_report(
962 event: &FuturesOrderEvent,
963 event_type: Option<KrakenFuturesOrderEventType>,
964 instrument: &InstrumentAny,
965 account_id: AccountId,
966 ts_init: UnixNanos,
967) -> anyhow::Result<OrderStatusReport> {
968 let instrument_id = instrument.id();
969 let venue_order_id = VenueOrderId::new(&event.order_id);
970
971 let order_side = OrderSide::from(event.side).into();
972 let order_type: OrderType = event.order_type.into();
973 let order_type = if order_type == OrderType::MarketIfTouched && event.limit_price.is_some() {
974 OrderType::LimitIfTouched
975 } else {
976 order_type
977 };
978
979 let order_status = parse_futures_order_event_status(event_type, event.filled, event.quantity);
980
981 let quantity = Quantity::from_decimal_dp(event.quantity, instrument.size_precision())?;
982 let filled_qty = Quantity::from_decimal_dp(event.filled, instrument.size_precision())?;
983
984 let ts_accepted = parse_rfc3339_timestamp(&event.timestamp, "event.timestamp")?;
985 let ts_last =
986 parse_rfc3339_timestamp(&event.last_update_timestamp, "event.last_update_timestamp")?;
987
988 let price = event
989 .limit_price
990 .map(|p| Price::from_decimal_dp(p, instrument.price_precision()))
991 .transpose()?;
992
993 let trigger_price = event
994 .stop_price
995 .map(|p| Price::from_decimal_dp(p, instrument.price_precision()))
996 .transpose()?;
997
998 let trigger_type = parse_futures_trigger_type(order_type, None);
999
1000 Ok(OrderStatusReport {
1001 account_id,
1002 instrument_id,
1003 client_order_id: event.cli_ord_id.as_ref().map(|s| s.as_str().into()),
1004 venue_order_id,
1005 order_side,
1006 order_type,
1007 time_in_force: TimeInForce::Gtc,
1008 order_status,
1009 quantity,
1010 filled_qty,
1011 report_id: UUID4::new(),
1012 ts_accepted,
1013 ts_last,
1014 ts_init,
1015 order_list_id: None,
1016 venue_position_id: None,
1017 linked_order_ids: None,
1018 parent_order_id: None,
1019 contingency_type: None,
1020 expire_time: None,
1021 price,
1022 activation_price: None,
1023 trigger_price,
1024 trigger_type,
1025 limit_offset: None,
1026 trailing_offset: None,
1027 trailing_offset_type: None,
1028 display_qty: None,
1029 avg_px: None,
1030 post_only: false,
1031 reduce_only: event.reduce_only,
1032 cancel_reason: None,
1033 ts_triggered: None,
1034 })
1035}
1036
1037pub fn parse_futures_order_status_details_report(
1049 details: &FuturesOrderStatusDetails,
1050 instrument: &InstrumentAny,
1051 account_id: AccountId,
1052 ts_init: UnixNanos,
1053) -> anyhow::Result<OrderStatusReport> {
1054 let venue_order_id = VenueOrderId::new(&details.order.order_id);
1055
1056 let order_side = OrderSide::from(details.order.side).into();
1057
1058 let order_type = if details.order.limit_price.is_some() {
1059 OrderType::Limit
1060 } else {
1061 OrderType::Market
1062 };
1063
1064 let filled = details.order.filled.unwrap_or(Decimal::ZERO);
1065
1066 let order_status = match details.status {
1067 KrakenFuturesOrderLifecycleStatus::EnteredBook
1068 | KrakenFuturesOrderLifecycleStatus::TriggerPlaced => {
1069 if filled > Decimal::ZERO {
1070 OrderStatus::PartiallyFilled
1071 } else {
1072 OrderStatus::Accepted
1073 }
1074 }
1075 KrakenFuturesOrderLifecycleStatus::FullyExecuted => OrderStatus::Filled,
1076 KrakenFuturesOrderLifecycleStatus::Rejected
1077 | KrakenFuturesOrderLifecycleStatus::TriggerActivationFailure => OrderStatus::Rejected,
1078 KrakenFuturesOrderLifecycleStatus::Cancelled => OrderStatus::Canceled,
1079 };
1080
1081 let quantity_value = details
1082 .order
1083 .quantity
1084 .context("order status details missing quantity")?;
1085 let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())?;
1086 let filled_qty = Quantity::from_decimal_dp(filled, instrument.size_precision())?;
1087
1088 let ts_accepted = parse_rfc3339_timestamp(&details.order.timestamp, "order.timestamp")?;
1089 let ts_last = parse_rfc3339_timestamp(
1090 &details.order.last_update_timestamp,
1091 "order.last_update_timestamp",
1092 )?;
1093
1094 let price = details
1095 .order
1096 .limit_price
1097 .map(|p| Price::from_decimal_dp(p, instrument.price_precision()))
1098 .transpose()?;
1099
1100 let cancel_reason = match details.status {
1101 KrakenFuturesOrderLifecycleStatus::Cancelled
1102 | KrakenFuturesOrderLifecycleStatus::Rejected
1103 | KrakenFuturesOrderLifecycleStatus::TriggerActivationFailure => {
1104 details.update_reason.clone()
1105 }
1106 _ => None,
1107 };
1108
1109 Ok(OrderStatusReport {
1110 account_id,
1111 instrument_id: instrument.id(),
1112 client_order_id: details.order.cli_ord_id.as_ref().map(|s| s.as_str().into()),
1113 venue_order_id,
1114 order_side,
1115 order_type,
1116 time_in_force: TimeInForce::Gtc,
1117 order_status,
1118 quantity,
1119 filled_qty,
1120 report_id: UUID4::new(),
1121 ts_accepted,
1122 ts_last,
1123 ts_init,
1124 order_list_id: None,
1125 venue_position_id: None,
1126 linked_order_ids: None,
1127 parent_order_id: None,
1128 contingency_type: None,
1129 expire_time: None,
1130 price,
1131 activation_price: None,
1132 trigger_price: None,
1133 trigger_type: None,
1134 limit_offset: None,
1135 trailing_offset: None,
1136 trailing_offset_type: None,
1137 display_qty: None,
1138 avg_px: None,
1139 post_only: false,
1140 reduce_only: details.order.reduce_only,
1141 cancel_reason,
1142 ts_triggered: None,
1143 })
1144}
1145
1146fn parse_futures_order_event_status(
1147 event_type: Option<KrakenFuturesOrderEventType>,
1148 filled: Decimal,
1149 quantity: Decimal,
1150) -> OrderStatus {
1151 match event_type {
1152 Some(KrakenFuturesOrderEventType::Cancel) => OrderStatus::Canceled,
1153 Some(KrakenFuturesOrderEventType::Reject) => OrderStatus::Rejected,
1154 Some(KrakenFuturesOrderEventType::Expire) => OrderStatus::Expired,
1155 Some(
1156 KrakenFuturesOrderEventType::Fill
1157 | KrakenFuturesOrderEventType::Execution
1158 | KrakenFuturesOrderEventType::Place
1159 | KrakenFuturesOrderEventType::Edit,
1160 ) => {
1161 if filled >= quantity {
1162 OrderStatus::Filled
1163 } else if filled > Decimal::ZERO {
1164 OrderStatus::PartiallyFilled
1165 } else {
1166 OrderStatus::Accepted
1167 }
1168 }
1169 _ => {
1170 if filled >= quantity {
1171 OrderStatus::Filled
1172 } else if filled > Decimal::ZERO {
1173 OrderStatus::PartiallyFilled
1174 } else {
1175 OrderStatus::Canceled
1176 }
1177 }
1178 }
1179}
1180
1181pub fn parse_futures_fill_report(
1187 fill: &FuturesFill,
1188 instrument: &InstrumentAny,
1189 account_id: AccountId,
1190 ts_init: UnixNanos,
1191) -> anyhow::Result<FillReport> {
1192 let instrument_id = instrument.id();
1193 let venue_order_id = VenueOrderId::new(&fill.order_id);
1194 let trade_id = TradeId::new(&fill.fill_id);
1195
1196 let order_side = fill.side.into();
1197
1198 let last_qty = Quantity::from_decimal_dp(fill.size, instrument.size_precision())?;
1199 let last_px = Price::from_decimal_dp(fill.price, instrument.price_precision())?;
1200
1201 let quote_currency = match instrument {
1202 InstrumentAny::CryptoPerpetual(perp) => perp.quote_currency,
1203 InstrumentAny::CryptoFuture(future) => future.quote_currency,
1204 _ => anyhow::bail!("Unsupported instrument type for futures fill report"),
1205 };
1206
1207 let commission = Money::from_decimal(fill.fee_paid.unwrap_or(Decimal::ZERO), quote_currency)?;
1208
1209 let liquidity_side = fill.fill_type.into();
1210
1211 let ts_event = parse_rfc3339_timestamp(&fill.fill_time, "fill.fill_time")?;
1212
1213 Ok(FillReport {
1214 account_id,
1215 instrument_id,
1216 venue_order_id,
1217 trade_id,
1218 order_side,
1219 last_qty,
1220 last_px,
1221 commission,
1222 liquidity_side,
1223 avg_px: None,
1224 report_id: UUID4::new(),
1225 ts_event,
1226 ts_init,
1227 client_order_id: fill.cli_ord_id.as_ref().map(|s| s.as_str().into()),
1228 venue_position_id: None,
1229 })
1230}
1231
1232pub fn parse_futures_position_status_report(
1238 position: &FuturesPosition,
1239 instrument: &InstrumentAny,
1240 account_id: AccountId,
1241 ts_init: UnixNanos,
1242) -> anyhow::Result<PositionStatusReport> {
1243 let instrument_id = instrument.id();
1244
1245 let position_side = match position.side {
1246 KrakenPositionSide::Long => PositionSide::Long,
1247 KrakenPositionSide::Short => PositionSide::Short,
1248 };
1249
1250 let quantity = Quantity::from_decimal_dp(position.size, instrument.size_precision())?;
1251 let signed_decimal_qty = match position_side {
1252 PositionSide::Long => position.size,
1253 PositionSide::Short => -position.size,
1254 PositionSide::Flat => dec!(0),
1255 };
1256
1257 let avg_px_open = Some(position.price);
1258
1259 Ok(PositionStatusReport {
1260 account_id,
1261 instrument_id,
1262 position_side,
1263 quantity,
1264 signed_decimal_qty,
1265 report_id: UUID4::new(),
1266 ts_last: ts_init,
1267 ts_init,
1268 venue_position_id: None,
1269 avg_px_open,
1270 })
1271}
1272
1273pub fn bar_type_to_spot_interval(bar_type: BarType) -> anyhow::Result<u32> {
1281 let step = bar_type.spec().step.get() as u32;
1282 let base_interval = match bar_type.spec().aggregation {
1283 BarAggregation::Minute => 1,
1284 BarAggregation::Hour => 60,
1285 BarAggregation::Day => 1440,
1286 other => {
1287 anyhow::bail!("Unsupported bar aggregation for Kraken Spot: {other:?}");
1288 }
1289 };
1290 Ok(base_interval * step)
1291}
1292
1293pub fn bar_type_to_futures_resolution(bar_type: BarType) -> anyhow::Result<&'static str> {
1303 let step = bar_type.spec().step.get() as u32;
1304 match bar_type.spec().aggregation {
1305 BarAggregation::Minute => match step {
1306 1 => Ok("1m"),
1307 5 => Ok("5m"),
1308 15 => Ok("15m"),
1309 _ => anyhow::bail!("Unsupported minute step for Kraken Futures: {step}"),
1310 },
1311 BarAggregation::Hour => match step {
1312 1 => Ok("1h"),
1313 4 => Ok("4h"),
1314 12 => Ok("12h"),
1315 _ => anyhow::bail!("Unsupported hour step for Kraken Futures: {step}"),
1316 },
1317 BarAggregation::Day => {
1318 if step == 1 {
1319 Ok("1d")
1320 } else {
1321 anyhow::bail!("Unsupported day step for Kraken Futures: {step}")
1322 }
1323 }
1324 BarAggregation::Week => {
1325 if step == 1 {
1326 Ok("1w")
1327 } else {
1328 anyhow::bail!("Unsupported week step for Kraken Futures: {step}")
1329 }
1330 }
1331 other => {
1332 anyhow::bail!("Unsupported bar aggregation for Kraken Futures: {other:?}");
1333 }
1334 }
1335}
1336
1337pub fn truncate_cl_ord_id(client_order_id: &ClientOrderId) -> String {
1348 let id = client_order_id.as_str();
1349
1350 if id.len() <= 18 {
1351 return id.to_string();
1352 }
1353
1354 if id.len() == 36 && id.bytes().filter(|b| *b == b'-').count() == 4 {
1355 return id.to_string();
1356 }
1357
1358 if id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit()) {
1359 return id.to_string();
1360 }
1361
1362 format!("O{}", &id[id.len() - 17..])
1363}
1364
1365#[cfg(test)]
1366mod tests {
1367 use indexmap::IndexMap;
1368 use nautilus_model::{
1369 data::BarSpecification,
1370 enums::{AggregationSource, BarAggregation, OrderSide, OrderStatus, PriceType},
1371 instruments::crypto_perpetual::CryptoPerpetual,
1372 };
1373 use rstest::rstest;
1374
1375 use super::*;
1376 use crate::{
1377 common::enums::{
1378 KrakenFuturesOrderEventType, KrakenFuturesOrderStatus, KrakenFuturesOrderType,
1379 KrakenOrderSide,
1380 },
1381 http::{
1382 futures::models::{FuturesFillsResponse, FuturesOpenOrder, FuturesOrderEvent},
1383 models::{AssetPairsResponse, KrakenResponse},
1384 },
1385 };
1386
1387 const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
1388
1389 fn load_test_json(filename: &str) -> String {
1390 let path = format!("test_data/{filename}");
1391 std::fs::read_to_string(&path)
1392 .unwrap_or_else(|e| panic!("Failed to load test data from {path}: {e}"))
1393 }
1394
1395 #[rstest]
1396 fn test_parse_decimal() {
1397 assert_eq!(parse_decimal("123.45").unwrap(), dec!(123.45));
1398 assert_eq!(parse_decimal("0").unwrap(), dec!(0));
1399 assert_eq!(parse_decimal("").unwrap(), dec!(0));
1400 }
1401
1402 #[rstest]
1403 fn test_parse_decimal_opt() {
1404 assert_eq!(
1405 parse_decimal_opt(Some("123.45")).unwrap(),
1406 Some(dec!(123.45))
1407 );
1408 assert_eq!(parse_decimal_opt(Some("0")).unwrap(), None);
1409 assert_eq!(parse_decimal_opt(Some("")).unwrap(), None);
1410 assert_eq!(parse_decimal_opt(None).unwrap(), None);
1411 }
1412
1413 #[rstest]
1414 fn test_parse_spot_instrument() {
1415 let json = load_test_json("http_asset_pairs.json");
1416 let response: KrakenResponse<AssetPairsResponse> = serde_json::from_str(&json).unwrap();
1417 let pairs = response.result.unwrap();
1418
1419 let (pair_name, definition) = pairs.iter().next().unwrap();
1420
1421 let instrument = parse_spot_instrument(pair_name, definition, TS, TS).unwrap();
1422
1423 match instrument {
1424 InstrumentAny::CurrencyPair(pair) => {
1425 assert_eq!(pair.id.venue.as_str(), "KRAKEN");
1426 assert_eq!(pair.base_currency.code, "XXBT");
1427 assert_eq!(pair.quote_currency.code, "USDT");
1428 assert_eq!(pair.price_increment.as_decimal(), dec!(0.1));
1429 assert_eq!(pair.size_increment.as_decimal(), dec!(0.00000001));
1430 assert!(pair.min_quantity.is_some());
1431 assert_eq!(pair.maker_fee, dec!(0.0025));
1432 assert_eq!(pair.taker_fee, dec!(0.004));
1433 assert_eq!(pair.margin_init, dec!(0));
1434 assert_eq!(pair.margin_maint, dec!(0));
1435 }
1436 _ => panic!("Expected CurrencyPair"),
1437 }
1438 }
1439
1440 #[rstest]
1441 fn test_parse_spot_instrument_with_account_fee_rates() {
1442 let json = load_test_json("http_asset_pairs.json");
1443 let response: KrakenResponse<AssetPairsResponse> = serde_json::from_str(&json).unwrap();
1444 let pairs = response.result.unwrap();
1445 let (pair_name, definition) = pairs.iter().next().unwrap();
1446
1447 let instrument = parse_spot_instrument_with_fee_rates(
1448 pair_name,
1449 definition,
1450 Some((dec!(0.0017), dec!(0.0029))),
1451 TS,
1452 TS,
1453 )
1454 .unwrap();
1455
1456 match instrument {
1457 InstrumentAny::CurrencyPair(pair) => {
1458 assert_eq!(pair.maker_fee, dec!(0.0017));
1459 assert_eq!(pair.taker_fee, dec!(0.0029));
1460 }
1461 _ => panic!("Expected CurrencyPair"),
1462 }
1463 }
1464
1465 #[rstest]
1466 fn test_parse_futures_instrument_inverse() {
1467 let json = load_test_json("http_futures_instruments.json");
1468 let response: crate::http::models::FuturesInstrumentsResponse =
1469 serde_json::from_str(&json).unwrap();
1470
1471 let fut_instrument = &response.instruments[0];
1472
1473 let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1474
1475 match instrument {
1476 InstrumentAny::CryptoPerpetual(perp) => {
1477 assert_eq!(perp.id.venue.as_str(), "KRAKEN");
1478 assert_eq!(perp.id.symbol.as_str(), "PI_XBTUSD");
1479 assert_eq!(perp.raw_symbol.as_str(), "PI_XBTUSD");
1480 assert_eq!(perp.base_currency.code, "BTC");
1481 assert_eq!(perp.quote_currency.code, "USD");
1482 assert_eq!(perp.settlement_currency.code, "BTC");
1483 assert!(perp.is_inverse);
1484 assert_eq!(perp.price_increment.as_decimal(), dec!(0.5));
1485 assert_eq!(perp.size_increment.as_decimal(), dec!(1));
1486 assert_eq!(perp.size_precision(), 0);
1487 assert_eq!(perp.margin_init, dec!(0.02));
1488 assert_eq!(perp.margin_maint, dec!(0.01));
1489 }
1490 _ => panic!("Expected CryptoPerpetual"),
1491 }
1492 }
1493
1494 #[rstest]
1495 fn test_parse_futures_instrument_flexible() {
1496 let json = load_test_json("http_futures_instruments.json");
1497 let response: crate::http::models::FuturesInstrumentsResponse =
1498 serde_json::from_str(&json).unwrap();
1499
1500 let fut_instrument = &response.instruments[1];
1501
1502 let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1503
1504 match instrument {
1505 InstrumentAny::CryptoPerpetual(perp) => {
1506 assert_eq!(perp.id.venue.as_str(), "KRAKEN");
1507 assert_eq!(perp.id.symbol.as_str(), "PF_ETHUSD");
1508 assert_eq!(perp.raw_symbol.as_str(), "PF_ETHUSD");
1509 assert_eq!(perp.base_currency.code, "ETH");
1510 assert_eq!(perp.quote_currency.code, "USD");
1511 assert_eq!(perp.settlement_currency.code, "USD");
1512 assert!(!perp.is_inverse);
1513 assert_eq!(perp.price_increment.as_decimal(), dec!(0.1));
1514 assert_eq!(perp.size_increment.as_decimal(), dec!(0.001));
1515 assert_eq!(perp.size_precision(), 3);
1516 assert_eq!(perp.margin_init, dec!(0.02));
1517 assert_eq!(perp.margin_maint, dec!(0.01));
1518 }
1519 _ => panic!("Expected CryptoPerpetual"),
1520 }
1521 }
1522
1523 #[rstest]
1524 fn test_parse_futures_instrument_accepts_max_precision() {
1525 let json = load_test_json("http_futures_instruments.json");
1526 let response: crate::http::models::FuturesInstrumentsResponse =
1527 serde_json::from_str(&json).unwrap();
1528 let mut fut_instrument = response.instruments[1].clone();
1529 let tick_size = Decimal::try_new(1, u32::from(FIXED_PRECISION)).unwrap();
1530 fut_instrument.tick_size = tick_size;
1531
1532 let instrument = parse_futures_instrument(&fut_instrument, TS, TS).unwrap();
1533
1534 match instrument {
1535 InstrumentAny::CryptoPerpetual(perp) => {
1536 assert_eq!(perp.price_precision(), FIXED_PRECISION);
1537 assert_eq!(perp.price_increment.as_decimal(), tick_size);
1538 }
1539 _ => panic!("Expected CryptoPerpetual"),
1540 }
1541 }
1542
1543 #[cfg(feature = "high-precision")]
1544 #[rstest]
1545 fn test_parse_futures_instrument_negative_precision() {
1546 let json = load_test_json("http_futures_instruments.json");
1547 let response: crate::http::models::FuturesInstrumentsResponse =
1548 serde_json::from_str(&json).unwrap();
1549
1550 let fut_instrument = &response.instruments[2];
1552
1553 let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1554
1555 match instrument {
1556 InstrumentAny::CryptoPerpetual(perp) => {
1557 assert_eq!(perp.id.symbol.as_str(), "PF_PEPEUSD");
1558 assert_eq!(perp.base_currency.code, "PEPE");
1559 assert!(!perp.is_inverse);
1560 assert_eq!(perp.size_increment.as_decimal(), dec!(1000));
1561 assert_eq!(perp.size_precision(), 0);
1562 }
1563 _ => panic!("Expected CryptoPerpetual"),
1564 }
1565 }
1566
1567 #[cfg(feature = "high-precision")]
1568 #[rstest]
1569 fn test_parse_futures_instrument_rejects_precision_above_high_max() {
1570 let json = load_test_json("http_futures_instruments.json");
1571 let response: crate::http::models::FuturesInstrumentsResponse =
1572 serde_json::from_str(&json).unwrap();
1573 let mut fut_instrument = response.instruments[1].clone();
1574 fut_instrument.tick_size = dec!(0.00000000000000001);
1575
1576 let error = parse_futures_instrument(&fut_instrument, TS, TS).unwrap_err();
1577
1578 assert_eq!(
1579 error.to_string(),
1580 "Cannot parse Kraken Futures instrument 'PF_ETHUSD': tick_size \
1581 0.00000000000000001 requires precision 17, but this build supports at most 16"
1582 );
1583 }
1584
1585 #[cfg(not(feature = "high-precision"))]
1586 #[rstest]
1587 fn test_parse_futures_instrument_rejects_unsupported_precision() {
1588 let json = load_test_json("http_futures_instruments.json");
1589 let response: crate::http::models::FuturesInstrumentsResponse =
1590 serde_json::from_str(&json).unwrap();
1591
1592 let tick_error = parse_futures_instrument(&response.instruments[2], TS, TS).unwrap_err();
1593
1594 let mut trade_precision_instrument = response.instruments[1].clone();
1595 trade_precision_instrument.contract_value_trade_precision = 256;
1596 let trade_precision_error =
1597 parse_futures_instrument(&trade_precision_instrument, TS, TS).unwrap_err();
1598
1599 let mut contract_size_instrument = response.instruments[1].clone();
1600 contract_size_instrument.contract_size = dec!(0.0000000001);
1601 let contract_size_error =
1602 parse_futures_instrument(&contract_size_instrument, TS, TS).unwrap_err();
1603
1604 assert_eq!(
1605 tick_error.to_string(),
1606 "Cannot parse Kraken Futures instrument 'PF_PEPEUSD': tick_size 0.0000000001 requires \
1607 precision 10, but this build supports at most 9; enable the 'high-precision' Cargo \
1608 feature and rebuild"
1609 );
1610 assert_eq!(
1611 trade_precision_error.to_string(),
1612 "Cannot parse Kraken Futures instrument 'PF_ETHUSD': contract_value_trade_precision 256 \
1613 requires precision 256, but this build supports at most 9; enable the 'high-precision' \
1614 Cargo feature and rebuild"
1615 );
1616 assert_eq!(
1617 contract_size_error.to_string(),
1618 "Cannot parse Kraken Futures instrument 'PF_ETHUSD': contract_size 0.0000000001 requires \
1619 precision 10, but this build supports at most 9; enable the 'high-precision' Cargo \
1620 feature and rebuild"
1621 );
1622 }
1623
1624 #[rstest]
1625 fn test_parse_futures_instrument_tokenized_underlying() {
1626 let json = load_test_json("http_futures_instruments.json");
1627 let response: crate::http::models::FuturesInstrumentsResponse =
1628 serde_json::from_str(&json).unwrap();
1629
1630 let fut_instrument = &response.instruments[3];
1631
1632 let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1633
1634 match instrument {
1635 InstrumentAny::CryptoPerpetual(perp) => {
1636 assert_eq!(perp.id.symbol.as_str(), "PF_AAPLxUSD");
1637 assert_eq!(perp.raw_symbol.as_str(), "PF_AAPLxUSD");
1638 assert_eq!(perp.base_currency.code, "AAPLx");
1639 assert_eq!(perp.quote_currency.code, "USD");
1640 assert_eq!(perp.settlement_currency.code, "USD");
1641 assert!(!perp.is_inverse);
1642 assert_eq!(perp.price_increment.as_decimal(), dec!(0.01));
1643 assert_eq!(perp.size_increment.as_decimal(), dec!(0.01));
1644 assert_eq!(perp.size_precision(), 2);
1645 assert_eq!(perp.margin_init, dec!(0.2));
1646 assert_eq!(perp.margin_maint, dec!(0.1));
1647 }
1648 _ => panic!("Expected CryptoPerpetual"),
1649 }
1650 }
1651
1652 #[rstest]
1653 fn test_parse_futures_instrument_without_fee_schedule_uid() {
1654 let json = load_test_json("http_futures_instrument_no_fee_schedule.json");
1655 let response: crate::http::models::FuturesInstrumentsResponse =
1656 serde_json::from_str(&json).unwrap();
1657
1658 let fut_instrument = &response.instruments[0];
1659 assert!(fut_instrument.fee_schedule_uid.is_none());
1660
1661 let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1662 match instrument {
1663 InstrumentAny::CryptoPerpetual(perp) => {
1664 assert_eq!(perp.id.symbol.as_str(), "PF_ETHUSD");
1665 }
1666 _ => panic!("Expected CryptoPerpetual"),
1667 }
1668 }
1669
1670 #[rstest]
1671 fn test_parse_trade_tick_from_array() {
1672 let json = load_test_json("http_trades.json");
1673 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1674 let result = wrapper.get("result").unwrap();
1675 let trades_map = result.as_object().unwrap();
1676
1677 let (_pair, trades_value) = trades_map.iter().find(|(k, _)| *k != "last").unwrap();
1679 let trades = trades_value.as_array().unwrap();
1680 let trade_array = trades[0].as_array().unwrap();
1681
1682 let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1684 let instrument = InstrumentAny::CurrencyPair(
1685 CurrencyPair::builder()
1686 .instrument_id(instrument_id)
1687 .raw_symbol(Symbol::new("XBTUSDT"))
1688 .base_currency(Currency::BTC())
1689 .quote_currency(Currency::USDT())
1690 .price_precision(1)
1691 .size_precision(8)
1692 .price_increment(Price::from("0.1"))
1693 .size_increment(Quantity::from("0.00000001"))
1694 .ts_event(TS)
1695 .ts_init(TS)
1696 .build()
1697 .unwrap(),
1698 );
1699
1700 let trade_tick = parse_trade_tick_from_array(trade_array, &instrument, TS).unwrap();
1701
1702 assert_eq!(trade_tick.instrument_id, instrument_id);
1703 assert_eq!(trade_tick.price, Price::from("105433.60000"));
1704 assert_eq!(trade_tick.size, Quantity::from("0.00027625"));
1705 }
1706
1707 #[rstest]
1708 fn test_parse_bar() {
1709 let json = load_test_json("http_ohlc.json");
1710 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1711 let result = wrapper.get("result").unwrap();
1712 let ohlc_map = result.as_object().unwrap();
1713
1714 let (_pair, ohlc_value) = ohlc_map.iter().find(|(k, _)| *k != "last").unwrap();
1716 let ohlcs = ohlc_value.as_array().unwrap();
1717
1718 let ohlc_array = ohlcs[0].as_array().unwrap();
1720 let ohlc = OhlcData {
1721 time: ohlc_array[0].as_i64().unwrap(),
1722 open: ohlc_array[1].as_str().unwrap().to_string(),
1723 high: ohlc_array[2].as_str().unwrap().to_string(),
1724 low: ohlc_array[3].as_str().unwrap().to_string(),
1725 close: ohlc_array[4].as_str().unwrap().to_string(),
1726 vwap: ohlc_array[5].as_str().unwrap().to_string(),
1727 volume: ohlc_array[6].as_str().unwrap().to_string(),
1728 count: ohlc_array[7].as_i64().unwrap(),
1729 };
1730
1731 let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1733 let instrument = InstrumentAny::CurrencyPair(
1734 CurrencyPair::builder()
1735 .instrument_id(instrument_id)
1736 .raw_symbol(Symbol::new("XBTUSDT"))
1737 .base_currency(Currency::BTC())
1738 .quote_currency(Currency::USDT())
1739 .price_precision(1)
1740 .size_precision(8)
1741 .price_increment(Price::from("0.1"))
1742 .size_increment(Quantity::from("0.00000001"))
1743 .ts_event(TS)
1744 .ts_init(TS)
1745 .build()
1746 .unwrap(),
1747 );
1748
1749 let bar_type = BarType::new(
1750 instrument_id,
1751 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
1752 AggregationSource::External,
1753 );
1754
1755 let bar = parse_bar(&ohlc, &instrument, bar_type, TS).unwrap();
1756
1757 assert_eq!(bar.bar_type, bar_type);
1758 assert_eq!(bar.open, Price::from("106038.2"));
1759 assert_eq!(bar.high, Price::from("106038.2"));
1760 assert_eq!(bar.low, Price::from("106038.2"));
1761 assert_eq!(bar.close, Price::from("106038.2"));
1762 assert_eq!(bar.volume, Quantity::from("0.00000000"));
1763 }
1764
1765 #[rstest]
1766 fn test_parse_millis_timestamp() {
1767 let timestamp = 1762795433.9717445;
1768 let result = parse_millis_timestamp(timestamp, "test").unwrap();
1769 assert!(result.as_u64() > 0);
1770 }
1771
1772 #[rstest]
1773 #[case(1, BarAggregation::Minute, 1)]
1774 #[case(5, BarAggregation::Minute, 5)]
1775 #[case(15, BarAggregation::Minute, 15)]
1776 #[case(1, BarAggregation::Hour, 60)]
1777 #[case(4, BarAggregation::Hour, 240)]
1778 #[case(1, BarAggregation::Day, 1440)]
1779 fn test_bar_type_to_spot_interval(
1780 #[case] step: usize,
1781 #[case] aggregation: BarAggregation,
1782 #[case] expected: u32,
1783 ) {
1784 let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1785 let bar_type = BarType::new(
1786 instrument_id,
1787 BarSpecification::new(step, aggregation, PriceType::Last),
1788 AggregationSource::External,
1789 );
1790
1791 let result = bar_type_to_spot_interval(bar_type).unwrap();
1792 assert_eq!(result, expected);
1793 }
1794
1795 #[rstest]
1796 fn test_bar_type_to_spot_interval_unsupported() {
1797 let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1798 let bar_type = BarType::new(
1799 instrument_id,
1800 BarSpecification::new(1, BarAggregation::Second, PriceType::Last),
1801 AggregationSource::External,
1802 );
1803
1804 let result = bar_type_to_spot_interval(bar_type);
1805 assert!(result.is_err());
1806 assert!(result.unwrap_err().to_string().contains("Unsupported"));
1807 }
1808
1809 #[rstest]
1810 #[case(1, BarAggregation::Minute, "1m")]
1811 #[case(5, BarAggregation::Minute, "5m")]
1812 #[case(15, BarAggregation::Minute, "15m")]
1813 #[case(1, BarAggregation::Hour, "1h")]
1814 #[case(4, BarAggregation::Hour, "4h")]
1815 #[case(12, BarAggregation::Hour, "12h")]
1816 #[case(1, BarAggregation::Day, "1d")]
1817 #[case(1, BarAggregation::Week, "1w")]
1818 fn test_bar_type_to_futures_resolution(
1819 #[case] step: usize,
1820 #[case] aggregation: BarAggregation,
1821 #[case] expected: &str,
1822 ) {
1823 let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
1824 let bar_type = BarType::new(
1825 instrument_id,
1826 BarSpecification::new(step, aggregation, PriceType::Last),
1827 AggregationSource::External,
1828 );
1829
1830 let result = bar_type_to_futures_resolution(bar_type).unwrap();
1831 assert_eq!(result, expected);
1832 }
1833
1834 #[rstest]
1835 #[case(30, BarAggregation::Minute)] #[case(2, BarAggregation::Hour)] #[case(2, BarAggregation::Day)] #[case(1, BarAggregation::Second)] fn test_bar_type_to_futures_resolution_unsupported(
1840 #[case] step: usize,
1841 #[case] aggregation: BarAggregation,
1842 ) {
1843 let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
1844 let bar_type = BarType::new(
1845 instrument_id,
1846 BarSpecification::new(step, aggregation, PriceType::Last),
1847 AggregationSource::External,
1848 );
1849
1850 let result = bar_type_to_futures_resolution(bar_type);
1851 assert!(result.is_err());
1852 assert!(result.unwrap_err().to_string().contains("Unsupported"));
1853 }
1854
1855 #[rstest]
1856 fn test_parse_order_status_report() {
1857 let json = load_test_json("http_open_orders.json");
1858 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1859 let result = wrapper.get("result").unwrap();
1860 let open_map = result.get("open").unwrap();
1861 let orders: IndexMap<String, SpotOrder> = serde_json::from_value(open_map.clone()).unwrap();
1862
1863 let account_id = AccountId::new("KRAKEN-001");
1864 let instrument_id = InstrumentId::new(Symbol::new("BTC/USDT"), *KRAKEN_VENUE);
1865 let instrument = InstrumentAny::CurrencyPair(
1866 CurrencyPair::builder()
1867 .instrument_id(instrument_id)
1868 .raw_symbol(Symbol::new("XBTUSDT"))
1869 .base_currency(Currency::BTC())
1870 .quote_currency(Currency::USDT())
1871 .price_precision(2)
1872 .size_precision(8)
1873 .price_increment(Price::from("0.01"))
1874 .size_increment(Quantity::from("0.00000001"))
1875 .ts_event(TS)
1876 .ts_init(TS)
1877 .build()
1878 .unwrap(),
1879 );
1880
1881 let (order_id, order) = orders.iter().next().unwrap();
1882
1883 let report =
1884 parse_order_status_report(order_id, order, &instrument, account_id, TS).unwrap();
1885
1886 assert_eq!(report.account_id, account_id);
1887 assert_eq!(report.instrument_id, instrument_id);
1888 assert_eq!(report.venue_order_id.as_str(), order_id);
1889 assert_eq!(report.order_status, OrderStatus::Accepted);
1890 assert_eq!(report.quantity, Quantity::from("0.50000000"));
1891 }
1892
1893 fn create_mock_perp() -> InstrumentAny {
1894 let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
1895 InstrumentAny::CryptoPerpetual(
1896 CryptoPerpetual::builder()
1897 .instrument_id(instrument_id)
1898 .raw_symbol(Symbol::new("PI_XBTUSD"))
1899 .base_currency(Currency::BTC())
1900 .quote_currency(Currency::USD())
1901 .settlement_currency(Currency::USD())
1902 .is_inverse(false)
1903 .price_precision(1)
1904 .size_precision(0)
1905 .price_increment(Price::from("0.5"))
1906 .size_increment(Quantity::from("1"))
1907 .ts_event(TS)
1908 .ts_init(TS)
1909 .build()
1910 .unwrap(),
1911 )
1912 }
1913
1914 #[rstest]
1915 fn test_parse_futures_assignee_fill_report() {
1916 let json = load_test_json("http_futures_fills.json");
1917 let response: FuturesFillsResponse = serde_json::from_str(&json).unwrap();
1918 let fill = &response.fills[2];
1919 let instrument = create_mock_perp();
1920 let account_id = AccountId::new("KRAKEN-001");
1921
1922 let report = parse_futures_fill_report(fill, &instrument, account_id, TS).unwrap();
1923
1924 assert_eq!(report.account_id, account_id);
1925 assert_eq!(report.instrument_id, instrument.id());
1926 assert_eq!(
1927 report.venue_order_id,
1928 VenueOrderId::new("f8a7b6c5-d4e3-2f1a-0b9c-8d7e6f5a4b3c")
1929 );
1930 assert_eq!(
1931 report.trade_id,
1932 TradeId::new("d3f4e5a6-b7c8-9d0e-1f2a-3b4c5d6e7f8a")
1933 );
1934 assert_eq!(report.order_side, OrderSide::Sell);
1935 assert_eq!(report.last_qty, Quantity::from("2500"));
1936 assert_eq!(report.last_px, Price::from("28050.0"));
1937 assert_eq!(report.commission, Money::zero(Currency::USD()));
1938 assert_eq!(report.liquidity_side, LiquiditySide::NoLiquiditySide);
1939 assert_eq!(report.avg_px, None);
1940 assert_eq!(
1941 report.ts_event,
1942 "2023-04-07T15:55:20.123Z".parse::<UnixNanos>().unwrap()
1943 );
1944 assert_eq!(report.ts_init, TS);
1945 assert_eq!(report.client_order_id, None);
1946 assert_eq!(report.venue_position_id, None);
1947 }
1948
1949 #[rstest]
1950 fn test_parse_futures_order_status_report_market_if_touched() {
1951 let order = FuturesOpenOrder {
1952 order_id: "tp-001".to_string(),
1953 symbol: "PI_XBTUSD".to_string(),
1954 side: KrakenOrderSide::Buy,
1955 order_type: KrakenFuturesOrderType::TakeProfit,
1956 limit_price: None,
1957 stop_price: Some(dec!(36000)),
1958 unfilled_size: Some(dec!(500)),
1959 received_time: "2023-11-14T22:13:20.000Z".to_string(),
1960 status: KrakenFuturesOrderStatus::PartiallyFilled,
1961 filled_size: dec!(25),
1962 reduce_only: Some(true),
1963 last_update_time: "2023-11-14T22:13:20.000Z".to_string(),
1964 trigger_signal: None,
1965 cli_ord_id: Some("my-tp-1".to_string()),
1966 };
1967 let instrument = create_mock_perp();
1968 let account_id = AccountId::new("KRAKEN-001");
1969
1970 let report =
1971 parse_futures_order_status_report(&order, &instrument, account_id, None, TS).unwrap();
1972
1973 assert_eq!(report.order_type, OrderType::MarketIfTouched);
1974 assert_eq!(report.quantity.as_decimal(), dec!(525));
1975 assert_eq!(report.filled_qty.as_decimal(), dec!(25));
1976 assert_eq!(report.trigger_price.unwrap().as_decimal(), dec!(36000));
1977 assert!(report.price.is_none());
1978 assert!(report.reduce_only);
1979 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1980 }
1981
1982 #[rstest]
1983 fn test_parse_futures_order_status_report_limit_if_touched() {
1984 let order = FuturesOpenOrder {
1985 order_id: "tpl-001".to_string(),
1986 symbol: "PI_XBTUSD".to_string(),
1987 side: KrakenOrderSide::Sell,
1988 order_type: KrakenFuturesOrderType::TakeProfit,
1989 limit_price: Some(dec!(35500)),
1990 stop_price: Some(dec!(36000)),
1991 unfilled_size: Some(dec!(500)),
1992 received_time: "2023-11-14T22:13:20.000Z".to_string(),
1993 status: KrakenFuturesOrderStatus::Untouched,
1994 filled_size: dec!(0),
1995 reduce_only: None,
1996 last_update_time: "2023-11-14T22:13:20.000Z".to_string(),
1997 trigger_signal: None,
1998 cli_ord_id: Some("my-tpl-1".to_string()),
1999 };
2000 let instrument = create_mock_perp();
2001 let account_id = AccountId::new("KRAKEN-001");
2002
2003 let report =
2004 parse_futures_order_status_report(&order, &instrument, account_id, None, TS).unwrap();
2005
2006 assert_eq!(report.order_type, OrderType::LimitIfTouched);
2007 assert_eq!(report.trigger_price.unwrap().as_decimal(), dec!(36000));
2008 assert_eq!(report.price.unwrap().as_decimal(), dec!(35500));
2009 assert_eq!(report.order_side, OrderSide::Sell.into());
2010 assert!(!report.reduce_only);
2011 }
2012
2013 #[rstest]
2014 fn test_parse_futures_order_event_market_if_touched() {
2015 let event = FuturesOrderEvent {
2016 order_id: "tp-evt-001".to_string(),
2017 cli_ord_id: None,
2018 order_type: KrakenFuturesOrderType::TakeProfit,
2019 symbol: "PI_XBTUSD".to_string(),
2020 side: KrakenOrderSide::Buy,
2021 quantity: dec!(100),
2022 filled: dec!(100),
2023 limit_price: None,
2024 stop_price: Some(dec!(40000)),
2025 timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2026 last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2027 reduce_only: false,
2028 };
2029 let instrument = create_mock_perp();
2030 let account_id = AccountId::new("KRAKEN-001");
2031
2032 let report = parse_futures_order_event_status_report(
2033 &event,
2034 Some(KrakenFuturesOrderEventType::Fill),
2035 &instrument,
2036 account_id,
2037 TS,
2038 )
2039 .unwrap();
2040
2041 assert_eq!(report.order_type, OrderType::MarketIfTouched);
2042 assert_eq!(report.trigger_price.unwrap().as_decimal(), dec!(40000));
2043 assert!(report.price.is_none());
2044 assert_eq!(report.order_status, OrderStatus::Filled);
2045 }
2046
2047 #[rstest]
2048 fn test_parse_futures_order_event_limit_if_touched() {
2049 let event = FuturesOrderEvent {
2050 order_id: "tpl-evt-001".to_string(),
2051 cli_ord_id: Some("my-tpl-evt".to_string()),
2052 order_type: KrakenFuturesOrderType::TakeProfit,
2053 symbol: "PI_XBTUSD".to_string(),
2054 side: KrakenOrderSide::Sell,
2055 quantity: dec!(200),
2056 filled: Decimal::ZERO,
2057 limit_price: Some(dec!(39500)),
2058 stop_price: Some(dec!(40000)),
2059 timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2060 last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2061 reduce_only: true,
2062 };
2063 let instrument = create_mock_perp();
2064 let account_id = AccountId::new("KRAKEN-001");
2065
2066 let report = parse_futures_order_event_status_report(
2067 &event,
2068 Some(KrakenFuturesOrderEventType::Place),
2069 &instrument,
2070 account_id,
2071 TS,
2072 )
2073 .unwrap();
2074
2075 assert_eq!(report.order_type, OrderType::LimitIfTouched);
2076 assert_eq!(report.trigger_price.unwrap().as_decimal(), dec!(40000));
2077 assert_eq!(report.price.unwrap().as_decimal(), dec!(39500));
2078 assert_eq!(report.order_side, OrderSide::Sell.into());
2079 assert_eq!(report.order_status, OrderStatus::Accepted);
2080 assert!(report.reduce_only);
2081 }
2082
2083 #[rstest]
2084 fn test_parse_futures_order_event_cancel_status() {
2085 let event = FuturesOrderEvent {
2086 order_id: "cancel-evt-001".to_string(),
2087 cli_ord_id: Some("cancel-evt".to_string()),
2088 order_type: KrakenFuturesOrderType::Stop,
2089 symbol: "PI_XBTUSD".to_string(),
2090 side: KrakenOrderSide::Sell,
2091 quantity: dec!(200),
2092 filled: Decimal::ZERO,
2093 limit_price: None,
2094 stop_price: Some(dec!(39000)),
2095 timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2096 last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2097 reduce_only: true,
2098 };
2099 let instrument = create_mock_perp();
2100 let account_id = AccountId::new("KRAKEN-001");
2101
2102 let report = parse_futures_order_event_status_report(
2103 &event,
2104 Some(KrakenFuturesOrderEventType::Cancel),
2105 &instrument,
2106 account_id,
2107 TS,
2108 )
2109 .unwrap();
2110
2111 assert_eq!(report.order_status, OrderStatus::Canceled);
2112 assert!(report.reduce_only);
2113 }
2114
2115 #[rstest]
2116 fn test_parse_futures_order_event_reject_status() {
2117 let event = FuturesOrderEvent {
2118 order_id: "reject-evt-001".to_string(),
2119 cli_ord_id: Some("reject-evt".to_string()),
2120 order_type: KrakenFuturesOrderType::Limit,
2121 symbol: "PI_XBTUSD".to_string(),
2122 side: KrakenOrderSide::Buy,
2123 quantity: dec!(200),
2124 filled: Decimal::ZERO,
2125 limit_price: Some(dec!(35000)),
2126 stop_price: None,
2127 timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2128 last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2129 reduce_only: false,
2130 };
2131 let instrument = create_mock_perp();
2132 let account_id = AccountId::new("KRAKEN-001");
2133
2134 let report = parse_futures_order_event_status_report(
2135 &event,
2136 Some(KrakenFuturesOrderEventType::Reject),
2137 &instrument,
2138 account_id,
2139 TS,
2140 )
2141 .unwrap();
2142
2143 assert_eq!(report.order_status, OrderStatus::Rejected);
2144 }
2145
2146 #[rstest]
2147 fn test_parse_futures_order_event_expire_status() {
2148 let event = FuturesOrderEvent {
2149 order_id: "expire-evt-001".to_string(),
2150 cli_ord_id: Some("expire-evt".to_string()),
2151 order_type: KrakenFuturesOrderType::Limit,
2152 symbol: "PI_XBTUSD".to_string(),
2153 side: KrakenOrderSide::Buy,
2154 quantity: dec!(200),
2155 filled: Decimal::ZERO,
2156 limit_price: Some(dec!(35000)),
2157 stop_price: None,
2158 timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2159 last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2160 reduce_only: false,
2161 };
2162 let instrument = create_mock_perp();
2163 let account_id = AccountId::new("KRAKEN-001");
2164
2165 let report = parse_futures_order_event_status_report(
2166 &event,
2167 Some(KrakenFuturesOrderEventType::Expire),
2168 &instrument,
2169 account_id,
2170 TS,
2171 )
2172 .unwrap();
2173
2174 assert_eq!(report.order_status, OrderStatus::Expired);
2175 }
2176
2177 #[rstest]
2178 fn test_parse_futures_order_event_execution_status() {
2179 let event = FuturesOrderEvent {
2180 order_id: "execution-evt-001".to_string(),
2181 cli_ord_id: Some("execution-evt".to_string()),
2182 order_type: KrakenFuturesOrderType::Limit,
2183 symbol: "PI_XBTUSD".to_string(),
2184 side: KrakenOrderSide::Buy,
2185 quantity: dec!(200),
2186 filled: dec!(50),
2187 limit_price: Some(dec!(35000)),
2188 stop_price: None,
2189 timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2190 last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2191 reduce_only: false,
2192 };
2193 let instrument = create_mock_perp();
2194 let account_id = AccountId::new("KRAKEN-001");
2195
2196 let report = parse_futures_order_event_status_report(
2197 &event,
2198 Some(KrakenFuturesOrderEventType::Execution),
2199 &instrument,
2200 account_id,
2201 TS,
2202 )
2203 .unwrap();
2204
2205 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
2206 }
2207
2208 #[rstest]
2209 fn test_parse_fill_report() {
2210 let json = load_test_json("http_trades_history.json");
2211 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
2212 let result = wrapper.get("result").unwrap();
2213 let trades_map = result.get("trades").unwrap();
2214 let trades: IndexMap<String, SpotTrade> =
2215 serde_json::from_value(trades_map.clone()).unwrap();
2216
2217 let account_id = AccountId::new("KRAKEN-001");
2218 let instrument_id = InstrumentId::new(Symbol::new("BTC/USDT"), *KRAKEN_VENUE);
2219 let instrument = InstrumentAny::CurrencyPair(
2220 CurrencyPair::builder()
2221 .instrument_id(instrument_id)
2222 .raw_symbol(Symbol::new("XBTUSDT"))
2223 .base_currency(Currency::BTC())
2224 .quote_currency(Currency::USDT())
2225 .price_precision(2)
2226 .size_precision(8)
2227 .price_increment(Price::from("0.01"))
2228 .size_increment(Quantity::from("0.00000001"))
2229 .ts_event(TS)
2230 .ts_init(TS)
2231 .build()
2232 .unwrap(),
2233 );
2234
2235 let (trade_id, trade) = trades.iter().next().unwrap();
2236
2237 let report = parse_fill_report(trade_id, trade, &instrument, account_id, TS).unwrap();
2238
2239 assert_eq!(report.account_id, account_id);
2240 assert_eq!(report.instrument_id, instrument_id);
2241 assert_eq!(report.trade_id.to_string(), *trade_id);
2242 assert_eq!(report.last_qty, Quantity::from("0.50000000"));
2243 assert_eq!(report.last_px, Price::from("29500.50"));
2244 assert_eq!(report.commission.as_decimal(), dec!(23.60));
2245 }
2246
2247 #[rstest]
2248 #[case("XXBT", "XBT")]
2249 #[case("XETH", "ETH")]
2250 #[case("ZUSD", "USD")]
2251 #[case("ZEUR", "EUR")]
2252 #[case("BTC", "BTC")]
2253 #[case("ETH", "ETH")]
2254 #[case("USDT", "USDT")]
2255 #[case("SOL", "SOL")]
2256 fn test_normalize_currency_code(#[case] input: &str, #[case] expected: &str) {
2257 assert_eq!(normalize_currency_code(input), expected);
2258 }
2259
2260 #[rstest]
2261 #[case("XBT/EUR", "BTC/EUR")]
2262 #[case("XBT/USD", "BTC/USD")]
2263 #[case("XBT/USDT", "BTC/USDT")]
2264 #[case("ETH/USD", "ETH/USD")]
2265 #[case("ETH/XBT", "ETH/BTC")]
2266 #[case("SOL/XBT", "SOL/BTC")]
2267 #[case("SOL/USD", "SOL/USD")]
2268 #[case("BTC/USD", "BTC/USD")]
2269 #[case("ETH/BTC", "ETH/BTC")]
2270 #[case("XDG/USD", "DOGE/USD")]
2271 #[case("XDG/EUR", "DOGE/EUR")]
2272 #[case("XDG/BTC", "DOGE/BTC")]
2273 #[case("XDG/XBT", "DOGE/BTC")]
2274 fn test_normalize_spot_symbol(#[case] input: &str, #[case] expected: &str) {
2275 assert_eq!(normalize_spot_symbol(input), expected);
2276 }
2277
2278 #[rstest]
2279 #[case("A", "A")] #[case("O2026022700232", "O2026022700232")] #[case("ABCDEFGHIJKLMNOPQR", "ABCDEFGHIJKLMNOPQR")] fn test_truncate_cl_ord_id_short_passthrough(#[case] input: &str, #[case] expected: &str) {
2283 let id = ClientOrderId::new(input);
2284 assert_eq!(truncate_cl_ord_id(&id), expected);
2285 }
2286
2287 #[rstest]
2288 #[case("6d47a5f0-6fd4-4b84-b56e-c23f0f689c20")] #[case("6D47A5F0-6FD4-4B84-B56E-C23F0F689C20")] #[case("00000000-0000-0000-0000-000000000000")] #[case("ffffffff-ffff-ffff-ffff-ffffffffffff")] fn test_truncate_cl_ord_id_uuid_hyphenated_passthrough(#[case] input: &str) {
2293 let id = ClientOrderId::new(input);
2294 assert_eq!(truncate_cl_ord_id(&id), input);
2295 }
2296
2297 #[rstest]
2298 #[case("6d47a5f06fd44b84b56ec23f0f689c20")] #[case("6D47A5F06FD44B84B56EC23F0F689C20")] #[case("00000000000000000000000000000000")] #[case("aAbBcCdDeEfF00112233445566778899")] fn test_truncate_cl_ord_id_uuid_compact_passthrough(#[case] input: &str) {
2303 let id = ClientOrderId::new(input);
2304 assert_eq!(truncate_cl_ord_id(&id), input);
2305 }
2306
2307 #[rstest]
2308 #[case("O2026022700232100400", "O26022700232100400")] #[case("O202602270023210040011", "O02270023210040011")] #[case("O20260227002321004001100", "O27002321004001100")] fn test_truncate_cl_ord_id_sequential_truncated(#[case] input: &str, #[case] expected: &str) {
2312 let id = ClientOrderId::new(input);
2313 let result = truncate_cl_ord_id(&id);
2314 assert_eq!(result, expected);
2315 assert_eq!(result.len(), 18);
2316 assert!(result.starts_with('O'));
2317 }
2318
2319 #[rstest]
2320 fn test_truncate_cl_ord_id_32_chars_non_hex_truncated() {
2321 let input = "0123456789abcdef0123456789abcdeg";
2322 let id = ClientOrderId::new(input);
2323 let result = truncate_cl_ord_id(&id);
2324 assert_eq!(result.len(), 18);
2325 assert!(result.starts_with('O'));
2326 assert_eq!(result, "Of0123456789abcdeg");
2327 }
2328
2329 #[rstest]
2330 fn test_truncate_cl_ord_id_36_chars_wrong_hyphens_truncated() {
2331 let input = "6d47a5f0-6fd4-4b84-b56ec23f0f689c200";
2332 let id = ClientOrderId::new(input);
2333 let result = truncate_cl_ord_id(&id);
2334 assert_eq!(result.len(), 18);
2335 assert!(result.starts_with('O'));
2336 }
2337
2338 #[rstest]
2339 fn test_parse_tokenized_instrument() {
2340 let json = load_test_json("http_asset_pairs_tokenized.json");
2341 let response: KrakenResponse<AssetPairsResponse> = serde_json::from_str(&json).unwrap();
2342 let pairs = response.result.unwrap();
2343
2344 let (pair_name, definition) = pairs.iter().next().unwrap();
2345
2346 let instrument = parse_tokenized_instrument(pair_name, definition, TS, TS).unwrap();
2347
2348 match instrument {
2349 InstrumentAny::TokenizedAsset(ta) => {
2350 assert_eq!(ta.id.symbol.as_str(), "AAPLx/USD");
2351 assert_eq!(ta.id.venue.as_str(), "KRAKEN");
2352 assert_eq!(ta.raw_symbol.as_str(), "AAPLxUSD");
2353 assert_eq!(ta.asset_class, AssetClass::Equity);
2354 assert_eq!(ta.base_currency.code, "AAPLx");
2355 assert_eq!(ta.quote_currency.code, "ZUSD");
2356 assert_eq!(ta.price_precision, 2);
2357 assert_eq!(ta.size_precision, 8);
2358 assert_eq!(ta.price_increment.as_decimal(), dec!(0.01));
2359 assert_eq!(ta.size_increment.as_decimal(), dec!(0.00000001));
2360 assert!(ta.min_quantity.is_some());
2361 assert_eq!(ta.maker_fee, dec!(-0.0002));
2362 assert_eq!(ta.taker_fee, dec!(0.001));
2363 }
2364 _ => panic!("Expected TokenizedAsset, received {instrument:?}"),
2365 }
2366 }
2367
2368 #[rstest]
2369 fn test_parse_tokenized_instrument_with_account_fee_rates() {
2370 let json = load_test_json("http_asset_pairs_tokenized.json");
2371 let response: KrakenResponse<AssetPairsResponse> = serde_json::from_str(&json).unwrap();
2372 let pairs = response.result.unwrap();
2373 let (pair_name, definition) = pairs.iter().next().unwrap();
2374
2375 let instrument = parse_tokenized_instrument_with_fee_rates(
2376 pair_name,
2377 definition,
2378 Some((dec!(0.0003), dec!(0.0019))),
2379 TS,
2380 TS,
2381 )
2382 .unwrap();
2383
2384 match instrument {
2385 InstrumentAny::TokenizedAsset(asset) => {
2386 assert_eq!(asset.maker_fee, dec!(0.0003));
2387 assert_eq!(asset.taker_fee, dec!(0.0019));
2388 }
2389 _ => panic!("Expected TokenizedAsset, received {instrument:?}"),
2390 }
2391 }
2392
2393 #[rstest]
2394 fn test_parse_fill_report_tokenized_asset() {
2395 let json = load_test_json("http_trades_history.json");
2396 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
2397 let result = wrapper.get("result").unwrap();
2398 let trades_map = result.get("trades").unwrap();
2399 let trades: IndexMap<String, SpotTrade> =
2400 serde_json::from_value(trades_map.clone()).unwrap();
2401
2402 let account_id = AccountId::new("KRAKEN-001");
2403 let instrument_id = InstrumentId::new(Symbol::new("AAPLx/USD"), *KRAKEN_VENUE);
2404 let instrument = InstrumentAny::TokenizedAsset(
2405 TokenizedAsset::builder()
2406 .instrument_id(instrument_id)
2407 .raw_symbol(Symbol::new("AAPLxUSD"))
2408 .asset_class(AssetClass::Equity)
2409 .base_currency(Currency::get_or_create_crypto("AAPLx"))
2410 .quote_currency(Currency::USD())
2411 .price_precision(2)
2412 .size_precision(8)
2413 .price_increment(Price::from("0.01"))
2414 .size_increment(Quantity::from("0.00000001"))
2415 .ts_event(TS)
2416 .ts_init(TS)
2417 .build()
2418 .unwrap(),
2419 );
2420
2421 let (trade_id, trade) = trades.iter().next().unwrap();
2422
2423 let report = parse_fill_report(trade_id, trade, &instrument, account_id, TS).unwrap();
2424
2425 assert_eq!(report.account_id, account_id);
2426 assert_eq!(report.instrument_id, instrument_id);
2427 assert_eq!(report.trade_id.to_string(), *trade_id);
2428 assert_eq!(report.last_qty, Quantity::from("0.50000000"));
2429 assert_eq!(report.last_px, Price::from("29500.50"));
2430 assert_eq!(report.commission.currency, Currency::USD());
2431 }
2432
2433 #[rstest]
2434 fn test_truncate_cl_ord_id_19_chars_truncated() {
2435 let input = "O202602270023210040";
2436 assert_eq!(input.len(), 19);
2437 let id = ClientOrderId::new(input);
2438 let result = truncate_cl_ord_id(&id);
2439 assert_eq!(result.len(), 18);
2440 assert_eq!(result, "O02602270023210040");
2441 }
2442
2443 #[rstest]
2444 fn test_truncate_cl_ord_id_preserves_tail() {
2445 let input = "O20260227002321004001100";
2446 let id = ClientOrderId::new(input);
2447 let result = truncate_cl_ord_id(&id);
2448 assert_eq!(&result[1..], &input[input.len() - 17..]);
2449 }
2450}