diff --git a/docs/reference/cli/contributions.md b/docs/reference/cli/contributions.md index 1a2ea83..880a83b 100644 --- a/docs/reference/cli/contributions.md +++ b/docs/reference/cli/contributions.md @@ -54,6 +54,9 @@ 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 sale is valued at `close_price` when you record one (see [`portfolio.srf`](../config/portfolio-srf.md)), which is what the sale @@ -71,6 +74,11 @@ 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. + Movement **between** accounts is a different matter -- zfin cannot tell it from a contribution, so declare it in [`transaction_log.srf`](../config/transaction-log-srf.md). An explicit diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 5e833ed..cc244ea 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -1568,7 +1568,8 @@ const Change = struct { /// - **cash that visibly left** (a negative `cash_delta` or a /// removed cash lot) funding a `new_stock` / `new_cd` buy; /// - **proceeds of a security sale** - a valued `lot_removed` / - /// `drip_negative`. Proceeds that were spent fund `new_stock` / + /// `drip_negative` - or of a CD paying out (`cd_matured` / + /// `cd_removed_early`). Proceeds that were spent fund `new_stock` / /// `new_cd`; proceeds still sitting in cash at window end /// instead cancel a `cash_contribution` on that account, which /// would otherwise book the sale as new money (see @@ -3076,7 +3077,8 @@ const FundingShortfall = struct { /// changes so the spent/resting split can be computed before any /// drawdown happens. const AccountFlows = struct { - /// Dollars realized by selling securities in this account. + /// Dollars realized in this account by selling securities, or by a + /// CD paying out (matured or redeemed early, then removed). sale_proceeds: f64 = 0, /// Signed net movement of the account's cash pool. Positive means /// the account is holding more cash at the end of the window. @@ -3119,6 +3121,14 @@ fn matchIntraAccountPurchases( // on an unchanged key, so `value()` is the negative // proceeds. .drip_negative => f.sale_proceeds += -c.value(), + // A CD that matured or was redeemed, then removed: its face + // value came back into the account exactly as a sale's + // proceeds do. Without this, rolling a matured CD into a new + // one (a ladder) booked the new CD as fresh money, and on a + // `cash_is_contribution` account the payout landing in cash + // did too. A CD closed IN PLACE already funds as + // `position_closed`; this makes deleting it equivalent. + .cd_matured, .cd_removed_early => f.sale_proceeds += c.face_value, else => {}, } } @@ -3174,6 +3184,78 @@ fn drawDownAgainstCashContribution(changes: *std.ArrayList(Change), account: []c } } +// ── CD payouts as funding (see `matchIntraAccountPurchases`) ── + +/// A CD lot for the funding tests: $10k face, `open_price` 1. +fn testCd(symbol: []const u8, account: []const u8, opened: Date, matures: Date) Lot { + return .{ .symbol = symbol, .shares = 10_000, .open_date = opened, .open_price = 1, .security_type = .cd, .maturity_date = matures, .account = account }; +} + +test "matchIntraAccountPurchases: a matured CD rolled into a new CD is not new money" { + // A CD ladder: CD1 matures and is deleted, CD2 is opened with the + // proceeds, no cash visibly moves. CD2 used to count as a $10k + // contribution. + 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); + const before = [_]Lot{testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1))}; + const after = [_]Lot{testCd("CD2", "Sample IRA", Date.fromYmd(2026, 3, 2), Date.fromYmd(2027, 3, 2))}; + const report = try computeReport(a, &before, &after, &prices, Date.fromYmd(2026, 3, 15), .{}); + try std.testing.expectApproxEqAbs(@as(f64, 0), attributionTotalForTest(report), 0.01); +} + +test "matchIntraAccountPurchases: a CD redeemed early and deleted funds a same-account buy" { + // The deleted-CD twin of the closed-in-place case, which already + // funded the buy via `position_closed`. Both must agree. + 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); + const cd = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 6, 1), Date.fromYmd(2027, 6, 1)); + const buy: Lot = .{ .symbol = "VTI", .shares = 40, .open_date = Date.fromYmd(2026, 1, 12), .open_price = 250, .account = "Sample IRA" }; + + const deleted = try computeReport(a, &.{cd}, &.{buy}, &prices, Date.fromYmd(2026, 1, 20), .{}); + try std.testing.expectApproxEqAbs(@as(f64, 0), attributionTotalForTest(deleted), 0.01); + + var closed = cd; + closed.close_date = Date.fromYmd(2026, 1, 10); + closed.close_price = 1; + const in_place = try computeReport(a, &.{cd}, &.{ closed, buy }, &prices, Date.fromYmd(2026, 1, 20), .{}); + try std.testing.expectApproxEqAbs(attributionTotalForTest(in_place), attributionTotalForTest(deleted), 0.01); +} + +test "matchIntraAccountPurchases: a matured CD's payout parked in cash isn't new money on a cash_is_contribution account" { + // The payout lands as a positive cash change, which the flag would + // book as a contribution. The CD's face value offsets it; only the + // $300 interest is left, because on this account the flag's premise + // is "cash arriving is new money". + 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 cd = 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, &.{ cd, cash0 }, &.{cash1}, &prices, Date.fromYmd(2026, 3, 15), .{ .account_map = &am }); + try std.testing.expectApproxEqAbs(@as(f64, 300), attributionTotalForTest(report), 0.01); +} + +test "matchIntraAccountPurchases: a CD payout can't absorb an unrelated deposit elsewhere" { + // Funding is per account. A CD maturing in one account must not + // cancel a genuine contribution landing in another. + 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); + const cd = testCd("CD1", "Sample IRA", Date.fromYmd(2025, 3, 1), Date.fromYmd(2026, 3, 1)); + const buy: Lot = .{ .symbol = "VTI", .shares = 20, .open_date = Date.fromYmd(2026, 3, 10), .open_price = 250, .account = "Sample Brokerage" }; + const report = try computeReport(a, &.{cd}, &.{buy}, &prices, Date.fromYmd(2026, 3, 15), .{}); + try std.testing.expectApproxEqAbs(@as(f64, 5000), attributionTotalForTest(report), 0.01); +} + // ── Output ─────────────────────────────────────────────────── fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, color: bool) !void {