Skip to main content

nautilus_deribit/http/
client.rs

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