contributions: track matured CDs properly
All checks were successful
Generic zig build / build (push) Successful in 6m58s
Generic zig build / publish-macos (push) Successful in 11s
Generic zig build / deploy (push) Successful in 19s

This commit is contained in:
Emil Lerch 2026-09-29 07:20:24 -07:00
parent c020f92449
commit c463d94ea5
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 360 additions and 13 deletions

View file

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

View file

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

View file

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