1use std::collections::HashMap;
34
35#[cfg(test)]
36use nautilus_core::string::secret::REDACTED;
37use nautilus_core::{
38 Params,
39 serialization::{
40 deserialize_decimal_token,
41 deserialize_optional_decimal_token as deserialize_optional_decimal,
42 serialize_decimal_as_str,
43 },
44 string::secret::SecretString,
45};
46use rust_decimal::Decimal;
47use serde::{Deserialize, Serialize};
48use serde_json::{Value, value::RawValue};
49use ustr::Ustr;
50
51use crate::common::{
52 enums::{
53 DeriveAssetType, DeriveInstrumentType, DeriveLiquidityRole, DeriveOptionKind,
54 DeriveOrderCancelReason, DeriveOrderSide, DeriveOrderStatus, DeriveOrderType,
55 DeriveTimeInForce, DeriveTriggerPriceType, DeriveTriggerType,
56 },
57 parse::{deserialize_decimal, deserialize_salvaged_vec, salvage_rows},
58};
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct JsonRpcRequest<P> {
65 pub jsonrpc: &'static str,
67 pub id: u64,
69 pub method: &'static str,
71 pub params: P,
73}
74
75impl<P> JsonRpcRequest<P> {
76 #[must_use]
78 pub fn new(id: u64, method: &'static str, params: P) -> Self {
79 Self {
80 jsonrpc: "2.0",
81 id,
82 method,
83 params,
84 }
85 }
86}
87
88#[derive(Debug, Clone, Deserialize)]
91#[serde(bound(deserialize = "R: Deserialize<'de>"))]
92pub struct JsonRpcResponse<R> {
93 #[serde(default, deserialize_with = "deserialize_optional_jsonrpc_id")]
96 pub id: Option<u64>,
97 #[serde(default, deserialize_with = "deserialize_present_jsonrpc_result")]
99 pub result: Option<R>,
100 #[serde(default)]
102 pub error: Option<JsonRpcError>,
103}
104
105fn deserialize_present_jsonrpc_result<'de, D, R>(deserializer: D) -> Result<Option<R>, D::Error>
106where
107 D: serde::Deserializer<'de>,
108 R: Deserialize<'de>,
109{
110 R::deserialize(deserializer).map(Some)
111}
112
113fn deserialize_optional_jsonrpc_id<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
114where
115 D: serde::Deserializer<'de>,
116{
117 let value = Option::<Value>::deserialize(deserializer)?;
118 match value {
119 None | Some(Value::Null) => Ok(None),
120 Some(Value::Number(number)) => number
121 .as_u64()
122 .map(Some)
123 .ok_or_else(|| serde::de::Error::custom("JSON-RPC id must be an unsigned integer")),
124 Some(Value::String(value)) => Ok(value.parse::<u64>().ok()),
125 Some(other) => Err(serde::de::Error::custom(format!(
126 "JSON-RPC id must be an unsigned integer or string, was {other}"
127 ))),
128 }
129}
130
131#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
133pub struct JsonRpcError {
134 pub code: i64,
136 pub message: String,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub data: Option<Value>,
141}
142
143#[derive(Clone, Debug, Serialize, Deserialize)]
146pub struct DeriveOptionPublicDetails {
147 pub expiry: i64,
149 pub index: Ustr,
151 pub option_type: DeriveOptionKind,
153 #[serde(default, deserialize_with = "deserialize_optional_decimal")]
155 pub settlement_price: Option<Decimal>,
156 #[serde(deserialize_with = "deserialize_decimal")]
158 pub strike: Decimal,
159}
160
161#[derive(Clone, Debug, Serialize, Deserialize)]
164pub struct DerivePerpPublicDetails {
165 #[serde(deserialize_with = "deserialize_decimal")]
167 pub aggregate_funding: Decimal,
168 #[serde(deserialize_with = "deserialize_decimal")]
170 pub funding_rate: Decimal,
171 pub index: Ustr,
173 #[serde(deserialize_with = "deserialize_decimal")]
175 pub max_rate_per_hour: Decimal,
176 #[serde(deserialize_with = "deserialize_decimal")]
178 pub min_rate_per_hour: Decimal,
179 #[serde(default, deserialize_with = "deserialize_optional_decimal")]
181 pub static_interest_rate: Option<Decimal>,
182}
183
184#[derive(Clone, Debug, Serialize, Deserialize)]
186#[serde(remote = "Self")]
187pub struct DeriveInstrument {
188 #[serde(deserialize_with = "deserialize_decimal")]
190 pub amount_step: Decimal,
191 pub base_asset_address: Ustr,
193 pub base_asset_sub_id: Ustr,
195 pub base_currency: Ustr,
197 #[serde(deserialize_with = "deserialize_decimal")]
199 pub base_fee: Decimal,
200 pub instrument_name: Ustr,
202 pub instrument_type: DeriveInstrumentType,
204 pub is_active: bool,
206 #[serde(deserialize_with = "deserialize_decimal")]
208 pub maker_fee_rate: Decimal,
209 #[serde(default, deserialize_with = "deserialize_optional_decimal")]
211 pub mark_price_fee_rate_cap: Option<Decimal>,
212 #[serde(deserialize_with = "deserialize_decimal")]
214 pub maximum_amount: Decimal,
215 #[serde(deserialize_with = "deserialize_decimal")]
218 pub minimum_amount: Decimal,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub option_details: Option<DeriveOptionPublicDetails>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub perp_details: Option<DerivePerpPublicDetails>,
225 pub quote_currency: Ustr,
227 pub scheduled_activation: i64,
229 pub scheduled_deactivation: i64,
231 #[serde(deserialize_with = "deserialize_decimal")]
233 pub taker_fee_rate: Decimal,
234 #[serde(deserialize_with = "deserialize_decimal")]
236 pub tick_size: Decimal,
237 #[serde(skip)]
239 pub raw: Option<Params>,
240}
241
242impl<'de> Deserialize<'de> for DeriveInstrument {
243 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
244 let raw = Box::<RawValue>::deserialize(deserializer)?;
245 let mut typed = serde_json::Deserializer::from_str(raw.get());
246
247 let mut instrument = Self::deserialize(&mut typed).map_err(serde::de::Error::custom)?;
249 instrument.raw = Some(serde_json::from_str(raw.get()).map_err(serde::de::Error::custom)?);
250 Ok(instrument)
251 }
252}
253
254impl Serialize for DeriveInstrument {
255 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
256 Self::serialize(self, serializer)
257 }
258}
259
260#[derive(Clone, Debug, Serialize, Deserialize)]
262pub struct DeriveAggregateTradingStats {
263 #[serde(alias = "c", deserialize_with = "deserialize_decimal")]
265 pub contract_volume: Decimal,
266 #[serde(alias = "h", deserialize_with = "deserialize_decimal")]
268 pub high: Decimal,
269 #[serde(alias = "l", deserialize_with = "deserialize_decimal")]
271 pub low: Decimal,
272 #[serde(alias = "n", deserialize_with = "deserialize_decimal")]
274 pub num_trades: Decimal,
275 #[serde(alias = "oi", deserialize_with = "deserialize_decimal")]
277 pub open_interest: Decimal,
278 #[serde(alias = "p", deserialize_with = "deserialize_decimal")]
280 pub percent_change: Decimal,
281 #[serde(alias = "pr", deserialize_with = "deserialize_decimal")]
283 pub usd_change: Decimal,
284}
285
286#[derive(Clone, Debug, Serialize, Deserialize)]
288pub struct DeriveOptionPricing {
289 #[serde(alias = "ai", deserialize_with = "deserialize_decimal")]
291 pub ask_iv: Decimal,
292 #[serde(alias = "bi", deserialize_with = "deserialize_decimal")]
294 pub bid_iv: Decimal,
295 #[serde(alias = "d", deserialize_with = "deserialize_decimal")]
297 pub delta: Decimal,
298 #[serde(alias = "f", deserialize_with = "deserialize_decimal")]
300 pub forward_price: Decimal,
301 #[serde(alias = "g", deserialize_with = "deserialize_decimal")]
303 pub gamma: Decimal,
304 #[serde(alias = "i", deserialize_with = "deserialize_decimal")]
306 pub iv: Decimal,
307 #[serde(alias = "m", deserialize_with = "deserialize_decimal")]
309 pub mark_price: Decimal,
310 #[serde(alias = "r", deserialize_with = "deserialize_decimal")]
312 pub rho: Decimal,
313 #[serde(alias = "t", deserialize_with = "deserialize_decimal")]
315 pub theta: Decimal,
316 #[serde(alias = "v", deserialize_with = "deserialize_decimal")]
318 pub vega: Decimal,
319}
320
321#[derive(Clone, Debug, Serialize, Deserialize)]
323pub struct DeriveTickerSnapshot {
324 #[serde(default)]
326 pub instrument_name: Ustr,
327 #[serde(
329 rename = "A",
330 alias = "best_ask_amount",
331 deserialize_with = "deserialize_decimal"
332 )]
333 pub best_ask_amount: Decimal,
334 #[serde(
336 rename = "a",
337 alias = "best_ask_price",
338 deserialize_with = "deserialize_decimal"
339 )]
340 pub best_ask_price: Decimal,
341 #[serde(
343 rename = "B",
344 alias = "best_bid_amount",
345 deserialize_with = "deserialize_decimal"
346 )]
347 pub best_bid_amount: Decimal,
348 #[serde(
350 rename = "b",
351 alias = "best_bid_price",
352 deserialize_with = "deserialize_decimal"
353 )]
354 pub best_bid_price: Decimal,
355 #[serde(
357 rename = "f",
358 alias = "funding_rate",
359 default,
360 deserialize_with = "deserialize_optional_decimal"
361 )]
362 pub funding_rate: Option<Decimal>,
363 #[serde(
365 rename = "I",
366 alias = "index_price",
367 deserialize_with = "deserialize_decimal"
368 )]
369 pub index_price: Decimal,
370 #[serde(
372 rename = "M",
373 alias = "mark_price",
374 deserialize_with = "deserialize_decimal"
375 )]
376 pub mark_price: Decimal,
377 #[serde(
379 rename = "maxp",
380 alias = "max_price",
381 deserialize_with = "deserialize_decimal"
382 )]
383 pub max_price: Decimal,
384 #[serde(
386 rename = "minp",
387 alias = "min_price",
388 deserialize_with = "deserialize_decimal"
389 )]
390 pub min_price: Decimal,
391 #[serde(default)]
393 pub option_pricing: Option<DeriveOptionPricing>,
394 #[serde(default)]
396 pub stats: Option<DeriveAggregateTradingStats>,
397 #[serde(rename = "t", alias = "timestamp")]
399 pub timestamp: i64,
400}
401
402#[derive(Clone, Debug, Serialize, Deserialize)]
404pub struct DeriveTickersResult {
405 pub tickers: HashMap<String, DeriveTickerSnapshot>,
407}
408
409#[derive(Clone, Debug, Serialize, Deserialize)]
412pub struct DeriveTicker {
413 #[serde(deserialize_with = "deserialize_decimal")]
415 pub amount_step: Decimal,
416 pub base_asset_address: Ustr,
418 pub base_asset_sub_id: Ustr,
420 pub base_currency: Ustr,
422 #[serde(deserialize_with = "deserialize_decimal")]
424 pub base_fee: Decimal,
425 #[serde(deserialize_with = "deserialize_decimal")]
427 pub best_ask_amount: Decimal,
428 #[serde(deserialize_with = "deserialize_decimal")]
430 pub best_ask_price: Decimal,
431 #[serde(deserialize_with = "deserialize_decimal")]
433 pub best_bid_amount: Decimal,
434 #[serde(deserialize_with = "deserialize_decimal")]
436 pub best_bid_price: Decimal,
437 #[serde(deserialize_with = "deserialize_decimal")]
439 pub index_price: Decimal,
440 pub instrument_name: Ustr,
442 pub instrument_type: DeriveInstrumentType,
444 pub is_active: bool,
446 #[serde(deserialize_with = "deserialize_decimal")]
448 pub maker_fee_rate: Decimal,
449 #[serde(deserialize_with = "deserialize_decimal")]
451 pub mark_price: Decimal,
452 #[serde(default, deserialize_with = "deserialize_optional_decimal")]
454 pub mark_price_fee_rate_cap: Option<Decimal>,
455 #[serde(deserialize_with = "deserialize_decimal")]
457 pub max_price: Decimal,
458 #[serde(deserialize_with = "deserialize_decimal")]
460 pub maximum_amount: Decimal,
461 #[serde(deserialize_with = "deserialize_decimal")]
463 pub min_price: Decimal,
464 #[serde(deserialize_with = "deserialize_decimal")]
466 pub minimum_amount: Decimal,
467 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub option_details: Option<DeriveOptionPublicDetails>,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
472 pub option_pricing: Option<DeriveOptionPricing>,
473 #[serde(default, skip_serializing_if = "Option::is_none")]
475 pub perp_details: Option<DerivePerpPublicDetails>,
476 pub quote_currency: Ustr,
478 pub scheduled_activation: i64,
480 pub scheduled_deactivation: i64,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
484 pub stats: Option<DeriveAggregateTradingStats>,
485 #[serde(deserialize_with = "deserialize_decimal")]
487 pub taker_fee_rate: Decimal,
488 #[serde(deserialize_with = "deserialize_decimal")]
490 pub tick_size: Decimal,
491 pub timestamp: i64,
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct DeriveOrder {
499 #[serde(deserialize_with = "deserialize_decimal_token")]
501 pub amount: Decimal,
502 #[serde(deserialize_with = "deserialize_decimal_token")]
504 pub average_price: Decimal,
505 #[serde(default = "cancel_reason_empty")]
507 pub cancel_reason: DeriveOrderCancelReason,
508 pub creation_timestamp: i64,
510 pub direction: DeriveOrderSide,
512 #[serde(deserialize_with = "deserialize_decimal_token")]
514 pub filled_amount: Decimal,
515 pub instrument_name: Ustr,
517 pub is_transfer: bool,
519 #[serde(default)]
521 pub label: Ustr,
522 pub last_update_timestamp: i64,
524 #[serde(deserialize_with = "deserialize_decimal_token")]
526 pub limit_price: Decimal,
527 #[serde(deserialize_with = "deserialize_decimal_token")]
529 pub max_fee: Decimal,
530 pub mmp: bool,
532 #[serde(
534 serialize_with = "crate::signing::nonce::serialize_nonce",
535 deserialize_with = "crate::signing::nonce::deserialize_nonce"
536 )]
537 pub nonce: u64,
538 #[serde(deserialize_with = "deserialize_decimal_token")]
540 pub order_fee: Decimal,
541 pub order_id: String,
543 pub order_status: DeriveOrderStatus,
545 pub order_type: DeriveOrderType,
547 #[serde(default, skip_serializing_if = "Option::is_none")]
549 pub quote_id: Option<String>,
550 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub replaced_order_id: Option<String>,
553 pub signature: SecretString,
555 pub signature_expiry_sec: i64,
557 pub signer: Ustr,
559 pub subaccount_id: i64,
561 pub time_in_force: DeriveTimeInForce,
563 #[serde(default, deserialize_with = "deserialize_optional_decimal")]
565 pub trigger_price: Option<Decimal>,
566 #[serde(default, skip_serializing_if = "Option::is_none")]
568 pub trigger_price_type: Option<DeriveTriggerPriceType>,
569 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub trigger_reject_message: Option<String>,
572 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub trigger_type: Option<DeriveTriggerType>,
575}
576
577#[derive(Clone, Debug, Serialize, Deserialize)]
579pub struct DeriveOrderResult {
580 pub order: DeriveOrder,
582 #[serde(default, deserialize_with = "deserialize_salvaged_vec")]
584 pub trades: Vec<DeriveTrade>,
585}
586
587#[derive(Clone, Debug, Serialize, Deserialize)]
589pub struct DeriveReplaceResult {
590 #[serde(default, skip_serializing_if = "Option::is_none")]
592 pub order: Option<DeriveOrder>,
593 #[serde(default, skip_serializing_if = "Option::is_none")]
595 pub cancelled_order: Option<DeriveOrder>,
596 #[serde(default, skip_serializing_if = "Option::is_none")]
598 pub create_order_error: Option<JsonRpcError>,
599}
600
601#[derive(Clone, Debug)]
603pub enum DeriveReplaceOutcome {
604 Replaced(DeriveOrder),
606 Canceled {
608 cancelled_order: DeriveOrder,
610 create_order_error: JsonRpcError,
612 },
613}
614
615impl DeriveReplaceResult {
616 pub(crate) fn into_outcome(
623 self,
624 expected_cancel_order_id: &str,
625 expected_replacement_label: &str,
626 expected_subaccount_id: u64,
627 expected_instrument: &str,
628 ) -> Result<DeriveReplaceOutcome, String> {
629 let validate_cancelled_order = |order: &DeriveOrder| {
630 if order.order_id != expected_cancel_order_id {
631 return Err(format!(
632 "private/replace cancelled order {} did not match requested order {expected_cancel_order_id}",
633 order.order_id,
634 ));
635 }
636
637 if order.order_status != DeriveOrderStatus::Cancelled {
638 return Err(format!(
639 "private/replace cancellation record for {expected_cancel_order_id} had status {}",
640 order.order_status,
641 ));
642 }
643
644 validate_replace_order_scope(
645 order,
646 expected_replacement_label,
647 expected_subaccount_id,
648 expected_instrument,
649 )
650 };
651
652 match (self.order, self.cancelled_order, self.create_order_error) {
653 (Some(order), cancelled_order, None) => {
654 if order.order_id == expected_cancel_order_id {
655 return Err(format!(
656 "private/replace returned the cancelled order {expected_cancel_order_id} as its replacement",
657 ));
658 }
659
660 if !matches!(
661 order.order_status,
662 DeriveOrderStatus::Open | DeriveOrderStatus::Filled
663 ) {
664 return Err(format!(
665 "private/replace replacement {} had status {}",
666 order.order_id, order.order_status,
667 ));
668 }
669
670 validate_replace_order_scope(
671 &order,
672 expected_replacement_label,
673 expected_subaccount_id,
674 expected_instrument,
675 )?;
676
677 if let Some(cancelled_order) = cancelled_order.as_ref() {
678 validate_cancelled_order(cancelled_order)?;
679 }
680
681 Ok(DeriveReplaceOutcome::Replaced(order))
682 }
683 (None, Some(cancelled_order), Some(create_order_error)) => {
684 validate_cancelled_order(&cancelled_order)?;
685 Ok(DeriveReplaceOutcome::Canceled {
686 cancelled_order,
687 create_order_error,
688 })
689 }
690 _ => Err("private/replace returned an inconsistent result".to_string()),
691 }
692 }
693}
694
695fn validate_replace_order_scope(
696 order: &DeriveOrder,
697 expected_replacement_label: &str,
698 expected_subaccount_id: u64,
699 expected_instrument: &str,
700) -> Result<(), String> {
701 if u64::try_from(order.subaccount_id).ok() != Some(expected_subaccount_id)
702 || order.instrument_name != expected_instrument
703 {
704 return Err(format!(
705 "private/replace order {} did not match requested account and instrument",
706 order.order_id
707 ));
708 }
709
710 if order.label != expected_replacement_label {
711 return Err(format!(
712 "private/replace order {} had label {}, expected {expected_replacement_label}",
713 order.order_id, order.label
714 ));
715 }
716
717 Ok(())
718}
719
720#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
722pub struct DeriveCancelByLabelResult {
723 pub cancelled_orders: i64,
725}
726
727pub type DeriveCancelByInstrumentResult = DeriveCancelByLabelResult;
729
730#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
732pub struct DeriveEmptyResult {}
733
734impl<'de> Deserialize<'de> for DeriveEmptyResult {
735 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
736 where
737 D: serde::Deserializer<'de>,
738 {
739 match Value::deserialize(deserializer)? {
740 Value::Null | Value::Object(_) => Ok(Self {}),
741 Value::String(value) if value == "ok" => Ok(Self {}),
742 other => Err(serde::de::Error::custom(format!(
743 "empty Derive result must be an object, null, or \"ok\", was {other}"
744 ))),
745 }
746 }
747}
748
749#[derive(Clone, Debug, Serialize, Deserialize)]
752pub struct DerivePosition {
753 #[serde(deserialize_with = "deserialize_decimal_token")]
755 pub amount: Decimal,
756 #[serde(deserialize_with = "deserialize_decimal_token")]
758 pub average_price: Decimal,
759 pub creation_timestamp: i64,
761 #[serde(deserialize_with = "deserialize_decimal_token")]
763 pub cumulative_funding: Decimal,
764 #[serde(deserialize_with = "deserialize_decimal_token")]
766 pub delta: Decimal,
767 #[serde(deserialize_with = "deserialize_decimal_token")]
769 pub gamma: Decimal,
770 #[serde(deserialize_with = "deserialize_decimal_token")]
772 pub index_price: Decimal,
773 #[serde(deserialize_with = "deserialize_decimal_token")]
775 pub initial_margin: Decimal,
776 pub instrument_name: Ustr,
778 pub instrument_type: DeriveInstrumentType,
780 #[serde(default, deserialize_with = "deserialize_optional_decimal")]
782 pub leverage: Option<Decimal>,
783 #[serde(default, deserialize_with = "deserialize_optional_decimal")]
785 pub liquidation_price: Option<Decimal>,
786 #[serde(deserialize_with = "deserialize_decimal_token")]
788 pub maintenance_margin: Decimal,
789 #[serde(deserialize_with = "deserialize_decimal_token")]
791 pub mark_price: Decimal,
792 #[serde(deserialize_with = "deserialize_decimal_token")]
794 pub mark_value: Decimal,
795 #[serde(deserialize_with = "deserialize_decimal_token")]
797 pub net_settlements: Decimal,
798 #[serde(deserialize_with = "deserialize_decimal_token")]
800 pub open_orders_margin: Decimal,
801 #[serde(deserialize_with = "deserialize_decimal_token")]
803 pub pending_funding: Decimal,
804 #[serde(deserialize_with = "deserialize_decimal_token")]
806 pub realized_pnl: Decimal,
807 #[serde(deserialize_with = "deserialize_decimal_token")]
809 pub theta: Decimal,
810 #[serde(deserialize_with = "deserialize_decimal_token")]
812 pub unrealized_pnl: Decimal,
813 #[serde(deserialize_with = "deserialize_decimal_token")]
815 pub vega: Decimal,
816}
817
818#[derive(Clone, Debug, Serialize, Deserialize)]
820pub struct DeriveCollateral {
821 #[serde(
823 deserialize_with = "deserialize_decimal_token",
824 serialize_with = "serialize_decimal_as_str"
825 )]
826 pub amount: Decimal,
827 pub asset_name: Ustr,
829 pub asset_type: DeriveAssetType,
831 #[serde(
833 deserialize_with = "deserialize_decimal_token",
834 serialize_with = "serialize_decimal_as_str"
835 )]
836 pub cumulative_interest: Decimal,
837 pub currency: Ustr,
839 #[serde(
841 deserialize_with = "deserialize_decimal_token",
842 serialize_with = "serialize_decimal_as_str"
843 )]
844 pub initial_margin: Decimal,
845 #[serde(
847 deserialize_with = "deserialize_decimal_token",
848 serialize_with = "serialize_decimal_as_str"
849 )]
850 pub maintenance_margin: Decimal,
851 #[serde(
853 deserialize_with = "deserialize_decimal_token",
854 serialize_with = "serialize_decimal_as_str"
855 )]
856 pub mark_price: Decimal,
857 #[serde(
859 deserialize_with = "deserialize_decimal_token",
860 serialize_with = "serialize_decimal_as_str"
861 )]
862 pub mark_value: Decimal,
863 #[serde(
865 deserialize_with = "deserialize_decimal_token",
866 serialize_with = "serialize_decimal_as_str"
867 )]
868 pub pending_interest: Decimal,
869}
870
871#[derive(Clone, Debug, Serialize, Deserialize)]
873pub struct DeriveSubaccount {
874 pub collaterals: Vec<DeriveCollateral>,
876 #[serde(deserialize_with = "deserialize_decimal_token")]
878 pub collaterals_initial_margin: Decimal,
879 #[serde(deserialize_with = "deserialize_decimal_token")]
881 pub collaterals_maintenance_margin: Decimal,
882 #[serde(deserialize_with = "deserialize_decimal_token")]
884 pub collaterals_value: Decimal,
885 pub currency: Vec<Ustr>,
887 pub failed_to_fetch: bool,
889 #[serde(deserialize_with = "deserialize_decimal_token")]
892 pub initial_margin: Decimal,
893 pub is_under_liquidation: bool,
895 #[serde(default, skip_serializing_if = "Option::is_none")]
897 pub label: Option<String>,
898 #[serde(deserialize_with = "deserialize_decimal_token")]
900 pub maintenance_margin: Decimal,
901 pub manager_id: u32,
903 pub margin_type: String,
905 #[serde(deserialize_with = "deserialize_decimal_token")]
907 pub mm_credits: Decimal,
908 pub open_orders: Vec<DeriveOrder>,
910 #[serde(deserialize_with = "deserialize_decimal_token")]
912 pub open_orders_margin: Decimal,
913 pub positions: Vec<DerivePosition>,
915 #[serde(deserialize_with = "deserialize_decimal_token")]
917 pub positions_initial_margin: Decimal,
918 #[serde(deserialize_with = "deserialize_decimal_token")]
920 pub positions_maintenance_margin: Decimal,
921 #[serde(deserialize_with = "deserialize_decimal_token")]
923 pub positions_value: Decimal,
924 #[serde(deserialize_with = "deserialize_decimal_token")]
926 pub projected_margin_change: Decimal,
927 pub risk_universe_id: u32,
929 pub subaccount_id: i64,
931 #[serde(deserialize_with = "deserialize_decimal_token")]
933 pub subaccount_value: Decimal,
934 pub vault_deposit_holds: Vec<DeriveVaultDepositHold>,
936}
937
938#[derive(Debug, Clone, Serialize, Deserialize)]
940pub struct DeriveVaultDepositHold {
941 #[serde(
943 deserialize_with = "deserialize_decimal_token",
944 serialize_with = "serialize_decimal_as_str"
945 )]
946 pub amount: Decimal,
947 pub asset_name: Ustr,
949 pub currency: Ustr,
951 pub vault_id: u64,
953}
954
955#[derive(Clone, Debug, Serialize, Deserialize)]
958pub struct DeriveTrade {
959 pub direction: DeriveOrderSide,
961 #[serde(deserialize_with = "deserialize_decimal")]
963 pub index_price: Decimal,
964 pub instrument_name: Ustr,
966 pub is_transfer: bool,
968 #[serde(default)]
970 pub label: Ustr,
971 pub liquidity_role: DeriveLiquidityRole,
973 #[serde(deserialize_with = "deserialize_decimal")]
975 pub mark_price: Decimal,
976 pub order_id: String,
978 #[serde(default, skip_serializing_if = "Option::is_none")]
980 pub quote_id: Option<String>,
981 #[serde(deserialize_with = "deserialize_decimal")]
983 pub realized_pnl: Decimal,
984 pub subaccount_id: i64,
986 pub timestamp: i64,
988 #[serde(deserialize_with = "deserialize_decimal_token")]
990 pub trade_amount: Decimal,
991 #[serde(deserialize_with = "deserialize_decimal_token")]
993 pub trade_fee: Decimal,
994 pub trade_id: String,
996 #[serde(deserialize_with = "deserialize_decimal_token")]
998 pub trade_price: Decimal,
999 #[serde(default, skip_serializing_if = "Option::is_none")]
1001 pub tx_hash: Option<String>,
1002 #[serde(default, skip_serializing_if = "Option::is_none")]
1004 pub batch_status: Option<String>,
1005 #[serde(default, skip_serializing_if = "Option::is_none")]
1007 pub op_uuid: Option<String>,
1008 #[serde(default, skip_serializing_if = "Option::is_none")]
1010 pub wallet: Option<Ustr>,
1011}
1012
1013#[derive(Clone, Debug, Serialize, Deserialize)]
1019pub struct DerivePublicTrade {
1020 pub direction: DeriveOrderSide,
1022 #[serde(deserialize_with = "deserialize_decimal")]
1024 pub index_price: Decimal,
1025 pub instrument_name: Ustr,
1027 #[serde(default, skip_serializing_if = "Option::is_none")]
1031 pub liquidity_role: Option<DeriveLiquidityRole>,
1032 #[serde(deserialize_with = "deserialize_decimal")]
1034 pub mark_price: Decimal,
1035 #[serde(default, skip_serializing_if = "Option::is_none")]
1037 pub quote_id: Option<String>,
1038 #[serde(default, skip_serializing_if = "Option::is_none")]
1040 pub rfq_id: Option<String>,
1041 #[serde(default, deserialize_with = "deserialize_optional_decimal")]
1043 pub realized_pnl: Option<Decimal>,
1044 #[serde(default, skip_serializing_if = "Option::is_none")]
1046 pub subaccount_id: Option<i64>,
1047 pub timestamp: i64,
1049 #[serde(deserialize_with = "deserialize_decimal_token")]
1051 pub trade_amount: Decimal,
1052 #[serde(default, deserialize_with = "deserialize_optional_decimal")]
1054 pub trade_fee: Option<Decimal>,
1055 pub trade_id: String,
1057 #[serde(deserialize_with = "deserialize_decimal_token")]
1059 pub trade_price: Decimal,
1060 #[serde(default, skip_serializing_if = "Option::is_none")]
1062 pub tx_hash: Option<String>,
1063 #[serde(default, skip_serializing_if = "Option::is_none")]
1065 pub batch_status: Option<String>,
1066 #[serde(default, skip_serializing_if = "Option::is_none")]
1068 pub op_uuid: Option<String>,
1069 #[serde(default, skip_serializing_if = "Option::is_none")]
1071 pub wallet: Option<Ustr>,
1072}
1073
1074#[derive(Clone, Debug, Serialize, Deserialize)]
1076pub struct DerivePaginationInfo {
1077 pub count: i64,
1079 pub num_pages: i64,
1081}
1082
1083#[derive(Debug, Clone, Deserialize)]
1084pub(crate) struct DeriveInstrumentsResult {
1085 pub(crate) instruments: Vec<DeriveInstrument>,
1086 pub(crate) pagination: DerivePaginationInfo,
1087}
1088
1089#[derive(Clone, Debug, Serialize, Deserialize)]
1091pub struct DeriveOrdersResult {
1092 pub orders: Vec<DeriveOrder>,
1095 pub pagination: DerivePaginationInfo,
1097 pub subaccount_id: i64,
1099}
1100
1101#[derive(Clone, Debug, Serialize, Deserialize)]
1103pub struct DeriveOpenOrdersResult {
1104 pub orders: Vec<DeriveOrder>,
1107 pub subaccount_id: i64,
1109}
1110
1111#[derive(Clone, Debug, Serialize)]
1113pub struct DeriveTradesResult {
1114 pub trades: Vec<DeriveTrade>,
1116 pub pagination: DerivePaginationInfo,
1118 pub subaccount_id: i64,
1120 #[serde(skip)]
1121 pub(crate) records_complete: bool,
1122}
1123
1124impl<'de> Deserialize<'de> for DeriveTradesResult {
1125 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1126 #[derive(Deserialize)]
1127 struct TradePage {
1128 trades: Vec<Box<RawValue>>,
1129 pagination: DerivePaginationInfo,
1130 subaccount_id: i64,
1131 }
1132 #[derive(Deserialize)]
1133 struct TradeFee {
1134 #[serde(rename = "trade_fee", deserialize_with = "deserialize_decimal_token")]
1135 _trade_fee: Decimal,
1136 }
1137 let page = TradePage::deserialize(deserializer)?;
1138 for trade in &page.trades {
1139 if !trade.get().starts_with('{') {
1140 return Err(serde::de::Error::custom(
1141 "private trade row must be an object",
1142 ));
1143 }
1144
1145 let _: TradeFee =
1146 serde_json::from_str(trade.get()).map_err(serde::de::Error::custom)?;
1147 }
1148
1149 let count = page.trades.len();
1150 let trades: Vec<DeriveTrade> = salvage_rows(page.trades);
1151 Ok(Self {
1152 records_complete: trades.len() == count,
1153 trades,
1154 pagination: page.pagination,
1155 subaccount_id: page.subaccount_id,
1156 })
1157 }
1158}
1159
1160#[derive(Clone, Debug, Serialize, Deserialize)]
1162pub struct DerivePublicTradesResult {
1163 pub trades: Vec<DerivePublicTrade>,
1165 pub pagination: DerivePaginationInfo,
1167}
1168
1169#[derive(Clone, Debug, Serialize, Deserialize)]
1176pub struct DerivePublicCandle {
1177 #[serde(deserialize_with = "deserialize_decimal")]
1179 pub open_price: Decimal,
1180 #[serde(deserialize_with = "deserialize_decimal")]
1182 pub high_price: Decimal,
1183 #[serde(deserialize_with = "deserialize_decimal")]
1185 pub low_price: Decimal,
1186 #[serde(deserialize_with = "deserialize_decimal")]
1188 pub close_price: Decimal,
1189 #[serde(deserialize_with = "deserialize_decimal")]
1191 pub volume_usd: Decimal,
1192 #[serde(deserialize_with = "deserialize_decimal")]
1194 pub volume_contracts: Decimal,
1195 pub timestamp: i64,
1197 pub timestamp_bucket: i64,
1199}
1200
1201#[derive(Clone, Debug, Serialize, Deserialize)]
1203pub struct DerivePublicFundingRate {
1204 #[serde(deserialize_with = "deserialize_decimal")]
1206 pub funding_rate: Decimal,
1207 pub timestamp: i64,
1209}
1210
1211#[derive(Clone, Debug, Serialize, Deserialize)]
1213pub struct DerivePublicFundingRateHistoryResult {
1214 pub funding_rate_history: Vec<DerivePublicFundingRate>,
1216}
1217
1218#[derive(Clone, Debug, Serialize, Deserialize)]
1220pub struct DerivePositionsResult {
1221 pub positions: Vec<DerivePosition>,
1226 pub subaccount_id: i64,
1228}
1229
1230fn cancel_reason_empty() -> DeriveOrderCancelReason {
1231 DeriveOrderCancelReason::Empty
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236 use std::path::PathBuf;
1237
1238 use nautilus_core::UnixNanos;
1239 use nautilus_model::identifiers::AccountId;
1240 use rstest::rstest;
1241 use serde_json::{Value, json};
1242
1243 use super::*;
1244
1245 fn data_path() -> PathBuf {
1246 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data")
1247 }
1248
1249 fn load_json(filename: &str) -> Value {
1250 let content = std::fs::read_to_string(data_path().join(filename))
1251 .unwrap_or_else(|_| panic!("failed to read {filename}"));
1252 serde_json::from_str(&content).expect("invalid json")
1253 }
1254
1255 #[rstest]
1256 fn test_request_serializes_with_jsonrpc_version_tag() {
1257 let req = JsonRpcRequest::new(7, "public/get_instruments", json!({"currency": "ETH"}));
1258 let wire = serde_json::to_value(&req).unwrap();
1259 assert_eq!(wire["jsonrpc"], "2.0");
1260 assert_eq!(wire["id"], 7);
1261 assert_eq!(wire["method"], "public/get_instruments");
1262 assert_eq!(wire["params"]["currency"], "ETH");
1263 }
1264
1265 #[rstest]
1266 fn test_response_decodes_success_envelope() {
1267 let body = json!({"id": 1, "result": {"instruments": []}});
1268 let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
1269 assert_eq!(resp.id, Some(1));
1270 assert!(resp.error.is_none());
1271 assert!(resp.result.is_some());
1272 }
1273
1274 #[rstest]
1275 fn test_response_decodes_error_envelope() {
1276 let body = json!({
1277 "id": 9,
1278 "error": {"code": -32600, "message": "Invalid Request"}
1279 });
1280 let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
1281 assert_eq!(resp.id, Some(9));
1282 assert!(resp.result.is_none());
1283 let err = resp.error.expect("error present");
1284 assert_eq!(err.code, -32600);
1285 assert_eq!(err.message, "Invalid Request");
1286 assert!(err.data.is_none());
1287 }
1288
1289 #[rstest]
1290 fn test_response_decodes_error_envelope_with_data_field() {
1291 let body = json!({
1292 "id": 9,
1293 "error": {
1294 "code": -32602,
1295 "message": "Invalid params",
1296 "data": {"field": "currency"},
1297 }
1298 });
1299 let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
1300 let err = resp.error.expect("error present");
1301 assert_eq!(err.data, Some(json!({"field": "currency"})));
1302 }
1303
1304 #[rstest]
1305 fn test_response_tolerates_missing_id() {
1306 let body = json!({"result": {"ok": true}});
1307 let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
1308 assert!(resp.id.is_none());
1309 assert!(resp.result.is_some());
1310 }
1311
1312 #[rstest]
1313 fn test_response_tolerates_string_id() {
1314 let body = json!({"id": "e3c970c6-94aa-420c-b6db-d0f585a7fde9", "result": {"ok": true}});
1315 let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
1316 assert!(resp.id.is_none());
1317 assert!(resp.result.is_some());
1318 }
1319
1320 #[rstest]
1321 fn test_response_decodes_numeric_string_id() {
1322 let body = json!({"id": "42", "result": {"ok": true}});
1323 let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
1324 assert_eq!(resp.id, Some(42));
1325 assert!(resp.result.is_some());
1326 }
1327
1328 #[rstest]
1329 fn test_instrument_decodes_perp_with_perp_details() {
1330 let body = load_json("perps/instrument_eth.json");
1331 let instrument: DeriveInstrument = serde_json::from_value(body).unwrap();
1332 assert_eq!(instrument.instrument_name, "ETH-PERP");
1333 assert_eq!(instrument.instrument_type, DeriveInstrumentType::Perp);
1334 assert!(instrument.option_details.is_none());
1335 let perp = instrument.perp_details.expect("perp details present");
1336 assert_eq!(perp.index, "ETH-USD");
1337 }
1338
1339 #[rstest]
1340 fn test_instrument_decodes_option_with_option_details() {
1341 let mut body = load_json("options/instrument_eth.json");
1342 body["scheduled_activation"] = json!(0);
1343 let instrument: DeriveInstrument = serde_json::from_value(body).unwrap();
1344 let option = instrument.option_details.expect("option details present");
1345 assert_eq!(option.option_type, DeriveOptionKind::Call);
1346 assert_eq!(option.strike.to_string(), "3500");
1347 assert!(option.settlement_price.is_none());
1348 }
1349
1350 #[rstest]
1351 #[case(1_695_836_058_725_001_000)]
1352 #[case(u64::MAX)]
1353 fn test_order_nonce_string_round_trip(#[case] nonce: u64) {
1354 let mut body = load_json("perps/http_order_eth_partially_filled.json");
1355 body["nonce"] = json!(nonce.to_string());
1356 let order: DeriveOrder = serde_json::from_value(body).unwrap();
1357 let encoded = serde_json::to_value(&order).unwrap();
1358 let decoded: DeriveOrder = serde_json::from_value(encoded.clone()).unwrap();
1359 assert_eq!(order.nonce, nonce);
1360 assert_eq!(encoded["nonce"], nonce.to_string());
1361 assert_eq!(serde_json::to_value(decoded).unwrap(), encoded);
1362 }
1363
1364 #[rstest]
1365 #[case(json!(""))]
1366 #[case(json!("-1"))]
1367 #[case(json!("18446744073709551616"))]
1368 #[case(json!(1_695_836_058_725_001_000_u64))]
1369 fn test_order_nonce_rejects_invalid_wire_values(#[case] nonce: Value) {
1370 let mut body = load_json("perps/http_order_eth_partially_filled.json");
1371 body["nonce"] = nonce;
1372 assert!(serde_json::from_value::<DeriveOrder>(body).is_err());
1373 }
1374
1375 #[rstest]
1376 #[case(true, false)]
1377 #[case(false, true)]
1378 #[case(true, true)]
1379 fn test_order_defaults_optional_label_and_cancel_reason(
1380 #[case] omit_label: bool,
1381 #[case] omit_reason: bool,
1382 ) {
1383 let mut body = load_json("perps/http_order_eth_partially_filled.json");
1384 if omit_label {
1385 body["label"] = json!("");
1386 }
1387
1388 let expected: DeriveOrder = serde_json::from_value(body.clone()).unwrap();
1389 let fields = body.as_object_mut().unwrap();
1390 if omit_label {
1391 fields.remove("label");
1392 }
1393
1394 if omit_reason {
1395 fields.remove("cancel_reason");
1396 }
1397
1398 let order: DeriveOrder = serde_json::from_value(body).unwrap();
1399 assert_eq!(
1400 serde_json::to_value(order).unwrap(),
1401 serde_json::to_value(expected).unwrap()
1402 );
1403 }
1404
1405 #[rstest]
1406 fn test_order_decodes_partially_filled_market_order() {
1407 let body = load_json("perps/http_order_eth_partially_filled.json");
1411 let order: DeriveOrder = serde_json::from_value(body).unwrap();
1412 let debug = format!("{order:?}");
1413 assert_eq!(order.amount.to_string(), "2.0");
1414 assert_eq!(order.filled_amount.to_string(), "1.5");
1415 assert_eq!(order.average_price.to_string(), "3500.25");
1416 assert_eq!(order.order_status, DeriveOrderStatus::Filled);
1417 assert_eq!(order.cancel_reason, DeriveOrderCancelReason::Empty);
1418 assert_eq!(order.direction, DeriveOrderSide::Buy);
1419 assert_eq!(order.time_in_force, DeriveTimeInForce::Ioc);
1420 assert_eq!(order.order_type, DeriveOrderType::Market);
1421 assert_eq!(order.instrument_name, "ETH-PERP");
1422 assert_eq!(order.label, "alpha-strategy");
1423 assert_eq!(order.signer, "0xsigner");
1424 assert_eq!(order.order_id, "abc-123");
1425 assert_eq!(order.subaccount_id, 42);
1426 assert_eq!(order.signature_expiry_sec, 1_700_001_000);
1427 assert!(!order.mmp);
1428 assert!(!order.is_transfer);
1429 assert!(order.quote_id.is_none());
1430 assert!(order.replaced_order_id.is_none());
1431 assert!(debug.contains(REDACTED));
1432 assert!(!debug.contains("0xdead"));
1433 }
1434
1435 #[rstest]
1436 fn test_replace_result_decodes_canceled_without_replacement() {
1437 let mut cancelled_order = load_json("perps/http_order_eth_partially_filled.json");
1438 cancelled_order["order_id"] = json!("old-order");
1439 cancelled_order["order_status"] = json!("cancelled");
1440 cancelled_order["label"] = json!("replacement-label");
1441 let result: DeriveReplaceResult = serde_json::from_value(json!({
1442 "order": null,
1443 "cancelled_order": cancelled_order,
1444 "create_order_error": {
1445 "code": 10001,
1446 "message": "insufficient margin",
1447 },
1448 }))
1449 .unwrap();
1450
1451 let outcome = result
1452 .into_outcome("old-order", "replacement-label", 42, "ETH-PERP")
1453 .unwrap();
1454
1455 let DeriveReplaceOutcome::Canceled {
1456 cancelled_order,
1457 create_order_error,
1458 } = outcome
1459 else {
1460 panic!("expected canceled outcome");
1461 };
1462
1463 assert_eq!(cancelled_order.order_id, "old-order");
1464 assert_eq!(cancelled_order.order_status, DeriveOrderStatus::Cancelled);
1465 assert_eq!(create_order_error.code, 10001);
1466 assert_eq!(create_order_error.message, "insufficient margin");
1467 assert!(create_order_error.data.is_none());
1468 }
1469
1470 #[rstest]
1471 fn test_replace_result_rejects_non_cancelled_partial_record() {
1472 let mut cancelled_order = load_json("perps/http_order_eth_partially_filled.json");
1473 cancelled_order["order_id"] = json!("old-order");
1474 cancelled_order["order_status"] = json!("open");
1475 let result: DeriveReplaceResult = serde_json::from_value(json!({
1476 "order": null,
1477 "cancelled_order": cancelled_order,
1478 "create_order_error": {
1479 "code": 10001,
1480 "message": "insufficient margin",
1481 },
1482 }))
1483 .unwrap();
1484
1485 let error = result
1486 .into_outcome("old-order", "replacement-label", 42, "ETH-PERP")
1487 .unwrap_err();
1488 assert!(error.contains("had status open"));
1489 }
1490
1491 #[rstest]
1492 fn test_replace_result_rejects_mismatched_replacement_label() {
1493 let mut replacement_order = load_json("perps/http_order_eth_partially_filled.json");
1494 replacement_order["order_id"] = json!("new-order");
1495 replacement_order["order_status"] = json!("open");
1496 replacement_order["label"] = json!("wrong-label");
1497 let result: DeriveReplaceResult = serde_json::from_value(json!({
1498 "order": replacement_order,
1499 "cancelled_order": null,
1500 "create_order_error": null,
1501 }))
1502 .unwrap();
1503
1504 let error = result
1505 .into_outcome("old-order", "expected-label", 42, "ETH-PERP")
1506 .unwrap_err();
1507 assert!(error.contains("had label wrong-label, expected expected-label"));
1508 }
1509
1510 #[rstest]
1511 fn test_position_decodes_perp_with_optional_leverage() {
1512 let body = load_json("perps/http_position_eth.json");
1514 let position: DerivePosition = serde_json::from_value(body).unwrap();
1515 assert_eq!(position.instrument_type, DeriveInstrumentType::Perp);
1516 assert_eq!(position.instrument_name, "ETH-PERP");
1517 assert_eq!(position.amount.to_string(), "-2");
1518 assert_eq!(position.delta.to_string(), "-2");
1519 assert_eq!(position.gamma.to_string(), "0.1");
1520 assert_eq!(position.theta.to_string(), "-0.3");
1521 assert_eq!(position.vega.to_string(), "0.5");
1522 assert_eq!(position.unrealized_pnl.to_string(), "8");
1523 assert_eq!(position.mark_value.to_string(), "-7008");
1524 assert_eq!(
1525 position.leverage.as_ref().map(ToString::to_string),
1526 Some("5.0".into()),
1527 );
1528 assert_eq!(
1529 position.liquidation_price.as_ref().map(ToString::to_string),
1530 Some("4200".into()),
1531 );
1532 }
1533
1534 #[rstest]
1535 fn test_subaccount_decodes_with_collaterals_and_open_orders() {
1536 let body = load_json("common/http_subaccount_usdc.json");
1537 let subaccount: DeriveSubaccount = serde_json::from_value(body).unwrap();
1538 assert_eq!(subaccount.subaccount_id, 42);
1539 assert_eq!(subaccount.margin_type, "SM");
1540 assert_eq!(subaccount.collaterals.len(), 1);
1541 assert_eq!(subaccount.collaterals[0].asset_type, DeriveAssetType::Erc20);
1542 assert!(!subaccount.is_under_liquidation);
1543 }
1544
1545 #[rstest]
1546 #[case("currency")]
1547 #[case("manager_id")]
1548 #[case("risk_universe_id")]
1549 #[case("failed_to_fetch")]
1550 #[case("margin_type")]
1551 #[case("mm_credits")]
1552 #[case("projected_margin_change")]
1553 #[case("vault_deposit_holds")]
1554 #[case("positions_value")]
1555 #[case("positions")]
1556 #[case("open_orders")]
1557 fn test_subaccount_requires_complete_v3_envelope(#[case] field: &str) {
1558 let mut body = load_json("common/http_subaccount_pm2.json");
1559 body.as_object_mut().unwrap().remove(field);
1560
1561 assert!(serde_json::from_value::<DeriveSubaccount>(body).is_err());
1562 }
1563
1564 #[rstest]
1565 #[case(serde_json::Value::Null)]
1566 #[case(json!(""))]
1567 fn test_subaccount_rejects_null_or_empty_required_decimals(#[case] invalid: Value) {
1568 for field in [
1569 "initial_margin",
1570 "maintenance_margin",
1571 "positions_initial_margin",
1572 "positions_maintenance_margin",
1573 "positions_value",
1574 "open_orders_margin",
1575 "mm_credits",
1576 "projected_margin_change",
1577 ] {
1578 let mut body = load_json("common/http_subaccount_pm2.json");
1579 body[field] = invalid.clone();
1580 assert!(
1581 serde_json::from_value::<DeriveSubaccount>(body).is_err(),
1582 "{field}"
1583 );
1584 }
1585
1586 let mut body = load_json("common/http_subaccount_pm2.json");
1587 body["collaterals"][0]["amount"] = invalid;
1588 assert!(serde_json::from_value::<DeriveSubaccount>(body).is_err());
1589 }
1590
1591 #[rstest]
1592 #[case("amount")]
1593 #[case("average_price")]
1594 #[case("filled_amount")]
1595 #[case("limit_price")]
1596 #[case("max_fee")]
1597 #[case("order_fee")]
1598 fn test_subaccount_rejects_empty_or_null_order_decimal(#[case] field: &str) {
1599 for invalid in [Value::Null, json!("")] {
1600 let mut body = load_json("common/http_subaccount_pm2.json");
1601 let mut order = load_json("perps/http_order_eth_partially_filled.json");
1602 order[field] = invalid;
1603 body["open_orders"] = json!([order]);
1604
1605 assert!(
1606 serde_json::from_value::<DeriveSubaccount>(body).is_err(),
1607 "{field}"
1608 );
1609 }
1610 }
1611
1612 #[rstest]
1613 fn test_subaccount_decodes_high_scale_decimal_values() {
1614 let body = load_json("common/http_subaccount_high_scale.json");
1615 let subaccount: DeriveSubaccount = serde_json::from_value(body).unwrap();
1616 let position = &subaccount.positions[0];
1617
1618 assert_eq!(
1619 subaccount.initial_margin.to_string(),
1620 "0.1234567890123456789012345679",
1621 );
1622 assert_eq!(
1623 subaccount.collaterals[0].amount.to_string(),
1624 "0.1234567890123456789012345679",
1625 );
1626 assert_eq!(
1627 position.pending_funding.to_string(),
1628 "0.1234567890123456789012345679",
1629 );
1630 assert_eq!(
1631 position.leverage.as_ref().map(ToString::to_string),
1632 Some("5.1234567890123456789012345679".into()),
1633 );
1634 assert_eq!(
1635 position.liquidation_price.as_ref().map(ToString::to_string),
1636 Some("4200.1234567890123456789012346".into()),
1637 );
1638
1639 let open_order = &subaccount.open_orders[0];
1641 assert_eq!(
1642 open_order.filled_amount.to_string(),
1643 "0.1234567890123456789012345679",
1644 );
1645 assert_eq!(
1646 open_order.max_fee.to_string(),
1647 "0.1234567890123456789012345679",
1648 );
1649 }
1650
1651 #[rstest]
1652 fn test_subaccount_retains_unknown_order_status_for_account_conversion() {
1653 let body = load_json("common/http_subaccount_unknown_variants.json");
1654 let subaccount: DeriveSubaccount = serde_json::from_value(body).unwrap();
1655 assert_eq!(subaccount.open_orders.len(), 2);
1656 assert_eq!(
1657 subaccount.open_orders[1].order_status,
1658 DeriveOrderStatus::Unknown
1659 );
1660 let mut modeled = subaccount.clone();
1661 modeled.open_orders[1].order_status = DeriveOrderStatus::Open;
1662 let expected = crate::http::parse::parse_derive_subaccount_to_balances(&modeled).unwrap();
1663 let actual = crate::http::parse::parse_derive_subaccount_to_balances(&subaccount).unwrap();
1664
1665 assert_eq!(actual, expected);
1666 }
1667
1668 #[rstest]
1669 fn test_subaccount_decodes_populated_vault_deposit_hold() {
1670 let mut body = load_json("common/http_subaccount_pm2.json");
1671 let hold = json!({
1672 "amount": "0.000000000123456789",
1673 "asset_name": "ETH",
1674 "currency": "ETH",
1675 "vault_id": 123,
1676 });
1677 body["vault_deposit_holds"] = json!([hold]);
1678
1679 let subaccount: DeriveSubaccount = serde_json::from_value(body).unwrap();
1680
1681 assert_eq!(
1682 serde_json::to_value(&subaccount.vault_deposit_holds).unwrap(),
1683 json!([hold])
1684 );
1685 assert_eq!(
1686 subaccount.vault_deposit_holds[0].amount.to_string(),
1687 "0.000000000123456789"
1688 );
1689 }
1690
1691 #[rstest]
1692 fn test_public_trade_round_trips() {
1693 let body = load_json("perps/http_public_trade_eth_sell.json");
1694 let trade: DerivePublicTrade = serde_json::from_value(body).unwrap();
1695 assert_eq!(trade.direction, DeriveOrderSide::Sell);
1696 assert_eq!(trade.batch_status.as_deref(), Some("Settled"));
1697 let reserialized = serde_json::to_value(&trade).unwrap();
1698 assert_eq!(reserialized["instrument_name"], "ETH-PERP");
1699 assert_eq!(reserialized["liquidity_role"], "taker");
1700 }
1701
1702 #[rstest]
1703 fn test_orders_result_envelope_decodes() {
1704 let body = json!({
1705 "orders": [],
1706 "pagination": {"count": 0, "num_pages": 0},
1707 "subaccount_id": 42,
1708 });
1709 let result: DeriveOrdersResult = serde_json::from_value(body).unwrap();
1710 assert!(result.orders.is_empty());
1711 assert_eq!(result.subaccount_id, 42);
1712 assert_eq!(result.pagination.count, 0);
1713 }
1714
1715 #[rstest]
1716 fn test_orders_result_decodes_unknown_variant_fields() {
1717 let body = load_json("perps/http_orders_result_eth_unknown_variants.json");
1718 let result: DeriveOrdersResult = serde_json::from_value(body).unwrap();
1719
1720 assert_eq!(result.orders.len(), 2);
1721 assert_eq!(result.orders[0].order_status, DeriveOrderStatus::Open);
1722 let unknowns = &result.orders[1];
1723 assert_eq!(unknowns.order_status, DeriveOrderStatus::Cancelled);
1724 assert_eq!(unknowns.cancel_reason, DeriveOrderCancelReason::Unknown);
1725 assert_eq!(unknowns.order_type, DeriveOrderType::Unknown);
1726 assert_eq!(unknowns.time_in_force, DeriveTimeInForce::Unknown);
1727 assert_eq!(unknowns.trigger_type, Some(DeriveTriggerType::Unknown));
1728 assert_eq!(
1729 unknowns.trigger_price_type,
1730 Some(DeriveTriggerPriceType::Unknown),
1731 );
1732 }
1733
1734 #[rstest]
1735 fn test_orders_result_retains_unknown_status_and_rejects_report_conversion() {
1736 let mut body = load_json("perps/http_orders_result_eth_unknown_variants.json");
1737 body["orders"][0]["order_status"] = json!("queued");
1738 let result: DeriveOrdersResult = serde_json::from_value(body).unwrap();
1739 assert_eq!(result.orders.len(), 2);
1740 assert_eq!(result.orders[0].order_status, DeriveOrderStatus::Unknown);
1741 let error = crate::http::parse::parse_derive_order_to_report(
1742 &result.orders[0],
1743 AccountId::from("DERIVE-001"),
1744 UnixNanos::from(17),
1745 )
1746 .unwrap_err();
1747 assert_eq!(error.to_string(), "unmodeled Derive order status");
1748 }
1749
1750 #[rstest]
1751 fn test_positions_result_fails_on_undecodable_row() {
1752 let mut body = load_json("perps/http_positions_result_eth.json");
1754 body["positions"][0]["amount"] = json!({});
1755
1756 assert!(serde_json::from_value::<DerivePositionsResult>(body).is_err());
1757 }
1758
1759 fn perp_ticker_json() -> Value {
1760 load_json("perps/http_ticker_eth_snapshot.json")
1761 }
1762
1763 #[rstest]
1764 fn test_ticker_decodes_perp_snapshot() {
1765 let ticker: DeriveTicker = serde_json::from_value(perp_ticker_json()).unwrap();
1766 assert_eq!(ticker.instrument_name, "ETH-PERP");
1767 assert_eq!(ticker.instrument_type, DeriveInstrumentType::Perp);
1768 assert_eq!(ticker.mark_price.to_string(), "3500.5");
1769 assert_eq!(ticker.best_bid_price.to_string(), "3499.5");
1770 assert_eq!(ticker.best_ask_price.to_string(), "3501.0");
1771 assert_eq!(ticker.timestamp, 1_700_000_000_000);
1772 assert!(ticker.option_details.is_none());
1773 assert!(ticker.option_pricing.is_none());
1774 let perp = ticker.perp_details.expect("perp details present");
1775 assert_eq!(perp.index, "ETH-USD");
1776 assert_eq!(perp.funding_rate.to_string(), "0.0002");
1777 let stats = ticker
1778 .stats
1779 .as_ref()
1780 .expect("WS ticker fixture includes stats");
1781 assert_eq!(stats.contract_volume.to_string(), "12345.6");
1782 assert_eq!(stats.high.to_string(), "3600");
1783 assert_eq!(stats.num_trades.to_string(), "789");
1784 }
1785
1786 #[rstest]
1787 fn test_ticker_decodes_option_snapshot_with_greeks() {
1788 let body = load_json("options/http_ticker_eth_snapshot.json");
1789 let ticker: DeriveTicker = serde_json::from_value(body).unwrap();
1790 assert_eq!(ticker.instrument_type, DeriveInstrumentType::Option);
1791 assert!(ticker.perp_details.is_none());
1792 let option = ticker.option_details.expect("option details present");
1793 assert_eq!(option.option_type, DeriveOptionKind::Call);
1794 assert_eq!(option.strike.to_string(), "3500");
1795 assert!(option.settlement_price.is_none());
1796 let greeks = ticker.option_pricing.expect("option pricing present");
1797 assert_eq!(greeks.delta.to_string(), "0.55");
1798 assert_eq!(greeks.gamma.to_string(), "0.0008");
1799 assert_eq!(greeks.theta.to_string(), "-2.1");
1800 assert_eq!(greeks.vega.to_string(), "4.5");
1801 assert_eq!(greeks.iv.to_string(), "0.60");
1802 assert_eq!(greeks.forward_price.to_string(), "3505");
1803 }
1804
1805 #[rstest]
1806 fn test_private_trade_decodes_with_order_link() {
1807 let body = load_json("perps/http_private_trade_eth.json");
1811 let trade: DeriveTrade = serde_json::from_value(body).unwrap();
1812 assert_eq!(trade.direction, DeriveOrderSide::Buy);
1813 assert_eq!(trade.liquidity_role, DeriveLiquidityRole::Maker);
1814 assert_eq!(trade.batch_status.as_deref(), Some("Settled"));
1815 assert_eq!(trade.instrument_name, "ETH-PERP");
1816 assert_eq!(trade.label, "alpha-strategy");
1817 assert_eq!(trade.wallet.as_ref().map(Ustr::as_str), Some("0xwallet"));
1818 assert_eq!(trade.order_id, "order-abc");
1819 assert_eq!(trade.trade_id, "trade-xyz");
1820 assert_eq!(trade.subaccount_id, 42);
1821 assert_eq!(trade.realized_pnl.to_string(), "12.5");
1822 assert_eq!(trade.trade_amount.to_string(), "0.5");
1823 assert_eq!(trade.trade_price.to_string(), "3499.0");
1824 assert!(!trade.is_transfer);
1825 assert!(trade.quote_id.is_none());
1826 assert_eq!(trade.tx_hash.as_deref(), Some("0xhash"));
1827 }
1828
1829 #[rstest]
1830 #[case("trade_amount", "0.5")]
1831 #[case("trade_price", "3499.0")]
1832 #[case("trade_fee", "0.02")]
1833 fn test_native_trade_numeric_tokens_preserve_exact_value(
1834 #[case] field: &str,
1835 #[case] original: &str,
1836 #[values(
1837 ("1234567.123456789012", rust_decimal_macros::dec!(1234567.123456789012)),
1838 ("1.234567890123456789e-10", rust_decimal_macros::dec!(0.0000000001234567890123456789))
1839 )]
1840 (token, expected): (&str, Decimal),
1841 ) {
1842 let fixture = include_str!("../../test_data/perps/http_private_trade_eth.json");
1843 let row = fixture.replace(
1844 &format!("\"{field}\": \"{original}\""),
1845 &format!("\"{field}\": {token}"),
1846 );
1847 assert_ne!(row, fixture);
1848 let direct: DeriveTrade = serde_json::from_str(&row).unwrap();
1849 let page = serde_json::to_string(&serde_json::json!({
1850 "pagination": {"count": 1, "num_pages": 1},
1851 "subaccount_id": 42,
1852 }))
1853 .unwrap();
1854 let page = format!("{},\"trades\":[{row}]}}", page.trim_end_matches('}'));
1855 let page: DeriveTradesResult = serde_json::from_str(&page).unwrap();
1856 let subscription: crate::websocket::DeriveTradesSubscriptionData =
1857 serde_json::from_str(&format!("[{row}]")).unwrap();
1858
1859 assert_eq!(page.trades.len(), 1);
1860 assert_eq!(subscription.trades.len(), 1);
1861 assert!(page.records_complete);
1862
1863 for trade in [&direct, &page.trades[0], &subscription.trades[0]] {
1864 assert_eq!(
1865 trade.trade_amount,
1866 if field == "trade_amount" {
1867 expected
1868 } else {
1869 rust_decimal_macros::dec!(0.5)
1870 }
1871 );
1872 assert_eq!(
1873 trade.trade_price,
1874 if field == "trade_price" {
1875 expected
1876 } else {
1877 rust_decimal_macros::dec!(3499.0)
1878 }
1879 );
1880 assert_eq!(
1881 trade.trade_fee,
1882 if field == "trade_fee" {
1883 expected
1884 } else {
1885 rust_decimal_macros::dec!(0.02)
1886 }
1887 );
1888 assert_eq!(
1889 serde_json::to_value(trade).unwrap(),
1890 serde_json::to_value(&direct).unwrap()
1891 );
1892 }
1893 }
1894
1895 #[rstest]
1896 fn test_v3_trade_rest_and_private_subscription_schema_parity() {
1897 let native: Vec<Value> = serde_json::from_str(include_str!(
1898 "../../test_data/perps/http_trades_v3_native.json"
1899 ))
1900 .unwrap();
1901 let stages: Vec<Value> = serde_json::from_str(include_str!(
1902 "../../test_data/common/trade_batch_stages.json"
1903 ))
1904 .unwrap();
1905
1906 for stage in stages {
1907 let mut row = native[0].clone();
1908 row["batch_status"] = stage.clone();
1909 row["op_uuid"] = Value::Null;
1910 row["tx_hash"] = Value::Null;
1911 let result: DeriveTradesResult = serde_json::from_value(json!({
1912 "trades": [row.clone()], "pagination": {"count": 1, "num_pages": 1},
1913 "subaccount_id": 42,
1914 }))
1915 .unwrap();
1916 let subscription: crate::websocket::DeriveTradesSubscriptionData =
1917 serde_json::from_value(json!([row])).unwrap();
1918 assert_eq!(result.trades.len(), 1);
1919 assert_eq!(subscription.trades.len(), 1);
1920 let rest = &result.trades[0];
1921 let ws = &subscription.trades[0];
1922 assert_eq!(
1923 serde_json::to_value(rest).unwrap(),
1924 serde_json::to_value(ws).unwrap()
1925 );
1926 assert_eq!(rest.batch_status.as_deref(), stage.as_str());
1927 assert_eq!(rest.op_uuid, None);
1928 assert_eq!(rest.tx_hash, None);
1929 assert_eq!(rest.trade_fee, rust_decimal_macros::dec!(0.087052478068));
1930 assert_eq!(rest.trade_id, "pm2-held-fixture-trade-close");
1931 assert_eq!(rest.order_id, "pm2-held-fixture-order-close");
1932 }
1933
1934 let mut row = native[1].clone();
1935 row.as_object_mut().unwrap().remove("batch_status");
1936 row.as_object_mut().unwrap().remove("op_uuid");
1937 row.as_object_mut().unwrap().remove("tx_hash");
1938 row.as_object_mut().unwrap().remove("label");
1939 let early: DeriveTrade = serde_json::from_value(row).unwrap();
1940 assert_eq!(early.batch_status, None);
1941 assert_eq!(early.op_uuid, None);
1942 assert_eq!(early.tx_hash, None);
1943 assert_eq!(early.label, Ustr::from(""));
1944 }
1945
1946 #[rstest]
1947 #[case("trade_amount")]
1948 #[case("trade_price")]
1949 #[case("trade_fee")]
1950 fn test_v3_required_execution_decimals_reject_invalid_values(
1951 #[case] field: &str,
1952 #[values("null", "empty", "missing")] invalid: &str,
1953 ) {
1954 let mut row = load_json("perps/http_private_trade_eth.json");
1955 match invalid {
1956 "null" => row[field] = Value::Null,
1957 "empty" => row[field] = json!(""),
1958 "missing" => {
1959 row.as_object_mut().unwrap().remove(field);
1960 }
1961 _ => unreachable!(),
1962 }
1963
1964 assert!(serde_json::from_value::<DeriveTrade>(row.clone()).is_err());
1965 let rest = serde_json::from_value::<DeriveTradesResult>(json!({
1966 "trades": [row.clone()], "pagination": {"count": 1, "num_pages": 1}, "subaccount_id": 42,
1967 }));
1968 let ws: crate::websocket::DeriveTradesSubscriptionData =
1969 serde_json::from_value(json!([row.clone()])).unwrap();
1970 let mut order = load_json("spot/http_submit_order_response_mainnet.json")["result"].clone();
1971 order["trades"] = json!([row.clone()]);
1972 let order_result: DeriveOrderResult = serde_json::from_value(order).unwrap();
1973
1974 if field == "trade_fee" {
1975 assert!(rest.is_err());
1976 } else {
1977 let rest = rest.unwrap();
1978 assert_eq!(rest.trades.len(), 0);
1979 assert!(!rest.records_complete);
1980 }
1981
1982 assert_eq!(ws.trades.len(), 0);
1983 assert_eq!(order_result.trades.len(), 0);
1984 assert!(
1985 serde_json::from_value::<crate::websocket::DeriveTradesSubscriptionData>(row.clone())
1986 .is_err()
1987 );
1988
1989 if field != "trade_fee" {
1990 assert!(serde_json::from_value::<DerivePublicTrade>(row.clone()).is_err());
1991 let valid = load_json("perps/http_public_trade_eth_sell.json");
1992 assert!(
1993 serde_json::from_value::<DerivePublicTradesResult>(json!({
1994 "trades": [valid, row.clone()],
1995 "pagination": {"count": 2, "num_pages": 1},
1996 }))
1997 .is_err()
1998 );
1999 let payload: crate::websocket::WsSubscriptionPayload = serde_json::from_str(
2000 &json!({"channel": "trades.perp.ETH", "data": [valid, row]}).to_string(),
2001 )
2002 .unwrap();
2003 assert!(crate::websocket::parse_public_ws_data(&payload).is_err());
2004 }
2005 }
2006
2007 #[rstest]
2008 #[case(json!("0"))]
2009 #[case(json!(0))]
2010 fn test_v3_execution_fee_accepts_explicit_zero(#[case] fee: Value) {
2011 let mut row = load_json("perps/http_private_trade_eth.json");
2012 row["trade_fee"] = fee;
2013 let trade: DeriveTrade = serde_json::from_value(row).unwrap();
2014 assert_eq!(trade.trade_fee, Decimal::ZERO);
2015 assert_eq!(trade.trade_amount, rust_decimal_macros::dec!(0.5));
2016 assert_eq!(trade.trade_price, rust_decimal_macros::dec!(3499));
2017 }
2018
2019 #[rstest]
2020 fn test_private_trade_decodes_high_scale_decimal_values() {
2021 let mut body = load_json("perps/http_private_trade_eth.json");
2022 body["trade_fee"] = json!("1.234567890123456789012345678912345e-1");
2023 body["realized_pnl"] = json!("0.1234567890123456789012345678912345");
2024
2025 let trade: DeriveTrade = serde_json::from_value(body).unwrap();
2026
2027 assert_eq!(
2028 trade.trade_fee.to_string(),
2029 "0.1234567890123456789012345679"
2030 );
2031 assert_eq!(
2032 trade.realized_pnl.to_string(),
2033 "0.1234567890123456789012345679",
2034 );
2035 }
2036
2037 #[rstest]
2038 fn test_order_result_decodes_pending_trade_with_null_tx_hash() {
2039 let mut body = load_json("spot/http_submit_order_response_mainnet.json");
2040 let mut trade = load_json("perps/http_private_trade_eth.json");
2041 trade["tx_hash"] = Value::Null;
2042 trade["batch_status"] = Value::Null;
2043 trade.as_object_mut().unwrap().remove("wallet");
2044 body["result"]["trades"] = json!([trade]);
2045
2046 let result: DeriveOrderResult =
2047 serde_json::from_value(body["result"].clone()).expect("result decodes");
2048
2049 assert_eq!(result.trades.len(), 1);
2050 assert!(result.trades[0].tx_hash.is_none());
2051 assert_eq!(result.trades[0].batch_status, None);
2052 assert!(result.trades[0].wallet.is_none());
2053 }
2054
2055 #[rstest]
2056 fn test_empty_result_decodes_cancel_ack_shapes() {
2057 let object: DeriveEmptyResult = serde_json::from_value(json!({})).unwrap();
2058 let ok_string: DeriveEmptyResult = serde_json::from_value(json!("ok")).unwrap();
2059 let null_envelope: JsonRpcResponse<DeriveEmptyResult> =
2060 serde_json::from_value(json!({"id": 1, "result": null})).unwrap();
2061
2062 assert_eq!(object, DeriveEmptyResult {});
2063 assert_eq!(ok_string, DeriveEmptyResult {});
2064 assert_eq!(null_envelope.result, Some(DeriveEmptyResult {}));
2065 }
2066
2067 #[rstest]
2068 #[case("common/ws_cancel_by_label_zero.json", 0)]
2069 #[case("common/ws_cancel_by_label_nonzero.json", 2)]
2070 fn test_cancel_order_by_label_result_decodes_count(
2071 #[case] filename: &str,
2072 #[case] expected: i64,
2073 ) {
2074 let response: JsonRpcResponse<DeriveCancelByLabelResult> =
2075 serde_json::from_value(load_json(filename)).expect("response decodes");
2076
2077 assert_eq!(response.result.unwrap().cancelled_orders, expected);
2078 }
2079
2080 #[rstest]
2081 fn test_order_result_preserves_trades_with_unknown_status() {
2082 let mut order = load_json("perps/http_order_eth_partially_filled.json");
2083 order["order_status"] = serde_json::json!("future-status");
2084 let trade = load_json("perps/http_private_trade_eth.json");
2085 let result: DeriveOrderResult = serde_json::from_value(serde_json::json!({
2086 "order": order, "trades": [trade],
2087 }))
2088 .unwrap();
2089 assert_eq!(result.trades.len(), 1);
2090 assert_eq!(
2091 serde_json::to_value(&result.trades[0]).unwrap(),
2092 serde_json::to_value(serde_json::from_value::<DeriveTrade>(trade).unwrap()).unwrap()
2093 );
2094 }
2095
2096 #[rstest]
2097 fn test_trades_result_envelope_decodes() {
2098 let body = load_json("perps/http_trades_result_eth.json");
2099 let result: DeriveTradesResult = serde_json::from_value(body).unwrap();
2100 assert_eq!(result.trades.len(), 1);
2101 assert_eq!(result.subaccount_id, 7);
2102 assert_eq!(result.pagination.count, 1);
2103 assert_eq!(result.pagination.num_pages, 1);
2104 assert_eq!(result.trades[0].trade_id, "t-1");
2105 assert!(result.records_complete);
2106 }
2107
2108 #[rstest]
2109 fn test_trades_result_salvages_unknown_variant_rows() {
2110 let body = load_json("perps/http_trades_result_eth_unknown_variants.json");
2111 let result: DeriveTradesResult = serde_json::from_value(body).unwrap();
2112
2113 assert_eq!(result.trades.len(), 2);
2115 assert_eq!(result.trades[0].trade_id, "t-1");
2116 let unknowns = &result.trades[1];
2117 assert_eq!(unknowns.trade_id, "t-2");
2118 assert_eq!(unknowns.liquidity_role, DeriveLiquidityRole::Unknown);
2119 assert!(!result.records_complete);
2120 }
2121
2122 #[rstest]
2123 #[case(json!("invalid-fee"))]
2124 #[case(json!(""))]
2125 #[case(Value::Null)]
2126 fn test_trades_result_rejects_undecodable_fee(#[case] fee: Value) {
2127 let mut body = load_json("perps/http_trades_result_eth.json");
2128 let mut bad = body["trades"][0].clone();
2129 bad["trade_id"] = json!("bad-fee-row");
2130 bad["trade_fee"] = fee;
2131 body["trades"].as_array_mut().unwrap().push(bad);
2132 assert!(serde_json::from_value::<DeriveTradesResult>(body).is_err());
2133 }
2134
2135 #[rstest]
2136 #[case(serde_json::json!(["0.1"]))]
2137 #[case(serde_json::json!(0.1))]
2138 #[case(serde_json::Value::Null)]
2139 fn test_trades_result_rejects_non_object_fee_rows(#[case] row: Value) {
2140 let result = serde_json::from_value::<DeriveTradesResult>(json!({
2141 "trades": [row],
2142 "pagination": {"count": 1, "num_pages": 1},
2143 "subaccount_id": 42,
2144 }));
2145
2146 assert_eq!(
2147 result.unwrap_err().to_string(),
2148 "private trade row must be an object"
2149 );
2150 }
2151
2152 #[rstest]
2153 fn test_trades_result_preserves_unknown_batch_metadata() {
2154 let mut body = load_json("perps/http_trades_result_eth.json");
2155 body["trades"][0]["batch_status"] = json!("FutureStage");
2156
2157 let result: DeriveTradesResult = serde_json::from_value(body).unwrap();
2158
2159 assert_eq!(result.trades.len(), 1);
2160 assert_eq!(
2161 result.trades[0].batch_status.as_deref(),
2162 Some("FutureStage")
2163 );
2164 }
2165
2166 #[rstest]
2167 fn test_public_trades_result_envelope_decodes() {
2168 let body = load_json("perps/http_public_trades_result_eth.json");
2169 let result: DerivePublicTradesResult = serde_json::from_value(body).unwrap();
2170 assert_eq!(result.trades.len(), 1);
2171 assert_eq!(result.pagination.count, 1);
2172 assert_eq!(result.trades[0].trade_id, "pub-1");
2173 }
2174
2175 #[rstest]
2176 fn test_public_funding_rate_history_result_envelope_decodes() {
2177 let body = load_json("perps/http_public_funding_rate_history_eth.json");
2178 let result: DerivePublicFundingRateHistoryResult = serde_json::from_value(body).unwrap();
2179 assert_eq!(result.funding_rate_history.len(), 3);
2180 let first = &result.funding_rate_history[0];
2181 assert_eq!(first.funding_rate.to_string(), "0.00012");
2182 assert_eq!(first.timestamp, 1_700_000_000_000);
2183 assert_eq!(
2184 result.funding_rate_history.last().unwrap().timestamp,
2185 1_700_007_200_000,
2186 );
2187 }
2188
2189 #[rstest]
2190 fn test_public_candles_decode_array() {
2191 let body = load_json("perps/http_public_candles_eth.json");
2195 let candles: Vec<DerivePublicCandle> = serde_json::from_value(body).unwrap();
2196 assert_eq!(candles.len(), 3);
2197 let first = &candles[0];
2198 assert_eq!(first.open_price.to_string(), "3500.0");
2199 assert_eq!(first.high_price.to_string(), "3501.5");
2200 assert_eq!(first.low_price.to_string(), "3499.0");
2201 assert_eq!(first.close_price.to_string(), "3501.0");
2202 assert_eq!(first.volume_usd.to_string(), "12345.6");
2203 assert_eq!(first.volume_contracts.to_string(), "3.527");
2204 assert_eq!(first.timestamp, 1_700_000_007);
2207 assert_eq!(first.timestamp_bucket, 1_700_000_000);
2208 assert_eq!(candles.last().unwrap().timestamp_bucket, 1_700_001_800);
2209 }
2210
2211 #[rstest]
2212 fn test_positions_result_envelope_decodes() {
2213 let body = load_json("perps/http_positions_result_eth.json");
2214 let result: DerivePositionsResult = serde_json::from_value(body).unwrap();
2215 assert_eq!(result.positions.len(), 1);
2216 assert_eq!(result.subaccount_id, 42);
2217 assert_eq!(result.positions[0].instrument_name, "ETH-PERP");
2218 assert!(result.positions[0].leverage.is_none());
2219 assert!(result.positions[0].liquidation_price.is_none());
2220 }
2221}