Skip to main content

nautilus_okx/http/
query.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Request parameter structures for the OKX **v5 REST API**.
17//!
18//! Each struct corresponds 1-to-1 with an OKX REST endpoint and is annotated
19//! using `serde` so that it can be serialized directly into the query string
20//! or request body expected by the exchange.
21//!
22//! The inline documentation repeats the required/optional fields described in
23//! the [official OKX documentation](https://www.okx.com/docs-v5/en/) and, where
24//! beneficial, links to the exact endpoint section.  All links point to the
25//! English version.
26//!
27//! Parameter structs are built using the builder pattern and then passed to
28//! `OKXHttpClient::get`/`post` where they are automatically serialized.
29
30use derive_builder::Builder;
31use serde::{self, Deserialize, Serialize};
32
33use crate::{
34    common::enums::{
35        OKXAlgoOrderStatus, OKXAlgoOrderType, OKXInstrumentType, OKXOrderStatus, OKXOrderType,
36        OKXPositionMode, OKXPositionSide, OKXTradeMode,
37    },
38    http::error::BuildError,
39};
40
41/// Parameters for the POST /api/v5/account/set-position-mode endpoint.
42#[derive(Clone, Debug, Deserialize, Serialize, Builder)]
43#[builder(setter(into, strip_option))]
44#[serde(rename_all = "camelCase")]
45pub struct SetPositionModeParams {
46    /// Position mode: "`net_mode`" or "`long_short_mode`".
47    #[serde(rename = "posMode")]
48    pub pos_mode: OKXPositionMode,
49}
50
51/// Parameters for the POST /api/v5/account/activate-feature endpoint.
52#[derive(Clone, Debug, Deserialize, Serialize, Builder)]
53#[builder(setter(into, strip_option))]
54#[serde(rename_all = "camelCase")]
55pub struct ActivateFeatureParams {
56    /// Feature to activate. `1` enables USDC order book trading.
57    pub feature: String,
58}
59
60/// Parameters for the GET /api/v5/public/position-tiers endpoint.
61#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
62#[builder(default)]
63#[builder(setter(into, strip_option))]
64#[serde(rename_all = "camelCase")]
65pub struct GetPositionTiersParams {
66    /// Instrument type: MARGIN, SWAP, FUTURES, OPTION.
67    pub inst_type: OKXInstrumentType,
68    /// Trading mode, valid values: cross, isolated.
69    pub td_mode: OKXTradeMode,
70    /// Underlying, required for SWAP/FUTURES/OPTION
71    /// Single underlying or multiple underlyings (no more than 3) separated with comma.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub uly: Option<String>,
74    /// Instrument family, required for SWAP/FUTURES/OPTION
75    /// Single instrument family or multiple families (no more than 5) separated with comma.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub inst_family: Option<String>,
78    /// Specific instrument ID.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub inst_id: Option<String>,
81    /// Margin currency, only applicable to cross MARGIN.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub ccy: Option<String>,
84    /// Tiers.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub tier: Option<String>,
87}
88
89/// Parameters for the public and account instrument endpoints.
90#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
91#[builder(default)]
92#[builder(setter(into, strip_option))]
93#[serde(rename_all = "camelCase")]
94pub struct GetInstrumentsParams {
95    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION.
96    pub inst_type: OKXInstrumentType,
97    /// Underlying. Only applicable to FUTURES/SWAP/OPTION.
98    /// If instType is OPTION, either uly or instFamily is required.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub uly: Option<String>,
101    /// Instrument family. Only applicable to FUTURES/SWAP/OPTION.
102    /// If instType is OPTION, either uly or instFamily is required.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub inst_family: Option<String>,
105    /// Instrument ID, e.g. BTC-USD-SWAP.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub inst_id: Option<String>,
108    /// Series ID. Required when `inst_type` is EVENTS.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub series_id: Option<String>,
111}
112
113/// Parameters for the GET /api/v5/sprd/spreads endpoint.
114#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
115#[builder(default)]
116#[builder(setter(into, strip_option))]
117#[serde(rename_all = "camelCase")]
118pub struct GetSpreadsParams {
119    /// Currency the spread is based in, e.g. BTC or ETH.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub base_ccy: Option<String>,
122    /// Instrument ID to include in the spread.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub inst_id: Option<String>,
125    /// Spread ID.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub sprd_id: Option<String>,
128    /// Spread state: live, suspend, or expired.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub state: Option<String>,
131}
132
133/// Parameters for the GET /api/v5/sprd/order endpoint.
134#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
135#[builder(default)]
136#[builder(setter(into, strip_option))]
137#[serde(rename_all = "camelCase")]
138pub struct GetSpreadOrderParams {
139    /// Exchange-assigned order ID.
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub ord_id: Option<String>,
142    /// User-assigned client order ID.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub cl_ord_id: Option<String>,
145}
146
147/// Parameters for the GET /api/v5/sprd/orders-pending and orders-history endpoints.
148#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
149#[builder(default)]
150#[builder(setter(into, strip_option))]
151#[serde(rename_all = "camelCase")]
152pub struct GetSpreadOrdersParams {
153    /// Spread ID.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub sprd_id: Option<String>,
156    /// Order type filter.
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub ord_type: Option<OKXOrderType>,
159    /// Order state filter.
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub state: Option<OKXOrderStatus>,
162    /// Start order ID cursor.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub begin_id: Option<String>,
165    /// End order ID cursor.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub end_id: Option<String>,
168    /// Start timestamp in milliseconds.
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub begin: Option<String>,
171    /// End timestamp in milliseconds.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub end: Option<String>,
174    /// Maximum number of records to return.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub limit: Option<u32>,
177}
178
179/// Parameters for the GET /api/v5/sprd/trades endpoint.
180#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
181#[builder(default)]
182#[builder(setter(into, strip_option))]
183#[serde(rename_all = "camelCase")]
184pub struct GetSpreadTradesParams {
185    /// Spread ID.
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub sprd_id: Option<String>,
188    /// Trade ID filter.
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub trade_id: Option<String>,
191    /// Order ID filter.
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub ord_id: Option<String>,
194    /// Start ID cursor.
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub begin_id: Option<String>,
197    /// End ID cursor.
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub end_id: Option<String>,
200    /// Start timestamp in milliseconds.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub begin: Option<String>,
203    /// End timestamp in milliseconds.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub end: Option<String>,
206    /// Maximum number of records to return.
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub limit: Option<u32>,
209}
210
211/// Parameters for the GET /api/v5/public/event-contract/series endpoint.
212#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
213#[builder(default)]
214#[builder(setter(into, strip_option))]
215#[serde(rename_all = "camelCase")]
216pub struct GetEventContractSeriesParams {
217    /// Series ID, e.g. BTC-ABOVE-DAILY. If absent, all series are returned.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub series_id: Option<String>,
220}
221
222/// Parameters for the GET /api/v5/public/event-contract/events endpoint.
223#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
224#[builder(default)]
225#[builder(setter(into, strip_option))]
226#[serde(rename_all = "camelCase")]
227pub struct GetEventContractEventsParams {
228    /// Series ID, e.g. BTC-ABOVE-DAILY.
229    pub series_id: String,
230    /// Event ID.
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub event_id: Option<String>,
233    /// Event state filter: preopen, live, settling, or expired.
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub state: Option<String>,
236    /// Maximum number of records to return.
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub limit: Option<String>,
239    /// Pagination cursor. Returns records newer than this expiry time.
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub before: Option<String>,
242    /// Pagination cursor. Returns records older than this expiry time.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub after: Option<String>,
245}
246
247/// Parameters for the GET /api/v5/public/event-contract/markets endpoint.
248#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
249#[builder(default)]
250#[builder(setter(into, strip_option))]
251#[serde(rename_all = "camelCase")]
252pub struct GetEventContractMarketsParams {
253    /// Series ID, e.g. BTC-ABOVE-DAILY.
254    pub series_id: String,
255    /// Event ID.
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub event_id: Option<String>,
258    /// Instrument ID.
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub inst_id: Option<String>,
261    /// Market state filter: preopen, live, settling, or expired.
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub state: Option<String>,
264    /// Maximum number of records to return.
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub limit: Option<String>,
267    /// Pagination cursor. Returns records newer than this expiry time.
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub before: Option<String>,
270    /// Pagination cursor. Returns records older than this expiry time.
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub after: Option<String>,
273}
274
275/// Parameters for the GET /api/v5/public/opt-summary endpoint.
276#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
277#[builder(default)]
278#[builder(setter(into, strip_option))]
279#[serde(rename_all = "camelCase")]
280pub struct GetOptionSummaryParams {
281    /// Instrument family. Only applicable to OPTION.
282    pub inst_family: String,
283    /// Contract expiry date in YYMMDD format, e.g. "250328".
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub exp_time: Option<String>,
286}
287
288/// Parameters for the GET /api/v5/market/history-trades endpoint.
289#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
290#[builder(default)]
291#[builder(setter(into, strip_option))]
292#[serde(rename_all = "camelCase")]
293pub struct GetTradesParams {
294    /// Instrument ID, e.g. "BTC-USDT".
295    pub inst_id: String,
296    /// Pagination type: 1 = trade ID (default), 2 = timestamp.
297    #[serde(rename = "type")]
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub pagination_type: Option<u8>,
300    /// Pagination: fetch records after this cursor (trade ID or timestamp).
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub after: Option<String>,
303    /// Pagination: fetch records before this cursor (trade ID or timestamp).
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub before: Option<String>,
306    /// Maximum number of records to return (default 100, max 1000).
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub limit: Option<u32>,
309}
310
311/// Parameters for the GET /api/v5/market/history-candles endpoint.
312#[derive(Clone, Debug, Deserialize, Serialize)]
313#[serde(rename_all = "camelCase")]
314pub struct GetCandlesticksParams {
315    /// Instrument ID, e.g. "BTC-USDT".
316    pub inst_id: String,
317    /// Time interval, e.g. "1m", "5m", "1H".
318    pub bar: String,
319    /// Pagination: fetch records after this timestamp (milliseconds).
320    #[serde(skip_serializing_if = "Option::is_none")]
321    #[serde(rename = "after")]
322    pub after_ms: Option<i64>,
323    /// Pagination: fetch records before this timestamp (milliseconds).
324    #[serde(skip_serializing_if = "Option::is_none")]
325    #[serde(rename = "before")]
326    pub before_ms: Option<i64>,
327    /// Maximum number of records to return (default 100, max 300 for regular candles, max 100 for history).
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub limit: Option<u32>,
330}
331
332/// Builder for `GetCandlesticksParams` with validation.
333#[derive(Debug, Default)]
334pub struct GetCandlesticksParamsBuilder {
335    inst_id: Option<String>,
336    bar: Option<String>,
337    after_ms: Option<i64>,
338    before_ms: Option<i64>,
339    limit: Option<u32>,
340}
341
342impl GetCandlesticksParamsBuilder {
343    /// Sets the instrument ID.
344    pub fn inst_id(&mut self, inst_id: impl Into<String>) -> &mut Self {
345        self.inst_id = Some(inst_id.into());
346        self
347    }
348
349    /// Sets the bar interval.
350    pub fn bar(&mut self, bar: impl Into<String>) -> &mut Self {
351        self.bar = Some(bar.into());
352        self
353    }
354
355    /// Sets the after timestamp (milliseconds).
356    pub fn after_ms(&mut self, after_ms: i64) -> &mut Self {
357        self.after_ms = Some(after_ms);
358        self
359    }
360
361    /// Sets the before timestamp (milliseconds).
362    pub fn before_ms(&mut self, before_ms: i64) -> &mut Self {
363        self.before_ms = Some(before_ms);
364        self
365    }
366
367    /// Sets the limit.
368    pub fn limit(&mut self, limit: u32) -> &mut Self {
369        self.limit = Some(limit);
370        self
371    }
372
373    /// Builds the parameters with embedded invariant validation.
374    ///
375    /// # Errors
376    ///
377    /// Returns an error if the parameters are invalid.
378    pub fn build(&mut self) -> Result<GetCandlesticksParams, BuildError> {
379        // Extract values from builder
380        let inst_id = self.inst_id.clone().ok_or(BuildError::MissingInstId)?;
381        let bar = self.bar.clone().ok_or(BuildError::MissingBar)?;
382        let after_ms = self.after_ms;
383        let before_ms = self.before_ms;
384        let limit = self.limit;
385
386        // ───────── Both cursors validation
387        // Note: OKX DOES support both 'after' and 'before' together for time range queries
388        // They can only NOT be used together when one is a pagination cursor
389        // For now, we allow both and let the API validate
390        // if after_ms.is_some() && before_ms.is_some() {
391        //     return Err(BuildError::BothCursors);
392        // }
393
394        // ───────── Cursor chronological validation
395        // IMPORTANT: OKX has counter-intuitive parameter semantics:
396        // - before_ms is the START time (lower bound, older) - returns bars > before
397        // - after_ms is the END time (upper bound, newer) - returns bars < after
398        // Therefore: before_ms < after_ms for valid time ranges
399        if let (Some(after), Some(before)) = (after_ms, before_ms)
400            && before >= after
401        {
402            return Err(BuildError::InvalidTimeRange {
403                after_ms: after,
404                before_ms: before,
405            });
406        }
407
408        // ───────── Cursor unit (≤ 13 digits ⇒ milliseconds)
409        if let Some(nanos) = after_ms
410            && nanos.abs() > 9_999_999_999_999
411        {
412            return Err(BuildError::CursorIsNanoseconds);
413        }
414
415        if let Some(nanos) = before_ms
416            && nanos.abs() > 9_999_999_999_999
417        {
418            return Err(BuildError::CursorIsNanoseconds);
419        }
420
421        // ───────── Limit validation
422        // Note: Regular endpoint supports up to 300, history endpoint up to 100
423        // This validation is conservative for safety across both endpoints
424        if let Some(limit) = limit
425            && limit > 300
426        {
427            return Err(BuildError::LimitTooHigh);
428        }
429
430        Ok(GetCandlesticksParams {
431            inst_id,
432            bar,
433            after_ms,
434            before_ms,
435            limit,
436        })
437    }
438}
439
440/// Parameters for the GET /api/v5/public/mark-price.
441#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
442#[builder(default)]
443#[builder(setter(into, strip_option))]
444#[serde(rename_all = "camelCase")]
445pub struct GetMarkPriceParams {
446    /// Instrument type: MARGIN, SWAP, FUTURES, OPTION.
447    pub inst_type: OKXInstrumentType,
448    /// Underlying, required for SWAP/FUTURES/OPTION
449    /// Single underlying or multiple underlyings (no more than 3) separated with comma.
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub uly: Option<String>,
452    /// Instrument family, required for SWAP/FUTURES/OPTION
453    /// Single instrument family or multiple families (no more than 5) separated with comma.
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub inst_family: Option<String>,
456    /// Specific instrument ID.
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub inst_id: Option<String>,
459}
460
461/// Parameters for the GET /api/v5/public/price-limit endpoint.
462#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
463#[builder(default)]
464#[builder(setter(into, strip_option))]
465#[serde(rename_all = "camelCase")]
466pub struct GetPriceLimitParams {
467    /// Instrument ID, e.g. "BTC-USDT-SWAP".
468    pub inst_id: String,
469}
470
471/// Parameters for the GET /api/v5/market/index-tickers.
472#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
473#[builder(default)]
474#[builder(setter(into, strip_option))]
475#[serde(rename_all = "camelCase")]
476pub struct GetIndexTickerParams {
477    /// Specific instrument ID.
478    #[serde(skip_serializing_if = "Option::is_none")]
479    pub inst_id: Option<String>,
480    /// Quote currency.
481    #[serde(skip_serializing_if = "Option::is_none")]
482    pub quote_ccy: Option<String>,
483}
484
485/// Parameters for the GET /api/v5/market/books endpoint.
486#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
487#[builder(default)]
488#[builder(setter(into, strip_option))]
489#[serde(rename_all = "camelCase")]
490pub struct GetOrderBookParams {
491    /// Instrument ID, e.g. "BTC-USDT-SWAP".
492    pub inst_id: String,
493    /// Order book depth per side. Maximum 400, default 1.
494    #[serde(skip_serializing_if = "Option::is_none")]
495    pub sz: Option<u32>,
496}
497
498/// Parameters for the GET /api/v5/market/books-rpi endpoint.
499#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
500#[builder(default)]
501#[builder(setter(into, strip_option))]
502#[serde(rename_all = "camelCase")]
503pub struct GetRpiOrderBookParams {
504    /// Instrument ID, e.g. "BTC-USDT".
505    pub inst_id: String,
506    /// Order book depth per side. Maximum 400, default 1.
507    #[serde(skip_serializing_if = "Option::is_none")]
508    pub sz: Option<u32>,
509}
510
511/// Parameters for the GET /api/v5/public/funding-rate-history endpoint.
512#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
513#[builder(default)]
514#[builder(setter(into, strip_option))]
515#[serde(rename_all = "camelCase")]
516pub struct GetFundingRateHistoryParams {
517    /// Instrument ID, e.g. "BTC-USDT-SWAP".
518    pub inst_id: String,
519    /// Pagination: records newer than this timestamp (ms).
520    #[serde(skip_serializing_if = "Option::is_none")]
521    pub before: Option<String>,
522    /// Pagination: records older than this timestamp (ms).
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub after: Option<String>,
525    /// Number of results per request (default 100, max 100).
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub limit: Option<u32>,
528}
529
530/// Parameters for the GET /api/v5/trade/order-history endpoint.
531#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
532#[builder(default)]
533#[builder(setter(into, strip_option))]
534#[serde(rename_all = "camelCase")]
535pub struct GetOrderHistoryParams {
536    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION.
537    pub inst_type: OKXInstrumentType,
538    /// Underlying, for FUTURES, SWAP, OPTION (optional).
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub uly: Option<String>,
541    /// Instrument family, for FUTURES, SWAP, OPTION (optional).
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub inst_family: Option<String>,
544    /// Instrument ID, e.g. "BTC-USD-SWAP" (optional).
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub inst_id: Option<String>,
547    /// Order type: limit, market, `post_only`, fok, ioc (optional).
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub ord_type: Option<OKXOrderType>,
550    /// Order state: live, filled, canceled (optional).
551    #[serde(skip_serializing_if = "Option::is_none")]
552    pub state: Option<String>,
553    /// Pagination parameter: fetch records after this order ID or timestamp (optional).
554    #[serde(skip_serializing_if = "Option::is_none")]
555    pub after: Option<String>,
556    /// Pagination parameter: fetch records before this order ID or timestamp (optional).
557    #[serde(skip_serializing_if = "Option::is_none")]
558    pub before: Option<String>,
559    /// Maximum number of records to return (default 100, max 100) (optional).
560    #[serde(skip_serializing_if = "Option::is_none")]
561    pub limit: Option<u32>,
562}
563
564/// Parameters for the GET /api/v5/trade/orders-pending endpoint.
565#[derive(Clone, Debug, Default, Deserialize, Serialize, Builder)]
566#[builder(default)]
567#[builder(setter(into, strip_option))]
568#[serde(rename_all = "camelCase")]
569pub struct GetOrderListParams {
570    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION.
571    #[serde(skip_serializing_if = "Option::is_none")]
572    pub inst_type: Option<OKXInstrumentType>,
573    /// Instrument ID, e.g. "BTC-USDT" (optional).
574    #[serde(skip_serializing_if = "Option::is_none")]
575    pub inst_id: Option<String>,
576    /// Instrument family, e.g. "BTC-USD" (optional).
577    #[serde(skip_serializing_if = "Option::is_none")]
578    pub inst_family: Option<String>,
579    /// State to filter for (optional).
580    #[serde(skip_serializing_if = "Option::is_none")]
581    pub state: Option<OKXOrderStatus>,
582    /// Pagination - fetch records **after** this order ID (optional).
583    #[serde(skip_serializing_if = "Option::is_none")]
584    pub after: Option<String>,
585    /// Pagination - fetch records **before** this order ID (optional).
586    #[serde(skip_serializing_if = "Option::is_none")]
587    pub before: Option<String>,
588    /// Number of results per request (default 100, max 100).
589    #[serde(skip_serializing_if = "Option::is_none")]
590    pub limit: Option<u32>,
591}
592
593/// Parameters for the GET /api/v5/trade/order-algo endpoint.
594#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
595#[builder(default)]
596#[builder(setter(into, strip_option))]
597#[serde(rename_all = "camelCase")]
598pub struct GetAlgoOrderParams {
599    /// Algo order identifier assigned by OKX (optional).
600    #[serde(rename = "algoId", skip_serializing_if = "Option::is_none")]
601    pub algo_id: Option<String>,
602    /// Client supplied algo order identifier (optional).
603    #[serde(rename = "algoClOrdId", skip_serializing_if = "Option::is_none")]
604    pub algo_cl_ord_id: Option<String>,
605}
606
607/// Parameters for the GET /api/v5/trade/orders-algo-* endpoints.
608#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
609#[builder(default)]
610#[builder(setter(into, strip_option))]
611#[serde(rename_all = "camelCase")]
612pub struct GetAlgoOrdersParams {
613    /// Algo order identifier assigned by OKX (optional).
614    #[serde(rename = "algoId", skip_serializing_if = "Option::is_none")]
615    pub algo_id: Option<String>,
616    /// Client supplied algo order identifier (optional).
617    #[serde(rename = "algoClOrdId", skip_serializing_if = "Option::is_none")]
618    pub algo_cl_ord_id: Option<String>,
619    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION.
620    pub inst_type: OKXInstrumentType,
621    /// Specific instrument identifier (optional).
622    #[serde(rename = "instId", skip_serializing_if = "Option::is_none")]
623    pub inst_id: Option<String>,
624    /// Order type filter (optional).
625    #[serde(rename = "ordType", skip_serializing_if = "Option::is_none")]
626    pub ord_type: Option<OKXAlgoOrderType>,
627    /// State filter (optional).
628    #[serde(skip_serializing_if = "Option::is_none")]
629    pub state: Option<OKXAlgoOrderStatus>,
630    /// Pagination cursor - fetch records after this value (optional).
631    #[serde(skip_serializing_if = "Option::is_none")]
632    pub after: Option<String>,
633    /// Pagination cursor - fetch records before this value (optional).
634    #[serde(skip_serializing_if = "Option::is_none")]
635    pub before: Option<String>,
636    /// Maximum number of records to return (optional, default 100).
637    #[serde(skip_serializing_if = "Option::is_none")]
638    pub limit: Option<u32>,
639}
640
641/// Parameters for the GET /api/v5/trade/fills endpoint (transaction details).
642#[derive(Clone, Debug, Default, Deserialize, Serialize, Builder)]
643#[builder(default)]
644#[builder(setter(into, strip_option))]
645#[serde(rename_all = "camelCase")]
646pub struct GetTransactionDetailsParams {
647    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION (optional).
648    #[serde(skip_serializing_if = "Option::is_none")]
649    pub inst_type: Option<OKXInstrumentType>,
650    /// Instrument ID, e.g. "BTC-USDT" (optional).
651    #[serde(skip_serializing_if = "Option::is_none")]
652    pub inst_id: Option<String>,
653    /// Order ID (optional).
654    #[serde(skip_serializing_if = "Option::is_none")]
655    pub ord_id: Option<String>,
656    /// Pagination of data to return records earlier than the requested ID (optional).
657    #[serde(skip_serializing_if = "Option::is_none")]
658    pub after: Option<String>,
659    /// Pagination of data to return records newer than the requested ID (optional).
660    #[serde(skip_serializing_if = "Option::is_none")]
661    pub before: Option<String>,
662    /// Filter with a begin timestamp in milliseconds (optional).
663    #[serde(skip_serializing_if = "Option::is_none")]
664    pub begin: Option<String>,
665    /// Filter with an end timestamp in milliseconds (optional).
666    #[serde(skip_serializing_if = "Option::is_none")]
667    pub end: Option<String>,
668    /// Number of results per request (optional, default 100, max 100).
669    #[serde(skip_serializing_if = "Option::is_none")]
670    pub limit: Option<u32>,
671}
672
673/// Parameters for the GET /api/v5/public/positions endpoint.
674#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
675#[builder(default)]
676#[builder(setter(into, strip_option))]
677#[serde(rename_all = "camelCase")]
678pub struct GetPositionsParams {
679    /// Instrument type: MARGIN, SWAP, FUTURES, OPTION.
680    #[serde(skip_serializing_if = "Option::is_none")]
681    pub inst_type: Option<OKXInstrumentType>,
682    /// Specific instrument ID.
683    #[serde(skip_serializing_if = "Option::is_none")]
684    pub inst_id: Option<String>,
685    /// Single position ID or multiple position IDs (no more than 20) separated with comma.
686    #[serde(skip_serializing_if = "Option::is_none")]
687    pub pos_id: Option<String>,
688}
689
690/// Parameters for the GET /api/v5/account/positions-history endpoint.
691#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
692#[builder(default)]
693#[builder(setter(into, strip_option))]
694#[serde(rename_all = "camelCase")]
695pub struct GetPositionsHistoryParams {
696    /// Instrument type: MARGIN, SWAP, FUTURES, OPTION.
697    pub inst_type: OKXInstrumentType,
698    /// Instrument ID, e.g. "BTC-USD-SWAP" (optional).
699    #[serde(skip_serializing_if = "Option::is_none")]
700    pub inst_id: Option<String>,
701    /// One or more position IDs, separated by commas (optional).
702    #[serde(skip_serializing_if = "Option::is_none")]
703    pub pos_id: Option<String>,
704    /// Pagination parameter - requests records **after** this ID or timestamp (optional).
705    #[serde(skip_serializing_if = "Option::is_none")]
706    pub after: Option<String>,
707    /// Pagination parameter - requests records **before** this ID or timestamp (optional).
708    #[serde(skip_serializing_if = "Option::is_none")]
709    pub before: Option<String>,
710    /// Number of results per request (default 100, max 100).
711    #[serde(skip_serializing_if = "Option::is_none")]
712    pub limit: Option<u32>,
713}
714
715/// Parameters for the GET /api/v5/trade/order endpoint (fetch order details).
716#[derive(Clone, Debug, Default, Deserialize, Serialize, Builder)]
717#[builder(default)]
718#[builder(setter(into, strip_option))]
719#[serde(rename_all = "camelCase")]
720pub struct GetOrderParams {
721    /// Instrument type retained for API compatibility; not accepted by this endpoint.
722    #[serde(skip_serializing)]
723    pub inst_type: OKXInstrumentType,
724    /// Instrument ID, e.g. "BTC-USDT".
725    pub inst_id: String,
726    /// Exchange-assigned order ID (optional if client order ID is provided).
727    #[serde(skip_serializing_if = "Option::is_none")]
728    pub ord_id: Option<String>,
729    /// User-assigned client order ID (optional if order ID is provided).
730    #[serde(skip_serializing_if = "Option::is_none")]
731    pub cl_ord_id: Option<String>,
732    /// Position side retained for API compatibility; not accepted by this endpoint.
733    #[serde(skip_serializing)]
734    pub pos_side: Option<OKXPositionSide>,
735}
736
737/// Parameters for the GET /api/v5/account/trade-fee endpoint.
738#[derive(Clone, Debug, Deserialize, Serialize, Builder)]
739#[builder(setter(into, strip_option))]
740#[serde(rename_all = "camelCase")]
741pub struct GetTradeFeeParams {
742    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION.
743    pub inst_type: OKXInstrumentType,
744    /// Underlying, required for SWAP/FUTURES/OPTION (optional).
745    #[serde(skip_serializing_if = "Option::is_none")]
746    pub uly: Option<String>,
747    /// Instrument family, required for SWAP/FUTURES/OPTION (optional).
748    #[serde(skip_serializing_if = "Option::is_none")]
749    pub inst_family: Option<String>,
750}
751
752#[cfg(test)]
753mod tests {
754    use rstest::rstest;
755
756    use super::*;
757
758    #[rstest]
759    fn test_optional_parameters_are_omitted_when_none() {
760        let mut builder = GetCandlesticksParamsBuilder::default();
761        builder.inst_id("BTC-USDT-SWAP");
762        builder.bar("1m");
763
764        let params = builder.build().unwrap();
765        let qs = serde_urlencoded::to_string(&params).unwrap();
766        assert_eq!(
767            qs, "instId=BTC-USDT-SWAP&bar=1m",
768            "unexpected optional parameters were serialized: {qs}",
769        );
770    }
771
772    #[rstest]
773    fn test_no_literal_none_strings_leak_into_query_string() {
774        let mut builder = GetCandlesticksParamsBuilder::default();
775        builder.inst_id("BTC-USDT-SWAP");
776        builder.bar("1m");
777
778        let params = builder.build().unwrap();
779        let qs = serde_urlencoded::to_string(&params).unwrap();
780        assert!(
781            !qs.contains("None"),
782            "found literal \"None\" in query string: {qs}",
783        );
784        assert!(
785            !qs.contains("after=") && !qs.contains("before=") && !qs.contains("limit="),
786            "empty optional parameters must be omitted entirely: {qs}",
787        );
788    }
789
790    #[rstest]
791    fn test_cursor_nanoseconds_rejected() {
792        // 2025-07-01T00:00:00Z in *nanoseconds* on purpose.
793        let after_nanos = 1_725_307_200_000_000_000i64;
794
795        let mut builder = GetCandlesticksParamsBuilder::default();
796        builder.inst_id("BTC-USDT-SWAP");
797        builder.bar("1m");
798        builder.after_ms(after_nanos);
799
800        // This should fail because nanoseconds > 13 digits
801        let result = builder.build();
802        assert!(result.is_err());
803        assert!(result.unwrap_err().to_string().contains("nanoseconds"));
804    }
805
806    #[rstest]
807    fn test_both_cursors_rejected() {
808        let mut builder = GetCandlesticksParamsBuilder::default();
809        builder.inst_id("BTC-USDT-SWAP");
810        builder.bar("1m");
811        // OKX backwards semantics: before=lower bound, after=upper bound
812        // This creates invalid range where before >= after
813        builder.after_ms(1_725_307_200_000);
814        builder.before_ms(1_725_393_600_000);
815
816        let result = builder.build();
817        assert!(result.is_err());
818        assert!(result.unwrap_err().to_string().contains("time range"));
819    }
820
821    #[rstest]
822    fn test_limit_exceeds_maximum_rejected() {
823        let mut builder = GetCandlesticksParamsBuilder::default();
824        builder.inst_id("BTC-USDT-SWAP");
825        builder.bar("1m");
826        builder.limit(301u32); // Exceeds maximum limit
827
828        // Limit should be rejected
829        let result = builder.build();
830        assert!(result.is_err());
831        assert!(result.unwrap_err().to_string().contains("300"));
832    }
833
834    #[rstest]
835    #[case(1_725_307_200_000, "after=1725307200000")] // 13 digits = milliseconds
836    #[case(1_725_307_200, "after=1725307200")] // 10 digits = seconds
837    #[case(1_725_307, "after=1725307")] // 7 digits = also valid
838    fn test_valid_millisecond_cursor_passes(#[case] timestamp: i64, #[case] expected: &str) {
839        let mut builder = GetCandlesticksParamsBuilder::default();
840        builder.inst_id("BTC-USDT-SWAP");
841        builder.bar("1m");
842        builder.after_ms(timestamp);
843
844        let params = builder.build().unwrap();
845        let qs = serde_urlencoded::to_string(&params).unwrap();
846        assert!(qs.contains(expected));
847    }
848
849    #[rstest]
850    #[case(1, "limit=1")]
851    #[case(50, "limit=50")]
852    #[case(100, "limit=100")]
853    #[case(300, "limit=300")] // Maximum allowed limit
854    fn test_valid_limit_passes(#[case] limit: u32, #[case] expected: &str) {
855        let mut builder = GetCandlesticksParamsBuilder::default();
856        builder.inst_id("BTC-USDT-SWAP");
857        builder.bar("1m");
858        builder.limit(limit);
859
860        let params = builder.build().unwrap();
861        let qs = serde_urlencoded::to_string(&params).unwrap();
862        assert!(qs.contains(expected));
863    }
864}