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