Skip to main content

nautilus_derive/http/
models.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! HTTP payload models and JSON-RPC envelope types for the Derive REST API.
17//!
18//! Envelope types (`JsonRpcRequest`, `JsonRpcResponse`, `JsonRpcError`) cover
19//! the wire framing shared with the WebSocket transport. Payload structs below
20//! mirror the response shapes generated by Derive's upstream Rust SDK at
21//! [`derivexyz/cockpit`](https://github.com/derivexyz/cockpit/tree/master/orderbook-types/src/generated),
22//! adapted to project conventions:
23//!
24//! - `bigdecimal::BigDecimal` -> [`rust_decimal::Decimal`] via the project's
25//!   `deserialize_decimal` / `deserialize_optional_decimal` functions.
26//! - Hot-path string identifiers (`instrument_name`, `currency`) -> [`Ustr`]
27//!   for interning across decoded messages.
28//! - `uuid::Uuid` fields kept as [`String`] to avoid a fresh dep when only a
29//!   couple of methods carry one.
30
31use std::collections::HashMap;
32
33use nautilus_core::serialization::{deserialize_decimal, deserialize_optional_decimal};
34use rust_decimal::Decimal;
35use serde::{Deserialize, Serialize};
36use serde_json::Value;
37use ustr::Ustr;
38
39use crate::common::{
40    enums::{
41        DeriveAssetType, DeriveInstrumentType, DeriveLiquidityRole, DeriveMarginType,
42        DeriveOptionKind, DeriveOrderCancelReason, DeriveOrderSide, DeriveOrderStatus,
43        DeriveOrderType, DeriveTimeInForce, DeriveTriggerPriceType, DeriveTriggerType,
44        DeriveTxStatus,
45    },
46    parse::deserialize_salvaged_vec,
47};
48
49/// Outbound JSON-RPC request frame. Used as-is by the WebSocket transport; the
50/// REST transport addresses the method by URL path and sends only `params` on
51/// the wire, but keeps the same `id` for telemetry.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct JsonRpcRequest<P> {
54    /// JSON-RPC version tag; constant `"2.0"`.
55    pub jsonrpc: &'static str,
56    /// Correlator chosen by the client.
57    pub id: u64,
58    /// Method name (e.g. `public/get_instruments`).
59    pub method: &'static str,
60    /// Method-specific params payload.
61    pub params: P,
62}
63
64impl<P> JsonRpcRequest<P> {
65    /// Constructs a `2.0` request with the given correlator, method, and params.
66    #[must_use]
67    pub fn new(id: u64, method: &'static str, params: P) -> Self {
68        Self {
69            jsonrpc: "2.0",
70            id,
71            method,
72            params,
73        }
74    }
75}
76
77/// Inbound JSON-RPC response frame. Exactly one of `result` or `error` is set
78/// by the venue.
79#[derive(Debug, Clone, Deserialize)]
80#[serde(bound(deserialize = "R: Deserialize<'de>"))]
81pub struct JsonRpcResponse<R> {
82    /// Correlator echoing the request `id`. The Derive REST API may omit this
83    /// for some endpoints, hence `Option`.
84    #[serde(default, deserialize_with = "deserialize_optional_jsonrpc_id")]
85    pub id: Option<u64>,
86    /// Result payload on success.
87    #[serde(default, deserialize_with = "deserialize_present_jsonrpc_result")]
88    pub result: Option<R>,
89    /// Error payload on failure.
90    #[serde(default)]
91    pub error: Option<JsonRpcError>,
92}
93
94fn deserialize_present_jsonrpc_result<'de, D, R>(deserializer: D) -> Result<Option<R>, D::Error>
95where
96    D: serde::Deserializer<'de>,
97    R: Deserialize<'de>,
98{
99    R::deserialize(deserializer).map(Some)
100}
101
102fn deserialize_optional_jsonrpc_id<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
103where
104    D: serde::Deserializer<'de>,
105{
106    let value = Option::<Value>::deserialize(deserializer)?;
107    match value {
108        None | Some(Value::Null) => Ok(None),
109        Some(Value::Number(number)) => number
110            .as_u64()
111            .map(Some)
112            .ok_or_else(|| serde::de::Error::custom("JSON-RPC id must be an unsigned integer")),
113        Some(Value::String(value)) => Ok(value.parse::<u64>().ok()),
114        Some(other) => Err(serde::de::Error::custom(format!(
115            "JSON-RPC id must be an unsigned integer or string, was {other}"
116        ))),
117    }
118}
119
120/// JSON-RPC error object as returned by Derive on failed requests.
121#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
122pub struct JsonRpcError {
123    /// Numeric error code defined by the venue.
124    pub code: i64,
125    /// Human-readable error message.
126    pub message: String,
127    /// Optional structured diagnostic payload.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub data: Option<Value>,
130}
131
132/// Option-specific fields appearing on `public/get_instruments` and legacy
133/// full ticker payloads when the instrument is an option.
134#[derive(Clone, Debug, Serialize, Deserialize)]
135pub struct DeriveOptionPublicDetails {
136    /// Option expiry as a UNIX timestamp in seconds.
137    pub expiry: i64,
138    /// Underlying index identifier (e.g. `"ETH-USD"`).
139    pub index: Ustr,
140    /// Call or put.
141    pub option_type: DeriveOptionKind,
142    /// Final settlement price, populated after expiry.
143    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
144    pub settlement_price: Option<Decimal>,
145    /// Strike price in quote currency.
146    #[serde(deserialize_with = "deserialize_decimal")]
147    pub strike: Decimal,
148}
149
150/// Perp-specific fields appearing on `public/get_instruments` and legacy full
151/// ticker payloads when the instrument is a perpetual.
152#[derive(Clone, Debug, Serialize, Deserialize)]
153pub struct DerivePerpPublicDetails {
154    /// Cumulative funding accrued since contract inception.
155    #[serde(deserialize_with = "deserialize_decimal")]
156    pub aggregate_funding: Decimal,
157    /// Current funding rate per funding interval.
158    #[serde(deserialize_with = "deserialize_decimal")]
159    pub funding_rate: Decimal,
160    /// Underlying index identifier (e.g. `"ETH-USD"`).
161    pub index: Ustr,
162    /// Maximum allowable funding rate per hour.
163    #[serde(deserialize_with = "deserialize_decimal")]
164    pub max_rate_per_hour: Decimal,
165    /// Minimum allowable funding rate per hour.
166    #[serde(deserialize_with = "deserialize_decimal")]
167    pub min_rate_per_hour: Decimal,
168    /// Static interest-rate component of the funding curve.
169    #[serde(deserialize_with = "deserialize_decimal")]
170    pub static_interest_rate: Decimal,
171}
172
173/// Instrument definition returned by `public/get_instruments`.
174#[derive(Clone, Debug, Serialize, Deserialize)]
175pub struct DeriveInstrument {
176    /// Minimum increment of the `amount` field for orders.
177    #[serde(deserialize_with = "deserialize_decimal")]
178    pub amount_step: Decimal,
179    /// On-chain address of the base asset.
180    pub base_asset_address: Ustr,
181    /// Sub-id of the base asset within the asset module (decimal string).
182    pub base_asset_sub_id: Ustr,
183    /// Underlying currency (e.g. `"ETH"`).
184    pub base_currency: Ustr,
185    /// Base flat fee in USD.
186    #[serde(deserialize_with = "deserialize_decimal")]
187    pub base_fee: Decimal,
188    /// Canonical instrument name (e.g. `"ETH-PERP"`, `"ETH-20250627-3500-C"`).
189    pub instrument_name: Ustr,
190    /// Instrument category.
191    pub instrument_type: DeriveInstrumentType,
192    /// Whether the instrument is currently tradable.
193    pub is_active: bool,
194    /// Maker fee rate (fraction).
195    #[serde(deserialize_with = "deserialize_decimal")]
196    pub maker_fee_rate: Decimal,
197    /// Optional cap on the mark-price-derived fee rate.
198    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
199    pub mark_price_fee_rate_cap: Option<Decimal>,
200    /// Maximum allowed order amount.
201    #[serde(deserialize_with = "deserialize_decimal")]
202    pub maximum_amount: Decimal,
203    /// Minimum allowed order amount.
204    #[serde(deserialize_with = "deserialize_decimal")]
205    pub minimum_amount: Decimal,
206    /// Option-specific details (populated when `instrument_type == option`).
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub option_details: Option<DeriveOptionPublicDetails>,
209    /// Perp-specific details (populated when `instrument_type == perp`).
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub perp_details: Option<DerivePerpPublicDetails>,
212    /// Quote currency (e.g. `"USDC"`).
213    pub quote_currency: Ustr,
214    /// Scheduled activation timestamp (UNIX seconds).
215    pub scheduled_activation: i64,
216    /// Scheduled deactivation timestamp (UNIX seconds; `i64::MAX` if none).
217    pub scheduled_deactivation: i64,
218    /// Taker fee rate (fraction).
219    #[serde(deserialize_with = "deserialize_decimal")]
220    pub taker_fee_rate: Decimal,
221    /// Minimum price increment.
222    #[serde(deserialize_with = "deserialize_decimal")]
223    pub tick_size: Decimal,
224}
225
226/// 24-hour rolling trading statistics embedded in ticker payloads.
227#[derive(Clone, Debug, Serialize, Deserialize)]
228pub struct DeriveAggregateTradingStats {
229    /// Total contract volume over the last 24 hours.
230    #[serde(alias = "c", deserialize_with = "deserialize_decimal")]
231    pub contract_volume: Decimal,
232    /// Highest trade price in the last 24 hours.
233    #[serde(alias = "h", deserialize_with = "deserialize_decimal")]
234    pub high: Decimal,
235    /// Lowest trade price in the last 24 hours.
236    #[serde(alias = "l", deserialize_with = "deserialize_decimal")]
237    pub low: Decimal,
238    /// Number of trades over the last 24 hours.
239    #[serde(alias = "n", deserialize_with = "deserialize_decimal")]
240    pub num_trades: Decimal,
241    /// Current total open interest.
242    #[serde(alias = "oi", deserialize_with = "deserialize_decimal")]
243    pub open_interest: Decimal,
244    /// 24-hour percentage price change.
245    #[serde(alias = "p", deserialize_with = "deserialize_decimal")]
246    pub percent_change: Decimal,
247    /// 24-hour USD price change.
248    #[serde(alias = "pr", deserialize_with = "deserialize_decimal")]
249    pub usd_change: Decimal,
250}
251
252/// Option pricing greeks and implied volatilities (option tickers only).
253#[derive(Clone, Debug, Serialize, Deserialize)]
254pub struct DeriveOptionPricing {
255    /// Implied volatility of the current best ask.
256    #[serde(alias = "ai", deserialize_with = "deserialize_decimal")]
257    pub ask_iv: Decimal,
258    /// Implied volatility of the current best bid.
259    #[serde(alias = "bi", deserialize_with = "deserialize_decimal")]
260    pub bid_iv: Decimal,
261    /// Option delta.
262    #[serde(alias = "d", deserialize_with = "deserialize_decimal")]
263    pub delta: Decimal,
264    /// Forward price used in pricing.
265    #[serde(alias = "f", deserialize_with = "deserialize_decimal")]
266    pub forward_price: Decimal,
267    /// Option gamma.
268    #[serde(alias = "g", deserialize_with = "deserialize_decimal")]
269    pub gamma: Decimal,
270    /// Implied volatility of the option.
271    #[serde(alias = "i", deserialize_with = "deserialize_decimal")]
272    pub iv: Decimal,
273    /// Mark price of the option.
274    #[serde(alias = "m", deserialize_with = "deserialize_decimal")]
275    pub mark_price: Decimal,
276    /// Option rho.
277    #[serde(alias = "r", deserialize_with = "deserialize_decimal")]
278    pub rho: Decimal,
279    /// Option theta.
280    #[serde(alias = "t", deserialize_with = "deserialize_decimal")]
281    pub theta: Decimal,
282    /// Option vega.
283    #[serde(alias = "v", deserialize_with = "deserialize_decimal")]
284    pub vega: Decimal,
285}
286
287/// Current ticker snapshot returned by `public/get_tickers` and `ticker_slim`.
288#[derive(Clone, Debug, Serialize, Deserialize)]
289pub struct DeriveTickerSnapshot {
290    /// Instrument identifier, injected from the `tickers` map key.
291    #[serde(default)]
292    pub instrument_name: Ustr,
293    /// Best ask amount.
294    #[serde(
295        rename = "A",
296        alias = "best_ask_amount",
297        deserialize_with = "deserialize_decimal"
298    )]
299    pub best_ask_amount: Decimal,
300    /// Best ask price.
301    #[serde(
302        rename = "a",
303        alias = "best_ask_price",
304        deserialize_with = "deserialize_decimal"
305    )]
306    pub best_ask_price: Decimal,
307    /// Best bid amount.
308    #[serde(
309        rename = "B",
310        alias = "best_bid_amount",
311        deserialize_with = "deserialize_decimal"
312    )]
313    pub best_bid_amount: Decimal,
314    /// Best bid price.
315    #[serde(
316        rename = "b",
317        alias = "best_bid_price",
318        deserialize_with = "deserialize_decimal"
319    )]
320    pub best_bid_price: Decimal,
321    /// Current hourly funding rate for perpetuals.
322    #[serde(
323        rename = "f",
324        alias = "funding_rate",
325        default,
326        deserialize_with = "deserialize_optional_decimal"
327    )]
328    pub funding_rate: Option<Decimal>,
329    /// Current oracle index price for the underlying.
330    #[serde(
331        rename = "I",
332        alias = "index_price",
333        deserialize_with = "deserialize_decimal"
334    )]
335    pub index_price: Decimal,
336    /// Current mark price.
337    #[serde(
338        rename = "M",
339        alias = "mark_price",
340        deserialize_with = "deserialize_decimal"
341    )]
342    pub mark_price: Decimal,
343    /// Maximum allowed price.
344    #[serde(
345        rename = "maxp",
346        alias = "max_price",
347        deserialize_with = "deserialize_decimal"
348    )]
349    pub max_price: Decimal,
350    /// Minimum allowed price.
351    #[serde(
352        rename = "minp",
353        alias = "min_price",
354        deserialize_with = "deserialize_decimal"
355    )]
356    pub min_price: Decimal,
357    /// Option pricing greeks (options only).
358    #[serde(default)]
359    pub option_pricing: Option<DeriveOptionPricing>,
360    /// 24-hour rolling statistics.
361    #[serde(default)]
362    pub stats: Option<DeriveAggregateTradingStats>,
363    /// Ticker timestamp (UNIX ms).
364    #[serde(rename = "t", alias = "timestamp")]
365    pub timestamp: i64,
366}
367
368/// Result returned by `public/get_tickers`.
369#[derive(Clone, Debug, Serialize, Deserialize)]
370pub struct DeriveTickersResult {
371    /// Ticker snapshots keyed by instrument name.
372    pub tickers: HashMap<String, DeriveTickerSnapshot>,
373}
374
375/// Legacy full ticker snapshot pushed on the deprecated WS
376/// `ticker.{instrument_name}.{interval}` channel.
377#[derive(Clone, Debug, Serialize, Deserialize)]
378pub struct DeriveTicker {
379    /// Minimum order amount increment.
380    #[serde(deserialize_with = "deserialize_decimal")]
381    pub amount_step: Decimal,
382    /// On-chain address of the base asset.
383    pub base_asset_address: Ustr,
384    /// Sub-id of the base asset within the asset module (decimal string).
385    pub base_asset_sub_id: Ustr,
386    /// Underlying currency.
387    pub base_currency: Ustr,
388    /// Base flat fee in USD.
389    #[serde(deserialize_with = "deserialize_decimal")]
390    pub base_fee: Decimal,
391    /// Best ask amount.
392    #[serde(deserialize_with = "deserialize_decimal")]
393    pub best_ask_amount: Decimal,
394    /// Best ask price.
395    #[serde(deserialize_with = "deserialize_decimal")]
396    pub best_ask_price: Decimal,
397    /// Best bid amount.
398    #[serde(deserialize_with = "deserialize_decimal")]
399    pub best_bid_amount: Decimal,
400    /// Best bid price.
401    #[serde(deserialize_with = "deserialize_decimal")]
402    pub best_bid_price: Decimal,
403    /// Current oracle index price for the underlying.
404    #[serde(deserialize_with = "deserialize_decimal")]
405    pub index_price: Decimal,
406    /// Instrument identifier.
407    pub instrument_name: Ustr,
408    /// Instrument category.
409    pub instrument_type: DeriveInstrumentType,
410    /// Whether the instrument is currently tradable.
411    pub is_active: bool,
412    /// Maker fee rate.
413    #[serde(deserialize_with = "deserialize_decimal")]
414    pub maker_fee_rate: Decimal,
415    /// Current mark price.
416    #[serde(deserialize_with = "deserialize_decimal")]
417    pub mark_price: Decimal,
418    /// Optional fee-rate cap derived from mark price.
419    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
420    pub mark_price_fee_rate_cap: Option<Decimal>,
421    /// Maximum allowed price.
422    #[serde(deserialize_with = "deserialize_decimal")]
423    pub max_price: Decimal,
424    /// Maximum order amount.
425    #[serde(deserialize_with = "deserialize_decimal")]
426    pub maximum_amount: Decimal,
427    /// Minimum allowed price.
428    #[serde(deserialize_with = "deserialize_decimal")]
429    pub min_price: Decimal,
430    /// Minimum order amount.
431    #[serde(deserialize_with = "deserialize_decimal")]
432    pub minimum_amount: Decimal,
433    /// Option-specific reference data.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub option_details: Option<DeriveOptionPublicDetails>,
436    /// Option pricing greeks (options only).
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub option_pricing: Option<DeriveOptionPricing>,
439    /// Perp-specific reference data.
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub perp_details: Option<DerivePerpPublicDetails>,
442    /// Quote currency.
443    pub quote_currency: Ustr,
444    /// Scheduled activation timestamp (UNIX seconds).
445    pub scheduled_activation: i64,
446    /// Scheduled deactivation timestamp (UNIX seconds; `i64::MAX` if none).
447    pub scheduled_deactivation: i64,
448    /// 24-hour rolling statistics. Populated by the WebSocket ticker channel.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub stats: Option<DeriveAggregateTradingStats>,
451    /// Taker fee rate.
452    #[serde(deserialize_with = "deserialize_decimal")]
453    pub taker_fee_rate: Decimal,
454    /// Minimum price increment.
455    #[serde(deserialize_with = "deserialize_decimal")]
456    pub tick_size: Decimal,
457    /// Ticker timestamp (UNIX ms).
458    pub timestamp: i64,
459}
460
461/// Order record returned by `private/order`, `private/get_orders`,
462/// `private/get_order_history`, and the `{subaccount_id}.orders` WS channel.
463#[derive(Clone, Debug, Serialize, Deserialize)]
464pub struct DeriveOrder {
465    /// Order amount in base units.
466    #[serde(deserialize_with = "deserialize_decimal")]
467    pub amount: Decimal,
468    /// Average fill price.
469    #[serde(deserialize_with = "deserialize_decimal")]
470    pub average_price: Decimal,
471    /// Cancel reason; [`DeriveOrderCancelReason::Empty`] when not cancelled.
472    pub cancel_reason: DeriveOrderCancelReason,
473    /// Creation timestamp (UNIX ms).
474    pub creation_timestamp: i64,
475    /// Order side.
476    pub direction: DeriveOrderSide,
477    /// Cumulative filled amount.
478    #[serde(deserialize_with = "deserialize_decimal")]
479    pub filled_amount: Decimal,
480    /// Instrument identifier.
481    pub instrument_name: Ustr,
482    /// Whether this order was generated via `private/transfer_position`.
483    pub is_transfer: bool,
484    /// Free-form user label.
485    pub label: Ustr,
486    /// Last update timestamp (UNIX ms).
487    pub last_update_timestamp: i64,
488    /// Limit price in quote currency.
489    #[serde(deserialize_with = "deserialize_decimal")]
490    pub limit_price: Decimal,
491    /// Max fee in quote currency signed into the order.
492    #[serde(deserialize_with = "deserialize_decimal")]
493    pub max_fee: Decimal,
494    /// Whether MMP tags this order.
495    pub mmp: bool,
496    /// Order nonce.
497    pub nonce: i64,
498    /// Total fees paid against this order.
499    #[serde(deserialize_with = "deserialize_decimal")]
500    pub order_fee: Decimal,
501    /// Venue-assigned order ID (UUID-shaped).
502    pub order_id: String,
503    /// Order status.
504    pub order_status: DeriveOrderStatus,
505    /// Order type.
506    pub order_type: DeriveOrderType,
507    /// RFQ quote ID when the order is an RFQ execution.
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub quote_id: Option<String>,
510    /// Replaced order ID when this order resulted from a replace.
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub replaced_order_id: Option<String>,
513    /// 65-byte order signature, `0x`-prefixed hex.
514    pub signature: String,
515    /// Signature expiry (UNIX seconds).
516    pub signature_expiry_sec: i64,
517    /// Session-key signer address.
518    pub signer: Ustr,
519    /// Owning subaccount.
520    pub subaccount_id: i64,
521    /// Time-in-force.
522    pub time_in_force: DeriveTimeInForce,
523    /// Trigger price for trigger orders.
524    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
525    pub trigger_price: Option<Decimal>,
526    /// Trigger price source for trigger orders.
527    #[serde(default, skip_serializing_if = "Option::is_none")]
528    pub trigger_price_type: Option<DeriveTriggerPriceType>,
529    /// Trigger rejection text when the trigger worker cannot submit.
530    #[serde(default, skip_serializing_if = "Option::is_none")]
531    pub trigger_reject_message: Option<String>,
532    /// Stop-loss or take-profit trigger flag.
533    #[serde(default, skip_serializing_if = "Option::is_none")]
534    pub trigger_type: Option<DeriveTriggerType>,
535}
536
537/// Result envelope returned by `private/order`.
538#[derive(Clone, Debug, Serialize, Deserialize)]
539pub struct DeriveOrderResult {
540    /// Accepted order.
541    pub order: DeriveOrder,
542    /// Trades generated synchronously by the submission, when any.
543    #[serde(default, deserialize_with = "deserialize_salvaged_vec")]
544    pub trades: Vec<DeriveTrade>,
545}
546
547/// Result envelope returned by `private/replace`.
548#[derive(Clone, Debug, Serialize, Deserialize)]
549pub struct DeriveReplaceResult {
550    /// Newly accepted replacement order, absent when cancellation succeeded but creation failed.
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub order: Option<DeriveOrder>,
553    /// Cancelled stale order, omitted by some responses and mocks.
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub cancelled_order: Option<DeriveOrder>,
556    /// Replacement creation error after the stale order was cancelled.
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub create_order_error: Option<JsonRpcError>,
559}
560
561/// Confirmed outcome returned by `private/replace`.
562#[derive(Clone, Debug)]
563pub enum DeriveReplaceOutcome {
564    /// The stale order was cancelled and the replacement was accepted.
565    Replaced(DeriveOrder),
566    /// The stale order was cancelled but replacement creation failed.
567    Canceled {
568        /// Cancelled stale order returned by the venue.
569        cancelled_order: DeriveOrder,
570        /// Structured error returned by replacement creation.
571        create_order_error: JsonRpcError,
572    },
573}
574
575impl DeriveReplaceResult {
576    /// Validates the response shape and cancellation identity.
577    ///
578    /// # Errors
579    ///
580    /// Returns an error for contradictory fields, an unexpected cancellation record, or an
581    /// invalid replacement state.
582    pub(crate) fn into_outcome(
583        self,
584        expected_cancel_order_id: &str,
585        expected_replacement_label: &str,
586    ) -> Result<DeriveReplaceOutcome, String> {
587        let validate_cancelled_order = |order: &DeriveOrder| {
588            if order.order_id != expected_cancel_order_id {
589                return Err(format!(
590                    "private/replace cancelled order {} did not match requested order {expected_cancel_order_id}",
591                    order.order_id,
592                ));
593            }
594
595            if order.order_status != DeriveOrderStatus::Cancelled {
596                return Err(format!(
597                    "private/replace cancellation record for {expected_cancel_order_id} had status {}",
598                    order.order_status,
599                ));
600            }
601            Ok(())
602        };
603
604        match (self.order, self.cancelled_order, self.create_order_error) {
605            (Some(order), cancelled_order, None) => {
606                if order.order_id == expected_cancel_order_id {
607                    return Err(format!(
608                        "private/replace returned the cancelled order {expected_cancel_order_id} as its replacement",
609                    ));
610                }
611
612                if !matches!(
613                    order.order_status,
614                    DeriveOrderStatus::Open | DeriveOrderStatus::Filled
615                ) {
616                    return Err(format!(
617                        "private/replace replacement {} had status {}",
618                        order.order_id, order.order_status,
619                    ));
620                }
621
622                if order.label.as_str() != expected_replacement_label {
623                    return Err(format!(
624                        "private/replace replacement {} had label {}, expected {expected_replacement_label}",
625                        order.order_id, order.label,
626                    ));
627                }
628
629                if let Some(cancelled_order) = cancelled_order.as_ref() {
630                    validate_cancelled_order(cancelled_order)?;
631                }
632                Ok(DeriveReplaceOutcome::Replaced(order))
633            }
634            (None, Some(cancelled_order), Some(create_order_error)) => {
635                validate_cancelled_order(&cancelled_order)?;
636                Ok(DeriveReplaceOutcome::Canceled {
637                    cancelled_order,
638                    create_order_error,
639                })
640            }
641            _ => Err("private/replace returned an inconsistent result".to_string()),
642        }
643    }
644}
645
646/// Result returned by `private/cancel_by_label`.
647#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
648pub struct DeriveCancelByLabelResult {
649    /// Number of open orders cancelled by the venue.
650    pub cancelled_orders: i64,
651}
652
653/// Result returned by `private/cancel_by_instrument`.
654pub type DeriveCancelByInstrumentResult = DeriveCancelByLabelResult;
655
656/// Empty result returned by state-changing endpoints without a typed payload.
657#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
658pub struct DeriveEmptyResult {}
659
660impl<'de> Deserialize<'de> for DeriveEmptyResult {
661    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
662    where
663        D: serde::Deserializer<'de>,
664    {
665        match Value::deserialize(deserializer)? {
666            Value::Null | Value::Object(_) => Ok(Self {}),
667            Value::String(value) if value == "ok" => Ok(Self {}),
668            other => Err(serde::de::Error::custom(format!(
669                "empty Derive result must be an object, null, or \"ok\", was {other}"
670            ))),
671        }
672    }
673}
674
675/// Position record returned by `private/get_positions` and embedded in
676/// `private/get_subaccount` responses.
677#[derive(Clone, Debug, Serialize, Deserialize)]
678pub struct DerivePosition {
679    /// Signed position amount; positive = long, negative = short.
680    #[serde(deserialize_with = "deserialize_decimal")]
681    pub amount: Decimal,
682    /// Average entry price over the lifetime of the position.
683    #[serde(deserialize_with = "deserialize_decimal")]
684    pub average_price: Decimal,
685    /// Position opening timestamp (UNIX ms).
686    pub creation_timestamp: i64,
687    /// Cumulative funding accrued by this position (perps only).
688    #[serde(deserialize_with = "deserialize_decimal")]
689    pub cumulative_funding: Decimal,
690    /// Position delta (with respect to forward for options).
691    #[serde(deserialize_with = "deserialize_decimal")]
692    pub delta: Decimal,
693    /// Position gamma (zero for non-options).
694    #[serde(deserialize_with = "deserialize_decimal")]
695    pub gamma: Decimal,
696    /// Current oracle index price for the underlying.
697    #[serde(deserialize_with = "deserialize_decimal")]
698    pub index_price: Decimal,
699    /// USD initial margin requirement for this position.
700    #[serde(deserialize_with = "deserialize_decimal")]
701    pub initial_margin: Decimal,
702    /// Instrument identifier (same as the base asset name).
703    pub instrument_name: Ustr,
704    /// Instrument category.
705    pub instrument_type: DeriveInstrumentType,
706    /// Effective leverage (perps only).
707    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
708    pub leverage: Option<Decimal>,
709    /// Index price at which the position would liquidate.
710    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
711    pub liquidation_price: Option<Decimal>,
712    /// USD maintenance margin requirement.
713    #[serde(deserialize_with = "deserialize_decimal")]
714    pub maintenance_margin: Decimal,
715    /// Current mark price.
716    #[serde(deserialize_with = "deserialize_decimal")]
717    pub mark_price: Decimal,
718    /// USD mark-to-market value of the position.
719    #[serde(deserialize_with = "deserialize_decimal")]
720    pub mark_value: Decimal,
721    /// Net USD settled from this position.
722    #[serde(deserialize_with = "deserialize_decimal")]
723    pub net_settlements: Decimal,
724    /// USD margin held against open orders touching this asset.
725    #[serde(deserialize_with = "deserialize_decimal")]
726    pub open_orders_margin: Decimal,
727    /// Funding not yet settled into cash balance (perps only).
728    #[serde(deserialize_with = "deserialize_decimal")]
729    pub pending_funding: Decimal,
730    /// Realized PnL booked on this position.
731    #[serde(deserialize_with = "deserialize_decimal")]
732    pub realized_pnl: Decimal,
733    /// Position theta (zero for non-options).
734    #[serde(deserialize_with = "deserialize_decimal")]
735    pub theta: Decimal,
736    /// Unrealized PnL.
737    #[serde(deserialize_with = "deserialize_decimal")]
738    pub unrealized_pnl: Decimal,
739    /// Position vega (zero for non-options).
740    #[serde(deserialize_with = "deserialize_decimal")]
741    pub vega: Decimal,
742}
743
744/// Collateral row inside a `private/get_subaccount` response.
745#[derive(Clone, Debug, Serialize, Deserialize)]
746pub struct DeriveCollateral {
747    /// Collateral amount.
748    #[serde(deserialize_with = "deserialize_decimal")]
749    pub amount: Decimal,
750    /// Asset name (e.g. `"ETH"`, `"USDC"`).
751    pub asset_name: Ustr,
752    /// Asset category.
753    pub asset_type: DeriveAssetType,
754    /// Cumulative interest earned or paid.
755    #[serde(deserialize_with = "deserialize_decimal")]
756    pub cumulative_interest: Decimal,
757    /// Underlying currency.
758    pub currency: Ustr,
759    /// USD initial margin credit from this collateral.
760    #[serde(deserialize_with = "deserialize_decimal")]
761    pub initial_margin: Decimal,
762    /// USD maintenance margin credit.
763    #[serde(deserialize_with = "deserialize_decimal")]
764    pub maintenance_margin: Decimal,
765    /// Current mark price of the asset.
766    #[serde(deserialize_with = "deserialize_decimal")]
767    pub mark_price: Decimal,
768    /// USD value (`amount * mark_price`).
769    #[serde(deserialize_with = "deserialize_decimal")]
770    pub mark_value: Decimal,
771    /// Interest not yet settled on-chain.
772    #[serde(deserialize_with = "deserialize_decimal")]
773    pub pending_interest: Decimal,
774}
775
776/// Subaccount snapshot returned by `private/get_subaccount`.
777#[derive(Clone, Debug, Serialize, Deserialize)]
778pub struct DeriveSubaccount {
779    /// Collateral rows contributing to margin.
780    pub collaterals: Vec<DeriveCollateral>,
781    /// Total initial margin credit from collaterals.
782    #[serde(deserialize_with = "deserialize_decimal")]
783    pub collaterals_initial_margin: Decimal,
784    /// Total maintenance margin credit from collaterals.
785    #[serde(deserialize_with = "deserialize_decimal")]
786    pub collaterals_maintenance_margin: Decimal,
787    /// Mark-to-market value of all collaterals.
788    #[serde(deserialize_with = "deserialize_decimal")]
789    pub collaterals_value: Decimal,
790    /// Subaccount currency (e.g. `"USDC"`).
791    pub currency: Ustr,
792    /// Signed net initial margin health; negative blocks risk-increasing trades.
793    #[serde(deserialize_with = "deserialize_decimal")]
794    pub initial_margin: Decimal,
795    /// Whether the subaccount is mid-liquidation.
796    pub is_under_liquidation: bool,
797    /// Free-form subaccount label.
798    #[serde(default, skip_serializing_if = "Option::is_none")]
799    pub label: Option<String>,
800    /// Signed net maintenance margin health; negative permits liquidation.
801    #[serde(deserialize_with = "deserialize_decimal")]
802    pub maintenance_margin: Decimal,
803    /// Margining mode (standard, portfolio, or PMRM v2).
804    pub margin_type: DeriveMarginType,
805    /// Open orders held by the subaccount.
806    #[serde(deserialize_with = "deserialize_salvaged_vec")]
807    pub open_orders: Vec<DeriveOrder>,
808    /// USD margin held against open orders.
809    #[serde(deserialize_with = "deserialize_decimal")]
810    pub open_orders_margin: Decimal,
811    /// Open positions held by the subaccount.
812    #[serde(deserialize_with = "deserialize_salvaged_vec")]
813    pub positions: Vec<DerivePosition>,
814    /// USD initial margin requirement attributable to positions.
815    #[serde(deserialize_with = "deserialize_decimal")]
816    pub positions_initial_margin: Decimal,
817    /// USD maintenance margin requirement attributable to positions.
818    #[serde(deserialize_with = "deserialize_decimal")]
819    pub positions_maintenance_margin: Decimal,
820    /// Mark-to-market value of positions.
821    #[serde(deserialize_with = "deserialize_decimal")]
822    pub positions_value: Decimal,
823    /// Subaccount identifier.
824    pub subaccount_id: i64,
825    /// Total subaccount value (collateral + positions).
826    #[serde(deserialize_with = "deserialize_decimal")]
827    pub subaccount_value: Decimal,
828}
829
830/// Private trade record returned by `private/get_trade_history` and the
831/// `{subaccount_id}.trades` WS channel.
832#[derive(Clone, Debug, Serialize, Deserialize)]
833pub struct DeriveTrade {
834    /// Trade side.
835    pub direction: DeriveOrderSide,
836    /// Underlying index price at the time of the trade.
837    #[serde(deserialize_with = "deserialize_decimal")]
838    pub index_price: Decimal,
839    /// Instrument identifier.
840    pub instrument_name: Ustr,
841    /// Whether this trade was generated via `private/transfer_position`.
842    pub is_transfer: bool,
843    /// Free-form user label inherited from the order.
844    pub label: Ustr,
845    /// Maker / taker role of the user.
846    pub liquidity_role: DeriveLiquidityRole,
847    /// Mark price at the time of the trade.
848    #[serde(deserialize_with = "deserialize_decimal")]
849    pub mark_price: Decimal,
850    /// Originating order ID.
851    pub order_id: String,
852    /// RFQ quote ID when relevant.
853    #[serde(default, skip_serializing_if = "Option::is_none")]
854    pub quote_id: Option<String>,
855    /// Realized PnL booked by this trade.
856    #[serde(deserialize_with = "deserialize_decimal")]
857    pub realized_pnl: Decimal,
858    /// Owning subaccount.
859    pub subaccount_id: i64,
860    /// Trade timestamp (UNIX ms).
861    pub timestamp: i64,
862    /// Filled amount on this trade.
863    #[serde(deserialize_with = "deserialize_decimal")]
864    pub trade_amount: Decimal,
865    /// Fee charged for this trade.
866    #[serde(deserialize_with = "deserialize_decimal")]
867    pub trade_fee: Decimal,
868    /// Trade identifier.
869    pub trade_id: String,
870    /// Trade execution price.
871    #[serde(deserialize_with = "deserialize_decimal")]
872    pub trade_price: Decimal,
873    /// On-chain settlement tx hash, absent until settlement starts.
874    #[serde(default, skip_serializing_if = "Option::is_none")]
875    pub tx_hash: Option<String>,
876    /// On-chain settlement status.
877    pub tx_status: DeriveTxStatus,
878    /// Owning wallet address, absent in pending order responses.
879    #[serde(default, skip_serializing_if = "Option::is_none")]
880    pub wallet: Option<Ustr>,
881}
882
883/// Public trade record returned by `public/get_trade_history` and the
884/// `trades.{instrument_type}.{currency}` WS channel.
885///
886/// The public WS feed strips private fields (subaccount, wallet, settlement
887/// metadata, role, fee, PnL) and only carries values visible to every market
888/// participant. Those fields are modelled as `Option` so the same struct can
889/// deserialize both the HTTP shape (richer, when the caller has account
890/// context) and the WS shape (slim).
891#[derive(Clone, Debug, Serialize, Deserialize)]
892pub struct DerivePublicTrade {
893    /// Trade side.
894    pub direction: DeriveOrderSide,
895    /// Underlying index price at the time of the trade.
896    #[serde(deserialize_with = "deserialize_decimal")]
897    pub index_price: Decimal,
898    /// Instrument identifier.
899    pub instrument_name: Ustr,
900    /// Role of this row's participant in the trade. The REST history endpoint
901    /// returns one maker row and one taker row per trade under the same
902    /// `trade_id`; absent on the public WS feed.
903    #[serde(default, skip_serializing_if = "Option::is_none")]
904    pub liquidity_role: Option<DeriveLiquidityRole>,
905    /// Mark price at the time of the trade.
906    #[serde(deserialize_with = "deserialize_decimal")]
907    pub mark_price: Decimal,
908    /// RFQ quote ID when relevant.
909    #[serde(default, skip_serializing_if = "Option::is_none")]
910    pub quote_id: Option<String>,
911    /// RFQ session ID when the trade originated from a request-for-quote.
912    #[serde(default, skip_serializing_if = "Option::is_none")]
913    pub rfq_id: Option<String>,
914    /// Realized PnL attributed to the caller. Absent on the public WS feed.
915    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
916    pub realized_pnl: Option<Decimal>,
917    /// Aggressor subaccount. Absent on the public WS feed.
918    #[serde(default, skip_serializing_if = "Option::is_none")]
919    pub subaccount_id: Option<i64>,
920    /// Trade timestamp (UNIX ms).
921    pub timestamp: i64,
922    /// Filled amount.
923    #[serde(deserialize_with = "deserialize_decimal")]
924    pub trade_amount: Decimal,
925    /// Fee charged to the caller. Absent on the public WS feed.
926    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
927    pub trade_fee: Option<Decimal>,
928    /// Trade identifier.
929    pub trade_id: String,
930    /// Execution price.
931    #[serde(deserialize_with = "deserialize_decimal")]
932    pub trade_price: Decimal,
933    /// On-chain settlement tx hash. Absent on the public WS feed.
934    #[serde(default, skip_serializing_if = "Option::is_none")]
935    pub tx_hash: Option<String>,
936    /// On-chain settlement status. Absent on the public WS feed.
937    #[serde(default, skip_serializing_if = "Option::is_none")]
938    pub tx_status: Option<DeriveTxStatus>,
939    /// Aggressor wallet address. Absent on the public WS feed.
940    #[serde(default, skip_serializing_if = "Option::is_none")]
941    pub wallet: Option<Ustr>,
942}
943
944/// Pagination metadata attached to listing endpoints.
945#[derive(Clone, Debug, Serialize, Deserialize)]
946pub struct DerivePaginationInfo {
947    /// Total number of items across all pages.
948    pub count: i64,
949    /// Number of pages available.
950    pub num_pages: i64,
951}
952
953/// Paginated `private/get_orders` result envelope.
954#[derive(Clone, Debug, Serialize, Deserialize)]
955pub struct DeriveOrdersResult {
956    /// Orders on the current page. Deliberately strict: reconciliation infers
957    /// state from absence, so a dropped order row is worse than a loud failure.
958    pub orders: Vec<DeriveOrder>,
959    /// Pagination metadata.
960    pub pagination: DerivePaginationInfo,
961    /// Owning subaccount.
962    pub subaccount_id: i64,
963}
964
965/// `private/get_open_orders` result envelope.
966#[derive(Clone, Debug, Serialize, Deserialize)]
967pub struct DeriveOpenOrdersResult {
968    /// Currently open orders. Deliberately strict: reconciliation infers
969    /// state from absence, so a dropped order row is worse than a loud failure.
970    pub orders: Vec<DeriveOrder>,
971    /// Owning subaccount.
972    pub subaccount_id: i64,
973}
974
975/// Paginated `private/get_trade_history` result envelope.
976#[derive(Clone, Debug, Serialize, Deserialize)]
977pub struct DeriveTradesResult {
978    /// Trades on the current page.
979    #[serde(deserialize_with = "deserialize_salvaged_vec")]
980    pub trades: Vec<DeriveTrade>,
981    /// Pagination metadata.
982    pub pagination: DerivePaginationInfo,
983    /// Owning subaccount.
984    pub subaccount_id: i64,
985}
986
987/// Paginated `public/get_trade_history` result envelope.
988#[derive(Clone, Debug, Serialize, Deserialize)]
989pub struct DerivePublicTradesResult {
990    /// Trades on the current page.
991    pub trades: Vec<DerivePublicTrade>,
992    /// Pagination metadata.
993    pub pagination: DerivePaginationInfo,
994}
995
996/// OHLCV candle returned by `public/get_tradingview_chart_data`.
997///
998/// The venue ships the `result` field as a flat array of these records; the
999/// HTTP client deserializes that array directly into `Vec<DerivePublicCandle>`.
1000/// Timestamps are UNIX **seconds** (not milliseconds, as on the trade and
1001/// funding endpoints).
1002#[derive(Clone, Debug, Serialize, Deserialize)]
1003pub struct DerivePublicCandle {
1004    /// Open price for the bucket.
1005    #[serde(deserialize_with = "deserialize_decimal")]
1006    pub open_price: Decimal,
1007    /// High price for the bucket.
1008    #[serde(deserialize_with = "deserialize_decimal")]
1009    pub high_price: Decimal,
1010    /// Low price for the bucket.
1011    #[serde(deserialize_with = "deserialize_decimal")]
1012    pub low_price: Decimal,
1013    /// Close price for the bucket.
1014    #[serde(deserialize_with = "deserialize_decimal")]
1015    pub close_price: Decimal,
1016    /// Notional volume in USD over the bucket.
1017    #[serde(deserialize_with = "deserialize_decimal")]
1018    pub volume_usd: Decimal,
1019    /// Base-asset volume (contracts) over the bucket.
1020    #[serde(deserialize_with = "deserialize_decimal")]
1021    pub volume_contracts: Decimal,
1022    /// Sample timestamp (UNIX seconds).
1023    pub timestamp: i64,
1024    /// Bucket start timestamp (UNIX seconds); regularly spaced by `period`.
1025    pub timestamp_bucket: i64,
1026}
1027
1028/// Funding rate sample returned by `public/get_funding_rate_history`.
1029#[derive(Clone, Debug, Serialize, Deserialize)]
1030pub struct DerivePublicFundingRate {
1031    /// Funding rate observed at `timestamp` (fraction per funding interval).
1032    #[serde(deserialize_with = "deserialize_decimal")]
1033    pub funding_rate: Decimal,
1034    /// Sample timestamp (UNIX ms).
1035    pub timestamp: i64,
1036}
1037
1038/// `public/get_funding_rate_history` result envelope.
1039#[derive(Clone, Debug, Serialize, Deserialize)]
1040pub struct DerivePublicFundingRateHistoryResult {
1041    /// Funding rate samples in venue response order (currently newest to oldest).
1042    pub funding_rate_history: Vec<DerivePublicFundingRate>,
1043}
1044
1045/// `private/get_positions` result envelope.
1046#[derive(Clone, Debug, Serialize, Deserialize)]
1047pub struct DerivePositionsResult {
1048    /// Positions held by the subaccount. Deliberately strict: mass status
1049    /// synthesizes flat reports for instruments absent from this list, so a
1050    /// dropped position row would report "flat" for a position the venue
1051    /// still holds.
1052    pub positions: Vec<DerivePosition>,
1053    /// Owning subaccount.
1054    pub subaccount_id: i64,
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059    use std::path::PathBuf;
1060
1061    use rstest::rstest;
1062    use serde_json::{Value, json};
1063
1064    use super::*;
1065
1066    fn data_path() -> PathBuf {
1067        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data")
1068    }
1069
1070    fn load_json(filename: &str) -> Value {
1071        let content = std::fs::read_to_string(data_path().join(filename))
1072            .unwrap_or_else(|_| panic!("failed to read {filename}"));
1073        serde_json::from_str(&content).expect("invalid json")
1074    }
1075
1076    #[rstest]
1077    fn test_request_serializes_with_jsonrpc_version_tag() {
1078        let req = JsonRpcRequest::new(7, "public/get_instruments", json!({"currency": "ETH"}));
1079        let wire = serde_json::to_value(&req).unwrap();
1080        assert_eq!(wire["jsonrpc"], "2.0");
1081        assert_eq!(wire["id"], 7);
1082        assert_eq!(wire["method"], "public/get_instruments");
1083        assert_eq!(wire["params"]["currency"], "ETH");
1084    }
1085
1086    #[rstest]
1087    fn test_response_decodes_success_envelope() {
1088        let body = json!({"id": 1, "result": {"instruments": []}});
1089        let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
1090        assert_eq!(resp.id, Some(1));
1091        assert!(resp.error.is_none());
1092        assert!(resp.result.is_some());
1093    }
1094
1095    #[rstest]
1096    fn test_response_decodes_error_envelope() {
1097        let body = json!({
1098            "id": 9,
1099            "error": {"code": -32600, "message": "Invalid Request"}
1100        });
1101        let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
1102        assert_eq!(resp.id, Some(9));
1103        assert!(resp.result.is_none());
1104        let err = resp.error.expect("error present");
1105        assert_eq!(err.code, -32600);
1106        assert_eq!(err.message, "Invalid Request");
1107        assert!(err.data.is_none());
1108    }
1109
1110    #[rstest]
1111    fn test_response_decodes_error_envelope_with_data_field() {
1112        let body = json!({
1113            "id": 9,
1114            "error": {
1115                "code": -32602,
1116                "message": "Invalid params",
1117                "data": {"field": "currency"},
1118            }
1119        });
1120        let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
1121        let err = resp.error.expect("error present");
1122        assert_eq!(err.data, Some(json!({"field": "currency"})));
1123    }
1124
1125    #[rstest]
1126    fn test_response_tolerates_missing_id() {
1127        let body = json!({"result": {"ok": true}});
1128        let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
1129        assert!(resp.id.is_none());
1130        assert!(resp.result.is_some());
1131    }
1132
1133    #[rstest]
1134    fn test_response_tolerates_string_id() {
1135        let body = json!({"id": "e3c970c6-94aa-420c-b6db-d0f585a7fde9", "result": {"ok": true}});
1136        let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
1137        assert!(resp.id.is_none());
1138        assert!(resp.result.is_some());
1139    }
1140
1141    #[rstest]
1142    fn test_response_decodes_numeric_string_id() {
1143        let body = json!({"id": "42", "result": {"ok": true}});
1144        let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
1145        assert_eq!(resp.id, Some(42));
1146        assert!(resp.result.is_some());
1147    }
1148
1149    #[rstest]
1150    fn test_instrument_decodes_perp_with_perp_details() {
1151        let body = load_json("perps/instrument_eth.json");
1152        let instrument: DeriveInstrument = serde_json::from_value(body).unwrap();
1153        assert_eq!(instrument.instrument_name.as_str(), "ETH-PERP");
1154        assert_eq!(instrument.instrument_type, DeriveInstrumentType::Perp);
1155        assert!(instrument.option_details.is_none());
1156        let perp = instrument.perp_details.expect("perp details present");
1157        assert_eq!(perp.index, "ETH-USD");
1158    }
1159
1160    #[rstest]
1161    fn test_instrument_decodes_option_with_option_details() {
1162        let mut body = load_json("options/instrument_eth.json");
1163        body["scheduled_activation"] = json!(0);
1164        let instrument: DeriveInstrument = serde_json::from_value(body).unwrap();
1165        let option = instrument.option_details.expect("option details present");
1166        assert_eq!(option.option_type, DeriveOptionKind::Call);
1167        assert_eq!(option.strike.to_string(), "3500");
1168        assert!(option.settlement_price.is_none());
1169    }
1170
1171    #[rstest]
1172    fn test_order_decodes_partially_filled_market_order() {
1173        // Distinct `amount` and `filled_amount` so a struct-field swap is
1174        // detectable. Every Ustr-typed field has a unique value so a serde
1175        // rename or field-order regression surfaces against a single fixture.
1176        let body = load_json("perps/http_order_eth_partially_filled.json");
1177        let order: DeriveOrder = serde_json::from_value(body).unwrap();
1178        assert_eq!(order.amount.to_string(), "2.0");
1179        assert_eq!(order.filled_amount.to_string(), "1.5");
1180        assert_eq!(order.average_price.to_string(), "3500.25");
1181        assert_eq!(order.order_status, DeriveOrderStatus::Filled);
1182        assert_eq!(order.cancel_reason, DeriveOrderCancelReason::Empty);
1183        assert_eq!(order.direction, DeriveOrderSide::Buy);
1184        assert_eq!(order.time_in_force, DeriveTimeInForce::Ioc);
1185        assert_eq!(order.order_type, DeriveOrderType::Market);
1186        assert_eq!(order.instrument_name.as_str(), "ETH-PERP");
1187        assert_eq!(order.label.as_str(), "alpha-strategy");
1188        assert_eq!(order.signer.as_str(), "0xsigner");
1189        assert_eq!(order.order_id, "abc-123");
1190        assert_eq!(order.subaccount_id, 42);
1191        assert_eq!(order.signature_expiry_sec, 1_700_001_000);
1192        assert!(!order.mmp);
1193        assert!(!order.is_transfer);
1194        assert!(order.quote_id.is_none());
1195        assert!(order.replaced_order_id.is_none());
1196    }
1197
1198    #[rstest]
1199    fn test_replace_result_decodes_canceled_without_replacement() {
1200        let mut cancelled_order = load_json("perps/http_order_eth_partially_filled.json");
1201        cancelled_order["order_id"] = json!("old-order");
1202        cancelled_order["order_status"] = json!("cancelled");
1203        let result: DeriveReplaceResult = serde_json::from_value(json!({
1204            "order": null,
1205            "cancelled_order": cancelled_order,
1206            "create_order_error": {
1207                "code": 10001,
1208                "message": "insufficient margin",
1209            },
1210        }))
1211        .unwrap();
1212
1213        let outcome = result
1214            .into_outcome("old-order", "replacement-label")
1215            .unwrap();
1216        let DeriveReplaceOutcome::Canceled {
1217            cancelled_order,
1218            create_order_error,
1219        } = outcome
1220        else {
1221            panic!("expected canceled outcome");
1222        };
1223        assert_eq!(cancelled_order.order_id, "old-order");
1224        assert_eq!(cancelled_order.order_status, DeriveOrderStatus::Cancelled);
1225        assert_eq!(create_order_error.code, 10001);
1226        assert_eq!(create_order_error.message, "insufficient margin");
1227        assert!(create_order_error.data.is_none());
1228    }
1229
1230    #[rstest]
1231    fn test_replace_result_rejects_non_cancelled_partial_record() {
1232        let mut cancelled_order = load_json("perps/http_order_eth_partially_filled.json");
1233        cancelled_order["order_id"] = json!("old-order");
1234        cancelled_order["order_status"] = json!("open");
1235        let result: DeriveReplaceResult = serde_json::from_value(json!({
1236            "order": null,
1237            "cancelled_order": cancelled_order,
1238            "create_order_error": {
1239                "code": 10001,
1240                "message": "insufficient margin",
1241            },
1242        }))
1243        .unwrap();
1244
1245        let error = result
1246            .into_outcome("old-order", "replacement-label")
1247            .unwrap_err();
1248        assert!(error.contains("had status open"));
1249    }
1250
1251    #[rstest]
1252    fn test_replace_result_rejects_mismatched_replacement_label() {
1253        let mut replacement_order = load_json("perps/http_order_eth_partially_filled.json");
1254        replacement_order["order_id"] = json!("new-order");
1255        replacement_order["order_status"] = json!("open");
1256        replacement_order["label"] = json!("wrong-label");
1257        let result: DeriveReplaceResult = serde_json::from_value(json!({
1258            "order": replacement_order,
1259            "cancelled_order": null,
1260            "create_order_error": null,
1261        }))
1262        .unwrap();
1263
1264        let error = result
1265            .into_outcome("old-order", "expected-label")
1266            .unwrap_err();
1267        assert!(error.contains("had label wrong-label, expected expected-label"));
1268    }
1269
1270    #[rstest]
1271    fn test_position_decodes_perp_with_optional_leverage() {
1272        // Distinct decimal values per field so a struct-field swap surfaces.
1273        let body = load_json("perps/http_position_eth.json");
1274        let position: DerivePosition = serde_json::from_value(body).unwrap();
1275        assert_eq!(position.instrument_type, DeriveInstrumentType::Perp);
1276        assert_eq!(position.instrument_name.as_str(), "ETH-PERP");
1277        assert_eq!(position.amount.to_string(), "-2");
1278        assert_eq!(position.delta.to_string(), "-2");
1279        assert_eq!(position.gamma.to_string(), "0.1");
1280        assert_eq!(position.theta.to_string(), "-0.3");
1281        assert_eq!(position.vega.to_string(), "0.5");
1282        assert_eq!(position.unrealized_pnl.to_string(), "8");
1283        assert_eq!(position.mark_value.to_string(), "-7008");
1284        assert_eq!(
1285            position.leverage.as_ref().map(ToString::to_string),
1286            Some("5.0".into()),
1287        );
1288        assert_eq!(
1289            position.liquidation_price.as_ref().map(ToString::to_string),
1290            Some("4200".into()),
1291        );
1292    }
1293
1294    #[rstest]
1295    fn test_subaccount_decodes_with_collaterals_and_open_orders() {
1296        let body = load_json("common/http_subaccount_usdc.json");
1297        let subaccount: DeriveSubaccount = serde_json::from_value(body).unwrap();
1298        assert_eq!(subaccount.subaccount_id, 42);
1299        assert_eq!(subaccount.margin_type, DeriveMarginType::Pm);
1300        assert_eq!(subaccount.collaterals.len(), 1);
1301        assert_eq!(subaccount.collaterals[0].asset_type, DeriveAssetType::Erc20);
1302        assert!(!subaccount.is_under_liquidation);
1303    }
1304
1305    #[rstest]
1306    fn test_subaccount_decodes_high_scale_decimal_values() {
1307        let body = load_json("common/http_subaccount_high_scale.json");
1308        let subaccount: DeriveSubaccount = serde_json::from_value(body).unwrap();
1309        let position = &subaccount.positions[0];
1310
1311        assert_eq!(
1312            subaccount.initial_margin.to_string(),
1313            "0.1234567890123456789012345679",
1314        );
1315        assert_eq!(
1316            subaccount.collaterals[0].amount.to_string(),
1317            "0.1234567890123456789012345679",
1318        );
1319        assert_eq!(
1320            position.pending_funding.to_string(),
1321            "0.1234567890123456789012345679",
1322        );
1323        assert_eq!(
1324            position.leverage.as_ref().map(ToString::to_string),
1325            Some("5.1234567890123456789012345679".into()),
1326        );
1327        assert_eq!(
1328            position.liquidation_price.as_ref().map(ToString::to_string),
1329            Some("4200.1234567890123456789012346".into()),
1330        );
1331
1332        // Order rows nested in the snapshot carry the same high-scale values.
1333        let open_order = &subaccount.open_orders[0];
1334        assert_eq!(
1335            open_order.filled_amount.to_string(),
1336            "0.1234567890123456789012345679",
1337        );
1338        assert_eq!(
1339            open_order.max_fee.to_string(),
1340            "0.1234567890123456789012345679",
1341        );
1342    }
1343
1344    #[rstest]
1345    fn test_subaccount_salvages_unknown_variant_rows() {
1346        let body = load_json("common/http_subaccount_unknown_variants.json");
1347        let subaccount: DeriveSubaccount = serde_json::from_value(body).unwrap();
1348
1349        // The `queued`-status open order is skipped; snapshot and siblings survive.
1350        assert_eq!(subaccount.margin_type, DeriveMarginType::Unknown);
1351        assert_eq!(
1352            subaccount.collaterals[0].asset_type,
1353            DeriveAssetType::Unknown
1354        );
1355        assert_eq!(subaccount.open_orders.len(), 1);
1356        assert_eq!(subaccount.open_orders[0].label.as_str(), "alpha-strategy");
1357        assert_eq!(subaccount.positions.len(), 1);
1358        assert_eq!(
1359            subaccount.positions[0].instrument_type,
1360            DeriveInstrumentType::Unknown,
1361        );
1362    }
1363
1364    #[rstest]
1365    fn test_public_trade_round_trips() {
1366        let body = load_json("perps/http_public_trade_eth_sell.json");
1367        let trade: DerivePublicTrade = serde_json::from_value(body).unwrap();
1368        assert_eq!(trade.direction, DeriveOrderSide::Sell);
1369        assert_eq!(trade.tx_status, Some(DeriveTxStatus::Settled));
1370        let reserialized = serde_json::to_value(&trade).unwrap();
1371        assert_eq!(reserialized["instrument_name"], "ETH-PERP");
1372        assert_eq!(reserialized["liquidity_role"], "taker");
1373    }
1374
1375    #[rstest]
1376    fn test_orders_result_envelope_decodes() {
1377        let body = json!({
1378            "orders": [],
1379            "pagination": {"count": 0, "num_pages": 0},
1380            "subaccount_id": 42,
1381        });
1382        let result: DeriveOrdersResult = serde_json::from_value(body).unwrap();
1383        assert!(result.orders.is_empty());
1384        assert_eq!(result.subaccount_id, 42);
1385        assert_eq!(result.pagination.count, 0);
1386    }
1387
1388    #[rstest]
1389    fn test_orders_result_decodes_unknown_variant_fields() {
1390        let body = load_json("perps/http_orders_result_eth_unknown_variants.json");
1391        let result: DeriveOrdersResult = serde_json::from_value(body).unwrap();
1392
1393        assert_eq!(result.orders.len(), 2);
1394        assert_eq!(result.orders[0].order_status, DeriveOrderStatus::Open);
1395        let unknowns = &result.orders[1];
1396        assert_eq!(unknowns.order_status, DeriveOrderStatus::Cancelled);
1397        assert_eq!(unknowns.cancel_reason, DeriveOrderCancelReason::Unknown);
1398        assert_eq!(unknowns.order_type, DeriveOrderType::Unknown);
1399        assert_eq!(unknowns.time_in_force, DeriveTimeInForce::Unknown);
1400        assert_eq!(unknowns.trigger_type, Some(DeriveTriggerType::Unknown));
1401        assert_eq!(
1402            unknowns.trigger_price_type,
1403            Some(DeriveTriggerPriceType::Unknown),
1404        );
1405    }
1406
1407    #[rstest]
1408    fn test_orders_result_fails_on_unknown_order_status() {
1409        // Pins the deliberate strictness documented on the struct.
1410        let mut body = load_json("perps/http_orders_result_eth_unknown_variants.json");
1411        body["orders"][0]["order_status"] = json!("queued");
1412
1413        assert!(serde_json::from_value::<DeriveOrdersResult>(body).is_err());
1414    }
1415
1416    #[rstest]
1417    fn test_positions_result_fails_on_undecodable_row() {
1418        // Pins the deliberate strictness documented on the struct.
1419        let mut body = load_json("perps/http_positions_result_eth.json");
1420        body["positions"][0]["amount"] = json!({});
1421
1422        assert!(serde_json::from_value::<DerivePositionsResult>(body).is_err());
1423    }
1424
1425    fn perp_ticker_json() -> Value {
1426        load_json("perps/http_ticker_eth_snapshot.json")
1427    }
1428
1429    #[rstest]
1430    fn test_ticker_decodes_perp_snapshot() {
1431        let ticker: DeriveTicker = serde_json::from_value(perp_ticker_json()).unwrap();
1432        assert_eq!(ticker.instrument_name.as_str(), "ETH-PERP");
1433        assert_eq!(ticker.instrument_type, DeriveInstrumentType::Perp);
1434        assert_eq!(ticker.mark_price.to_string(), "3500.5");
1435        assert_eq!(ticker.best_bid_price.to_string(), "3499.5");
1436        assert_eq!(ticker.best_ask_price.to_string(), "3501.0");
1437        assert_eq!(ticker.timestamp, 1_700_000_000_000);
1438        assert!(ticker.option_details.is_none());
1439        assert!(ticker.option_pricing.is_none());
1440        let perp = ticker.perp_details.expect("perp details present");
1441        assert_eq!(perp.index.as_str(), "ETH-USD");
1442        assert_eq!(perp.funding_rate.to_string(), "0.0002");
1443        let stats = ticker
1444            .stats
1445            .as_ref()
1446            .expect("WS ticker fixture includes stats");
1447        assert_eq!(stats.contract_volume.to_string(), "12345.6");
1448        assert_eq!(stats.high.to_string(), "3600");
1449        assert_eq!(stats.num_trades.to_string(), "789");
1450    }
1451
1452    #[rstest]
1453    fn test_ticker_decodes_option_snapshot_with_greeks() {
1454        let body = load_json("options/http_ticker_eth_snapshot.json");
1455        let ticker: DeriveTicker = serde_json::from_value(body).unwrap();
1456        assert_eq!(ticker.instrument_type, DeriveInstrumentType::Option);
1457        assert!(ticker.perp_details.is_none());
1458        let option = ticker.option_details.expect("option details present");
1459        assert_eq!(option.option_type, DeriveOptionKind::Call);
1460        assert_eq!(option.strike.to_string(), "3500");
1461        assert!(option.settlement_price.is_none());
1462        let greeks = ticker.option_pricing.expect("option pricing present");
1463        assert_eq!(greeks.delta.to_string(), "0.55");
1464        assert_eq!(greeks.gamma.to_string(), "0.0008");
1465        assert_eq!(greeks.theta.to_string(), "-2.1");
1466        assert_eq!(greeks.vega.to_string(), "4.5");
1467        assert_eq!(greeks.iv.to_string(), "0.60");
1468        assert_eq!(greeks.forward_price.to_string(), "3505");
1469    }
1470
1471    #[rstest]
1472    fn test_private_trade_decodes_with_order_link() {
1473        // Asserts the fields that distinguish DeriveTrade from DerivePublicTrade
1474        // (order_id, label, is_transfer, realized_pnl) plus the Ustr-typed
1475        // wallet and the typed enum fields.
1476        let body = load_json("perps/http_private_trade_eth.json");
1477        let trade: DeriveTrade = serde_json::from_value(body).unwrap();
1478        assert_eq!(trade.direction, DeriveOrderSide::Buy);
1479        assert_eq!(trade.liquidity_role, DeriveLiquidityRole::Maker);
1480        assert_eq!(trade.tx_status, DeriveTxStatus::Settled);
1481        assert_eq!(trade.instrument_name.as_str(), "ETH-PERP");
1482        assert_eq!(trade.label.as_str(), "alpha-strategy");
1483        assert_eq!(trade.wallet.as_ref().map(Ustr::as_str), Some("0xwallet"));
1484        assert_eq!(trade.order_id, "order-abc");
1485        assert_eq!(trade.trade_id, "trade-xyz");
1486        assert_eq!(trade.subaccount_id, 42);
1487        assert_eq!(trade.realized_pnl.to_string(), "12.5");
1488        assert_eq!(trade.trade_amount.to_string(), "0.5");
1489        assert_eq!(trade.trade_price.to_string(), "3499.0");
1490        assert!(!trade.is_transfer);
1491        assert!(trade.quote_id.is_none());
1492        assert_eq!(trade.tx_hash.as_deref(), Some("0xhash"));
1493    }
1494
1495    #[rstest]
1496    fn test_private_trade_decodes_high_scale_decimal_values() {
1497        let mut body = load_json("perps/http_private_trade_eth.json");
1498        body["trade_fee"] = json!("1.234567890123456789012345678912345e-1");
1499        body["realized_pnl"] = json!("0.1234567890123456789012345678912345");
1500
1501        let trade: DeriveTrade = serde_json::from_value(body).unwrap();
1502
1503        assert_eq!(
1504            trade.trade_fee.to_string(),
1505            "0.1234567890123456789012345679"
1506        );
1507        assert_eq!(
1508            trade.realized_pnl.to_string(),
1509            "0.1234567890123456789012345679",
1510        );
1511    }
1512
1513    #[rstest]
1514    fn test_order_result_decodes_pending_trade_with_null_tx_hash() {
1515        let mut body = load_json("spot/http_submit_order_response_mainnet.json");
1516        let mut trade = load_json("perps/http_private_trade_eth.json");
1517        trade["tx_hash"] = Value::Null;
1518        trade["tx_status"] = json!("requested");
1519        trade.as_object_mut().unwrap().remove("wallet");
1520        body["result"]["trades"] = json!([trade]);
1521
1522        let result: DeriveOrderResult =
1523            serde_json::from_value(body["result"].clone()).expect("result decodes");
1524
1525        assert_eq!(result.trades.len(), 1);
1526        assert!(result.trades[0].tx_hash.is_none());
1527        assert_eq!(result.trades[0].tx_status, DeriveTxStatus::Requested);
1528        assert!(result.trades[0].wallet.is_none());
1529    }
1530
1531    #[rstest]
1532    fn test_empty_result_decodes_cancel_ack_shapes() {
1533        let object: DeriveEmptyResult = serde_json::from_value(json!({})).unwrap();
1534        let ok_string: DeriveEmptyResult = serde_json::from_value(json!("ok")).unwrap();
1535        let null_envelope: JsonRpcResponse<DeriveEmptyResult> =
1536            serde_json::from_value(json!({"id": 1, "result": null})).unwrap();
1537
1538        assert_eq!(object, DeriveEmptyResult {});
1539        assert_eq!(ok_string, DeriveEmptyResult {});
1540        assert_eq!(null_envelope.result, Some(DeriveEmptyResult {}));
1541    }
1542
1543    #[rstest]
1544    #[case("common/ws_cancel_by_label_zero.json", 0)]
1545    #[case("common/ws_cancel_by_label_nonzero.json", 2)]
1546    fn test_cancel_order_by_label_result_decodes_count(
1547        #[case] filename: &str,
1548        #[case] expected: i64,
1549    ) {
1550        let response: JsonRpcResponse<DeriveCancelByLabelResult> =
1551            serde_json::from_value(load_json(filename)).expect("response decodes");
1552
1553        assert_eq!(response.result.unwrap().cancelled_orders, expected);
1554    }
1555
1556    #[rstest]
1557    fn test_trades_result_envelope_decodes() {
1558        let body = load_json("perps/http_trades_result_eth.json");
1559        let result: DeriveTradesResult = serde_json::from_value(body).unwrap();
1560        assert_eq!(result.trades.len(), 1);
1561        assert_eq!(result.subaccount_id, 7);
1562        assert_eq!(result.pagination.count, 1);
1563        assert_eq!(result.pagination.num_pages, 1);
1564        assert_eq!(result.trades[0].trade_id, "t-1");
1565    }
1566
1567    #[rstest]
1568    fn test_trades_result_salvages_unknown_variant_rows() {
1569        let body = load_json("perps/http_trades_result_eth_unknown_variants.json");
1570        let result: DeriveTradesResult = serde_json::from_value(body).unwrap();
1571
1572        // The `short`-direction row is undecodable and skipped; the rest survive.
1573        assert_eq!(result.trades.len(), 2);
1574        assert_eq!(result.trades[0].trade_id, "t-1");
1575        let unknowns = &result.trades[1];
1576        assert_eq!(unknowns.trade_id, "t-2");
1577        assert_eq!(unknowns.liquidity_role, DeriveLiquidityRole::Unknown);
1578    }
1579
1580    #[rstest]
1581    fn test_trades_result_drops_unknown_tx_status_rows() {
1582        // An unknown settlement status must salvage away the row, never emit a fill.
1583        let mut body = load_json("perps/http_trades_result_eth.json");
1584        body["trades"][0]["tx_status"] = json!("bridging");
1585
1586        let result: DeriveTradesResult = serde_json::from_value(body).unwrap();
1587
1588        assert!(result.trades.is_empty());
1589    }
1590
1591    #[rstest]
1592    fn test_public_trades_result_envelope_decodes() {
1593        let body = load_json("perps/http_public_trades_result_eth.json");
1594        let result: DerivePublicTradesResult = serde_json::from_value(body).unwrap();
1595        assert_eq!(result.trades.len(), 1);
1596        assert_eq!(result.pagination.count, 1);
1597        assert_eq!(result.trades[0].trade_id, "pub-1");
1598    }
1599
1600    #[rstest]
1601    fn test_public_funding_rate_history_result_envelope_decodes() {
1602        let body = load_json("perps/http_public_funding_rate_history_eth.json");
1603        let result: DerivePublicFundingRateHistoryResult = serde_json::from_value(body).unwrap();
1604        assert_eq!(result.funding_rate_history.len(), 3);
1605        let first = &result.funding_rate_history[0];
1606        assert_eq!(first.funding_rate.to_string(), "0.00012");
1607        assert_eq!(first.timestamp, 1_700_000_000_000);
1608        assert_eq!(
1609            result.funding_rate_history.last().unwrap().timestamp,
1610            1_700_007_200_000,
1611        );
1612    }
1613
1614    #[rstest]
1615    fn test_public_candles_decode_array() {
1616        // The venue ships `result` as a flat array; the HTTP client decodes
1617        // directly into `Vec<DerivePublicCandle>`. The fixture mirrors that
1618        // wire shape.
1619        let body = load_json("perps/http_public_candles_eth.json");
1620        let candles: Vec<DerivePublicCandle> = serde_json::from_value(body).unwrap();
1621        assert_eq!(candles.len(), 3);
1622        let first = &candles[0];
1623        assert_eq!(first.open_price.to_string(), "3500.0");
1624        assert_eq!(first.high_price.to_string(), "3501.5");
1625        assert_eq!(first.low_price.to_string(), "3499.0");
1626        assert_eq!(first.close_price.to_string(), "3501.0");
1627        assert_eq!(first.volume_usd.to_string(), "12345.6");
1628        assert_eq!(first.volume_contracts.to_string(), "3.527");
1629        // Distinct `timestamp` vs `timestamp_bucket` so a field-swap mutation
1630        // in any downstream parser is detectable.
1631        assert_eq!(first.timestamp, 1_700_000_007);
1632        assert_eq!(first.timestamp_bucket, 1_700_000_000);
1633        assert_eq!(candles.last().unwrap().timestamp_bucket, 1_700_001_800);
1634    }
1635
1636    #[rstest]
1637    fn test_positions_result_envelope_decodes() {
1638        let body = load_json("perps/http_positions_result_eth.json");
1639        let result: DerivePositionsResult = serde_json::from_value(body).unwrap();
1640        assert_eq!(result.positions.len(), 1);
1641        assert_eq!(result.subaccount_id, 42);
1642        assert_eq!(result.positions[0].instrument_name.as_str(), "ETH-PERP");
1643        assert!(result.positions[0].leverage.is_none());
1644        assert!(result.positions[0].liquidation_price.is_none());
1645    }
1646}