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