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