From 0cd01dd452338ddabdededb7acf4f34662d81434 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 26 Jun 2026 16:28:53 -0700 Subject: [PATCH] fix bug in market-aware cache ttl. The function+tests is a bit overkill, but this is hard... --- src/market.zig | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ src/service.zig | 14 +++++++++++-- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/market.zig b/src/market.zig index 19246c4..472a313 100644 --- a/src/market.zig +++ b/src/market.zig @@ -315,6 +315,26 @@ pub fn candleFreshness(now_s: i64, kind: InstrumentKind, last_cached: Date) Cand return .overdue; } +/// Whether a candle fetch is warranted for `kind` as of `now_s`, given +/// `last_cached` (the newest bar already in the cache). True when a +/// newer bar is due (`candleFreshness` is `.lagging` or `.overdue`); +/// false when the cache already holds the latest *available* bar - a +/// weekend/holiday/pre-close gap, or genuinely caught up - so the caller +/// should just bump the TTL to the next boundary without hitting the +/// network. +/// +/// This is the market-aware gate for getCandles' "do I need to fetch?" +/// decision. It deliberately shares `candleFreshness`'s availability +/// math so the fetch decision and the lag report cannot disagree. A +/// naive `last_cached + 1 >= today` calendar check would skip the fetch +/// for a just-closed session whose bar is due (last_cached == yesterday) +/// while `candleFreshness` simultaneously flagged it `.lagging`, freezing +/// the cache on the stale bar until the next boundary. Pure given +/// `now_s`, so it is fully deterministic for tests. +pub fn shouldRefresh(now_s: i64, kind: InstrumentKind, last_cached: Date) bool { + return candleFreshness(now_s, kind, last_cached) != .current; +} + /// Expiry to stamp on candle meta after an *incremental* fetch on a /// stale entry returned zero new bars. Maps `candleFreshness` to a /// boundary: a `.lagging` bar retries soon (`short_retry_s`); `.current` @@ -737,6 +757,41 @@ test "candleFreshness mutual_fund: late NAV is lagging" { try testing.expectEqual(CandleFreshness.current, candleFreshness(now, .mutual_fund, Date.fromYmd(2025, 6, 16))); } +test "shouldRefresh equity: just-closed session with only yesterday's bar -> refresh" { + // Fri 2025-06-13, 17:00 ET: Friday's bar is due (past the 16:55 + // boundary) but the cache only holds Thursday 06-12. The old naive + // `last_cached + 1 >= today` check skipped this fetch and froze the + // cache until Monday; shouldRefresh must say yes. + const now = etLocalToUtc(Date.fromYmd(2025, 6, 13), 17 * std.time.s_per_hour); + try testing.expect(shouldRefresh(now, .equity, Date.fromYmd(2025, 6, 12))); +} + +test "shouldRefresh equity: already holding the just-closed bar -> no refresh" { + const now = etLocalToUtc(Date.fromYmd(2025, 6, 13), 17 * std.time.s_per_hour); + try testing.expect(!shouldRefresh(now, .equity, Date.fromYmd(2025, 6, 13))); +} + +test "shouldRefresh equity: pre-close, yesterday's bar is still the latest -> no refresh" { + // Fri 2025-06-13, 10:00 ET: before the 16:55 boundary, Thursday's bar + // is still the latest available. (The case the old check got right.) + const now = etLocalToUtc(Date.fromYmd(2025, 6, 13), 10 * std.time.s_per_hour); + try testing.expect(!shouldRefresh(now, .equity, Date.fromYmd(2025, 6, 12))); +} + +test "shouldRefresh equity: weekend gap holding Friday's bar -> no refresh" { + // Sun 2025-06-15: nothing newer than Friday is due over the weekend, + // so no wasteful fetch (the old calendar check would have fetched). + const now = etLocalToUtc(Date.fromYmd(2025, 6, 15), 12 * std.time.s_per_hour); + try testing.expect(!shouldRefresh(now, .equity, Date.fromYmd(2025, 6, 13))); +} + +test "shouldRefresh mutual_fund: weekend morning holding latest NAV -> no refresh" { + // Sat 2025-06-14, 05:00 ET: Friday's NAV (data_date 06-13) posted this + // morning; holding it means nothing newer is due. + const now = etLocalToUtc(Date.fromYmd(2025, 6, 14), 5 * std.time.s_per_hour); + try testing.expect(!shouldRefresh(now, .mutual_fund, Date.fromYmd(2025, 6, 13))); +} + test "fmtClockET: 12-hour rendering with EST/EDT and AM/PM" { var buf: [16]u8 = undefined; // Afternoon (EST, winter): 14:34 ET. diff --git a/src/service.zig b/src/service.zig index 38caeb1..41ff1f6 100644 --- a/src/service.zig +++ b/src/service.zig @@ -809,8 +809,18 @@ pub const DataService = struct { // this stale path (next post-close / NAV-availability time). const expires = market.nextCandleExpiry(now_s, kind); - // If last cached date is today or later, just refresh the TTL (meta only) - if (!fetch_from.lessThan(today)) { + // Only skip the fetch when we already hold the latest + // *available* bar (weekend/holiday/pre-close gap, or + // caught up): just bump the TTL. Gating on the + // market-aware `shouldRefresh` -- not a naive + // `last_cached + 1 >= today` calendar check -- keeps this + // decision consistent with the `candleFreshness` lag + // report. The old calendar check skipped the fetch when + // `last_date` was merely yesterday, so a just-closed + // session's due bar got cached as "fresh" until the next + // boundary while the lag check reported it lagging (the + // Friday-17:00 deadlock that exited 75 every retry). + if (!market.shouldRefresh(now_s, kind, m.last_date)) { s.updateCandleMeta(symbol, m.last_close, m.last_date, m.provider, m.fail_count, expires); if (s.read(self.allocator, Candle, symbol, null, .any)) |r| return .{ .data = r.data, .source = .cached, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };