enable early skip check when cache is fresh

This commit is contained in:
Emil Lerch 2026-04-22 01:15:00 -07:00
parent 6ed2ff1f20
commit ea0dd624b3
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 91 additions and 1 deletions

View file

@ -94,10 +94,38 @@ pub fn run(
return SnapshotError.PortfolioEmpty;
}
// Fetch prices via the shared TTL-driven loader.
const syms = try portfolio.stockSymbols(allocator);
defer allocator.free(syms);
// Early duplicate-skip: if the cache is fully fresh, we can compute
// as_of_date without touching the network or doing a full price load,
// then short-circuit when today's snapshot already exists. Critically,
// this only applies when ALL non-MM symbols have fresh metadata a
// single stale symbol means a refresh might bring forward a newer
// `last_date`, which would change as_of_date and make the existing
// snapshot file no longer a duplicate.
if (!force and out_override == null) {
if (try probeFreshAsOfDate(allocator, svc, syms)) |candidate| {
var cand_buf: [10]u8 = undefined;
const cand_str = candidate.format(&cand_buf);
const candidate_path = try deriveSnapshotPath(allocator, portfolio_path, cand_str);
defer allocator.free(candidate_path);
if (std.fs.cwd().access(candidate_path, .{})) |_| {
var msg_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(
&msg_buf,
"snapshot for {s} already exists: {s} (cache fresh, skipped without refresh)\n",
.{ cand_str, candidate_path },
) catch "snapshot already exists\n";
try cli.stderrPrint(msg);
if (!dry_run) return;
// --dry-run falls through: the user probably wants to see
// what would be written.
} else |_| {}
}
}
// Fetch prices via the shared TTL-driven loader.
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
@ -215,6 +243,44 @@ pub const QuoteDates = struct {
dates: []QuoteInfo,
};
/// Probe the cache to see if we can safely compute `as_of_date` without
/// doing a full price load. Returns the candidate date only if EVERY
/// non-MM held symbol has fresh cache metadata a single stale symbol
/// means a refresh could bring forward a newer `last_date` and change
/// the answer, so we must do the full load in that case.
///
/// This exists purely as a fast path for the duplicate-skip check:
/// callers that get a non-null result may safely consult
/// `history/<date>-portfolio.srf` for an existing file without spending
/// the ~15s network round-trip of `loadPortfolioPrices`.
///
/// MM symbols are allowed to be stale their `last_date` is excluded
/// from the mode calculation anyway.
pub fn probeFreshAsOfDate(
allocator: std.mem.Allocator,
svc: *zfin.DataService,
symbols: []const []const u8,
) !?Date {
if (symbols.len == 0) return null;
var infos = try allocator.alloc(QuoteInfo, symbols.len);
defer allocator.free(infos);
for (symbols, 0..) |sym, idx| {
const is_mm = portfolio_mod.isMoneyMarketSymbol(sym);
// MM symbols are excluded from as_of_date computation regardless
// of freshness, so their TTL state doesn't matter here.
if (!is_mm and !svc.isCandleCacheFresh(sym)) return null;
infos[idx] = .{
.symbol = sym,
.last_date = svc.getCachedLastDate(sym),
.is_money_market = is_mm,
};
}
return computeAsOfDate(infos);
}
/// Gather quote-date info for each symbol from the cache. Does not
/// fetch; relies on whatever the cache has. Symbols with no candles at
/// all get `last_date = null`.
@ -669,6 +735,17 @@ test "computeAsOfDate: empty input returns null" {
try testing.expect(computeAsOfDate(&.{}) == null);
}
test "probeFreshAsOfDate: empty symbol list returns null without touching the service" {
// No symbols means no refresh is needed and no date to compute. A
// null service pointer here would be dereferenced if the function
// touched it, so this also proves the early-return path.
var svc: zfin.DataService = undefined;
// Pointer not dereferenced because the function returns before the
// loop. Using @constCast to produce a pointer of the right type
// without zero-initializing DataService internals.
try testing.expect((try probeFreshAsOfDate(testing.allocator, &svc, &.{})) == null);
}
test "quoteDateRange: min and max skip MM symbols" {
const d_old = Date.fromYmd(2026, 4, 17);
const d_new = Date.fromYmd(2026, 4, 20);

View file

@ -679,6 +679,19 @@ pub const DataService = struct {
return s.readLastClose(symbol);
}
/// Read the latest cached candle date for `symbol` without deserializing
/// the full candle history. Returns null if no cached metadata exists.
///
/// Callers should pair this with `isCandleCacheFresh` before trusting
/// the date: a stale cache entry can return a date from days or weeks
/// ago, which is fine for diagnostics but wrong for anything that
/// needs "the current market date".
pub fn getCachedLastDate(self: *DataService, symbol: []const u8) ?Date {
var s = self.store();
const mr = s.readCandleMeta(symbol) orelse return null;
return mr.meta.last_date;
}
/// Estimate wait time (in seconds) before the next TwelveData API call can proceed.
/// Returns 0 if a request can be made immediately. Returns null if no API key.
pub fn estimateWaitSeconds(self: *DataService) ?u64 {