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