ig-client 0.12.1

This crate provides a client for the IG Markets API
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
/******************************************************************************
   Author: Joaquín Béjar García
   Email: jb@taunais.com
   Date: 19/10/25
******************************************************************************/

//! Rate limiter module for controlling API request rates
//!
//! This module provides rate limiting functionality using the `governor` crate
//! to ensure compliance with IG Markets API rate limits.
//!
//! # Enforced budget
//!
//! `RateLimiter` holds one `governor` token bucket per
//! `RateLimitClass`, all derived from the single
//! `RateLimiterConfig` passed to
//! `RateLimiter::new`:
//!
//! - `NonTrading` honors the configured budget
//!   exactly: `max_requests` requests per `period_seconds`, with `burst_size`
//!   burst capacity. The per-cell replenishment interval is `period_seconds /
//!   max_requests`.
//! - `Trading` uses a stricter, fixed budget derived
//!   from IG's published per-app trading limit (~1 request/second), independent
//!   of the configured budget so a permissive config cannot loosen it.
//! - `Historical` uses its own conservative bucket
//!   because historical price fetches also draw down a weekly data-point
//!   allowance.
//!
//! The buckets are independent, so bulk non-trading traffic can never queue
//! order placement behind it.

use crate::application::config::RateLimiterConfig;
use crate::constants::{
    DEFAULT_RATE_LIMIT_BURST_SIZE, FALLBACK_RATE_LIMIT_MAX_REQUESTS,
    HISTORICAL_RATE_LIMIT_PER_SECOND, TRADING_HISTORICAL_BURST_SIZE, TRADING_RATE_LIMIT_PER_SECOND,
};
use governor::{
    Quota, RateLimiter as GovernorRateLimiter,
    clock::QuantaClock,
    state::{InMemoryState, NotKeyed},
};
use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::Duration;

/// Concrete `governor` direct rate limiter used for every class.
type DirectLimiter = GovernorRateLimiter<NotKeyed, InMemoryState, QuantaClock>;

/// Number of nanoseconds in one second, used when deriving per-second budgets.
const NANOS_PER_SECOND: u64 = 1_000_000_000;

/// Rate-limit class for an IG endpoint.
///
/// IG applies separate budgets to trading and non-trading traffic, and
/// historical price fetches additionally draw down a weekly data-point
/// allowance. Each class maps to an independent token bucket inside
/// `RateLimiter` so one kind of traffic never starves another.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum RateLimitClass {
    /// Order and position mutations (`positions/otc`, `workingorders/otc`).
    ///
    /// Subject to IG's strict per-app trading limit; the most tightly paced
    /// class.
    Trading,
    /// Historical price fetches (endpoints under `prices/`).
    ///
    /// Subject to a weekly data-point allowance in addition to a per-second
    /// rate, so it gets its own conservative bucket.
    Historical,
    /// Everything else: market data, account queries, sentiment, watchlists,
    /// working-order and position *reads*, and so on.
    ///
    /// Paced by the configured `RateLimiterConfig` budget.
    NonTrading,
}

/// Rate limiter for controlling API request rates
///
/// Uses the `governor` crate to implement a token bucket algorithm for rate
/// limiting API requests, with one independent bucket per `RateLimitClass`.
/// See the [module documentation](self) for the enforced budget of each class.
#[derive(Clone)]
pub struct RateLimiter {
    /// Bucket for `RateLimitClass::NonTrading` — the configured budget.
    non_trading: Arc<DirectLimiter>,
    /// Bucket for `RateLimitClass::Trading` — derived strict trading budget.
    trading: Arc<DirectLimiter>,
    /// Bucket for `RateLimitClass::Historical` — derived conservative budget.
    historical: Arc<DirectLimiter>,
}

/// Computes the per-cell replenishment interval that honors the configured
/// budget: `period_seconds / max_requests`.
///
/// This is the core fix for issue #48. The previous implementation used the
/// whole `period_seconds` as the interval and ignored `max_requests` entirely,
/// so a `{ max_requests: 60, period_seconds: 60 }` config allowed ~1
/// request/minute instead of 60.
///
/// Guards the structurally-invalid zero cases without dividing by zero or
/// building a zero-length period:
/// - `max_requests == 0` falls back to [`FALLBACK_RATE_LIMIT_MAX_REQUESTS`]
///   (one request per period).
/// - `period_seconds == 0` falls back to a one-second period.
///
/// Rounding is toward positive infinity (ceiling division): the per-cell
/// interval is never shorter than `period / max_requests`, so the enforced rate
/// never *exceeds* the configured budget for periods that are not evenly
/// divisible. The result is clamped to at least one nanosecond so the interval
/// is always non-zero.
#[must_use]
#[inline]
fn replenish_period(config: &RateLimiterConfig) -> Duration {
    let max_requests = config.max_requests.max(FALLBACK_RATE_LIMIT_MAX_REQUESTS);
    let period = if config.period_seconds == 0 {
        Duration::from_secs(1)
    } else {
        Duration::from_secs(config.period_seconds)
    };
    // `as_nanos` is u128; clamp to u64 for the very large (multi-century) periods
    // that cannot occur in practice but must not panic.
    let period_nanos = u64::try_from(period.as_nanos()).unwrap_or(u64::MAX);
    // Ceiling division: round the interval UP so the derived rate can only be
    // at or below `max_requests` per period, never above it. `max_requests >= 1`
    // here, so this can never divide by zero.
    let per_cell_nanos = period_nanos.div_ceil(u64::from(max_requests)).max(1);
    Duration::from_nanos(per_cell_nanos)
}

/// Per-cell replenishment interval for a fixed "requests per second" budget.
///
/// Used to derive the trading and historical buckets. Guards `requests_per_second
/// == 0` by treating it as one, and clamps the result to at least one nanosecond.
#[must_use]
#[inline]
fn per_second_period(requests_per_second: u32) -> Duration {
    let rps = u64::from(requests_per_second.max(1));
    Duration::from_nanos((NANOS_PER_SECOND / rps).max(1))
}

/// Builds a direct `governor` limiter for a per-cell interval and burst size.
///
/// Guards both invalid inputs without panicking:
/// - a zero `burst_size` falls back to [`DEFAULT_RATE_LIMIT_BURST_SIZE`];
/// - a zero-length `period_per_cell` (structurally unreachable given the callers)
///   falls back to a one-request-per-second quota via [`Quota::per_second`].
#[must_use]
fn build_limiter(period_per_cell: Duration, burst_size: u32) -> Arc<DirectLimiter> {
    let burst = NonZeroU32::new(burst_size)
        .or_else(|| NonZeroU32::new(DEFAULT_RATE_LIMIT_BURST_SIZE))
        .unwrap_or(NonZeroU32::MIN);

    let quota = match Quota::with_period(period_per_cell) {
        Some(quota) => quota.allow_burst(burst),
        // Unreachable in practice: `replenish_period` / `per_second_period`
        // always yield a non-zero interval. Fall back to a safe quota instead of
        // panicking so the limiter stays functional.
        None => Quota::per_second(NonZeroU32::MIN).allow_burst(burst),
    };

    Arc::new(GovernorRateLimiter::direct(quota))
}

impl RateLimiter {
    /// Creates a new rate limiter from configuration
    ///
    /// The non-trading bucket honors the configured budget exactly: it admits
    /// `config.max_requests` requests per `config.period_seconds`, with
    /// `config.burst_size` burst capacity (replenishing one cell every
    /// `period_seconds / max_requests`). The trading and historical buckets use
    /// stricter, fixed budgets derived from IG's published limits and are
    /// independent of the configured budget. See the [module documentation](self).
    ///
    /// # Arguments
    ///
    /// * `config` - Rate limiter configuration containing max requests, period, and burst size
    ///
    /// # Returns
    ///
    /// A new `RateLimiter` instance
    ///
    /// # Example
    ///
    /// ```ignore
    /// use ig_client::application::config::RateLimiterConfig;
    /// use ig_client::application::rate_limiter::RateLimiter;
    ///
    /// let config = RateLimiterConfig {
    ///     max_requests: 60,
    ///     period_seconds: 60,
    ///     burst_size: 10,
    /// };
    ///
    /// // Non-trading admits 60 requests/minute (one cell per second, +burst).
    /// let limiter = RateLimiter::new(&config);
    /// ```
    #[must_use]
    pub fn new(config: &RateLimiterConfig) -> Self {
        let non_trading = build_limiter(replenish_period(config), config.burst_size);
        let trading = build_limiter(
            per_second_period(TRADING_RATE_LIMIT_PER_SECOND),
            TRADING_HISTORICAL_BURST_SIZE,
        );
        let historical = build_limiter(
            per_second_period(HISTORICAL_RATE_LIMIT_PER_SECOND),
            TRADING_HISTORICAL_BURST_SIZE,
        );

        Self {
            non_trading,
            trading,
            historical,
        }
    }

    /// Returns the underlying limiter for a given rate-limit class.
    #[inline]
    fn limiter_for(&self, class: RateLimitClass) -> &DirectLimiter {
        match class {
            RateLimitClass::Trading => &self.trading,
            RateLimitClass::Historical => &self.historical,
            RateLimitClass::NonTrading => &self.non_trading,
        }
    }

    /// Waits until a request in the given class can be made according to its
    /// rate limit.
    ///
    /// Uses `governor`'s async scheduler ([`until_ready`](GovernorRateLimiter::until_ready)):
    /// the future is parked until a slot is available rather than busy-polling.
    /// Each class has an independent bucket, so waiting on one class never blocks
    /// another.
    ///
    /// # Example
    ///
    /// ```ignore
    /// use ig_client::application::rate_limiter::RateLimitClass;
    /// limiter.wait_for(RateLimitClass::Trading).await;
    /// // Place order here
    /// ```
    pub async fn wait_for(&self, class: RateLimitClass) {
        self.limiter_for(class).until_ready().await;
    }

    /// Checks if a request in the given class can be made immediately without
    /// waiting.
    ///
    /// # Returns
    ///
    /// * `true` if a request can be made immediately
    /// * `false` if that class's rate limit has been reached
    #[must_use]
    pub fn check_for(&self, class: RateLimitClass) -> bool {
        self.limiter_for(class).check().is_ok()
    }

    /// Waits until a non-trading request can be made according to the rate limit
    ///
    /// Convenience wrapper over [`wait_for`](Self::wait_for) with
    /// `RateLimitClass::NonTrading`. Blocks (asynchronously) until the rate
    /// limiter allows the request to proceed.
    ///
    /// # Example
    ///
    /// ```ignore
    /// limiter.wait().await;
    /// // Make API request here
    /// ```
    pub async fn wait(&self) {
        self.wait_for(RateLimitClass::NonTrading).await;
    }

    /// Checks if a non-trading request can be made immediately without waiting
    ///
    /// Convenience wrapper over [`check_for`](Self::check_for) with
    /// `RateLimitClass::NonTrading`.
    ///
    /// # Returns
    ///
    /// * `true` if a request can be made immediately
    /// * `false` if the rate limit has been reached
    ///
    /// # Example
    ///
    /// ```ignore
    /// if limiter.check() {
    ///     // Make API request
    /// } else {
    ///     // Wait or handle rate limit
    /// }
    /// ```
    #[must_use]
    pub fn check(&self) -> bool {
        self.check_for(RateLimitClass::NonTrading)
    }
}

impl std::fmt::Debug for RateLimiter {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("RateLimiter")
            .field("non_trading", &"GovernorRateLimiter")
            .field("trading", &"GovernorRateLimiter")
            .field("historical", &"GovernorRateLimiter")
            .finish()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_replenish_period_honors_max_requests() {
        // 60 requests per 60 seconds => one cell every 1 second.
        let config = RateLimiterConfig {
            max_requests: 60,
            period_seconds: 60,
            burst_size: 3,
        };
        assert_eq!(replenish_period(&config), Duration::from_secs(1));
        // The OLD bug used the whole 60s period as the interval (~1 req/minute);
        // the interval must be P/N, not P.
        assert_ne!(replenish_period(&config), Duration::from_secs(60));

        // 4 requests per 12 seconds => one cell every 3 seconds.
        let config = RateLimiterConfig {
            max_requests: 4,
            period_seconds: 12,
            burst_size: 3,
        };
        assert_eq!(replenish_period(&config), Duration::from_secs(3));
    }

    #[test]
    fn test_replenish_period_zero_max_requests_falls_back() {
        // max_requests == 0 is structurally invalid: fall back to one request per
        // period (10s per cell) rather than dividing by zero.
        let config = RateLimiterConfig {
            max_requests: 0,
            period_seconds: 10,
            burst_size: 1,
        };
        assert_eq!(replenish_period(&config), Duration::from_secs(10));
    }

    #[test]
    fn test_replenish_period_zero_period_falls_back() {
        // period_seconds == 0 falls back to a one-second period: 5 req/s => 200ms.
        let config = RateLimiterConfig {
            max_requests: 5,
            period_seconds: 0,
            burst_size: 1,
        };
        assert_eq!(replenish_period(&config), Duration::from_millis(200));
    }

    #[test]
    fn test_per_second_period_matches_rate() {
        assert_eq!(per_second_period(1), Duration::from_secs(1));
        assert_eq!(per_second_period(4), Duration::from_millis(250));
        // Zero is treated as one to avoid dividing by zero.
        assert_eq!(per_second_period(0), Duration::from_secs(1));
    }

    #[test]
    fn test_configured_quota_replenishes_at_max_requests_rate() {
        // Deterministic (no wall clock): drive the config-derived quota through
        // governor's FakeRelativeClock. 20 requests / 1 second => one cell every
        // 50ms. Under the OLD `with_period(period)` bug the interval was the whole
        // 1s period, so advancing 50ms would NOT replenish — this test pins the fix.
        use governor::clock::FakeRelativeClock;

        let config = RateLimiterConfig {
            max_requests: 20,
            period_seconds: 1,
            burst_size: 1,
        };
        let interval = replenish_period(&config);
        assert_eq!(interval, Duration::from_millis(50));

        let burst = NonZeroU32::new(config.burst_size).expect("burst is non-zero");
        let quota = Quota::with_period(interval)
            .expect("non-zero interval")
            .allow_burst(burst);
        let clock = FakeRelativeClock::default();
        let limiter = GovernorRateLimiter::direct_with_clock(quota, clock.clone());

        // The single burst cell is available, then exhausted.
        assert!(limiter.check().is_ok());
        assert!(limiter.check().is_err(), "burst cell must be exhausted");

        // Before a full interval elapses the cell stays denied (the old 1s-per-cell
        // bug would still be denied here — that is fine — but see the next step).
        clock.advance(interval / 2);
        assert!(
            limiter.check().is_err(),
            "must not replenish before the configured interval"
        );

        // After the full 50ms interval exactly one cell replenishes. The old bug
        // (1s per cell) would still deny here — so this asserts max_requests is honored.
        clock.advance(interval / 2);
        assert!(
            limiter.check().is_ok(),
            "one cell must replenish after the configured 50ms interval"
        );
    }

    #[tokio::test]
    async fn test_trading_not_blocked_behind_saturated_non_trading() {
        // Single-cell non-trading bucket that will not replenish for ~60s.
        let config = RateLimiterConfig {
            max_requests: 1,
            period_seconds: 60,
            burst_size: 1,
        };
        let limiter = RateLimiter::new(&config);

        // Saturate the non-trading bucket: first admits, second is denied.
        assert!(limiter.check_for(RateLimitClass::NonTrading));
        assert!(
            !limiter.check_for(RateLimitClass::NonTrading),
            "non-trading bucket should be saturated"
        );

        // Trading and historical have independent buckets and still admit.
        assert!(
            limiter.check_for(RateLimitClass::Trading),
            "trading must not be blocked behind a saturated non-trading bucket"
        );
        assert!(
            limiter.check_for(RateLimitClass::Historical),
            "historical must not be blocked behind a saturated non-trading bucket"
        );
    }

    #[tokio::test]
    async fn test_wait_for_returns_without_parking_when_slot_available() {
        // A fresh bucket with burst capacity has a permit available, so `wait_for`
        // resolves immediately via governor's scheduler rather than parking or
        // polling. Asserted deterministically via permit availability (no wall
        // clock): `check_for` is true, and the subsequent `wait_for` must not hang.
        let config = RateLimiterConfig {
            max_requests: 10,
            period_seconds: 1,
            burst_size: 5,
        };
        let limiter = RateLimiter::new(&config);

        assert!(
            limiter.check_for(RateLimitClass::NonTrading),
            "a burst slot must be available on a fresh bucket"
        );
        // Must return without parking because a permit is available; if it hung,
        // the test would time out rather than pass.
        limiter.wait_for(RateLimitClass::NonTrading).await;
    }

    #[test]
    fn test_check_delegates_to_non_trading() {
        // `check()` (no class) must observe the same bucket as
        // `check_for(NonTrading)` so existing callers keep their semantics.
        let config = RateLimiterConfig {
            max_requests: 1,
            period_seconds: 60,
            burst_size: 1,
        };
        let limiter = RateLimiter::new(&config);

        assert!(limiter.check());
        assert!(
            !limiter.check(),
            "check() must delegate to the non-trading bucket"
        );
        assert!(
            !limiter.check_for(RateLimitClass::NonTrading),
            "check_for(NonTrading) must observe the same saturated bucket as check()"
        );
    }
}