Skip to main content

nautilus_okx/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//! Data transfer objects for deserializing OKX HTTP API payloads.
17
18use nautilus_core::serialization::deserialize_optional_decimal;
19use rust_decimal::Decimal;
20use serde::{Deserialize, Deserializer, Serialize, de::IntoDeserializer};
21use ustr::Ustr;
22
23use crate::common::{
24    models::OKXRpiBookLevel,
25    parse::{
26        deserialize_empty_string_as_none, deserialize_empty_ustr_as_none,
27        deserialize_optional_string_to_u64, deserialize_target_currency_as_none,
28    },
29};
30
31/// Represents a trade tick from the GET /api/v5/market/trades endpoint.
32#[derive(Clone, Debug, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub struct OKXTrade {
35    /// Instrument ID.
36    pub inst_id: Ustr,
37    /// Trade price.
38    pub px: String,
39    /// Trade size.
40    pub sz: String,
41    /// Trade side: buy or sell.
42    pub side: OKXSide,
43    /// Trade ID assigned by OKX.
44    pub trade_id: Ustr,
45    /// Trade timestamp in milliseconds.
46    #[serde(deserialize_with = "deserialize_string_to_u64")]
47    pub ts: u64,
48    /// Trade source (0: normal order, 1: RPI order).
49    #[serde(default)]
50    pub source: Option<String>,
51}
52
53/// Represents a candlestick from the GET /api/v5/market/history-candles endpoint.
54/// The tuple contains [timestamp(ms), open, high, low, close, volume, turnover, `base_volume`, count].
55#[derive(Clone, Debug, Serialize, Deserialize)]
56pub struct OKXCandlestick(
57    /// Timestamp in milliseconds.
58    pub String,
59    /// Open price.
60    pub String,
61    /// High price.
62    pub String,
63    /// Low price.
64    pub String,
65    /// Close price.
66    pub String,
67    /// Volume.
68    pub String,
69    /// Turnover in quote currency.
70    pub String,
71    /// Base volume.
72    pub String,
73    /// Record count.
74    pub String,
75);
76
77use crate::common::{
78    enums::{
79        OKXAccountLevel, OKXAlgoOrderStatus, OKXAlgoOrderType, OKXApiKeyPermission, OKXExecType,
80        OKXFeeType, OKXInstrumentType, OKXMarginMode, OKXOrderCategory, OKXOrderStatus,
81        OKXOrderType, OKXPositionMode, OKXPositionSide, OKXSide, OKXSpreadState, OKXSpreadType,
82        OKXTargetCurrency, OKXTradeMode, OKXTriggerType, OKXVipLevel,
83    },
84    parse::deserialize_string_to_u64,
85};
86
87/// Represents a mark price from the GET /api/v5/public/mark-price endpoint.
88#[derive(Clone, Debug, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct OKXMarkPrice {
91    /// Underlying.
92    pub uly: Option<Ustr>,
93    /// Instrument ID.
94    pub inst_id: Ustr,
95    /// The mark price.
96    pub mark_px: String,
97    /// The timestamp for the mark price.
98    #[serde(deserialize_with = "deserialize_string_to_u64")]
99    pub ts: u64,
100}
101
102/// Represents a price-limit row from the GET /api/v5/public/price-limit endpoint.
103#[derive(Clone, Debug, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct OKXPriceLimit {
106    /// Instrument type.
107    pub inst_type: OKXInstrumentType,
108    /// Instrument ID.
109    pub inst_id: Ustr,
110    /// Highest buy limit price.
111    pub buy_lmt: String,
112    /// Lowest sell limit price.
113    pub sell_lmt: String,
114    /// Data timestamp in milliseconds.
115    #[serde(deserialize_with = "deserialize_string_to_u64")]
116    pub ts: u64,
117    /// Whether the price limit is effective.
118    #[serde(default)]
119    pub enabled: bool,
120}
121
122/// Represents an option summary row from the GET /api/v5/public/opt-summary endpoint.
123#[derive(Clone, Debug, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub struct OKXOptionSummary {
126    /// Instrument type.
127    pub inst_type: OKXInstrumentType,
128    /// Instrument ID.
129    pub inst_id: Ustr,
130    /// Underlying index.
131    pub uly: Ustr,
132    /// Bid volatility.
133    pub bid_vol: String,
134    /// Ask volatility.
135    pub ask_vol: String,
136    /// Mark volatility.
137    pub mark_vol: String,
138    /// Forward price.
139    pub fwd_px: String,
140    /// Data timestamp in milliseconds.
141    #[serde(deserialize_with = "deserialize_string_to_u64")]
142    pub ts: u64,
143}
144
145/// Represents a spread from the GET /api/v5/sprd/spreads endpoint.
146#[derive(Clone, Debug, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub struct OKXSpread {
149    /// Spread ID.
150    pub sprd_id: Ustr,
151    /// Spread type.
152    pub sprd_type: OKXSpreadType,
153    /// Spread status.
154    pub state: OKXSpreadState,
155    /// Base currency.
156    pub base_ccy: Ustr,
157    /// Size currency.
158    pub sz_ccy: Ustr,
159    /// Quote currency.
160    pub quote_ccy: Ustr,
161    /// Tick size in quote currency.
162    pub tick_sz: String,
163    /// Minimum order size in size currency.
164    pub min_sz: String,
165    /// Order size increment in size currency.
166    pub lot_sz: String,
167    /// Listing time in milliseconds.
168    #[serde(default, deserialize_with = "deserialize_optional_string_to_u64")]
169    pub list_time: Option<u64>,
170    /// Expiry time in milliseconds.
171    #[serde(default, deserialize_with = "deserialize_optional_string_to_u64")]
172    pub exp_time: Option<u64>,
173    /// Last update time in milliseconds.
174    #[serde(default, deserialize_with = "deserialize_optional_string_to_u64")]
175    pub u_time: Option<u64>,
176    /// Spread legs.
177    pub legs: Vec<OKXSpreadLeg>,
178}
179
180/// Represents a leg in an OKX spread.
181#[derive(Clone, Debug, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct OKXSpreadLeg {
184    /// Instrument ID.
185    pub inst_id: Ustr,
186    /// Leg side.
187    pub side: OKXSide,
188}
189
190/// Represents the request body for `POST /api/v5/sprd/order`.
191#[derive(Clone, Debug, Serialize, Deserialize)]
192#[serde(rename_all = "camelCase")]
193pub struct OKXPlaceSpreadOrderRequest {
194    /// Spread ID.
195    pub sprd_id: String,
196    /// Client-supplied order ID.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub cl_ord_id: Option<String>,
199    /// Order tag.
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub tag: Option<String>,
202    /// Order side.
203    pub side: OKXSide,
204    /// Order type.
205    pub ord_type: OKXOrderType,
206    /// Order size.
207    pub sz: String,
208    /// Limit price.
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub px: Option<String>,
211}
212
213/// Represents the request body for `POST /api/v5/sprd/cancel-order`.
214#[derive(Clone, Debug, Serialize, Deserialize)]
215#[serde(rename_all = "camelCase")]
216pub struct OKXCancelSpreadOrderRequest {
217    /// Order ID.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub ord_id: Option<String>,
220    /// Client-supplied order ID.
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub cl_ord_id: Option<String>,
223}
224
225/// Represents the request body for `POST /api/v5/sprd/mass-cancel`.
226#[derive(Clone, Debug, Serialize, Deserialize)]
227#[serde(rename_all = "camelCase")]
228pub struct OKXCancelAllSpreadOrdersRequest {
229    /// Spread ID.
230    pub sprd_id: String,
231}
232
233/// Represents a spread order from `GET /api/v5/sprd/order` and history endpoints.
234#[derive(Clone, Debug, Serialize, Deserialize)]
235#[serde(rename_all = "camelCase")]
236pub struct OKXSpreadOrder {
237    /// Spread ID.
238    pub sprd_id: Ustr,
239    /// Order ID.
240    pub ord_id: Ustr,
241    /// Client order ID.
242    #[serde(default)]
243    pub cl_ord_id: Ustr,
244    /// Order tag.
245    #[serde(default)]
246    pub tag: String,
247    /// Order side.
248    pub side: OKXSide,
249    /// Order type.
250    pub ord_type: OKXOrderType,
251    /// Order size.
252    pub sz: String,
253    /// Order price.
254    #[serde(default)]
255    pub px: String,
256    /// Average fill price.
257    #[serde(default)]
258    pub avg_px: String,
259    /// Order state.
260    pub state: OKXOrderStatus,
261    /// Accumulated filled size.
262    #[serde(default)]
263    pub acc_fill_sz: String,
264    /// Pending fill size.
265    #[serde(default)]
266    pub pending_fill_sz: String,
267    /// Pending settlement size.
268    #[serde(default)]
269    pub pending_settle_sz: String,
270    /// Canceled size.
271    #[serde(default)]
272    pub canceled_sz: String,
273    /// Last fill size.
274    #[serde(default)]
275    pub fill_sz: String,
276    /// Last fill price.
277    #[serde(default)]
278    pub fill_px: String,
279    /// Trade ID for the last fill, if provided.
280    #[serde(default)]
281    pub trade_id: Ustr,
282    /// Cancel source.
283    #[serde(default)]
284    pub cancel_source: String,
285    /// Request ID for amend responses.
286    #[serde(default)]
287    pub req_id: String,
288    /// Amend result.
289    #[serde(default)]
290    pub amend_result: String,
291    /// Response code.
292    #[serde(default)]
293    pub code: String,
294    /// Response message.
295    #[serde(default)]
296    pub msg: String,
297    /// Creation time in milliseconds.
298    #[serde(default, deserialize_with = "deserialize_optional_string_to_u64")]
299    pub c_time: Option<u64>,
300    /// Last update time in milliseconds.
301    #[serde(default, deserialize_with = "deserialize_optional_string_to_u64")]
302    pub u_time: Option<u64>,
303}
304
305/// Represents a spread trade from `GET /api/v5/sprd/trades`.
306#[derive(Clone, Debug, Serialize, Deserialize)]
307#[serde(rename_all = "camelCase")]
308pub struct OKXSpreadTrade {
309    /// Spread ID.
310    pub sprd_id: Ustr,
311    /// Trade ID.
312    pub trade_id: Ustr,
313    /// Order ID.
314    pub ord_id: Ustr,
315    /// Client order ID.
316    #[serde(default)]
317    pub cl_ord_id: Ustr,
318    /// Last filled price.
319    pub fill_px: String,
320    /// Last filled quantity.
321    pub fill_sz: String,
322    /// Trade side.
323    pub side: OKXSide,
324    /// Execution type.
325    #[serde(default)]
326    pub exec_type: OKXExecType,
327    /// Fee currency.
328    #[serde(default)]
329    pub fee_ccy: String,
330    /// Fee amount.
331    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
332    pub fee: Option<String>,
333    /// Timestamp in milliseconds.
334    #[serde(deserialize_with = "deserialize_string_to_u64")]
335    pub ts: u64,
336}
337
338/// Settlement configuration for an OKX event contract series.
339#[derive(Clone, Debug, Default, Serialize, Deserialize)]
340#[serde(rename_all = "camelCase")]
341pub struct OKXEventContractSettlement {
342    /// Settlement method.
343    #[serde(default)]
344    pub method: String,
345    /// Whether the market can settle before expiry.
346    #[serde(default)]
347    pub close_early: bool,
348    /// Settlement source name.
349    #[serde(default)]
350    pub src_name: String,
351    /// Price underlying in OKX symbol format.
352    #[serde(default)]
353    pub underlying: String,
354}
355
356/// Represents an event contract series from the GET /api/v5/public/event-contract/series endpoint.
357#[derive(Clone, Debug, Serialize, Deserialize)]
358#[serde(rename_all = "camelCase")]
359pub struct OKXEventContractSeries {
360    /// Series ID.
361    pub series_id: String,
362    /// Series frequency.
363    #[serde(default)]
364    pub freq: String,
365    /// Series title.
366    #[serde(default)]
367    pub title: String,
368    /// Series category.
369    #[serde(default)]
370    pub category: String,
371    /// Settlement information.
372    #[serde(default)]
373    pub settlement: OKXEventContractSettlement,
374}
375
376/// Represents an event from the GET /api/v5/public/event-contract/events endpoint.
377#[derive(Clone, Debug, Serialize, Deserialize)]
378#[serde(rename_all = "camelCase")]
379pub struct OKXEventContractEvent {
380    /// Series ID.
381    pub series_id: String,
382    /// Event ID.
383    pub event_id: String,
384    /// Fixing time in milliseconds, if available.
385    #[serde(default, deserialize_with = "deserialize_optional_string_to_u64")]
386    pub fix_time: Option<u64>,
387    /// Expiry time in milliseconds.
388    #[serde(default, deserialize_with = "deserialize_optional_string_to_u64")]
389    pub exp_time: Option<u64>,
390    /// Event state.
391    pub state: String,
392}
393
394/// Represents an event market from the GET /api/v5/public/event-contract/markets endpoint.
395#[derive(Clone, Debug, Serialize, Deserialize)]
396#[serde(rename_all = "camelCase")]
397pub struct OKXEventContractMarket {
398    /// Series ID.
399    pub series_id: String,
400    /// Event ID.
401    pub event_id: String,
402    /// Instrument ID.
403    pub inst_id: Ustr,
404    /// Listing time in milliseconds.
405    #[serde(default, deserialize_with = "deserialize_optional_string_to_u64")]
406    pub list_time: Option<u64>,
407    /// Fixing time in milliseconds, if available.
408    #[serde(default, deserialize_with = "deserialize_optional_string_to_u64")]
409    pub fix_time: Option<u64>,
410    /// Expiry time in milliseconds.
411    #[serde(default, deserialize_with = "deserialize_optional_string_to_u64")]
412    pub exp_time: Option<u64>,
413    /// Market state.
414    pub state: String,
415    /// Whether the market has been disputed.
416    pub disputed: bool,
417    /// Market outcome: 0 unavailable, 1 yes, 2 no.
418    pub outcome: String,
419    /// Minimum expiration value for a yes outcome.
420    pub floor_strike: String,
421    /// Maximum expiration value for a yes outcome, INF when unbounded.
422    #[serde(default)]
423    pub cap_strike: String,
424    /// Settlement value when expired.
425    pub settle_value: String,
426    /// Hit direction: up or dn, empty when not applicable.
427    #[serde(default)]
428    pub hit_dir: String,
429}
430
431/// Represents an index price from the GET /api/v5/public/index-tickers endpoint.
432#[derive(Clone, Debug, Serialize, Deserialize)]
433#[serde(rename_all = "camelCase")]
434pub struct OKXIndexTicker {
435    /// Instrument ID.
436    pub inst_id: Ustr,
437    /// The index price.
438    pub idx_px: String,
439    /// The timestamp for the index price.
440    #[serde(deserialize_with = "deserialize_string_to_u64")]
441    pub ts: u64,
442}
443
444/// Represents an order book level from the GET /api/v5/market/books endpoint.
445/// Each entry is a 4-element tuple: [price, size, `liquidated_orders`, `num_orders`].
446pub type OKXOrderBookLevel = (String, String, String, String);
447
448/// Represents an order book snapshot from the GET /api/v5/market/books endpoint.
449#[derive(Clone, Debug, Serialize, Deserialize)]
450#[serde(rename_all = "camelCase")]
451pub struct OKXOrderBookSnapshot {
452    /// Ask levels [price, size, `liquidated_orders_count`, `orders_count`].
453    pub asks: Vec<OKXOrderBookLevel>,
454    /// Bid levels [price, size, `liquidated_orders_count`, `orders_count`].
455    pub bids: Vec<OKXOrderBookLevel>,
456    /// Timestamp in milliseconds.
457    #[serde(deserialize_with = "deserialize_string_to_u64")]
458    pub ts: u64,
459}
460
461/// Represents an order book snapshot from the GET /api/v5/market/books-rpi endpoint.
462#[derive(Clone, Debug, Serialize, Deserialize)]
463#[serde(rename_all = "camelCase")]
464pub struct OKXRpiOrderBookSnapshot {
465    /// Ask levels [price, total quantity, non-RPI quantity, order count].
466    pub asks: Vec<OKXRpiBookLevel>,
467    /// Bid levels [price, total quantity, non-RPI quantity, order count].
468    pub bids: Vec<OKXRpiBookLevel>,
469    /// Sequence ID of the snapshot.
470    pub seq_id: u64,
471    /// Timestamp in milliseconds.
472    #[serde(deserialize_with = "deserialize_string_to_u64")]
473    pub ts: u64,
474}
475
476/// Represents a funding rate history entry from the GET /api/v5/public/funding-rate-history endpoint.
477#[derive(Clone, Debug, Serialize, Deserialize)]
478#[serde(rename_all = "camelCase")]
479pub struct OKXFundingRateHistory {
480    /// Instrument type.
481    pub inst_type: OKXInstrumentType,
482    /// Instrument ID.
483    pub inst_id: Ustr,
484    /// Funding rate.
485    pub funding_rate: String,
486    /// Realized rate.
487    pub realized_rate: String,
488    /// Funding time, Unix timestamp in milliseconds.
489    #[serde(deserialize_with = "deserialize_string_to_u64")]
490    pub funding_time: u64,
491    /// Funding rate calculation method.
492    #[serde(default)]
493    pub method: Option<String>,
494}
495
496/// Represents a position tier from the GET /api/v5/public/position-tiers endpoint.
497#[derive(Clone, Debug, Serialize, Deserialize)]
498#[serde(rename_all = "camelCase")]
499pub struct OKXPositionTier {
500    /// Underlying.
501    pub uly: Ustr,
502    /// Instrument family.
503    pub inst_family: String,
504    /// Instrument ID.
505    pub inst_id: Ustr,
506    /// Tier level.
507    pub tier: String,
508    /// Minimum size/amount for the tier.
509    pub min_sz: String,
510    /// Maximum size/amount for the tier.
511    pub max_sz: String,
512    /// Maintenance margin requirement rate.
513    pub mmr: String,
514    /// Initial margin requirement rate.
515    pub imr: String,
516    /// Maximum available leverage.
517    pub max_lever: String,
518    /// Option Margin Coefficient (only applicable to options).
519    pub opt_mgn_factor: String,
520    /// Quote currency borrowing amount.
521    pub quote_max_loan: String,
522    /// Base currency borrowing amount.
523    pub base_max_loan: String,
524}
525
526/// Represents configuration evidence from `GET /api/v5/account/config`.
527///
528/// The configuration fields are required and unknown enum values are rejected.
529/// Account-mode and API-key-permission policy remains the caller's responsibility.
530#[derive(Clone, Debug, Serialize, Deserialize)]
531#[serde(rename_all = "camelCase")]
532pub struct OKXAccountConfiguration {
533    /// Account mode.
534    #[serde(rename = "acctLv", deserialize_with = "deserialize_configuration_enum")]
535    pub account_level: OKXAccountLevel,
536    /// Position mode.
537    #[serde(
538        rename = "posMode",
539        deserialize_with = "deserialize_configuration_enum"
540    )]
541    pub position_mode: OKXPositionMode,
542    /// Whether automatic borrowing is enabled.
543    pub auto_loan: bool,
544    /// Configured fee-charging currency.
545    #[serde(deserialize_with = "deserialize_configuration_enum")]
546    pub fee_type: OKXFeeType,
547    /// Permissions of the requesting API key or access token, in response order.
548    #[serde(rename = "perm", with = "account_permissions")]
549    pub permissions: Vec<OKXApiKeyPermission>,
550}
551
552/// Represents an account balance snapshot from `GET /api/v5/account/balance`.
553#[derive(Clone, Debug, Serialize, Deserialize)]
554#[serde(rename_all = "camelCase")]
555pub struct OKXAccount {
556    /// Adjusted/Effective equity in USD.
557    pub adj_eq: String,
558    /// Borrow frozen amount.
559    pub borrow_froz: String,
560    /// Account details by currency.
561    pub details: Vec<OKXBalanceDetail>,
562    /// Initial margin requirement.
563    pub imr: String,
564    /// Isolated margin equity.
565    pub iso_eq: String,
566    /// Margin ratio.
567    pub mgn_ratio: String,
568    /// Maintenance margin requirement.
569    pub mmr: String,
570    /// Notional value in USD for borrow.
571    pub notional_usd_for_borrow: String,
572    /// Notional value in USD for futures.
573    pub notional_usd_for_futures: String,
574    /// Notional value in USD for option.
575    pub notional_usd_for_option: String,
576    /// Notional value in USD for swap.
577    pub notional_usd_for_swap: String,
578    /// Notional value in USD.
579    pub notional_usd: String,
580    /// Order frozen.
581    pub ord_froz: String,
582    /// Total equity in USD.
583    pub total_eq: String,
584    /// Last update time, Unix timestamp in milliseconds.
585    #[serde(deserialize_with = "deserialize_string_to_u64")]
586    pub u_time: u64,
587    /// Unrealized profit and loss.
588    pub upl: String,
589}
590
591/// Represents a balance detail for a single currency in an OKX account.
592#[derive(Clone, Debug, Serialize, Deserialize)]
593#[serde(rename_all = "camelCase")]
594#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object))]
595#[cfg_attr(
596    feature = "python",
597    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.okx")
598)]
599pub struct OKXBalanceDetail {
600    /// Available balance.
601    pub avail_bal: String,
602    /// Available equity.
603    pub avail_eq: String,
604    /// Borrow frozen amount.
605    pub borrow_froz: String,
606    /// Cash balance.
607    pub cash_bal: String,
608    /// Currency.
609    pub ccy: Ustr,
610    /// Cross liability.
611    pub cross_liab: String,
612    /// Discount equity in USD.
613    pub dis_eq: String,
614    /// Equity.
615    pub eq: String,
616    /// Equity in USD.
617    pub eq_usd: String,
618    /// Same-token equity.
619    pub smt_sync_eq: String,
620    /// Copy trading equity.
621    pub spot_copy_trading_eq: String,
622    /// Fixed balance.
623    pub fixed_bal: String,
624    /// Frozen balance.
625    pub frozen_bal: String,
626    /// Initial margin requirement.
627    pub imr: String,
628    /// Interest.
629    pub interest: String,
630    /// Isolated margin equity.
631    pub iso_eq: String,
632    /// Isolated margin liability.
633    pub iso_liab: String,
634    /// Isolated unrealized profit and loss.
635    pub iso_upl: String,
636    /// Liability.
637    pub liab: String,
638    /// Maximum loan amount.
639    pub max_loan: String,
640    /// Margin ratio.
641    pub mgn_ratio: String,
642    /// Maintenance margin requirement.
643    pub mmr: String,
644    /// Notional leverage.
645    pub notional_lever: String,
646    /// Order frozen.
647    pub ord_frozen: String,
648    /// Reward balance.
649    pub reward_bal: String,
650    /// Spot in use amount.
651    #[serde(alias = "spotInUse")]
652    pub spot_in_use_amt: String,
653    /// Cross liability spot in use amount.
654    #[serde(alias = "clSpotInUse")]
655    pub cl_spot_in_use_amt: String,
656    /// Maximum spot in use amount.
657    #[serde(alias = "maxSpotInUse")]
658    pub max_spot_in_use_amt: String,
659    /// Spot isolated balance.
660    pub spot_iso_bal: String,
661    /// Strategy equity.
662    pub stgy_eq: String,
663    /// Time-weighted average price.
664    pub twap: String,
665    /// Last update time, Unix timestamp in milliseconds.
666    #[serde(deserialize_with = "deserialize_string_to_u64")]
667    pub u_time: u64,
668    /// Unrealized profit and loss.
669    pub upl: String,
670    /// Unrealized profit and loss liability.
671    pub upl_liab: String,
672    /// Spot balance.
673    pub spot_bal: String,
674    /// Open average price.
675    pub open_avg_px: String,
676    /// Accumulated average price.
677    pub acc_avg_px: String,
678    /// Spot unrealized profit and loss.
679    pub spot_upl: String,
680    /// Spot unrealized profit and loss ratio.
681    pub spot_upl_ratio: String,
682    /// Total profit and loss.
683    pub total_pnl: String,
684    /// Total profit and loss ratio.
685    pub total_pnl_ratio: String,
686}
687
688/// Represents a single open position from `GET /api/v5/account/positions`.
689#[derive(Clone, Debug, Serialize, Deserialize)]
690#[serde(rename_all = "camelCase")]
691pub struct OKXPosition {
692    /// Instrument ID.
693    pub inst_id: Ustr,
694    /// Instrument type.
695    pub inst_type: OKXInstrumentType,
696    /// Margin mode: isolated/cross.
697    pub mgn_mode: OKXMarginMode,
698    /// Position ID.
699    #[serde(default, deserialize_with = "deserialize_empty_ustr_as_none")]
700    pub pos_id: Option<Ustr>,
701    /// Position side: long/short.
702    pub pos_side: OKXPositionSide,
703    /// Position size.
704    pub pos: String,
705    /// Base currency balance.
706    pub base_bal: String,
707    /// Position currency.
708    pub ccy: String,
709    /// Trading fee.
710    pub fee: String,
711    /// Position leverage.
712    pub lever: String,
713    /// Last traded price.
714    pub last: String,
715    /// Mark price.
716    pub mark_px: String,
717    /// Liquidation price.
718    pub liq_px: String,
719    /// Maintenance margin requirement.
720    pub mmr: String,
721    /// Interest.
722    pub interest: String,
723    /// Trade ID.
724    pub trade_id: Ustr,
725    /// Notional value of position in USD.
726    pub notional_usd: String,
727    /// Average entry price.
728    pub avg_px: String,
729    /// Unrealized profit and loss.
730    pub upl: String,
731    /// Unrealized profit and loss ratio.
732    pub upl_ratio: String,
733    /// Last update time, Unix timestamp in milliseconds.
734    #[serde(deserialize_with = "deserialize_string_to_u64")]
735    pub u_time: u64,
736    /// Position margin.
737    pub margin: String,
738    /// Margin ratio.
739    pub mgn_ratio: String,
740    /// Auto-deleveraging (ADL) ranking.
741    pub adl: String,
742    /// Creation time, Unix timestamp in milliseconds.
743    pub c_time: String,
744    /// Realized profit and loss.
745    pub realized_pnl: String,
746    /// Unrealized profit and loss at last price.
747    pub upl_last_px: String,
748    /// Unrealized profit and loss ratio at last price.
749    pub upl_ratio_last_px: String,
750    /// Available position that can be closed.
751    pub avail_pos: String,
752    /// Breakeven price.
753    pub be_px: String,
754    /// Funding fee.
755    pub funding_fee: String,
756    /// Index price.
757    pub idx_px: String,
758    /// Liquidation penalty.
759    pub liq_penalty: String,
760    /// Option value.
761    pub opt_val: String,
762    /// Pending close order liability value.
763    pub pending_close_ord_liab_val: String,
764    /// Total profit and loss.
765    pub pnl: String,
766    /// Position currency.
767    pub pos_ccy: String,
768    /// Quote currency balance.
769    pub quote_bal: String,
770    /// Borrowed amount in quote currency.
771    pub quote_borrowed: String,
772    /// Interest on quote currency.
773    pub quote_interest: String,
774    /// Amount in use for spot trading.
775    #[serde(alias = "spotInUse")]
776    pub spot_in_use_amt: String,
777    /// Currency in use for spot trading.
778    pub spot_in_use_ccy: String,
779    /// USD price.
780    pub usd_px: String,
781    /// Black-Scholes delta in dollars, only applicable to OPTION.
782    #[serde(default)]
783    pub delta_bs: String,
784    /// Black-Scholes gamma in dollars, only applicable to OPTION.
785    #[serde(default)]
786    pub gamma_bs: String,
787    /// Black-Scholes theta in dollars, only applicable to OPTION.
788    #[serde(default)]
789    pub theta_bs: String,
790    /// Black-Scholes vega in dollars, only applicable to OPTION.
791    #[serde(default)]
792    pub vega_bs: String,
793    /// Algo orders placed with `closeFraction` to close this position.
794    #[serde(default)]
795    pub close_order_algo: Vec<OKXCloseOrderAlgo>,
796}
797
798/// Represents an algo order that closes an OKX position through `closeFraction`.
799#[derive(Clone, Debug, Serialize, Deserialize)]
800#[serde(rename_all = "camelCase")]
801pub struct OKXCloseOrderAlgo {
802    /// Algo order ID.
803    pub algo_id: String,
804}
805
806/// Represents the response from `POST /api/v5/trade/order` (place order).
807/// This model is designed to be flexible and handle the minimal fields that the API returns.
808#[derive(Clone, Debug, Serialize, Deserialize)]
809#[serde(rename_all = "camelCase")]
810pub struct OKXPlaceOrderResponse {
811    /// Order ID.
812    #[serde(default)]
813    pub ord_id: Option<Ustr>,
814    /// Client order ID.
815    #[serde(default)]
816    pub cl_ord_id: Option<Ustr>,
817    /// Order tag.
818    #[serde(default)]
819    pub tag: Option<String>,
820    /// Instrument ID (optional - might not be in response).
821    #[serde(default)]
822    pub inst_id: Option<Ustr>,
823    /// Order side (optional).
824    #[serde(default)]
825    pub side: Option<OKXSide>,
826    /// Order type (optional).
827    #[serde(default)]
828    pub ord_type: Option<OKXOrderType>,
829    /// Order size (optional).
830    #[serde(default)]
831    pub sz: Option<String>,
832    /// Order state (optional).
833    pub state: Option<OKXOrderStatus>,
834    /// Price (optional).
835    #[serde(default)]
836    pub px: Option<String>,
837    /// Average price (optional).
838    #[serde(default)]
839    pub avg_px: Option<String>,
840    /// Accumulated filled size.
841    #[serde(default)]
842    pub acc_fill_sz: Option<String>,
843    /// Fill size (optional).
844    #[serde(default)]
845    pub fill_sz: Option<String>,
846    /// Fill price (optional).
847    #[serde(default)]
848    pub fill_px: Option<String>,
849    /// Trade ID (optional).
850    #[serde(default)]
851    pub trade_id: Option<Ustr>,
852    /// Fill time (optional).
853    #[serde(default)]
854    pub fill_time: Option<String>,
855    /// Fee (optional).
856    #[serde(default)]
857    pub fee: Option<String>,
858    /// Fee currency (optional).
859    #[serde(default)]
860    pub fee_ccy: Option<String>,
861    /// Request ID (optional).
862    #[serde(default)]
863    pub req_id: Option<Ustr>,
864    /// Position side (optional).
865    #[serde(default)]
866    pub pos_side: Option<OKXPositionSide>,
867    /// Reduce-only flag (optional).
868    #[serde(default)]
869    pub reduce_only: Option<String>,
870    /// Target currency (optional).
871    #[serde(default, deserialize_with = "deserialize_target_currency_as_none")]
872    pub tgt_ccy: Option<OKXTargetCurrency>,
873    /// Creation time.
874    #[serde(default)]
875    pub c_time: Option<String>,
876    /// Last update time (optional).
877    #[serde(default)]
878    pub u_time: Option<String>,
879    /// The result of the request.
880    #[serde(skip_serializing_if = "Option::is_none")]
881    pub s_code: Option<String>,
882    /// Error message if the request failed.
883    #[serde(skip_serializing_if = "Option::is_none")]
884    pub s_msg: Option<String>,
885    /// Detailed error code if the request failed.
886    #[serde(skip_serializing_if = "Option::is_none")]
887    pub sub_code: Option<String>,
888}
889
890/// Represents an attached TP/SL instruction on `POST /api/v5/trade/order`.
891#[derive(Clone, Debug, Default, Serialize, Deserialize)]
892#[serde(rename_all = "camelCase")]
893pub struct OKXAttachAlgoOrdRequest {
894    /// Client order ID for the attached TP/SL OCO object.
895    #[serde(skip_serializing_if = "Option::is_none")]
896    pub attach_algo_cl_ord_id: Option<String>,
897    /// Stop-loss trigger price.
898    #[serde(skip_serializing_if = "Option::is_none")]
899    pub sl_trigger_px: Option<String>,
900    /// Stop-loss order price.
901    #[serde(skip_serializing_if = "Option::is_none")]
902    pub sl_ord_px: Option<String>,
903    /// Stop-loss trigger price type.
904    #[serde(skip_serializing_if = "Option::is_none")]
905    pub sl_trigger_px_type: Option<OKXTriggerType>,
906    /// Take-profit trigger price.
907    #[serde(skip_serializing_if = "Option::is_none")]
908    pub tp_trigger_px: Option<String>,
909    /// Take-profit order price.
910    #[serde(skip_serializing_if = "Option::is_none")]
911    pub tp_ord_px: Option<String>,
912    /// Take-profit trigger price type.
913    #[serde(skip_serializing_if = "Option::is_none")]
914    pub tp_trigger_px_type: Option<OKXTriggerType>,
915    /// Callback ratio for attached trailing stop orders.
916    #[serde(skip_serializing_if = "Option::is_none")]
917    pub callback_ratio: Option<String>,
918    /// Callback spread for attached trailing stop orders.
919    #[serde(skip_serializing_if = "Option::is_none")]
920    pub callback_spread: Option<String>,
921    /// Activation price for attached trailing stop orders.
922    #[serde(skip_serializing_if = "Option::is_none")]
923    pub active_px: Option<String>,
924    /// New callback ratio for amended attached trailing stop orders.
925    #[serde(skip_serializing_if = "Option::is_none")]
926    pub new_callback_ratio: Option<String>,
927    /// New callback spread for amended attached trailing stop orders.
928    #[serde(skip_serializing_if = "Option::is_none")]
929    pub new_callback_spread: Option<String>,
930    /// New activation price for amended attached trailing stop orders.
931    #[serde(skip_serializing_if = "Option::is_none")]
932    pub new_active_px: Option<String>,
933}
934
935/// Represents the request body for `POST /api/v5/trade/order` (place order).
936#[derive(Clone, Debug, Serialize, Deserialize)]
937#[serde(rename_all = "camelCase")]
938pub struct OKXPlaceOrderRequest {
939    /// Instrument ID.
940    pub inst_id: String,
941    /// Trade mode (cash, cross, isolated).
942    pub td_mode: OKXTradeMode,
943    /// Currency used for margin trading when required by OKX.
944    #[serde(skip_serializing_if = "Option::is_none")]
945    pub ccy: Option<String>,
946    /// Client-supplied order ID.
947    #[serde(skip_serializing_if = "Option::is_none")]
948    pub cl_ord_id: Option<String>,
949    /// Order tag.
950    #[serde(skip_serializing_if = "Option::is_none")]
951    pub tag: Option<String>,
952    /// Order side (buy, sell).
953    pub side: OKXSide,
954    /// Position side for derivatives.
955    #[serde(skip_serializing_if = "Option::is_none")]
956    pub pos_side: Option<OKXPositionSide>,
957    /// Order type.
958    pub ord_type: OKXOrderType,
959    /// Order size.
960    pub sz: String,
961    /// Limit price when required by the order type.
962    #[serde(skip_serializing_if = "Option::is_none")]
963    pub px: Option<String>,
964    /// Price in USD, only applicable to options. Mutually exclusive with `px` and `px_vol`.
965    #[serde(rename = "pxUsd", skip_serializing_if = "Option::is_none")]
966    pub px_usd: Option<String>,
967    /// Price in implied volatility (1 = 100%), only applicable to options.
968    /// Mutually exclusive with `px` and `px_usd`.
969    #[serde(rename = "pxVol", skip_serializing_if = "Option::is_none")]
970    pub px_vol: Option<String>,
971    /// Reduce-only flag.
972    #[serde(skip_serializing_if = "Option::is_none")]
973    pub reduce_only: Option<bool>,
974    /// Target currency for spot market orders.
975    #[serde(skip_serializing_if = "Option::is_none")]
976    pub tgt_ccy: Option<OKXTargetCurrency>,
977    /// Quote currency used for trading. Only applicable to SPOT.
978    #[serde(skip_serializing_if = "Option::is_none")]
979    pub trade_quote_ccy: Option<Ustr>,
980    /// Attached TP/SL OCO instructions.
981    #[serde(skip_serializing_if = "Option::is_none")]
982    pub attach_algo_ords: Option<Vec<OKXAttachAlgoOrdRequest>>,
983    /// Event contract market outcome: yes or no.
984    #[serde(skip_serializing_if = "Option::is_none")]
985    pub outcome: Option<String>,
986    /// Slippage tolerance for market orders, expressed as a decimal fraction
987    /// (e.g., "0.005" for 0.5%). Supported instrument/order-type scope is
988    /// venue-controlled; rejected with `54084`/`54085` if exceeded or out of
989    /// the venue's accepted range. See the OKX v5 docs for the current matrix.
990    #[serde(skip_serializing_if = "Option::is_none")]
991    pub slippage_pct: Option<String>,
992    /// Whether the order may take RPI liquidity.
993    #[serde(skip_serializing_if = "Option::is_none")]
994    pub rpi_taker_access: Option<bool>,
995    /// Whether OKX may round the order price to an eligible RPI price.
996    #[serde(skip_serializing_if = "Option::is_none")]
997    pub rpi_px_round: Option<bool>,
998}
999
1000/// Represents the request body for `POST /api/v5/trade/amend-order`.
1001#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1002#[serde(rename_all = "camelCase")]
1003pub struct OKXAmendOrderRequest {
1004    /// Instrument ID.
1005    pub inst_id: String,
1006    /// Order ID.
1007    #[serde(skip_serializing_if = "Option::is_none")]
1008    pub ord_id: Option<String>,
1009    /// Client-supplied order ID.
1010    #[serde(skip_serializing_if = "Option::is_none")]
1011    pub cl_ord_id: Option<String>,
1012    /// Client-supplied request ID.
1013    #[serde(skip_serializing_if = "Option::is_none")]
1014    pub req_id: Option<String>,
1015    /// New order size.
1016    #[serde(skip_serializing_if = "Option::is_none")]
1017    pub new_sz: Option<String>,
1018    /// New order price.
1019    #[serde(skip_serializing_if = "Option::is_none")]
1020    pub new_px: Option<String>,
1021    /// Whether the order may take RPI liquidity after amendment.
1022    #[serde(skip_serializing_if = "Option::is_none")]
1023    pub rpi_taker_access: Option<bool>,
1024    /// Whether OKX may round the amended price to an eligible RPI price.
1025    #[serde(skip_serializing_if = "Option::is_none")]
1026    pub rpi_px_round: Option<bool>,
1027}
1028
1029/// Represents the request body for `POST /api/v5/trade/cancel-batch-orders`.
1030#[derive(Clone, Debug, Serialize, Deserialize)]
1031#[serde(rename_all = "camelCase")]
1032pub struct OKXCancelOrderRequest {
1033    /// Instrument ID.
1034    pub inst_id: String,
1035    /// Instrument ID code (numeric). May be required per OKX deprecation notice.
1036    #[serde(rename = "instIdCode", skip_serializing_if = "Option::is_none")]
1037    pub inst_id_code: Option<u64>,
1038    /// Order ID.
1039    #[serde(skip_serializing_if = "Option::is_none")]
1040    pub ord_id: Option<String>,
1041    /// Client-supplied order ID.
1042    #[serde(skip_serializing_if = "Option::is_none")]
1043    pub cl_ord_id: Option<String>,
1044}
1045
1046/// Represents a single response item from `POST /api/v5/trade/cancel-batch-orders`.
1047#[derive(Clone, Debug, Serialize, Deserialize)]
1048#[serde(rename_all = "camelCase")]
1049pub struct OKXCancelOrderResponse {
1050    /// Order ID.
1051    pub ord_id: String,
1052    /// Client-supplied order ID.
1053    #[serde(default)]
1054    pub cl_ord_id: Option<String>,
1055    /// The result of the request.
1056    #[serde(skip_serializing_if = "Option::is_none")]
1057    pub s_code: Option<String>,
1058    /// Error message if the request failed.
1059    #[serde(skip_serializing_if = "Option::is_none")]
1060    pub s_msg: Option<String>,
1061    /// Response timestamp.
1062    #[serde(default)]
1063    pub ts: Option<String>,
1064}
1065
1066pub use crate::common::models::OKXAttachedAlgoOrd;
1067
1068/// Represents a single historical order record from `GET /api/v5/trade/orders-history`.
1069#[derive(Clone, Debug, Serialize, Deserialize)]
1070#[serde(rename_all = "camelCase")]
1071pub struct OKXOrderHistory {
1072    /// Order ID.
1073    pub ord_id: Ustr,
1074    /// Client order ID.
1075    pub cl_ord_id: Ustr,
1076    /// Algo order ID (for conditional orders).
1077    #[serde(default)]
1078    pub algo_id: Option<Ustr>,
1079    /// Client-supplied algo order ID (for conditional orders).
1080    #[serde(default)]
1081    pub algo_cl_ord_id: Option<Ustr>,
1082    /// Attached child client order ID if OKX surfaces one at the top level.
1083    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
1084    pub attach_algo_cl_ord_id: Option<String>,
1085    /// Attached TP/SL child orders associated with the parent order.
1086    #[serde(default)]
1087    pub attach_algo_ords: Vec<OKXAttachedAlgoOrd>,
1088    /// Client account ID (may be omitted by OKX).
1089    #[serde(default)]
1090    pub cl_act_id: Option<Ustr>,
1091    /// Order tag.
1092    pub tag: String,
1093    /// Instrument type.
1094    pub inst_type: OKXInstrumentType,
1095    /// Underlying (optional).
1096    pub uly: Option<Ustr>,
1097    /// Instrument ID.
1098    pub inst_id: Ustr,
1099    /// Order type.
1100    pub ord_type: OKXOrderType,
1101    /// Order size.
1102    pub sz: String,
1103    /// Price (optional).
1104    pub px: String,
1105    /// Price in USD (options only).
1106    #[serde(default)]
1107    pub px_usd: String,
1108    /// Price in implied volatility (options only).
1109    #[serde(default)]
1110    pub px_vol: String,
1111    /// Side.
1112    pub side: OKXSide,
1113    /// Position side.
1114    pub pos_side: OKXPositionSide,
1115    /// Trade mode.
1116    pub td_mode: OKXTradeMode,
1117    /// Reduce-only flag.
1118    pub reduce_only: String,
1119    /// Target currency (optional).
1120    #[serde(default, deserialize_with = "deserialize_target_currency_as_none")]
1121    pub tgt_ccy: Option<OKXTargetCurrency>,
1122    /// Order state.
1123    pub state: OKXOrderStatus,
1124    /// Average price (optional).
1125    pub avg_px: String,
1126    /// Execution fee.
1127    pub fee: String,
1128    /// Fee currency.
1129    pub fee_ccy: String,
1130    /// Filled size (optional).
1131    pub fill_sz: String,
1132    /// Fill price (optional).
1133    pub fill_px: String,
1134    /// Trade ID (optional).
1135    pub trade_id: Ustr,
1136    /// Fill time, Unix timestamp in milliseconds.
1137    #[serde(deserialize_with = "deserialize_string_to_u64")]
1138    pub fill_time: u64,
1139    /// Accumulated filled size.
1140    pub acc_fill_sz: String,
1141    /// Fill fee (optional, may be omitted).
1142    #[serde(default)]
1143    pub fill_fee: Option<String>,
1144    /// Request ID (optional).
1145    #[serde(default)]
1146    pub req_id: Option<Ustr>,
1147    /// Cancelled filled size (optional).
1148    #[serde(default)]
1149    pub cancel_fill_sz: Option<String>,
1150    /// Cancelled total size (optional).
1151    #[serde(default)]
1152    pub cancel_total_sz: Option<String>,
1153    /// Venue cancellation source code.
1154    #[serde(default)]
1155    pub cancel_source: String,
1156    /// Venue cancellation reason.
1157    #[serde(default)]
1158    pub cancel_source_reason: String,
1159    /// Fee discount (optional).
1160    #[serde(default)]
1161    pub fee_discount: Option<String>,
1162    /// Order category (normal, liquidation, ADL, etc.).
1163    pub category: OKXOrderCategory,
1164    /// Event contract market outcome, if applicable.
1165    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
1166    pub outcome: Option<String>,
1167    /// Last update time, Unix timestamp in milliseconds.
1168    #[serde(deserialize_with = "deserialize_string_to_u64")]
1169    pub u_time: u64,
1170    /// Creation time.
1171    #[serde(deserialize_with = "deserialize_string_to_u64")]
1172    pub c_time: u64,
1173}
1174
1175/// Represents an algo order response from `/trade/order-algo-*` endpoints.
1176#[derive(Clone, Debug, Serialize, Deserialize)]
1177#[serde(rename_all = "camelCase")]
1178pub struct OKXOrderAlgo {
1179    /// Algo order ID assigned by OKX.
1180    pub algo_id: String,
1181    /// Client-specified algo order ID.
1182    #[serde(default)]
1183    pub algo_cl_ord_id: String,
1184    /// Client order ID (empty until triggered).
1185    #[serde(default)]
1186    pub cl_ord_id: String,
1187    /// Latest regular order ID (deprecated by OKX; empty until triggered).
1188    #[serde(default)]
1189    pub ord_id: String,
1190    /// Regular order IDs created after the algo order triggers.
1191    #[serde(default)]
1192    pub ord_id_list: Vec<String>,
1193    /// Child algo order IDs created for split take-profit orders.
1194    #[serde(default)]
1195    pub sub_algo_id_list: Vec<String>,
1196    /// Instrument ID, e.g. `ETH-USDT-SWAP`.
1197    pub inst_id: Ustr,
1198    /// Instrument type.
1199    pub inst_type: OKXInstrumentType,
1200    /// Algo order type.
1201    pub ord_type: OKXAlgoOrderType,
1202    /// Current order state.
1203    pub state: OKXAlgoOrderStatus,
1204    /// Order side.
1205    pub side: OKXSide,
1206    /// Position side.
1207    pub pos_side: OKXPositionSide,
1208    /// Submitted size.
1209    #[serde(default)]
1210    pub sz: String,
1211    /// Trigger price (empty for certain algo styles).
1212    #[serde(default)]
1213    pub trigger_px: String,
1214    /// Trigger price type (last/mark/index).
1215    #[serde(default)]
1216    pub trigger_px_type: Option<OKXTriggerType>,
1217    /// Stop-loss trigger price for conditional close orders.
1218    #[serde(default)]
1219    pub sl_trigger_px: String,
1220    /// Stop-loss order price for conditional close orders.
1221    #[serde(default)]
1222    pub sl_ord_px: String,
1223    /// Stop-loss trigger price type (last/mark/index).
1224    #[serde(default)]
1225    pub sl_trigger_px_type: Option<OKXTriggerType>,
1226    /// Take-profit trigger price for conditional close orders.
1227    #[serde(default)]
1228    pub tp_trigger_px: String,
1229    /// Take-profit order price for conditional close orders.
1230    #[serde(default)]
1231    pub tp_ord_px: String,
1232    /// Take-profit trigger price type (last/mark/index).
1233    #[serde(default)]
1234    pub tp_trigger_px_type: Option<OKXTriggerType>,
1235    /// Order price (-1 indicates market execution once triggered).
1236    #[serde(default)]
1237    pub ord_px: String,
1238    /// Trade mode (cash/cross/isolated).
1239    pub td_mode: OKXTradeMode,
1240    /// Algo leverage configuration.
1241    #[serde(default)]
1242    pub lever: String,
1243    /// Reduce-only flag.
1244    #[serde(default)]
1245    pub reduce_only: String,
1246    /// Fraction of the position to close for close-order algos.
1247    #[serde(default)]
1248    pub close_fraction: String,
1249    /// Executed price (if triggered).
1250    #[serde(default)]
1251    pub actual_px: String,
1252    /// Executed size (if triggered).
1253    #[serde(default)]
1254    pub actual_sz: String,
1255    /// Notional value in USD.
1256    #[serde(default)]
1257    pub notional_usd: String,
1258    /// Creation time (milliseconds).
1259    #[serde(deserialize_with = "deserialize_string_to_u64")]
1260    pub c_time: u64,
1261    /// Last update time (milliseconds).
1262    #[serde(deserialize_with = "deserialize_string_to_u64")]
1263    pub u_time: u64,
1264    /// Trigger timestamp (if triggered).
1265    #[serde(default)]
1266    pub trigger_time: String,
1267    /// Optional tag supplied during submission.
1268    #[serde(default)]
1269    pub tag: String,
1270    /// Callback price ratio for trailing stop (e.g. "0.01" for 1%).
1271    #[serde(default)]
1272    pub callback_ratio: String,
1273    /// Callback price spread for trailing stop (absolute distance).
1274    #[serde(default)]
1275    pub callback_spread: String,
1276    /// Activation price for trailing stop.
1277    #[serde(default)]
1278    pub active_px: String,
1279}
1280
1281/// Represents a transaction detail (fill) from `GET /api/v5/trade/fills`.
1282#[derive(Clone, Debug, Serialize, Deserialize)]
1283#[serde(rename_all = "camelCase")]
1284pub struct OKXTransactionDetail {
1285    /// Product type (SPOT, MARGIN, SWAP, FUTURES, OPTION).
1286    pub inst_type: OKXInstrumentType,
1287    /// Instrument ID, e.g. "BTC-USDT".
1288    pub inst_id: Ustr,
1289    /// Trade ID.
1290    pub trade_id: Ustr,
1291    /// Order ID.
1292    pub ord_id: Ustr,
1293    /// Client order ID.
1294    pub cl_ord_id: Ustr,
1295    /// Bill ID.
1296    pub bill_id: Ustr,
1297    /// Last filled price.
1298    pub fill_px: String,
1299    /// Last filled quantity.
1300    pub fill_sz: String,
1301    /// Trade side: buy or sell.
1302    pub side: OKXSide,
1303    /// Execution type.
1304    pub exec_type: OKXExecType,
1305    /// Fee currency.
1306    pub fee_ccy: String,
1307    /// Fee amount.
1308    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
1309    pub fee: Option<String>,
1310    /// Timestamp, Unix timestamp format in milliseconds.
1311    #[serde(deserialize_with = "deserialize_string_to_u64")]
1312    pub ts: u64,
1313}
1314
1315/// Represents a single historical position record from `GET /api/v5/account/positions-history`.
1316#[derive(Clone, Debug, Serialize, Deserialize)]
1317#[serde(rename_all = "camelCase")]
1318pub struct OKXPositionHistory {
1319    /// Instrument type (e.g. "SWAP", "FUTURES", etc.).
1320    pub inst_type: OKXInstrumentType,
1321    /// Instrument ID (e.g. "BTC-USD-SWAP").
1322    pub inst_id: Ustr,
1323    /// Margin mode: e.g. "cross", "isolated".
1324    pub mgn_mode: OKXMarginMode,
1325    /// The type of the last close, e.g. "1" (close partially), "2" (close all), etc.
1326    /// See OKX docs for the meaning of each numeric code.
1327    #[serde(rename = "type")]
1328    pub r#type: Ustr,
1329    /// Creation time of the position (Unix timestamp in milliseconds).
1330    pub c_time: String,
1331    /// Last update time, Unix timestamp in milliseconds.
1332    #[serde(deserialize_with = "deserialize_string_to_u64")]
1333    pub u_time: u64,
1334    /// Average price of opening position.
1335    pub open_avg_px: String,
1336    /// Average price of closing position (if applicable).
1337    #[serde(skip_serializing_if = "Option::is_none")]
1338    pub close_avg_px: Option<String>,
1339    /// The position ID.
1340    #[serde(default, deserialize_with = "deserialize_empty_ustr_as_none")]
1341    pub pos_id: Option<Ustr>,
1342    /// Max quantity of the position at open time.
1343    #[serde(skip_serializing_if = "Option::is_none")]
1344    pub open_max_pos: Option<String>,
1345    /// Cumulative closed volume of the position.
1346    #[serde(skip_serializing_if = "Option::is_none")]
1347    pub close_total_pos: Option<String>,
1348    /// Realized profit and loss (only for FUTURES/SWAP/OPTION).
1349    #[serde(skip_serializing_if = "Option::is_none")]
1350    pub realized_pnl: Option<String>,
1351    /// Accumulated fee for the position.
1352    #[serde(skip_serializing_if = "Option::is_none")]
1353    pub fee: Option<String>,
1354    /// Accumulated funding fee (for perpetual swaps).
1355    #[serde(skip_serializing_if = "Option::is_none")]
1356    pub funding_fee: Option<String>,
1357    /// Accumulated liquidation penalty. Negative if there was a penalty.
1358    #[serde(skip_serializing_if = "Option::is_none")]
1359    pub liq_penalty: Option<String>,
1360    /// Profit and loss (realized or unrealized depending on status).
1361    #[serde(skip_serializing_if = "Option::is_none")]
1362    pub pnl: Option<String>,
1363    /// PnL ratio.
1364    #[serde(skip_serializing_if = "Option::is_none")]
1365    pub pnl_ratio: Option<String>,
1366    /// Position side: "long" / "short" / "net".
1367    pub pos_side: OKXPositionSide,
1368    /// Leverage used (the JSON field is "lev", but we rename it in Rust).
1369    pub lever: String,
1370    /// Direction: "long" or "short" (only for MARGIN/FUTURES/SWAP/OPTION).
1371    #[serde(skip_serializing_if = "Option::is_none")]
1372    pub direction: Option<String>,
1373    /// Trigger mark price. Populated if `type` indicates liquidation or ADL.
1374    #[serde(skip_serializing_if = "Option::is_none")]
1375    pub trigger_px: Option<String>,
1376    /// The underlying (e.g. "BTC-USD" for futures or swap).
1377    #[serde(skip_serializing_if = "Option::is_none")]
1378    pub uly: Option<String>,
1379    /// Currency (e.g. "BTC"). May or may not appear in all responses.
1380    #[serde(skip_serializing_if = "Option::is_none")]
1381    pub ccy: Option<String>,
1382}
1383
1384/// Represents the request body for `POST /api/v5/trade/order-algo` (place algo order).
1385#[derive(Clone, Debug, Serialize, Deserialize)]
1386#[serde(rename_all = "camelCase")]
1387pub struct OKXPlaceAlgoOrderRequest {
1388    /// Instrument ID.
1389    #[serde(rename = "instId")]
1390    pub inst_id: String,
1391    /// Instrument ID code (numeric). May be required per OKX deprecation notice.
1392    #[serde(rename = "instIdCode", skip_serializing_if = "Option::is_none")]
1393    pub inst_id_code: Option<u64>,
1394    /// Trade mode (isolated, cross, cash).
1395    #[serde(rename = "tdMode")]
1396    pub td_mode: OKXTradeMode,
1397    /// Order side (buy, sell).
1398    pub side: OKXSide,
1399    /// Algo order type (trigger, conditional, `move_order_stop`, etc.).
1400    #[serde(rename = "ordType")]
1401    pub ord_type: OKXAlgoOrderType,
1402    /// Order size. Omitted for `closeFraction` close orders.
1403    #[serde(skip_serializing_if = "Option::is_none")]
1404    pub sz: Option<String>,
1405    /// Client-supplied algo order ID.
1406    #[serde(rename = "algoClOrdId", skip_serializing_if = "Option::is_none")]
1407    pub algo_cl_ord_id: Option<String>,
1408    /// Trigger price.
1409    #[serde(rename = "triggerPx", skip_serializing_if = "Option::is_none")]
1410    pub trigger_px: Option<String>,
1411    /// Order price (for limit orders).
1412    #[serde(rename = "orderPx", skip_serializing_if = "Option::is_none")]
1413    pub order_px: Option<String>,
1414    /// Trigger type (last, mark, index).
1415    #[serde(rename = "triggerPxType", skip_serializing_if = "Option::is_none")]
1416    pub trigger_px_type: Option<OKXTriggerType>,
1417    /// Stop-loss trigger price for conditional close orders.
1418    #[serde(rename = "slTriggerPx", skip_serializing_if = "Option::is_none")]
1419    pub sl_trigger_px: Option<String>,
1420    /// Stop-loss order price for conditional close orders.
1421    #[serde(rename = "slOrdPx", skip_serializing_if = "Option::is_none")]
1422    pub sl_ord_px: Option<String>,
1423    /// Stop-loss trigger type (last, mark, index).
1424    #[serde(rename = "slTriggerPxType", skip_serializing_if = "Option::is_none")]
1425    pub sl_trigger_px_type: Option<OKXTriggerType>,
1426    /// Take-profit trigger price for conditional close orders.
1427    #[serde(rename = "tpTriggerPx", skip_serializing_if = "Option::is_none")]
1428    pub tp_trigger_px: Option<String>,
1429    /// Take-profit order price for conditional close orders.
1430    #[serde(rename = "tpOrdPx", skip_serializing_if = "Option::is_none")]
1431    pub tp_ord_px: Option<String>,
1432    /// Take-profit trigger type (last, mark, index).
1433    #[serde(rename = "tpTriggerPxType", skip_serializing_if = "Option::is_none")]
1434    pub tp_trigger_px_type: Option<OKXTriggerType>,
1435    /// Target currency (`base_ccy` or `quote_ccy`).
1436    #[serde(rename = "tgtCcy", skip_serializing_if = "Option::is_none")]
1437    pub tgt_ccy: Option<OKXTargetCurrency>,
1438    /// Position side (net, long, short).
1439    #[serde(rename = "posSide", skip_serializing_if = "Option::is_none")]
1440    pub pos_side: Option<OKXPositionSide>,
1441    /// Whether to close position.
1442    #[serde(rename = "closePosition", skip_serializing_if = "Option::is_none")]
1443    pub close_position: Option<bool>,
1444    /// Order tag.
1445    #[serde(skip_serializing_if = "Option::is_none")]
1446    pub tag: Option<String>,
1447    /// Whether it's a reduce-only order.
1448    #[serde(rename = "reduceOnly", skip_serializing_if = "Option::is_none")]
1449    pub reduce_only: Option<bool>,
1450    /// Fraction of the position to close for eligible algo close orders.
1451    #[serde(rename = "closeFraction", skip_serializing_if = "Option::is_none")]
1452    pub close_fraction: Option<String>,
1453    /// Callback rate for trailing stop (e.g., "0.01" for 1%). Either this or
1454    /// `callback_spread` is required for `move_order_stop` orders.
1455    #[serde(rename = "callbackRatio", skip_serializing_if = "Option::is_none")]
1456    pub callback_ratio: Option<String>,
1457    /// Callback spread for trailing stop (fixed price distance). Either this or
1458    /// `callback_ratio` is required for `move_order_stop` orders.
1459    #[serde(rename = "callbackSpread", skip_serializing_if = "Option::is_none")]
1460    pub callback_spread: Option<String>,
1461    /// Activation price for trailing stop. If empty, the trailing stop
1462    /// activates immediately when placed.
1463    #[serde(rename = "activePx", skip_serializing_if = "Option::is_none")]
1464    pub active_px: Option<String>,
1465}
1466
1467/// Represents the response from `POST /api/v5/trade/order-algo` (place algo order).
1468#[derive(Clone, Debug, Serialize, Deserialize)]
1469#[serde(rename_all = "camelCase")]
1470pub struct OKXPlaceAlgoOrderResponse {
1471    /// Algo order ID.
1472    pub algo_id: String,
1473    /// Client-supplied algo order ID.
1474    #[serde(skip_serializing_if = "Option::is_none")]
1475    pub algo_cl_ord_id: Option<String>,
1476    /// The result of the request.
1477    #[serde(skip_serializing_if = "Option::is_none")]
1478    pub s_code: Option<String>,
1479    /// Error message if the request failed.
1480    #[serde(skip_serializing_if = "Option::is_none")]
1481    pub s_msg: Option<String>,
1482    /// Request ID.
1483    #[serde(skip_serializing_if = "Option::is_none")]
1484    pub req_id: Option<String>,
1485}
1486
1487/// Represents the request body for `POST /api/v5/trade/cancel-algos` (cancel algo order).
1488#[derive(Clone, Debug, Serialize, Deserialize)]
1489#[serde(rename_all = "camelCase")]
1490pub struct OKXCancelAlgoOrderRequest {
1491    /// Instrument ID.
1492    pub inst_id: String,
1493    /// Instrument ID code (numeric). May be required per OKX deprecation notice.
1494    #[serde(rename = "instIdCode", skip_serializing_if = "Option::is_none")]
1495    pub inst_id_code: Option<u64>,
1496    /// Algo order ID.
1497    #[serde(skip_serializing_if = "Option::is_none")]
1498    pub algo_id: Option<String>,
1499    /// Client-supplied algo order ID.
1500    #[serde(skip_serializing_if = "Option::is_none")]
1501    pub algo_cl_ord_id: Option<String>,
1502}
1503
1504/// Represents the response from `POST /api/v5/trade/cancel-algos` (cancel algo order).
1505#[derive(Clone, Debug, Serialize, Deserialize)]
1506#[serde(rename_all = "camelCase")]
1507pub struct OKXCancelAlgoOrderResponse {
1508    /// Algo order ID.
1509    pub algo_id: String,
1510    /// The result of the request.
1511    #[serde(skip_serializing_if = "Option::is_none")]
1512    pub s_code: Option<String>,
1513    /// Error message if the request failed.
1514    #[serde(skip_serializing_if = "Option::is_none")]
1515    pub s_msg: Option<String>,
1516}
1517
1518/// Represents the request body for `POST /api/v5/trade/amend-algos` (amend algo order).
1519#[derive(Clone, Debug, Serialize, Deserialize)]
1520#[serde(rename_all = "camelCase")]
1521pub struct OKXAmendAlgoOrderRequest {
1522    /// Instrument ID.
1523    pub inst_id: String,
1524    /// Algo order ID.
1525    pub algo_id: String,
1526    /// Client-supplied algo order ID.
1527    #[serde(skip_serializing_if = "Option::is_none")]
1528    pub algo_cl_ord_id: Option<String>,
1529    /// New order size.
1530    #[serde(skip_serializing_if = "Option::is_none")]
1531    pub new_sz: Option<String>,
1532    /// New trigger price (for `trigger` algo orders).
1533    #[serde(skip_serializing_if = "Option::is_none")]
1534    pub new_trigger_px: Option<String>,
1535    /// New trigger price type (last, mark, index) for `trigger` algo orders.
1536    #[serde(skip_serializing_if = "Option::is_none")]
1537    pub new_trigger_px_type: Option<String>,
1538    /// New take-profit trigger price (for attached/OCO TP legs).
1539    #[serde(skip_serializing_if = "Option::is_none")]
1540    pub new_tp_trigger_px: Option<String>,
1541    /// New take-profit order price (`-1` for market).
1542    #[serde(skip_serializing_if = "Option::is_none")]
1543    pub new_tp_ord_px: Option<String>,
1544    /// New take-profit trigger price type (last, mark, index).
1545    #[serde(skip_serializing_if = "Option::is_none")]
1546    pub new_tp_trigger_px_type: Option<String>,
1547    /// New stop-loss trigger price (for `conditional` SL algo orders, incl.
1548    /// `closeFraction` close-position stops, which OKX amends via `newSlTriggerPx`
1549    /// rather than `newTriggerPx`).
1550    #[serde(skip_serializing_if = "Option::is_none")]
1551    pub new_sl_trigger_px: Option<String>,
1552    /// New stop-loss order price (`-1` for market).
1553    #[serde(skip_serializing_if = "Option::is_none")]
1554    pub new_sl_ord_px: Option<String>,
1555    /// New stop-loss trigger price type (last, mark, index).
1556    #[serde(skip_serializing_if = "Option::is_none")]
1557    pub new_sl_trigger_px_type: Option<String>,
1558    /// New order price (`-1` for market).
1559    #[serde(skip_serializing_if = "Option::is_none")]
1560    #[serde(rename = "newOrdPx")]
1561    pub new_order_px: Option<String>,
1562    /// New callback ratio for trailing stop (e.g., "0.01" for 1%).
1563    #[serde(skip_serializing_if = "Option::is_none")]
1564    pub new_callback_ratio: Option<String>,
1565    /// New callback spread for trailing stop (fixed price distance).
1566    #[serde(skip_serializing_if = "Option::is_none")]
1567    pub new_callback_spread: Option<String>,
1568    /// New activation price for trailing stop.
1569    #[serde(skip_serializing_if = "Option::is_none")]
1570    pub new_active_px: Option<String>,
1571}
1572
1573/// Represents the response from `POST /api/v5/trade/amend-algos` (amend algo order).
1574#[derive(Clone, Debug, Serialize, Deserialize)]
1575#[serde(rename_all = "camelCase")]
1576pub struct OKXAmendAlgoOrderResponse {
1577    /// Algo order ID.
1578    pub algo_id: String,
1579    /// Client-supplied algo order ID.
1580    #[serde(skip_serializing_if = "Option::is_none")]
1581    pub algo_cl_ord_id: Option<String>,
1582    /// The result of the request.
1583    #[serde(skip_serializing_if = "Option::is_none")]
1584    pub s_code: Option<String>,
1585    /// Error message if the request failed.
1586    #[serde(skip_serializing_if = "Option::is_none")]
1587    pub s_msg: Option<String>,
1588    /// Request ID.
1589    #[serde(skip_serializing_if = "Option::is_none")]
1590    pub req_id: Option<String>,
1591}
1592
1593/// Represents the response from `GET /api/v5/public/time` (get system time).
1594#[derive(Clone, Debug, Serialize, Deserialize)]
1595#[serde(rename_all = "camelCase")]
1596pub struct OKXServerTime {
1597    /// Server timestamp in milliseconds.
1598    #[serde(deserialize_with = "deserialize_string_to_u64")]
1599    pub ts: u64,
1600}
1601
1602/// Represents a fee group from `GET /api/v5/account/trade-fee`.
1603///
1604/// Rates retain OKX signs: positive values are rebates and negative values are commissions.
1605/// Missing or empty rates are `None`, distinct from an explicit zero rate.
1606#[derive(Clone, Debug, Serialize, Deserialize)]
1607#[serde(rename_all = "camelCase")]
1608pub struct OKXFeeGroup {
1609    /// Fee group ID, interpreted together with the parent response's instrument type.
1610    pub group_id: String,
1611    /// Maker fee rate.
1612    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
1613    pub maker: Option<Decimal>,
1614    /// Taker fee rate.
1615    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
1616    pub taker: Option<Decimal>,
1617    /// Maker fee rate for RPI orders.
1618    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
1619    pub rpi_maker: Option<Decimal>,
1620    /// Legacy ELP maker fee rate, retained independently when both names are present.
1621    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
1622    pub elp_maker: Option<Decimal>,
1623}
1624
1625/// Represents a fee rate entry from `GET /api/v5/account/trade-fee`.
1626#[derive(Clone, Debug, Serialize, Deserialize)]
1627#[serde(rename_all = "camelCase")]
1628pub struct OKXFeeRate {
1629    /// Fee level (VIP tier) - indicates the user's VIP tier (0-9).
1630    #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
1631    pub level: OKXVipLevel,
1632    /// Legacy taker fee rate for crypto-margined contracts; empty when absent.
1633    #[serde(default)]
1634    pub taker: String,
1635    /// Legacy maker fee rate for crypto-margined contracts; empty when absent.
1636    #[serde(default)]
1637    pub maker: String,
1638    /// Legacy taker fee rate for USDT-margined contracts; empty when absent.
1639    #[serde(default)]
1640    pub taker_u: String,
1641    /// Legacy maker fee rate for USDT-margined contracts; empty when absent.
1642    #[serde(default)]
1643    pub maker_u: String,
1644    /// Fee groups returned for the requested scope, without selecting a rate.
1645    #[serde(default)]
1646    pub fee_group: Vec<OKXFeeGroup>,
1647    /// Maker fee rate for RPI orders.
1648    #[serde(
1649        default,
1650        alias = "elpMaker",
1651        deserialize_with = "deserialize_optional_decimal"
1652    )]
1653    pub rpi_maker: Option<Decimal>,
1654    /// Delivery fee rate.
1655    #[serde(default)]
1656    pub delivery: String,
1657    /// Option exercise fee rate.
1658    #[serde(default)]
1659    pub exercise: String,
1660    /// Event contract settlement fee rate.
1661    #[serde(default)]
1662    pub settle: String,
1663    /// Instrument type (SPOT, MARGIN, SWAP, FUTURES, OPTION, EVENTS).
1664    pub inst_type: OKXInstrumentType,
1665    /// Fee schedule category (being deprecated).
1666    #[serde(default)]
1667    pub category: String,
1668    /// Data return timestamp (Unix timestamp in milliseconds).
1669    #[serde(deserialize_with = "deserialize_string_to_u64")]
1670    pub ts: u64,
1671}
1672
1673fn deserialize_configuration_enum<'de, D, T>(deserializer: D) -> Result<T, D::Error>
1674where
1675    D: Deserializer<'de>,
1676    T: Deserialize<'de>,
1677{
1678    let value = String::deserialize(deserializer)?;
1679    T::deserialize(value.into_deserializer())
1680}
1681
1682mod account_permissions {
1683    use serde::{Deserialize, Deserializer, Serializer, de::IntoDeserializer};
1684
1685    use crate::common::enums::OKXApiKeyPermission;
1686
1687    pub(super) fn serialize<S>(
1688        permissions: &[OKXApiKeyPermission],
1689        serializer: S,
1690    ) -> Result<S::Ok, S::Error>
1691    where
1692        S: Serializer,
1693    {
1694        let value = permissions
1695            .iter()
1696            .map(AsRef::as_ref)
1697            .collect::<Vec<_>>()
1698            .join(",");
1699        serializer.serialize_str(&value)
1700    }
1701
1702    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Vec<OKXApiKeyPermission>, D::Error>
1703    where
1704        D: Deserializer<'de>,
1705    {
1706        let value = String::deserialize(deserializer)?;
1707        value
1708            .split(',')
1709            .map(|permission| OKXApiKeyPermission::deserialize(permission.into_deserializer()))
1710            .collect()
1711    }
1712}
1713
1714#[cfg(test)]
1715mod tests {
1716    use rstest::rstest;
1717    use serde_json;
1718
1719    use super::*;
1720
1721    #[rstest]
1722    fn test_algo_order_deserializes_current_child_identifier_lists() {
1723        let order: OKXOrderAlgo = serde_json::from_value(serde_json::json!({
1724            "algoId": "123",
1725            "algoClOrdId": "algo-client-1",
1726            "ordId": "456",
1727            "ordIdList": ["456", "457"],
1728            "subAlgoIdList": ["789"],
1729            "instId": "ETH-USDT-SWAP",
1730            "instType": "SWAP",
1731            "ordType": "conditional",
1732            "state": "effective",
1733            "side": "sell",
1734            "posSide": "net",
1735            "tdMode": "cross",
1736            "cTime": "1700000000000",
1737            "uTime": "1700000001000"
1738        }))
1739        .unwrap();
1740
1741        assert_eq!(order.ord_id, "456");
1742        assert_eq!(order.ord_id_list, ["456", "457"]);
1743        assert_eq!(order.sub_algo_id_list, ["789"]);
1744    }
1745
1746    #[rstest]
1747    fn test_algo_order_request_serialization() {
1748        let request = OKXPlaceAlgoOrderRequest {
1749            inst_id: "ETH-USDT-SWAP".to_string(),
1750            inst_id_code: None,
1751            td_mode: OKXTradeMode::Isolated,
1752            side: OKXSide::Buy,
1753            ord_type: OKXAlgoOrderType::Trigger,
1754            sz: Some("0.01".to_string()),
1755            algo_cl_ord_id: Some("test123".to_string()),
1756            trigger_px: Some("3000".to_string()),
1757            order_px: Some("-1".to_string()),
1758            trigger_px_type: Some(OKXTriggerType::Last),
1759            sl_trigger_px: None,
1760            sl_ord_px: None,
1761            sl_trigger_px_type: None,
1762            tp_trigger_px: None,
1763            tp_ord_px: None,
1764            tp_trigger_px_type: None,
1765            tgt_ccy: None,
1766            pos_side: None,
1767            close_position: None,
1768            tag: None,
1769            reduce_only: None,
1770            close_fraction: None,
1771            callback_ratio: None,
1772            callback_spread: None,
1773            active_px: None,
1774        };
1775
1776        let json = serde_json::to_string(&request).unwrap();
1777
1778        // Verify that fields are serialized with correct camelCase names
1779        assert!(json.contains("\"instId\":\"ETH-USDT-SWAP\""));
1780        assert!(json.contains("\"tdMode\":\"isolated\""));
1781        assert!(json.contains("\"ordType\":\"trigger\""));
1782        assert!(json.contains("\"algoClOrdId\":\"test123\""));
1783        assert!(json.contains("\"triggerPx\":\"3000\""));
1784        assert!(json.contains("\"orderPx\":\"-1\""));
1785        assert!(json.contains("\"triggerPxType\":\"last\""));
1786
1787        // Verify that None fields are not included
1788        assert!(!json.contains("tgtCcy"));
1789        assert!(!json.contains("posSide"));
1790        assert!(!json.contains("closePosition"));
1791        assert!(!json.contains("closeFraction"));
1792    }
1793
1794    #[rstest]
1795    fn test_amend_algo_order_request_serializes_sl_trigger_px() {
1796        // A conditional stop-loss algo order (incl. closeFraction stops) is amended
1797        // via `newSlTriggerPx`, not `newTriggerPx`. Verify the camelCase field name
1798        // and that an unset `new_trigger_px` is omitted.
1799        let request = OKXAmendAlgoOrderRequest {
1800            inst_id: "ETH-USDT-SWAP".to_string(),
1801            algo_id: "123".to_string(),
1802            algo_cl_ord_id: None,
1803            new_sz: None,
1804            new_trigger_px: None,
1805            new_trigger_px_type: None,
1806            new_tp_trigger_px: None,
1807            new_tp_ord_px: None,
1808            new_tp_trigger_px_type: None,
1809            new_sl_trigger_px: Some("850".to_string()),
1810            new_sl_ord_px: None,
1811            new_sl_trigger_px_type: None,
1812            new_order_px: None,
1813            new_callback_ratio: None,
1814            new_callback_spread: None,
1815            new_active_px: None,
1816        };
1817
1818        let json = serde_json::to_string(&request).unwrap();
1819
1820        assert!(json.contains("\"algoId\":\"123\""));
1821        assert!(json.contains("\"newSlTriggerPx\":\"850\""));
1822        // Unset optional fields must be omitted (OKX rejects an empty newTriggerPx).
1823        assert!(!json.contains("newTriggerPx"));
1824        assert!(!json.contains("newSz"));
1825        assert!(!json.contains("algoClOrdId"));
1826    }
1827
1828    #[rstest]
1829    fn test_amend_algo_order_request_serializes_oco_tp_sl_fields() {
1830        let request = OKXAmendAlgoOrderRequest {
1831            inst_id: "DOGE-USDT-SWAP".to_string(),
1832            algo_id: "algo-oco-1".to_string(),
1833            algo_cl_ord_id: None,
1834            new_sz: None,
1835            new_trigger_px: None,
1836            new_trigger_px_type: None,
1837            new_tp_trigger_px: Some("0.10495".to_string()),
1838            new_tp_ord_px: Some("-1".to_string()),
1839            new_tp_trigger_px_type: Some("last".to_string()),
1840            new_sl_trigger_px: Some("0.06297".to_string()),
1841            new_sl_ord_px: Some("-1".to_string()),
1842            new_sl_trigger_px_type: Some("last".to_string()),
1843            new_order_px: None,
1844            new_callback_ratio: None,
1845            new_callback_spread: None,
1846            new_active_px: None,
1847        };
1848
1849        let json = serde_json::to_string(&request).unwrap();
1850
1851        assert!(json.contains("\"algoId\":\"algo-oco-1\""));
1852        assert!(json.contains("\"newTpTriggerPx\":\"0.10495\""));
1853        assert!(json.contains("\"newTpOrdPx\":\"-1\""));
1854        assert!(json.contains("\"newTpTriggerPxType\":\"last\""));
1855        assert!(json.contains("\"newSlTriggerPx\":\"0.06297\""));
1856        assert!(json.contains("\"newSlOrdPx\":\"-1\""));
1857        assert!(json.contains("\"newSlTriggerPxType\":\"last\""));
1858        assert!(!json.contains("newTriggerPx"));
1859    }
1860
1861    #[rstest]
1862    fn test_algo_order_request_serializes_close_fraction() {
1863        let request = OKXPlaceAlgoOrderRequest {
1864            inst_id: "ETH-USDT-SWAP".to_string(),
1865            inst_id_code: None,
1866            td_mode: OKXTradeMode::Cross,
1867            side: OKXSide::Sell,
1868            ord_type: OKXAlgoOrderType::Conditional,
1869            sz: None,
1870            algo_cl_ord_id: Some("close-frac-123".to_string()),
1871            trigger_px: None,
1872            order_px: None,
1873            trigger_px_type: None,
1874            sl_trigger_px: Some("3000".to_string()),
1875            sl_ord_px: Some("-1".to_string()),
1876            sl_trigger_px_type: Some(OKXTriggerType::Last),
1877            tp_trigger_px: None,
1878            tp_ord_px: None,
1879            tp_trigger_px_type: None,
1880            tgt_ccy: None,
1881            pos_side: Some(OKXPositionSide::Net),
1882            close_position: None,
1883            tag: None,
1884            reduce_only: Some(true),
1885            close_fraction: Some("1".to_string()),
1886            callback_ratio: None,
1887            callback_spread: None,
1888            active_px: None,
1889        };
1890
1891        let json = serde_json::to_string(&request).unwrap();
1892
1893        assert!(json.contains("\"ordType\":\"conditional\""));
1894        assert!(json.contains("\"closeFraction\":\"1\""));
1895        assert!(json.contains("\"slTriggerPx\":\"3000\""));
1896        assert!(json.contains("\"slOrdPx\":\"-1\""));
1897        assert!(json.contains("\"slTriggerPxType\":\"last\""));
1898        assert!(json.contains("\"reduceOnly\":true"));
1899        assert!(!json.contains("\"sz\""));
1900        assert!(!json.contains("triggerPx"));
1901    }
1902
1903    #[rstest]
1904    fn test_algo_order_request_array_serialization() {
1905        let request = OKXPlaceAlgoOrderRequest {
1906            inst_id: "BTC-USDT".to_string(),
1907            inst_id_code: Some(10459),
1908            td_mode: OKXTradeMode::Cross,
1909            side: OKXSide::Sell,
1910            ord_type: OKXAlgoOrderType::Trigger,
1911            sz: Some("0.1".to_string()),
1912            algo_cl_ord_id: None,
1913            trigger_px: Some("50000".to_string()),
1914            order_px: Some("49900".to_string()),
1915            trigger_px_type: Some(OKXTriggerType::Mark),
1916            sl_trigger_px: None,
1917            sl_ord_px: None,
1918            sl_trigger_px_type: None,
1919            tp_trigger_px: None,
1920            tp_ord_px: None,
1921            tp_trigger_px_type: None,
1922            tgt_ccy: Some(OKXTargetCurrency::BaseCcy),
1923            pos_side: Some(OKXPositionSide::Net),
1924            close_position: None,
1925            tag: None,
1926            reduce_only: Some(true),
1927            close_fraction: None,
1928            callback_ratio: None,
1929            callback_spread: None,
1930            active_px: None,
1931        };
1932
1933        // OKX expects an array of requests
1934        let json = serde_json::to_string(&[request]).unwrap();
1935
1936        // Verify array format
1937        assert!(json.starts_with('['));
1938        assert!(json.ends_with(']'));
1939
1940        // Verify correct field names
1941        assert!(json.contains("\"instId\":\"BTC-USDT\""));
1942        assert!(json.contains("\"tdMode\":\"cross\""));
1943        assert!(json.contains("\"triggerPx\":\"50000\""));
1944        assert!(json.contains("\"orderPx\":\"49900\""));
1945        assert!(json.contains("\"triggerPxType\":\"mark\""));
1946        assert!(json.contains("\"tgtCcy\":\"base_ccy\""));
1947        assert!(json.contains("\"posSide\":\"net\""));
1948        assert!(json.contains("\"reduceOnly\":true"));
1949    }
1950
1951    #[rstest]
1952    fn test_cancel_algo_order_request_serialization() {
1953        let request = OKXCancelAlgoOrderRequest {
1954            inst_id: "ETH-USDT-SWAP".to_string(),
1955            inst_id_code: None,
1956            algo_id: Some("123456".to_string()),
1957            algo_cl_ord_id: None,
1958        };
1959
1960        let json = serde_json::to_string(&request).unwrap();
1961
1962        // Verify correct field names
1963        assert!(json.contains("\"instId\":\"ETH-USDT-SWAP\""));
1964        assert!(json.contains("\"algoId\":\"123456\""));
1965        assert!(!json.contains("algoClOrdId"));
1966    }
1967
1968    #[rstest]
1969    fn test_cancel_algo_order_with_client_id_serialization() {
1970        let request = OKXCancelAlgoOrderRequest {
1971            inst_id: "BTC-USDT".to_string(),
1972            inst_id_code: Some(10459),
1973            algo_id: None,
1974            algo_cl_ord_id: Some("client123".to_string()),
1975        };
1976
1977        // OKX expects an array of requests
1978        let json = serde_json::to_string(&[request]).unwrap();
1979
1980        // Verify array format and field names
1981        assert!(json.starts_with('['));
1982        assert!(json.contains("\"instId\":\"BTC-USDT\""));
1983        assert!(json.contains("\"algoClOrdId\":\"client123\""));
1984        assert!(!json.contains("\"algoId\""));
1985    }
1986
1987    #[rstest]
1988    fn test_amend_algo_order_trigger_serialization() {
1989        let request = OKXAmendAlgoOrderRequest {
1990            inst_id: "ETH-USDT-SWAP".to_string(),
1991            algo_id: "123456".to_string(),
1992            algo_cl_ord_id: None,
1993            new_sz: None,
1994            new_trigger_px: Some("3500".to_string()),
1995            new_trigger_px_type: None,
1996            new_tp_trigger_px: None,
1997            new_tp_ord_px: None,
1998            new_tp_trigger_px_type: None,
1999            new_sl_trigger_px: None,
2000            new_sl_ord_px: None,
2001            new_sl_trigger_px_type: None,
2002            new_order_px: Some("3490".to_string()),
2003            new_callback_ratio: None,
2004            new_callback_spread: None,
2005            new_active_px: None,
2006        };
2007
2008        let json = serde_json::to_string(&request).unwrap();
2009
2010        assert!(json.contains("\"instId\":\"ETH-USDT-SWAP\""));
2011        assert!(json.contains("\"algoId\":\"123456\""));
2012        assert!(json.contains("\"newTriggerPx\":\"3500\""));
2013        assert!(json.contains("\"newOrdPx\":\"3490\""));
2014        assert!(!json.contains("newSz"));
2015        assert!(!json.contains("algoClOrdId"));
2016        assert!(!json.contains("newCallbackRatio"));
2017    }
2018
2019    #[rstest]
2020    fn test_amend_algo_order_trailing_stop_serialization() {
2021        let request = OKXAmendAlgoOrderRequest {
2022            inst_id: "BTC-USDT-SWAP".to_string(),
2023            algo_id: "789012".to_string(),
2024            algo_cl_ord_id: Some("client456".to_string()),
2025            new_sz: Some("0.1".to_string()),
2026            new_trigger_px: None,
2027            new_trigger_px_type: None,
2028            new_tp_trigger_px: None,
2029            new_tp_ord_px: None,
2030            new_tp_trigger_px_type: None,
2031            new_sl_trigger_px: None,
2032            new_sl_ord_px: None,
2033            new_sl_trigger_px_type: None,
2034            new_order_px: None,
2035            new_callback_ratio: Some("0.02".to_string()),
2036            new_callback_spread: None,
2037            new_active_px: Some("50000".to_string()),
2038        };
2039
2040        let json = serde_json::to_string(&request).unwrap();
2041
2042        assert!(json.contains("\"instId\":\"BTC-USDT-SWAP\""));
2043        assert!(json.contains("\"algoId\":\"789012\""));
2044        assert!(json.contains("\"algoClOrdId\":\"client456\""));
2045        assert!(json.contains("\"newSz\":\"0.1\""));
2046        assert!(json.contains("\"newCallbackRatio\":\"0.02\""));
2047        assert!(json.contains("\"newActivePx\":\"50000\""));
2048        assert!(!json.contains("newTriggerPx"));
2049        assert!(!json.contains("newOrdPx"));
2050    }
2051
2052    #[rstest]
2053    fn test_trailing_stop_request_callback_ratio_serialization() {
2054        let request = OKXPlaceAlgoOrderRequest {
2055            inst_id: "BTC-USDT-SWAP".to_string(),
2056            inst_id_code: None,
2057            td_mode: OKXTradeMode::Cross,
2058            side: OKXSide::Buy,
2059            ord_type: OKXAlgoOrderType::MoveOrderStop,
2060            sz: Some("0.1".to_string()),
2061            algo_cl_ord_id: Some("trail-001".to_string()),
2062            trigger_px: None,
2063            order_px: None,
2064            trigger_px_type: None,
2065            sl_trigger_px: None,
2066            sl_ord_px: None,
2067            sl_trigger_px_type: None,
2068            tp_trigger_px: None,
2069            tp_ord_px: None,
2070            tp_trigger_px_type: None,
2071            tgt_ccy: None,
2072            pos_side: None,
2073            close_position: None,
2074            tag: None,
2075            reduce_only: None,
2076            close_fraction: None,
2077            callback_ratio: Some("0.01".to_string()),
2078            callback_spread: None,
2079            active_px: None,
2080        };
2081
2082        let json = serde_json::to_string(&request).unwrap();
2083
2084        assert!(json.contains("\"ordType\":\"move_order_stop\""));
2085        assert!(json.contains("\"callbackRatio\":\"0.01\""));
2086        assert!(!json.contains("callbackSpread"));
2087        assert!(!json.contains("activePx"));
2088    }
2089
2090    #[rstest]
2091    fn test_trailing_stop_request_callback_spread_serialization() {
2092        let request = OKXPlaceAlgoOrderRequest {
2093            inst_id: "ETH-USDT-SWAP".to_string(),
2094            inst_id_code: None,
2095            td_mode: OKXTradeMode::Isolated,
2096            side: OKXSide::Sell,
2097            ord_type: OKXAlgoOrderType::MoveOrderStop,
2098            sz: Some("1.0".to_string()),
2099            algo_cl_ord_id: None,
2100            trigger_px: None,
2101            order_px: None,
2102            trigger_px_type: None,
2103            sl_trigger_px: None,
2104            sl_ord_px: None,
2105            sl_trigger_px_type: None,
2106            tp_trigger_px: None,
2107            tp_ord_px: None,
2108            tp_trigger_px_type: None,
2109            tgt_ccy: None,
2110            pos_side: None,
2111            close_position: None,
2112            tag: None,
2113            reduce_only: Some(true),
2114            close_fraction: None,
2115            callback_ratio: None,
2116            callback_spread: Some("50.5".to_string()),
2117            active_px: None,
2118        };
2119
2120        let json = serde_json::to_string(&request).unwrap();
2121
2122        assert!(json.contains("\"callbackSpread\":\"50.5\""));
2123        assert!(!json.contains("callbackRatio"));
2124        assert!(!json.contains("activePx"));
2125    }
2126
2127    #[rstest]
2128    fn test_trailing_stop_request_with_activation_price_serialization() {
2129        let request = OKXPlaceAlgoOrderRequest {
2130            inst_id: "BTC-USDT-SWAP".to_string(),
2131            inst_id_code: None,
2132            td_mode: OKXTradeMode::Cross,
2133            side: OKXSide::Buy,
2134            ord_type: OKXAlgoOrderType::MoveOrderStop,
2135            sz: Some("0.5".to_string()),
2136            algo_cl_ord_id: None,
2137            trigger_px: None,
2138            order_px: None,
2139            trigger_px_type: None,
2140            sl_trigger_px: None,
2141            sl_ord_px: None,
2142            sl_trigger_px_type: None,
2143            tp_trigger_px: None,
2144            tp_ord_px: None,
2145            tp_trigger_px_type: None,
2146            tgt_ccy: None,
2147            pos_side: None,
2148            close_position: None,
2149            tag: None,
2150            reduce_only: None,
2151            close_fraction: None,
2152            callback_ratio: Some("0.005".to_string()),
2153            callback_spread: None,
2154            active_px: Some("65000".to_string()),
2155        };
2156
2157        let json = serde_json::to_string(&request).unwrap();
2158
2159        assert!(json.contains("\"callbackRatio\":\"0.005\""));
2160        assert!(json.contains("\"activePx\":\"65000\""));
2161        assert!(!json.contains("callbackSpread"));
2162    }
2163
2164    #[rstest]
2165    fn test_amend_algo_order_callback_spread_serialization() {
2166        let request = OKXAmendAlgoOrderRequest {
2167            inst_id: "ETH-USDT-SWAP".to_string(),
2168            algo_id: "456789".to_string(),
2169            algo_cl_ord_id: None,
2170            new_sz: None,
2171            new_trigger_px: None,
2172            new_trigger_px_type: None,
2173            new_tp_trigger_px: None,
2174            new_tp_ord_px: None,
2175            new_tp_trigger_px_type: None,
2176            new_sl_trigger_px: None,
2177            new_sl_ord_px: None,
2178            new_sl_trigger_px_type: None,
2179            new_order_px: None,
2180            new_callback_ratio: None,
2181            new_callback_spread: Some("25.0".to_string()),
2182            new_active_px: Some("4000".to_string()),
2183        };
2184
2185        let json = serde_json::to_string(&request).unwrap();
2186
2187        assert!(json.contains("\"newCallbackSpread\":\"25.0\""));
2188        assert!(json.contains("\"newActivePx\":\"4000\""));
2189        assert!(!json.contains("newCallbackRatio"));
2190        assert!(!json.contains("newTriggerPx"));
2191        assert!(!json.contains("newSz"));
2192    }
2193
2194    #[rstest]
2195    fn test_amend_algo_order_size_only_serialization() {
2196        let request = OKXAmendAlgoOrderRequest {
2197            inst_id: "BTC-USDT-SWAP".to_string(),
2198            algo_id: "111222".to_string(),
2199            algo_cl_ord_id: None,
2200            new_sz: Some("0.5".to_string()),
2201            new_trigger_px: None,
2202            new_trigger_px_type: None,
2203            new_tp_trigger_px: None,
2204            new_tp_ord_px: None,
2205            new_tp_trigger_px_type: None,
2206            new_sl_trigger_px: None,
2207            new_sl_ord_px: None,
2208            new_sl_trigger_px_type: None,
2209            new_order_px: None,
2210            new_callback_ratio: None,
2211            new_callback_spread: None,
2212            new_active_px: None,
2213        };
2214
2215        let json = serde_json::to_string(&request).unwrap();
2216
2217        assert!(json.contains("\"newSz\":\"0.5\""));
2218        assert!(!json.contains("newTriggerPx"));
2219        assert!(!json.contains("newOrdPx"));
2220        assert!(!json.contains("newCallbackRatio"));
2221        assert!(!json.contains("newCallbackSpread"));
2222        assert!(!json.contains("newActivePx"));
2223    }
2224
2225    #[rstest]
2226    fn test_amend_algo_order_all_fields_serialization() {
2227        let request = OKXAmendAlgoOrderRequest {
2228            inst_id: "BTC-USDT-SWAP".to_string(),
2229            algo_id: "333444".to_string(),
2230            algo_cl_ord_id: Some("client789".to_string()),
2231            new_sz: Some("1.0".to_string()),
2232            new_trigger_px: Some("60000".to_string()),
2233            new_trigger_px_type: None,
2234            new_tp_trigger_px: None,
2235            new_tp_ord_px: None,
2236            new_tp_trigger_px_type: None,
2237            new_sl_trigger_px: None,
2238            new_sl_ord_px: None,
2239            new_sl_trigger_px_type: None,
2240            new_order_px: Some("59900".to_string()),
2241            new_callback_ratio: Some("0.015".to_string()),
2242            new_callback_spread: Some("100".to_string()),
2243            new_active_px: Some("62000".to_string()),
2244        };
2245
2246        let json = serde_json::to_string(&request).unwrap();
2247
2248        assert!(json.contains("\"instId\":\"BTC-USDT-SWAP\""));
2249        assert!(json.contains("\"algoId\":\"333444\""));
2250        assert!(json.contains("\"algoClOrdId\":\"client789\""));
2251        assert!(json.contains("\"newSz\":\"1.0\""));
2252        assert!(json.contains("\"newTriggerPx\":\"60000\""));
2253        assert!(json.contains("\"newOrdPx\":\"59900\""));
2254        assert!(json.contains("\"newCallbackRatio\":\"0.015\""));
2255        assert!(json.contains("\"newCallbackSpread\":\"100\""));
2256        assert!(json.contains("\"newActivePx\":\"62000\""));
2257    }
2258
2259    #[rstest]
2260    fn test_place_order_request_serializes_px_usd() {
2261        let request = OKXPlaceOrderRequest {
2262            inst_id: "BTC-USD-250328-50000-C".to_string(),
2263            td_mode: OKXTradeMode::Cross,
2264            ccy: None,
2265            cl_ord_id: Some("test-opt-1".to_string()),
2266            tag: None,
2267            side: OKXSide::Buy,
2268            pos_side: Some(OKXPositionSide::Net),
2269            ord_type: OKXOrderType::Limit,
2270            sz: "1".to_string(),
2271            px: None,
2272            px_usd: Some("100.5".to_string()),
2273            px_vol: None,
2274            reduce_only: None,
2275            tgt_ccy: None,
2276            trade_quote_ccy: None,
2277            attach_algo_ords: None,
2278            outcome: None,
2279            slippage_pct: None,
2280            rpi_taker_access: None,
2281            rpi_px_round: None,
2282        };
2283
2284        let json = serde_json::to_string(&request).unwrap();
2285        assert!(json.contains("\"pxUsd\":\"100.5\""));
2286        assert!(!json.contains("\"pxVol\""));
2287        assert!(!json.contains("\"px\":"));
2288        assert!(!json.contains("slippagePct"));
2289    }
2290
2291    #[rstest]
2292    fn test_place_order_request_serializes_px_vol() {
2293        let request = OKXPlaceOrderRequest {
2294            inst_id: "BTC-USD-250328-50000-C".to_string(),
2295            td_mode: OKXTradeMode::Cross,
2296            ccy: None,
2297            cl_ord_id: Some("test-opt-2".to_string()),
2298            tag: None,
2299            side: OKXSide::Buy,
2300            pos_side: Some(OKXPositionSide::Net),
2301            ord_type: OKXOrderType::Limit,
2302            sz: "1".to_string(),
2303            px: None,
2304            px_usd: None,
2305            px_vol: Some("0.55".to_string()),
2306            reduce_only: None,
2307            tgt_ccy: None,
2308            trade_quote_ccy: None,
2309            attach_algo_ords: None,
2310            outcome: None,
2311            slippage_pct: None,
2312            rpi_taker_access: None,
2313            rpi_px_round: None,
2314        };
2315
2316        let json = serde_json::to_string(&request).unwrap();
2317        assert!(json.contains("\"pxVol\":\"0.55\""));
2318        assert!(!json.contains("\"pxUsd\""));
2319        assert!(!json.contains("\"px\":"));
2320    }
2321
2322    #[rstest]
2323    fn test_place_order_request_serializes_slippage_pct() {
2324        let request = OKXPlaceOrderRequest {
2325            inst_id: "BTC-USDT-SWAP".to_string(),
2326            td_mode: OKXTradeMode::Cross,
2327            ccy: None,
2328            cl_ord_id: Some("mkt-slip-1".to_string()),
2329            tag: None,
2330            side: OKXSide::Buy,
2331            pos_side: Some(OKXPositionSide::Net),
2332            ord_type: OKXOrderType::Market,
2333            sz: "1".to_string(),
2334            px: None,
2335            px_usd: None,
2336            px_vol: None,
2337            reduce_only: None,
2338            tgt_ccy: None,
2339            trade_quote_ccy: None,
2340            attach_algo_ords: None,
2341            outcome: None,
2342            slippage_pct: Some("0.005".to_string()),
2343            rpi_taker_access: None,
2344            rpi_px_round: None,
2345        };
2346
2347        let json: serde_json::Value = serde_json::to_value(&request).unwrap();
2348        assert_eq!(json["slippagePct"], "0.005");
2349    }
2350
2351    #[rstest]
2352    fn test_rpi_place_order_request_serialization() {
2353        let request = OKXPlaceOrderRequest {
2354            inst_id: "OMI-USD".to_string(),
2355            td_mode: OKXTradeMode::Cash,
2356            ccy: None,
2357            cl_ord_id: Some("ORPI001".to_string()),
2358            tag: Some("rpi-test".to_string()),
2359            side: OKXSide::Sell,
2360            pos_side: None,
2361            ord_type: OKXOrderType::Rpi,
2362            sz: "250000".to_string(),
2363            px: Some("0.0001600".to_string()),
2364            px_usd: None,
2365            px_vol: None,
2366            reduce_only: Some(false),
2367            tgt_ccy: None,
2368            trade_quote_ccy: None,
2369            attach_algo_ords: None,
2370            outcome: None,
2371            slippage_pct: None,
2372            rpi_taker_access: Some(true),
2373            rpi_px_round: Some(false),
2374        };
2375
2376        let value = serde_json::to_value(request).unwrap();
2377
2378        assert_eq!(
2379            value,
2380            serde_json::json!({
2381                "instId": "OMI-USD",
2382                "tdMode": "cash",
2383                "clOrdId": "ORPI001",
2384                "tag": "rpi-test",
2385                "side": "sell",
2386                "ordType": "rpi",
2387                "sz": "250000",
2388                "px": "0.0001600",
2389                "reduceOnly": false,
2390                "rpiTakerAccess": true,
2391                "rpiPxRound": false
2392            })
2393        );
2394    }
2395
2396    #[rstest]
2397    fn test_rpi_amend_order_request_serialization() {
2398        let request = OKXAmendOrderRequest {
2399            inst_id: "OMI-USD".to_string(),
2400            ord_id: Some("2500000000000000001".to_string()),
2401            cl_ord_id: None,
2402            req_id: Some("RPI-AMEND-1".to_string()),
2403            new_sz: Some("275000".to_string()),
2404            new_px: Some("0.0001599".to_string()),
2405            rpi_taker_access: Some(false),
2406            rpi_px_round: Some(true),
2407        };
2408
2409        let value = serde_json::to_value(request).unwrap();
2410
2411        assert_eq!(
2412            value,
2413            serde_json::json!({
2414                "instId": "OMI-USD",
2415                "ordId": "2500000000000000001",
2416                "reqId": "RPI-AMEND-1",
2417                "newSz": "275000",
2418                "newPx": "0.0001599",
2419                "rpiTakerAccess": false,
2420                "rpiPxRound": true
2421            })
2422        );
2423    }
2424
2425    #[rstest]
2426    fn test_rpi_place_order_response_parsing() {
2427        let response: OKXPlaceOrderResponse = serde_json::from_value(serde_json::json!({
2428            "ordId": "2500000000000000001",
2429            "clOrdId": "ORPI001",
2430            "tag": "rpi-test",
2431            "instId": "OMI-USD",
2432            "side": "sell",
2433            "ordType": "rpi",
2434            "sz": "250000",
2435            "state": "live",
2436            "px": "0.0001600",
2437            "avgPx": "",
2438            "sCode": "0",
2439            "sMsg": ""
2440        }))
2441        .unwrap();
2442
2443        assert_eq!(response.ord_id, Some(Ustr::from("2500000000000000001")));
2444        assert_eq!(response.cl_ord_id, Some(Ustr::from("ORPI001")));
2445        assert_eq!(response.tag.as_deref(), Some("rpi-test"));
2446        assert_eq!(response.inst_id, Some(Ustr::from("OMI-USD")));
2447        assert_eq!(response.side, Some(OKXSide::Sell));
2448        assert_eq!(response.ord_type, Some(OKXOrderType::Rpi));
2449        assert_eq!(response.sz.as_deref(), Some("250000"));
2450        assert_eq!(response.state, Some(OKXOrderStatus::Live));
2451        assert_eq!(response.px.as_deref(), Some("0.0001600"));
2452        assert_eq!(response.avg_px.as_deref(), Some(""));
2453        assert_eq!(response.s_code.as_deref(), Some("0"));
2454        assert_eq!(response.s_msg.as_deref(), Some(""));
2455    }
2456
2457    #[rstest]
2458    fn test_event_contract_models_deserialize() {
2459        let series: OKXEventContractSeries = serde_json::from_value(serde_json::json!({
2460            "seriesId": "BTC-ABOVE-DAILY",
2461            "freq": "daily",
2462            "title": "BTC above daily",
2463            "category": "1",
2464            "settlement": {
2465                "method": "cash",
2466                "closeEarly": false,
2467                "srcName": "OKX BTC/USD Index",
2468                "underlying": "BTC-USD"
2469            }
2470        }))
2471        .unwrap();
2472        let event: OKXEventContractEvent = serde_json::from_value(serde_json::json!({
2473            "seriesId": "BTC-ABOVE-DAILY",
2474            "eventId": "BTC-ABOVE-DAILY-260224-1600",
2475            "fixTime": "",
2476            "expTime": "1769697132335",
2477            "state": "live"
2478        }))
2479        .unwrap();
2480        let market: OKXEventContractMarket = serde_json::from_value(serde_json::json!({
2481            "seriesId": "BTC-ABOVE-DAILY",
2482            "eventId": "BTC-ABOVE-DAILY-260224-1600",
2483            "instId": "BTC-ABOVE-DAILY-260224-1600-65000",
2484            "listTime": "1769697132335",
2485            "fixTime": "",
2486            "expTime": "1769697132335",
2487            "state": "live",
2488            "disputed": false,
2489            "outcome": "0",
2490            "floorStrike": "120000",
2491            "capStrike": "INF",
2492            "settleValue": "",
2493            "hitDir": ""
2494        }))
2495        .unwrap();
2496
2497        assert_eq!(series.series_id, "BTC-ABOVE-DAILY");
2498        assert_eq!(series.settlement.underlying, "BTC-USD");
2499        assert_eq!(event.fix_time, None);
2500        assert_eq!(event.exp_time, Some(1_769_697_132_335));
2501        assert_eq!(
2502            market.inst_id,
2503            Ustr::from("BTC-ABOVE-DAILY-260224-1600-65000")
2504        );
2505        assert_eq!(market.list_time, Some(1_769_697_132_335));
2506        assert_eq!(market.exp_time, Some(1_769_697_132_335));
2507        assert_eq!(market.outcome, "0");
2508        assert_eq!(market.cap_strike, "INF");
2509        assert_eq!(market.hit_dir, "");
2510
2511        let serialized = serde_json::to_value(&market).unwrap();
2512        assert_eq!(serialized["capStrike"], "INF");
2513        assert_eq!(serialized["hitDir"], "");
2514    }
2515
2516    #[rstest]
2517    fn test_event_contract_models_accept_missing_optional_fields() {
2518        let series: OKXEventContractSeries = serde_json::from_value(serde_json::json!({
2519            "seriesId": "BTC-ABOVE-DAILY"
2520        }))
2521        .unwrap();
2522        let event: OKXEventContractEvent = serde_json::from_value(serde_json::json!({
2523            "seriesId": "BTC-ABOVE-DAILY",
2524            "eventId": "BTC-ABOVE-DAILY-260224-1600",
2525            "fixTime": "",
2526            "expTime": "",
2527            "state": "live"
2528        }))
2529        .unwrap();
2530        let market: OKXEventContractMarket = serde_json::from_value(serde_json::json!({
2531            "seriesId": "BTC-ABOVE-DAILY",
2532            "eventId": "BTC-ABOVE-DAILY-260224-1600",
2533            "instId": "BTC-ABOVE-DAILY-260224-1600-65000",
2534            "listTime": "",
2535            "fixTime": "",
2536            "expTime": "",
2537            "state": "live",
2538            "disputed": false,
2539            "outcome": "0",
2540            "floorStrike": "120000",
2541            "settleValue": ""
2542        }))
2543        .unwrap();
2544
2545        assert_eq!(series.freq, "");
2546        assert_eq!(series.settlement.underlying, "");
2547        assert_eq!(event.exp_time, None);
2548        assert_eq!(market.list_time, None);
2549        assert_eq!(market.exp_time, None);
2550        assert_eq!(market.cap_strike, "");
2551        assert_eq!(market.hit_dir, "");
2552    }
2553
2554    #[rstest]
2555    fn test_place_order_request_serializes_event_contract_fields() {
2556        let request = OKXPlaceOrderRequest {
2557            inst_id: "BTC-ABOVE-DAILY-260224-1600-65000".to_string(),
2558            td_mode: OKXTradeMode::Cash,
2559            ccy: None,
2560            cl_ord_id: Some("event-1".to_string()),
2561            tag: None,
2562            side: OKXSide::Buy,
2563            pos_side: None,
2564            ord_type: OKXOrderType::Limit,
2565            sz: "10".to_string(),
2566            px: Some("0.42".to_string()),
2567            px_usd: None,
2568            px_vol: None,
2569            reduce_only: None,
2570            tgt_ccy: None,
2571            trade_quote_ccy: None,
2572            attach_algo_ords: None,
2573            outcome: Some("yes".to_string()),
2574            slippage_pct: None,
2575            rpi_taker_access: None,
2576            rpi_px_round: None,
2577        };
2578
2579        let json: serde_json::Value = serde_json::to_value(&request).unwrap();
2580
2581        assert!(json.get("speedBump").is_none());
2582        assert_eq!(json["outcome"], "yes");
2583        assert!(json.get("tradeQuoteCcy").is_none());
2584    }
2585
2586    #[rstest]
2587    fn test_place_order_request_serializes_trade_quote_ccy_usd() {
2588        let request = OKXPlaceOrderRequest {
2589            inst_id: "BTC-USDC".to_string(),
2590            td_mode: OKXTradeMode::Cash,
2591            ccy: None,
2592            cl_ord_id: Some("usd-quote-1".to_string()),
2593            tag: None,
2594            side: OKXSide::Buy,
2595            pos_side: None,
2596            ord_type: OKXOrderType::Limit,
2597            sz: "0.01".to_string(),
2598            px: Some("100000".to_string()),
2599            px_usd: None,
2600            px_vol: None,
2601            reduce_only: None,
2602            tgt_ccy: None,
2603            trade_quote_ccy: Some(Ustr::from("USD")),
2604            attach_algo_ords: None,
2605            outcome: None,
2606            slippage_pct: None,
2607            rpi_taker_access: None,
2608            rpi_px_round: None,
2609        };
2610
2611        let json: serde_json::Value = serde_json::to_value(&request).unwrap();
2612        assert_eq!(json["instId"], "BTC-USDC");
2613        assert_eq!(json["tradeQuoteCcy"], "USD");
2614    }
2615
2616    #[rstest]
2617    fn test_place_order_request_omits_trade_quote_ccy_when_unset() {
2618        let request = OKXPlaceOrderRequest {
2619            inst_id: "BTC-USDC".to_string(),
2620            td_mode: OKXTradeMode::Cash,
2621            ccy: None,
2622            cl_ord_id: Some("usdc-default-1".to_string()),
2623            tag: None,
2624            side: OKXSide::Buy,
2625            pos_side: None,
2626            ord_type: OKXOrderType::Limit,
2627            sz: "0.01".to_string(),
2628            px: Some("100000".to_string()),
2629            px_usd: None,
2630            px_vol: None,
2631            reduce_only: None,
2632            tgt_ccy: None,
2633            trade_quote_ccy: None,
2634            attach_algo_ords: None,
2635            outcome: None,
2636            slippage_pct: None,
2637            rpi_taker_access: None,
2638            rpi_px_round: None,
2639        };
2640
2641        let json = serde_json::to_string(&request).unwrap();
2642        assert!(!json.contains("tradeQuoteCcy"));
2643    }
2644
2645    #[rstest]
2646    fn test_attach_algo_ord_request_serializes_trailing_fields() {
2647        let request = OKXAttachAlgoOrdRequest {
2648            attach_algo_cl_ord_id: Some("trail-1".to_string()),
2649            sl_trigger_px: None,
2650            sl_ord_px: None,
2651            sl_trigger_px_type: None,
2652            tp_trigger_px: None,
2653            tp_ord_px: None,
2654            tp_trigger_px_type: None,
2655            callback_ratio: Some("0.01".to_string()),
2656            callback_spread: None,
2657            active_px: Some("64000".to_string()),
2658            new_callback_ratio: Some("0.02".to_string()),
2659            new_callback_spread: Some("25".to_string()),
2660            new_active_px: Some("65000".to_string()),
2661        };
2662
2663        let json: serde_json::Value = serde_json::to_value(&request).unwrap();
2664
2665        assert_eq!(json["callbackRatio"], "0.01");
2666        assert_eq!(json["activePx"], "64000");
2667        assert_eq!(json["newCallbackRatio"], "0.02");
2668        assert_eq!(json["newCallbackSpread"], "25");
2669        assert_eq!(json["newActivePx"], "65000");
2670        assert!(json.get("callbackSpread").is_none());
2671    }
2672
2673    #[rstest]
2674    fn test_place_order_response_deserializes_sub_code() {
2675        let response: OKXPlaceOrderResponse = serde_json::from_value(serde_json::json!({
2676            "ordId": "",
2677            "clOrdId": "event-1",
2678            "sCode": "51000",
2679            "sMsg": "Parameter error",
2680            "subCode": "51004"
2681        }))
2682        .unwrap();
2683
2684        assert_eq!(response.cl_ord_id, Some(Ustr::from("event-1")));
2685        assert_eq!(response.s_code, Some("51000".to_string()));
2686        assert_eq!(response.sub_code, Some("51004".to_string()));
2687    }
2688
2689    #[rstest]
2690    fn test_fee_rate_deserializes_settle() {
2691        let fee_rate: OKXFeeRate = serde_json::from_value(serde_json::json!({
2692            "level": "VIP1",
2693            "taker": "-0.0005",
2694            "maker": "-0.0002",
2695            "takerU": "-0.0005",
2696            "makerU": "-0.0002",
2697            "settle": "-0.001",
2698            "instType": "EVENTS",
2699            "category": "1",
2700            "ts": "1769697132335"
2701        }))
2702        .unwrap();
2703
2704        assert_eq!(fee_rate.settle, "-0.001");
2705        assert_eq!(fee_rate.inst_type, OKXInstrumentType::Events);
2706    }
2707
2708    #[rstest]
2709    #[case::missing(None, None)]
2710    #[case::empty(Some(""), None)]
2711    #[case::zero(Some("0"), Some(Decimal::ZERO))]
2712    #[case::commission(Some("-0.001"), Some(Decimal::from_str_exact("-0.001").unwrap()))]
2713    #[case::rebate(Some("0.0002"), Some(Decimal::from_str_exact("0.0002").unwrap()))]
2714    fn test_fee_group_rates_preserve_unavailable_zero_and_signs(
2715        #[case] value: Option<&str>,
2716        #[case] expected: Option<Decimal>,
2717    ) {
2718        let mut payload = serde_json::json!({"groupId": "1"});
2719
2720        if let Some(value) = value {
2721            for field in ["maker", "taker", "rpiMaker", "elpMaker"] {
2722                payload[field] = serde_json::json!(value);
2723            }
2724        }
2725
2726        let group: OKXFeeGroup = serde_json::from_value(payload).unwrap();
2727
2728        assert_eq!(group.group_id, "1");
2729        assert_eq!(group.maker, expected);
2730        assert_eq!(group.taker, expected);
2731        assert_eq!(group.rpi_maker, expected);
2732        assert_eq!(group.elp_maker, expected);
2733    }
2734
2735    #[rstest]
2736    #[case::rpi_only(Some("-0.00015"), None)]
2737    #[case::elp_only(None, Some("-0.00016"))]
2738    #[case::both_equal(Some("-0.00015"), Some("-0.00015"))]
2739    #[case::both_distinct(Some("-0.00015"), Some("-0.00016"))]
2740    fn test_fee_group_preserves_rpi_and_elp_independently(
2741        #[case] rpi: Option<&str>,
2742        #[case] elp: Option<&str>,
2743    ) {
2744        let mut payload = serde_json::json!({"groupId": "1"});
2745        if let Some(value) = rpi {
2746            payload["rpiMaker"] = serde_json::json!(value);
2747        }
2748
2749        if let Some(value) = elp {
2750            payload["elpMaker"] = serde_json::json!(value);
2751        }
2752
2753        let group: OKXFeeGroup = serde_json::from_value(payload).unwrap();
2754
2755        assert_eq!(
2756            group.rpi_maker,
2757            rpi.map(|value| Decimal::from_str_exact(value).unwrap())
2758        );
2759        assert_eq!(
2760            group.elp_maker,
2761            elp.map(|value| Decimal::from_str_exact(value).unwrap())
2762        );
2763    }
2764
2765    #[rstest]
2766    fn test_fee_rate_preserves_legacy_scalars_alongside_groups() {
2767        let fee_rate: OKXFeeRate = serde_json::from_value(serde_json::json!({
2768            "level": "Lv1",
2769            "maker": "-0.0008",
2770            "taker": "-0.001",
2771            "makerU": "",
2772            "takerU": "0",
2773            "feeGroup": [{"groupId": "1", "maker": "0.0002", "taker": "-0.0005"}],
2774            "instType": "SPOT",
2775            "ts": "1763979985847"
2776        }))
2777        .unwrap();
2778
2779        assert_eq!(fee_rate.maker, "-0.0008");
2780        assert_eq!(fee_rate.taker, "-0.001");
2781        assert_eq!(fee_rate.maker_u, "");
2782        assert_eq!(fee_rate.taker_u, "0");
2783        assert_eq!(fee_rate.fee_group.len(), 1);
2784        assert_eq!(
2785            fee_rate.fee_group[0].maker,
2786            Some(Decimal::from_str_exact("0.0002").unwrap())
2787        );
2788        assert_eq!(
2789            fee_rate.fee_group[0].taker,
2790            Some(Decimal::from_str_exact("-0.0005").unwrap())
2791        );
2792    }
2793
2794    #[rstest]
2795    fn test_fee_rate_missing_scalars_do_not_imply_zero() {
2796        let fee_rate: OKXFeeRate = serde_json::from_value(serde_json::json!({
2797            "level": "Lv1",
2798            "instType": "SPOT",
2799            "ts": "1763979985847"
2800        }))
2801        .unwrap();
2802
2803        assert!(fee_rate.maker.is_empty());
2804        assert!(fee_rate.taker.is_empty());
2805        assert!(fee_rate.maker_u.is_empty());
2806        assert!(fee_rate.taker_u.is_empty());
2807        assert!(fee_rate.fee_group.is_empty());
2808    }
2809
2810    #[rstest]
2811    fn test_fee_group_rejects_invalid_rates(
2812        #[values("maker", "taker", "rpiMaker", "elpMaker")] field: &str,
2813    ) {
2814        let mut payload = serde_json::json!({"groupId": "1"});
2815        payload[field] = serde_json::json!("invalid");
2816
2817        assert!(serde_json::from_value::<OKXFeeGroup>(payload).is_err());
2818    }
2819
2820    #[rstest]
2821    fn test_rpi_maker_fee_rate_deserializes_decimal_and_legacy_alias() {
2822        let rpi: OKXFeeRate = serde_json::from_value(serde_json::json!({
2823            "level": "VIP1",
2824            "taker": "-0.0005",
2825            "maker": "-0.0002",
2826            "takerU": "-0.0005",
2827            "makerU": "-0.0002",
2828            "rpiMaker": "-0.00015",
2829            "instType": "SPOT",
2830            "category": "1",
2831            "ts": "1785406500000"
2832        }))
2833        .unwrap();
2834        let legacy: OKXFeeRate = serde_json::from_value(serde_json::json!({
2835            "level": "VIP1",
2836            "taker": "-0.0005",
2837            "maker": "-0.0002",
2838            "takerU": "-0.0005",
2839            "makerU": "-0.0002",
2840            "elpMaker": "-0.00016",
2841            "instType": "SPOT",
2842            "category": "1",
2843            "ts": "1785406500001"
2844        }))
2845        .unwrap();
2846        let not_applicable: OKXFeeRate = serde_json::from_value(serde_json::json!({
2847            "level": "VIP1",
2848            "taker": "-0.0005",
2849            "maker": "-0.0002",
2850            "takerU": "-0.0005",
2851            "makerU": "-0.0002",
2852            "rpiMaker": "",
2853            "instType": "OPTION",
2854            "category": "1",
2855            "ts": "1785406500002"
2856        }))
2857        .unwrap();
2858
2859        assert_eq!(
2860            rpi.rpi_maker,
2861            Some(Decimal::from_str_exact("-0.00015").unwrap())
2862        );
2863        assert_eq!(
2864            legacy.rpi_maker,
2865            Some(Decimal::from_str_exact("-0.00016").unwrap())
2866        );
2867        assert_eq!(not_applicable.rpi_maker, None);
2868    }
2869}