avoid counting dividends as contributions
This commit is contained in:
parent
155c1fbcfb
commit
cc60c1991f
4 changed files with 668 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue