Skip to main content

nautilus_derive/http/
query.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//! Typed JSON-RPC params for Derive private execution endpoints.
17
18use alloy::signers::local::PrivateKeySigner;
19use alloy_primitives::{Address, B256, U256};
20use anyhow::Context;
21#[cfg(test)]
22use nautilus_core::string::secret::REDACTED;
23use nautilus_core::{
24    serialization::{
25        deserialize_decimal, serialize_decimal_as_str, serialize_optional_decimal_as_str,
26    },
27    string::secret::SecretString,
28};
29use nautilus_model::orders::{Order, OrderAny};
30use rust_decimal::Decimal;
31use serde::{Deserialize, Serialize};
32use ustr::Ustr;
33
34use crate::{
35    common::{
36        consts::DERIVE_NAUTILUS_REFERRAL_CODE,
37        enums::{
38            DeriveOrderSide, DeriveOrderType, DeriveTimeInForce, DeriveTriggerPriceType,
39            DeriveTriggerType,
40        },
41        parse::{
42            order_side_to_derive, order_type_to_derive, time_in_force_to_derive,
43            trigger_order_type_to_derive, trigger_price_type_to_derive, trigger_type_to_derive,
44        },
45    },
46    http::models::DeriveInstrument,
47    signing::{
48        eip712::{ActionContext, SignedAction},
49        modules::{ModuleData, trade::TradeModuleData},
50    },
51};
52
53/// Signed EIP-712 envelope shared by `private/order` and `private/replace`.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
55pub struct DeriveSignedEnvelope {
56    /// Owning subaccount identifier.
57    pub subaccount_id: u64,
58    /// Per-action nonce.
59    pub nonce: u64,
60    /// Session-key signer address.
61    pub signer: String,
62    /// Signature expiry in UNIX seconds.
63    pub signature_expiry_sec: i64,
64    /// 65-byte EIP-712 signature as `0x`-prefixed hex.
65    pub signature: SecretString,
66}
67
68impl DeriveSignedEnvelope {
69    #[must_use]
70    pub fn from_signed_action<M: ModuleData>(action: &SignedAction<'_, M>) -> Self {
71        Self {
72            subaccount_id: action.subaccount_id(),
73            nonce: action.nonce(),
74            signer: format!("{:?}", action.signer_address()),
75            signature_expiry_sec: action.signature_expiry_sec(),
76            signature: SecretString::from(action.signature_hex()),
77        }
78    }
79}
80
81/// Params for `private/order`.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
83pub struct DeriveOrderParams {
84    /// Signed action envelope.
85    #[serde(flatten)]
86    pub envelope: DeriveSignedEnvelope,
87    /// Canonical Derive instrument name.
88    pub instrument_name: Ustr,
89    /// Order side.
90    pub direction: DeriveOrderSide,
91    /// Order type.
92    pub order_type: DeriveOrderType,
93    /// Time-in-force.
94    pub time_in_force: DeriveTimeInForce,
95    /// Signed limit price.
96    #[serde(
97        serialize_with = "serialize_decimal_as_str",
98        deserialize_with = "deserialize_decimal"
99    )]
100    pub limit_price: Decimal,
101    /// Signed order amount.
102    #[serde(
103        serialize_with = "serialize_decimal_as_str",
104        deserialize_with = "deserialize_decimal"
105    )]
106    pub amount: Decimal,
107    /// Signed per-contract fee cap.
108    #[serde(
109        serialize_with = "serialize_decimal_as_str",
110        deserialize_with = "deserialize_decimal"
111    )]
112    pub max_fee: Decimal,
113    /// User label, mapped from Nautilus client order id.
114    pub label: String,
115    /// Nautilus referral code.
116    pub referral_code: String,
117    /// Reduce-only flag, omitted unless set.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub reduce_only: Option<bool>,
120    /// MMP flag, omitted unless set.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub mmp: Option<bool>,
123    /// Trigger price for `private/trigger_order`; omitted for normal orders.
124    #[serde(
125        default,
126        skip_serializing_if = "Option::is_none",
127        serialize_with = "serialize_optional_decimal_as_str",
128        deserialize_with = "nautilus_core::serialization::deserialize_optional_decimal"
129    )]
130    pub trigger_price: Option<Decimal>,
131    /// Trigger price source for `private/trigger_order`; omitted for normal orders.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub trigger_price_type: Option<DeriveTriggerPriceType>,
134    /// Trigger side for `private/trigger_order`; omitted for normal orders.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub trigger_type: Option<DeriveTriggerType>,
137}
138
139/// Params for `private/trigger_order`.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
141pub struct DeriveTriggerOrderParams {
142    /// New signed trigger order body.
143    #[serde(flatten)]
144    pub order: DeriveOrderParams,
145    /// WebSocket connection id supplied by the client.
146    pub conn_id: String,
147    /// Client-supplied Derive trigger order id.
148    pub order_id: String,
149}
150
151/// Params for `private/replace`.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
153pub struct DeriveReplaceParams {
154    /// New signed order body.
155    #[serde(flatten)]
156    pub order: DeriveOrderParams,
157    /// Venue order id to atomically cancel.
158    pub order_id_to_cancel: String,
159}
160
161/// Params for `private/cancel_trigger_order`.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
163pub struct DeriveCancelTriggerOrderParams {
164    /// Owning subaccount identifier.
165    pub subaccount_id: u64,
166    /// Venue order id.
167    pub order_id: String,
168}
169
170impl DeriveCancelTriggerOrderParams {
171    #[must_use]
172    pub fn new(subaccount_id: u64, order_id: impl Into<String>) -> Self {
173        Self {
174            subaccount_id,
175            order_id: order_id.into(),
176        }
177    }
178}
179
180/// Params for `private/cancel`.
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
182pub struct DeriveCancelParams {
183    /// Owning subaccount identifier.
184    pub subaccount_id: u64,
185    /// Canonical Derive instrument name.
186    pub instrument_name: Ustr,
187    /// Venue order id.
188    pub order_id: String,
189}
190
191impl DeriveCancelParams {
192    #[must_use]
193    pub fn new(
194        subaccount_id: u64,
195        instrument_name: impl Into<Ustr>,
196        order_id: impl Into<String>,
197    ) -> Self {
198        Self {
199            subaccount_id,
200            instrument_name: instrument_name.into(),
201            order_id: order_id.into(),
202        }
203    }
204}
205
206/// Params for `private/cancel_by_instrument`.
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
208pub struct DeriveCancelByInstrumentParams {
209    /// Owning subaccount identifier.
210    pub subaccount_id: u64,
211    /// Canonical Derive instrument name.
212    pub instrument_name: Ustr,
213}
214
215impl DeriveCancelByInstrumentParams {
216    #[must_use]
217    pub fn new(subaccount_id: u64, instrument_name: impl Into<Ustr>) -> Self {
218        Self {
219            subaccount_id,
220            instrument_name: instrument_name.into(),
221        }
222    }
223}
224
225/// Params for `private/cancel_all`.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
227pub struct DeriveCancelAllParams {
228    /// Owning subaccount identifier.
229    pub subaccount_id: u64,
230    /// Optional instrument scope.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub instrument_name: Option<Ustr>,
233}
234
235impl DeriveCancelAllParams {
236    #[must_use]
237    pub const fn new(subaccount_id: u64) -> Self {
238        Self {
239            subaccount_id,
240            instrument_name: None,
241        }
242    }
243
244    #[must_use]
245    pub fn with_instrument_name(mut self, instrument_name: impl Into<Ustr>) -> Self {
246        self.instrument_name = Some(instrument_name.into());
247        self
248    }
249}
250
251/// Params for `private/cancel_by_label`.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
253pub struct DeriveCancelByLabelParams {
254    /// Owning subaccount identifier.
255    pub subaccount_id: u64,
256    /// User label to cancel.
257    pub label: String,
258}
259
260impl DeriveCancelByLabelParams {
261    #[must_use]
262    pub fn new(subaccount_id: u64, label: impl Into<String>) -> Self {
263        Self {
264            subaccount_id,
265            label: label.into(),
266        }
267    }
268}
269
270/// Params for `private/get_subaccount`.
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
272pub struct DeriveGetSubaccountParams {
273    /// Owning subaccount identifier.
274    pub subaccount_id: u64,
275}
276
277impl DeriveGetSubaccountParams {
278    #[must_use]
279    pub const fn new(subaccount_id: u64) -> Self {
280        Self { subaccount_id }
281    }
282}
283
284/// Params for `private/get_open_orders`.
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
286pub struct DeriveGetOpenOrdersParams {
287    /// Owning subaccount identifier.
288    pub subaccount_id: u64,
289}
290
291impl DeriveGetOpenOrdersParams {
292    #[must_use]
293    pub const fn new(subaccount_id: u64) -> Self {
294        Self { subaccount_id }
295    }
296}
297
298/// Params for `private/get_trigger_orders`.
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
300pub struct DeriveGetTriggerOrdersParams {
301    /// Owning subaccount identifier.
302    pub subaccount_id: u64,
303}
304
305impl DeriveGetTriggerOrdersParams {
306    #[must_use]
307    pub const fn new(subaccount_id: u64) -> Self {
308        Self { subaccount_id }
309    }
310}
311
312/// Params for `private/get_order`.
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
314pub struct DeriveGetOrderParams {
315    /// Owning subaccount identifier.
316    pub subaccount_id: u64,
317    /// Venue order id.
318    pub order_id: String,
319}
320
321impl DeriveGetOrderParams {
322    #[must_use]
323    pub fn new(subaccount_id: u64, order_id: impl Into<String>) -> Self {
324        Self {
325            subaccount_id,
326            order_id: order_id.into(),
327        }
328    }
329}
330
331/// Params for `private/get_order_history`.
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
333pub struct DeriveGetOrderHistoryParams {
334    /// Owning subaccount identifier.
335    pub subaccount_id: u64,
336    /// Optional instrument scope.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub instrument_name: Option<Ustr>,
339    /// Optional inclusive lower timestamp bound in UNIX milliseconds.
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub from_timestamp: Option<i64>,
342    /// Optional inclusive upper timestamp bound in UNIX milliseconds.
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub to_timestamp: Option<i64>,
345    /// 1-indexed page number.
346    pub page: u32,
347    /// Page size.
348    pub page_size: u32,
349}
350
351impl DeriveGetOrderHistoryParams {
352    #[must_use]
353    pub fn new(subaccount_id: u64, page: u32, page_size: u32) -> Self {
354        Self {
355            subaccount_id,
356            instrument_name: None,
357            from_timestamp: None,
358            to_timestamp: None,
359            page,
360            page_size,
361        }
362    }
363
364    #[must_use]
365    pub fn with_instrument_name(mut self, instrument_name: impl Into<Ustr>) -> Self {
366        self.instrument_name = Some(instrument_name.into());
367        self
368    }
369
370    #[must_use]
371    pub const fn with_window(
372        mut self,
373        from_timestamp: Option<i64>,
374        to_timestamp: Option<i64>,
375    ) -> Self {
376        self.from_timestamp = from_timestamp;
377        self.to_timestamp = to_timestamp;
378        self
379    }
380}
381
382/// Params for `private/get_trade_history`.
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
384pub struct DeriveGetTradeHistoryParams {
385    /// Owning subaccount identifier.
386    pub subaccount_id: u64,
387    /// Optional instrument scope.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub instrument_name: Option<Ustr>,
390    /// Optional inclusive lower timestamp bound in UNIX milliseconds.
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub from_timestamp: Option<i64>,
393    /// Optional inclusive upper timestamp bound in UNIX milliseconds.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub to_timestamp: Option<i64>,
396    /// 1-indexed page number.
397    pub page: u32,
398    /// Page size.
399    pub page_size: u32,
400}
401
402impl DeriveGetTradeHistoryParams {
403    #[must_use]
404    pub fn new(subaccount_id: u64, page: u32, page_size: u32) -> Self {
405        Self {
406            subaccount_id,
407            instrument_name: None,
408            from_timestamp: None,
409            to_timestamp: None,
410            page,
411            page_size,
412        }
413    }
414
415    #[must_use]
416    pub fn with_instrument_name(mut self, instrument_name: impl Into<Ustr>) -> Self {
417        self.instrument_name = Some(instrument_name.into());
418        self
419    }
420
421    #[must_use]
422    pub const fn with_window(
423        mut self,
424        from_timestamp: Option<i64>,
425        to_timestamp: Option<i64>,
426    ) -> Self {
427        self.from_timestamp = from_timestamp;
428        self.to_timestamp = to_timestamp;
429        self
430    }
431}
432
433/// Params for `private/get_positions`.
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
435pub struct DeriveGetPositionsParams {
436    /// Owning subaccount identifier.
437    pub subaccount_id: u64,
438}
439
440impl DeriveGetPositionsParams {
441    #[must_use]
442    pub const fn new(subaccount_id: u64) -> Self {
443        Self { subaccount_id }
444    }
445}
446
447/// Builds typed params for a signed `private/order` request.
448///
449/// `wallet` is the owner address, `signer_address` the session-key address,
450/// and `nonce` / `signature_expiry_sec` come from
451/// [`crate::signing::nonce::NonceManager`] and the configured expiry policy.
452/// `explicit_price` overrides the limit price slot. Callers must supply it
453/// for market orders because Derive signs the worst-acceptable price into the
454/// EIP-712 trade module data.
455///
456/// # Errors
457///
458/// Returns an error when the order is not a Limit or Market order, when the
459/// instrument's `base_asset_address` cannot be parsed, when decimal scaling
460/// fails, when a Market order is submitted without an `explicit_price`, or
461/// when EIP-712 signing fails.
462#[expect(clippy::too_many_arguments)]
463pub fn order_to_derive_payload(
464    order: &OrderAny,
465    instrument: &DeriveInstrument,
466    subaccount_id: u64,
467    wallet: Address,
468    signer: &PrivateKeySigner,
469    nonce: u64,
470    signature_expiry_sec: i64,
471    module_address: Address,
472    domain_separator: B256,
473    action_typehash: B256,
474    max_fee: Decimal,
475    explicit_price: Option<Decimal>,
476) -> anyhow::Result<DeriveOrderParams> {
477    validate_order_support(order)?;
478    let limit_price = resolve_limit_price(order, explicit_price)?;
479    let amount = order.quantity().as_decimal();
480    let order_type = order_type_to_derive(order.order_type())?;
481    let time_in_force = time_in_force_to_derive(order.time_in_force(), order.is_post_only())?;
482    build_signed_order_params(
483        order,
484        instrument,
485        subaccount_id,
486        wallet,
487        signer,
488        nonce,
489        signature_expiry_sec,
490        module_address,
491        domain_separator,
492        action_typehash,
493        max_fee,
494        limit_price,
495        amount,
496        order_type,
497        time_in_force,
498        None,
499    )
500}
501
502/// Builds typed params for a signed `private/trigger_order` request.
503///
504/// Derive stores trigger orders off-book until the venue trigger worker
505/// submits the signed child order. `conn_id` and `order_id` are client-supplied
506/// fields required by the WebSocket-only endpoint.
507///
508/// # Errors
509///
510/// Returns an error when the order is not one of StopMarket, StopLimit,
511/// MarketIfTouched, or LimitIfTouched, when the trigger source is not
512/// MarkPrice, when required prices are absent, or when EIP-712 signing fails.
513#[expect(clippy::too_many_arguments)]
514pub fn trigger_order_to_derive_payload(
515    order: &OrderAny,
516    instrument: &DeriveInstrument,
517    subaccount_id: u64,
518    wallet: Address,
519    signer: &PrivateKeySigner,
520    nonce: u64,
521    signature_expiry_sec: i64,
522    module_address: Address,
523    domain_separator: B256,
524    action_typehash: B256,
525    max_fee: Decimal,
526    explicit_price: Option<Decimal>,
527    conn_id: impl Into<String>,
528    order_id: impl Into<String>,
529) -> anyhow::Result<DeriveTriggerOrderParams> {
530    validate_trigger_order_support(order)?;
531    let limit_price = resolve_limit_price(order, explicit_price)?;
532    let amount = order.quantity().as_decimal();
533    let order_type = trigger_order_type_to_derive(order.order_type())?;
534    let time_in_force = time_in_force_to_derive(order.time_in_force(), order.is_post_only())?;
535    let trigger_price = order.trigger_price().ok_or_else(|| {
536        anyhow::anyhow!(
537            "missing trigger price for Derive trigger order {}",
538            order.client_order_id()
539        )
540    })?;
541    let trigger_fields = DeriveTriggerFields {
542        trigger_price: trigger_price.as_decimal(),
543        trigger_price_type: trigger_price_type_to_derive(order.trigger_type())?,
544        trigger_type: trigger_type_to_derive(order.order_type())?,
545    };
546    let order = build_signed_order_params(
547        order,
548        instrument,
549        subaccount_id,
550        wallet,
551        signer,
552        nonce,
553        signature_expiry_sec,
554        module_address,
555        domain_separator,
556        action_typehash,
557        max_fee,
558        limit_price,
559        amount,
560        order_type,
561        time_in_force,
562        Some(trigger_fields),
563    )?;
564
565    Ok(DeriveTriggerOrderParams {
566        order,
567        conn_id: conn_id.into(),
568        order_id: order_id.into(),
569    })
570}
571
572/// Builds typed params for a signed `private/replace` request.
573///
574/// Derive's replace endpoint atomically cancels a stale order and submits a
575/// new signed order. The new-order half is signed against
576/// [`TradeModuleData`] exactly like `private/order`.
577///
578/// # Errors
579///
580/// Returns an error when the order is not a Limit or Market order, when the
581/// instrument's `base_asset_address` cannot be parsed, when decimal scaling
582/// fails, when a Market order has no `explicit_price`, or when EIP-712 signing
583/// fails.
584#[expect(clippy::too_many_arguments)]
585pub fn order_replace_to_derive_payload(
586    order: &OrderAny,
587    instrument: &DeriveInstrument,
588    subaccount_id: u64,
589    wallet: Address,
590    signer: &PrivateKeySigner,
591    nonce: u64,
592    signature_expiry_sec: i64,
593    module_address: Address,
594    domain_separator: B256,
595    action_typehash: B256,
596    max_fee: Decimal,
597    explicit_quantity: Option<Decimal>,
598    explicit_price: Option<Decimal>,
599    order_id_to_cancel: &str,
600) -> anyhow::Result<DeriveReplaceParams> {
601    validate_order_support(order)?;
602    let limit_price = resolve_limit_price(order, explicit_price)?;
603    let amount = explicit_quantity.unwrap_or_else(|| order.quantity().as_decimal());
604    let order_type = order_type_to_derive(order.order_type())?;
605    let time_in_force = time_in_force_to_derive(order.time_in_force(), order.is_post_only())?;
606    let order = build_signed_order_params(
607        order,
608        instrument,
609        subaccount_id,
610        wallet,
611        signer,
612        nonce,
613        signature_expiry_sec,
614        module_address,
615        domain_separator,
616        action_typehash,
617        max_fee,
618        limit_price,
619        amount,
620        order_type,
621        time_in_force,
622        None,
623    )?;
624
625    Ok(DeriveReplaceParams {
626        order,
627        order_id_to_cancel: order_id_to_cancel.to_string(),
628    })
629}
630
631pub(crate) fn validate_order_support(order: &OrderAny) -> anyhow::Result<()> {
632    order_type_to_derive(order.order_type())?;
633    time_in_force_to_derive(order.time_in_force(), order.is_post_only())?;
634    Ok(())
635}
636
637pub(crate) fn validate_trigger_order_support(order: &OrderAny) -> anyhow::Result<()> {
638    trigger_order_type_to_derive(order.order_type())?;
639    time_in_force_to_derive(order.time_in_force(), order.is_post_only())?;
640    trigger_price_type_to_derive(order.trigger_type())?;
641    Ok(())
642}
643
644fn resolve_limit_price(
645    order: &OrderAny,
646    explicit_price: Option<Decimal>,
647) -> anyhow::Result<Decimal> {
648    match explicit_price {
649        Some(p) => Ok(p),
650        None => match order.price() {
651            Some(p) => Ok(p.as_decimal()),
652            None => anyhow::bail!(
653                "missing limit price for order {} (market orders require an explicit slippage-adjusted price)",
654                order.client_order_id(),
655            ),
656        },
657    }
658}
659
660#[derive(Debug, Clone, Copy, PartialEq, Eq)]
661struct DeriveTriggerFields {
662    trigger_price: Decimal,
663    trigger_price_type: DeriveTriggerPriceType,
664    trigger_type: DeriveTriggerType,
665}
666
667#[expect(clippy::too_many_arguments)]
668fn build_signed_order_params(
669    order: &OrderAny,
670    instrument: &DeriveInstrument,
671    subaccount_id: u64,
672    wallet: Address,
673    signer: &PrivateKeySigner,
674    nonce: u64,
675    signature_expiry_sec: i64,
676    module_address: Address,
677    domain_separator: B256,
678    action_typehash: B256,
679    max_fee: Decimal,
680    limit_price: Decimal,
681    amount: Decimal,
682    order_type: DeriveOrderType,
683    time_in_force: DeriveTimeInForce,
684    trigger_fields: Option<DeriveTriggerFields>,
685) -> anyhow::Result<DeriveOrderParams> {
686    let direction = order_side_to_derive(order.order_side());
687
688    let asset_address: Address = instrument.base_asset_address.parse().with_context(|| {
689        format!(
690            "failed to parse base_asset_address `{}`",
691            instrument.base_asset_address.as_str(),
692        )
693    })?;
694    let sub_id =
695        U256::from_str_radix(instrument.base_asset_sub_id.as_str(), 10).with_context(|| {
696            format!(
697                "failed to parse base_asset_sub_id `{}`",
698                instrument.base_asset_sub_id.as_str(),
699            )
700        })?;
701
702    let trade = TradeModuleData {
703        asset_address,
704        sub_id,
705        limit_price,
706        amount,
707        max_fee,
708        recipient_id: subaccount_id,
709        is_bid: matches!(direction, DeriveOrderSide::Buy),
710    };
711
712    let ctx = ActionContext {
713        subaccount_id,
714        nonce,
715        module_address,
716        signature_expiry_sec,
717        owner: wallet,
718        signer: signer.address(),
719    };
720
721    let mut action = SignedAction::new(ctx, &trade, domain_separator, action_typehash);
722    action
723        .sign(signer)
724        .context("failed to sign Derive trade action")?;
725
726    Ok(DeriveOrderParams {
727        envelope: DeriveSignedEnvelope::from_signed_action(&action),
728        instrument_name: instrument.instrument_name,
729        direction,
730        order_type,
731        time_in_force,
732        limit_price,
733        amount,
734        max_fee,
735        label: order.client_order_id().to_string(),
736        referral_code: DERIVE_NAUTILUS_REFERRAL_CODE.to_string(),
737        reduce_only: order.is_reduce_only().then_some(true),
738        mmp: order.is_post_only().then_some(false),
739        trigger_price: trigger_fields.map(|f| f.trigger_price),
740        trigger_price_type: trigger_fields.map(|f| f.trigger_price_type),
741        trigger_type: trigger_fields.map(|f| f.trigger_type),
742    })
743}
744
745#[cfg(test)]
746mod tests {
747    use std::time::{SystemTime, UNIX_EPOCH};
748
749    use nautilus_core::{UUID4, UnixNanos};
750    use nautilus_model::{
751        enums::{OrderSide, OrderType, TimeInForce, TriggerType},
752        identifiers::{ClientOrderId, InstrumentId, StrategyId, Symbol, TraderId},
753        orders::{LimitOrder, MarketOrder, OrderTestBuilder},
754        types::{Price, Quantity},
755    };
756    use rstest::rstest;
757    use rust_decimal_macros::dec;
758    use serde_json::Value;
759
760    use super::*;
761    use crate::common::{consts::DERIVE_VENUE, enums::DeriveInstrumentType};
762
763    fn canonical_wire<T: Serialize>(params: &T) -> String {
764        let mut value = serde_json::to_value(params).unwrap();
765        value.sort_all_objects();
766        serde_json::to_string(&value).unwrap()
767    }
768
769    fn to_value<T: Serialize>(value: T) -> Value {
770        serde_json::to_value(value).unwrap()
771    }
772
773    fn fixed_envelope(nonce: u64, signature: &str) -> DeriveSignedEnvelope {
774        DeriveSignedEnvelope {
775            subaccount_id: 30769,
776            nonce,
777            signer: "0xsigner".to_string(),
778            signature_expiry_sec: 1_700_000_600 + (nonce as i64 - 123_456),
779            signature: SecretString::from(signature),
780        }
781    }
782
783    #[rstest]
784    fn test_order_params_wire_round_trip_omits_unset_optionals() {
785        let params = DeriveOrderParams {
786            envelope: fixed_envelope(123_456, "0xabc"),
787            instrument_name: "ETH-PERP".into(),
788            direction: DeriveOrderSide::Buy,
789            order_type: DeriveOrderType::Limit,
790            time_in_force: DeriveTimeInForce::Gtc,
791            limit_price: dec!(3500.01),
792            amount: dec!(1.25),
793            max_fee: dec!(0.5),
794            label: "client-1".to_string(),
795            referral_code: DERIVE_NAUTILUS_REFERRAL_CODE.to_string(),
796            reduce_only: None,
797            mmp: None,
798            trigger_price: None,
799            trigger_price_type: None,
800            trigger_type: None,
801        };
802        let debug = format!("{params:?}");
803
804        let wire = canonical_wire(&params);
805        let expected = include_str!("../../test_data/common/private_order_params_limit.json")
806            .trim_end_matches('\n');
807        let round_trip: DeriveOrderParams = serde_json::from_str(&wire).unwrap();
808
809        assert_eq!(wire, expected);
810        assert_eq!(round_trip, params);
811        assert!(debug.contains(REDACTED));
812        assert!(!debug.contains("0xabc"));
813    }
814
815    #[rstest]
816    fn test_replace_params_wire_round_trip_includes_set_optionals() {
817        let params = DeriveReplaceParams {
818            order: DeriveOrderParams {
819                envelope: fixed_envelope(123_457, "0xdef"),
820                instrument_name: "ETH-PERP".into(),
821                direction: DeriveOrderSide::Sell,
822                order_type: DeriveOrderType::Limit,
823                time_in_force: DeriveTimeInForce::PostOnly,
824                limit_price: dec!(3499.5),
825                amount: dec!(2),
826                max_fee: dec!(0),
827                label: "client-2".to_string(),
828                referral_code: DERIVE_NAUTILUS_REFERRAL_CODE.to_string(),
829                reduce_only: Some(true),
830                mmp: Some(false),
831                trigger_price: None,
832                trigger_price_type: None,
833                trigger_type: None,
834            },
835            order_id_to_cancel: "ord-stale-1".to_string(),
836        };
837
838        let wire = canonical_wire(&params);
839        let expected =
840            include_str!("../../test_data/common/private_replace_params_reduce_mmp.json")
841                .trim_end_matches('\n');
842        let round_trip: DeriveReplaceParams = serde_json::from_str(&wire).unwrap();
843
844        assert_eq!(wire, expected);
845        assert_eq!(round_trip, params);
846    }
847
848    #[rstest]
849    fn test_history_params_wire_round_trip_omits_unset_filters() {
850        let params = DeriveGetOrderHistoryParams::new(30769, 2, 500);
851
852        let wire = canonical_wire(&params);
853        let expected =
854            include_str!("../../test_data/common/private_order_history_params_required.json")
855                .trim_end_matches('\n');
856        let round_trip: DeriveGetOrderHistoryParams = serde_json::from_str(&wire).unwrap();
857
858        assert_eq!(wire, expected);
859        assert_eq!(round_trip, params);
860    }
861
862    #[rstest]
863    fn test_trigger_order_params_wire_round_trip_includes_trigger_fields() {
864        let params = DeriveTriggerOrderParams {
865            order: DeriveOrderParams {
866                envelope: fixed_envelope(123_458, "0xfeed"),
867                instrument_name: "ETH-PERP".into(),
868                direction: DeriveOrderSide::Sell,
869                order_type: DeriveOrderType::Market,
870                time_in_force: DeriveTimeInForce::Gtc,
871                limit_price: dec!(3400),
872                amount: dec!(0.1),
873                max_fee: dec!(0.5),
874                label: "client-stop-1".to_string(),
875                referral_code: DERIVE_NAUTILUS_REFERRAL_CODE.to_string(),
876                reduce_only: Some(true),
877                mmp: None,
878                trigger_price: Some(dec!(3450)),
879                trigger_price_type: Some(DeriveTriggerPriceType::Mark),
880                trigger_type: Some(DeriveTriggerType::Stoploss),
881            },
882            conn_id: "conn-1".to_string(),
883            order_id: "trigger-order-1".to_string(),
884        };
885
886        let wire = canonical_wire(&params);
887        let expected =
888            include_str!("../../test_data/common/private_trigger_order_params_stop_market.json")
889                .trim_end_matches('\n');
890        let round_trip: DeriveTriggerOrderParams = serde_json::from_str(&wire).unwrap();
891
892        assert_eq!(wire, expected);
893        assert_eq!(round_trip, params);
894    }
895
896    fn sample_perp_instrument() -> DeriveInstrument {
897        // Manually-constructed instrument record that satisfies signing's
898        // address/sub-id parsing without depending on an on-disk fixture.
899        DeriveInstrument {
900            amount_step: dec!(0.001),
901            base_asset_address: "0x000000000000000000000000000000000000abcd".into(),
902            base_asset_sub_id: "42".into(),
903            base_currency: "ETH".into(),
904            base_fee: dec!(0),
905            instrument_name: "ETH-PERP".into(),
906            instrument_type: DeriveInstrumentType::Perp,
907            is_active: true,
908            maker_fee_rate: dec!(0.0001),
909            mark_price_fee_rate_cap: None,
910            maximum_amount: dec!(1000),
911            minimum_amount: dec!(0.001),
912            option_details: None,
913            perp_details: None,
914            quote_currency: "USDC".into(),
915            scheduled_activation: 0,
916            scheduled_deactivation: 0,
917            taker_fee_rate: dec!(0.0005),
918            tick_size: dec!(0.01),
919        }
920    }
921
922    fn sample_signer() -> PrivateKeySigner {
923        "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd"
924            .parse()
925            .unwrap()
926    }
927
928    fn sample_wallet() -> Address {
929        "0x000000000000000000000000000000000000aaaa"
930            .parse()
931            .unwrap()
932    }
933
934    fn sample_module() -> Address {
935        "0x000000000000000000000000000000000000bbbb"
936            .parse()
937            .unwrap()
938    }
939
940    fn sample_domain() -> B256 {
941        "0x2222222222222222222222222222222222222222222222222222222222222222"
942            .parse()
943            .unwrap()
944    }
945
946    fn sample_typehash() -> B256 {
947        "0x1111111111111111111111111111111111111111111111111111111111111111"
948            .parse()
949            .unwrap()
950    }
951
952    fn fresh_expiry_secs() -> i64 {
953        (SystemTime::now()
954            .duration_since(UNIX_EPOCH)
955            .unwrap()
956            .as_secs() as i64)
957            + 3600
958    }
959
960    fn build_test_limit_order(
961        side: OrderSide,
962        price: Decimal,
963        qty: Decimal,
964        post_only: bool,
965        reduce_only: bool,
966    ) -> OrderAny {
967        build_test_limit_order_with_time_in_force(
968            side,
969            price,
970            qty,
971            TimeInForce::Gtc,
972            post_only,
973            reduce_only,
974        )
975    }
976
977    fn build_test_limit_order_with_time_in_force(
978        side: OrderSide,
979        price: Decimal,
980        qty: Decimal,
981        time_in_force: TimeInForce,
982        post_only: bool,
983        reduce_only: bool,
984    ) -> OrderAny {
985        OrderAny::Limit(LimitOrder::new(
986            TraderId::from("TRADER-001"),
987            StrategyId::from("S-1"),
988            InstrumentId::new(Symbol::new("ETH-PERP"), *DERIVE_VENUE),
989            ClientOrderId::from("STRAT-PAYLOAD-1"),
990            side,
991            Quantity::from_decimal(qty).unwrap(),
992            Price::from_decimal(price).unwrap(),
993            time_in_force,
994            None,
995            post_only,
996            reduce_only,
997            false,
998            None,
999            None,
1000            None,
1001            None,
1002            None,
1003            None,
1004            None,
1005            None,
1006            None,
1007            None,
1008            None,
1009            UUID4::new(),
1010            UnixNanos::default(),
1011        ))
1012    }
1013
1014    fn build_test_stop_market_order() -> OrderAny {
1015        build_test_trigger_order(
1016            OrderType::StopMarket,
1017            OrderSide::Buy,
1018            None,
1019            TriggerType::Default,
1020        )
1021    }
1022
1023    fn build_test_trigger_order(
1024        order_type: OrderType,
1025        side: OrderSide,
1026        price: Option<Decimal>,
1027        trigger_type: TriggerType,
1028    ) -> OrderAny {
1029        let mut builder = OrderTestBuilder::new(order_type);
1030        builder
1031            .instrument_id(InstrumentId::new(Symbol::new("ETH-PERP"), *DERIVE_VENUE))
1032            .client_order_id(ClientOrderId::from("STRAT-PAYLOAD-STOP"))
1033            .side(side)
1034            .quantity(Quantity::from_decimal(dec!(1)).unwrap())
1035            .trigger_price(Price::from_decimal(dec!(3600)).unwrap())
1036            .trigger_type(trigger_type)
1037            .time_in_force(TimeInForce::Gtc);
1038
1039        if let Some(price) = price {
1040            builder.price(Price::from_decimal(price).unwrap());
1041        }
1042
1043        builder.build()
1044    }
1045
1046    fn build_test_market_order(side: OrderSide, qty: Decimal) -> OrderAny {
1047        OrderAny::Market(MarketOrder::new(
1048            TraderId::from("TRADER-001"),
1049            StrategyId::from("S-1"),
1050            InstrumentId::new(Symbol::new("ETH-PERP"), *DERIVE_VENUE),
1051            ClientOrderId::from("STRAT-PAYLOAD-MK"),
1052            side,
1053            Quantity::from_decimal(qty).unwrap(),
1054            TimeInForce::Ioc,
1055            UUID4::new(),
1056            UnixNanos::default(),
1057            false,
1058            false,
1059            None,
1060            None,
1061            None,
1062            None,
1063            None,
1064            None,
1065            None,
1066            None,
1067        ))
1068    }
1069
1070    #[rstest]
1071    fn test_order_to_derive_payload_limit_carries_all_required_fields() {
1072        let order = build_test_limit_order(OrderSide::Buy, dec!(3500), dec!(1), false, false);
1073        let instrument = sample_perp_instrument();
1074        let signer = sample_signer();
1075        let payload = order_to_derive_payload(
1076            &order,
1077            &instrument,
1078            30769,
1079            sample_wallet(),
1080            &signer,
1081            17_000_000_000_001,
1082            fresh_expiry_secs(),
1083            sample_module(),
1084            sample_domain(),
1085            sample_typehash(),
1086            dec!(1),
1087            None,
1088        )
1089        .map(to_value)
1090        .expect("payload built");
1091
1092        assert_eq!(payload["instrument_name"], "ETH-PERP");
1093        assert_eq!(payload["direction"], "buy");
1094        assert_eq!(payload["order_type"], "limit");
1095        assert_eq!(payload["time_in_force"], "gtc");
1096        assert_eq!(payload["label"], "STRAT-PAYLOAD-1");
1097        assert_eq!(payload["referral_code"], "nautilus");
1098        assert_eq!(payload["limit_price"], "3500");
1099        assert_eq!(payload["amount"], "1");
1100        assert_eq!(payload["max_fee"], "1");
1101        assert_eq!(payload["subaccount_id"], 30769);
1102        assert_eq!(payload["nonce"], 17_000_000_000_001_u64);
1103        assert!(payload["signature_expiry_sec"].as_i64().unwrap() > 0);
1104        let signature = payload["signature"].as_str().unwrap();
1105        assert!(signature.starts_with("0x"));
1106        assert_eq!(signature.len(), 2 + 130, "65-byte sig = 132 hex chars");
1107        assert!(payload.get("reduce_only").is_none());
1108        assert!(payload.get("mmp").is_none());
1109    }
1110
1111    #[rstest]
1112    fn test_order_to_derive_payload_accepts_uint256_sub_id() {
1113        let order = build_test_limit_order(OrderSide::Buy, dec!(1), dec!(0.1), true, false);
1114        let mut instrument = sample_perp_instrument();
1115        instrument.instrument_name = "ETH-20260529-2200-C".into();
1116        instrument.base_asset_sub_id = "39614082202024973918552016768".into();
1117        instrument.instrument_type = DeriveInstrumentType::Option;
1118        let signer = sample_signer();
1119        let payload = order_to_derive_payload(
1120            &order,
1121            &instrument,
1122            30769,
1123            sample_wallet(),
1124            &signer,
1125            17_000_000_000_002,
1126            fresh_expiry_secs(),
1127            sample_module(),
1128            sample_domain(),
1129            sample_typehash(),
1130            dec!(1),
1131            None,
1132        )
1133        .map(to_value)
1134        .expect("payload built");
1135
1136        assert_eq!(payload["instrument_name"], "ETH-20260529-2200-C");
1137        assert_eq!(payload["time_in_force"], "post_only");
1138        assert!(payload["signature"].as_str().unwrap().starts_with("0x"));
1139    }
1140
1141    #[rstest]
1142    #[case(TimeInForce::Gtc, false, "gtc")]
1143    #[case(TimeInForce::Ioc, false, "ioc")]
1144    #[case(TimeInForce::Fok, false, "fok")]
1145    #[case(TimeInForce::Gtc, true, "post_only")]
1146    fn test_order_to_derive_payload_carries_supported_time_in_force(
1147        #[case] time_in_force: TimeInForce,
1148        #[case] post_only: bool,
1149        #[case] expected: &str,
1150    ) {
1151        let order = build_test_limit_order_with_time_in_force(
1152            OrderSide::Buy,
1153            dec!(3500),
1154            dec!(1),
1155            time_in_force,
1156            post_only,
1157            false,
1158        );
1159        let instrument = sample_perp_instrument();
1160        let signer = sample_signer();
1161        let payload = order_to_derive_payload(
1162            &order,
1163            &instrument,
1164            30769,
1165            sample_wallet(),
1166            &signer,
1167            17_000_000_000_002,
1168            fresh_expiry_secs(),
1169            sample_module(),
1170            sample_domain(),
1171            sample_typehash(),
1172            dec!(0),
1173            None,
1174        )
1175        .map(to_value)
1176        .expect("payload built");
1177
1178        assert_eq!(payload["time_in_force"], expected);
1179    }
1180
1181    #[rstest]
1182    fn test_order_to_derive_payload_emits_reduce_only_and_mmp_flags_when_set() {
1183        let order = build_test_limit_order(OrderSide::Sell, dec!(3500), dec!(1), true, true);
1184        let instrument = sample_perp_instrument();
1185        let signer = sample_signer();
1186        let payload = order_to_derive_payload(
1187            &order,
1188            &instrument,
1189            30769,
1190            sample_wallet(),
1191            &signer,
1192            17_000_000_000_002,
1193            fresh_expiry_secs(),
1194            sample_module(),
1195            sample_domain(),
1196            sample_typehash(),
1197            dec!(0),
1198            None,
1199        )
1200        .map(to_value)
1201        .expect("payload built");
1202
1203        assert_eq!(payload["direction"], "sell");
1204        assert_eq!(payload["time_in_force"], "post_only");
1205        assert_eq!(payload["reduce_only"], true);
1206        assert_eq!(payload["mmp"], false);
1207    }
1208
1209    #[rstest]
1210    fn test_order_to_derive_payload_market_uses_explicit_price_override() {
1211        let order = build_test_market_order(OrderSide::Buy, dec!(0.5));
1212        let instrument = sample_perp_instrument();
1213        let signer = sample_signer();
1214        let payload = order_to_derive_payload(
1215            &order,
1216            &instrument,
1217            30769,
1218            sample_wallet(),
1219            &signer,
1220            17_000_000_000_003,
1221            fresh_expiry_secs(),
1222            sample_module(),
1223            sample_domain(),
1224            sample_typehash(),
1225            dec!(0),
1226            Some(dec!(3519)),
1227        )
1228        .map(to_value)
1229        .expect("payload built");
1230
1231        assert_eq!(payload["order_type"], "market");
1232        assert_eq!(payload["limit_price"], "3519");
1233    }
1234
1235    #[rstest]
1236    fn test_order_to_derive_payload_market_without_explicit_price_errors() {
1237        let order = build_test_market_order(OrderSide::Buy, dec!(0.5));
1238        let instrument = sample_perp_instrument();
1239        let signer = sample_signer();
1240        let err = order_to_derive_payload(
1241            &order,
1242            &instrument,
1243            30769,
1244            sample_wallet(),
1245            &signer,
1246            17_000_000_000_004,
1247            fresh_expiry_secs(),
1248            sample_module(),
1249            sample_domain(),
1250            sample_typehash(),
1251            dec!(0),
1252            None,
1253        )
1254        .expect_err("market without price must error");
1255
1256        assert!(
1257            err.to_string().contains("missing limit price"),
1258            "unexpected error: {err}",
1259        );
1260    }
1261
1262    #[rstest]
1263    #[case(TimeInForce::Day, false, "unsupported time in force")]
1264    #[case(TimeInForce::Day, true, "unsupported time in force")]
1265    #[case(TimeInForce::Ioc, true, "post-only Derive orders only support GTC")]
1266    #[case(TimeInForce::Fok, true, "post-only Derive orders only support GTC")]
1267    fn test_order_to_derive_payload_rejects_unsupported_tif(
1268        #[case] time_in_force: TimeInForce,
1269        #[case] post_only: bool,
1270        #[case] reason_fragment: &str,
1271    ) {
1272        let order = build_test_limit_order_with_time_in_force(
1273            OrderSide::Buy,
1274            dec!(3500),
1275            dec!(1),
1276            time_in_force,
1277            post_only,
1278            false,
1279        );
1280        let instrument = sample_perp_instrument();
1281        let signer = sample_signer();
1282        let err = order_to_derive_payload(
1283            &order,
1284            &instrument,
1285            30769,
1286            sample_wallet(),
1287            &signer,
1288            17_000_000_000_005,
1289            fresh_expiry_secs(),
1290            sample_module(),
1291            sample_domain(),
1292            sample_typehash(),
1293            dec!(0),
1294            None,
1295        )
1296        .expect_err("unsupported TIF must error");
1297
1298        assert!(
1299            err.to_string().contains(reason_fragment),
1300            "unexpected error: {err}",
1301        );
1302    }
1303
1304    #[rstest]
1305    fn test_order_to_derive_payload_rejects_stop_order_before_price_resolution() {
1306        let order = build_test_stop_market_order();
1307        let instrument = sample_perp_instrument();
1308        let signer = sample_signer();
1309        let err = order_to_derive_payload(
1310            &order,
1311            &instrument,
1312            30769,
1313            sample_wallet(),
1314            &signer,
1315            17_000_000_000_006,
1316            fresh_expiry_secs(),
1317            sample_module(),
1318            sample_domain(),
1319            sample_typehash(),
1320            dec!(0),
1321            None,
1322        )
1323        .expect_err("unsupported order type must error");
1324
1325        assert!(
1326            err.to_string().contains("unsupported order type"),
1327            "unexpected error: {err}",
1328        );
1329        assert!(
1330            !err.to_string().contains("missing limit price"),
1331            "unexpected error: {err}",
1332        );
1333    }
1334
1335    #[rstest]
1336    fn test_trigger_order_to_derive_payload_stop_market_uses_mark_trigger() {
1337        let order = build_test_trigger_order(
1338            OrderType::StopMarket,
1339            OrderSide::Sell,
1340            None,
1341            TriggerType::MarkPrice,
1342        );
1343        let instrument = sample_perp_instrument();
1344        let signer = sample_signer();
1345        let payload = trigger_order_to_derive_payload(
1346            &order,
1347            &instrument,
1348            30769,
1349            sample_wallet(),
1350            &signer,
1351            17_000_000_000_007,
1352            fresh_expiry_secs(),
1353            sample_module(),
1354            sample_domain(),
1355            sample_typehash(),
1356            dec!(1),
1357            Some(dec!(3400)),
1358            "conn-1",
1359            "trigger-1",
1360        )
1361        .map(to_value)
1362        .expect("trigger payload built");
1363
1364        assert_eq!(payload["conn_id"], "conn-1");
1365        assert_eq!(payload["order_id"], "trigger-1");
1366        assert_eq!(payload["direction"], "sell");
1367        assert_eq!(payload["order_type"], "market");
1368        assert_eq!(payload["limit_price"], "3400");
1369        assert_eq!(payload["trigger_price"], "3600");
1370        assert_eq!(payload["trigger_price_type"], "mark");
1371        assert_eq!(payload["trigger_type"], "stoploss");
1372    }
1373
1374    #[rstest]
1375    fn test_trigger_order_to_derive_payload_stop_limit_maps_limit_stoploss() {
1376        let order = build_test_trigger_order(
1377            OrderType::StopLimit,
1378            OrderSide::Sell,
1379            Some(dec!(3500)),
1380            TriggerType::MarkPrice,
1381        );
1382        let instrument = sample_perp_instrument();
1383        let signer = sample_signer();
1384        let payload = trigger_order_to_derive_payload(
1385            &order,
1386            &instrument,
1387            30769,
1388            sample_wallet(),
1389            &signer,
1390            17_000_000_000_008,
1391            fresh_expiry_secs(),
1392            sample_module(),
1393            sample_domain(),
1394            sample_typehash(),
1395            dec!(1),
1396            None,
1397            "conn-1",
1398            "trigger-2",
1399        )
1400        .map(to_value)
1401        .expect("trigger payload built");
1402
1403        assert_eq!(payload["order_type"], "limit");
1404        assert_eq!(payload["limit_price"], "3500");
1405        assert_eq!(payload["trigger_type"], "stoploss");
1406    }
1407
1408    #[rstest]
1409    #[case(
1410        OrderType::MarketIfTouched,
1411        DeriveOrderType::Market,
1412        DeriveTriggerType::Takeprofit
1413    )]
1414    #[case(
1415        OrderType::LimitIfTouched,
1416        DeriveOrderType::Limit,
1417        DeriveTriggerType::Takeprofit
1418    )]
1419    fn test_trigger_order_to_derive_payload_take_profit_types(
1420        #[case] order_type: OrderType,
1421        #[case] expected_order_type: DeriveOrderType,
1422        #[case] expected_trigger_type: DeriveTriggerType,
1423    ) {
1424        let price = if order_type == OrderType::LimitIfTouched {
1425            Some(dec!(3700))
1426        } else {
1427            None
1428        };
1429        let explicit_price = if price.is_none() {
1430            Some(dec!(3705))
1431        } else {
1432            None
1433        };
1434        let order =
1435            build_test_trigger_order(order_type, OrderSide::Buy, price, TriggerType::MarkPrice);
1436        let instrument = sample_perp_instrument();
1437        let signer = sample_signer();
1438        let payload = trigger_order_to_derive_payload(
1439            &order,
1440            &instrument,
1441            30769,
1442            sample_wallet(),
1443            &signer,
1444            17_000_000_000_009,
1445            fresh_expiry_secs(),
1446            sample_module(),
1447            sample_domain(),
1448            sample_typehash(),
1449            dec!(1),
1450            explicit_price,
1451            "conn-1",
1452            "trigger-3",
1453        )
1454        .expect("trigger payload built");
1455
1456        assert_eq!(payload.order.order_type, expected_order_type);
1457        assert_eq!(payload.order.trigger_type, Some(expected_trigger_type));
1458    }
1459
1460    #[rstest]
1461    fn test_trigger_order_to_derive_payload_rejects_index_trigger_price_type() {
1462        let order = build_test_trigger_order(
1463            OrderType::StopMarket,
1464            OrderSide::Buy,
1465            None,
1466            TriggerType::IndexPrice,
1467        );
1468        let instrument = sample_perp_instrument();
1469        let signer = sample_signer();
1470        let err = trigger_order_to_derive_payload(
1471            &order,
1472            &instrument,
1473            30769,
1474            sample_wallet(),
1475            &signer,
1476            17_000_000_000_010,
1477            fresh_expiry_secs(),
1478            sample_module(),
1479            sample_domain(),
1480            sample_typehash(),
1481            dec!(1),
1482            Some(dec!(3618)),
1483            "conn-1",
1484            "trigger-4",
1485        )
1486        .expect_err("index trigger price type must fail");
1487
1488        assert!(
1489            err.to_string()
1490                .contains("Derive currently accepts only MarkPrice"),
1491            "unexpected error: {err}",
1492        );
1493    }
1494
1495    #[rstest]
1496    fn test_trigger_order_to_derive_payload_maps_default_trigger_type_to_mark() {
1497        let order = build_test_trigger_order(
1498            OrderType::StopMarket,
1499            OrderSide::Buy,
1500            None,
1501            TriggerType::Default,
1502        );
1503        let instrument = sample_perp_instrument();
1504        let signer = sample_signer();
1505        let payload = trigger_order_to_derive_payload(
1506            &order,
1507            &instrument,
1508            30769,
1509            sample_wallet(),
1510            &signer,
1511            17_000_000_000_011,
1512            fresh_expiry_secs(),
1513            sample_module(),
1514            sample_domain(),
1515            sample_typehash(),
1516            dec!(1),
1517            Some(dec!(3618)),
1518            "conn-1",
1519            "trigger-5",
1520        )
1521        .expect("default trigger type should map to mark");
1522
1523        assert_eq!(
1524            payload.order.trigger_price_type,
1525            Some(DeriveTriggerPriceType::Mark),
1526        );
1527    }
1528
1529    #[rstest]
1530    fn test_order_replace_to_derive_payload_stamps_cancel_clause_and_overrides() {
1531        let order = build_test_limit_order(OrderSide::Buy, dec!(3500), dec!(1), false, false);
1532        let instrument = sample_perp_instrument();
1533        let signer = sample_signer();
1534        let payload = order_replace_to_derive_payload(
1535            &order,
1536            &instrument,
1537            30769,
1538            sample_wallet(),
1539            &signer,
1540            17_000_000_000_010,
1541            fresh_expiry_secs(),
1542            sample_module(),
1543            sample_domain(),
1544            sample_typehash(),
1545            dec!(1),
1546            Some(dec!(2)),
1547            Some(dec!(3505)),
1548            "ord-stale-1",
1549        )
1550        .map(to_value)
1551        .expect("replace payload built");
1552
1553        assert_eq!(payload["order_id_to_cancel"], "ord-stale-1");
1554        assert_eq!(payload["amount"], "2");
1555        assert_eq!(payload["limit_price"], "3505");
1556        assert_eq!(payload["direction"], "buy");
1557        assert_eq!(payload["order_type"], "limit");
1558        assert_eq!(payload["time_in_force"], "gtc");
1559        assert_eq!(payload["label"], "STRAT-PAYLOAD-1");
1560        assert_eq!(payload["subaccount_id"], 30769);
1561        assert_eq!(payload["nonce"], 17_000_000_000_010_u64);
1562        let signature = payload["signature"].as_str().unwrap();
1563        assert!(signature.starts_with("0x"));
1564        assert_eq!(signature.len(), 2 + 130);
1565    }
1566
1567    #[rstest]
1568    fn test_order_replace_to_derive_payload_falls_back_to_cached_quantity_and_price() {
1569        let order = build_test_limit_order(OrderSide::Sell, dec!(3501), dec!(0.5), false, false);
1570        let instrument = sample_perp_instrument();
1571        let signer = sample_signer();
1572        let payload = order_replace_to_derive_payload(
1573            &order,
1574            &instrument,
1575            30769,
1576            sample_wallet(),
1577            &signer,
1578            17_000_000_000_011,
1579            fresh_expiry_secs(),
1580            sample_module(),
1581            sample_domain(),
1582            sample_typehash(),
1583            dec!(0),
1584            None,
1585            None,
1586            "ord-stale-2",
1587        )
1588        .map(to_value)
1589        .expect("replace payload built");
1590
1591        assert_eq!(payload["order_id_to_cancel"], "ord-stale-2");
1592        assert_eq!(payload["amount"], "0.5");
1593        assert_eq!(payload["limit_price"], "3501");
1594        assert_eq!(payload["direction"], "sell");
1595    }
1596
1597    #[rstest]
1598    fn test_order_replace_to_derive_payload_market_without_explicit_price_errors() {
1599        let order = build_test_market_order(OrderSide::Buy, dec!(0.5));
1600        let instrument = sample_perp_instrument();
1601        let signer = sample_signer();
1602        let err = order_replace_to_derive_payload(
1603            &order,
1604            &instrument,
1605            30769,
1606            sample_wallet(),
1607            &signer,
1608            17_000_000_000_012,
1609            fresh_expiry_secs(),
1610            sample_module(),
1611            sample_domain(),
1612            sample_typehash(),
1613            dec!(0),
1614            None,
1615            None,
1616            "ord-stale-3",
1617        )
1618        .expect_err("market replace without price must error");
1619
1620        assert!(
1621            err.to_string().contains("missing limit price"),
1622            "unexpected error: {err}",
1623        );
1624    }
1625
1626    #[rstest]
1627    fn test_order_replace_to_derive_payload_rejects_stop_order_before_price_resolution() {
1628        let order = build_test_stop_market_order();
1629        let instrument = sample_perp_instrument();
1630        let signer = sample_signer();
1631        let err = order_replace_to_derive_payload(
1632            &order,
1633            &instrument,
1634            30769,
1635            sample_wallet(),
1636            &signer,
1637            17_000_000_000_013,
1638            fresh_expiry_secs(),
1639            sample_module(),
1640            sample_domain(),
1641            sample_typehash(),
1642            dec!(0),
1643            None,
1644            None,
1645            "ord-stale-4",
1646        )
1647        .expect_err("unsupported order type must error");
1648
1649        assert!(
1650            err.to_string().contains("unsupported order type"),
1651            "unexpected error: {err}",
1652        );
1653        assert!(
1654            !err.to_string().contains("missing limit price"),
1655            "unexpected error: {err}",
1656        );
1657    }
1658}