estimateWaitSeconds needs to take a data type due to provider diffs
All checks were successful
Generic zig build / build (push) Successful in 9m51s
Generic zig build / publish-macos (push) Successful in 10s
Generic zig build / deploy (push) Successful in 25s

This commit is contained in:
Emil Lerch 2026-06-01 12:32:27 -07:00
parent 60435b645f
commit 4d65cc45f4
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 94 additions and 10 deletions

View file

@ -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);

View file

@ -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`,