cadence aware dividend refreshes (note this will net increase polygon fetching)
This commit is contained in:
parent
1c1c6716ee
commit
ca3bd11110
2 changed files with 880 additions and 20 deletions
77
src/cache/store.zig
vendored
77
src/cache/store.zig
vendored
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
823
src/service.zig
823
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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue