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