1use std::{
23 collections::HashMap,
24 fmt::{Debug, Display},
25 str::FromStr,
26};
27
28#[cfg(test)]
29use nautilus_core::string::secret::REDACTED;
30use nautilus_core::{serialization::deserialize_decimal, string::secret::SecretString};
31use nautilus_model::identifiers::InstrumentId;
32use rust_decimal::Decimal;
33use serde::{Deserialize, Serialize};
34use serde_json::{Value, value::RawValue};
35use ustr::Ustr;
36use zeroize::Zeroize;
37
38use crate::{
39 common::{
40 enums::{
41 DeriveInstrumentType, DeriveOrderbookDepth, DeriveOrderbookGroup, DeriveTickerInterval,
42 },
43 parse::{format_instrument_id, salvage_elements},
44 },
45 http::models::{
46 DeriveAggregateTradingStats, DeriveOptionPricing, DeriveOrder, DerivePublicTrade,
47 DeriveTicker, DeriveTickerSnapshot, DeriveTrade, JsonRpcError,
48 },
49};
50
51pub(crate) const DEFAULT_ORDERBOOK_GROUP: &str = "1";
52pub(crate) const DEFAULT_ORDERBOOK_DEPTH: &str = "10";
53pub(crate) const DEFAULT_TICKER_INTERVAL: &str = "1000";
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Zeroize)]
61pub struct WsLoginParams {
62 pub wallet: String,
64 pub timestamp: String,
66 pub signature: SecretString,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct WsSubscribeParams {
73 pub channels: Vec<DeriveWsChannel>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct WsUnsubscribeParams {
80 pub channels: Vec<DeriveWsChannel>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Hash)]
86pub enum DeriveWsChannel {
87 TickerSlim {
89 instrument_name: Ustr,
91 interval: DeriveTickerInterval,
93 },
94 Orderbook {
96 instrument_name: Ustr,
98 group: DeriveOrderbookGroup,
100 depth: DeriveOrderbookDepth,
102 },
103 Trades {
105 instrument_type: DeriveInstrumentType,
107 currency: Ustr,
109 },
110 Orders {
112 subaccount_id: u64,
114 },
115 PrivateTrades {
117 subaccount_id: u64,
119 },
120 Balances {
122 subaccount_id: u64,
124 },
125 Raw(String),
127}
128
129impl DeriveWsChannel {
130 #[must_use]
132 pub fn ticker_slim(instrument_name: impl AsRef<str>, interval: impl AsRef<str>) -> Self {
133 let instrument_name = instrument_name.as_ref();
134 let interval = interval.as_ref();
135 let Ok(interval) = DeriveTickerInterval::from_str(interval) else {
136 return Self::Raw(ticker_channel(instrument_name, interval));
137 };
138 Self::TickerSlim {
139 instrument_name: Ustr::from(instrument_name),
140 interval,
141 }
142 }
143
144 #[must_use]
146 pub fn orderbook(
147 instrument_name: impl AsRef<str>,
148 group: impl AsRef<str>,
149 depth: impl AsRef<str>,
150 ) -> Self {
151 let instrument_name = instrument_name.as_ref();
152 let group = group.as_ref();
153 let depth = depth.as_ref();
154 let Ok(group) = DeriveOrderbookGroup::from_str(group) else {
155 return Self::Raw(orderbook_channel(instrument_name, group, depth));
156 };
157 let Ok(depth) = DeriveOrderbookDepth::from_str(depth) else {
158 return Self::Raw(orderbook_channel(instrument_name, group.as_ref(), depth));
159 };
160 Self::Orderbook {
161 instrument_name: Ustr::from(instrument_name),
162 group,
163 depth,
164 }
165 }
166
167 #[must_use]
169 pub fn trades(instrument_type: impl AsRef<str>, currency: impl AsRef<str>) -> Self {
170 let instrument_type = instrument_type.as_ref();
171 let currency = currency.as_ref();
172 let Ok(instrument_type) = DeriveInstrumentType::from_str(instrument_type) else {
173 return Self::Raw(trades_channel(instrument_type, currency));
174 };
175 Self::Trades {
176 instrument_type,
177 currency: Ustr::from(currency),
178 }
179 }
180
181 #[must_use]
183 pub const fn orders(subaccount_id: u64) -> Self {
184 Self::Orders { subaccount_id }
185 }
186
187 #[must_use]
189 pub const fn private_trades(subaccount_id: u64) -> Self {
190 Self::PrivateTrades { subaccount_id }
191 }
192
193 #[must_use]
195 pub const fn balances(subaccount_id: u64) -> Self {
196 Self::Balances { subaccount_id }
197 }
198
199 #[must_use]
201 pub fn from_topic(topic: impl Into<String>) -> Self {
202 let topic = topic.into();
203
204 if let Some(rest) = topic.strip_prefix("ticker_slim.")
205 && let Some((instrument_name, interval)) = rest.rsplit_once('.')
206 && !instrument_name.is_empty()
207 && !interval.is_empty()
208 {
209 return Self::ticker_slim(instrument_name, interval);
210 }
211
212 if let Some(rest) = topic.strip_prefix("orderbook.")
213 && let Some((rest, depth)) = rest.rsplit_once('.')
214 && let Some((instrument_name, group)) = rest.rsplit_once('.')
215 && !instrument_name.is_empty()
216 && !group.is_empty()
217 && !depth.is_empty()
218 {
219 return Self::orderbook(instrument_name, group, depth);
220 }
221
222 if let Some(rest) = topic.strip_prefix("trades.")
223 && let Some((instrument_type, currency)) = rest.split_once('.')
224 && !instrument_type.is_empty()
225 && !currency.is_empty()
226 {
227 return Self::trades(instrument_type, currency);
228 }
229
230 if let Some((subaccount_id, suffix)) = topic.split_once('.')
231 && let Ok(subaccount_id) = subaccount_id.parse::<u64>()
232 {
233 return match suffix {
234 "orders" => Self::orders(subaccount_id),
235 "trades" => Self::private_trades(subaccount_id),
236 "balances" => Self::balances(subaccount_id),
237 _ => Self::Raw(topic),
238 };
239 }
240
241 Self::Raw(topic)
242 }
243}
244
245impl Display for DeriveWsChannel {
246 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 match self {
248 Self::TickerSlim {
249 instrument_name,
250 interval,
251 } => f.write_str(&ticker_channel(instrument_name.as_str(), interval.as_ref())),
252 Self::Orderbook {
253 instrument_name,
254 group,
255 depth,
256 } => f.write_str(&orderbook_channel(
257 instrument_name.as_str(),
258 group.as_ref(),
259 depth.as_ref(),
260 )),
261 Self::Trades {
262 instrument_type,
263 currency,
264 } => f.write_str(&trades_channel(instrument_type.as_ref(), currency.as_str())),
265 Self::Orders { subaccount_id } => f.write_str(&orders_channel(*subaccount_id)),
266 Self::PrivateTrades { subaccount_id } => {
267 f.write_str(&private_trades_channel(*subaccount_id))
268 }
269 Self::Balances { subaccount_id } => f.write_str(&balances_channel(*subaccount_id)),
270 Self::Raw(topic) => f.write_str(topic),
271 }
272 }
273}
274
275impl Serialize for DeriveWsChannel {
276 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
277 where
278 S: serde::Serializer,
279 {
280 serializer.serialize_str(&self.to_string())
281 }
282}
283
284impl<'de> Deserialize<'de> for DeriveWsChannel {
285 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
286 where
287 D: serde::Deserializer<'de>,
288 {
289 String::deserialize(deserializer).map(Self::from_topic)
290 }
291}
292
293impl From<String> for DeriveWsChannel {
294 fn from(value: String) -> Self {
295 Self::from_topic(value)
296 }
297}
298
299impl From<&str> for DeriveWsChannel {
300 fn from(value: &str) -> Self {
301 Self::from_topic(value)
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(untagged)]
308pub enum WsRequestParams {
309 Login(WsLoginParams),
311 Subscribe(WsSubscribeParams),
313 Unsubscribe(WsUnsubscribeParams),
315}
316
317impl From<WsLoginParams> for WsRequestParams {
318 fn from(value: WsLoginParams) -> Self {
319 Self::Login(value)
320 }
321}
322
323impl From<WsSubscribeParams> for WsRequestParams {
324 fn from(value: WsSubscribeParams) -> Self {
325 Self::Subscribe(value)
326 }
327}
328
329impl From<WsUnsubscribeParams> for WsRequestParams {
330 fn from(value: WsUnsubscribeParams) -> Self {
331 Self::Unsubscribe(value)
332 }
333}
334
335#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
337#[serde(untagged)]
338pub enum WsLoginResult {
339 Success {
341 #[serde(default)]
343 success: bool,
344 },
345 AuthorizedSubaccounts(Vec<u64>),
347}
348
349impl Default for WsLoginResult {
350 fn default() -> Self {
351 Self::Success { success: false }
352 }
353}
354
355#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
357pub struct WsSubscribeResult {
358 #[serde(default, alias = "current_subscriptions")]
360 pub channels: Vec<DeriveWsChannel>,
361 #[serde(default)]
363 pub status: HashMap<DeriveWsChannel, Ustr>,
364}
365
366#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
368pub struct WsUnsubscribeResult {
369 #[serde(default)]
371 pub success: bool,
372 #[serde(default)]
374 pub channels: Vec<DeriveWsChannel>,
375}
376
377#[derive(Debug, Clone, Deserialize)]
382pub struct WsSubscriptionFrame {
383 #[serde(default)]
385 pub method: Option<Ustr>,
386 pub params: WsSubscriptionPayload,
388}
389
390#[derive(Debug, Clone, Deserialize)]
397pub struct WsSubscriptionPayload {
398 pub channel: Ustr,
400 pub data: Box<RawValue>,
402}
403
404#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
408pub struct DeriveOrderbookLevel(
409 #[serde(deserialize_with = "deserialize_decimal")]
411 pub Decimal,
412 #[serde(deserialize_with = "deserialize_decimal")]
414 pub Decimal,
415);
416
417impl DeriveOrderbookLevel {
418 #[must_use]
420 pub const fn price(&self) -> Decimal {
421 self.0
422 }
423
424 #[must_use]
426 pub const fn amount(&self) -> Decimal {
427 self.1
428 }
429}
430
431#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
433pub struct DeriveOrderbookData {
434 pub instrument_name: Ustr,
436 pub timestamp: i64,
438 pub bids: Vec<DeriveOrderbookLevel>,
440 pub asks: Vec<DeriveOrderbookLevel>,
442}
443
444impl DeriveOrderbookData {
445 #[must_use]
447 pub fn instrument_id(&self) -> InstrumentId {
448 format_instrument_id(self.instrument_name)
449 }
450}
451
452#[derive(Debug, Clone, PartialEq, Eq)]
454pub struct DeriveOrderbookMsg {
455 pub channel: Ustr,
457 pub data: DeriveOrderbookData,
459}
460
461#[derive(Debug, Clone)]
463pub struct DeriveTradesMsg {
464 pub channel: Ustr,
466 pub trades: Vec<DerivePublicTrade>,
468}
469
470#[derive(Debug, Clone)]
472pub struct DeriveOrdersSubscriptionData {
473 pub orders: Vec<DeriveOrder>,
475}
476
477impl<'de> Deserialize<'de> for DeriveOrdersSubscriptionData {
478 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
479 where
480 D: serde::Deserializer<'de>,
481 {
482 Ok(Self {
483 orders: subscription_rows(deserializer)?,
484 })
485 }
486}
487
488#[derive(Debug, Clone)]
490pub struct DeriveTradesSubscriptionData {
491 pub trades: Vec<DeriveTrade>,
493}
494
495impl<'de> Deserialize<'de> for DeriveTradesSubscriptionData {
496 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
497 where
498 D: serde::Deserializer<'de>,
499 {
500 Ok(Self {
501 trades: subscription_rows(deserializer)?,
502 })
503 }
504}
505
506fn subscription_rows<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
509where
510 D: serde::Deserializer<'de>,
511 T: serde::de::DeserializeOwned,
512{
513 match Value::deserialize(deserializer)? {
514 Value::Array(values) => Ok(salvage_elements(values)),
515 value => Ok(vec![
516 serde_json::from_value::<T>(value).map_err(serde::de::Error::custom)?,
517 ]),
518 }
519}
520
521#[derive(Debug, Clone, Deserialize)]
523#[serde(untagged)]
524pub enum DeriveTickerData {
525 Envelope {
527 timestamp: i64,
529 instrument_ticker: DeriveTicker,
531 },
532 SlimEnvelope {
534 timestamp: i64,
536 instrument_ticker: DeriveTickerSnapshot,
538 },
539 Ticker(DeriveTicker),
541}
542
543impl DeriveTickerData {
544 #[must_use]
546 pub const fn timestamp(&self) -> i64 {
547 match self {
548 Self::Envelope { timestamp, .. } => *timestamp,
549 Self::SlimEnvelope { timestamp, .. } => *timestamp,
550 Self::Ticker(ticker) => ticker.timestamp,
551 }
552 }
553
554 #[must_use]
556 pub fn instrument_name(&self) -> &Ustr {
557 match self {
558 Self::Envelope {
559 instrument_ticker, ..
560 } => &instrument_ticker.instrument_name,
561 Self::SlimEnvelope {
562 instrument_ticker, ..
563 } => &instrument_ticker.instrument_name,
564 Self::Ticker(ticker) => &ticker.instrument_name,
565 }
566 }
567
568 #[must_use]
570 pub fn best_ask_price(&self) -> Decimal {
571 match self {
572 Self::Envelope {
573 instrument_ticker, ..
574 } => instrument_ticker.best_ask_price,
575 Self::SlimEnvelope {
576 instrument_ticker, ..
577 } => instrument_ticker.best_ask_price,
578 Self::Ticker(ticker) => ticker.best_ask_price,
579 }
580 }
581
582 #[must_use]
584 pub fn best_bid_price(&self) -> Decimal {
585 match self {
586 Self::Envelope {
587 instrument_ticker, ..
588 } => instrument_ticker.best_bid_price,
589 Self::SlimEnvelope {
590 instrument_ticker, ..
591 } => instrument_ticker.best_bid_price,
592 Self::Ticker(ticker) => ticker.best_bid_price,
593 }
594 }
595
596 #[must_use]
598 pub fn best_ask_amount(&self) -> Decimal {
599 match self {
600 Self::Envelope {
601 instrument_ticker, ..
602 } => instrument_ticker.best_ask_amount,
603 Self::SlimEnvelope {
604 instrument_ticker, ..
605 } => instrument_ticker.best_ask_amount,
606 Self::Ticker(ticker) => ticker.best_ask_amount,
607 }
608 }
609
610 #[must_use]
612 pub fn best_bid_amount(&self) -> Decimal {
613 match self {
614 Self::Envelope {
615 instrument_ticker, ..
616 } => instrument_ticker.best_bid_amount,
617 Self::SlimEnvelope {
618 instrument_ticker, ..
619 } => instrument_ticker.best_bid_amount,
620 Self::Ticker(ticker) => ticker.best_bid_amount,
621 }
622 }
623
624 #[must_use]
626 pub fn mark_price(&self) -> Decimal {
627 match self {
628 Self::Envelope {
629 instrument_ticker, ..
630 } => instrument_ticker.mark_price,
631 Self::SlimEnvelope {
632 instrument_ticker, ..
633 } => instrument_ticker.mark_price,
634 Self::Ticker(ticker) => ticker.mark_price,
635 }
636 }
637
638 #[must_use]
640 pub fn index_price(&self) -> Decimal {
641 match self {
642 Self::Envelope {
643 instrument_ticker, ..
644 } => instrument_ticker.index_price,
645 Self::SlimEnvelope {
646 instrument_ticker, ..
647 } => instrument_ticker.index_price,
648 Self::Ticker(ticker) => ticker.index_price,
649 }
650 }
651
652 #[must_use]
654 pub fn funding_rate(&self) -> Option<Decimal> {
655 match self {
656 Self::Envelope {
657 instrument_ticker, ..
658 } => instrument_ticker
659 .perp_details
660 .as_ref()
661 .map(|perp| perp.funding_rate),
662 Self::SlimEnvelope {
663 instrument_ticker, ..
664 } => instrument_ticker.funding_rate,
665 Self::Ticker(ticker) => ticker.perp_details.as_ref().map(|perp| perp.funding_rate),
666 }
667 }
668
669 #[must_use]
671 pub fn option_pricing(&self) -> Option<&DeriveOptionPricing> {
672 match self {
673 Self::Envelope {
674 instrument_ticker, ..
675 } => instrument_ticker.option_pricing.as_ref(),
676 Self::SlimEnvelope {
677 instrument_ticker, ..
678 } => instrument_ticker.option_pricing.as_ref(),
679 Self::Ticker(ticker) => ticker.option_pricing.as_ref(),
680 }
681 }
682
683 #[must_use]
685 pub fn stats(&self) -> Option<&DeriveAggregateTradingStats> {
686 match self {
687 Self::Envelope {
688 instrument_ticker, ..
689 } => instrument_ticker.stats.as_ref(),
690 Self::SlimEnvelope {
691 instrument_ticker, ..
692 } => instrument_ticker.stats.as_ref(),
693 Self::Ticker(ticker) => ticker.stats.as_ref(),
694 }
695 }
696
697 pub fn apply_channel_context(&mut self, channel: &str) -> Result<(), String> {
704 let Self::SlimEnvelope {
705 instrument_ticker, ..
706 } = self
707 else {
708 return Ok(());
709 };
710
711 if !instrument_ticker.instrument_name.is_empty() {
712 return Ok(());
713 }
714
715 let instrument_name = ticker_instrument_name_from_channel(channel)
716 .ok_or_else(|| format!("invalid Derive ticker channel `{channel}`"))?;
717 instrument_ticker.instrument_name = Ustr::from(instrument_name);
718 Ok(())
719 }
720
721 #[must_use]
723 pub fn instrument_id(&self) -> InstrumentId {
724 format_instrument_id(self.instrument_name())
725 }
726}
727
728#[derive(Debug, Clone)]
730pub struct DeriveTickerMsg {
731 pub channel: Ustr,
733 pub data: DeriveTickerData,
735}
736
737#[derive(Debug, Clone)]
739pub enum DerivePublicWsData {
740 Orderbook(DeriveOrderbookMsg),
742 Trades(DeriveTradesMsg),
744 Ticker(Box<DeriveTickerMsg>),
746}
747
748#[derive(Debug, Clone)]
751pub enum DeriveWsFrame {
752 Response {
754 id: u64,
756 result: Option<Value>,
758 error: Option<JsonRpcError>,
760 },
761 Subscription(WsSubscriptionPayload),
763 UncorrelatedError(JsonRpcError),
765 Unknown(Value),
768}
769
770#[derive(Debug, Deserialize)]
779struct InboundFrame {
780 #[serde(default)]
781 id: Option<u64>,
782 #[serde(default)]
783 method: Option<Ustr>,
784 #[serde(default)]
785 result: Option<Value>,
786 #[serde(default)]
787 error: Option<JsonRpcError>,
788 #[serde(default)]
789 params: Option<Box<RawValue>>,
790}
791
792impl DeriveWsFrame {
793 pub fn parse(text: &str) -> serde_json::Result<Self> {
802 let frame: InboundFrame = serde_json::from_str(text)?;
803
804 if let Some(id) = frame.id {
805 return Ok(Self::Response {
806 id,
807 result: frame.result,
808 error: frame.error,
809 });
810 }
811
812 if frame
813 .method
814 .as_ref()
815 .is_some_and(|method| method.as_str() == "subscription")
816 && let Some(params) = frame.params
817 {
818 let payload: WsSubscriptionPayload = serde_json::from_str(params.get())?;
819 return Ok(Self::Subscription(payload));
820 }
821
822 if let Some(error) = frame.error {
823 return Ok(Self::UncorrelatedError(error));
824 }
825
826 Ok(Self::Unknown(serde_json::from_str(text)?))
830 }
831}
832
833#[must_use]
839pub fn ticker_channel(instrument_name: &str, interval: &str) -> String {
840 format!("ticker_slim.{instrument_name}.{interval}")
841}
842
843fn ticker_instrument_name_from_channel(channel: &str) -> Option<&str> {
844 let rest = channel
845 .strip_prefix("ticker_slim.")
846 .or_else(|| channel.strip_prefix("ticker."))?;
847 let (instrument_name, _) = rest.rsplit_once('.')?;
848 (!instrument_name.is_empty()).then_some(instrument_name)
849}
850
851#[must_use]
853pub fn orderbook_channel(instrument_name: &str, group: &str, depth: &str) -> String {
854 format!("orderbook.{instrument_name}.{group}.{depth}")
855}
856
857#[must_use]
859pub fn trades_channel(instrument_type: &str, currency: &str) -> String {
860 format!("trades.{instrument_type}.{currency}")
861}
862
863#[must_use]
865pub fn orders_channel(subaccount_id: u64) -> String {
866 format!("{subaccount_id}.orders")
867}
868
869#[must_use]
871pub fn private_trades_channel(subaccount_id: u64) -> String {
872 format!("{subaccount_id}.trades")
873}
874
875#[must_use]
877pub fn balances_channel(subaccount_id: u64) -> String {
878 format!("{subaccount_id}.balances")
879}
880
881pub mod methods {
889 pub const PUBLIC_LOGIN: &str = "public/login";
891 pub const PUBLIC_SUBSCRIBE: &str = "subscribe";
893 pub const PUBLIC_UNSUBSCRIBE: &str = "unsubscribe";
895 pub const PRIVATE_ORDER: &str = "private/order";
897 pub const PRIVATE_TRIGGER_ORDER: &str = "private/trigger_order";
900 pub const PRIVATE_CANCEL: &str = "private/cancel";
902 pub const PRIVATE_CANCEL_BY_INSTRUMENT: &str = "private/cancel_by_instrument";
905 pub const PRIVATE_CANCEL_TRIGGER_ORDER: &str = "private/cancel_trigger_order";
908 pub const PRIVATE_CANCEL_BY_LABEL: &str = "private/cancel_by_label";
911 pub const PRIVATE_GET_TRIGGER_ORDERS: &str = "private/get_trigger_orders";
914 pub const PRIVATE_CANCEL_ALL: &str = "private/cancel_all";
917 pub const PRIVATE_REPLACE: &str = "private/replace";
920}
921
922#[cfg(test)]
923mod tests {
924 use rstest::rstest;
925 use serde_json::json;
926
927 use super::*;
928 use crate::http::models::JsonRpcRequest;
929
930 #[rstest]
931 fn test_ticker_channel_joins_with_dots() {
932 assert_eq!(
933 ticker_channel("ETH-PERP", "1000"),
934 "ticker_slim.ETH-PERP.1000",
935 );
936 assert_eq!(
937 ticker_channel("BTC-20260627-100000-C", "100"),
938 "ticker_slim.BTC-20260627-100000-C.100",
939 );
940 }
941
942 #[rstest]
943 fn test_orderbook_channel_joins_with_dots() {
944 assert_eq!(
945 orderbook_channel("ETH-PERP", "1", "10"),
946 "orderbook.ETH-PERP.1.10",
947 );
948 }
949
950 #[rstest]
951 fn test_trades_channel_joins_with_dots() {
952 assert_eq!(trades_channel("perp", "ETH"), "trades.perp.ETH");
953 }
954
955 #[rstest]
956 #[case(0_u64, "0.orders", "0.trades", "0.balances")]
957 #[case(1_u64, "1.orders", "1.trades", "1.balances")]
958 #[case(30769_u64, "30769.orders", "30769.trades", "30769.balances")]
959 fn test_private_channel_formatters_emit_subaccount_prefix(
960 #[case] subaccount: u64,
961 #[case] expected_orders: &str,
962 #[case] expected_trades: &str,
963 #[case] expected_balances: &str,
964 ) {
965 assert_eq!(orders_channel(subaccount), expected_orders);
966 assert_eq!(private_trades_channel(subaccount), expected_trades);
967 assert_eq!(balances_channel(subaccount), expected_balances);
968 }
969
970 #[rstest]
971 fn test_ws_channel_formats_known_topics() {
972 assert_eq!(
973 DeriveWsChannel::ticker_slim("ETH-PERP", DeriveTickerInterval::Ms1000).to_string(),
974 "ticker_slim.ETH-PERP.1000",
975 );
976 assert_eq!(
977 DeriveWsChannel::orderbook(
978 "ETH-PERP",
979 DeriveOrderbookGroup::G1,
980 DeriveOrderbookDepth::D10,
981 )
982 .to_string(),
983 "orderbook.ETH-PERP.1.10",
984 );
985 assert_eq!(
986 DeriveWsChannel::trades(DeriveInstrumentType::Perp, "ETH").to_string(),
987 "trades.perp.ETH",
988 );
989 assert_eq!(DeriveWsChannel::orders(30769).to_string(), "30769.orders");
990 assert_eq!(
991 DeriveWsChannel::private_trades(30769).to_string(),
992 "30769.trades",
993 );
994 assert_eq!(
995 DeriveWsChannel::balances(30769).to_string(),
996 "30769.balances",
997 );
998 }
999
1000 #[rstest]
1001 fn test_ws_channel_deserializes_known_and_raw_topics() {
1002 let ticker: DeriveWsChannel =
1003 serde_json::from_value(json!("ticker_slim.ETH.TEST-PERP.1000")).unwrap();
1004 let orderbook: DeriveWsChannel =
1005 serde_json::from_value(json!("orderbook.ETH.TEST-PERP.1.10")).unwrap();
1006 let private_trades: DeriveWsChannel =
1007 serde_json::from_value(json!("30769.trades")).unwrap();
1008 let raw: DeriveWsChannel = serde_json::from_value(json!("trades.ETH-USDC")).unwrap();
1009
1010 assert_eq!(
1011 ticker,
1012 DeriveWsChannel::ticker_slim("ETH.TEST-PERP", "1000"),
1013 );
1014 assert_eq!(
1015 orderbook,
1016 DeriveWsChannel::orderbook("ETH.TEST-PERP", "1", "10"),
1017 );
1018 assert_eq!(private_trades, DeriveWsChannel::private_trades(30769));
1019 assert_eq!(raw, DeriveWsChannel::Raw("trades.ETH-USDC".to_string()));
1020 }
1021
1022 #[rstest]
1023 fn test_ws_channel_uses_typed_known_topic_fields() {
1024 let ticker = DeriveWsChannel::from_topic("ticker_slim.ETH-PERP.1000");
1025 let orderbook = DeriveWsChannel::from_topic("orderbook.ETH-PERP.1.10");
1026 let trades = DeriveWsChannel::from_topic("trades.perp.ETH");
1027
1028 match ticker {
1029 DeriveWsChannel::TickerSlim {
1030 instrument_name,
1031 interval,
1032 } => {
1033 assert_eq!(instrument_name, "ETH-PERP");
1034 assert_eq!(interval, DeriveTickerInterval::Ms1000);
1035 }
1036 other => panic!("expected TickerSlim, was {other:?}"),
1037 }
1038
1039 match orderbook {
1040 DeriveWsChannel::Orderbook {
1041 instrument_name,
1042 group,
1043 depth,
1044 } => {
1045 assert_eq!(instrument_name, "ETH-PERP");
1046 assert_eq!(group, DeriveOrderbookGroup::G1);
1047 assert_eq!(depth, DeriveOrderbookDepth::D10);
1048 }
1049 other => panic!("expected Orderbook, was {other:?}"),
1050 }
1051
1052 match trades {
1053 DeriveWsChannel::Trades {
1054 instrument_type,
1055 currency,
1056 } => {
1057 assert_eq!(instrument_type, DeriveInstrumentType::Perp);
1058 assert_eq!(currency, "ETH");
1059 }
1060 other => panic!("expected Trades, was {other:?}"),
1061 }
1062 }
1063
1064 #[rstest]
1065 fn test_subscribe_request_serializes_as_jsonrpc_envelope() {
1066 let req = JsonRpcRequest::new(
1067 1,
1068 methods::PUBLIC_SUBSCRIBE,
1069 WsSubscribeParams {
1070 channels: vec![DeriveWsChannel::ticker_slim("ETH-PERP", "1000")],
1071 },
1072 );
1073 let wire = serde_json::to_value(&req).unwrap();
1074 assert_eq!(wire["jsonrpc"], "2.0");
1075 assert_eq!(wire["id"], 1);
1076 assert_eq!(wire["method"], "subscribe");
1077 assert_eq!(wire["params"]["channels"][0], "ticker_slim.ETH-PERP.1000");
1078 }
1079
1080 #[rstest]
1081 fn test_ws_request_params_preserve_jsonrpc_wire_output() {
1082 let login = JsonRpcRequest::new(
1083 1,
1084 methods::PUBLIC_LOGIN,
1085 WsRequestParams::from(WsLoginParams {
1086 wallet: "0xWALLET".to_string(),
1087 timestamp: "1700000000000".to_string(),
1088 signature: SecretString::from("0xSIG"),
1089 }),
1090 );
1091 let subscribe = JsonRpcRequest::new(
1092 2,
1093 methods::PUBLIC_SUBSCRIBE,
1094 WsRequestParams::from(WsSubscribeParams {
1095 channels: vec![DeriveWsChannel::ticker_slim("ETH-PERP", "1000")],
1096 }),
1097 );
1098 let unsubscribe = JsonRpcRequest::new(
1099 3,
1100 methods::PUBLIC_UNSUBSCRIBE,
1101 WsRequestParams::from(WsUnsubscribeParams {
1102 channels: vec![DeriveWsChannel::ticker_slim("ETH-PERP", "1000")],
1103 }),
1104 );
1105
1106 assert_eq!(
1107 serde_json::to_string(&login).unwrap(),
1108 concat!(
1109 r#"{"jsonrpc":"2.0","id":1,"method":"public/login","params":{"#,
1110 r#""wallet":"0xWALLET","timestamp":"1700000000000","signature":"0xSIG"}}"#,
1111 ),
1112 );
1113 assert_eq!(
1114 serde_json::to_string(&subscribe).unwrap(),
1115 concat!(
1116 r#"{"jsonrpc":"2.0","id":2,"method":"subscribe","params":{"#,
1117 r#""channels":["ticker_slim.ETH-PERP.1000"]}}"#,
1118 ),
1119 );
1120 assert_eq!(
1121 serde_json::to_string(&unsubscribe).unwrap(),
1122 concat!(
1123 r#"{"jsonrpc":"2.0","id":3,"method":"unsubscribe","params":{"#,
1124 r#""channels":["ticker_slim.ETH-PERP.1000"]}}"#,
1125 ),
1126 );
1127 }
1128
1129 #[rstest]
1130 fn test_ws_response_results_decode_known_shapes() {
1131 let login_object: WsLoginResult = serde_json::from_value(json!({"success": true})).unwrap();
1132 let login_array: WsLoginResult = serde_json::from_value(json!([30769])).unwrap();
1133 let subscribe: WsSubscribeResult = serde_json::from_value(json!({
1134 "channels": ["ticker_slim.ETH-PERP.1000"],
1135 }))
1136 .unwrap();
1137 let unsubscribe: WsUnsubscribeResult =
1138 serde_json::from_value(json!({"success": true})).unwrap();
1139
1140 assert_eq!(login_object, WsLoginResult::Success { success: true });
1141 assert_eq!(
1142 login_array,
1143 WsLoginResult::AuthorizedSubaccounts(vec![30769]),
1144 );
1145 assert_eq!(
1146 subscribe.channels,
1147 vec![DeriveWsChannel::ticker_slim("ETH-PERP", "1000")],
1148 );
1149 assert!(unsubscribe.success);
1150 }
1151
1152 #[rstest]
1153 fn test_subscribe_result_decodes_recorded_venue_ack() {
1154 let ack: Value =
1155 serde_json::from_str(include_str!("../../test_data/spot/ws_subscribe_ack.json"))
1156 .unwrap();
1157 let result: WsSubscribeResult =
1158 serde_json::from_value(ack["result"].clone()).expect("subscribe ack parses");
1159
1160 assert!(
1161 result
1162 .channels
1163 .contains(&DeriveWsChannel::ticker_slim("ETH-USDC", "1000"))
1164 );
1165 assert_eq!(
1166 result
1167 .status
1168 .get(&DeriveWsChannel::orderbook("ETH-USDC", "1", "10"))
1169 .map(|status| status.as_str()),
1170 Some("ok"),
1171 );
1172 }
1173
1174 #[rstest]
1175 fn test_login_params_round_trip() {
1176 let params = WsLoginParams {
1177 wallet: "0xWALLET".to_string(),
1178 timestamp: "1700000000000".to_string(),
1179 signature: SecretString::from("0xDEAD"),
1180 };
1181 let debug = format!("{params:?}");
1182 let wire = serde_json::to_value(¶ms).unwrap();
1183
1184 assert_eq!(wire["wallet"], "0xWALLET");
1185 assert_eq!(wire["timestamp"], "1700000000000");
1186 assert_eq!(wire["signature"], "0xDEAD");
1187 assert!(debug.contains(REDACTED));
1188 assert!(!debug.contains("0xDEAD"));
1189 let back: WsLoginParams = serde_json::from_value(wire).unwrap();
1190 assert_eq!(back, params);
1191 }
1192
1193 #[rstest]
1194 fn test_parse_response_with_result() {
1195 let text = json!({"id": 42, "result": {"ok": true}}).to_string();
1196 let frame = DeriveWsFrame::parse(&text).unwrap();
1197 match frame {
1198 DeriveWsFrame::Response { id, result, error } => {
1199 assert_eq!(id, 42);
1200 assert_eq!(result, Some(json!({"ok": true})));
1201 assert!(error.is_none());
1202 }
1203 other => panic!("expected Response, was {other:?}"),
1204 }
1205 }
1206
1207 #[rstest]
1208 fn test_parse_response_with_error_payload() {
1209 let text = json!({
1210 "id": 7,
1211 "error": {"code": -32602, "message": "bad params", "data": {"field": "channels"}},
1212 })
1213 .to_string();
1214 let frame = DeriveWsFrame::parse(&text).unwrap();
1215 match frame {
1216 DeriveWsFrame::Response { id, result, error } => {
1217 assert_eq!(id, 7);
1218 assert!(result.is_none());
1219 let err = error.expect("error present");
1220 assert_eq!(err.code, -32602);
1221 assert_eq!(err.data, Some(json!({"field": "channels"})));
1222 }
1223 other => panic!("expected Response, was {other:?}"),
1224 }
1225 }
1226
1227 #[rstest]
1228 fn test_parse_subscription_notification() {
1229 let text = json!({
1230 "method": "subscription",
1231 "params": {
1232 "channel": "ticker.ETH-PERP.1000",
1233 "data": {"instrument_name": "ETH-PERP", "mark_price": "3500.5"},
1234 },
1235 })
1236 .to_string();
1237 let frame = DeriveWsFrame::parse(&text).unwrap();
1238 match frame {
1239 DeriveWsFrame::Subscription(payload) => {
1240 assert_eq!(payload.channel, "ticker.ETH-PERP.1000");
1241 let data: Value = serde_json::from_str(payload.data.get()).unwrap();
1242 assert_eq!(data["mark_price"], "3500.5");
1243 }
1244 other => panic!("expected Subscription, was {other:?}"),
1245 }
1246 }
1247
1248 #[rstest]
1249 fn test_parse_unknown_frame_preserves_value() {
1250 let text = json!({"hello": "world"}).to_string();
1251 let frame = DeriveWsFrame::parse(&text).unwrap();
1252 match frame {
1253 DeriveWsFrame::Unknown(v) => {
1254 assert_eq!(v["hello"], "world");
1255 assert!(v.get("id").is_none(), "unknown frame must not carry id");
1256 let method = v.get("method").and_then(Value::as_str);
1257 assert_ne!(method, Some("subscription"));
1258 }
1259 other => panic!("expected Unknown, was {other:?}"),
1260 }
1261 }
1262
1263 #[rstest]
1264 fn test_parse_null_id_error_preserves_structured_error() {
1265 let text = json!({
1266 "id": null,
1267 "error": {
1268 "code": -32700,
1269 "message": "Parse error",
1270 "data": "invalid JSON",
1271 },
1272 })
1273 .to_string();
1274 let frame = DeriveWsFrame::parse(&text).unwrap();
1275
1276 match frame {
1277 DeriveWsFrame::UncorrelatedError(error) => {
1278 assert_eq!(error.code, -32700);
1279 assert_eq!(error.message, "Parse error");
1280 assert_eq!(error.data, Some(json!("invalid JSON")));
1281 }
1282 other => panic!("expected UncorrelatedError, was {other:?}"),
1283 }
1284 }
1285
1286 #[rstest]
1287 fn test_parse_non_subscription_notification_with_params_is_unknown() {
1288 let text = json!({"method": "heartbeat", "params": {"interval": 30}}).to_string();
1292 let frame = DeriveWsFrame::parse(&text).unwrap();
1293 match frame {
1294 DeriveWsFrame::Unknown(v) => {
1295 assert_eq!(v["method"], "heartbeat");
1296 assert_eq!(v["params"]["interval"], 30);
1297 }
1298 other => panic!("expected Unknown, was {other:?}"),
1299 }
1300 }
1301
1302 #[rstest]
1303 fn test_parse_response_with_both_result_and_error_prefers_error() {
1304 let text = json!({
1306 "id": 11,
1307 "result": {"should_not_win": true},
1308 "error": {"code": -1, "message": "wins"},
1309 })
1310 .to_string();
1311 let frame = DeriveWsFrame::parse(&text).unwrap();
1312 match frame {
1313 DeriveWsFrame::Response { id, result, error } => {
1314 assert_eq!(id, 11);
1315 assert!(result.is_some(), "result is preserved on the frame");
1316 let err = error.expect("error present");
1317 assert_eq!(err.code, -1);
1318 assert_eq!(err.message, "wins");
1319 }
1320 other => panic!("expected Response, was {other:?}"),
1321 }
1322 }
1323
1324 #[rstest]
1325 fn test_parse_rejects_malformed_json() {
1326 let err = DeriveWsFrame::parse("{not json").expect_err("must reject");
1327 assert_eq!(err.classify(), serde_json::error::Category::Syntax);
1329 }
1330
1331 #[rstest]
1332 fn test_unsubscribe_params_round_trip() {
1333 let params = WsUnsubscribeParams {
1334 channels: vec![
1335 DeriveWsChannel::ticker_slim("ETH-PERP", "1000"),
1336 DeriveWsChannel::ticker_slim("BTC-PERP", "100"),
1337 ],
1338 };
1339 let wire = serde_json::to_value(¶ms).unwrap();
1340 assert_eq!(wire["channels"][0], "ticker_slim.ETH-PERP.1000");
1341 assert_eq!(wire["channels"][1], "ticker_slim.BTC-PERP.100");
1342 let back: WsUnsubscribeParams = serde_json::from_value(wire).unwrap();
1343 assert_eq!(back, params);
1344 }
1345
1346 #[rstest]
1347 fn test_private_orders_subscription_data_decodes_single_and_array_payloads() {
1348 let order: Value = serde_json::from_str(include_str!(
1349 "../../test_data/perps/http_order_eth_partially_filled.json"
1350 ))
1351 .unwrap();
1352 let single: DeriveOrdersSubscriptionData = serde_json::from_value(order.clone()).unwrap();
1353 let array: DeriveOrdersSubscriptionData =
1354 serde_json::from_value(json!([order, {"not": "an order"}])).unwrap();
1355
1356 assert_eq!(single.orders.len(), 1);
1357 assert_eq!(single.orders[0].order_id, "abc-123");
1358 assert_eq!(array.orders.len(), 1);
1359 assert_eq!(array.orders[0].order_id, "abc-123");
1360 }
1361
1362 #[rstest]
1363 fn test_private_trades_subscription_data_decodes_single_and_array_payloads() {
1364 let trade: Value = serde_json::from_str(include_str!(
1365 "../../test_data/perps/http_private_trade_eth.json"
1366 ))
1367 .unwrap();
1368 let single: DeriveTradesSubscriptionData = serde_json::from_value(trade.clone()).unwrap();
1369 let array: DeriveTradesSubscriptionData =
1370 serde_json::from_value(json!([trade, {"not": "a trade"}])).unwrap();
1371
1372 assert_eq!(single.trades.len(), 1);
1373 assert_eq!(single.trades[0].trade_id, "trade-xyz");
1374 assert_eq!(array.trades.len(), 1);
1375 assert_eq!(array.trades[0].trade_id, "trade-xyz");
1376 }
1377}