Skip to content

Commit 8de442f

Browse files
authored
Implement Deribit rate limiting for HTTP and WebSocket clients (#3424)
1 parent 608fb0c commit 8de442f

File tree

3 files changed

+212
-19
lines changed

3 files changed

+212
-19
lines changed

crates/adapters/deribit/src/common/consts.rs

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@
1515

1616
//! Core constants for the Deribit adapter.
1717
18-
use std::sync::LazyLock;
18+
use std::{num::NonZeroU32, sync::LazyLock};
1919

2020
use ahash::AHashSet;
2121
use nautilus_model::identifiers::Venue;
22+
use nautilus_network::ratelimiter::quota::Quota;
2223
use ustr::Ustr;
2324

2425
/// Venue identifier string.
@@ -111,3 +112,74 @@ pub static DERIBIT_RETRY_ERROR_CODES: LazyLock<AHashSet<i64>> = LazyLock::new(||
111112
pub fn should_retry_error_code(error_code: i64) -> bool {
112113
DERIBIT_RETRY_ERROR_CODES.contains(&error_code)
113114
}
115+
116+
/// Default Deribit REST API rate limit: 20 requests per second sustained.
117+
///
118+
/// Deribit uses a credit-based system for non-matching engine requests:
119+
/// - Each request costs 500 credits
120+
/// - Maximum credits: 50,000
121+
/// - Refill rate: 10,000 credits/second (~20 sustained req/s)
122+
/// - Burst capacity: up to 100 requests (50,000 / 500)
123+
///
124+
/// # References
125+
///
126+
/// <https://docs.deribit.com/#rate-limits>
127+
pub static DERIBIT_HTTP_REST_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
128+
Quota::per_second(NonZeroU32::new(20).expect("20 is non-zero"))
129+
.allow_burst(NonZeroU32::new(100).expect("100 is non-zero"))
130+
});
131+
132+
/// Deribit matching engine (order operations) rate limit.
133+
///
134+
/// Matching engine requests (buy, sell, edit, cancel) have separate limits:
135+
/// - Default burst: 20
136+
/// - Default rate: 5 requests/second
137+
///
138+
/// Note: Actual limits vary by account tier based on 7-day trading volume.
139+
pub static DERIBIT_HTTP_ORDER_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
140+
Quota::per_second(NonZeroU32::new(5).expect("5 is non-zero"))
141+
.allow_burst(NonZeroU32::new(20).expect("20 is non-zero"))
142+
});
143+
144+
/// Conservative rate limit for account information endpoints.
145+
pub static DERIBIT_HTTP_ACCOUNT_QUOTA: LazyLock<Quota> =
146+
LazyLock::new(|| Quota::per_second(NonZeroU32::new(5).expect("5 is non-zero")));
147+
148+
/// Global rate limit key for Deribit HTTP requests.
149+
pub const DERIBIT_GLOBAL_RATE_KEY: &str = "deribit:global";
150+
151+
/// Rate limit key for Deribit order operations (matching engine).
152+
pub const DERIBIT_ORDER_RATE_KEY: &str = "deribit:orders";
153+
154+
/// Rate limit key for account information endpoints.
155+
pub const DERIBIT_ACCOUNT_RATE_KEY: &str = "deribit:account";
156+
157+
/// Deribit WebSocket subscription rate limit.
158+
///
159+
/// Subscribe methods have custom rate limits:
160+
/// - Cost per request: 3,000 credits
161+
/// - Maximum credits: 30,000
162+
/// - Sustained rate: ~3.3 requests/second
163+
/// - Burst capacity: 10 requests
164+
///
165+
/// # References
166+
///
167+
/// <https://support.deribit.com/hc/en-us/articles/25944617523357-Rate-Limits>
168+
pub static DERIBIT_WS_SUBSCRIPTION_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
169+
Quota::per_second(NonZeroU32::new(3).expect("3 is non-zero"))
170+
.allow_burst(NonZeroU32::new(10).expect("10 is non-zero"))
171+
});
172+
173+
/// Deribit WebSocket order rate limit: 5 requests per second with 20 burst.
174+
///
175+
/// Matching engine operations (buy, sell, edit, cancel) have stricter limits.
176+
pub static DERIBIT_WS_ORDER_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
177+
Quota::per_second(NonZeroU32::new(5).expect("5 is non-zero"))
178+
.allow_burst(NonZeroU32::new(20).expect("20 is non-zero"))
179+
});
180+
181+
/// Rate limit key for WebSocket subscriptions.
182+
pub const DERIBIT_WS_SUBSCRIPTION_KEY: &str = "subscription";
183+
184+
/// Rate limit key for WebSocket order operations.
185+
pub const DERIBIT_WS_ORDER_KEY: &str = "order";

crates/adapters/deribit/src/http/client.rs

Lines changed: 126 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ use nautilus_model::{
3737
};
3838
use nautilus_network::{
3939
http::{HttpClient, Method},
40+
ratelimiter::quota::Quota,
4041
retry::{RetryConfig, RetryManager},
4142
};
4243
use serde::{Serialize, de::DeserializeOwned};
@@ -59,7 +60,11 @@ use super::{
5960
};
6061
use crate::{
6162
common::{
62-
consts::{DERIBIT_API_PATH, JSONRPC_VERSION, should_retry_error_code},
63+
consts::{
64+
DERIBIT_ACCOUNT_RATE_KEY, DERIBIT_API_PATH, DERIBIT_GLOBAL_RATE_KEY,
65+
DERIBIT_HTTP_ACCOUNT_QUOTA, DERIBIT_HTTP_ORDER_QUOTA, DERIBIT_HTTP_REST_QUOTA,
66+
DERIBIT_ORDER_RATE_KEY, JSONRPC_VERSION, should_retry_error_code,
67+
},
6368
credential::Credential,
6469
parse::{
6570
extract_server_timestamp, parse_account_state, parse_bars,
@@ -128,10 +133,10 @@ impl DeribitRawHttpClient {
128133
Ok(Self {
129134
base_url,
130135
client: HttpClient::new(
131-
HashMap::new(), // headers
132-
Vec::new(), // header_keys
133-
Vec::new(), // keyed_quotas
134-
None, // default_quota
136+
HashMap::new(),
137+
Vec::new(),
138+
Self::rate_limiter_quotas(),
139+
Some(*DERIBIT_HTTP_REST_QUOTA),
135140
timeout_secs,
136141
proxy_url,
137142
)
@@ -154,6 +159,80 @@ impl DeribitRawHttpClient {
154159
self.base_url.contains("test")
155160
}
156161

162+
/// Returns the rate limiter quotas for the HTTP client.
163+
///
164+
/// Quotas are organized by:
165+
/// - Global: Overall rate limit for all requests
166+
/// - Orders: Matching engine operations (buy, sell, cancel, etc.)
167+
/// - Account: Account information endpoints
168+
fn rate_limiter_quotas() -> Vec<(String, Quota)> {
169+
vec![
170+
(
171+
DERIBIT_GLOBAL_RATE_KEY.to_string(),
172+
*DERIBIT_HTTP_REST_QUOTA,
173+
),
174+
(
175+
DERIBIT_ORDER_RATE_KEY.to_string(),
176+
*DERIBIT_HTTP_ORDER_QUOTA,
177+
),
178+
(
179+
DERIBIT_ACCOUNT_RATE_KEY.to_string(),
180+
*DERIBIT_HTTP_ACCOUNT_QUOTA,
181+
),
182+
]
183+
}
184+
185+
/// Returns rate limit keys for a given RPC method.
186+
///
187+
/// Maps Deribit JSON-RPC methods to appropriate rate limit buckets.
188+
fn rate_limit_keys(method: &str) -> Vec<String> {
189+
let mut keys = vec![DERIBIT_GLOBAL_RATE_KEY.to_string()];
190+
191+
// Categorize by method type
192+
if Self::is_order_method(method) {
193+
keys.push(DERIBIT_ORDER_RATE_KEY.to_string());
194+
} else if Self::is_account_method(method) {
195+
keys.push(DERIBIT_ACCOUNT_RATE_KEY.to_string());
196+
}
197+
198+
// Add method-specific key
199+
keys.push(format!("deribit:{method}"));
200+
201+
keys
202+
}
203+
204+
/// Returns true if the method is an order operation (matching engine).
205+
fn is_order_method(method: &str) -> bool {
206+
matches!(
207+
method,
208+
"private/buy"
209+
| "private/sell"
210+
| "private/edit"
211+
| "private/cancel"
212+
| "private/cancel_all"
213+
| "private/cancel_all_by_currency"
214+
| "private/cancel_all_by_instrument"
215+
| "private/cancel_by_label"
216+
| "private/close_position"
217+
)
218+
}
219+
220+
/// Returns true if the method accesses account information.
221+
fn is_account_method(method: &str) -> bool {
222+
matches!(
223+
method,
224+
"private/get_account_summaries"
225+
| "private/get_account_summary"
226+
| "private/get_positions"
227+
| "private/get_position"
228+
| "private/get_open_orders_by_currency"
229+
| "private/get_open_orders_by_instrument"
230+
| "private/get_order_state"
231+
| "private/get_user_trades_by_currency"
232+
| "private/get_user_trades_by_instrument"
233+
)
234+
}
235+
157236
/// Creates a new [`DeribitRawHttpClient`] with explicit credentials.
158237
///
159238
/// # Errors
@@ -192,8 +271,8 @@ impl DeribitRawHttpClient {
192271
client: HttpClient::new(
193272
HashMap::new(),
194273
Vec::new(),
195-
Vec::new(),
196-
None,
274+
Self::rate_limiter_quotas(),
275+
Some(*DERIBIT_HTTP_REST_QUOTA),
197276
timeout_secs,
198277
proxy_url,
199278
)
@@ -308,6 +387,7 @@ impl DeribitRawHttpClient {
308387
headers.extend(auth_headers);
309388
}
310389

390+
let rate_limit_keys = Self::rate_limit_keys(&method);
311391
let resp = self
312392
.client
313393
.request(
@@ -317,7 +397,7 @@ impl DeribitRawHttpClient {
317397
Some(headers),
318398
Some(body),
319399
None,
320-
None,
400+
Some(rate_limit_keys),
321401
)
322402
.await
323403
.map_err(|e| DeribitHttpError::NetworkError(e.to_string()))?;
@@ -1456,3 +1536,41 @@ impl DeribitHttpClient {
14561536
Ok(reports)
14571537
}
14581538
}
1539+
1540+
#[cfg(test)]
1541+
mod tests {
1542+
use rstest::rstest;
1543+
1544+
use super::*;
1545+
use crate::common::consts::{
1546+
DERIBIT_ACCOUNT_RATE_KEY, DERIBIT_GLOBAL_RATE_KEY, DERIBIT_ORDER_RATE_KEY,
1547+
};
1548+
1549+
#[rstest]
1550+
#[case("private/buy", true, false)]
1551+
#[case("private/cancel", true, false)]
1552+
#[case("private/get_account_summaries", false, true)]
1553+
#[case("private/get_positions", false, true)]
1554+
#[case("public/get_instruments", false, false)]
1555+
fn test_method_classification(
1556+
#[case] method: &str,
1557+
#[case] is_order: bool,
1558+
#[case] is_account: bool,
1559+
) {
1560+
assert_eq!(DeribitRawHttpClient::is_order_method(method), is_order);
1561+
assert_eq!(DeribitRawHttpClient::is_account_method(method), is_account);
1562+
}
1563+
1564+
#[rstest]
1565+
#[case("private/buy", vec![DERIBIT_GLOBAL_RATE_KEY, DERIBIT_ORDER_RATE_KEY])]
1566+
#[case("private/get_account_summaries", vec![DERIBIT_GLOBAL_RATE_KEY, DERIBIT_ACCOUNT_RATE_KEY])]
1567+
#[case("public/get_instruments", vec![DERIBIT_GLOBAL_RATE_KEY])]
1568+
fn test_rate_limit_keys(#[case] method: &str, #[case] expected_keys: Vec<&str>) {
1569+
let keys = DeribitRawHttpClient::rate_limit_keys(method);
1570+
1571+
for key in &expected_keys {
1572+
assert!(keys.contains(&key.to_string()));
1573+
}
1574+
assert!(keys.contains(&format!("deribit:{method}")));
1575+
}
1576+
}

crates/adapters/deribit/src/websocket/client.rs

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,8 @@
2121
2222
use std::{
2323
fmt::Debug,
24-
num::NonZeroU32,
2524
sync::{
26-
Arc, LazyLock,
25+
Arc,
2726
atomic::{AtomicBool, AtomicU8, Ordering},
2827
},
2928
time::Duration,
@@ -45,7 +44,6 @@ use nautilus_model::{
4544
use nautilus_network::{
4645
http::USER_AGENT,
4746
mode::ConnectionMode,
48-
ratelimiter::quota::Quota,
4947
websocket::{
5048
AuthTracker, PingHandler, SubscriptionState, WebSocketClient, WebSocketConfig,
5149
channel_message_handler,
@@ -65,14 +63,13 @@ use super::{
6563
},
6664
};
6765
use crate::common::{
68-
consts::{DERIBIT_TESTNET_WS_URL, DERIBIT_WS_URL},
66+
consts::{
67+
DERIBIT_TESTNET_WS_URL, DERIBIT_WS_ORDER_KEY, DERIBIT_WS_ORDER_QUOTA,
68+
DERIBIT_WS_SUBSCRIPTION_KEY, DERIBIT_WS_SUBSCRIPTION_QUOTA, DERIBIT_WS_URL,
69+
},
6970
credential::Credential,
7071
};
7172

72-
/// Default Deribit WebSocket subscription rate limit: 20 requests per second.
73-
pub static DERIBIT_WS_SUBSCRIPTION_QUOTA: LazyLock<Quota> =
74-
LazyLock::new(|| Quota::per_second(NonZeroU32::new(20).unwrap()));
75-
7673
/// Authentication timeout in seconds.
7774
const AUTHENTICATION_TIMEOUT_SECS: u64 = 30;
7875

@@ -392,7 +389,13 @@ impl DeribitWebSocketClient {
392389
};
393390

394391
// Configure rate limits
395-
let keyed_quotas = vec![("subscription".to_string(), *DERIBIT_WS_SUBSCRIPTION_QUOTA)];
392+
let keyed_quotas = vec![
393+
(
394+
DERIBIT_WS_SUBSCRIPTION_KEY.to_string(),
395+
*DERIBIT_WS_SUBSCRIPTION_QUOTA,
396+
),
397+
(DERIBIT_WS_ORDER_KEY.to_string(), *DERIBIT_WS_ORDER_QUOTA),
398+
];
396399

397400
// Connect the WebSocket
398401
let ws_client = WebSocketClient::connect(
@@ -401,7 +404,7 @@ impl DeribitWebSocketClient {
401404
Some(ping_handler),
402405
None, // post_reconnection
403406
keyed_quotas,
404-
Some(*DERIBIT_WS_SUBSCRIPTION_QUOTA), // Default quota
407+
Some(*DERIBIT_WS_SUBSCRIPTION_QUOTA), // Default quota for non-order operations
405408
)
406409
.await?;
407410

0 commit comments

Comments
 (0)