contributions: CD payouts fund purchases do not count
This commit is contained in:
parent
c0dca12623
commit
cc13572d7c
2 changed files with 92 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue