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