nautilus_okx/http/
query.rs

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