contributions: CD payouts fund purchases do not count

This commit is contained in:
Emil Lerch 2026-09-29 06:46:11 -07:00
parent c0dca12623
commit cc13572d7c
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 92 additions and 2 deletions

View file

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

View file

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