cadence aware dividend refreshes (note this will net increase polygon fetching)

This commit is contained in:
Emil Lerch 2026-09-19 07:43:05 -07:00
parent 1c1c6716ee
commit ca3bd11110
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 880 additions and 20 deletions

77
src/cache/store.zig vendored
View file

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

View file

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