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                // Try to parse as JSON first
495                let json_value: serde_json::Value = match serde_json::from_slice(&resp.body) {
496                    Ok(json) => json,
497                    Err(_) => {
498                        // Not valid JSON - treat as HTTP error
499                        let error_body = String::from_utf8_lossy(&resp.body);
500                        log::warn!(
501                            "Non-JSON response: method={method}, status={}, body={error_body}",
502                            resp.status.as_u16()
503                        );
504                        return Err(DeribitHttpError::UnexpectedStatus {
505                            status: resp.status.as_u16(),
506                            body: error_body.to_string(),
507                        });
508                    }
509                };
510
511                // Try to parse as JSON-RPC response
512                let json_rpc_response: DeribitJsonRpcResponse<T> =
513                    serde_json::from_value(json_value.clone()).map_err(|e| {
514                        log::warn!(
515                            "Failed to deserialize Deribit JSON-RPC response: method={method}, status={}, error={e}",
516                            resp.status.as_u16()
517                        );
518                        log::debug!(
519                            "Response JSON (first 2000 chars): {}",
520                            json_value
521                                .to_string()
522                                .chars()
523                                .take(2000)
524                                .collect::<String>()
525                        );
526                        DeribitHttpError::JsonError(e.to_string())
527                    })?;
528
529                // Check if it's a success or error result
530                if json_rpc_response.result.is_some() {
531                    Ok(json_rpc_response)
532                } else if let Some(error) = &json_rpc_response.error {
533                    // JSON-RPC error (may come with any HTTP status)
534                    log::warn!(
535                        "Deribit RPC error response: method={method}, http_status={}, error_code={}, error_message={}, error_data={:?}",
536                        resp.status.as_u16(),
537                        error.code,
538                        error.message,
539                        error.data
540                    );
541
542                    // Map JSON-RPC error to appropriate error variant
543                    Err(DeribitHttpError::from_jsonrpc_error(
544                        error.code,
545                        error.message.clone(),
546                        error.data.as_ref(),
547                    ))
548                } else {
549                    log::warn!(
550                        "Response contains neither result nor error field: method={method}, status={}, request_id={:?}",
551                        resp.status.as_u16(),
552                        json_rpc_response.id
553                    );
554                    Err(DeribitHttpError::JsonError(
555                        "Response contains neither result nor error".to_string(),
556                    ))
557                }
558            }
559        };
560
561        // Retry strategy based on Deribit error responses and HTTP status codes:
562        //
563        // 1. Network errors: always retry (transient connection issues)
564        // 2. HTTP 5xx/429: server errors and rate limiting should be retried
565        // 3. Deribit-specific retryable error codes (defined in common::consts)
566        //
567        // Note: Deribit returns many permanent errors which should NOT be retried
568        // (e.g., "invalid_credentials", "not_enough_funds", "order_not_found")
569        let should_retry = |error: &DeribitHttpError| -> bool { error.is_retryable() };
570
571        let create_error = |error: RetryError| -> DeribitHttpError {
572            match error {
573                RetryError::Canceled => {
574                    DeribitHttpError::Canceled("Adapter disconnecting or shutting down".to_string())
575                }
576                error => DeribitHttpError::NetworkError(error.to_string()),
577            }
578        };
579
580        let result = self
581            .retry_manager
582            .invocation(&operation_id, operation, should_retry, create_error)
583            .cancellation_token(&self.cancellation_token)
584            .execute()
585            .await;
586
587        if let Err(ref e) = result
588            && e.is_retryable()
589        {
590            log::error!("Request exhausted retries: method={method}, error={e}");
591        }
592
593        result
594    }
595
596    /// Gets available trading instruments.
597    ///
598    /// # Errors
599    ///
600    /// Returns an error if the request fails or the response cannot be parsed.
601    pub async fn get_instruments(
602        &self,
603        params: GetInstrumentsParams,
604    ) -> Result<DeribitJsonRpcResponse<Vec<DeribitInstrument>>, DeribitHttpError> {
605        self.send_request("public/get_instruments", params, false)
606            .await
607    }
608
609    /// Gets details for a specific trading instrument.
610    ///
611    /// # Errors
612    ///
613    /// Returns an error if the request fails or the response cannot be parsed.
614    pub async fn get_instrument(
615        &self,
616        params: GetInstrumentParams,
617    ) -> Result<DeribitJsonRpcResponse<DeribitInstrument>, DeribitHttpError> {
618        self.send_request("public/get_instrument", params, false)
619            .await
620    }
621
622    /// Gets combo definitions for a currency.
623    ///
624    /// # Errors
625    ///
626    /// Returns an error if the request fails or the response cannot be parsed.
627    pub async fn get_combos(
628        &self,
629        params: GetCombosParams,
630    ) -> Result<DeribitJsonRpcResponse<Vec<DeribitCombo>>, DeribitHttpError> {
631        self.send_request("public/get_combos", params, false).await
632    }
633
634    /// Gets recent trades for an instrument within a time range.
635    ///
636    /// # Errors
637    ///
638    /// Returns an error if the request fails or the response cannot be parsed.
639    pub async fn get_last_trades_by_instrument_and_time(
640        &self,
641        params: GetLastTradesByInstrumentAndTimeParams,
642    ) -> Result<DeribitJsonRpcResponse<DeribitTradesResponse>, DeribitHttpError> {
643        self.send_request(
644            "public/get_last_trades_by_instrument_and_time",
645            params,
646            false,
647        )
648        .await
649    }
650
651    /// Gets recent trades for a currency, optionally filtered by product kind.
652    ///
653    /// The instrument-and-time variant accepts combo instrument names, but
654    /// this currency-scoped endpoint is the only way to sweep trades across
655    /// all combos of a given kind (e.g., every BTC future combo) in one call.
656    ///
657    /// # Errors
658    ///
659    /// Returns an error if the request fails or the response cannot be parsed.
660    pub async fn get_last_trades_by_currency(
661        &self,
662        params: GetLastTradesByCurrencyParams,
663    ) -> Result<DeribitJsonRpcResponse<DeribitTradesResponse>, DeribitHttpError> {
664        self.send_request("public/get_last_trades_by_currency", params, false)
665            .await
666    }
667
668    /// Gets traded expirations by currency and instrument kind.
669    ///
670    /// # Errors
671    ///
672    /// Returns an error if the request fails or the response cannot be parsed.
673    pub async fn get_expirations(
674        &self,
675        params: GetExpirationsParams,
676    ) -> Result<DeribitJsonRpcResponse<DeribitExpirationsResponse>, DeribitHttpError> {
677        self.send_request("public/get_expirations", params, false)
678            .await
679    }
680
681    /// Gets TradingView chart data (OHLCV) for an instrument.
682    ///
683    /// # Errors
684    ///
685    /// Returns an error if the request fails or the response cannot be parsed.
686    pub async fn get_tradingview_chart_data(
687        &self,
688        params: GetTradingViewChartDataParams,
689    ) -> Result<DeribitJsonRpcResponse<DeribitTradingViewChartData>, DeribitHttpError> {
690        self.send_request("public/get_tradingview_chart_data", params, false)
691            .await
692    }
693
694    /// Gets account summaries for all currencies.
695    ///
696    /// # Errors
697    ///
698    /// Returns an error if:
699    /// - Credentials are missing ([`DeribitHttpError::MissingCredentials`])
700    /// - Authentication fails (invalid signature, expired timestamp)
701    /// - The request fails or the response cannot be parsed
702    pub async fn get_account_summaries(
703        &self,
704        params: GetAccountSummariesParams,
705    ) -> Result<DeribitJsonRpcResponse<DeribitAccountSummariesResponse>, DeribitHttpError> {
706        self.send_request("private/get_account_summaries", params, true)
707            .await
708    }
709
710    /// Gets order book for an instrument.
711    ///
712    /// # Errors
713    ///
714    /// Returns an error if the request fails or the response cannot be parsed.
715    pub async fn get_order_book(
716        &self,
717        params: GetOrderBookParams,
718    ) -> Result<DeribitJsonRpcResponse<DeribitOrderBook>, DeribitHttpError> {
719        self.send_request("public/get_order_book", params, false)
720            .await
721    }
722
723    /// Gets a single order by its ID.
724    ///
725    /// # Errors
726    ///
727    /// Returns an error if:
728    /// - Credentials are missing ([`DeribitHttpError::MissingCredentials`])
729    /// - Authentication fails (invalid signature, expired timestamp)
730    /// - The request fails or the response cannot be parsed
731    pub async fn get_order_state(
732        &self,
733        params: GetOrderStateParams,
734    ) -> Result<DeribitJsonRpcResponse<DeribitOrderMsg>, DeribitHttpError> {
735        self.send_request("private/get_order_state", params, true)
736            .await
737    }
738
739    /// Gets all open orders across all currencies and instruments.
740    ///
741    /// # Errors
742    ///
743    /// Returns an error if:
744    /// - Credentials are missing ([`DeribitHttpError::MissingCredentials`])
745    /// - Authentication fails (invalid signature, expired timestamp)
746    /// - The request fails or the response cannot be parsed
747    pub async fn get_open_orders(
748        &self,
749        params: GetOpenOrdersParams,
750    ) -> Result<DeribitJsonRpcResponse<Vec<DeribitOrderMsg>>, DeribitHttpError> {
751        self.send_request("private/get_open_orders", params, true)
752            .await
753    }
754
755    /// Gets open orders for a specific instrument.
756    ///
757    /// # Errors
758    ///
759    /// Returns an error if:
760    /// - Credentials are missing ([`DeribitHttpError::MissingCredentials`])
761    /// - Authentication fails (invalid signature, expired timestamp)
762    /// - The request fails or the response cannot be parsed
763    pub async fn get_open_orders_by_instrument(
764        &self,
765        params: GetOpenOrdersByInstrumentParams,
766    ) -> Result<DeribitJsonRpcResponse<Vec<DeribitOrderMsg>>, DeribitHttpError> {
767        self.send_request("private/get_open_orders_by_instrument", params, true)
768            .await
769    }
770
771    /// Gets historical orders for a specific instrument.
772    ///
773    /// # Errors
774    ///
775    /// Returns an error if:
776    /// - Credentials are missing ([`DeribitHttpError::MissingCredentials`])
777    /// - Authentication fails (invalid signature, expired timestamp)
778    /// - The request fails or the response cannot be parsed
779    pub async fn get_order_history_by_instrument(
780        &self,
781        params: GetOrderHistoryByInstrumentParams,
782    ) -> Result<DeribitJsonRpcResponse<Vec<DeribitOrderMsg>>, DeribitHttpError> {
783        self.send_request("private/get_order_history_by_instrument", params, true)
784            .await
785    }
786
787    /// Gets historical orders for a specific currency.
788    ///
789    /// # Errors
790    ///
791    /// Returns an error if:
792    /// - Credentials are missing ([`DeribitHttpError::MissingCredentials`])
793    /// - Authentication fails (invalid signature, expired timestamp)
794    /// - The request fails or the response cannot be parsed
795    pub async fn get_order_history_by_currency(
796        &self,
797        params: GetOrderHistoryByCurrencyParams,
798    ) -> Result<DeribitJsonRpcResponse<Vec<DeribitOrderMsg>>, DeribitHttpError> {
799        self.send_request("private/get_order_history_by_currency", params, true)
800            .await
801    }
802
803    /// Gets user trades for a specific instrument within a time range.
804    ///
805    /// # Errors
806    ///
807    /// Returns an error if:
808    /// - Credentials are missing ([`DeribitHttpError::MissingCredentials`])
809    /// - Authentication fails (invalid signature, expired timestamp)
810    /// - The request fails or the response cannot be parsed
811    pub async fn get_user_trades_by_instrument_and_time(
812        &self,
813        params: GetUserTradesByInstrumentAndTimeParams,
814    ) -> Result<DeribitJsonRpcResponse<DeribitUserTradesResponse>, DeribitHttpError> {
815        self.send_request(
816            "private/get_user_trades_by_instrument_and_time",
817            params,
818            true,
819        )
820        .await
821    }
822
823    /// Gets user trades for a specific currency within a time range.
824    ///
825    /// # Errors
826    ///
827    /// Returns an error if:
828    /// - Credentials are missing ([`DeribitHttpError::MissingCredentials`])
829    /// - Authentication fails (invalid signature, expired timestamp)
830    /// - The request fails or the response cannot be parsed
831    pub async fn get_user_trades_by_currency_and_time(
832        &self,
833        params: GetUserTradesByCurrencyAndTimeParams,
834    ) -> Result<DeribitJsonRpcResponse<DeribitUserTradesResponse>, DeribitHttpError> {
835        self.send_request("private/get_user_trades_by_currency_and_time", params, true)
836            .await
837    }
838
839    /// Gets book summaries for all instruments of a given currency.
840    ///
841    /// # Errors
842    ///
843    /// Returns an error if the request fails or the response cannot be parsed.
844    pub async fn get_book_summary_by_currency(
845        &self,
846        params: GetBookSummaryByCurrencyParams,
847    ) -> Result<DeribitJsonRpcResponse<Vec<DeribitBookSummaryRaw>>, DeribitHttpError> {
848        self.send_request("public/get_book_summary_by_currency", params, false)
849            .await
850    }
851
852    /// Gets ticker data for a single instrument.
853    ///
854    /// # Errors
855    ///
856    /// Returns an error if the request fails or the response cannot be parsed.
857    pub async fn get_ticker(
858        &self,
859        params: GetTickerParams,
860    ) -> Result<DeribitJsonRpcResponse<DeribitTicker>, DeribitHttpError> {
861        self.send_request("public/ticker", params, false).await
862    }
863
864    /// Gets positions for a specific currency.
865    ///
866    /// # Errors
867    ///
868    /// Returns an error if:
869    /// - Credentials are missing ([`DeribitHttpError::MissingCredentials`])
870    /// - Authentication fails (invalid signature, expired timestamp)
871    /// - The request fails or the response cannot be parsed
872    pub async fn get_positions(
873        &self,
874        params: GetPositionsParams,
875    ) -> Result<DeribitJsonRpcResponse<Vec<DeribitPosition>>, DeribitHttpError> {
876        self.send_request("private/get_positions", params, true)
877            .await
878    }
879}
880
881/// High-level Deribit HTTP client with domain-level abstractions.
882///
883/// This client wraps the raw HTTP client and provides methods that use Nautilus
884/// domain types. It maintains an instrument cache for efficient lookups.
885#[derive(Debug)]
886#[cfg_attr(
887    feature = "python",
888    pyo3::pyclass(module = "nautilus_trader.adapters.deribit", from_py_object)
889)]
890#[cfg_attr(
891    feature = "python",
892    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.deribit")
893)]
894pub struct DeribitHttpClient {
895    pub(crate) inner: Arc<DeribitRawHttpClient>,
896    pub(crate) instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
897    clock: &'static AtomicTime,
898    cache_initialized: AtomicBool,
899}
900
901impl Clone for DeribitHttpClient {
902    fn clone(&self) -> Self {
903        let cache_initialized = AtomicBool::new(false);
904
905        let is_initialized = self.cache_initialized.load(Ordering::Acquire);
906        if is_initialized {
907            cache_initialized.store(true, Ordering::Release);
908        }
909
910        Self {
911            inner: self.inner.clone(),
912            instruments_cache: self.instruments_cache.clone(),
913            cache_initialized,
914            clock: self.clock,
915        }
916    }
917}
918
919impl DeribitHttpClient {
920    /// Returns a reference to the underlying raw HTTP client.
921    ///
922    /// Exposes low-level endpoint methods (e.g., `get_last_trades_by_currency`)
923    /// that this wrapper does not yet adapt.
924    #[must_use]
925    pub fn inner(&self) -> &DeribitRawHttpClient {
926        &self.inner
927    }
928
929    /// Creates a new [`DeribitHttpClient`] with default configuration.
930    ///
931    /// # Parameters
932    /// - `base_url`: Optional custom base URL (for testing)
933    /// - `environment`: The Deribit environment to connect to
934    ///
935    /// # Errors
936    ///
937    /// Returns an error if the HTTP client cannot be created.
938    pub fn new(
939        base_url: Option<String>,
940        environment: DeribitEnvironment,
941        timeout_secs: u64,
942        max_retries: u32,
943        retry_delay_ms: u64,
944        retry_delay_max_ms: u64,
945        proxy_url: Option<String>,
946    ) -> anyhow::Result<Self> {
947        let raw_client = Arc::new(DeribitRawHttpClient::new(
948            base_url,
949            environment,
950            timeout_secs,
951            max_retries,
952            retry_delay_ms,
953            retry_delay_max_ms,
954            proxy_url,
955        )?);
956
957        Ok(Self {
958            inner: raw_client,
959            instruments_cache: Arc::new(AtomicMap::new()),
960            cache_initialized: AtomicBool::new(false),
961            clock: get_atomic_clock_realtime(),
962        })
963    }
964
965    /// Creates a new [`DeribitHttpClient`] with credentials from environment variables.
966    ///
967    /// If `api_key` or `api_secret` are not provided, they will be loaded from environment:
968    /// - Mainnet: `DERIBIT_API_KEY`, `DERIBIT_API_SECRET`
969    /// - Testnet: `DERIBIT_TESTNET_API_KEY`, `DERIBIT_TESTNET_API_SECRET`
970    ///
971    /// # Errors
972    ///
973    /// Returns an error if:
974    /// - The HTTP client cannot be created
975    /// - Credentials are not provided and environment variables are not set
976    #[expect(clippy::too_many_arguments)]
977    pub fn new_with_env(
978        api_key: Option<String>,
979        api_secret: Option<String>,
980        base_url: Option<String>,
981        environment: DeribitEnvironment,
982        timeout_secs: u64,
983        max_retries: u32,
984        retry_delay_ms: u64,
985        retry_delay_max_ms: u64,
986        proxy_url: Option<String>,
987    ) -> anyhow::Result<Self> {
988        let raw_client = Arc::new(DeribitRawHttpClient::new_with_env(
989            api_key,
990            api_secret,
991            base_url,
992            environment,
993            timeout_secs,
994            max_retries,
995            retry_delay_ms,
996            retry_delay_max_ms,
997            proxy_url,
998        )?);
999
1000        Ok(Self {
1001            inner: raw_client,
1002            instruments_cache: Arc::new(AtomicMap::new()),
1003            cache_initialized: AtomicBool::new(false),
1004            clock: get_atomic_clock_realtime(),
1005        })
1006    }
1007
1008    /// Requests instruments for a specific currency.
1009    ///
1010    /// # Errors
1011    ///
1012    /// Returns an error if the request fails or instruments cannot be parsed.
1013    pub async fn request_instruments(
1014        &self,
1015        currency: DeribitCurrency,
1016        product_type: Option<DeribitProductType>,
1017    ) -> anyhow::Result<Vec<InstrumentAny>> {
1018        // Build parameters
1019        let params = if let Some(pt) = product_type {
1020            GetInstrumentsParams::with_kind(currency, pt)
1021        } else {
1022            GetInstrumentsParams::new(currency)
1023        };
1024
1025        // Call raw client
1026        let full_response = self.inner.get_instruments(params).await?;
1027        let result = full_response
1028            .result
1029            .ok_or_else(|| anyhow::anyhow!("No result in response"))?;
1030        let ts_event = extract_server_timestamp(full_response.us_out)?;
1031        let ts_init = self.generate_ts_init();
1032        let combo_by_id = self.combo_map_for_instruments(currency, &result).await;
1033
1034        // Parse each instrument
1035        let mut instruments = Vec::new();
1036        let mut skipped_count = 0;
1037        let mut error_count = 0;
1038
1039        for raw_instrument in result {
1040            match parse_deribit_instrument_any(&raw_instrument, ts_init, ts_event) {
1041                Ok(Some(mut instrument)) => {
1042                    if let Some(combo) = combo_by_id.get(&raw_instrument.instrument_name) {
1043                        Self::attach_combo_leg_info(&mut instrument, combo);
1044                    }
1045                    instruments.push(instrument);
1046                }
1047                Ok(None) => {
1048                    // Unsupported instrument type (e.g., combos)
1049                    skipped_count += 1;
1050                    log::debug!(
1051                        "Skipped unsupported instrument type: {} (kind: {:?})",
1052                        raw_instrument.instrument_name,
1053                        raw_instrument.kind
1054                    );
1055                }
1056                Err(e) => {
1057                    error_count += 1;
1058                    log::warn!(
1059                        "Failed to parse instrument {}: {}",
1060                        raw_instrument.instrument_name,
1061                        e
1062                    );
1063                }
1064            }
1065        }
1066
1067        log::debug!(
1068            "Parsed {} instruments ({} skipped, {} errors)",
1069            instruments.len(),
1070            skipped_count,
1071            error_count
1072        );
1073
1074        Ok(instruments)
1075    }
1076
1077    /// Requests a specific instrument by its Nautilus instrument ID.
1078    ///
1079    /// This is a high-level method that fetches the raw instrument data from Deribit
1080    /// and converts it to a Nautilus `InstrumentAny` type.
1081    ///
1082    /// # Errors
1083    ///
1084    /// Returns an error if:
1085    /// - The instrument name format is invalid (error code `-32602`)
1086    /// - The instrument doesn't exist (error code `13020`)
1087    /// - Network or API errors occur
1088    pub async fn request_instrument(
1089        &self,
1090        instrument_id: InstrumentId,
1091    ) -> anyhow::Result<InstrumentAny> {
1092        let params = GetInstrumentParams {
1093            instrument_name: instrument_id.symbol.to_string(),
1094        };
1095
1096        let full_response = self.inner.get_instrument(params).await?;
1097        let response = full_response
1098            .result
1099            .ok_or_else(|| anyhow::anyhow!("No result in response"))?;
1100        let ts_event = extract_server_timestamp(full_response.us_out)?;
1101        let ts_init = self.generate_ts_init();
1102
1103        match parse_deribit_instrument_any(&response, ts_init, ts_event)? {
1104            Some(mut instrument) => {
1105                if Self::is_combo_kind(response.kind) {
1106                    let currency = DeribitCurrency::from_str(response.base_currency.as_str())
1107                        .unwrap_or(DeribitCurrency::ANY);
1108                    let combo_by_id = self
1109                        .combo_map_for_instruments(currency, std::slice::from_ref(&response))
1110                        .await;
1111
1112                    if let Some(combo) = combo_by_id.get(&response.instrument_name) {
1113                        Self::attach_combo_leg_info(&mut instrument, combo);
1114                    }
1115                }
1116
1117                Ok(instrument)
1118            }
1119            None => anyhow::bail!(
1120                "Unsupported instrument type: {} (kind: {:?})",
1121                response.instrument_name,
1122                response.kind
1123            ),
1124        }
1125    }
1126
1127    async fn combo_map_for_instruments(
1128        &self,
1129        requested_currency: DeribitCurrency,
1130        raw_instruments: &[DeribitInstrument],
1131    ) -> AHashMap<Ustr, DeribitCombo> {
1132        if !raw_instruments
1133            .iter()
1134            .any(|instrument| Self::is_combo_kind(instrument.kind))
1135        {
1136            return AHashMap::new();
1137        }
1138
1139        let mut currencies = AHashSet::new();
1140
1141        if requested_currency == DeribitCurrency::ANY {
1142            for instrument in raw_instruments
1143                .iter()
1144                .filter(|instrument| Self::is_combo_kind(instrument.kind))
1145            {
1146                if let Ok(currency) = DeribitCurrency::from_str(instrument.base_currency.as_str()) {
1147                    currencies.insert(currency);
1148                }
1149            }
1150        } else {
1151            currencies.insert(requested_currency);
1152        }
1153
1154        let mut combo_by_id = AHashMap::new();
1155
1156        for currency in currencies {
1157            match self.inner.get_combos(GetCombosParams::new(currency)).await {
1158                Ok(response) => {
1159                    if let Some(combos) = response.result {
1160                        for combo in combos {
1161                            combo_by_id.insert(combo.id, combo);
1162                        }
1163                    }
1164                }
1165                Err(e) => {
1166                    log::warn!("Failed to load Deribit combo definitions for {currency}: {e}");
1167                }
1168            }
1169        }
1170
1171        combo_by_id
1172    }
1173
1174    fn is_combo_kind(kind: DeribitProductType) -> bool {
1175        matches!(
1176            kind,
1177            DeribitProductType::FutureCombo | DeribitProductType::OptionCombo
1178        )
1179    }
1180
1181    fn attach_combo_leg_info(instrument: &mut InstrumentAny, combo: &DeribitCombo) {
1182        if let Some(info) = Self::combo_leg_info(instrument, combo) {
1183            match instrument {
1184                InstrumentAny::CryptoOptionSpread(spread) => spread.info = Some(info),
1185                InstrumentAny::CryptoFuturesSpread(spread) => spread.info = Some(info),
1186                _ => {}
1187            }
1188        }
1189    }
1190
1191    fn combo_leg_info(instrument: &InstrumentAny, combo: &DeribitCombo) -> Option<Params> {
1192        let existing_info = match instrument {
1193            InstrumentAny::CryptoOptionSpread(spread) => spread.info.clone(),
1194            InstrumentAny::CryptoFuturesSpread(spread) => spread.info.clone(),
1195            _ => return None,
1196        };
1197
1198        let mut info = existing_info.unwrap_or_default();
1199        let legs = combo
1200            .legs
1201            .iter()
1202            .map(|leg| {
1203                let instrument_id =
1204                    InstrumentId::new(Symbol::new(leg.instrument_name), *DERIBIT_VENUE);
1205
1206                json!({
1207                    "amount": leg.amount,
1208                    "instrument_id": instrument_id.to_string(),
1209                    "instrument_name": leg.instrument_name,
1210                })
1211            })
1212            .collect::<Vec<_>>();
1213
1214        info.insert("deribit_combo_id".to_string(), json!(combo.id));
1215        info.insert(
1216            "deribit_combo_state".to_string(),
1217            json!(combo.state.as_str()),
1218        );
1219        info.insert("deribit_combo_legs".to_string(), json!(legs));
1220
1221        Some(info)
1222    }
1223
1224    /// Requests historical trades for an instrument within a time range.
1225    ///
1226    /// Fetches trade ticks from Deribit and converts them to Nautilus [`TradeTick`] objects.
1227    ///
1228    /// # Arguments
1229    ///
1230    /// * `instrument_id` - The instrument to fetch trades for
1231    /// * `start` - Optional start time filter
1232    /// * `end` - Optional end time filter
1233    /// * `limit` - Optional limit on number of trades (max 1000)
1234    ///
1235    /// # Errors
1236    ///
1237    /// Returns an error if:
1238    /// - The instrument is not found in cache
1239    /// - The request fails
1240    /// - Trade parsing fails
1241    ///
1242    /// # Pagination
1243    ///
1244    /// When `limit` is `None`, this function automatically paginates through all available
1245    /// trades in the time range using the `has_more` field from the API response.
1246    /// When `limit` is specified, pagination stops once that many trades are collected.
1247    pub async fn request_trades(
1248        &self,
1249        instrument_id: InstrumentId,
1250        start: Option<Timestamp>,
1251        end: Option<Timestamp>,
1252        limit: Option<u32>,
1253    ) -> anyhow::Result<Vec<TradeTick>> {
1254        // Get instrument from cache to determine precisions
1255        let (price_precision, size_precision) =
1256            if let Some(instrument) = self.get_instrument(&instrument_id.symbol.inner()) {
1257                (instrument.price_precision(), instrument.size_precision())
1258            } else {
1259                log::warn!("Instrument {instrument_id} not in cache, skipping trades request");
1260                return Err(InstrumentLookupError::not_found(instrument_id).into());
1261            };
1262
1263        // Convert timestamps to milliseconds
1264        let now = Timestamp::now();
1265        let end_dt = end.unwrap_or(now);
1266        let start_dt = start.unwrap_or(end_dt - jiff::SignedDuration::from_hours(1));
1267
1268        if let (Some(s), Some(e)) = (start, end) {
1269            anyhow::ensure!(s < e, "Invalid time range: start={s:?} end={e:?}");
1270        }
1271
1272        let start_ms = start_dt.as_millisecond();
1273        let end_ms = end_dt.as_millisecond();
1274        let ts_init = self.generate_ts_init();
1275        let mut all_trades = Vec::new();
1276        let mut paginator = TradePaginator::new(start_ms, end_ms);
1277
1278        loop {
1279            let params = GetLastTradesByInstrumentAndTimeParams::new(
1280                instrument_id.symbol.to_string(),
1281                paginator.cursor,
1282                end_ms,
1283                Some(DERIBIT_HISTORICAL_TRADES_MAX_COUNT),
1284                Some("asc".to_string()),
1285            );
1286
1287            let full_response = self
1288                .inner
1289                .get_last_trades_by_instrument_and_time(params)
1290                .await
1291                .map_err(|e| anyhow::anyhow!(e))?;
1292
1293            let response_data = full_response
1294                .result
1295                .ok_or_else(|| anyhow::anyhow!("No result in response"))?;
1296
1297            let ids: Vec<String> = response_data
1298                .trades
1299                .iter()
1300                .map(|t| t.trade_id.clone())
1301                .collect();
1302            let timestamps: Vec<i64> = response_data.trades.iter().map(|t| t.timestamp).collect();
1303
1304            let Some(new_indices) = paginator.advance(&ids, &timestamps, response_data.has_more)
1305            else {
1306                break;
1307            };
1308
1309            for i in &new_indices {
1310                let raw_trade = &response_data.trades[*i];
1311
1312                match parse_trade_tick(
1313                    raw_trade,
1314                    instrument_id,
1315                    price_precision,
1316                    size_precision,
1317                    ts_init,
1318                ) {
1319                    Ok(trade) => {
1320                        all_trades.push(trade);
1321
1322                        if let Some(max) = limit
1323                            && all_trades.len() >= max as usize
1324                        {
1325                            return Ok(all_trades);
1326                        }
1327                    }
1328                    Err(e) => {
1329                        log::warn!(
1330                            "Failed to parse trade {} for {}: {}",
1331                            raw_trade.trade_id,
1332                            instrument_id,
1333                            e
1334                        );
1335                    }
1336                }
1337            }
1338
1339            if !response_data.has_more || paginator.is_exhausted() {
1340                break;
1341            }
1342        }
1343
1344        log::debug!(
1345            "Fetched {} historical trades for {} from {} to {}",
1346            all_trades.len(),
1347            instrument_id,
1348            start_dt,
1349            end_dt
1350        );
1351
1352        Ok(all_trades)
1353    }
1354
1355    /// Requests historical bars (OHLCV) for an instrument.
1356    ///
1357    /// Uses the `public/get_tradingview_chart_data` endpoint to fetch candlestick data.
1358    ///
1359    /// # Errors
1360    ///
1361    /// Returns an error if:
1362    /// - Aggregation source is not EXTERNAL
1363    /// - Bar aggregation type is not supported by Deribit
1364    /// - The instrument is not found in cache
1365    /// - The request fails or response cannot be parsed
1366    ///
1367    /// # Supported Resolutions
1368    ///
1369    /// Deribit supports: 1, 3, 5, 10, 15, 30, 60, 120, 180, 360, 720 minutes, and 1D (daily)
1370    pub async fn request_bars(
1371        &self,
1372        bar_type: BarType,
1373        start: Option<Timestamp>,
1374        end: Option<Timestamp>,
1375        limit: Option<u32>,
1376    ) -> anyhow::Result<Vec<Bar>> {
1377        anyhow::ensure!(
1378            bar_type.aggregation_source() == AggregationSource::External,
1379            "Only EXTERNAL aggregation is supported"
1380        );
1381
1382        let now = Timestamp::now();
1383
1384        // Default to last hour if no start/end provided
1385        let end_dt = end.unwrap_or(now);
1386        let start_dt = start.unwrap_or(end_dt - jiff::SignedDuration::from_hours(1));
1387
1388        if let (Some(s), Some(e)) = (start, end) {
1389            anyhow::ensure!(s < e, "Invalid time range: start={s:?} end={e:?}");
1390        }
1391
1392        // Convert BarType to Deribit resolution
1393        let spec = bar_type.spec();
1394        let step = spec.step.get();
1395        let resolution = match spec.aggregation {
1396            BarAggregation::Minute => format!("{step}"),
1397            BarAggregation::Hour => format!("{}", step * 60),
1398            BarAggregation::Day => "1D".to_string(),
1399            a => anyhow::bail!("Deribit does not support {a:?} aggregation"),
1400        };
1401
1402        // Validate resolution is supported by Deribit
1403        let supported_resolutions = [
1404            "1", "3", "5", "10", "15", "30", "60", "120", "180", "360", "720", "1D",
1405        ];
1406
1407        if !supported_resolutions.contains(&resolution.as_str()) {
1408            anyhow::bail!(
1409                "Deribit does not support resolution '{resolution}'. Supported: {supported_resolutions:?}"
1410            );
1411        }
1412
1413        let instrument_id = bar_type.instrument_id();
1414        let (price_precision, size_precision, use_cost_for_volume) =
1415            if let Some(instrument) = self.get_instrument(&instrument_id.symbol.inner()) {
1416                (
1417                    instrument.price_precision(),
1418                    instrument.size_precision(),
1419                    use_cost_for_bar_volume(&instrument),
1420                )
1421            } else {
1422                log::warn!("Instrument {instrument_id} not in cache, skipping bars request");
1423                return Err(InstrumentLookupError::not_found(instrument_id).into());
1424            };
1425
1426        let instrument_name = instrument_id.symbol.to_string();
1427        let start_timestamp = start_dt.as_millisecond();
1428        let end_timestamp = end_dt.as_millisecond();
1429
1430        let params = GetTradingViewChartDataParams::new(
1431            instrument_name,
1432            start_timestamp,
1433            end_timestamp,
1434            resolution,
1435        );
1436
1437        let full_response = self.inner.get_tradingview_chart_data(params).await?;
1438        let chart_data = full_response
1439            .result
1440            .ok_or_else(|| anyhow::anyhow!("No result in response"))?;
1441
1442        if chart_data.status == "no_data" {
1443            log::debug!("No bar data returned for {bar_type}");
1444            return Ok(Vec::new());
1445        }
1446
1447        let ts_init = self.generate_ts_init();
1448        let mut bars = parse_bars(
1449            &chart_data,
1450            bar_type,
1451            price_precision,
1452            size_precision,
1453            use_cost_for_volume,
1454            ts_init,
1455        )?;
1456
1457        if let Some(max) = limit {
1458            let max = max as usize;
1459            if bars.len() > max {
1460                bars.drain(..bars.len() - max);
1461            }
1462        }
1463
1464        log::debug!("Parsed {} bars for {}", bars.len(), bar_type);
1465
1466        Ok(bars)
1467    }
1468
1469    /// Requests a snapshot of the order book for an instrument.
1470    ///
1471    /// Fetches the order book from Deribit and converts it to a Nautilus [`OrderBook`].
1472    ///
1473    /// # Arguments
1474    ///
1475    /// * `instrument_id` - The instrument to fetch the order book for
1476    /// * `depth` - Optional depth limit (valid values: 1, 5, 10, 20, 50, 100, 1000, 10000)
1477    ///
1478    /// # Errors
1479    ///
1480    /// Returns an error if:
1481    /// - The instrument is not found in cache
1482    /// - The request fails
1483    /// - Order book parsing fails
1484    pub async fn request_book_snapshot(
1485        &self,
1486        instrument_id: InstrumentId,
1487        depth: Option<u32>,
1488    ) -> anyhow::Result<OrderBook> {
1489        let (price_precision, size_precision) =
1490            if let Some(instrument) = self.get_instrument(&instrument_id.symbol.inner()) {
1491                (instrument.price_precision(), instrument.size_precision())
1492            } else {
1493                return Err(InstrumentLookupError::not_found(instrument_id).into());
1494            };
1495
1496        let params = GetOrderBookParams::new(instrument_id.symbol.to_string(), depth);
1497        let full_response = self
1498            .inner
1499            .get_order_book(params)
1500            .await
1501            .map_err(|e| anyhow::anyhow!(e))?;
1502
1503        let order_book_data = full_response
1504            .result
1505            .ok_or_else(|| anyhow::anyhow!("No result in response"))?;
1506
1507        let ts_init = self.generate_ts_init();
1508        let book = parse_order_book(
1509            &order_book_data,
1510            instrument_id,
1511            price_precision,
1512            size_precision,
1513            ts_init,
1514        )?;
1515
1516        log::debug!(
1517            "Fetched order book for {} with {} bids and {} asks",
1518            instrument_id,
1519            order_book_data.bids.len(),
1520            order_book_data.asks.len()
1521        );
1522
1523        Ok(book)
1524    }
1525
1526    /// Requests account state for all currencies.
1527    ///
1528    /// Fetches account balance and margin information for all currencies from Deribit
1529    /// and converts it to Nautilus [`AccountState`] event.
1530    ///
1531    /// # Errors
1532    ///
1533    /// Returns an error if:
1534    /// - The request fails
1535    /// - Currency conversion fails
1536    pub async fn request_account_state(
1537        &self,
1538        account_id: AccountId,
1539    ) -> anyhow::Result<AccountState> {
1540        let params = GetAccountSummariesParams::default();
1541        let full_response = self
1542            .inner
1543            .get_account_summaries(params)
1544            .await
1545            .map_err(|e| anyhow::anyhow!(e))?;
1546        let response_data = full_response
1547            .result
1548            .ok_or_else(|| anyhow::anyhow!("No result in response"))?;
1549        let ts_init = self.generate_ts_init();
1550        let ts_event = extract_server_timestamp(full_response.us_out)?;
1551
1552        parse_account_state(&response_data.summaries, account_id, ts_init, ts_event)
1553    }
1554
1555    /// Generates a timestamp for initialization.
1556    fn generate_ts_init(&self) -> UnixNanos {
1557        self.clock.get_time_ns()
1558    }
1559
1560    /// Caches instruments for later retrieval.
1561    pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
1562        self.instruments_cache.rcu(|m| {
1563            for inst in instruments {
1564                m.insert(inst.raw_symbol().inner(), inst.clone());
1565            }
1566        });
1567        self.cache_initialized.store(true, Ordering::Release);
1568    }
1569
1570    /// Retrieves a cached instrument by symbol.
1571    #[must_use]
1572    pub fn get_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1573        self.instruments_cache.get_cloned(symbol)
1574    }
1575
1576    /// Checks if the instrument cache has been initialized.
1577    #[must_use]
1578    pub fn is_cache_initialized(&self) -> bool {
1579        self.cache_initialized.load(Ordering::Acquire)
1580    }
1581
1582    /// Returns whether this client is connected to testnet.
1583    #[must_use]
1584    pub fn is_testnet(&self) -> bool {
1585        self.inner.is_testnet()
1586    }
1587
1588    /// Requests order status reports for reconciliation.
1589    ///
1590    /// Fetches order statuses from Deribit and converts them to Nautilus [`OrderStatusReport`].
1591    ///
1592    /// # Strategy
1593    /// - Uses `/private/get_open_orders` for all open orders (single efficient API call)
1594    /// - Uses `/private/get_open_orders_by_instrument` when specific instrument is provided
1595    /// - For historical orders (when `open_only=false`), iterates over currencies
1596    ///
1597    /// # Errors
1598    ///
1599    /// Returns an error if the request fails or parsing fails.
1600    pub async fn request_order_status_reports(
1601        &self,
1602        account_id: AccountId,
1603        instrument_id: Option<InstrumentId>,
1604        start: Option<UnixNanos>,
1605        end: Option<UnixNanos>,
1606        open_only: bool,
1607    ) -> anyhow::Result<Vec<OrderStatusReport>> {
1608        let ts_init = self.generate_ts_init();
1609        let mut reports = Vec::new();
1610        let mut seen_order_ids = AHashSet::new();
1611
1612        let mut parse_and_add = |order: &DeribitOrderMsg| {
1613            let symbol = order.instrument_name;
1614            if let Some(instrument) = self.get_instrument(&symbol) {
1615                match parse_user_order_msg(order, &instrument, account_id, ts_init) {
1616                    Ok(report) => {
1617                        // Apply time range filter based on ts_last
1618                        let ts_last = report.ts_last;
1619                        let in_range = match (start, end) {
1620                            (Some(s), Some(e)) => ts_last >= s && ts_last <= e,
1621                            (Some(s), None) => ts_last >= s,
1622                            (None, Some(e)) => ts_last <= e,
1623                            (None, None) => true,
1624                        };
1625                        // Only deduplicate if in range (prevents dropping valid historical reports)
1626                        if in_range && seen_order_ids.insert(order.order_id.clone()) {
1627                            reports.push(report);
1628                        }
1629                    }
1630                    Err(e) => {
1631                        log::warn!(
1632                            "Failed to parse order {} for {}: {}",
1633                            order.order_id,
1634                            order.instrument_name,
1635                            e
1636                        );
1637                    }
1638                }
1639            } else {
1640                log::debug!(
1641                    "Skipping order {} - instrument {} not in cache",
1642                    order.order_id,
1643                    order.instrument_name
1644                );
1645            }
1646        };
1647
1648        if let Some(instrument_id) = instrument_id {
1649            // Use instrument-specific endpoint (efficient)
1650            let instrument_name = instrument_id.symbol.to_string();
1651
1652            // Get open orders for this instrument
1653            let open_params = GetOpenOrdersByInstrumentParams {
1654                instrument_name: instrument_name.clone(),
1655                r#type: None,
1656            };
1657
1658            if let Some(orders) = self
1659                .inner
1660                .get_open_orders_by_instrument(open_params)
1661                .await?
1662                .result
1663            {
1664                for order in &orders {
1665                    parse_and_add(order);
1666                }
1667            }
1668
1669            if !open_only {
1670                const PAGE_SIZE: u32 = 100;
1671                let mut offset: u32 = 0;
1672
1673                loop {
1674                    let history_params = GetOrderHistoryByInstrumentParams {
1675                        instrument_name: instrument_name.clone(),
1676                        count: Some(PAGE_SIZE),
1677                        offset: Some(offset),
1678                        include_old: Some(true),
1679                        include_unfilled: Some(true),
1680                    };
1681                    let orders = self
1682                        .inner
1683                        .get_order_history_by_instrument(history_params)
1684                        .await?
1685                        .result
1686                        .unwrap_or_default();
1687
1688                    let count = orders.len() as u32;
1689                    for order in &orders {
1690                        parse_and_add(order);
1691                    }
1692
1693                    if count < PAGE_SIZE {
1694                        break;
1695                    }
1696                    offset += count;
1697                }
1698            }
1699        } else {
1700            // Use get_open_orders for ALL open orders - single API call!
1701            let open_params = GetOpenOrdersParams::default();
1702            if let Some(orders) = self.inner.get_open_orders(open_params).await?.result {
1703                for order in &orders {
1704                    parse_and_add(order);
1705                }
1706            }
1707
1708            if !open_only {
1709                const PAGE_SIZE: u32 = 100;
1710
1711                for currency in DeribitCurrency::iter().filter(|c| *c != DeribitCurrency::ANY) {
1712                    let mut offset: u32 = 0;
1713
1714                    loop {
1715                        let history_params = GetOrderHistoryByCurrencyParams {
1716                            currency,
1717                            kind: None,
1718                            count: Some(PAGE_SIZE),
1719                            offset: Some(offset),
1720                            include_old: Some(true),
1721                            include_unfilled: Some(true),
1722                        };
1723                        let orders = self
1724                            .inner
1725                            .get_order_history_by_currency(history_params)
1726                            .await?
1727                            .result
1728                            .unwrap_or_default();
1729
1730                        let count = orders.len() as u32;
1731                        for order in &orders {
1732                            parse_and_add(order);
1733                        }
1734
1735                        if count < PAGE_SIZE {
1736                            break;
1737                        }
1738                        offset += count;
1739                    }
1740                }
1741            }
1742        }
1743
1744        log::debug!("Generated {} order status reports", reports.len());
1745        Ok(reports)
1746    }
1747
1748    /// Requests fill reports for reconciliation.
1749    ///
1750    /// Fetches user trades from Deribit and converts them to Nautilus [`FillReport`].
1751    /// Automatically paginates through all results using time-cursor advancement.
1752    ///
1753    /// # Strategy
1754    /// - Uses `/private/get_user_trades_by_instrument_and_time` when instrument is provided
1755    /// - Otherwise iterates over currencies using `/private/get_user_trades_by_currency_and_time`
1756    ///
1757    /// # Errors
1758    ///
1759    /// Returns an error if the request fails or parsing fails.
1760    pub async fn request_fill_reports(
1761        &self,
1762        account_id: AccountId,
1763        instrument_id: Option<InstrumentId>,
1764        start: Option<UnixNanos>,
1765        end: Option<UnixNanos>,
1766    ) -> anyhow::Result<Vec<FillReport>> {
1767        let ts_init = self.generate_ts_init();
1768        let now_ms = Timestamp::now().as_millisecond();
1769
1770        // Convert UnixNanos to milliseconds for Deribit API
1771        let start_ms = start.map_or(0, |ns| nanos_to_millis(ns.as_u64()) as i64);
1772        let end_ms = end.map_or(now_ms, |ns| nanos_to_millis(ns.as_u64()) as i64);
1773        let mut reports = Vec::new();
1774
1775        let mut parse_and_add = |trade: &DeribitUserTradeMsg| {
1776            let symbol = trade.instrument_name;
1777            if let Some(instrument) = self.get_instrument(&symbol) {
1778                match parse_user_trade_msg(trade, &instrument, account_id, ts_init) {
1779                    Ok(report) => reports.push(report),
1780                    Err(e) => {
1781                        log::warn!(
1782                            "Failed to parse trade {} for {}: {}",
1783                            trade.trade_id,
1784                            trade.instrument_name,
1785                            e
1786                        );
1787                    }
1788                }
1789            } else {
1790                log::debug!(
1791                    "Skipping trade {} - instrument {} not in cache",
1792                    trade.trade_id,
1793                    trade.instrument_name
1794                );
1795            }
1796        };
1797
1798        let mut paginator = TradePaginator::new(start_ms, end_ms);
1799
1800        if let Some(instrument_id) = instrument_id {
1801            loop {
1802                let params = GetUserTradesByInstrumentAndTimeParams {
1803                    instrument_name: instrument_id.symbol.to_string(),
1804                    start_timestamp: paginator.cursor,
1805                    end_timestamp: end_ms,
1806                    count: Some(DERIBIT_HISTORICAL_TRADES_MAX_COUNT),
1807                    sorting: Some("asc".to_string()),
1808                };
1809                let response = self
1810                    .inner
1811                    .get_user_trades_by_instrument_and_time(params)
1812                    .await?;
1813
1814                let Some(data) = response.result else { break };
1815
1816                let ids: Vec<String> = data.trades.iter().map(|t| t.trade_id.clone()).collect();
1817                let timestamps: Vec<i64> = data.trades.iter().map(|t| t.timestamp as i64).collect();
1818
1819                let Some(new_indices) = paginator.advance(&ids, &timestamps, data.has_more) else {
1820                    break;
1821                };
1822
1823                for i in &new_indices {
1824                    parse_and_add(&data.trades[*i]);
1825                }
1826
1827                if !data.has_more || paginator.is_exhausted() {
1828                    break;
1829                }
1830            }
1831        } else {
1832            for currency in DeribitCurrency::iter().filter(|c| *c != DeribitCurrency::ANY) {
1833                paginator.reset(start_ms);
1834
1835                loop {
1836                    let params = GetUserTradesByCurrencyAndTimeParams {
1837                        currency,
1838                        start_timestamp: paginator.cursor,
1839                        end_timestamp: end_ms,
1840                        kind: None,
1841                        count: Some(DERIBIT_HISTORICAL_TRADES_MAX_COUNT),
1842                        sorting: Some("asc".to_string()),
1843                    };
1844                    let response = self
1845                        .inner
1846                        .get_user_trades_by_currency_and_time(params)
1847                        .await?;
1848
1849                    let Some(data) = response.result else { break };
1850
1851                    let ids: Vec<String> = data.trades.iter().map(|t| t.trade_id.clone()).collect();
1852                    let timestamps: Vec<i64> =
1853                        data.trades.iter().map(|t| t.timestamp as i64).collect();
1854
1855                    let Some(new_indices) = paginator.advance(&ids, &timestamps, data.has_more)
1856                    else {
1857                        break;
1858                    };
1859
1860                    for i in &new_indices {
1861                        parse_and_add(&data.trades[*i]);
1862                    }
1863
1864                    if !data.has_more || paginator.is_exhausted() {
1865                        break;
1866                    }
1867                }
1868            }
1869        }
1870
1871        log::debug!("Generated {} fill reports", reports.len());
1872        Ok(reports)
1873    }
1874
1875    /// Requests ticker data for a single instrument.
1876    ///
1877    /// Returns the `DeribitTicker` including its option-chain reference price.
1878    ///
1879    /// # Errors
1880    ///
1881    /// Returns an error if the request fails.
1882    pub async fn request_ticker(&self, instrument_name: &str) -> anyhow::Result<DeribitTicker> {
1883        let params = GetTickerParams {
1884            instrument_name: instrument_name.to_string(),
1885        };
1886        let response = self
1887            .inner
1888            .get_ticker(params)
1889            .await
1890            .map_err(|e| anyhow::anyhow!(e))?;
1891        response
1892            .result
1893            .ok_or_else(|| anyhow::anyhow!("No result in ticker response"))
1894    }
1895
1896    /// Requests book summaries for a currency via `public/get_book_summary_by_currency`.
1897    ///
1898    /// Defaults to product kind `option`.
1899    /// Entries include mark/IV, bid-ask, volumes, and `underlying_price` (forward) when present.
1900    ///
1901    /// # Errors
1902    ///
1903    /// Returns an error if the request fails.
1904    pub async fn request_book_summaries(
1905        &self,
1906        currency: &str,
1907    ) -> anyhow::Result<Vec<DeribitBookSummaryRaw>> {
1908        self.request_book_summaries_kind(currency, Some("option"))
1909            .await
1910    }
1911
1912    /// Requests book summaries for a currency with an optional product `kind` filter.
1913    ///
1914    /// When `kind` is `None`, Deribit returns summaries for all product kinds.
1915    ///
1916    /// # Errors
1917    ///
1918    /// Returns an error if the request fails.
1919    pub async fn request_book_summaries_kind(
1920        &self,
1921        currency: &str,
1922        kind: Option<&str>,
1923    ) -> anyhow::Result<Vec<DeribitBookSummaryRaw>> {
1924        let params = GetBookSummaryByCurrencyParams {
1925            currency: currency.to_string(),
1926            kind: kind.map(str::to_string),
1927        };
1928        let full_response = self
1929            .inner
1930            .get_book_summary_by_currency(params)
1931            .await
1932            .map_err(|e| anyhow::anyhow!(e))?;
1933        full_response
1934            .result
1935            .ok_or_else(|| anyhow::anyhow!("No result in book summary response"))
1936    }
1937
1938    /// Requests traded option expirations for a settlement currency.
1939    ///
1940    /// # Errors
1941    ///
1942    /// Returns an error if the request fails.
1943    pub async fn request_option_expirations(
1944        &self,
1945        currency: DeribitCurrency,
1946    ) -> anyhow::Result<Vec<String>> {
1947        let params = GetExpirationsParams::new(currency.as_str(), DeribitExpirationKind::Option);
1948        let full_response = self
1949            .inner
1950            .get_expirations(params)
1951            .await
1952            .map_err(|e| anyhow::anyhow!(e))?;
1953        let response = full_response
1954            .result
1955            .ok_or_else(|| anyhow::anyhow!("No result in expirations response"))?;
1956        let expirations = response
1957            .expirations_for_currency(currency.as_str())
1958            .ok_or_else(|| anyhow::anyhow!("No option expirations for {currency}"))?;
1959
1960        Ok(expirations.option.clone())
1961    }
1962
1963    /// Requests position status reports for reconciliation.
1964    ///
1965    /// Fetches positions from Deribit and converts them to Nautilus [`PositionStatusReport`].
1966    ///
1967    /// # Strategy
1968    /// - Uses `currency=any` to fetch all positions in one call
1969    /// - Filters by instrument_id if provided
1970    ///
1971    /// # Errors
1972    ///
1973    /// Returns an error if the request fails or parsing fails.
1974    pub async fn request_position_status_reports(
1975        &self,
1976        account_id: AccountId,
1977        instrument_id: Option<InstrumentId>,
1978    ) -> anyhow::Result<Vec<PositionStatusReport>> {
1979        let ts_init = self.generate_ts_init();
1980        let mut reports = Vec::new();
1981
1982        // Use ANY to get all positions across all currencies in one call
1983        let params = GetPositionsParams {
1984            currency: DeribitCurrency::ANY,
1985            kind: None,
1986        };
1987
1988        if let Some(positions) = self.inner.get_positions(params).await?.result {
1989            for position in &positions {
1990                // Skip flat positions (size == 0)
1991                if position.size.is_zero() {
1992                    continue;
1993                }
1994
1995                let symbol = position.instrument_name;
1996                if let Some(instrument) = self.get_instrument(&symbol) {
1997                    let report =
1998                        parse_position_status_report(position, &instrument, account_id, ts_init);
1999                    reports.push(report);
2000                } else {
2001                    log::debug!(
2002                        "Skipping position - instrument {} not in cache",
2003                        position.instrument_name
2004                    );
2005                }
2006            }
2007        }
2008
2009        // Filter by instrument if provided
2010        if let Some(instrument_id) = instrument_id {
2011            reports.retain(|r| r.instrument_id == instrument_id);
2012        }
2013
2014        log::debug!("Generated {} position status reports", reports.len());
2015        Ok(reports)
2016    }
2017}
2018
2019#[cfg(test)]
2020mod tests {
2021    use nautilus_testkit::http::assert_http_redirect_rejected;
2022    use rstest::rstest;
2023
2024    use super::*;
2025    use crate::common::consts::{
2026        DERIBIT_ACCOUNT_RATE_KEY, DERIBIT_GLOBAL_RATE_KEY, DERIBIT_ORDER_RATE_KEY,
2027    };
2028
2029    #[tokio::test]
2030    async fn test_authenticated_client_rejects_redirects() {
2031        let client = DeribitRawHttpClient::with_credentials(
2032            "key".into(),
2033            "secret".into(),
2034            None,
2035            DeribitEnvironment::Testnet,
2036            3,
2037            0,
2038            1,
2039            1,
2040            None,
2041        )
2042        .unwrap()
2043        .client;
2044        assert_http_redirect_rejected(|url| async move {
2045            client
2046                .get(url, None, None, Some(3), None)
2047                .await
2048                .unwrap()
2049                .status
2050                .as_u16()
2051        })
2052        .await;
2053    }
2054
2055    #[rstest]
2056    #[case("private/buy", true, false)]
2057    #[case("private/cancel", true, false)]
2058    #[case("private/get_account_summaries", false, true)]
2059    #[case("private/get_positions", false, true)]
2060    #[case("public/get_instruments", false, false)]
2061    fn test_method_classification(
2062        #[case] method: &str,
2063        #[case] is_order: bool,
2064        #[case] is_account: bool,
2065    ) {
2066        assert_eq!(DeribitRawHttpClient::is_order_method(method), is_order);
2067        assert_eq!(DeribitRawHttpClient::is_account_method(method), is_account);
2068    }
2069
2070    #[rstest]
2071    #[case("private/buy", vec![DERIBIT_GLOBAL_RATE_KEY, DERIBIT_ORDER_RATE_KEY])]
2072    #[case("private/get_account_summaries", vec![DERIBIT_GLOBAL_RATE_KEY, DERIBIT_ACCOUNT_RATE_KEY])]
2073    #[case("public/get_instruments", vec![DERIBIT_GLOBAL_RATE_KEY])]
2074    fn test_rate_limit_keys(#[case] method: &str, #[case] expected_keys: Vec<&str>) {
2075        let keys = DeribitRawHttpClient::rate_limit_keys(method);
2076
2077        for key in &expected_keys {
2078            assert!(keys.contains(&key.to_string()));
2079        }
2080        assert!(keys.contains(&format!("deribit:{method}")));
2081    }
2082
2083    #[rstest]
2084    fn test_paginator_empty_page_returns_none() {
2085        let mut p = TradePaginator::new(100, 200);
2086        assert!(p.advance(&[], &[], true).is_none());
2087    }
2088
2089    #[rstest]
2090    fn test_paginator_single_page_no_more() {
2091        let mut p = TradePaginator::new(100, 200);
2092        let ids = vec!["t1".into(), "t2".into()];
2093        let ts = vec![150, 160];
2094
2095        let result = p.advance(&ids, &ts, false);
2096        assert_eq!(result, Some(vec![0, 1]));
2097    }
2098
2099    #[rstest]
2100    fn test_paginator_dedup_across_pages() {
2101        let mut p = TradePaginator::new(100, 200);
2102
2103        // First page: two new trades
2104        let ids1 = vec!["t1".into(), "t2".into()];
2105        let ts1 = vec![150, 150];
2106        let r1 = p.advance(&ids1, &ts1, true);
2107        assert_eq!(r1, Some(vec![0, 1]));
2108        assert_eq!(p.cursor, 150);
2109
2110        // Second page: t2 repeated, t3 new
2111        let ids2 = vec!["t2".into(), "t3".into()];
2112        let ts2 = vec![150, 150];
2113        let r2 = p.advance(&ids2, &ts2, false);
2114        assert_eq!(r2, Some(vec![1])); // Only t3 is new
2115    }
2116
2117    #[rstest]
2118    fn test_paginator_all_duplicates_advances_past_timestamp() {
2119        let mut p = TradePaginator::new(100, 200);
2120
2121        // First page
2122        let ids = vec!["t1".into(), "t2".into()];
2123        let ts = vec![150, 150];
2124        p.advance(&ids, &ts, true);
2125        assert_eq!(p.cursor, 150);
2126
2127        // Second page: same trades again (all duplicates)
2128        let r2 = p.advance(&ids, &ts, true);
2129        assert_eq!(r2, Some(vec![])); // No new items
2130        assert_eq!(p.cursor, 151); // Advanced past 150
2131    }
2132
2133    #[rstest]
2134    fn test_paginator_is_exhausted_strict_greater_than() {
2135        let mut p = TradePaginator::new(100, 150);
2136
2137        let ids = vec!["t1".into()];
2138        let ts = vec![150];
2139        p.advance(&ids, &ts, true);
2140
2141        // Cursor at end (150) should NOT be exhausted
2142        assert_eq!(p.cursor, 150);
2143        assert!(!p.is_exhausted());
2144
2145        // All duplicates: cursor advances to 151
2146        p.advance(&ids, &ts, true);
2147        assert_eq!(p.cursor, 151);
2148        assert!(p.is_exhausted());
2149    }
2150
2151    #[rstest]
2152    fn test_paginator_reset_clears_state() {
2153        let mut p = TradePaginator::new(100, 200);
2154
2155        let ids = vec!["t1".into()];
2156        let ts = vec![150];
2157        p.advance(&ids, &ts, true);
2158        assert_eq!(p.seen_ids.len(), 1);
2159
2160        p.reset(100);
2161        assert_eq!(p.cursor, 100);
2162        assert!(p.seen_ids.is_empty());
2163
2164        // Same ID is now treated as new
2165        let r = p.advance(&ids, &ts, false);
2166        assert_eq!(r, Some(vec![0]));
2167    }
2168}