From cc60c1991f7980f11870bb2b198d9902a77a5345 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 30 Sep 2026 10:28:51 -0700 Subject: [PATCH] avoid counting dividends as contributions --- docs/guides/track-contributions.md | 7 + docs/reference/cli/contributions.md | 14 + docs/reference/config/accounts-srf.md | 5 + src/commands/contributions.zig | 644 +++++++++++++++++++++++++- 4 files changed, 668 insertions(+), 2 deletions(-) diff --git a/docs/guides/track-contributions.md b/docs/guides/track-contributions.md index f3f1d8e..f985609 100644 --- a/docs/guides/track-contributions.md +++ b/docs/guides/track-contributions.md @@ -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 diff --git a/docs/reference/cli/contributions.md b/docs/reference/cli/contributions.md index 1d29afc..b452769 100644 --- a/docs/reference/cli/contributions.md +++ b/docs/reference/cli/contributions.md @@ -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 diff --git a/docs/reference/config/accounts-srf.md b/docs/reference/config/accounts-srf.md index 1fbb99e..1a510ab 100644 --- a/docs/reference/config/accounts-srf.md +++ b/docs/reference/config/accounts-srf.md @@ -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) diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 281d984..0b23a40 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -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.