1use std::str::FromStr;
19
20use anyhow::Context;
21use nautilus_core::{
22 datetime::NANOSECONDS_IN_MILLISECOND, nanos::UnixNanos, parsing::precision_from_str,
23 uuid::UUID4,
24};
25use nautilus_model::{
26 data::{Bar, BarType, TradeTick},
27 enums::{
28 AggressorSide, BarAggregation, ContingencyType, LiquiditySide, OrderStatus, OrderType,
29 PositionSideSpecified, TimeInForce, TrailingOffsetType, TriggerType,
30 },
31 identifiers::{AccountId, InstrumentId, Symbol, TradeId, VenueOrderId},
32 instruments::{
33 Instrument, any::InstrumentAny, crypto_perpetual::CryptoPerpetual,
34 currency_pair::CurrencyPair,
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, KrakenInstrumentType, KrakenPositionSide, KrakenSpotTrigger,
47 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 ts_event,
240 ts_init,
241 );
242
243 Ok(InstrumentAny::CurrencyPair(instrument))
244}
245
246pub fn parse_futures_instrument(
255 instrument: &FuturesInstrument,
256 ts_event: UnixNanos,
257 ts_init: UnixNanos,
258) -> anyhow::Result<InstrumentAny> {
259 let instrument_id = InstrumentId::new(Symbol::new(&instrument.symbol), *KRAKEN_VENUE);
260 let raw_symbol = Symbol::new(&instrument.symbol);
261
262 let base_currency = get_currency(&instrument.base);
263 let quote_currency = get_currency(&instrument.quote);
264
265 let is_inverse = instrument.instrument_type == KrakenInstrumentType::FuturesInverse;
266 let settlement_currency = if is_inverse {
267 base_currency
268 } else {
269 quote_currency
270 };
271
272 let tick_size = instrument.tick_size;
275 let price_precision = precision_from_str(&tick_size.to_string());
276 if price_precision > FIXED_PRECISION {
277 anyhow::bail!(
278 "Cannot parse instrument '{}': tick_size {tick_size} requires precision {price_precision} \
279 which exceeds FIXED_PRECISION ({FIXED_PRECISION})",
280 instrument.symbol
281 );
282 }
283 let price_increment = Price::new(tick_size, price_precision);
284
285 let (_size_precision, size_increment) = if instrument.contract_value_trade_precision >= 0 {
290 let precision = instrument.contract_value_trade_precision as u8;
291 let increment = Quantity::new(10.0_f64.powi(-(precision as i32)), precision);
292 (precision, increment)
293 } else {
294 let increment_value = 10.0_f64.powi(-instrument.contract_value_trade_precision);
296 (0, Quantity::new(increment_value, 0))
297 };
298
299 let multiplier_precision = if instrument.contract_size.fract() == 0.0 {
300 0
301 } else {
302 instrument
303 .contract_size
304 .to_string()
305 .split('.')
306 .nth(1)
307 .map_or(0, |s| s.len() as u8)
308 };
309 let multiplier = Some(Quantity::new(
310 instrument.contract_size,
311 multiplier_precision,
312 ));
313
314 let (margin_init, margin_maint) = instrument
316 .margin_levels
317 .first()
318 .and_then(|level| {
319 let init = Decimal::try_from(level.initial_margin).ok()?;
320 let maint = Decimal::try_from(level.maintenance_margin).ok()?;
321 Some((Some(init), Some(maint)))
322 })
323 .unwrap_or((None, None));
324
325 let instrument = CryptoPerpetual::new(
326 instrument_id,
327 raw_symbol,
328 base_currency,
329 quote_currency,
330 settlement_currency,
331 is_inverse,
332 price_increment.precision,
333 size_increment.precision,
334 price_increment,
335 size_increment,
336 multiplier,
337 None, None, None, None, None, None, None, margin_init,
345 margin_maint,
346 None, None, ts_event,
349 ts_init,
350 );
351
352 Ok(InstrumentAny::CryptoPerpetual(instrument))
353}
354
355fn parse_price(value: &str, field: &str) -> anyhow::Result<Price> {
356 Price::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
357}
358
359fn parse_quantity(value: &str, field: &str) -> anyhow::Result<Quantity> {
360 Quantity::from_str(value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
361}
362
363pub fn get_currency(code: &str) -> Currency {
368 Currency::get_or_create_crypto(code)
369}
370
371pub fn parse_trade_tick_from_array(
382 trade_array: &[serde_json::Value],
383 instrument: &InstrumentAny,
384 ts_init: UnixNanos,
385) -> anyhow::Result<TradeTick> {
386 let price_str = trade_array
387 .first()
388 .and_then(|v| v.as_str())
389 .context("Missing or invalid price")?;
390 let price = parse_price_with_precision(price_str, instrument.price_precision(), "trade.price")?;
391
392 let size_str = trade_array
393 .get(1)
394 .and_then(|v| v.as_str())
395 .context("Missing or invalid volume")?;
396 let size = parse_quantity_with_precision(size_str, instrument.size_precision(), "trade.size")?;
397
398 let time = trade_array
399 .get(2)
400 .and_then(|v| v.as_f64())
401 .context("Missing or invalid timestamp")?;
402 let ts_event = parse_millis_timestamp(time, "trade.time")?;
403
404 let side_str = trade_array
405 .get(3)
406 .and_then(|v| v.as_str())
407 .context("Missing or invalid side")?;
408 let aggressor = match side_str {
409 "b" => AggressorSide::Buyer,
410 "s" => AggressorSide::Seller,
411 _ => AggressorSide::NoAggressor,
412 };
413
414 let trade_id_value = trade_array.get(6).context("Missing trade_id")?;
415 let trade_id = if let Some(id) = trade_id_value.as_i64() {
416 TradeId::new_checked(id.to_string())?
417 } else if let Some(id_str) = trade_id_value.as_str() {
418 TradeId::new_checked(id_str)?
419 } else {
420 anyhow::bail!("Invalid trade_id format");
421 };
422
423 TradeTick::new_checked(
424 instrument.id(),
425 price,
426 size,
427 aggressor,
428 trade_id,
429 ts_event,
430 ts_init,
431 )
432 .context("Failed to construct TradeTick from Kraken trade")
433}
434
435pub fn parse_futures_public_execution(
443 execution: &FuturesPublicExecution,
444 instrument: &InstrumentAny,
445 ts_init: UnixNanos,
446) -> anyhow::Result<TradeTick> {
447 let price =
448 parse_price_with_precision(&execution.price, instrument.price_precision(), "price")?;
449 let size = parse_quantity_with_precision(
450 &execution.quantity,
451 instrument.size_precision(),
452 "quantity",
453 )?;
454
455 let ts_event = UnixNanos::from((execution.timestamp as u64) * 1_000_000);
457
458 let aggressor = match execution.taker_order.direction.to_lowercase().as_str() {
460 "buy" => AggressorSide::Buyer,
461 "sell" => AggressorSide::Seller,
462 _ => AggressorSide::NoAggressor,
463 };
464
465 let trade_id = TradeId::new_checked(&execution.uid)?;
466
467 TradeTick::new_checked(
468 instrument.id(),
469 price,
470 size,
471 aggressor,
472 trade_id,
473 ts_event,
474 ts_init,
475 )
476 .context("Failed to construct TradeTick from Kraken futures execution")
477}
478
479pub fn parse_bar(
487 ohlc: &OhlcData,
488 instrument: &InstrumentAny,
489 bar_type: BarType,
490 ts_init: UnixNanos,
491) -> anyhow::Result<Bar> {
492 let price_precision = instrument.price_precision();
493 let size_precision = instrument.size_precision();
494
495 let open = parse_price_with_precision(&ohlc.open, price_precision, "ohlc.open")?;
496 let high = parse_price_with_precision(&ohlc.high, price_precision, "ohlc.high")?;
497 let low = parse_price_with_precision(&ohlc.low, price_precision, "ohlc.low")?;
498 let close = parse_price_with_precision(&ohlc.close, price_precision, "ohlc.close")?;
499 let volume = parse_quantity_with_precision(&ohlc.volume, size_precision, "ohlc.volume")?;
500
501 let ts_event = UnixNanos::from((ohlc.time as u64) * 1_000_000_000);
502
503 Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
504 .context("Failed to construct Bar from Kraken OHLC")
505}
506
507fn parse_price_with_precision(value: &str, precision: u8, field: &str) -> anyhow::Result<Price> {
508 let parsed = value
509 .parse::<f64>()
510 .with_context(|| format!("Failed to parse {field}='{value}' as f64"))?;
511 Price::new_checked(parsed, precision).with_context(|| {
512 format!("Failed to construct Price for {field} with precision {precision}")
513 })
514}
515
516fn parse_quantity_with_precision(
517 value: &str,
518 precision: u8,
519 field: &str,
520) -> anyhow::Result<Quantity> {
521 let parsed = value
522 .parse::<f64>()
523 .with_context(|| format!("Failed to parse {field}='{value}' as f64"))?;
524 Quantity::new_checked(parsed, precision).with_context(|| {
525 format!("Failed to construct Quantity for {field} with precision {precision}")
526 })
527}
528
529pub fn parse_millis_timestamp(value: f64, field: &str) -> anyhow::Result<UnixNanos> {
530 let millis = (value * 1000.0) as u64;
531 let nanos = millis
532 .checked_mul(NANOSECONDS_IN_MILLISECOND)
533 .with_context(|| format!("{field} timestamp overflowed when converting to nanoseconds"))?;
534 Ok(UnixNanos::from(nanos))
535}
536
537pub fn parse_order_status_report(
545 order_id: &str,
546 order: &SpotOrder,
547 instrument: &InstrumentAny,
548 account_id: AccountId,
549 ts_init: UnixNanos,
550) -> anyhow::Result<OrderStatusReport> {
551 let instrument_id = instrument.id();
552 let venue_order_id = VenueOrderId::new(order_id);
553
554 let order_side = order.descr.order_side.into();
555 let order_type = order.descr.ordertype.into();
556 let order_status = order.status.into();
557
558 let has_expiration = order.expiretm.is_some_and(|t| t > 0.0);
560 let time_in_force = if has_expiration {
561 TimeInForce::Gtd
562 } else if order.oflags.contains("ioc") {
563 TimeInForce::Ioc
564 } else {
565 TimeInForce::Gtc
566 };
567
568 let quantity =
569 parse_quantity_with_precision(&order.vol, instrument.size_precision(), "order.vol")?;
570
571 let filled_qty = parse_quantity_with_precision(
572 &order.vol_exec,
573 instrument.size_precision(),
574 "order.vol_exec",
575 )?;
576
577 let ts_accepted = parse_millis_timestamp(order.opentm, "order.opentm")?;
578
579 let ts_last = order
580 .closetm
581 .map(|t| parse_millis_timestamp(t, "order.closetm"))
582 .transpose()?
583 .unwrap_or(ts_accepted);
584
585 let price = if !order.price.is_empty() && order.price != "0" {
586 Some(parse_price_with_precision(
587 &order.price,
588 instrument.price_precision(),
589 "order.price",
590 )?)
591 } else {
592 None
593 };
594
595 let trigger_price = order
596 .stopprice
597 .as_ref()
598 .and_then(|p| {
599 if !p.is_empty() && p != "0" {
600 Some(parse_price_with_precision(
601 p,
602 instrument.price_precision(),
603 "order.stopprice",
604 ))
605 } else {
606 None
607 }
608 })
609 .transpose()?;
610
611 let expire_time = if has_expiration {
612 order
613 .expiretm
614 .map(|t| parse_millis_timestamp(t, "order.expiretm"))
615 .transpose()?
616 } else {
617 None
618 };
619
620 let trigger_type = parse_trigger_type(order_type, order.trigger);
621
622 Ok(OrderStatusReport {
623 account_id,
624 instrument_id,
625 client_order_id: None,
626 venue_order_id,
627 order_side,
628 order_type,
629 time_in_force,
630 order_status,
631 quantity,
632 filled_qty,
633 report_id: UUID4::new(),
634 ts_accepted,
635 ts_last,
636 ts_init,
637 order_list_id: None,
638 venue_position_id: None,
639 linked_order_ids: None,
640 parent_order_id: None,
641 contingency_type: ContingencyType::NoContingency,
642 expire_time,
643 price,
644 trigger_price,
645 trigger_type,
646 limit_offset: None,
647 trailing_offset: None,
648 trailing_offset_type: TrailingOffsetType::NoTrailingOffset,
649 display_qty: None,
650 avg_px: compute_avg_px(order),
651 post_only: order.oflags.contains("post"),
652 reduce_only: false,
653 cancel_reason: order.reason.clone(),
654 ts_triggered: None,
655 })
656}
657
658fn compute_avg_px(order: &SpotOrder) -> Option<Decimal> {
662 if let Some(ref avg) = order.avg_price
663 && let Ok(v) = parse_decimal(avg)
664 && v > dec!(0)
665 {
666 return Some(v);
667 }
668
669 let cost = parse_decimal(&order.cost);
670 let vol_exec = parse_decimal(&order.vol_exec);
671 match (&cost, &vol_exec) {
672 (Ok(c), Ok(v)) if *v > dec!(0) => Some(*c / *v),
673 _ => {
674 if let Ok(v) = &vol_exec
675 && *v > dec!(0)
676 {
677 log::warn!("Cannot compute avg_px: cost={cost:?}, vol_exec={vol_exec:?}");
678 }
679 None
680 }
681 }
682}
683
684pub fn parse_fill_report(
691 trade_id: &str,
692 trade: &SpotTrade,
693 instrument: &InstrumentAny,
694 account_id: AccountId,
695 ts_init: UnixNanos,
696) -> anyhow::Result<FillReport> {
697 let instrument_id = instrument.id();
698 let venue_order_id = VenueOrderId::new(&trade.ordertxid);
699 let trade_id_obj = TradeId::new(trade_id);
700
701 let order_side = trade.trade_type.into();
702
703 let last_qty =
704 parse_quantity_with_precision(&trade.vol, instrument.size_precision(), "trade.vol")?;
705
706 let last_px =
707 parse_price_with_precision(&trade.price, instrument.price_precision(), "trade.price")?;
708
709 let fee_decimal = parse_decimal(&trade.fee)?;
710 let quote_currency = match instrument {
711 InstrumentAny::CurrencyPair(pair) => pair.quote_currency,
712 InstrumentAny::CryptoPerpetual(perp) => perp.quote_currency,
713 _ => anyhow::bail!("Unsupported instrument type for fill report"),
714 };
715
716 let fee_f64 = fee_decimal
717 .try_into()
718 .context("Failed to convert fee to f64")?;
719 let commission = Money::new(fee_f64, quote_currency);
720
721 let liquidity_side = match trade.maker {
722 Some(true) => LiquiditySide::Maker,
723 Some(false) => LiquiditySide::Taker,
724 None => LiquiditySide::NoLiquiditySide,
725 };
726
727 let ts_event = parse_millis_timestamp(trade.time, "trade.time")?;
728
729 Ok(FillReport {
730 account_id,
731 instrument_id,
732 venue_order_id,
733 trade_id: trade_id_obj,
734 order_side,
735 last_qty,
736 last_px,
737 commission,
738 liquidity_side,
739 report_id: UUID4::new(),
740 ts_event,
741 ts_init,
742 client_order_id: None,
743 venue_position_id: None,
744 })
745}
746
747pub fn parse_futures_order_status_report(
753 order: &FuturesOpenOrder,
754 instrument: &InstrumentAny,
755 account_id: AccountId,
756 ts_init: UnixNanos,
757) -> anyhow::Result<OrderStatusReport> {
758 let instrument_id = instrument.id();
759 let venue_order_id = VenueOrderId::new(&order.order_id);
760
761 let order_side = order.side.into();
762 let order_type = order.order_type.into();
763 let order_status = order.status.into();
764
765 let quantity = Quantity::new(
766 order.unfilled_size + order.filled_size,
767 instrument.size_precision(),
768 );
769
770 let filled_qty = Quantity::new(order.filled_size, instrument.size_precision());
771
772 let ts_accepted = parse_rfc3339_timestamp(&order.received_time, "order.received_time")?;
773 let ts_last = parse_rfc3339_timestamp(&order.last_update_time, "order.last_update_time")?;
774
775 let price = order
776 .limit_price
777 .map(|p| Price::new(p, instrument.price_precision()));
778
779 let trigger_price = order
780 .stop_price
781 .map(|p| Price::new(p, instrument.price_precision()));
782
783 let trigger_type = parse_futures_trigger_type(order_type, order.trigger_signal);
784
785 Ok(OrderStatusReport {
786 account_id,
787 instrument_id,
788 client_order_id: order.cli_ord_id.as_ref().map(|s| s.as_str().into()),
789 venue_order_id,
790 order_side,
791 order_type,
792 time_in_force: TimeInForce::Gtc,
793 order_status,
794 quantity,
795 filled_qty,
796 report_id: UUID4::new(),
797 ts_accepted,
798 ts_last,
799 ts_init,
800 order_list_id: None,
801 venue_position_id: None,
802 linked_order_ids: None,
803 parent_order_id: None,
804 contingency_type: ContingencyType::NoContingency,
805 expire_time: None,
806 price,
807 trigger_price,
808 trigger_type,
809 limit_offset: None,
810 trailing_offset: None,
811 trailing_offset_type: TrailingOffsetType::NoTrailingOffset,
812 display_qty: None,
813 avg_px: None,
814 post_only: false,
815 reduce_only: order.reduce_only.unwrap_or(false),
816 cancel_reason: None,
817 ts_triggered: None,
818 })
819}
820
821pub fn parse_futures_order_event_status_report(
827 event: &FuturesOrderEvent,
828 instrument: &InstrumentAny,
829 account_id: AccountId,
830 ts_init: UnixNanos,
831) -> anyhow::Result<OrderStatusReport> {
832 let instrument_id = instrument.id();
833 let venue_order_id = VenueOrderId::new(&event.order_id);
834
835 let order_side = event.side.into();
836 let order_type = event.order_type.into();
837
838 let order_status = if event.filled >= event.quantity {
840 OrderStatus::Filled
841 } else if event.filled > 0.0 {
842 OrderStatus::PartiallyFilled
843 } else {
844 OrderStatus::Canceled
845 };
846
847 let quantity = Quantity::new(event.quantity, instrument.size_precision());
848 let filled_qty = Quantity::new(event.filled, instrument.size_precision());
849
850 let ts_accepted = parse_rfc3339_timestamp(&event.timestamp, "event.timestamp")?;
851 let ts_last =
852 parse_rfc3339_timestamp(&event.last_update_timestamp, "event.last_update_timestamp")?;
853
854 let price = event
855 .limit_price
856 .map(|p| Price::new(p, instrument.price_precision()));
857
858 let trigger_price = event
859 .stop_price
860 .map(|p| Price::new(p, instrument.price_precision()));
861
862 let trigger_type = parse_futures_trigger_type(order_type, None);
865
866 Ok(OrderStatusReport {
867 account_id,
868 instrument_id,
869 client_order_id: event.cli_ord_id.as_ref().map(|s| s.as_str().into()),
870 venue_order_id,
871 order_side,
872 order_type,
873 time_in_force: TimeInForce::Gtc,
874 order_status,
875 quantity,
876 filled_qty,
877 report_id: UUID4::new(),
878 ts_accepted,
879 ts_last,
880 ts_init,
881 order_list_id: None,
882 venue_position_id: None,
883 linked_order_ids: None,
884 parent_order_id: None,
885 contingency_type: ContingencyType::NoContingency,
886 expire_time: None,
887 price,
888 trigger_price,
889 trigger_type,
890 limit_offset: None,
891 trailing_offset: None,
892 trailing_offset_type: TrailingOffsetType::NoTrailingOffset,
893 display_qty: None,
894 avg_px: None,
895 post_only: false,
896 reduce_only: event.reduce_only,
897 cancel_reason: None,
898 ts_triggered: None,
899 })
900}
901
902pub fn parse_futures_fill_report(
908 fill: &FuturesFill,
909 instrument: &InstrumentAny,
910 account_id: AccountId,
911 ts_init: UnixNanos,
912) -> anyhow::Result<FillReport> {
913 let instrument_id = instrument.id();
914 let venue_order_id = VenueOrderId::new(&fill.order_id);
915 let trade_id = TradeId::new(&fill.fill_id);
916
917 let order_side = fill.side.into();
918
919 let last_qty = Quantity::new(fill.size, instrument.size_precision());
920 let last_px = Price::new(fill.price, instrument.price_precision());
921
922 let quote_currency = match instrument {
923 InstrumentAny::CryptoPerpetual(perp) => perp.quote_currency,
924 InstrumentAny::CryptoFuture(future) => future.quote_currency,
925 _ => anyhow::bail!("Unsupported instrument type for futures fill report"),
926 };
927
928 let fee_f64 = fill.fee_paid.unwrap_or(0.0);
929 let commission = Money::new(fee_f64, quote_currency);
930
931 let liquidity_side = match fill.fill_type {
932 KrakenFillType::Maker => LiquiditySide::Maker,
933 KrakenFillType::Taker => LiquiditySide::Taker,
934 };
935
936 let ts_event = parse_rfc3339_timestamp(&fill.fill_time, "fill.fill_time")?;
937
938 Ok(FillReport {
939 account_id,
940 instrument_id,
941 venue_order_id,
942 trade_id,
943 order_side,
944 last_qty,
945 last_px,
946 commission,
947 liquidity_side,
948 report_id: UUID4::new(),
949 ts_event,
950 ts_init,
951 client_order_id: fill.cli_ord_id.as_ref().map(|s| s.as_str().into()),
952 venue_position_id: None,
953 })
954}
955
956pub fn parse_futures_position_status_report(
962 position: &FuturesPosition,
963 instrument: &InstrumentAny,
964 account_id: AccountId,
965 ts_init: UnixNanos,
966) -> anyhow::Result<PositionStatusReport> {
967 let instrument_id = instrument.id();
968
969 let position_side = match position.side {
970 KrakenPositionSide::Long => PositionSideSpecified::Long,
971 KrakenPositionSide::Short => PositionSideSpecified::Short,
972 };
973
974 let quantity = Quantity::new(position.size, instrument.size_precision());
975 let size_decimal = Decimal::from_str(&position.size.to_string()).unwrap_or(dec!(0));
976 let signed_decimal_qty = match position_side {
977 PositionSideSpecified::Long => size_decimal,
978 PositionSideSpecified::Short => -size_decimal,
979 PositionSideSpecified::Flat => dec!(0),
980 };
981
982 let avg_px_open = Decimal::from_str(&position.price.to_string()).ok();
983
984 Ok(PositionStatusReport {
985 account_id,
986 instrument_id,
987 position_side,
988 quantity,
989 signed_decimal_qty,
990 report_id: UUID4::new(),
991 ts_last: ts_init,
992 ts_init,
993 venue_position_id: None,
994 avg_px_open,
995 })
996}
997
998pub fn bar_type_to_spot_interval(bar_type: BarType) -> anyhow::Result<u32> {
1006 let step = bar_type.spec().step.get() as u32;
1007 let base_interval = match bar_type.spec().aggregation {
1008 BarAggregation::Minute => 1,
1009 BarAggregation::Hour => 60,
1010 BarAggregation::Day => 1440,
1011 other => {
1012 anyhow::bail!("Unsupported bar aggregation for Kraken Spot: {other:?}");
1013 }
1014 };
1015 Ok(base_interval * step)
1016}
1017
1018pub fn bar_type_to_futures_resolution(bar_type: BarType) -> anyhow::Result<&'static str> {
1028 let step = bar_type.spec().step.get() as u32;
1029 match bar_type.spec().aggregation {
1030 BarAggregation::Minute => match step {
1031 1 => Ok("1m"),
1032 5 => Ok("5m"),
1033 15 => Ok("15m"),
1034 _ => anyhow::bail!("Unsupported minute step for Kraken Futures: {step}"),
1035 },
1036 BarAggregation::Hour => match step {
1037 1 => Ok("1h"),
1038 4 => Ok("4h"),
1039 12 => Ok("12h"),
1040 _ => anyhow::bail!("Unsupported hour step for Kraken Futures: {step}"),
1041 },
1042 BarAggregation::Day => {
1043 if step == 1 {
1044 Ok("1d")
1045 } else {
1046 anyhow::bail!("Unsupported day step for Kraken Futures: {step}")
1047 }
1048 }
1049 BarAggregation::Week => {
1050 if step == 1 {
1051 Ok("1w")
1052 } else {
1053 anyhow::bail!("Unsupported week step for Kraken Futures: {step}")
1054 }
1055 }
1056 other => {
1057 anyhow::bail!("Unsupported bar aggregation for Kraken Futures: {other:?}");
1058 }
1059 }
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 use indexmap::IndexMap;
1065 use nautilus_model::{
1066 data::BarSpecification,
1067 enums::{AggregationSource, BarAggregation, OrderStatus, PriceType},
1068 };
1069 use rstest::rstest;
1070
1071 use super::*;
1072 use crate::http::models::AssetPairsResponse;
1073
1074 const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
1075
1076 fn load_test_json(filename: &str) -> String {
1077 let path = format!("test_data/{filename}");
1078 std::fs::read_to_string(&path)
1079 .unwrap_or_else(|e| panic!("Failed to load test data from {path}: {e}"))
1080 }
1081
1082 #[rstest]
1083 fn test_parse_decimal() {
1084 assert_eq!(parse_decimal("123.45").unwrap(), dec!(123.45));
1085 assert_eq!(parse_decimal("0").unwrap(), dec!(0));
1086 assert_eq!(parse_decimal("").unwrap(), dec!(0));
1087 }
1088
1089 #[rstest]
1090 fn test_parse_decimal_opt() {
1091 assert_eq!(
1092 parse_decimal_opt(Some("123.45")).unwrap(),
1093 Some(dec!(123.45))
1094 );
1095 assert_eq!(parse_decimal_opt(Some("0")).unwrap(), None);
1096 assert_eq!(parse_decimal_opt(Some("")).unwrap(), None);
1097 assert_eq!(parse_decimal_opt(None).unwrap(), None);
1098 }
1099
1100 #[rstest]
1101 fn test_parse_spot_instrument() {
1102 let json = load_test_json("http_asset_pairs.json");
1103 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1104 let result = wrapper.get("result").unwrap();
1105 let pairs: AssetPairsResponse = serde_json::from_value(result.clone()).unwrap();
1106
1107 let (pair_name, definition) = pairs.iter().next().unwrap();
1108
1109 let instrument = parse_spot_instrument(pair_name, definition, TS, TS).unwrap();
1110
1111 match instrument {
1112 InstrumentAny::CurrencyPair(pair) => {
1113 assert_eq!(pair.id.venue.as_str(), "KRAKEN");
1114 assert_eq!(pair.base_currency.code.as_str(), "XXBT");
1115 assert_eq!(pair.quote_currency.code.as_str(), "USDT");
1116 assert!(pair.price_increment.as_f64() > 0.0);
1117 assert!(pair.size_increment.as_f64() > 0.0);
1118 assert!(pair.min_quantity.is_some());
1119 assert_eq!(pair.maker_fee, dec!(0.0025));
1120 assert_eq!(pair.taker_fee, dec!(0.004));
1121 assert_eq!(pair.margin_init, dec!(0));
1122 assert_eq!(pair.margin_maint, dec!(0));
1123 }
1124 _ => panic!("Expected CurrencyPair"),
1125 }
1126 }
1127
1128 #[rstest]
1129 fn test_parse_futures_instrument_inverse() {
1130 let json = load_test_json("http_futures_instruments.json");
1131 let response: crate::http::models::FuturesInstrumentsResponse =
1132 serde_json::from_str(&json).unwrap();
1133
1134 let fut_instrument = &response.instruments[0];
1135
1136 let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1137
1138 match instrument {
1139 InstrumentAny::CryptoPerpetual(perp) => {
1140 assert_eq!(perp.id.venue.as_str(), "KRAKEN");
1141 assert_eq!(perp.id.symbol.as_str(), "PI_XBTUSD");
1142 assert_eq!(perp.raw_symbol.as_str(), "PI_XBTUSD");
1143 assert_eq!(perp.base_currency.code.as_str(), "BTC");
1144 assert_eq!(perp.quote_currency.code.as_str(), "USD");
1145 assert_eq!(perp.settlement_currency.code.as_str(), "BTC");
1146 assert!(perp.is_inverse);
1147 assert_eq!(perp.price_increment.as_f64(), 0.5);
1148 assert_eq!(perp.size_increment.as_f64(), 1.0);
1149 assert_eq!(perp.size_precision(), 0);
1150 assert_eq!(perp.margin_init, dec!(0.02));
1151 assert_eq!(perp.margin_maint, dec!(0.01));
1152 }
1153 _ => panic!("Expected CryptoPerpetual"),
1154 }
1155 }
1156
1157 #[rstest]
1158 fn test_parse_futures_instrument_flexible() {
1159 let json = load_test_json("http_futures_instruments.json");
1160 let response: crate::http::models::FuturesInstrumentsResponse =
1161 serde_json::from_str(&json).unwrap();
1162
1163 let fut_instrument = &response.instruments[1];
1164
1165 let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1166
1167 match instrument {
1168 InstrumentAny::CryptoPerpetual(perp) => {
1169 assert_eq!(perp.id.venue.as_str(), "KRAKEN");
1170 assert_eq!(perp.id.symbol.as_str(), "PF_ETHUSD");
1171 assert_eq!(perp.raw_symbol.as_str(), "PF_ETHUSD");
1172 assert_eq!(perp.base_currency.code.as_str(), "ETH");
1173 assert_eq!(perp.quote_currency.code.as_str(), "USD");
1174 assert_eq!(perp.settlement_currency.code.as_str(), "USD");
1175 assert!(!perp.is_inverse);
1176 assert_eq!(perp.price_increment.as_f64(), 0.1);
1177 assert_eq!(perp.size_increment.as_f64(), 0.001);
1178 assert_eq!(perp.size_precision(), 3);
1179 assert_eq!(perp.margin_init, dec!(0.02));
1180 assert_eq!(perp.margin_maint, dec!(0.01));
1181 }
1182 _ => panic!("Expected CryptoPerpetual"),
1183 }
1184 }
1185
1186 #[rstest]
1189 fn test_parse_futures_instrument_negative_precision() {
1190 let json = load_test_json("http_futures_instruments.json");
1191 let response: crate::http::models::FuturesInstrumentsResponse =
1192 serde_json::from_str(&json).unwrap();
1193
1194 let fut_instrument = &response.instruments[2];
1196
1197 let instrument = parse_futures_instrument(fut_instrument, TS, TS).unwrap();
1198
1199 match instrument {
1200 InstrumentAny::CryptoPerpetual(perp) => {
1201 assert_eq!(perp.id.symbol.as_str(), "PF_PEPEUSD");
1202 assert_eq!(perp.base_currency.code.as_str(), "PEPE");
1203 assert!(!perp.is_inverse);
1204 assert_eq!(perp.size_increment.as_f64(), 1000.0);
1205 assert_eq!(perp.size_precision(), 0);
1206 }
1207 _ => panic!("Expected CryptoPerpetual"),
1208 }
1209 }
1210
1211 #[rstest]
1212 fn test_parse_trade_tick_from_array() {
1213 let json = load_test_json("http_trades.json");
1214 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1215 let result = wrapper.get("result").unwrap();
1216 let trades_map = result.as_object().unwrap();
1217
1218 let (_pair, trades_value) = trades_map.iter().find(|(k, _)| *k != "last").unwrap();
1220 let trades = trades_value.as_array().unwrap();
1221 let trade_array = trades[0].as_array().unwrap();
1222
1223 let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1225 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1226 instrument_id,
1227 Symbol::new("XBTUSDT"),
1228 Currency::BTC(),
1229 Currency::USDT(),
1230 1, 8, Price::from("0.1"),
1233 Quantity::from("0.00000001"),
1234 None,
1235 None,
1236 None,
1237 None,
1238 None,
1239 None,
1240 None,
1241 None,
1242 None,
1243 None,
1244 None,
1245 None,
1246 TS,
1247 TS,
1248 ));
1249
1250 let trade_tick = parse_trade_tick_from_array(trade_array, &instrument, TS).unwrap();
1251
1252 assert_eq!(trade_tick.instrument_id, instrument_id);
1253 assert!(trade_tick.price.as_f64() > 0.0);
1254 assert!(trade_tick.size.as_f64() > 0.0);
1255 }
1256
1257 #[rstest]
1258 fn test_parse_bar() {
1259 let json = load_test_json("http_ohlc.json");
1260 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1261 let result = wrapper.get("result").unwrap();
1262 let ohlc_map = result.as_object().unwrap();
1263
1264 let (_pair, ohlc_value) = ohlc_map.iter().find(|(k, _)| *k != "last").unwrap();
1266 let ohlcs = ohlc_value.as_array().unwrap();
1267
1268 let ohlc_array = ohlcs[0].as_array().unwrap();
1270 let ohlc = OhlcData {
1271 time: ohlc_array[0].as_i64().unwrap(),
1272 open: ohlc_array[1].as_str().unwrap().to_string(),
1273 high: ohlc_array[2].as_str().unwrap().to_string(),
1274 low: ohlc_array[3].as_str().unwrap().to_string(),
1275 close: ohlc_array[4].as_str().unwrap().to_string(),
1276 vwap: ohlc_array[5].as_str().unwrap().to_string(),
1277 volume: ohlc_array[6].as_str().unwrap().to_string(),
1278 count: ohlc_array[7].as_i64().unwrap(),
1279 };
1280
1281 let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1283 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1284 instrument_id,
1285 Symbol::new("XBTUSDT"),
1286 Currency::BTC(),
1287 Currency::USDT(),
1288 1, 8, Price::from("0.1"),
1291 Quantity::from("0.00000001"),
1292 None,
1293 None,
1294 None,
1295 None,
1296 None,
1297 None,
1298 None,
1299 None,
1300 None,
1301 None,
1302 None,
1303 None,
1304 TS,
1305 TS,
1306 ));
1307
1308 let bar_type = BarType::new(
1309 instrument_id,
1310 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
1311 AggregationSource::External,
1312 );
1313
1314 let bar = parse_bar(&ohlc, &instrument, bar_type, TS).unwrap();
1315
1316 assert_eq!(bar.bar_type, bar_type);
1317 assert!(bar.open.as_f64() > 0.0);
1318 assert!(bar.high.as_f64() > 0.0);
1319 assert!(bar.low.as_f64() > 0.0);
1320 assert!(bar.close.as_f64() > 0.0);
1321 assert!(bar.volume.as_f64() >= 0.0);
1322 }
1323
1324 #[rstest]
1325 fn test_parse_millis_timestamp() {
1326 let timestamp = 1762795433.9717445;
1327 let result = parse_millis_timestamp(timestamp, "test").unwrap();
1328 assert!(result.as_u64() > 0);
1329 }
1330
1331 #[rstest]
1332 #[case(1, BarAggregation::Minute, 1)]
1333 #[case(5, BarAggregation::Minute, 5)]
1334 #[case(15, BarAggregation::Minute, 15)]
1335 #[case(1, BarAggregation::Hour, 60)]
1336 #[case(4, BarAggregation::Hour, 240)]
1337 #[case(1, BarAggregation::Day, 1440)]
1338 fn test_bar_type_to_spot_interval(
1339 #[case] step: usize,
1340 #[case] aggregation: BarAggregation,
1341 #[case] expected: u32,
1342 ) {
1343 let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1344 let bar_type = BarType::new(
1345 instrument_id,
1346 BarSpecification::new(step, aggregation, PriceType::Last),
1347 AggregationSource::External,
1348 );
1349
1350 let result = bar_type_to_spot_interval(bar_type).unwrap();
1351 assert_eq!(result, expected);
1352 }
1353
1354 #[rstest]
1355 fn test_bar_type_to_spot_interval_unsupported() {
1356 let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
1357 let bar_type = BarType::new(
1358 instrument_id,
1359 BarSpecification::new(1, BarAggregation::Second, PriceType::Last),
1360 AggregationSource::External,
1361 );
1362
1363 let result = bar_type_to_spot_interval(bar_type);
1364 assert!(result.is_err());
1365 assert!(result.unwrap_err().to_string().contains("Unsupported"));
1366 }
1367
1368 #[rstest]
1369 #[case(1, BarAggregation::Minute, "1m")]
1370 #[case(5, BarAggregation::Minute, "5m")]
1371 #[case(15, BarAggregation::Minute, "15m")]
1372 #[case(1, BarAggregation::Hour, "1h")]
1373 #[case(4, BarAggregation::Hour, "4h")]
1374 #[case(12, BarAggregation::Hour, "12h")]
1375 #[case(1, BarAggregation::Day, "1d")]
1376 #[case(1, BarAggregation::Week, "1w")]
1377 fn test_bar_type_to_futures_resolution(
1378 #[case] step: usize,
1379 #[case] aggregation: BarAggregation,
1380 #[case] expected: &str,
1381 ) {
1382 let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
1383 let bar_type = BarType::new(
1384 instrument_id,
1385 BarSpecification::new(step, aggregation, PriceType::Last),
1386 AggregationSource::External,
1387 );
1388
1389 let result = bar_type_to_futures_resolution(bar_type).unwrap();
1390 assert_eq!(result, expected);
1391 }
1392
1393 #[rstest]
1394 #[case(30, BarAggregation::Minute)] #[case(2, BarAggregation::Hour)] #[case(2, BarAggregation::Day)] #[case(1, BarAggregation::Second)] fn test_bar_type_to_futures_resolution_unsupported(
1399 #[case] step: usize,
1400 #[case] aggregation: BarAggregation,
1401 ) {
1402 let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
1403 let bar_type = BarType::new(
1404 instrument_id,
1405 BarSpecification::new(step, aggregation, PriceType::Last),
1406 AggregationSource::External,
1407 );
1408
1409 let result = bar_type_to_futures_resolution(bar_type);
1410 assert!(result.is_err());
1411 assert!(result.unwrap_err().to_string().contains("Unsupported"));
1412 }
1413
1414 #[rstest]
1415 fn test_parse_order_status_report() {
1416 let json = load_test_json("http_open_orders.json");
1417 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1418 let result = wrapper.get("result").unwrap();
1419 let open_map = result.get("open").unwrap();
1420 let orders: IndexMap<String, SpotOrder> = serde_json::from_value(open_map.clone()).unwrap();
1421
1422 let account_id = AccountId::new("KRAKEN-001");
1423 let instrument_id = InstrumentId::new(Symbol::new("BTC/USDT"), *KRAKEN_VENUE);
1424 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1425 instrument_id,
1426 Symbol::new("XBTUSDT"),
1427 Currency::BTC(),
1428 Currency::USDT(),
1429 2,
1430 8,
1431 Price::from("0.01"),
1432 Quantity::from("0.00000001"),
1433 None,
1434 None,
1435 None,
1436 None,
1437 None,
1438 None,
1439 None,
1440 None,
1441 None,
1442 None,
1443 None,
1444 None,
1445 TS,
1446 TS,
1447 ));
1448
1449 let (order_id, order) = orders.iter().next().unwrap();
1450
1451 let report =
1452 parse_order_status_report(order_id, order, &instrument, account_id, TS).unwrap();
1453
1454 assert_eq!(report.account_id, account_id);
1455 assert_eq!(report.instrument_id, instrument_id);
1456 assert_eq!(report.venue_order_id.as_str(), order_id);
1457 assert_eq!(report.order_status, OrderStatus::Accepted);
1458 assert!(report.quantity.as_f64() > 0.0);
1459 }
1460
1461 #[rstest]
1462 fn test_parse_fill_report() {
1463 let json = load_test_json("http_trades_history.json");
1464 let wrapper: serde_json::Value = serde_json::from_str(&json).unwrap();
1465 let result = wrapper.get("result").unwrap();
1466 let trades_map = result.get("trades").unwrap();
1467 let trades: IndexMap<String, SpotTrade> =
1468 serde_json::from_value(trades_map.clone()).unwrap();
1469
1470 let account_id = AccountId::new("KRAKEN-001");
1471 let instrument_id = InstrumentId::new(Symbol::new("BTC/USDT"), *KRAKEN_VENUE);
1472 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1473 instrument_id,
1474 Symbol::new("XBTUSDT"),
1475 Currency::BTC(),
1476 Currency::USDT(),
1477 2,
1478 8,
1479 Price::from("0.01"),
1480 Quantity::from("0.00000001"),
1481 None,
1482 None,
1483 None,
1484 None,
1485 None,
1486 None,
1487 None,
1488 None,
1489 None,
1490 None,
1491 None,
1492 None,
1493 TS,
1494 TS,
1495 ));
1496
1497 let (trade_id, trade) = trades.iter().next().unwrap();
1498
1499 let report = parse_fill_report(trade_id, trade, &instrument, account_id, TS).unwrap();
1500
1501 assert_eq!(report.account_id, account_id);
1502 assert_eq!(report.instrument_id, instrument_id);
1503 assert_eq!(report.trade_id.to_string(), *trade_id);
1504 assert!(report.last_qty.as_f64() > 0.0);
1505 assert!(report.last_px.as_f64() > 0.0);
1506 assert!(report.commission.as_f64() > 0.0);
1507 }
1508
1509 #[rstest]
1510 #[case("XXBT", "XBT")]
1511 #[case("XETH", "ETH")]
1512 #[case("ZUSD", "USD")]
1513 #[case("ZEUR", "EUR")]
1514 #[case("BTC", "BTC")]
1515 #[case("ETH", "ETH")]
1516 #[case("USDT", "USDT")]
1517 #[case("SOL", "SOL")]
1518 fn test_normalize_currency_code(#[case] input: &str, #[case] expected: &str) {
1519 assert_eq!(normalize_currency_code(input), expected);
1520 }
1521
1522 #[rstest]
1523 #[case("XBT/EUR", "BTC/EUR")]
1524 #[case("XBT/USD", "BTC/USD")]
1525 #[case("XBT/USDT", "BTC/USDT")]
1526 #[case("ETH/USD", "ETH/USD")]
1527 #[case("ETH/XBT", "ETH/BTC")]
1528 #[case("SOL/XBT", "SOL/BTC")]
1529 #[case("SOL/USD", "SOL/USD")]
1530 #[case("BTC/USD", "BTC/USD")]
1531 #[case("ETH/BTC", "ETH/BTC")]
1532 fn test_normalize_spot_symbol(#[case] input: &str, #[case] expected: &str) {
1533 assert_eq!(normalize_spot_symbol(input), expected);
1534 }
1535}