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 a trading-account bill from the recent or archived bills endpoint.
553///
554/// Numeric values and identifiers retain the exact strings returned by OKX.
555/// Bill codes are not restricted to known values. Optional fields preserve empty strings
556/// separately from absent fields, including trade fields on transfer bills.
557#[derive(Clone, Debug, Serialize, Deserialize)]
558#[serde(rename_all = "camelCase")]
559pub struct OKXAccountBill {
560    /// Bill ID.
561    pub bill_id: String,
562    /// Account balance currency.
563    pub ccy: String,
564    /// Balance update time, as a Unix timestamp in milliseconds.
565    pub ts: String,
566    /// Balance at the account level.
567    pub bal: String,
568    /// Signed change in account balance, in `ccy`.
569    pub bal_chg: String,
570    /// Bill type code, including unrecognized codes.
571    #[serde(rename = "type")]
572    pub bill_type: String,
573    /// Bill subtype code, including unrecognized codes.
574    pub sub_type: String,
575    /// Instrument type; empty for non-trading bills.
576    #[serde(skip_serializing_if = "Option::is_none")]
577    pub inst_type: Option<String>,
578    /// Instrument ID.
579    #[serde(skip_serializing_if = "Option::is_none")]
580    pub inst_id: Option<String>,
581    /// Margin mode: cross, isolated, cash, or empty for non-trading bills.
582    #[serde(skip_serializing_if = "Option::is_none")]
583    pub mgn_mode: Option<String>,
584    /// Balance at the position level.
585    #[serde(skip_serializing_if = "Option::is_none")]
586    pub pos_bal: Option<String>,
587    /// Change in balance at the position level.
588    #[serde(skip_serializing_if = "Option::is_none")]
589    pub pos_bal_chg: Option<String>,
590    /// Quantity, in contracts for derivatives or in `ccy` otherwise.
591    #[serde(skip_serializing_if = "Option::is_none")]
592    pub sz: Option<String>,
593    /// Price associated with the bill subtype.
594    #[serde(skip_serializing_if = "Option::is_none")]
595    pub px: Option<String>,
596    /// Profit and loss; includes funding payments for funding-fee bills.
597    #[serde(skip_serializing_if = "Option::is_none")]
598    pub pnl: Option<String>,
599    /// Fee: negative for charges, positive for rebates.
600    #[serde(skip_serializing_if = "Option::is_none")]
601    pub fee: Option<String>,
602    /// Auto earn amount, when applicable.
603    #[serde(skip_serializing_if = "Option::is_none")]
604    pub earn_amt: Option<String>,
605    /// Auto earn APR, when applicable.
606    #[serde(skip_serializing_if = "Option::is_none")]
607    pub earn_apr: Option<String>,
608    /// Order ID; empty when there is no order.
609    #[serde(skip_serializing_if = "Option::is_none")]
610    pub ord_id: Option<String>,
611    /// Client order ID.
612    #[serde(skip_serializing_if = "Option::is_none")]
613    pub cl_ord_id: Option<String>,
614    /// Last trade ID.
615    #[serde(skip_serializing_if = "Option::is_none")]
616    pub trade_id: Option<String>,
617    /// Liquidity role: T for taker, M for maker.
618    #[serde(skip_serializing_if = "Option::is_none")]
619    pub exec_type: Option<String>,
620    /// Remitting account for transfers: 6 for funding, 18 for trading.
621    #[serde(skip_serializing_if = "Option::is_none")]
622    pub from: Option<String>,
623    /// Beneficiary account for transfers: 6 for funding, 18 for trading.
624    #[serde(skip_serializing_if = "Option::is_none")]
625    pub to: Option<String>,
626    /// Bill notes.
627    #[serde(skip_serializing_if = "Option::is_none")]
628    pub notes: Option<String>,
629    /// Interest amount.
630    #[serde(skip_serializing_if = "Option::is_none")]
631    pub interest: Option<String>,
632    /// Order tag.
633    #[serde(skip_serializing_if = "Option::is_none")]
634    pub tag: Option<String>,
635    /// Last fill time in Unix milliseconds.
636    #[serde(skip_serializing_if = "Option::is_none")]
637    pub fill_time: Option<String>,
638    /// Index price at execution.
639    #[serde(skip_serializing_if = "Option::is_none")]
640    pub fill_idx_px: Option<String>,
641    /// Mark price at execution.
642    #[serde(skip_serializing_if = "Option::is_none")]
643    pub fill_mark_px: Option<String>,
644    /// Implied volatility at execution, for options.
645    #[serde(skip_serializing_if = "Option::is_none")]
646    pub fill_px_vol: Option<String>,
647    /// Option price at execution, in USD.
648    #[serde(skip_serializing_if = "Option::is_none")]
649    pub fill_px_usd: Option<String>,
650    /// Mark volatility at execution, for options.
651    #[serde(skip_serializing_if = "Option::is_none")]
652    pub fill_mark_vol: Option<String>,
653    /// Forward price at execution, for options.
654    #[serde(skip_serializing_if = "Option::is_none")]
655    pub fill_fwd_px: Option<String>,
656}
657
658/// Represents an account balance snapshot from `GET /api/v5/account/balance`.
659#[derive(Clone, Debug, Serialize, Deserialize)]
660#[serde(rename_all = "camelCase")]
661pub struct OKXAccount {
662    /// Adjusted/Effective equity in USD.
663    pub adj_eq: String,
664    /// Borrow frozen amount.
665    pub borrow_froz: String,
666    /// Account details by currency.
667    pub details: Vec<OKXBalanceDetail>,
668    /// Initial margin requirement.
669    pub imr: String,
670    /// Isolated margin equity.
671    pub iso_eq: String,
672    /// Margin ratio.
673    pub mgn_ratio: String,
674    /// Maintenance margin requirement.
675    pub mmr: String,
676    /// Notional value in USD for borrow.
677    pub notional_usd_for_borrow: String,
678    /// Notional value in USD for futures.
679    pub notional_usd_for_futures: String,
680    /// Notional value in USD for option.
681    pub notional_usd_for_option: String,
682    /// Notional value in USD for swap.
683    pub notional_usd_for_swap: String,
684    /// Notional value in USD.
685    pub notional_usd: String,
686    /// Order frozen.
687    pub ord_froz: String,
688    /// Total equity in USD.
689    pub total_eq: String,
690    /// Last update time, Unix timestamp in milliseconds.
691    #[serde(deserialize_with = "deserialize_string_to_u64")]
692    pub u_time: u64,
693    /// Unrealized profit and loss.
694    pub upl: String,
695}
696
697/// Represents a balance detail for a single currency in an OKX account.
698#[derive(Clone, Debug, Serialize, Deserialize)]
699#[serde(rename_all = "camelCase")]
700#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object))]
701#[cfg_attr(
702    feature = "python",
703    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.okx")
704)]
705pub struct OKXBalanceDetail {
706    /// Available balance.
707    pub avail_bal: String,
708    /// Available equity.
709    pub avail_eq: String,
710    /// Borrow frozen amount.
711    pub borrow_froz: String,
712    /// Cash balance.
713    pub cash_bal: String,
714    /// Currency.
715    pub ccy: Ustr,
716    /// Cross liability.
717    pub cross_liab: String,
718    /// Discount equity in USD.
719    pub dis_eq: String,
720    /// Equity.
721    pub eq: String,
722    /// Equity in USD.
723    pub eq_usd: String,
724    /// Same-token equity.
725    pub smt_sync_eq: String,
726    /// Copy trading equity.
727    pub spot_copy_trading_eq: String,
728    /// Fixed balance.
729    pub fixed_bal: String,
730    /// Frozen balance.
731    pub frozen_bal: String,
732    /// Initial margin requirement.
733    pub imr: String,
734    /// Interest.
735    pub interest: String,
736    /// Isolated margin equity.
737    pub iso_eq: String,
738    /// Isolated margin liability.
739    pub iso_liab: String,
740    /// Isolated unrealized profit and loss.
741    pub iso_upl: String,
742    /// Liability.
743    pub liab: String,
744    /// Maximum loan amount.
745    pub max_loan: String,
746    /// Margin ratio.
747    pub mgn_ratio: String,
748    /// Maintenance margin requirement.
749    pub mmr: String,
750    /// Notional leverage.
751    pub notional_lever: String,
752    /// Order frozen.
753    pub ord_frozen: String,
754    /// Reward balance.
755    pub reward_bal: String,
756    /// Spot in use amount.
757    #[serde(alias = "spotInUse")]
758    pub spot_in_use_amt: String,
759    /// Cross liability spot in use amount.
760    #[serde(alias = "clSpotInUse")]
761    pub cl_spot_in_use_amt: String,
762    /// Maximum spot in use amount.
763    #[serde(alias = "maxSpotInUse")]
764    pub max_spot_in_use_amt: String,
765    /// Spot isolated balance.
766    pub spot_iso_bal: String,
767    /// Strategy equity.
768    pub stgy_eq: String,
769    /// Time-weighted average price.
770    pub twap: String,
771    /// Last update time, Unix timestamp in milliseconds.
772    #[serde(deserialize_with = "deserialize_string_to_u64")]
773    pub u_time: u64,
774    /// Unrealized profit and loss.
775    pub upl: String,
776    /// Unrealized profit and loss liability.
777    pub upl_liab: String,
778    /// Spot balance.
779    pub spot_bal: String,
780    /// Open average price.
781    pub open_avg_px: String,
782    /// Accumulated average price.
783    pub acc_avg_px: String,
784    /// Spot unrealized profit and loss.
785    pub spot_upl: String,
786    /// Spot unrealized profit and loss ratio.
787    pub spot_upl_ratio: String,
788    /// Total profit and loss.
789    pub total_pnl: String,
790    /// Total profit and loss ratio.
791    pub total_pnl_ratio: String,
792}
793
794/// Represents a single open position from `GET /api/v5/account/positions`.
795#[derive(Clone, Debug, Serialize, Deserialize)]
796#[serde(rename_all = "camelCase")]
797pub struct OKXPosition {
798    /// Instrument ID.
799    pub inst_id: Ustr,
800    /// Instrument type.
801    pub inst_type: OKXInstrumentType,
802    /// Margin mode: isolated/cross.
803    pub mgn_mode: OKXMarginMode,
804    /// Position ID.
805    #[serde(default, deserialize_with = "deserialize_empty_ustr_as_none")]
806    pub pos_id: Option<Ustr>,
807    /// Position side: long/short.
808    pub pos_side: OKXPositionSide,
809    /// Position size.
810    pub pos: String,
811    /// Base currency balance.
812    pub base_bal: String,
813    /// Position currency.
814    pub ccy: String,
815    /// Trading fee.
816    pub fee: String,
817    /// Position leverage.
818    pub lever: String,
819    /// Last traded price.
820    pub last: String,
821    /// Mark price.
822    pub mark_px: String,
823    /// Liquidation price.
824    pub liq_px: String,
825    /// Maintenance margin requirement.
826    pub mmr: String,
827    /// Interest.
828    pub interest: String,
829    /// Trade ID.
830    pub trade_id: Ustr,
831    /// Notional value of position in USD.
832    pub notional_usd: String,
833    /// Average entry price.
834    pub avg_px: String,
835    /// Unrealized profit and loss.
836    pub upl: String,
837    /// Unrealized profit and loss ratio.
838    pub upl_ratio: String,
839    /// Last update time, Unix timestamp in milliseconds.
840    #[serde(deserialize_with = "deserialize_string_to_u64")]
841    pub u_time: u64,
842    /// Position margin.
843    pub margin: String,
844    /// Margin ratio.
845    pub mgn_ratio: String,
846    /// Auto-deleveraging (ADL) ranking.
847    pub adl: String,
848    /// Creation time, Unix timestamp in milliseconds.
849    pub c_time: String,
850    /// Realized profit and loss.
851    pub realized_pnl: String,
852    /// Unrealized profit and loss at last price.
853    pub upl_last_px: String,
854    /// Unrealized profit and loss ratio at last price.
855    pub upl_ratio_last_px: String,
856    /// Available position that can be closed.
857    pub avail_pos: String,
858    /// Breakeven price.
859    pub be_px: String,
860    /// Funding fee.
861    pub funding_fee: String,
862    /// Index price.
863    pub idx_px: String,
864    /// Liquidation penalty.
865    pub liq_penalty: String,
866    /// Option value.
867    pub opt_val: String,
868    /// Pending close order liability value.
869    pub pending_close_ord_liab_val: String,
870    /// Total profit and loss.
871    pub pnl: String,
872    /// Position currency.
873    pub pos_ccy: String,
874    /// Quote currency balance.
875    pub quote_bal: String,
876    /// Borrowed amount in quote currency.
877    pub quote_borrowed: String,
878    /// Interest on quote currency.
879    pub quote_interest: String,
880    /// Amount in use for spot trading.
881    #[serde(alias = "spotInUse")]
882    pub spot_in_use_amt: String,
883    /// Currency in use for spot trading.
884    pub spot_in_use_ccy: String,
885    /// USD price.
886    pub usd_px: String,
887    /// Black-Scholes delta in dollars, only applicable to OPTION.
888    #[serde(default)]
889    pub delta_bs: String,
890    /// Black-Scholes gamma in dollars, only applicable to OPTION.
891    #[serde(default)]
892    pub gamma_bs: String,
893    /// Black-Scholes theta in dollars, only applicable to OPTION.
894    #[serde(default)]
895    pub theta_bs: String,
896    /// Black-Scholes vega in dollars, only applicable to OPTION.
897    #[serde(default)]
898    pub vega_bs: String,
899    /// Algo orders placed with `closeFraction` to close this position.
900    #[serde(default)]
901    pub close_order_algo: Vec<OKXCloseOrderAlgo>,
902}
903
904/// Represents an algo order that closes an OKX position through `closeFraction`.
905#[derive(Clone, Debug, Serialize, Deserialize)]
906#[serde(rename_all = "camelCase")]
907pub struct OKXCloseOrderAlgo {
908    /// Algo order ID.
909    pub algo_id: String,
910}
911
912/// Represents the response from `POST /api/v5/trade/order` (place order).
913/// This model is designed to be flexible and handle the minimal fields that the API returns.
914#[derive(Clone, Debug, Serialize, Deserialize)]
915#[serde(rename_all = "camelCase")]
916pub struct OKXPlaceOrderResponse {
917    /// Order ID.
918    #[serde(default)]
919    pub ord_id: Option<Ustr>,
920    /// Client order ID.
921    #[serde(default)]
922    pub cl_ord_id: Option<Ustr>,
923    /// Order tag.
924    #[serde(default)]
925    pub tag: Option<String>,
926    /// Instrument ID (optional - might not be in response).
927    #[serde(default)]
928    pub inst_id: Option<Ustr>,
929    /// Order side (optional).
930    #[serde(default)]
931    pub side: Option<OKXSide>,
932    /// Order type (optional).
933    #[serde(default)]
934    pub ord_type: Option<OKXOrderType>,
935    /// Order size (optional).
936    #[serde(default)]
937    pub sz: Option<String>,
938    /// Order state (optional).
939    pub state: Option<OKXOrderStatus>,
940    /// Price (optional).
941    #[serde(default)]
942    pub px: Option<String>,
943    /// Average price (optional).
944    #[serde(default)]
945    pub avg_px: Option<String>,
946    /// Accumulated filled size.
947    #[serde(default)]
948    pub acc_fill_sz: Option<String>,
949    /// Fill size (optional).
950    #[serde(default)]
951    pub fill_sz: Option<String>,
952    /// Fill price (optional).
953    #[serde(default)]
954    pub fill_px: Option<String>,
955    /// Trade ID (optional).
956    #[serde(default)]
957    pub trade_id: Option<Ustr>,
958    /// Fill time (optional).
959    #[serde(default)]
960    pub fill_time: Option<String>,
961    /// Fee (optional).
962    #[serde(default)]
963    pub fee: Option<String>,
964    /// Fee currency (optional).
965    #[serde(default)]
966    pub fee_ccy: Option<String>,
967    /// Request ID (optional).
968    #[serde(default)]
969    pub req_id: Option<Ustr>,
970    /// Position side (optional).
971    #[serde(default)]
972    pub pos_side: Option<OKXPositionSide>,
973    /// Reduce-only flag (optional).
974    #[serde(default)]
975    pub reduce_only: Option<String>,
976    /// Target currency (optional).
977    #[serde(default, deserialize_with = "deserialize_target_currency_as_none")]
978    pub tgt_ccy: Option<OKXTargetCurrency>,
979    /// Creation time.
980    #[serde(default)]
981    pub c_time: Option<String>,
982    /// Last update time (optional).
983    #[serde(default)]
984    pub u_time: Option<String>,
985    /// The result of the request.
986    #[serde(skip_serializing_if = "Option::is_none")]
987    pub s_code: Option<String>,
988    /// Error message if the request failed.
989    #[serde(skip_serializing_if = "Option::is_none")]
990    pub s_msg: Option<String>,
991    /// Detailed error code if the request failed.
992    #[serde(skip_serializing_if = "Option::is_none")]
993    pub sub_code: Option<String>,
994}
995
996/// Represents an attached TP/SL instruction on `POST /api/v5/trade/order`.
997#[derive(Clone, Debug, Default, Serialize, Deserialize)]
998#[serde(rename_all = "camelCase")]
999pub struct OKXAttachAlgoOrdRequest {
1000    /// Client order ID for the attached TP/SL OCO object.
1001    #[serde(skip_serializing_if = "Option::is_none")]
1002    pub attach_algo_cl_ord_id: Option<String>,
1003    /// Stop-loss trigger price.
1004    #[serde(skip_serializing_if = "Option::is_none")]
1005    pub sl_trigger_px: Option<String>,
1006    /// Stop-loss order price.
1007    #[serde(skip_serializing_if = "Option::is_none")]
1008    pub sl_ord_px: Option<String>,
1009    /// Stop-loss trigger price type.
1010    #[serde(skip_serializing_if = "Option::is_none")]
1011    pub sl_trigger_px_type: Option<OKXTriggerType>,
1012    /// Take-profit trigger price.
1013    #[serde(skip_serializing_if = "Option::is_none")]
1014    pub tp_trigger_px: Option<String>,
1015    /// Take-profit order price.
1016    #[serde(skip_serializing_if = "Option::is_none")]
1017    pub tp_ord_px: Option<String>,
1018    /// Take-profit trigger price type.
1019    #[serde(skip_serializing_if = "Option::is_none")]
1020    pub tp_trigger_px_type: Option<OKXTriggerType>,
1021    /// Callback ratio for attached trailing stop orders.
1022    #[serde(skip_serializing_if = "Option::is_none")]
1023    pub callback_ratio: Option<String>,
1024    /// Callback spread for attached trailing stop orders.
1025    #[serde(skip_serializing_if = "Option::is_none")]
1026    pub callback_spread: Option<String>,
1027    /// Activation price for attached trailing stop orders.
1028    #[serde(skip_serializing_if = "Option::is_none")]
1029    pub active_px: Option<String>,
1030    /// New callback ratio for amended attached trailing stop orders.
1031    #[serde(skip_serializing_if = "Option::is_none")]
1032    pub new_callback_ratio: Option<String>,
1033    /// New callback spread for amended attached trailing stop orders.
1034    #[serde(skip_serializing_if = "Option::is_none")]
1035    pub new_callback_spread: Option<String>,
1036    /// New activation price for amended attached trailing stop orders.
1037    #[serde(skip_serializing_if = "Option::is_none")]
1038    pub new_active_px: Option<String>,
1039}
1040
1041/// Represents the request body for `POST /api/v5/trade/order` (place order).
1042#[derive(Clone, Debug, Serialize, Deserialize)]
1043#[serde(rename_all = "camelCase")]
1044pub struct OKXPlaceOrderRequest {
1045    /// Instrument ID.
1046    pub inst_id: String,
1047    /// Trade mode (cash, cross, isolated).
1048    pub td_mode: OKXTradeMode,
1049    /// Currency used for margin trading when required by OKX.
1050    #[serde(skip_serializing_if = "Option::is_none")]
1051    pub ccy: Option<String>,
1052    /// Client-supplied order ID.
1053    #[serde(skip_serializing_if = "Option::is_none")]
1054    pub cl_ord_id: Option<String>,
1055    /// Order tag.
1056    #[serde(skip_serializing_if = "Option::is_none")]
1057    pub tag: Option<String>,
1058    /// Order side (buy, sell).
1059    pub side: OKXSide,
1060    /// Position side for derivatives.
1061    #[serde(skip_serializing_if = "Option::is_none")]
1062    pub pos_side: Option<OKXPositionSide>,
1063    /// Order type.
1064    pub ord_type: OKXOrderType,
1065    /// Order size.
1066    pub sz: String,
1067    /// Limit price when required by the order type.
1068    #[serde(skip_serializing_if = "Option::is_none")]
1069    pub px: Option<String>,
1070    /// Price in USD, only applicable to options. Mutually exclusive with `px` and `px_vol`.
1071    #[serde(rename = "pxUsd", skip_serializing_if = "Option::is_none")]
1072    pub px_usd: Option<String>,
1073    /// Price in implied volatility (1 = 100%), only applicable to options.
1074    /// Mutually exclusive with `px` and `px_usd`.
1075    #[serde(rename = "pxVol", skip_serializing_if = "Option::is_none")]
1076    pub px_vol: Option<String>,
1077    /// Reduce-only flag.
1078    #[serde(skip_serializing_if = "Option::is_none")]
1079    pub reduce_only: Option<bool>,
1080    /// Target currency for spot market orders.
1081    #[serde(skip_serializing_if = "Option::is_none")]
1082    pub tgt_ccy: Option<OKXTargetCurrency>,
1083    /// Quote currency used for trading. Only applicable to SPOT.
1084    #[serde(skip_serializing_if = "Option::is_none")]
1085    pub trade_quote_ccy: Option<Ustr>,
1086    /// Attached TP/SL OCO instructions.
1087    #[serde(skip_serializing_if = "Option::is_none")]
1088    pub attach_algo_ords: Option<Vec<OKXAttachAlgoOrdRequest>>,
1089    /// Event contract market outcome: yes or no.
1090    #[serde(skip_serializing_if = "Option::is_none")]
1091    pub outcome: Option<String>,
1092    /// Slippage tolerance for market orders, expressed as a decimal fraction
1093    /// (e.g., "0.005" for 0.5%). Supported instrument/order-type scope is
1094    /// venue-controlled; rejected with `54084`/`54085` if exceeded or out of
1095    /// the venue's accepted range. See the OKX v5 docs for the current matrix.
1096    #[serde(skip_serializing_if = "Option::is_none")]
1097    pub slippage_pct: Option<String>,
1098    /// Whether the order may take RPI liquidity.
1099    #[serde(skip_serializing_if = "Option::is_none")]
1100    pub rpi_taker_access: Option<bool>,
1101    /// Whether OKX may round the order price to an eligible RPI price.
1102    #[serde(skip_serializing_if = "Option::is_none")]
1103    pub rpi_px_round: Option<bool>,
1104}
1105
1106/// Represents the request body for `POST /api/v5/trade/amend-order`.
1107#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1108#[serde(rename_all = "camelCase")]
1109pub struct OKXAmendOrderRequest {
1110    /// Instrument ID.
1111    pub inst_id: String,
1112    /// Order ID.
1113    #[serde(skip_serializing_if = "Option::is_none")]
1114    pub ord_id: Option<String>,
1115    /// Client-supplied order ID.
1116    #[serde(skip_serializing_if = "Option::is_none")]
1117    pub cl_ord_id: Option<String>,
1118    /// Client-supplied request ID.
1119    #[serde(skip_serializing_if = "Option::is_none")]
1120    pub req_id: Option<String>,
1121    /// New order size.
1122    #[serde(skip_serializing_if = "Option::is_none")]
1123    pub new_sz: Option<String>,
1124    /// New order price.
1125    #[serde(skip_serializing_if = "Option::is_none")]
1126    pub new_px: Option<String>,
1127    /// Whether the order may take RPI liquidity after amendment.
1128    #[serde(skip_serializing_if = "Option::is_none")]
1129    pub rpi_taker_access: Option<bool>,
1130    /// Whether OKX may round the amended price to an eligible RPI price.
1131    #[serde(skip_serializing_if = "Option::is_none")]
1132    pub rpi_px_round: Option<bool>,
1133}
1134
1135/// Represents the request body for `POST /api/v5/trade/cancel-batch-orders`.
1136#[derive(Clone, Debug, Serialize, Deserialize)]
1137#[serde(rename_all = "camelCase")]
1138pub struct OKXCancelOrderRequest {
1139    /// Instrument ID.
1140    pub inst_id: String,
1141    /// Instrument ID code (numeric). May be required per OKX deprecation notice.
1142    #[serde(rename = "instIdCode", skip_serializing_if = "Option::is_none")]
1143    pub inst_id_code: Option<u64>,
1144    /// Order ID.
1145    #[serde(skip_serializing_if = "Option::is_none")]
1146    pub ord_id: Option<String>,
1147    /// Client-supplied order ID.
1148    #[serde(skip_serializing_if = "Option::is_none")]
1149    pub cl_ord_id: Option<String>,
1150}
1151
1152/// Represents a single response item from `POST /api/v5/trade/cancel-batch-orders`.
1153#[derive(Clone, Debug, Serialize, Deserialize)]
1154#[serde(rename_all = "camelCase")]
1155pub struct OKXCancelOrderResponse {
1156    /// Order ID.
1157    pub ord_id: String,
1158    /// Client-supplied order ID.
1159    #[serde(default)]
1160    pub cl_ord_id: Option<String>,
1161    /// The result of the request.
1162    #[serde(skip_serializing_if = "Option::is_none")]
1163    pub s_code: Option<String>,
1164    /// Error message if the request failed.
1165    #[serde(skip_serializing_if = "Option::is_none")]
1166    pub s_msg: Option<String>,
1167    /// Response timestamp.
1168    #[serde(default)]
1169    pub ts: Option<String>,
1170}
1171
1172pub use crate::common::models::OKXAttachedAlgoOrd;
1173
1174/// Represents a single historical order record from `GET /api/v5/trade/orders-history`.
1175#[derive(Clone, Debug, Serialize, Deserialize)]
1176#[serde(rename_all = "camelCase")]
1177pub struct OKXOrderHistory {
1178    /// Order ID.
1179    pub ord_id: Ustr,
1180    /// Client order ID.
1181    pub cl_ord_id: Ustr,
1182    /// Algo order ID (for conditional orders).
1183    #[serde(default)]
1184    pub algo_id: Option<Ustr>,
1185    /// Client-supplied algo order ID (for conditional orders).
1186    #[serde(default)]
1187    pub algo_cl_ord_id: Option<Ustr>,
1188    /// Attached child client order ID if OKX surfaces one at the top level.
1189    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
1190    pub attach_algo_cl_ord_id: Option<String>,
1191    /// Attached TP/SL child orders associated with the parent order.
1192    #[serde(default)]
1193    pub attach_algo_ords: Vec<OKXAttachedAlgoOrd>,
1194    /// Client account ID (may be omitted by OKX).
1195    #[serde(default)]
1196    pub cl_act_id: Option<Ustr>,
1197    /// Order tag.
1198    pub tag: String,
1199    /// Instrument type.
1200    pub inst_type: OKXInstrumentType,
1201    /// Underlying (optional).
1202    pub uly: Option<Ustr>,
1203    /// Instrument ID.
1204    pub inst_id: Ustr,
1205    /// Order type.
1206    pub ord_type: OKXOrderType,
1207    /// Order size.
1208    pub sz: String,
1209    /// Price (optional).
1210    pub px: String,
1211    /// Price in USD (options only).
1212    #[serde(default)]
1213    pub px_usd: String,
1214    /// Price in implied volatility (options only).
1215    #[serde(default)]
1216    pub px_vol: String,
1217    /// Side.
1218    pub side: OKXSide,
1219    /// Position side.
1220    pub pos_side: OKXPositionSide,
1221    /// Trade mode.
1222    pub td_mode: OKXTradeMode,
1223    /// Reduce-only flag.
1224    pub reduce_only: String,
1225    /// Target currency (optional).
1226    #[serde(default, deserialize_with = "deserialize_target_currency_as_none")]
1227    pub tgt_ccy: Option<OKXTargetCurrency>,
1228    /// Order state.
1229    pub state: OKXOrderStatus,
1230    /// Average price (optional).
1231    pub avg_px: String,
1232    /// Execution fee.
1233    pub fee: String,
1234    /// Fee currency.
1235    pub fee_ccy: String,
1236    /// Filled size (optional).
1237    pub fill_sz: String,
1238    /// Fill price (optional).
1239    pub fill_px: String,
1240    /// Trade ID (optional).
1241    pub trade_id: Ustr,
1242    /// Fill time, Unix timestamp in milliseconds.
1243    #[serde(deserialize_with = "deserialize_string_to_u64")]
1244    pub fill_time: u64,
1245    /// Accumulated filled size.
1246    pub acc_fill_sz: String,
1247    /// Fill fee (optional, may be omitted).
1248    #[serde(default)]
1249    pub fill_fee: Option<String>,
1250    /// Request ID (optional).
1251    #[serde(default)]
1252    pub req_id: Option<Ustr>,
1253    /// Cancelled filled size (optional).
1254    #[serde(default)]
1255    pub cancel_fill_sz: Option<String>,
1256    /// Cancelled total size (optional).
1257    #[serde(default)]
1258    pub cancel_total_sz: Option<String>,
1259    /// Venue cancellation source code.
1260    #[serde(default)]
1261    pub cancel_source: String,
1262    /// Venue cancellation reason.
1263    #[serde(default)]
1264    pub cancel_source_reason: String,
1265    /// Fee discount (optional).
1266    #[serde(default)]
1267    pub fee_discount: Option<String>,
1268    /// Order category (normal, liquidation, ADL, etc.).
1269    pub category: OKXOrderCategory,
1270    /// Event contract market outcome, if applicable.
1271    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
1272    pub outcome: Option<String>,
1273    /// Last update time, Unix timestamp in milliseconds.
1274    #[serde(deserialize_with = "deserialize_string_to_u64")]
1275    pub u_time: u64,
1276    /// Creation time.
1277    #[serde(deserialize_with = "deserialize_string_to_u64")]
1278    pub c_time: u64,
1279}
1280
1281/// Represents an algo order response from `/trade/order-algo-*` endpoints.
1282#[derive(Clone, Debug, Serialize, Deserialize)]
1283#[serde(rename_all = "camelCase")]
1284pub struct OKXOrderAlgo {
1285    /// Algo order ID assigned by OKX.
1286    pub algo_id: String,
1287    /// Client-specified algo order ID.
1288    #[serde(default)]
1289    pub algo_cl_ord_id: String,
1290    /// Client order ID (empty until triggered).
1291    #[serde(default)]
1292    pub cl_ord_id: String,
1293    /// Latest regular order ID (deprecated by OKX; empty until triggered).
1294    #[serde(default)]
1295    pub ord_id: String,
1296    /// Regular order IDs created after the algo order triggers.
1297    #[serde(default)]
1298    pub ord_id_list: Vec<String>,
1299    /// Child algo order IDs created for split take-profit orders.
1300    #[serde(default)]
1301    pub sub_algo_id_list: Vec<String>,
1302    /// Instrument ID, e.g. `ETH-USDT-SWAP`.
1303    pub inst_id: Ustr,
1304    /// Instrument type.
1305    pub inst_type: OKXInstrumentType,
1306    /// Algo order type.
1307    pub ord_type: OKXAlgoOrderType,
1308    /// Current order state.
1309    pub state: OKXAlgoOrderStatus,
1310    /// Order side.
1311    pub side: OKXSide,
1312    /// Position side.
1313    pub pos_side: OKXPositionSide,
1314    /// Submitted size.
1315    #[serde(default)]
1316    pub sz: String,
1317    /// Trigger price (empty for certain algo styles).
1318    #[serde(default)]
1319    pub trigger_px: String,
1320    /// Trigger price type (last/mark/index).
1321    #[serde(default)]
1322    pub trigger_px_type: Option<OKXTriggerType>,
1323    /// Stop-loss trigger price for conditional close orders.
1324    #[serde(default)]
1325    pub sl_trigger_px: String,
1326    /// Stop-loss order price for conditional close orders.
1327    #[serde(default)]
1328    pub sl_ord_px: String,
1329    /// Stop-loss trigger price type (last/mark/index).
1330    #[serde(default)]
1331    pub sl_trigger_px_type: Option<OKXTriggerType>,
1332    /// Take-profit trigger price for conditional close orders.
1333    #[serde(default)]
1334    pub tp_trigger_px: String,
1335    /// Take-profit order price for conditional close orders.
1336    #[serde(default)]
1337    pub tp_ord_px: String,
1338    /// Take-profit trigger price type (last/mark/index).
1339    #[serde(default)]
1340    pub tp_trigger_px_type: Option<OKXTriggerType>,
1341    /// Order price (-1 indicates market execution once triggered).
1342    #[serde(default)]
1343    pub ord_px: String,
1344    /// Trade mode (cash/cross/isolated).
1345    pub td_mode: OKXTradeMode,
1346    /// Algo leverage configuration.
1347    #[serde(default)]
1348    pub lever: String,
1349    /// Reduce-only flag.
1350    #[serde(default)]
1351    pub reduce_only: String,
1352    /// Fraction of the position to close for close-order algos.
1353    #[serde(default)]
1354    pub close_fraction: String,
1355    /// Executed price (if triggered).
1356    #[serde(default)]
1357    pub actual_px: String,
1358    /// Executed size (if triggered).
1359    #[serde(default)]
1360    pub actual_sz: String,
1361    /// Notional value in USD.
1362    #[serde(default)]
1363    pub notional_usd: String,
1364    /// Creation time (milliseconds).
1365    #[serde(deserialize_with = "deserialize_string_to_u64")]
1366    pub c_time: u64,
1367    /// Last update time (milliseconds).
1368    #[serde(deserialize_with = "deserialize_string_to_u64")]
1369    pub u_time: u64,
1370    /// Trigger timestamp (if triggered).
1371    #[serde(default)]
1372    pub trigger_time: String,
1373    /// Optional tag supplied during submission.
1374    #[serde(default)]
1375    pub tag: String,
1376    /// Callback price ratio for trailing stop (e.g. "0.01" for 1%).
1377    #[serde(default)]
1378    pub callback_ratio: String,
1379    /// Callback price spread for trailing stop (absolute distance).
1380    #[serde(default)]
1381    pub callback_spread: String,
1382    /// Activation price for trailing stop.
1383    #[serde(default)]
1384    pub active_px: String,
1385}
1386
1387/// Represents a transaction detail (fill) from `GET /api/v5/trade/fills`.
1388#[derive(Clone, Debug, Serialize, Deserialize)]
1389#[serde(rename_all = "camelCase")]
1390pub struct OKXTransactionDetail {
1391    /// Product type (SPOT, MARGIN, SWAP, FUTURES, OPTION).
1392    pub inst_type: OKXInstrumentType,
1393    /// Instrument ID, e.g. "BTC-USDT".
1394    pub inst_id: Ustr,
1395    /// Trade ID.
1396    pub trade_id: Ustr,
1397    /// Order ID.
1398    pub ord_id: Ustr,
1399    /// Client order ID.
1400    pub cl_ord_id: Ustr,
1401    /// Bill ID.
1402    pub bill_id: Ustr,
1403    /// Last filled price.
1404    pub fill_px: String,
1405    /// Last filled quantity.
1406    pub fill_sz: String,
1407    /// Trade side: buy or sell.
1408    pub side: OKXSide,
1409    /// Position side for futures and swaps.
1410    #[serde(default)]
1411    pub pos_side: Option<OKXPositionSide>,
1412    /// Execution type.
1413    pub exec_type: OKXExecType,
1414    /// Fee currency.
1415    pub fee_ccy: String,
1416    /// Fee amount.
1417    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
1418    pub fee: Option<String>,
1419    /// Timestamp, Unix timestamp format in milliseconds.
1420    #[serde(deserialize_with = "deserialize_string_to_u64")]
1421    pub ts: u64,
1422}
1423
1424/// Represents a single historical position record from `GET /api/v5/account/positions-history`.
1425#[derive(Clone, Debug, Serialize, Deserialize)]
1426#[serde(rename_all = "camelCase")]
1427pub struct OKXPositionHistory {
1428    /// Instrument type (e.g. "SWAP", "FUTURES", etc.).
1429    pub inst_type: OKXInstrumentType,
1430    /// Instrument ID (e.g. "BTC-USD-SWAP").
1431    pub inst_id: Ustr,
1432    /// Margin mode: e.g. "cross", "isolated".
1433    pub mgn_mode: OKXMarginMode,
1434    /// The type of the last close, e.g. "1" (close partially), "2" (close all), etc.
1435    /// See OKX docs for the meaning of each numeric code.
1436    #[serde(rename = "type")]
1437    pub r#type: Ustr,
1438    /// Creation time of the position (Unix timestamp in milliseconds).
1439    pub c_time: String,
1440    /// Last update time, Unix timestamp in milliseconds.
1441    #[serde(deserialize_with = "deserialize_string_to_u64")]
1442    pub u_time: u64,
1443    /// Average price of opening position.
1444    pub open_avg_px: String,
1445    /// Average price of closing position (if applicable).
1446    #[serde(skip_serializing_if = "Option::is_none")]
1447    pub close_avg_px: Option<String>,
1448    /// The position ID.
1449    #[serde(default, deserialize_with = "deserialize_empty_ustr_as_none")]
1450    pub pos_id: Option<Ustr>,
1451    /// Max quantity of the position at open time.
1452    #[serde(skip_serializing_if = "Option::is_none")]
1453    pub open_max_pos: Option<String>,
1454    /// Cumulative closed volume of the position.
1455    #[serde(skip_serializing_if = "Option::is_none")]
1456    pub close_total_pos: Option<String>,
1457    /// Realized profit and loss (only for FUTURES/SWAP/OPTION).
1458    #[serde(skip_serializing_if = "Option::is_none")]
1459    pub realized_pnl: Option<String>,
1460    /// Accumulated fee for the position.
1461    #[serde(skip_serializing_if = "Option::is_none")]
1462    pub fee: Option<String>,
1463    /// Accumulated funding fee (for perpetual swaps).
1464    #[serde(skip_serializing_if = "Option::is_none")]
1465    pub funding_fee: Option<String>,
1466    /// Accumulated liquidation penalty. Negative if there was a penalty.
1467    #[serde(skip_serializing_if = "Option::is_none")]
1468    pub liq_penalty: Option<String>,
1469    /// Profit and loss (realized or unrealized depending on status).
1470    #[serde(skip_serializing_if = "Option::is_none")]
1471    pub pnl: Option<String>,
1472    /// PnL ratio.
1473    #[serde(skip_serializing_if = "Option::is_none")]
1474    pub pnl_ratio: Option<String>,
1475    /// Position side: "long" / "short" / "net".
1476    pub pos_side: OKXPositionSide,
1477    /// Leverage used (the JSON field is "lev", but we rename it in Rust).
1478    pub lever: String,
1479    /// Direction: "long" or "short" (only for MARGIN/FUTURES/SWAP/OPTION).
1480    #[serde(skip_serializing_if = "Option::is_none")]
1481    pub direction: Option<String>,
1482    /// Trigger mark price. Populated if `type` indicates liquidation or ADL.
1483    #[serde(skip_serializing_if = "Option::is_none")]
1484    pub trigger_px: Option<String>,
1485    /// The underlying (e.g. "BTC-USD" for futures or swap).
1486    #[serde(skip_serializing_if = "Option::is_none")]
1487    pub uly: Option<String>,
1488    /// Currency (e.g. "BTC"). May or may not appear in all responses.
1489    #[serde(skip_serializing_if = "Option::is_none")]
1490    pub ccy: Option<String>,
1491}
1492
1493/// Represents the request body for `POST /api/v5/trade/order-algo` (place algo order).
1494#[derive(Clone, Debug, Serialize, Deserialize)]
1495#[serde(rename_all = "camelCase")]
1496pub struct OKXPlaceAlgoOrderRequest {
1497    /// Instrument ID.
1498    #[serde(rename = "instId")]
1499    pub inst_id: String,
1500    /// Instrument ID code (numeric). May be required per OKX deprecation notice.
1501    #[serde(rename = "instIdCode", skip_serializing_if = "Option::is_none")]
1502    pub inst_id_code: Option<u64>,
1503    /// Trade mode (isolated, cross, cash).
1504    #[serde(rename = "tdMode")]
1505    pub td_mode: OKXTradeMode,
1506    /// Order side (buy, sell).
1507    pub side: OKXSide,
1508    /// Algo order type (trigger, conditional, `move_order_stop`, etc.).
1509    #[serde(rename = "ordType")]
1510    pub ord_type: OKXAlgoOrderType,
1511    /// Order size. Omitted for `closeFraction` close orders.
1512    #[serde(skip_serializing_if = "Option::is_none")]
1513    pub sz: Option<String>,
1514    /// Client-supplied algo order ID.
1515    #[serde(rename = "algoClOrdId", skip_serializing_if = "Option::is_none")]
1516    pub algo_cl_ord_id: Option<String>,
1517    /// Trigger price.
1518    #[serde(rename = "triggerPx", skip_serializing_if = "Option::is_none")]
1519    pub trigger_px: Option<String>,
1520    /// Order price (for limit orders).
1521    #[serde(rename = "orderPx", skip_serializing_if = "Option::is_none")]
1522    pub order_px: Option<String>,
1523    /// Trigger type (last, mark, index).
1524    #[serde(rename = "triggerPxType", skip_serializing_if = "Option::is_none")]
1525    pub trigger_px_type: Option<OKXTriggerType>,
1526    /// Stop-loss trigger price for conditional close orders.
1527    #[serde(rename = "slTriggerPx", skip_serializing_if = "Option::is_none")]
1528    pub sl_trigger_px: Option<String>,
1529    /// Stop-loss order price for conditional close orders.
1530    #[serde(rename = "slOrdPx", skip_serializing_if = "Option::is_none")]
1531    pub sl_ord_px: Option<String>,
1532    /// Stop-loss trigger type (last, mark, index).
1533    #[serde(rename = "slTriggerPxType", skip_serializing_if = "Option::is_none")]
1534    pub sl_trigger_px_type: Option<OKXTriggerType>,
1535    /// Take-profit trigger price for conditional close orders.
1536    #[serde(rename = "tpTriggerPx", skip_serializing_if = "Option::is_none")]
1537    pub tp_trigger_px: Option<String>,
1538    /// Take-profit order price for conditional close orders.
1539    #[serde(rename = "tpOrdPx", skip_serializing_if = "Option::is_none")]
1540    pub tp_ord_px: Option<String>,
1541    /// Take-profit trigger type (last, mark, index).
1542    #[serde(rename = "tpTriggerPxType", skip_serializing_if = "Option::is_none")]
1543    pub tp_trigger_px_type: Option<OKXTriggerType>,
1544    /// Target currency (`base_ccy` or `quote_ccy`).
1545    #[serde(rename = "tgtCcy", skip_serializing_if = "Option::is_none")]
1546    pub tgt_ccy: Option<OKXTargetCurrency>,
1547    /// Position side (net, long, short).
1548    #[serde(rename = "posSide", skip_serializing_if = "Option::is_none")]
1549    pub pos_side: Option<OKXPositionSide>,
1550    /// Whether to close position.
1551    #[serde(rename = "closePosition", skip_serializing_if = "Option::is_none")]
1552    pub close_position: Option<bool>,
1553    /// Order tag.
1554    #[serde(skip_serializing_if = "Option::is_none")]
1555    pub tag: Option<String>,
1556    /// Whether it's a reduce-only order.
1557    #[serde(rename = "reduceOnly", skip_serializing_if = "Option::is_none")]
1558    pub reduce_only: Option<bool>,
1559    /// Fraction of the position to close for eligible algo close orders.
1560    #[serde(rename = "closeFraction", skip_serializing_if = "Option::is_none")]
1561    pub close_fraction: Option<String>,
1562    /// Callback rate for trailing stop (e.g., "0.01" for 1%). Either this or
1563    /// `callback_spread` is required for `move_order_stop` orders.
1564    #[serde(rename = "callbackRatio", skip_serializing_if = "Option::is_none")]
1565    pub callback_ratio: Option<String>,
1566    /// Callback spread for trailing stop (fixed price distance). Either this or
1567    /// `callback_ratio` is required for `move_order_stop` orders.
1568    #[serde(rename = "callbackSpread", skip_serializing_if = "Option::is_none")]
1569    pub callback_spread: Option<String>,
1570    /// Activation price for trailing stop. If empty, the trailing stop
1571    /// activates immediately when placed.
1572    #[serde(rename = "activePx", skip_serializing_if = "Option::is_none")]
1573    pub active_px: Option<String>,
1574    /// Margin currency for spot margin orders.
1575    #[serde(skip_serializing_if = "Option::is_none")]
1576    pub ccy: Option<String>,
1577}
1578
1579/// Represents the response from `POST /api/v5/trade/order-algo` (place algo order).
1580#[derive(Clone, Debug, Serialize, Deserialize)]
1581#[serde(rename_all = "camelCase")]
1582pub struct OKXPlaceAlgoOrderResponse {
1583    /// Algo order ID.
1584    pub algo_id: String,
1585    /// Client-supplied algo order ID.
1586    #[serde(skip_serializing_if = "Option::is_none")]
1587    pub algo_cl_ord_id: Option<String>,
1588    /// The result of the request.
1589    #[serde(skip_serializing_if = "Option::is_none")]
1590    pub s_code: Option<String>,
1591    /// Error message if the request failed.
1592    #[serde(skip_serializing_if = "Option::is_none")]
1593    pub s_msg: Option<String>,
1594    /// Request ID.
1595    #[serde(skip_serializing_if = "Option::is_none")]
1596    pub req_id: Option<String>,
1597}
1598
1599/// Represents the request body for `POST /api/v5/trade/cancel-algos` (cancel algo order).
1600#[derive(Clone, Debug, Serialize, Deserialize)]
1601#[serde(rename_all = "camelCase")]
1602pub struct OKXCancelAlgoOrderRequest {
1603    /// Instrument ID.
1604    pub inst_id: String,
1605    /// Instrument ID code (numeric). May be required per OKX deprecation notice.
1606    #[serde(rename = "instIdCode", skip_serializing_if = "Option::is_none")]
1607    pub inst_id_code: Option<u64>,
1608    /// Algo order ID.
1609    #[serde(skip_serializing_if = "Option::is_none")]
1610    pub algo_id: Option<String>,
1611    /// Client-supplied algo order ID.
1612    #[serde(skip_serializing_if = "Option::is_none")]
1613    pub algo_cl_ord_id: Option<String>,
1614}
1615
1616/// Represents the response from `POST /api/v5/trade/cancel-algos` (cancel algo order).
1617#[derive(Clone, Debug, Serialize, Deserialize)]
1618#[serde(rename_all = "camelCase")]
1619pub struct OKXCancelAlgoOrderResponse {
1620    /// Algo order ID.
1621    pub algo_id: String,
1622    /// The result of the request.
1623    #[serde(skip_serializing_if = "Option::is_none")]
1624    pub s_code: Option<String>,
1625    /// Error message if the request failed.
1626    #[serde(skip_serializing_if = "Option::is_none")]
1627    pub s_msg: Option<String>,
1628}
1629
1630/// Represents the request body for `POST /api/v5/trade/amend-algos` (amend algo order).
1631#[derive(Clone, Debug, Serialize, Deserialize)]
1632#[serde(rename_all = "camelCase")]
1633pub struct OKXAmendAlgoOrderRequest {
1634    /// Instrument ID.
1635    pub inst_id: String,
1636    /// Algo order ID.
1637    pub algo_id: String,
1638    /// Client-supplied algo order ID.
1639    #[serde(skip_serializing_if = "Option::is_none")]
1640    pub algo_cl_ord_id: Option<String>,
1641    /// New order size.
1642    #[serde(skip_serializing_if = "Option::is_none")]
1643    pub new_sz: Option<String>,
1644    /// New trigger price (for `trigger` algo orders).
1645    #[serde(skip_serializing_if = "Option::is_none")]
1646    pub new_trigger_px: Option<String>,
1647    /// New trigger price type (last, mark, index) for `trigger` algo orders.
1648    #[serde(skip_serializing_if = "Option::is_none")]
1649    pub new_trigger_px_type: Option<String>,
1650    /// New take-profit trigger price (for attached/OCO TP legs).
1651    #[serde(skip_serializing_if = "Option::is_none")]
1652    pub new_tp_trigger_px: Option<String>,
1653    /// New take-profit order price (`-1` for market).
1654    #[serde(skip_serializing_if = "Option::is_none")]
1655    pub new_tp_ord_px: Option<String>,
1656    /// New take-profit trigger price type (last, mark, index).
1657    #[serde(skip_serializing_if = "Option::is_none")]
1658    pub new_tp_trigger_px_type: Option<String>,
1659    /// New stop-loss trigger price (for `conditional` SL algo orders, incl.
1660    /// `closeFraction` close-position stops, which OKX amends via `newSlTriggerPx`
1661    /// rather than `newTriggerPx`).
1662    #[serde(skip_serializing_if = "Option::is_none")]
1663    pub new_sl_trigger_px: Option<String>,
1664    /// New stop-loss order price (`-1` for market).
1665    #[serde(skip_serializing_if = "Option::is_none")]
1666    pub new_sl_ord_px: Option<String>,
1667    /// New stop-loss trigger price type (last, mark, index).
1668    #[serde(skip_serializing_if = "Option::is_none")]
1669    pub new_sl_trigger_px_type: Option<String>,
1670    /// New order price (`-1` for market).
1671    #[serde(skip_serializing_if = "Option::is_none")]
1672    #[serde(rename = "newOrdPx")]
1673    pub new_order_px: Option<String>,
1674    /// New callback ratio for trailing stop (e.g., "0.01" for 1%).
1675    #[serde(skip_serializing_if = "Option::is_none")]
1676    pub new_callback_ratio: Option<String>,
1677    /// New callback spread for trailing stop (fixed price distance).
1678    #[serde(skip_serializing_if = "Option::is_none")]
1679    pub new_callback_spread: Option<String>,
1680    /// New activation price for trailing stop.
1681    #[serde(skip_serializing_if = "Option::is_none")]
1682    pub new_active_px: Option<String>,
1683}
1684
1685/// Represents the response from `POST /api/v5/trade/amend-algos` (amend algo order).
1686#[derive(Clone, Debug, Serialize, Deserialize)]
1687#[serde(rename_all = "camelCase")]
1688pub struct OKXAmendAlgoOrderResponse {
1689    /// Algo order ID.
1690    pub algo_id: String,
1691    /// Client-supplied algo order ID.
1692    #[serde(skip_serializing_if = "Option::is_none")]
1693    pub algo_cl_ord_id: Option<String>,
1694    /// The result of the request.
1695    #[serde(skip_serializing_if = "Option::is_none")]
1696    pub s_code: Option<String>,
1697    /// Error message if the request failed.
1698    #[serde(skip_serializing_if = "Option::is_none")]
1699    pub s_msg: Option<String>,
1700    /// Request ID.
1701    #[serde(skip_serializing_if = "Option::is_none")]
1702    pub req_id: Option<String>,
1703}
1704
1705/// Represents the response from `GET /api/v5/public/time` (get system time).
1706#[derive(Clone, Debug, Serialize, Deserialize)]
1707#[serde(rename_all = "camelCase")]
1708pub struct OKXServerTime {
1709    /// Server timestamp in milliseconds.
1710    #[serde(deserialize_with = "deserialize_string_to_u64")]
1711    pub ts: u64,
1712}
1713
1714/// Represents a fee group from `GET /api/v5/account/trade-fee`.
1715///
1716/// Rates retain OKX signs: positive values are rebates and negative values are commissions.
1717/// Missing or empty rates are `None`, distinct from an explicit zero rate.
1718#[derive(Clone, Debug, Serialize, Deserialize)]
1719#[serde(rename_all = "camelCase")]
1720pub struct OKXFeeGroup {
1721    /// Fee group ID, interpreted together with the parent response's instrument type.
1722    pub group_id: String,
1723    /// Maker fee rate.
1724    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
1725    pub maker: Option<Decimal>,
1726    /// Taker fee rate.
1727    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
1728    pub taker: Option<Decimal>,
1729    /// Maker fee rate for RPI orders.
1730    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
1731    pub rpi_maker: Option<Decimal>,
1732    /// Legacy ELP maker fee rate, retained independently when both names are present.
1733    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
1734    pub elp_maker: Option<Decimal>,
1735}
1736
1737/// Represents a fee rate entry from `GET /api/v5/account/trade-fee`.
1738#[derive(Clone, Debug, Serialize, Deserialize)]
1739#[serde(rename_all = "camelCase")]
1740pub struct OKXFeeRate {
1741    /// Fee level (VIP tier) - indicates the user's VIP tier (0-9).
1742    #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
1743    pub level: OKXVipLevel,
1744    /// Legacy taker fee rate for crypto-margined contracts; empty when absent.
1745    #[serde(default)]
1746    pub taker: String,
1747    /// Legacy maker fee rate for crypto-margined contracts; empty when absent.
1748    #[serde(default)]
1749    pub maker: String,
1750    /// Legacy taker fee rate for USDT-margined contracts; empty when absent.
1751    #[serde(default)]
1752    pub taker_u: String,
1753    /// Legacy maker fee rate for USDT-margined contracts; empty when absent.
1754    #[serde(default)]
1755    pub maker_u: String,
1756    /// Fee groups returned for the requested scope, without selecting a rate.
1757    #[serde(default)]
1758    pub fee_group: Vec<OKXFeeGroup>,
1759    /// Maker fee rate for RPI orders.
1760    #[serde(
1761        default,
1762        alias = "elpMaker",
1763        deserialize_with = "deserialize_optional_decimal"
1764    )]
1765    pub rpi_maker: Option<Decimal>,
1766    /// Delivery fee rate.
1767    #[serde(default)]
1768    pub delivery: String,
1769    /// Option exercise fee rate.
1770    #[serde(default)]
1771    pub exercise: String,
1772    /// Event contract settlement fee rate.
1773    #[serde(default)]
1774    pub settle: String,
1775    /// Instrument type (SPOT, MARGIN, SWAP, FUTURES, OPTION, EVENTS).
1776    pub inst_type: OKXInstrumentType,
1777    /// Fee schedule category (being deprecated).
1778    #[serde(default)]
1779    pub category: String,
1780    /// Data return timestamp (Unix timestamp in milliseconds).
1781    #[serde(deserialize_with = "deserialize_string_to_u64")]
1782    pub ts: u64,
1783}
1784
1785fn deserialize_configuration_enum<'de, D, T>(deserializer: D) -> Result<T, D::Error>
1786where
1787    D: Deserializer<'de>,
1788    T: Deserialize<'de>,
1789{
1790    let value = String::deserialize(deserializer)?;
1791    T::deserialize(value.into_deserializer())
1792}
1793
1794mod account_permissions {
1795    use serde::{Deserialize, Deserializer, Serializer, de::IntoDeserializer};
1796
1797    use crate::common::enums::OKXApiKeyPermission;
1798
1799    pub(super) fn serialize<S>(
1800        permissions: &[OKXApiKeyPermission],
1801        serializer: S,
1802    ) -> Result<S::Ok, S::Error>
1803    where
1804        S: Serializer,
1805    {
1806        let value = permissions
1807            .iter()
1808            .map(AsRef::as_ref)
1809            .collect::<Vec<_>>()
1810            .join(",");
1811        serializer.serialize_str(&value)
1812    }
1813
1814    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Vec<OKXApiKeyPermission>, D::Error>
1815    where
1816        D: Deserializer<'de>,
1817    {
1818        let value = String::deserialize(deserializer)?;
1819        value
1820            .split(',')
1821            .map(|permission| OKXApiKeyPermission::deserialize(permission.into_deserializer()))
1822            .collect()
1823    }
1824}
1825
1826#[cfg(test)]
1827mod tests {
1828    use rstest::rstest;
1829    use serde_json;
1830
1831    use super::*;
1832
1833    #[rstest]
1834    fn test_algo_order_deserializes_current_child_identifier_lists() {
1835        let order: OKXOrderAlgo = serde_json::from_value(serde_json::json!({
1836            "algoId": "123",
1837            "algoClOrdId": "algo-client-1",
1838            "ordId": "456",
1839            "ordIdList": ["456", "457"],
1840            "subAlgoIdList": ["789"],
1841            "instId": "ETH-USDT-SWAP",
1842            "instType": "SWAP",
1843            "ordType": "conditional",
1844            "state": "effective",
1845            "side": "sell",
1846            "posSide": "net",
1847            "tdMode": "cross",
1848            "cTime": "1700000000000",
1849            "uTime": "1700000001000"
1850        }))
1851        .unwrap();
1852
1853        assert_eq!(order.ord_id, "456");
1854        assert_eq!(order.ord_id_list, ["456", "457"]);
1855        assert_eq!(order.sub_algo_id_list, ["789"]);
1856    }
1857
1858    #[rstest]
1859    fn test_algo_order_request_serialization() {
1860        let request = OKXPlaceAlgoOrderRequest {
1861            inst_id: "ETH-USDT-SWAP".to_string(),
1862            inst_id_code: None,
1863            td_mode: OKXTradeMode::Isolated,
1864            side: OKXSide::Buy,
1865            ord_type: OKXAlgoOrderType::Trigger,
1866            sz: Some("0.01".to_string()),
1867            algo_cl_ord_id: Some("test123".to_string()),
1868            trigger_px: Some("3000".to_string()),
1869            order_px: Some("-1".to_string()),
1870            trigger_px_type: Some(OKXTriggerType::Last),
1871            sl_trigger_px: None,
1872            sl_ord_px: None,
1873            sl_trigger_px_type: None,
1874            tp_trigger_px: None,
1875            tp_ord_px: None,
1876            tp_trigger_px_type: None,
1877            tgt_ccy: None,
1878            pos_side: None,
1879            close_position: None,
1880            tag: None,
1881            reduce_only: None,
1882            close_fraction: None,
1883            callback_ratio: None,
1884            callback_spread: None,
1885            active_px: None,
1886            ccy: None,
1887        };
1888
1889        let json = serde_json::to_string(&request).unwrap();
1890
1891        // Verify that fields are serialized with correct camelCase names
1892        assert!(json.contains("\"instId\":\"ETH-USDT-SWAP\""));
1893        assert!(json.contains("\"tdMode\":\"isolated\""));
1894        assert!(json.contains("\"ordType\":\"trigger\""));
1895        assert!(json.contains("\"algoClOrdId\":\"test123\""));
1896        assert!(json.contains("\"triggerPx\":\"3000\""));
1897        assert!(json.contains("\"orderPx\":\"-1\""));
1898        assert!(json.contains("\"triggerPxType\":\"last\""));
1899
1900        // Verify that None fields are not included
1901        assert!(!json.contains("tgtCcy"));
1902        assert!(!json.contains("posSide"));
1903        assert!(!json.contains("closePosition"));
1904        assert!(!json.contains("closeFraction"));
1905    }
1906
1907    #[rstest]
1908    fn test_amend_algo_order_request_serializes_sl_trigger_px() {
1909        // A conditional stop-loss algo order (incl. closeFraction stops) is amended
1910        // via `newSlTriggerPx`, not `newTriggerPx`. Verify the camelCase field name
1911        // and that an unset `new_trigger_px` is omitted.
1912        let request = OKXAmendAlgoOrderRequest {
1913            inst_id: "ETH-USDT-SWAP".to_string(),
1914            algo_id: "123".to_string(),
1915            algo_cl_ord_id: None,
1916            new_sz: None,
1917            new_trigger_px: None,
1918            new_trigger_px_type: None,
1919            new_tp_trigger_px: None,
1920            new_tp_ord_px: None,
1921            new_tp_trigger_px_type: None,
1922            new_sl_trigger_px: Some("850".to_string()),
1923            new_sl_ord_px: None,
1924            new_sl_trigger_px_type: None,
1925            new_order_px: None,
1926            new_callback_ratio: None,
1927            new_callback_spread: None,
1928            new_active_px: None,
1929        };
1930
1931        let json = serde_json::to_string(&request).unwrap();
1932
1933        assert!(json.contains("\"algoId\":\"123\""));
1934        assert!(json.contains("\"newSlTriggerPx\":\"850\""));
1935        // Unset optional fields must be omitted (OKX rejects an empty newTriggerPx).
1936        assert!(!json.contains("newTriggerPx"));
1937        assert!(!json.contains("newSz"));
1938        assert!(!json.contains("algoClOrdId"));
1939    }
1940
1941    #[rstest]
1942    fn test_amend_algo_order_request_serializes_oco_tp_sl_fields() {
1943        let request = OKXAmendAlgoOrderRequest {
1944            inst_id: "DOGE-USDT-SWAP".to_string(),
1945            algo_id: "algo-oco-1".to_string(),
1946            algo_cl_ord_id: None,
1947            new_sz: None,
1948            new_trigger_px: None,
1949            new_trigger_px_type: None,
1950            new_tp_trigger_px: Some("0.10495".to_string()),
1951            new_tp_ord_px: Some("-1".to_string()),
1952            new_tp_trigger_px_type: Some("last".to_string()),
1953            new_sl_trigger_px: Some("0.06297".to_string()),
1954            new_sl_ord_px: Some("-1".to_string()),
1955            new_sl_trigger_px_type: Some("last".to_string()),
1956            new_order_px: None,
1957            new_callback_ratio: None,
1958            new_callback_spread: None,
1959            new_active_px: None,
1960        };
1961
1962        let json = serde_json::to_string(&request).unwrap();
1963
1964        assert!(json.contains("\"algoId\":\"algo-oco-1\""));
1965        assert!(json.contains("\"newTpTriggerPx\":\"0.10495\""));
1966        assert!(json.contains("\"newTpOrdPx\":\"-1\""));
1967        assert!(json.contains("\"newTpTriggerPxType\":\"last\""));
1968        assert!(json.contains("\"newSlTriggerPx\":\"0.06297\""));
1969        assert!(json.contains("\"newSlOrdPx\":\"-1\""));
1970        assert!(json.contains("\"newSlTriggerPxType\":\"last\""));
1971        assert!(!json.contains("newTriggerPx"));
1972    }
1973
1974    #[rstest]
1975    fn test_algo_order_request_serializes_close_fraction() {
1976        let request = OKXPlaceAlgoOrderRequest {
1977            inst_id: "ETH-USDT-SWAP".to_string(),
1978            inst_id_code: None,
1979            td_mode: OKXTradeMode::Cross,
1980            side: OKXSide::Sell,
1981            ord_type: OKXAlgoOrderType::Conditional,
1982            sz: None,
1983            algo_cl_ord_id: Some("close-frac-123".to_string()),
1984            trigger_px: None,
1985            order_px: None,
1986            trigger_px_type: None,
1987            sl_trigger_px: Some("3000".to_string()),
1988            sl_ord_px: Some("-1".to_string()),
1989            sl_trigger_px_type: Some(OKXTriggerType::Last),
1990            tp_trigger_px: None,
1991            tp_ord_px: None,
1992            tp_trigger_px_type: None,
1993            tgt_ccy: None,
1994            pos_side: Some(OKXPositionSide::Net),
1995            close_position: None,
1996            tag: None,
1997            reduce_only: Some(true),
1998            close_fraction: Some("1".to_string()),
1999            callback_ratio: None,
2000            callback_spread: None,
2001            active_px: None,
2002            ccy: None,
2003        };
2004
2005        let json = serde_json::to_string(&request).unwrap();
2006
2007        assert!(json.contains("\"ordType\":\"conditional\""));
2008        assert!(json.contains("\"closeFraction\":\"1\""));
2009        assert!(json.contains("\"slTriggerPx\":\"3000\""));
2010        assert!(json.contains("\"slOrdPx\":\"-1\""));
2011        assert!(json.contains("\"slTriggerPxType\":\"last\""));
2012        assert!(json.contains("\"reduceOnly\":true"));
2013        assert!(!json.contains("\"sz\""));
2014        assert!(!json.contains("triggerPx"));
2015    }
2016
2017    #[rstest]
2018    fn test_algo_order_request_array_serialization() {
2019        let request = OKXPlaceAlgoOrderRequest {
2020            inst_id: "BTC-USDT".to_string(),
2021            inst_id_code: Some(10459),
2022            td_mode: OKXTradeMode::Cross,
2023            side: OKXSide::Sell,
2024            ord_type: OKXAlgoOrderType::Trigger,
2025            sz: Some("0.1".to_string()),
2026            algo_cl_ord_id: None,
2027            trigger_px: Some("50000".to_string()),
2028            order_px: Some("49900".to_string()),
2029            trigger_px_type: Some(OKXTriggerType::Mark),
2030            sl_trigger_px: None,
2031            sl_ord_px: None,
2032            sl_trigger_px_type: None,
2033            tp_trigger_px: None,
2034            tp_ord_px: None,
2035            tp_trigger_px_type: None,
2036            tgt_ccy: Some(OKXTargetCurrency::BaseCcy),
2037            pos_side: Some(OKXPositionSide::Net),
2038            close_position: None,
2039            tag: None,
2040            reduce_only: Some(true),
2041            close_fraction: None,
2042            callback_ratio: None,
2043            callback_spread: None,
2044            active_px: None,
2045            ccy: None,
2046        };
2047
2048        // OKX expects an array of requests
2049        let json = serde_json::to_string(&[request]).unwrap();
2050
2051        // Verify array format
2052        assert!(json.starts_with('['));
2053        assert!(json.ends_with(']'));
2054
2055        // Verify correct field names
2056        assert!(json.contains("\"instId\":\"BTC-USDT\""));
2057        assert!(json.contains("\"tdMode\":\"cross\""));
2058        assert!(json.contains("\"triggerPx\":\"50000\""));
2059        assert!(json.contains("\"orderPx\":\"49900\""));
2060        assert!(json.contains("\"triggerPxType\":\"mark\""));
2061        assert!(json.contains("\"tgtCcy\":\"base_ccy\""));
2062        assert!(json.contains("\"posSide\":\"net\""));
2063        assert!(json.contains("\"reduceOnly\":true"));
2064    }
2065
2066    #[rstest]
2067    fn test_cancel_algo_order_request_serialization() {
2068        let request = OKXCancelAlgoOrderRequest {
2069            inst_id: "ETH-USDT-SWAP".to_string(),
2070            inst_id_code: None,
2071            algo_id: Some("123456".to_string()),
2072            algo_cl_ord_id: None,
2073        };
2074
2075        let json = serde_json::to_string(&request).unwrap();
2076
2077        // Verify correct field names
2078        assert!(json.contains("\"instId\":\"ETH-USDT-SWAP\""));
2079        assert!(json.contains("\"algoId\":\"123456\""));
2080        assert!(!json.contains("algoClOrdId"));
2081    }
2082
2083    #[rstest]
2084    fn test_cancel_algo_order_with_client_id_serialization() {
2085        let request = OKXCancelAlgoOrderRequest {
2086            inst_id: "BTC-USDT".to_string(),
2087            inst_id_code: Some(10459),
2088            algo_id: None,
2089            algo_cl_ord_id: Some("client123".to_string()),
2090        };
2091
2092        // OKX expects an array of requests
2093        let json = serde_json::to_string(&[request]).unwrap();
2094
2095        // Verify array format and field names
2096        assert!(json.starts_with('['));
2097        assert!(json.contains("\"instId\":\"BTC-USDT\""));
2098        assert!(json.contains("\"algoClOrdId\":\"client123\""));
2099        assert!(!json.contains("\"algoId\""));
2100    }
2101
2102    #[rstest]
2103    fn test_amend_algo_order_trigger_serialization() {
2104        let request = OKXAmendAlgoOrderRequest {
2105            inst_id: "ETH-USDT-SWAP".to_string(),
2106            algo_id: "123456".to_string(),
2107            algo_cl_ord_id: None,
2108            new_sz: None,
2109            new_trigger_px: Some("3500".to_string()),
2110            new_trigger_px_type: None,
2111            new_tp_trigger_px: None,
2112            new_tp_ord_px: None,
2113            new_tp_trigger_px_type: None,
2114            new_sl_trigger_px: None,
2115            new_sl_ord_px: None,
2116            new_sl_trigger_px_type: None,
2117            new_order_px: Some("3490".to_string()),
2118            new_callback_ratio: None,
2119            new_callback_spread: None,
2120            new_active_px: None,
2121        };
2122
2123        let json = serde_json::to_string(&request).unwrap();
2124
2125        assert!(json.contains("\"instId\":\"ETH-USDT-SWAP\""));
2126        assert!(json.contains("\"algoId\":\"123456\""));
2127        assert!(json.contains("\"newTriggerPx\":\"3500\""));
2128        assert!(json.contains("\"newOrdPx\":\"3490\""));
2129        assert!(!json.contains("newSz"));
2130        assert!(!json.contains("algoClOrdId"));
2131        assert!(!json.contains("newCallbackRatio"));
2132    }
2133
2134    #[rstest]
2135    fn test_amend_algo_order_trailing_stop_serialization() {
2136        let request = OKXAmendAlgoOrderRequest {
2137            inst_id: "BTC-USDT-SWAP".to_string(),
2138            algo_id: "789012".to_string(),
2139            algo_cl_ord_id: Some("client456".to_string()),
2140            new_sz: Some("0.1".to_string()),
2141            new_trigger_px: None,
2142            new_trigger_px_type: None,
2143            new_tp_trigger_px: None,
2144            new_tp_ord_px: None,
2145            new_tp_trigger_px_type: None,
2146            new_sl_trigger_px: None,
2147            new_sl_ord_px: None,
2148            new_sl_trigger_px_type: None,
2149            new_order_px: None,
2150            new_callback_ratio: Some("0.02".to_string()),
2151            new_callback_spread: None,
2152            new_active_px: Some("50000".to_string()),
2153        };
2154
2155        let json = serde_json::to_string(&request).unwrap();
2156
2157        assert!(json.contains("\"instId\":\"BTC-USDT-SWAP\""));
2158        assert!(json.contains("\"algoId\":\"789012\""));
2159        assert!(json.contains("\"algoClOrdId\":\"client456\""));
2160        assert!(json.contains("\"newSz\":\"0.1\""));
2161        assert!(json.contains("\"newCallbackRatio\":\"0.02\""));
2162        assert!(json.contains("\"newActivePx\":\"50000\""));
2163        assert!(!json.contains("newTriggerPx"));
2164        assert!(!json.contains("newOrdPx"));
2165    }
2166
2167    #[rstest]
2168    fn test_trailing_stop_request_callback_ratio_serialization() {
2169        let request = OKXPlaceAlgoOrderRequest {
2170            inst_id: "BTC-USDT-SWAP".to_string(),
2171            inst_id_code: None,
2172            td_mode: OKXTradeMode::Cross,
2173            side: OKXSide::Buy,
2174            ord_type: OKXAlgoOrderType::MoveOrderStop,
2175            sz: Some("0.1".to_string()),
2176            algo_cl_ord_id: Some("trail-001".to_string()),
2177            trigger_px: None,
2178            order_px: None,
2179            trigger_px_type: None,
2180            sl_trigger_px: None,
2181            sl_ord_px: None,
2182            sl_trigger_px_type: None,
2183            tp_trigger_px: None,
2184            tp_ord_px: None,
2185            tp_trigger_px_type: None,
2186            tgt_ccy: None,
2187            pos_side: None,
2188            close_position: None,
2189            tag: None,
2190            reduce_only: None,
2191            close_fraction: None,
2192            callback_ratio: Some("0.01".to_string()),
2193            callback_spread: None,
2194            active_px: None,
2195            ccy: None,
2196        };
2197
2198        let json = serde_json::to_string(&request).unwrap();
2199
2200        assert!(json.contains("\"ordType\":\"move_order_stop\""));
2201        assert!(json.contains("\"callbackRatio\":\"0.01\""));
2202        assert!(!json.contains("callbackSpread"));
2203        assert!(!json.contains("activePx"));
2204    }
2205
2206    #[rstest]
2207    fn test_trailing_stop_request_callback_spread_serialization() {
2208        let request = OKXPlaceAlgoOrderRequest {
2209            inst_id: "ETH-USDT-SWAP".to_string(),
2210            inst_id_code: None,
2211            td_mode: OKXTradeMode::Isolated,
2212            side: OKXSide::Sell,
2213            ord_type: OKXAlgoOrderType::MoveOrderStop,
2214            sz: Some("1.0".to_string()),
2215            algo_cl_ord_id: None,
2216            trigger_px: None,
2217            order_px: None,
2218            trigger_px_type: None,
2219            sl_trigger_px: None,
2220            sl_ord_px: None,
2221            sl_trigger_px_type: None,
2222            tp_trigger_px: None,
2223            tp_ord_px: None,
2224            tp_trigger_px_type: None,
2225            tgt_ccy: None,
2226            pos_side: None,
2227            close_position: None,
2228            tag: None,
2229            reduce_only: Some(true),
2230            close_fraction: None,
2231            callback_ratio: None,
2232            callback_spread: Some("50.5".to_string()),
2233            active_px: None,
2234            ccy: None,
2235        };
2236
2237        let json = serde_json::to_string(&request).unwrap();
2238
2239        assert!(json.contains("\"callbackSpread\":\"50.5\""));
2240        assert!(!json.contains("callbackRatio"));
2241        assert!(!json.contains("activePx"));
2242    }
2243
2244    #[rstest]
2245    fn test_trailing_stop_request_with_activation_price_serialization() {
2246        let request = OKXPlaceAlgoOrderRequest {
2247            inst_id: "BTC-USDT-SWAP".to_string(),
2248            inst_id_code: None,
2249            td_mode: OKXTradeMode::Cross,
2250            side: OKXSide::Buy,
2251            ord_type: OKXAlgoOrderType::MoveOrderStop,
2252            sz: Some("0.5".to_string()),
2253            algo_cl_ord_id: None,
2254            trigger_px: None,
2255            order_px: None,
2256            trigger_px_type: None,
2257            sl_trigger_px: None,
2258            sl_ord_px: None,
2259            sl_trigger_px_type: None,
2260            tp_trigger_px: None,
2261            tp_ord_px: None,
2262            tp_trigger_px_type: None,
2263            tgt_ccy: None,
2264            pos_side: None,
2265            close_position: None,
2266            tag: None,
2267            reduce_only: None,
2268            close_fraction: None,
2269            callback_ratio: Some("0.005".to_string()),
2270            callback_spread: None,
2271            active_px: Some("65000".to_string()),
2272            ccy: None,
2273        };
2274
2275        let json = serde_json::to_string(&request).unwrap();
2276
2277        assert!(json.contains("\"callbackRatio\":\"0.005\""));
2278        assert!(json.contains("\"activePx\":\"65000\""));
2279        assert!(!json.contains("callbackSpread"));
2280    }
2281
2282    #[rstest]
2283    fn test_amend_algo_order_callback_spread_serialization() {
2284        let request = OKXAmendAlgoOrderRequest {
2285            inst_id: "ETH-USDT-SWAP".to_string(),
2286            algo_id: "456789".to_string(),
2287            algo_cl_ord_id: None,
2288            new_sz: None,
2289            new_trigger_px: None,
2290            new_trigger_px_type: None,
2291            new_tp_trigger_px: None,
2292            new_tp_ord_px: None,
2293            new_tp_trigger_px_type: None,
2294            new_sl_trigger_px: None,
2295            new_sl_ord_px: None,
2296            new_sl_trigger_px_type: None,
2297            new_order_px: None,
2298            new_callback_ratio: None,
2299            new_callback_spread: Some("25.0".to_string()),
2300            new_active_px: Some("4000".to_string()),
2301        };
2302
2303        let json = serde_json::to_string(&request).unwrap();
2304
2305        assert!(json.contains("\"newCallbackSpread\":\"25.0\""));
2306        assert!(json.contains("\"newActivePx\":\"4000\""));
2307        assert!(!json.contains("newCallbackRatio"));
2308        assert!(!json.contains("newTriggerPx"));
2309        assert!(!json.contains("newSz"));
2310    }
2311
2312    #[rstest]
2313    fn test_amend_algo_order_size_only_serialization() {
2314        let request = OKXAmendAlgoOrderRequest {
2315            inst_id: "BTC-USDT-SWAP".to_string(),
2316            algo_id: "111222".to_string(),
2317            algo_cl_ord_id: None,
2318            new_sz: Some("0.5".to_string()),
2319            new_trigger_px: None,
2320            new_trigger_px_type: None,
2321            new_tp_trigger_px: None,
2322            new_tp_ord_px: None,
2323            new_tp_trigger_px_type: None,
2324            new_sl_trigger_px: None,
2325            new_sl_ord_px: None,
2326            new_sl_trigger_px_type: None,
2327            new_order_px: None,
2328            new_callback_ratio: None,
2329            new_callback_spread: None,
2330            new_active_px: None,
2331        };
2332
2333        let json = serde_json::to_string(&request).unwrap();
2334
2335        assert!(json.contains("\"newSz\":\"0.5\""));
2336        assert!(!json.contains("newTriggerPx"));
2337        assert!(!json.contains("newOrdPx"));
2338        assert!(!json.contains("newCallbackRatio"));
2339        assert!(!json.contains("newCallbackSpread"));
2340        assert!(!json.contains("newActivePx"));
2341    }
2342
2343    #[rstest]
2344    fn test_amend_algo_order_all_fields_serialization() {
2345        let request = OKXAmendAlgoOrderRequest {
2346            inst_id: "BTC-USDT-SWAP".to_string(),
2347            algo_id: "333444".to_string(),
2348            algo_cl_ord_id: Some("client789".to_string()),
2349            new_sz: Some("1.0".to_string()),
2350            new_trigger_px: Some("60000".to_string()),
2351            new_trigger_px_type: None,
2352            new_tp_trigger_px: None,
2353            new_tp_ord_px: None,
2354            new_tp_trigger_px_type: None,
2355            new_sl_trigger_px: None,
2356            new_sl_ord_px: None,
2357            new_sl_trigger_px_type: None,
2358            new_order_px: Some("59900".to_string()),
2359            new_callback_ratio: Some("0.015".to_string()),
2360            new_callback_spread: Some("100".to_string()),
2361            new_active_px: Some("62000".to_string()),
2362        };
2363
2364        let json = serde_json::to_string(&request).unwrap();
2365
2366        assert!(json.contains("\"instId\":\"BTC-USDT-SWAP\""));
2367        assert!(json.contains("\"algoId\":\"333444\""));
2368        assert!(json.contains("\"algoClOrdId\":\"client789\""));
2369        assert!(json.contains("\"newSz\":\"1.0\""));
2370        assert!(json.contains("\"newTriggerPx\":\"60000\""));
2371        assert!(json.contains("\"newOrdPx\":\"59900\""));
2372        assert!(json.contains("\"newCallbackRatio\":\"0.015\""));
2373        assert!(json.contains("\"newCallbackSpread\":\"100\""));
2374        assert!(json.contains("\"newActivePx\":\"62000\""));
2375    }
2376
2377    #[rstest]
2378    fn test_place_order_request_serializes_px_usd() {
2379        let request = OKXPlaceOrderRequest {
2380            inst_id: "BTC-USD-250328-50000-C".to_string(),
2381            td_mode: OKXTradeMode::Cross,
2382            ccy: None,
2383            cl_ord_id: Some("test-opt-1".to_string()),
2384            tag: None,
2385            side: OKXSide::Buy,
2386            pos_side: Some(OKXPositionSide::Net),
2387            ord_type: OKXOrderType::Limit,
2388            sz: "1".to_string(),
2389            px: None,
2390            px_usd: Some("100.5".to_string()),
2391            px_vol: None,
2392            reduce_only: None,
2393            tgt_ccy: None,
2394            trade_quote_ccy: None,
2395            attach_algo_ords: None,
2396            outcome: None,
2397            slippage_pct: None,
2398            rpi_taker_access: None,
2399            rpi_px_round: None,
2400        };
2401
2402        let json = serde_json::to_string(&request).unwrap();
2403        assert!(json.contains("\"pxUsd\":\"100.5\""));
2404        assert!(!json.contains("\"pxVol\""));
2405        assert!(!json.contains("\"px\":"));
2406        assert!(!json.contains("slippagePct"));
2407    }
2408
2409    #[rstest]
2410    fn test_place_order_request_serializes_px_vol() {
2411        let request = OKXPlaceOrderRequest {
2412            inst_id: "BTC-USD-250328-50000-C".to_string(),
2413            td_mode: OKXTradeMode::Cross,
2414            ccy: None,
2415            cl_ord_id: Some("test-opt-2".to_string()),
2416            tag: None,
2417            side: OKXSide::Buy,
2418            pos_side: Some(OKXPositionSide::Net),
2419            ord_type: OKXOrderType::Limit,
2420            sz: "1".to_string(),
2421            px: None,
2422            px_usd: None,
2423            px_vol: Some("0.55".to_string()),
2424            reduce_only: None,
2425            tgt_ccy: None,
2426            trade_quote_ccy: None,
2427            attach_algo_ords: None,
2428            outcome: None,
2429            slippage_pct: None,
2430            rpi_taker_access: None,
2431            rpi_px_round: None,
2432        };
2433
2434        let json = serde_json::to_string(&request).unwrap();
2435        assert!(json.contains("\"pxVol\":\"0.55\""));
2436        assert!(!json.contains("\"pxUsd\""));
2437        assert!(!json.contains("\"px\":"));
2438    }
2439
2440    #[rstest]
2441    fn test_place_order_request_serializes_slippage_pct() {
2442        let request = OKXPlaceOrderRequest {
2443            inst_id: "BTC-USDT-SWAP".to_string(),
2444            td_mode: OKXTradeMode::Cross,
2445            ccy: None,
2446            cl_ord_id: Some("mkt-slip-1".to_string()),
2447            tag: None,
2448            side: OKXSide::Buy,
2449            pos_side: Some(OKXPositionSide::Net),
2450            ord_type: OKXOrderType::Market,
2451            sz: "1".to_string(),
2452            px: None,
2453            px_usd: None,
2454            px_vol: None,
2455            reduce_only: None,
2456            tgt_ccy: None,
2457            trade_quote_ccy: None,
2458            attach_algo_ords: None,
2459            outcome: None,
2460            slippage_pct: Some("0.005".to_string()),
2461            rpi_taker_access: None,
2462            rpi_px_round: None,
2463        };
2464
2465        let json: serde_json::Value = serde_json::to_value(&request).unwrap();
2466        assert_eq!(json["slippagePct"], "0.005");
2467    }
2468
2469    #[rstest]
2470    fn test_rpi_place_order_request_serialization() {
2471        let request = OKXPlaceOrderRequest {
2472            inst_id: "OMI-USD".to_string(),
2473            td_mode: OKXTradeMode::Cash,
2474            ccy: None,
2475            cl_ord_id: Some("ORPI001".to_string()),
2476            tag: Some("rpi-test".to_string()),
2477            side: OKXSide::Sell,
2478            pos_side: None,
2479            ord_type: OKXOrderType::Rpi,
2480            sz: "250000".to_string(),
2481            px: Some("0.0001600".to_string()),
2482            px_usd: None,
2483            px_vol: None,
2484            reduce_only: Some(false),
2485            tgt_ccy: None,
2486            trade_quote_ccy: None,
2487            attach_algo_ords: None,
2488            outcome: None,
2489            slippage_pct: None,
2490            rpi_taker_access: Some(true),
2491            rpi_px_round: Some(false),
2492        };
2493
2494        let value = serde_json::to_value(request).unwrap();
2495
2496        assert_eq!(
2497            value,
2498            serde_json::json!({
2499                "instId": "OMI-USD",
2500                "tdMode": "cash",
2501                "clOrdId": "ORPI001",
2502                "tag": "rpi-test",
2503                "side": "sell",
2504                "ordType": "rpi",
2505                "sz": "250000",
2506                "px": "0.0001600",
2507                "reduceOnly": false,
2508                "rpiTakerAccess": true,
2509                "rpiPxRound": false
2510            })
2511        );
2512    }
2513
2514    #[rstest]
2515    fn test_rpi_amend_order_request_serialization() {
2516        let request = OKXAmendOrderRequest {
2517            inst_id: "OMI-USD".to_string(),
2518            ord_id: Some("2500000000000000001".to_string()),
2519            cl_ord_id: None,
2520            req_id: Some("RPI-AMEND-1".to_string()),
2521            new_sz: Some("275000".to_string()),
2522            new_px: Some("0.0001599".to_string()),
2523            rpi_taker_access: Some(false),
2524            rpi_px_round: Some(true),
2525        };
2526
2527        let value = serde_json::to_value(request).unwrap();
2528
2529        assert_eq!(
2530            value,
2531            serde_json::json!({
2532                "instId": "OMI-USD",
2533                "ordId": "2500000000000000001",
2534                "reqId": "RPI-AMEND-1",
2535                "newSz": "275000",
2536                "newPx": "0.0001599",
2537                "rpiTakerAccess": false,
2538                "rpiPxRound": true
2539            })
2540        );
2541    }
2542
2543    #[rstest]
2544    fn test_rpi_place_order_response_parsing() {
2545        let response: OKXPlaceOrderResponse = serde_json::from_value(serde_json::json!({
2546            "ordId": "2500000000000000001",
2547            "clOrdId": "ORPI001",
2548            "tag": "rpi-test",
2549            "instId": "OMI-USD",
2550            "side": "sell",
2551            "ordType": "rpi",
2552            "sz": "250000",
2553            "state": "live",
2554            "px": "0.0001600",
2555            "avgPx": "",
2556            "sCode": "0",
2557            "sMsg": ""
2558        }))
2559        .unwrap();
2560
2561        assert_eq!(response.ord_id, Some(Ustr::from("2500000000000000001")));
2562        assert_eq!(response.cl_ord_id, Some(Ustr::from("ORPI001")));
2563        assert_eq!(response.tag.as_deref(), Some("rpi-test"));
2564        assert_eq!(response.inst_id, Some(Ustr::from("OMI-USD")));
2565        assert_eq!(response.side, Some(OKXSide::Sell));
2566        assert_eq!(response.ord_type, Some(OKXOrderType::Rpi));
2567        assert_eq!(response.sz.as_deref(), Some("250000"));
2568        assert_eq!(response.state, Some(OKXOrderStatus::Live));
2569        assert_eq!(response.px.as_deref(), Some("0.0001600"));
2570        assert_eq!(response.avg_px.as_deref(), Some(""));
2571        assert_eq!(response.s_code.as_deref(), Some("0"));
2572        assert_eq!(response.s_msg.as_deref(), Some(""));
2573    }
2574
2575    #[rstest]
2576    fn test_event_contract_models_deserialize() {
2577        let series: OKXEventContractSeries = serde_json::from_value(serde_json::json!({
2578            "seriesId": "BTC-ABOVE-DAILY",
2579            "freq": "daily",
2580            "title": "BTC above daily",
2581            "category": "1",
2582            "settlement": {
2583                "method": "cash",
2584                "closeEarly": false,
2585                "srcName": "OKX BTC/USD Index",
2586                "underlying": "BTC-USD"
2587            }
2588        }))
2589        .unwrap();
2590        let event: OKXEventContractEvent = serde_json::from_value(serde_json::json!({
2591            "seriesId": "BTC-ABOVE-DAILY",
2592            "eventId": "BTC-ABOVE-DAILY-260224-1600",
2593            "fixTime": "",
2594            "expTime": "1769697132335",
2595            "state": "live"
2596        }))
2597        .unwrap();
2598        let market: OKXEventContractMarket = serde_json::from_value(serde_json::json!({
2599            "seriesId": "BTC-ABOVE-DAILY",
2600            "eventId": "BTC-ABOVE-DAILY-260224-1600",
2601            "instId": "BTC-ABOVE-DAILY-260224-1600-65000",
2602            "listTime": "1769697132335",
2603            "fixTime": "",
2604            "expTime": "1769697132335",
2605            "state": "live",
2606            "disputed": false,
2607            "outcome": "0",
2608            "floorStrike": "120000",
2609            "capStrike": "INF",
2610            "settleValue": "",
2611            "hitDir": ""
2612        }))
2613        .unwrap();
2614
2615        assert_eq!(series.series_id, "BTC-ABOVE-DAILY");
2616        assert_eq!(series.settlement.underlying, "BTC-USD");
2617        assert_eq!(event.fix_time, None);
2618        assert_eq!(event.exp_time, Some(1_769_697_132_335));
2619        assert_eq!(
2620            market.inst_id,
2621            Ustr::from("BTC-ABOVE-DAILY-260224-1600-65000")
2622        );
2623        assert_eq!(market.list_time, Some(1_769_697_132_335));
2624        assert_eq!(market.exp_time, Some(1_769_697_132_335));
2625        assert_eq!(market.outcome, "0");
2626        assert_eq!(market.cap_strike, "INF");
2627        assert_eq!(market.hit_dir, "");
2628
2629        let serialized = serde_json::to_value(&market).unwrap();
2630        assert_eq!(serialized["capStrike"], "INF");
2631        assert_eq!(serialized["hitDir"], "");
2632    }
2633
2634    #[rstest]
2635    fn test_event_contract_models_accept_missing_optional_fields() {
2636        let series: OKXEventContractSeries = serde_json::from_value(serde_json::json!({
2637            "seriesId": "BTC-ABOVE-DAILY"
2638        }))
2639        .unwrap();
2640        let event: OKXEventContractEvent = serde_json::from_value(serde_json::json!({
2641            "seriesId": "BTC-ABOVE-DAILY",
2642            "eventId": "BTC-ABOVE-DAILY-260224-1600",
2643            "fixTime": "",
2644            "expTime": "",
2645            "state": "live"
2646        }))
2647        .unwrap();
2648        let market: OKXEventContractMarket = serde_json::from_value(serde_json::json!({
2649            "seriesId": "BTC-ABOVE-DAILY",
2650            "eventId": "BTC-ABOVE-DAILY-260224-1600",
2651            "instId": "BTC-ABOVE-DAILY-260224-1600-65000",
2652            "listTime": "",
2653            "fixTime": "",
2654            "expTime": "",
2655            "state": "live",
2656            "disputed": false,
2657            "outcome": "0",
2658            "floorStrike": "120000",
2659            "settleValue": ""
2660        }))
2661        .unwrap();
2662
2663        assert_eq!(series.freq, "");
2664        assert_eq!(series.settlement.underlying, "");
2665        assert_eq!(event.exp_time, None);
2666        assert_eq!(market.list_time, None);
2667        assert_eq!(market.exp_time, None);
2668        assert_eq!(market.cap_strike, "");
2669        assert_eq!(market.hit_dir, "");
2670    }
2671
2672    #[rstest]
2673    fn test_place_order_request_serializes_event_contract_fields() {
2674        let request = OKXPlaceOrderRequest {
2675            inst_id: "BTC-ABOVE-DAILY-260224-1600-65000".to_string(),
2676            td_mode: OKXTradeMode::Cash,
2677            ccy: None,
2678            cl_ord_id: Some("event-1".to_string()),
2679            tag: None,
2680            side: OKXSide::Buy,
2681            pos_side: None,
2682            ord_type: OKXOrderType::Limit,
2683            sz: "10".to_string(),
2684            px: Some("0.42".to_string()),
2685            px_usd: None,
2686            px_vol: None,
2687            reduce_only: None,
2688            tgt_ccy: None,
2689            trade_quote_ccy: None,
2690            attach_algo_ords: None,
2691            outcome: Some("yes".to_string()),
2692            slippage_pct: None,
2693            rpi_taker_access: None,
2694            rpi_px_round: None,
2695        };
2696
2697        let json: serde_json::Value = serde_json::to_value(&request).unwrap();
2698
2699        assert!(json.get("speedBump").is_none());
2700        assert_eq!(json["outcome"], "yes");
2701        assert!(json.get("tradeQuoteCcy").is_none());
2702    }
2703
2704    #[rstest]
2705    fn test_place_order_request_serializes_trade_quote_ccy_usd() {
2706        let request = OKXPlaceOrderRequest {
2707            inst_id: "BTC-USDC".to_string(),
2708            td_mode: OKXTradeMode::Cash,
2709            ccy: None,
2710            cl_ord_id: Some("usd-quote-1".to_string()),
2711            tag: None,
2712            side: OKXSide::Buy,
2713            pos_side: None,
2714            ord_type: OKXOrderType::Limit,
2715            sz: "0.01".to_string(),
2716            px: Some("100000".to_string()),
2717            px_usd: None,
2718            px_vol: None,
2719            reduce_only: None,
2720            tgt_ccy: None,
2721            trade_quote_ccy: Some(Ustr::from("USD")),
2722            attach_algo_ords: None,
2723            outcome: None,
2724            slippage_pct: None,
2725            rpi_taker_access: None,
2726            rpi_px_round: None,
2727        };
2728
2729        let json: serde_json::Value = serde_json::to_value(&request).unwrap();
2730        assert_eq!(json["instId"], "BTC-USDC");
2731        assert_eq!(json["tradeQuoteCcy"], "USD");
2732    }
2733
2734    #[rstest]
2735    fn test_place_order_request_omits_trade_quote_ccy_when_unset() {
2736        let request = OKXPlaceOrderRequest {
2737            inst_id: "BTC-USDC".to_string(),
2738            td_mode: OKXTradeMode::Cash,
2739            ccy: None,
2740            cl_ord_id: Some("usdc-default-1".to_string()),
2741            tag: None,
2742            side: OKXSide::Buy,
2743            pos_side: None,
2744            ord_type: OKXOrderType::Limit,
2745            sz: "0.01".to_string(),
2746            px: Some("100000".to_string()),
2747            px_usd: None,
2748            px_vol: None,
2749            reduce_only: None,
2750            tgt_ccy: None,
2751            trade_quote_ccy: None,
2752            attach_algo_ords: None,
2753            outcome: None,
2754            slippage_pct: None,
2755            rpi_taker_access: None,
2756            rpi_px_round: None,
2757        };
2758
2759        let json = serde_json::to_string(&request).unwrap();
2760        assert!(!json.contains("tradeQuoteCcy"));
2761    }
2762
2763    #[rstest]
2764    fn test_attach_algo_ord_request_serializes_trailing_fields() {
2765        let request = OKXAttachAlgoOrdRequest {
2766            attach_algo_cl_ord_id: Some("trail-1".to_string()),
2767            sl_trigger_px: None,
2768            sl_ord_px: None,
2769            sl_trigger_px_type: None,
2770            tp_trigger_px: None,
2771            tp_ord_px: None,
2772            tp_trigger_px_type: None,
2773            callback_ratio: Some("0.01".to_string()),
2774            callback_spread: None,
2775            active_px: Some("64000".to_string()),
2776            new_callback_ratio: Some("0.02".to_string()),
2777            new_callback_spread: Some("25".to_string()),
2778            new_active_px: Some("65000".to_string()),
2779        };
2780
2781        let json: serde_json::Value = serde_json::to_value(&request).unwrap();
2782
2783        assert_eq!(json["callbackRatio"], "0.01");
2784        assert_eq!(json["activePx"], "64000");
2785        assert_eq!(json["newCallbackRatio"], "0.02");
2786        assert_eq!(json["newCallbackSpread"], "25");
2787        assert_eq!(json["newActivePx"], "65000");
2788        assert!(json.get("callbackSpread").is_none());
2789    }
2790
2791    #[rstest]
2792    fn test_place_order_response_deserializes_sub_code() {
2793        let response: OKXPlaceOrderResponse = serde_json::from_value(serde_json::json!({
2794            "ordId": "",
2795            "clOrdId": "event-1",
2796            "sCode": "51000",
2797            "sMsg": "Parameter error",
2798            "subCode": "51004"
2799        }))
2800        .unwrap();
2801
2802        assert_eq!(response.cl_ord_id, Some(Ustr::from("event-1")));
2803        assert_eq!(response.s_code, Some("51000".to_string()));
2804        assert_eq!(response.sub_code, Some("51004".to_string()));
2805    }
2806
2807    #[rstest]
2808    fn test_fee_rate_deserializes_settle() {
2809        let fee_rate: OKXFeeRate = serde_json::from_value(serde_json::json!({
2810            "level": "VIP1",
2811            "taker": "-0.0005",
2812            "maker": "-0.0002",
2813            "takerU": "-0.0005",
2814            "makerU": "-0.0002",
2815            "settle": "-0.001",
2816            "instType": "EVENTS",
2817            "category": "1",
2818            "ts": "1769697132335"
2819        }))
2820        .unwrap();
2821
2822        assert_eq!(fee_rate.settle, "-0.001");
2823        assert_eq!(fee_rate.inst_type, OKXInstrumentType::Events);
2824    }
2825
2826    #[rstest]
2827    #[case::missing(None, None)]
2828    #[case::empty(Some(""), None)]
2829    #[case::zero(Some("0"), Some(Decimal::ZERO))]
2830    #[case::commission(Some("-0.001"), Some(Decimal::from_str_exact("-0.001").unwrap()))]
2831    #[case::rebate(Some("0.0002"), Some(Decimal::from_str_exact("0.0002").unwrap()))]
2832    fn test_fee_group_rates_preserve_unavailable_zero_and_signs(
2833        #[case] value: Option<&str>,
2834        #[case] expected: Option<Decimal>,
2835    ) {
2836        let mut payload = serde_json::json!({"groupId": "1"});
2837
2838        if let Some(value) = value {
2839            for field in ["maker", "taker", "rpiMaker", "elpMaker"] {
2840                payload[field] = serde_json::json!(value);
2841            }
2842        }
2843
2844        let group: OKXFeeGroup = serde_json::from_value(payload).unwrap();
2845
2846        assert_eq!(group.group_id, "1");
2847        assert_eq!(group.maker, expected);
2848        assert_eq!(group.taker, expected);
2849        assert_eq!(group.rpi_maker, expected);
2850        assert_eq!(group.elp_maker, expected);
2851    }
2852
2853    #[rstest]
2854    #[case::rpi_only(Some("-0.00015"), None)]
2855    #[case::elp_only(None, Some("-0.00016"))]
2856    #[case::both_equal(Some("-0.00015"), Some("-0.00015"))]
2857    #[case::both_distinct(Some("-0.00015"), Some("-0.00016"))]
2858    fn test_fee_group_preserves_rpi_and_elp_independently(
2859        #[case] rpi: Option<&str>,
2860        #[case] elp: Option<&str>,
2861    ) {
2862        let mut payload = serde_json::json!({"groupId": "1"});
2863        if let Some(value) = rpi {
2864            payload["rpiMaker"] = serde_json::json!(value);
2865        }
2866
2867        if let Some(value) = elp {
2868            payload["elpMaker"] = serde_json::json!(value);
2869        }
2870
2871        let group: OKXFeeGroup = serde_json::from_value(payload).unwrap();
2872
2873        assert_eq!(
2874            group.rpi_maker,
2875            rpi.map(|value| Decimal::from_str_exact(value).unwrap())
2876        );
2877        assert_eq!(
2878            group.elp_maker,
2879            elp.map(|value| Decimal::from_str_exact(value).unwrap())
2880        );
2881    }
2882
2883    #[rstest]
2884    fn test_fee_rate_preserves_legacy_scalars_alongside_groups() {
2885        let fee_rate: OKXFeeRate = serde_json::from_value(serde_json::json!({
2886            "level": "Lv1",
2887            "maker": "-0.0008",
2888            "taker": "-0.001",
2889            "makerU": "",
2890            "takerU": "0",
2891            "feeGroup": [{"groupId": "1", "maker": "0.0002", "taker": "-0.0005"}],
2892            "instType": "SPOT",
2893            "ts": "1763979985847"
2894        }))
2895        .unwrap();
2896
2897        assert_eq!(fee_rate.maker, "-0.0008");
2898        assert_eq!(fee_rate.taker, "-0.001");
2899        assert_eq!(fee_rate.maker_u, "");
2900        assert_eq!(fee_rate.taker_u, "0");
2901        assert_eq!(fee_rate.fee_group.len(), 1);
2902        assert_eq!(
2903            fee_rate.fee_group[0].maker,
2904            Some(Decimal::from_str_exact("0.0002").unwrap())
2905        );
2906        assert_eq!(
2907            fee_rate.fee_group[0].taker,
2908            Some(Decimal::from_str_exact("-0.0005").unwrap())
2909        );
2910    }
2911
2912    #[rstest]
2913    fn test_fee_rate_missing_scalars_do_not_imply_zero() {
2914        let fee_rate: OKXFeeRate = serde_json::from_value(serde_json::json!({
2915            "level": "Lv1",
2916            "instType": "SPOT",
2917            "ts": "1763979985847"
2918        }))
2919        .unwrap();
2920
2921        assert!(fee_rate.maker.is_empty());
2922        assert!(fee_rate.taker.is_empty());
2923        assert!(fee_rate.maker_u.is_empty());
2924        assert!(fee_rate.taker_u.is_empty());
2925        assert!(fee_rate.fee_group.is_empty());
2926    }
2927
2928    #[rstest]
2929    fn test_fee_group_rejects_invalid_rates(
2930        #[values("maker", "taker", "rpiMaker", "elpMaker")] field: &str,
2931    ) {
2932        let mut payload = serde_json::json!({"groupId": "1"});
2933        payload[field] = serde_json::json!("invalid");
2934
2935        assert!(serde_json::from_value::<OKXFeeGroup>(payload).is_err());
2936    }
2937
2938    #[rstest]
2939    fn test_rpi_maker_fee_rate_deserializes_decimal_and_legacy_alias() {
2940        let rpi: OKXFeeRate = serde_json::from_value(serde_json::json!({
2941            "level": "VIP1",
2942            "taker": "-0.0005",
2943            "maker": "-0.0002",
2944            "takerU": "-0.0005",
2945            "makerU": "-0.0002",
2946            "rpiMaker": "-0.00015",
2947            "instType": "SPOT",
2948            "category": "1",
2949            "ts": "1785406500000"
2950        }))
2951        .unwrap();
2952        let legacy: OKXFeeRate = serde_json::from_value(serde_json::json!({
2953            "level": "VIP1",
2954            "taker": "-0.0005",
2955            "maker": "-0.0002",
2956            "takerU": "-0.0005",
2957            "makerU": "-0.0002",
2958            "elpMaker": "-0.00016",
2959            "instType": "SPOT",
2960            "category": "1",
2961            "ts": "1785406500001"
2962        }))
2963        .unwrap();
2964        let not_applicable: OKXFeeRate = serde_json::from_value(serde_json::json!({
2965            "level": "VIP1",
2966            "taker": "-0.0005",
2967            "maker": "-0.0002",
2968            "takerU": "-0.0005",
2969            "makerU": "-0.0002",
2970            "rpiMaker": "",
2971            "instType": "OPTION",
2972            "category": "1",
2973            "ts": "1785406500002"
2974        }))
2975        .unwrap();
2976
2977        assert_eq!(
2978            rpi.rpi_maker,
2979            Some(Decimal::from_str_exact("-0.00015").unwrap())
2980        );
2981        assert_eq!(
2982            legacy.rpi_maker,
2983            Some(Decimal::from_str_exact("-0.00016").unwrap())
2984        );
2985        assert_eq!(not_applicable.rpi_maker, None);
2986    }
2987}