From 4d65cc45f4fd7c71a4222d2d3c7c41100acd7069 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 1 Jun 2026 12:32:27 -0700 Subject: [PATCH] estimateWaitSeconds needs to take a data type due to provider diffs --- src/commands/common.zig | 10 ++++- src/service.zig | 94 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 94 insertions(+), 10 deletions(-) diff --git a/src/commands/common.zig b/src/commands/common.zig index 0f8dddc..4ea1ddf 100644 --- a/src/commands/common.zig +++ b/src/commands/common.zig @@ -142,8 +142,14 @@ pub const LoadProgress = struct { const display_idx = self.index_offset + index + 1; switch (status) { .fetching => { - // Show rate-limit wait before the fetch - if (self.svc.estimateWaitSeconds()) |w| { + // Show rate-limit wait before the fetch. + // Prices come from the candle pipeline; ask about + // candles_daily so the wait reflects whichever + // provider serves candles (currently Tiingo, no + // rate limiter -- so this is effectively a no-op, + // but stays correct if the provider gains a limiter + // or the type's primary changes). + if (self.svc.estimateWaitSeconds(.candles_daily)) |w| { if (w > 0) stderrRateLimitWait(self.io, w, self.color); } stderrProgress(self.io, symbol, " (fetching)", display_idx, self.grand_total, self.color); diff --git a/src/service.zig b/src/service.zig index 7c49445..0d96197 100644 --- a/src/service.zig +++ b/src/service.zig @@ -1687,14 +1687,35 @@ pub const DataService = struct { return mr.meta.last_date; } - /// Estimate wait time (in seconds) before the next TwelveData API call can proceed. - /// Returns 0 if a request can be made immediately. Returns null if no API key. - pub fn estimateWaitSeconds(self: *DataService) ?u64 { - if (self.td) |*td| { - const ns = td.rate_limiter.estimateWaitNs(); - return if (ns == 0) 0 else @max(1, ns / std.time.ns_per_s); - } - return null; + /// Estimate wait time (in seconds) before a fetch for `data_type` + /// can proceed without blocking on its provider's rate limiter. + /// Returns 0 if a request can be made immediately, or if the + /// provider for this data type has no rate limiter. Returns null + /// if the relevant provider isn't instantiated yet (e.g., no API + /// key, or first call hasn't happened to lazy-init it). + /// + /// The caller asks "how long until getX can proceed?" -- the + /// service maps data type to provider internally so the caller + /// doesn't have to know which provider serves which data. + pub fn estimateWaitSeconds(self: *DataService, data_type: cache.DataType) ?u64 { + const ns: u64 = switch (data_type) { + // Polygon-served: dividends and splits. + .dividends, .splits => if (self.pg) |*pg| pg.rate_limiter.estimateWaitNs() else return null, + // FMP-served: earnings. + .earnings => if (self.fmp) |*fmp| fmp.rate_limiter.estimateWaitNs() else return null, + // Cboe-served: options chains. + .options => if (self.cboe) |*cboe| cboe.rate_limiter.estimateWaitNs() else return null, + // EDGAR-served: ETF metrics, entity facts, ticker maps. + .etf_metrics, .entity_facts, .tickers_funds, .tickers_companies => if (self.edgar) |*e| e.rate_limiter.estimateWaitNs() else return null, + // No proactive token-bucket limiter for these. Tiingo + // (candles) has a 1000/day quota enforced reactively + // via 429-then-backoff in `getCandles`; Wikidata + // (classification) has no published quota; the legacy + // `etf_profile` and `meta` types aren't fetched. Nothing + // useful to wait for at the call site, so report 0. + .candles_daily, .candles_meta, .classification, .etf_profile, .meta => 0, + }; + return if (ns == 0) 0 else @max(1, ns / std.time.ns_per_s); } /// Read candles from cache only (no network fetch). Used by TUI for display. @@ -3087,6 +3108,63 @@ test "DataService getProvider returns NoApiKey for Wikidata without user_email" try std.testing.expectError(DataError.NoApiKey, ed_result); } +test "estimateWaitSeconds returns null when relevant provider not instantiated" { + const allocator = std.testing.allocator; + const config = Config{ .cache_dir = "/tmp/zfin-test-cache" }; + var svc = DataService.init(std.testing.io, allocator, config); + defer svc.deinit(); + + // No providers initialized yet (lazy). Each rate-limited data + // type returns null because its provider is missing. + try std.testing.expectEqual(@as(?u64, null), svc.estimateWaitSeconds(.dividends)); + try std.testing.expectEqual(@as(?u64, null), svc.estimateWaitSeconds(.splits)); + try std.testing.expectEqual(@as(?u64, null), svc.estimateWaitSeconds(.earnings)); + try std.testing.expectEqual(@as(?u64, null), svc.estimateWaitSeconds(.options)); + try std.testing.expectEqual(@as(?u64, null), svc.estimateWaitSeconds(.etf_metrics)); + try std.testing.expectEqual(@as(?u64, null), svc.estimateWaitSeconds(.entity_facts)); +} + +test "estimateWaitSeconds returns 0 for types without rate limiters" { + // candles_daily, classification, etc. are served by providers + // that don't have a rate limiter (Tiingo, Wikidata). The + // function returns 0 for these regardless of provider state -- + // there's nothing to wait for. + const allocator = std.testing.allocator; + const config = Config{ .cache_dir = "/tmp/zfin-test-cache" }; + var svc = DataService.init(std.testing.io, allocator, config); + defer svc.deinit(); + + try std.testing.expectEqual(@as(?u64, 0), svc.estimateWaitSeconds(.candles_daily)); + try std.testing.expectEqual(@as(?u64, 0), svc.estimateWaitSeconds(.candles_meta)); + try std.testing.expectEqual(@as(?u64, 0), svc.estimateWaitSeconds(.classification)); + try std.testing.expectEqual(@as(?u64, 0), svc.estimateWaitSeconds(.etf_profile)); + try std.testing.expectEqual(@as(?u64, 0), svc.estimateWaitSeconds(.meta)); +} + +test "estimateWaitSeconds returns 0 for fresh rate-limited providers" { + // Once the provider is instantiated, an unused rate limiter + // returns 0 (no wait). This is the steady-state happy path + // for the call at the top of each refresh iteration. + const allocator = std.testing.allocator; + const config = Config{ + .cache_dir = "/tmp/zfin-test-cache", + .polygon_key = "test-polygon-key", + .fmp_key = "test-fmp-key", + }; + var svc = DataService.init(std.testing.io, allocator, config); + defer svc.deinit(); + + // Touch each provider to lazy-init it. We don't care about the + // returned pointer; just need svc.pg / svc.fmp to be non-null. + _ = try svc.getProvider(Polygon); + _ = try svc.getProvider(Fmp); + + // Fresh limiters have full token bucket -> 0 wait. + try std.testing.expectEqual(@as(?u64, 0), svc.estimateWaitSeconds(.dividends)); + try std.testing.expectEqual(@as(?u64, 0), svc.estimateWaitSeconds(.splits)); + try std.testing.expectEqual(@as(?u64, 0), svc.estimateWaitSeconds(.earnings)); +} + // ── lookupInTickerMaps ──────────────────────────────────────── // // Pure function — no I/O. Consumed by `lookupEdgarFallback`,