From c463d94ea5c7c51b78a2417bdf905ac5f236e9cc Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 29 Sep 2026 07:20:24 -0700 Subject: [PATCH] contributions: track matured CDs properly --- docs/reference/cli/contributions.md | 19 +- src/commands/contributions.zig | 326 +++++++++++++++++++++++++++- src/git.zig | 28 +++ 3 files changed, 360 insertions(+), 13 deletions(-) diff --git a/docs/reference/cli/contributions.md b/docs/reference/cli/contributions.md index 17faee8..1d29afc 100644 --- a/docs/reference/cli/contributions.md +++ b/docs/reference/cli/contributions.md @@ -54,9 +54,8 @@ rather than counting toward the total: alongside the account's cash going down. - **Reallocating -- selling one holding to buy another in the same account.** The sale's proceeds offset the repurchase. -- **A CD paying out.** A CD that matures or is redeemed early, and is - then closed or removed, counts like a sale: rolling it into a new CD, - or buying with the payout, isn't new money. +- **A CD paying out.** A CD's payout counts like a sale: rolling it + into a new CD, or buying with the payout, isn't new money. A sale is valued at `close_price` when you record one (see [`portfolio.srf`](../config/portfolio-srf.md)), which is what the sale @@ -88,10 +87,16 @@ funded anything, and are treated accordingly. On an account marked `cash_is_contribution::true` they also cancel that account's cash credit, since the sale is not new money even though cash arrived. -Record a CD's close -- or remove it -- in the same commit as its -payout. The proceeds land in whichever window the CD leaves the file, -so removing it months later hands its face value to that later window, -where it can hide a real contribution. +A CD that matures pays out in the window containing its maturity date +-- between the dates of the two commits being compared, or today for +uncommitted changes -- whether you close it, +delete it, archive it to `portfolio_closed.srf`, or leave it where it +is. Closing or removing it in a later window changes nothing. A CD +redeemed before maturity pays out in the window you close or remove it. + +On an account marked `cash_is_contribution::true`, update its cash +balance in that same window: a payout that reaches the cash line a +commit later reads as new money there. Movement **between** accounts is a different matter -- zfin cannot tell it from a contribution, so declare it in diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 071a160..281d984 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -661,6 +661,7 @@ fn prepareReport( .{ .account_map = if (account_map_opt) |*am| am else null, .transfer_log = new_records, + .window = diffWindow(io, arena, env, repo.root, endpoints.range, as_of), }, ) catch { if (verbosity == .verbose) cli.stderrPrint(io, "Error computing contributions diff.\n"); @@ -684,6 +685,33 @@ fn prepareReport( /// a hard error. const Verbosity = enum { verbose, silent }; +/// The dates the diff's two sides were taken, from the commits' +/// committer timestamps; a working-copy after side is `as_of` (today). +/// Null when a timestamp can't be read, which falls back to the +/// window-free behavior - worth a debug line, not a failed report. +fn diffWindow( + io: std.Io, + arena: std.mem.Allocator, + env: *const std.process.Environ.Map, + root: []const u8, + range: git.CommitRange, + as_of: Date, +) ?Window { + const log = std.log.scoped(.contributions); + const start = git.commitDate(io, arena, env, root, range.before_rev) catch |err| { + log.debug("no diff window: before-commit date unavailable ({t})", .{err}); + return null; + }; + const end: Date = if (range.after_rev) |rev| + git.commitDate(io, arena, env, root, rev) catch |err| { + log.debug("no diff window: after-commit date unavailable ({t})", .{err}); + return null; + } + else + as_of; + return .{ .start = start, .end = end }; +} + /// Resolve `since` / `until` flags plus dirty-working-tree state to /// the pair of git revisions to diff, along with a human-readable /// label for the report header. @@ -890,10 +918,7 @@ fn maybeSnapNote( // this note outright the moment `--since` began producing `.snapshot_add`. if (s == .snapshot_add and anchor != null) return; - // Get the committer-date of the resolved commit. `%ct` gives a - // Unix timestamp. - const ts = git.commitTimestamp(io, arena, env, repo.root, resolved_ref) catch return; - const commit_date = zfin.Date.fromEpoch(ts); + const commit_date = git.commitDate(io, arena, env, repo.root, resolved_ref) catch return; if (!commit_date.lessThan(requested_date)) return; const gap_days = requested_date.days - commit_date.days; @@ -2219,8 +2244,49 @@ const ReportOptions = struct { /// See `diffTransferLogs` and the prepareReport pipeline for /// how the production caller assembles the slice. transfer_log: ?[]const transaction_log.TransferRecord = null, + /// The dates the two sides of the diff were taken: the before + /// commit's date and the after commit's (or today, for the working + /// copy). `prepareReport` fills it from the commit timestamps. + /// + /// It is what lets a CD's maturity date mean something. The lots + /// alone say only "this CD matures Mar 1", identically on both + /// sides, so a CD that matured but was left in the file - or + /// archived unchanged - showed no change and its payout funded + /// nothing. With the window, the payout lands in exactly the one + /// window that contains the maturity date. See `cdMaturity`. + /// + /// Null (tests, or a timestamp lookup that failed) keeps the + /// window-free behavior. + window: ?Window = null, }; +/// See `ReportOptions.window`. +const Window = struct { + start: Date, + end: Date, +}; + +/// Where a CD's maturity falls relative to the diff window. End-of-day +/// semantics like everywhere else: a CD maturing on the start date had +/// already matured when the before side was taken. +const CdMaturity = enum { + /// Not a CD, no maturity, no window, or not yet matured at `end`. + none, + /// Matured in (start, end]: its payout belongs to this window. + in_window, + /// Matured on or before `start`: its payout belonged to an earlier + /// window, so closing or deleting it now is housekeeping. + before_window, +}; + +fn cdMaturity(lot: Lot, window: ?Window) CdMaturity { + if (lot.security_type != .cd) return .none; + const w = window orelse return .none; + if (lot.hasMaturedAsOf(w.start)) return .before_window; + if (lot.hasMaturedAsOf(w.end)) return .in_window; + return .none; +} + fn computeReport( allocator: std.mem.Allocator, before: []const Lot, @@ -2272,7 +2338,12 @@ fn computeReport( // checked independently of the residual and wins over the // metadata comparisons below: a lot both closed and repriced // in one window is a sale, not a price edit. - if (@abs(split.sold) > 0.000001) { + // + // Except a CD that matured before this window: its payout was + // counted in the window containing its maturity date (see + // below), so recording its close now is housekeeping. + const cd_maturity = cdMaturity(lot, opts.window); + if (@abs(split.sold) > 0.000001 and cd_maturity != .before_window) { try appendSale(allocator, &changes, sym, acct, lot.security_type, split.sold, after_agg.sold_value - before_agg.sold_value, !after_agg.sold_unpriced); } @@ -2346,7 +2417,32 @@ fn computeReport( .unit_value = unit_value, }); } else if (@abs(split.sold) <= 0.000001) { - // Same held shares and no sale: only metadata changed. + // Same held shares and no sale. + // + // A CD whose maturity date falls in this window paid out, + // even though nothing about its line changed - left in + // place, or archived to a sibling file unchanged. That is + // a funding source exactly like a deleted matured CD. Uses + // the AFTER side's maturity, so a CD renewed by pushing + // `maturity_date` out isn't a payout. + // Held shares only: a CD redeemed early (already sold) + // whose original maturity date comes round later paid + // out when it was closed. + if (cd_maturity == .in_window and @abs(after_agg.unsold()) > 0.000001) { + const held = after_agg.unsold(); + const unit_value = outflowUnitValue(lot, prices, as_of); + try changes.append(allocator, .{ + .kind = .cd_matured, + .symbol = sym, + .account = acct, + .security_type = lot.security_type, + .unit_value = unit_value, + .face_value = held * unit_value, + .maturity_date = lot.maturity_date, + .delta_shares = -held, + }); + } + // Otherwise only metadata changed. const before_lot = before_agg.lot; const a_price = lot.price; const b_price = before_lot.price; @@ -2452,6 +2548,12 @@ fn computeReport( const acct = try sdup.of(lot.account orelse ""); const sym = try sdup.of(lot.symbol); + // A CD that matured before this window paid out in the window + // containing its maturity date; deleting its line now is + // housekeeping. (Without a window this can't be known, and the + // deletion funds this window as before.) + if (cdMaturity(lot, opts.window) == .before_window) continue; + var kind: ChangeKind = .lot_removed; if (lot.security_type == .cd) { // Matured specifically, not "ended": a CD redeemed early @@ -3653,6 +3755,166 @@ test "matchIntraAccountPurchases: a CD payout can't absorb an unrelated deposit try std.testing.expectApproxEqAbs(@as(f64, 5000), attributionTotalForTest(report), 0.01); } +// ── CD maturity inside the diff window (see `ReportOptions.window`) ── + +/// A window around a CD maturing 2026-03-01. +const mar_window: Window = .{ .start = Date.fromYmd(2026, 2, 20), .end = Date.fromYmd(2026, 3, 6) }; +const later_window: Window = .{ .start = Date.fromYmd(2026, 6, 1), .end = Date.fromYmd(2026, 6, 8) }; + +fn windowReport(a: std.mem.Allocator, before: []const Lot, after: []const Lot, window: ?Window) !Report { + var prices = std.StringHashMap(f64).init(a); + const as_of = if (window) |w| w.end else Date.fromYmd(2026, 6, 8); + return computeReport(a, before, after, &prices, as_of, .{ .window = window }); +} + +test "window: a CD left in the file that matures in the window funds a rollover" { + // The case the maturity date alone couldn't handle: CD1's line is + // identical on both sides (left in place, or archived unchanged), + // CD2 is bought with the payout, no cash visibly moves. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1)); + const cd2 = testCd("CD2", "Sample IRA", Date.fromYmd(2026, 3, 2), Date.fromYmd(2027, 3, 2)); + + const with = try windowReport(a, &.{cd1}, &.{ cd1, cd2 }, mar_window); + try std.testing.expectEqual(@as(usize, 1), countKind(with, .cd_matured)); + try std.testing.expectApproxEqAbs(@as(f64, 0), attributionTotalForTest(with), 0.01); + + // Without window dates it can't be known; the new CD counts, as before. + const without = try windowReport(a, &.{cd1}, &.{ cd1, cd2 }, null); + try std.testing.expectApproxEqAbs(@as(f64, 10_000), attributionTotalForTest(without), 0.01); +} + +test "window: an unchanged CD pays out only in the window containing its maturity" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1)); + // Months later the line is still there, untouched: nothing again. + const later = try windowReport(a, &.{cd1}, &.{cd1}, later_window); + try std.testing.expectEqual(@as(usize, 0), later.changes.len); + // Not yet matured: nothing either. + const early: Window = .{ .start = Date.fromYmd(2026, 1, 1), .end = Date.fromYmd(2026, 2, 28) }; + const before = try windowReport(a, &.{cd1}, &.{cd1}, early); + try std.testing.expectEqual(@as(usize, 0), before.changes.len); +} + +test "window: maturing on the window's start date belongs to the earlier window" { + // End-of-day semantics: on the start date it had already matured. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1)); + const on_start: Window = .{ .start = Date.fromYmd(2026, 3, 1), .end = Date.fromYmd(2026, 3, 8) }; + const on_end: Window = .{ .start = Date.fromYmd(2026, 2, 22), .end = Date.fromYmd(2026, 3, 1) }; + try std.testing.expectEqual(@as(usize, 0), (try windowReport(a, &.{cd1}, &.{cd1}, on_start)).changes.len); + try std.testing.expectEqual(@as(usize, 1), countKind(try windowReport(a, &.{cd1}, &.{cd1}, on_end), .cd_matured)); +} + +test "window: deleting a CD long after it matured is housekeeping" { + // Its payout landed in its maturity window. This is the late-deletion + // caveat the window-free fix had: the face value used to fund - and + // so hide - a real contribution in the later window. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1)); + const buy: Lot = .{ .symbol = "VTI", .shares = 40, .open_date = Date.fromYmd(2026, 6, 3), .open_price = 250, .account = "Sample IRA" }; + const report = try windowReport(a, &.{cd1}, &.{buy}, later_window); + try std.testing.expectEqual(@as(usize, 0), countKind(report, .cd_matured)); + try std.testing.expectApproxEqAbs(@as(f64, 10_000), attributionTotalForTest(report), 0.01); +} + +test "window: deleting a CD in the window it matures still pays out" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1)); + const cd2 = testCd("CD2", "Sample IRA", Date.fromYmd(2026, 3, 2), Date.fromYmd(2027, 3, 2)); + const report = try windowReport(a, &.{cd1}, &.{cd2}, mar_window); + try std.testing.expectApproxEqAbs(@as(f64, 0), attributionTotalForTest(report), 0.01); +} + +test "window: closing a CD long after it matured is housekeeping" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1)); + var closed = cd1; + closed.close_date = Date.fromYmd(2026, 3, 1); + closed.close_price = 1; + const report = try windowReport(a, &.{cd1}, &.{closed}, later_window); + try std.testing.expectEqual(@as(usize, 0), report.changes.len); +} + +test "window: closing a CD in the window it matures pays out once, not twice" { + // The close is the sale; the in-window maturity must not add a second + // funding event for the same money. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1)); + var closed = cd1; + closed.close_date = Date.fromYmd(2026, 3, 1); + closed.close_price = 1; + const report = try windowReport(a, &.{cd1}, &.{closed}, mar_window); + try std.testing.expectEqual(@as(usize, 1), countKind(report, .position_closed) + countKind(report, .cd_matured)); +} + +test "window: a CD redeemed early doesn't pay out again at its original maturity" { + // Closed in January; its March maturity date passing later is not a + // second payout, nor a zero-value "matured" row. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + var redeemed = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1)); + redeemed.close_date = Date.fromYmd(2026, 1, 15); + redeemed.close_price = 1; + const report = try windowReport(a, &.{redeemed}, &.{redeemed}, mar_window); + try std.testing.expectEqual(@as(usize, 0), report.changes.len); +} + +test "window: renewing a CD by extending maturity_date isn't a payout" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1)); + var renewed = cd1; + renewed.maturity_date = Date.fromYmd(2027, 3, 1); + const report = try windowReport(a, &.{cd1}, &.{renewed}, mar_window); + try std.testing.expectEqual(@as(usize, 0), countKind(report, .cd_matured)); +} + +test "window: a payout parked in cash isn't new money on a cash_is_contribution account" { + // The G1b case, now with the CD left in the file: the cash rise is + // offset by the payout, leaving only the $300 interest. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(a); + var am = try analysis.parseAccountsFile(a, "#!srfv1\naccount::Sample IRA,tax_type::traditional,cash_is_contribution:bool:true\n"); + const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1)); + const cash0: Lot = .{ .symbol = "CASH", .shares = 500, .open_date = Date.epoch, .open_price = 1, .security_type = .cash, .account = "Sample IRA" }; + var cash1 = cash0; + cash1.shares = 10_800; + const report = try computeReport(a, &.{ cd1, cash0 }, &.{ cd1, cash1 }, &prices, mar_window.end, .{ .account_map = &am, .window = mar_window }); + try std.testing.expectApproxEqAbs(@as(f64, 300), attributionTotalForTest(report), 0.01); +} + +test "cdMaturity: only CDs, and only with a window" { + const cd1 = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1)); + try std.testing.expectEqual(CdMaturity.in_window, cdMaturity(cd1, mar_window)); + try std.testing.expectEqual(CdMaturity.before_window, cdMaturity(cd1, later_window)); + try std.testing.expectEqual(CdMaturity.none, cdMaturity(cd1, null)); + var option = cd1; + option.security_type = .option; + try std.testing.expectEqual(CdMaturity.none, cdMaturity(option, mar_window)); + var no_maturity = cd1; + no_maturity.maturity_date = null; + try std.testing.expectEqual(CdMaturity.none, cdMaturity(no_maturity, mar_window)); +} + // ── Output ─────────────────────────────────────────────────── fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, color: bool) !void { @@ -6102,6 +6364,58 @@ test "prepareReport: a lot archived into a sibling portfolio file is one sale" { try std.testing.expectApproxEqAbs(@as(f64, 0.0), summarizeAttribution(ctx).total(), 0.01); } +test "prepareReport: a CD archived unchanged pays out in the commits that span its maturity" { + // End to end: the window comes from the two commits' dates, with no + // edit to the CD's line at all - it just moves to the archive file, + // exactly as a matured CD usually does. + 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]; + + const cd1 = "security_type::cd,symbol::CD1,shares:num:10000,open_date::2025-03-01,open_price:num:1,maturity_date::2026-03-01,account::Sample IRA\n"; + try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = "#!srfv1\n" ++ cd1 }); + try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_closed.srf", .data = "#!srfv1\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", "portfolio_closed.srf" }); + try test_git.run(allocator, dir, "2026-02-20T12:00:00", &.{ "commit", "-q", "-m", "before" }); + + // After maturity: CD1 archived unchanged, CD2 bought with the payout. + try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = + \\#!srfv1 + \\security_type::cd,symbol::CD2,shares:num:10000,open_date::2026-03-02,open_price:num:1,maturity_date::2027-03-02,account::Sample IRA + \\ + }); + try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_closed.srf", .data = "#!srfv1\n" ++ cd1 }); + try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf", "portfolio_closed.srf" }); + try test_git.run(allocator, dir, "2026-03-06T12:00:00", &.{ "commit", "-q", "-m", "rolled" }); + + var env = try std.testing.environ.createMap(allocator); + defer env.deinit(); + const open_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" }); + const closed_path = try std.fs.path.join(allocator, &.{ dir, "portfolio_closed.srf" }); + const paths: []const []const u8 = &.{ open_path, closed_path }; + 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; + defer ctx.deinit(); + + try std.testing.expectEqual(@as(usize, 1), countKind(ctx.report, .cd_matured)); + try std.testing.expectApproxEqAbs(@as(f64, 0.0), summarizeAttribution(ctx).total(), 0.01); +} + 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/git.zig b/src/git.zig index c31fc3a..eae69da 100644 --- a/src/git.zig +++ b/src/git.zig @@ -676,6 +676,21 @@ pub fn commitTimestamp( return std.fmt.parseInt(i64, trimmed, 10) catch return error.GitLogFailed; } +/// The UTC calendar day `ref` was committed (committer date, `%ct`). +/// +/// The one place a commit becomes a `Date`: callers that need "which +/// day was this commit" should use this rather than pairing +/// `commitTimestamp` with `Date.fromEpoch` themselves. +pub fn commitDate( + io: std.Io, + allocator: std.mem.Allocator, + env: *const std.process.Environ.Map, + root: []const u8, + ref: []const u8, +) Error!Date { + return Date.fromEpoch(try commitTimestamp(io, allocator, env, root, ref)); +} + /// Resolve a before/after commit range for diffing `repo.rel_path`. /// /// Three modes selected by `since` / `until`: @@ -1044,6 +1059,19 @@ test "commitAtOrBeforeDate returns a SHA for a past date" { for (sha) |c| try std.testing.expect(std.ascii.isHex(c)); } +test "commitDate is the committer day of commitTimestamp" { + const allocator = std.testing.allocator; + var env = gitTestEnv(allocator); + defer env.deinit(); + const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return; + defer allocator.free(info.root); + defer allocator.free(info.rel_path); + + const ts = commitTimestamp(std.testing.io, allocator, &env, info.root, "HEAD") catch return; + const d = try commitDate(std.testing.io, allocator, &env, info.root, "HEAD"); + try std.testing.expect(d.eql(Date.fromEpoch(ts))); +} + test "commitAtOrBeforeDate returns null for date before repo existed" { const allocator = std.testing.allocator; var env = gitTestEnv(allocator);