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