Skip to main content

nautilus_deribit/http/
client.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//! Deribit HTTP client implementation.
17
18use std::{
19    collections::HashMap,
20    str::FromStr,
21    sync::{
22        Arc,
23        atomic::{AtomicBool, AtomicU64, Ordering},
24    },
25};
26
27use ahash::{AHashMap, AHashSet};
28use jiff::Timestamp;
29use nautilus_common::cache::InstrumentLookupError;
30use nautilus_core::{
31    AtomicMap, AtomicTime, Params, datetime::nanos_to_millis, nanos::UnixNanos,
32    time::get_atomic_clock_realtime,
33};
34use nautilus_model::{
35    data::{Bar, BarType, TradeTick},
36    enums::{AggregationSource, BarAggregation},
37    events::AccountState,
38    identifiers::{AccountId, InstrumentId, Symbol},
39    instruments::{Instrument, InstrumentAny},
40    orderbook::OrderBook,
41    reports::{FillReport, OrderStatusReport, PositionStatusReport},
42};
43use nautilus_network::{
44    http::{HttpClient, HttpRedirectPolicy, Method, create_standard_nautilus_headers},
45    ratelimiter::quota::Quota,
46    retry::{RetryConfig, RetryError, RetryManager},
47};
48use serde::{Serialize, de::DeserializeOwned};
49use serde_json::json;
50use strum::IntoEnumIterator;
51use tokio_util::sync::CancellationToken;
52use ustr::Ustr;
53
54use super::{
55    error::DeribitHttpError,
56    models::{
57        DeribitAccountSummariesResponse, DeribitBookSummaryRaw, DeribitCombo, DeribitCurrency,
58        DeribitExpirationsResponse, DeribitInstrument, DeribitJsonRpcRequest,
59        DeribitJsonRpcResponse, DeribitPosition, DeribitProductType, DeribitTicker,
60        DeribitUserTradesResponse,
61    },
62    query::{
63        DeribitExpirationKind, GetAccountSummariesParams, GetBookSummaryByCurrencyParams,
64        GetCombosParams, GetExpirationsParams, GetInstrumentParams, GetInstrumentsParams,
65        GetOpenOrdersByInstrumentParams, GetOpenOrdersParams, GetOrderHistoryByCurrencyParams,
66        GetOrderHistoryByInstrumentParams, GetOrderStateParams, GetPositionsParams,
67        GetTickerParams, GetUserTradesByCurrencyAndTimeParams,
68        GetUserTradesByInstrumentAndTimeParams,
69    },
70};
71use crate::{
72    common::{
73        consts::{
74            DERIBIT_ACCOUNT_RATE_KEY, DERIBIT_API_PATH, DERIBIT_GLOBAL_RATE_KEY,
75            DERIBIT_HTTP_ACCOUNT_QUOTA, DERIBIT_HTTP_ORDER_QUOTA, DERIBIT_HTTP_REST_QUOTA,
76            DERIBIT_ORDER_RATE_KEY, DERIBIT_VENUE, JSONRPC_VERSION,
77        },
78        credential::{Credential, credential_env_vars},
79        enums::DeribitEnvironment,
80        parse::{
81            extract_server_timestamp, parse_account_state, parse_bars,
82            parse_deribit_instrument_any, parse_order_book, parse_trade_tick,
83            use_cost_for_bar_volume,
84        },
85        urls::get_http_base_url,
86    },
87    http::{
88        models::{DeribitOrderBook, DeribitTradesResponse, DeribitTradingViewChartData},
89        query::{
90            GetLastTradesByCurrencyParams, GetLastTradesByInstrumentAndTimeParams,
91            GetOrderBookParams, GetTradingViewChartDataParams,
92        },
93    },
94    websocket::{
95        messages::{DeribitOrderMsg, DeribitUserTradeMsg},
96        parse::{parse_position_status_report, parse_user_order_msg, parse_user_trade_msg},
97    },
98};
99
100/// Maximum number of trades per request for Deribit's historical trades API.
101/// Deribit's default is 10 which is insufficient for most use cases.
102/// The API maximum is 1000.
103pub const DERIBIT_HISTORICAL_TRADES_MAX_COUNT: u32 = 1000;
104
105// Dedup and cursor state for timestamp-based trade pagination.
106// Deribit provides no offset cursor, so when multiple trades share
107// one millisecond we use trade-ID dedup to avoid reprocessing.
108// If an entire page contains only seen IDs we advance past that
109// millisecond, which can skip trades when >1000 share one timestamp.
110struct TradePaginator {
111    seen_ids: AHashSet<String>,
112    cursor: i64,
113    end: i64,
114}
115
116impl TradePaginator {
117    fn new(start: i64, end: i64) -> Self {
118        Self {
119            seen_ids: AHashSet::new(),
120            cursor: start,
121            end,
122        }
123    }
124
125    // Returns indices of new (unseen) items and advances the cursor.
126    // Returns None when the page is empty (pagination should stop).
127    fn advance(
128        &mut self,
129        ids: &[String],
130        timestamps: &[i64],
131        has_more: bool,
132    ) -> Option<Vec<usize>> {
133        if ids.is_empty() {
134            return None;
135        }
136
137        let prev_seen = self.seen_ids.len();
138        let mut new_indices = Vec::new();
139        let mut last_ts = self.cursor;
140
141        for (i, id) in ids.iter().enumerate() {
142            last_ts = timestamps[i];
143
144            if self.seen_ids.insert(id.clone()) {
145                new_indices.push(i);
146            }
147        }
148
149        if !has_more {
150            return Some(new_indices);
151        }
152
153        let new_count = self.seen_ids.len() - prev_seen;
154
155        if new_count == 0 {
156            self.cursor = last_ts + 1;
157        } else {
158            self.cursor = last_ts;
159        }
160
161        Some(new_indices)
162    }
163
164    // Strict greater-than so pages at exactly end_ms are still
165    // fetched (Deribit treats start_timestamp as inclusive).
166    fn is_exhausted(&self) -> bool {
167        self.cursor > self.end
168    }
169
170    fn reset(&mut self, start: i64) {
171        self.seen_ids.clear();
172        self.cursor = start;
173    }
174}
175
176/// Low-level Deribit HTTP client for raw API operations.
177///
178/// This client handles JSON-RPC 2.0 protocol, request signing, rate limiting,
179/// and retry logic. It returns venue-specific response types.
180#[derive(Debug)]
181pub struct DeribitRawHttpClient {
182    base_url: String,
183    client: HttpClient,
184    credential: Option<Credential>,
185    retry_manager: RetryManager<DeribitHttpError>,
186    cancellation_token: CancellationToken,
187    request_id: AtomicU64,
188}
189
190impl DeribitRawHttpClient {
191    /// Creates a new [`DeribitRawHttpClient`].
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the HTTP client cannot be created.
196    pub fn new(
197        base_url: Option<String>,
198        environment: DeribitEnvironment,
199        timeout_secs: u64,
200        max_retries: u32,
201        retry_delay_ms: u64,
202        retry_delay_max_ms: u64,
203        proxy_url: Option<String>,
204    ) -> Result<Self, DeribitHttpError> {
205        let base_url = base_url
206            .unwrap_or_else(|| format!("{}{}", get_http_base_url(environment), DERIBIT_API_PATH));
207        let retry_config = RetryConfig {
208            max_retries,
209            initial_delay_ms: retry_delay_ms,
210            max_delay_ms: retry_delay_max_ms,
211            backoff_factor: 2.0,
212            jitter_ms: 1000,
213            operation_timeout_ms: Some(60_000),
214            immediate_first: false,
215            max_elapsed_ms: Some(180_000),
216        };
217
218        let retry_manager = RetryManager::new(retry_config);
219
220        Ok(Self {
221            base_url,
222            client: HttpClient::builder()
223                .headers(create_standard_nautilus_headers().into_iter().collect())
224                .keyed_quotas(Self::rate_limiter_quotas())
225                .default_quota(*DERIBIT_HTTP_REST_QUOTA)
226                .timeout_secs(timeout_secs)
227                .maybe_proxy_url(proxy_url)
228                .build()
229                .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?,
230            credential: None,
231            retry_manager,
232            cancellation_token: CancellationToken::new(),
233            request_id: AtomicU64::new(1),
234        })
235    }
236
237    /// Get the cancellation token for this client.
238    pub fn cancellation_token(&self) -> &CancellationToken {
239        &self.cancellation_token
240    }
241
242    /// Returns whether this client is connected to testnet.
243    #[must_use]
244    pub fn is_testnet(&self) -> bool {
245        self.base_url.contains("test.")
246    }
247
248    /// Returns the rate limiter quotas for the HTTP client.
249    ///
250    /// Quotas are organized by:
251    /// - Global: Overall rate limit for all requests
252    /// - Orders: Matching engine operations (buy, sell, cancel, etc.)
253    /// - Account: Account information endpoints
254    fn rate_limiter_quotas() -> Vec<(String, Quota)> {
255        vec![
256            (
257                DERIBIT_GLOBAL_RATE_KEY.to_string(),
258                *DERIBIT_HTTP_REST_QUOTA,
259            ),
260            (
261                DERIBIT_ORDER_RATE_KEY.to_string(),
262                *DERIBIT_HTTP_ORDER_QUOTA,
263            ),
264            (
265                DERIBIT_ACCOUNT_RATE_KEY.to_string(),
266                *DERIBIT_HTTP_ACCOUNT_QUOTA,
267            ),
268        ]
269    }
270
271    /// Returns rate limit keys for a given RPC method.
272    ///
273    /// Maps Deribit JSON-RPC methods to appropriate rate limit buckets.
274    fn rate_limit_keys(method: &str) -> Vec<String> {
275        let mut keys = vec![DERIBIT_GLOBAL_RATE_KEY.to_string()];
276
277        // Categorize by method type
278        if Self::is_order_method(method) {
279            keys.push(DERIBIT_ORDER_RATE_KEY.to_string());
280        } else if Self::is_account_method(method) {
281            keys.push(DERIBIT_ACCOUNT_RATE_KEY.to_string());
282        }
283
284        // Add method-specific key
285        keys.push(format!("deribit:{method}"));
286
287        keys
288    }
289
290    /// Returns true if the method is an order operation (matching engine).
291    fn is_order_method(method: &str) -> bool {
292        matches!(
293            method,
294            "private/buy"
295                | "private/sell"
296                | "private/edit"
297                | "private/cancel"
298                | "private/cancel_all"
299                | "private/cancel_all_by_currency"
300                | "private/cancel_all_by_instrument"
301                | "private/cancel_by_label"
302                | "private/close_position"
303        )
304    }
305
306    /// Returns true if the method accesses account information.
307    fn is_account_method(method: &str) -> bool {
308        matches!(
309            method,
310            "private/get_account_summaries"
311                | "private/get_account_summary"
312                | "private/get_positions"
313                | "private/get_position"
314                | "private/get_open_orders_by_currency"
315                | "private/get_open_orders_by_instrument"
316                | "private/get_order_state"
317                | "private/get_user_trades_by_currency"
318                | "private/get_user_trades_by_instrument"
319        )
320    }
321
322    /// Creates a new [`DeribitRawHttpClient`] with explicit credentials.
323    ///
324    /// # Errors
325    ///
326    /// Returns an error if the HTTP client cannot be created.
327    #[expect(clippy::too_many_arguments)]
328    pub fn with_credentials(
329        api_key: String,
330        api_secret: String,
331        base_url: Option<String>,
332        environment: DeribitEnvironment,
333        timeout_secs: u64,
334        max_retries: u32,
335        retry_delay_ms: u64,
336        retry_delay_max_ms: u64,
337        proxy_url: Option<String>,
338    ) -> Result<Self, DeribitHttpError> {
339        let base_url = base_url
340            .unwrap_or_else(|| format!("{}{}", get_http_base_url(environment), DERIBIT_API_PATH));
341        let retry_config = RetryConfig {
342            max_retries,
343            initial_delay_ms: retry_delay_ms,
344            max_delay_ms: retry_delay_max_ms,
345            backoff_factor: 2.0,
346            jitter_ms: 1000,
347            operation_timeout_ms: Some(60_000),
348            immediate_first: false,
349            max_elapsed_ms: Some(180_000),
350        };
351
352        let retry_manager = RetryManager::new(retry_config);
353        let credential = Credential::new(api_key, api_secret);
354
355        Ok(Self {
356            base_url,
357            client: HttpClient::builder()
358                .redirect_policy(HttpRedirectPolicy::Reject)
359                .headers(create_standard_nautilus_headers().into_iter().collect())
360                .keyed_quotas(Self::rate_limiter_quotas())
361                .default_quota(*DERIBIT_HTTP_REST_QUOTA)
362                .timeout_secs(timeout_secs)
363                .maybe_proxy_url(proxy_url)
364                .build()
365                .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?,
366            credential: Some(credential),
367            retry_manager,
368            cancellation_token: CancellationToken::new(),
369            request_id: AtomicU64::new(1),
370        })
371    }
372
373    /// Creates a new [`DeribitRawHttpClient`] with credentials from environment variables.
374    ///
375    /// If `api_key` or `api_secret` are not provided, they will be loaded from environment:
376    /// - Mainnet: `DERIBIT_API_KEY`, `DERIBIT_API_SECRET`
377    /// - Testnet: `DERIBIT_TESTNET_API_KEY`, `DERIBIT_TESTNET_API_SECRET`
378    ///
379    /// # Errors
380    ///
381    /// Returns an error if:
382    /// - The HTTP client cannot be created.
383    /// - Credentials are not provided and environment variables are not set.
384    #[expect(clippy::too_many_arguments)]
385    pub fn new_with_env(
386        api_key: Option<String>,
387        api_secret: Option<String>,
388        base_url: Option<String>,
389        environment: DeribitEnvironment,
390        timeout_secs: u64,
391        max_retries: u32,
392        retry_delay_ms: u64,
393        retry_delay_max_ms: u64,
394        proxy_url: Option<String>,
395    ) -> Result<Self, DeribitHttpError> {
396        // Determine environment variable names based on environment
397        let (key_env, secret_env) = credential_env_vars(environment);
398
399        // Resolve credentials from explicit params or environment
400        let api_key = nautilus_core::env::get_or_env_var_opt(api_key, key_env);
401        let api_secret = nautilus_core::env::get_or_env_var_opt(api_secret, secret_env);
402
403        // If credentials were resolved, create authenticated client
404        if let (Some(key), Some(secret)) = (api_key, api_secret) {
405            Self::with_credentials(
406                key,
407                secret,
408                base_url,
409                environment,
410                timeout_secs,
411                max_retries,
412                retry_delay_ms,
413                retry_delay_max_ms,
414                proxy_url,
415            )
416        } else {
417            // No credentials - create unauthenticated client
418            Self::new(
419                base_url,
420                environment,
421                timeout_secs,
422                max_retries,
423                retry_delay_ms,
424                retry_delay_max_ms,
425                proxy_url,
426            )
427        }
428    }
429
430    /// Sends a JSON-RPC 2.0 request to the Deribit API.
431    async fn send_request<T, P>(
432        &self,
433        method: &str,
434        params: P,
435        authenticate: bool,
436    ) -> Result<DeribitJsonRpcResponse<T>, DeribitHttpError>
437    where
438        T: DeserializeOwned,
439        P: Serialize,
440    {
441        // Create operation identifier combining URL and RPC method
442        let operation_id = format!("{}#{}", self.base_url, method);
443        let params_clone = serde_json::to_value(&params)?;
444
445        let operation = || {
446            let method = method.to_string();
447            let params_clone = params_clone.clone();
448
449            async move {
450                // Build JSON-RPC request
451                let id = self.request_id.fetch_add(1, Ordering::SeqCst);
452                let request = DeribitJsonRpcRequest {
453                    jsonrpc: JSONRPC_VERSION,
454                    id,
455                    method: method.clone(),
456                    params: params_clone.clone(),
457                };
458
459                let body = serde_json::to_vec(&request)?;
460
461                // Build headers
462                let mut headers = HashMap::new();
463                headers.insert("Content-Type".to_string(), "application/json".to_string());
464
465                // Add authentication headers if required
466                if authenticate {
467                    let credentials = self
468                        .credential
469                        .as_ref()
470                        .ok_or(DeribitHttpError::MissingCredentials)?;
471                    let auth_headers = credentials.sign_auth_headers("POST", "/api/v2", &body)?;
472                    headers.extend(auth_headers);
473                }
474
475                let rate_limit_keys = Self::rate_limit_keys(&method);
476                let resp = self
477                    .client
478                    .request(
479                        Method::POST,
480                        self.base_url.clone(),
481                        None,
482                        Some(headers),
483                        Some(body),
484                        None,
485                        Some(rate_limit_keys),
486                    )
487                    .await
488                    .map_err(|e| DeribitHttpError::NetworkError(e.to_string()))?;
489
490                // Parse JSON-RPC response
491                // Note: Deribit may return JSON-RPC errors with non-2xx HTTP status (e.g., 400)
492                // Always try to parse as JSON-RPC first, then fall back to HTTP error handling
493
494                // Decode the body directly so exact decimal fields read the raw numeric tokens
495                let json_rpc_response: DeribitJsonRpcResponse<T> = match serde_json::from_slice(
496                    &resp.body,
497                ) {
498                    Ok(response) => response,
499                    Err(e) if e.is_data() => {
500                        log::warn!(
501                            "Failed to deserialize Deribit JSON-RPC response: method={method}, status={}, error={e}",
502                            resp.status.as_u16()
503                        );
504                        log::debug!(
505                            "Response JSON (first 2000 chars): {}",
506                            String::from_utf8_lossy(&resp.body)
507                                .chars()
508                                .take(2000)
509                                .collect::<String>()
510                        );
511                        return Err(DeribitHttpError::JsonError(e.to_string()));
512                    }
513                    Err(_) => {
514                        // Not valid JSON - treat as HTTP error
515                        let error_body = String::from_utf8_lossy(&resp.body);
516                        log::warn!(
517                            "Non-JSON response: method={method}, status={}, body={error_body}",
518                            resp.status.as_u16()
519                        );
520                        return Err(DeribitHttpError::UnexpectedStatus {
521                            status: resp.status.as_u16(),
522                            body: error_body.to_string(),
523                        });
524                    }
525                };
526
527                // Check if it's a success or error result
528                if json_rpc_response.result.is_some() {
529                    Ok(json_rpc_response)
530                } else if let Some(error) = &json_rpc_response.error {
531                    // JSON-RPC error (may come with any HTTP status)
532                    log::warn!(
533                        "Deribit RPC error response: method={method}, http_status={}, error_code={}, error_message={}, error_data={:?}",
534                        resp.status.as_u16(),
535                        error.code,
536                        error.message,
537                        error.data
538                    );
539
540                    // Map JSON-RPC error to appropriate error variant
541                    Err(DeribitHttpError::from_jsonrpc_error(
542                        error.code,
543                        error.message.clone(),
544                        error.data.as_ref(),
545                    ))
546                } else {
547                    log::warn!(
548                        "Response contains neither result nor error field: method={method}, status={}, request_id={:?}",
549                        resp.status.as_u16(),
550                        json_rpc_response.id
551                    );
552                    Err(DeribitHttpError::JsonError(
553                        "Response contains neither result nor error".to_string(),
554                    ))
555                }
556            }
557        };
558
559        // Retry strategy based on Deribit error responses and HTTP status codes:
560        //
561        // 1. Network errors: always retry (transient connection issues)
562        // 2. HTTP 5xx/429: server errors and rate limiting should be retried
563        // 3. Deribit-specific retryable error codes (defined in common::consts)
564        //
565        // Note: Deribit returns many permanent errors which should NOT be retried
566        // (e.g., "invalid_credentials", "not_enough_funds", "order_not_found")
567        let should_retry = |error: &DeribitHttpError| -> bool { error.is_retryable() };
568
569        let create_error = |error: RetryError| -> DeribitHttpError {
570            match error {
571                RetryError::Canceled => {
572                    DeribitHttpError::Canceled("Adapter disconnecting or shutting down".to_string())
573                }
574                error => DeribitHttpError::NetworkError(error.to_string()),
575            }
576        };
577
578        let result = self
579            .retry_manager
580            .invocation(&operation_id, operation, should_retry, create_error)
581            .cancellation_token(&self.cancellation_token)
582            .execute()
583            .await;
584
585        if let Err(ref e) = result
586            && e.is_retryable()
587        {
588            log::warn!("Request exhausted retries: method={method}, error={e}");
589        }
590
591        result
592    }
593
594    /// Gets available trading instruments.
595    ///
596    /// # Errors
597    ///
598    /// Returns an error if the request fails or the response cannot be parsed.
599    pub async fn get_instruments(
600        &self,
601        params: GetInstrumentsParams,
602    ) -> Result<DeribitJsonRpcResponse<Vec<DeribitInstrument>>, DeribitHttpError> {
603        self.send_request("public/get_instruments", params, false)
604            .await
605    }
606
607    /// Gets details for a specific trading instrument.
608    ///
609    /// # Errors
610    ///
611    /// Returns an error if the request fails or the response cannot be parsed.
612    pub async fn get_instrument(
613        &self,
614        params: GetInstrumentParams,
615    ) -> Result<DeribitJsonRpcResponse<DeribitInstrument>, DeribitHttpError> {
616        self.send_request("public/get_instrument", params, false)
617            .await
618    }
619
620    /// Gets combo definitions for a currency.
621    ///
622    /// # Errors
623    ///
624    /// Returns an error if the request fails or the response cannot be parsed.
625    pub async fn get_combos(
626        &self,
627        params: GetCombosParams,
628    ) -> Result<DeribitJsonRpcResponse<Vec<DeribitCombo>>, DeribitHttpError> {
629        self.send_request("public/get_combos", params, false).await
630    }
631
632    /// Gets recent trades for an instrument within a time range.
633    ///
634    /// # Errors
635    ///
636    /// Returns an error if the request fails or the response cannot be parsed.
637    pub async fn get_last_trades_by_instrument_and_time(
638        &self,
639        params: GetLastTradesByInstrumentAndTimeParams,
640    ) -> Result<DeribitJsonRpcResponse<DeribitTradesResponse>, DeribitHttpError> {
641        self.send_request(
642            "public/get_last_trades_by_instrument_and_time",
643            params,
644            false,
645        )
646        .await
647    }
648
649    /// Gets recent trades for a currency, optionally filtered by product kind.
650    ///
651    /// The instrument-and-time variant accepts combo instrument names, but
652    /// this currency-scoped endpoint is the only way to sweep trades across
653    /// all combos of a given kind (e.g., every BTC future combo) in one call.
654    ///
655    /// # Errors
656    ///
657    /// Returns an error if the request fails or the response cannot be parsed.
658    pub async fn get_last_trades_by_currency(
659        &self,
660        params: GetLastTradesByCurrencyParams,
661    ) -> Result<DeribitJsonRpcResponse<DeribitTradesResponse>, DeribitHttpError> {
662        self.send_request("public/get_last_trades_by_currency", params, false)
663            .await
664    }
665
666    /// Gets traded expirations by currency and instrument kind.
667    ///
668    /// # Errors
669    ///
670    /// Returns an error if the request fails or the response cannot be parsed.
671    pub async fn get_expirations(
672        &self,
673        params: GetExpirationsParams,
674    ) -> Result<DeribitJsonRpcResponse<DeribitExpirationsResponse>, DeribitHttpError> {
675        self.send_request("public/get_expirations", params, false)
676            .await
677    }
678
679    /// Gets TradingView chart data (OHLCV) for an instrument.
680    ///
681    /// # Errors
682    ///
683    /// Returns an error if the request fails or the response cannot be parsed.
684    pub async fn get_tradingview_chart_data(
685        &self,
686        params: GetTradingViewChartDataParams,
687    ) -> Result<DeribitJsonRpcResponse<DeribitTradingViewChartData>, DeribitHttpError> {
688        self.send_request("public/get_tradingview_chart_data", params, false)
689            .await
690    }
691
692    /// Gets account summaries for all currencies.
693    ///
694    /// # Errors
695    ///
696    /// Returns an error if:
697    /// - Credentials are missing.
698    /// - Authentication fails (invalid signature, expired timestamp).
699    /// - The request fails or the response cannot be parsed.
700    pub async fn get_account_summaries(
701        &self,
702        params: GetAccountSummariesParams,
703    ) -> Result<DeribitJsonRpcResponse<DeribitAccountSummariesResponse>, DeribitHttpError> {
704        self.send_request("private/get_account_summaries", params, true)
705            .await
706    }
707
708    /// Gets order book for an instrument.
709    ///
710    /// # Errors
711    ///
712    /// Returns an error if the request fails or the response cannot be parsed.
713    pub async fn get_order_book(
714        &self,
715        params: GetOrderBookParams,
716    ) -> Result<DeribitJsonRpcResponse<DeribitOrderBook>, DeribitHttpError> {
717        self.send_request("public/get_order_book", params, false)
718            .await
719    }
720
721    /// Gets a single order by its ID.
722    ///
723    /// # Errors
724    ///
725    /// Returns an error if:
726    /// - Credentials are missing.
727    /// - Authentication fails (invalid signature, expired timestamp).
728    /// - The request fails or the response cannot be parsed.
729    pub async fn get_order_state(
730        &self,
731        params: GetOrderStateParams,
732    ) -> Result<DeribitJsonRpcResponse<DeribitOrderMsg>, DeribitHttpError> {
733        self.send_request("private/get_order_state", params, true)
734            .await
735    }
736
737    /// Gets all open orders across all currencies and instruments.
738    ///
739    /// # Errors
740    ///
741    /// Returns an error if:
742    /// - Credentials are missing.
743    /// - Authentication fails (invalid signature, expired timestamp).
744    /// - The request fails or the response cannot be parsed.
745    pub async fn get_open_orders(
746        &self,
747        params: GetOpenOrdersParams,
748    ) -> Result<DeribitJsonRpcResponse<Vec<DeribitOrderMsg>>, DeribitHttpError> {
749        self.send_request("private/get_open_orders", params, true)
750            .await
751    }
752
753    /// Gets open orders for a specific instrument.
754    ///
755    /// # Errors
756    ///
757    /// Returns an error if:
758    /// - Credentials are missing.
759    /// - Authentication fails (invalid signature, expired timestamp).
760    /// - The request fails or the response cannot be parsed.
761    pub async fn get_open_orders_by_instrument(
762        &self,
763        params: GetOpenOrdersByInstrumentParams,
764    ) -> Result<DeribitJsonRpcResponse<Vec<DeribitOrderMsg>>, DeribitHttpError> {
765        self.send_request("private/get_open_orders_by_instrument", params, true)
766            .await
767    }
768
769    /// Gets historical orders for a specific instrument.
770    ///
771    /// # Errors
772    ///
773    /// Returns an error if:
774    /// - Credentials are missing.
775    /// - Authentication fails (invalid signature, expired timestamp).
776    /// - The request fails or the response cannot be parsed.
777    pub async fn get_order_history_by_instrument(
778        &self,
779        params: GetOrderHistoryByInstrumentParams,
780    ) -> Result<DeribitJsonRpcResponse<Vec<DeribitOrderMsg>>, DeribitHttpError> {
781        self.send_request("private/get_order_history_by_instrument", params, true)
782            .await
783    }
784
785    /// Gets historical orders for a specific currency.
786    ///
787    /// # Errors
788    ///
789    /// Returns an error if:
790    /// - Credentials are missing.
791    /// - Authentication fails (invalid signature, expired timestamp).
792    /// - The request fails or the response cannot be parsed.
793    pub async fn get_order_history_by_currency(
794        &self,
795        params: GetOrderHistoryByCurrencyParams,
796    ) -> Result<DeribitJsonRpcResponse<Vec<DeribitOrderMsg>>, DeribitHttpError> {
797        self.send_request("private/get_order_history_by_currency", params, true)
798            .await
799    }
800
801    /// Gets user trades for a specific instrument within a time range.
802    ///
803    /// # Errors
804    ///
805    /// Returns an error if:
806    /// - Credentials are missing.
807    /// - Authentication fails (invalid signature, expired timestamp).
808    /// - The request fails or the response cannot be parsed.
809    pub async fn get_user_trades_by_instrument_and_time(
810        &self,
811        params: GetUserTradesByInstrumentAndTimeParams,
812    ) -> Result<DeribitJsonRpcResponse<DeribitUserTradesResponse>, DeribitHttpError> {
813        self.send_request(
814            "private/get_user_trades_by_instrument_and_time",
815            params,
816            true,
817        )
818        .await
819    }
820
821    /// Gets user trades for a specific currency within a time range.
822    ///
823    /// # Errors
824    ///
825    /// Returns an error if:
826    /// - Credentials are missing.
827    /// - Authentication fails (invalid signature, expired timestamp).
828    /// - The request fails or the response cannot be parsed.
829    pub async fn get_user_trades_by_currency_and_time(
830        &self,
831        params: GetUserTradesByCurrencyAndTimeParams,
832    ) -> Result<DeribitJsonRpcResponse<DeribitUserTradesResponse>, DeribitHttpError> {
833        self.send_request("private/get_user_trades_by_currency_and_time", params, true)
834            .await
835    }
836
837    /// Gets book summaries for all instruments of a given currency.
838    ///
839    /// # Errors
840    ///
841    /// Returns an error if the request fails or the response cannot be parsed.
842    pub async fn get_book_summary_by_currency(
843        &self,
844        params: GetBookSummaryByCurrencyParams,
845    ) -> Result<DeribitJsonRpcResponse<Vec<DeribitBookSummaryRaw>>, DeribitHttpError> {
846        self.send_request("public/get_book_summary_by_currency", params, false)
847            .await
848    }
849
850    /// Gets ticker data for a single instrument.
851    ///
852    /// # Errors
853    ///
854    /// Returns an error if the request fails or the response cannot be parsed.
855    pub async fn get_ticker(
856        &self,
857        params: GetTickerParams,
858    ) -> Result<DeribitJsonRpcResponse<DeribitTicker>, DeribitHttpError> {
859        self.send_request("public/ticker", params, false).await
860    }
861
862    /// Gets positions for a specific currency.
863    ///
864    /// # Errors
865    ///
866    /// Returns an error if:
867    /// - Credentials are missing.
868    /// - Authentication fails (invalid signature, expired timestamp).
869    /// - The request fails or the response cannot be parsed.
870    pub async fn get_positions(
871        &self,
872        params: GetPositionsParams,
873    ) -> Result<DeribitJsonRpcResponse<Vec<DeribitPosition>>, DeribitHttpError> {
874        self.send_request("private/get_positions", params, true)
875            .await
876    }
877}
878
879/// High-level Deribit HTTP client with domain-level abstractions.
880///
881/// This client wraps the raw HTTP client and provides methods that use Nautilus
882/// domain types. It maintains an instrument cache for efficient lookups.
883#[derive(Debug)]
884#[cfg_attr(
885    feature = "python",
886    pyo3::pyclass(module = "nautilus_trader.adapters.deribit", from_py_object)
887)]
888#[cfg_attr(
889    feature = "python",
890    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.deribit")
891)]
892pub struct DeribitHttpClient {
893    pub(crate) inner: Arc<DeribitRawHttpClient>,
894    pub(crate) instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
895    clock: &'static AtomicTime,
896    cache_initialized: AtomicBool,
897}
898
899impl Clone for DeribitHttpClient {
900    fn clone(&self) -> Self {
901        let cache_initialized = AtomicBool::new(false);
902
903        let is_initialized = self.cache_initialized.load(Ordering::Acquire);
904        if is_initialized {
905            cache_initialized.store(true, Ordering::Release);
906        }
907
908        Self {
909            inner: self.inner.clone(),
910            instruments_cache: self.instruments_cache.clone(),
911            cache_initialized,
912            clock: self.clock,
913        }
914    }
915}
916
917impl DeribitHttpClient {
918    /// Returns a reference to the underlying raw HTTP client.
919    ///
920    /// Exposes low-level endpoint methods (e.g., `get_last_trades_by_currency`)
921    /// that this wrapper does not yet adapt.
922    #[must_use]
923    pub fn inner(&self) -> &DeribitRawHttpClient {
924        &self.inner
925    }
926
927    /// Creates a new [`DeribitHttpClient`] with default configuration.
928    ///
929    /// # Parameters
930    /// - `base_url`: Optional custom base URL (for testing)
931    /// - `environment`: The Deribit environment to connect to
932    ///
933    /// # Errors
934    ///
935    /// Returns an error if the HTTP client cannot be created.
936    pub fn new(
937        base_url: Option<String>,
938        environment: DeribitEnvironment,
939        timeout_secs: u64,
940        max_retries: u32,
941        retry_delay_ms: u64,
942        retry_delay_max_ms: u64,
943        proxy_url: Option<String>,
944    ) -> anyhow::Result<Self> {
945        let raw_client = Arc::new(DeribitRawHttpClient::new(
946            base_url,
947            environment,
948            timeout_secs,
949            max_retries,
950            retry_delay_ms,
951            retry_delay_max_ms,
952            proxy_url,
953        )?);
954
955        Ok(Self {
956            inner: raw_client,
957            instruments_cache: Arc::new(AtomicMap::new()),
958            cache_initialized: AtomicBool::new(false),
959            clock: get_atomic_clock_realtime(),
960        })
961    }
962
963    /// Creates a new [`DeribitHttpClient`] with credentials from environment variables.
964    ///
965    /// If `api_key` or `api_secret` are not provided, they will be loaded from environment:
966    /// - Mainnet: `DERIBIT_API_KEY`, `DERIBIT_API_SECRET`
967    /// - Testnet: `DERIBIT_TESTNET_API_KEY`, `DERIBIT_TESTNET_API_SECRET`
968    ///
969    /// # Errors
970    ///
971    /// Returns an error if:
972    /// - The HTTP client cannot be created.
973    /// - Credentials are not provided and environment variables are not set.
974    #[expect(clippy::too_many_arguments)]
975    pub fn new_with_env(
976        api_key: Option<String>,
977        api_secret: Option<String>,
978        base_url: Option<String>,
979        environment: DeribitEnvironment,
980        timeout_secs: u64,
981        max_retries: u32,
982        retry_delay_ms: u64,
983        retry_delay_max_ms: u64,
984        proxy_url: Option<String>,
985    ) -> anyhow::Result<Self> {
986        let raw_client = Arc::new(DeribitRawHttpClient::new_with_env(
987            api_key,
988            api_secret,
989            base_url,
990            environment,
991            timeout_secs,
992            max_retries,
993            retry_delay_ms,
994            retry_delay_max_ms,
995            proxy_url,
996        )?);
997
998        Ok(Self {
999            inner: raw_client,
1000            instruments_cache: Arc::new(AtomicMap::new()),
1001            cache_initialized: AtomicBool::new(false),
1002            clock: get_atomic_clock_realtime(),
1003        })
1004    }
1005
1006    /// Requests instruments for a specific currency.
1007    ///
1008    /// # Errors
1009    ///
1010    /// Returns an error if the request fails or instruments cannot be parsed.
1011    pub async fn request_instruments(
1012        &self,
1013        currency: DeribitCurrency,
1014        product_type: Option<DeribitProductType>,
1015    ) -> anyhow::Result<Vec<InstrumentAny>> {
1016        // Build parameters
1017        let params = if let Some(pt) = product_type {
1018            GetInstrumentsParams::with_kind(currency, pt)
1019        } else {
1020            GetInstrumentsParams::new(currency)
1021        };
1022
1023        // Call raw client
1024        let full_response = self.inner.get_instruments(params).await?;
1025        let result = full_response
1026            .result
1027            .ok_or_else(|| anyhow::anyhow!("No result in response"))?;
1028        let ts_event = extract_server_timestamp(full_response.us_out)?;
1029        let ts_init = self.generate_ts_init();
1030        let combo_by_id = self.combo_map_for_instruments(currency, &result).await;
1031
1032        // Parse each instrument
1033        let mut instruments = Vec::new();
1034        let mut skipped_count = 0;
1035        let mut error_count = 0;
1036
1037        for raw_instrument in result {
1038            match parse_deribit_instrument_any(&raw_instrument, ts_init, ts_event) {
1039                Ok(Some(mut instrument)) => {
1040                    if let Some(combo) = combo_by_id.get(&raw_instrument.instrument_name) {
1041                        Self::attach_combo_leg_info(&mut instrument, combo);
1042                    }
1043                    instruments.push(instrument);
1044                }
1045                Ok(None) => {
1046                    // Unsupported instrument type (e.g., combos)
1047                    skipped_count += 1;
1048                    log::debug!(
1049                        "Skipped unsupported instrument type: {} (kind: {:?})",
1050                        raw_instrument.instrument_name,
1051                        raw_instrument.kind
1052                    );
1053                }
1054                Err(e) => {
1055                    error_count += 1;
1056                    log::warn!(
1057                        "Failed to parse instrument {}: {}",
1058                        raw_instrument.instrument_name,
1059                        e
1060                    );
1061                }
1062            }
1063        }
1064
1065        log::debug!(
1066            "Parsed {} instruments ({} skipped, {} errors)",
1067            instruments.len(),
1068            skipped_count,
1069            error_count
1070        );
1071
1072        Ok(instruments)
1073    }
1074
1075    /// Requests a specific instrument by its Nautilus instrument ID.
1076    ///
1077    /// This is a high-level method that fetches the raw instrument data from Deribit
1078    /// and converts it to a Nautilus `InstrumentAny` type.
1079    ///
1080    /// # Errors
1081    ///
1082    /// Returns an error if:
1083    /// - The instrument name format is invalid (error code `-32602`).
1084    /// - The instrument doesn't exist (error code `13020`).
1085    /// - Network or API errors occur.
1086    pub async fn request_instrument(
1087        &self,
1088        instrument_id: InstrumentId,
1089    ) -> anyhow::Result<InstrumentAny> {
1090        let params = GetInstrumentParams {
1091            instrument_name: instrument_id.symbol.to_string(),
1092        };
1093
1094        let full_response = self.inner.get_instrument(params).await?;
1095        let response = full_response
1096            .result
1097            .ok_or_else(|| anyhow::anyhow!("No result in response"))?;
1098        let ts_event = extract_server_timestamp(full_response.us_out)?;
1099        let ts_init = self.generate_ts_init();
1100
1101        match parse_deribit_instrument_any(&response, ts_init, ts_event)? {
1102            Some(mut instrument) => {
1103                if Self::is_combo_kind(response.kind) {
1104                    let currency = DeribitCurrency::from_str(response.base_currency.as_str())
1105                        .unwrap_or(DeribitCurrency::ANY);
1106                    let combo_by_id = self
1107                        .combo_map_for_instruments(currency, std::slice::from_ref(&response))
1108                        .await;
1109
1110                    if let Some(combo) = combo_by_id.get(&response.instrument_name) {
1111                        Self::attach_combo_leg_info(&mut instrument, combo);
1112                    }
1113                }
1114
1115                Ok(instrument)
1116            }
1117            None => anyhow::bail!(
1118                "Unsupported instrument type: {} (kind: {:?})",
1119                response.instrument_name,
1120                response.kind
1121            ),
1122        }
1123    }
1124
1125    async fn combo_map_for_instruments(
1126        &self,
1127        requested_currency: DeribitCurrency,
1128        raw_instruments: &[DeribitInstrument],
1129    ) -> AHashMap<Ustr, DeribitCombo> {
1130        if !raw_instruments
1131            .iter()
1132            .any(|instrument| Self::is_combo_kind(instrument.kind))
1133        {
1134            return AHashMap::new();
1135        }
1136
1137        let mut currencies = AHashSet::new();
1138
1139        if requested_currency == DeribitCurrency::ANY {
1140            for instrument in raw_instruments
1141                .iter()
1142                .filter(|instrument| Self::is_combo_kind(instrument.kind))
1143            {
1144                if let Ok(currency) = DeribitCurrency::from_str(instrument.base_currency.as_str()) {
1145                    currencies.insert(currency);
1146                }
1147            }
1148        } else {
1149            currencies.insert(requested_currency);
1150        }
1151
1152        let mut combo_by_id = AHashMap::new();
1153
1154        for currency in currencies {
1155            match self.inner.get_combos(GetCombosParams::new(currency)).await {
1156                Ok(response) => {
1157                    if let Some(combos) = response.result {
1158                        for combo in combos {
1159                            combo_by_id.insert(combo.id, combo);
1160                        }
1161                    }
1162                }
1163                Err(e) => {
1164                    log::warn!("Failed to load Deribit combo definitions for {currency}: {e}");
1165                }
1166            }
1167        }
1168
1169        combo_by_id
1170    }
1171
1172    fn is_combo_kind(kind: DeribitProductType) -> bool {
1173        matches!(
1174            kind,
1175            DeribitProductType::FutureCombo | DeribitProductType::OptionCombo
1176        )
1177    }
1178
1179    fn attach_combo_leg_info(instrument: &mut InstrumentAny, combo: &DeribitCombo) {
1180        if let Some(info) = Self::combo_leg_info(instrument, combo) {
1181            match instrument {
1182                InstrumentAny::CryptoOptionSpread(spread) => spread.info = Some(info),
1183                InstrumentAny::CryptoFuturesSpread(spread) => spread.info = Some(info),
1184                _ => {}
1185            }
1186        }
1187    }
1188
1189    fn combo_leg_info(instrument: &InstrumentAny, combo: &DeribitCombo) -> Option<Params> {
1190        let existing_info = match instrument {
1191            InstrumentAny::CryptoOptionSpread(spread) => spread.info.clone(),
1192            InstrumentAny::CryptoFuturesSpread(spread) => spread.info.clone(),
1193            _ => return None,
1194        };
1195
1196        let mut info = existing_info.unwrap_or_default();
1197        let legs = combo
1198            .legs
1199            .iter()
1200            .map(|leg| {
1201                let instrument_id =
1202                    InstrumentId::new(Symbol::new(leg.instrument_name), *DERIBIT_VENUE);
1203
1204                json!({
1205                    "amount": leg.amount,
1206                    "instrument_id": instrument_id.to_string(),
1207                    "instrument_name": leg.instrument_name,
1208                })
1209            })
1210            .collect::<Vec<_>>();
1211
1212        info.insert("deribit_combo_id".to_string(), json!(combo.id));
1213        info.insert(
1214            "deribit_combo_state".to_string(),
1215            json!(combo.state.as_str()),
1216        );
1217        info.insert("deribit_combo_legs".to_string(), json!(legs));
1218
1219        Some(info)
1220    }
1221
1222    /// Requests historical trades for an instrument within a time range.
1223    ///
1224    /// Fetches trade ticks from Deribit and converts them to Nautilus [`TradeTick`] objects.
1225    ///
1226    /// # Arguments
1227    ///
1228    /// * `instrument_id` - The instrument to fetch trades for
1229    /// * `start` - Optional start time filter
1230    /// * `end` - Optional end time filter
1231    /// * `limit` - Optional limit on number of trades (max 1000)
1232    ///
1233    /// # Errors
1234    ///
1235    /// Returns an error if:
1236    /// - The instrument is not found in cache.
1237    /// - The request fails.
1238    /// - Trade parsing fails.
1239    ///
1240    /// # Pagination
1241    ///
1242    /// When `limit` is `None`, this function automatically paginates through all available
1243    /// trades in the time range using the `has_more` field from the API response.
1244    /// When `limit` is specified, pagination stops once that many trades are collected.
1245    pub async fn request_trades(
1246        &self,
1247        instrument_id: InstrumentId,
1248        start: Option<Timestamp>,
1249        end: Option<Timestamp>,
1250        limit: Option<u32>,
1251    ) -> anyhow::Result<Vec<TradeTick>> {
1252        // Get instrument from cache to determine precisions
1253        let (price_precision, size_precision) =
1254            if let Some(instrument) = self.get_instrument(&instrument_id.symbol.inner()) {
1255                (instrument.price_precision(), instrument.size_precision())
1256            } else {
1257                log::warn!("Instrument {instrument_id} not in cache, skipping trades request");
1258                return Err(InstrumentLookupError::not_found(instrument_id).into());
1259            };
1260
1261        // Convert timestamps to milliseconds
1262        let now = Timestamp::now();
1263        let end_dt = end.unwrap_or(now);
1264        let start_dt = start.unwrap_or(end_dt - jiff::SignedDuration::from_hours(1));
1265
1266        if let (Some(s), Some(e)) = (start, end) {
1267            anyhow::ensure!(s < e, "Invalid time range: start={s:?} end={e:?}");
1268        }
1269
1270        let start_ms = start_dt.as_millisecond();
1271        let end_ms = end_dt.as_millisecond();
1272        let ts_init = self.generate_ts_init();
1273        let mut all_trades = Vec::new();
1274        let mut paginator = TradePaginator::new(start_ms, end_ms);
1275
1276        loop {
1277            let params = GetLastTradesByInstrumentAndTimeParams::new(
1278                instrument_id.symbol.to_string(),
1279                paginator.cursor,
1280                end_ms,
1281                Some(DERIBIT_HISTORICAL_TRADES_MAX_COUNT),
1282                Some("asc".to_string()),
1283            );
1284
1285            let full_response = self
1286                .inner
1287                .get_last_trades_by_instrument_and_time(params)
1288                .await
1289                .map_err(|e| anyhow::anyhow!(e))?;
1290
1291            let response_data = full_response
1292                .result
1293                .ok_or_else(|| anyhow::anyhow!("No result in response"))?;
1294
1295            let ids: Vec<String> = response_data
1296                .trades
1297                .iter()
1298                .map(|t| t.trade_id.clone())
1299                .collect();
1300            let timestamps: Vec<i64> = response_data.trades.iter().map(|t| t.timestamp).collect();
1301
1302            let Some(new_indices) = paginator.advance(&ids, &timestamps, response_data.has_more)
1303            else {
1304                break;
1305            };
1306
1307            for i in &new_indices {
1308                let raw_trade = &response_data.trades[*i];
1309
1310                match parse_trade_tick(
1311                    raw_trade,
1312                    instrument_id,
1313                    price_precision,
1314                    size_precision,
1315                    ts_init,
1316                ) {
1317                    Ok(trade) => {
1318                        all_trades.push(trade);
1319
1320                        if let Some(max) = limit
1321                            && all_trades.len() >= max as usize
1322                        {
1323                            return Ok(all_trades);
1324                        }
1325                    }
1326                    Err(e) => {
1327                        log::warn!(
1328                            "Failed to parse trade {} for {}: {}",
1329                            raw_trade.trade_id,
1330                            instrument_id,
1331                            e
1332                        );
1333                    }
1334                }
1335            }
1336
1337            if !response_data.has_more || paginator.is_exhausted() {
1338                break;
1339            }
1340        }
1341
1342        log::debug!(
1343            "Fetched {} historical trades for {} from {} to {}",
1344            all_trades.len(),
1345            instrument_id,
1346            start_dt,
1347            end_dt
1348        );
1349
1350        Ok(all_trades)
1351    }
1352
1353    /// Requests historical bars (OHLCV) for an instrument.
1354    ///
1355    /// Uses the `public/get_tradingview_chart_data` endpoint to fetch candlestick data.
1356    ///
1357    /// # Errors
1358    ///
1359    /// Returns an error if:
1360    /// - Aggregation source is not EXTERNAL.
1361    /// - Bar aggregation type is not supported by Deribit.
1362    /// - The instrument is not found in cache.
1363    /// - The request fails or response cannot be parsed.
1364    ///
1365    /// # Supported Resolutions
1366    ///
1367    /// Deribit supports: 1, 3, 5, 10, 15, 30, 60, 120, 180, 360, 720 minutes, and 1D (daily)
1368    pub async fn request_bars(
1369        &self,
1370        bar_type: BarType,
1371        start: Option<Timestamp>,
1372        end: Option<Timestamp>,
1373        limit: Option<u32>,
1374    ) -> anyhow::Result<Vec<Bar>> {
1375        anyhow::ensure!(
1376            bar_type.aggregation_source() == AggregationSource::External,
1377            "Only EXTERNAL aggregation is supported"
1378        );
1379
1380        let now = Timestamp::now();
1381
1382        // Default to last hour if no start/end provided
1383        let end_dt = end.unwrap_or(now);
1384        let start_dt = start.unwrap_or(end_dt - jiff::SignedDuration::from_hours(1));
1385
1386        if let (Some(s), Some(e)) = (start, end) {
1387            anyhow::ensure!(s < e, "Invalid time range: start={s:?} end={e:?}");
1388        }
1389
1390        // Convert BarType to Deribit resolution
1391        let spec = bar_type.spec();
1392        let step = spec.step.get();
1393        let resolution = match spec.aggregation {
1394            BarAggregation::Minute => format!("{step}"),
1395            BarAggregation::Hour => format!("{}", step * 60),
1396            BarAggregation::Day => "1D".to_string(),
1397            a => anyhow::bail!("Deribit does not support {a:?} aggregation"),
1398        };
1399
1400        // Validate resolution is supported by Deribit
1401        let supported_resolutions = [
1402            "1", "3", "5", "10", "15", "30", "60", "120", "180", "360", "720", "1D",
1403        ];
1404
1405        if !supported_resolutions.contains(&resolution.as_str()) {
1406            anyhow::bail!(
1407                "Deribit does not support resolution '{resolution}'. Supported: {supported_resolutions:?}"
1408            );
1409        }
1410
1411        let instrument_id = bar_type.instrument_id();
1412        let (price_precision, size_precision, use_cost_for_volume) =
1413            if let Some(instrument) = self.get_instrument(&instrument_id.symbol.inner()) {
1414                (
1415                    instrument.price_precision(),
1416                    instrument.size_precision(),
1417                    use_cost_for_bar_volume(&instrument),
1418                )
1419            } else {
1420                log::warn!("Instrument {instrument_id} not in cache, skipping bars request");
1421                return Err(InstrumentLookupError::not_found(instrument_id).into());
1422            };
1423
1424        let instrument_name = instrument_id.symbol.to_string();
1425        let start_timestamp = start_dt.as_millisecond();
1426        let end_timestamp = end_dt.as_millisecond();
1427
1428        let params = GetTradingViewChartDataParams::new(
1429            instrument_name,
1430            start_timestamp,
1431            end_timestamp,
1432            resolution,
1433        );
1434
1435        let full_response = self.inner.get_tradingview_chart_data(params).await?;
1436        let chart_data = full_response
1437            .result
1438            .ok_or_else(|| anyhow::anyhow!("No result in response"))?;
1439
1440        if chart_data.status == "no_data" {
1441            log::debug!("No bar data returned for {bar_type}");
1442            return Ok(Vec::new());
1443        }
1444
1445        let ts_init = self.generate_ts_init();
1446        let mut bars = parse_bars(
1447            &chart_data,
1448            bar_type,
1449            price_precision,
1450            size_precision,
1451            use_cost_for_volume,
1452            ts_init,
1453        )?;
1454
1455        if let Some(max) = limit {
1456            let max = max as usize;
1457            if bars.len() > max {
1458                bars.drain(..bars.len() - max);
1459            }
1460        }
1461
1462        log::debug!("Parsed {} bars for {}", bars.len(), bar_type);
1463
1464        Ok(bars)
1465    }
1466
1467    /// Requests a snapshot of the order book for an instrument.
1468    ///
1469    /// Fetches the order book from Deribit and converts it to a Nautilus [`OrderBook`].
1470    ///
1471    /// # Arguments
1472    ///
1473    /// * `instrument_id` - The instrument to fetch the order book for
1474    /// * `depth` - Optional depth limit (valid values: 1, 5, 10, 20, 50, 100, 1000, 10000)
1475    ///
1476    /// # Errors
1477    ///
1478    /// Returns an error if:
1479    /// - The instrument is not found in cache.
1480    /// - The request fails.
1481    /// - Order book parsing fails.
1482    pub async fn request_book_snapshot(
1483        &self,
1484        instrument_id: InstrumentId,
1485        depth: Option<u32>,
1486    ) -> anyhow::Result<OrderBook> {
1487        let (price_precision, size_precision) =
1488            if let Some(instrument) = self.get_instrument(&instrument_id.symbol.inner()) {
1489                (instrument.price_precision(), instrument.size_precision())
1490            } else {
1491                return Err(InstrumentLookupError::not_found(instrument_id).into());
1492            };
1493
1494        let params = GetOrderBookParams::new(instrument_id.symbol.to_string(), depth);
1495        let full_response = self
1496            .inner
1497            .get_order_book(params)
1498            .await
1499            .map_err(|e| anyhow::anyhow!(e))?;
1500
1501        let order_book_data = full_response
1502            .result
1503            .ok_or_else(|| anyhow::anyhow!("No result in response"))?;
1504
1505        let ts_init = self.generate_ts_init();
1506        let book = parse_order_book(
1507            &order_book_data,
1508            instrument_id,
1509            price_precision,
1510            size_precision,
1511            ts_init,
1512        )?;
1513
1514        log::debug!(
1515            "Fetched order book for {} with {} bids and {} asks",
1516            instrument_id,
1517            order_book_data.bids.len(),
1518            order_book_data.asks.len()
1519        );
1520
1521        Ok(book)
1522    }
1523
1524    /// Requests account state for all currencies.
1525    ///
1526    /// Fetches account balance and margin information for all currencies from Deribit
1527    /// and converts it to Nautilus [`AccountState`] event.
1528    ///
1529    /// # Errors
1530    ///
1531    /// Returns an error if:
1532    /// - The request fails.
1533    /// - Currency conversion fails.
1534    pub async fn request_account_state(
1535        &self,
1536        account_id: AccountId,
1537    ) -> anyhow::Result<AccountState> {
1538        let params = GetAccountSummariesParams::default();
1539        let full_response = self
1540            .inner
1541            .get_account_summaries(params)
1542            .await
1543            .map_err(|e| anyhow::anyhow!(e))?;
1544        let response_data = full_response
1545            .result
1546            .ok_or_else(|| anyhow::anyhow!("No result in response"))?;
1547        let ts_init = self.generate_ts_init();
1548        let ts_event = extract_server_timestamp(full_response.us_out)?;
1549
1550        parse_account_state(&response_data.summaries, account_id, ts_init, ts_event)
1551    }
1552
1553    /// Generates a timestamp for initialization.
1554    fn generate_ts_init(&self) -> UnixNanos {
1555        self.clock.get_time_ns()
1556    }
1557
1558    /// Caches instruments for later retrieval.
1559    pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
1560        self.instruments_cache.rcu(|m| {
1561            for inst in instruments {
1562                m.insert(inst.raw_symbol().inner(), inst.clone());
1563            }
1564        });
1565        self.cache_initialized.store(true, Ordering::Release);
1566    }
1567
1568    /// Retrieves a cached instrument by symbol.
1569    #[must_use]
1570    pub fn get_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1571        self.instruments_cache.get_cloned(symbol)
1572    }
1573
1574    /// Checks if the instrument cache has been initialized.
1575    #[must_use]
1576    pub fn is_cache_initialized(&self) -> bool {
1577        self.cache_initialized.load(Ordering::Acquire)
1578    }
1579
1580    /// Returns whether this client is connected to testnet.
1581    #[must_use]
1582    pub fn is_testnet(&self) -> bool {
1583        self.inner.is_testnet()
1584    }
1585
1586    /// Requests order status reports for reconciliation.
1587    ///
1588    /// Fetches order statuses from Deribit and converts them to Nautilus [`OrderStatusReport`].
1589    ///
1590    /// # Strategy
1591    /// - Uses `/private/get_open_orders` for all open orders (single efficient API call)
1592    /// - Uses `/private/get_open_orders_by_instrument` when specific instrument is provided
1593    /// - For historical orders (when `open_only=false`), iterates over currencies
1594    ///
1595    /// # Errors
1596    ///
1597    /// Returns an error if the request fails or parsing fails.
1598    pub async fn request_order_status_reports(
1599        &self,
1600        account_id: AccountId,
1601        instrument_id: Option<InstrumentId>,
1602        start: Option<UnixNanos>,
1603        end: Option<UnixNanos>,
1604        open_only: bool,
1605    ) -> anyhow::Result<Vec<OrderStatusReport>> {
1606        let ts_init = self.generate_ts_init();
1607        let mut reports = Vec::new();
1608        let mut seen_order_ids = AHashSet::new();
1609
1610        let mut parse_and_add = |order: &DeribitOrderMsg| {
1611            let symbol = order.instrument_name;
1612            if let Some(instrument) = self.get_instrument(&symbol) {
1613                match parse_user_order_msg(order, &instrument, account_id, ts_init) {
1614                    Ok(report) => {
1615                        // Apply time range filter based on ts_last
1616                        let ts_last = report.ts_last;
1617                        let in_range = match (start, end) {
1618                            (Some(s), Some(e)) => ts_last >= s && ts_last <= e,
1619                            (Some(s), None) => ts_last >= s,
1620                            (None, Some(e)) => ts_last <= e,
1621                            (None, None) => true,
1622                        };
1623                        // Only deduplicate if in range (prevents dropping valid historical reports)
1624                        if in_range && seen_order_ids.insert(order.order_id.clone()) {
1625                            reports.push(report);
1626                        }
1627                    }
1628                    Err(e) => {
1629                        log::warn!(
1630                            "Failed to parse order {} for {}: {}",
1631                            order.order_id,
1632                            order.instrument_name,
1633                            e
1634                        );
1635                    }
1636                }
1637            } else {
1638                log::debug!(
1639                    "Skipping order {} - instrument {} not in cache",
1640                    order.order_id,
1641                    order.instrument_name
1642                );
1643            }
1644        };
1645
1646        if let Some(instrument_id) = instrument_id {
1647            // Use instrument-specific endpoint (efficient)
1648            let instrument_name = instrument_id.symbol.to_string();
1649
1650            // Get open orders for this instrument
1651            let open_params = GetOpenOrdersByInstrumentParams {
1652                instrument_name: instrument_name.clone(),
1653                r#type: None,
1654            };
1655
1656            if let Some(orders) = self
1657                .inner
1658                .get_open_orders_by_instrument(open_params)
1659                .await?
1660                .result
1661            {
1662                for order in &orders {
1663                    parse_and_add(order);
1664                }
1665            }
1666
1667            if !open_only {
1668                const PAGE_SIZE: u32 = 100;
1669                let mut offset: u32 = 0;
1670
1671                loop {
1672                    let history_params = GetOrderHistoryByInstrumentParams {
1673                        instrument_name: instrument_name.clone(),
1674                        count: Some(PAGE_SIZE),
1675                        offset: Some(offset),
1676                        include_old: Some(true),
1677                        include_unfilled: Some(true),
1678                    };
1679                    let orders = self
1680                        .inner
1681                        .get_order_history_by_instrument(history_params)
1682                        .await?
1683                        .result
1684                        .unwrap_or_default();
1685
1686                    let count = orders.len() as u32;
1687                    for order in &orders {
1688                        parse_and_add(order);
1689                    }
1690
1691                    if count < PAGE_SIZE {
1692                        break;
1693                    }
1694                    offset += count;
1695                }
1696            }
1697        } else {
1698            // Use get_open_orders for ALL open orders - single API call!
1699            let open_params = GetOpenOrdersParams::default();
1700            if let Some(orders) = self.inner.get_open_orders(open_params).await?.result {
1701                for order in &orders {
1702                    parse_and_add(order);
1703                }
1704            }
1705
1706            if !open_only {
1707                const PAGE_SIZE: u32 = 100;
1708
1709                for currency in DeribitCurrency::iter().filter(|c| *c != DeribitCurrency::ANY) {
1710                    let mut offset: u32 = 0;
1711
1712                    loop {
1713                        let history_params = GetOrderHistoryByCurrencyParams {
1714                            currency,
1715                            kind: None,
1716                            count: Some(PAGE_SIZE),
1717                            offset: Some(offset),
1718                            include_old: Some(true),
1719                            include_unfilled: Some(true),
1720                        };
1721                        let orders = self
1722                            .inner
1723                            .get_order_history_by_currency(history_params)
1724                            .await?
1725                            .result
1726                            .unwrap_or_default();
1727
1728                        let count = orders.len() as u32;
1729                        for order in &orders {
1730                            parse_and_add(order);
1731                        }
1732
1733                        if count < PAGE_SIZE {
1734                            break;
1735                        }
1736                        offset += count;
1737                    }
1738                }
1739            }
1740        }
1741
1742        log::debug!("Generated {} order status reports", reports.len());
1743        Ok(reports)
1744    }
1745
1746    /// Requests fill reports for reconciliation.
1747    ///
1748    /// Fetches user trades from Deribit and converts them to Nautilus [`FillReport`].
1749    /// Automatically paginates through all results using time-cursor advancement.
1750    ///
1751    /// # Strategy
1752    /// - Uses `/private/get_user_trades_by_instrument_and_time` when instrument is provided
1753    /// - Otherwise iterates over currencies using `/private/get_user_trades_by_currency_and_time`
1754    ///
1755    /// # Errors
1756    ///
1757    /// Returns an error if the request fails or parsing fails.
1758    pub async fn request_fill_reports(
1759        &self,
1760        account_id: AccountId,
1761        instrument_id: Option<InstrumentId>,
1762        start: Option<UnixNanos>,
1763        end: Option<UnixNanos>,
1764    ) -> anyhow::Result<Vec<FillReport>> {
1765        let ts_init = self.generate_ts_init();
1766        let now_ms = Timestamp::now().as_millisecond();
1767
1768        // Convert UnixNanos to milliseconds for Deribit API
1769        let start_ms = start.map_or(0, |ns| nanos_to_millis(ns.as_u64()) as i64);
1770        let end_ms = end.map_or(now_ms, |ns| nanos_to_millis(ns.as_u64()) as i64);
1771        let mut reports = Vec::new();
1772
1773        let mut parse_and_add = |trade: &DeribitUserTradeMsg| {
1774            let symbol = trade.instrument_name;
1775            if let Some(instrument) = self.get_instrument(&symbol) {
1776                match parse_user_trade_msg(trade, &instrument, account_id, ts_init) {
1777                    Ok(report) => reports.push(report),
1778                    Err(e) => {
1779                        log::warn!(
1780                            "Failed to parse trade {} for {}: {}",
1781                            trade.trade_id,
1782                            trade.instrument_name,
1783                            e
1784                        );
1785                    }
1786                }
1787            } else {
1788                log::debug!(
1789                    "Skipping trade {} - instrument {} not in cache",
1790                    trade.trade_id,
1791                    trade.instrument_name
1792                );
1793            }
1794        };
1795
1796        let mut paginator = TradePaginator::new(start_ms, end_ms);
1797
1798        if let Some(instrument_id) = instrument_id {
1799            loop {
1800                let params = GetUserTradesByInstrumentAndTimeParams {
1801                    instrument_name: instrument_id.symbol.to_string(),
1802                    start_timestamp: paginator.cursor,
1803                    end_timestamp: end_ms,
1804                    count: Some(DERIBIT_HISTORICAL_TRADES_MAX_COUNT),
1805                    sorting: Some("asc".to_string()),
1806                };
1807                let response = self
1808                    .inner
1809                    .get_user_trades_by_instrument_and_time(params)
1810                    .await?;
1811
1812                let Some(data) = response.result else { break };
1813
1814                let ids: Vec<String> = data.trades.iter().map(|t| t.trade_id.clone()).collect();
1815                let timestamps: Vec<i64> = data.trades.iter().map(|t| t.timestamp as i64).collect();
1816
1817                let Some(new_indices) = paginator.advance(&ids, &timestamps, data.has_more) else {
1818                    break;
1819                };
1820
1821                for i in &new_indices {
1822                    parse_and_add(&data.trades[*i]);
1823                }
1824
1825                if !data.has_more || paginator.is_exhausted() {
1826                    break;
1827                }
1828            }
1829        } else {
1830            for currency in DeribitCurrency::iter().filter(|c| *c != DeribitCurrency::ANY) {
1831                paginator.reset(start_ms);
1832
1833                loop {
1834                    let params = GetUserTradesByCurrencyAndTimeParams {
1835                        currency,
1836                        start_timestamp: paginator.cursor,
1837                        end_timestamp: end_ms,
1838                        kind: None,
1839                        count: Some(DERIBIT_HISTORICAL_TRADES_MAX_COUNT),
1840                        sorting: Some("asc".to_string()),
1841                    };
1842                    let response = self
1843                        .inner
1844                        .get_user_trades_by_currency_and_time(params)
1845                        .await?;
1846
1847                    let Some(data) = response.result else { break };
1848
1849                    let ids: Vec<String> = data.trades.iter().map(|t| t.trade_id.clone()).collect();
1850                    let timestamps: Vec<i64> =
1851                        data.trades.iter().map(|t| t.timestamp as i64).collect();
1852
1853                    let Some(new_indices) = paginator.advance(&ids, &timestamps, data.has_more)
1854                    else {
1855                        break;
1856                    };
1857
1858                    for i in &new_indices {
1859                        parse_and_add(&data.trades[*i]);
1860                    }
1861
1862                    if !data.has_more || paginator.is_exhausted() {
1863                        break;
1864                    }
1865                }
1866            }
1867        }
1868
1869        log::debug!("Generated {} fill reports", reports.len());
1870        Ok(reports)
1871    }
1872
1873    /// Requests ticker data for a single instrument.
1874    ///
1875    /// Returns the `DeribitTicker` including its option-chain reference price.
1876    ///
1877    /// # Errors
1878    ///
1879    /// Returns an error if the request fails.
1880    pub async fn request_ticker(&self, instrument_name: &str) -> anyhow::Result<DeribitTicker> {
1881        let params = GetTickerParams {
1882            instrument_name: instrument_name.to_string(),
1883        };
1884        let response = self
1885            .inner
1886            .get_ticker(params)
1887            .await
1888            .map_err(|e| anyhow::anyhow!(e))?;
1889        response
1890            .result
1891            .ok_or_else(|| anyhow::anyhow!("No result in ticker response"))
1892    }
1893
1894    /// Requests book summaries for a currency via `public/get_book_summary_by_currency`.
1895    ///
1896    /// Defaults to product kind `option`.
1897    /// Entries include mark/IV, bid-ask, volumes, and `underlying_price` (forward) when present.
1898    ///
1899    /// # Errors
1900    ///
1901    /// Returns an error if the request fails.
1902    pub async fn request_book_summaries(
1903        &self,
1904        currency: &str,
1905    ) -> anyhow::Result<Vec<DeribitBookSummaryRaw>> {
1906        self.request_book_summaries_kind(currency, Some("option"))
1907            .await
1908    }
1909
1910    /// Requests book summaries for a currency with an optional product `kind` filter.
1911    ///
1912    /// When `kind` is `None`, Deribit returns summaries for all product kinds.
1913    ///
1914    /// # Errors
1915    ///
1916    /// Returns an error if the request fails.
1917    pub async fn request_book_summaries_kind(
1918        &self,
1919        currency: &str,
1920        kind: Option<&str>,
1921    ) -> anyhow::Result<Vec<DeribitBookSummaryRaw>> {
1922        let params = GetBookSummaryByCurrencyParams {
1923            currency: currency.to_string(),
1924            kind: kind.map(str::to_string),
1925        };
1926        let full_response = self
1927            .inner
1928            .get_book_summary_by_currency(params)
1929            .await
1930            .map_err(|e| anyhow::anyhow!(e))?;
1931        full_response
1932            .result
1933            .ok_or_else(|| anyhow::anyhow!("No result in book summary response"))
1934    }
1935
1936    /// Requests traded option expirations for a settlement currency.
1937    ///
1938    /// # Errors
1939    ///
1940    /// Returns an error if the request fails.
1941    pub async fn request_option_expirations(
1942        &self,
1943        currency: DeribitCurrency,
1944    ) -> anyhow::Result<Vec<String>> {
1945        let params = GetExpirationsParams::new(currency.as_str(), DeribitExpirationKind::Option);
1946        let full_response = self
1947            .inner
1948            .get_expirations(params)
1949            .await
1950            .map_err(|e| anyhow::anyhow!(e))?;
1951        let response = full_response
1952            .result
1953            .ok_or_else(|| anyhow::anyhow!("No result in expirations response"))?;
1954        let expirations = response
1955            .expirations_for_currency(currency.as_str())
1956            .ok_or_else(|| anyhow::anyhow!("No option expirations for {currency}"))?;
1957
1958        Ok(expirations.option.clone())
1959    }
1960
1961    /// Requests position status reports for reconciliation.
1962    ///
1963    /// Fetches positions from Deribit and converts them to Nautilus [`PositionStatusReport`].
1964    ///
1965    /// # Strategy
1966    /// - Uses `currency=any` to fetch all positions in one call
1967    /// - Filters by instrument_id if provided
1968    ///
1969    /// # Errors
1970    ///
1971    /// Returns an error if the request fails or parsing fails.
1972    pub async fn request_position_status_reports(
1973        &self,
1974        account_id: AccountId,
1975        instrument_id: Option<InstrumentId>,
1976    ) -> anyhow::Result<Vec<PositionStatusReport>> {
1977        let ts_init = self.generate_ts_init();
1978        let mut reports = Vec::new();
1979
1980        // Use ANY to get all positions across all currencies in one call
1981        let params = GetPositionsParams {
1982            currency: DeribitCurrency::ANY,
1983            kind: None,
1984        };
1985
1986        if let Some(positions) = self.inner.get_positions(params).await?.result {
1987            for position in &positions {
1988                // Skip flat positions (size == 0)
1989                if position.size.is_zero() {
1990                    continue;
1991                }
1992
1993                let symbol = position.instrument_name;
1994                if let Some(instrument) = self.get_instrument(&symbol) {
1995                    let report =
1996                        parse_position_status_report(position, &instrument, account_id, ts_init);
1997                    reports.push(report);
1998                } else {
1999                    log::debug!(
2000                        "Skipping position - instrument {} not in cache",
2001                        position.instrument_name
2002                    );
2003                }
2004            }
2005        }
2006
2007        // Filter by instrument if provided
2008        if let Some(instrument_id) = instrument_id {
2009            reports.retain(|r| r.instrument_id == instrument_id);
2010        }
2011
2012        log::debug!("Generated {} position status reports", reports.len());
2013        Ok(reports)
2014    }
2015}
2016
2017#[cfg(test)]
2018mod tests {
2019    use nautilus_testkit::http::assert_http_redirect_rejected;
2020    use rstest::rstest;
2021
2022    use super::*;
2023    use crate::common::consts::{
2024        DERIBIT_ACCOUNT_RATE_KEY, DERIBIT_GLOBAL_RATE_KEY, DERIBIT_ORDER_RATE_KEY,
2025    };
2026
2027    #[tokio::test]
2028    async fn test_authenticated_client_rejects_redirects() {
2029        let client = DeribitRawHttpClient::with_credentials(
2030            "key".into(),
2031            "secret".into(),
2032            None,
2033            DeribitEnvironment::Testnet,
2034            3,
2035            0,
2036            1,
2037            1,
2038            None,
2039        )
2040        .unwrap()
2041        .client;
2042        assert_http_redirect_rejected(|url| async move {
2043            client
2044                .get(url, None, None, Some(3), None)
2045                .await
2046                .unwrap()
2047                .status
2048                .as_u16()
2049        })
2050        .await;
2051    }
2052
2053    #[rstest]
2054    #[case("private/buy", true, false)]
2055    #[case("private/cancel", true, false)]
2056    #[case("private/get_account_summaries", false, true)]
2057    #[case("private/get_positions", false, true)]
2058    #[case("public/get_instruments", false, false)]
2059    fn test_method_classification(
2060        #[case] method: &str,
2061        #[case] is_order: bool,
2062        #[case] is_account: bool,
2063    ) {
2064        assert_eq!(DeribitRawHttpClient::is_order_method(method), is_order);
2065        assert_eq!(DeribitRawHttpClient::is_account_method(method), is_account);
2066    }
2067
2068    #[rstest]
2069    #[case("private/buy", vec![DERIBIT_GLOBAL_RATE_KEY, DERIBIT_ORDER_RATE_KEY])]
2070    #[case("private/get_account_summaries", vec![DERIBIT_GLOBAL_RATE_KEY, DERIBIT_ACCOUNT_RATE_KEY])]
2071    #[case("public/get_instruments", vec![DERIBIT_GLOBAL_RATE_KEY])]
2072    fn test_rate_limit_keys(#[case] method: &str, #[case] expected_keys: Vec<&str>) {
2073        let keys = DeribitRawHttpClient::rate_limit_keys(method);
2074
2075        for key in &expected_keys {
2076            assert!(keys.contains(&key.to_string()));
2077        }
2078        assert!(keys.contains(&format!("deribit:{method}")));
2079    }
2080
2081    #[rstest]
2082    fn test_paginator_empty_page_returns_none() {
2083        let mut p = TradePaginator::new(100, 200);
2084        assert!(p.advance(&[], &[], true).is_none());
2085    }
2086
2087    #[rstest]
2088    fn test_paginator_single_page_no_more() {
2089        let mut p = TradePaginator::new(100, 200);
2090        let ids = vec!["t1".into(), "t2".into()];
2091        let ts = vec![150, 160];
2092
2093        let result = p.advance(&ids, &ts, false);
2094        assert_eq!(result, Some(vec![0, 1]));
2095    }
2096
2097    #[rstest]
2098    fn test_paginator_dedup_across_pages() {
2099        let mut p = TradePaginator::new(100, 200);
2100
2101        // First page: two new trades
2102        let ids1 = vec!["t1".into(), "t2".into()];
2103        let ts1 = vec![150, 150];
2104        let r1 = p.advance(&ids1, &ts1, true);
2105        assert_eq!(r1, Some(vec![0, 1]));
2106        assert_eq!(p.cursor, 150);
2107
2108        // Second page: t2 repeated, t3 new
2109        let ids2 = vec!["t2".into(), "t3".into()];
2110        let ts2 = vec![150, 150];
2111        let r2 = p.advance(&ids2, &ts2, false);
2112        assert_eq!(r2, Some(vec![1])); // Only t3 is new
2113    }
2114
2115    #[rstest]
2116    fn test_paginator_all_duplicates_advances_past_timestamp() {
2117        let mut p = TradePaginator::new(100, 200);
2118
2119        // First page
2120        let ids = vec!["t1".into(), "t2".into()];
2121        let ts = vec![150, 150];
2122        p.advance(&ids, &ts, true);
2123        assert_eq!(p.cursor, 150);
2124
2125        // Second page: same trades again (all duplicates)
2126        let r2 = p.advance(&ids, &ts, true);
2127        assert_eq!(r2, Some(vec![])); // No new items
2128        assert_eq!(p.cursor, 151); // Advanced past 150
2129    }
2130
2131    #[rstest]
2132    fn test_paginator_is_exhausted_strict_greater_than() {
2133        let mut p = TradePaginator::new(100, 150);
2134
2135        let ids = vec!["t1".into()];
2136        let ts = vec![150];
2137        p.advance(&ids, &ts, true);
2138
2139        // Cursor at end (150) should NOT be exhausted
2140        assert_eq!(p.cursor, 150);
2141        assert!(!p.is_exhausted());
2142
2143        // All duplicates: cursor advances to 151
2144        p.advance(&ids, &ts, true);
2145        assert_eq!(p.cursor, 151);
2146        assert!(p.is_exhausted());
2147    }
2148
2149    #[rstest]
2150    fn test_paginator_reset_clears_state() {
2151        let mut p = TradePaginator::new(100, 200);
2152
2153        let ids = vec!["t1".into()];
2154        let ts = vec![150];
2155        p.advance(&ids, &ts, true);
2156        assert_eq!(p.seen_ids.len(), 1);
2157
2158        p.reset(100);
2159        assert_eq!(p.cursor, 100);
2160        assert!(p.seen_ids.is_empty());
2161
2162        // Same ID is now treated as new
2163        let r = p.advance(&ids, &ts, false);
2164        assert_eq!(r, Some(vec![0]));
2165    }
2166}