From 375af77b583c3fb25b7819a8907a098a1fc92782 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 26 Jun 2026 15:57:29 -0700 Subject: [PATCH] tweak to earnings refresh based on data seen in the wild --- src/service.zig | 88 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 8 deletions(-) diff --git a/src/service.zig b/src/service.zig index 2cfb8ed..38caeb1 100644 --- a/src/service.zig +++ b/src/service.zig @@ -934,10 +934,38 @@ pub const DataService = struct { return self.fetchCached(OptionsChain, symbol, null, opts); } + /// Days after an earnings report date during which a still-missing + /// `actual` is worth chasing with a re-fetch. Past this window the + /// gap is treated as permanent (FMP won't backfill it; earnings has + /// no secondary source), so the cache is honored until its TTL. + const earnings_actual_chase_days: i32 = 14; + + /// Whether a fresh-in-cache earnings set warrants a re-fetch: true + /// when an event whose report date has arrived (date <= today) is + /// still missing its `actual` AND the report is recent enough + /// (within `window_days`) that the actual could still post. + /// + /// Earnings has no cross-provider backfill (unlike dividends/splits, + /// which Tiingo supplements via the candle fetch), so a missing + /// actual only arrives through a later FMP fetch. Without the recency + /// bound a permanently-incomplete past row -- e.g. SPY's 2005-2006 + /// estimate-only rows FMP never backfills -- forces a re-fetch every + /// run. The 30-day TTL backstops any actual slower than the window. + fn earningsNeedsRefresh(events: []const EarningsEvent, today: Date, window_days: i32) bool { + for (events) |ev| { + if (ev.actual == null and !today.lessThan(ev.date) and today.days - ev.date.days <= window_days) { + return true; + } + } + return false; + } + /// Fetch earnings history for a symbol. /// Checks cache first; fetches from FMP if stale/missing. - /// Smart refresh: even if cache is fresh, re-fetches when a past earnings - /// date has no actual results yet (i.e. results just came out). + /// Smart refresh: even if cache is fresh, re-fetches when a *recent* + /// past earnings date (within `earnings_actual_chase_days`) still has + /// no actual yet (results just came out). Older gaps are treated as + /// permanent and honored until TTL -- see `earningsNeedsRefresh`. /// /// `opts.skip_network = true` -> returns cached data even if stale, /// returns FetchFailed on cache miss without touching the network. @@ -953,12 +981,12 @@ pub const DataService = struct { if (!opts.force_refresh) { if (s.read(self.allocator, EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| { - // Check if any past/today earnings event is still missing actual results. - // If so, the announcement likely just happened - force a refresh. - // (Suppressed when opts.skip_network - offline mode never refetches.) - const needs_refresh = if (opts.skip_network) false else for (cached.data) |ev| { - if (ev.actual == null and !today.lessThan(ev.date)) break true; - } else false; + // Re-fetch only when a recent report (within the chase + // window) is still missing its actual; older gaps never + // backfill, so honor the cache. Suppressed under + // skip_network (offline mode never refetches). + const needs_refresh = !opts.skip_network and + earningsNeedsRefresh(cached.data, today, earnings_actual_chase_days); if (!needs_refresh) { log.debug("{s}: earnings fresh in local cache", .{symbol}); @@ -4372,3 +4400,47 @@ test "getEtfProfile: carries holding CUSIP through the model boundary" { try std.testing.expectEqualStrings("999999999", holdings[0].cusip orelse return error.NoCusip); try std.testing.expect(holdings[0].symbol == null); // filing had no ticker } + +test "earningsNeedsRefresh: recent missing actual triggers a re-fetch" { + const today = Date.fromYmd(2026, 6, 26); + const events = [_]EarningsEvent{ + .{ .date = Date.fromYmd(2026, 6, 20), .estimate = 1.0 }, // 6 days ago, actual not posted yet + }; + try std.testing.expect(DataService.earningsNeedsRefresh(&events, today, 14)); +} + +test "earningsNeedsRefresh: stale missing actual is NOT chased (the SPY case)" { + const today = Date.fromYmd(2026, 6, 26); + const events = [_]EarningsEvent{ + .{ .date = Date.fromYmd(2006, 5, 15), .estimate = 2.11 }, // ~20y old, FMP never backfills + .{ .date = Date.fromYmd(2005, 2, 15), .actual = 1.81 }, + }; + try std.testing.expect(!DataService.earningsNeedsRefresh(&events, today, 14)); +} + +test "earningsNeedsRefresh: all actuals present -> no re-fetch" { + const today = Date.fromYmd(2026, 6, 26); + const events = [_]EarningsEvent{ + .{ .date = Date.fromYmd(2026, 5, 20), .actual = 1.5 }, + .{ .date = Date.fromYmd(2026, 2, 20), .actual = 1.2 }, + }; + try std.testing.expect(!DataService.earningsNeedsRefresh(&events, today, 14)); +} + +test "earningsNeedsRefresh: upcoming event without actual does not trigger" { + const today = Date.fromYmd(2026, 6, 26); + const events = [_]EarningsEvent{ + .{ .date = Date.fromYmd(2026, 8, 26), .estimate = 2.0 }, // future report, no actual yet + }; + try std.testing.expect(!DataService.earningsNeedsRefresh(&events, today, 14)); +} + +test "earningsNeedsRefresh: chase window is inclusive at the boundary" { + const today = Date.fromYmd(2026, 6, 26); + // Exactly 14 days ago -> still chased. + const at_window = [_]EarningsEvent{.{ .date = Date.fromYmd(2026, 6, 12), .estimate = 1.0 }}; + try std.testing.expect(DataService.earningsNeedsRefresh(&at_window, today, 14)); + // 15 days ago -> past the window, left alone. + const past_window = [_]EarningsEvent{.{ .date = Date.fromYmd(2026, 6, 11), .estimate = 1.0 }}; + try std.testing.expect(!DataService.earningsNeedsRefresh(&past_window, today, 14)); +}