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