Skip to main content

kraken_api_client/rate_limit/
trading.rs

1//! Trading-specific rate limiting with order lifetime penalties.
2//!
3//! Kraken applies different rate limit penalties for order cancellation
4//! based on how long the order has been open. Orders cancelled quickly
5//! incur higher penalties.
6//!
7//! # Penalty Schedule
8//!
9//! | Order Age | Cancel Penalty |
10//! |-----------|----------------|
11//! | < 5s      | 8 points       |
12//! | 5-10s     | 6 points       |
13//! | 10-15s    | 5 points       |
14//! | 15-45s    | 4 points       |
15//! | 45-90s    | 2 points       |
16//! | > 90s     | 0 points       |
17
18use std::collections::HashMap;
19use std::time::{Duration, Instant};
20
21use crate::rate_limit::limits::trading;
22use crate::rate_limit::TtlCache;
23
24/// Information tracked for each order.
25#[derive(Debug, Clone)]
26pub struct OrderTrackingInfo {
27    /// When the order was placed.
28    pub created_at: Instant,
29    /// Trading pair for the order.
30    pub pair: String,
31    /// Client order ID if provided.
32    pub client_order_id: Option<String>,
33}
34
35impl OrderTrackingInfo {
36    /// Create new order tracking info.
37    pub fn new(pair: impl Into<String>) -> Self {
38        Self {
39            created_at: Instant::now(),
40            pair: pair.into(),
41            client_order_id: None,
42        }
43    }
44
45    /// Create new order tracking info with a client order ID.
46    pub fn with_client_id(pair: impl Into<String>, client_order_id: impl Into<String>) -> Self {
47        Self {
48            created_at: Instant::now(),
49            pair: pair.into(),
50            client_order_id: Some(client_order_id.into()),
51        }
52    }
53
54    /// Get the age of this order.
55    pub fn age(&self) -> Duration {
56        self.created_at.elapsed()
57    }
58}
59
60/// Trading rate limiter with order lifetime penalty tracking.
61///
62/// Tracks orders and calculates appropriate rate limit penalties
63/// when they are cancelled based on their age.
64#[derive(Debug)]
65pub struct TradingRateLimiter {
66    /// Order tracking cache (orders expire after 5 minutes)
67    orders: TtlCache<String, OrderTrackingInfo>,
68    /// Current rate limit counter (scaled 100x for precision)
69    counter: i64,
70    /// Maximum counter value (scaled 100x)
71    max_counter: i64,
72    /// Decay rate per second (scaled 100x)
73    decay_rate: i64,
74    /// Last time the counter was updated
75    last_update: Instant,
76}
77
78impl TradingRateLimiter {
79    /// Create a new trading rate limiter.
80    ///
81    /// # Arguments
82    ///
83    /// * `max_counter` - Maximum counter value
84    /// * `decay_rate_per_sec` - How much the counter decays per second
85    pub fn new(max_counter: u32, decay_rate_per_sec: f64) -> Self {
86        Self {
87            orders: TtlCache::new(Duration::from_secs(300)), // 5 minute TTL
88            counter: 0,
89            max_counter: (max_counter as i64) * 100,
90            decay_rate: (decay_rate_per_sec * 100.0) as i64,
91            last_update: Instant::now(),
92        }
93    }
94
95    /// Update the counter based on time decay.
96    fn update_counter(&mut self) {
97        let elapsed = self.last_update.elapsed();
98        let elapsed_secs = elapsed.as_secs_f64();
99        let decay = (elapsed_secs * self.decay_rate as f64) as i64;
100        self.counter = (self.counter - decay).max(0);
101        self.last_update = Instant::now();
102    }
103
104    /// Try to acquire capacity for a new order.
105    ///
106    /// Returns `Ok(())` if allowed, `Err(wait_time)` if rate limited.
107    pub fn try_place_order(&mut self, order_id: &str, info: OrderTrackingInfo) -> Result<(), Duration> {
108        self.update_counter();
109
110        // Adding an order costs 1 point (100 in scaled units)
111        let cost = 100;
112
113        if self.counter + cost <= self.max_counter {
114            self.counter += cost;
115            self.orders.insert(order_id.to_string(), info);
116            Ok(())
117        } else {
118            // Calculate wait time
119            let excess = self.counter + cost - self.max_counter;
120            let wait_secs = excess as f64 / self.decay_rate as f64;
121            Err(Duration::from_secs_f64(wait_secs))
122        }
123    }
124
125    /// Track an order that was placed (without rate limit check).
126    ///
127    /// Use this when the order was already placed successfully.
128    pub fn track_order(&mut self, order_id: impl Into<String>, info: OrderTrackingInfo) {
129        self.orders.insert(order_id.into(), info);
130    }
131
132    /// Calculate the penalty for cancelling an order.
133    ///
134    /// Returns the penalty in points based on the order's age.
135    pub fn cancel_penalty(age: Duration) -> u32 {
136        let secs = age.as_secs();
137
138        if secs < 5 {
139            trading::CANCEL_PENALTY_UNDER_5S
140        } else if secs < 10 {
141            trading::CANCEL_PENALTY_5_TO_10S
142        } else if secs < 15 {
143            trading::CANCEL_PENALTY_10_TO_15S
144        } else if secs < 45 {
145            trading::CANCEL_PENALTY_15_TO_45S
146        } else if secs < 90 {
147            trading::CANCEL_PENALTY_45_TO_90S
148        } else {
149            trading::CANCEL_PENALTY_OVER_90S
150        }
151    }
152
153    /// Try to cancel an order with rate limit penalty.
154    ///
155    /// Returns `Ok(penalty)` if allowed (with the penalty that was applied),
156    /// or `Err(wait_time)` if rate limited.
157    pub fn try_cancel_order(&mut self, order_id: &str) -> Result<u32, Duration> {
158        self.update_counter();
159
160        // Get the order age and calculate penalty
161        let penalty = if let Some((_, age)) = self.orders.remove_with_age(&order_id.to_string()) {
162            Self::cancel_penalty(age)
163        } else {
164            // Order not tracked, assume worst case
165            trading::CANCEL_PENALTY_UNDER_5S
166        };
167
168        let cost = (penalty as i64) * 100;
169
170        if self.counter + cost <= self.max_counter {
171            self.counter += cost;
172            Ok(penalty)
173        } else {
174            // Calculate wait time
175            let excess = self.counter + cost - self.max_counter;
176            let wait_secs = excess as f64 / self.decay_rate as f64;
177            Err(Duration::from_secs_f64(wait_secs))
178        }
179    }
180
181    /// Notify the limiter that an order was cancelled (without rate limit check).
182    ///
183    /// Use this when the cancellation was already processed.
184    pub fn order_cancelled(&mut self, order_id: &str) {
185        self.orders.remove(&order_id.to_string());
186    }
187
188    /// Notify the limiter that an order was filled.
189    ///
190    /// Filled orders don't incur cancellation penalties.
191    pub fn order_filled(&mut self, order_id: &str) {
192        self.orders.remove(&order_id.to_string());
193    }
194
195    /// Get the current counter value (unscaled).
196    pub fn current_counter(&self) -> f64 {
197        let elapsed = self.last_update.elapsed();
198        let elapsed_secs = elapsed.as_secs_f64();
199        let decay = elapsed_secs * self.decay_rate as f64;
200        let counter = (self.counter as f64 - decay).max(0.0);
201        counter / 100.0
202    }
203
204    /// Get the available capacity (unscaled).
205    pub fn available_capacity(&self) -> f64 {
206        (self.max_counter as f64 / 100.0) - self.current_counter()
207    }
208
209    /// Check if placing an order would be allowed.
210    pub fn would_allow_place(&self) -> bool {
211        let current = (self.current_counter() * 100.0) as i64;
212        current + 100 <= self.max_counter
213    }
214
215    /// Get the number of tracked orders.
216    pub fn tracked_orders(&self) -> usize {
217        self.orders.active_count()
218    }
219
220    /// Clean up expired order tracking entries.
221    pub fn cleanup(&mut self) {
222        self.orders.cleanup();
223    }
224}
225
226impl Default for TradingRateLimiter {
227    fn default() -> Self {
228        // Default: Pro tier limits
229        Self::new(20, 1.0)
230    }
231}
232
233/// Per-pair trading rate limiter.
234///
235/// Maintains separate rate limits for each trading pair.
236#[derive(Debug, Default)]
237pub struct PerPairTradingLimiter {
238    limiters: HashMap<String, TradingRateLimiter>,
239    max_counter: u32,
240    decay_rate: f64,
241}
242
243impl PerPairTradingLimiter {
244    /// Create a new per-pair trading limiter.
245    pub fn new(max_counter: u32, decay_rate: f64) -> Self {
246        Self {
247            limiters: HashMap::new(),
248            max_counter,
249            decay_rate,
250        }
251    }
252
253    /// Get or create a limiter for a specific pair.
254    pub fn limiter_for(&mut self, pair: &str) -> &mut TradingRateLimiter {
255        self.limiters
256            .entry(pair.to_string())
257            .or_insert_with(|| TradingRateLimiter::new(self.max_counter, self.decay_rate))
258    }
259
260    /// Clean up all limiters.
261    pub fn cleanup(&mut self) {
262        for limiter in self.limiters.values_mut() {
263            limiter.cleanup();
264        }
265    }
266
267    /// Get the number of pairs being tracked.
268    pub fn tracked_pairs(&self) -> usize {
269        self.limiters.len()
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use std::thread;
277
278    #[test]
279    fn test_cancel_penalty_calculation() {
280        assert_eq!(TradingRateLimiter::cancel_penalty(Duration::from_secs(2)), 8);
281        assert_eq!(TradingRateLimiter::cancel_penalty(Duration::from_secs(5)), 6);
282        assert_eq!(TradingRateLimiter::cancel_penalty(Duration::from_secs(12)), 5);
283        assert_eq!(TradingRateLimiter::cancel_penalty(Duration::from_secs(30)), 4);
284        assert_eq!(TradingRateLimiter::cancel_penalty(Duration::from_secs(60)), 2);
285        assert_eq!(TradingRateLimiter::cancel_penalty(Duration::from_secs(100)), 0);
286    }
287
288    #[test]
289    fn test_place_order_tracking() {
290        let mut limiter = TradingRateLimiter::new(20, 1.0);
291
292        let info = OrderTrackingInfo::new("BTC/USD");
293        assert!(limiter.try_place_order("order1", info).is_ok());
294        assert_eq!(limiter.tracked_orders(), 1);
295    }
296
297    #[test]
298    fn test_cancel_penalty_applied() {
299        let mut limiter = TradingRateLimiter::new(20, 1.0);
300
301        let info = OrderTrackingInfo::new("BTC/USD");
302        limiter.try_place_order("order1", info).ok();
303
304        // Cancel immediately (should get max penalty)
305        let result = limiter.try_cancel_order("order1");
306        assert!(result.is_ok());
307        assert_eq!(result.unwrap(), 8); // Under 5s penalty
308    }
309
310    #[test]
311    fn test_decay_over_time() {
312        let mut limiter = TradingRateLimiter::new(20, 10.0); // High decay rate for testing
313
314        // Fill up the counter
315        for i in 0..20 {
316            let info = OrderTrackingInfo::new("BTC/USD");
317            limiter.try_place_order(&format!("order{}", i), info).ok();
318        }
319
320        let initial = limiter.current_counter();
321        thread::sleep(Duration::from_millis(200));
322        let after = limiter.current_counter();
323
324        assert!(after < initial);
325    }
326
327    #[test]
328    fn test_order_info_age() {
329        let info = OrderTrackingInfo::new("BTC/USD");
330        thread::sleep(Duration::from_millis(50));
331        let age = info.age();
332        assert!(age >= Duration::from_millis(50));
333    }
334}