From ea0dd624b3aa6fe05277955ebc0ba2af3232cca0 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 22 Apr 2026 01:15:00 -0700 Subject: [PATCH] enable early skip check when cache is fresh --- src/commands/snapshot.zig | 79 ++++++++++++++++++++++++++++++++++++++- src/service.zig | 13 +++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/commands/snapshot.zig b/src/commands/snapshot.zig index a640afe..ce879e1 100644 --- a/src/commands/snapshot.zig +++ b/src/commands/snapshot.zig @@ -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/-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); diff --git a/src/service.zig b/src/service.zig index 4b1dbe3..cffb20e 100644 --- a/src/service.zig +++ b/src/service.zig @@ -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 {