nautilus_derive/common/rate_limit.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//! Rate-limit quotas and keys for the Derive adapter.
17//!
18//! Derive runs a fixed-window limiter that replenishes the request allowance
19//! every five seconds, splitting traffic into matching-engine actions (order
20//! create/cancel/replace) and everything else ("non-matching": market-data
21//! reads, channel subscriptions, login). Matching limits are per account and
22//! tiered (Trader vs Market Maker); the non-matching limit is a flat per-IP
23//! allowance. See <https://docs.derive.xyz/reference/rate-limits>.
24//!
25//! The `nautilus_network` limiter is GCRA-based, so each quota is expressed as a
26//! sustained per-second rate with a burst capacity of five seconds' worth of
27//! cells. That reproduces Derive's "5x burst, replenished every 5 seconds"
28//! model: a full burst of `tps * 5` cells drains, then refills one cell every
29//! `1 / tps` seconds (five seconds to refill the whole burst).
30
31use std::num::NonZeroU32;
32
33use nautilus_network::ratelimiter::quota::Quota;
34
35/// Rate-limit key for matching-engine requests (order create/cancel/replace).
36pub const DERIVE_MATCHING_RATE_KEY: &str = "derive:matching";
37
38/// Rate-limit key for non-matching requests (reads, subscriptions, login).
39pub const DERIVE_NON_MATCHING_RATE_KEY: &str = "derive:non-matching";
40
41/// Default matching-engine allowance for a Trader-tier account (requests per
42/// second). Market Maker accounts negotiate higher limits and raise this via
43/// [`crate::config::DeriveExecClientConfig::max_matching_requests_per_second`].
44pub const DERIVE_DEFAULT_MATCHING_TPS: u32 = 1;
45
46/// Flat non-matching allowance per IP (requests per second).
47pub const DERIVE_NON_MATCHING_TPS: u32 = 10;
48
49/// Burst multiplier: Derive permits five seconds' worth of requests in a single
50/// burst before the fixed window replenishes.
51pub const DERIVE_RATE_BURST_MULTIPLIER: u32 = 5;
52
53/// Builds the matching-engine quota for `max_requests_per_second`, falling back
54/// to [`DERIVE_DEFAULT_MATCHING_TPS`] when unset or zero.
55#[must_use]
56pub fn matching_quota(max_requests_per_second: Option<u32>) -> Quota {
57 let tps = max_requests_per_second
58 .filter(|&v| v > 0)
59 .unwrap_or(DERIVE_DEFAULT_MATCHING_TPS);
60 quota_with_burst(tps)
61}
62
63/// Builds the flat non-matching quota ([`DERIVE_NON_MATCHING_TPS`]).
64#[must_use]
65pub fn non_matching_quota() -> Quota {
66 quota_with_burst(DERIVE_NON_MATCHING_TPS)
67}
68
69fn quota_with_burst(tps: u32) -> Quota {
70 let rate = NonZeroU32::new(tps).expect("tps must be non-zero");
71 let burst = NonZeroU32::new(tps.saturating_mul(DERIVE_RATE_BURST_MULTIPLIER))
72 .expect("burst must be non-zero");
73 Quota::per_second(rate)
74 .expect("per-second quota replenish interval must be non-zero")
75 .allow_burst(burst)
76}
77
78#[cfg(test)]
79mod tests {
80 use std::time::Duration;
81
82 use rstest::rstest;
83
84 use super::*;
85
86 #[rstest]
87 fn test_non_matching_quota_is_ten_per_second_with_five_second_burst() {
88 let quota = non_matching_quota();
89 assert_eq!(quota.burst_size().get(), 50);
90 assert_eq!(quota.replenish_interval(), Duration::from_millis(100));
91 }
92
93 #[rstest]
94 fn test_matching_quota_defaults_to_trader_tier() {
95 let quota = matching_quota(None);
96 assert_eq!(quota.burst_size().get(), 5);
97 assert_eq!(quota.replenish_interval(), Duration::from_secs(1));
98 }
99
100 #[rstest]
101 fn test_matching_quota_treats_zero_as_unset() {
102 assert_eq!(matching_quota(Some(0)).burst_size().get(), 5);
103 }
104
105 #[rstest]
106 fn test_matching_quota_honors_market_maker_override() {
107 let quota = matching_quota(Some(500));
108 assert_eq!(quota.burst_size().get(), 2500);
109 assert_eq!(quota.replenish_interval(), Duration::from_millis(2));
110 }
111}