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`]. Required account and order
25//!   financial fields, private fill amount/price/fee, and public trade amount/price
26//!   use strict raw-token decimal decoding;
27//!   other fields retain `deserialize_decimal` / `deserialize_optional_decimal`.
28//! - Hot-path string identifiers (`instrument_name`, `currency`) -> [`Ustr`]
29//!   for interning across decoded messages.
30//! - `uuid::Uuid` fields kept as [`String`] to avoid a fresh dep when only a
31//!   couple of methods carry one.
32
33use std::collections::HashMap;
34
35#[cfg(test)]
36use nautilus_core::string::secret::REDACTED;
37use nautilus_core::{
38    Params,
39    serialization::{
40        deserialize_decimal_token,
41        deserialize_optional_decimal_token as deserialize_optional_decimal,
42        serialize_decimal_as_str,
43    },
44    string::secret::SecretString,
45};
46use rust_decimal::Decimal;
47use serde::{Deserialize, Serialize};
48use serde_json::{Value, value::RawValue};
49use ustr::Ustr;
50
51use crate::common::{
52    enums::{
53        DeriveAssetType, DeriveInstrumentType, DeriveLiquidityRole, DeriveOptionKind,
54        DeriveOrderCancelReason, DeriveOrderSide, DeriveOrderStatus, DeriveOrderType,
55        DeriveTimeInForce, DeriveTriggerPriceType, DeriveTriggerType,
56    },
57    parse::{deserialize_decimal, deserialize_salvaged_vec, salvage_rows},
58};
59
60/// Outbound JSON-RPC request frame. Used as-is by the WebSocket transport; the
61/// REST transport addresses the method by URL path and sends only `params` on
62/// the wire, but keeps the same `id` for telemetry.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct JsonRpcRequest<P> {
65    /// JSON-RPC version tag; constant `"2.0"`.
66    pub jsonrpc: &'static str,
67    /// Correlator chosen by the client.
68    pub id: u64,
69    /// Method name (e.g. `public/get_all_instruments`).
70    pub method: &'static str,
71    /// Method-specific params payload.
72    pub params: P,
73}
74
75impl<P> JsonRpcRequest<P> {
76    /// Constructs a `2.0` request with the given correlator, method, and params.
77    #[must_use]
78    pub fn new(id: u64, method: &'static str, params: P) -> Self {
79        Self {
80            jsonrpc: "2.0",
81            id,
82            method,
83            params,
84        }
85    }
86}
87
88/// Inbound JSON-RPC response frame. Exactly one of `result` or `error` is set
89/// by the venue.
90#[derive(Debug, Clone, Deserialize)]
91#[serde(bound(deserialize = "R: Deserialize<'de>"))]
92pub struct JsonRpcResponse<R> {
93    /// Correlator echoing the request `id`. The Derive REST API may omit this
94    /// for some endpoints, hence `Option`.
95    #[serde(default, deserialize_with = "deserialize_optional_jsonrpc_id")]
96    pub id: Option<u64>,
97    /// Result payload on success.
98    #[serde(default, deserialize_with = "deserialize_present_jsonrpc_result")]
99    pub result: Option<R>,
100    /// Error payload on failure.
101    #[serde(default)]
102    pub error: Option<JsonRpcError>,
103}
104
105fn deserialize_present_jsonrpc_result<'de, D, R>(deserializer: D) -> Result<Option<R>, D::Error>
106where
107    D: serde::Deserializer<'de>,
108    R: Deserialize<'de>,
109{
110    R::deserialize(deserializer).map(Some)
111}
112
113fn deserialize_optional_jsonrpc_id<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
114where
115    D: serde::Deserializer<'de>,
116{
117    let value = Option::<Value>::deserialize(deserializer)?;
118    match value {
119        None | Some(Value::Null) => Ok(None),
120        Some(Value::Number(number)) => number
121            .as_u64()
122            .map(Some)
123            .ok_or_else(|| serde::de::Error::custom("JSON-RPC id must be an unsigned integer")),
124        Some(Value::String(value)) => Ok(value.parse::<u64>().ok()),
125        Some(other) => Err(serde::de::Error::custom(format!(
126            "JSON-RPC id must be an unsigned integer or string, was {other}"
127        ))),
128    }
129}
130
131/// JSON-RPC error object as returned by Derive on failed requests.
132#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
133pub struct JsonRpcError {
134    /// Numeric error code defined by the venue.
135    pub code: i64,
136    /// Human-readable error message.
137    pub message: String,
138    /// Optional structured diagnostic payload.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub data: Option<Value>,
141}
142
143/// Option-specific fields appearing on `public/get_all_instruments` and legacy
144/// full ticker payloads when the instrument is an option.
145#[derive(Clone, Debug, Serialize, Deserialize)]
146pub struct DeriveOptionPublicDetails {
147    /// Option expiry as a UNIX timestamp in seconds.
148    pub expiry: i64,
149    /// Underlying index identifier (e.g. `"ETH-USD"`).
150    pub index: Ustr,
151    /// Call or put.
152    pub option_type: DeriveOptionKind,
153    /// Final settlement price, populated after expiry.
154    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
155    pub settlement_price: Option<Decimal>,
156    /// Strike price in quote currency.
157    #[serde(deserialize_with = "deserialize_decimal")]
158    pub strike: Decimal,
159}
160
161/// Perp-specific fields appearing on `public/get_all_instruments` and legacy full
162/// ticker payloads when the instrument is a perpetual.
163#[derive(Clone, Debug, Serialize, Deserialize)]
164pub struct DerivePerpPublicDetails {
165    /// Cumulative funding accrued since contract inception.
166    #[serde(deserialize_with = "deserialize_decimal")]
167    pub aggregate_funding: Decimal,
168    /// Current funding rate per funding interval.
169    #[serde(deserialize_with = "deserialize_decimal")]
170    pub funding_rate: Decimal,
171    /// Underlying index identifier (e.g. `"ETH-USD"`).
172    pub index: Ustr,
173    /// Maximum allowable funding rate per hour.
174    #[serde(deserialize_with = "deserialize_decimal")]
175    pub max_rate_per_hour: Decimal,
176    /// Minimum allowable funding rate per hour.
177    #[serde(deserialize_with = "deserialize_decimal")]
178    pub min_rate_per_hour: Decimal,
179    /// Legacy static interest-rate component, absent from API v3 definitions.
180    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
181    pub static_interest_rate: Option<Decimal>,
182}
183
184/// Instrument definition returned by `public/get_all_instruments`.
185#[derive(Clone, Debug, Serialize, Deserialize)]
186#[serde(remote = "Self")]
187pub struct DeriveInstrument {
188    /// Minimum increment of the `amount` field for orders.
189    #[serde(deserialize_with = "deserialize_decimal")]
190    pub amount_step: Decimal,
191    /// On-chain address of the base asset.
192    pub base_asset_address: Ustr,
193    /// Sub-id of the base asset within the asset module (decimal string).
194    pub base_asset_sub_id: Ustr,
195    /// Underlying currency (e.g. `"ETH"`).
196    pub base_currency: Ustr,
197    /// Base flat fee in USD.
198    #[serde(deserialize_with = "deserialize_decimal")]
199    pub base_fee: Decimal,
200    /// Canonical instrument name (e.g. `"ETH-PERP"`, `"ETH-20250627-3500-C"`).
201    pub instrument_name: Ustr,
202    /// Instrument category.
203    pub instrument_type: DeriveInstrumentType,
204    /// Whether the instrument is currently tradable.
205    pub is_active: bool,
206    /// Maker fee rate (fraction).
207    #[serde(deserialize_with = "deserialize_decimal")]
208    pub maker_fee_rate: Decimal,
209    /// Optional cap on the mark-price-derived fee rate.
210    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
211    pub mark_price_fee_rate_cap: Option<Decimal>,
212    /// Maximum allowed order amount.
213    #[serde(deserialize_with = "deserialize_decimal")]
214    pub maximum_amount: Decimal,
215    /// Venue-reported minimum contracts or tokens per trade.
216    /// Taker orders can fill below this value.
217    #[serde(deserialize_with = "deserialize_decimal")]
218    pub minimum_amount: Decimal,
219    /// Option-specific details (populated when `instrument_type == option`).
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub option_details: Option<DeriveOptionPublicDetails>,
222    /// Perp-specific details (populated when `instrument_type == perp`).
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub perp_details: Option<DerivePerpPublicDetails>,
225    /// Quote currency (e.g. `"USDC"`).
226    pub quote_currency: Ustr,
227    /// Scheduled activation timestamp (UNIX seconds).
228    pub scheduled_activation: i64,
229    /// Scheduled deactivation timestamp (UNIX seconds; `i64::MAX` if none).
230    pub scheduled_deactivation: i64,
231    /// Taker fee rate (fraction).
232    #[serde(deserialize_with = "deserialize_decimal")]
233    pub taker_fee_rate: Decimal,
234    /// Minimum price increment.
235    #[serde(deserialize_with = "deserialize_decimal")]
236    pub tick_size: Decimal,
237    /// Original instrument response fields, before typed decoding or normalization.
238    #[serde(skip)]
239    pub raw: Option<Params>,
240}
241
242impl<'de> Deserialize<'de> for DeriveInstrument {
243    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
244        let raw = Box::<RawValue>::deserialize(deserializer)?;
245        let mut typed = serde_json::Deserializer::from_str(raw.get());
246
247        // The remote derive decodes the typed fields without calling this implementation
248        let mut instrument = Self::deserialize(&mut typed).map_err(serde::de::Error::custom)?;
249        instrument.raw = Some(serde_json::from_str(raw.get()).map_err(serde::de::Error::custom)?);
250        Ok(instrument)
251    }
252}
253
254impl Serialize for DeriveInstrument {
255    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
256        Self::serialize(self, serializer)
257    }
258}
259
260/// 24-hour rolling trading statistics embedded in ticker payloads.
261#[derive(Clone, Debug, Serialize, Deserialize)]
262pub struct DeriveAggregateTradingStats {
263    /// Total contract volume over the last 24 hours.
264    #[serde(alias = "c", deserialize_with = "deserialize_decimal")]
265    pub contract_volume: Decimal,
266    /// Highest trade price in the last 24 hours.
267    #[serde(alias = "h", deserialize_with = "deserialize_decimal")]
268    pub high: Decimal,
269    /// Lowest trade price in the last 24 hours.
270    #[serde(alias = "l", deserialize_with = "deserialize_decimal")]
271    pub low: Decimal,
272    /// Number of trades over the last 24 hours.
273    #[serde(alias = "n", deserialize_with = "deserialize_decimal")]
274    pub num_trades: Decimal,
275    /// Current total open interest.
276    #[serde(alias = "oi", deserialize_with = "deserialize_decimal")]
277    pub open_interest: Decimal,
278    /// 24-hour percentage price change.
279    #[serde(alias = "p", deserialize_with = "deserialize_decimal")]
280    pub percent_change: Decimal,
281    /// 24-hour premium volume in USD (legacy field name).
282    #[serde(alias = "pr", deserialize_with = "deserialize_decimal")]
283    pub usd_change: Decimal,
284}
285
286/// Option pricing greeks and implied volatilities (option tickers only).
287#[derive(Clone, Debug, Serialize, Deserialize)]
288pub struct DeriveOptionPricing {
289    /// Implied volatility of the current best ask.
290    #[serde(alias = "ai", deserialize_with = "deserialize_decimal")]
291    pub ask_iv: Decimal,
292    /// Implied volatility of the current best bid.
293    #[serde(alias = "bi", deserialize_with = "deserialize_decimal")]
294    pub bid_iv: Decimal,
295    /// Option delta.
296    #[serde(alias = "d", deserialize_with = "deserialize_decimal")]
297    pub delta: Decimal,
298    /// Forward price used in pricing.
299    #[serde(alias = "f", deserialize_with = "deserialize_decimal")]
300    pub forward_price: Decimal,
301    /// Option gamma.
302    #[serde(alias = "g", deserialize_with = "deserialize_decimal")]
303    pub gamma: Decimal,
304    /// Implied volatility of the option.
305    #[serde(alias = "i", deserialize_with = "deserialize_decimal")]
306    pub iv: Decimal,
307    /// Mark price of the option.
308    #[serde(alias = "m", deserialize_with = "deserialize_decimal")]
309    pub mark_price: Decimal,
310    /// Option rho.
311    #[serde(alias = "r", deserialize_with = "deserialize_decimal")]
312    pub rho: Decimal,
313    /// Option theta.
314    #[serde(alias = "t", deserialize_with = "deserialize_decimal")]
315    pub theta: Decimal,
316    /// Option vega.
317    #[serde(alias = "v", deserialize_with = "deserialize_decimal")]
318    pub vega: Decimal,
319}
320
321/// Current ticker snapshot returned by `public/get_tickers` and `ticker_slim`.
322#[derive(Clone, Debug, Serialize, Deserialize)]
323pub struct DeriveTickerSnapshot {
324    /// Instrument identifier, injected from the `tickers` map key.
325    #[serde(default)]
326    pub instrument_name: Ustr,
327    /// Best ask amount.
328    #[serde(
329        rename = "A",
330        alias = "best_ask_amount",
331        deserialize_with = "deserialize_decimal"
332    )]
333    pub best_ask_amount: Decimal,
334    /// Best ask price.
335    #[serde(
336        rename = "a",
337        alias = "best_ask_price",
338        deserialize_with = "deserialize_decimal"
339    )]
340    pub best_ask_price: Decimal,
341    /// Best bid amount.
342    #[serde(
343        rename = "B",
344        alias = "best_bid_amount",
345        deserialize_with = "deserialize_decimal"
346    )]
347    pub best_bid_amount: Decimal,
348    /// Best bid price.
349    #[serde(
350        rename = "b",
351        alias = "best_bid_price",
352        deserialize_with = "deserialize_decimal"
353    )]
354    pub best_bid_price: Decimal,
355    /// Current hourly funding rate for perpetuals.
356    #[serde(
357        rename = "f",
358        alias = "funding_rate",
359        default,
360        deserialize_with = "deserialize_optional_decimal"
361    )]
362    pub funding_rate: Option<Decimal>,
363    /// Current oracle index price for the underlying.
364    #[serde(
365        rename = "I",
366        alias = "index_price",
367        deserialize_with = "deserialize_decimal"
368    )]
369    pub index_price: Decimal,
370    /// Current mark price.
371    #[serde(
372        rename = "M",
373        alias = "mark_price",
374        deserialize_with = "deserialize_decimal"
375    )]
376    pub mark_price: Decimal,
377    /// Maximum allowed price.
378    #[serde(
379        rename = "maxp",
380        alias = "max_price",
381        deserialize_with = "deserialize_decimal"
382    )]
383    pub max_price: Decimal,
384    /// Minimum allowed price.
385    #[serde(
386        rename = "minp",
387        alias = "min_price",
388        deserialize_with = "deserialize_decimal"
389    )]
390    pub min_price: Decimal,
391    /// Option pricing greeks (options only).
392    #[serde(default)]
393    pub option_pricing: Option<DeriveOptionPricing>,
394    /// 24-hour rolling statistics.
395    #[serde(default)]
396    pub stats: Option<DeriveAggregateTradingStats>,
397    /// Ticker timestamp (UNIX ms).
398    #[serde(rename = "t", alias = "timestamp")]
399    pub timestamp: i64,
400}
401
402/// Result returned by `public/get_tickers`.
403#[derive(Clone, Debug, Serialize, Deserialize)]
404pub struct DeriveTickersResult {
405    /// Ticker snapshots keyed by instrument name.
406    pub tickers: HashMap<String, DeriveTickerSnapshot>,
407}
408
409/// Legacy full ticker snapshot pushed on the deprecated WS
410/// `ticker.{instrument_name}.{interval}` channel.
411#[derive(Clone, Debug, Serialize, Deserialize)]
412pub struct DeriveTicker {
413    /// Minimum order amount increment.
414    #[serde(deserialize_with = "deserialize_decimal")]
415    pub amount_step: Decimal,
416    /// On-chain address of the base asset.
417    pub base_asset_address: Ustr,
418    /// Sub-id of the base asset within the asset module (decimal string).
419    pub base_asset_sub_id: Ustr,
420    /// Underlying currency.
421    pub base_currency: Ustr,
422    /// Base flat fee in USD.
423    #[serde(deserialize_with = "deserialize_decimal")]
424    pub base_fee: Decimal,
425    /// Best ask amount.
426    #[serde(deserialize_with = "deserialize_decimal")]
427    pub best_ask_amount: Decimal,
428    /// Best ask price.
429    #[serde(deserialize_with = "deserialize_decimal")]
430    pub best_ask_price: Decimal,
431    /// Best bid amount.
432    #[serde(deserialize_with = "deserialize_decimal")]
433    pub best_bid_amount: Decimal,
434    /// Best bid price.
435    #[serde(deserialize_with = "deserialize_decimal")]
436    pub best_bid_price: Decimal,
437    /// Current oracle index price for the underlying.
438    #[serde(deserialize_with = "deserialize_decimal")]
439    pub index_price: Decimal,
440    /// Instrument identifier.
441    pub instrument_name: Ustr,
442    /// Instrument category.
443    pub instrument_type: DeriveInstrumentType,
444    /// Whether the instrument is currently tradable.
445    pub is_active: bool,
446    /// Maker fee rate.
447    #[serde(deserialize_with = "deserialize_decimal")]
448    pub maker_fee_rate: Decimal,
449    /// Current mark price.
450    #[serde(deserialize_with = "deserialize_decimal")]
451    pub mark_price: Decimal,
452    /// Optional fee-rate cap derived from mark price.
453    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
454    pub mark_price_fee_rate_cap: Option<Decimal>,
455    /// Maximum allowed price.
456    #[serde(deserialize_with = "deserialize_decimal")]
457    pub max_price: Decimal,
458    /// Maximum order amount.
459    #[serde(deserialize_with = "deserialize_decimal")]
460    pub maximum_amount: Decimal,
461    /// Minimum allowed price.
462    #[serde(deserialize_with = "deserialize_decimal")]
463    pub min_price: Decimal,
464    /// Minimum order amount.
465    #[serde(deserialize_with = "deserialize_decimal")]
466    pub minimum_amount: Decimal,
467    /// Option-specific reference data.
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub option_details: Option<DeriveOptionPublicDetails>,
470    /// Option pricing greeks (options only).
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub option_pricing: Option<DeriveOptionPricing>,
473    /// Perp-specific reference data.
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub perp_details: Option<DerivePerpPublicDetails>,
476    /// Quote currency.
477    pub quote_currency: Ustr,
478    /// Scheduled activation timestamp (UNIX seconds).
479    pub scheduled_activation: i64,
480    /// Scheduled deactivation timestamp (UNIX seconds; `i64::MAX` if none).
481    pub scheduled_deactivation: i64,
482    /// 24-hour rolling statistics. Populated by the WebSocket ticker channel.
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub stats: Option<DeriveAggregateTradingStats>,
485    /// Taker fee rate.
486    #[serde(deserialize_with = "deserialize_decimal")]
487    pub taker_fee_rate: Decimal,
488    /// Minimum price increment.
489    #[serde(deserialize_with = "deserialize_decimal")]
490    pub tick_size: Decimal,
491    /// Ticker timestamp (UNIX ms).
492    pub timestamp: i64,
493}
494
495/// Order record returned by `private/order`, `private/get_open_orders`,
496/// `private/get_order_history`, and the `{subaccount_id}.orders` WS channel.
497#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct DeriveOrder {
499    /// Order amount in base units.
500    #[serde(deserialize_with = "deserialize_decimal_token")]
501    pub amount: Decimal,
502    /// Average fill price.
503    #[serde(deserialize_with = "deserialize_decimal_token")]
504    pub average_price: Decimal,
505    /// Cancel reason; [`DeriveOrderCancelReason::Empty`] when not cancelled.
506    #[serde(default = "cancel_reason_empty")]
507    pub cancel_reason: DeriveOrderCancelReason,
508    /// Creation timestamp (UNIX ms).
509    pub creation_timestamp: i64,
510    /// Order side.
511    pub direction: DeriveOrderSide,
512    /// Cumulative filled amount.
513    #[serde(deserialize_with = "deserialize_decimal_token")]
514    pub filled_amount: Decimal,
515    /// Instrument identifier.
516    pub instrument_name: Ustr,
517    /// Whether this order was generated via `private/transfer_position`.
518    pub is_transfer: bool,
519    /// Free-form user label.
520    #[serde(default)]
521    pub label: Ustr,
522    /// Last update timestamp (UNIX ms).
523    pub last_update_timestamp: i64,
524    /// Limit price in quote currency.
525    #[serde(deserialize_with = "deserialize_decimal_token")]
526    pub limit_price: Decimal,
527    /// Max fee in quote currency signed into the order.
528    #[serde(deserialize_with = "deserialize_decimal_token")]
529    pub max_fee: Decimal,
530    /// Whether MMP tags this order.
531    pub mmp: bool,
532    /// Order UNIX nanosecond nonce, encoded as a decimal string.
533    #[serde(
534        serialize_with = "crate::signing::nonce::serialize_nonce",
535        deserialize_with = "crate::signing::nonce::deserialize_nonce"
536    )]
537    pub nonce: u64,
538    /// Total fees paid against this order.
539    #[serde(deserialize_with = "deserialize_decimal_token")]
540    pub order_fee: Decimal,
541    /// Venue-assigned order ID (UUID-shaped).
542    pub order_id: String,
543    /// Order status.
544    pub order_status: DeriveOrderStatus,
545    /// Order type.
546    pub order_type: DeriveOrderType,
547    /// RFQ quote ID when the order is an RFQ execution.
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    pub quote_id: Option<String>,
550    /// Replaced order ID when this order resulted from a replace.
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub replaced_order_id: Option<String>,
553    /// 65-byte order signature, `0x`-prefixed hex.
554    pub signature: SecretString,
555    /// Signature expiry (UNIX seconds).
556    pub signature_expiry_sec: i64,
557    /// Session-key signer address.
558    pub signer: Ustr,
559    /// Owning subaccount.
560    pub subaccount_id: i64,
561    /// Time-in-force.
562    pub time_in_force: DeriveTimeInForce,
563    /// Trigger price for trigger orders.
564    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
565    pub trigger_price: Option<Decimal>,
566    /// Trigger price source for trigger orders.
567    #[serde(default, skip_serializing_if = "Option::is_none")]
568    pub trigger_price_type: Option<DeriveTriggerPriceType>,
569    /// Trigger rejection text when the trigger worker cannot submit.
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub trigger_reject_message: Option<String>,
572    /// Stop-loss or take-profit trigger flag.
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub trigger_type: Option<DeriveTriggerType>,
575}
576
577/// Result envelope returned by `private/order`.
578#[derive(Clone, Debug, Serialize, Deserialize)]
579pub struct DeriveOrderResult {
580    /// Accepted order.
581    pub order: DeriveOrder,
582    /// Trades generated synchronously by the submission, when any.
583    #[serde(default, deserialize_with = "deserialize_salvaged_vec")]
584    pub trades: Vec<DeriveTrade>,
585}
586
587/// Result envelope returned by `private/replace`.
588#[derive(Clone, Debug, Serialize, Deserialize)]
589pub struct DeriveReplaceResult {
590    /// Newly accepted replacement order, absent when cancellation succeeded but creation failed.
591    #[serde(default, skip_serializing_if = "Option::is_none")]
592    pub order: Option<DeriveOrder>,
593    /// Cancelled stale order, omitted by some responses and mocks.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub cancelled_order: Option<DeriveOrder>,
596    /// Replacement creation error after the stale order was cancelled.
597    #[serde(default, skip_serializing_if = "Option::is_none")]
598    pub create_order_error: Option<JsonRpcError>,
599}
600
601/// Confirmed outcome returned by `private/replace`.
602#[derive(Clone, Debug)]
603pub enum DeriveReplaceOutcome {
604    /// The stale order was cancelled and the replacement was accepted.
605    Replaced(DeriveOrder),
606    /// The stale order was cancelled but replacement creation failed.
607    Canceled {
608        /// Cancelled stale order returned by the venue.
609        cancelled_order: DeriveOrder,
610        /// Structured error returned by replacement creation.
611        create_order_error: JsonRpcError,
612    },
613}
614
615impl DeriveReplaceResult {
616    /// Validates the response shape and cancellation identity.
617    ///
618    /// # Errors
619    ///
620    /// Returns an error if fields contradict each other, a cancellation record is unexpected, or
621    /// the replacement state is invalid.
622    pub(crate) fn into_outcome(
623        self,
624        expected_cancel_order_id: &str,
625        expected_replacement_label: &str,
626        expected_subaccount_id: u64,
627        expected_instrument: &str,
628    ) -> Result<DeriveReplaceOutcome, String> {
629        let validate_cancelled_order = |order: &DeriveOrder| {
630            if order.order_id != expected_cancel_order_id {
631                return Err(format!(
632                    "private/replace cancelled order {} did not match requested order {expected_cancel_order_id}",
633                    order.order_id,
634                ));
635            }
636
637            if order.order_status != DeriveOrderStatus::Cancelled {
638                return Err(format!(
639                    "private/replace cancellation record for {expected_cancel_order_id} had status {}",
640                    order.order_status,
641                ));
642            }
643
644            validate_replace_order_scope(
645                order,
646                expected_replacement_label,
647                expected_subaccount_id,
648                expected_instrument,
649            )
650        };
651
652        match (self.order, self.cancelled_order, self.create_order_error) {
653            (Some(order), cancelled_order, None) => {
654                if order.order_id == expected_cancel_order_id {
655                    return Err(format!(
656                        "private/replace returned the cancelled order {expected_cancel_order_id} as its replacement",
657                    ));
658                }
659
660                if !matches!(
661                    order.order_status,
662                    DeriveOrderStatus::Open | DeriveOrderStatus::Filled
663                ) {
664                    return Err(format!(
665                        "private/replace replacement {} had status {}",
666                        order.order_id, order.order_status,
667                    ));
668                }
669
670                validate_replace_order_scope(
671                    &order,
672                    expected_replacement_label,
673                    expected_subaccount_id,
674                    expected_instrument,
675                )?;
676
677                if let Some(cancelled_order) = cancelled_order.as_ref() {
678                    validate_cancelled_order(cancelled_order)?;
679                }
680
681                Ok(DeriveReplaceOutcome::Replaced(order))
682            }
683            (None, Some(cancelled_order), Some(create_order_error)) => {
684                validate_cancelled_order(&cancelled_order)?;
685                Ok(DeriveReplaceOutcome::Canceled {
686                    cancelled_order,
687                    create_order_error,
688                })
689            }
690            _ => Err("private/replace returned an inconsistent result".to_string()),
691        }
692    }
693}
694
695fn validate_replace_order_scope(
696    order: &DeriveOrder,
697    expected_replacement_label: &str,
698    expected_subaccount_id: u64,
699    expected_instrument: &str,
700) -> Result<(), String> {
701    if u64::try_from(order.subaccount_id).ok() != Some(expected_subaccount_id)
702        || order.instrument_name != expected_instrument
703    {
704        return Err(format!(
705            "private/replace order {} did not match requested account and instrument",
706            order.order_id
707        ));
708    }
709
710    if order.label != expected_replacement_label {
711        return Err(format!(
712            "private/replace order {} had label {}, expected {expected_replacement_label}",
713            order.order_id, order.label
714        ));
715    }
716
717    Ok(())
718}
719
720/// Result returned by `private/cancel_by_label`.
721#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
722pub struct DeriveCancelByLabelResult {
723    /// Confirmed count, or -1 for an unscoped acknowledgement requiring state confirmation.
724    pub cancelled_orders: i64,
725}
726
727/// Result returned by `private/cancel_by_instrument`.
728pub type DeriveCancelByInstrumentResult = DeriveCancelByLabelResult;
729
730/// Empty result returned by state-changing endpoints without a typed payload.
731#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
732pub struct DeriveEmptyResult {}
733
734impl<'de> Deserialize<'de> for DeriveEmptyResult {
735    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
736    where
737        D: serde::Deserializer<'de>,
738    {
739        match Value::deserialize(deserializer)? {
740            Value::Null | Value::Object(_) => Ok(Self {}),
741            Value::String(value) if value == "ok" => Ok(Self {}),
742            other => Err(serde::de::Error::custom(format!(
743                "empty Derive result must be an object, null, or \"ok\", was {other}"
744            ))),
745        }
746    }
747}
748
749/// Position record returned by `private/get_positions` and embedded in
750/// `private/get_subaccount` responses.
751#[derive(Clone, Debug, Serialize, Deserialize)]
752pub struct DerivePosition {
753    /// Signed position amount; positive = long, negative = short.
754    #[serde(deserialize_with = "deserialize_decimal_token")]
755    pub amount: Decimal,
756    /// Average entry price over the lifetime of the position; includes opening fees for perpetuals.
757    #[serde(deserialize_with = "deserialize_decimal_token")]
758    pub average_price: Decimal,
759    /// Position opening timestamp (UNIX ms).
760    pub creation_timestamp: i64,
761    /// Cumulative funding accrued by this position (perps only).
762    #[serde(deserialize_with = "deserialize_decimal_token")]
763    pub cumulative_funding: Decimal,
764    /// Position delta (with respect to forward for options).
765    #[serde(deserialize_with = "deserialize_decimal_token")]
766    pub delta: Decimal,
767    /// Position gamma (zero for non-options).
768    #[serde(deserialize_with = "deserialize_decimal_token")]
769    pub gamma: Decimal,
770    /// Current oracle index price for the underlying.
771    #[serde(deserialize_with = "deserialize_decimal_token")]
772    pub index_price: Decimal,
773    /// Signed USD initial-health contribution, including mark-to-market value.
774    #[serde(deserialize_with = "deserialize_decimal_token")]
775    pub initial_margin: Decimal,
776    /// Instrument identifier (same as the base asset name).
777    pub instrument_name: Ustr,
778    /// Instrument category.
779    pub instrument_type: DeriveInstrumentType,
780    /// Effective leverage (perps only).
781    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
782    pub leverage: Option<Decimal>,
783    /// Index price at which the position would liquidate.
784    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
785    pub liquidation_price: Option<Decimal>,
786    /// Signed USD maintenance-health contribution, including mark-to-market value.
787    #[serde(deserialize_with = "deserialize_decimal_token")]
788    pub maintenance_margin: Decimal,
789    /// Current mark price.
790    #[serde(deserialize_with = "deserialize_decimal_token")]
791    pub mark_price: Decimal,
792    /// USD mark-to-market value of the position.
793    #[serde(deserialize_with = "deserialize_decimal_token")]
794    pub mark_value: Decimal,
795    /// Net USD settled from this position.
796    #[serde(deserialize_with = "deserialize_decimal_token")]
797    pub net_settlements: Decimal,
798    /// USD open-order margin contribution for this asset.
799    #[serde(deserialize_with = "deserialize_decimal_token")]
800    pub open_orders_margin: Decimal,
801    /// Funding not yet settled into cash balance (perps only).
802    #[serde(deserialize_with = "deserialize_decimal_token")]
803    pub pending_funding: Decimal,
804    /// Realized PnL booked on this position.
805    #[serde(deserialize_with = "deserialize_decimal_token")]
806    pub realized_pnl: Decimal,
807    /// Position theta (zero for non-options).
808    #[serde(deserialize_with = "deserialize_decimal_token")]
809    pub theta: Decimal,
810    /// Unrealized PnL.
811    #[serde(deserialize_with = "deserialize_decimal_token")]
812    pub unrealized_pnl: Decimal,
813    /// Position vega (zero for non-options).
814    #[serde(deserialize_with = "deserialize_decimal_token")]
815    pub vega: Decimal,
816}
817
818/// Collateral row inside a `private/get_subaccount` response.
819#[derive(Clone, Debug, Serialize, Deserialize)]
820pub struct DeriveCollateral {
821    /// Collateral amount.
822    #[serde(
823        deserialize_with = "deserialize_decimal_token",
824        serialize_with = "serialize_decimal_as_str"
825    )]
826    pub amount: Decimal,
827    /// Asset name (e.g. `"ETH"`, `"USDC"`).
828    pub asset_name: Ustr,
829    /// Asset category.
830    pub asset_type: DeriveAssetType,
831    /// Cumulative interest earned or paid.
832    #[serde(
833        deserialize_with = "deserialize_decimal_token",
834        serialize_with = "serialize_decimal_as_str"
835    )]
836    pub cumulative_interest: Decimal,
837    /// Underlying currency.
838    pub currency: Ustr,
839    /// USD initial margin credit from this collateral.
840    #[serde(
841        deserialize_with = "deserialize_decimal_token",
842        serialize_with = "serialize_decimal_as_str"
843    )]
844    pub initial_margin: Decimal,
845    /// USD maintenance margin credit.
846    #[serde(
847        deserialize_with = "deserialize_decimal_token",
848        serialize_with = "serialize_decimal_as_str"
849    )]
850    pub maintenance_margin: Decimal,
851    /// Current mark price of the asset.
852    #[serde(
853        deserialize_with = "deserialize_decimal_token",
854        serialize_with = "serialize_decimal_as_str"
855    )]
856    pub mark_price: Decimal,
857    /// USD value (`amount * mark_price`).
858    #[serde(
859        deserialize_with = "deserialize_decimal_token",
860        serialize_with = "serialize_decimal_as_str"
861    )]
862    pub mark_value: Decimal,
863    /// Interest not yet settled on-chain.
864    #[serde(
865        deserialize_with = "deserialize_decimal_token",
866        serialize_with = "serialize_decimal_as_str"
867    )]
868    pub pending_interest: Decimal,
869}
870
871/// Subaccount snapshot returned by `private/get_subaccount`.
872#[derive(Clone, Debug, Serialize, Deserialize)]
873pub struct DeriveSubaccount {
874    /// Collateral rows contributing to margin.
875    pub collaterals: Vec<DeriveCollateral>,
876    /// Total initial margin credit from collaterals.
877    #[serde(deserialize_with = "deserialize_decimal_token")]
878    pub collaterals_initial_margin: Decimal,
879    /// Total maintenance margin credit from collaterals.
880    #[serde(deserialize_with = "deserialize_decimal_token")]
881    pub collaterals_maintenance_margin: Decimal,
882    /// Mark-to-market value of all collaterals.
883    #[serde(deserialize_with = "deserialize_decimal_token")]
884    pub collaterals_value: Decimal,
885    /// Currencies covered by the subaccount manager.
886    pub currency: Vec<Ustr>,
887    /// Whether the venue failed to load the portfolio.
888    pub failed_to_fetch: bool,
889    /// Signed net initial margin health, excluding open-order reservations.
890    /// Negative health blocks risk-increasing trades.
891    #[serde(deserialize_with = "deserialize_decimal_token")]
892    pub initial_margin: Decimal,
893    /// Whether the subaccount is mid-liquidation.
894    pub is_under_liquidation: bool,
895    /// Free-form subaccount label.
896    #[serde(default, skip_serializing_if = "Option::is_none")]
897    pub label: Option<String>,
898    /// Signed net maintenance margin health; negative permits liquidation.
899    #[serde(deserialize_with = "deserialize_decimal_token")]
900    pub maintenance_margin: Decimal,
901    /// Venue manager identity.
902    pub manager_id: u32,
903    /// Open-ended venue margin model (for example `"SM"` or `"PM2"`).
904    pub margin_type: String,
905    /// Maintenance-margin credit from risk-universe loss socialization.
906    #[serde(deserialize_with = "deserialize_decimal_token")]
907    pub mm_credits: Decimal,
908    /// Open orders held by the subaccount.
909    pub open_orders: Vec<DeriveOrder>,
910    /// Signed USD open-order reservation, excluded from `initial_margin`.
911    #[serde(deserialize_with = "deserialize_decimal_token")]
912    pub open_orders_margin: Decimal,
913    /// Open positions held by the subaccount.
914    pub positions: Vec<DerivePosition>,
915    /// Signed USD initial-health contribution from positions, including mark-to-market value.
916    #[serde(deserialize_with = "deserialize_decimal_token")]
917    pub positions_initial_margin: Decimal,
918    /// Signed USD maintenance-health contribution from positions, including mark-to-market value.
919    #[serde(deserialize_with = "deserialize_decimal_token")]
920    pub positions_maintenance_margin: Decimal,
921    /// Mark-to-market value of positions.
922    #[serde(deserialize_with = "deserialize_decimal_token")]
923    pub positions_value: Decimal,
924    /// Maintenance-margin change after the next option expiry.
925    #[serde(deserialize_with = "deserialize_decimal_token")]
926    pub projected_margin_change: Decimal,
927    /// Risk universe containing the manager.
928    pub risk_universe_id: u32,
929    /// Subaccount identifier.
930    pub subaccount_id: i64,
931    /// Total subaccount value (collateral + positions).
932    #[serde(deserialize_with = "deserialize_decimal_token")]
933    pub subaccount_value: Decimal,
934    /// Pending vault-deposit reservations on the source subaccount.
935    pub vault_deposit_holds: Vec<DeriveVaultDepositHold>,
936}
937
938/// Pending vault-deposit reservation returned with a subaccount.
939#[derive(Debug, Clone, Serialize, Deserialize)]
940pub struct DeriveVaultDepositHold {
941    /// Reserved amount in native asset units.
942    #[serde(
943        deserialize_with = "deserialize_decimal_token",
944        serialize_with = "serialize_decimal_as_str"
945    )]
946    pub amount: Decimal,
947    /// Reserved asset.
948    pub asset_name: Ustr,
949    /// Asset currency.
950    pub currency: Ustr,
951    /// Target vault identifier.
952    pub vault_id: u64,
953}
954
955/// Private trade record returned by `private/get_trade_history` and the
956/// `{subaccount_id}.trades` WS channel.
957#[derive(Clone, Debug, Serialize, Deserialize)]
958pub struct DeriveTrade {
959    /// Trade side.
960    pub direction: DeriveOrderSide,
961    /// Underlying index price at the time of the trade.
962    #[serde(deserialize_with = "deserialize_decimal")]
963    pub index_price: Decimal,
964    /// Instrument identifier.
965    pub instrument_name: Ustr,
966    /// Whether this trade was generated via `private/transfer_position`.
967    pub is_transfer: bool,
968    /// Free-form user label inherited from the order.
969    #[serde(default)]
970    pub label: Ustr,
971    /// Maker / taker role of the user.
972    pub liquidity_role: DeriveLiquidityRole,
973    /// Mark price at the time of the trade.
974    #[serde(deserialize_with = "deserialize_decimal")]
975    pub mark_price: Decimal,
976    /// Originating order ID.
977    pub order_id: String,
978    /// RFQ quote ID when relevant.
979    #[serde(default, skip_serializing_if = "Option::is_none")]
980    pub quote_id: Option<String>,
981    /// Realized PnL booked by this trade.
982    #[serde(deserialize_with = "deserialize_decimal")]
983    pub realized_pnl: Decimal,
984    /// Owning subaccount.
985    pub subaccount_id: i64,
986    /// Trade timestamp (UNIX ms).
987    pub timestamp: i64,
988    /// Filled amount on this trade.
989    #[serde(deserialize_with = "deserialize_decimal_token")]
990    pub trade_amount: Decimal,
991    /// Fee charged for this trade.
992    #[serde(deserialize_with = "deserialize_decimal_token")]
993    pub trade_fee: Decimal,
994    /// Trade identifier.
995    pub trade_id: String,
996    /// Trade execution price.
997    #[serde(deserialize_with = "deserialize_decimal_token")]
998    pub trade_price: Decimal,
999    /// Ethereum L1 batch transaction hash, absent before settlement.
1000    #[serde(default, skip_serializing_if = "Option::is_none")]
1001    pub tx_hash: Option<String>,
1002    /// Settlement batch stage when reported, independent of trade execution.
1003    #[serde(default, skip_serializing_if = "Option::is_none")]
1004    pub batch_status: Option<String>,
1005    /// Settlement operation identity, independent of the trade and order IDs.
1006    #[serde(default, skip_serializing_if = "Option::is_none")]
1007    pub op_uuid: Option<String>,
1008    /// Owning wallet address, absent in pending order responses.
1009    #[serde(default, skip_serializing_if = "Option::is_none")]
1010    pub wallet: Option<Ustr>,
1011}
1012
1013/// Public trade record returned by `public/get_trade_history` and the
1014/// `trades.{instrument_type}.{currency}` WS channel.
1015///
1016/// Public REST and WS rows carry participant roles and account detail. Optional
1017/// fields also accept sparse frames; settlement metadata does not gate trades.
1018#[derive(Clone, Debug, Serialize, Deserialize)]
1019pub struct DerivePublicTrade {
1020    /// Trade side.
1021    pub direction: DeriveOrderSide,
1022    /// Underlying index price at the time of the trade.
1023    #[serde(deserialize_with = "deserialize_decimal")]
1024    pub index_price: Decimal,
1025    /// Instrument identifier.
1026    pub instrument_name: Ustr,
1027    /// Role of this row's participant in the trade. The REST history endpoint
1028    /// returns one maker row and one taker row per trade under the same
1029    /// `trade_id`. Public WS rows also carry participant roles.
1030    #[serde(default, skip_serializing_if = "Option::is_none")]
1031    pub liquidity_role: Option<DeriveLiquidityRole>,
1032    /// Mark price at the time of the trade.
1033    #[serde(deserialize_with = "deserialize_decimal")]
1034    pub mark_price: Decimal,
1035    /// RFQ quote ID when relevant.
1036    #[serde(default, skip_serializing_if = "Option::is_none")]
1037    pub quote_id: Option<String>,
1038    /// RFQ session ID when the trade originated from a request-for-quote.
1039    #[serde(default, skip_serializing_if = "Option::is_none")]
1040    pub rfq_id: Option<String>,
1041    /// Realized PnL attributed to this participant, when supplied.
1042    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
1043    pub realized_pnl: Option<Decimal>,
1044    /// Participant subaccount, when supplied.
1045    #[serde(default, skip_serializing_if = "Option::is_none")]
1046    pub subaccount_id: Option<i64>,
1047    /// Trade timestamp (UNIX ms).
1048    pub timestamp: i64,
1049    /// Filled amount.
1050    #[serde(deserialize_with = "deserialize_decimal_token")]
1051    pub trade_amount: Decimal,
1052    /// Fee charged to this participant, when supplied.
1053    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
1054    pub trade_fee: Option<Decimal>,
1055    /// Trade identifier.
1056    pub trade_id: String,
1057    /// Execution price.
1058    #[serde(deserialize_with = "deserialize_decimal_token")]
1059    pub trade_price: Decimal,
1060    /// Ethereum L1 batch transaction hash from historical rows, when supplied.
1061    #[serde(default, skip_serializing_if = "Option::is_none")]
1062    pub tx_hash: Option<String>,
1063    /// Settlement batch stage from historical rows, when supplied.
1064    #[serde(default, skip_serializing_if = "Option::is_none")]
1065    pub batch_status: Option<String>,
1066    /// Settlement operation identity accepted when supplied.
1067    #[serde(default, skip_serializing_if = "Option::is_none")]
1068    pub op_uuid: Option<String>,
1069    /// Participant wallet address, when supplied.
1070    #[serde(default, skip_serializing_if = "Option::is_none")]
1071    pub wallet: Option<Ustr>,
1072}
1073
1074/// Pagination metadata attached to listing endpoints.
1075#[derive(Clone, Debug, Serialize, Deserialize)]
1076pub struct DerivePaginationInfo {
1077    /// Total number of items across all pages.
1078    pub count: i64,
1079    /// Number of pages available.
1080    pub num_pages: i64,
1081}
1082
1083#[derive(Debug, Clone, Deserialize)]
1084pub(crate) struct DeriveInstrumentsResult {
1085    pub(crate) instruments: Vec<DeriveInstrument>,
1086    pub(crate) pagination: DerivePaginationInfo,
1087}
1088
1089/// Paginated `private/get_orders` result envelope.
1090#[derive(Clone, Debug, Serialize, Deserialize)]
1091pub struct DeriveOrdersResult {
1092    /// Orders on the current page. Deliberately strict: reconciliation infers
1093    /// state from absence, so a dropped order row is worse than a loud failure.
1094    pub orders: Vec<DeriveOrder>,
1095    /// Pagination metadata.
1096    pub pagination: DerivePaginationInfo,
1097    /// Owning subaccount.
1098    pub subaccount_id: i64,
1099}
1100
1101/// `private/get_open_orders` result envelope.
1102#[derive(Clone, Debug, Serialize, Deserialize)]
1103pub struct DeriveOpenOrdersResult {
1104    /// Currently open orders. Deliberately strict: reconciliation infers
1105    /// state from absence, so a dropped order row is worse than a loud failure.
1106    pub orders: Vec<DeriveOrder>,
1107    /// Owning subaccount.
1108    pub subaccount_id: i64,
1109}
1110
1111/// Paginated `private/get_trade_history` result envelope.
1112#[derive(Clone, Debug, Serialize)]
1113pub struct DeriveTradesResult {
1114    /// Trades on the current page.
1115    pub trades: Vec<DeriveTrade>,
1116    /// Pagination metadata.
1117    pub pagination: DerivePaginationInfo,
1118    /// Owning subaccount.
1119    pub subaccount_id: i64,
1120    #[serde(skip)]
1121    pub(crate) records_complete: bool,
1122}
1123
1124impl<'de> Deserialize<'de> for DeriveTradesResult {
1125    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1126        #[derive(Deserialize)]
1127        struct TradePage {
1128            trades: Vec<Box<RawValue>>,
1129            pagination: DerivePaginationInfo,
1130            subaccount_id: i64,
1131        }
1132        #[derive(Deserialize)]
1133        struct TradeFee {
1134            #[serde(rename = "trade_fee", deserialize_with = "deserialize_decimal_token")]
1135            _trade_fee: Decimal,
1136        }
1137        let page = TradePage::deserialize(deserializer)?;
1138        for trade in &page.trades {
1139            if !trade.get().starts_with('{') {
1140                return Err(serde::de::Error::custom(
1141                    "private trade row must be an object",
1142                ));
1143            }
1144
1145            let _: TradeFee =
1146                serde_json::from_str(trade.get()).map_err(serde::de::Error::custom)?;
1147        }
1148
1149        let count = page.trades.len();
1150        let trades: Vec<DeriveTrade> = salvage_rows(page.trades);
1151        Ok(Self {
1152            records_complete: trades.len() == count,
1153            trades,
1154            pagination: page.pagination,
1155            subaccount_id: page.subaccount_id,
1156        })
1157    }
1158}
1159
1160/// Paginated `public/get_trade_history` result envelope.
1161#[derive(Clone, Debug, Serialize, Deserialize)]
1162pub struct DerivePublicTradesResult {
1163    /// Trades on the current page.
1164    pub trades: Vec<DerivePublicTrade>,
1165    /// Pagination metadata.
1166    pub pagination: DerivePaginationInfo,
1167}
1168
1169/// OHLCV candle returned by `public/get_tradingview_chart_data`.
1170///
1171/// The venue ships the `result` field as a flat array of these records; the
1172/// HTTP client deserializes that array directly into `Vec<DerivePublicCandle>`.
1173/// Timestamps are UNIX **seconds** (not milliseconds, as on the trade and
1174/// funding endpoints).
1175#[derive(Clone, Debug, Serialize, Deserialize)]
1176pub struct DerivePublicCandle {
1177    /// Open price for the bucket.
1178    #[serde(deserialize_with = "deserialize_decimal")]
1179    pub open_price: Decimal,
1180    /// High price for the bucket.
1181    #[serde(deserialize_with = "deserialize_decimal")]
1182    pub high_price: Decimal,
1183    /// Low price for the bucket.
1184    #[serde(deserialize_with = "deserialize_decimal")]
1185    pub low_price: Decimal,
1186    /// Close price for the bucket.
1187    #[serde(deserialize_with = "deserialize_decimal")]
1188    pub close_price: Decimal,
1189    /// Notional volume in USD over the bucket.
1190    #[serde(deserialize_with = "deserialize_decimal")]
1191    pub volume_usd: Decimal,
1192    /// Base-asset volume (contracts) over the bucket.
1193    #[serde(deserialize_with = "deserialize_decimal")]
1194    pub volume_contracts: Decimal,
1195    /// Sample timestamp (UNIX seconds).
1196    pub timestamp: i64,
1197    /// Bucket start timestamp (UNIX seconds); regularly spaced by `period`.
1198    pub timestamp_bucket: i64,
1199}
1200
1201/// Funding rate sample returned by `public/get_funding_rate_history`.
1202#[derive(Clone, Debug, Serialize, Deserialize)]
1203pub struct DerivePublicFundingRate {
1204    /// Funding rate observed at `timestamp` (fraction per funding interval).
1205    #[serde(deserialize_with = "deserialize_decimal")]
1206    pub funding_rate: Decimal,
1207    /// Sample timestamp (UNIX ms).
1208    pub timestamp: i64,
1209}
1210
1211/// `public/get_funding_rate_history` result envelope.
1212#[derive(Clone, Debug, Serialize, Deserialize)]
1213pub struct DerivePublicFundingRateHistoryResult {
1214    /// Funding rate samples in venue response order (currently newest to oldest).
1215    pub funding_rate_history: Vec<DerivePublicFundingRate>,
1216}
1217
1218/// `private/get_positions` result envelope.
1219#[derive(Clone, Debug, Serialize, Deserialize)]
1220pub struct DerivePositionsResult {
1221    /// Positions held by the subaccount. Deliberately strict: mass status
1222    /// synthesizes flat reports for instruments absent from this list, so a
1223    /// dropped position row would report "flat" for a position the venue
1224    /// still holds.
1225    pub positions: Vec<DerivePosition>,
1226    /// Owning subaccount.
1227    pub subaccount_id: i64,
1228}
1229
1230fn cancel_reason_empty() -> DeriveOrderCancelReason {
1231    DeriveOrderCancelReason::Empty
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236    use std::path::PathBuf;
1237
1238    use nautilus_core::UnixNanos;
1239    use nautilus_model::identifiers::AccountId;
1240    use rstest::rstest;
1241    use serde_json::{Value, json};
1242
1243    use super::*;
1244
1245    fn data_path() -> PathBuf {
1246        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data")
1247    }
1248
1249    fn load_json(filename: &str) -> Value {
1250        let content = std::fs::read_to_string(data_path().join(filename))
1251            .unwrap_or_else(|_| panic!("failed to read {filename}"));
1252        serde_json::from_str(&content).expect("invalid json")
1253    }
1254
1255    #[rstest]
1256    fn test_request_serializes_with_jsonrpc_version_tag() {
1257        let req = JsonRpcRequest::new(7, "public/get_instruments", json!({"currency": "ETH"}));
1258        let wire = serde_json::to_value(&req).unwrap();
1259        assert_eq!(wire["jsonrpc"], "2.0");
1260        assert_eq!(wire["id"], 7);
1261        assert_eq!(wire["method"], "public/get_instruments");
1262        assert_eq!(wire["params"]["currency"], "ETH");
1263    }
1264
1265    #[rstest]
1266    fn test_response_decodes_success_envelope() {
1267        let body = json!({"id": 1, "result": {"instruments": []}});
1268        let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
1269        assert_eq!(resp.id, Some(1));
1270        assert!(resp.error.is_none());
1271        assert!(resp.result.is_some());
1272    }
1273
1274    #[rstest]
1275    fn test_response_decodes_error_envelope() {
1276        let body = json!({
1277            "id": 9,
1278            "error": {"code": -32600, "message": "Invalid Request"}
1279        });
1280        let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
1281        assert_eq!(resp.id, Some(9));
1282        assert!(resp.result.is_none());
1283        let err = resp.error.expect("error present");
1284        assert_eq!(err.code, -32600);
1285        assert_eq!(err.message, "Invalid Request");
1286        assert!(err.data.is_none());
1287    }
1288
1289    #[rstest]
1290    fn test_response_decodes_error_envelope_with_data_field() {
1291        let body = json!({
1292            "id": 9,
1293            "error": {
1294                "code": -32602,
1295                "message": "Invalid params",
1296                "data": {"field": "currency"},
1297            }
1298        });
1299        let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
1300        let err = resp.error.expect("error present");
1301        assert_eq!(err.data, Some(json!({"field": "currency"})));
1302    }
1303
1304    #[rstest]
1305    fn test_response_tolerates_missing_id() {
1306        let body = json!({"result": {"ok": true}});
1307        let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
1308        assert!(resp.id.is_none());
1309        assert!(resp.result.is_some());
1310    }
1311
1312    #[rstest]
1313    fn test_response_tolerates_string_id() {
1314        let body = json!({"id": "e3c970c6-94aa-420c-b6db-d0f585a7fde9", "result": {"ok": true}});
1315        let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
1316        assert!(resp.id.is_none());
1317        assert!(resp.result.is_some());
1318    }
1319
1320    #[rstest]
1321    fn test_response_decodes_numeric_string_id() {
1322        let body = json!({"id": "42", "result": {"ok": true}});
1323        let resp: JsonRpcResponse<Value> = serde_json::from_value(body).unwrap();
1324        assert_eq!(resp.id, Some(42));
1325        assert!(resp.result.is_some());
1326    }
1327
1328    #[rstest]
1329    fn test_instrument_decodes_perp_with_perp_details() {
1330        let body = load_json("perps/instrument_eth.json");
1331        let instrument: DeriveInstrument = serde_json::from_value(body).unwrap();
1332        assert_eq!(instrument.instrument_name, "ETH-PERP");
1333        assert_eq!(instrument.instrument_type, DeriveInstrumentType::Perp);
1334        assert!(instrument.option_details.is_none());
1335        let perp = instrument.perp_details.expect("perp details present");
1336        assert_eq!(perp.index, "ETH-USD");
1337    }
1338
1339    #[rstest]
1340    fn test_instrument_decodes_option_with_option_details() {
1341        let mut body = load_json("options/instrument_eth.json");
1342        body["scheduled_activation"] = json!(0);
1343        let instrument: DeriveInstrument = serde_json::from_value(body).unwrap();
1344        let option = instrument.option_details.expect("option details present");
1345        assert_eq!(option.option_type, DeriveOptionKind::Call);
1346        assert_eq!(option.strike.to_string(), "3500");
1347        assert!(option.settlement_price.is_none());
1348    }
1349
1350    #[rstest]
1351    #[case(1_695_836_058_725_001_000)]
1352    #[case(u64::MAX)]
1353    fn test_order_nonce_string_round_trip(#[case] nonce: u64) {
1354        let mut body = load_json("perps/http_order_eth_partially_filled.json");
1355        body["nonce"] = json!(nonce.to_string());
1356        let order: DeriveOrder = serde_json::from_value(body).unwrap();
1357        let encoded = serde_json::to_value(&order).unwrap();
1358        let decoded: DeriveOrder = serde_json::from_value(encoded.clone()).unwrap();
1359        assert_eq!(order.nonce, nonce);
1360        assert_eq!(encoded["nonce"], nonce.to_string());
1361        assert_eq!(serde_json::to_value(decoded).unwrap(), encoded);
1362    }
1363
1364    #[rstest]
1365    #[case(json!(""))]
1366    #[case(json!("-1"))]
1367    #[case(json!("18446744073709551616"))]
1368    #[case(json!(1_695_836_058_725_001_000_u64))]
1369    fn test_order_nonce_rejects_invalid_wire_values(#[case] nonce: Value) {
1370        let mut body = load_json("perps/http_order_eth_partially_filled.json");
1371        body["nonce"] = nonce;
1372        assert!(serde_json::from_value::<DeriveOrder>(body).is_err());
1373    }
1374
1375    #[rstest]
1376    #[case(true, false)]
1377    #[case(false, true)]
1378    #[case(true, true)]
1379    fn test_order_defaults_optional_label_and_cancel_reason(
1380        #[case] omit_label: bool,
1381        #[case] omit_reason: bool,
1382    ) {
1383        let mut body = load_json("perps/http_order_eth_partially_filled.json");
1384        if omit_label {
1385            body["label"] = json!("");
1386        }
1387
1388        let expected: DeriveOrder = serde_json::from_value(body.clone()).unwrap();
1389        let fields = body.as_object_mut().unwrap();
1390        if omit_label {
1391            fields.remove("label");
1392        }
1393
1394        if omit_reason {
1395            fields.remove("cancel_reason");
1396        }
1397
1398        let order: DeriveOrder = serde_json::from_value(body).unwrap();
1399        assert_eq!(
1400            serde_json::to_value(order).unwrap(),
1401            serde_json::to_value(expected).unwrap()
1402        );
1403    }
1404
1405    #[rstest]
1406    fn test_order_decodes_partially_filled_market_order() {
1407        // Distinct `amount` and `filled_amount` so a struct-field swap is
1408        // detectable. Every Ustr-typed field has a unique value so a serde
1409        // rename or field-order regression surfaces against a single fixture.
1410        let body = load_json("perps/http_order_eth_partially_filled.json");
1411        let order: DeriveOrder = serde_json::from_value(body).unwrap();
1412        let debug = format!("{order:?}");
1413        assert_eq!(order.amount.to_string(), "2.0");
1414        assert_eq!(order.filled_amount.to_string(), "1.5");
1415        assert_eq!(order.average_price.to_string(), "3500.25");
1416        assert_eq!(order.order_status, DeriveOrderStatus::Filled);
1417        assert_eq!(order.cancel_reason, DeriveOrderCancelReason::Empty);
1418        assert_eq!(order.direction, DeriveOrderSide::Buy);
1419        assert_eq!(order.time_in_force, DeriveTimeInForce::Ioc);
1420        assert_eq!(order.order_type, DeriveOrderType::Market);
1421        assert_eq!(order.instrument_name, "ETH-PERP");
1422        assert_eq!(order.label, "alpha-strategy");
1423        assert_eq!(order.signer, "0xsigner");
1424        assert_eq!(order.order_id, "abc-123");
1425        assert_eq!(order.subaccount_id, 42);
1426        assert_eq!(order.signature_expiry_sec, 1_700_001_000);
1427        assert!(!order.mmp);
1428        assert!(!order.is_transfer);
1429        assert!(order.quote_id.is_none());
1430        assert!(order.replaced_order_id.is_none());
1431        assert!(debug.contains(REDACTED));
1432        assert!(!debug.contains("0xdead"));
1433    }
1434
1435    #[rstest]
1436    fn test_replace_result_decodes_canceled_without_replacement() {
1437        let mut cancelled_order = load_json("perps/http_order_eth_partially_filled.json");
1438        cancelled_order["order_id"] = json!("old-order");
1439        cancelled_order["order_status"] = json!("cancelled");
1440        cancelled_order["label"] = json!("replacement-label");
1441        let result: DeriveReplaceResult = serde_json::from_value(json!({
1442            "order": null,
1443            "cancelled_order": cancelled_order,
1444            "create_order_error": {
1445                "code": 10001,
1446                "message": "insufficient margin",
1447            },
1448        }))
1449        .unwrap();
1450
1451        let outcome = result
1452            .into_outcome("old-order", "replacement-label", 42, "ETH-PERP")
1453            .unwrap();
1454
1455        let DeriveReplaceOutcome::Canceled {
1456            cancelled_order,
1457            create_order_error,
1458        } = outcome
1459        else {
1460            panic!("expected canceled outcome");
1461        };
1462
1463        assert_eq!(cancelled_order.order_id, "old-order");
1464        assert_eq!(cancelled_order.order_status, DeriveOrderStatus::Cancelled);
1465        assert_eq!(create_order_error.code, 10001);
1466        assert_eq!(create_order_error.message, "insufficient margin");
1467        assert!(create_order_error.data.is_none());
1468    }
1469
1470    #[rstest]
1471    fn test_replace_result_rejects_non_cancelled_partial_record() {
1472        let mut cancelled_order = load_json("perps/http_order_eth_partially_filled.json");
1473        cancelled_order["order_id"] = json!("old-order");
1474        cancelled_order["order_status"] = json!("open");
1475        let result: DeriveReplaceResult = serde_json::from_value(json!({
1476            "order": null,
1477            "cancelled_order": cancelled_order,
1478            "create_order_error": {
1479                "code": 10001,
1480                "message": "insufficient margin",
1481            },
1482        }))
1483        .unwrap();
1484
1485        let error = result
1486            .into_outcome("old-order", "replacement-label", 42, "ETH-PERP")
1487            .unwrap_err();
1488        assert!(error.contains("had status open"));
1489    }
1490
1491    #[rstest]
1492    fn test_replace_result_rejects_mismatched_replacement_label() {
1493        let mut replacement_order = load_json("perps/http_order_eth_partially_filled.json");
1494        replacement_order["order_id"] = json!("new-order");
1495        replacement_order["order_status"] = json!("open");
1496        replacement_order["label"] = json!("wrong-label");
1497        let result: DeriveReplaceResult = serde_json::from_value(json!({
1498            "order": replacement_order,
1499            "cancelled_order": null,
1500            "create_order_error": null,
1501        }))
1502        .unwrap();
1503
1504        let error = result
1505            .into_outcome("old-order", "expected-label", 42, "ETH-PERP")
1506            .unwrap_err();
1507        assert!(error.contains("had label wrong-label, expected expected-label"));
1508    }
1509
1510    #[rstest]
1511    fn test_position_decodes_perp_with_optional_leverage() {
1512        // Distinct decimal values per field so a struct-field swap surfaces.
1513        let body = load_json("perps/http_position_eth.json");
1514        let position: DerivePosition = serde_json::from_value(body).unwrap();
1515        assert_eq!(position.instrument_type, DeriveInstrumentType::Perp);
1516        assert_eq!(position.instrument_name, "ETH-PERP");
1517        assert_eq!(position.amount.to_string(), "-2");
1518        assert_eq!(position.delta.to_string(), "-2");
1519        assert_eq!(position.gamma.to_string(), "0.1");
1520        assert_eq!(position.theta.to_string(), "-0.3");
1521        assert_eq!(position.vega.to_string(), "0.5");
1522        assert_eq!(position.unrealized_pnl.to_string(), "8");
1523        assert_eq!(position.mark_value.to_string(), "-7008");
1524        assert_eq!(
1525            position.leverage.as_ref().map(ToString::to_string),
1526            Some("5.0".into()),
1527        );
1528        assert_eq!(
1529            position.liquidation_price.as_ref().map(ToString::to_string),
1530            Some("4200".into()),
1531        );
1532    }
1533
1534    #[rstest]
1535    fn test_subaccount_decodes_with_collaterals_and_open_orders() {
1536        let body = load_json("common/http_subaccount_usdc.json");
1537        let subaccount: DeriveSubaccount = serde_json::from_value(body).unwrap();
1538        assert_eq!(subaccount.subaccount_id, 42);
1539        assert_eq!(subaccount.margin_type, "SM");
1540        assert_eq!(subaccount.collaterals.len(), 1);
1541        assert_eq!(subaccount.collaterals[0].asset_type, DeriveAssetType::Erc20);
1542        assert!(!subaccount.is_under_liquidation);
1543    }
1544
1545    #[rstest]
1546    #[case("currency")]
1547    #[case("manager_id")]
1548    #[case("risk_universe_id")]
1549    #[case("failed_to_fetch")]
1550    #[case("margin_type")]
1551    #[case("mm_credits")]
1552    #[case("projected_margin_change")]
1553    #[case("vault_deposit_holds")]
1554    #[case("positions_value")]
1555    #[case("positions")]
1556    #[case("open_orders")]
1557    fn test_subaccount_requires_complete_v3_envelope(#[case] field: &str) {
1558        let mut body = load_json("common/http_subaccount_pm2.json");
1559        body.as_object_mut().unwrap().remove(field);
1560
1561        assert!(serde_json::from_value::<DeriveSubaccount>(body).is_err());
1562    }
1563
1564    #[rstest]
1565    #[case(serde_json::Value::Null)]
1566    #[case(json!(""))]
1567    fn test_subaccount_rejects_null_or_empty_required_decimals(#[case] invalid: Value) {
1568        for field in [
1569            "initial_margin",
1570            "maintenance_margin",
1571            "positions_initial_margin",
1572            "positions_maintenance_margin",
1573            "positions_value",
1574            "open_orders_margin",
1575            "mm_credits",
1576            "projected_margin_change",
1577        ] {
1578            let mut body = load_json("common/http_subaccount_pm2.json");
1579            body[field] = invalid.clone();
1580            assert!(
1581                serde_json::from_value::<DeriveSubaccount>(body).is_err(),
1582                "{field}"
1583            );
1584        }
1585
1586        let mut body = load_json("common/http_subaccount_pm2.json");
1587        body["collaterals"][0]["amount"] = invalid;
1588        assert!(serde_json::from_value::<DeriveSubaccount>(body).is_err());
1589    }
1590
1591    #[rstest]
1592    #[case("amount")]
1593    #[case("average_price")]
1594    #[case("filled_amount")]
1595    #[case("limit_price")]
1596    #[case("max_fee")]
1597    #[case("order_fee")]
1598    fn test_subaccount_rejects_empty_or_null_order_decimal(#[case] field: &str) {
1599        for invalid in [Value::Null, json!("")] {
1600            let mut body = load_json("common/http_subaccount_pm2.json");
1601            let mut order = load_json("perps/http_order_eth_partially_filled.json");
1602            order[field] = invalid;
1603            body["open_orders"] = json!([order]);
1604
1605            assert!(
1606                serde_json::from_value::<DeriveSubaccount>(body).is_err(),
1607                "{field}"
1608            );
1609        }
1610    }
1611
1612    #[rstest]
1613    fn test_subaccount_decodes_high_scale_decimal_values() {
1614        let body = load_json("common/http_subaccount_high_scale.json");
1615        let subaccount: DeriveSubaccount = serde_json::from_value(body).unwrap();
1616        let position = &subaccount.positions[0];
1617
1618        assert_eq!(
1619            subaccount.initial_margin.to_string(),
1620            "0.1234567890123456789012345679",
1621        );
1622        assert_eq!(
1623            subaccount.collaterals[0].amount.to_string(),
1624            "0.1234567890123456789012345679",
1625        );
1626        assert_eq!(
1627            position.pending_funding.to_string(),
1628            "0.1234567890123456789012345679",
1629        );
1630        assert_eq!(
1631            position.leverage.as_ref().map(ToString::to_string),
1632            Some("5.1234567890123456789012345679".into()),
1633        );
1634        assert_eq!(
1635            position.liquidation_price.as_ref().map(ToString::to_string),
1636            Some("4200.1234567890123456789012346".into()),
1637        );
1638
1639        // Order rows nested in the snapshot carry the same high-scale values.
1640        let open_order = &subaccount.open_orders[0];
1641        assert_eq!(
1642            open_order.filled_amount.to_string(),
1643            "0.1234567890123456789012345679",
1644        );
1645        assert_eq!(
1646            open_order.max_fee.to_string(),
1647            "0.1234567890123456789012345679",
1648        );
1649    }
1650
1651    #[rstest]
1652    fn test_subaccount_retains_unknown_order_status_for_account_conversion() {
1653        let body = load_json("common/http_subaccount_unknown_variants.json");
1654        let subaccount: DeriveSubaccount = serde_json::from_value(body).unwrap();
1655        assert_eq!(subaccount.open_orders.len(), 2);
1656        assert_eq!(
1657            subaccount.open_orders[1].order_status,
1658            DeriveOrderStatus::Unknown
1659        );
1660        let mut modeled = subaccount.clone();
1661        modeled.open_orders[1].order_status = DeriveOrderStatus::Open;
1662        let expected = crate::http::parse::parse_derive_subaccount_to_balances(&modeled).unwrap();
1663        let actual = crate::http::parse::parse_derive_subaccount_to_balances(&subaccount).unwrap();
1664
1665        assert_eq!(actual, expected);
1666    }
1667
1668    #[rstest]
1669    fn test_subaccount_decodes_populated_vault_deposit_hold() {
1670        let mut body = load_json("common/http_subaccount_pm2.json");
1671        let hold = json!({
1672            "amount": "0.000000000123456789",
1673            "asset_name": "ETH",
1674            "currency": "ETH",
1675            "vault_id": 123,
1676        });
1677        body["vault_deposit_holds"] = json!([hold]);
1678
1679        let subaccount: DeriveSubaccount = serde_json::from_value(body).unwrap();
1680
1681        assert_eq!(
1682            serde_json::to_value(&subaccount.vault_deposit_holds).unwrap(),
1683            json!([hold])
1684        );
1685        assert_eq!(
1686            subaccount.vault_deposit_holds[0].amount.to_string(),
1687            "0.000000000123456789"
1688        );
1689    }
1690
1691    #[rstest]
1692    fn test_public_trade_round_trips() {
1693        let body = load_json("perps/http_public_trade_eth_sell.json");
1694        let trade: DerivePublicTrade = serde_json::from_value(body).unwrap();
1695        assert_eq!(trade.direction, DeriveOrderSide::Sell);
1696        assert_eq!(trade.batch_status.as_deref(), Some("Settled"));
1697        let reserialized = serde_json::to_value(&trade).unwrap();
1698        assert_eq!(reserialized["instrument_name"], "ETH-PERP");
1699        assert_eq!(reserialized["liquidity_role"], "taker");
1700    }
1701
1702    #[rstest]
1703    fn test_orders_result_envelope_decodes() {
1704        let body = json!({
1705            "orders": [],
1706            "pagination": {"count": 0, "num_pages": 0},
1707            "subaccount_id": 42,
1708        });
1709        let result: DeriveOrdersResult = serde_json::from_value(body).unwrap();
1710        assert!(result.orders.is_empty());
1711        assert_eq!(result.subaccount_id, 42);
1712        assert_eq!(result.pagination.count, 0);
1713    }
1714
1715    #[rstest]
1716    fn test_orders_result_decodes_unknown_variant_fields() {
1717        let body = load_json("perps/http_orders_result_eth_unknown_variants.json");
1718        let result: DeriveOrdersResult = serde_json::from_value(body).unwrap();
1719
1720        assert_eq!(result.orders.len(), 2);
1721        assert_eq!(result.orders[0].order_status, DeriveOrderStatus::Open);
1722        let unknowns = &result.orders[1];
1723        assert_eq!(unknowns.order_status, DeriveOrderStatus::Cancelled);
1724        assert_eq!(unknowns.cancel_reason, DeriveOrderCancelReason::Unknown);
1725        assert_eq!(unknowns.order_type, DeriveOrderType::Unknown);
1726        assert_eq!(unknowns.time_in_force, DeriveTimeInForce::Unknown);
1727        assert_eq!(unknowns.trigger_type, Some(DeriveTriggerType::Unknown));
1728        assert_eq!(
1729            unknowns.trigger_price_type,
1730            Some(DeriveTriggerPriceType::Unknown),
1731        );
1732    }
1733
1734    #[rstest]
1735    fn test_orders_result_retains_unknown_status_and_rejects_report_conversion() {
1736        let mut body = load_json("perps/http_orders_result_eth_unknown_variants.json");
1737        body["orders"][0]["order_status"] = json!("queued");
1738        let result: DeriveOrdersResult = serde_json::from_value(body).unwrap();
1739        assert_eq!(result.orders.len(), 2);
1740        assert_eq!(result.orders[0].order_status, DeriveOrderStatus::Unknown);
1741        let error = crate::http::parse::parse_derive_order_to_report(
1742            &result.orders[0],
1743            AccountId::from("DERIVE-001"),
1744            UnixNanos::from(17),
1745        )
1746        .unwrap_err();
1747        assert_eq!(error.to_string(), "unmodeled Derive order status");
1748    }
1749
1750    #[rstest]
1751    fn test_positions_result_fails_on_undecodable_row() {
1752        // Pins the deliberate strictness documented on the struct.
1753        let mut body = load_json("perps/http_positions_result_eth.json");
1754        body["positions"][0]["amount"] = json!({});
1755
1756        assert!(serde_json::from_value::<DerivePositionsResult>(body).is_err());
1757    }
1758
1759    fn perp_ticker_json() -> Value {
1760        load_json("perps/http_ticker_eth_snapshot.json")
1761    }
1762
1763    #[rstest]
1764    fn test_ticker_decodes_perp_snapshot() {
1765        let ticker: DeriveTicker = serde_json::from_value(perp_ticker_json()).unwrap();
1766        assert_eq!(ticker.instrument_name, "ETH-PERP");
1767        assert_eq!(ticker.instrument_type, DeriveInstrumentType::Perp);
1768        assert_eq!(ticker.mark_price.to_string(), "3500.5");
1769        assert_eq!(ticker.best_bid_price.to_string(), "3499.5");
1770        assert_eq!(ticker.best_ask_price.to_string(), "3501.0");
1771        assert_eq!(ticker.timestamp, 1_700_000_000_000);
1772        assert!(ticker.option_details.is_none());
1773        assert!(ticker.option_pricing.is_none());
1774        let perp = ticker.perp_details.expect("perp details present");
1775        assert_eq!(perp.index, "ETH-USD");
1776        assert_eq!(perp.funding_rate.to_string(), "0.0002");
1777        let stats = ticker
1778            .stats
1779            .as_ref()
1780            .expect("WS ticker fixture includes stats");
1781        assert_eq!(stats.contract_volume.to_string(), "12345.6");
1782        assert_eq!(stats.high.to_string(), "3600");
1783        assert_eq!(stats.num_trades.to_string(), "789");
1784    }
1785
1786    #[rstest]
1787    fn test_ticker_decodes_option_snapshot_with_greeks() {
1788        let body = load_json("options/http_ticker_eth_snapshot.json");
1789        let ticker: DeriveTicker = serde_json::from_value(body).unwrap();
1790        assert_eq!(ticker.instrument_type, DeriveInstrumentType::Option);
1791        assert!(ticker.perp_details.is_none());
1792        let option = ticker.option_details.expect("option details present");
1793        assert_eq!(option.option_type, DeriveOptionKind::Call);
1794        assert_eq!(option.strike.to_string(), "3500");
1795        assert!(option.settlement_price.is_none());
1796        let greeks = ticker.option_pricing.expect("option pricing present");
1797        assert_eq!(greeks.delta.to_string(), "0.55");
1798        assert_eq!(greeks.gamma.to_string(), "0.0008");
1799        assert_eq!(greeks.theta.to_string(), "-2.1");
1800        assert_eq!(greeks.vega.to_string(), "4.5");
1801        assert_eq!(greeks.iv.to_string(), "0.60");
1802        assert_eq!(greeks.forward_price.to_string(), "3505");
1803    }
1804
1805    #[rstest]
1806    fn test_private_trade_decodes_with_order_link() {
1807        // Asserts the fields that distinguish DeriveTrade from DerivePublicTrade
1808        // (order_id, label, is_transfer, realized_pnl) plus the Ustr-typed
1809        // wallet and the typed enum fields.
1810        let body = load_json("perps/http_private_trade_eth.json");
1811        let trade: DeriveTrade = serde_json::from_value(body).unwrap();
1812        assert_eq!(trade.direction, DeriveOrderSide::Buy);
1813        assert_eq!(trade.liquidity_role, DeriveLiquidityRole::Maker);
1814        assert_eq!(trade.batch_status.as_deref(), Some("Settled"));
1815        assert_eq!(trade.instrument_name, "ETH-PERP");
1816        assert_eq!(trade.label, "alpha-strategy");
1817        assert_eq!(trade.wallet.as_ref().map(Ustr::as_str), Some("0xwallet"));
1818        assert_eq!(trade.order_id, "order-abc");
1819        assert_eq!(trade.trade_id, "trade-xyz");
1820        assert_eq!(trade.subaccount_id, 42);
1821        assert_eq!(trade.realized_pnl.to_string(), "12.5");
1822        assert_eq!(trade.trade_amount.to_string(), "0.5");
1823        assert_eq!(trade.trade_price.to_string(), "3499.0");
1824        assert!(!trade.is_transfer);
1825        assert!(trade.quote_id.is_none());
1826        assert_eq!(trade.tx_hash.as_deref(), Some("0xhash"));
1827    }
1828
1829    #[rstest]
1830    #[case("trade_amount", "0.5")]
1831    #[case("trade_price", "3499.0")]
1832    #[case("trade_fee", "0.02")]
1833    fn test_native_trade_numeric_tokens_preserve_exact_value(
1834        #[case] field: &str,
1835        #[case] original: &str,
1836        #[values(
1837            ("1234567.123456789012", rust_decimal_macros::dec!(1234567.123456789012)),
1838            ("1.234567890123456789e-10", rust_decimal_macros::dec!(0.0000000001234567890123456789))
1839        )]
1840        (token, expected): (&str, Decimal),
1841    ) {
1842        let fixture = include_str!("../../test_data/perps/http_private_trade_eth.json");
1843        let row = fixture.replace(
1844            &format!("\"{field}\": \"{original}\""),
1845            &format!("\"{field}\": {token}"),
1846        );
1847        assert_ne!(row, fixture);
1848        let direct: DeriveTrade = serde_json::from_str(&row).unwrap();
1849        let page = serde_json::to_string(&serde_json::json!({
1850            "pagination": {"count": 1, "num_pages": 1},
1851            "subaccount_id": 42,
1852        }))
1853        .unwrap();
1854        let page = format!("{},\"trades\":[{row}]}}", page.trim_end_matches('}'));
1855        let page: DeriveTradesResult = serde_json::from_str(&page).unwrap();
1856        let subscription: crate::websocket::DeriveTradesSubscriptionData =
1857            serde_json::from_str(&format!("[{row}]")).unwrap();
1858
1859        assert_eq!(page.trades.len(), 1);
1860        assert_eq!(subscription.trades.len(), 1);
1861        assert!(page.records_complete);
1862
1863        for trade in [&direct, &page.trades[0], &subscription.trades[0]] {
1864            assert_eq!(
1865                trade.trade_amount,
1866                if field == "trade_amount" {
1867                    expected
1868                } else {
1869                    rust_decimal_macros::dec!(0.5)
1870                }
1871            );
1872            assert_eq!(
1873                trade.trade_price,
1874                if field == "trade_price" {
1875                    expected
1876                } else {
1877                    rust_decimal_macros::dec!(3499.0)
1878                }
1879            );
1880            assert_eq!(
1881                trade.trade_fee,
1882                if field == "trade_fee" {
1883                    expected
1884                } else {
1885                    rust_decimal_macros::dec!(0.02)
1886                }
1887            );
1888            assert_eq!(
1889                serde_json::to_value(trade).unwrap(),
1890                serde_json::to_value(&direct).unwrap()
1891            );
1892        }
1893    }
1894
1895    #[rstest]
1896    fn test_v3_trade_rest_and_private_subscription_schema_parity() {
1897        let native: Vec<Value> = serde_json::from_str(include_str!(
1898            "../../test_data/perps/http_trades_v3_native.json"
1899        ))
1900        .unwrap();
1901        let stages: Vec<Value> = serde_json::from_str(include_str!(
1902            "../../test_data/common/trade_batch_stages.json"
1903        ))
1904        .unwrap();
1905
1906        for stage in stages {
1907            let mut row = native[0].clone();
1908            row["batch_status"] = stage.clone();
1909            row["op_uuid"] = Value::Null;
1910            row["tx_hash"] = Value::Null;
1911            let result: DeriveTradesResult = serde_json::from_value(json!({
1912                "trades": [row.clone()], "pagination": {"count": 1, "num_pages": 1},
1913                "subaccount_id": 42,
1914            }))
1915            .unwrap();
1916            let subscription: crate::websocket::DeriveTradesSubscriptionData =
1917                serde_json::from_value(json!([row])).unwrap();
1918            assert_eq!(result.trades.len(), 1);
1919            assert_eq!(subscription.trades.len(), 1);
1920            let rest = &result.trades[0];
1921            let ws = &subscription.trades[0];
1922            assert_eq!(
1923                serde_json::to_value(rest).unwrap(),
1924                serde_json::to_value(ws).unwrap()
1925            );
1926            assert_eq!(rest.batch_status.as_deref(), stage.as_str());
1927            assert_eq!(rest.op_uuid, None);
1928            assert_eq!(rest.tx_hash, None);
1929            assert_eq!(rest.trade_fee, rust_decimal_macros::dec!(0.087052478068));
1930            assert_eq!(rest.trade_id, "pm2-held-fixture-trade-close");
1931            assert_eq!(rest.order_id, "pm2-held-fixture-order-close");
1932        }
1933
1934        let mut row = native[1].clone();
1935        row.as_object_mut().unwrap().remove("batch_status");
1936        row.as_object_mut().unwrap().remove("op_uuid");
1937        row.as_object_mut().unwrap().remove("tx_hash");
1938        row.as_object_mut().unwrap().remove("label");
1939        let early: DeriveTrade = serde_json::from_value(row).unwrap();
1940        assert_eq!(early.batch_status, None);
1941        assert_eq!(early.op_uuid, None);
1942        assert_eq!(early.tx_hash, None);
1943        assert_eq!(early.label, Ustr::from(""));
1944    }
1945
1946    #[rstest]
1947    #[case("trade_amount")]
1948    #[case("trade_price")]
1949    #[case("trade_fee")]
1950    fn test_v3_required_execution_decimals_reject_invalid_values(
1951        #[case] field: &str,
1952        #[values("null", "empty", "missing")] invalid: &str,
1953    ) {
1954        let mut row = load_json("perps/http_private_trade_eth.json");
1955        match invalid {
1956            "null" => row[field] = Value::Null,
1957            "empty" => row[field] = json!(""),
1958            "missing" => {
1959                row.as_object_mut().unwrap().remove(field);
1960            }
1961            _ => unreachable!(),
1962        }
1963
1964        assert!(serde_json::from_value::<DeriveTrade>(row.clone()).is_err());
1965        let rest = serde_json::from_value::<DeriveTradesResult>(json!({
1966            "trades": [row.clone()], "pagination": {"count": 1, "num_pages": 1}, "subaccount_id": 42,
1967        }));
1968        let ws: crate::websocket::DeriveTradesSubscriptionData =
1969            serde_json::from_value(json!([row.clone()])).unwrap();
1970        let mut order = load_json("spot/http_submit_order_response_mainnet.json")["result"].clone();
1971        order["trades"] = json!([row.clone()]);
1972        let order_result: DeriveOrderResult = serde_json::from_value(order).unwrap();
1973
1974        if field == "trade_fee" {
1975            assert!(rest.is_err());
1976        } else {
1977            let rest = rest.unwrap();
1978            assert_eq!(rest.trades.len(), 0);
1979            assert!(!rest.records_complete);
1980        }
1981
1982        assert_eq!(ws.trades.len(), 0);
1983        assert_eq!(order_result.trades.len(), 0);
1984        assert!(
1985            serde_json::from_value::<crate::websocket::DeriveTradesSubscriptionData>(row.clone())
1986                .is_err()
1987        );
1988
1989        if field != "trade_fee" {
1990            assert!(serde_json::from_value::<DerivePublicTrade>(row.clone()).is_err());
1991            let valid = load_json("perps/http_public_trade_eth_sell.json");
1992            assert!(
1993                serde_json::from_value::<DerivePublicTradesResult>(json!({
1994                    "trades": [valid, row.clone()],
1995                    "pagination": {"count": 2, "num_pages": 1},
1996                }))
1997                .is_err()
1998            );
1999            let payload: crate::websocket::WsSubscriptionPayload = serde_json::from_str(
2000                &json!({"channel": "trades.perp.ETH", "data": [valid, row]}).to_string(),
2001            )
2002            .unwrap();
2003            assert!(crate::websocket::parse_public_ws_data(&payload).is_err());
2004        }
2005    }
2006
2007    #[rstest]
2008    #[case(json!("0"))]
2009    #[case(json!(0))]
2010    fn test_v3_execution_fee_accepts_explicit_zero(#[case] fee: Value) {
2011        let mut row = load_json("perps/http_private_trade_eth.json");
2012        row["trade_fee"] = fee;
2013        let trade: DeriveTrade = serde_json::from_value(row).unwrap();
2014        assert_eq!(trade.trade_fee, Decimal::ZERO);
2015        assert_eq!(trade.trade_amount, rust_decimal_macros::dec!(0.5));
2016        assert_eq!(trade.trade_price, rust_decimal_macros::dec!(3499));
2017    }
2018
2019    #[rstest]
2020    fn test_private_trade_decodes_high_scale_decimal_values() {
2021        let mut body = load_json("perps/http_private_trade_eth.json");
2022        body["trade_fee"] = json!("1.234567890123456789012345678912345e-1");
2023        body["realized_pnl"] = json!("0.1234567890123456789012345678912345");
2024
2025        let trade: DeriveTrade = serde_json::from_value(body).unwrap();
2026
2027        assert_eq!(
2028            trade.trade_fee.to_string(),
2029            "0.1234567890123456789012345679"
2030        );
2031        assert_eq!(
2032            trade.realized_pnl.to_string(),
2033            "0.1234567890123456789012345679",
2034        );
2035    }
2036
2037    #[rstest]
2038    fn test_order_result_decodes_pending_trade_with_null_tx_hash() {
2039        let mut body = load_json("spot/http_submit_order_response_mainnet.json");
2040        let mut trade = load_json("perps/http_private_trade_eth.json");
2041        trade["tx_hash"] = Value::Null;
2042        trade["batch_status"] = Value::Null;
2043        trade.as_object_mut().unwrap().remove("wallet");
2044        body["result"]["trades"] = json!([trade]);
2045
2046        let result: DeriveOrderResult =
2047            serde_json::from_value(body["result"].clone()).expect("result decodes");
2048
2049        assert_eq!(result.trades.len(), 1);
2050        assert!(result.trades[0].tx_hash.is_none());
2051        assert_eq!(result.trades[0].batch_status, None);
2052        assert!(result.trades[0].wallet.is_none());
2053    }
2054
2055    #[rstest]
2056    fn test_empty_result_decodes_cancel_ack_shapes() {
2057        let object: DeriveEmptyResult = serde_json::from_value(json!({})).unwrap();
2058        let ok_string: DeriveEmptyResult = serde_json::from_value(json!("ok")).unwrap();
2059        let null_envelope: JsonRpcResponse<DeriveEmptyResult> =
2060            serde_json::from_value(json!({"id": 1, "result": null})).unwrap();
2061
2062        assert_eq!(object, DeriveEmptyResult {});
2063        assert_eq!(ok_string, DeriveEmptyResult {});
2064        assert_eq!(null_envelope.result, Some(DeriveEmptyResult {}));
2065    }
2066
2067    #[rstest]
2068    #[case("common/ws_cancel_by_label_zero.json", 0)]
2069    #[case("common/ws_cancel_by_label_nonzero.json", 2)]
2070    fn test_cancel_order_by_label_result_decodes_count(
2071        #[case] filename: &str,
2072        #[case] expected: i64,
2073    ) {
2074        let response: JsonRpcResponse<DeriveCancelByLabelResult> =
2075            serde_json::from_value(load_json(filename)).expect("response decodes");
2076
2077        assert_eq!(response.result.unwrap().cancelled_orders, expected);
2078    }
2079
2080    #[rstest]
2081    fn test_order_result_preserves_trades_with_unknown_status() {
2082        let mut order = load_json("perps/http_order_eth_partially_filled.json");
2083        order["order_status"] = serde_json::json!("future-status");
2084        let trade = load_json("perps/http_private_trade_eth.json");
2085        let result: DeriveOrderResult = serde_json::from_value(serde_json::json!({
2086            "order": order, "trades": [trade],
2087        }))
2088        .unwrap();
2089        assert_eq!(result.trades.len(), 1);
2090        assert_eq!(
2091            serde_json::to_value(&result.trades[0]).unwrap(),
2092            serde_json::to_value(serde_json::from_value::<DeriveTrade>(trade).unwrap()).unwrap()
2093        );
2094    }
2095
2096    #[rstest]
2097    fn test_trades_result_envelope_decodes() {
2098        let body = load_json("perps/http_trades_result_eth.json");
2099        let result: DeriveTradesResult = serde_json::from_value(body).unwrap();
2100        assert_eq!(result.trades.len(), 1);
2101        assert_eq!(result.subaccount_id, 7);
2102        assert_eq!(result.pagination.count, 1);
2103        assert_eq!(result.pagination.num_pages, 1);
2104        assert_eq!(result.trades[0].trade_id, "t-1");
2105        assert!(result.records_complete);
2106    }
2107
2108    #[rstest]
2109    fn test_trades_result_salvages_unknown_variant_rows() {
2110        let body = load_json("perps/http_trades_result_eth_unknown_variants.json");
2111        let result: DeriveTradesResult = serde_json::from_value(body).unwrap();
2112
2113        // The `short`-direction row is undecodable and skipped; the rest survive.
2114        assert_eq!(result.trades.len(), 2);
2115        assert_eq!(result.trades[0].trade_id, "t-1");
2116        let unknowns = &result.trades[1];
2117        assert_eq!(unknowns.trade_id, "t-2");
2118        assert_eq!(unknowns.liquidity_role, DeriveLiquidityRole::Unknown);
2119        assert!(!result.records_complete);
2120    }
2121
2122    #[rstest]
2123    #[case(json!("invalid-fee"))]
2124    #[case(json!(""))]
2125    #[case(Value::Null)]
2126    fn test_trades_result_rejects_undecodable_fee(#[case] fee: Value) {
2127        let mut body = load_json("perps/http_trades_result_eth.json");
2128        let mut bad = body["trades"][0].clone();
2129        bad["trade_id"] = json!("bad-fee-row");
2130        bad["trade_fee"] = fee;
2131        body["trades"].as_array_mut().unwrap().push(bad);
2132        assert!(serde_json::from_value::<DeriveTradesResult>(body).is_err());
2133    }
2134
2135    #[rstest]
2136    #[case(serde_json::json!(["0.1"]))]
2137    #[case(serde_json::json!(0.1))]
2138    #[case(serde_json::Value::Null)]
2139    fn test_trades_result_rejects_non_object_fee_rows(#[case] row: Value) {
2140        let result = serde_json::from_value::<DeriveTradesResult>(json!({
2141            "trades": [row],
2142            "pagination": {"count": 1, "num_pages": 1},
2143            "subaccount_id": 42,
2144        }));
2145
2146        assert_eq!(
2147            result.unwrap_err().to_string(),
2148            "private trade row must be an object"
2149        );
2150    }
2151
2152    #[rstest]
2153    fn test_trades_result_preserves_unknown_batch_metadata() {
2154        let mut body = load_json("perps/http_trades_result_eth.json");
2155        body["trades"][0]["batch_status"] = json!("FutureStage");
2156
2157        let result: DeriveTradesResult = serde_json::from_value(body).unwrap();
2158
2159        assert_eq!(result.trades.len(), 1);
2160        assert_eq!(
2161            result.trades[0].batch_status.as_deref(),
2162            Some("FutureStage")
2163        );
2164    }
2165
2166    #[rstest]
2167    fn test_public_trades_result_envelope_decodes() {
2168        let body = load_json("perps/http_public_trades_result_eth.json");
2169        let result: DerivePublicTradesResult = serde_json::from_value(body).unwrap();
2170        assert_eq!(result.trades.len(), 1);
2171        assert_eq!(result.pagination.count, 1);
2172        assert_eq!(result.trades[0].trade_id, "pub-1");
2173    }
2174
2175    #[rstest]
2176    fn test_public_funding_rate_history_result_envelope_decodes() {
2177        let body = load_json("perps/http_public_funding_rate_history_eth.json");
2178        let result: DerivePublicFundingRateHistoryResult = serde_json::from_value(body).unwrap();
2179        assert_eq!(result.funding_rate_history.len(), 3);
2180        let first = &result.funding_rate_history[0];
2181        assert_eq!(first.funding_rate.to_string(), "0.00012");
2182        assert_eq!(first.timestamp, 1_700_000_000_000);
2183        assert_eq!(
2184            result.funding_rate_history.last().unwrap().timestamp,
2185            1_700_007_200_000,
2186        );
2187    }
2188
2189    #[rstest]
2190    fn test_public_candles_decode_array() {
2191        // The venue ships `result` as a flat array; the HTTP client decodes
2192        // directly into `Vec<DerivePublicCandle>`. The fixture mirrors that
2193        // wire shape.
2194        let body = load_json("perps/http_public_candles_eth.json");
2195        let candles: Vec<DerivePublicCandle> = serde_json::from_value(body).unwrap();
2196        assert_eq!(candles.len(), 3);
2197        let first = &candles[0];
2198        assert_eq!(first.open_price.to_string(), "3500.0");
2199        assert_eq!(first.high_price.to_string(), "3501.5");
2200        assert_eq!(first.low_price.to_string(), "3499.0");
2201        assert_eq!(first.close_price.to_string(), "3501.0");
2202        assert_eq!(first.volume_usd.to_string(), "12345.6");
2203        assert_eq!(first.volume_contracts.to_string(), "3.527");
2204        // Distinct `timestamp` vs `timestamp_bucket` so a field-swap mutation
2205        // in any downstream parser is detectable.
2206        assert_eq!(first.timestamp, 1_700_000_007);
2207        assert_eq!(first.timestamp_bucket, 1_700_000_000);
2208        assert_eq!(candles.last().unwrap().timestamp_bucket, 1_700_001_800);
2209    }
2210
2211    #[rstest]
2212    fn test_positions_result_envelope_decodes() {
2213        let body = load_json("perps/http_positions_result_eth.json");
2214        let result: DerivePositionsResult = serde_json::from_value(body).unwrap();
2215        assert_eq!(result.positions.len(), 1);
2216        assert_eq!(result.subaccount_id, 42);
2217        assert_eq!(result.positions[0].instrument_name, "ETH-PERP");
2218        assert!(result.positions[0].leverage.is_none());
2219        assert!(result.positions[0].liquidation_price.is_none());
2220    }
2221}