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::from_index_map(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::from_index_map(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: order.cli_ord_id.as_ref().map(|s| s.as_str().into()),
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: event.cli_ord_id.as_ref().map(|s| s.as_str().into()),
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
1156pub fn parse_futures_order_status_details_report(
1168 details: &FuturesOrderStatusDetails,
1169 instrument: &InstrumentAny,
1170 account_id: AccountId,
1171 ts_init: UnixNanos,
1172) -> anyhow::Result<OrderStatusReport> {
1173 let venue_order_id = VenueOrderId::new(&details.order.order_id);
1174
1175 let order_side = OrderSide::from(details.order.side).into();
1176
1177 let order_type = if details.order.limit_price.is_some() {
1178 OrderType::Limit
1179 } else {
1180 OrderType::Market
1181 };
1182
1183 let filled = details.order.filled.unwrap_or(Decimal::ZERO);
1184
1185 let order_status = match details.status {
1186 KrakenFuturesOrderLifecycleStatus::EnteredBook
1187 | KrakenFuturesOrderLifecycleStatus::TriggerPlaced => {
1188 if filled > Decimal::ZERO {
1189 OrderStatus::PartiallyFilled
1190 } else {
1191 OrderStatus::Accepted
1192 }
1193 }
1194 KrakenFuturesOrderLifecycleStatus::FullyExecuted => OrderStatus::Filled,
1195 KrakenFuturesOrderLifecycleStatus::Rejected
1196 | KrakenFuturesOrderLifecycleStatus::TriggerActivationFailure => OrderStatus::Rejected,
1197 KrakenFuturesOrderLifecycleStatus::Cancelled => OrderStatus::Canceled,
1198 };
1199
1200 let quantity_value = details
1201 .order
1202 .quantity
1203 .context("order status details missing quantity")?;
1204 let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())?;
1205 let filled_qty = Quantity::from_decimal_dp(filled, instrument.size_precision())?;
1206
1207 let ts_accepted = parse_rfc3339_timestamp(&details.order.timestamp, "order.timestamp")?;
1208 let ts_last = parse_rfc3339_timestamp(
1209 &details.order.last_update_timestamp,
1210 "order.last_update_timestamp",
1211 )?;
1212
1213 let price = details
1214 .order
1215 .limit_price
1216 .map(|p| Price::from_decimal_dp(p, instrument.price_precision()))
1217 .transpose()?;
1218
1219 let cancel_reason = match details.status {
1220 KrakenFuturesOrderLifecycleStatus::Cancelled
1221 | KrakenFuturesOrderLifecycleStatus::Rejected
1222 | KrakenFuturesOrderLifecycleStatus::TriggerActivationFailure => {
1223 details.update_reason.clone()
1224 }
1225 _ => None,
1226 };
1227
1228 Ok(OrderStatusReport {
1229 account_id,
1230 instrument_id: instrument.id(),
1231 client_order_id: details.order.cli_ord_id.as_ref().map(|s| s.as_str().into()),
1232 venue_order_id,
1233 order_side,
1234 order_type,
1235 time_in_force: TimeInForce::Gtc,
1236 order_status,
1237 quantity,
1238 filled_qty,
1239 report_id: UUID4::new(),
1240 ts_accepted,
1241 ts_last,
1242 ts_init,
1243 order_list_id: None,
1244 venue_position_id: None,
1245 linked_order_ids: None,
1246 parent_order_id: None,
1247 contingency_type: None,
1248 expire_time: None,
1249 price,
1250 activation_price: None,
1251 trigger_price: None,
1252 trigger_type: None,
1253 limit_offset: None,
1254 trailing_offset: None,
1255 trailing_offset_type: None,
1256 display_qty: None,
1257 avg_px: None,
1258 post_only: false,
1259 reduce_only: details.order.reduce_only,
1260 cancel_reason,
1261 ts_triggered: None,
1262 })
1263}
1264
1265fn parse_futures_order_event_status(
1266 event_type: Option<KrakenFuturesOrderEventType>,
1267 filled: Decimal,
1268 quantity: Decimal,
1269) -> OrderStatus {
1270 match event_type {
1271 Some(KrakenFuturesOrderEventType::Cancel) => OrderStatus::Canceled,
1272 Some(KrakenFuturesOrderEventType::Reject) => OrderStatus::Rejected,
1273 Some(KrakenFuturesOrderEventType::Expire) => OrderStatus::Expired,
1274 Some(
1275 KrakenFuturesOrderEventType::Fill
1276 | KrakenFuturesOrderEventType::Execution
1277 | KrakenFuturesOrderEventType::Place
1278 | KrakenFuturesOrderEventType::Edit,
1279 ) => {
1280 if filled >= quantity {
1281 OrderStatus::Filled
1282 } else if filled > Decimal::ZERO {
1283 OrderStatus::PartiallyFilled
1284 } else {
1285 OrderStatus::Accepted
1286 }
1287 }
1288 _ => {
1289 if filled >= quantity {
1290 OrderStatus::Filled
1291 } else if filled > Decimal::ZERO {
1292 OrderStatus::PartiallyFilled
1293 } else {
1294 OrderStatus::Canceled
1295 }
1296 }
1297 }
1298}
1299
1300pub fn parse_futures_fill_report(
1306 fill: &FuturesFill,
1307 instrument: &InstrumentAny,
1308 account_id: AccountId,
1309 ts_init: UnixNanos,
1310) -> anyhow::Result<FillReport> {
1311 let instrument_id = instrument.id();
1312 let venue_order_id = VenueOrderId::new(&fill.order_id);
1313 let trade_id = TradeId::new(&fill.fill_id);
1314
1315 let order_side = fill.side.into();
1316
1317 let last_qty = Quantity::from_decimal_dp(fill.size, instrument.size_precision())?;
1318 let last_px = Price::from_decimal_dp(fill.price, instrument.price_precision())?;
1319
1320 let quote_currency = match instrument {
1321 InstrumentAny::CryptoPerpetual(perp) => perp.quote_currency,
1322 InstrumentAny::CryptoFuture(future) => future.quote_currency,
1323 _ => anyhow::bail!("Unsupported instrument type for futures fill report"),
1324 };
1325
1326 let commission_currency = fill
1328 .fee_currency
1329 .as_deref()
1330 .map_or(quote_currency, |reported| {
1331 fee_currency(reported, instrument)
1332 });
1333 let commission =
1334 Money::from_decimal(fill.fee_paid.unwrap_or(Decimal::ZERO), commission_currency)?;
1335
1336 let liquidity_side = fill.fill_type.into();
1337
1338 let ts_event = parse_rfc3339_timestamp(&fill.fill_time, "fill.fill_time")?;
1339
1340 Ok(FillReport {
1341 account_id,
1342 instrument_id,
1343 venue_order_id,
1344 trade_id,
1345 order_side,
1346 last_qty,
1347 last_px,
1348 commission,
1349 liquidity_side,
1350 avg_px: None,
1351 report_id: UUID4::new(),
1352 ts_event,
1353 ts_init,
1354 client_order_id: fill.cli_ord_id.as_ref().map(|s| s.as_str().into()),
1355 venue_position_id: None,
1356 })
1357}
1358
1359pub fn parse_futures_position_status_report(
1365 position: &FuturesPosition,
1366 instrument: &InstrumentAny,
1367 account_id: AccountId,
1368 ts_init: UnixNanos,
1369) -> anyhow::Result<PositionStatusReport> {
1370 let instrument_id = instrument.id();
1371
1372 let position_side = match position.side {
1373 KrakenPositionSide::Long => PositionSide::Long,
1374 KrakenPositionSide::Short => PositionSide::Short,
1375 };
1376
1377 let quantity = Quantity::from_decimal_dp(position.size, instrument.size_precision())?;
1378 let signed_decimal_qty = match position_side {
1379 PositionSide::Long => position.size,
1380 PositionSide::Short => -position.size,
1381 PositionSide::Flat => dec!(0),
1382 };
1383
1384 let avg_px_open = Some(position.price);
1385
1386 Ok(PositionStatusReport {
1387 account_id,
1388 instrument_id,
1389 position_side,
1390 quantity,
1391 signed_decimal_qty,
1392 report_id: UUID4::new(),
1393 ts_last: ts_init,
1394 ts_init,
1395 venue_position_id: None,
1396 avg_px_open,
1397 avg_px_open_reconciliation: AvgPxReconciliation::default(),
1398 avg_px_open_precision: None,
1399 })
1400}
1401
1402pub fn bar_type_to_spot_interval(bar_type: BarType) -> anyhow::Result<u32> {
1410 let step = bar_type.spec().step.get() as u32;
1411 let base_interval = match bar_type.spec().aggregation {
1412 BarAggregation::Minute => 1,
1413 BarAggregation::Hour => 60,
1414 BarAggregation::Day => 1440,
1415 other => {
1416 anyhow::bail!("Unsupported bar aggregation for Kraken Spot: {other:?}");
1417 }
1418 };
1419 Ok(base_interval * step)
1420}
1421
1422pub fn bar_type_to_futures_resolution(bar_type: BarType) -> anyhow::Result<&'static str> {
1432 let step = bar_type.spec().step.get() as u32;
1433 match bar_type.spec().aggregation {
1434 BarAggregation::Minute => match step {
1435 1 => Ok("1m"),
1436 5 => Ok("5m"),
1437 15 => Ok("15m"),
1438 _ => anyhow::bail!("Unsupported minute step for Kraken Futures: {step}"),
1439 },
1440 BarAggregation::Hour => match step {
1441 1 => Ok("1h"),
1442 4 => Ok("4h"),
1443 12 => Ok("12h"),
1444 _ => anyhow::bail!("Unsupported hour step for Kraken Futures: {step}"),
1445 },
1446 BarAggregation::Day => {
1447 if step == 1 {
1448 Ok("1d")
1449 } else {
1450 anyhow::bail!("Unsupported day step for Kraken Futures: {step}")
1451 }
1452 }
1453 BarAggregation::Week => {
1454 if step == 1 {
1455 Ok("1w")
1456 } else {
1457 anyhow::bail!("Unsupported week step for Kraken Futures: {step}")
1458 }
1459 }
1460 other => {
1461 anyhow::bail!("Unsupported bar aggregation for Kraken Futures: {other:?}");
1462 }
1463 }
1464}
1465
1466pub fn truncate_cl_ord_id(client_order_id: &ClientOrderId) -> String {
1477 let id = client_order_id.as_str();
1478
1479 if id.len() <= 18 {
1480 return id.to_string();
1481 }
1482
1483 if id.len() == 36 && id.bytes().filter(|b| *b == b'-').count() == 4 {
1484 return id.to_string();
1485 }
1486
1487 if id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit()) {
1488 return id.to_string();
1489 }
1490
1491 format!("O{}", &id[id.len() - 17..])
1492}
1493
1494#[cfg(test)]
1495mod tests {
1496 use indexmap::IndexMap;
1497 use nautilus_model::{
1498 data::BarSpecification,
1499 enums::{AggregationSource, BarAggregation, OrderSide, OrderStatus, PriceType},
1500 instruments::crypto_perpetual::CryptoPerpetual,
1501 };
1502 use rstest::rstest;
1503
1504 use super::*;
1505 use crate::{
1506 common::enums::{
1507 KrakenFuturesOrderEventType, KrakenFuturesOrderStatus, KrakenFuturesOrderType,
1508 KrakenOrderSide,
1509 },
1510 http::{
1511 futures::models::{FuturesFillsResponse, FuturesOpenOrder, FuturesOrderEvent},
1512 models::{
1513 AssetPairsResponse, KrakenResponse, SpotClosedOrdersResult, SpotOpenOrdersResult,
1514 },
1515 },
1516 };
1517
1518 const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
1519
1520 fn load_test_json(filename: &str) -> String {
1521 let path = format!("test_data/{filename}");
1522 std::fs::read_to_string(&path)
1523 .unwrap_or_else(|e| panic!("Failed to load test data from {path}: {e}"))
1524 }
1525
1526 #[rstest]
1527 fn test_parse_decimal() {
1528 assert_eq!(parse_decimal("123.45").unwrap(), dec!(123.45));
1529 assert_eq!(parse_decimal("0").unwrap(), dec!(0));
1530 assert_eq!(parse_decimal("").unwrap(), dec!(0));
1531 }
1532
1533 #[rstest]
1534 fn test_parse_decimal_opt() {
1535 assert_eq!(
1536 parse_decimal_opt(Some("123.45")).unwrap(),
1537 Some(dec!(123.45))
1538 );
1539 assert_eq!(parse_decimal_opt(Some("0")).unwrap(), None);
1540 assert_eq!(parse_decimal_opt(Some("")).unwrap(), None);
1541 assert_eq!(parse_decimal_opt(None).unwrap(), None);
1542 }
1543
1544 #[rstest]
1545 fn test_parse_spot_instrument() {
1546 let json = load_test_json("http_asset_pairs.json");
1547 let response: KrakenResponse<AssetPairsResponse> = serde_json::from_str(&json).unwrap();
1548 let pairs = response.result.unwrap();
1549
1550 let (pair_name, definition) = pairs.iter().next().unwrap();
1551
1552 let instrument = parse_spot_instrument(pair_name, definition, TS, TS).unwrap();
1553
1554 match instrument {
1555 InstrumentAny::CurrencyPair(pair) => {
1556 assert_eq!(pair.id.venue.as_str(), "KRAKEN");
1557 assert_eq!(pair.base_currency.code, "BTC");
1558 assert_eq!(pair.quote_currency.code, "USDT");
1559 assert_eq!(pair.price_increment.as_decimal(), dec!(0.1));
1560 assert_eq!(pair.size_increment.as_decimal(), dec!(0.00000001));
1561 assert!(pair.min_quantity.is_some());
1562 assert_eq!(pair.margin_init, dec!(0));
1563 assert_eq!(pair.margin_maint, dec!(0));
1564 }
1565 _ => panic!("Expected CurrencyPair"),
1566 }
1567 }
1568
1569 #[rstest]
1570 fn test_parse_futures_instrument_inverse() {
1571 let json = load_test_json("http_futures_instruments.json");
1572 let response: crate::http::models::FuturesInstrumentsResponse =
1573 serde_json::from_str(&json).unwrap();
1574
1575 let fut_instrument = &response.instruments[0];
1576
1577 let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1578
1579 match instrument {
1580 InstrumentAny::CryptoPerpetual(perp) => {
1581 assert_eq!(perp.id.venue.as_str(), "KRAKEN");
1582 assert_eq!(perp.id.symbol.as_str(), "PI_XBTUSD");
1583 assert_eq!(perp.raw_symbol.as_str(), "PI_XBTUSD");
1584 assert_eq!(perp.base_currency.code, "BTC");
1585 assert_eq!(perp.quote_currency.code, "USD");
1586 assert_eq!(perp.settlement_currency.code, "BTC");
1587 assert!(perp.is_inverse);
1588 assert_eq!(perp.price_increment.as_decimal(), dec!(0.5));
1589 assert_eq!(perp.size_increment.as_decimal(), dec!(1));
1590 assert_eq!(perp.size_precision(), 0);
1591 assert_eq!(perp.margin_init, dec!(0.02));
1592 assert_eq!(perp.margin_maint, dec!(0.01));
1593 }
1594 _ => panic!("Expected CryptoPerpetual"),
1595 }
1596 }
1597
1598 #[rstest]
1599 fn test_parse_futures_instrument_flexible() {
1600 let json = load_test_json("http_futures_instruments.json");
1601 let response: crate::http::models::FuturesInstrumentsResponse =
1602 serde_json::from_str(&json).unwrap();
1603
1604 let fut_instrument = &response.instruments[1];
1605
1606 let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1607
1608 match instrument {
1609 InstrumentAny::CryptoPerpetual(perp) => {
1610 assert_eq!(perp.id.venue.as_str(), "KRAKEN");
1611 assert_eq!(perp.id.symbol.as_str(), "PF_ETHUSD");
1612 assert_eq!(perp.raw_symbol.as_str(), "PF_ETHUSD");
1613 assert_eq!(perp.base_currency.code, "ETH");
1614 assert_eq!(perp.quote_currency.code, "USD");
1615 assert_eq!(perp.settlement_currency.code, "USD");
1616 assert!(!perp.is_inverse);
1617 assert_eq!(perp.price_increment.as_decimal(), dec!(0.1));
1618 assert_eq!(perp.size_increment.as_decimal(), dec!(0.001));
1619 assert_eq!(perp.size_precision(), 3);
1620 assert_eq!(perp.margin_init, dec!(0.02));
1621 assert_eq!(perp.margin_maint, dec!(0.01));
1622 }
1623 _ => panic!("Expected CryptoPerpetual"),
1624 }
1625 }
1626
1627 #[rstest]
1628 fn test_parse_futures_instrument_accepts_max_precision() {
1629 let json = load_test_json("http_futures_instruments.json");
1630 let response: crate::http::models::FuturesInstrumentsResponse =
1631 serde_json::from_str(&json).unwrap();
1632 let mut fut_instrument = response.instruments[1].clone();
1633 let tick_size = Decimal::try_new(1, u32::from(FIXED_PRECISION)).unwrap();
1634 fut_instrument.tick_size = tick_size;
1635
1636 let instrument = parse_futures_instrument(&fut_instrument, TS, TS).unwrap();
1637
1638 match instrument {
1639 InstrumentAny::CryptoPerpetual(perp) => {
1640 assert_eq!(perp.price_precision(), FIXED_PRECISION);
1641 assert_eq!(perp.price_increment.as_decimal(), tick_size);
1642 }
1643 _ => panic!("Expected CryptoPerpetual"),
1644 }
1645 }
1646
1647 #[cfg(feature = "high-precision")]
1648 #[rstest]
1649 fn test_parse_futures_instrument_negative_precision() {
1650 let json = load_test_json("http_futures_instruments.json");
1651 let response: crate::http::models::FuturesInstrumentsResponse =
1652 serde_json::from_str(&json).unwrap();
1653
1654 let fut_instrument = &response.instruments[2];
1656
1657 let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1658
1659 match instrument {
1660 InstrumentAny::CryptoPerpetual(perp) => {
1661 assert_eq!(perp.id.symbol.as_str(), "PF_PEPEUSD");
1662 assert_eq!(perp.base_currency.code, "PEPE");
1663 assert!(!perp.is_inverse);
1664 assert_eq!(perp.size_increment.as_decimal(), dec!(1000));
1665 assert_eq!(perp.size_precision(), 0);
1666 }
1667 _ => panic!("Expected CryptoPerpetual"),
1668 }
1669 }
1670
1671 #[cfg(feature = "high-precision")]
1672 #[rstest]
1673 fn test_parse_futures_instrument_rejects_precision_above_high_max() {
1674 let json = load_test_json("http_futures_instruments.json");
1675 let response: crate::http::models::FuturesInstrumentsResponse =
1676 serde_json::from_str(&json).unwrap();
1677 let mut fut_instrument = response.instruments[1].clone();
1678 fut_instrument.tick_size = dec!(0.00000000000000001);
1679
1680 let error = parse_futures_instrument(&fut_instrument, TS, TS).unwrap_err();
1681
1682 assert_eq!(
1683 error.to_string(),
1684 "Cannot parse Kraken Futures instrument 'PF_ETHUSD': tick_size \
1685 0.00000000000000001 requires precision 17, but this build supports at most 16"
1686 );
1687 }
1688
1689 #[cfg(not(feature = "high-precision"))]
1690 #[rstest]
1691 fn test_parse_futures_instrument_rejects_unsupported_precision() {
1692 let json = load_test_json("http_futures_instruments.json");
1693 let response: crate::http::models::FuturesInstrumentsResponse =
1694 serde_json::from_str(&json).unwrap();
1695
1696 let tick_error = parse_futures_instrument(&response.instruments[2], TS, TS).unwrap_err();
1697
1698 let mut trade_precision_instrument = response.instruments[1].clone();
1699 trade_precision_instrument.contract_value_trade_precision = 256;
1700 let trade_precision_error =
1701 parse_futures_instrument(&trade_precision_instrument, TS, TS).unwrap_err();
1702
1703 let mut contract_size_instrument = response.instruments[1].clone();
1704 contract_size_instrument.contract_size = dec!(0.0000000001);
1705 let contract_size_error =
1706 parse_futures_instrument(&contract_size_instrument, TS, TS).unwrap_err();
1707
1708 assert_eq!(
1709 tick_error.to_string(),
1710 "Cannot parse Kraken Futures instrument 'PF_PEPEUSD': tick_size 0.0000000001 requires \
1711 precision 10, but this build supports at most 9; enable the 'high-precision' Cargo \
1712 feature and rebuild"
1713 );
1714 assert_eq!(
1715 trade_precision_error.to_string(),
1716 "Cannot parse Kraken Futures instrument 'PF_ETHUSD': contract_value_trade_precision 256 \
1717 requires precision 256, but this build supports at most 9; enable the 'high-precision' \
1718 Cargo feature and rebuild"
1719 );
1720 assert_eq!(
1721 contract_size_error.to_string(),
1722 "Cannot parse Kraken Futures instrument 'PF_ETHUSD': contract_size 0.0000000001 requires \
1723 precision 10, but this build supports at most 9; enable the 'high-precision' Cargo \
1724 feature and rebuild"
1725 );
1726 }
1727
1728 #[rstest]
1729 fn test_parse_futures_instrument_tokenized_underlying() {
1730 let json = load_test_json("http_futures_instruments.json");
1731 let response: crate::http::models::FuturesInstrumentsResponse =
1732 serde_json::from_str(&json).unwrap();
1733
1734 let fut_instrument = &response.instruments[3];
1735
1736 let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1737
1738 match instrument {
1739 InstrumentAny::CryptoPerpetual(perp) => {
1740 assert_eq!(perp.id.symbol.as_str(), "PF_AAPLxUSD");
1741 assert_eq!(perp.raw_symbol.as_str(), "PF_AAPLxUSD");
1742 assert_eq!(perp.base_currency.code, "AAPLx");
1743 assert_eq!(perp.quote_currency.code, "USD");
1744 assert_eq!(perp.settlement_currency.code, "USD");
1745 assert!(!perp.is_inverse);
1746 assert_eq!(perp.price_increment.as_decimal(), dec!(0.01));
1747 assert_eq!(perp.size_increment.as_decimal(), dec!(0.01));
1748 assert_eq!(perp.size_precision(), 2);
1749 assert_eq!(perp.margin_init, dec!(0.2));
1750 assert_eq!(perp.margin_maint, dec!(0.1));
1751 }
1752 _ => panic!("Expected CryptoPerpetual"),
1753 }
1754 }
1755
1756 #[rstest]
1757 fn test_parse_futures_instrument_without_fee_schedule_uid() {
1758 let json = load_test_json("http_futures_instrument_no_fee_schedule.json");
1759 let response: crate::http::models::FuturesInstrumentsResponse =
1760 serde_json::from_str(&json).unwrap();
1761
1762 let fut_instrument = &response.instruments[0];
1763 assert!(fut_instrument.fee_schedule_uid.is_none());
1764
1765 let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1766 match instrument {
1767 InstrumentAny::CryptoPerpetual(perp) => {
1768 assert_eq!(perp.id.symbol.as_str(), "PF_ETHUSD");
1769 }
1770 _ => panic!("Expected CryptoPerpetual"),
1771 }
1772 }
1773
1774 #[rstest]
1775 fn test_parse_trade_tick_from_array() {
1776 let json = load_test_json("http_trades.json");
1777 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1778 let result = wrapper.get("result").unwrap();
1779 let trades_map = result.as_object().unwrap();
1780
1781 let (_pair, trades_value) = trades_map.iter().find(|(k, _)| *k != "last").unwrap();
1783 let trades = trades_value.as_array().unwrap();
1784 let trade_array = trades[0].as_array().unwrap();
1785
1786 let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1788 let instrument = InstrumentAny::CurrencyPair(
1789 CurrencyPair::builder()
1790 .instrument_id(instrument_id)
1791 .raw_symbol(Symbol::new("XBTUSDT"))
1792 .base_currency(Currency::BTC())
1793 .quote_currency(Currency::USDT())
1794 .price_precision(1)
1795 .size_precision(8)
1796 .price_increment(Price::from("0.1"))
1797 .size_increment(Quantity::from("0.00000001"))
1798 .ts_event(TS)
1799 .ts_init(TS)
1800 .build()
1801 .unwrap(),
1802 );
1803
1804 let trade_tick = parse_trade_tick_from_array(trade_array, &instrument, TS).unwrap();
1805
1806 assert_eq!(trade_tick.instrument_id, instrument_id);
1807 assert_eq!(trade_tick.price, Price::from("105433.60000"));
1808 assert_eq!(trade_tick.size, Quantity::from("0.00027625"));
1809 }
1810
1811 #[rstest]
1812 fn test_parse_bar() {
1813 let json = load_test_json("http_ohlc.json");
1814 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1815 let result = wrapper.get("result").unwrap();
1816 let ohlc_map = result.as_object().unwrap();
1817
1818 let (_pair, ohlc_value) = ohlc_map.iter().find(|(k, _)| *k != "last").unwrap();
1820 let ohlcs = ohlc_value.as_array().unwrap();
1821
1822 let ohlc_array = ohlcs[0].as_array().unwrap();
1824 let ohlc = OhlcData {
1825 time: ohlc_array[0].as_i64().unwrap(),
1826 open: ohlc_array[1].as_str().unwrap().to_string(),
1827 high: ohlc_array[2].as_str().unwrap().to_string(),
1828 low: ohlc_array[3].as_str().unwrap().to_string(),
1829 close: ohlc_array[4].as_str().unwrap().to_string(),
1830 vwap: ohlc_array[5].as_str().unwrap().to_string(),
1831 volume: ohlc_array[6].as_str().unwrap().to_string(),
1832 count: ohlc_array[7].as_i64().unwrap(),
1833 };
1834
1835 let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1837 let instrument = InstrumentAny::CurrencyPair(
1838 CurrencyPair::builder()
1839 .instrument_id(instrument_id)
1840 .raw_symbol(Symbol::new("XBTUSDT"))
1841 .base_currency(Currency::BTC())
1842 .quote_currency(Currency::USDT())
1843 .price_precision(1)
1844 .size_precision(8)
1845 .price_increment(Price::from("0.1"))
1846 .size_increment(Quantity::from("0.00000001"))
1847 .ts_event(TS)
1848 .ts_init(TS)
1849 .build()
1850 .unwrap(),
1851 );
1852
1853 let bar_type = BarType::new(
1854 instrument_id,
1855 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
1856 AggregationSource::External,
1857 );
1858
1859 let bar = parse_bar(&ohlc, &instrument, bar_type, TS).unwrap();
1860
1861 assert_eq!(bar.bar_type, bar_type);
1862 assert_eq!(bar.open, Price::from("106038.2"));
1863 assert_eq!(bar.high, Price::from("106038.2"));
1864 assert_eq!(bar.low, Price::from("106038.2"));
1865 assert_eq!(bar.close, Price::from("106038.2"));
1866 assert_eq!(bar.volume, Quantity::from("0.00000000"));
1867 }
1868
1869 #[rstest]
1870 fn test_parse_millis_timestamp() {
1871 let timestamp = 1762795433.9717445;
1872 let result = parse_millis_timestamp(timestamp, "test").unwrap();
1873 assert!(result.as_u64() > 0);
1874 }
1875
1876 #[rstest]
1877 #[case(1, BarAggregation::Minute, 1)]
1878 #[case(5, BarAggregation::Minute, 5)]
1879 #[case(15, BarAggregation::Minute, 15)]
1880 #[case(1, BarAggregation::Hour, 60)]
1881 #[case(4, BarAggregation::Hour, 240)]
1882 #[case(1, BarAggregation::Day, 1440)]
1883 fn test_bar_type_to_spot_interval(
1884 #[case] step: usize,
1885 #[case] aggregation: BarAggregation,
1886 #[case] expected: u32,
1887 ) {
1888 let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1889 let bar_type = BarType::new(
1890 instrument_id,
1891 BarSpecification::new(step, aggregation, PriceType::Last),
1892 AggregationSource::External,
1893 );
1894
1895 let result = bar_type_to_spot_interval(bar_type).unwrap();
1896 assert_eq!(result, expected);
1897 }
1898
1899 #[rstest]
1900 fn test_bar_type_to_spot_interval_unsupported() {
1901 let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1902 let bar_type = BarType::new(
1903 instrument_id,
1904 BarSpecification::new(1, BarAggregation::Second, PriceType::Last),
1905 AggregationSource::External,
1906 );
1907
1908 let result = bar_type_to_spot_interval(bar_type);
1909 assert!(result.is_err());
1910 assert!(result.unwrap_err().to_string().contains("Unsupported"));
1911 }
1912
1913 #[rstest]
1914 #[case(1, BarAggregation::Minute, "1m")]
1915 #[case(5, BarAggregation::Minute, "5m")]
1916 #[case(15, BarAggregation::Minute, "15m")]
1917 #[case(1, BarAggregation::Hour, "1h")]
1918 #[case(4, BarAggregation::Hour, "4h")]
1919 #[case(12, BarAggregation::Hour, "12h")]
1920 #[case(1, BarAggregation::Day, "1d")]
1921 #[case(1, BarAggregation::Week, "1w")]
1922 fn test_bar_type_to_futures_resolution(
1923 #[case] step: usize,
1924 #[case] aggregation: BarAggregation,
1925 #[case] expected: &str,
1926 ) {
1927 let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
1928 let bar_type = BarType::new(
1929 instrument_id,
1930 BarSpecification::new(step, aggregation, PriceType::Last),
1931 AggregationSource::External,
1932 );
1933
1934 let result = bar_type_to_futures_resolution(bar_type).unwrap();
1935 assert_eq!(result, expected);
1936 }
1937
1938 #[rstest]
1939 #[case(30, BarAggregation::Minute)] #[case(2, BarAggregation::Hour)] #[case(2, BarAggregation::Day)] #[case(1, BarAggregation::Second)] fn test_bar_type_to_futures_resolution_unsupported(
1944 #[case] step: usize,
1945 #[case] aggregation: BarAggregation,
1946 ) {
1947 let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
1948 let bar_type = BarType::new(
1949 instrument_id,
1950 BarSpecification::new(step, aggregation, PriceType::Last),
1951 AggregationSource::External,
1952 );
1953
1954 let result = bar_type_to_futures_resolution(bar_type);
1955 assert!(result.is_err());
1956 assert!(result.unwrap_err().to_string().contains("Unsupported"));
1957 }
1958
1959 #[rstest]
1960 fn test_parse_order_status_report() {
1961 let json = load_test_json("http_open_orders.json");
1962 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1963 let result = wrapper.get("result").unwrap();
1964 let open_map = result.get("open").unwrap();
1965 let orders: IndexMap<String, SpotOrder> = serde_json::from_value(open_map.clone()).unwrap();
1966
1967 let account_id = AccountId::new("KRAKEN-001");
1968 let instrument_id = InstrumentId::new(Symbol::new("BTC/USDT"), *KRAKEN_VENUE);
1969 let instrument = InstrumentAny::CurrencyPair(
1970 CurrencyPair::builder()
1971 .instrument_id(instrument_id)
1972 .raw_symbol(Symbol::new("XBTUSDT"))
1973 .base_currency(Currency::BTC())
1974 .quote_currency(Currency::USDT())
1975 .price_precision(2)
1976 .size_precision(8)
1977 .price_increment(Price::from("0.01"))
1978 .size_increment(Quantity::from("0.00000001"))
1979 .ts_event(TS)
1980 .ts_init(TS)
1981 .build()
1982 .unwrap(),
1983 );
1984
1985 let (order_id, order) = orders.iter().next().unwrap();
1986
1987 let report =
1988 parse_order_status_report(order_id, order, &instrument, account_id, TS).unwrap();
1989
1990 assert_eq!(report.account_id, account_id);
1991 assert_eq!(report.instrument_id, instrument_id);
1992 assert_eq!(report.venue_order_id.as_str(), order_id);
1993 assert_eq!(report.order_status, OrderStatus::Accepted);
1994 assert_eq!(report.quantity, Quantity::from("0.50000000"));
1995 }
1996
1997 type ExpectedPrices<'a> = (&'a str, Option<&'a str>, Option<&'a str>, Option<Decimal>);
1999
2000 #[rstest]
2008 #[case::untriggered_open_orders(
2009 "http_open_orders_conditional.json",
2010 usdc_eur_instrument(),
2011 &[
2012 ("OCAP01-STOPL-000001", None, Some("1.3403"), None),
2013 ("OCAP02-STOPL-000002", None, Some("1.3402"), None),
2014 ("OCAP03-TAKEP-000003", None, Some("0.4467"), None),
2015 ("OCAP04-STLMT-000004", Some("1.3403"), Some("1.3403"), None),
2016 ("OCAP05-TPLMT-000005", Some("0.4467"), Some("0.4467"), None),
2017 ("OCAP06-TRAIL-000006", None, Some("1.3402"), None),
2018 ("OCAP07-TRLMT-000007", None, Some("1.3402"), None),
2019 ("OCAP08-LIMIT-000008", Some("0.4467"), None, None),
2020 ]
2021 )]
2022 #[case::untriggered_canceled_orders_one_amended(
2023 "http_closed_orders_conditional_canceled.json",
2024 usdc_eur_instrument(),
2025 &[
2026 ("OCAP01-STOPL-000001", None, Some("1.4296"), None),
2027 ("OCAP02-STOPL-000002", None, Some("1.3402"), None),
2028 ("OCAP03-TAKEP-000003", None, Some("0.4467"), None),
2029 ("OCAP04-STLMT-000004", Some("1.3403"), Some("1.3403"), None),
2030 ("OCAP05-TPLMT-000005", Some("0.4467"), Some("0.4467"), None),
2031 ("OCAP06-TRAIL-000006", None, Some("1.3401"), None),
2032 ("OCAP07-TRLMT-000007", None, Some("1.3401"), None),
2033 ("OCAP08-LIMIT-000008", Some("0.4467"), None, None),
2034 ]
2035 )]
2036 #[case::canceled_orders_three_triggered(
2037 "http_closed_orders_conditional_triggered.json",
2038 usdc_eur_instrument(),
2039 &[
2040 ("OCAP09-STLMT-000009", Some("0.4464"), Some("1.3394"), None),
2041 ("OCAP10-TPLMT-000010", Some("0.4455"), Some("0.4465"), None),
2042 ("OCAP11-STLMT-000011", Some("0.4464"), Some("0.8929"), None),
2043 ("OCAP12-TPLMT-000012", Some("0.4464"), Some("0.8929"), None),
2044 ("OCAP13-TRLMT-000013", Some("0.4465"), Some("0.8930"), None),
2045 ]
2046 )]
2047 #[case::documented_open_limit_orders(
2048 "http_open_orders_doc_example.json",
2049 xbt_usd_instrument(),
2050 &[
2051 ("OQCLML-BW3P3-BUCMWZ", Some("30010.0"), None, Some(dec!(11253.7) / dec!(0.375))),
2052 ("OB5VMB-B4U2U-DK2WRW", Some("14500.0"), None, None),
2053 ]
2054 )]
2055 #[case::documented_filled_market_order(
2056 "http_query_orders_doc_example.json",
2057 xbt_usd_instrument(),
2058 &[("OMMDB2-FSB6Z-7W3HPO", None, None, Some(dec!(30000)))]
2059 )]
2060 fn test_parse_order_status_report_prices_the_order_from_its_own_fields(
2061 #[case] filename: &str,
2062 #[case] instrument: InstrumentAny,
2063 #[case] expected: &[ExpectedPrices],
2064 ) {
2065 let rows = spot_order_rows(filename);
2066
2067 for &(order_id, expected_price, expected_trigger_price, expected_avg_px) in expected {
2068 let report = parse_order_status_report(
2069 order_id,
2070 &rows[order_id],
2071 &instrument,
2072 AccountId::new("KRAKEN-001"),
2073 TS,
2074 )
2075 .unwrap();
2076
2077 assert_eq!(report.price, expected_price.map(Price::from), "{order_id}");
2078 assert_eq!(
2079 report.trigger_price,
2080 expected_trigger_price.map(Price::from),
2081 "{order_id}"
2082 );
2083 assert_eq!(report.avg_px, expected_avg_px, "{order_id}");
2084 }
2085 }
2086
2087 #[rstest]
2090 fn test_parse_order_status_report_fails_on_a_malformed_order_price() {
2091 let order_id = "OCAP08-LIMIT-000008";
2092 let mut order = spot_order_rows("http_open_orders_conditional.json")
2093 .swap_remove(order_id)
2094 .unwrap();
2095 order.descr.price = "abc".to_string();
2096
2097 let result = parse_order_status_report(
2098 order_id,
2099 &order,
2100 &usdc_eur_instrument(),
2101 AccountId::new("KRAKEN-001"),
2102 TS,
2103 );
2104
2105 assert!(result.is_err(), "{result:?}");
2106 }
2107
2108 #[rstest]
2112 #[case::empty("")]
2113 #[case::relative("+1.5%")]
2114 fn test_parse_order_status_report_reads_an_unpriced_stop_price_as_none(
2115 #[case] stop_price: &str,
2116 ) {
2117 let order_id = "OCAP02-STOPL-000002";
2118 let mut order = spot_order_rows("http_open_orders_conditional.json")
2119 .swap_remove(order_id)
2120 .unwrap();
2121 order.stopprice = Some(stop_price.to_string());
2122
2123 let report = parse_order_status_report(
2124 order_id,
2125 &order,
2126 &usdc_eur_instrument(),
2127 AccountId::new("KRAKEN-001"),
2128 TS,
2129 )
2130 .unwrap();
2131
2132 assert_eq!(report.trigger_price, Some(Price::from("1.3402")));
2133 }
2134
2135 #[rstest]
2139 fn test_parse_order_status_report_prices_no_untriggered_trailing_stop_limit() {
2140 let order_id = "OCAP07-TRLMT-000007";
2141 let mut order = spot_order_rows("http_open_orders_conditional.json")
2142 .swap_remove(order_id)
2143 .unwrap();
2144 order.descr.price2 = "1.0000".to_string();
2145
2146 let report = parse_order_status_report(
2147 order_id,
2148 &order,
2149 &usdc_eur_instrument(),
2150 AccountId::new("KRAKEN-001"),
2151 TS,
2152 )
2153 .unwrap();
2154
2155 assert_eq!(report.price, None);
2156 assert_eq!(report.trigger_price, Some(Price::from("1.3402")));
2157 }
2158
2159 fn spot_order_rows(filename: &str) -> IndexMap<String, SpotOrder> {
2161 let json = load_test_json(filename);
2162 if filename.starts_with("http_open_orders") {
2163 let response: KrakenResponse<SpotOpenOrdersResult> =
2164 serde_json::from_str(&json).unwrap();
2165 response.result.unwrap().open
2166 } else if filename.starts_with("http_closed_orders") {
2167 let response: KrakenResponse<SpotClosedOrdersResult> =
2168 serde_json::from_str(&json).unwrap();
2169 response.result.unwrap().closed
2170 } else {
2171 let response: KrakenResponse<IndexMap<String, SpotOrder>> =
2172 serde_json::from_str(&json).unwrap();
2173 response.result.unwrap()
2174 }
2175 }
2176
2177 fn usdc_eur_instrument() -> InstrumentAny {
2178 InstrumentAny::CurrencyPair(
2179 CurrencyPair::builder()
2180 .instrument_id(InstrumentId::new(Symbol::new("USDC/EUR"), *KRAKEN_VENUE))
2181 .raw_symbol(Symbol::new("USDCEUR"))
2182 .base_currency(Currency::USDC())
2183 .quote_currency(Currency::EUR())
2184 .price_precision(4)
2185 .size_precision(8)
2186 .price_increment(Price::from("0.0001"))
2187 .size_increment(Quantity::from("0.00000001"))
2188 .ts_event(TS)
2189 .ts_init(TS)
2190 .build()
2191 .unwrap(),
2192 )
2193 }
2194
2195 fn xbt_usd_instrument() -> InstrumentAny {
2196 InstrumentAny::CurrencyPair(
2197 CurrencyPair::builder()
2198 .instrument_id(InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE))
2199 .raw_symbol(Symbol::new("XBTUSD"))
2200 .base_currency(Currency::BTC())
2201 .quote_currency(Currency::USD())
2202 .price_precision(1)
2203 .size_precision(8)
2204 .price_increment(Price::from("0.1"))
2205 .size_increment(Quantity::from("0.00000001"))
2206 .ts_event(TS)
2207 .ts_init(TS)
2208 .build()
2209 .unwrap(),
2210 )
2211 }
2212
2213 fn create_mock_perp() -> InstrumentAny {
2214 let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
2215 InstrumentAny::CryptoPerpetual(
2216 CryptoPerpetual::builder()
2217 .instrument_id(instrument_id)
2218 .raw_symbol(Symbol::new("PI_XBTUSD"))
2219 .base_currency(Currency::BTC())
2220 .quote_currency(Currency::USD())
2221 .settlement_currency(Currency::USD())
2222 .is_inverse(false)
2223 .price_precision(1)
2224 .size_precision(0)
2225 .price_increment(Price::from("0.5"))
2226 .size_increment(Quantity::from("1"))
2227 .ts_event(TS)
2228 .ts_init(TS)
2229 .build()
2230 .unwrap(),
2231 )
2232 }
2233
2234 #[rstest]
2235 fn test_parse_futures_assignee_fill_report() {
2236 let json = load_test_json("http_futures_fills.json");
2237 let response: FuturesFillsResponse = serde_json::from_str(&json).unwrap();
2238 let fill = &response.fills[2];
2239 let instrument = create_mock_perp();
2240 let account_id = AccountId::new("KRAKEN-001");
2241
2242 let report = parse_futures_fill_report(fill, &instrument, account_id, TS).unwrap();
2243
2244 assert_eq!(report.account_id, account_id);
2245 assert_eq!(report.instrument_id, instrument.id());
2246 assert_eq!(
2247 report.venue_order_id,
2248 VenueOrderId::new("f8a7b6c5-d4e3-2f1a-0b9c-8d7e6f5a4b3c")
2249 );
2250 assert_eq!(
2251 report.trade_id,
2252 TradeId::new("d3f4e5a6-b7c8-9d0e-1f2a-3b4c5d6e7f8a")
2253 );
2254 assert_eq!(report.order_side, OrderSide::Sell);
2255 assert_eq!(report.last_qty, Quantity::from("2500"));
2256 assert_eq!(report.last_px, Price::from("28050.0"));
2257 assert_eq!(report.commission, Money::zero(Currency::USD()));
2258 assert_eq!(report.liquidity_side, LiquiditySide::NoLiquiditySide);
2259 assert_eq!(report.avg_px, None);
2260 assert_eq!(
2261 report.ts_event,
2262 "2023-04-07T15:55:20.123Z".parse::<UnixNanos>().unwrap()
2263 );
2264 assert_eq!(report.ts_init, TS);
2265 assert_eq!(report.client_order_id, None);
2266 assert_eq!(report.venue_position_id, None);
2267 }
2268
2269 #[rstest]
2270 fn test_parse_futures_order_status_report_market_if_touched() {
2271 let order = FuturesOpenOrder {
2272 order_id: "tp-001".to_string(),
2273 symbol: "PI_XBTUSD".to_string(),
2274 side: KrakenOrderSide::Buy,
2275 order_type: KrakenFuturesOrderType::TakeProfit,
2276 limit_price: None,
2277 stop_price: Some(dec!(36000)),
2278 unfilled_size: Some(dec!(500)),
2279 received_time: "2023-11-14T22:13:20.000Z".to_string(),
2280 status: KrakenFuturesOrderStatus::PartiallyFilled,
2281 filled_size: dec!(25),
2282 reduce_only: Some(true),
2283 last_update_time: "2023-11-14T22:13:20.000Z".to_string(),
2284 trigger_signal: None,
2285 cli_ord_id: Some("my-tp-1".to_string()),
2286 };
2287 let instrument = create_mock_perp();
2288 let account_id = AccountId::new("KRAKEN-001");
2289
2290 let report =
2291 parse_futures_order_status_report(&order, &instrument, account_id, None, TS).unwrap();
2292
2293 assert_eq!(report.order_type, OrderType::MarketIfTouched);
2294 assert_eq!(report.quantity.as_decimal(), dec!(525));
2295 assert_eq!(report.filled_qty.as_decimal(), dec!(25));
2296 assert_eq!(report.trigger_price.unwrap().as_decimal(), dec!(36000));
2297 assert!(report.price.is_none());
2298 assert!(report.reduce_only);
2299 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
2300 }
2301
2302 #[rstest]
2303 fn test_parse_futures_order_status_report_limit_if_touched() {
2304 let order = FuturesOpenOrder {
2305 order_id: "tpl-001".to_string(),
2306 symbol: "PI_XBTUSD".to_string(),
2307 side: KrakenOrderSide::Sell,
2308 order_type: KrakenFuturesOrderType::TakeProfit,
2309 limit_price: Some(dec!(35500)),
2310 stop_price: Some(dec!(36000)),
2311 unfilled_size: Some(dec!(500)),
2312 received_time: "2023-11-14T22:13:20.000Z".to_string(),
2313 status: KrakenFuturesOrderStatus::Untouched,
2314 filled_size: dec!(0),
2315 reduce_only: None,
2316 last_update_time: "2023-11-14T22:13:20.000Z".to_string(),
2317 trigger_signal: None,
2318 cli_ord_id: Some("my-tpl-1".to_string()),
2319 };
2320 let instrument = create_mock_perp();
2321 let account_id = AccountId::new("KRAKEN-001");
2322
2323 let report =
2324 parse_futures_order_status_report(&order, &instrument, account_id, None, TS).unwrap();
2325
2326 assert_eq!(report.order_type, OrderType::LimitIfTouched);
2327 assert_eq!(report.trigger_price.unwrap().as_decimal(), dec!(36000));
2328 assert_eq!(report.price.unwrap().as_decimal(), dec!(35500));
2329 assert_eq!(report.order_side, OrderSide::Sell.into());
2330 assert!(!report.reduce_only);
2331 }
2332
2333 #[rstest]
2334 fn test_parse_futures_order_event_market_if_touched() {
2335 let event = FuturesOrderEvent {
2336 order_id: "tp-evt-001".to_string(),
2337 cli_ord_id: None,
2338 order_type: KrakenFuturesOrderType::TakeProfit,
2339 symbol: "PI_XBTUSD".to_string(),
2340 side: KrakenOrderSide::Buy,
2341 quantity: dec!(100),
2342 filled: dec!(100),
2343 limit_price: None,
2344 stop_price: Some(dec!(40000)),
2345 timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2346 last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2347 reduce_only: false,
2348 };
2349 let instrument = create_mock_perp();
2350 let account_id = AccountId::new("KRAKEN-001");
2351
2352 let report = parse_futures_order_event_status_report(
2353 &event,
2354 Some(KrakenFuturesOrderEventType::Fill),
2355 &instrument,
2356 account_id,
2357 TS,
2358 )
2359 .unwrap();
2360
2361 assert_eq!(report.order_type, OrderType::MarketIfTouched);
2362 assert_eq!(report.trigger_price.unwrap().as_decimal(), dec!(40000));
2363 assert!(report.price.is_none());
2364 assert_eq!(report.order_status, OrderStatus::Filled);
2365 }
2366
2367 #[rstest]
2368 fn test_parse_futures_order_event_limit_if_touched() {
2369 let event = FuturesOrderEvent {
2370 order_id: "tpl-evt-001".to_string(),
2371 cli_ord_id: Some("my-tpl-evt".to_string()),
2372 order_type: KrakenFuturesOrderType::TakeProfit,
2373 symbol: "PI_XBTUSD".to_string(),
2374 side: KrakenOrderSide::Sell,
2375 quantity: dec!(200),
2376 filled: Decimal::ZERO,
2377 limit_price: Some(dec!(39500)),
2378 stop_price: Some(dec!(40000)),
2379 timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2380 last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2381 reduce_only: true,
2382 };
2383 let instrument = create_mock_perp();
2384 let account_id = AccountId::new("KRAKEN-001");
2385
2386 let report = parse_futures_order_event_status_report(
2387 &event,
2388 Some(KrakenFuturesOrderEventType::Place),
2389 &instrument,
2390 account_id,
2391 TS,
2392 )
2393 .unwrap();
2394
2395 assert_eq!(report.order_type, OrderType::LimitIfTouched);
2396 assert_eq!(report.trigger_price.unwrap().as_decimal(), dec!(40000));
2397 assert_eq!(report.price.unwrap().as_decimal(), dec!(39500));
2398 assert_eq!(report.order_side, OrderSide::Sell.into());
2399 assert_eq!(report.order_status, OrderStatus::Accepted);
2400 assert!(report.reduce_only);
2401 }
2402
2403 #[rstest]
2404 fn test_parse_futures_order_event_cancel_status() {
2405 let event = FuturesOrderEvent {
2406 order_id: "cancel-evt-001".to_string(),
2407 cli_ord_id: Some("cancel-evt".to_string()),
2408 order_type: KrakenFuturesOrderType::Stop,
2409 symbol: "PI_XBTUSD".to_string(),
2410 side: KrakenOrderSide::Sell,
2411 quantity: dec!(200),
2412 filled: Decimal::ZERO,
2413 limit_price: None,
2414 stop_price: Some(dec!(39000)),
2415 timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2416 last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2417 reduce_only: true,
2418 };
2419 let instrument = create_mock_perp();
2420 let account_id = AccountId::new("KRAKEN-001");
2421
2422 let report = parse_futures_order_event_status_report(
2423 &event,
2424 Some(KrakenFuturesOrderEventType::Cancel),
2425 &instrument,
2426 account_id,
2427 TS,
2428 )
2429 .unwrap();
2430
2431 assert_eq!(report.order_status, OrderStatus::Canceled);
2432 assert!(report.reduce_only);
2433 }
2434
2435 #[rstest]
2436 fn test_parse_futures_order_event_reject_status() {
2437 let event = FuturesOrderEvent {
2438 order_id: "reject-evt-001".to_string(),
2439 cli_ord_id: Some("reject-evt".to_string()),
2440 order_type: KrakenFuturesOrderType::Limit,
2441 symbol: "PI_XBTUSD".to_string(),
2442 side: KrakenOrderSide::Buy,
2443 quantity: dec!(200),
2444 filled: Decimal::ZERO,
2445 limit_price: Some(dec!(35000)),
2446 stop_price: None,
2447 timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2448 last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2449 reduce_only: false,
2450 };
2451 let instrument = create_mock_perp();
2452 let account_id = AccountId::new("KRAKEN-001");
2453
2454 let report = parse_futures_order_event_status_report(
2455 &event,
2456 Some(KrakenFuturesOrderEventType::Reject),
2457 &instrument,
2458 account_id,
2459 TS,
2460 )
2461 .unwrap();
2462
2463 assert_eq!(report.order_status, OrderStatus::Rejected);
2464 }
2465
2466 #[rstest]
2467 fn test_parse_futures_order_event_expire_status() {
2468 let event = FuturesOrderEvent {
2469 order_id: "expire-evt-001".to_string(),
2470 cli_ord_id: Some("expire-evt".to_string()),
2471 order_type: KrakenFuturesOrderType::Limit,
2472 symbol: "PI_XBTUSD".to_string(),
2473 side: KrakenOrderSide::Buy,
2474 quantity: dec!(200),
2475 filled: Decimal::ZERO,
2476 limit_price: Some(dec!(35000)),
2477 stop_price: None,
2478 timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2479 last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2480 reduce_only: false,
2481 };
2482 let instrument = create_mock_perp();
2483 let account_id = AccountId::new("KRAKEN-001");
2484
2485 let report = parse_futures_order_event_status_report(
2486 &event,
2487 Some(KrakenFuturesOrderEventType::Expire),
2488 &instrument,
2489 account_id,
2490 TS,
2491 )
2492 .unwrap();
2493
2494 assert_eq!(report.order_status, OrderStatus::Expired);
2495 }
2496
2497 #[rstest]
2498 fn test_parse_futures_order_event_execution_status() {
2499 let event = FuturesOrderEvent {
2500 order_id: "execution-evt-001".to_string(),
2501 cli_ord_id: Some("execution-evt".to_string()),
2502 order_type: KrakenFuturesOrderType::Limit,
2503 symbol: "PI_XBTUSD".to_string(),
2504 side: KrakenOrderSide::Buy,
2505 quantity: dec!(200),
2506 filled: dec!(50),
2507 limit_price: Some(dec!(35000)),
2508 stop_price: None,
2509 timestamp: "2023-11-14T22:13:20.000Z".to_string(),
2510 last_update_timestamp: "2023-11-14T22:13:21.000Z".to_string(),
2511 reduce_only: false,
2512 };
2513 let instrument = create_mock_perp();
2514 let account_id = AccountId::new("KRAKEN-001");
2515
2516 let report = parse_futures_order_event_status_report(
2517 &event,
2518 Some(KrakenFuturesOrderEventType::Execution),
2519 &instrument,
2520 account_id,
2521 TS,
2522 )
2523 .unwrap();
2524
2525 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
2526 }
2527
2528 #[rstest]
2529 fn test_parse_fill_report() {
2530 let json = load_test_json("http_trades_history.json");
2531 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
2532 let result = wrapper.get("result").unwrap();
2533 let trades_map = result.get("trades").unwrap();
2534 let trades: IndexMap<String, SpotTrade> =
2535 serde_json::from_value(trades_map.clone()).unwrap();
2536
2537 let account_id = AccountId::new("KRAKEN-001");
2538 let instrument_id = InstrumentId::new(Symbol::new("BTC/USDT"), *KRAKEN_VENUE);
2539 let instrument = InstrumentAny::CurrencyPair(
2540 CurrencyPair::builder()
2541 .instrument_id(instrument_id)
2542 .raw_symbol(Symbol::new("XBTUSDT"))
2543 .base_currency(Currency::BTC())
2544 .quote_currency(Currency::USDT())
2545 .price_precision(2)
2546 .size_precision(8)
2547 .price_increment(Price::from("0.01"))
2548 .size_increment(Quantity::from("0.00000001"))
2549 .ts_event(TS)
2550 .ts_init(TS)
2551 .build()
2552 .unwrap(),
2553 );
2554
2555 let (trade_id, trade) = trades.iter().next().unwrap();
2556
2557 let report = parse_fill_report(trade_id, trade, &instrument, account_id, TS).unwrap();
2558
2559 assert_eq!(report.account_id, account_id);
2560 assert_eq!(report.instrument_id, instrument_id);
2561 assert_eq!(report.trade_id.to_string(), *trade_id);
2562 assert_eq!(report.last_qty, Quantity::from("0.50000000"));
2563 assert_eq!(report.last_px, Price::from("29500.50"));
2564 assert_eq!(report.commission.as_decimal(), dec!(23.60));
2565 }
2566
2567 #[rstest]
2568 #[case("XXBT", "BTC")]
2569 #[case("XBT", "BTC")]
2570 #[case("XXDG", "DOGE")]
2571 #[case("XDG", "DOGE")]
2572 #[case("XETH", "ETH")]
2573 #[case("ZUSD", "USD")]
2574 #[case("ZEUR", "EUR")]
2575 #[case("KFEE", "FEE")]
2576 #[case("BTC", "BTC")]
2577 #[case("ETH", "ETH")]
2578 #[case("USDT", "USDT")]
2579 #[case("SOL", "SOL")]
2580 #[case("XTZ", "XTZ")]
2582 #[case("XRP", "XRP")]
2583 #[case("XLM", "XLM")]
2584 #[case("XAUT", "XAUT")]
2585 #[case("XCN", "XCN")]
2586 #[case("ZRX", "ZRX")]
2587 #[case("ZEC", "ZEC")]
2588 #[case("ZK", "ZK")]
2589 fn test_normalize_currency_code(#[case] input: &str, #[case] expected: &str) {
2590 assert_eq!(normalize_currency_code(input), expected);
2591 }
2592
2593 #[rstest]
2595 #[case("xbt", "BTC")]
2596 #[case("XBT", "BTC")]
2597 #[case("xxbt", "BTC")]
2598 #[case("zeur", "EUR")]
2599 #[case("usd", "USD")]
2600 #[case("xtz", "XTZ")]
2601 #[case("flr", "FLR")]
2602 fn test_normalize_asset_key_is_case_tolerant(#[case] input: &str, #[case] expected: &str) {
2603 assert_eq!(normalize_asset_key(input), expected);
2604 }
2605
2606 #[rstest]
2607 #[case("XBT/EUR", "BTC/EUR")]
2608 #[case("XBT/USD", "BTC/USD")]
2609 #[case("XBT/USDT", "BTC/USDT")]
2610 #[case("ETH/USD", "ETH/USD")]
2611 #[case("ETH/XBT", "ETH/BTC")]
2612 #[case("SOL/XBT", "SOL/BTC")]
2613 #[case("SOL/USD", "SOL/USD")]
2614 #[case("BTC/USD", "BTC/USD")]
2615 #[case("ETH/BTC", "ETH/BTC")]
2616 #[case("XDG/USD", "DOGE/USD")]
2617 #[case("XDG/EUR", "DOGE/EUR")]
2618 #[case("XDG/BTC", "DOGE/BTC")]
2619 #[case("XDG/XBT", "DOGE/BTC")]
2620 fn test_normalize_spot_symbol(#[case] input: &str, #[case] expected: &str) {
2621 assert_eq!(normalize_spot_symbol(input), expected);
2622 }
2623
2624 #[rstest]
2625 #[case("A", "A")] #[case("O2026022700232", "O2026022700232")] #[case("ABCDEFGHIJKLMNOPQR", "ABCDEFGHIJKLMNOPQR")] fn test_truncate_cl_ord_id_short_passthrough(#[case] input: &str, #[case] expected: &str) {
2629 let id = ClientOrderId::new(input);
2630 assert_eq!(truncate_cl_ord_id(&id), expected);
2631 }
2632
2633 #[rstest]
2634 #[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) {
2639 let id = ClientOrderId::new(input);
2640 assert_eq!(truncate_cl_ord_id(&id), input);
2641 }
2642
2643 #[rstest]
2644 #[case("6d47a5f06fd44b84b56ec23f0f689c20")] #[case("6D47A5F06FD44B84B56EC23F0F689C20")] #[case("00000000000000000000000000000000")] #[case("aAbBcCdDeEfF00112233445566778899")] fn test_truncate_cl_ord_id_uuid_compact_passthrough(#[case] input: &str) {
2649 let id = ClientOrderId::new(input);
2650 assert_eq!(truncate_cl_ord_id(&id), input);
2651 }
2652
2653 #[rstest]
2654 #[case("O2026022700232100400", "O26022700232100400")] #[case("O202602270023210040011", "O02270023210040011")] #[case("O20260227002321004001100", "O27002321004001100")] fn test_truncate_cl_ord_id_sequential_truncated(#[case] input: &str, #[case] expected: &str) {
2658 let id = ClientOrderId::new(input);
2659 let result = truncate_cl_ord_id(&id);
2660 assert_eq!(result, expected);
2661 assert_eq!(result.len(), 18);
2662 assert!(result.starts_with('O'));
2663 }
2664
2665 #[rstest]
2666 fn test_truncate_cl_ord_id_32_chars_non_hex_truncated() {
2667 let input = "0123456789abcdef0123456789abcdeg";
2668 let id = ClientOrderId::new(input);
2669 let result = truncate_cl_ord_id(&id);
2670 assert_eq!(result.len(), 18);
2671 assert!(result.starts_with('O'));
2672 assert_eq!(result, "Of0123456789abcdeg");
2673 }
2674
2675 #[rstest]
2676 fn test_truncate_cl_ord_id_36_chars_wrong_hyphens_truncated() {
2677 let input = "6d47a5f0-6fd4-4b84-b56ec23f0f689c200";
2678 let id = ClientOrderId::new(input);
2679 let result = truncate_cl_ord_id(&id);
2680 assert_eq!(result.len(), 18);
2681 assert!(result.starts_with('O'));
2682 }
2683
2684 #[rstest]
2687 fn test_pair_info_records_pair_decimals_only_when_finer_than_the_tick() {
2688 let json = load_test_json("http_asset_pairs_tokenized.json");
2689 let response: KrakenResponse<AssetPairsResponse> = serde_json::from_str(&json).unwrap();
2690 let pairs = response.result.unwrap();
2691 let (pair_name, definition) = pairs.iter().next().unwrap();
2692
2693 let same = parse_tokenized_instrument(pair_name, definition, TS, TS).unwrap();
2694 assert!(
2695 same.info()
2696 .is_none_or(|info| !info.contains_key(KRAKEN_PAIR_DECIMALS_KEY)),
2697 "a scale equal to the tick precision is not recorded"
2698 );
2699
2700 let mut coarser = definition.clone();
2701 coarser.pair_decimals = same.price_precision() - 1;
2702 let tokenized = parse_tokenized_instrument(pair_name, &coarser, TS, TS).unwrap();
2703 let spot = parse_spot_instrument(pair_name, &coarser, TS, TS).unwrap();
2704 for instrument in [tokenized, spot] {
2705 assert!(
2706 instrument
2707 .info()
2708 .is_none_or(|info| !info.contains_key(KRAKEN_PAIR_DECIMALS_KEY)),
2709 "a scale coarser than the tick precision is not recorded"
2710 );
2711 }
2712
2713 let mut finer = definition.clone();
2714 finer.pair_decimals = same.price_precision() + 1;
2715 let tokenized = parse_tokenized_instrument(pair_name, &finer, TS, TS).unwrap();
2716 let spot = parse_spot_instrument(pair_name, &finer, TS, TS).unwrap();
2717 for instrument in [tokenized, spot] {
2718 let recorded = instrument
2719 .info()
2720 .and_then(|info| info.get(KRAKEN_PAIR_DECIMALS_KEY))
2721 .and_then(serde_json::Value::as_u64);
2722 assert_eq!(recorded, Some(u64::from(finer.pair_decimals)));
2723 }
2724 }
2725
2726 #[rstest]
2727 fn test_parse_tokenized_instrument() {
2728 let json = load_test_json("http_asset_pairs_tokenized.json");
2729 let response: KrakenResponse<AssetPairsResponse> = serde_json::from_str(&json).unwrap();
2730 let pairs = response.result.unwrap();
2731
2732 let (pair_name, definition) = pairs.iter().next().unwrap();
2733
2734 let instrument = parse_tokenized_instrument(pair_name, definition, TS, TS).unwrap();
2735
2736 match instrument {
2737 InstrumentAny::TokenizedAsset(ta) => {
2738 assert_eq!(ta.id.symbol.as_str(), "AAPLx/USD");
2739 assert_eq!(ta.id.venue.as_str(), "KRAKEN");
2740 assert_eq!(ta.raw_symbol.as_str(), "AAPLxUSD");
2741 assert_eq!(ta.asset_class, AssetClass::Equity);
2742 assert_eq!(ta.base_currency.code, "AAPLx");
2743 assert_eq!(ta.quote_currency.code, "USD");
2744 assert_eq!(ta.price_precision, 2);
2745 assert_eq!(ta.size_precision, 8);
2746 assert_eq!(ta.price_increment.as_decimal(), dec!(0.01));
2747 assert_eq!(ta.size_increment.as_decimal(), dec!(0.00000001));
2748 assert!(ta.min_quantity.is_some());
2749 }
2750 _ => panic!("Expected TokenizedAsset, received {instrument:?}"),
2751 }
2752
2753 let mut finer = definition.clone();
2756 finer.pair_decimals = 3;
2757 let instrument = parse_tokenized_instrument("XAAPLxZUSD", &finer, TS, TS).unwrap();
2758 let info = instrument.info().expect("the wire scale is recorded");
2759 assert_eq!(
2760 info.get(KRAKEN_PAIR_DECIMALS_KEY)
2761 .and_then(serde_json::Value::as_u64),
2762 Some(3)
2763 );
2764 assert!(
2765 !info.contains_key(KRAKEN_ALTNAME_KEY),
2766 "a tokenized pair carries no altname alias"
2767 );
2768 }
2769
2770 #[rstest]
2771 fn test_parse_fill_report_tokenized_asset() {
2772 let json = load_test_json("http_trades_history.json");
2773 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
2774 let result = wrapper.get("result").unwrap();
2775 let trades_map = result.get("trades").unwrap();
2776 let trades: IndexMap<String, SpotTrade> =
2777 serde_json::from_value(trades_map.clone()).unwrap();
2778
2779 let account_id = AccountId::new("KRAKEN-001");
2780 let instrument_id = InstrumentId::new(Symbol::new("AAPLx/USD"), *KRAKEN_VENUE);
2781 let instrument = InstrumentAny::TokenizedAsset(
2782 TokenizedAsset::builder()
2783 .instrument_id(instrument_id)
2784 .raw_symbol(Symbol::new("AAPLxUSD"))
2785 .asset_class(AssetClass::Equity)
2786 .base_currency(Currency::get_or_create_crypto("AAPLx"))
2787 .quote_currency(Currency::USD())
2788 .price_precision(2)
2789 .size_precision(8)
2790 .price_increment(Price::from("0.01"))
2791 .size_increment(Quantity::from("0.00000001"))
2792 .ts_event(TS)
2793 .ts_init(TS)
2794 .build()
2795 .unwrap(),
2796 );
2797
2798 let (trade_id, trade) = trades.iter().next().unwrap();
2799
2800 let report = parse_fill_report(trade_id, trade, &instrument, account_id, TS).unwrap();
2801
2802 assert_eq!(report.account_id, account_id);
2803 assert_eq!(report.instrument_id, instrument_id);
2804 assert_eq!(report.trade_id.to_string(), *trade_id);
2805 assert_eq!(report.last_qty, Quantity::from("0.50000000"));
2806 assert_eq!(report.last_px, Price::from("29500.50"));
2807 assert_eq!(report.commission.currency, Currency::USD());
2808 }
2809
2810 #[rstest]
2811 fn test_truncate_cl_ord_id_19_chars_truncated() {
2812 let input = "O202602270023210040";
2813 assert_eq!(input.len(), 19);
2814 let id = ClientOrderId::new(input);
2815 let result = truncate_cl_ord_id(&id);
2816 assert_eq!(result.len(), 18);
2817 assert_eq!(result, "O02602270023210040");
2818 }
2819
2820 #[rstest]
2821 fn test_truncate_cl_ord_id_preserves_tail() {
2822 let input = "O20260227002321004001100";
2823 let id = ClientOrderId::new(input);
2824 let result = truncate_cl_ord_id(&id);
2825 assert_eq!(&result[1..], &input[input.len() - 17..]);
2826 }
2827}