Skip to main content

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}