From 0b0c18e2679b94316144087ce92df94062b655ef Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 3 Aug 2026 21:52:36 -0700 Subject: [PATCH] split fetchoptions --- src/PortfolioData.zig | 2 +- src/commands/audit/hygiene.zig | 2 +- src/commands/common.zig | 2 +- src/commands/snapshot.zig | 5 ++- src/portfolio_loader.zig | 81 +++++++++++++++++++++++++++++----- 5 files changed, 75 insertions(+), 17 deletions(-) diff --git a/src/PortfolioData.zig b/src/PortfolioData.zig index 86731b1..392a350 100644 --- a/src/PortfolioData.zig +++ b/src/PortfolioData.zig @@ -656,7 +656,7 @@ pub fn load( // Opt-in split adjustment before positions are aggregated, so the // TUI's positions and valuation carry effective shares. No-op // unless `splits_current_through` is set in metadata.srf. - portfolio_loader.enrichLotsSplits(self.svc, gpa, pf.lots, syms, self.paths[0], today); + portfolio_loader.enrichLotsSplits(self.svc, gpa, pf.lots, syms, self.paths[0], today, self.fetch_opts); const positions = pf.positions(today, gpa) catch return error.NoAllocations; defer gpa.free(positions); diff --git a/src/commands/audit/hygiene.zig b/src/commands/audit/hygiene.zig index c4e454d..b73780d 100644 --- a/src/commands/audit/hygiene.zig +++ b/src/commands/audit/hygiene.zig @@ -1346,7 +1346,7 @@ pub fn runHygieneCheck( // split. Cache-only detection; silent when everything is handled. if (portfolio.stockSymbols(allocator)) |split_syms| { defer allocator.free(split_syms); - const unhandled = cli.findUnhandledSplits(svc, allocator, portfolio.lots, split_syms, portfolio_path, as_of); + const unhandled = cli.findUnhandledSplits(svc, allocator, portfolio.lots, split_syms, portfolio_path, as_of, cli.fetchOptionsFromPolicy(refresh)); defer allocator.free(unhandled); if (unhandled.len > 0) { try out.print("\n", .{}); diff --git a/src/commands/common.zig b/src/commands/common.zig index 07d8d04..787ff71 100644 --- a/src/commands/common.zig +++ b/src/commands/common.zig @@ -428,7 +428,7 @@ pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio // `splits_current_through` in metadata.srf. No-op otherwise, so // every existing portfolio behaves exactly as before. if (ctx.svc) |svc| { - portfolio_loader.applySplitAdjustment(svc, ctx.allocator, &loaded, as_of); + portfolio_loader.applySplitAdjustment(svc, ctx.allocator, &loaded, as_of, fetchOptionsFromPolicy(ctx.globals.refresh_policy)); } return loaded; diff --git a/src/commands/snapshot.zig b/src/commands/snapshot.zig index 619b9e0..731b583 100644 --- a/src/commands/snapshot.zig +++ b/src/commands/snapshot.zig @@ -336,7 +336,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { } // Build and render the snapshot. - var snap = try captureSnapshot(io, allocator, &portfolio, portfolio_path, svc, prices, symbol_prices, syms, as_of, qdates, now_s); + var snap = try captureSnapshot(io, allocator, &portfolio, portfolio_path, svc, prices, symbol_prices, syms, as_of, qdates, now_s, cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy)); defer snap.deinit(allocator); const rendered = try renderSnapshot(allocator, snap); @@ -678,6 +678,7 @@ fn captureSnapshot( as_of: Date, qdates: QuoteDates, now_s: i64, + fetch_opts: zfin.FetchOptions, ) !Snapshot { // Use `positionsAsOf(as_of)` rather than `positions()` so historical // backfills correctly count lots that were held on `as_of` @@ -688,7 +689,7 @@ fn captureSnapshot( // Effective shares flow into the summary (via positionsAsOf) and // into each per-lot `value` (via marketValue); the stored `.shares` // field stays RAW - a snapshot is a frozen historical record. - portfolio_loader.enrichLotsSplits(svc, allocator, portfolio.lots, syms, portfolio_path, as_of); + portfolio_loader.enrichLotsSplits(svc, allocator, portfolio.lots, syms, portfolio_path, as_of, fetch_opts); const positions = try portfolio.positionsAsOf(allocator, as_of); defer allocator.free(positions); diff --git a/src/portfolio_loader.zig b/src/portfolio_loader.zig index 778fdeb..0716e52 100644 --- a/src/portfolio_loader.zig +++ b/src/portfolio_loader.zig @@ -550,6 +550,11 @@ pub fn buildPortfolioData( /// cutovers and the fetched split corpus. A no-op (and skips the corpus /// fetch entirely) when no symbol has opted in. /// +/// `opts` is the caller's cache policy and must be threaded from the +/// invocation's `--refresh-data` setting. It used to be hardcoded to +/// defaults here, which silently fetched splits over the network even under +/// `--refresh-data=never`. +/// /// This mutates only `lots[].split_factor`; callers that hold an /// already-computed positions slice must recompute it afterward (the /// CLI wrapper `applySplitAdjustment` does this). Used directly by the @@ -565,12 +570,13 @@ pub fn enrichLotsSplits( syms: []const []const u8, anchor_path: []const u8, as_of: zfin.Date, + opts: zfin.FetchOptions, ) void { var cutovers = svc.loadSplitsCutovers(allocator, anchor_path); defer freeCutovers(allocator, &cutovers); if (cutovers.count() == 0) return; // nothing opted in -> skip the corpus fetch - var corpus = svc.loadAllSplits(allocator, syms, .{}); + var corpus = svc.loadAllSplits(allocator, syms, opts); defer { var it = corpus.valueIterator(); while (it.next()) |v| allocator.free(v.*); @@ -599,14 +605,24 @@ pub const SplitNudge = struct { }; /// Collect every held stock symbol that has NOT opted into split -/// adjustment yet has a split after a lot's purchase date, in the CACHED -/// split data (no network). One entry per symbol (first qualifying -/// split). Powers the `audit` hygiene "unhandled stock splits" section. +/// adjustment yet has a split after a lot's purchase date. One entry per +/// symbol (first qualifying split). Powers the `audit` hygiene "unhandled +/// stock splits" section. /// /// Caller owns the returned slice (`allocator.free` it); each `symbol` /// borrows from `lots`, so use the result before the portfolio is freed. -/// Cheap: cache-only reads. Returns an empty slice on any allocation -/// failure (hygiene is best-effort). +/// Returns an empty slice on any allocation failure (hygiene is +/// best-effort). +/// +/// `opts` is the caller's cache policy. This used to hardcode +/// `skip_network`, which made the check cheap but meant flagless `zfin +/// audit` - which never warms the split cache, since it returns into +/// hygiene before `applySplitAdjustment` runs - reported findings from +/// whatever stale data happened to be on disk, and logged a +/// "stale-cached returned (skip_network)" line per symbol while doing it. +/// A hygiene check that tells you your data is stale instead of +/// refreshing it is the wrong trade; pass the invocation's real policy so +/// `--refresh-data=never` still gets cache-only behavior on request. pub fn findUnhandledSplits( svc: *zfin.DataService, allocator: std.mem.Allocator, @@ -614,11 +630,12 @@ pub fn findUnhandledSplits( syms: []const []const u8, anchor_path: []const u8, as_of: zfin.Date, + opts: zfin.FetchOptions, ) []SplitNudge { var cutovers = svc.loadSplitsCutovers(allocator, anchor_path); defer freeCutovers(allocator, &cutovers); - var corpus = svc.loadAllSplits(allocator, syms, .{ .skip_network = true }); + var corpus = svc.loadAllSplits(allocator, syms, opts); defer { var it = corpus.valueIterator(); while (it.next()) |v| allocator.free(v.*); @@ -666,8 +683,9 @@ pub fn applySplitAdjustment( allocator: std.mem.Allocator, loaded: *LoadedPortfolio, as_of: zfin.Date, + opts: zfin.FetchOptions, ) void { - enrichLotsSplits(svc, allocator, loaded.portfolio.lots, loaded.syms, loaded.anchor(), as_of); + enrichLotsSplits(svc, allocator, loaded.portfolio.lots, loaded.syms, loaded.anchor(), as_of, opts); // Positions were aggregated from raw lots at load time; recompute // from the (now possibly enriched) lots. On failure, keep the @@ -979,7 +997,7 @@ test "applySplitAdjustment: end-to-end enriches loaded positions from seeded cac // Before enrichment: raw 100 shares. try testing.expectApproxEqAbs(@as(f64, 100), loaded.positions[0].shares, 0.001); - applySplitAdjustment(&svc, allocator, &loaded, zfin.Date.fromYmd(2026, 1, 1)); + applySplitAdjustment(&svc, allocator, &loaded, zfin.Date.fromYmd(2026, 1, 1), .{ .skip_network = true }); // After: 100 * 10 = 1000 effective shares; factor stamped on the lot; // cost basis stays invariant (100 * 40 = 4000). @@ -1009,13 +1027,52 @@ test "applySplitAdjustment: no cutover is a no-op (raw shares preserved)" { return error.TestUnexpectedResult; defer loaded.deinit(allocator); - applySplitAdjustment(&svc, allocator, &loaded, zfin.Date.fromYmd(2026, 1, 1)); + applySplitAdjustment(&svc, allocator, &loaded, zfin.Date.fromYmd(2026, 1, 1), .{ .skip_network = true }); // Opt-in off -> factor stays 1.0, shares stay raw. try testing.expectApproxEqAbs(@as(f64, 1.0), loaded.portfolio.lots[0].split_factor, 0.001); try testing.expectApproxEqAbs(@as(f64, 100), loaded.positions[0].shares, 0.001); } +test "enrichLotsSplits: skip_network is honored even when the split cache is stale" { + // Regression: this used to hardcode default FetchOptions, so + // `--refresh-data=never` still hit the network for splits. A FRESH cache + // cannot catch that (fetchCached short-circuits before any network), so + // the seeded entry is deliberately expired to force the decision point. + const allocator = testing.allocator; + const io = testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir = try seedSplitFixture(io, &tmp, &path_buf, "symbol::NVDA,splits_current_through::2024-01-01\n"); + + // Re-seed the same split with a long-expired TTL. + { + var store = zfin.cache.Store.init(io, allocator, dir); + var splits = [_]zfin.Split{.{ .date = zfin.Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }}; + store.write(zfin.Split, "NVDA", splits[0..], .{ .seconds = -1_000_000 }); + } + + var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir }); + defer svc.deinit(); + + const pf_path = try std.fs.path.join(allocator, &.{ dir, "zfintest_split_pf.srf" }); + defer allocator.free(pf_path); + const paths = try allocator.dupe([]const u8, &.{pf_path}); + defer allocator.free(paths); + var loaded = loadPortfolioFromPaths(io, allocator, paths, zfin.Date.fromYmd(2026, 1, 1)) orelse + return error.TestUnexpectedResult; + defer loaded.deinit(allocator); + + // Any provider or server call from here on is a policy violation. + svc.panic_on_network_attempt = true; + applySplitAdjustment(&svc, allocator, &loaded, zfin.Date.fromYmd(2026, 1, 1), .{ .skip_network = true }); + + // Offline still applies the stale split it already had: 100 -> 1000. + try testing.expectApproxEqAbs(@as(f64, 1000), loaded.positions[0].shares, 0.001); +} + test "findUnhandledSplits: flags post-purchase splits for un-opted-in symbols only" { const allocator = testing.allocator; const io = testing.io; @@ -1037,7 +1094,7 @@ test "findUnhandledSplits: flags post-purchase splits for un-opted-in symbols on const syms = [_][]const u8{"NVDA"}; // Opt-in off + a split after purchase -> one finding. - const found = findUnhandledSplits(&svc, allocator, &lots, &syms, anchor, zfin.Date.fromYmd(2026, 1, 1)); + const found = findUnhandledSplits(&svc, allocator, &lots, &syms, anchor, zfin.Date.fromYmd(2026, 1, 1), .{ .skip_network = true }); defer allocator.free(found); try testing.expectEqual(@as(usize, 1), found.len); try testing.expectEqualStrings("NVDA", found[0].symbol); @@ -1047,7 +1104,7 @@ test "findUnhandledSplits: flags post-purchase splits for un-opted-in symbols on var lots_after = [_]zfin.Lot{ .{ .symbol = "NVDA", .shares = 5, .open_date = zfin.Date.fromYmd(2025, 1, 1), .open_price = 120 }, }; - const none = findUnhandledSplits(&svc, allocator, &lots_after, &syms, anchor, zfin.Date.fromYmd(2026, 1, 1)); + const none = findUnhandledSplits(&svc, allocator, &lots_after, &syms, anchor, zfin.Date.fromYmd(2026, 1, 1), .{ .skip_network = true }); defer allocator.free(none); try testing.expectEqual(@as(usize, 0), none.len); }