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