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_token, 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_rows},
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
136 let Ok(interval) = DeriveTickerInterval::from_str(interval) else {
137 return Self::Raw(ticker_channel(instrument_name, interval));
138 };
139
140 Self::TickerSlim {
141 instrument_name: Ustr::from(instrument_name),
142 interval,
143 }
144 }
145
146 #[must_use]
148 pub fn orderbook(
149 instrument_name: impl AsRef<str>,
150 group: impl AsRef<str>,
151 depth: impl AsRef<str>,
152 ) -> Self {
153 let instrument_name = instrument_name.as_ref();
154 let group = group.as_ref();
155 let depth = depth.as_ref();
156
157 let Ok(group) = DeriveOrderbookGroup::from_str(group) else {
158 return Self::Raw(orderbook_channel(instrument_name, group, depth));
159 };
160
161 let Ok(depth) = DeriveOrderbookDepth::from_str(depth) else {
162 return Self::Raw(orderbook_channel(instrument_name, group.as_ref(), depth));
163 };
164
165 Self::Orderbook {
166 instrument_name: Ustr::from(instrument_name),
167 group,
168 depth,
169 }
170 }
171
172 #[must_use]
174 pub fn trades(instrument_type: impl AsRef<str>, currency: impl AsRef<str>) -> Self {
175 let instrument_type = instrument_type.as_ref();
176 let currency = currency.as_ref();
177
178 let Ok(instrument_type) = DeriveInstrumentType::from_str(instrument_type) else {
179 return Self::Raw(trades_channel(instrument_type, currency));
180 };
181
182 Self::Trades {
183 instrument_type,
184 currency: Ustr::from(currency),
185 }
186 }
187
188 #[must_use]
190 pub const fn orders(subaccount_id: u64) -> Self {
191 Self::Orders { subaccount_id }
192 }
193
194 #[must_use]
196 pub const fn private_trades(subaccount_id: u64) -> Self {
197 Self::PrivateTrades { subaccount_id }
198 }
199
200 #[must_use]
202 pub const fn balances(subaccount_id: u64) -> Self {
203 Self::Balances { subaccount_id }
204 }
205
206 #[must_use]
208 pub fn from_topic(topic: impl Into<String>) -> Self {
209 let topic = topic.into();
210
211 if let Some(rest) = topic.strip_prefix("ticker_slim.")
212 && let Some((instrument_name, interval)) = rest.rsplit_once('.')
213 && !instrument_name.is_empty()
214 && !interval.is_empty()
215 {
216 return Self::ticker_slim(instrument_name, interval);
217 }
218
219 if let Some(rest) = topic.strip_prefix("orderbook.")
220 && let Some((rest, depth)) = rest.rsplit_once('.')
221 && let Some((instrument_name, group)) = rest.rsplit_once('.')
222 && !instrument_name.is_empty()
223 && !group.is_empty()
224 && !depth.is_empty()
225 {
226 return Self::orderbook(instrument_name, group, depth);
227 }
228
229 if let Some(rest) = topic.strip_prefix("trades.")
230 && let Some((instrument_type, currency)) = rest.split_once('.')
231 && !instrument_type.is_empty()
232 && !currency.is_empty()
233 {
234 return Self::trades(instrument_type, currency);
235 }
236
237 if let Some((subaccount_id, suffix)) = topic.split_once('.')
238 && let Ok(subaccount_id) = subaccount_id.parse::<u64>()
239 {
240 return match suffix {
241 "orders" => Self::orders(subaccount_id),
242 "trades" => Self::private_trades(subaccount_id),
243 "balances" => Self::balances(subaccount_id),
244 _ => Self::Raw(topic),
245 };
246 }
247
248 Self::Raw(topic)
249 }
250}
251
252impl Display for DeriveWsChannel {
253 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254 match self {
255 Self::TickerSlim {
256 instrument_name,
257 interval,
258 } => f.write_str(&ticker_channel(instrument_name.as_str(), interval.as_ref())),
259 Self::Orderbook {
260 instrument_name,
261 group,
262 depth,
263 } => f.write_str(&orderbook_channel(
264 instrument_name.as_str(),
265 group.as_ref(),
266 depth.as_ref(),
267 )),
268 Self::Trades {
269 instrument_type,
270 currency,
271 } => f.write_str(&trades_channel(instrument_type.as_ref(), currency.as_str())),
272 Self::Orders { subaccount_id } => f.write_str(&orders_channel(*subaccount_id)),
273 Self::PrivateTrades { subaccount_id } => {
274 f.write_str(&private_trades_channel(*subaccount_id))
275 }
276 Self::Balances { subaccount_id } => f.write_str(&balances_channel(*subaccount_id)),
277 Self::Raw(topic) => f.write_str(topic),
278 }
279 }
280}
281
282impl Serialize for DeriveWsChannel {
283 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
284 where
285 S: serde::Serializer,
286 {
287 serializer.serialize_str(&self.to_string())
288 }
289}
290
291impl<'de> Deserialize<'de> for DeriveWsChannel {
292 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
293 where
294 D: serde::Deserializer<'de>,
295 {
296 String::deserialize(deserializer).map(Self::from_topic)
297 }
298}
299
300impl From<String> for DeriveWsChannel {
301 fn from(value: String) -> Self {
302 Self::from_topic(value)
303 }
304}
305
306impl From<&str> for DeriveWsChannel {
307 fn from(value: &str) -> Self {
308 Self::from_topic(value)
309 }
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314#[serde(untagged)]
315pub enum WsRequestParams {
316 Login(WsLoginParams),
318 Subscribe(WsSubscribeParams),
320 Unsubscribe(WsUnsubscribeParams),
322}
323
324impl From<WsLoginParams> for WsRequestParams {
325 fn from(value: WsLoginParams) -> Self {
326 Self::Login(value)
327 }
328}
329
330impl From<WsSubscribeParams> for WsRequestParams {
331 fn from(value: WsSubscribeParams) -> Self {
332 Self::Subscribe(value)
333 }
334}
335
336impl From<WsUnsubscribeParams> for WsRequestParams {
337 fn from(value: WsUnsubscribeParams) -> Self {
338 Self::Unsubscribe(value)
339 }
340}
341
342pub type WsLoginResult = Vec<u64>;
344
345#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
347pub struct WsSubscribeResult {
348 #[serde(rename = "current_subscriptions")]
350 pub channels: Vec<DeriveWsChannel>,
351 pub status: HashMap<DeriveWsChannel, Ustr>,
353}
354
355#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
357pub struct WsUnsubscribeResult {
358 pub remaining_subscriptions: Vec<DeriveWsChannel>,
360 pub status: HashMap<DeriveWsChannel, Ustr>,
362}
363
364#[derive(Debug, Clone, Deserialize)]
369pub struct WsSubscriptionFrame {
370 #[serde(default)]
372 pub method: Option<Ustr>,
373 pub params: WsSubscriptionPayload,
375}
376
377#[derive(Debug, Clone, Deserialize)]
384pub struct WsSubscriptionPayload {
385 pub channel: Ustr,
387 pub data: Box<RawValue>,
389}
390
391#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
395pub struct DeriveOrderbookLevel(
396 #[serde(deserialize_with = "deserialize_decimal_token")]
398 pub Decimal,
399 #[serde(deserialize_with = "deserialize_decimal_token")]
401 pub Decimal,
402);
403
404impl DeriveOrderbookLevel {
405 #[must_use]
407 pub const fn price(&self) -> Decimal {
408 self.0
409 }
410
411 #[must_use]
413 pub const fn amount(&self) -> Decimal {
414 self.1
415 }
416}
417
418#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
420pub struct DeriveOrderbookData {
421 pub instrument_name: Ustr,
423 pub timestamp: i64,
425 pub bids: Vec<DeriveOrderbookLevel>,
427 pub asks: Vec<DeriveOrderbookLevel>,
429}
430
431impl DeriveOrderbookData {
432 pub fn instrument_id(&self) -> anyhow::Result<InstrumentId> {
438 format_instrument_id(self.instrument_name)
439 }
440}
441
442#[derive(Debug, Clone, PartialEq, Eq)]
444pub struct DeriveOrderbookMsg {
445 pub channel: Ustr,
447 pub data: DeriveOrderbookData,
449}
450
451#[derive(Debug, Clone)]
453pub struct DeriveTradesMsg {
454 pub channel: Ustr,
456 pub trades: Vec<DerivePublicTrade>,
458}
459
460#[derive(Debug, Clone)]
462pub struct DeriveOrdersSubscriptionData {
463 pub orders: Vec<DeriveOrder>,
465}
466
467impl<'de> Deserialize<'de> for DeriveOrdersSubscriptionData {
468 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
469 where
470 D: serde::Deserializer<'de>,
471 {
472 Ok(Self {
473 orders: subscription_rows(deserializer)?,
474 })
475 }
476}
477
478#[derive(Debug, Clone)]
480pub struct DeriveTradesSubscriptionData {
481 pub trades: Vec<DeriveTrade>,
483}
484
485impl<'de> Deserialize<'de> for DeriveTradesSubscriptionData {
486 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
487 where
488 D: serde::Deserializer<'de>,
489 {
490 Ok(Self {
491 trades: subscription_rows(deserializer)?,
492 })
493 }
494}
495
496fn subscription_rows<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
499where
500 D: serde::Deserializer<'de>,
501 T: serde::de::DeserializeOwned,
502{
503 let raw = Box::<RawValue>::deserialize(deserializer)?;
504 if raw.get().starts_with('[') {
505 let rows = serde_json::from_str(raw.get()).map_err(serde::de::Error::custom)?;
506 Ok(salvage_rows(rows))
507 } else {
508 serde_json::from_str(raw.get())
509 .map(|row| vec![row])
510 .map_err(serde::de::Error::custom)
511 }
512}
513
514#[derive(Debug, Clone)]
516pub enum DeriveTickerData {
517 Envelope {
519 timestamp: i64,
521 instrument_ticker: DeriveTicker,
523 },
524 SlimEnvelope {
526 timestamp: i64,
528 instrument_ticker: DeriveTickerSnapshot,
530 },
531 Ticker(DeriveTicker),
533}
534
535impl<'de> Deserialize<'de> for DeriveTickerData {
536 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
537 #[derive(Deserialize)]
538 struct Envelope<T> {
539 timestamp: i64,
540 instrument_ticker: T,
541 }
542 let raw = Box::<RawValue>::deserialize(deserializer)?;
543 if let Ok(envelope) = serde_json::from_str::<Envelope<DeriveTicker>>(raw.get()) {
544 return Ok(Self::Envelope {
545 timestamp: envelope.timestamp,
546 instrument_ticker: envelope.instrument_ticker,
547 });
548 }
549
550 if let Ok(envelope) = serde_json::from_str::<Envelope<DeriveTickerSnapshot>>(raw.get()) {
551 return Ok(Self::SlimEnvelope {
552 timestamp: envelope.timestamp,
553 instrument_ticker: envelope.instrument_ticker,
554 });
555 }
556
557 serde_json::from_str(raw.get())
558 .map(Self::Ticker)
559 .map_err(serde::de::Error::custom)
560 }
561}
562
563impl DeriveTickerData {
564 #[must_use]
566 pub const fn timestamp(&self) -> i64 {
567 match self {
568 Self::Envelope { timestamp, .. } => *timestamp,
569 Self::SlimEnvelope { timestamp, .. } => *timestamp,
570 Self::Ticker(ticker) => ticker.timestamp,
571 }
572 }
573
574 #[must_use]
576 pub fn instrument_name(&self) -> &Ustr {
577 match self {
578 Self::Envelope {
579 instrument_ticker, ..
580 } => &instrument_ticker.instrument_name,
581 Self::SlimEnvelope {
582 instrument_ticker, ..
583 } => &instrument_ticker.instrument_name,
584 Self::Ticker(ticker) => &ticker.instrument_name,
585 }
586 }
587
588 #[must_use]
590 pub fn best_ask_price(&self) -> Decimal {
591 match self {
592 Self::Envelope {
593 instrument_ticker, ..
594 } => instrument_ticker.best_ask_price,
595 Self::SlimEnvelope {
596 instrument_ticker, ..
597 } => instrument_ticker.best_ask_price,
598 Self::Ticker(ticker) => ticker.best_ask_price,
599 }
600 }
601
602 #[must_use]
604 pub fn best_bid_price(&self) -> Decimal {
605 match self {
606 Self::Envelope {
607 instrument_ticker, ..
608 } => instrument_ticker.best_bid_price,
609 Self::SlimEnvelope {
610 instrument_ticker, ..
611 } => instrument_ticker.best_bid_price,
612 Self::Ticker(ticker) => ticker.best_bid_price,
613 }
614 }
615
616 #[must_use]
618 pub fn best_ask_amount(&self) -> Decimal {
619 match self {
620 Self::Envelope {
621 instrument_ticker, ..
622 } => instrument_ticker.best_ask_amount,
623 Self::SlimEnvelope {
624 instrument_ticker, ..
625 } => instrument_ticker.best_ask_amount,
626 Self::Ticker(ticker) => ticker.best_ask_amount,
627 }
628 }
629
630 #[must_use]
632 pub fn best_bid_amount(&self) -> Decimal {
633 match self {
634 Self::Envelope {
635 instrument_ticker, ..
636 } => instrument_ticker.best_bid_amount,
637 Self::SlimEnvelope {
638 instrument_ticker, ..
639 } => instrument_ticker.best_bid_amount,
640 Self::Ticker(ticker) => ticker.best_bid_amount,
641 }
642 }
643
644 #[must_use]
646 pub fn mark_price(&self) -> Decimal {
647 match self {
648 Self::Envelope {
649 instrument_ticker, ..
650 } => instrument_ticker.mark_price,
651 Self::SlimEnvelope {
652 instrument_ticker, ..
653 } => instrument_ticker.mark_price,
654 Self::Ticker(ticker) => ticker.mark_price,
655 }
656 }
657
658 #[must_use]
660 pub fn index_price(&self) -> Decimal {
661 match self {
662 Self::Envelope {
663 instrument_ticker, ..
664 } => instrument_ticker.index_price,
665 Self::SlimEnvelope {
666 instrument_ticker, ..
667 } => instrument_ticker.index_price,
668 Self::Ticker(ticker) => ticker.index_price,
669 }
670 }
671
672 #[must_use]
674 pub fn funding_rate(&self) -> Option<Decimal> {
675 match self {
676 Self::Envelope {
677 instrument_ticker, ..
678 } => instrument_ticker
679 .perp_details
680 .as_ref()
681 .map(|perp| perp.funding_rate),
682 Self::SlimEnvelope {
683 instrument_ticker, ..
684 } => instrument_ticker.funding_rate,
685 Self::Ticker(ticker) => ticker.perp_details.as_ref().map(|perp| perp.funding_rate),
686 }
687 }
688
689 #[must_use]
691 pub fn option_pricing(&self) -> Option<&DeriveOptionPricing> {
692 match self {
693 Self::Envelope {
694 instrument_ticker, ..
695 } => instrument_ticker.option_pricing.as_ref(),
696 Self::SlimEnvelope {
697 instrument_ticker, ..
698 } => instrument_ticker.option_pricing.as_ref(),
699 Self::Ticker(ticker) => ticker.option_pricing.as_ref(),
700 }
701 }
702
703 #[must_use]
705 pub fn stats(&self) -> Option<&DeriveAggregateTradingStats> {
706 match self {
707 Self::Envelope {
708 instrument_ticker, ..
709 } => instrument_ticker.stats.as_ref(),
710 Self::SlimEnvelope {
711 instrument_ticker, ..
712 } => instrument_ticker.stats.as_ref(),
713 Self::Ticker(ticker) => ticker.stats.as_ref(),
714 }
715 }
716
717 pub fn apply_channel_context(&mut self, channel: &str) -> Result<(), String> {
723 let Self::SlimEnvelope {
724 instrument_ticker, ..
725 } = self
726 else {
727 return Ok(());
728 };
729
730 if !instrument_ticker.instrument_name.is_empty() {
731 return Ok(());
732 }
733
734 let instrument_name = ticker_instrument_name_from_channel(channel)
735 .ok_or_else(|| format!("invalid Derive ticker channel `{channel}`"))?;
736 instrument_ticker.instrument_name = Ustr::from(instrument_name);
737 Ok(())
738 }
739
740 pub fn instrument_id(&self) -> anyhow::Result<InstrumentId> {
746 format_instrument_id(self.instrument_name())
747 }
748}
749
750#[derive(Debug, Clone)]
752pub struct DeriveTickerMsg {
753 pub channel: Ustr,
755 pub data: DeriveTickerData,
757}
758
759#[derive(Debug, Clone)]
761pub enum DerivePublicWsData {
762 Orderbook(DeriveOrderbookMsg),
764 Trades(DeriveTradesMsg),
766 Ticker(Box<DeriveTickerMsg>),
768}
769
770#[derive(Debug, Clone)]
773pub enum DeriveWsFrame {
774 Response {
776 id: u64,
778 result: Option<Value>,
780 error: Option<JsonRpcError>,
782 },
783 Subscription(WsSubscriptionPayload),
785 UncorrelatedError(JsonRpcError),
787 Unknown(Value),
790}
791
792#[derive(Debug)]
793pub(super) enum ParsedFrame<R> {
794 Response {
795 id: u64,
796 result: Option<R>,
797 error: Option<JsonRpcError>,
798 },
799 Subscription(WsSubscriptionPayload),
800 UncorrelatedError(JsonRpcError),
801 Unknown(Value),
802}
803
804#[derive(Debug, Deserialize)]
812#[serde(bound(deserialize = "R: Deserialize<'de>"))]
813struct InboundFrame<R> {
814 #[serde(default)]
815 id: Option<u64>,
816 #[serde(default)]
817 method: Option<Ustr>,
818 #[serde(default)]
819 result: Option<R>,
820 #[serde(default)]
821 error: Option<JsonRpcError>,
822 #[serde(default)]
823 params: Option<Box<RawValue>>,
824}
825
826impl DeriveWsFrame {
827 pub fn parse(text: &str) -> serde_json::Result<Self> {
836 match parse_frame(text)? {
837 ParsedFrame::Response { id, result, error } => Ok(Self::Response { id, result, error }),
838 ParsedFrame::Subscription(payload) => Ok(Self::Subscription(payload)),
839 ParsedFrame::UncorrelatedError(error) => Ok(Self::UncorrelatedError(error)),
840 ParsedFrame::Unknown(value) => Ok(Self::Unknown(value)),
841 }
842 }
843}
844
845impl ParsedFrame<Box<RawValue>> {
846 pub(super) fn parse(text: &str) -> serde_json::Result<Self> {
847 parse_frame(text)
848 }
849}
850
851fn parse_frame<R: serde::de::DeserializeOwned>(text: &str) -> serde_json::Result<ParsedFrame<R>> {
852 let frame: InboundFrame<R> = serde_json::from_str(text)?;
853
854 if let Some(id) = frame.id {
855 return Ok(ParsedFrame::Response {
856 id,
857 result: frame.result,
858 error: frame.error,
859 });
860 }
861
862 if frame
863 .method
864 .as_ref()
865 .is_some_and(|method| method.as_str() == "subscription")
866 && let Some(params) = frame.params
867 {
868 let payload: WsSubscriptionPayload = serde_json::from_str(params.get())?;
869 return Ok(ParsedFrame::Subscription(payload));
870 }
871
872 if let Some(error) = frame.error {
873 return Ok(ParsedFrame::UncorrelatedError(error));
874 }
875
876 Ok(ParsedFrame::Unknown(serde_json::from_str(text)?))
880}
881
882#[must_use]
888pub fn ticker_channel(instrument_name: &str, interval: &str) -> String {
889 format!("ticker_slim.{instrument_name}.{interval}")
890}
891
892fn ticker_instrument_name_from_channel(channel: &str) -> Option<&str> {
893 let rest = channel
894 .strip_prefix("ticker_slim.")
895 .or_else(|| channel.strip_prefix("ticker."))?;
896 let (instrument_name, _) = rest.rsplit_once('.')?;
897 (!instrument_name.is_empty()).then_some(instrument_name)
898}
899
900#[must_use]
902pub fn orderbook_channel(instrument_name: &str, group: &str, depth: &str) -> String {
903 format!("orderbook.{instrument_name}.{group}.{depth}")
904}
905
906#[must_use]
908pub fn trades_channel(instrument_type: &str, currency: &str) -> String {
909 format!("trades.{instrument_type}.{currency}")
910}
911
912#[must_use]
914pub fn orders_channel(subaccount_id: u64) -> String {
915 format!("{subaccount_id}.orders")
916}
917
918#[must_use]
920pub fn private_trades_channel(subaccount_id: u64) -> String {
921 format!("{subaccount_id}.trades")
922}
923
924#[must_use]
926pub fn balances_channel(subaccount_id: u64) -> String {
927 format!("{subaccount_id}.balances")
928}
929
930pub mod methods {
938 pub const PUBLIC_LOGIN: &str = "public/login";
940 pub const PUBLIC_SUBSCRIBE: &str = "subscribe";
942 pub const PUBLIC_UNSUBSCRIBE: &str = "unsubscribe";
944 pub const PRIVATE_ORDER: &str = "private/order";
946 pub const PRIVATE_TRIGGER_ORDER: &str = PRIVATE_ORDER;
949 pub const PRIVATE_CANCEL: &str = "private/cancel";
951 pub const PRIVATE_CANCEL_BY_INSTRUMENT: &str = "private/cancel_by_instrument";
954 pub const PRIVATE_CANCEL_TRIGGER_ORDER: &str = "private/cancel_trigger_order";
957 pub const PRIVATE_CANCEL_BY_LABEL: &str = "private/cancel_by_label";
960 pub const PRIVATE_GET_TRIGGER_ORDERS: &str = "private/get_trigger_orders";
963 pub const PRIVATE_CANCEL_ALL: &str = "private/cancel_all";
966 pub const PRIVATE_REPLACE: &str = "private/replace";
969}
970
971#[cfg(test)]
972mod tests {
973 use rstest::rstest;
974 use serde_json::json;
975
976 use super::*;
977 use crate::http::models::JsonRpcRequest;
978
979 #[rstest]
980 fn test_ticker_channel_joins_with_dots() {
981 assert_eq!(
982 ticker_channel("ETH-PERP", "1000"),
983 "ticker_slim.ETH-PERP.1000",
984 );
985 assert_eq!(
986 ticker_channel("BTC-20260627-100000-C", "100"),
987 "ticker_slim.BTC-20260627-100000-C.100",
988 );
989 }
990
991 #[rstest]
992 fn test_orderbook_channel_joins_with_dots() {
993 assert_eq!(
994 orderbook_channel("ETH-PERP", "1", "10"),
995 "orderbook.ETH-PERP.1.10",
996 );
997 }
998
999 #[rstest]
1000 fn test_trades_channel_joins_with_dots() {
1001 assert_eq!(trades_channel("perp", "ETH"), "trades.perp.ETH");
1002 }
1003
1004 #[rstest]
1005 #[case(0_u64, "0.orders", "0.trades", "0.balances")]
1006 #[case(1_u64, "1.orders", "1.trades", "1.balances")]
1007 #[case(30769_u64, "30769.orders", "30769.trades", "30769.balances")]
1008 fn test_private_channel_formatters_emit_subaccount_prefix(
1009 #[case] subaccount: u64,
1010 #[case] expected_orders: &str,
1011 #[case] expected_trades: &str,
1012 #[case] expected_balances: &str,
1013 ) {
1014 assert_eq!(orders_channel(subaccount), expected_orders);
1015 assert_eq!(private_trades_channel(subaccount), expected_trades);
1016 assert_eq!(balances_channel(subaccount), expected_balances);
1017 }
1018
1019 #[rstest]
1020 fn test_ws_channel_formats_known_topics() {
1021 assert_eq!(
1022 DeriveWsChannel::ticker_slim("ETH-PERP", DeriveTickerInterval::Ms1000).to_string(),
1023 "ticker_slim.ETH-PERP.1000",
1024 );
1025 assert_eq!(
1026 DeriveWsChannel::orderbook(
1027 "ETH-PERP",
1028 DeriveOrderbookGroup::G1,
1029 DeriveOrderbookDepth::D10,
1030 )
1031 .to_string(),
1032 "orderbook.ETH-PERP.1.10",
1033 );
1034 assert_eq!(
1035 DeriveWsChannel::trades(DeriveInstrumentType::Perp, "ETH").to_string(),
1036 "trades.perp.ETH",
1037 );
1038 assert_eq!(DeriveWsChannel::orders(30769).to_string(), "30769.orders");
1039 assert_eq!(
1040 DeriveWsChannel::private_trades(30769).to_string(),
1041 "30769.trades",
1042 );
1043 assert_eq!(
1044 DeriveWsChannel::balances(30769).to_string(),
1045 "30769.balances",
1046 );
1047 }
1048
1049 #[rstest]
1050 fn test_ws_channel_deserializes_known_and_raw_topics() {
1051 let ticker: DeriveWsChannel =
1052 serde_json::from_value(json!("ticker_slim.ETH.TEST-PERP.1000")).unwrap();
1053 let orderbook: DeriveWsChannel =
1054 serde_json::from_value(json!("orderbook.ETH.TEST-PERP.1.10")).unwrap();
1055 let private_trades: DeriveWsChannel =
1056 serde_json::from_value(json!("30769.trades")).unwrap();
1057 let raw: DeriveWsChannel = serde_json::from_value(json!("trades.ETH-USDC")).unwrap();
1058
1059 assert_eq!(
1060 ticker,
1061 DeriveWsChannel::ticker_slim("ETH.TEST-PERP", "1000"),
1062 );
1063 assert_eq!(
1064 orderbook,
1065 DeriveWsChannel::orderbook("ETH.TEST-PERP", "1", "10"),
1066 );
1067 assert_eq!(private_trades, DeriveWsChannel::private_trades(30769));
1068 assert_eq!(raw, DeriveWsChannel::Raw("trades.ETH-USDC".to_string()));
1069 }
1070
1071 #[rstest]
1072 fn test_ws_channel_uses_typed_known_topic_fields() {
1073 let ticker = DeriveWsChannel::from_topic("ticker_slim.ETH-PERP.1000");
1074 let orderbook = DeriveWsChannel::from_topic("orderbook.ETH-PERP.1.10");
1075 let trades = DeriveWsChannel::from_topic("trades.perp.ETH");
1076
1077 match ticker {
1078 DeriveWsChannel::TickerSlim {
1079 instrument_name,
1080 interval,
1081 } => {
1082 assert_eq!(instrument_name, "ETH-PERP");
1083 assert_eq!(interval, DeriveTickerInterval::Ms1000);
1084 }
1085 other => panic!("expected TickerSlim, was {other:?}"),
1086 }
1087
1088 match orderbook {
1089 DeriveWsChannel::Orderbook {
1090 instrument_name,
1091 group,
1092 depth,
1093 } => {
1094 assert_eq!(instrument_name, "ETH-PERP");
1095 assert_eq!(group, DeriveOrderbookGroup::G1);
1096 assert_eq!(depth, DeriveOrderbookDepth::D10);
1097 }
1098 other => panic!("expected Orderbook, was {other:?}"),
1099 }
1100
1101 match trades {
1102 DeriveWsChannel::Trades {
1103 instrument_type,
1104 currency,
1105 } => {
1106 assert_eq!(instrument_type, DeriveInstrumentType::Perp);
1107 assert_eq!(currency, "ETH");
1108 }
1109 other => panic!("expected Trades, was {other:?}"),
1110 }
1111 }
1112
1113 #[rstest]
1114 fn test_subscribe_request_serializes_as_jsonrpc_envelope() {
1115 let req = JsonRpcRequest::new(
1116 1,
1117 methods::PUBLIC_SUBSCRIBE,
1118 WsSubscribeParams {
1119 channels: vec![DeriveWsChannel::ticker_slim("ETH-PERP", "1000")],
1120 },
1121 );
1122
1123 let wire = serde_json::to_value(&req).unwrap();
1124 assert_eq!(wire["jsonrpc"], "2.0");
1125 assert_eq!(wire["id"], 1);
1126 assert_eq!(wire["method"], "subscribe");
1127 assert_eq!(wire["params"]["channels"][0], "ticker_slim.ETH-PERP.1000");
1128 }
1129
1130 #[rstest]
1131 fn test_ws_request_params_preserve_jsonrpc_wire_output() {
1132 let login = JsonRpcRequest::new(
1133 1,
1134 methods::PUBLIC_LOGIN,
1135 WsRequestParams::from(WsLoginParams {
1136 wallet: "0xWALLET".to_string(),
1137 timestamp: "1700000000000".to_string(),
1138 signature: SecretString::from("0xSIG"),
1139 }),
1140 );
1141
1142 let subscribe = JsonRpcRequest::new(
1143 2,
1144 methods::PUBLIC_SUBSCRIBE,
1145 WsRequestParams::from(WsSubscribeParams {
1146 channels: vec![DeriveWsChannel::ticker_slim("ETH-PERP", "1000")],
1147 }),
1148 );
1149
1150 let unsubscribe = JsonRpcRequest::new(
1151 3,
1152 methods::PUBLIC_UNSUBSCRIBE,
1153 WsRequestParams::from(WsUnsubscribeParams {
1154 channels: vec![DeriveWsChannel::ticker_slim("ETH-PERP", "1000")],
1155 }),
1156 );
1157
1158 assert_eq!(
1159 serde_json::to_string(&login).unwrap(),
1160 concat!(
1161 r#"{"jsonrpc":"2.0","id":1,"method":"public/login","params":{"#,
1162 r#""wallet":"0xWALLET","timestamp":"1700000000000","signature":"0xSIG"}}"#,
1163 ),
1164 );
1165 assert_eq!(
1166 serde_json::to_string(&subscribe).unwrap(),
1167 concat!(
1168 r#"{"jsonrpc":"2.0","id":2,"method":"subscribe","params":{"#,
1169 r#""channels":["ticker_slim.ETH-PERP.1000"]}}"#,
1170 ),
1171 );
1172 assert_eq!(
1173 serde_json::to_string(&unsubscribe).unwrap(),
1174 concat!(
1175 r#"{"jsonrpc":"2.0","id":3,"method":"unsubscribe","params":{"#,
1176 r#""channels":["ticker_slim.ETH-PERP.1000"]}}"#,
1177 ),
1178 );
1179 }
1180
1181 #[rstest]
1182 fn test_ws_response_results_decode_known_shapes() {
1183 let login: WsLoginResult = serde_json::from_value(json!([30769, 42])).unwrap();
1184 let subscribe: WsSubscribeResult = serde_json::from_value(json!({
1185 "current_subscriptions": ["ticker_slim.ETH-PERP.1000"],
1186 "status": {"ticker_slim.ETH-PERP.1000": "ok"},
1187 }))
1188 .unwrap();
1189 let unsubscribe: WsUnsubscribeResult = serde_json::from_value(
1190 json!({"status": {"ticker_slim.ETH-PERP.1000": "ok"}, "remaining_subscriptions": []}),
1191 )
1192 .unwrap();
1193
1194 assert_eq!(login, vec![30769, 42]);
1195 assert_eq!(
1196 subscribe.channels,
1197 vec![DeriveWsChannel::ticker_slim("ETH-PERP", "1000")],
1198 );
1199 assert_eq!(
1200 unsubscribe.status[&DeriveWsChannel::ticker_slim("ETH-PERP", "1000")],
1201 "ok"
1202 );
1203 assert!(unsubscribe.remaining_subscriptions.is_empty());
1204 }
1205
1206 #[rstest]
1207 fn test_subscribe_result_decodes_recorded_venue_ack() {
1208 let ack: Value =
1209 serde_json::from_str(include_str!("../../test_data/spot/ws_subscribe_ack.json"))
1210 .unwrap();
1211 let result: WsSubscribeResult =
1212 serde_json::from_value(ack["result"].clone()).expect("subscribe ack parses");
1213
1214 assert!(
1215 result
1216 .channels
1217 .contains(&DeriveWsChannel::ticker_slim("ETH-USDC", "1000"))
1218 );
1219 assert_eq!(
1220 result
1221 .status
1222 .get(&DeriveWsChannel::orderbook("ETH-USDC", "1", "10"))
1223 .map(|status| status.as_str()),
1224 Some("ok"),
1225 );
1226 }
1227
1228 #[rstest]
1229 fn test_login_params_round_trip() {
1230 let params = WsLoginParams {
1231 wallet: "0xWALLET".to_string(),
1232 timestamp: "1700000000000".to_string(),
1233 signature: SecretString::from("0xDEAD"),
1234 };
1235
1236 let debug = format!("{params:?}");
1237 let wire = serde_json::to_value(¶ms).unwrap();
1238
1239 assert_eq!(wire["wallet"], "0xWALLET");
1240 assert_eq!(wire["timestamp"], "1700000000000");
1241 assert_eq!(wire["signature"], "0xDEAD");
1242 assert!(debug.contains(REDACTED));
1243 assert!(!debug.contains("0xDEAD"));
1244 let back: WsLoginParams = serde_json::from_value(wire).unwrap();
1245 assert_eq!(back, params);
1246 }
1247
1248 #[rstest]
1249 fn test_parse_response_with_result() {
1250 let text = json!({"id": 42, "result": {"ok": true}}).to_string();
1251 let frame = DeriveWsFrame::parse(&text).unwrap();
1252 match frame {
1253 DeriveWsFrame::Response { id, result, error } => {
1254 assert_eq!(id, 42);
1255 assert_eq!(result, Some(json!({"ok": true})));
1256 assert!(error.is_none());
1257 }
1258 other => panic!("expected Response, was {other:?}"),
1259 }
1260 }
1261
1262 #[rstest]
1263 fn test_parse_response_with_error_payload() {
1264 let text = json!({
1265 "id": 7,
1266 "error": {"code": -32602, "message": "bad params", "data": {"field": "channels"}},
1267 })
1268 .to_string();
1269 let frame = DeriveWsFrame::parse(&text).unwrap();
1270 match frame {
1271 DeriveWsFrame::Response { id, result, error } => {
1272 assert_eq!(id, 7);
1273 assert!(result.is_none());
1274 let err = error.expect("error present");
1275 assert_eq!(err.code, -32602);
1276 assert_eq!(err.data, Some(json!({"field": "channels"})));
1277 }
1278 other => panic!("expected Response, was {other:?}"),
1279 }
1280 }
1281
1282 #[rstest]
1283 fn test_parse_subscription_notification() {
1284 let text = json!({
1285 "method": "subscription",
1286 "params": {
1287 "channel": "ticker.ETH-PERP.1000",
1288 "data": {"instrument_name": "ETH-PERP", "mark_price": "3500.5"},
1289 },
1290 })
1291 .to_string();
1292 let frame = DeriveWsFrame::parse(&text).unwrap();
1293 match frame {
1294 DeriveWsFrame::Subscription(payload) => {
1295 assert_eq!(payload.channel, "ticker.ETH-PERP.1000");
1296 let data: Value = serde_json::from_str(payload.data.get()).unwrap();
1297 assert_eq!(data["mark_price"], "3500.5");
1298 }
1299 other => panic!("expected Subscription, was {other:?}"),
1300 }
1301 }
1302
1303 #[rstest]
1304 fn test_parse_unknown_frame_preserves_value() {
1305 let text = json!({"hello": "world"}).to_string();
1306 let frame = DeriveWsFrame::parse(&text).unwrap();
1307 match frame {
1308 DeriveWsFrame::Unknown(v) => {
1309 assert_eq!(v["hello"], "world");
1310 assert!(v.get("id").is_none(), "unknown frame must not carry id");
1311 let method = v.get("method").and_then(Value::as_str);
1312 assert_ne!(method, Some("subscription"));
1313 }
1314 other => panic!("expected Unknown, was {other:?}"),
1315 }
1316 }
1317
1318 #[rstest]
1319 fn test_parse_null_id_error_preserves_structured_error() {
1320 let text = json!({
1321 "id": null,
1322 "error": {
1323 "code": -32700,
1324 "message": "Parse error",
1325 "data": "invalid JSON",
1326 },
1327 })
1328 .to_string();
1329 let frame = DeriveWsFrame::parse(&text).unwrap();
1330
1331 match frame {
1332 DeriveWsFrame::UncorrelatedError(error) => {
1333 assert_eq!(error.code, -32700);
1334 assert_eq!(error.message, "Parse error");
1335 assert_eq!(error.data, Some(json!("invalid JSON")));
1336 }
1337 other => panic!("expected UncorrelatedError, was {other:?}"),
1338 }
1339 }
1340
1341 #[rstest]
1342 fn test_parse_non_subscription_notification_with_params_is_unknown() {
1343 let text = json!({"method": "heartbeat", "params": {"interval": 30}}).to_string();
1347 let frame = DeriveWsFrame::parse(&text).unwrap();
1348 match frame {
1349 DeriveWsFrame::Unknown(v) => {
1350 assert_eq!(v["method"], "heartbeat");
1351 assert_eq!(v["params"]["interval"], 30);
1352 }
1353 other => panic!("expected Unknown, was {other:?}"),
1354 }
1355 }
1356
1357 #[rstest]
1358 fn test_parse_response_with_both_result_and_error_prefers_error() {
1359 let text = json!({
1361 "id": 11,
1362 "result": {"should_not_win": true},
1363 "error": {"code": -1, "message": "wins"},
1364 })
1365 .to_string();
1366 let frame = DeriveWsFrame::parse(&text).unwrap();
1367 match frame {
1368 DeriveWsFrame::Response { id, result, error } => {
1369 assert_eq!(id, 11);
1370 assert!(result.is_some(), "result is preserved on the frame");
1371 let err = error.expect("error present");
1372 assert_eq!(err.code, -1);
1373 assert_eq!(err.message, "wins");
1374 }
1375 other => panic!("expected Response, was {other:?}"),
1376 }
1377 }
1378
1379 #[rstest]
1380 fn test_parse_rejects_malformed_json() {
1381 let err = DeriveWsFrame::parse("{not json").expect_err("must reject");
1382 assert_eq!(err.classify(), serde_json::error::Category::Syntax);
1384 }
1385
1386 #[rstest]
1387 fn test_unsubscribe_params_round_trip() {
1388 let params = WsUnsubscribeParams {
1389 channels: vec![
1390 DeriveWsChannel::ticker_slim("ETH-PERP", "1000"),
1391 DeriveWsChannel::ticker_slim("BTC-PERP", "100"),
1392 ],
1393 };
1394
1395 let wire = serde_json::to_value(¶ms).unwrap();
1396 assert_eq!(wire["channels"][0], "ticker_slim.ETH-PERP.1000");
1397 assert_eq!(wire["channels"][1], "ticker_slim.BTC-PERP.100");
1398 let back: WsUnsubscribeParams = serde_json::from_value(wire).unwrap();
1399 assert_eq!(back, params);
1400 }
1401
1402 #[rstest]
1403 fn test_private_orders_subscription_data_decodes_single_and_array_payloads() {
1404 let order: Value = serde_json::from_str(include_str!(
1405 "../../test_data/perps/http_order_eth_partially_filled.json"
1406 ))
1407 .unwrap();
1408 let single: DeriveOrdersSubscriptionData = serde_json::from_value(order.clone()).unwrap();
1409 let array: DeriveOrdersSubscriptionData =
1410 serde_json::from_value(json!([order, {"not": "an order"}])).unwrap();
1411
1412 assert_eq!(single.orders.len(), 1);
1413 assert_eq!(single.orders[0].order_id, "abc-123");
1414 assert_eq!(array.orders.len(), 1);
1415 assert_eq!(array.orders[0].order_id, "abc-123");
1416 }
1417
1418 #[rstest]
1419 fn test_private_trades_subscription_data_decodes_single_and_array_payloads() {
1420 let trade: Value = serde_json::from_str(include_str!(
1421 "../../test_data/perps/http_private_trade_eth.json"
1422 ))
1423 .unwrap();
1424 let single: DeriveTradesSubscriptionData = serde_json::from_value(trade.clone()).unwrap();
1425 let array: DeriveTradesSubscriptionData =
1426 serde_json::from_value(json!([trade, {"not": "a trade"}])).unwrap();
1427
1428 assert_eq!(single.trades.len(), 1);
1429 assert_eq!(single.trades[0].trade_id, "trade-xyz");
1430 assert_eq!(array.trades.len(), 1);
1431 assert_eq!(array.trades[0].trade_id, "trade-xyz");
1432 }
1433}