Skip to main content

polyoxide_core/
signer_limit.rs

1//! Per-signer token-bucket limits for CLOB trading.
2//!
3//! Polymarket evaluates order and cancellation requests against token buckets
4//! keyed on the **signer address**, independently of the Cloudflare IP limits in
5//! [`crate::rate_limit`]. A request must satisfy both layers.
6//!
7//! The two layers count different things: Cloudflare counts *requests*, this one
8//! counts *orders*. For batch endpoints they diverge by the batch size, which is
9//! why this module exists at all — [`RateLimiter`](crate::RateLimiter) charges
10//! exactly one token per call and cannot express "this request costs 500".
11//!
12//! Transcribed from <https://docs.polymarket.com/api-reference/trading-rate-limits>
13//! as fetched 2026-08-05; mirrored in `docs/specs/clob/trading-rate-limits.md`.
14
15/// Which of a signer's two buckets a request draws from.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum TradingBucket {
18    /// Order placement.
19    Order,
20    /// Order cancellation.
21    Cancel,
22}
23
24/// An account's volume tier, which sets both buckets' rate and capacity.
25///
26/// Tier is assigned upstream from 30-day volume. Clients cannot compute it, so
27/// it is discovered from the `Poly-RateLimit-Tier` response header; until one is
28/// seen, [`Tier::Standard`] is assumed because it is the tightest.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
30pub enum Tier {
31    /// No volume requirement.
32    #[default]
33    Standard,
34    /// $30,000+ 30-day volume.
35    Copper,
36    /// $50,000+ 30-day volume.
37    Bronze,
38    /// $100,000+ 30-day volume.
39    Silver,
40    /// $500,000+ 30-day volume.
41    Gold,
42    /// $2.5M+ 30-day volume.
43    Platinum,
44    /// $5M+ 30-day volume.
45    Diamond,
46    /// $10M+ 30-day volume.
47    Elite,
48}
49
50impl Tier {
51    /// Sustained refill rate for `bucket`, in tokens per second.
52    pub fn rate(self, bucket: TradingBucket) -> u32 {
53        let (order_rate, _, cancel_rate, _) = self.allowances();
54        match bucket {
55            TradingBucket::Order => order_rate,
56            TradingBucket::Cancel => cancel_rate,
57        }
58    }
59
60    /// Burst capacity for `bucket` — the most tokens it ever holds.
61    ///
62    /// A request costing more than this can never be satisfied, however long
63    /// the caller waits.
64    pub fn burst(self, bucket: TradingBucket) -> u32 {
65        let (_, order_burst, _, cancel_burst) = self.allowances();
66        match bucket {
67            TradingBucket::Order => order_burst,
68            TradingBucket::Cancel => cancel_burst,
69        }
70    }
71
72    /// `(order rate, order burst, cancel rate, cancel burst)` as published.
73    ///
74    /// Transcribed literally rather than derived. The figures look regular —
75    /// cancel is twice order throughout, burst is 1.5x rate — but Diamond
76    /// breaks the second pattern (787, not 787.5), and a table copied from
77    /// vendor docs is exactly where a clever formula goes stale unnoticed.
78    fn allowances(self) -> (u32, u32, u32, u32) {
79        match self {
80            Tier::Standard => (40, 60, 80, 120),
81            Tier::Copper => (60, 90, 120, 180),
82            Tier::Bronze => (80, 120, 160, 240),
83            Tier::Silver => (200, 300, 400, 600),
84            Tier::Gold => (400, 600, 800, 1_200),
85            Tier::Platinum => (450, 675, 900, 1_350),
86            Tier::Diamond => (525, 787, 1_050, 1_575),
87            Tier::Elite => (600, 900, 1_200, 1_800),
88        }
89    }
90
91    /// Parse the value of a `Poly-RateLimit-Tier` header.
92    ///
93    /// Matching is case-insensitive; an unrecognised tier returns `None` rather
94    /// than guessing, so an upstream addition cannot silently widen a bucket.
95    pub fn from_header(value: &str) -> Option<Self> {
96        match value.trim().to_ascii_lowercase().as_str() {
97            "standard" => Some(Tier::Standard),
98            "copper" => Some(Tier::Copper),
99            "bronze" => Some(Tier::Bronze),
100            "silver" => Some(Tier::Silver),
101            "gold" => Some(Tier::Gold),
102            "platinum" => Some(Tier::Platinum),
103            "diamond" => Some(Tier::Diamond),
104            "elite" => Some(Tier::Elite),
105            _ => None,
106        }
107    }
108}
109
110/// A trading request, carrying whatever its token cost depends on.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum TradingRequest {
113    /// `POST /order` — always 1 token.
114    PostOrder,
115    /// `POST /orders` — one token per order in the batch.
116    PostOrders {
117        /// Number of orders in the batch.
118        count: u32,
119    },
120    /// `DELETE /order` — always 1 token.
121    CancelOrder,
122    /// `DELETE /orders` — one token per submitted order ID.
123    CancelOrders {
124        /// Number of order IDs submitted.
125        count: u32,
126    },
127    /// `DELETE /cancel-all` — costs `1 + orders canceled`.
128    ///
129    /// The true cost is not knowable client-side; see [`TradingRequest::cost`].
130    CancelAll,
131    /// `DELETE /cancel-market-orders` — costs `1 + matching orders canceled`.
132    ///
133    /// The true cost is not knowable client-side; see [`TradingRequest::cost`].
134    CancelMarketOrders,
135}
136
137impl TradingRequest {
138    /// Which bucket this request draws from.
139    pub fn bucket(self) -> TradingBucket {
140        match self {
141            TradingRequest::PostOrder | TradingRequest::PostOrders { .. } => TradingBucket::Order,
142            TradingRequest::CancelOrder
143            | TradingRequest::CancelOrders { .. }
144            | TradingRequest::CancelAll
145            | TradingRequest::CancelMarketOrders => TradingBucket::Cancel,
146        }
147    }
148
149    /// The token cost, as far as the client can know it.
150    ///
151    /// Exact for the four request kinds whose cost is a function of the payload.
152    /// For [`CancelAll`](Self::CancelAll) and
153    /// [`CancelMarketOrders`](Self::CancelMarketOrders) the published cost is
154    /// `1 + orders canceled`, and the client does not know how many orders are
155    /// open — so this returns the floor of 1. Those two can therefore overdraw
156    /// the real bucket, and a resulting 429 is genuine rather than a client bug.
157    pub fn cost(self) -> u32 {
158        match self {
159            TradingRequest::PostOrder | TradingRequest::CancelOrder => 1,
160            TradingRequest::PostOrders { count } | TradingRequest::CancelOrders { count } => count,
161            // Floor of the published `1 + orders canceled`.
162            TradingRequest::CancelAll | TradingRequest::CancelMarketOrders => 1,
163        }
164    }
165
166    /// Whether this request's cost is exactly known client-side.
167    ///
168    /// Only requests where this is true can be safely rejected before sending;
169    /// the rest must be attempted and may come back 429.
170    pub fn cost_is_exact(self) -> bool {
171        !matches!(
172            self,
173            TradingRequest::CancelAll | TradingRequest::CancelMarketOrders
174        )
175    }
176}
177
178/// What the venue reported about a signer's trading allowance on one response.
179///
180/// Polymarket returns these on every evaluated order/cancel request. They are
181/// the only way a client learns its own tier, since tier derives from 30-day
182/// volume that the client cannot compute.
183///
184/// Every field is optional: the headers are absent on responses that never
185/// reached the trading limiter (market data, auth, anything non-trading), and a
186/// malformed value is dropped rather than guessed.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
188pub struct RateLimitStatus {
189    /// `Poly-RateLimit-Remaining` — token balance after this request.
190    pub remaining: Option<u32>,
191    /// `Poly-RateLimit-Reset` — Unix timestamp when the wait period ends.
192    pub reset: Option<u64>,
193    /// `Poly-RateLimit-Tier` — the tier the venue applied.
194    pub tier: Option<Tier>,
195    /// `Poly-RateLimit-Warning` — true when the venue is in warning mode.
196    pub warning: bool,
197}
198
199impl RateLimitStatus {
200    /// Read the `Poly-RateLimit-*` family from a response's headers.
201    ///
202    /// Returns an all-`None` value rather than an error when the headers are
203    /// absent, so callers need not distinguish "not a trading request" from
204    /// "trading request with no telemetry".
205    pub fn from_headers(headers: &reqwest::header::HeaderMap) -> Self {
206        let get = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
207
208        Self {
209            remaining: get("poly-ratelimit-remaining").and_then(|v| v.trim().parse().ok()),
210            reset: get("poly-ratelimit-reset").and_then(|v| v.trim().parse().ok()),
211            tier: get("poly-ratelimit-tier").and_then(Tier::from_header),
212            warning: get("poly-ratelimit-warning")
213                .is_some_and(|v| v.trim().eq_ignore_ascii_case("true")),
214        }
215    }
216
217    /// Whether the venue reported anything at all.
218    pub fn is_empty(&self) -> bool {
219        self.remaining.is_none() && self.reset.is_none() && self.tier.is_none() && !self.warning
220    }
221}
222
223/// A request whose token cost exceeds its bucket's capacity.
224///
225/// This is **not** a throttle. A token bucket never holds more than its burst
226/// capacity, so a request costing more can never be satisfied no matter how long
227/// the caller waits. Splitting the batch is the only remedy, which is why this
228/// is a distinct error rather than a 429 the retry loop would burn attempts on.
229#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
230#[error(
231    "batch costs {cost} tokens but the {bucket:?} bucket at tier {tier:?} holds at most \
232     {capacity}; this can never succeed — split it into batches of {capacity} or fewer"
233)]
234pub struct BurstCapacityExceeded {
235    /// Tokens the request would cost.
236    pub cost: u32,
237    /// The bucket's maximum capacity at the current tier.
238    pub capacity: u32,
239    /// Tier in force when the request was rejected.
240    pub tier: Tier,
241    /// Which bucket the request draws from.
242    pub bucket: TradingBucket,
243}
244
245type DirectLimiter = governor::RateLimiter<
246    governor::state::NotKeyed,
247    governor::state::InMemoryState,
248    governor::clock::DefaultClock,
249>;
250
251struct Buckets {
252    tier: Tier,
253    order: std::sync::Arc<DirectLimiter>,
254    cancel: std::sync::Arc<DirectLimiter>,
255}
256
257impl Buckets {
258    fn for_tier(tier: Tier) -> Self {
259        let build = |bucket: TradingBucket| {
260            let rate = tier.rate(bucket).max(1);
261            let burst = tier.burst(bucket).max(1);
262            let quota = governor::Quota::with_period(std::time::Duration::from_secs(1) / rate)
263                .expect("per-token interval is non-zero")
264                .allow_burst(std::num::NonZeroU32::new(burst).expect("burst is non-zero"));
265            std::sync::Arc::new(DirectLimiter::direct(quota))
266        };
267        Self {
268            tier,
269            order: build(TradingBucket::Order),
270            cancel: build(TradingBucket::Cancel),
271        }
272    }
273}
274
275/// Per-signer trading limiter, with the tier discovered from response headers.
276///
277/// Starts at [`Tier::Standard`] — the tightest — and resizes both buckets the
278/// first time a `Poly-RateLimit-Tier` header reports something different.
279///
280/// # The resize discards accumulated state
281///
282/// governor buckets cannot be resized in place, so adopting a new tier replaces
283/// them with fresh ones at full capacity. Moving *up* a tier is therefore safe:
284/// the venue already permits the wider allowance. Moving *down* briefly permits
285/// a full burst at the narrower capacity, which the venue may throttle. Tier
286/// changes are rare (they track 30-day volume), so this is preferred to the
287/// complexity of draining the old bucket into the new one.
288#[derive(Clone)]
289pub struct SignerLimiter {
290    inner: std::sync::Arc<SignerLimiterInner>,
291}
292
293struct SignerLimiterInner {
294    buckets: std::sync::RwLock<Buckets>,
295    status: std::sync::RwLock<RateLimitStatus>,
296}
297
298impl std::fmt::Debug for SignerLimiter {
299    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300        f.debug_struct("SignerLimiter")
301            .field("tier", &self.tier())
302            .finish()
303    }
304}
305
306impl Default for SignerLimiter {
307    fn default() -> Self {
308        Self::new()
309    }
310}
311
312impl SignerLimiter {
313    /// Create a limiter at the default (tightest) tier.
314    pub fn new() -> Self {
315        Self {
316            inner: std::sync::Arc::new(SignerLimiterInner {
317                buckets: std::sync::RwLock::new(Buckets::for_tier(Tier::default())),
318                status: std::sync::RwLock::new(RateLimitStatus::default()),
319            }),
320        }
321    }
322
323    /// The tier currently in force.
324    pub fn tier(&self) -> Tier {
325        self.inner
326            .buckets
327            .read()
328            .expect("lock is never poisoned")
329            .tier
330    }
331
332    /// The most recent `Poly-RateLimit-*` telemetry seen.
333    pub fn last_status(&self) -> RateLimitStatus {
334        *self.inner.status.read().expect("lock is never poisoned")
335    }
336
337    /// Record the rate-limit headers from a response, adopting a reported tier.
338    ///
339    /// Responses carrying none of the headers are ignored, so non-trading
340    /// requests cannot clear the telemetry.
341    pub fn observe(&self, headers: &reqwest::header::HeaderMap) {
342        let status = RateLimitStatus::from_headers(headers);
343        if status.is_empty() {
344            return;
345        }
346        *self.inner.status.write().expect("lock is never poisoned") = status;
347
348        // An unrecognised tier parses to None and so leaves the buckets alone,
349        // keeping the tighter allowance rather than guessing a wider one.
350        if let Some(tier) = status.tier {
351            let mut buckets = self.inner.buckets.write().expect("lock is never poisoned");
352            if buckets.tier != tier {
353                tracing::debug!("adopting rate limit tier {tier:?} (was {:?})", buckets.tier);
354                *buckets = Buckets::for_tier(tier);
355            }
356        }
357    }
358
359    /// Wait for `request`'s token cost to be available, then consume it.
360    ///
361    /// # Errors
362    ///
363    /// [`BurstCapacityExceeded`] when the cost exceeds the bucket's capacity.
364    /// This returns immediately rather than waiting forever.
365    pub async fn acquire(&self, request: TradingRequest) -> Result<(), BurstCapacityExceeded> {
366        let bucket = request.bucket();
367
368        // Clone the Arc out under the lock and drop the guard before awaiting:
369        // an RwLock guard held across an await would make this future !Send.
370        let (tier, limiter) = {
371            let buckets = self.inner.buckets.read().expect("lock is never poisoned");
372            let limiter = match bucket {
373                TradingBucket::Order => buckets.order.clone(),
374                TradingBucket::Cancel => buckets.cancel.clone(),
375            };
376            (buckets.tier, limiter)
377        };
378
379        let cost = request.cost().max(1);
380        let n = std::num::NonZeroU32::new(cost).expect("cost floor is 1");
381
382        // governor reports InsufficientCapacity when n exceeds the bucket's
383        // burst — exactly the permanently-impossible case, and it returns
384        // straight away rather than parking the task forever.
385        limiter
386            .until_n_ready(n)
387            .await
388            .map_err(|_| BurstCapacityExceeded {
389                cost,
390                capacity: tier.burst(bucket),
391                tier,
392                bucket,
393            })
394    }
395}
396
397#[cfg(test)]
398mod limiter_tests {
399    use super::*;
400    use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
401    use std::time::Duration;
402
403    fn tier_header(tier: &str) -> HeaderMap {
404        let mut map = HeaderMap::new();
405        map.insert(
406            HeaderName::from_static("poly-ratelimit-tier"),
407            HeaderValue::from_str(tier).unwrap(),
408        );
409        map
410    }
411
412    #[test]
413    fn starts_at_the_tightest_tier() {
414        assert_eq!(SignerLimiter::new().tier(), Tier::Standard);
415    }
416
417    #[test]
418    fn observing_a_tier_header_adopts_it() {
419        let limiter = SignerLimiter::new();
420        limiter.observe(&tier_header("gold"));
421        assert_eq!(limiter.tier(), Tier::Gold);
422        assert_eq!(limiter.last_status().tier, Some(Tier::Gold));
423    }
424
425    #[test]
426    fn an_unrecognised_tier_leaves_the_current_one_in_force() {
427        // Adopting an unknown tier would mean guessing a capacity. Staying put
428        // keeps the tighter, known-safe allowance.
429        let limiter = SignerLimiter::new();
430        limiter.observe(&tier_header("silver"));
431        limiter.observe(&tier_header("titanium"));
432        assert_eq!(limiter.tier(), Tier::Silver);
433    }
434
435    #[test]
436    fn a_response_without_the_headers_does_not_clear_telemetry() {
437        let limiter = SignerLimiter::new();
438        limiter.observe(&tier_header("silver"));
439        limiter.observe(&HeaderMap::new());
440        assert_eq!(limiter.tier(), Tier::Silver);
441        assert_eq!(limiter.last_status().tier, Some(Tier::Silver));
442    }
443
444    #[tokio::test]
445    async fn an_over_capacity_batch_is_rejected_immediately_not_queued() {
446        // The headline case: 2,000 IDs exceeds every tier's cancel burst, so
447        // waiting can never help. It must come back as an error, fast.
448        let limiter = SignerLimiter::new();
449        let request = TradingRequest::CancelOrders { count: 2_000 };
450
451        let result = tokio::time::timeout(Duration::from_millis(100), limiter.acquire(request))
452            .await
453            .expect("must not hang waiting for capacity that can never exist");
454
455        let err = result.expect_err("2,000 tokens exceeds Standard's 120 cancel burst");
456        assert_eq!(err.cost, 2_000);
457        assert_eq!(err.capacity, 120);
458        assert_eq!(err.bucket, TradingBucket::Cancel);
459    }
460
461    #[tokio::test]
462    async fn a_batch_within_capacity_is_admitted() {
463        let limiter = SignerLimiter::new();
464        limiter
465            .acquire(TradingRequest::CancelOrders { count: 100 })
466            .await
467            .expect("100 fits Standard's 120 cancel burst");
468    }
469
470    #[tokio::test]
471    async fn adopting_a_higher_tier_admits_a_batch_that_was_impossible() {
472        // Proves the resize actually changes capacity rather than just the
473        // reported tier: 500 IDs is impossible at Standard (120) and fine at
474        // Gold (1,200).
475        let limiter = SignerLimiter::new();
476        let batch = TradingRequest::CancelOrders { count: 500 };
477        assert!(limiter.acquire(batch).await.is_err());
478
479        limiter.observe(&tier_header("gold"));
480        limiter
481            .acquire(batch)
482            .await
483            .expect("500 fits Gold's 1,200 cancel burst");
484    }
485
486    #[tokio::test]
487    async fn the_order_and_cancel_buckets_are_independent() {
488        // Draining orders must not throttle cancels — they are separate buckets
489        // upstream, and conflating them would block cancels during a burst of
490        // order placement, which is exactly when cancelling matters most.
491        let limiter = SignerLimiter::new();
492        limiter
493            .acquire(TradingRequest::PostOrders { count: 60 })
494            .await
495            .expect("60 fills Standard's order burst exactly");
496
497        let start = std::time::Instant::now();
498        limiter
499            .acquire(TradingRequest::CancelOrder)
500            .await
501            .expect("cancel bucket is untouched");
502        assert!(
503            start.elapsed() < Duration::from_millis(25),
504            "cancelling was throttled by order placement"
505        );
506    }
507
508    #[tokio::test]
509    async fn batch_cost_is_charged_in_full_not_as_one_request() {
510        // The whole point of this layer. Draining the order burst with one
511        // 60-order batch must leave the next single order waiting — if batches
512        // were charged as 1, this would return instantly.
513        let limiter = SignerLimiter::new();
514        limiter
515            .acquire(TradingRequest::PostOrders { count: 60 })
516            .await
517            .unwrap();
518
519        let start = std::time::Instant::now();
520        limiter.acquire(TradingRequest::PostOrder).await.unwrap();
521        assert!(
522            start.elapsed() >= Duration::from_millis(10),
523            "a 60-order batch was charged as a single token"
524        );
525    }
526}
527
528#[cfg(test)]
529mod status_tests {
530    use super::*;
531    use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
532
533    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
534        let mut map = HeaderMap::new();
535        for (k, v) in pairs {
536            map.insert(
537                HeaderName::from_bytes(k.as_bytes()).unwrap(),
538                HeaderValue::from_str(v).unwrap(),
539            );
540        }
541        map
542    }
543
544    #[test]
545    fn reads_the_full_header_family() {
546        let status = RateLimitStatus::from_headers(&headers(&[
547            ("poly-ratelimit-remaining", "57"),
548            ("poly-ratelimit-reset", "1767225660"),
549            ("poly-ratelimit-tier", "silver"),
550            ("poly-ratelimit-warning", "true"),
551        ]));
552
553        assert_eq!(status.remaining, Some(57));
554        assert_eq!(status.reset, Some(1_767_225_660));
555        assert_eq!(status.tier, Some(Tier::Silver));
556        assert!(status.warning);
557        assert!(!status.is_empty());
558    }
559
560    #[test]
561    fn header_names_are_matched_case_insensitively() {
562        // reqwest lowercases header names on receipt, but a HeaderMap built by
563        // hand (or a proxy preserving case) must resolve identically.
564        let status = RateLimitStatus::from_headers(&headers(&[
565            ("Poly-RateLimit-Tier", "GOLD"),
566            ("POLY-RATELIMIT-REMAINING", "3"),
567        ]));
568        assert_eq!(status.tier, Some(Tier::Gold));
569        assert_eq!(status.remaining, Some(3));
570    }
571
572    #[test]
573    fn absent_headers_yield_an_empty_status_not_an_error() {
574        let status = RateLimitStatus::from_headers(&HeaderMap::new());
575        assert!(status.is_empty());
576        assert_eq!(status, RateLimitStatus::default());
577    }
578
579    #[test]
580    fn malformed_values_are_dropped_rather_than_guessed() {
581        // A garbage tier must not fall back to Standard here — that would be
582        // indistinguishable from the venue actually reporting Standard, and the
583        // limiter would resize a bucket on noise.
584        let status = RateLimitStatus::from_headers(&headers(&[
585            ("poly-ratelimit-remaining", "not-a-number"),
586            ("poly-ratelimit-reset", ""),
587            ("poly-ratelimit-tier", "titanium"),
588            ("poly-ratelimit-warning", "false"),
589        ]));
590
591        assert_eq!(status.remaining, None);
592        assert_eq!(status.reset, None);
593        assert_eq!(status.tier, None);
594        assert!(!status.warning);
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    /// The published tier table, transcribed by hand. This is the golden vector.
603    ///
604    /// `(tier, order rate, order burst, cancel rate, cancel burst)`
605    fn published() -> Vec<(Tier, u32, u32, u32, u32)> {
606        vec![
607            (Tier::Standard, 40, 60, 80, 120),
608            (Tier::Copper, 60, 90, 120, 180),
609            (Tier::Bronze, 80, 120, 160, 240),
610            (Tier::Silver, 200, 300, 400, 600),
611            (Tier::Gold, 400, 600, 800, 1_200),
612            (Tier::Platinum, 450, 675, 900, 1_350),
613            (Tier::Diamond, 525, 787, 1_050, 1_575),
614            (Tier::Elite, 600, 900, 1_200, 1_800),
615        ]
616    }
617
618    #[test]
619    fn every_tier_matches_the_published_table() {
620        for (tier, o_rate, o_burst, c_rate, c_burst) in published() {
621            assert_eq!(
622                tier.rate(TradingBucket::Order),
623                o_rate,
624                "{tier:?} order rate"
625            );
626            assert_eq!(
627                tier.burst(TradingBucket::Order),
628                o_burst,
629                "{tier:?} order burst"
630            );
631            assert_eq!(
632                tier.rate(TradingBucket::Cancel),
633                c_rate,
634                "{tier:?} cancel rate"
635            );
636            assert_eq!(
637                tier.burst(TradingBucket::Cancel),
638                c_burst,
639                "{tier:?} cancel burst"
640            );
641        }
642    }
643
644    #[test]
645    fn the_default_tier_is_the_tightest_one() {
646        // An unconfigured client must never assume more allowance than it has.
647        let default = Tier::default();
648        for (tier, ..) in published() {
649            assert!(
650                default.rate(TradingBucket::Order) <= tier.rate(TradingBucket::Order),
651                "default tier {default:?} is looser than {tier:?}"
652            );
653            assert!(
654                default.burst(TradingBucket::Cancel) <= tier.burst(TradingBucket::Cancel),
655                "default tier {default:?} bursts higher than {tier:?}"
656            );
657        }
658    }
659
660    #[test]
661    fn tier_headers_parse_case_insensitively() {
662        assert_eq!(Tier::from_header("standard"), Some(Tier::Standard));
663        assert_eq!(Tier::from_header("Silver"), Some(Tier::Silver));
664        assert_eq!(Tier::from_header("ELITE"), Some(Tier::Elite));
665    }
666
667    #[test]
668    fn an_unknown_tier_header_is_not_guessed() {
669        // Guessing would widen a bucket on an upstream addition we know nothing
670        // about. Returning None keeps the current (tighter) tier in force.
671        assert_eq!(Tier::from_header("titanium"), None);
672        assert_eq!(Tier::from_header(""), None);
673    }
674
675    #[test]
676    fn batch_costs_scale_with_the_payload() {
677        assert_eq!(TradingRequest::PostOrder.cost(), 1);
678        assert_eq!(TradingRequest::PostOrders { count: 40 }.cost(), 40);
679        assert_eq!(TradingRequest::CancelOrder.cost(), 1);
680        assert_eq!(TradingRequest::CancelOrders { count: 250 }.cost(), 250);
681    }
682
683    #[test]
684    fn requests_draw_from_the_right_bucket() {
685        assert_eq!(TradingRequest::PostOrder.bucket(), TradingBucket::Order);
686        assert_eq!(
687            TradingRequest::PostOrders { count: 2 }.bucket(),
688            TradingBucket::Order
689        );
690        assert_eq!(TradingRequest::CancelOrder.bucket(), TradingBucket::Cancel);
691        assert_eq!(
692            TradingRequest::CancelOrders { count: 2 }.bucket(),
693            TradingBucket::Cancel
694        );
695        assert_eq!(TradingRequest::CancelAll.bucket(), TradingBucket::Cancel);
696        assert_eq!(
697            TradingRequest::CancelMarketOrders.bucket(),
698            TradingBucket::Cancel
699        );
700    }
701
702    #[test]
703    fn cancel_all_reports_a_floor_cost_and_says_so() {
704        // Published cost is 1 + orders canceled, which the client cannot know.
705        // Reporting 1 while flagging it inexact is the honest answer; claiming
706        // exactness here would let the guard reject or admit batches wrongly.
707        assert_eq!(TradingRequest::CancelAll.cost(), 1);
708        assert!(!TradingRequest::CancelAll.cost_is_exact());
709        assert_eq!(TradingRequest::CancelMarketOrders.cost(), 1);
710        assert!(!TradingRequest::CancelMarketOrders.cost_is_exact());
711
712        for exact in [
713            TradingRequest::PostOrder,
714            TradingRequest::PostOrders { count: 3 },
715            TradingRequest::CancelOrder,
716            TradingRequest::CancelOrders { count: 3 },
717        ] {
718            assert!(exact.cost_is_exact(), "{exact:?} cost is computable");
719        }
720    }
721
722    #[test]
723    fn a_batch_larger_than_elite_burst_is_impossible_on_every_tier() {
724        // The finding that motivated this module: a token bucket never holds
725        // more than its capacity, so an over-capacity batch is permanently
726        // rejected rather than throttled. 2,000 IDs exceeds every cancel burst.
727        let batch = TradingRequest::CancelOrders { count: 2_000 };
728        for (tier, ..) in published() {
729            assert!(
730                batch.cost() > tier.burst(batch.bucket()),
731                "{tier:?} could absorb a 2,000-ID batch — check the published table"
732            );
733        }
734    }
735}