tweak to earnings refresh based on data seen in the wild

This commit is contained in:
Emil Lerch 2026-06-26 15:57:29 -07:00
parent 120c51bce4
commit 375af77b58
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

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