avoid counting dividends as contributions

This commit is contained in:
Emil Lerch 2026-09-30 10:28:51 -07:00
parent 155c1fbcfb
commit cc60c1991f
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 668 additions and 2 deletions

View file

@ -123,6 +123,13 @@ set `cash_is_contribution:bool:true` in
[`accounts.srf`](set-up-accounts.md#4-advanced-flags) so those increases
count.
Declared dividends are still kept out on those accounts. If the account
holds a fund that pays - an HSA invested in something, typically - the
dividend is split off the cash increase and reported under Cash deltas,
so only the payroll part counts. See
[`contributions`](../reference/cli/contributions.md) for exactly when a
dividend is recognised.
## Related: `compare`
[`zfin compare`](../reference/cli/compare.md) shows the same

View file

@ -98,6 +98,20 @@ 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.
The same account can also **hold** something that pays - an HSA
invested in a fund, say - and then payroll and dividends arrive in the
same cash line. A declared cash dividend is carved out of the cash
credit and shown under Cash deltas instead, with the payer, shares,
rate and pay date beside it, exactly where the same dividend lands on
an account without the flag. It counts when its pay date falls between
the dates of the two commits being compared, on the shares held at the
earlier one, and never for more than the cash that actually arrived. A
payer with a dividend-reinvestment lot in the window is left alone,
since its dividend bought shares instead. The figures come from zfin's
dividend data, so `--refresh-data=never` uses what is cached, and a
symbol with no data leaves its dividend counted as a contribution (zfin
logs a warning naming it).
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

@ -261,6 +261,11 @@ if counted as new money. So cash deltas are ignored by default. Set
is dominated by external deposits (payroll ESPP accrual, direct 401k
cash contributions).
Declared cash dividends on stocks the account holds are the exception:
they are split off the cash increase and stay uncounted, so an HSA that
both receives payroll and holds a paying fund counts only the payroll.
See [`zfin contributions`](../cli/contributions.md).
## `shielded` (umbrella exposure)
The umbrella-exposure estimate in [`zfin analysis`](../cli/analysis.md)

View file

@ -103,9 +103,18 @@
//! |-------------------------------|--------------------|----------------------|:--------------:|:--------------:|
//! | Brand-new cash lot appears | `new_cash` | New contributions | yes | yes |
//! | Existing cash, balance up | `cash_contribution`| New contributions | yes | yes |
//! | ...of which a dividend payout | `cash_delta` | Cash deltas (raw) | no | no |
//! | Existing cash, balance down | `cash_delta` | Cash deltas (raw) | no | no |
//! | Cash lot fully removed | `lot_removed` | Flagged for review | no | no |
//!
//! The dividend row is the one exception to "cash arriving is new
//! money". An opted-in account that HOLDS a payer (an HSA invested in a
//! fund) receives payroll and dividends into the same cash line, so the
//! declared dividend - pay date inside the diff window, on shares held at
//! its start, not reinvested - is carved out and reported as the
//! `cash_delta` it would be on any other account. See
//! `splitDividendCredits`.
//!
//! Opted-OUT accounts (default):
//!
//! | Scenario | Kind | Section | In Grand Total | In Attribution |
@ -652,6 +661,10 @@ fn prepareReport(
break :blk diffTransferLogs(arena, before_ptr, after_tl) catch return error.PrepareFailed;
};
const window = diffWindow(io, arena, env, repo.root, endpoints.range, as_of);
const account_map_ptr: ?*const analysis.AccountMap = if (account_map_opt) |*am| am else null;
const cash_dividends = loadDividendCredits(arena, svc, before_pf.portfolio.lots, account_map_ptr, window, refresh) catch return error.PrepareFailed;
const report = computeReport(
arena,
before_pf.portfolio.lots,
@ -659,9 +672,10 @@ fn prepareReport(
&prices,
as_of,
.{
.account_map = if (account_map_opt) |*am| am else null,
.account_map = account_map_ptr,
.transfer_log = new_records,
.window = diffWindow(io, arena, env, repo.root, endpoints.range, as_of),
.window = window,
.cash_dividends = cash_dividends,
},
) catch {
if (verbosity == .verbose) cli.stderrPrint(io, "Error computing contributions diff.\n");
@ -685,6 +699,66 @@ fn prepareReport(
/// a hard error.
const Verbosity = enum { verbose, silent };
/// Fetch declared dividends for the stocks held in opted-in accounts and
/// turn them into `ReportOptions.cash_dividends`.
///
/// Best-effort about DATA: this can only REMOVE a mis-booked dividend
/// from contributions, so anything missing - no account map, no window,
/// a fetch that fails - degrades to the previous behavior rather than
/// failing the report. A failed fetch is still named, because it is the
/// one case where the report goes on saying something the data would
/// have corrected: that symbol's dividend stays in contributions. Out of
/// memory is not a data problem and fails the report, as it does
/// everywhere else in `prepareReport`.
///
/// Honors the caller's refresh policy, so `--refresh-data=never` reads
/// whatever is cached and never touches the network. Only symbols held
/// in an opted-in account are fetched, which in practice is a handful.
fn loadDividendCredits(
arena: std.mem.Allocator,
svc: *zfin.DataService,
before: []const Lot,
account_map: ?*const analysis.AccountMap,
window: ?Window,
refresh: framework.RefreshPolicy,
) ![]const CashDividend {
const am = account_map orelse return &.{};
const w = window orelse return &.{};
const holdings = try dividendHoldings(arena, before, am, w.start);
if (holdings.len == 0) return &.{};
var divs = std.StringHashMap([]const zfin.Dividend).init(arena);
const fetch_opts = cli.fetchOptionsFromPolicy(refresh);
for (holdings) |h| {
if (divs.contains(h.symbol)) continue;
fetchDividendsInto(arena, svc, &divs, h.symbol, fetch_opts) catch |err| {
std.log.scoped(.contributions).warn("{s}: dividends unavailable ({t}); a dividend it paid into {s} will count as a contribution", .{ h.symbol, err, h.account });
};
}
return dividendCredits(arena, holdings, w, &divs);
}
/// One symbol's dividend history into `divs`, copied into `arena`.
///
/// Copied without `currency`, the one field that borrows from the
/// fetch result, so the copy outlives `result.deinit()`.
fn fetchDividendsInto(
arena: std.mem.Allocator,
svc: *zfin.DataService,
divs: *std.StringHashMap([]const zfin.Dividend),
symbol: []const u8,
fetch_opts: zfin.FetchOptions,
) !void {
const result = try svc.getDividends(symbol, fetch_opts);
defer result.deinit();
const copy = try arena.alloc(zfin.Dividend, result.data.len);
for (result.data, copy) |d, *dst| {
dst.* = d;
dst.currency = null;
}
try divs.put(symbol, copy);
}
/// 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
@ -1614,6 +1688,13 @@ const Change = struct {
/// transfer-matched lot is reclassified away from those kinds.
internal_funded: f64 = 0,
/// For a `cash_delta` carved out of a `cash_contribution` by
/// `splitDividendCredits`: which declared dividends it is, e.g.
/// "QTUM 197 sh x $0.4646 paid 2026-09-24". Printed beside the cash
/// line so the reader can see why money arriving on an opted-in
/// account was not counted. Null otherwise.
dividend_note: ?[]const u8 = null,
pub fn value(self: Change) f64 {
return self.delta_shares * self.unit_value;
}
@ -2258,6 +2339,16 @@ const ReportOptions = struct {
/// Null (tests, or a timestamp lookup that failed) keeps the
/// window-free behavior.
window: ?Window = null,
/// Declared cash dividends paid inside `window` on stocks held in
/// accounts marked `cash_is_contribution::true` - see
/// `splitDividendCredits`, the one consumer. `prepareReport` builds
/// it from `DataService.getDividends` via `dividendCredits`, so this
/// function stays pure and the tests can hand it figures directly.
///
/// Empty (tests, no opted-in account, no window, or no dividend
/// data) keeps the previous behavior: the whole cash increase on an
/// opted-in account counts as a contribution.
cash_dividends: []const CashDividend = &.{},
};
/// See `ReportOptions.window`.
@ -2266,6 +2357,97 @@ const Window = struct {
end: Date,
};
/// One declared cash dividend landing in an opted-in account inside the
/// diff window. See `ReportOptions.cash_dividends`.
const CashDividend = struct {
account: []const u8,
symbol: []const u8,
pay_date: Date,
per_share: f64,
/// Shares held at the window's start. The entitlement is fixed on the
/// ex-date, which precedes the pay date, and the before side is the
/// closest record of the position on it.
shares: f64,
fn amount(self: CashDividend) f64 {
return self.per_share * self.shares;
}
};
/// A stock position in an opted-in account at the window's start - the
/// set `prepareReport` fetches dividends for. See `dividendHoldings`.
const DividendHolding = struct {
account: []const u8,
symbol: []const u8,
shares: f64,
};
/// Stock positions held at `start` in accounts marked
/// `cash_is_contribution::true`, summed across lots.
///
/// Only those accounts, because they are the only ones where a dividend
/// is mis-booked: everywhere else a cash increase is already a
/// `cash_delta`, uncounted, which is exactly where a dividend belongs.
///
/// A lot priced through a `ticker::` alias is skipped. The alias's
/// dividend belongs to the proxy, not to the position (a direct-indexing
/// basket proxied as SPYM does not pay SPYM's distribution), so crediting
/// it would carve out money that never arrived.
fn dividendHoldings(
allocator: std.mem.Allocator,
before: []const Lot,
account_map: *const analysis.AccountMap,
start: Date,
) ![]const DividendHolding {
var out: std.ArrayList(DividendHolding) = .empty;
for (before) |lot| {
if (lot.security_type != .stock) continue;
if (lot.ticker != null) continue;
const acct = lot.account orelse continue;
if (!account_map.cashIsContribution(acct)) continue;
if (!lot.lotIsOpenAsOf(start)) continue;
for (out.items) |*h| {
if (std.mem.eql(u8, h.account, acct) and std.mem.eql(u8, h.symbol, lot.symbol)) {
h.shares += lot.shares;
break;
}
} else try out.append(allocator, .{ .account = acct, .symbol = lot.symbol, .shares = lot.shares });
}
return out.toOwnedSlice(allocator);
}
/// The declared payments in `dividends` (keyed by symbol) that land in
/// `(window.start, window.end]` for each holding.
///
/// Half-open like `cdMaturity`: a payment dated on the start day had
/// already landed when the before side was taken. A record with no
/// `pay_date` is skipped rather than dated from its ex-date - the lag
/// varies by issuer, and guessing it would put money in the wrong window.
fn dividendCredits(
allocator: std.mem.Allocator,
holdings: []const DividendHolding,
window: Window,
dividends: *const std.StringHashMap([]const zfin.Dividend),
) ![]const CashDividend {
var out: std.ArrayList(CashDividend) = .empty;
for (holdings) |h| {
const divs = dividends.get(h.symbol) orelse continue;
for (divs) |d| {
const pay = d.pay_date orelse continue;
if (!window.start.lessThan(pay)) continue;
if (window.end.lessThan(pay)) continue;
try out.append(allocator, .{
.account = h.account,
.symbol = h.symbol,
.pay_date = pay,
.per_share = d.amount,
.shares = h.shares,
});
}
}
return out.toOwnedSlice(allocator);
}
/// 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.
@ -2582,6 +2764,13 @@ fn computeReport(
});
}
// Declared dividends on opted-in accounts: carve them out of the
// `cash_contribution` they would otherwise be booked as. Runs after
// both diff passes, because the reinvestment guard reads the DRIP
// changes they emit, and before the transfer and intra-account
// matchers so both see the final split. See `splitDividendCredits`.
try splitDividendCredits(allocator, &changes, opts.cash_dividends);
// Transfer reclassification pass: rewrite destination/source
// Change kinds for records the caller passed in (typically the
// diff between before-side and after-side
@ -3683,6 +3872,121 @@ fn drawDownAgainstCashContribution(changes: *std.ArrayList(Change), account: []c
}
}
/// Move declared dividends out of `cash_contribution` and into
/// `cash_delta`, where every other account's dividends already are.
///
/// The opt-in's premise - "cash arriving here is new money" - is true of
/// payroll and false of a dividend, and on an account that HOLDS a
/// payer the two land in the same cash line. Observed 2026-09-27: both
/// HSAs hold QTUM, one account's entire +$91.53 was its dividend
/// (197 x $0.4646272) and the other's +$470.98 was a $364.58 payroll
/// deposit plus $106.40 of the same dividend. All of it was booked as
/// contribution, in this report and in `compare`, every quarter QTUM
/// pays.
///
/// A `cash_delta` rather than `internal_funded`, deliberately. The
/// resting-sale-proceeds case cancels a contribution through
/// `internal_funded`, which prints under "Internal purchases" as money
/// "from existing cash" - wrong for a dividend, which is new value, just
/// not new MONEY. As a `cash_delta` it lands in "Cash deltas" and in
/// the uncounted total (`isUncountedKind`), exactly as the same dividend
/// does on an account without the flag - so the flag changes where
/// payroll goes and nothing else.
///
/// Three limits keep this from eating a real contribution:
///
/// - CAPPED at the cash increase. A dividend partly spent inside the
/// window cannot be carved out of more cash than arrived.
/// - REINVESTED payers are skipped: a `new_drip_lot` or
/// `drip_confirmed` for the symbol in the same account means the
/// dividend bought shares, and the cash line never saw it.
/// `rollup_delta` is not taken as evidence either way - it cannot
/// tell a DRIP from a purchase.
/// - PER ACCOUNT, with the budget consumed across that account's cash
/// lots so one dividend is never carved twice.
///
/// A cash increase that is ENTIRELY dividend is reclassified in place,
/// so the account shows one line rather than a zero contribution beside
/// it. Otherwise the dividend is appended as its own `cash_delta` and
/// the contribution keeps the remainder.
fn splitDividendCredits(
allocator: std.mem.Allocator,
changes: *std.ArrayList(Change),
credits: []const CashDividend,
) !void {
if (credits.len == 0) return;
var carved = std.StringHashMap(f64).init(allocator);
defer carved.deinit();
// Only the changes the diff produced: an appended dividend line must
// not be revisited, and the reinvestment guard reads this same prefix.
const n = changes.items.len;
for (0..n) |i| {
const c = changes.items[i];
if (c.kind != .cash_contribution) continue;
if (c.unit_value <= 0) continue;
const increase = c.value();
if (increase <= 0.005) continue;
var declared: f64 = 0;
for (credits) |d| {
if (!creditApplies(changes.items[0..n], d, c.account)) continue;
declared += d.amount();
}
const already = carved.get(c.account) orelse 0;
const available = declared - already;
if (available <= 0.005) continue;
const carve = @min(available, increase);
try carved.put(c.account, already + carve);
var note: std.ArrayList(u8) = .empty;
for (credits) |d| {
if (!creditApplies(changes.items[0..n], d, c.account)) continue;
if (note.items.len > 0) try note.appendSlice(allocator, "; ");
try note.print(allocator, "{s} {d} sh x ${d:.4} paid {f}", .{ d.symbol, d.shares, d.per_share, d.pay_date });
}
if (carve + 0.005 < available) {
try note.print(allocator, " - {f} declared, capped at the {f} cash increase", .{ Money.from(available), Money.from(increase) });
}
const text = try note.toOwnedSlice(allocator);
if (increase - carve <= 0.005) {
changes.items[i].kind = .cash_delta;
changes.items[i].dividend_note = text;
continue;
}
changes.items[i].delta_shares -= carve / c.unit_value;
try changes.append(allocator, .{
.kind = .cash_delta,
.symbol = c.symbol,
.account = c.account,
.security_type = c.security_type,
.delta_shares = carve / c.unit_value,
.unit_value = c.unit_value,
.dividend_note = text,
});
}
}
/// Does `d` count against a cash increase on `account`? Same account,
/// and not reinvested there - see `splitDividendCredits`.
fn creditApplies(changes: []const Change, d: CashDividend, account: []const u8) bool {
if (!std.mem.eql(u8, d.account, account)) return false;
return !reinvestedIn(changes, d.account, d.symbol);
}
/// Did `symbol` reinvest into `account` in this diff? See
/// `splitDividendCredits`.
fn reinvestedIn(changes: []const Change, account: []const u8, symbol: []const u8) bool {
for (changes) |c| {
if (c.kind != .new_drip_lot and c.kind != .drip_confirmed) continue;
if (std.mem.eql(u8, c.account, account) and std.mem.eql(u8, c.symbol, symbol)) return true;
}
return false;
}
// ── CD payouts as funding (see `matchIntraAccountPurchases`) ──
/// A CD lot for the funding tests: $10k face, `open_price` 1.
@ -3755,6 +4059,240 @@ test "matchIntraAccountPurchases: a CD payout can't absorb an unrelated deposit
try std.testing.expectApproxEqAbs(@as(f64, 5000), attributionTotalForTest(report), 0.01);
}
// ── Declared dividends on opted-in accounts (see `splitDividendCredits`) ──
const hsa_accounts =
\\#!srfv1
\\account::Sample HSA,tax_type::hsa,cash_is_contribution:bool:true
\\account::Sample Brokerage,tax_type::taxable
\\
;
/// A September window, and a QTUM-shaped quarterly paying $0.4646272 on
/// the 24th.
const div_window: Window = .{ .start = Date.fromYmd(2026, 9, 19), .end = Date.fromYmd(2026, 9, 27) };
fn qtumCredit(account: []const u8, shares: f64) CashDividend {
return .{ .account = account, .symbol = "QTUM", .pay_date = Date.fromYmd(2026, 9, 24), .per_share = 0.4646272, .shares = shares };
}
fn hsaCash(shares: f64) Lot {
return .{ .symbol = "CASH", .shares = shares, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 1, .security_type = .cash, .account = "Sample HSA" };
}
const hsa_qtum: Lot = .{ .symbol = "QTUM", .shares = 229, .open_date = Date.fromYmd(2026, 4, 6), .open_price = 109.90, .account = "Sample HSA" };
fn cashDeltaIn(report: Report, account: []const u8) ?Change {
for (report.changes) |c| {
if (c.kind == .cash_delta and std.mem.eql(u8, c.account, account)) return c;
}
return null;
}
test "computeReport: a declared dividend on an opted-in account is a cash delta, not a contribution" {
// The real 2026-09-27 shape: +$470.98 of cash was a $364.58 payroll
// deposit plus a $106.40 QTUM dividend (229 x $0.4646272). All of it
// was booked as contribution. Only the payroll is.
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, hsa_accounts);
const credits = [_]CashDividend{qtumCredit("Sample HSA", 229)};
const report = try computeReport(a, &.{ hsa_qtum, hsaCash(2330.87) }, &.{ hsa_qtum, hsaCash(2801.85) }, &prices, div_window.end, .{
.account_map = &am,
.window = div_window,
.cash_dividends = &credits,
});
try std.testing.expectApproxEqAbs(@as(f64, 364.58), attributionTotalForTest(report), 0.01);
const div = cashDeltaIn(report, "Sample HSA").?;
try std.testing.expectApproxEqAbs(@as(f64, 106.40), div.value(), 0.01);
try std.testing.expectEqualStrings("QTUM 229 sh x $0.4646 paid 2026-09-24", div.dividend_note.?);
// It lands in the uncounted total, which is what `compare` prints as
// "Uncounted in" - the same place the dividend goes on an account
// without the flag.
const unc = uncountedTotals(report.changes);
try std.testing.expectApproxEqAbs(@as(f64, 106.40), unc.in, 0.01);
}
test "computeReport: a cash increase that is all dividend becomes one cash delta" {
// The other HSA that week: 197 x $0.4646272 = $91.53, the whole
// increase. One line, reclassified in place - not a $0 contribution
// beside a $91.53 delta.
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, hsa_accounts);
const credits = [_]CashDividend{qtumCredit("Sample HSA", 197)};
const report = try computeReport(a, &.{hsaCash(1146.05)}, &.{hsaCash(1237.58)}, &prices, div_window.end, .{
.account_map = &am,
.window = div_window,
.cash_dividends = &credits,
});
try std.testing.expectApproxEqAbs(@as(f64, 0), attributionTotalForTest(report), 0.01);
try std.testing.expectEqual(@as(usize, 0), countKind(report, .cash_contribution));
try std.testing.expectEqual(@as(usize, 1), countKind(report, .cash_delta));
try std.testing.expect(cashDeltaIn(report, "Sample HSA").?.dividend_note != null);
}
test "computeReport: a dividend is capped at the cash that actually arrived" {
// $106.40 declared, but cash rose only $50 - the rest went somewhere
// inside the window. Carving the full dividend would invent a negative
// contribution; carve what arrived, and say it was capped.
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, hsa_accounts);
const credits = [_]CashDividend{qtumCredit("Sample HSA", 229)};
const report = try computeReport(a, &.{hsaCash(1000)}, &.{hsaCash(1050)}, &prices, div_window.end, .{
.account_map = &am,
.window = div_window,
.cash_dividends = &credits,
});
try std.testing.expectApproxEqAbs(@as(f64, 0), attributionTotalForTest(report), 0.01);
const div = cashDeltaIn(report, "Sample HSA").?;
try std.testing.expectApproxEqAbs(@as(f64, 50), div.value(), 0.01);
try std.testing.expect(std.mem.indexOf(u8, div.dividend_note.?, "$106.40 declared, capped at the $50.00 cash increase") != null);
}
test "computeReport: a reinvested dividend is not carved out of a contribution" {
// A drip lot for the payer means the dividend bought shares and never
// reached cash. The whole cash increase is then payroll, and carving
// the dividend out of it would undercount a real 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);
try prices.put("QTUM", 160.0);
var am = try analysis.parseAccountsFile(a, hsa_accounts);
const credits = [_]CashDividend{qtumCredit("Sample HSA", 229)};
const drip: Lot = .{ .symbol = "QTUM", .shares = 0.665, .open_date = Date.fromYmd(2026, 9, 24), .open_price = 160.0, .drip = true, .account = "Sample HSA" };
const report = try computeReport(a, &.{ hsa_qtum, hsaCash(2330.87) }, &.{ hsa_qtum, drip, hsaCash(2695.45) }, &prices, div_window.end, .{
.account_map = &am,
.window = div_window,
.cash_dividends = &credits,
});
try std.testing.expectEqual(@as(usize, 1), countKind(report, .cash_contribution));
try std.testing.expectApproxEqAbs(@as(f64, 364.58), findKind(report, .cash_contribution).?.attributedValue(), 0.01);
try std.testing.expect(cashDeltaIn(report, "Sample HSA") == null);
}
test "computeReport: a dividend credited to one account leaves another's contribution alone" {
// Per account. A dividend declared into one HSA must not cancel a
// deposit landing in a second one.
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
\\account::Sample HSA,tax_type::hsa,cash_is_contribution:bool:true
\\account::Sample HSA 2,tax_type::hsa,cash_is_contribution:bool:true
\\
);
const credits = [_]CashDividend{qtumCredit("Sample HSA", 229)};
var other0 = hsaCash(100);
other0.account = "Sample HSA 2";
var other1 = other0;
other1.shares = 458.34;
const report = try computeReport(a, &.{other0}, &.{other1}, &prices, div_window.end, .{
.account_map = &am,
.window = div_window,
.cash_dividends = &credits,
});
try std.testing.expectApproxEqAbs(@as(f64, 358.34), attributionTotalForTest(report), 0.01);
try std.testing.expectEqual(@as(usize, 0), countKind(report, .cash_delta));
}
test "printReport: a carved dividend says what it is on the cash line" {
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, hsa_accounts);
const credits = [_]CashDividend{qtumCredit("Sample HSA", 229)};
const report = try computeReport(a, &.{ hsa_qtum, hsaCash(2330.87) }, &.{ hsa_qtum, hsaCash(2801.85) }, &prices, div_window.end, .{
.account_map = &am,
.window = div_window,
.cash_dividends = &credits,
});
var aw: std.Io.Writer.Allocating = .init(a);
try printReport(&aw.writer, &report, "test window", false);
const text = aw.written();
try std.testing.expect(std.mem.indexOf(u8, text, "(declared dividend: QTUM 229 sh x $0.4646 paid 2026-09-24)") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "New contributions / purchases: $364.58") != null);
}
test "dividendHoldings: opted-in stock positions at the window start, summed across lots" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
var am = try analysis.parseAccountsFile(a, hsa_accounts);
const start = Date.fromYmd(2026, 9, 19);
const lots = [_]Lot{
// Two lots of one payer: summed.
hsa_qtum,
.{ .symbol = "QTUM", .shares = 11, .open_date = Date.fromYmd(2026, 6, 23), .open_price = 164.08, .account = "Sample HSA" },
// Cash never pays a dividend of its own.
hsaCash(2330.87),
// Not opted in: its dividends are already uncounted cash deltas.
.{ .symbol = "QTUM", .shares = 600, .open_date = Date.fromYmd(2025, 5, 28), .open_price = 86.89, .account = "Sample Brokerage" },
// Proxied through a ticker alias: the proxy's dividend is not this lot's.
.{ .symbol = "DI-SPX", .ticker = "SPYM", .shares = 700, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.24, .account = "Sample HSA" },
// Sold before the window: entitled to nothing in it.
.{ .symbol = "XLV", .shares = 100, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 146.75, .close_date = Date.fromYmd(2026, 4, 6), .close_price = 146.46, .account = "Sample HSA" },
};
const got = try dividendHoldings(a, &lots, &am, start);
try std.testing.expectEqual(@as(usize, 1), got.len);
try std.testing.expectEqualStrings("Sample HSA", got[0].account);
try std.testing.expectEqualStrings("QTUM", got[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 240), got[0].shares, 0.001);
}
test "dividendCredits: only payments dated inside the window, and only with a pay date" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const holdings = [_]DividendHolding{
.{ .account = "Sample HSA", .symbol = "QTUM", .shares = 229 },
.{ .account = "Sample HSA", .symbol = "NODATA", .shares = 10 },
};
const qtum = [_]zfin.Dividend{
// Paid on the start day: already landed when the before side was taken.
.{ .ex_date = Date.fromYmd(2026, 9, 18), .pay_date = Date.fromYmd(2026, 9, 19), .amount = 1.0 },
// The one that counts.
.{ .ex_date = Date.fromYmd(2026, 9, 23), .pay_date = Date.fromYmd(2026, 9, 24), .amount = 0.4646272 },
// On the end day: inclusive.
.{ .ex_date = Date.fromYmd(2026, 9, 25), .pay_date = Date.fromYmd(2026, 9, 27), .amount = 0.01 },
// After the window.
.{ .ex_date = Date.fromYmd(2026, 12, 29), .pay_date = Date.fromYmd(2026, 12, 30), .amount = 0.45 },
// No pay date: never dated from the ex-date.
.{ .ex_date = Date.fromYmd(2026, 9, 22), .amount = 5.0 },
};
var divs = std.StringHashMap([]const zfin.Dividend).init(a);
try divs.put("QTUM", &qtum);
const got = try dividendCredits(a, &holdings, div_window, &divs);
try std.testing.expectEqual(@as(usize, 2), got.len);
try std.testing.expectApproxEqAbs(@as(f64, 106.40), got[0].amount(), 0.01);
try std.testing.expectEqual(@as(u8, 27), got[1].pay_date.day());
}
// ── CD maturity inside the diff window (see `ReportOptions.window`) ──
/// A window around a CD maturing 2026-03-01.
@ -4428,6 +4966,12 @@ fn printCashDeltaLine(out: *std.Io.Writer, c: Change, report: *const Report, col
try out.writeAll(" cash ");
try cli.printGainLoss(out, color, v, "{s}{f}", .{ sign, Money.from(@abs(v)) });
// Why cash arriving on an opted-in account was not counted: it is a
// declared dividend (see `splitDividendCredits`).
if (c.dividend_note) |n| {
try cli.printFg(out, color, cli.CLR_MUTED, " (declared dividend: {s})", .{n});
}
// Hint if a CD matured in the same account.
for (report.changes) |o| {
if (o.kind == .cd_matured and std.mem.eql(u8, o.account, c.account)) {
@ -6416,6 +6960,102 @@ test "prepareReport: a CD archived unchanged pays out in the commits that span i
try std.testing.expectApproxEqAbs(@as(f64, 0.0), summarizeAttribution(ctx).total(), 0.01);
}
/// Two commits of one opted-in HSA holding 229 QTUM: 09-19, then 09-27 with
/// cash up $470.98 - the real week's shape. Callers seed the cache.
fn commitHsaWeek(io: std.Io, allocator: std.mem.Allocator, tmp: *std.testing.TmpDir, dir: []const u8) !void {
const qtum = "symbol::QTUM,shares:num:229,open_date::2026-04-06,open_price:num:109.90,account::Sample HSA\n";
try tmp.dir.writeFile(io, .{ .sub_path = "accounts.srf", .data = hsa_accounts });
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = "#!srfv1\n" ++ qtum ++
"security_type::cash,shares:num:2330.87,open_date::2026-02-26,open_price:num:1,account::Sample HSA\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" });
try test_git.run(allocator, dir, "2026-09-19T12:00:00", &.{ "commit", "-q", "-m", "before" });
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = "#!srfv1\n" ++ qtum ++
"security_type::cash,shares:num:2801.85,open_date::2026-02-26,open_price:num:1,account::Sample HSA\n" });
try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf" });
try test_git.run(allocator, dir, "2026-09-27T12:00:00", &.{ "commit", "-q", "-m", "after" });
}
test "prepareReport: a cached dividend on an opted-in account leaves attribution" {
// End to end: the window from the two commits, the holding from the
// before side, the dividend from the cache - offline, as
// `--refresh-data=never` runs it. `summarizeAttribution` is what
// `compare` reads, so this pins both commands at once.
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];
try commitHsaWeek(io, allocator, &tmp, dir);
// The cache layout `DataService` reads: {cache_dir}/{SYMBOL}/dividends.srf.
try tmp.dir.createDirPath(io, "QTUM");
try tmp.dir.writeFile(io, .{ .sub_path = "QTUM/dividends.srf", .data =
\\#!srfv1
\\ex_date::2026-09-23,pay_date::2026-09-24,record_date::2026-09-23,amount:num:0.4646272,type::regular,currency::USD
\\ex_date::2026-06-24,pay_date::2026-06-25,record_date::2026-06-24,amount:num:0.26993581,type::regular,currency::USD
\\
});
var env = try std.testing.environ.createMap(allocator);
defer env.deinit();
const port_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
const paths: []const []const u8 = &.{port_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, 9, 28), false, .never, .silent) catch return error.PrepareFailed;
defer ctx.deinit();
try std.testing.expectApproxEqAbs(@as(f64, 364.58), summarizeAttribution(ctx).total(), 0.01);
const div = cashDeltaIn(ctx.report, "Sample HSA").?;
try std.testing.expectApproxEqAbs(@as(f64, 106.40), div.value(), 0.01);
try std.testing.expect(div.dividend_note != null);
}
test "prepareReport: with no dividend data the report runs and counts the cash as before" {
// The degradation path. Nothing cached and no network: the fetch fails,
// is logged by name, and the whole increase stays a contribution - the
// pre-existing behavior, never a failed report.
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];
try commitHsaWeek(io, allocator, &tmp, dir);
var env = try std.testing.environ.createMap(allocator);
defer env.deinit();
const port_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
const paths: []const []const u8 = &.{port_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, 9, 28), false, .never, .silent) catch return error.PrepareFailed;
defer ctx.deinit();
try std.testing.expectApproxEqAbs(@as(f64, 470.98), summarizeAttribution(ctx).total(), 0.01);
try std.testing.expect(cashDeltaIn(ctx.report, "Sample HSA") == null);
}
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.