From ca3bd1111000a2b98fd0004ec8b1cd4c8d007756 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 19 Sep 2026 07:43:05 -0700 Subject: [PATCH] cadence aware dividend refreshes (note this will net increase polygon fetching) --- src/cache/store.zig | 77 ++++- src/service.zig | 823 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 880 insertions(+), 20 deletions(-) diff --git a/src/cache/store.zig b/src/cache/store.zig index 879102e..08e6587 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -35,9 +35,32 @@ pub const Ttl = struct { const s_per_day = std.time.s_per_day; /// Historical candles older than 1 day never expire pub const candles_historical: i64 = -1; // infinite - /// Dividend data refreshes biweekly - pub const dividends: i64 = 14 * s_per_day; - /// Split data refreshes biweekly + /// Dividend data. Six days, and the short value is load-bearing: + /// an ETF's distribution is not retrievable until roughly its own + /// ex-date, and it pays 1-7 days later, so the entire window in + /// which a new record exists but the cash has not yet landed is + /// about a week. A 14-day TTL straddles that window whole - a cache + /// written days before the ex-date stays "fresh" until well after + /// the payment, and the distribution is invisible for the run that + /// needed it. + /// + /// Quarter-end clustering is what makes it systematic rather than + /// unlucky: a dozen funds go ex within the same three days, so one + /// refresh pass writes a dozen entries onto the same expiry shelf + /// and every one of them straddles the next quarter's payment. + /// + /// This is the BACKSTOP, not the mechanism. `dividendsNeedRefresh` + /// in service.zig does the real work by asking whether a scheduled + /// distribution has come due; the TTL covers the three cases that + /// predicate cannot see - a holding too new to have a cadence, an + /// off-cycle special, and a symbol with fewer than three cached + /// records. + pub const dividends: i64 = 6 * s_per_day; + /// Split data refreshes biweekly. Deliberately NOT shortened + /// alongside `dividends`: splits are announced weeks to months + /// ahead of their effective date, so a forward-looking record is + /// already cached long before it matters and none of the + /// short-window exposure above applies. pub const splits: i64 = 14 * s_per_day; /// Options chains refresh hourly pub const options: i64 = std.time.s_per_hour; @@ -226,9 +249,16 @@ pub const DataType = enum { /// /// Jitter assignments: /// - /// - 11% on dividends/splits (14d base, ~3d total spread). - /// Tuned so a daily cron sees roughly 1/3 of a portfolio's - /// symbols expire each day instead of all in lockstep. + /// - 11% on dividends (6d base, ~1.3d total spread) and splits + /// (14d base, ~3d). The two no longer share a base - see + /// `Ttl.dividends` for why only dividends were shortened - but + /// they keep the same percentage because the goal is the same: + /// a daily cron should see a portfolio's symbols expire across + /// several days instead of all in lockstep. Jitter is only a + /// load-spreading measure here, never a correctness one: + /// `dividendsNeedRefresh` is what guarantees a due + /// distribution is seen, and a run that depended on jitter + /// landing favourably would be relying on luck. /// /// - 8% on the longer-TTL types (classification 90d, /// etf_metrics 90d, entity_facts 30d, earnings 30d, @@ -2567,7 +2597,7 @@ test "writeMerged Dividend: no-change merge still rewrites to refresh expires" { s.writeWithSource(Dividend, "TEST", repeat[0..], .{ .seconds = Ttl.dividends }, "polygon"); // Confirm fresh expires landed on disk: read the raw file and - // parse out the directive, expecting it to be roughly now+14d. + // parse out the directive, expecting it to be roughly now+TTL. const path = try std.fs.path.join(allocator, &.{ dir_path, "TEST", "dividends.srf" }); defer allocator.free(path); const data = try std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)); @@ -2577,9 +2607,15 @@ test "writeMerged Dividend: no-change merge still rewrites to refresh expires" { const it = try srf.iterator(&reader, allocator, .{ .parse_allocator = .none }); defer it.deinit(); + // Bound derived from the constant and its jitter rather than + // hardcoded: this test is about "was the file rewritten with a + // fresh clock", not about how long the TTL happens to be, and a + // literal here made a TTL change look like a merge regression. + const spec = DataType.dividends.ttl(); + const jitter_floor = @divFloor(spec.seconds * (100 - @as(i64, spec.jitter_pct)), 100); const new_expires = it.expires orelse return error.ExpiresMissing; try std.testing.expect(new_expires > now_s); - try std.testing.expect(new_expires - now_s > 13 * std.time.s_per_day); + try std.testing.expect(new_expires - now_s >= jitter_floor); } test "writeSupplement Dividend: preserves an existing future expires" { @@ -2701,10 +2737,14 @@ test "writeSupplement Dividend: no existing file establishes an initial TTL" { const it = try srf.iterator(&reader, allocator, .{ .parse_allocator = .none }); defer it.deinit(); - // ~14d TTL with ±11% jitter; comfortably bracket the band. + // The jittered band, derived from the constant rather than + // hardcoded - this test is about "a first supplement stamps a + // normal TTL", not about the TTL's length. + const spec = DataType.dividends.ttl(); + const jitter = @divFloor(spec.seconds * @as(i64, spec.jitter_pct), 100); const new_expires = it.expires orelse return error.ExpiresMissing; - try std.testing.expect(new_expires - now_s > 13 * std.time.s_per_day); - try std.testing.expect(new_expires - now_s < 16 * std.time.s_per_day); + try std.testing.expect(new_expires - now_s >= spec.seconds - jitter); + try std.testing.expect(new_expires - now_s <= spec.seconds + jitter); } test "writeMerged Dividend: field-level upgrade fills nulls (Tiingo-then-Polygon)" { @@ -3368,10 +3408,21 @@ test "TTL constants are reasonable" { // Latest candles use a market-aware expiry computed per-write by // market.nextCandleExpiry (no fixed TTL constant here anymore). - // Dividends and splits refresh biweekly - try std.testing.expectEqual(@as(i64, 14 * std.time.s_per_day), Ttl.dividends); + // Dividends refresh every six days, splits biweekly. The two used + // to share a 14-day constant; they were deliberately split apart + // because an ETF distribution is only retrievable for the few days + // between its ex-date and its payment, while a split is announced + // weeks ahead. See `Ttl.dividends`. + try std.testing.expectEqual(@as(i64, 6 * std.time.s_per_day), Ttl.dividends); try std.testing.expectEqual(@as(i64, 14 * std.time.s_per_day), Ttl.splits); + // The relationship, not just the values: a dividend TTL longer than + // the ex-to-pay window is what made distributions invisible for the + // run that needed them. Every observed ETF pays within 7 days of + // going ex, so this bound is the property the constant exists to + // satisfy - if someone lengthens it, this fails and says why. + try std.testing.expect(Ttl.dividends <= 7 * std.time.s_per_day); + // Options refresh hourly try std.testing.expectEqual(@as(i64, std.time.s_per_hour), Ttl.options); diff --git a/src/service.zig b/src/service.zig index 8e82d98..0b245fe 100644 --- a/src/service.zig +++ b/src/service.zig @@ -489,6 +489,38 @@ pub const DataService = struct { return cache.Store.init(self.io, self.allocator, self.config.cache_dir); } + /// Whether a FRESH cache read may be served as-is. Returns false - + /// having already freed `data` - when the smart-refresh hook says the + /// entry is fresh but incomplete. + /// + /// One function rather than two inline blocks because `fetchCached` + /// has two fresh-cache read sites (local, and post-server-sync) and + /// the rule must be identical at both. It owns the free so a caller + /// cannot decide to discard and then leak. + /// + /// Suppressed under `skip_network`: offline mode never refetches, so + /// discarding a usable entry could only turn a served result into a + /// failure. Same rule `getEarnings` applies. + fn serveFreshOrDiscard( + self: *DataService, + comptime T: type, + comptime needsRefresh: ?*const fn ([]const T, Date) bool, + data: []const T, + skip_network: bool, + ) bool { + if (needsRefresh) |hook| { + // wall-clock required: the hook asks whether a scheduled + // event has come due, which is a question about the actual + // current day. Threading `today` down would put a date + // parameter on four public getters for one type's benefit. + if (!skip_network and hook(data, fmt.todayDate(self.io))) { + T.freeSlice(self.allocator, data); + return false; + } + } + return true; + } + /// Generic fetch-or-cache for simple data types (dividends, splits, options). /// Checks cache first; on miss, fetches from the appropriate provider, /// writes to cache, and returns. On permanent fetch failure, writes a negative @@ -497,11 +529,21 @@ pub const DataService = struct { /// `opts.skip_network = true` -> returns cached data even if stale, /// returns FetchFailed on cache miss without touching the network. /// `opts.force_refresh = true` -> treats cache as stale and fetches. + /// + /// `needsRefresh` is the smart-refresh hook: given a FRESH cache + /// entry it answers "is this nonetheless incomplete?". It exists + /// because freshness and completeness are different questions - + /// `dividendsNeedRefresh` documents the case that forced it. Null + /// means TTL is the only gate, which is the behaviour every type had + /// before the hook existed. The hook's branch is comptime-elided for + /// a null argument, so types with no `freeSlice` (Split) stay + /// compilable. fn fetchCached( self: *DataService, comptime T: type, symbol: []const u8, comptime postProcess: ?*const fn (*T, std.mem.Allocator) anyerror!void, + comptime needsRefresh: ?*const fn ([]const T, Date) bool, opts_in: FetchOptions, ) DataError!FetchResult(T) { // See `getCandles` - one fold, covering every type routed through here. @@ -514,8 +556,11 @@ pub const DataService = struct { // returns cached even if stale, never touches the network. if (!opts.force_refresh) { if (s.read(self.allocator, T, symbol, postProcess, .fresh_only)) |cached| { - log.debug("{s}: {s} fresh in local cache", .{ symbol, @tagName(data_type) }); - return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; + if (self.serveFreshOrDiscard(T, needsRefresh, cached.data, opts.skip_network)) { + log.debug("{s}: {s} fresh in local cache", .{ symbol, @tagName(data_type) }); + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; + } + log.debug("{s}: {s} fresh in local cache but a scheduled event is overdue; refetching", .{ symbol, @tagName(data_type) }); } } @@ -532,8 +577,18 @@ pub const DataService = struct { // Try server sync before hitting providers (skipped on force_refresh). if (!opts.force_refresh and self.syncFromServer(symbol, data_type)) { if (s.read(self.allocator, T, symbol, postProcess, .fresh_only)) |cached| { - log.debug("{s}: {s} synced from server and fresh", .{ symbol, @tagName(data_type) }); - return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; + // The hook applies here too. Without it a configured + // ZFIN_SERVER defeats the whole mechanism: the sync + // leaves a still-incomplete entry on disk, this read + // finds it fresh, and the overdue distribution is served + // anyway. Re-asking is also the cheap outcome when the + // server DID have the newer record - that is the tier's + // reason for existing, and we return without spending a + // provider request. + if (self.serveFreshOrDiscard(T, needsRefresh, cached.data, opts.skip_network)) { + log.debug("{s}: {s} synced from server and fresh", .{ symbol, @tagName(data_type) }); + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator }; + } } log.debug("{s}: {s} synced from server but stale, falling through to provider", .{ symbol, @tagName(data_type) }); } @@ -1393,18 +1448,159 @@ pub const DataService = struct { } /// Fetch dividend history for a symbol. + /// + /// Carries a schedule-aware refresh hook (`dividendsNeedRefresh`) + /// because a fresh dividend cache can still be *incomplete*: an + /// ETF's distribution is not retrievable until roughly its own + /// ex-date and pays within a week of it, so TTL alone cannot + /// guarantee the record is seen before the cash lands. pub fn getDividends(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(Dividend) { - return self.fetchCached(Dividend, symbol, null, opts); + return self.fetchCached(Dividend, symbol, null, dividendsNeedRefresh, opts); } /// Fetch split history for a symbol. + /// + /// No refresh hook: splits are announced weeks to months ahead, so + /// the forward-looking record is cached long before it matters. pub fn getSplits(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(Split) { - return self.fetchCached(Split, symbol, null, opts); + return self.fetchCached(Split, symbol, null, null, opts); } /// Fetch options chain for a symbol (all expirations, no API key needed). pub fn getOptions(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(OptionsChain) { - return self.fetchCached(OptionsChain, symbol, null, opts); + return self.fetchCached(OptionsChain, symbol, null, null, opts); + } + + /// Days after an expected ex-date during which a still-absent + /// distribution is worth chasing with a re-fetch. + /// + /// Bounded for the same reason `earnings_actual_chase_days` is: an + /// unbounded chase refetches forever whenever the cadence estimate + /// is wrong or a sponsor skips a period. Fourteen days covers the + /// prediction error actually observed (ex-dates land 1-3 days off a + /// median-of-gaps forecast) plus the few days a sponsor can take to + /// publish, and then hands back to the TTL. + const dividend_chase_days: i32 = 14; + + /// Fewest cached records that can establish a distribution cadence. + /// + /// Three records give two gaps, which is the minimum that can + /// disagree - and therefore the minimum where a median means + /// anything. Below it there is no cadence, the predicate declines, + /// and `Ttl.dividends` is the only guard. That is the correct + /// direction: a newly-bought holding has no schedule to be late + /// against, and inventing one from a single gap would fire on + /// noise. + const dividend_cadence_min_records: usize = 3; + + /// How many of the newest consecutive gaps the cadence medians over. + const dividend_cadence_gaps: usize = 5; + + /// The `out.len` newest ex-dates in `divs`, descending. Returns how + /// many were written. + /// + /// Sorts rather than trusting input order. The cache file happens to + /// be written newest-first today, but nothing in the SRF contract + /// promises it, `writeSupplement`'s sorted-union merge means two + /// providers' records interleave, and a cadence silently computed + /// from negative gaps would predict dates in the past forever. + fn newestExDates(divs: []const Dividend, out: []Date) usize { + var n: usize = 0; + for (divs) |d| { + // Insertion position in the descending prefix. + var i: usize = 0; + while (i < n and !out[i].lessThan(d.ex_date)) i += 1; + if (i >= out.len) continue; + var j = @min(n, out.len - 1); + while (j > i) : (j -= 1) out[j] = out[j - 1]; + out[i] = d.ex_date; + if (n < out.len) n += 1; + } + return n; + } + + /// The symbol's distribution cadence in days, or null when the + /// cache cannot establish one. + /// + /// MEDIAN of the newest gaps, not the mean and not the minimum, and + /// the choice matters in both directions. The mean is dragged by a + /// single special distribution landing days after a regular one; the + /// minimum is destroyed by it outright, predicting the next ex-date + /// a few days out and firing for the whole chase window after every + /// payment. A median over up to five gaps absorbs one outlier and + /// still tracks a genuine schedule. + /// + /// What it does NOT survive is a cadence CHANGE - a fund moving + /// quarterly to monthly keeps a ~91-day median for four more + /// periods and the forecast runs late. That is a real blind spot, + /// deliberately left to `Ttl.dividends` rather than papered over + /// with a more eager statistic that would cost requests on every + /// symbol to protect against a rare event on one. + fn dividendCadenceDays(divs: []const Dividend) ?i32 { + // SAFETY: only indices below the count returned by + // `newestExDates` are read. + var dates: [dividend_cadence_gaps + 1]Date = undefined; + const n = newestExDates(divs, &dates); + if (n < dividend_cadence_min_records) return null; + + // SAFETY: only indices below `g` are read. + var gaps: [dividend_cadence_gaps]i32 = undefined; + var g: usize = 0; + while (g + 1 < n) : (g += 1) gaps[g] = dates[g].days - dates[g + 1].days; + std.mem.sort(i32, gaps[0..g], {}, std.sort.asc(i32)); + + const mid = g / 2; + const median = if (g % 2 == 1) gaps[mid] else @divFloor(gaps[mid - 1] + gaps[mid], 2); + // Duplicate ex-dates (a regular and a special on the same day, + // repeated) can median to zero. Zero is not a cadence, and it + // would divide by zero below. + return if (median > 0) median else null; + } + + /// Whether a fresh-in-cache dividend set warrants a re-fetch: true + /// when at least one full cadence period has elapsed since the + /// newest cached ex-date and the distribution that should have + /// landed in it is still absent. + /// + /// This exists because dividend TTL and dividend availability are + /// uncorrelated. An ETF's distribution is not retrievable from the + /// provider until roughly its own ex-date - Fidelity's FDVV + /// publishes a whole-year calendar in February yet had no amount on + /// the day before its September ex-date - and it pays 1-7 days + /// later. So the interval between "the record exists" and "the cash + /// is in the account" is about a week, and any TTL longer than that + /// can be written before the record exists and still be fresh after + /// the payment. Quarter-end clustering makes it systematic: a dozen + /// funds go ex within three days of each other, so one refresh pass + /// puts a dozen entries on the same expiry shelf and every one of + /// them straddles the next quarter. + /// + /// The predicate keys off the failure condition itself - "a + /// distribution I should have seen has not arrived" - rather than + /// off elapsed time since an arbitrary fetch, which is why it fires + /// on exactly the overdue symbols and stays silent on the rest. + /// + /// ROLLS FORWARD over missed periods. Anchoring the chase window on + /// the first expected date after the newest cached record means a + /// symbol two quarters behind is past its window and can never + /// recover; taking the latest expected date at or before `today` + /// keeps it recoverable. And requiring a FULL period to have elapsed + /// is what stops it firing immediately after a successful fetch, + /// when the record it just stored is days old. + /// + /// A newest ex-date in the FUTURE - normal for an issuer like NKE + /// that declares a quarter ahead - yields a negative elapsed and + /// declines, which is correct: nothing is overdue. + fn dividendsNeedRefresh(divs: []const Dividend, today: Date) bool { + const cadence = dividendCadenceDays(divs) orelse return false; + // SAFETY: a non-null cadence guarantees at least one date. + var newest: [1]Date = undefined; + if (newestExDates(divs, &newest) == 0) return false; + + const elapsed = today.days - newest[0].days; + if (elapsed < cadence) return false; + const expected = newest[0].addDays(@divFloor(elapsed, cadence) * cadence); + return today.days - expected.days <= dividend_chase_days; } /// Days after an earnings report date during which a still-missing @@ -4179,6 +4375,619 @@ test "fetchCached offline mode returns stale-cached data" { try std.testing.expectEqual(Source.cached, result.source); } +// ── Schedule-aware dividend refresh ────────────────────────────── +// +// `dividendsNeedRefresh` and its two helpers. The suite is deliberately +// heavy on real-corpus regressions: the predicate replaced a TTL that +// looked adequate on a mid-quarter snapshot and was in fact missing +// roughly half the portfolio's payers across two consecutive weekly +// runs, so the fixtures below are the actual cached ex-dates that +// produced that failure. Symbol identities are public tickers and fund +// distribution schedules - no account data is involved. + +/// Build a dividend slice from ex-dates given as `.{ y, m, d }`. +fn divsFromYmd(comptime dates: anytype) [dates.len]Dividend { + // SAFETY: every element is assigned in the loop below. + var out: [dates.len]Dividend = undefined; + inline for (dates, 0..) |d, i| { + out[i] = .{ .ex_date = Date.fromYmd(d[0], d[1], d[2]), .amount = 1.0, .type = .regular }; + } + return out; +} + +/// A quarterly series of `n` ex-dates ending on `newest`, newest-first. +fn quarterlySeries(comptime n: usize, newest: Date, cadence: i32) [n]Dividend { + // SAFETY: every element is assigned in the loop below. + var out: [n]Dividend = undefined; + for (0..n) |i| { + out[i] = .{ + .ex_date = newest.addDays(-cadence * @as(i32, @intCast(i))), + .amount = 1.0, + .type = .regular, + }; + } + return out; +} + +test "newestExDates: empty input writes nothing" { + var out: [4]Date = @splat(Date.epoch); + try std.testing.expectEqual(@as(usize, 0), DataService.newestExDates(&.{}, &out)); +} + +test "newestExDates: fewer records than the buffer yields them all, descending" { + const divs = divsFromYmd(.{ .{ 2026, 3, 17 }, .{ 2026, 6, 15 }, .{ 2025, 12, 16 } }); + var out: [6]Date = @splat(Date.epoch); + try std.testing.expectEqual(@as(usize, 3), DataService.newestExDates(&divs, &out)); + try std.testing.expect(out[0].eql(Date.fromYmd(2026, 6, 15))); + try std.testing.expect(out[1].eql(Date.fromYmd(2026, 3, 17))); + try std.testing.expect(out[2].eql(Date.fromYmd(2025, 12, 16))); +} + +test "newestExDates: more records than the buffer keeps only the newest" { + // Six years of quarterly history, buffer of three. The oldest + // entries must not displace a newer one, and the newest must not be + // lost to an early-full buffer. + const divs = quarterlySeries(24, Date.fromYmd(2026, 6, 15), 91); + var out: [3]Date = @splat(Date.epoch); + try std.testing.expectEqual(@as(usize, 3), DataService.newestExDates(&divs, &out)); + try std.testing.expect(out[0].eql(Date.fromYmd(2026, 6, 15))); + try std.testing.expect(out[1].eql(Date.fromYmd(2026, 6, 15).addDays(-91))); + try std.testing.expect(out[2].eql(Date.fromYmd(2026, 6, 15).addDays(-182))); +} + +test "newestExDates: input order does not matter" { + // The cache file happens to be newest-first, but `writeSupplement` + // merges two providers' records and nothing in SRF promises an + // order. Ascending and shuffled inputs must agree with descending. + const descending = divsFromYmd(.{ .{ 2026, 6, 15 }, .{ 2026, 3, 17 }, .{ 2025, 12, 16 }, .{ 2025, 9, 16 } }); + const ascending = divsFromYmd(.{ .{ 2025, 9, 16 }, .{ 2025, 12, 16 }, .{ 2026, 3, 17 }, .{ 2026, 6, 15 } }); + const shuffled = divsFromYmd(.{ .{ 2025, 12, 16 }, .{ 2026, 6, 15 }, .{ 2025, 9, 16 }, .{ 2026, 3, 17 } }); + + var a: [4]Date = @splat(Date.epoch); + var b: [4]Date = @splat(Date.epoch); + var c: [4]Date = @splat(Date.epoch); + try std.testing.expectEqual(@as(usize, 4), DataService.newestExDates(&descending, &a)); + try std.testing.expectEqual(@as(usize, 4), DataService.newestExDates(&ascending, &b)); + try std.testing.expectEqual(@as(usize, 4), DataService.newestExDates(&shuffled, &c)); + for (a, b, c) |x, y, z| { + try std.testing.expect(x.eql(y)); + try std.testing.expect(x.eql(z)); + } +} + +test "newestExDates: duplicate ex-dates are both retained" { + // A regular and a special on the same day. Silently collapsing them + // would understate the record count and could flip the + // minimum-records gate. + const divs = divsFromYmd(.{ .{ 2026, 6, 15 }, .{ 2026, 6, 15 }, .{ 2026, 3, 17 } }); + var out: [6]Date = @splat(Date.epoch); + try std.testing.expectEqual(@as(usize, 3), DataService.newestExDates(&divs, &out)); + try std.testing.expect(out[0].eql(Date.fromYmd(2026, 6, 15))); + try std.testing.expect(out[1].eql(Date.fromYmd(2026, 6, 15))); + try std.testing.expect(out[2].eql(Date.fromYmd(2026, 3, 17))); +} + +test "newestExDates: a one-slot buffer yields the single newest" { + const divs = divsFromYmd(.{ .{ 2025, 12, 16 }, .{ 2026, 6, 15 }, .{ 2026, 3, 17 } }); + var out: [1]Date = @splat(Date.epoch); + try std.testing.expectEqual(@as(usize, 1), DataService.newestExDates(&divs, &out)); + try std.testing.expect(out[0].eql(Date.fromYmd(2026, 6, 15))); +} + +test "dividendCadenceDays: below the minimum record count there is no cadence" { + // One gap cannot disagree with anything, so a median over it is a + // guess. Declining hands the symbol to the TTL, which is the safe + // direction for a newly-bought holding. + const none: []const Dividend = &.{}; + try std.testing.expect(DataService.dividendCadenceDays(none) == null); + + const one = divsFromYmd(.{.{ 2026, 6, 15 }}); + try std.testing.expect(DataService.dividendCadenceDays(&one) == null); + + const two = divsFromYmd(.{ .{ 2026, 6, 15 }, .{ 2026, 3, 17 } }); + try std.testing.expect(DataService.dividendCadenceDays(&two) == null); + + // Three is the first count that yields one. + const three = divsFromYmd(.{ .{ 2026, 6, 15 }, .{ 2026, 3, 17 }, .{ 2025, 12, 16 } }); + try std.testing.expect(DataService.dividendCadenceDays(&three) != null); +} + +test "dividendCadenceDays: recognises quarterly, monthly, semi-annual and annual" { + // The four cadences present in a real portfolio. An annual payer + // matters as much as a quarterly one: getting its cadence wrong + // would fire the predicate for eleven months of the year. + const q = quarterlySeries(6, Date.fromYmd(2026, 6, 15), 91); + try std.testing.expectEqual(@as(i32, 91), DataService.dividendCadenceDays(&q).?); + + const m = quarterlySeries(6, Date.fromYmd(2026, 9, 1), 30); + try std.testing.expectEqual(@as(i32, 30), DataService.dividendCadenceDays(&m).?); + + const semi = quarterlySeries(4, Date.fromYmd(2026, 6, 15), 181); + try std.testing.expectEqual(@as(i32, 181), DataService.dividendCadenceDays(&semi).?); + + const annual = quarterlySeries(3, Date.fromYmd(2025, 12, 12), 364); + try std.testing.expectEqual(@as(i32, 364), DataService.dividendCadenceDays(&annual).?); +} + +test "dividendCadenceDays: median absorbs a single outlier gap" { + // The case that rules out both the mean and the minimum. A special + // distribution three days after a regular one creates one tiny gap. + // The minimum would read the cadence as 3 and fire for the whole + // chase window after every payment; the mean would be dragged low. + // Gaps here are 3, 91, 91, 91, 91 -> median 91. + var divs = [_]Dividend{ + .{ .ex_date = Date.fromYmd(2026, 6, 18), .amount = 2.0, .type = .special }, + .{ .ex_date = Date.fromYmd(2026, 6, 15), .amount = 1.0, .type = .regular }, + .{ .ex_date = Date.fromYmd(2026, 3, 16), .amount = 1.0, .type = .regular }, + .{ .ex_date = Date.fromYmd(2025, 12, 15), .amount = 1.0, .type = .regular }, + .{ .ex_date = Date.fromYmd(2025, 9, 15), .amount = 1.0, .type = .regular }, + .{ .ex_date = Date.fromYmd(2025, 6, 16), .amount = 1.0, .type = .regular }, + }; + try std.testing.expectEqual(@as(i32, 91), DataService.dividendCadenceDays(divs[0..]).?); +} + +test "dividendCadenceDays: an even gap count averages the two middles" { + // Four records -> three gaps (odd, true middle). Five -> four gaps + // (even, floored average). Both branches exercised explicitly + // because an off-by-one in the median index is silent. + const odd_gaps = divsFromYmd(.{ .{ 2026, 6, 15 }, .{ 2026, 3, 16 }, .{ 2025, 12, 15 }, .{ 2025, 9, 15 } }); + // Gaps: 91, 91, 91. + try std.testing.expectEqual(@as(i32, 91), DataService.dividendCadenceDays(&odd_gaps).?); + + // Gaps 10, 20, 30, 40 -> sorted the two middles are 20 and 30 -> + // floored average 25. + var uneven = [_]Dividend{ + .{ .ex_date = Date.epoch.addDays(100), .amount = 1.0 }, + .{ .ex_date = Date.epoch.addDays(90), .amount = 1.0 }, + .{ .ex_date = Date.epoch.addDays(70), .amount = 1.0 }, + .{ .ex_date = Date.epoch.addDays(40), .amount = 1.0 }, + .{ .ex_date = Date.epoch, .amount = 1.0 }, + }; + try std.testing.expectEqual(@as(i32, 25), DataService.dividendCadenceDays(uneven[0..]).?); +} + +test "dividendCadenceDays: all-duplicate ex-dates yield no cadence, not a zero" { + // A zero cadence is not a schedule, and it would divide by zero in + // `dividendsNeedRefresh`. This is the guard for that. + const divs = divsFromYmd(.{ .{ 2026, 6, 15 }, .{ 2026, 6, 15 }, .{ 2026, 6, 15 }, .{ 2026, 6, 15 } }); + try std.testing.expect(DataService.dividendCadenceDays(&divs) == null); +} + +test "dividendCadenceDays: only the newest gaps count, so old history cannot drag it" { + // Monthly for the last six records, quarterly before that. The + // window must read 30, not something between. + var divs = [_]Dividend{ + .{ .ex_date = Date.fromYmd(2026, 9, 1), .amount = 1.0 }, + .{ .ex_date = Date.fromYmd(2026, 8, 2), .amount = 1.0 }, + .{ .ex_date = Date.fromYmd(2026, 7, 3), .amount = 1.0 }, + .{ .ex_date = Date.fromYmd(2026, 6, 3), .amount = 1.0 }, + .{ .ex_date = Date.fromYmd(2026, 5, 4), .amount = 1.0 }, + .{ .ex_date = Date.fromYmd(2026, 4, 4), .amount = 1.0 }, + // Quarterly history further back - outside the gap window. + .{ .ex_date = Date.fromYmd(2026, 1, 3), .amount = 1.0 }, + .{ .ex_date = Date.fromYmd(2025, 10, 4), .amount = 1.0 }, + .{ .ex_date = Date.fromYmd(2025, 7, 5), .amount = 1.0 }, + }; + try std.testing.expectEqual(@as(i32, 30), DataService.dividendCadenceDays(divs[0..]).?); +} + +test "dividendsNeedRefresh: declines when there is no cadence to be late against" { + const today = Date.fromYmd(2026, 9, 19); + const none: []const Dividend = &.{}; + try std.testing.expect(!DataService.dividendsNeedRefresh(none, today)); + + // Two records: a gap exists but no median does. + const two = divsFromYmd(.{ .{ 2026, 3, 17 }, .{ 2025, 12, 16 } }); + try std.testing.expect(!DataService.dividendsNeedRefresh(&two, today)); + + // Duplicates: three records, zero cadence. + const dup = divsFromYmd(.{ .{ 2026, 3, 17 }, .{ 2026, 3, 17 }, .{ 2026, 3, 17 } }); + try std.testing.expect(!DataService.dividendsNeedRefresh(&dup, today)); +} + +test "dividendsNeedRefresh: a full cadence period must elapse before it fires" { + // The property that stops it refiring immediately after a + // successful fetch, when the record it just stored is days old. + const newest = Date.fromYmd(2026, 6, 15); + const divs = quarterlySeries(6, newest, 91); + + // Day of, mid-period, and the last day before due: all quiet. + try std.testing.expect(!DataService.dividendsNeedRefresh(&divs, newest)); + try std.testing.expect(!DataService.dividendsNeedRefresh(&divs, newest.addDays(45))); + try std.testing.expect(!DataService.dividendsNeedRefresh(&divs, newest.addDays(90))); + + // Exactly one cadence period: due. + try std.testing.expect(DataService.dividendsNeedRefresh(&divs, newest.addDays(91))); +} + +test "dividendsNeedRefresh: the chase window has both bounds" { + const newest = Date.fromYmd(2026, 6, 15); + const divs = quarterlySeries(6, newest, 91); + const chase = DataService.dividend_chase_days; + + // Last day inside the window fires; the next day does not. Past it + // the sponsor is off-schedule and the TTL takes over - an unbounded + // chase would refetch forever on a wrong cadence estimate. + try std.testing.expect(DataService.dividendsNeedRefresh(&divs, newest.addDays(91 + chase))); + try std.testing.expect(!DataService.dividendsNeedRefresh(&divs, newest.addDays(91 + chase + 1))); +} + +test "dividendsNeedRefresh: rolls forward so a long-neglected symbol stays recoverable" { + // Anchoring the chase window on the FIRST expected date after the + // newest cached record means a symbol two periods behind is past its + // window forever and the predicate can never recover it. Taking the + // latest expected date at or before today fixes that. + const newest = Date.fromYmd(2026, 3, 17); + const divs = quarterlySeries(6, newest, 91); + + // Two periods on: due again, and within the second window. + try std.testing.expect(DataService.dividendsNeedRefresh(&divs, newest.addDays(182))); + try std.testing.expect(DataService.dividendsNeedRefresh(&divs, newest.addDays(182 + 5))); + + // Eight periods on, just after that period's expected date. + try std.testing.expect(DataService.dividendsNeedRefresh(&divs, newest.addDays(91 * 8 + 2))); +} + +test "dividendsNeedRefresh: quiet in the dead zone between two expected dates" { + // The other half of rolling forward. Between periods there is + // nothing outstanding, and firing there would spend a request per + // run for no possible gain. + const newest = Date.fromYmd(2026, 3, 17); + const divs = quarterlySeries(6, newest, 91); + const chase = DataService.dividend_chase_days; + + // One period + past the window, but before the second period. + try std.testing.expect(!DataService.dividendsNeedRefresh(&divs, newest.addDays(91 + chase + 1))); + try std.testing.expect(!DataService.dividendsNeedRefresh(&divs, newest.addDays(150))); + // ...and it wakes up again at the second period. + try std.testing.expect(DataService.dividendsNeedRefresh(&divs, newest.addDays(182))); +} + +test "dividendsNeedRefresh: a forward-declared ex-date is not overdue" { + // NKE's shape - it declares roughly a quarter ahead, so its newest + // cached ex-date is normally in the future. A negative elapsed must + // read as "nothing outstanding", not wrap into a fire. + const divs = divsFromYmd(.{ .{ 2026, 12, 1 }, .{ 2026, 9, 1 }, .{ 2026, 6, 1 }, .{ 2026, 3, 2 } }); + try std.testing.expect(!DataService.dividendsNeedRefresh(&divs, Date.fromYmd(2026, 9, 19))); + try std.testing.expect(!DataService.dividendsNeedRefresh(&divs, Date.fromYmd(2026, 10, 1))); +} + +test "dividendsNeedRefresh: regression - the real corpus that a 14-day TTL missed" { + // Cached ex-dates as they actually stood on 2026-09-19, when four + // symbols had a distribution that had already paid and a fresh cache + // that did not contain it. Public tickers and fund schedules only. + const today = Date.fromYmd(2026, 9, 19); + + // Overdue: newest cached ex-date is a full quarter old and the + // September distribution is absent. + const soxx = quarterlySeries(6, Date.fromYmd(2026, 6, 15), 91); + const fdvv = quarterlySeries(6, Date.fromYmd(2026, 6, 18), 91); + const hfxi = quarterlySeries(6, Date.fromYmd(2026, 6, 18), 91); + const spym = quarterlySeries(6, Date.fromYmd(2026, 6, 12), 91); + try std.testing.expect(DataService.dividendsNeedRefresh(&soxx, today)); + try std.testing.expect(DataService.dividendsNeedRefresh(&fdvv, today)); + try std.testing.expect(DataService.dividendsNeedRefresh(&hfxi, today)); + try std.testing.expect(DataService.dividendsNeedRefresh(&spym, today)); + + // Not yet due on that date - their ex-dates were still days away. + // Firing on these would be the false-positive cost of the mechanism, + // and there is none. + const rsp = quarterlySeries(6, Date.fromYmd(2026, 6, 22), 91); + const xmmo = quarterlySeries(6, Date.fromYmd(2026, 6, 22), 91); + const xlv = quarterlySeries(6, Date.fromYmd(2026, 6, 22), 91); + const idmo = quarterlySeries(6, Date.fromYmd(2026, 6, 22), 91); + const qqq = quarterlySeries(6, Date.fromYmd(2026, 6, 22), 91); + const qtum = quarterlySeries(6, Date.fromYmd(2026, 6, 24), 91); + const schd = quarterlySeries(6, Date.fromYmd(2026, 6, 24), 91); + const frdm = quarterlySeries(6, Date.fromYmd(2026, 6, 29), 91); + const ivlu = quarterlySeries(4, Date.fromYmd(2026, 6, 15), 181); + const sphy = quarterlySeries(6, Date.fromYmd(2026, 9, 1), 30); + const nvda = quarterlySeries(6, Date.fromYmd(2026, 9, 10), 92); + const fdscx = quarterlySeries(3, Date.fromYmd(2025, 12, 12), 364); + for ([_][]const Dividend{ &rsp, &xmmo, &xlv, &idmo, &qqq, &qtum, &schd, &frdm, &ivlu, &sphy, &nvda, &fdscx }) |corpus| { + try std.testing.expect(!DataService.dividendsNeedRefresh(corpus, today)); + } +} + +test "dividendsNeedRefresh: regression - the quarter-end cluster one week on" { + // The same corpus at the following weekly run. Six funds went ex + // 2026-09-21..09-23 and paid within days; their caches were all + // written in the same mid-September pass and so all expired well + // after the payments. This is the cluster the TTL cannot see. + const next_week = Date.fromYmd(2026, 9, 26); + + const rsp = quarterlySeries(6, Date.fromYmd(2026, 6, 22), 91); + const xmmo = quarterlySeries(6, Date.fromYmd(2026, 6, 22), 91); + const xlv = quarterlySeries(6, Date.fromYmd(2026, 6, 22), 91); + const idmo = quarterlySeries(6, Date.fromYmd(2026, 6, 22), 91); + const qqq = quarterlySeries(6, Date.fromYmd(2026, 6, 22), 91); + const qtum = quarterlySeries(6, Date.fromYmd(2026, 6, 24), 91); + const schd = quarterlySeries(6, Date.fromYmd(2026, 6, 24), 91); + for ([_][]const Dividend{ &rsp, &xmmo, &xlv, &idmo, &qqq, &qtum, &schd }) |corpus| { + try std.testing.expect(DataService.dividendsNeedRefresh(corpus, next_week)); + } + + // A monthly payer that has not reached its next ex-date stays quiet + // even in the same run. + const sphy = quarterlySeries(6, Date.fromYmd(2026, 9, 1), 30); + try std.testing.expect(!DataService.dividendsNeedRefresh(&sphy, next_week)); +} + +test "dividendsNeedRefresh: once the overdue record lands, it goes quiet for a period" { + // Convergence. The whole point is to stop asking as soon as the + // answer arrives - otherwise the mechanism costs a request per run + // forever. + const today = Date.fromYmd(2026, 9, 19); + const before = quarterlySeries(6, Date.fromYmd(2026, 6, 15), 91); + try std.testing.expect(DataService.dividendsNeedRefresh(&before, today)); + + // The refetch stores the 2026-09-15 record. + var after = [_]Dividend{ + .{ .ex_date = Date.fromYmd(2026, 9, 15), .amount = 0.325046, .type = .regular }, + } ++ before; + try std.testing.expect(!DataService.dividendsNeedRefresh(after[0..], today)); + // ...and stays quiet until the next period comes due. + try std.testing.expect(!DataService.dividendsNeedRefresh(after[0..], Date.fromYmd(2026, 12, 1))); + try std.testing.expect(DataService.dividendsNeedRefresh(after[0..], Date.fromYmd(2026, 12, 15))); +} + +test "dividendsNeedRefresh: a monthly payer is due a month on, not a quarter on" { + // SPHY's shape. A cadence read as quarterly would leave two monthly + // distributions unseen, so this pins the short-cadence arithmetic + // rather than trusting the quarterly cases to cover it. + const newest = Date.fromYmd(2026, 9, 1); + const divs = quarterlySeries(6, newest, 30); + try std.testing.expect(!DataService.dividendsNeedRefresh(&divs, newest.addDays(29))); + try std.testing.expect(DataService.dividendsNeedRefresh(&divs, newest.addDays(30))); + // Two months on, rolled forward. + try std.testing.expect(DataService.dividendsNeedRefresh(&divs, newest.addDays(60))); +} + +test "dividendsNeedRefresh: KNOWN LIMITATION - a cadence change forecasts late" { + // Documented, not fixed. A fund that moves quarterly to monthly + // keeps a ~91-day median for several periods, so the predicate does + // not fire until a quarter has passed and the intervening monthly + // distributions are invisible to it. `Ttl.dividends` is what covers + // this, and the trade is deliberate: a more eager statistic (the + // minimum gap) would spend requests on every symbol, every period, + // to protect against a rare event on one. + // + // This test exists so that changing the statistic shows up as a + // deliberate diff here rather than as a silent behaviour change. + const divs = quarterlySeries(6, Date.fromYmd(2026, 6, 15), 91); + + // A monthly schedule would have gone ex on 07-15 and 08-15. The + // predicate is silent through both. + try std.testing.expect(!DataService.dividendsNeedRefresh(&divs, Date.fromYmd(2026, 7, 20))); + try std.testing.expect(!DataService.dividendsNeedRefresh(&divs, Date.fromYmd(2026, 8, 20))); + // It only wakes at the quarterly boundary. + try std.testing.expect(DataService.dividendsNeedRefresh(&divs, Date.fromYmd(2026, 9, 14))); +} + +// ── The refresh hook inside fetchCached ────────────────────────── +// +// These go through the real cache and the real `fetchCached`, so they +// also pin the free-on-discard: `std.testing.allocator` fails the test +// if the discarded entry leaks, and a double free would trip its +// bookkeeping too. +// +// Fixtures are built relative to the actual current day because the hook +// reads the wall clock (see `serveFreshOrDiscard`). Hardcoded dates would +// make these pass or fail depending on when they run. + +/// A quarterly dividend corpus positioned relative to `today`. +/// `age_days` is how old the newest cached ex-date is, so `age_days < +/// 91` is "not due" and `>= 91` is "overdue". +fn corpusAged(today: Date, age_days: i32) [6]Dividend { + return quarterlySeries(6, today.addDays(-age_days), 91); +} + +test "fetchCached hook: a null hook leaves the fresh-cache path untouched" { + // The regression guard for every other type. Splits pass no hook and + // have no `freeSlice`; if the hook branch were not comptime-elided + // this would not compile, and if it were consulted anyway this would + // panic on the network assertion. + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(dir_path); + + var svc = DataService.init(io, allocator, Config{ .cache_dir = dir_path }); + defer svc.deinit(); + + var store = svc.store(); + var splits = [_]Split{.{ .date = Date.fromYmd(2024, 3, 7), .numerator = 3, .denominator = 1 }}; + store.write(Split, "TEST", splits[0..], cache.DataType.splits.ttl()); + + svc.panic_on_network_attempt = true; + const result = try svc.getSplits("TEST", .{}); + defer result.deinit(); + try std.testing.expectEqual(@as(usize, 1), result.data.len); + try std.testing.expectEqual(Source.cached, result.source); +} + +test "fetchCached hook: a fresh, complete dividend cache is still served" { + // Nothing outstanding, so the hook must not discard a usable entry. + // `panic_on_network_attempt` is the assertion: reaching the provider + // at all would be a spurious refetch on every symbol, every run. + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(dir_path); + + var svc = DataService.init(io, allocator, Config{ .cache_dir = dir_path }); + defer svc.deinit(); + + const today = fmt.todayDate(io); + var divs = corpusAged(today, 10); + var store = svc.store(); + store.write(Dividend, "TEST", divs[0..], cache.DataType.dividends.ttl()); + + svc.panic_on_network_attempt = true; + const result = try svc.getDividends("TEST", .{}); + defer result.deinit(); + try std.testing.expectEqual(@as(usize, 6), result.data.len); + try std.testing.expectEqual(Source.cached, result.source); +} + +test "fetchCached hook: skip_network suppresses it rather than discarding for nothing" { + // Offline mode never refetches, so consulting the hook could only + // turn a served result into a failure. Same rule `getEarnings` + // applies. The corpus here IS overdue - without the suppression this + // would discard the entry and then fail. + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(dir_path); + + var svc = DataService.init(io, allocator, Config{ .cache_dir = dir_path }); + defer svc.deinit(); + + const today = fmt.todayDate(io); + var divs = corpusAged(today, 95); + try std.testing.expect(DataService.dividendsNeedRefresh(divs[0..], today)); + + var store = svc.store(); + store.write(Dividend, "TEST", divs[0..], cache.DataType.dividends.ttl()); + + svc.panic_on_network_attempt = true; + const result = try svc.getDividends("TEST", .{ .skip_network = true }); + defer result.deinit(); + try std.testing.expectEqual(@as(usize, 6), result.data.len); + try std.testing.expectEqual(Source.cached, result.source); +} + +test "fetchCached hook: an overdue distribution discards the fresh entry and refetches" { + // The behaviour the whole change exists for. With no Polygon key the + // provider call fails before any network I/O, so FetchFailed is the + // observable proof that the fresh cache was NOT served. If the hook + // did not fire, this would return six cached records instead. + // + // It also pins the free: the discarded slice was allocated by the + // cache read, and `std.testing.allocator` fails the test if it leaks + // or is freed twice. + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(dir_path); + + var svc = DataService.init(io, allocator, Config{ .cache_dir = dir_path }); + defer svc.deinit(); + + const today = fmt.todayDate(io); + var divs = corpusAged(today, 95); + var store = svc.store(); + store.write(Dividend, "TEST", divs[0..], cache.DataType.dividends.ttl()); + + // Sanity: the entry really is fresh, so TTL alone would have served + // it. That is the failure this replaces. + { + const fresh = svc.getCachedDividends(allocator, "TEST") orelse return error.TestUnexpectedResult; + defer fresh.deinit(); + try std.testing.expectEqual(@as(usize, 6), fresh.data.len); + } + + try std.testing.expectError(DataError.FetchFailed, svc.getDividends("TEST", .{})); + + // A transient failure must not poison the cache - the entry is still + // there for the next run, and no negative entry was written. + const after = svc.getCachedDividends(allocator, "TEST") orelse return error.TestUnexpectedResult; + defer after.deinit(); + try std.testing.expectEqual(@as(usize, 6), after.data.len); +} + +test "fetchCached hook: force_refresh never reaches it" { + // force_refresh bypasses the fresh-cache read entirely, so the hook + // is not consulted and cannot double-free the entry the caller + // already skipped. + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(dir_path); + + var svc = DataService.init(io, allocator, Config{ .cache_dir = dir_path }); + defer svc.deinit(); + + const today = fmt.todayDate(io); + // Deliberately NOT due, so any refetch can only be force_refresh's. + var divs = corpusAged(today, 10); + var store = svc.store(); + store.write(Dividend, "TEST", divs[0..], cache.DataType.dividends.ttl()); + + try std.testing.expectError(DataError.FetchFailed, svc.getDividends("TEST", .{ .force_refresh = true })); +} + +test "fetchCached hook: a symbol with too little history is left to the TTL" { + // A newly-bought holding has no cadence, so the hook declines and the + // fresh entry is served. Reaching the provider here would mean every + // new position refetched on every run. + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(dir_path); + + var svc = DataService.init(io, allocator, Config{ .cache_dir = dir_path }); + defer svc.deinit(); + + const today = fmt.todayDate(io); + // Two records, one gap - and deliberately ancient, so the only + // reason not to refetch is the missing cadence. + var divs = [_]Dividend{ + .{ .ex_date = today.addDays(-400), .amount = 1.0, .type = .regular }, + .{ .ex_date = today.addDays(-491), .amount = 1.0, .type = .regular }, + }; + var store = svc.store(); + store.write(Dividend, "TEST", divs[0..], cache.DataType.dividends.ttl()); + + svc.panic_on_network_attempt = true; + const result = try svc.getDividends("TEST", .{}); + defer result.deinit(); + try std.testing.expectEqual(@as(usize, 2), result.data.len); + try std.testing.expectEqual(Source.cached, result.source); +} + +test "fetchCached hook: getOptions passes no hook and is unaffected" { + // The third type routed through `fetchCached`, and a comptime + // combination neither of the others covers: OptionsChain HAS a + // `freeSlice` but passes no hook, where Split has a hook-less type + // with NO `freeSlice`. Both must reach the plain fresh-cache return. + // + // A negative cache entry is the cheapest always-fresh options + // fixture - `Store.read` returns an empty slice for one under + // `.fresh_only` - and the assertion is that we get there without + // touching the network. + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(dir_path); + + var svc = DataService.init(io, allocator, Config{ .cache_dir = dir_path }); + defer svc.deinit(); + + var store = svc.store(); + store.writeNegative("TEST", .options); + + svc.panic_on_network_attempt = true; + const result = try svc.getOptions("TEST", .{}); + defer result.deinit(); + try std.testing.expectEqual(@as(usize, 0), result.data.len); + try std.testing.expectEqual(Source.cached, result.source); +} + test "loadAllDividends: honors skip_network for every symbol, and one miss does not abort the rest" { const allocator = std.testing.allocator; const io = std.testing.io;