Skip to main content

bybit/models/
closed_options_positions_request.rs

1use crate::prelude::*;
2
3/// Parameters for requesting closed options positions.
4///
5/// Used to construct a request to the `/v5/position/get-closed-positions` endpoint to retrieve closed options positions.
6/// Bots use this to analyze historical options trading performance, track P&L, and audit trading activity.
7///
8/// # Bybit API Reference
9/// According to the Bybit V5 API documentation:
10/// - Only supports querying closed options positions in the last 6 months.
11/// - Sorted by `closeTime` in descending order.
12/// - Fee and price are displayed with trailing zeroes up to 8 decimal places.
13///
14/// # Usage Example
15/// ```rust
16/// // Query closed options positions for a specific symbol
17/// let request = ClosedOptionsPositionsRequest::new(
18///     Category::Option,
19///     Some("BTC-12JUN25-104019-C-USDT"),
20///     None,
21///     None,
22///     Some(50),
23///     None,
24/// );
25///
26/// // Query all closed options positions from the last day
27/// let request = ClosedOptionsPositionsRequest::new(
28///     Category::Option,
29///     None,
30///     Some(1749730000000), // startTime in milliseconds
31///     None,
32///     None,
33///     None,
34/// );
35/// ```
36#[derive(Clone, Default)]
37pub struct ClosedOptionsPositionsRequest<'a> {
38    /// The product category (must be `option`).
39    ///
40    /// Specifies the instrument type. For this endpoint, must be `Category::Option`.
41    pub category: Category,
42
43    /// The options symbol name (e.g., "BTC-12JUN25-104019-C-USDT") (optional).
44    ///
45    /// Filters closed positions by symbol. If unset, all closed options positions are returned.
46    /// Bots should specify this for targeted analysis of specific options contracts.
47    pub symbol: Option<Cow<'a, str>>,
48
49    /// The start timestamp in milliseconds (optional).
50    ///
51    /// The beginning of the time range for querying closed positions.
52    /// According to Bybit API:
53    /// - If neither `start_time` nor `end_time` are provided, returns data from the last 1 day
54    /// - If only `start_time` is provided, returns range between `start_time` and `start_time + 1 day`
55    /// - If only `end_time` is provided, returns range between `end_time - 1 day` and `end_time`
56    /// - If both are provided, the rule is `end_time - start_time <= 7 days`
57    pub start_time: Option<u64>,
58
59    /// The end timestamp in milliseconds (optional).
60    ///
61    /// The end of the time range for querying closed positions.
62    pub end_time: Option<u64>,
63
64    /// The maximum number of records to return (optional).
65    ///
66    /// Limit for data size per page. Valid range: [`1`, `100`]. Default: `50`.
67    /// Bots should use pagination with the `cursor` field for large result sets.
68    pub limit: Option<usize>,
69
70    /// The cursor for pagination (optional).
71    ///
72    /// Use the `nextPageCursor` token from the response to retrieve the next page of results.
73    /// Bots use this to efficiently paginate through large sets of closed positions.
74    pub cursor: Option<Cow<'a, str>>,
75}
76
77impl<'a> ClosedOptionsPositionsRequest<'a> {
78    /// Constructs a new ClosedOptionsPositions request with specified parameters.
79    ///
80    /// Allows full customization of the closed options positions query.
81    /// Bots should use this to specify time ranges, symbols, and pagination parameters.
82    ///
83    /// # Arguments
84    /// * `category` - The product category (must be `Category::Option`)
85    /// * `symbol` - The options symbol name (optional)
86    /// * `start_time` - The start timestamp in milliseconds (optional)
87    /// * `end_time` - The end timestamp in milliseconds (optional)
88    /// * `limit` - The maximum number of records to return (optional, range: 1-100)
89    /// * `cursor` - The cursor for pagination (optional)
90    pub fn new(
91        category: Category,
92        symbol: Option<&'a str>,
93        start_time: Option<u64>,
94        end_time: Option<u64>,
95        limit: Option<usize>,
96        cursor: Option<&'a str>,
97    ) -> Self {
98        Self {
99            category,
100            symbol: symbol.map(Cow::Borrowed),
101            start_time,
102            end_time,
103            limit,
104            cursor: cursor.map(Cow::Borrowed),
105        }
106    }
107
108    /// Creates a default ClosedOptionsPositions request.
109    ///
110    /// Returns a request with `category` set to `Option`, no symbol filter,
111    /// no time range (returns last 1 day by default), limit set to `50`, and no cursor.
112    /// Suitable for testing but should be customized for production analysis.
113    pub fn default() -> ClosedOptionsPositionsRequest<'a> {
114        ClosedOptionsPositionsRequest::new(Category::Option, None, None, None, Some(50), None)
115    }
116
117    /// Validates the request parameters according to Bybit API constraints.
118    ///
119    /// Bots should call this method before sending the request to ensure compliance with API limits.
120    ///
121    /// # Returns
122    /// * `Ok(())` if validation passes
123    /// * `Err(String)` with error message if validation fails
124    pub fn validate(&self) -> Result<(), String> {
125        // Category must be Option
126        if !matches!(self.category, Category::Option) {
127            return Err("Category must be 'option' for closed options positions".to_string());
128        }
129
130        // Validate limit range
131        if let Some(limit) = self.limit {
132            if limit < 1 || limit > 100 {
133                return Err("Limit must be between 1 and 100".to_string());
134            }
135        }
136
137        // Validate time range if both start_time and end_time are provided
138        if let (Some(start), Some(end)) = (self.start_time, self.end_time) {
139            if start >= end {
140                return Err("start_time must be less than end_time".to_string());
141            }
142
143            // Check if time range exceeds 7 days (604800000 milliseconds)
144            if end - start > 7 * 24 * 60 * 60 * 1000 {
145                return Err("Time range cannot exceed 7 days".to_string());
146            }
147        }
148
149        Ok(())
150    }
151
152    /// Sets the symbol filter for the request.
153    ///
154    /// Convenience method for updating the symbol filter.
155    ///
156    /// # Arguments
157    /// * `symbol` - The options symbol name
158    pub fn with_symbol(mut self, symbol: &'a str) -> Self {
159        self.symbol = Some(Cow::Borrowed(symbol));
160        self
161    }
162
163    /// Sets the time range for the request.
164    ///
165    /// Convenience method for updating both start and end times.
166    ///
167    /// # Arguments
168    /// * `start_time` - The start timestamp in milliseconds
169    /// * `end_time` - The end timestamp in milliseconds
170    pub fn with_time_range(mut self, start_time: u64, end_time: u64) -> Self {
171        self.start_time = Some(start_time);
172        self.end_time = Some(end_time);
173        self
174    }
175
176    /// Sets the limit for the request.
177    ///
178    /// Convenience method for updating the result limit.
179    ///
180    /// # Arguments
181    /// * `limit` - The maximum number of records to return (1-100)
182    pub fn with_limit(mut self, limit: usize) -> Self {
183        self.limit = Some(limit);
184        self
185    }
186
187    /// Sets the cursor for pagination.
188    ///
189    /// Convenience method for updating the pagination cursor.
190    ///
191    /// # Arguments
192    /// * `cursor` - The cursor string from previous response
193    pub fn with_cursor(mut self, cursor: &'a str) -> Self {
194        self.cursor = Some(Cow::Borrowed(cursor));
195        self
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_new() {
205        let request = ClosedOptionsPositionsRequest::new(
206            Category::Option,
207            Some("BTC-12JUN25-104019-C-USDT"),
208            Some(1749730000000),
209            Some(1749736000000),
210            Some(25),
211            Some("cursor_token"),
212        );
213
214        assert_eq!(request.category, Category::Option);
215        assert_eq!(request.symbol.unwrap(), "BTC-12JUN25-104019-C-USDT");
216        assert_eq!(request.start_time.unwrap(), 1749730000000);
217        assert_eq!(request.end_time.unwrap(), 1749736000000);
218        assert_eq!(request.limit.unwrap(), 25);
219        assert_eq!(request.cursor.unwrap(), "cursor_token");
220    }
221
222    #[test]
223    fn test_default() {
224        let request = ClosedOptionsPositionsRequest::default();
225        assert_eq!(request.category, Category::Option);
226        assert!(request.symbol.is_none());
227        assert!(request.start_time.is_none());
228        assert!(request.end_time.is_none());
229        assert_eq!(request.limit.unwrap(), 50);
230        assert!(request.cursor.is_none());
231    }
232
233    #[test]
234    fn test_validation() {
235        // Valid request
236        let valid_request =
237            ClosedOptionsPositionsRequest::new(Category::Option, None, None, None, Some(50), None);
238        assert!(valid_request.validate().is_ok());
239
240        // Invalid category
241        let invalid_category =
242            ClosedOptionsPositionsRequest::new(Category::Linear, None, None, None, Some(50), None);
243        assert!(invalid_category.validate().is_err());
244
245        // Invalid limit (too low)
246        let invalid_limit_low =
247            ClosedOptionsPositionsRequest::new(Category::Option, None, None, None, Some(0), None);
248        assert!(invalid_limit_low.validate().is_err());
249
250        // Invalid limit (too high)
251        let invalid_limit_high =
252            ClosedOptionsPositionsRequest::new(Category::Option, None, None, None, Some(101), None);
253        assert!(invalid_limit_high.validate().is_err());
254
255        // Invalid time range (start >= end)
256        let invalid_time_range = ClosedOptionsPositionsRequest::new(
257            Category::Option,
258            None,
259            Some(1749736000000),
260            Some(1749730000000),
261            Some(50),
262            None,
263        );
264        assert!(invalid_time_range.validate().is_err());
265
266        // Invalid time range (exceeds 7 days)
267        let invalid_time_range_long = ClosedOptionsPositionsRequest::new(
268            Category::Option,
269            None,
270            Some(1749730000000),
271            Some(1749730000000 + 8 * 24 * 60 * 60 * 1000), // 8 days later
272            Some(50),
273            None,
274        );
275        assert!(invalid_time_range_long.validate().is_err());
276    }
277
278    #[test]
279    fn test_builder_methods() {
280        let request = ClosedOptionsPositionsRequest::default()
281            .with_symbol("BTC-12JUN25-104019-C-USDT")
282            .with_time_range(1749730000000, 1749736000000)
283            .with_limit(25)
284            .with_cursor("cursor_token");
285
286        assert_eq!(request.category, Category::Option);
287        assert_eq!(request.symbol.unwrap(), "BTC-12JUN25-104019-C-USDT");
288        assert_eq!(request.start_time.unwrap(), 1749730000000);
289        assert_eq!(request.end_time.unwrap(), 1749736000000);
290        assert_eq!(request.limit.unwrap(), 25);
291        assert_eq!(request.cursor.unwrap(), "cursor_token");
292    }
293}