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}
100
101/// Parameters for the GET /api/v5/public/opt-summary endpoint.
102#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
103#[builder(default)]
104#[builder(setter(into, strip_option))]
105#[serde(rename_all = "camelCase")]
106pub struct GetOptionSummaryParams {
107    /// Instrument family. Only applicable to OPTION.
108    pub inst_family: String,
109    /// Contract expiry date in YYMMDD format, e.g. "250328".
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub exp_time: Option<String>,
112}
113
114/// Parameters for the GET /api/v5/market/history-trades endpoint.
115#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
116#[builder(default)]
117#[builder(setter(into, strip_option))]
118#[serde(rename_all = "camelCase")]
119pub struct GetTradesParams {
120    /// Instrument ID, e.g. "BTC-USDT".
121    pub inst_id: String,
122    /// Pagination type: 1 = trade ID (default), 2 = timestamp.
123    #[serde(rename = "type")]
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub pagination_type: Option<u8>,
126    /// Pagination: fetch records after this cursor (trade ID or timestamp).
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub after: Option<String>,
129    /// Pagination: fetch records before this cursor (trade ID or timestamp).
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub before: Option<String>,
132    /// Maximum number of records to return (default 100, max 1000).
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub limit: Option<u32>,
135}
136
137/// Parameters for the GET /api/v5/market/history-candles endpoint.
138#[derive(Clone, Debug, Deserialize, Serialize)]
139#[serde(rename_all = "camelCase")]
140pub struct GetCandlesticksParams {
141    /// Instrument ID, e.g. "BTC-USDT".
142    pub inst_id: String,
143    /// Time interval, e.g. "1m", "5m", "1H".
144    pub bar: String,
145    /// Pagination: fetch records after this timestamp (milliseconds).
146    #[serde(skip_serializing_if = "Option::is_none")]
147    #[serde(rename = "after")]
148    pub after_ms: Option<i64>,
149    /// Pagination: fetch records before this timestamp (milliseconds).
150    #[serde(skip_serializing_if = "Option::is_none")]
151    #[serde(rename = "before")]
152    pub before_ms: Option<i64>,
153    /// Maximum number of records to return (default 100, max 300 for regular candles, max 100 for history).
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub limit: Option<u32>,
156}
157
158/// Builder for GetCandlesticksParams with validation.
159#[derive(Debug, Default)]
160pub struct GetCandlesticksParamsBuilder {
161    inst_id: Option<String>,
162    bar: Option<String>,
163    after_ms: Option<i64>,
164    before_ms: Option<i64>,
165    limit: Option<u32>,
166}
167
168impl GetCandlesticksParamsBuilder {
169    /// Sets the instrument ID.
170    pub fn inst_id(&mut self, inst_id: impl Into<String>) -> &mut Self {
171        self.inst_id = Some(inst_id.into());
172        self
173    }
174
175    /// Sets the bar interval.
176    pub fn bar(&mut self, bar: impl Into<String>) -> &mut Self {
177        self.bar = Some(bar.into());
178        self
179    }
180
181    /// Sets the after timestamp (milliseconds).
182    pub fn after_ms(&mut self, after_ms: i64) -> &mut Self {
183        self.after_ms = Some(after_ms);
184        self
185    }
186
187    /// Sets the before timestamp (milliseconds).
188    pub fn before_ms(&mut self, before_ms: i64) -> &mut Self {
189        self.before_ms = Some(before_ms);
190        self
191    }
192
193    /// Sets the limit.
194    pub fn limit(&mut self, limit: u32) -> &mut Self {
195        self.limit = Some(limit);
196        self
197    }
198
199    /// Builds the parameters with embedded invariant validation.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error if the parameters are invalid.
204    pub fn build(&mut self) -> Result<GetCandlesticksParams, BuildError> {
205        // Extract values from builder
206        let inst_id = self.inst_id.clone().ok_or(BuildError::MissingInstId)?;
207        let bar = self.bar.clone().ok_or(BuildError::MissingBar)?;
208        let after_ms = self.after_ms;
209        let before_ms = self.before_ms;
210        let limit = self.limit;
211
212        // ───────── Both cursors validation
213        // Note: OKX DOES support both 'after' and 'before' together for time range queries
214        // They can only NOT be used together when one is a pagination cursor
215        // For now, we allow both and let the API validate
216        // if after_ms.is_some() && before_ms.is_some() {
217        //     return Err(BuildError::BothCursors);
218        // }
219
220        // ───────── Cursor chronological validation
221        // IMPORTANT: OKX has counter-intuitive parameter semantics:
222        // - before_ms is the START time (lower bound, older) - returns bars > before
223        // - after_ms is the END time (upper bound, newer) - returns bars < after
224        // Therefore: before_ms < after_ms for valid time ranges
225        if let (Some(after), Some(before)) = (after_ms, before_ms)
226            && before >= after
227        {
228            return Err(BuildError::InvalidTimeRange {
229                after_ms: after,
230                before_ms: before,
231            });
232        }
233
234        // ───────── Cursor unit (≤ 13 digits ⇒ milliseconds)
235        if let Some(nanos) = after_ms
236            && nanos.abs() > 9_999_999_999_999
237        {
238            return Err(BuildError::CursorIsNanoseconds);
239        }
240
241        if let Some(nanos) = before_ms
242            && nanos.abs() > 9_999_999_999_999
243        {
244            return Err(BuildError::CursorIsNanoseconds);
245        }
246
247        // ───────── Limit validation
248        // Note: Regular endpoint supports up to 300, history endpoint up to 100
249        // This validation is conservative for safety across both endpoints
250        if let Some(limit) = limit
251            && limit > 300
252        {
253            return Err(BuildError::LimitTooHigh);
254        }
255
256        Ok(GetCandlesticksParams {
257            inst_id,
258            bar,
259            after_ms,
260            before_ms,
261            limit,
262        })
263    }
264}
265
266/// Parameters for the GET /api/v5/public/mark-price.
267#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
268#[builder(default)]
269#[builder(setter(into, strip_option))]
270#[serde(rename_all = "camelCase")]
271pub struct GetMarkPriceParams {
272    /// Instrument type: MARGIN, SWAP, FUTURES, OPTION.
273    pub inst_type: OKXInstrumentType,
274    /// Underlying, required for SWAP/FUTURES/OPTION
275    /// Single underlying or multiple underlyings (no more than 3) separated with comma.
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub uly: Option<String>,
278    /// Instrument family, required for SWAP/FUTURES/OPTION
279    /// Single instrument family or multiple families (no more than 5) separated with comma.
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub inst_family: Option<String>,
282    /// Specific instrument ID.
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub inst_id: Option<String>,
285}
286
287/// Parameters for the GET /api/v5/market/index-tickers.
288#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
289#[builder(default)]
290#[builder(setter(into, strip_option))]
291#[serde(rename_all = "camelCase")]
292pub struct GetIndexTickerParams {
293    /// Specific instrument ID.
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub inst_id: Option<String>,
296    /// Quote currency.
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub quote_ccy: Option<String>,
299}
300
301/// Parameters for the GET /api/v5/market/books endpoint.
302#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
303#[builder(default)]
304#[builder(setter(into, strip_option))]
305#[serde(rename_all = "camelCase")]
306pub struct GetOrderBookParams {
307    /// Instrument ID, e.g. "BTC-USDT-SWAP".
308    pub inst_id: String,
309    /// Order book depth per side. Maximum 400, default 1.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub sz: Option<u32>,
312}
313
314/// Parameters for the GET /api/v5/public/funding-rate-history endpoint.
315#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
316#[builder(default)]
317#[builder(setter(into, strip_option))]
318#[serde(rename_all = "camelCase")]
319pub struct GetFundingRateHistoryParams {
320    /// Instrument ID, e.g. "BTC-USDT-SWAP".
321    pub inst_id: String,
322    /// Pagination: records newer than this timestamp (ms).
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub before: Option<String>,
325    /// Pagination: records older than this timestamp (ms).
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub after: Option<String>,
328    /// Number of results per request (default 100, max 100).
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub limit: Option<u32>,
331}
332
333/// Parameters for the GET /api/v5/trade/order-history endpoint.
334#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
335#[builder(default)]
336#[builder(setter(into, strip_option))]
337#[serde(rename_all = "camelCase")]
338pub struct GetOrderHistoryParams {
339    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION.
340    pub inst_type: OKXInstrumentType,
341    /// Underlying, for FUTURES, SWAP, OPTION (optional).
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub uly: Option<String>,
344    /// Instrument family, for FUTURES, SWAP, OPTION (optional).
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub inst_family: Option<String>,
347    /// Instrument ID, e.g. "BTC-USD-SWAP" (optional).
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub inst_id: Option<String>,
350    /// Order type: limit, market, post_only, fok, ioc (optional).
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub ord_type: Option<OKXOrderType>,
353    /// Order state: live, filled, canceled (optional).
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub state: Option<String>,
356    /// Pagination parameter: fetch records after this order ID or timestamp (optional).
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub after: Option<String>,
359    /// Pagination parameter: fetch records before this order ID or timestamp (optional).
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub before: Option<String>,
362    /// Maximum number of records to return (default 100, max 100) (optional).
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub limit: Option<u32>,
365}
366
367/// Parameters for the GET /api/v5/trade/orders-pending endpoint.
368#[derive(Clone, Debug, Default, Deserialize, Serialize, Builder)]
369#[builder(default)]
370#[builder(setter(into, strip_option))]
371#[serde(rename_all = "camelCase")]
372pub struct GetOrderListParams {
373    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION.
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub inst_type: Option<OKXInstrumentType>,
376    /// Instrument ID, e.g. "BTC-USDT" (optional).
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub inst_id: Option<String>,
379    /// Instrument family, e.g. "BTC-USD" (optional).
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub inst_family: Option<String>,
382    /// State to filter for (optional).
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub state: Option<OKXOrderStatus>,
385    /// Pagination - fetch records **after** this order ID (optional).
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub after: Option<String>,
388    /// Pagination - fetch records **before** this order ID (optional).
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub before: Option<String>,
391    /// Number of results per request (default 100, max 100).
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub limit: Option<u32>,
394}
395
396/// Parameters for the GET /api/v5/trade/order-algo-* endpoints.
397#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
398#[builder(default)]
399#[builder(setter(into, strip_option))]
400#[serde(rename_all = "camelCase")]
401pub struct GetAlgoOrdersParams {
402    /// Algo order identifier assigned by OKX (optional).
403    #[serde(rename = "algoId", skip_serializing_if = "Option::is_none")]
404    pub algo_id: Option<String>,
405    /// Client supplied algo order identifier (optional).
406    #[serde(rename = "algoClOrdId", skip_serializing_if = "Option::is_none")]
407    pub algo_cl_ord_id: Option<String>,
408    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION.
409    pub inst_type: OKXInstrumentType,
410    /// Specific instrument identifier (optional).
411    #[serde(rename = "instId", skip_serializing_if = "Option::is_none")]
412    pub inst_id: Option<String>,
413    /// Order type filter (optional).
414    #[serde(rename = "ordType", skip_serializing_if = "Option::is_none")]
415    pub ord_type: Option<OKXAlgoOrderType>,
416    /// State filter (optional).
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub state: Option<OKXOrderStatus>,
419    /// Pagination cursor – fetch records after this value (optional).
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub after: Option<String>,
422    /// Pagination cursor – fetch records before this value (optional).
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub before: Option<String>,
425    /// Maximum number of records to return (optional, default 100).
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub limit: Option<u32>,
428}
429
430/// Parameters for the GET /api/v5/trade/fills endpoint (transaction details).
431#[derive(Clone, Debug, Default, Deserialize, Serialize, Builder)]
432#[builder(default)]
433#[builder(setter(into, strip_option))]
434#[serde(rename_all = "camelCase")]
435pub struct GetTransactionDetailsParams {
436    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION (optional).
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub inst_type: Option<OKXInstrumentType>,
439    /// Instrument ID, e.g. "BTC-USDT" (optional).
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub inst_id: Option<String>,
442    /// Order ID (optional).
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub ord_id: Option<String>,
445    /// Pagination of data to return records earlier than the requested ID (optional).
446    #[serde(skip_serializing_if = "Option::is_none")]
447    pub after: Option<String>,
448    /// Pagination of data to return records newer than the requested ID (optional).
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub before: Option<String>,
451    /// Number of results per request (optional, default 100, max 100).
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub limit: Option<u32>,
454}
455
456/// Parameters for the GET /api/v5/public/positions endpoint.
457#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
458#[builder(default)]
459#[builder(setter(into, strip_option))]
460#[serde(rename_all = "camelCase")]
461pub struct GetPositionsParams {
462    /// Instrument type: MARGIN, SWAP, FUTURES, OPTION.
463    #[serde(skip_serializing_if = "Option::is_none")]
464    pub inst_type: Option<OKXInstrumentType>,
465    /// Specific instrument ID.
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub inst_id: Option<String>,
468    /// Single position ID or multiple position IDs (no more than 20) separated with comma.
469    #[serde(skip_serializing_if = "Option::is_none")]
470    pub pos_id: Option<String>,
471}
472
473/// Parameters for the GET /api/v5/account/positions-history endpoint.
474#[derive(Clone, Debug, Deserialize, Serialize, Default, Builder)]
475#[builder(default)]
476#[builder(setter(into, strip_option))]
477#[serde(rename_all = "camelCase")]
478pub struct GetPositionsHistoryParams {
479    /// Instrument type: MARGIN, SWAP, FUTURES, OPTION.
480    pub inst_type: OKXInstrumentType,
481    /// Instrument ID, e.g. "BTC-USD-SWAP" (optional).
482    #[serde(skip_serializing_if = "Option::is_none")]
483    pub inst_id: Option<String>,
484    /// One or more position IDs, separated by commas (optional).
485    #[serde(skip_serializing_if = "Option::is_none")]
486    pub pos_id: Option<String>,
487    /// Pagination parameter - requests records **after** this ID or timestamp (optional).
488    #[serde(skip_serializing_if = "Option::is_none")]
489    pub after: Option<String>,
490    /// Pagination parameter - requests records **before** this ID or timestamp (optional).
491    #[serde(skip_serializing_if = "Option::is_none")]
492    pub before: 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 endpoint (fetch order details).
499#[derive(Clone, Debug, Default, Deserialize, Serialize, Builder)]
500#[builder(default)]
501#[builder(setter(into, strip_option))]
502#[serde(rename_all = "camelCase")]
503pub struct GetOrderParams {
504    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION.
505    pub inst_type: OKXInstrumentType,
506    /// Instrument ID, e.g. "BTC-USDT".
507    pub inst_id: String,
508    /// Exchange-assigned order ID (optional if client order ID is provided).
509    #[serde(skip_serializing_if = "Option::is_none")]
510    pub ord_id: Option<String>,
511    /// User-assigned client order ID (optional if order ID is provided).
512    #[serde(skip_serializing_if = "Option::is_none")]
513    pub cl_ord_id: Option<String>,
514    /// Position side (optional).
515    #[serde(skip_serializing_if = "Option::is_none")]
516    pub pos_side: Option<OKXPositionSide>,
517}
518
519/// Parameters for the GET /api/v5/account/trade-fee endpoint.
520#[derive(Clone, Debug, Deserialize, Serialize, Builder)]
521#[builder(setter(into, strip_option))]
522#[serde(rename_all = "camelCase")]
523pub struct GetTradeFeeParams {
524    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION.
525    pub inst_type: OKXInstrumentType,
526    /// Underlying, required for SWAP/FUTURES/OPTION (optional).
527    #[serde(skip_serializing_if = "Option::is_none")]
528    pub uly: Option<String>,
529    /// Instrument family, required for SWAP/FUTURES/OPTION (optional).
530    #[serde(skip_serializing_if = "Option::is_none")]
531    pub inst_family: Option<String>,
532}
533
534#[cfg(test)]
535mod tests {
536    use rstest::rstest;
537
538    use super::*;
539
540    #[rstest]
541    fn test_optional_parameters_are_omitted_when_none() {
542        let mut builder = GetCandlesticksParamsBuilder::default();
543        builder.inst_id("BTC-USDT-SWAP");
544        builder.bar("1m");
545
546        let params = builder.build().unwrap();
547        let qs = serde_urlencoded::to_string(&params).unwrap();
548        assert_eq!(
549            qs, "instId=BTC-USDT-SWAP&bar=1m",
550            "unexpected optional parameters were serialized: {qs}",
551        );
552    }
553
554    #[rstest]
555    fn test_no_literal_none_strings_leak_into_query_string() {
556        let mut builder = GetCandlesticksParamsBuilder::default();
557        builder.inst_id("BTC-USDT-SWAP");
558        builder.bar("1m");
559
560        let params = builder.build().unwrap();
561        let qs = serde_urlencoded::to_string(&params).unwrap();
562        assert!(
563            !qs.contains("None"),
564            "found literal \"None\" in query string: {qs}",
565        );
566        assert!(
567            !qs.contains("after=") && !qs.contains("before=") && !qs.contains("limit="),
568            "empty optional parameters must be omitted entirely: {qs}",
569        );
570    }
571
572    #[rstest]
573    fn test_cursor_nanoseconds_rejected() {
574        // 2025-07-01T00:00:00Z in *nanoseconds* on purpose.
575        let after_nanos = 1_725_307_200_000_000_000i64;
576
577        let mut builder = GetCandlesticksParamsBuilder::default();
578        builder.inst_id("BTC-USDT-SWAP");
579        builder.bar("1m");
580        builder.after_ms(after_nanos);
581
582        // This should fail because nanoseconds > 13 digits
583        let result = builder.build();
584        assert!(result.is_err());
585        assert!(result.unwrap_err().to_string().contains("nanoseconds"));
586    }
587
588    #[rstest]
589    fn test_both_cursors_rejected() {
590        let mut builder = GetCandlesticksParamsBuilder::default();
591        builder.inst_id("BTC-USDT-SWAP");
592        builder.bar("1m");
593        // OKX backwards semantics: before=lower bound, after=upper bound
594        // This creates invalid range where before >= after
595        builder.after_ms(1725307200000);
596        builder.before_ms(1725393600000);
597
598        let result = builder.build();
599        assert!(result.is_err());
600        assert!(result.unwrap_err().to_string().contains("time range"));
601    }
602
603    #[rstest]
604    fn test_limit_exceeds_maximum_rejected() {
605        let mut builder = GetCandlesticksParamsBuilder::default();
606        builder.inst_id("BTC-USDT-SWAP");
607        builder.bar("1m");
608        builder.limit(301u32); // Exceeds maximum limit
609
610        // Limit should be rejected
611        let result = builder.build();
612        assert!(result.is_err());
613        assert!(result.unwrap_err().to_string().contains("300"));
614    }
615
616    #[rstest]
617    #[case(1725307200000, "after=1725307200000")] // 13 digits = milliseconds
618    #[case(1725307200, "after=1725307200")] // 10 digits = seconds
619    #[case(1725307, "after=1725307")] // 7 digits = also valid
620    fn test_valid_millisecond_cursor_passes(#[case] timestamp: i64, #[case] expected: &str) {
621        let mut builder = GetCandlesticksParamsBuilder::default();
622        builder.inst_id("BTC-USDT-SWAP");
623        builder.bar("1m");
624        builder.after_ms(timestamp);
625
626        let params = builder.build().unwrap();
627        let qs = serde_urlencoded::to_string(&params).unwrap();
628        assert!(qs.contains(expected));
629    }
630
631    #[rstest]
632    #[case(1, "limit=1")]
633    #[case(50, "limit=50")]
634    #[case(100, "limit=100")]
635    #[case(300, "limit=300")] // Maximum allowed limit
636    fn test_valid_limit_passes(#[case] limit: u32, #[case] expected: &str) {
637        let mut builder = GetCandlesticksParamsBuilder::default();
638        builder.inst_id("BTC-USDT-SWAP");
639        builder.bar("1m");
640        builder.limit(limit);
641
642        let params = builder.build().unwrap();
643        let qs = serde_urlencoded::to_string(&params).unwrap();
644        assert!(qs.contains(expected));
645    }
646}