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