split fetchoptions

This commit is contained in:
Emil Lerch 2026-08-03 21:52:36 -07:00
parent ef97842807
commit 0b0c18e267
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 75 additions and 17 deletions

View file

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

View file

@ -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", .{});

View file

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

View file

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

View file

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