1use std::{fmt::Display, str::FromStr};
19
20use anyhow::Context;
21use indexmap::IndexMap;
22use nautilus_core::{Params, datetime::NANOSECONDS_IN_MILLISECOND, nanos::UnixNanos, uuid::UUID4};
23use nautilus_model::{
24 data::{Bar, BarType, TradeTick},
25 enums::{
26 AggressorSide, AssetClass, AvgPxReconciliation, BarAggregation, LiquiditySide, OrderSide,
27 OrderStatus, OrderType, PositionSide, TimeInForce, TriggerType,
28 },
29 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
30 instruments::{
31 Instrument, any::InstrumentAny, crypto_perpetual::CryptoPerpetual,
32 currency_pair::CurrencyPair, tokenized_asset::TokenizedAsset,
33 },
34 reports::{FillReport, OrderStatusReport, PositionStatusReport},
35 types::{Currency, Money, Price, Quantity, fixed::FIXED_PRECISION},
36};
37use rust_decimal::Decimal;
38use rust_decimal_macros::dec;
39
40use crate::{
41 common::{
42 consts::{KRAKEN_ALTNAME_KEY, KRAKEN_PAIR_DECIMALS_KEY, KRAKEN_VENUE},
43 enums::{
44 KrakenFuturesOrderEventType, KrakenFuturesOrderLifecycleStatus, KrakenInstrumentType,
45 KrakenOrderType, KrakenPositionSide, KrakenSpotTrigger, KrakenTriggerSignal,
46 },
47 },
48 http::models::{
49 AssetPairInfo, FuturesFill, FuturesInstrument, FuturesOpenOrder, FuturesOrderEvent,
50 FuturesOrderStatusDetails, FuturesPosition, FuturesPublicExecution, OhlcData, SpotOrder,
51 SpotTrade,
52 },
53};
54
55pub fn parse_decimal(value: &str) -> anyhow::Result<Decimal> {
57 if value.is_empty() || value == "0" {
58 return Ok(dec!(0));
59 }
60 value
61 .parse::<Decimal>()
62 .map_err(|e| anyhow::anyhow!("Failed to parse decimal '{value}': {e}"))
63}
64
65fn parse_rfc3339_timestamp(value: &str, field: &str) -> anyhow::Result<UnixNanos> {
66 value
67 .parse::<UnixNanos>()
68 .map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
69}
70
71#[inline]
77pub fn normalize_currency_code(code: &str) -> &str {
78 KRAKEN_ASSET_CODES
79 .iter()
80 .find(|(kraken, _)| *kraken == code)
81 .map_or(code, |(_, standard)| *standard)
82}
83
84pub fn normalize_asset_key(code: &str) -> String {
89 let upper = code.to_uppercase();
90 normalize_currency_code(&upper).to_string()
91}
92
93const KRAKEN_ASSET_CODES: &[(&str, &str)] = &[
102 ("XXBT", "BTC"),
103 ("XBT", "BTC"),
104 ("XXDG", "DOGE"),
105 ("XDG", "DOGE"),
106 ("XETC", "ETC"),
107 ("XETH", "ETH"),
108 ("XLTC", "LTC"),
109 ("XMLN", "MLN"),
110 ("XREP", "REP"),
111 ("XXLM", "XLM"),
112 ("XXMR", "XMR"),
113 ("XXRP", "XRP"),
114 ("XZEC", "ZEC"),
115 ("ZARS", "ARS"),
116 ("ZAUD", "AUD"),
117 ("ZCAD", "CAD"),
118 ("ZCLP", "CLP"),
119 ("ZCOP", "COP"),
120 ("ZDKK", "DKK"),
121 ("ZEUR", "EUR"),
122 ("ZGBP", "GBP"),
123 ("ZGEL", "GEL"),
124 ("ZGHS", "GHS"),
125 ("ZJPY", "JPY"),
126 ("ZLKR", "LKR"),
127 ("ZMXN", "MXN"),
128 ("ZPLN", "PLN"),
129 ("ZSEK", "SEK"),
130 ("ZUGX", "UGX"),
131 ("ZUSD", "USD"),
132 ("ZVND", "VND"),
133 ("ZXOF", "XOF"),
134 ("KFEE", "FEE"),
135];
136
137#[inline]
144pub fn normalize_spot_symbol(symbol: &str) -> String {
145 let Some((base, quote)) = symbol.split_once('/') else {
146 return symbol.to_string();
147 };
148 let base = normalize_currency_code(base);
149 let quote = normalize_currency_code(quote);
150 format!("{base}/{quote}")
151}
152
153pub fn parse_decimal_opt(value: Option<&str>) -> anyhow::Result<Option<Decimal>> {
155 match value {
156 Some(s) if !s.is_empty() && s != "0" => Ok(Some(parse_decimal(s)?)),
157 _ => Ok(None),
158 }
159}
160
161fn parse_trigger_type(
163 order_type: OrderType,
164 trigger: Option<KrakenSpotTrigger>,
165) -> Option<TriggerType> {
166 let is_conditional = matches!(
167 order_type,
168 OrderType::StopMarket
169 | OrderType::StopLimit
170 | OrderType::MarketIfTouched
171 | OrderType::LimitIfTouched
172 );
173
174 if !is_conditional {
175 return None;
176 }
177
178 match trigger {
179 Some(KrakenSpotTrigger::Last) => Some(TriggerType::LastPrice),
180 Some(KrakenSpotTrigger::Index) => Some(TriggerType::IndexPrice),
181 None => Some(TriggerType::Default),
182 }
183}
184
185fn parse_futures_trigger_type(
187 order_type: OrderType,
188 trigger_signal: Option<KrakenTriggerSignal>,
189) -> Option<TriggerType> {
190 let is_conditional = matches!(
191 order_type,
192 OrderType::StopMarket
193 | OrderType::StopLimit
194 | OrderType::MarketIfTouched
195 | OrderType::LimitIfTouched
196 );
197
198 if !is_conditional {
199 return None;
200 }
201
202 match trigger_signal {
203 Some(KrakenTriggerSignal::Last) => Some(TriggerType::LastPrice),
204 Some(KrakenTriggerSignal::Mark) => Some(TriggerType::MarkPrice),
205 Some(KrakenTriggerSignal::Index) => Some(TriggerType::IndexPrice),
206 Some(KrakenTriggerSignal::Unknown) => {
207 log::warn!(
208 "KrakenTriggerSignal::Unknown received from venue, defaulting to Default trigger"
209 );
210 Some(TriggerType::Default)
211 }
212 None => Some(TriggerType::Default),
213 }
214}
215
216pub fn parse_spot_instrument(
225 pair_name: &str,
226 definition: &AssetPairInfo,
227 ts_event: UnixNanos,
228 ts_init: UnixNanos,
229) -> anyhow::Result<InstrumentAny> {
230 let symbol_str = definition.wsname.as_ref().unwrap_or(&definition.altname);
231 let normalized_symbol = normalize_spot_symbol(symbol_str);
232 let instrument_id = InstrumentId::new(Symbol::new(&normalized_symbol), *KRAKEN_VENUE);
233 let raw_symbol = Symbol::new(pair_name);
234
235 let base_currency = get_currency(definition.base.as_str());
236 let quote_currency = get_currency(definition.quote.as_str());
237
238 let price_increment = parse_price(
239 definition
240 .tick_size
241 .as_ref()
242 .context("tick_size is required")?,
243 "tick_size",
244 )?;
245
246 let size_precision = definition.lot_decimals;
248 let size_increment = Quantity::from_decimal_dp(
249 Decimal::try_new(1, u32::from(size_precision)).context("Invalid lot_decimals")?,
250 size_precision,
251 )?;
252
253 let min_quantity = definition
254 .ordermin
255 .as_ref()
256 .map(|s| parse_quantity(s, "ordermin"))
257 .transpose()?;
258
259 let instrument = CurrencyPair::builder()
260 .instrument_id(instrument_id)
261 .raw_symbol(raw_symbol)
262 .base_currency(base_currency)
263 .quote_currency(quote_currency)
264 .price_precision(price_increment.precision)
265 .size_precision(size_increment.precision)
266 .price_increment(price_increment)
267 .size_increment(size_increment)
268 .maybe_min_quantity(min_quantity)
269 .maybe_info(pair_info(pair_name, definition, price_increment.precision))
270 .ts_event(ts_event)
271 .ts_init(ts_init)
272 .build()
273 .unwrap();
274
275 Ok(InstrumentAny::CurrencyPair(instrument))
276}
277
278pub fn parse_tokenized_instrument(
287 pair_name: &str,
288 definition: &AssetPairInfo,
289 ts_event: UnixNanos,
290 ts_init: UnixNanos,
291) -> anyhow::Result<InstrumentAny> {
292 let symbol_str = definition.wsname.as_ref().unwrap_or(&definition.altname);
293 let normalized_symbol = normalize_spot_symbol(symbol_str);
294 let instrument_id = InstrumentId::new(Symbol::new(&normalized_symbol), *KRAKEN_VENUE);
295 let raw_symbol = Symbol::new(pair_name);
296
297 let base_currency = get_currency(definition.base.as_str());
298 let quote_currency = get_currency(definition.quote.as_str());
299
300 let price_increment = parse_price(
301 definition
302 .tick_size
303 .as_ref()
304 .context("tick_size is required")?,
305 "tick_size",
306 )?;
307
308 let size_precision = definition.lot_decimals;
309 let size_increment = Quantity::from_decimal_dp(
310 Decimal::try_new(1, u32::from(size_precision)).context("Invalid lot_decimals")?,
311 size_precision,
312 )?;
313
314 let min_quantity = definition
315 .ordermin
316 .as_ref()
317 .map(|s| parse_quantity(s, "ordermin"))
318 .transpose()?;
319
320 let instrument = TokenizedAsset::builder()
321 .instrument_id(instrument_id)
322 .raw_symbol(raw_symbol)
323 .asset_class(AssetClass::Equity)
324 .base_currency(base_currency)
325 .quote_currency(quote_currency)
326 .price_precision(price_increment.precision)
327 .size_precision(size_increment.precision)
328 .price_increment(price_increment)
329 .maybe_info(price_scale_info(definition, price_increment.precision))
330 .size_increment(size_increment)
331 .maybe_min_quantity(min_quantity)
332 .ts_event(ts_event)
333 .ts_init(ts_init)
334 .build()
335 .unwrap();
336
337 Ok(InstrumentAny::TokenizedAsset(instrument))
338}
339
340pub fn parse_futures_instrument(
354 instrument: &FuturesInstrument,
355 ts_event: UnixNanos,
356 ts_init: UnixNanos,
357) -> anyhow::Result<InstrumentAny> {
358 let instrument_id = InstrumentId::new(Symbol::new(&instrument.symbol), *KRAKEN_VENUE);
359 let raw_symbol = Symbol::new(&instrument.symbol);
360
361 let base_currency = get_currency(&instrument.base);
362 let quote_currency = get_currency(&instrument.quote);
363
364 let is_inverse = instrument.instrument_type == KrakenInstrumentType::FuturesInverse;
365 let settlement_currency = if is_inverse {
366 base_currency
367 } else {
368 quote_currency
369 };
370
371 let tick_size = instrument.tick_size.normalize();
373 let price_precision = tick_size.scale();
374 check_futures_precision(&instrument.symbol, "tick_size", tick_size, price_precision)?;
375 let price_precision = u8::try_from(price_precision).context("Invalid tick_size precision")?;
376 let price_increment = Price::from_decimal_dp(tick_size, price_precision)?;
377
378 let size_increment = if instrument.contract_value_trade_precision >= 0 {
383 let precision = u32::try_from(instrument.contract_value_trade_precision)
384 .context("Invalid contract_value_trade_precision")?;
385 check_futures_precision(
386 &instrument.symbol,
387 "contract_value_trade_precision",
388 instrument.contract_value_trade_precision,
389 precision,
390 )?;
391 let precision =
392 u8::try_from(precision).context("Invalid contract_value_trade_precision")?;
393 Quantity::from_decimal_dp(
394 Decimal::try_new(1, u32::from(precision))
395 .context("Invalid contract_value_trade_precision")?,
396 precision,
397 )?
398 } else {
399 let exponent = instrument.contract_value_trade_precision.unsigned_abs();
401 let increment_value = 10_i64
402 .checked_pow(exponent)
403 .context("contract_value_trade_precision exceeds supported range")?;
404 Quantity::from_decimal_dp(Decimal::from(increment_value), 0)?
405 };
406
407 let contract_size = instrument.contract_size.normalize();
408 let multiplier_precision = contract_size.scale();
409 check_futures_precision(
410 &instrument.symbol,
411 "contract_size",
412 contract_size,
413 multiplier_precision,
414 )?;
415 let multiplier_precision =
416 u8::try_from(multiplier_precision).context("Invalid contract_size precision")?;
417 let multiplier = Some(Quantity::from_decimal_dp(
418 contract_size,
419 multiplier_precision,
420 )?);
421
422 let (margin_init, margin_maint) = instrument
424 .margin_levels
425 .first()
426 .map_or((None, None), |level| {
427 (Some(level.initial_margin), Some(level.maintenance_margin))
428 });
429
430 let instrument = CryptoPerpetual::builder()
431 .instrument_id(instrument_id)
432 .raw_symbol(raw_symbol)
433 .base_currency(base_currency)
434 .quote_currency(quote_currency)
435 .settlement_currency(settlement_currency)
436 .is_inverse(is_inverse)
437 .price_precision(price_increment.precision)
438 .size_precision(size_increment.precision)
439 .price_increment(price_increment)
440 .size_increment(size_increment)
441 .maybe_multiplier(multiplier)
442 .maybe_margin_init(margin_init)
443 .maybe_margin_maint(margin_maint)
444 .ts_event(ts_event)
445 .ts_init(ts_init)
446 .build()
447 .unwrap();
448
449 Ok(InstrumentAny::CryptoPerpetual(instrument))
450}
451
452fn check_futures_precision(
453 symbol: &str,
454 field: &str,
455 value: impl Display,
456 precision: u32,
457) -> anyhow::Result<()> {
458 if precision <= u32::from(FIXED_PRECISION) {
459 return Ok(());
460 }
461
462 #[cfg(feature = "high-precision")]
463 anyhow::bail!(
464 "Cannot parse Kraken Futures instrument '{symbol}': {field} {value} requires precision \
465 {precision}, but this build supports at most {FIXED_PRECISION}"
466 );
467
468 #[cfg(not(feature = "high-precision"))]
469 anyhow::bail!(
470 "Cannot parse Kraken Futures instrument '{symbol}': {field} {value} requires precision \
471 {precision}, but this build supports at most {FIXED_PRECISION}; enable the \
472 'high-precision' Cargo feature and rebuild"
473 );
474}
475
476fn parse_price(value: &str, field: &str) -> anyhow::Result<Price> {
477 Price::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
478}
479
480fn parse_quantity(value: &str, field: &str) -> anyhow::Result<Quantity> {
481 Quantity::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
482}
483
484pub fn fee_currency(reported: &str, instrument: &InstrumentAny) -> Currency {
490 let code = normalize_currency_code(reported);
491
492 for candidate in [
493 Some(instrument.quote_currency()),
494 instrument.base_currency(),
495 Some(instrument.settlement_currency()),
496 ]
497 .into_iter()
498 .flatten()
499 {
500 if candidate.code.as_str() == code {
501 return candidate;
502 }
503 }
504
505 get_currency(code)
506}
507
508pub fn get_currency(code: &str) -> Currency {
513 Currency::get_or_create_crypto(normalize_currency_code(code))
514}
515
516pub fn parse_trade_tick_from_array(
527 trade_array: &[serde_json::Value],
528 instrument: &InstrumentAny,
529 ts_init: UnixNanos,
530) -> anyhow::Result<TradeTick> {
531 let price_str = trade_array
532 .first()
533 .and_then(|v| v.as_str())
534 .context("Missing or invalid price")?;
535 let price = parse_price_with_precision(price_str, instrument.price_precision(), "trade.price")?;
536
537 let size_str = trade_array
538 .get(1)
539 .and_then(|v| v.as_str())
540 .context("Missing or invalid volume")?;
541 let size = parse_quantity_with_precision(size_str, instrument.size_precision(), "trade.size")?;
542
543 let time = trade_array
544 .get(2)
545 .and_then(|v| v.as_f64())
546 .context("Missing or invalid timestamp")?;
547 let ts_event = parse_millis_timestamp(time, "trade.time")?;
548
549 let side_str = trade_array
550 .get(3)
551 .and_then(|v| v.as_str())
552 .context("Missing or invalid side")?;
553 let aggressor = match side_str {
554 "b" => AggressorSide::Buy,
555 "s" => AggressorSide::Sell,
556 _ => AggressorSide::NoAggressor,
557 };
558
559 let trade_id_value = trade_array.get(6).context("Missing trade_id")?;
560 let trade_id = if let Some(id) = trade_id_value.as_i64() {
561 TradeId::new_checked(id.to_string())?
562 } else if let Some(id_str) = trade_id_value.as_str() {
563 TradeId::new_checked(id_str)?
564 } else {
565 anyhow::bail!("Invalid trade_id format");
566 };
567
568 TradeTick::new_checked(
569 instrument.id(),
570 price,
571 size,
572 aggressor,
573 trade_id,
574 ts_event,
575 ts_init,
576 )
577 .context("Failed to construct TradeTick from Kraken trade")
578}
579
580pub fn parse_futures_public_execution(
588 execution: &FuturesPublicExecution,
589 instrument: &InstrumentAny,
590 ts_init: UnixNanos,
591) -> anyhow::Result<TradeTick> {
592 let price =
593 parse_price_with_precision(&execution.price, instrument.price_precision(), "price")?;
594 let size = parse_quantity_with_precision(
595 &execution.quantity,
596 instrument.size_precision(),
597 "quantity",
598 )?;
599
600 let ts_event = UnixNanos::from((execution.timestamp as u64) * 1_000_000);
602
603 let aggressor = match execution.taker_order.direction.to_lowercase().as_str() {
605 "buy" => AggressorSide::Buy,
606 "sell" => AggressorSide::Sell,
607 _ => AggressorSide::NoAggressor,
608 };
609
610 let trade_id = TradeId::new_checked(&execution.uid)?;
611
612 TradeTick::new_checked(
613 instrument.id(),
614 price,
615 size,
616 aggressor,
617 trade_id,
618 ts_event,
619 ts_init,
620 )
621 .context("Failed to construct TradeTick from Kraken futures execution")
622}
623
624pub fn parse_bar(
632 ohlc: &OhlcData,
633 instrument: &InstrumentAny,
634 bar_type: BarType,
635 ts_init: UnixNanos,
636) -> anyhow::Result<Bar> {
637 let price_precision = instrument.price_precision();
638 let size_precision = instrument.size_precision();
639
640 let open = parse_price_with_precision(&ohlc.open, price_precision, "ohlc.open")?;
641 let high = parse_price_with_precision(&ohlc.high, price_precision, "ohlc.high")?;
642 let low = parse_price_with_precision(&ohlc.low, price_precision, "ohlc.low")?;
643 let close = parse_price_with_precision(&ohlc.close, price_precision, "ohlc.close")?;
644 let volume = parse_quantity_with_precision(&ohlc.volume, size_precision, "ohlc.volume")?;
645
646 let ts_event = UnixNanos::from((ohlc.time as u64) * 1_000_000_000);
647
648 Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
649 .context("Failed to construct Bar from Kraken OHLC")
650}
651
652fn parse_price_with_precision(value: &str, precision: u8, field: &str) -> anyhow::Result<Price> {
653 let parsed = value
654 .parse::<Decimal>()
655 .with_context(|| format!("Failed to parse {field}='{value}' as Decimal"))?;
656 Price::from_decimal_dp(parsed, precision).with_context(|| {
657 format!("Failed to construct Price for {field} with precision {precision}")
658 })
659}
660
661fn parse_quantity_with_precision(
662 value: &str,
663 precision: u8,
664 field: &str,
665) -> anyhow::Result<Quantity> {
666 let parsed = value
667 .parse::<Decimal>()
668 .with_context(|| format!("Failed to parse {field}='{value}' as Decimal"))?;
669 Quantity::from_decimal_dp(parsed, precision).with_context(|| {
670 format!("Failed to construct Quantity for {field} with precision {precision}")
671 })
672}
673
674pub fn parse_millis_timestamp(value: f64, field: &str) -> anyhow::Result<UnixNanos> {
675 let millis = (value * 1000.0) as u64;
676 let nanos = millis
677 .checked_mul(NANOSECONDS_IN_MILLISECOND)
678 .with_context(|| format!("{field} timestamp overflowed when converting to nanoseconds"))?;
679 Ok(UnixNanos::from(nanos))
680}
681
682fn pair_info(pair_name: &str, definition: &AssetPairInfo, price_precision: u8) -> Option<Params> {
689 let mut map = IndexMap::new();
690
691 if definition.altname.as_str() != pair_name {
692 map.insert(
693 KRAKEN_ALTNAME_KEY.to_string(),
694 serde_json::Value::String(definition.altname.to_string()),
695 );
696 }
697
698 insert_price_scale(&mut map, definition, price_precision);
699 (!map.is_empty()).then(|| Params::new(map))
700}
701
702fn price_scale_info(definition: &AssetPairInfo, price_precision: u8) -> Option<Params> {
708 let mut map = IndexMap::new();
709 insert_price_scale(&mut map, definition, price_precision);
710 (!map.is_empty()).then(|| Params::new(map))
711}
712
713fn insert_price_scale(
720 map: &mut IndexMap<String, serde_json::Value>,
721 definition: &AssetPairInfo,
722 price_precision: u8,
723) {
724 if definition.pair_decimals > price_precision {
725 map.insert(
726 KRAKEN_PAIR_DECIMALS_KEY.to_string(),
727 serde_json::Value::from(u64::from(definition.pair_decimals)),
728 );
729 }
730}
731
732pub fn parse_order_status_report(
740 order_id: &str,
741 order: &SpotOrder,
742 instrument: &InstrumentAny,
743 account_id: AccountId,
744 ts_init: UnixNanos,
745) -> anyhow::Result<OrderStatusReport> {
746 let instrument_id = instrument.id();
747 let venue_order_id = VenueOrderId::new(order_id);
748
749 let order_side = OrderSide::from(order.descr.order_side).into();
750 let order_type = order.descr.ordertype.into();
751 let order_status = order.status.into();
752
753 let has_expiration = order.expiretm.is_some_and(|t| t > 0.0);
755 let time_in_force = if has_expiration {
756 TimeInForce::Gtd
757 } else if order.oflags.contains("ioc") {
758 TimeInForce::Ioc
759 } else {
760 TimeInForce::Gtc
761 };
762
763 let quantity =
764 parse_quantity_with_precision(&order.vol, instrument.size_precision(), "order.vol")?;
765
766 let filled_qty = parse_quantity_with_precision(
767 &order.vol_exec,
768 instrument.size_precision(),
769 "order.vol_exec",
770 )?;
771
772 let ts_accepted = parse_millis_timestamp(order.opentm, "order.opentm")?;
773
774 let ts_last = order
775 .closetm
776 .map(|t| parse_millis_timestamp(t, "order.closetm"))
777 .transpose()?
778 .unwrap_or(ts_accepted);
779
780 let price_precision = instrument.price_precision();
783 let row_price =
784 |value: &str, field: &str| parse_spot_order_price(order_id, value, price_precision, field);
785 let price = match order.descr.ordertype {
786 KrakenOrderType::Limit => row_price(&order.descr.price, "descr.price")?,
787 KrakenOrderType::StopLossLimit | KrakenOrderType::TakeProfitLimit => {
788 row_price(&order.descr.price2, "descr.price2")?
789 }
790 KrakenOrderType::TrailingStopLimit
794 if order.misc.split(',').any(|info| info == "stopped") =>
795 {
796 row_price(&order.descr.price2, "descr.price2")?
797 }
798 KrakenOrderType::Market
799 | KrakenOrderType::SettlePosition
800 | KrakenOrderType::StopLoss
801 | KrakenOrderType::TakeProfit
802 | KrakenOrderType::TrailingStop
803 | KrakenOrderType::TrailingStopLimit => None,
804 };
805
806 let stop_price = order
807 .stopprice
808 .as_deref()
809 .map(|value| row_price(value, "stopprice"))
810 .transpose()?
811 .flatten();
812
813 let trigger_price = match order.descr.ordertype {
816 KrakenOrderType::StopLoss
817 | KrakenOrderType::TakeProfit
818 | KrakenOrderType::StopLossLimit
819 | KrakenOrderType::TakeProfitLimit => match stop_price {
820 Some(price) => Some(price),
821 None => row_price(&order.descr.price, "descr.price")?,
822 },
823 KrakenOrderType::Market
824 | KrakenOrderType::Limit
825 | KrakenOrderType::SettlePosition
826 | KrakenOrderType::TrailingStop
827 | KrakenOrderType::TrailingStopLimit => stop_price,
828 };
829
830 let expire_time = if has_expiration {
831 order
832 .expiretm
833 .map(|t| parse_millis_timestamp(t, "order.expiretm"))
834 .transpose()?
835 } else {
836 None
837 };
838
839 let trigger_type = parse_trigger_type(order_type, order.trigger);
840
841 Ok(OrderStatusReport {
842 account_id,
843 instrument_id,
844 client_order_id: None,
845 venue_order_id,
846 order_side,
847 order_type,
848 time_in_force,
849 order_status,
850 quantity,
851 filled_qty,
852 report_id: UUID4::new(),
853 ts_accepted,
854 ts_last,
855 ts_init,
856 order_list_id: None,
857 venue_position_id: None,
858 linked_order_ids: None,
859 parent_order_id: None,
860 contingency_type: None,
861 expire_time,
862 price,
863 activation_price: None,
864 trigger_price,
865 trigger_type,
866 limit_offset: None,
867 trailing_offset: None,
868 trailing_offset_type: None,
869 display_qty: None,
870 avg_px: compute_avg_px(order),
871 post_only: order.oflags.contains("post"),
872 reduce_only: false,
873 cancel_reason: order.reason.clone(),
874 ts_triggered: None,
875 })
876}
877
878fn parse_spot_order_price(
884 order_id: &str,
885 value: &str,
886 precision: u8,
887 field: &str,
888) -> anyhow::Result<Option<Price>> {
889 if value.is_empty() {
890 return Ok(None);
891 }
892
893 if value.starts_with(['+', '-', '#']) || value.ends_with('%') {
894 log::warn!("Spot order {order_id} {field}='{value}' is a relative price, reporting none");
895 return Ok(None);
896 }
897
898 let price = parse_price_with_precision(value, precision, field)?;
899 Ok((!price.is_zero()).then_some(price))
900}
901
902fn compute_avg_px(order: &SpotOrder) -> Option<Decimal> {
906 if let Some(ref avg) = order.avg_price
907 && let Ok(v) = parse_decimal(avg)
908 && v > dec!(0)
909 {
910 return Some(v);
911 }
912
913 let cost = parse_decimal(&order.cost);
914 let vol_exec = parse_decimal(&order.vol_exec);
915 match (&cost, &vol_exec) {
916 (Ok(c), Ok(v)) if *v > dec!(0) => Some(*c / *v),
917 _ => {
918 if let Ok(v) = &vol_exec
919 && *v > dec!(0)
920 {
921 log::warn!("Cannot compute avg_px: cost={cost:?}, vol_exec={vol_exec:?}");
922 }
923 None
924 }
925 }
926}
927
928pub fn parse_fill_report(
935 trade_id: &str,
936 trade: &SpotTrade,
937 instrument: &InstrumentAny,
938 account_id: AccountId,
939 ts_init: UnixNanos,
940) -> anyhow::Result<FillReport> {
941 let instrument_id = instrument.id();
942 let venue_order_id = VenueOrderId::new(&trade.ordertxid);
943 let trade_id_obj = TradeId::new(trade_id);
944
945 let order_side = trade.trade_type.into();
946
947 let last_qty =
948 parse_quantity_with_precision(&trade.vol, instrument.size_precision(), "trade.vol")?;
949
950 let last_px =
951 parse_price_with_precision(&trade.price, instrument.price_precision(), "trade.price")?;
952
953 let fee_decimal = parse_decimal(&trade.fee)?;
954 let quote_currency = match instrument {
955 InstrumentAny::CurrencyPair(pair) => pair.quote_currency,
956 InstrumentAny::CryptoPerpetual(perp) => perp.quote_currency,
957 InstrumentAny::TokenizedAsset(ta) => ta.quote_currency,
958 _ => anyhow::bail!("Unsupported instrument type for fill report"),
959 };
960
961 let commission = Money::from_decimal(fee_decimal, quote_currency)?;
962
963 let liquidity_side = match trade.maker {
964 Some(true) => LiquiditySide::Maker,
965 Some(false) => LiquiditySide::Taker,
966 None => LiquiditySide::NoLiquiditySide,
967 };
968
969 let ts_event = parse_millis_timestamp(trade.time, "trade.time")?;
970
971 Ok(FillReport {
972 account_id,
973 instrument_id,
974 venue_order_id,
975 trade_id: trade_id_obj,
976 order_side,
977 last_qty,
978 last_px,
979 commission,
980 liquidity_side,
981 avg_px: None,
982 report_id: UUID4::new(),
983 ts_event,
984 ts_init,
985 client_order_id: None,
986 venue_position_id: None,
987 })
988}
989
990pub fn parse_futures_order_status_report(
996 order: &FuturesOpenOrder,
997 instrument: &InstrumentAny,
998 account_id: AccountId,
999 fallback_quantity: Option<Decimal>,
1000 ts_init: UnixNanos,
1001) -> anyhow::Result<OrderStatusReport> {
1002 let instrument_id = instrument.id();
1003 let venue_order_id = VenueOrderId::new(&order.order_id);
1004
1005 let order_side = OrderSide::from(order.side).into();
1006 let order_type: OrderType = order.order_type.into();
1007 let order_type = if order_type == OrderType::MarketIfTouched && order.limit_price.is_some() {
1008 OrderType::LimitIfTouched
1009 } else {
1010 order_type
1011 };
1012 let order_status = order.status.into();
1013
1014 let quantity_value = order
1015 .unfilled_size
1016 .map(|unfilled_size| unfilled_size + order.filled_size)
1017 .or(fallback_quantity)
1018 .context("missing unfilled size and fallback quantity")?;
1019 let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())?;
1020
1021 let filled_qty = Quantity::from_decimal_dp(order.filled_size, instrument.size_precision())?;
1022
1023 let ts_accepted = parse_rfc3339_timestamp(&order.received_time, "order.received_time")?;
1024 let ts_last = parse_rfc3339_timestamp(&order.last_update_time, "order.last_update_time")?;
1025
1026 let price = order
1027 .limit_price
1028 .map(|p| Price::from_decimal_dp(p, instrument.price_precision()))
1029 .transpose()?;
1030
1031 let trigger_price = order
1032 .stop_price
1033 .map(|p| Price::from_decimal_dp(p, instrument.price_precision()))
1034 .transpose()?;
1035
1036 let trigger_type = parse_futures_trigger_type(order_type, order.trigger_signal);
1037
1038 Ok(OrderStatusReport {
1039 account_id,
1040 instrument_id,
1041 client_order_id: echoed_client_order_id(order.client_order_id()),
1042 venue_order_id,
1043 order_side,
1044 order_type,
1045 time_in_force: TimeInForce::Gtc,
1046 order_status,
1047 quantity,
1048 filled_qty,
1049 report_id: UUID4::new(),
1050 ts_accepted,
1051 ts_last,
1052 ts_init,
1053 order_list_id: None,
1054 venue_position_id: None,
1055 linked_order_ids: None,
1056 parent_order_id: None,
1057 contingency_type: None,
1058 expire_time: None,
1059 price,
1060 activation_price: None,
1061 trigger_price,
1062 trigger_type,
1063 limit_offset: None,
1064 trailing_offset: None,
1065 trailing_offset_type: None,
1066 display_qty: None,
1067 avg_px: None,
1068 post_only: false,
1069 reduce_only: order.reduce_only.unwrap_or(false),
1070 cancel_reason: None,
1071 ts_triggered: None,
1072 })
1073}
1074
1075pub fn parse_futures_order_event_status_report(
1081 event: &FuturesOrderEvent,
1082 event_type: Option<KrakenFuturesOrderEventType>,
1083 instrument: &InstrumentAny,
1084 account_id: AccountId,
1085 ts_init: UnixNanos,
1086) -> anyhow::Result<OrderStatusReport> {
1087 let instrument_id = instrument.id();
1088 let venue_order_id = VenueOrderId::new(&event.order_id);
1089
1090 let order_side = OrderSide::from(event.side).into();
1091 let order_type: OrderType = event.order_type.into();
1092 let order_type = if order_type == OrderType::MarketIfTouched && event.limit_price.is_some() {
1093 OrderType::LimitIfTouched
1094 } else {
1095 order_type
1096 };
1097
1098 let order_status = parse_futures_order_event_status(event_type, event.filled, event.quantity);
1099
1100 let quantity = Quantity::from_decimal_dp(event.quantity, instrument.size_precision())?;
1101 let filled_qty = Quantity::from_decimal_dp(event.filled, instrument.size_precision())?;
1102
1103 let ts_accepted = parse_rfc3339_timestamp(&event.timestamp, "event.timestamp")?;
1104 let ts_last =
1105 parse_rfc3339_timestamp(&event.last_update_timestamp, "event.last_update_timestamp")?;
1106
1107 let price = event
1108 .limit_price
1109 .map(|p| Price::from_decimal_dp(p, instrument.price_precision()))
1110 .transpose()?;
1111
1112 let trigger_price = event
1113 .stop_price
1114 .map(|p| Price::from_decimal_dp(p, instrument.price_precision()))
1115 .transpose()?;
1116
1117 let trigger_type = parse_futures_trigger_type(order_type, None);
1118
1119 Ok(OrderStatusReport {
1120 account_id,
1121 instrument_id,
1122 client_order_id: echoed_client_order_id(event.cli_ord_id.as_deref()),
1123 venue_order_id,
1124 order_side,
1125 order_type,
1126 time_in_force: TimeInForce::Gtc,
1127 order_status,
1128 quantity,
1129 filled_qty,
1130 report_id: UUID4::new(),
1131 ts_accepted,
1132 ts_last,
1133 ts_init,
1134 order_list_id: None,
1135 venue_position_id: None,
1136 linked_order_ids: None,
1137 parent_order_id: None,
1138 contingency_type: None,
1139 expire_time: None,
1140 price,
1141 activation_price: None,
1142 trigger_price,
1143 trigger_type,
1144 limit_offset: None,
1145 trailing_offset: None,
1146 trailing_offset_type: None,
1147 display_qty: None,
1148 avg_px: None,
1149 post_only: false,
1150 reduce_only: event.reduce_only,
1151 cancel_reason: None,
1152 ts_triggered: None,
1153 })
1154}
1155
1156fn echoed_client_order_id(echoed: Option<&str>) -> Option<ClientOrderId> {
1160 echoed
1161 .filter(|id| !id.is_empty())
1162 .and_then(|id| ClientOrderId::new_checked(id).ok())
1163}
1164
1165pub fn parse_futures_order_status_details_report(
1177 details: &FuturesOrderStatusDetails,
1178 instrument: &InstrumentAny,
1179 account_id: AccountId,
1180 ts_init: UnixNanos,
1181) -> anyhow::Result<OrderStatusReport> {
1182 let venue_order_id = VenueOrderId::new(&details.order.order_id);
1183
1184 let order_side = OrderSide::from(details.order.side).into();
1185
1186 let order_type = if details.order.limit_price.is_some() {
1187 OrderType::Limit
1188 } else {
1189 OrderType::Market
1190 };
1191
1192 let filled = details.order.filled.unwrap_or(Decimal::ZERO);
1193
1194 let order_status = match details.status {
1195 KrakenFuturesOrderLifecycleStatus::EnteredBook
1196 | KrakenFuturesOrderLifecycleStatus::TriggerPlaced => {
1197 if filled > Decimal::ZERO {
1198 OrderStatus::PartiallyFilled
1199 } else {
1200 OrderStatus::Accepted
1201 }
1202 }
1203 KrakenFuturesOrderLifecycleStatus::FullyExecuted => OrderStatus::Filled,
1204 KrakenFuturesOrderLifecycleStatus::Rejected
1205 | KrakenFuturesOrderLifecycleStatus::TriggerActivationFailure => OrderStatus::Rejected,
1206 KrakenFuturesOrderLifecycleStatus::Cancelled => OrderStatus::Canceled,
1207 };
1208
1209 let quantity_value = details
1210 .order
1211 .quantity
1212 .context("order status details missing quantity")?;
1213 let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())?;
1214 let filled_qty = Quantity::from_decimal_dp(filled, instrument.size_precision())?;
1215
1216 let ts_accepted = parse_rfc3339_timestamp(&details.order.timestamp, "order.timestamp")?;
1217 let ts_last = parse_rfc3339_timestamp(
1218 &details.order.last_update_timestamp,
1219 "order.last_update_timestamp",
1220 )?;
1221
1222 let price = details
1223 .order
1224 .limit_price
1225 .map(|p| Price::from_decimal_dp(p, instrument.price_precision()))
1226 .transpose()?;
1227
1228 let cancel_reason = match details.status {
1229 KrakenFuturesOrderLifecycleStatus::Cancelled
1230 | KrakenFuturesOrderLifecycleStatus::Rejected
1231 | KrakenFuturesOrderLifecycleStatus::TriggerActivationFailure => {
1232 details.update_reason.clone()
1233 }
1234 _ => None,
1235 };
1236
1237 Ok(OrderStatusReport {
1238 account_id,
1239 instrument_id: instrument.id(),
1240 client_order_id: echoed_client_order_id(details.order.cli_ord_id.as_deref()),
1241 venue_order_id,
1242 order_side,
1243 order_type,
1244 time_in_force: TimeInForce::Gtc,
1245 order_status,
1246 quantity,
1247 filled_qty,
1248 report_id: UUID4::new(),
1249 ts_accepted,
1250 ts_last,
1251 ts_init,
1252 order_list_id: None,
1253 venue_position_id: None,
1254 linked_order_ids: None,
1255 parent_order_id: None,
1256 contingency_type: None,
1257 expire_time: None,
1258 price,
1259 activation_price: None,
1260 trigger_price: None,
1261 trigger_type: None,
1262 limit_offset: None,
1263 trailing_offset: None,
1264 trailing_offset_type: None,
1265 display_qty: None,
1266 avg_px: None,
1267 post_only: false,
1268 reduce_only: details.order.reduce_only,
1269 cancel_reason,
1270 ts_triggered: None,
1271 })
1272}
1273
1274fn parse_futures_order_event_status(
1275 event_type: Option<KrakenFuturesOrderEventType>,
1276 filled: Decimal,
1277 quantity: Decimal,
1278) -> OrderStatus {
1279 match event_type {
1280 Some(KrakenFuturesOrderEventType::Cancel) => OrderStatus::Canceled,
1281 Some(KrakenFuturesOrderEventType::Reject) => OrderStatus::Rejected,
1282 Some(KrakenFuturesOrderEventType::Expire) => OrderStatus::Expired,
1283 Some(
1284 KrakenFuturesOrderEventType::Fill
1285 | KrakenFuturesOrderEventType::Execution
1286 | KrakenFuturesOrderEventType::Place
1287 | KrakenFuturesOrderEventType::Edit,
1288 ) => {
1289 if filled >= quantity {
1290 OrderStatus::Filled
1291 } else if filled > Decimal::ZERO {
1292 OrderStatus::PartiallyFilled
1293 } else {
1294 OrderStatus::Accepted
1295 }
1296 }
1297 _ => {
1298 if filled >= quantity {
1299 OrderStatus::Filled
1300 } else if filled > Decimal::ZERO {
1301 OrderStatus::PartiallyFilled
1302 } else {
1303 OrderStatus::Canceled
1304 }
1305 }
1306 }
1307}
1308
1309pub fn parse_futures_fill_report(
1315 fill: &FuturesFill,
1316 instrument: &InstrumentAny,
1317 account_id: AccountId,
1318 ts_init: UnixNanos,
1319) -> anyhow::Result<FillReport> {
1320 let instrument_id = instrument.id();
1321 let venue_order_id = VenueOrderId::new(&fill.order_id);
1322 let trade_id = TradeId::new(&fill.fill_id);
1323
1324 let order_side = fill.side.into();
1325
1326 let last_qty = Quantity::from_decimal_dp(fill.size, instrument.size_precision())?;
1327 let last_px = Price::from_decimal_dp(fill.price, instrument.price_precision())?;
1328
1329 let quote_currency = match instrument {
1330 InstrumentAny::CryptoPerpetual(perp) => perp.quote_currency,
1331 InstrumentAny::CryptoFuture(future) => future.quote_currency,
1332 _ => anyhow::bail!("Unsupported instrument type for futures fill report"),
1333 };
1334
1335 let commission_currency = fill
1337 .fee_currency
1338 .as_deref()
1339 .map_or(quote_currency, |reported| {
1340 fee_currency(reported, instrument)
1341 });
1342 let commission =
1343 Money::from_decimal(fill.fee_paid.unwrap_or(Decimal::ZERO), commission_currency)?;
1344
1345 let liquidity_side = fill.fill_type.into();
1346
1347 let ts_event = parse_rfc3339_timestamp(&fill.fill_time, "fill.fill_time")?;
1348
1349 Ok(FillReport {
1350 account_id,
1351 instrument_id,
1352 venue_order_id,
1353 trade_id,
1354 order_side,
1355 last_qty,
1356 last_px,
1357 commission,
1358 liquidity_side,
1359 avg_px: None,
1360 report_id: UUID4::new(),
1361 ts_event,
1362 ts_init,
1363 client_order_id: echoed_client_order_id(fill.client_order_id()),
1364 venue_position_id: None,
1365 })
1366}
1367
1368pub fn parse_futures_position_status_report(
1374 position: &FuturesPosition,
1375 instrument: &InstrumentAny,
1376 account_id: AccountId,
1377 ts_init: UnixNanos,
1378) -> anyhow::Result<PositionStatusReport> {
1379 let instrument_id = instrument.id();
1380
1381 let position_side = match position.side {
1382 KrakenPositionSide::Long => PositionSide::Long,
1383 KrakenPositionSide::Short => PositionSide::Short,
1384 };
1385
1386 let quantity = Quantity::from_decimal_dp(position.size, instrument.size_precision())?;
1387 let signed_decimal_qty = match position_side {
1388 PositionSide::Long => position.size,
1389 PositionSide::Short => -position.size,
1390 PositionSide::Flat => dec!(0),
1391 };
1392
1393 let avg_px_open = Some(position.price);
1394
1395 Ok(PositionStatusReport {
1396 account_id,
1397 instrument_id,
1398 position_side,
1399 quantity,
1400 signed_decimal_qty,
1401 report_id: UUID4::new(),
1402 ts_last: ts_init,
1403 ts_init,
1404 venue_position_id: None,
1405 avg_px_open,
1406 avg_px_open_reconciliation: AvgPxReconciliation::default(),
1407 avg_px_open_precision: None,
1408 liquidation_price: None,
1409 })
1410}
1411
1412pub fn bar_type_to_spot_interval(bar_type: BarType) -> anyhow::Result<u32> {
1420 let step = bar_type.spec().step.get() as u32;
1421 let base_interval = match bar_type.spec().aggregation {
1422 BarAggregation::Minute => 1,
1423 BarAggregation::Hour => 60,
1424 BarAggregation::Day => 1440,
1425 other => {
1426 anyhow::bail!("Unsupported bar aggregation for Kraken Spot: {other:?}");
1427 }
1428 };
1429 Ok(base_interval * step)
1430}
1431
1432pub fn bar_type_to_futures_resolution(bar_type: BarType) -> anyhow::Result<&'static str> {
1442 let step = bar_type.spec().step.get() as u32;
1443 match bar_type.spec().aggregation {
1444 BarAggregation::Minute => match step {
1445 1 => Ok("1m"),
1446 5 => Ok("5m"),
1447 15 => Ok("15m"),
1448 _ => anyhow::bail!("Unsupported minute step for Kraken Futures: {step}"),
1449 },
1450 BarAggregation::Hour => match step {
1451 1 => Ok("1h"),
1452 4 => Ok("4h"),
1453 12 => Ok("12h"),
1454 _ => anyhow::bail!("Unsupported hour step for Kraken Futures: {step}"),
1455 },
1456 BarAggregation::Day => {
1457 if step == 1 {
1458 Ok("1d")
1459 } else {
1460 anyhow::bail!("Unsupported day step for Kraken Futures: {step}")
1461 }
1462 }
1463 BarAggregation::Week => {
1464 if step == 1 {
1465 Ok("1w")
1466 } else {
1467 anyhow::bail!("Unsupported week step for Kraken Futures: {step}")
1468 }
1469 }
1470 other => {
1471 anyhow::bail!("Unsupported bar aggregation for Kraken Futures: {other:?}");
1472 }
1473 }
1474}
1475
1476pub fn truncate_cl_ord_id(client_order_id: &ClientOrderId) -> String {
1487 let id = client_order_id.as_str();
1488
1489 if id.len() <= 18 {
1490 return id.to_string();
1491 }
1492
1493 if id.len() == 36 && id.bytes().filter(|b| *b == b'-').count() == 4 {
1494 return id.to_string();
1495 }
1496
1497 if id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit()) {
1498 return id.to_string();
1499 }
1500
1501 format!("O{}", &id[id.len() - 17..])
1502}
1503
1504#[cfg(test)]
1505mod tests {
1506 use indexmap::IndexMap;
1507 use nautilus_model::{
1508 data::BarSpecification,
1509 enums::{AggregationSource, BarAggregation, OrderSide, OrderStatus, PriceType},
1510 instruments::crypto_perpetual::CryptoPerpetual,
1511 };
1512 use rstest::rstest;
1513
1514 use super::*;
1515 use crate::{
1516 common::enums::{
1517 KrakenFuturesOrderEventType, KrakenFuturesOrderStatus, KrakenFuturesOrderType,
1518 KrakenOrderSide,
1519 },
1520 http::{
1521 futures::models::{FuturesFillsResponse, FuturesOpenOrder, FuturesOrderEvent},
1522 models::{
1523 AssetPairsResponse, KrakenResponse, SpotClosedOrdersResult, SpotOpenOrdersResult,
1524 },
1525 },
1526 };
1527
1528 const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
1529
1530 fn load_test_json(filename: &str) -> String {
1531 let path = format!("test_data/{filename}");
1532 std::fs::read_to_string(&path)
1533 .unwrap_or_else(|e| panic!("Failed to load test data from {path}: {e}"))
1534 }
1535
1536 #[rstest]
1539 #[case::valid(Some("O-1"), Some("O-1"))]
1540 #[case::absent(None, None)]
1541 #[case::empty(Some(""), None)]
1542 #[case::whitespace(Some(" "), None)]
1543 #[case::non_ascii(Some("ordre-\u{e9}"), None)]
1544 fn test_echoed_client_order_id(#[case] echoed: Option<&str>, #[case] expected: Option<&str>) {
1545 assert_eq!(
1546 echoed_client_order_id(echoed),
1547 expected.map(ClientOrderId::from)
1548 );
1549 }
1550
1551 #[rstest]
1552 fn test_parse_decimal() {
1553 assert_eq!(parse_decimal("123.45").unwrap(), dec!(123.45));
1554 assert_eq!(parse_decimal("0").unwrap(), dec!(0));
1555 assert_eq!(parse_decimal("").unwrap(), dec!(0));
1556 }
1557
1558 #[rstest]
1559 fn test_parse_decimal_opt() {
1560 assert_eq!(
1561 parse_decimal_opt(Some("123.45")).unwrap(),
1562 Some(dec!(123.45))
1563 );
1564 assert_eq!(parse_decimal_opt(Some("0")).unwrap(), None);
1565 assert_eq!(parse_decimal_opt(Some("")).unwrap(), None);
1566 assert_eq!(parse_decimal_opt(None).unwrap(), None);
1567 }
1568
1569 #[rstest]
1570 fn test_parse_spot_instrument() {
1571 let json = load_test_json("http_asset_pairs.json");
1572 let response: KrakenResponse<AssetPairsResponse> = serde_json::from_str(&json).unwrap();
1573 let pairs = response.result.unwrap();
1574
1575 let (pair_name, definition) = pairs.iter().next().unwrap();
1576
1577 let instrument = parse_spot_instrument(pair_name, definition, TS, TS).unwrap();
1578
1579 match instrument {
1580 InstrumentAny::CurrencyPair(pair) => {
1581 assert_eq!(pair.id.venue.as_str(), "KRAKEN");
1582 assert_eq!(pair.base_currency.code, "BTC");
1583 assert_eq!(pair.quote_currency.code, "USDT");
1584 assert_eq!(pair.price_increment.as_decimal(), dec!(0.1));
1585 assert_eq!(pair.size_increment.as_decimal(), dec!(0.00000001));
1586 assert!(pair.min_quantity.is_some());
1587 assert_eq!(pair.margin_init, dec!(0));
1588 assert_eq!(pair.margin_maint, dec!(0));
1589 }
1590 _ => panic!("Expected CurrencyPair"),
1591 }
1592 }
1593
1594 #[rstest]
1595 fn test_parse_futures_instrument_inverse() {
1596 let json = load_test_json("http_futures_instruments.json");
1597 let response: crate::http::models::FuturesInstrumentsResponse =
1598 serde_json::from_str(&json).unwrap();
1599
1600 let fut_instrument = &response.instruments[0];
1601
1602 let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1603
1604 match instrument {
1605 InstrumentAny::CryptoPerpetual(perp) => {
1606 assert_eq!(perp.id.venue.as_str(), "KRAKEN");
1607 assert_eq!(perp.id.symbol.as_str(), "PI_XBTUSD");
1608 assert_eq!(perp.raw_symbol.as_str(), "PI_XBTUSD");
1609 assert_eq!(perp.base_currency.code, "BTC");
1610 assert_eq!(perp.quote_currency.code, "USD");
1611 assert_eq!(perp.settlement_currency.code, "BTC");
1612 assert!(perp.is_inverse);
1613 assert_eq!(perp.price_increment.as_decimal(), dec!(0.5));
1614 assert_eq!(perp.size_increment.as_decimal(), dec!(1));
1615 assert_eq!(perp.size_precision(), 0);
1616 assert_eq!(perp.margin_init, dec!(0.02));
1617 assert_eq!(perp.margin_maint, dec!(0.01));
1618 }
1619 _ => panic!("Expected CryptoPerpetual"),
1620 }
1621 }
1622
1623 #[rstest]
1624 fn test_parse_futures_instrument_flexible() {
1625 let json = load_test_json("http_futures_instruments.json");
1626 let response: crate::http::models::FuturesInstrumentsResponse =
1627 serde_json::from_str(&json).unwrap();
1628
1629 let fut_instrument = &response.instruments[1];
1630
1631 let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1632
1633 match instrument {
1634 InstrumentAny::CryptoPerpetual(perp) => {
1635 assert_eq!(perp.id.venue.as_str(), "KRAKEN");
1636 assert_eq!(perp.id.symbol.as_str(), "PF_ETHUSD");
1637 assert_eq!(perp.raw_symbol.as_str(), "PF_ETHUSD");
1638 assert_eq!(perp.base_currency.code, "ETH");
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.1));
1643 assert_eq!(perp.size_increment.as_decimal(), dec!(0.001));
1644 assert_eq!(perp.size_precision(), 3);
1645 assert_eq!(perp.margin_init, dec!(0.02));
1646 assert_eq!(perp.margin_maint, dec!(0.01));
1647 }
1648 _ => panic!("Expected CryptoPerpetual"),
1649 }
1650 }
1651
1652 #[rstest]
1653 fn test_parse_futures_instrument_accepts_max_precision() {
1654 let json = load_test_json("http_futures_instruments.json");
1655 let response: crate::http::models::FuturesInstrumentsResponse =
1656 serde_json::from_str(&json).unwrap();
1657 let mut fut_instrument = response.instruments[1].clone();
1658 let tick_size = Decimal::try_new(1, u32::from(FIXED_PRECISION)).unwrap();
1659 fut_instrument.tick_size = tick_size;
1660
1661 let instrument = parse_futures_instrument(&fut_instrument, TS, TS).unwrap();
1662
1663 match instrument {
1664 InstrumentAny::CryptoPerpetual(perp) => {
1665 assert_eq!(perp.price_precision(), FIXED_PRECISION);
1666 assert_eq!(perp.price_increment.as_decimal(), tick_size);
1667 }
1668 _ => panic!("Expected CryptoPerpetual"),
1669 }
1670 }
1671
1672 #[cfg(feature = "high-precision")]
1673 #[rstest]
1674 fn test_parse_futures_instrument_negative_precision() {
1675 let json = load_test_json("http_futures_instruments.json");
1676 let response: crate::http::models::FuturesInstrumentsResponse =
1677 serde_json::from_str(&json).unwrap();
1678
1679 let fut_instrument = &response.instruments[2];
1681
1682 let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1683
1684 match instrument {
1685 InstrumentAny::CryptoPerpetual(perp) => {
1686 assert_eq!(perp.id.symbol.as_str(), "PF_PEPEUSD");
1687 assert_eq!(perp.base_currency.code, "PEPE");
1688 assert!(!perp.is_inverse);
1689 assert_eq!(perp.size_increment.as_decimal(), dec!(1000));
1690 assert_eq!(perp.size_precision(), 0);
1691 }
1692 _ => panic!("Expected CryptoPerpetual"),
1693 }
1694 }
1695
1696 #[cfg(feature = "high-precision")]
1697 #[rstest]
1698 fn test_parse_futures_instrument_rejects_precision_above_high_max() {
1699 let json = load_test_json("http_futures_instruments.json");
1700 let response: crate::http::models::FuturesInstrumentsResponse =
1701 serde_json::from_str(&json).unwrap();
1702 let mut fut_instrument = response.instruments[1].clone();
1703 fut_instrument.tick_size = dec!(0.00000000000000001);
1704
1705 let error = parse_futures_instrument(&fut_instrument, TS, TS).unwrap_err();
1706
1707 assert_eq!(
1708 error.to_string(),
1709 "Cannot parse Kraken Futures instrument 'PF_ETHUSD': tick_size \
1710 0.00000000000000001 requires precision 17, but this build supports at most 16"
1711 );
1712 }
1713
1714 #[cfg(not(feature = "high-precision"))]
1715 #[rstest]
1716 fn test_parse_futures_instrument_rejects_unsupported_precision() {
1717 let json = load_test_json("http_futures_instruments.json");
1718 let response: crate::http::models::FuturesInstrumentsResponse =
1719 serde_json::from_str(&json).unwrap();
1720
1721 let tick_error = parse_futures_instrument(&response.instruments[2], TS, TS).unwrap_err();
1722
1723 let mut trade_precision_instrument = response.instruments[1].clone();
1724 trade_precision_instrument.contract_value_trade_precision = 256;
1725 let trade_precision_error =
1726 parse_futures_instrument(&trade_precision_instrument, TS, TS).unwrap_err();
1727
1728 let mut contract_size_instrument = response.instruments[1].clone();
1729 contract_size_instrument.contract_size = dec!(0.0000000001);
1730 let contract_size_error =
1731 parse_futures_instrument(&contract_size_instrument, TS, TS).unwrap_err();
1732
1733 assert_eq!(
1734 tick_error.to_string(),
1735 "Cannot parse Kraken Futures instrument 'PF_PEPEUSD': tick_size 0.0000000001 requires \
1736 precision 10, but this build supports at most 9; enable the 'high-precision' Cargo \
1737 feature and rebuild"
1738 );
1739 assert_eq!(
1740 trade_precision_error.to_string(),
1741 "Cannot parse Kraken Futures instrument 'PF_ETHUSD': contract_value_trade_precision 256 \
1742 requires precision 256, but this build supports at most 9; enable the 'high-precision' \
1743 Cargo feature and rebuild"
1744 );
1745 assert_eq!(
1746 contract_size_error.to_string(),
1747 "Cannot parse Kraken Futures instrument 'PF_ETHUSD': contract_size 0.0000000001 requires \
1748 precision 10, but this build supports at most 9; enable the 'high-precision' Cargo \
1749 feature and rebuild"
1750 );
1751 }
1752
1753 #[rstest]
1754 fn test_parse_futures_instrument_tokenized_underlying() {
1755 let json = load_test_json("http_futures_instruments.json");
1756 let response: crate::http::models::FuturesInstrumentsResponse =
1757 serde_json::from_str(&json).unwrap();
1758
1759 let fut_instrument = &response.instruments[3];
1760
1761 let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1762
1763 match instrument {
1764 InstrumentAny::CryptoPerpetual(perp) => {
1765 assert_eq!(perp.id.symbol.as_str(), "PF_AAPLxUSD");
1766 assert_eq!(perp.raw_symbol.as_str(), "PF_AAPLxUSD");
1767 assert_eq!(perp.base_currency.code, "AAPLx");
1768 assert_eq!(perp.quote_currency.code, "USD");
1769 assert_eq!(perp.settlement_currency.code, "USD");
1770 assert!(!perp.is_inverse);
1771 assert_eq!(perp.price_increment.as_decimal(), dec!(0.01));
1772 assert_eq!(perp.size_increment.as_decimal(), dec!(0.01));
1773 assert_eq!(perp.size_precision(), 2);
1774 assert_eq!(perp.margin_init, dec!(0.2));
1775 assert_eq!(perp.margin_maint, dec!(0.1));
1776 }
1777 _ => panic!("Expected CryptoPerpetual"),
1778 }
1779 }
1780
1781 #[rstest]
1782 fn test_parse_futures_instrument_without_fee_schedule_uid() {
1783 let json = load_test_json("http_futures_instrument_no_fee_schedule.json");
1784 let response: crate::http::models::FuturesInstrumentsResponse =
1785 serde_json::from_str(&json).unwrap();
1786
1787 let fut_instrument = &response.instruments[0];
1788 assert!(fut_instrument.fee_schedule_uid.is_none());
1789
1790 let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1791 match instrument {
1792 InstrumentAny::CryptoPerpetual(perp) => {
1793 assert_eq!(perp.id.symbol.as_str(), "PF_ETHUSD");
1794 }
1795 _ => panic!("Expected CryptoPerpetual"),
1796 }
1797 }
1798
1799 #[rstest]
1800 fn test_parse_trade_tick_from_array() {
1801 let json = load_test_json("http_trades.json");
1802 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1803 let result = wrapper.get("result").unwrap();
1804 let trades_map = result.as_object().unwrap();
1805
1806 let (_pair, trades_value) = trades_map.iter().find(|(k, _)| *k != "last").unwrap();
1808 let trades = trades_value.as_array().unwrap();
1809 let trade_array = trades[0].as_array().unwrap();
1810
1811 let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1813 let instrument = InstrumentAny::CurrencyPair(
1814 CurrencyPair::builder()
1815 .instrument_id(instrument_id)
1816 .raw_symbol(Symbol::new("XBTUSDT"))
1817 .base_currency(Currency::BTC())
1818 .quote_currency(Currency::USDT())
1819 .price_precision(1)
1820 .size_precision(8)
1821 .price_increment(Price::from("0.1"))
1822 .size_increment(Quantity::from("0.00000001"))
1823 .ts_event(TS)
1824 .ts_init(TS)
1825 .build()
1826 .unwrap(),
1827 );
1828
1829 let trade_tick = parse_trade_tick_from_array(trade_array, &instrument, TS).unwrap();
1830
1831 assert_eq!(trade_tick.instrument_id, instrument_id);
1832 assert_eq!(trade_tick.price, Price::from("105433.60000"));
1833 assert_eq!(trade_tick.size, Quantity::from("0.00027625"));
1834 }
1835
1836 #[rstest]
1837 fn test_parse_bar() {
1838 let json = load_test_json("http_ohlc.json");
1839 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1840 let result = wrapper.get("result").unwrap();
1841 let ohlc_map = result.as_object().unwrap();
1842
1843 let (_pair, ohlc_value) = ohlc_map.iter().find(|(k, _)| *k != "last").unwrap();
1845 let ohlcs = ohlc_value.as_array().unwrap();
1846
1847 let ohlc_array = ohlcs[0].as_array().unwrap();
1849 let ohlc = OhlcData {
1850 time: ohlc_array[0].as_i64().unwrap(),
1851 open: ohlc_array[1].as_str().unwrap().to_string(),
1852 high: ohlc_array[2].as_str().unwrap().to_string(),
1853 low: ohlc_array[3].as_str().unwrap().to_string(),
1854 close: ohlc_array[4].as_str().unwrap().to_string(),
1855 vwap: ohlc_array[5].as_str().unwrap().to_string(),
1856 volume: ohlc_array[6].as_str().unwrap().to_string(),
1857 count: ohlc_array[7].as_i64().unwrap(),
1858 };
1859
1860 let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1862 let instrument = InstrumentAny::CurrencyPair(
1863 CurrencyPair::builder()
1864 .instrument_id(instrument_id)
1865 .raw_symbol(Symbol::new("XBTUSDT"))
1866 .base_currency(Currency::BTC())
1867 .quote_currency(Currency::USDT())
1868 .price_precision(1)
1869 .size_precision(8)
1870 .price_increment(Price::from("0.1"))
1871 .size_increment(Quantity::from("0.00000001"))
1872 .ts_event(TS)
1873 .ts_init(TS)
1874 .build()
1875 .unwrap(),
1876 );
1877
1878 let bar_type = BarType::new(
1879 instrument_id,
1880 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
1881 AggregationSource::External,
1882 );
1883
1884 let bar = parse_bar(&ohlc, &instrument, bar_type, TS).unwrap();
1885
1886 assert_eq!(bar.bar_type, bar_type);
1887 assert_eq!(bar.open, Price::from("106038.2"));
1888 assert_eq!(bar.high, Price::from("106038.2"));
1889 assert_eq!(bar.low, Price::from("106038.2"));
1890 assert_eq!(bar.close, Price::from("106038.2"));
1891 assert_eq!(bar.volume, Quantity::from("0.00000000"));
1892 }
1893
1894 #[rstest]
1895 fn test_parse_millis_timestamp() {
1896 let timestamp = 1762795433.9717445;
1897 let result = parse_millis_timestamp(timestamp, "test").unwrap();
1898 assert!(result.as_u64() > 0);
1899 }
1900
1901 #[rstest]
1902 #[case(1, BarAggregation::Minute, 1)]
1903 #[case(5, BarAggregation::Minute, 5)]
1904 #[case(15, BarAggregation::Minute, 15)]
1905 #[case(1, BarAggregation::Hour, 60)]
1906 #[case(4, BarAggregation::Hour, 240)]
1907 #[case(1, BarAggregation::Day, 1440)]
1908 fn test_bar_type_to_spot_interval(
1909 #[case] step: usize,
1910 #[case] aggregation: BarAggregation,
1911 #[case] expected: u32,
1912 ) {
1913 let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1914 let bar_type = BarType::new(
1915 instrument_id,
1916 BarSpecification::new(step, aggregation, PriceType::Last),
1917 AggregationSource::External,
1918 );
1919
1920 let result = bar_type_to_spot_interval(bar_type).unwrap();
1921 assert_eq!(result, expected);
1922 }
1923
1924 #[rstest]
1925 fn test_bar_type_to_spot_interval_unsupported() {
1926 let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1927 let bar_type = BarType::new(
1928 instrument_id,
1929 BarSpecification::new(1, BarAggregation::Second, PriceType::Last),
1930 AggregationSource::External,
1931 );
1932
1933 let result = bar_type_to_spot_interval(bar_type);
1934 assert!(result.is_err());
1935 assert!(result.unwrap_err().to_string().contains("Unsupported"));
1936 }
1937
1938 #[rstest]
1939 #[case(1, BarAggregation::Minute, "1m")]
1940 #[case(5, BarAggregation::Minute, "5m")]
1941 #[case(15, BarAggregation::Minute, "15m")]
1942 #[case(1, BarAggregation::Hour, "1h")]
1943 #[case(4, BarAggregation::Hour, "4h")]
1944 #[case(12, BarAggregation::Hour, "12h")]
1945 #[case(1, BarAggregation::Day, "1d")]
1946 #[case(1, BarAggregation::Week, "1w")]
1947 fn test_bar_type_to_futures_resolution(
1948 #[case] step: usize,
1949 #[case] aggregation: BarAggregation,
1950 #[case] expected: &str,
1951 ) {
1952 let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
1953 let bar_type = BarType::new(
1954 instrument_id,
1955 BarSpecification::new(step, aggregation, PriceType::Last),
1956 AggregationSource::External,
1957 );
1958
1959 let result = bar_type_to_futures_resolution(bar_type).unwrap();
1960 assert_eq!(result, expected);
1961 }
1962
1963 #[rstest]
1964 #[case(30, BarAggregation::Minute)] #[case(2, BarAggregation::Hour)] #[case(2, BarAggregation::Day)] #[case(1, BarAggregation::Second)] fn test_bar_type_to_futures_resolution_unsupported(
1969 #[case] step: usize,
1970 #[case] aggregation: BarAggregation,
1971 ) {
1972 let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
1973 let bar_type = BarType::new(
1974 instrument_id,
1975 BarSpecification::new(step, aggregation, PriceType::Last),
1976 AggregationSource::External,
1977 );
1978
1979 let result = bar_type_to_futures_resolution(bar_type);
1980 assert!(result.is_err());
1981 assert!(result.unwrap_err().to_string().contains("Unsupported"));
1982 }
1983
1984 #[rstest]
1985 fn test_parse_order_status_report() {
1986 let json = load_test_json("http_open_orders.json");
1987 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1988 let result = wrapper.get("result").unwrap();
1989 let open_map = result.get("open").unwrap();
1990 let orders: IndexMap<String, SpotOrder> = serde_json::from_value(open_map.clone()).unwrap();
1991
1992 let account_id = AccountId::new("KRAKEN-001");
1993 let instrument_id = InstrumentId::new(Symbol::new("BTC/USDT"), *KRAKEN_VENUE);
1994 let instrument = InstrumentAny::CurrencyPair(
1995 CurrencyPair::builder()
1996 .instrument_id(instrument_id)
1997 .raw_symbol(Symbol::new("XBTUSDT"))
1998 .base_currency(Currency::BTC())
1999 .quote_currency(Currency::USDT())
2000 .price_precision(2)
2001 .size_precision(8)
2002 .price_increment(Price::from("0.01"))
2003 .size_increment(Quantity::from("0.00000001"))
2004 .ts_event(TS)
2005 .ts_init(TS)
2006 .build()
2007 .unwrap(),
2008 );
2009
2010 let (order_id, order) = orders.iter().next().unwrap();
2011
2012 let report =
2013 parse_order_status_report(order_id, order, &instrument, account_id, TS).unwrap();
2014
2015 assert_eq!(report.account_id, account_id);
2016 assert_eq!(report.instrument_id, instrument_id);
2017 assert_eq!(report.venue_order_id.as_str(), order_id);
2018 assert_eq!(report.order_status, OrderStatus::Accepted);
2019 assert_eq!(report.quantity, Quantity::from("0.50000000"));
2020 }
2021
2022 type ExpectedPrices<'a> = (&'a str, Option<&'a str>, Option<&'a str>, Option<Decimal>);
2024
2025 #[rstest]
2033 #[case::untriggered_open_orders(
2034 "http_open_orders_conditional.json",
2035 usdc_eur_instrument(),
2036 &[
2037 ("OCAP01-STOPL-000001", None, Some("1.3403"), None),
2038 ("OCAP02-STOPL-000002", None, Some("1.3402"), None),
2039 ("OCAP03-TAKEP-000003", None, Some("0.4467"), None),
2040 ("OCAP04-STLMT-000004", Some("1.3403"), Some("1.3403"), None),
2041 ("OCAP05-TPLMT-000005", Some("0.4467"), Some("0.4467"), None),
2042 ("OCAP06-TRAIL-000006", None, Some("1.3402"), None),
2043 ("OCAP07-TRLMT-000007", None, Some("1.3402"), None),
2044 ("OCAP08-LIMIT-000008", Some("0.4467"), None, None),
2045 ]
2046 )]
2047 #[case::untriggered_canceled_orders_one_amended(
2048 "http_closed_orders_conditional_canceled.json",
2049 usdc_eur_instrument(),
2050 &[
2051 ("OCAP01-STOPL-000001", None, Some("1.4296"), None),
2052 ("OCAP02-STOPL-000002", None, Some("1.3402"), None),
2053 ("OCAP03-TAKEP-000003", None, Some("0.4467"), None),
2054 ("OCAP04-STLMT-000004", Some("1.3403"), Some("1.3403"), None),
2055 ("OCAP05-TPLMT-000005", Some("0.4467"), Some("0.4467"), None),
2056 ("OCAP06-TRAIL-000006", None, Some("1.3401"), None),
2057 ("OCAP07-TRLMT-000007", None, Some("1.3401"), None),
2058 ("OCAP08-LIMIT-000008", Some("0.4467"), None, None),
2059 ]
2060 )]
2061 #[case::canceled_orders_three_triggered(
2062 "http_closed_orders_conditional_triggered.json",
2063 usdc_eur_instrument(),
2064 &[
2065 ("OCAP09-STLMT-000009", Some("0.4464"), Some("1.3394"), None),
2066 ("OCAP10-TPLMT-000010", Some("0.4455"), Some("0.4465"), None),
2067 ("OCAP11-STLMT-000011", Some("0.4464"), Some("0.8929"), None),
2068 ("OCAP12-TPLMT-000012", Some("0.4464"), Some("0.8929"), None),
2069 ("OCAP13-TRLMT-000013", Some("0.4465"), Some("0.8930"), None),
2070 ]
2071 )]
2072 #[case::documented_open_limit_orders(
2073 "http_open_orders_doc_example.json",
2074 xbt_usd_instrument(),
2075 &[
2076 ("OQCLML-BW3P3-BUCMWZ", Some("30010.0"), None, Some(dec!(11253.7) / dec!(0.375))),
2077 ("OB5VMB-B4U2U-DK2WRW", Some("14500.0"), None, None),
2078 ]
2079 )]
2080 #[case::documented_filled_market_order(
2081 "http_query_orders_doc_example.json",
2082 xbt_usd_instrument(),
2083 &[("OMMDB2-FSB6Z-7W3HPO", None, None, Some(dec!(30000)))]
2084 )]
2085 fn test_parse_order_status_report_prices_the_order_from_its_own_fields(
2086 #[case] filename: &str,
2087 #[case] instrument: InstrumentAny,
2088 #[case] expected: &[ExpectedPrices],
2089 ) {
2090 let rows = spot_order_rows(filename);
2091
2092 for &(order_id, expected_price, expected_trigger_price, expected_avg_px) in expected {
2093 let report = parse_order_status_report(
2094 order_id,
2095 &rows[order_id],
2096 &instrument,
2097 AccountId::new("KRAKEN-001"),
2098 TS,
2099 )
2100 .unwrap();
2101
2102 assert_eq!(report.price, expected_price.map(Price::from), "{order_id}");
2103 assert_eq!(
2104 report.trigger_price,
2105 expected_trigger_price.map(Price::from),
2106 "{order_id}"
2107 );
2108 assert_eq!(report.avg_px, expected_avg_px, "{order_id}");
2109 }
2110 }
2111
2112 #[rstest]
2115 fn test_parse_order_status_report_fails_on_a_malformed_order_price() {
2116 let order_id = "OCAP08-LIMIT-000008";
2117 let mut order = spot_order_rows("http_open_orders_conditional.json")
2118 .swap_remove(order_id)
2119 .unwrap();
2120 order.descr.price = "abc".to_string();
2121
2122 let result = parse_order_status_report(
2123 order_id,
2124 &order,
2125 &usdc_eur_instrument(),
2126 AccountId::new("KRAKEN-001"),
2127 TS,
2128 );
2129
2130 assert!(result.is_err(), "{result:?}");
2131 }
2132
2133 #[rstest]
2137 #[case::empty("")]
2138 #[case::relative("+1.5%")]
2139 fn test_parse_order_status_report_reads_an_unpriced_stop_price_as_none(
2140 #[case] stop_price: &str,
2141 ) {
2142 let order_id = "OCAP02-STOPL-000002";
2143 let mut order = spot_order_rows("http_open_orders_conditional.json")
2144 .swap_remove(order_id)
2145 .unwrap();
2146 order.stopprice = Some(stop_price.to_string());
2147
2148 let report = parse_order_status_report(
2149 order_id,
2150 &order,
2151 &usdc_eur_instrument(),
2152 AccountId::new("KRAKEN-001"),
2153 TS,
2154 )
2155 .unwrap();
2156
2157 assert_eq!(report.trigger_price, Some(Price::from("1.3402")));
2158 }
2159
2160 #[rstest]
2164 fn test_parse_order_status_report_prices_no_untriggered_trailing_stop_limit() {
2165 let order_id = "OCAP07-TRLMT-000007";
2166 let mut order = spot_order_rows("http_open_orders_conditional.json")
2167 .swap_remove(order_id)
2168 .unwrap();
2169 order.descr.price2 = "1.0000".to_string();
2170
2171 let report = parse_order_status_report(
2172 order_id,
2173 &order,
2174 &usdc_eur_instrument(),
2175 AccountId::new("KRAKEN-001"),
2176 TS,
2177 )
2178 .unwrap();
2179
2180 assert_eq!(report.price, None);
2181 assert_eq!(report.trigger_price, Some(Price::from("1.3402")));
2182 }
2183
2184 fn spot_order_rows(filename: &str) -> IndexMap<String, SpotOrder> {
2186 let json = load_test_json(filename);
2187 if filename.starts_with("http_open_orders") {
2188 let response: KrakenResponse<SpotOpenOrdersResult> =
2189 serde_json::from_str(&json).unwrap();
2190 response.result.unwrap().open
2191 } else if filename.starts_with("http_closed_orders") {
2192 let response: KrakenResponse<SpotClosedOrdersResult> =
2193 serde_json::from_str(&json).unwrap();
2194 response.result.unwrap().closed
2195 } else {
2196 let response: KrakenResponse<IndexMap<String, SpotOrder>> =
2197 serde_json::from_str(&json).unwrap();
2198 response.result.unwrap()
2199 }
2200 }
2201
2202 fn usdc_eur_instrument() -> InstrumentAny {
2203 InstrumentAny::CurrencyPair(
2204 CurrencyPair::builder()
2205 .instrument_id(InstrumentId::new(Symbol::new("USDC/EUR"), *KRAKEN_VENUE))
2206 .raw_symbol(Symbol::new("USDCEUR"))
2207 .base_currency(Currency::USDC())
2208 .quote_currency(Currency::EUR())
2209 .price_precision(4)
2210 .size_precision(8)
2211 .price_increment(Price::from("0.0001"))
2212 .size_increment(Quantity::from("0.00000001"))
2213 .ts_event(TS)
2214 .ts_init(TS)
2215 .build()
2216 .unwrap(),
2217 )
2218 }
2219
2220 fn xbt_usd_instrument() -> InstrumentAny {
2221 InstrumentAny::CurrencyPair(
2222 CurrencyPair::builder()
2223 .instrument_id(InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE))
2224 .raw_symbol(Symbol::new("XBTUSD"))
2225 .base_currency(Currency::BTC())
2226 .quote_currency(Currency::USD())
2227 .price_precision(1)
2228 .size_precision(8)
2229 .price_increment(Price::from("0.1"))
2230 .size_increment(Quantity::from("0.00000001"))
2231 .ts_event(TS)
2232 .ts_init(TS)
2233 .build()
2234 .unwrap(),
2235 )
2236 }
2237
2238 fn create_mock_perp() -> InstrumentAny {
2239 let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
2240 InstrumentAny::CryptoPerpetual(
2241 CryptoPerpetual::builder()
2242 .instrument_id(instrument_id)
2243 .raw_symbol(Symbol::new("PI_XBTUSD"))
2244 .base_currency(Currency::BTC())
2245 .quote_currency(Currency::USD())
2246 .settlement_currency(Currency::USD())
2247 .is_inverse(false)
2248 .price_precision(1)
2249 .size_precision(0)
2250 .price_increment(Price::from("0.5"))
2251 .size_increment(Quantity::from("1"))
2252 .ts_event(TS)
2253 .ts_init(TS)
2254 .build()
2255 .unwrap(),
2256 )
2257 }
2258
2259 #[rstest]
2260 fn test_parse_futures_assignee_fill_report() {
2261 let json = load_test_json("http_futures_fills.json");
2262 let response: FuturesFillsResponse = serde_json::from_str(&json).unwrap();
2263 let fill = &response.fills[2];
2264 let instrument = create_mock_perp();
2265 let account_id = AccountId::new("KRAKEN-001");
2266
2267 let report = parse_futures_fill_report(fill, &instrument, account_id, TS).unwrap();
2268
2269 assert_eq!(report.account_id, account_id);
2270 assert_eq!(report.instrument_id, instrument.id());
2271 assert_eq!(
2272 report.venue_order_id,
2273 VenueOrderId::new("f8a7b6c5-d4e3-2f1a-0b9c-8d7e6f5a4b3c")
2274 );
2275 assert_eq!(
2276 report.trade_id,
2277 TradeId::new("d3f4e5a6-b7c8-9d0e-1f2a-3b4c5d6e7f8a")
2278 );
2279 assert_eq!(report.order_side, OrderSide::Sell);
2280 assert_eq!(report.last_qty, Quantity::from("2500"));
2281 assert_eq!(report.last_px, Price::from("28050.0"));
2282 assert_eq!(report.commission, Money::zero(Currency::USD()));
2283 assert_eq!(report.liquidity_side, LiquiditySide::NoLiquiditySide);
2284 assert_eq!(report.avg_px, None);
2285 assert_eq!(
2286 report.ts_event,
2287 "2023-04-07T15:55:20.123Z".parse::<UnixNanos>().unwrap()
2288 );
2289 assert_eq!(report.ts_init, TS);
2290 assert_eq!(report.client_order_id, None);
2291 assert_eq!(report.venue_position_id, None);
2292 }
2293
2294 #[rstest]
2295 fn test_parse_futures_order_status_report_market_if_touched() {
2296 let order = FuturesOpenOrder {
2297 order_id: "tp-001".to_string(),
2298 symbol: "PI_XBTUSD".to_string(),
2299 side: KrakenOrderSide::Buy,
2300 order_type: KrakenFuturesOrderType::TakeProfit,
2301 limit_price: None,
2302 stop_price: Some(dec!(36000)),
2303 unfilled_size: Some(dec!(500)),
2304 received_time: "2023-11-14T22:13:20.000Z".to_string(),
2305 status: KrakenFuturesOrderStatus::PartiallyFilled,
2306 filled_size: dec!(25),
2307 reduce_only: Some(true),
2308 last_update_time: "2023-11-14T22:13:20.000Z".to_string(),
2309 trigger_signal: None,
2310 cli_ord_id: Some("my-tp-1".to_string()),
2311 cli_ord_id_snake_case: None,
2312 };
2313 let instrument = create_mock_perp();
2314 let account_id = AccountId::new("KRAKEN-001");
2315
2316 let report =
2317 parse_futures_order_status_report(&order, &instrument, account_id, None, TS).unwrap();
2318
2319 assert_eq!(report.order_type, OrderType::MarketIfTouched);
2320 assert_eq!(report.quantity.as_decimal(), dec!(525));
2321 assert_eq!(report.filled_qty.as_decimal(), dec!(25));
2322 assert_eq!(report.trigger_price.unwrap().as_decimal(), dec!(36000));
2323 assert!(report.price.is_none());
2324 assert!(report.reduce_only);
2325 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
2326 }
2327
2328 #[rstest]
2329 fn test_parse_futures_order_status_report_limit_if_touched() {
2330 let order = FuturesOpenOrder {
2331 order_id: "tpl-001".to_string(),
2332 symbol: "PI_XBTUSD".to_string(),
2333 side: KrakenOrderSide::Sell,
2334 order_type: KrakenFuturesOrderType::TakeProfit,
2335 limit_price: Some(dec!(35500)),
2336 stop_price: Some(dec!(36000)),
2337 unfilled_size: Some(dec!(500)),
2338 received_time: "2023-11-14T22:13:20.000Z".to_string(),
2339 status: KrakenFuturesOrderStatus::Untouched,
2340 filled_size: dec!(0),
2341 reduce_only: None,
2342 last_update_time: "2023-11-14T22:13:20.000Z".to_string(),
2343 trigger_signal: None,
2344 cli_ord_id: Some("my-tpl-1".to_string()),
2345 cli_ord_id_snake_case: None,
2346 };
2347 let instrument = create_mock_perp();
2348 let account_id = AccountId::new("KRAKEN-001");
2349
2350 let report =
2351 parse_futures_order_status_report(&order, &instrument, account_id, None, TS).unwrap();
2352
2353 assert_eq!(report.order_type, OrderType::LimitIfTouched);
2354 assert_eq!(report.trigger_price.unwrap().as_decimal(), dec!(36000));
2355 assert_eq!(report.price.unwrap().as_decimal(), dec!(35500));
2356 assert_eq!(report.order_side, OrderSide::Sell.into());
2357 assert!(!report.reduce_only);
2358 }
2359
2360 #[rstest]
2361 fn test_parse_futures_order_event_market_if_touched() {
2362 let event = FuturesOrderEvent {
2363 order_id: "tp-evt-001".to_string(),
2364 cli_ord_id: None,
2365 order_type: KrakenFuturesOrderType::TakeProfit,
2366 symbol: "PI_XBTUSD".to_string(),
2367 side: KrakenOrderSide::Buy,
2368 quantity: dec!(100),
2369 filled: dec!(100),
2370 limit_price: None,
2371 stop_price: Some(dec!(40000)),
2372 timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2373 last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2374 reduce_only: false,
2375 };
2376 let instrument = create_mock_perp();
2377 let account_id = AccountId::new("KRAKEN-001");
2378
2379 let report = parse_futures_order_event_status_report(
2380 &event,
2381 Some(KrakenFuturesOrderEventType::Fill),
2382 &instrument,
2383 account_id,
2384 TS,
2385 )
2386 .unwrap();
2387
2388 assert_eq!(report.order_type, OrderType::MarketIfTouched);
2389 assert_eq!(report.trigger_price.unwrap().as_decimal(), dec!(40000));
2390 assert!(report.price.is_none());
2391 assert_eq!(report.order_status, OrderStatus::Filled);
2392 }
2393
2394 #[rstest]
2395 fn test_parse_futures_order_event_limit_if_touched() {
2396 let event = FuturesOrderEvent {
2397 order_id: "tpl-evt-001".to_string(),
2398 cli_ord_id: Some("my-tpl-evt".to_string()),
2399 order_type: KrakenFuturesOrderType::TakeProfit,
2400 symbol: "PI_XBTUSD".to_string(),
2401 side: KrakenOrderSide::Sell,
2402 quantity: dec!(200),
2403 filled: Decimal::ZERO,
2404 limit_price: Some(dec!(39500)),
2405 stop_price: Some(dec!(40000)),
2406 timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2407 last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2408 reduce_only: true,
2409 };
2410 let instrument = create_mock_perp();
2411 let account_id = AccountId::new("KRAKEN-001");
2412
2413 let report = parse_futures_order_event_status_report(
2414 &event,
2415 Some(KrakenFuturesOrderEventType::Place),
2416 &instrument,
2417 account_id,
2418 TS,
2419 )
2420 .unwrap();
2421
2422 assert_eq!(report.order_type, OrderType::LimitIfTouched);
2423 assert_eq!(report.trigger_price.unwrap().as_decimal(), dec!(40000));
2424 assert_eq!(report.price.unwrap().as_decimal(), dec!(39500));
2425 assert_eq!(report.order_side, OrderSide::Sell.into());
2426 assert_eq!(report.order_status, OrderStatus::Accepted);
2427 assert!(report.reduce_only);
2428 }
2429
2430 #[rstest]
2431 fn test_parse_futures_order_event_cancel_status() {
2432 let event = FuturesOrderEvent {
2433 order_id: "cancel-evt-001".to_string(),
2434 cli_ord_id: Some("cancel-evt".to_string()),
2435 order_type: KrakenFuturesOrderType::Stop,
2436 symbol: "PI_XBTUSD".to_string(),
2437 side: KrakenOrderSide::Sell,
2438 quantity: dec!(200),
2439 filled: Decimal::ZERO,
2440 limit_price: None,
2441 stop_price: Some(dec!(39000)),
2442 timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2443 last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2444 reduce_only: true,
2445 };
2446 let instrument = create_mock_perp();
2447 let account_id = AccountId::new("KRAKEN-001");
2448
2449 let report = parse_futures_order_event_status_report(
2450 &event,
2451 Some(KrakenFuturesOrderEventType::Cancel),
2452 &instrument,
2453 account_id,
2454 TS,
2455 )
2456 .unwrap();
2457
2458 assert_eq!(report.order_status, OrderStatus::Canceled);
2459 assert!(report.reduce_only);
2460 }
2461
2462 #[rstest]
2463 fn test_parse_futures_order_event_reject_status() {
2464 let event = FuturesOrderEvent {
2465 order_id: "reject-evt-001".to_string(),
2466 cli_ord_id: Some("reject-evt".to_string()),
2467 order_type: KrakenFuturesOrderType::Limit,
2468 symbol: "PI_XBTUSD".to_string(),
2469 side: KrakenOrderSide::Buy,
2470 quantity: dec!(200),
2471 filled: Decimal::ZERO,
2472 limit_price: Some(dec!(35000)),
2473 stop_price: None,
2474 timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2475 last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2476 reduce_only: false,
2477 };
2478 let instrument = create_mock_perp();
2479 let account_id = AccountId::new("KRAKEN-001");
2480
2481 let report = parse_futures_order_event_status_report(
2482 &event,
2483 Some(KrakenFuturesOrderEventType::Reject),
2484 &instrument,
2485 account_id,
2486 TS,
2487 )
2488 .unwrap();
2489
2490 assert_eq!(report.order_status, OrderStatus::Rejected);
2491 }
2492
2493 #[rstest]
2494 fn test_parse_futures_order_event_expire_status() {
2495 let event = FuturesOrderEvent {
2496 order_id: "expire-evt-001".to_string(),
2497 cli_ord_id: Some("expire-evt".to_string()),
2498 order_type: KrakenFuturesOrderType::Limit,
2499 symbol: "PI_XBTUSD".to_string(),
2500 side: KrakenOrderSide::Buy,
2501 quantity: dec!(200),
2502 filled: Decimal::ZERO,
2503 limit_price: Some(dec!(35000)),
2504 stop_price: None,
2505 timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2506 last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2507 reduce_only: false,
2508 };
2509 let instrument = create_mock_perp();
2510 let account_id = AccountId::new("KRAKEN-001");
2511
2512 let report = parse_futures_order_event_status_report(
2513 &event,
2514 Some(KrakenFuturesOrderEventType::Expire),
2515 &instrument,
2516 account_id,
2517 TS,
2518 )
2519 .unwrap();
2520
2521 assert_eq!(report.order_status, OrderStatus::Expired);
2522 }
2523
2524 #[rstest]
2525 fn test_parse_futures_order_event_execution_status() {
2526 let event = FuturesOrderEvent {
2527 order_id: "execution-evt-001".to_string(),
2528 cli_ord_id: Some("execution-evt".to_string()),
2529 order_type: KrakenFuturesOrderType::Limit,
2530 symbol: "PI_XBTUSD".to_string(),
2531 side: KrakenOrderSide::Buy,
2532 quantity: dec!(200),
2533 filled: dec!(50),
2534 limit_price: Some(dec!(35000)),
2535 stop_price: None,
2536 timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2537 last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2538 reduce_only: false,
2539 };
2540 let instrument = create_mock_perp();
2541 let account_id = AccountId::new("KRAKEN-001");
2542
2543 let report = parse_futures_order_event_status_report(
2544 &event,
2545 Some(KrakenFuturesOrderEventType::Execution),
2546 &instrument,
2547 account_id,
2548 TS,
2549 )
2550 .unwrap();
2551
2552 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
2553 }
2554
2555 #[rstest]
2556 fn test_parse_fill_report() {
2557 let json = load_test_json("http_trades_history.json");
2558 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
2559 let result = wrapper.get("result").unwrap();
2560 let trades_map = result.get("trades").unwrap();
2561 let trades: IndexMap<String, SpotTrade> =
2562 serde_json::from_value(trades_map.clone()).unwrap();
2563
2564 let account_id = AccountId::new("KRAKEN-001");
2565 let instrument_id = InstrumentId::new(Symbol::new("BTC/USDT"), *KRAKEN_VENUE);
2566 let instrument = InstrumentAny::CurrencyPair(
2567 CurrencyPair::builder()
2568 .instrument_id(instrument_id)
2569 .raw_symbol(Symbol::new("XBTUSDT"))
2570 .base_currency(Currency::BTC())
2571 .quote_currency(Currency::USDT())
2572 .price_precision(2)
2573 .size_precision(8)
2574 .price_increment(Price::from("0.01"))
2575 .size_increment(Quantity::from("0.00000001"))
2576 .ts_event(TS)
2577 .ts_init(TS)
2578 .build()
2579 .unwrap(),
2580 );
2581
2582 let (trade_id, trade) = trades.iter().next().unwrap();
2583
2584 let report = parse_fill_report(trade_id, trade, &instrument, account_id, TS).unwrap();
2585
2586 assert_eq!(report.account_id, account_id);
2587 assert_eq!(report.instrument_id, instrument_id);
2588 assert_eq!(report.trade_id.to_string(), *trade_id);
2589 assert_eq!(report.last_qty, Quantity::from("0.50000000"));
2590 assert_eq!(report.last_px, Price::from("29500.50"));
2591 assert_eq!(report.commission.as_decimal(), dec!(23.60));
2592 }
2593
2594 #[rstest]
2595 #[case("XXBT", "BTC")]
2596 #[case("XBT", "BTC")]
2597 #[case("XXDG", "DOGE")]
2598 #[case("XDG", "DOGE")]
2599 #[case("XETH", "ETH")]
2600 #[case("ZUSD", "USD")]
2601 #[case("ZEUR", "EUR")]
2602 #[case("KFEE", "FEE")]
2603 #[case("BTC", "BTC")]
2604 #[case("ETH", "ETH")]
2605 #[case("USDT", "USDT")]
2606 #[case("SOL", "SOL")]
2607 #[case("XTZ", "XTZ")]
2609 #[case("XRP", "XRP")]
2610 #[case("XLM", "XLM")]
2611 #[case("XAUT", "XAUT")]
2612 #[case("XCN", "XCN")]
2613 #[case("ZRX", "ZRX")]
2614 #[case("ZEC", "ZEC")]
2615 #[case("ZK", "ZK")]
2616 fn test_normalize_currency_code(#[case] input: &str, #[case] expected: &str) {
2617 assert_eq!(normalize_currency_code(input), expected);
2618 }
2619
2620 #[rstest]
2622 #[case("xbt", "BTC")]
2623 #[case("XBT", "BTC")]
2624 #[case("xxbt", "BTC")]
2625 #[case("zeur", "EUR")]
2626 #[case("usd", "USD")]
2627 #[case("xtz", "XTZ")]
2628 #[case("flr", "FLR")]
2629 fn test_normalize_asset_key_is_case_tolerant(#[case] input: &str, #[case] expected: &str) {
2630 assert_eq!(normalize_asset_key(input), expected);
2631 }
2632
2633 #[rstest]
2634 #[case("XBT/EUR", "BTC/EUR")]
2635 #[case("XBT/USD", "BTC/USD")]
2636 #[case("XBT/USDT", "BTC/USDT")]
2637 #[case("ETH/USD", "ETH/USD")]
2638 #[case("ETH/XBT", "ETH/BTC")]
2639 #[case("SOL/XBT", "SOL/BTC")]
2640 #[case("SOL/USD", "SOL/USD")]
2641 #[case("BTC/USD", "BTC/USD")]
2642 #[case("ETH/BTC", "ETH/BTC")]
2643 #[case("XDG/USD", "DOGE/USD")]
2644 #[case("XDG/EUR", "DOGE/EUR")]
2645 #[case("XDG/BTC", "DOGE/BTC")]
2646 #[case("XDG/XBT", "DOGE/BTC")]
2647 fn test_normalize_spot_symbol(#[case] input: &str, #[case] expected: &str) {
2648 assert_eq!(normalize_spot_symbol(input), expected);
2649 }
2650
2651 #[rstest]
2652 #[case("A", "A")] #[case("O2026022700232", "O2026022700232")] #[case("ABCDEFGHIJKLMNOPQR", "ABCDEFGHIJKLMNOPQR")] fn test_truncate_cl_ord_id_short_passthrough(#[case] input: &str, #[case] expected: &str) {
2656 let id = ClientOrderId::new(input);
2657 assert_eq!(truncate_cl_ord_id(&id), expected);
2658 }
2659
2660 #[rstest]
2661 #[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) {
2666 let id = ClientOrderId::new(input);
2667 assert_eq!(truncate_cl_ord_id(&id), input);
2668 }
2669
2670 #[rstest]
2671 #[case("6d47a5f06fd44b84b56ec23f0f689c20")] #[case("6D47A5F06FD44B84B56EC23F0F689C20")] #[case("00000000000000000000000000000000")] #[case("aAbBcCdDeEfF00112233445566778899")] fn test_truncate_cl_ord_id_uuid_compact_passthrough(#[case] input: &str) {
2676 let id = ClientOrderId::new(input);
2677 assert_eq!(truncate_cl_ord_id(&id), input);
2678 }
2679
2680 #[rstest]
2681 #[case("O2026022700232100400", "O26022700232100400")] #[case("O202602270023210040011", "O02270023210040011")] #[case("O20260227002321004001100", "O27002321004001100")] fn test_truncate_cl_ord_id_sequential_truncated(#[case] input: &str, #[case] expected: &str) {
2685 let id = ClientOrderId::new(input);
2686 let result = truncate_cl_ord_id(&id);
2687 assert_eq!(result, expected);
2688 assert_eq!(result.len(), 18);
2689 assert!(result.starts_with('O'));
2690 }
2691
2692 #[rstest]
2693 fn test_truncate_cl_ord_id_32_chars_non_hex_truncated() {
2694 let input = "0123456789abcdef0123456789abcdeg";
2695 let id = ClientOrderId::new(input);
2696 let result = truncate_cl_ord_id(&id);
2697 assert_eq!(result.len(), 18);
2698 assert!(result.starts_with('O'));
2699 assert_eq!(result, "Of0123456789abcdeg");
2700 }
2701
2702 #[rstest]
2703 fn test_truncate_cl_ord_id_36_chars_wrong_hyphens_truncated() {
2704 let input = "6d47a5f0-6fd4-4b84-b56ec23f0f689c200";
2705 let id = ClientOrderId::new(input);
2706 let result = truncate_cl_ord_id(&id);
2707 assert_eq!(result.len(), 18);
2708 assert!(result.starts_with('O'));
2709 }
2710
2711 #[rstest]
2714 fn test_pair_info_records_pair_decimals_only_when_finer_than_the_tick() {
2715 let json = load_test_json("http_asset_pairs_tokenized.json");
2716 let response: KrakenResponse<AssetPairsResponse> = serde_json::from_str(&json).unwrap();
2717 let pairs = response.result.unwrap();
2718 let (pair_name, definition) = pairs.iter().next().unwrap();
2719
2720 let same = parse_tokenized_instrument(pair_name, definition, TS, TS).unwrap();
2721 assert!(
2722 same.info()
2723 .is_none_or(|info| !info.contains_key(KRAKEN_PAIR_DECIMALS_KEY)),
2724 "a scale equal to the tick precision is not recorded"
2725 );
2726
2727 let mut coarser = definition.clone();
2728 coarser.pair_decimals = same.price_precision() - 1;
2729 let tokenized = parse_tokenized_instrument(pair_name, &coarser, TS, TS).unwrap();
2730 let spot = parse_spot_instrument(pair_name, &coarser, TS, TS).unwrap();
2731 for instrument in [tokenized, spot] {
2732 assert!(
2733 instrument
2734 .info()
2735 .is_none_or(|info| !info.contains_key(KRAKEN_PAIR_DECIMALS_KEY)),
2736 "a scale coarser than the tick precision is not recorded"
2737 );
2738 }
2739
2740 let mut finer = definition.clone();
2741 finer.pair_decimals = same.price_precision() + 1;
2742 let tokenized = parse_tokenized_instrument(pair_name, &finer, TS, TS).unwrap();
2743 let spot = parse_spot_instrument(pair_name, &finer, TS, TS).unwrap();
2744 for instrument in [tokenized, spot] {
2745 let recorded = instrument
2746 .info()
2747 .and_then(|info| info.get(KRAKEN_PAIR_DECIMALS_KEY))
2748 .and_then(serde_json::Value::as_u64);
2749 assert_eq!(recorded, Some(u64::from(finer.pair_decimals)));
2750 }
2751 }
2752
2753 #[rstest]
2754 fn test_parse_tokenized_instrument() {
2755 let json = load_test_json("http_asset_pairs_tokenized.json");
2756 let response: KrakenResponse<AssetPairsResponse> = serde_json::from_str(&json).unwrap();
2757 let pairs = response.result.unwrap();
2758
2759 let (pair_name, definition) = pairs.iter().next().unwrap();
2760
2761 let instrument = parse_tokenized_instrument(pair_name, definition, TS, TS).unwrap();
2762
2763 match instrument {
2764 InstrumentAny::TokenizedAsset(ta) => {
2765 assert_eq!(ta.id.symbol.as_str(), "AAPLx/USD");
2766 assert_eq!(ta.id.venue.as_str(), "KRAKEN");
2767 assert_eq!(ta.raw_symbol.as_str(), "AAPLxUSD");
2768 assert_eq!(ta.asset_class, AssetClass::Equity);
2769 assert_eq!(ta.base_currency.code, "AAPLx");
2770 assert_eq!(ta.quote_currency.code, "USD");
2771 assert_eq!(ta.price_precision, 2);
2772 assert_eq!(ta.size_precision, 8);
2773 assert_eq!(ta.price_increment.as_decimal(), dec!(0.01));
2774 assert_eq!(ta.size_increment.as_decimal(), dec!(0.00000001));
2775 assert!(ta.min_quantity.is_some());
2776 }
2777 _ => panic!("Expected TokenizedAsset, received {instrument:?}"),
2778 }
2779
2780 let mut finer = definition.clone();
2783 finer.pair_decimals = 3;
2784 let instrument = parse_tokenized_instrument("XAAPLxZUSD", &finer, TS, TS).unwrap();
2785 let info = instrument.info().expect("the wire scale is recorded");
2786 assert_eq!(
2787 info.get(KRAKEN_PAIR_DECIMALS_KEY)
2788 .and_then(serde_json::Value::as_u64),
2789 Some(3)
2790 );
2791 assert!(
2792 !info.contains_key(KRAKEN_ALTNAME_KEY),
2793 "a tokenized pair carries no altname alias"
2794 );
2795 }
2796
2797 #[rstest]
2798 fn test_parse_fill_report_tokenized_asset() {
2799 let json = load_test_json("http_trades_history.json");
2800 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
2801 let result = wrapper.get("result").unwrap();
2802 let trades_map = result.get("trades").unwrap();
2803 let trades: IndexMap<String, SpotTrade> =
2804 serde_json::from_value(trades_map.clone()).unwrap();
2805
2806 let account_id = AccountId::new("KRAKEN-001");
2807 let instrument_id = InstrumentId::new(Symbol::new("AAPLx/USD"), *KRAKEN_VENUE);
2808 let instrument = InstrumentAny::TokenizedAsset(
2809 TokenizedAsset::builder()
2810 .instrument_id(instrument_id)
2811 .raw_symbol(Symbol::new("AAPLxUSD"))
2812 .asset_class(AssetClass::Equity)
2813 .base_currency(Currency::get_or_create_crypto("AAPLx"))
2814 .quote_currency(Currency::USD())
2815 .price_precision(2)
2816 .size_precision(8)
2817 .price_increment(Price::from("0.01"))
2818 .size_increment(Quantity::from("0.00000001"))
2819 .ts_event(TS)
2820 .ts_init(TS)
2821 .build()
2822 .unwrap(),
2823 );
2824
2825 let (trade_id, trade) = trades.iter().next().unwrap();
2826
2827 let report = parse_fill_report(trade_id, trade, &instrument, account_id, TS).unwrap();
2828
2829 assert_eq!(report.account_id, account_id);
2830 assert_eq!(report.instrument_id, instrument_id);
2831 assert_eq!(report.trade_id.to_string(), *trade_id);
2832 assert_eq!(report.last_qty, Quantity::from("0.50000000"));
2833 assert_eq!(report.last_px, Price::from("29500.50"));
2834 assert_eq!(report.commission.currency, Currency::USD());
2835 }
2836
2837 #[rstest]
2838 fn test_truncate_cl_ord_id_19_chars_truncated() {
2839 let input = "O202602270023210040";
2840 assert_eq!(input.len(), 19);
2841 let id = ClientOrderId::new(input);
2842 let result = truncate_cl_ord_id(&id);
2843 assert_eq!(result.len(), 18);
2844 assert_eq!(result, "O02602270023210040");
2845 }
2846
2847 #[rstest]
2848 fn test_truncate_cl_ord_id_preserves_tail() {
2849 let input = "O20260227002321004001100";
2850 let id = ClientOrderId::new(input);
2851 let result = truncate_cl_ord_id(&id);
2852 assert_eq!(&result[1..], &input[input.len() - 17..]);
2853 }
2854}