diff --git a/src/commands/audit/hygiene.zig b/src/commands/audit/hygiene.zig index 30e851b..8bfb869 100644 --- a/src/commands/audit/hygiene.zig +++ b/src/commands/audit/hygiene.zig @@ -1136,17 +1136,33 @@ pub fn runHygieneCheck( // ── Section 5: Auto-reconcile discovered files ── + // Section 5's price load, kept for Section 6: the large-lot check + // needs prices for the same symbols, and loading them again re-scanned + // the candle cache and printed a second "Loaded N symbols" line - one + // that landed in the middle of the reconciliation table. Stays null + // when no export was discovered, and Section 6 loads its own. + var recon_syms: [][]const u8 = &.{}; + defer allocator.free(recon_syms); + var recon_load: ?zfin.DataService.LoadAllResult = null; + defer if (recon_load) |*r| r.deinit(); + if (all_files.items.len > 0) { - // Build prices map (shared by all reconciliations) + // Flush before anything that writes to stderr. Audit, unlike the + // commands that load prices before printing, has already + // buffered several sections by now; stderr is unbuffered, so its + // lines otherwise land wherever the 4 KB stdout buffer last + // happened to fill - mid-line, mid-table. + try out.flush(); + + // Build prices map (shared by all reconciliations): the loader's + // closes, then manual prices for anything it didn't cover. var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); { - const pos_syms = try portfolio.stockSymbols(allocator); - defer allocator.free(pos_syms); - if (pos_syms.len > 0) { - var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, refresh, color); - defer load_result.deinit(); - var pit = load_result.prices.iterator(); + recon_syms = try portfolio.stockSymbols(allocator); + if (recon_syms.len > 0) { + recon_load = cli.loadPortfolioPrices(io, svc, recon_syms, &.{}, refresh, color); + var pit = recon_load.?.prices.iterator(); while (pit.next()) |entry| { try prices.put(entry.key_ptr.*, entry.value_ptr.*); } @@ -1295,7 +1311,14 @@ pub fn runHygieneCheck( // (not in a git repo). Threshold is per-account: an account's // `audit_large_lot_threshold` in accounts.srf wins, otherwise the // filter's built-in default applies. - if (try contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_paths, &account_map, as_of, color, refresh)) |found| { + // + // Reuses Section 5's prices; the pipeline loads only the symbols that + // covers nothing for (lots sold since HEAD, sibling portfolio files). + // Flush first, as before Section 5's load: that top-up, and the + // snapshot-anchor note, go to stderr. + try out.flush(); + const known: ?contributions.KnownPrices = if (recon_load) |*r| .{ .requested = recon_syms, .closes = &r.prices } else null; + if (try contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_paths, &account_map, as_of, color, refresh, known)) |found| { var found_mut = found; defer found_mut.deinit(); @@ -2809,19 +2832,19 @@ test "runHygieneCheck: discovered exports set unmapped accounts aside unless one try std.testing.expect(std.mem.indexOf(u8, out, " schwab summary: 1 account not in accounts.srf, not reconciled\n Sample Account #9012\n") != null); } -test "runHygieneCheck: one bad account in an all-accounts export tables only that account unless verbose" { - const allocator = std.testing.allocator; - const io = std.testing.io; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - try tmp.dir.writeFile(io, .{ +/// Two Schwab accounts in one all-accounts export, written into `dir`: +/// "Sample IRA" (#1234) reconciles clean, "Sample Roth" (#5678) is 10 +/// SCHD shares short. Discovered from the portfolio's own `audit/` +/// directory. `vti_extra` is appended to the VTI lot's record (e.g. a +/// typo'd field, to make Section 8 report something). +fn writeTwoAccountFixture(io: std.Io, dir: std.Io.Dir, comptime vti_extra: []const u8) !void { + try dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = "#!srfv1\n" ++ - "symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:200,price:num:300,account::Sample IRA\n" ++ + "symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:200,price:num:300,account::Sample IRA" ++ vti_extra ++ "\n" ++ "symbol::SCHD,shares:num:200,open_date::2024-01-15,open_price:num:25,price:num:30,account::Sample Roth\n", }); - try tmp.dir.writeFile(io, .{ + try dir.writeFile(io, .{ .sub_path = "accounts.srf", .data = "#!srfv1\n" ++ "account::Sample IRA,tax_type::traditional,institution::schwab,account_number::1234\n" ++ @@ -2832,13 +2855,68 @@ test "runHygieneCheck: one bad account in an all-accounts export tables only tha const vti = "\"VTI\",\"VANGUARD TOTAL STOCK MARKET ETF\",\"1.10\",\"0.37%\",\"300.00\",\"100\",\"$110.00\",\"0.37%\",\"$30,000.00\",\"$20,000.00\",\"$10,000.00\",\"50%\",\"--\",\"No\",\"N/A\",\"100%\",\"ETFs & Closed End Funds\",\n"; // 210 shares at the broker vs 200 in the portfolio. const schd = "\"SCHD\",\"SCHWAB US DIVIDEND EQUITY ETF\",\"0.10\",\"0.33%\",\"30.00\",\"210\",\"$21.00\",\"0.33%\",\"$6,300.00\",\"$5,250.00\",\"$1,050.00\",\"20%\",\"--\",\"No\",\"N/A\",\"100%\",\"ETFs & Closed End Funds\",\n"; - try tmp.dir.createDirPath(io, "audit"); - try tmp.dir.writeFile(io, .{ + try dir.createDirPath(io, "audit"); + try dir.writeFile(io, .{ .sub_path = "audit/all.csv", .data = "\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n" ++ "\"Sample_IRA ...1234\"\n" ++ header ++ vti ++ "\n\n" ++ "\"Sample_Roth ...5678\"\n" ++ header ++ schd, }); +} + +test "runHygieneCheck: reconciliation prices come from the shared load" { + // The other fixtures price every lot by hand, which `stockSymbols` + // excludes, so Section 5 never actually loads. Here VTI has no manual + // price and only a cached close: the IRA reconciles clean only if the + // load ran and its result reached the comparison. (The same result + // is what Section 6 reuses; that hand-off is pinned in + // contributions' "values share changes at the caller's closes".) + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try writeTwoAccountFixture(io, tmp.dir, ""); + try tmp.dir.writeFile(io, .{ + .sub_path = "portfolio.srf", + .data = "#!srfv1\n" ++ + "symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:200,account::Sample IRA\n" ++ + "symbol::SCHD,shares:num:200,open_date::2024-01-15,open_price:num:25,price:num:30,account::Sample Roth\n", + }); + // `{cache_dir}/{SYMBOL}/candles_meta.srf`, unexpired: a fresh cache + // hit. `provider` has no default, so a meta without it is a miss. + try tmp.dir.createDirPath(io, "VTI"); + try tmp.dir.writeFile(io, .{ .sub_path = "VTI/candles_meta.srf", .data = "#!srfv1\n#!expires=99999999999\nlast_close:num:300,last_date::2026-10-05,provider::tiingo\n" }); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf); + const dir = path_buf[0..dir_len]; + + var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir }); + defer svc.deinit(); + var env = try std.testing.environ.createMap(allocator); + defer env.deinit(); + const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" }); + defer allocator.free(pf_path); + + // wall-clock required: discovery keeps only exports modified in the + // last 24 hours, judged against the real mtimes of the files above. + const now_s = std.Io.Timestamp.now(io, .real).toSeconds(); + + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, false, zfin.Date.fromYmd(2026, 10, 6), now_s, false, .never, &aw.writer); + const out = aw.written(); + + try std.testing.expect(std.mem.indexOf(u8, out, "\n 1 more account, no discrepancies: Sample IRA\n") != null); + try std.testing.expect(std.mem.indexOf(u8, out, " Total: portfolio $36,000.00 brokerage $36,300.00") != null); +} + +test "runHygieneCheck: one bad account in an all-accounts export tables only that account unless verbose" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try writeTwoAccountFixture(io, tmp.dir, ""); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf); @@ -2886,3 +2964,101 @@ test "runHygieneCheck: one bad account in an all-accounts export tables only tha try std.testing.expect(std.mem.indexOf(u8, out, " Total: portfolio $36,000.00 brokerage $36,300.00") != null); } } + +/// Test writer that records how many bytes had been written at each +/// `flush`, so a test can check *where* `runHygieneCheck` flushes. +/// Must not move once `writer` is in use (it finds itself via +/// `@fieldParentPtr`). +const FlushLog = struct { + gpa: std.mem.Allocator, + writer: std.Io.Writer, + written: std.ArrayList(u8) = .empty, + /// `written.items.len` at each flush. + marks: std.ArrayList(usize) = .empty, + + fn init(gpa: std.mem.Allocator, buffer: []u8) FlushLog { + return .{ .gpa = gpa, .writer = .{ .vtable = &.{ .drain = drain, .flush = flush }, .buffer = buffer } }; + } + + fn deinit(self: *FlushLog) void { + self.written.deinit(self.gpa); + self.marks.deinit(self.gpa); + } + + fn take(self: *FlushLog, bytes: []const u8) std.Io.Writer.Error!void { + self.written.appendSlice(self.gpa, bytes) catch return error.WriteFailed; + } + + fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize { + const self: *FlushLog = @fieldParentPtr("writer", w); + try self.take(w.buffered()); + w.end = 0; + // Every element once, except the last, which repeats `splat` times. + var n: usize = 0; + for (data, 0..) |bytes, i| { + const reps = if (i + 1 == data.len) splat else 1; + for (0..reps) |_| try self.take(bytes); + n += bytes.len * reps; + } + return n; + } + + fn flush(w: *std.Io.Writer) std.Io.Writer.Error!void { + const self: *FlushLog = @fieldParentPtr("writer", w); + try self.take(w.buffered()); + w.end = 0; + self.marks.append(self.gpa, self.written.items.len) catch return error.WriteFailed; + } +}; + +test "runHygieneCheck: stdout is flushed before each step that can write to stderr" { + // Price loads and the contributions pipeline write progress to + // unbuffered stderr. Unflushed, those lines landed wherever the 4 KB + // stdout buffer last filled - observed mid-row in a reconciliation + // table. Pin both flush points to the section boundaries. + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + // A typo'd field gives Section 8 output, so "flushed before Section + // 6" can't be satisfied by a flush at the very end. + try writeTwoAccountFixture(io, tmp.dir, ",price_dat::2026-10-01"); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf); + const dir = path_buf[0..dir_len]; + + var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir }); + defer svc.deinit(); + var env = try std.testing.environ.createMap(allocator); + defer env.deinit(); + const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" }); + defer allocator.free(pf_path); + + // wall-clock required: discovery keeps only exports modified in the + // last 24 hours, judged against the real mtimes of the files above. + const now_s = std.Io.Timestamp.now(io, .real).toSeconds(); + + // Small buffer so the drain path is exercised too. + var buf: [256]u8 = undefined; + var log = FlushLog.init(allocator, &buf); + defer log.deinit(); + try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, false, zfin.Date.fromYmd(2026, 10, 6), now_s, false, .never, &log.writer); + try log.writer.flush(); // what main does at exit + const out = log.written.items; + + // Section 5: everything before the price load is out, nothing of + // the reconciliation is. + const recon = std.mem.indexOf(u8, out, "\n Reconciliation\n") orelse return error.ReconciliationMissing; + try std.testing.expect(std.mem.indexOfScalar(usize, log.marks.items, recon) != null); + + // Section 6: a flush after the whole reconciliation section and + // before Section 8's report. + const recon_end = (std.mem.indexOf(u8, out, " 1 mismatch to investigate\n") orelse return error.MismatchLineMissing) + " 1 mismatch to investigate\n".len; + const section8 = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing; + var found = false; + for (log.marks.items) |m| { + if (m >= recon_end and m < section8) found = true; + } + try std.testing.expect(found); +} diff --git a/src/commands/compare.zig b/src/commands/compare.zig index 9e40cb7..7bd03d7 100644 --- a/src/commands/compare.zig +++ b/src/commands/compare.zig @@ -492,7 +492,9 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // Attribution uses the resolved CommitSpecs so --commit-* // overrides + date fallbacks share one classifier. The caller // adapts dates to `CommitSpec.date_at_or_before` upstream. - const attribution = contributions.computeAttributionSpec(io, allocator, ctx.environ_map, svc, attr_paths, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy); + // It reuses the live side's price load rather than repeating it. + const known: ?contributions.KnownPrices = if (live_data) |*ld| ld.knownPrices() else null; + const attribution = contributions.computeAttributionSpec(io, allocator, ctx.environ_map, svc, attr_paths, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy, known); try renderFromParts(out, color, allocator, .{ .then_date = then_date, @@ -509,7 +511,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { var now_side = try compare_core.loadSnapshotSide(io, allocator, hist_dir, now_date); defer now_side.deinit(allocator); - const attribution = contributions.computeAttributionSpec(io, allocator, ctx.environ_map, svc, attr_paths, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy); + // No live price load on this side to reuse. + const attribution = contributions.computeAttributionSpec(io, allocator, ctx.environ_map, svc, attr_paths, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy, null); try renderFromParts(out, color, allocator, .{ .then_date = then_date, diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index c63c0a3..edd78d0 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -459,7 +459,7 @@ fn runImpl( return; } - var ctx = prepareReport(io, allocator, arena, env, svc, paths, before, after, as_of, color, refresh, .verbose) catch return; + var ctx = prepareReport(io, allocator, arena, env, svc, paths, before, after, as_of, color, refresh, .verbose, null) catch return; defer ctx.deinit(); try printReport(out, &ctx.report, ctx.endpoints.label, color); @@ -490,6 +490,28 @@ const ReportContext = struct { const PrepareError = error{PrepareFailed}; +/// Prices a caller already loaded through `cli.loadPortfolioPrices`, +/// handed to the pipeline so it doesn't load them a second time. `audit` +/// and `compare` call this pipeline as a library right after loading +/// prices for their own output; loading again re-scanned every candle +/// file and printed a second "Loaded N symbols" line, which in `audit` +/// landed mid-table. +/// +/// Only symbols missing from `requested` are loaded - in practice the +/// ones held at the before-side commit but sold since, or held in a +/// portfolio file the caller didn't load. +pub const KnownPrices = struct { + /// Every symbol the caller asked the loader for. One that is absent + /// from `closes` failed to load; asking again under the same refresh + /// policy fails the same way, so it is not retried. + requested: []const []const u8, + /// The loader's raw closes (`LoadAllResult.prices`). NOT a map that + /// `buildFallbackPrices` or a manual-price pass has filled in: this + /// pipeline applies its own fallbacks (`outflowUnitValue`), and a + /// manual or avg-cost entry posing as a close would change them. + closes: *const std.StringHashMap(f64), +}; + /// Run the common pipeline - resolve endpoints, load the merged /// portfolio at both revisions, fetch prices, build the report. /// @@ -510,6 +532,9 @@ const PrepareError = error{PrepareFailed}; /// Stderr output is gated by `verbosity`: `.verbose` is the `run` /// path (user sees why things failed); `.silent` is the attribution /// path (failure just means "no attribution line", don't nag). +/// +/// `known` is prices the caller has already loaded (see `KnownPrices`); +/// null loads everything. fn prepareReport( io: std.Io, allocator: std.mem.Allocator, @@ -523,6 +548,7 @@ fn prepareReport( color: bool, refresh: framework.RefreshPolicy, verbosity: Verbosity, + known: ?KnownPrices, ) PrepareError!ReportContext { if (paths.len == 0) { if (verbosity == .verbose) cli.stderrPrint(io, "Error: No portfolio file found\n"); @@ -601,12 +627,11 @@ fn prepareReport( sym_set.put(l.priceSymbol(), {}) catch return error.PrepareFailed; } } - var syms: std.ArrayList([]const u8) = .empty; - var sit = sym_set.keyIterator(); - while (sit.next()) |k| syms.append(arena, k.*) catch return error.PrepareFailed; + // Take what the caller already loaded; load only the rest. + const missing = takeKnownPrices(arena, &sym_set, known, &prices) catch return error.PrepareFailed; - if (syms.items.len > 0) { - var load_result = cli.loadPortfolioPrices(io, svc, syms.items, &.{}, refresh, color); + if (missing.len > 0) { + var load_result = cli.loadPortfolioPrices(io, svc, missing, &.{}, refresh, color); defer load_result.deinit(); var pit = load_result.prices.iterator(); while (pit.next()) |entry| { @@ -692,6 +717,34 @@ fn prepareReport( }; } +/// Split the symbols `prepareReport` needs (`needed`) into those the +/// caller already has a load result for and those still to load. Known +/// closes are copied into `prices`; a symbol the caller asked for but +/// got no close for is dropped, not retried (see `KnownPrices`). Returns +/// the symbols to load, allocated in `arena`. With `known` null, that is +/// all of them. +fn takeKnownPrices( + arena: std.mem.Allocator, + needed: *const std.StringHashMap(void), + known: ?KnownPrices, + prices: *std.StringHashMap(f64), +) ![]const []const u8 { + var asked = std.StringHashMap(void).init(arena); + if (known) |k| for (k.requested) |sym| try asked.put(sym, {}); + + var missing: std.ArrayList([]const u8) = .empty; + var it = needed.keyIterator(); + while (it.next()) |sym| { + if (known) |k| if (k.closes.get(sym.*)) |close| { + try prices.put(sym.*, close); + continue; + }; + if (asked.contains(sym.*)) continue; + try missing.append(arena, sym.*); + } + return missing.items; +} + /// Whether `resolveEndpoints` / `prepareReport` should print /// explanatory stderr messages when the window can't be resolved. The /// main `run` command uses `.verbose` so the user sees why the command @@ -1211,7 +1264,8 @@ pub const AttributionSummary = struct { /// /// Parameters mirror `run` but without the writer/color: no output /// is produced. Failures swallow silently via the shared -/// `prepareReport` helper's `.silent` verbosity. +/// `prepareReport` helper's `.silent` verbosity. Pass `known` when the +/// caller has just loaded prices itself (live-mode `compare` has). /// /// Classification logic is SOLELY in `computeReport` - this function /// just buckets pre-classified change kinds into their totals. In @@ -1231,6 +1285,7 @@ pub fn computeAttributionSpec( as_of: Date, color: bool, refresh: framework.RefreshPolicy, + known: ?KnownPrices, ) ?AttributionSummary { if (before == null and after != null) return null; @@ -1238,7 +1293,7 @@ pub fn computeAttributionSpec( defer arena_state.deinit(); const arena = arena_state.allocator(); - var ctx = prepareReport(io, allocator, arena, env, svc, paths, before, after, as_of, color, refresh, .silent) catch return null; + var ctx = prepareReport(io, allocator, arena, env, svc, paths, before, after, as_of, color, refresh, .silent, known) catch return null; defer ctx.deinit(); return summarizeAttribution(ctx); @@ -1315,7 +1370,9 @@ pub const UnmatchedLargeLotSet = struct { /// the lot as an external contribution or add a transfer record /// when it was really an internal movement. Works whether or not /// `transaction_log.srf` exists - when absent, every large lot -/// surfaces since nothing gets reclassified. +/// surfaces since nothing gets reclassified. `known` is the prices +/// audit loaded for its reconciliation, if it ran one (see +/// `KnownPrices`). pub fn findUnmatchedLargeLots( io: std.Io, allocator: std.mem.Allocator, @@ -1326,6 +1383,7 @@ pub fn findUnmatchedLargeLots( as_of: Date, color: bool, refresh: framework.RefreshPolicy, + known: ?KnownPrices, ) !?UnmatchedLargeLotSet { var arena_state = std.heap.ArenaAllocator.init(allocator); errdefer arena_state.deinit(); @@ -1339,7 +1397,7 @@ pub fn findUnmatchedLargeLots( // // Separate allocator here so we can tear the whole thing down // via `arena_state.deinit` once we've copied out the descriptors. - var ctx = prepareReport(io, allocator, arena, env, svc, paths, null, null, as_of, color, refresh, .silent) catch |err| { + var ctx = prepareReport(io, allocator, arena, env, svc, paths, null, null, as_of, color, refresh, .silent, known) catch |err| { std.log.scoped(.contributions).debug("large-lot check skipped: no comparable window ({t})", .{err}); arena_state.deinit(); return null; @@ -6890,7 +6948,7 @@ test "prepareReport: a lot archived into a sibling portfolio file is one sale" { var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir }); defer svc.deinit(); - var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 8, 17), false, .never, .silent) catch return; + var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 8, 17), false, .never, .silent, null) catch return; defer ctx.deinit(); var sale: ?Change = null; @@ -6956,7 +7014,7 @@ test "prepareReport: a CD archived unchanged pays out in the commits that span i var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir }); defer svc.deinit(); - var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 3, 10), false, .never, .silent) catch return error.PrepareFailed; + var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 3, 10), false, .never, .silent, null) catch return error.PrepareFailed; defer ctx.deinit(); try std.testing.expectEqual(@as(usize, 1), countKind(ctx.report, .cd_matured)); @@ -7018,7 +7076,7 @@ test "prepareReport: a cached dividend on an opted-in account leaves attribution var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir }); defer svc.deinit(); - var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 9, 28), false, .never, .silent) catch return error.PrepareFailed; + var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 9, 28), false, .never, .silent, null) catch return error.PrepareFailed; defer ctx.deinit(); try std.testing.expectApproxEqAbs(@as(f64, 364.58), summarizeAttribution(ctx).total(), 0.01); @@ -7052,13 +7110,104 @@ test "prepareReport: with no dividend data the report runs and counts the cash a var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir }); defer svc.deinit(); - var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 9, 28), false, .never, .silent) catch return error.PrepareFailed; + var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 9, 28), false, .never, .silent, null) catch return error.PrepareFailed; defer ctx.deinit(); try std.testing.expectApproxEqAbs(@as(f64, 470.98), summarizeAttribution(ctx).total(), 0.01); try std.testing.expect(cashDeltaIn(ctx.report, "Sample HSA") == null); } +test "takeKnownPrices: copies known closes, skips asked-but-failed, loads the rest" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var needed = std.StringHashMap(void).init(arena); + for ([_][]const u8{ "VTI", "SCHD", "SOLD" }) |s| try needed.put(s, {}); + + // No caller load: everything is still to load. + { + var prices = std.StringHashMap(f64).init(arena); + const missing = try takeKnownPrices(arena, &needed, null, &prices); + try std.testing.expectEqual(@as(usize, 3), missing.len); + try std.testing.expectEqual(@as(u32, 0), prices.count()); + } + + // The caller asked for VTI and SCHD; SCHD failed. SOLD (held only at + // the before-side commit) was never asked for. + { + var closes = std.StringHashMap(f64).init(arena); + try closes.put("VTI", 300); + const known: KnownPrices = .{ .requested = &.{ "VTI", "SCHD" }, .closes = &closes }; + var prices = std.StringHashMap(f64).init(arena); + const missing = try takeKnownPrices(arena, &needed, known, &prices); + try std.testing.expectEqual(@as(usize, 1), missing.len); + try std.testing.expectEqualStrings("SOLD", missing[0]); + try std.testing.expectEqual(@as(f64, 300), prices.get("VTI").?); + // Failed once under this refresh policy -> not retried, no price. + try std.testing.expect(!prices.contains("SCHD")); + } +} + +test "prepareReport: values share changes at the caller's closes instead of loading again" { + // End to end through git. The cache is empty and the policy is + // `.never`, so a fresh load here finds nothing and a share change is + // valued at open_price; a caller-supplied close must be used instead. + if (!test_git.available(std.testing.allocator)) return; + + const io = std.testing.io; + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf); + const dir = path_buf[0..dir_len]; + + try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = "#!srfv1\nsymbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:200,account::Sample IRA\n" }); + try test_git.run(allocator, dir, null, &.{ "init", "-q" }); + try test_git.run(allocator, dir, null, &.{ "config", "user.email", "test@example.com" }); + try test_git.run(allocator, dir, null, &.{ "config", "user.name", "Test" }); + try test_git.run(allocator, dir, null, &.{ "config", "commit.gpgsign", "false" }); + try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf" }); + try test_git.run(allocator, dir, "2026-09-19T12:00:00", &.{ "commit", "-q", "-m", "before" }); + // Working copy: 10 more shares on the same lot. + try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = "#!srfv1\nsymbol::VTI,shares:num:110,open_date::2024-01-15,open_price:num:200,account::Sample IRA\n" }); + + var env = try std.testing.environ.createMap(allocator); + defer env.deinit(); + const port_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" }); + const paths: []const []const u8 = &.{port_path}; + var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir }); + defer svc.deinit(); + + const Run = struct { + fn vtiUnitValue(ctx: ReportContext) !f64 { + for (ctx.report.changes) |c| { + if (std.mem.eql(u8, c.symbol, "VTI")) return c.unit_value; + } + return error.NoVtiChange; + } + }; + + // Without a caller load: nothing cached, so open_price. + { + var ctx = try prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 9, 28), false, .never, .silent, null); + defer ctx.deinit(); + try std.testing.expectApproxEqAbs(@as(f64, 200), try Run.vtiUnitValue(ctx), 0.001); + } + // With one: the caller's close. + { + var closes = std.StringHashMap(f64).init(allocator); + try closes.put("VTI", 300); + var ctx = try prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 9, 28), false, .never, .silent, .{ .requested = &.{"VTI"}, .closes = &closes }); + defer ctx.deinit(); + try std.testing.expectApproxEqAbs(@as(f64, 300), try Run.vtiUnitValue(ctx), 0.001); + } +} + test "computeReport: stock open_price renormalized reclassified as edit" { // Reconciliation tweak: user updates `open_price` to match the // institutional-share-class NAV, leaving everything else alone. diff --git a/src/commands/projections.zig b/src/commands/projections.zig index f647488..ca16051 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -12,6 +12,7 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const framework = @import("framework.zig"); +const contributions = @import("contributions.zig"); const fmt = cli.fmt; const Date = zfin.Date; const Money = @import("../Money.zig"); @@ -370,7 +371,10 @@ const AsOfResolution = struct { /// `prices` is retained alongside the summary so callers that /// need per-symbol price lookups (e.g. `compare`'s LiveSide via /// `compare_core.aggregateLiveStocks`) can borrow without -/// re-fetching. +/// re-fetching. It includes the manual and avg-cost fallbacks +/// `buildPortfolioData` adds; `closes` is the loader's output before +/// them, for consumers that apply their own fallbacks (see +/// `knownPrices`). /// /// Caller owns the fields and must call `deinit` after the helper /// returns. @@ -378,12 +382,20 @@ pub const LiveData = struct { loaded: cli.LoadedPortfolio, pf_data: cli.PortfolioData, prices: std.StringHashMap(f64), + closes: std.StringHashMap(f64), pub fn deinit(self: *LiveData, allocator: std.mem.Allocator) void { + self.closes.deinit(); self.prices.deinit(); self.pf_data.deinit(allocator); self.loaded.deinit(allocator); } + + /// The price load this was built from, for the contributions + /// pipeline to reuse instead of loading again. + pub fn knownPrices(self: *const LiveData) contributions.KnownPrices { + return .{ .requested = self.loaded.syms, .closes = &self.closes }; + } }; /// Load the live portfolio (multi-file union-merge), fetch prices @@ -408,26 +420,27 @@ pub fn loadLiveData( var loaded = cli.loadPortfolio(ctx, today) orelse return null; errdefer loaded.deinit(allocator); - var prices = std.StringHashMap(f64).init(allocator); + // Keep the loader's own map as `closes`; `prices` is a copy that + // `buildPortfolioData` fills in. + var closes = cli.loadPortfolioPrices(io, svc, loaded.syms, &.{}, ctx.globals.refresh_policy, color).prices; + errdefer closes.deinit(); + var prices = try closes.clone(); errdefer prices.deinit(); - { - var load_result = cli.loadPortfolioPrices(io, svc, loaded.syms, &.{}, ctx.globals.refresh_policy, color); - defer load_result.deinit(); - var it = load_result.prices.iterator(); - while (it.next()) |entry| { - try prices.put(entry.key_ptr.*, entry.value_ptr.*); - } - } const pf_data = cli.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, today) catch |err| switch (err) { error.NoAllocations, error.SummaryFailed => { cli.stderrPrint(io, "Error computing portfolio summary.\n"); + // A null return isn't an error return: the errdefers above + // don't run on it. + prices.deinit(); + closes.deinit(); + loaded.deinit(allocator); return null; }, else => return err, }; - return .{ .loaded = loaded, .pf_data = pf_data, .prices = prices }; + return .{ .loaded = loaded, .pf_data = pf_data, .prices = prices, .closes = closes }; } /// Per-call configuration for `runBands`. Bundled because the @@ -2164,10 +2177,12 @@ fn makeTestLiveData(io: std.Io, svc: *zfin.DataService, pf_path: []const u8, tod var prices = std.StringHashMap(f64).init(allocator); errdefer prices.deinit(); try prices.put("VTI", 200.0); + var closes = try prices.clone(); + errdefer closes.deinit(); const pf_data = try portfolio_loader.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, today); - return .{ .loaded = loaded, .pf_data = pf_data, .prices = prices }; + return .{ .loaded = loaded, .pf_data = pf_data, .prices = prices, .closes = closes }; } /// Write a `history/imported_values.srf` with the given body into @@ -2179,6 +2194,37 @@ fn writeFixtureImported(io: std.Io, tmp: *std.testing.TmpDir, body: []const u8) try hist_dir.writeFile(io, .{ .sub_path = "imported_values.srf", .data = body }); } +test "LiveData.knownPrices hands over the loader's closes, not the fallback-filled prices" { + // The contributions pipeline applies its own fallbacks, so an + // avg-cost entry posing as a close would change what it reports. + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var svc = makeTestSvc(); + defer svc.deinit(); + + // VTI has a close (makeTestLiveData supplies 200); SCHD has none. + try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = + \\#!srfv1 + \\symbol::VTI,shares:num:100,open_date::2020-01-15,open_price:num:200,account::Sample Roth + \\symbol::SCHD,shares:num:10,open_date::2020-01-15,open_price:num:25,account::Sample Roth + \\ + }); + const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); + defer testing.allocator.free(pf); + + var ld = try makeTestLiveData(io, &svc, pf, Date.fromYmd(2026, 3, 13)); + defer ld.deinit(testing.allocator); + + // `buildPortfolioData` filled SCHD in at its average cost... + try testing.expectApproxEqAbs(@as(f64, 25), ld.prices.get("SCHD").?, 0.001); + // ...but the hand-off carries only what the loader produced. + const known = ld.knownPrices(); + try testing.expectApproxEqAbs(@as(f64, 200), known.closes.get("VTI").?, 0.001); + try testing.expect(!known.closes.contains("SCHD")); + try testing.expectEqual(ld.loaded.syms.len, known.requested.len); +} + test "runBands: imported-only as_of scales today's composition and renders body" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{});