use std::collections::HashMap;
use nautilus_core::serialization::{deserialize_decimal, deserialize_optional_decimal};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use ustr::Ustr;
use crate::common::{
enums::{
DeriveAssetType, DeriveInstrumentType, DeriveLiquidityRole, DeriveMarginType,
DeriveOptionKind, DeriveOrderCancelReason, DeriveOrderSide, DeriveOrderStatus,
DeriveOrderType, DeriveTimeInForce, DeriveTriggerPriceType, DeriveTriggerType,
DeriveTxStatus,
},
parse::{deserialize_derive_decimal, deserialize_optional_derive_decimal},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcRequest<P> {
pub jsonrpc: &'static str,
pub id: u64,
pub method: &'static str,
pub params: P,
}
impl<P> JsonRpcRequest<P> {
#[must_use]
pub fn new(id: u64, method: &'static str, params: P) -> Self {
Self {
jsonrpc: "2.0",
id,
method,
params,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct JsonRpcResponse<R> {
#[serde(default, deserialize_with = "deserialize_optional_jsonrpc_id")]
pub id: Option<u64>,
#[serde(default = "Option::default")]
pub result: Option<R>,
#[serde(default)]
pub error: Option<JsonRpcError>,
}
fn deserialize_optional_jsonrpc_id<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<Value>::deserialize(deserializer)?;
match value {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(number)) => number
.as_u64()
.map(Some)
.ok_or_else(|| serde::de::Error::custom("JSON-RPC id must be an unsigned integer")),
Some(Value::String(value)) => Ok(value.parse::<u64>().ok()),
Some(other) => Err(serde::de::Error::custom(format!(
"JSON-RPC id must be an unsigned integer or string, was {other}"
))),
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct JsonRpcError {
pub code: i64,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeriveOptionPublicDetails {
pub expiry: i64,
pub index: Ustr,
pub option_type: DeriveOptionKind,
#[serde(default, deserialize_with = "deserialize_optional_decimal")]
pub settlement_price: Option<Decimal>,
#[serde(deserialize_with = "deserialize_decimal")]
pub strike: Decimal,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DerivePerpPublicDetails {
#[serde(deserialize_with = "deserialize_decimal")]
pub aggregate_funding: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub funding_rate: Decimal,
pub index: Ustr,
#[serde(deserialize_with = "deserialize_decimal")]
pub max_rate_per_hour: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub min_rate_per_hour: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub static_interest_rate: Decimal,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeriveInstrument {
#[serde(deserialize_with = "deserialize_decimal")]
pub amount_step: Decimal,
pub base_asset_address: Ustr,
pub base_asset_sub_id: Ustr,
pub base_currency: Ustr,
#[serde(deserialize_with = "deserialize_decimal")]
pub base_fee: Decimal,
pub instrument_name: Ustr,
pub instrument_type: DeriveInstrumentType,
pub is_active: bool,
#[serde(deserialize_with = "deserialize_decimal")]
pub maker_fee_rate: Decimal,
#[serde(default, deserialize_with = "deserialize_optional_decimal")]
pub mark_price_fee_rate_cap: Option<Decimal>,
#[serde(deserialize_with = "deserialize_decimal")]
pub maximum_amount: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub minimum_amount: Decimal,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub option_details: Option<DeriveOptionPublicDetails>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perp_details: Option<DerivePerpPublicDetails>,
pub quote_currency: Ustr,
pub scheduled_activation: i64,
pub scheduled_deactivation: i64,
#[serde(deserialize_with = "deserialize_decimal")]
pub taker_fee_rate: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub tick_size: Decimal,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeriveAggregateTradingStats {
#[serde(alias = "c", deserialize_with = "deserialize_decimal")]
pub contract_volume: Decimal,
#[serde(alias = "h", deserialize_with = "deserialize_decimal")]
pub high: Decimal,
#[serde(alias = "l", deserialize_with = "deserialize_decimal")]
pub low: Decimal,
#[serde(alias = "n", deserialize_with = "deserialize_decimal")]
pub num_trades: Decimal,
#[serde(alias = "oi", deserialize_with = "deserialize_decimal")]
pub open_interest: Decimal,
#[serde(alias = "p", deserialize_with = "deserialize_decimal")]
pub percent_change: Decimal,
#[serde(alias = "pr", deserialize_with = "deserialize_decimal")]
pub usd_change: Decimal,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeriveOptionPricing {
#[serde(alias = "ai", deserialize_with = "deserialize_decimal")]
pub ask_iv: Decimal,
#[serde(alias = "bi", deserialize_with = "deserialize_decimal")]
pub bid_iv: Decimal,
#[serde(alias = "d", deserialize_with = "deserialize_decimal")]
pub delta: Decimal,
#[serde(alias = "f", deserialize_with = "deserialize_decimal")]
pub forward_price: Decimal,
#[serde(alias = "g", deserialize_with = "deserialize_decimal")]
pub gamma: Decimal,
#[serde(alias = "i", deserialize_with = "deserialize_decimal")]
pub iv: Decimal,
#[serde(alias = "m", deserialize_with = "deserialize_decimal")]
pub mark_price: Decimal,
#[serde(alias = "r", deserialize_with = "deserialize_decimal")]
pub rho: Decimal,
#[serde(alias = "t", deserialize_with = "deserialize_decimal")]
pub theta: Decimal,
#[serde(alias = "v", deserialize_with = "deserialize_decimal")]
pub vega: Decimal,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeriveTickerSnapshot {
#[serde(default)]
pub instrument_name: Ustr,
#[serde(
rename = "A",
alias = "best_ask_amount",
deserialize_with = "deserialize_decimal"
)]
pub best_ask_amount: Decimal,
#[serde(
rename = "a",
alias = "best_ask_price",
deserialize_with = "deserialize_decimal"
)]
pub best_ask_price: Decimal,
#[serde(
rename = "B",
alias = "best_bid_amount",
deserialize_with = "deserialize_decimal"
)]
pub best_bid_amount: Decimal,
#[serde(
rename = "b",
alias = "best_bid_price",
deserialize_with = "deserialize_decimal"
)]
pub best_bid_price: Decimal,
#[serde(
rename = "f",
alias = "funding_rate",
default,
deserialize_with = "deserialize_optional_decimal"
)]
pub funding_rate: Option<Decimal>,
#[serde(
rename = "I",
alias = "index_price",
deserialize_with = "deserialize_decimal"
)]
pub index_price: Decimal,
#[serde(
rename = "M",
alias = "mark_price",
deserialize_with = "deserialize_decimal"
)]
pub mark_price: Decimal,
#[serde(
rename = "maxp",
alias = "max_price",
deserialize_with = "deserialize_decimal"
)]
pub max_price: Decimal,
#[serde(
rename = "minp",
alias = "min_price",
deserialize_with = "deserialize_decimal"
)]
pub min_price: Decimal,
#[serde(default)]
pub option_pricing: Option<DeriveOptionPricing>,
#[serde(default)]
pub stats: Option<DeriveAggregateTradingStats>,
#[serde(rename = "t", alias = "timestamp")]
pub timestamp: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeriveTickersResult {
pub tickers: HashMap<String, DeriveTickerSnapshot>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeriveTicker {
#[serde(deserialize_with = "deserialize_decimal")]
pub amount_step: Decimal,
pub base_asset_address: Ustr,
pub base_asset_sub_id: Ustr,
pub base_currency: Ustr,
#[serde(deserialize_with = "deserialize_decimal")]
pub base_fee: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub best_ask_amount: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub best_ask_price: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub best_bid_amount: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub best_bid_price: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub index_price: Decimal,
pub instrument_name: Ustr,
pub instrument_type: DeriveInstrumentType,
pub is_active: bool,
#[serde(deserialize_with = "deserialize_decimal")]
pub maker_fee_rate: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub mark_price: Decimal,
#[serde(default, deserialize_with = "deserialize_optional_decimal")]
pub mark_price_fee_rate_cap: Option<Decimal>,
#[serde(deserialize_with = "deserialize_decimal")]
pub max_price: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub maximum_amount: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub min_price: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub minimum_amount: Decimal,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub option_details: Option<DeriveOptionPublicDetails>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub option_pricing: Option<DeriveOptionPricing>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perp_details: Option<DerivePerpPublicDetails>,
pub quote_currency: Ustr,
pub scheduled_activation: i64,
pub scheduled_deactivation: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stats: Option<DeriveAggregateTradingStats>,
#[serde(deserialize_with = "deserialize_decimal")]
pub taker_fee_rate: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub tick_size: Decimal,
pub timestamp: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeriveOrder {
#[serde(deserialize_with = "deserialize_decimal")]
pub amount: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub average_price: Decimal,
pub cancel_reason: DeriveOrderCancelReason,
pub creation_timestamp: i64,
pub direction: DeriveOrderSide,
#[serde(deserialize_with = "deserialize_decimal")]
pub filled_amount: Decimal,
pub instrument_name: Ustr,
pub is_transfer: bool,
pub label: Ustr,
pub last_update_timestamp: i64,
#[serde(deserialize_with = "deserialize_decimal")]
pub limit_price: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub max_fee: Decimal,
pub mmp: bool,
pub nonce: i64,
#[serde(deserialize_with = "deserialize_decimal")]
pub order_fee: Decimal,
pub order_id: String,
pub order_status: DeriveOrderStatus,
pub order_type: DeriveOrderType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quote_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replaced_order_id: Option<String>,
pub signature: String,
pub signature_expiry_sec: i64,
pub signer: Ustr,
pub subaccount_id: i64,
pub time_in_force: DeriveTimeInForce,
#[serde(default, deserialize_with = "deserialize_optional_decimal")]
pub trigger_price: Option<Decimal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trigger_price_type: Option<DeriveTriggerPriceType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trigger_reject_message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trigger_type: Option<DeriveTriggerType>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeriveOrderResult {
pub order: DeriveOrder,
#[serde(default)]
pub trades: Vec<DeriveTrade>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeriveReplaceResult {
pub order: DeriveOrder,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cancelled_order: Option<DeriveOrder>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct DeriveEmptyResult {}
impl<'de> Deserialize<'de> for DeriveEmptyResult {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
match Value::deserialize(deserializer)? {
Value::Null | Value::Object(_) => Ok(Self {}),
Value::String(value) if value == "ok" => Ok(Self {}),
other => Err(serde::de::Error::custom(format!(
"empty Derive result must be an object, null, or \"ok\", was {other}"
))),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DerivePosition {
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub amount: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub average_price: Decimal,
pub creation_timestamp: i64,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub cumulative_funding: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub delta: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub gamma: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub index_price: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub initial_margin: Decimal,
pub instrument_name: Ustr,
pub instrument_type: DeriveInstrumentType,
#[serde(default, deserialize_with = "deserialize_optional_derive_decimal")]
pub leverage: Option<Decimal>,
#[serde(default, deserialize_with = "deserialize_optional_derive_decimal")]
pub liquidation_price: Option<Decimal>,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub maintenance_margin: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub mark_price: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub mark_value: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub net_settlements: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub open_orders_margin: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub pending_funding: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub realized_pnl: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub theta: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub unrealized_pnl: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub vega: Decimal,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeriveCollateral {
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub amount: Decimal,
pub asset_name: Ustr,
pub asset_type: DeriveAssetType,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub cumulative_interest: Decimal,
pub currency: Ustr,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub initial_margin: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub maintenance_margin: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub mark_price: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub mark_value: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub pending_interest: Decimal,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeriveSubaccount {
pub collaterals: Vec<DeriveCollateral>,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub collaterals_initial_margin: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub collaterals_maintenance_margin: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub collaterals_value: Decimal,
pub currency: Ustr,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub initial_margin: Decimal,
pub is_under_liquidation: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub maintenance_margin: Decimal,
pub margin_type: DeriveMarginType,
pub open_orders: Vec<DeriveOrder>,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub open_orders_margin: Decimal,
pub positions: Vec<DerivePosition>,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub positions_initial_margin: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub positions_maintenance_margin: Decimal,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub positions_value: Decimal,
pub subaccount_id: i64,
#[serde(deserialize_with = "deserialize_derive_decimal")]
pub subaccount_value: Decimal,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeriveTrade {
pub direction: DeriveOrderSide,
#[serde(deserialize_with = "deserialize_decimal")]
pub index_price: Decimal,
pub instrument_name: Ustr,
pub is_transfer: bool,
pub label: Ustr,
pub liquidity_role: DeriveLiquidityRole,
#[serde(deserialize_with = "deserialize_decimal")]
pub mark_price: Decimal,
pub order_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quote_id: Option<String>,
#[serde(deserialize_with = "deserialize_decimal")]
pub realized_pnl: Decimal,
pub subaccount_id: i64,
pub timestamp: i64,
#[serde(deserialize_with = "deserialize_decimal")]
pub trade_amount: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub trade_fee: Decimal,
pub trade_id: String,
#[serde(deserialize_with = "deserialize_decimal")]
pub trade_price: Decimal,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tx_hash: Option<String>,
pub tx_status: DeriveTxStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wallet: Option<Ustr>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DerivePublicTrade {
pub direction: DeriveOrderSide,
#[serde(deserialize_with = "deserialize_decimal")]
pub index_price: Decimal,
pub instrument_name: Ustr,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub liquidity_role: Option<DeriveLiquidityRole>,
#[serde(deserialize_with = "deserialize_decimal")]
pub mark_price: Decimal,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quote_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rfq_id: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_decimal")]
pub realized_pnl: Option<Decimal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subaccount_id: Option<i64>,
pub timestamp: i64,
#[serde(deserialize_with = "deserialize_decimal")]
pub trade_amount: Decimal,
#[serde(default, deserialize_with = "deserialize_optional_decimal")]
pub trade_fee: Option<Decimal>,
pub trade_id: String,
#[serde(deserialize_with = "deserialize_decimal")]
pub trade_price: Decimal,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tx_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tx_status: Option<DeriveTxStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wallet: Option<Ustr>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DerivePaginationInfo {
pub count: i64,
pub num_pages: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeriveOrdersResult {
pub orders: Vec<DeriveOrder>,
pub pagination: DerivePaginationInfo,
pub subaccount_id: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeriveOpenOrdersResult {
pub orders: Vec<DeriveOrder>,
pub subaccount_id: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeriveTradesResult {
pub trades: Vec<DeriveTrade>,
pub pagination: DerivePaginationInfo,
pub subaccount_id: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DerivePublicTradesResult {
pub trades: Vec<DerivePublicTrade>,
pub pagination: DerivePaginationInfo,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DerivePublicCandle {
#[serde(deserialize_with = "deserialize_decimal")]
pub open_price: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub high_price: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub low_price: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub close_price: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub volume_usd: Decimal,
#[serde(deserialize_with = "deserialize_decimal")]
pub volume_contracts: Decimal,
pub timestamp: i64,
pub timestamp_bucket: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DerivePublicFundingRate {
#[serde(deserialize_with = "deserialize_decimal")]
pub funding_rate: Decimal,
pub timestamp: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DerivePublicFundingRateHistoryResult {
pub funding_rate_history: Vec<DerivePublicFundingRate>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DerivePositionsResult {
pub positions: Vec<DerivePosition>,
pub subaccount_id: i64,
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use rstest::rstest;
use serde_json::{Value, json};
use super::*;
fn data_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data")
}
fn load_json(filename: &str) -> Value {
let content = std::fs::read_to_string(data_path().join(filename))
.unwrap_or_else(|_| panic!("failed to read {filename}"));
serde_json::from_str(&content).expect("invalid json")
}
#[rstest]
fn test_request_serializes_with_jsonrpc_version_tag() {
let req = JsonRpcRequest::new(7, "public/get_instruments", json!({"currency": "ETH"}));
let wire = serde_json::to_value(&req).unwrap();
assert_eq!(wire["jsonrpc"], "2.0");
assert_eq!(wire["id"], 7);
assert_eq!(wire["method"], "public/get_instruments");
assert_eq!(wire["params"]["currency"], "ETH");
}
#[rstest]
fn test_response_decodes_success_envelope() {
let body = json!({"id": 1, "result": {"instruments": []}});
let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
assert_eq!(resp.id, Some(1));
assert!(resp.error.is_none());
assert!(resp.result.is_some());
}
#[rstest]
fn test_response_decodes_error_envelope() {
let body = json!({
"id": 9,
"error": {"code": -32600, "message": "Invalid Request"}
});
let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
assert_eq!(resp.id, Some(9));
assert!(resp.result.is_none());
let err = resp.error.expect("error present");
assert_eq!(err.code, -32600);
assert_eq!(err.message, "Invalid Request");
assert!(err.data.is_none());
}
#[rstest]
fn test_response_decodes_error_envelope_with_data_field() {
let body = json!({
"id": 9,
"error": {
"code": -32602,
"message": "Invalid params",
"data": {"field": "currency"},
}
});
let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
let err = resp.error.expect("error present");
assert_eq!(err.data, Some(json!({"field": "currency"})));
}
#[rstest]
fn test_response_tolerates_missing_id() {
let body = json!({"result": {"ok": true}});
let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
assert!(resp.id.is_none());
assert!(resp.result.is_some());
}
#[rstest]
fn test_response_tolerates_string_id() {
let body = json!({"id": "e3c970c6-94aa-420c-b6db-d0f585a7fde9", "result": {"ok": true}});
let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
assert!(resp.id.is_none());
assert!(resp.result.is_some());
}
#[rstest]
fn test_response_decodes_numeric_string_id() {
let body = json!({"id": "42", "result": {"ok": true}});
let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
assert_eq!(resp.id, Some(42));
assert!(resp.result.is_some());
}
#[rstest]
fn test_instrument_decodes_perp_with_perp_details() {
let body = load_json("perps/instrument_eth.json");
let instrument: DeriveInstrument = serde_json::from_value(body).unwrap();
assert_eq!(instrument.instrument_name.as_str(), "ETH-PERP");
assert_eq!(instrument.instrument_type, DeriveInstrumentType::Perp);
assert!(instrument.option_details.is_none());
let perp = instrument.perp_details.expect("perp details present");
assert_eq!(perp.index, "ETH-USD");
}
#[rstest]
fn test_instrument_decodes_option_with_option_details() {
let mut body = load_json("options/instrument_eth.json");
body["scheduled_activation"] = json!(0);
let instrument: DeriveInstrument = serde_json::from_value(body).unwrap();
let option = instrument.option_details.expect("option details present");
assert_eq!(option.option_type, DeriveOptionKind::Call);
assert_eq!(option.strike.to_string(), "3500");
assert!(option.settlement_price.is_none());
}
#[rstest]
fn test_order_decodes_partially_filled_market_order() {
let body = load_json("perps/http_order_eth_partially_filled.json");
let order: DeriveOrder = serde_json::from_value(body).unwrap();
assert_eq!(order.amount.to_string(), "2.0");
assert_eq!(order.filled_amount.to_string(), "1.5");
assert_eq!(order.average_price.to_string(), "3500.25");
assert_eq!(order.order_status, DeriveOrderStatus::Filled);
assert_eq!(order.cancel_reason, DeriveOrderCancelReason::Empty);
assert_eq!(order.direction, DeriveOrderSide::Buy);
assert_eq!(order.time_in_force, DeriveTimeInForce::Ioc);
assert_eq!(order.order_type, DeriveOrderType::Market);
assert_eq!(order.instrument_name.as_str(), "ETH-PERP");
assert_eq!(order.label.as_str(), "alpha-strategy");
assert_eq!(order.signer.as_str(), "0xsigner");
assert_eq!(order.order_id, "abc-123");
assert_eq!(order.subaccount_id, 42);
assert_eq!(order.signature_expiry_sec, 1_700_001_000);
assert!(!order.mmp);
assert!(!order.is_transfer);
assert!(order.quote_id.is_none());
assert!(order.replaced_order_id.is_none());
}
#[rstest]
fn test_position_decodes_perp_with_optional_leverage() {
let body = load_json("perps/http_position_eth.json");
let position: DerivePosition = serde_json::from_value(body).unwrap();
assert_eq!(position.instrument_type, DeriveInstrumentType::Perp);
assert_eq!(position.instrument_name.as_str(), "ETH-PERP");
assert_eq!(position.amount.to_string(), "-2");
assert_eq!(position.delta.to_string(), "-2");
assert_eq!(position.gamma.to_string(), "0.1");
assert_eq!(position.theta.to_string(), "-0.3");
assert_eq!(position.vega.to_string(), "0.5");
assert_eq!(position.unrealized_pnl.to_string(), "8");
assert_eq!(position.mark_value.to_string(), "-7008");
assert_eq!(
position.leverage.as_ref().map(ToString::to_string),
Some("5.0".into()),
);
assert_eq!(
position.liquidation_price.as_ref().map(ToString::to_string),
Some("4200".into()),
);
}
#[rstest]
fn test_subaccount_decodes_with_collaterals_and_open_orders() {
let body = load_json("common/http_subaccount_usdc.json");
let subaccount: DeriveSubaccount = serde_json::from_value(body).unwrap();
assert_eq!(subaccount.subaccount_id, 42);
assert_eq!(subaccount.margin_type, DeriveMarginType::Pm);
assert_eq!(subaccount.collaterals.len(), 1);
assert_eq!(subaccount.collaterals[0].asset_type, DeriveAssetType::Erc20);
assert!(!subaccount.is_under_liquidation);
}
#[rstest]
fn test_subaccount_decodes_high_scale_decimal_values() {
let body = load_json("common/http_subaccount_high_scale.json");
let subaccount: DeriveSubaccount = serde_json::from_value(body).unwrap();
let position = &subaccount.positions[0];
assert_eq!(
subaccount.initial_margin.to_string(),
"0.1234567890123456789012345679",
);
assert_eq!(
subaccount.collaterals[0].amount.to_string(),
"0.1234567890123456789012345679",
);
assert_eq!(
position.pending_funding.to_string(),
"0.1234567890123456789012345679",
);
assert_eq!(
position.leverage.as_ref().map(ToString::to_string),
Some("5.1234567890123456789012345679".into()),
);
assert_eq!(
position.liquidation_price.as_ref().map(ToString::to_string),
Some("4200.1234567890123456789012346".into()),
);
}
#[rstest]
fn test_public_trade_round_trips() {
let body = load_json("perps/http_public_trade_eth_sell.json");
let trade: DerivePublicTrade = serde_json::from_value(body).unwrap();
assert_eq!(trade.direction, DeriveOrderSide::Sell);
assert_eq!(trade.tx_status, Some(DeriveTxStatus::Settled));
let reserialized = serde_json::to_value(&trade).unwrap();
assert_eq!(reserialized["instrument_name"], "ETH-PERP");
assert_eq!(reserialized["liquidity_role"], "taker");
}
#[rstest]
fn test_orders_result_envelope_decodes() {
let body = json!({
"orders": [],
"pagination": {"count": 0, "num_pages": 0},
"subaccount_id": 42,
});
let result: DeriveOrdersResult = serde_json::from_value(body).unwrap();
assert!(result.orders.is_empty());
assert_eq!(result.subaccount_id, 42);
assert_eq!(result.pagination.count, 0);
}
fn perp_ticker_json() -> Value {
load_json("perps/http_ticker_eth_snapshot.json")
}
#[rstest]
fn test_ticker_decodes_perp_snapshot() {
let ticker: DeriveTicker = serde_json::from_value(perp_ticker_json()).unwrap();
assert_eq!(ticker.instrument_name.as_str(), "ETH-PERP");
assert_eq!(ticker.instrument_type, DeriveInstrumentType::Perp);
assert_eq!(ticker.mark_price.to_string(), "3500.5");
assert_eq!(ticker.best_bid_price.to_string(), "3499.5");
assert_eq!(ticker.best_ask_price.to_string(), "3501.0");
assert_eq!(ticker.timestamp, 1_700_000_000_000);
assert!(ticker.option_details.is_none());
assert!(ticker.option_pricing.is_none());
let perp = ticker.perp_details.expect("perp details present");
assert_eq!(perp.index.as_str(), "ETH-USD");
assert_eq!(perp.funding_rate.to_string(), "0.0002");
let stats = ticker
.stats
.as_ref()
.expect("WS ticker fixture includes stats");
assert_eq!(stats.contract_volume.to_string(), "12345.6");
assert_eq!(stats.high.to_string(), "3600");
assert_eq!(stats.num_trades.to_string(), "789");
}
#[rstest]
fn test_ticker_decodes_option_snapshot_with_greeks() {
let body = load_json("options/http_ticker_eth_snapshot.json");
let ticker: DeriveTicker = serde_json::from_value(body).unwrap();
assert_eq!(ticker.instrument_type, DeriveInstrumentType::Option);
assert!(ticker.perp_details.is_none());
let option = ticker.option_details.expect("option details present");
assert_eq!(option.option_type, DeriveOptionKind::Call);
assert_eq!(option.strike.to_string(), "3500");
assert!(option.settlement_price.is_none());
let greeks = ticker.option_pricing.expect("option pricing present");
assert_eq!(greeks.delta.to_string(), "0.55");
assert_eq!(greeks.gamma.to_string(), "0.0008");
assert_eq!(greeks.theta.to_string(), "-2.1");
assert_eq!(greeks.vega.to_string(), "4.5");
assert_eq!(greeks.iv.to_string(), "0.60");
assert_eq!(greeks.forward_price.to_string(), "3505");
}
#[rstest]
fn test_private_trade_decodes_with_order_link() {
let body = load_json("perps/http_private_trade_eth.json");
let trade: DeriveTrade = serde_json::from_value(body).unwrap();
assert_eq!(trade.direction, DeriveOrderSide::Buy);
assert_eq!(trade.liquidity_role, DeriveLiquidityRole::Maker);
assert_eq!(trade.tx_status, DeriveTxStatus::Settled);
assert_eq!(trade.instrument_name.as_str(), "ETH-PERP");
assert_eq!(trade.label.as_str(), "alpha-strategy");
assert_eq!(trade.wallet.as_ref().map(Ustr::as_str), Some("0xwallet"));
assert_eq!(trade.order_id, "order-abc");
assert_eq!(trade.trade_id, "trade-xyz");
assert_eq!(trade.subaccount_id, 42);
assert_eq!(trade.realized_pnl.to_string(), "12.5");
assert_eq!(trade.trade_amount.to_string(), "0.5");
assert_eq!(trade.trade_price.to_string(), "3499.0");
assert!(!trade.is_transfer);
assert!(trade.quote_id.is_none());
assert_eq!(trade.tx_hash.as_deref(), Some("0xhash"));
}
#[rstest]
fn test_order_result_decodes_pending_trade_with_null_tx_hash() {
let mut body = load_json("spot/http_submit_order_response_mainnet.json");
let mut trade = load_json("perps/http_private_trade_eth.json");
trade["tx_hash"] = Value::Null;
trade["tx_status"] = json!("requested");
trade.as_object_mut().unwrap().remove("wallet");
body["result"]["trades"] = json!([trade]);
let result: DeriveOrderResult =
serde_json::from_value(body["result"].clone()).expect("result decodes");
assert_eq!(result.trades.len(), 1);
assert!(result.trades[0].tx_hash.is_none());
assert_eq!(result.trades[0].tx_status, DeriveTxStatus::Requested);
assert!(result.trades[0].wallet.is_none());
}
#[rstest]
fn test_empty_result_decodes_cancel_ack_shapes() {
let object: DeriveEmptyResult = serde_json::from_value(json!({})).unwrap();
let ok_string: DeriveEmptyResult = serde_json::from_value(json!("ok")).unwrap();
let null_value: DeriveEmptyResult = serde_json::from_value(Value::Null).unwrap();
assert_eq!(object, DeriveEmptyResult {});
assert_eq!(ok_string, DeriveEmptyResult {});
assert_eq!(null_value, DeriveEmptyResult {});
}
#[rstest]
fn test_trades_result_envelope_decodes() {
let body = load_json("perps/http_trades_result_eth.json");
let result: DeriveTradesResult = serde_json::from_value(body).unwrap();
assert_eq!(result.trades.len(), 1);
assert_eq!(result.subaccount_id, 7);
assert_eq!(result.pagination.count, 1);
assert_eq!(result.pagination.num_pages, 1);
assert_eq!(result.trades[0].trade_id, "t-1");
}
#[rstest]
fn test_public_trades_result_envelope_decodes() {
let body = load_json("perps/http_public_trades_result_eth.json");
let result: DerivePublicTradesResult = serde_json::from_value(body).unwrap();
assert_eq!(result.trades.len(), 1);
assert_eq!(result.pagination.count, 1);
assert_eq!(result.trades[0].trade_id, "pub-1");
}
#[rstest]
fn test_public_funding_rate_history_result_envelope_decodes() {
let body = load_json("perps/http_public_funding_rate_history_eth.json");
let result: DerivePublicFundingRateHistoryResult = serde_json::from_value(body).unwrap();
assert_eq!(result.funding_rate_history.len(), 3);
let first = &result.funding_rate_history[0];
assert_eq!(first.funding_rate.to_string(), "0.00012");
assert_eq!(first.timestamp, 1_700_000_000_000);
assert_eq!(
result.funding_rate_history.last().unwrap().timestamp,
1_700_007_200_000,
);
}
#[rstest]
fn test_public_candles_decode_array() {
let body = load_json("perps/http_public_candles_eth.json");
let candles: Vec<DerivePublicCandle> = serde_json::from_value(body).unwrap();
assert_eq!(candles.len(), 3);
let first = &candles[0];
assert_eq!(first.open_price.to_string(), "3500.0");
assert_eq!(first.high_price.to_string(), "3501.5");
assert_eq!(first.low_price.to_string(), "3499.0");
assert_eq!(first.close_price.to_string(), "3501.0");
assert_eq!(first.volume_usd.to_string(), "12345.6");
assert_eq!(first.volume_contracts.to_string(), "3.527");
assert_eq!(first.timestamp, 1_700_000_007);
assert_eq!(first.timestamp_bucket, 1_700_000_000);
assert_eq!(candles.last().unwrap().timestamp_bucket, 1_700_001_800);
}
#[rstest]
fn test_positions_result_envelope_decodes() {
let body = load_json("perps/http_positions_result_eth.json");
let result: DerivePositionsResult = serde_json::from_value(body).unwrap();
assert_eq!(result.positions.len(), 1);
assert_eq!(result.subaccount_id, 42);
assert_eq!(result.positions[0].instrument_name.as_str(), "ETH-PERP");
assert!(result.positions[0].leverage.is_none());
assert!(result.positions[0].liquidation_price.is_none());
}
}