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        OKXAlgoOrderType, OKXInstrumentType, OKXOrderStatus, OKXOrderType, OKXPositionMode,
36        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 GET /api/v5/public/instruments endpoint.
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/market/index-tickers.
453#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
454#[builder(default)]
455#[builder(setter(into, strip_option))]
456#[serde(rename_all = "camelCase")]
457pub struct GetIndexTickerParams {
458    /// Specific instrument ID.
459    #[serde(skip_serializing_if = "Option::is_none")]
460    pub inst_id: Option<String>,
461    /// Quote currency.
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub quote_ccy: Option<String>,
464}
465
466/// Parameters for the GET /api/v5/market/books endpoint.
467#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
468#[builder(default)]
469#[builder(setter(into, strip_option))]
470#[serde(rename_all = "camelCase")]
471pub struct GetOrderBookParams {
472    /// Instrument ID, e.g. "BTC-USDT-SWAP".
473    pub inst_id: String,
474    /// Order book depth per side. Maximum 400, default 1.
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub sz: Option<u32>,
477}
478
479/// Parameters for the GET /api/v5/public/funding-rate-history endpoint.
480#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
481#[builder(default)]
482#[builder(setter(into, strip_option))]
483#[serde(rename_all = "camelCase")]
484pub struct GetFundingRateHistoryParams {
485    /// Instrument ID, e.g. "BTC-USDT-SWAP".
486    pub inst_id: String,
487    /// Pagination: records newer than this timestamp (ms).
488    #[serde(skip_serializing_if = "Option::is_none")]
489    pub before: Option<String>,
490    /// Pagination: records older than this timestamp (ms).
491    #[serde(skip_serializing_if = "Option::is_none")]
492    pub after: Option<String>,
493    /// Number of results per request (default 100, max 100).
494    #[serde(skip_serializing_if = "Option::is_none")]
495    pub limit: Option<u32>,
496}
497
498/// Parameters for the GET /api/v5/trade/order-history 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 GetOrderHistoryParams {
504    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION.
505    pub inst_type: OKXInstrumentType,
506    /// Underlying, for FUTURES, SWAP, OPTION (optional).
507    #[serde(skip_serializing_if = "Option::is_none")]
508    pub uly: Option<String>,
509    /// Instrument family, for FUTURES, SWAP, OPTION (optional).
510    #[serde(skip_serializing_if = "Option::is_none")]
511    pub inst_family: Option<String>,
512    /// Instrument ID, e.g. "BTC-USD-SWAP" (optional).
513    #[serde(skip_serializing_if = "Option::is_none")]
514    pub inst_id: Option<String>,
515    /// Order type: limit, market, post_only, fok, ioc (optional).
516    #[serde(skip_serializing_if = "Option::is_none")]
517    pub ord_type: Option<OKXOrderType>,
518    /// Order state: live, filled, canceled (optional).
519    #[serde(skip_serializing_if = "Option::is_none")]
520    pub state: Option<String>,
521    /// Pagination parameter: fetch records after this order ID or timestamp (optional).
522    #[serde(skip_serializing_if = "Option::is_none")]
523    pub after: Option<String>,
524    /// Pagination parameter: fetch records before this order ID or timestamp (optional).
525    #[serde(skip_serializing_if = "Option::is_none")]
526    pub before: Option<String>,
527    /// Maximum number of records to return (default 100, max 100) (optional).
528    #[serde(skip_serializing_if = "Option::is_none")]
529    pub limit: Option<u32>,
530}
531
532/// Parameters for the GET /api/v5/trade/orders-pending endpoint.
533#[derive(Clone, Debug, Default, Deserialize, Serialize, Builder)]
534#[builder(default)]
535#[builder(setter(into, strip_option))]
536#[serde(rename_all = "camelCase")]
537pub struct GetOrderListParams {
538    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION.
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub inst_type: Option<OKXInstrumentType>,
541    /// Instrument ID, e.g. "BTC-USDT" (optional).
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub inst_id: Option<String>,
544    /// Instrument family, e.g. "BTC-USD" (optional).
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub inst_family: Option<String>,
547    /// State to filter for (optional).
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub state: Option<OKXOrderStatus>,
550    /// Pagination - fetch records **after** this order ID (optional).
551    #[serde(skip_serializing_if = "Option::is_none")]
552    pub after: Option<String>,
553    /// Pagination - fetch records **before** this order ID (optional).
554    #[serde(skip_serializing_if = "Option::is_none")]
555    pub before: Option<String>,
556    /// Number of results per request (default 100, max 100).
557    #[serde(skip_serializing_if = "Option::is_none")]
558    pub limit: Option<u32>,
559}
560
561/// Parameters for the GET /api/v5/trade/order-algo-* endpoints.
562#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
563#[builder(default)]
564#[builder(setter(into, strip_option))]
565#[serde(rename_all = "camelCase")]
566pub struct GetAlgoOrdersParams {
567    /// Algo order identifier assigned by OKX (optional).
568    #[serde(rename = "algoId", skip_serializing_if = "Option::is_none")]
569    pub algo_id: Option<String>,
570    /// Client supplied algo order identifier (optional).
571    #[serde(rename = "algoClOrdId", skip_serializing_if = "Option::is_none")]
572    pub algo_cl_ord_id: Option<String>,
573    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION.
574    pub inst_type: OKXInstrumentType,
575    /// Specific instrument identifier (optional).
576    #[serde(rename = "instId", skip_serializing_if = "Option::is_none")]
577    pub inst_id: Option<String>,
578    /// Order type filter (optional).
579    #[serde(rename = "ordType", skip_serializing_if = "Option::is_none")]
580    pub ord_type: Option<OKXAlgoOrderType>,
581    /// State filter (optional).
582    #[serde(skip_serializing_if = "Option::is_none")]
583    pub state: Option<OKXOrderStatus>,
584    /// Pagination cursor – fetch records after this value (optional).
585    #[serde(skip_serializing_if = "Option::is_none")]
586    pub after: Option<String>,
587    /// Pagination cursor – fetch records before this value (optional).
588    #[serde(skip_serializing_if = "Option::is_none")]
589    pub before: Option<String>,
590    /// Maximum number of records to return (optional, default 100).
591    #[serde(skip_serializing_if = "Option::is_none")]
592    pub limit: Option<u32>,
593}
594
595/// Parameters for the GET /api/v5/trade/fills endpoint (transaction details).
596#[derive(Clone, Debug, Default, Deserialize, Serialize, Builder)]
597#[builder(default)]
598#[builder(setter(into, strip_option))]
599#[serde(rename_all = "camelCase")]
600pub struct GetTransactionDetailsParams {
601    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION (optional).
602    #[serde(skip_serializing_if = "Option::is_none")]
603    pub inst_type: Option<OKXInstrumentType>,
604    /// Instrument ID, e.g. "BTC-USDT" (optional).
605    #[serde(skip_serializing_if = "Option::is_none")]
606    pub inst_id: Option<String>,
607    /// Order ID (optional).
608    #[serde(skip_serializing_if = "Option::is_none")]
609    pub ord_id: Option<String>,
610    /// Pagination of data to return records earlier than the requested ID (optional).
611    #[serde(skip_serializing_if = "Option::is_none")]
612    pub after: Option<String>,
613    /// Pagination of data to return records newer than the requested ID (optional).
614    #[serde(skip_serializing_if = "Option::is_none")]
615    pub before: Option<String>,
616    /// Number of results per request (optional, default 100, max 100).
617    #[serde(skip_serializing_if = "Option::is_none")]
618    pub limit: Option<u32>,
619}
620
621/// Parameters for the GET /api/v5/public/positions endpoint.
622#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
623#[builder(default)]
624#[builder(setter(into, strip_option))]
625#[serde(rename_all = "camelCase")]
626pub struct GetPositionsParams {
627    /// Instrument type: MARGIN, SWAP, FUTURES, OPTION.
628    #[serde(skip_serializing_if = "Option::is_none")]
629    pub inst_type: Option<OKXInstrumentType>,
630    /// Specific instrument ID.
631    #[serde(skip_serializing_if = "Option::is_none")]
632    pub inst_id: Option<String>,
633    /// Single position ID or multiple position IDs (no more than 20) separated with comma.
634    #[serde(skip_serializing_if = "Option::is_none")]
635    pub pos_id: Option<String>,
636}
637
638/// Parameters for the GET /api/v5/account/positions-history endpoint.
639#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
640#[builder(default)]
641#[builder(setter(into, strip_option))]
642#[serde(rename_all = "camelCase")]
643pub struct GetPositionsHistoryParams {
644    /// Instrument type: MARGIN, SWAP, FUTURES, OPTION.
645    pub inst_type: OKXInstrumentType,
646    /// Instrument ID, e.g. "BTC-USD-SWAP" (optional).
647    #[serde(skip_serializing_if = "Option::is_none")]
648    pub inst_id: Option<String>,
649    /// One or more position IDs, separated by commas (optional).
650    #[serde(skip_serializing_if = "Option::is_none")]
651    pub pos_id: Option<String>,
652    /// Pagination parameter - requests records **after** this ID or timestamp (optional).
653    #[serde(skip_serializing_if = "Option::is_none")]
654    pub after: Option<String>,
655    /// Pagination parameter - requests records **before** this ID or timestamp (optional).
656    #[serde(skip_serializing_if = "Option::is_none")]
657    pub before: Option<String>,
658    /// Number of results per request (default 100, max 100).
659    #[serde(skip_serializing_if = "Option::is_none")]
660    pub limit: Option<u32>,
661}
662
663/// Parameters for the GET /api/v5/trade/order endpoint (fetch order details).
664#[derive(Clone, Debug, Default, Deserialize, Serialize, Builder)]
665#[builder(default)]
666#[builder(setter(into, strip_option))]
667#[serde(rename_all = "camelCase")]
668pub struct GetOrderParams {
669    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION.
670    pub inst_type: OKXInstrumentType,
671    /// Instrument ID, e.g. "BTC-USDT".
672    pub inst_id: String,
673    /// Exchange-assigned order ID (optional if client order ID is provided).
674    #[serde(skip_serializing_if = "Option::is_none")]
675    pub ord_id: Option<String>,
676    /// User-assigned client order ID (optional if order ID is provided).
677    #[serde(skip_serializing_if = "Option::is_none")]
678    pub cl_ord_id: Option<String>,
679    /// Position side (optional).
680    #[serde(skip_serializing_if = "Option::is_none")]
681    pub pos_side: Option<OKXPositionSide>,
682}
683
684/// Parameters for the GET /api/v5/account/trade-fee endpoint.
685#[derive(Clone, Debug, Deserialize, Serialize, Builder)]
686#[builder(setter(into, strip_option))]
687#[serde(rename_all = "camelCase")]
688pub struct GetTradeFeeParams {
689    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION.
690    pub inst_type: OKXInstrumentType,
691    /// Underlying, required for SWAP/FUTURES/OPTION (optional).
692    #[serde(skip_serializing_if = "Option::is_none")]
693    pub uly: Option<String>,
694    /// Instrument family, required for SWAP/FUTURES/OPTION (optional).
695    #[serde(skip_serializing_if = "Option::is_none")]
696    pub inst_family: Option<String>,
697}
698
699#[cfg(test)]
700mod tests {
701    use rstest::rstest;
702
703    use super::*;
704
705    #[rstest]
706    fn test_optional_parameters_are_omitted_when_none() {
707        let mut builder = GetCandlesticksParamsBuilder::default();
708        builder.inst_id("BTC-USDT-SWAP");
709        builder.bar("1m");
710
711        let params = builder.build().unwrap();
712        let qs = serde_urlencoded::to_string(&params).unwrap();
713        assert_eq!(
714            qs, "instId=BTC-USDT-SWAP&bar=1m",
715            "unexpected optional parameters were serialized: {qs}",
716        );
717    }
718
719    #[rstest]
720    fn test_no_literal_none_strings_leak_into_query_string() {
721        let mut builder = GetCandlesticksParamsBuilder::default();
722        builder.inst_id("BTC-USDT-SWAP");
723        builder.bar("1m");
724
725        let params = builder.build().unwrap();
726        let qs = serde_urlencoded::to_string(&params).unwrap();
727        assert!(
728            !qs.contains("None"),
729            "found literal \"None\" in query string: {qs}",
730        );
731        assert!(
732            !qs.contains("after=") && !qs.contains("before=") && !qs.contains("limit="),
733            "empty optional parameters must be omitted entirely: {qs}",
734        );
735    }
736
737    #[rstest]
738    fn test_cursor_nanoseconds_rejected() {
739        // 2025-07-01T00:00:00Z in *nanoseconds* on purpose.
740        let after_nanos = 1_725_307_200_000_000_000i64;
741
742        let mut builder = GetCandlesticksParamsBuilder::default();
743        builder.inst_id("BTC-USDT-SWAP");
744        builder.bar("1m");
745        builder.after_ms(after_nanos);
746
747        // This should fail because nanoseconds > 13 digits
748        let result = builder.build();
749        assert!(result.is_err());
750        assert!(result.unwrap_err().to_string().contains("nanoseconds"));
751    }
752
753    #[rstest]
754    fn test_both_cursors_rejected() {
755        let mut builder = GetCandlesticksParamsBuilder::default();
756        builder.inst_id("BTC-USDT-SWAP");
757        builder.bar("1m");
758        // OKX backwards semantics: before=lower bound, after=upper bound
759        // This creates invalid range where before >= after
760        builder.after_ms(1725307200000);
761        builder.before_ms(1725393600000);
762
763        let result = builder.build();
764        assert!(result.is_err());
765        assert!(result.unwrap_err().to_string().contains("time range"));
766    }
767
768    #[rstest]
769    fn test_limit_exceeds_maximum_rejected() {
770        let mut builder = GetCandlesticksParamsBuilder::default();
771        builder.inst_id("BTC-USDT-SWAP");
772        builder.bar("1m");
773        builder.limit(301u32); // Exceeds maximum limit
774
775        // Limit should be rejected
776        let result = builder.build();
777        assert!(result.is_err());
778        assert!(result.unwrap_err().to_string().contains("300"));
779    }
780
781    #[rstest]
782    #[case(1725307200000, "after=1725307200000")] // 13 digits = milliseconds
783    #[case(1725307200, "after=1725307200")] // 10 digits = seconds
784    #[case(1725307, "after=1725307")] // 7 digits = also valid
785    fn test_valid_millisecond_cursor_passes(#[case] timestamp: i64, #[case] expected: &str) {
786        let mut builder = GetCandlesticksParamsBuilder::default();
787        builder.inst_id("BTC-USDT-SWAP");
788        builder.bar("1m");
789        builder.after_ms(timestamp);
790
791        let params = builder.build().unwrap();
792        let qs = serde_urlencoded::to_string(&params).unwrap();
793        assert!(qs.contains(expected));
794    }
795
796    #[rstest]
797    #[case(1, "limit=1")]
798    #[case(50, "limit=50")]
799    #[case(100, "limit=100")]
800    #[case(300, "limit=300")] // Maximum allowed limit
801    fn test_valid_limit_passes(#[case] limit: u32, #[case] expected: &str) {
802        let mut builder = GetCandlesticksParamsBuilder::default();
803        builder.inst_id("BTC-USDT-SWAP");
804        builder.bar("1m");
805        builder.limit(limit);
806
807        let params = builder.build().unwrap();
808        let qs = serde_urlencoded::to_string(&params).unwrap();
809        assert!(qs.contains(expected));
810    }
811}