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