zfin/src/commands/contributions.zig

8334 lines
375 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `zfin contributions` - show money added to the portfolio since the
//! last recorded state in git.
//!
//! Four modes:
//! - No flags (default):
//! - dirty working tree: HEAD vs working copy
//! - clean working tree: HEAD~1 vs HEAD (review last commit)
//! - `--since <DATE>`: commit-at-or-before(DATE) vs HEAD (or working copy if dirty)
//! - `--since <D1> --until <D2>`: commit-at-or-before(D1) vs commit-at-or-before(D2)
//! - `--until <DATE>` alone: rejected; window is ambiguous
//!
//! The `--since` / `--until` flags use `commitAtOrBeforeDate` in
//! `src/git.zig`, which runs `git log --until=<DATE> -1 -- portfolio.srf`
//! to pick the most recent commit at or before the requested date.
//! Relative forms (1M, 3Q, 1Y) are also accepted - parsed by
//! `cli.parseAsOfDate` and resolved to an absolute date before being
//! passed in.
//!
//! ## Classification matrix
//!
//! Every lot-level change gets exactly one `ChangeKind` assigned at
//! diff time in `computeReport`. Downstream consumers (section printer,
//! per-account summary, `computeAttributionSpec` used by `compare`) all
//! read the pre-classified kinds - there is no post-hoc reclassification
//! in any consumer. Single point of truth so the grand total in
//! `zfin contributions` and the attribution line in `zfin compare`
//! always agree over the same window.
//!
//! ### Base classifications (same for every account)
//!
//! - `new_stock` - stock lot appeared (drip::false)
//! - `new_drip_lot` - stock lot appeared (drip::true -> confirmed DRIP)
//! - `new_cash` - cash lot appeared (fresh line, not a balance bump)
//! - `new_cd` - CD opened
//! - `new_option` - option opened
//! - `drip_confirmed` - same-key drip::true stock lot, Δshares > 0
//! - `rollup_delta` - same-key drip::false stock lot, Δshares > 0
//! (ambiguous: DRIP or contribution)
//! - `drip_negative` - same-key stock lot, Δshares < 0 (unusual)
//! - `cash_delta` - same-key cash lot, Δshares, default noise
//! - `cd_matured` - CD disappeared, maturity_date ≤ today
//! - `cd_removed_early` - CD disappeared, maturity_date > today
//! - `lot_removed` - stock/cash/option lot disappeared
//! - `lot_edited` - secondary-key match across broken strict keys
//! (open_date/open_price/symbol-alias rewrite)
//! - `price_only` - same-key, only the `price::` field changed
//! - `flagged` - any other edit shape (maturity_date change, etc.)
//!
//! ### Intra-account netting (`matchIntraAccountPurchases`)
//!
//! Money that was already inside an account is not a fresh
//! contribution - it just changed form. Two shapes of that, both
//! showing up as changes on the SAME account:
//!
//! A plain buy made with cash already in the account: a `new_stock` /
//! `new_cd` lot appearing, and the account's cash going down (a
//! negative `cash_delta`, or a cash `lot_removed` if the line was
//! fully spent).
//!
//! A reallocation - sell A, buy B in the same account: a valued
//! outflow (`lot_removed`, or `drip_negative` for a partial sale) and
//! one or more new lots. Sale proceeds that were spent fund the new
//! lots; proceeds still sitting in cash at window end instead cancel a
//! `cash_contribution`, whose "cash arriving is new money" premise is
//! false for a sale. See `matchIntraAccountPurchases` for the
//! spent/resting split and why it is load-bearing.
//!
//! Either way the funded amount is recorded on the Change's
//! `internal_funded` and `attributedValue()` subtracts it, so the
//! funded portion leaves attribution everywhere at once (report,
//! per-account totals, compare, audit large-lot nudge) - no
//! `transaction_log.srf` entry required.
//!
//! | Scenario | Kind | Section | In Grand Total | In Attribution |
//! |------------------------------------------------|--------------|------------------------|:--------------:|:--------------:|
//! | Buy fully funded by same-account cash decrease | `new_stock` | Internal purchases | no | no |
//! | Buy partly funded (cash + new money) | `new_stock` | New contributions (residual) + Internal purchases | residual | residual |
//! | Buy funded by a same-account security sale | `new_stock` | Internal purchases | no | no |
//! | Sale proceeds parked in cash on a `cash_is_contribution` account | `cash_contribution` | Internal purchases | no | no |
//!
//! Runs AFTER the transfer matcher so explicit `transaction_log.srf`
//! records win: cash already credited to a `transfer_out` isn't in the
//! budget, and a lot already flipped to `transfer_in` is no longer
//! `new_stock` / `new_cd`. Scope on the destination side is deliberately
//! narrow - only brand-new `new_stock` / `new_cd` lots and
//! `cash_contribution`, same account. `new_drip_lot`,
//! `rollup_delta` / `drip_confirmed`, and `partial_transfer_in`
//! residuals are left untouched (see `matchIntraAccountPurchases`).
//!
//! ### Cash-account opt-in (`cash_is_contribution::true` in accounts.srf)
//!
//! Most cash-account activity is internal flow - DRIP cash legs,
//! dividend credits, CD coupons, settlement sweeps - which is why
//! `cash_delta` is noise by default. But for payroll-adjacent
//! accounts (ESPP accrual, direct 401k cash deposits, HSA employer
//! contributions), a positive cash movement IS the contribution.
//! The `cash_is_contribution::true` flag in `accounts.srf` opts an
//! account into "positive cash_delta = real contribution" semantics.
//!
//! Opted-IN accounts:
//!
//! | Scenario | Kind | Section | In Grand Total | In Attribution |
//! |-------------------------------|--------------------|----------------------|:--------------:|:--------------:|
//! | Brand-new cash lot appears | `new_cash` | New contributions | yes | yes |
//! | Existing cash, balance up | `cash_contribution`| New contributions | yes | yes |
//! | Existing cash, balance down | `cash_delta` | Cash deltas (raw) | no | no |
//! | Cash lot fully removed | `lot_removed` | Flagged for review | no | no |
//!
//! Opted-OUT accounts (default):
//!
//! | Scenario | Kind | Section | In Grand Total | In Attribution |
//! |-------------------------------|--------------------|----------------------|:--------------:|:--------------:|
//! | Brand-new cash lot appears | `new_cash` | New contributions | yes | yes |
//! | Existing cash, balance moves | `cash_delta` | Cash deltas (raw) | no | no |
//! | Cash lot fully removed | `lot_removed` | Flagged for review | no | no |
//!
//! The asymmetry on negative Δ for opted-in accounts is intentional:
//! a withdrawal from ESPP/HSA is rare and semantically different
//! from "a contribution of negative money." The branch for that
//! (withdraw-as-negative-contribution) is one `if (delta < 0)` in
//! `computeReport` if/when the need arises.
//!
//! ### Direct-indexing accounts (`direct_indexing::true` in accounts.srf)
//!
//! Direct-indexing proxies hold a basket of underlying stocks
//! tracked as a single benchmark via `ticker::`. The basket
//! naturally drifts against the benchmark week-to-week (tracking
//! error) and the user rebalances periodically - producing small
//! share-count adjustments that aren't real money flow.
//!
//! When a lot in a flagged account goes through `detectEdits`
//! (strict-key broken but secondary key matches), the residual
//! share-delta tolerance is loosened from 0.01% to 1%. The identity
//! match still collapses to `lot_edited`; residuals under 1% are
//! suppressed entirely instead of surfacing as `rollup_delta` /
//! `drip_negative`. Real contributions to direct-indexing accounts
//! (e.g. a $100k buy-in on a multi-million basket = ~1.2%) still
//! surface because they're above the tolerance.
//!
//! The `zfin audit` command uses the same flag for its companion
//! behavior: emit a `price_ratio` adjustment suggestion for these
//! lots even though their ratio is 1.0, bridging the brokerage-vs-
//! portfolio value gap that accumulates from tracking error.
//!
//! ### Transfer reclassification (`transaction_log.srf`)
//!
//! Records in `transaction_log.srf` flag internal money movement
//! between accounts the user owns. When present, the matcher runs
//! after Pass 1/Pass 2 and flips destination/source Changes to
//! dedicated transfer kinds that contribute $0 to attribution -
//! fixing the double-count where a transfer's destination lot would
//! otherwise read as a fresh external contribution.
//!
//! Four reclassification kinds emerge from the matcher:
//!
//! - `transfer_in` - destination lot (or cash-dest pool)
//! fully attributed to a transfer.
//! Replaces the base `new_*` kind.
//! - `partial_transfer_in` - destination lot partially attributed.
//! Residual (`value() - transfer_attributed`)
//! still counts toward attribution as
//! "pre-existing cash that funded the
//! rest of the lot."
//! - `transfer_out` - sending-side match (negative
//! `cash_delta` or `lot_removed`). Best-
//! effort: a missing `from` side is
//! silent, not an error (the sending
//! account may not be in portfolio.srf).
//! - `unmatched_transfer` - record that couldn't be matched.
//! Surfaces in the Flagged section with
//! a reason string; the transfer amount
//! stays out of attribution either way.
//!
//! | Scenario | Kind | Section | In Grand Total | In Attribution |
//! |-------------------------------------------------|--------------------------------|------------------------------------------|:--------------:|:--------------:|
//! | Lot fully funded by transfer | `transfer_in` | Transfers | no | no |
//! | Lot partially funded by transfer | `partial_transfer_in` | New contributions (residual) + Transfers | residual | residual |
//! | Sending-side `lot_removed` / `cash_delta` | `transfer_out` | Transfers | no | no |
//! | In-kind securities moved between accounts | `transfer_in` + `transfer_out` | Transfers | no | no |
//! | Record with no match (bad dest, mismatch, ...) | `unmatched_transfer` | Flagged | no | no |
//!
//! `type::cash` records and `type::in_kind` records take different
//! matching passes. Cash records match a cash budget / pooled
//! destination (see `matchCashDestination` / `matchLotDestination`).
//! In-kind records (securities moved without cash changing hands)
//! pair a source share-removal Change against a destination share-
//! addition Change on a per-symbol basis (see `matchInKindTransfer`).
//!
//! Cash-destination records don't flip the original `cash_delta` /
//! `new_cash` Change (a single cash delta can be drained by
//! multiple records, which `kind` can't represent). Instead the
//! matcher appends a synthetic `transfer_in` Change for the
//! Transfers section and accumulates the attributed amount onto each
//! consumed Change's `transfer_attributed` field. `attributedValue()`
//! then reports the unattributed residual, which is what the
//! per-account totals, the contributions sections, and audit's
//! large-lot filter all consume - one mechanism, so no two views of
//! the same attribution can drift apart.
//!
//! ### Which records does the matcher consider?
//!
//! The matcher runs against the records that are NEW in the after-side
//! `transaction_log.srf` relative to the before-side. Concretely:
//! `prepareReport` loads the file at both `before_rev` and the
//! after-side (working copy or `after_rev`), parses each, and passes
//! the set difference (`after - before`, by `TransferRecord.eql`) to
//! the matcher. See `diffTransferLogs`.
//!
//! Why not filter by `transfer::DATE` against the diff's git
//! timestamp window? Because the user's natural workflow is to
//! record a transfer days, weeks, or months after the actual
//! transaction date. A back-dated record is the rule, not the
//! exception. The original date-window filter rejected those
//! back-dated records and produced "unmatched contribution" noise
//! the user couldn't quiet without changing the record's date
//! to fall inside the diff window - a workaround that destroyed
//! the historical accuracy of `transaction_log.srf`.
//!
//! Editing a previously-recorded transfer (e.g. fixing a typo in
//! `from::`) produces an old-form record in `before` and a new-form
//! record in `after`. The old form silently drops out (its diff cycle
//! is over); the new form is treated as a fresh record and re-pairs
//! against the current diff. If no matching destination Change exists
//! in the current diff (because the lot was added in an earlier
//! commit), the record surfaces as `unmatched_transfer` - accept the
//! noise or undo the edit.
//!
//! The matcher also composes with `direct_indexing::true`: a transfer
//! into a direct-indexing account matches normally on the destination
//! lot; subsequent tracking-error drift on that lot is still swallowed
//! by the direct-indexing tolerance. Different problems, different
//! passes.
//!
//! ## Other architecture notes
//!
//! Relies on: portfolio.srf being tracked in a git repo, and the `git`
//! executable existing on PATH. We never rely on comments; maturity is
//! decided from the Lot.maturity_date field, not from the file's form.
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const git = @import("../git.zig");
const framework = @import("framework.zig");
const TimeRange = @import("TimeRange.zig");
const analysis = @import("../analytics/analysis.zig");
const transaction_log = @import("../models/transaction_log.zig");
const portfolio_loader = @import("../portfolio_loader.zig");
const history = @import("../history.zig");
const test_git = @import("../testutil/git.zig");
const Money = @import("../Money.zig");
const Date = zfin.Date;
const Lot = zfin.Lot;
const LotType = @import("../models/portfolio.zig").LotType;
// ── Public entry point ───────────────────────────────────────
/// Resolved endpoints for the contributions diff: the before/after
/// commit range (from `git.resolveCommitRange`) plus the
/// human-readable label for the report header.
const Endpoints = struct {
range: git.CommitRange,
label: []const u8,
};
pub const ParsedArgs = struct {
before: ?git.CommitSpec = null,
after: ?git.CommitSpec = null,
};
pub const meta: framework.Meta = .{
.name = "contributions",
.group = .timeseries,
.synopsis = "Show money added since the last recorded state in git",
.help =
\\Usage: zfin contributions [opts]
\\
\\Show contributions, withdrawals, and lot-level changes between
\\two points in the portfolio's git history. Four modes:
\\
\\ No flags (default):
\\ dirty working tree: HEAD vs working copy
\\ clean working tree: HEAD~1 vs HEAD (review last commit)
\\ --since <DATE> the commit recording DATE's snapshot vs
\\ HEAD (or working copy when dirty)
\\ --since <D1> --until <D2> the commits recording each snapshot
\\ --until <DATE> alone rejected; window is ambiguous
\\
\\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y).
\\
\\A date resolves to the commit that recorded `history/DATE-portfolio.srf`,
\\which is the same anchor `zfin compare` uses - so the two agree on what a
\\given week means. With no snapshot committed for that date it falls back to
\\commit-at-or-before(DATE) and says so.
\\
\\Options:
\\ --since <DATE> Earliest side (the commit recording
\\ that date's snapshot).
\\ --until <DATE> Latest side. Pair with --since.
\\ --commit-before <SPEC> Pin the before commit directly. Same
\\ grammar as --commit-after, minus
\\ `working`. Useful when you committed
\\ after your review date.
\\ --commit-after <SPEC> Pin the after commit. SPEC accepts
\\ YYYY-MM-DD, relative (1W/1M/1Q/1Y),
\\ HEAD, HEAD~N, hex SHA, or `working`
\\ for the working copy.
\\
\\--since and --commit-before describe the same axis; pass at most
\\one. Same for --until and --commit-after.
\\
,
.uppercase_first_arg = false,
.user_errors = error{ DuplicateEndpoint, InvalidArg, PrepareFailed, ResolveFailed, UnexpectedArg },
};
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
const io = ctx.io;
const today = ctx.today;
const allocator = ctx.allocator;
const tr_result = TimeRange.parse(io, allocator, today, cmd_args, .{
.accept_since = true,
.accept_until = true,
.accept_commit_before = true,
.accept_commit_after = true,
}) catch |err| switch (err) {
error.MissingValue,
error.InvalidValue,
error.WorkingCopyOnBeforeSide,
error.LiveNotAllowed,
=> return error.InvalidArg,
error.DuplicateEndpoint, error.RepeatedFlag => return error.DuplicateEndpoint,
error.OutOfMemory => return error.OutOfMemory,
};
defer allocator.free(tr_result.consumed);
// Reject any tokens TimeRange didn't consume - contributions has
// no other flags or positionals.
var consumed_set = std.AutoHashMap(usize, void).init(allocator);
defer consumed_set.deinit();
for (tr_result.consumed) |idx| try consumed_set.put(idx, {});
for (cmd_args, 0..) |a, i| {
if (consumed_set.contains(i)) continue;
cli.stderrPrint(io, "Error: unexpected argument to 'contributions': ");
cli.stderrPrint(io, a);
cli.stderrPrint(io, "\n");
return error.UnexpectedArg;
}
// Translate Endpoint to CommitSpec. `--since` produces a date
// endpoint; `--commit-before` a commit_spec endpoint. Map both
// to the same CommitSpec union the existing run() expects.
//
// A date becomes `.snapshot_add`, NOT `.date_at_or_before`, so this command
// resolves a window the same way `compare` does. They used to disagree, and
// the disagreement was load-bearing in the worst way: `compare`'s uncounted
// line says "see `zfin contributions`" and REPORT_RUNBOOK sends you to
// `zfin contributions --since 1W` to explain `compare`'s own contributions
// figure - while `commitAtOrBeforeDate` could land on a commit BEFORE the
// reconcile that the snapshot-anchored side had already stepped past. The two
// then reported different money for the same requested week.
//
// Safe as a default because `.snapshot_add` degrades to exactly the old
// `commitAtOrBeforeDate` behaviour whenever no snapshot for that date was
// committed (see `git.resolveSpec`), so any date without a snapshot resolves
// bit-for-bit as before. Where a snapshot DOES exist it also inherits the
// anchor verification and its warning, which the date path never had.
var parsed: ParsedArgs = .{};
if (tr_result.range.before) |ep| switch (ep) {
.date => |d| parsed.before = .{ .snapshot_add = d },
.commit_spec => |s| parsed.before = s,
.live => unreachable,
};
if (tr_result.range.after) |ep| switch (ep) {
.date => |d| parsed.after = .{ .snapshot_add = d },
.commit_spec => |s| parsed.after = s,
.live => unreachable,
};
// Validate `--since on or before --until` ordering for the date form, matching
// legacy behavior. Only checkable when BOTH sides carry a date - a `--commit-*`
// ref is opaque until git resolves it.
//
// Goes through `specDate` rather than testing for one variant. It used to read
// `b == .date_at_or_before`, which silently stopped rejecting inverted windows
// the moment a date started producing `.snapshot_add`.
if (parsed.before) |b| if (parsed.after) |a| {
if (specDate(b)) |bd| if (specDate(a)) |ad| {
if (bd.days > ad.days) {
cli.stderrPrint(io, "Error: --since must be on or before --until.\n");
return error.InvalidArg;
}
};
};
return parsed;
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
const io = ctx.io;
const allocator = ctx.allocator;
const out = ctx.out;
const color = ctx.color;
const as_of = ctx.today;
const before = parsed.before;
const after = parsed.after;
var pf = ctx.resolvePortfolioPaths() catch {
cli.stderrPrint(io, "Error: could not resolve portfolio file(s).\n");
return;
};
defer pf.deinit();
if (pf.paths.len == 0) {
cli.stderrPrint(io, "Error: No portfolio file found\n");
return;
}
return runImpl(io, allocator, ctx.environ_map, svc, pf.paths, before, after, as_of, color, ctx.globals.refresh_policy, out);
}
fn runImpl(
io: std.Io,
allocator: std.mem.Allocator,
env: *const std.process.Environ.Map,
svc: *zfin.DataService,
paths: []const []const u8,
before: ?git.CommitSpec,
after: ?git.CommitSpec,
as_of: Date,
color: bool,
refresh: framework.RefreshPolicy,
out: *std.Io.Writer,
) !void {
// Arena for all transient allocations: git subprocess buffers, duped path
// strings, the snapshot blobs, the symbol set, price map, and the Report
// itself. Portfolio objects use the base allocator (they own their own
// deinit). One defer cleans everything up at once.
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Enforce the "an `after` with no `before` is ambiguous" rule at
// the entry point so `resolveEndpoints`/`git.resolveCommitRangeSpec`
// can assume the invariant. The legacy no-flag path passes both
// as null and falls through to HEAD~1..HEAD / HEAD..WC.
if (before == null and after != null) {
cli.stderrPrint(io, "Error: --until / --commit-after requires --since / --commit-before.\n");
return;
}
var ctx = prepareReport(io, allocator, arena, env, svc, paths, before, after, as_of, color, refresh, .verbose) catch return;
defer ctx.deinit();
try printReport(out, &ctx.report, ctx.endpoints.label, color);
try out.flush();
}
/// Shared pipeline context: everything `run` and `computeAttributionSpec`
/// both need from the git-backed diff.
///
/// Owned fields split across two allocators:
/// - `before_pf` / `after_pf` are merged multi-file loads on the base
/// allocator (their own `deinit` frees lots and file bytes).
/// - `endpoints` and `report` live in the supplied arena; the arena's
/// own `deinit` cleans them up.
/// `deinit` releases only the base-allocator-owned pieces.
const ReportContext = struct {
endpoints: Endpoints,
before_pf: portfolio_loader.LoadedPortfolio,
after_pf: portfolio_loader.LoadedPortfolio,
report: Report,
allocator: std.mem.Allocator,
fn deinit(self: *ReportContext) void {
self.before_pf.deinit(self.allocator);
self.after_pf.deinit(self.allocator);
}
};
const PrepareError = error{PrepareFailed};
/// Run the common pipeline - resolve endpoints, load the merged
/// portfolio at both revisions, fetch prices, build the report.
///
/// Shared between `run` (prints the report) and
/// `computeAttributionImpl` (aggregates totals). Centralizes the git
/// plumbing and the price-loading step; callers own their output
/// decisions.
///
/// `paths` is the whole `portfolio*.srf` glob, and every file is read
/// at both revisions and merged - the same union the live loader and
/// the TUI see. Reading only the first file made a sold lot moved into
/// a sibling `portfolio_closed.srf` look like a bare disappearance,
/// and hid the `close_price` that records what the sale actually
/// realized. `paths[0]` remains the anchor for sibling-file derivation
/// (`accounts.srf`, `transaction_log.srf`), matching
/// `LoadedPortfolio`'s documented convention.
///
/// Stderr output is gated by `verbosity`: `.verbose` is the `run`
/// path (user sees why things failed); `.silent` is the attribution
/// path (failure just means "no attribution line", don't nag).
fn prepareReport(
io: std.Io,
allocator: std.mem.Allocator,
arena: std.mem.Allocator,
env: *const std.process.Environ.Map,
svc: *zfin.DataService,
paths: []const []const u8,
before_spec: ?git.CommitSpec,
after_spec: ?git.CommitSpec,
as_of: Date,
color: bool,
refresh: framework.RefreshPolicy,
verbosity: Verbosity,
) PrepareError!ReportContext {
if (paths.len == 0) {
if (verbosity == .verbose) cli.stderrPrint(io, "Error: No portfolio file found\n");
return error.PrepareFailed;
}
const portfolio_path = paths[0];
const repo = git.findRepo(io, arena, env, portfolio_path) catch |err| {
if (verbosity == .verbose) {
switch (err) {
error.NotInGitRepo => cli.stderrPrint(io, "Error: contributions requires portfolio.srf to be in a git repo.\n"),
error.GitUnavailable => cli.stderrPrint(io, "Error: could not run 'git'. Is git installed and on PATH?\n"),
else => cli.stderrPrint(io, "Error locating git repo.\n"),
}
}
return error.PrepareFailed;
};
// Dirty/untracked status covers EVERY portfolio file, not just the
// anchor: editing a sibling is just as much a working-copy change,
// and treating the tree as clean would diff HEAD~1..HEAD and miss
// the edit entirely.
var rel_paths: std.ArrayList([]const u8) = .empty;
var dirty = false;
for (paths) |abs| {
const rel = git.relPathIn(arena, repo, abs) catch return error.PrepareFailed;
rel_paths.append(arena, rel) catch return error.PrepareFailed;
const status = git.pathStatus(io, arena, env, repo.root, rel) catch {
if (verbosity == .verbose) cli.stderrPrint(io, "Error: could not determine git status of portfolio.srf.\n");
return error.PrepareFailed;
};
if (status == .untracked) {
if (verbosity == .verbose) {
var buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "Error: {s} is not tracked in git. Add and commit it first.\n", .{rel}) catch "Error: portfolio file is not tracked in git. Add and commit it first.\n";
cli.stderrPrint(io, msg);
}
return error.PrepareFailed;
}
if (status == .modified) dirty = true;
}
const endpoints = resolveEndpoints(io, arena, env, repo, rel_paths.items, before_spec, after_spec, dirty, verbosity) catch return error.PrepareFailed;
// Load the merged view at both endpoints: before is always from
// git; after is either from git (at some revision) or from the
// working copy. Files absent at a revision are skipped, so a
// portfolio that gained a sibling file mid-history still diffs.
var before_pf = portfolio_loader.loadPortfolioFromPathsAtRev(io, allocator, env, paths, endpoints.range.before_rev, as_of) orelse {
if (verbosity == .verbose) cli.stderrPrint(io, "Error reading before-side portfolio from git.\n");
return error.PrepareFailed;
};
errdefer before_pf.deinit(allocator);
var after_pf = if (endpoints.range.after_rev) |rev|
portfolio_loader.loadPortfolioFromPathsAtRev(io, allocator, env, paths, rev, as_of) orelse {
if (verbosity == .verbose) cli.stderrPrint(io, "Error reading after-side portfolio from git.\n");
return error.PrepareFailed;
}
else
portfolio_loader.loadPortfolioFromPaths(io, allocator, paths, as_of) orelse {
if (verbosity == .verbose) cli.stderrPrint(io, "Error reading working-copy portfolio file.\n");
return error.PrepareFailed;
};
errdefer after_pf.deinit(allocator);
// Fetch current prices (cache-hit preferred) for DRIP/share-delta valuation.
var prices = std.StringHashMap(f64).init(arena);
var sym_set = std.StringHashMap(void).init(arena);
for (before_pf.portfolio.lots) |l| {
if (l.security_type == .stock and !(l.price != null and l.ticker == null)) {
sym_set.put(l.priceSymbol(), {}) catch return error.PrepareFailed;
}
}
for (after_pf.portfolio.lots) |l| {
if (l.security_type == .stock and !(l.price != null and l.ticker == null)) {
sym_set.put(l.priceSymbol(), {}) catch return error.PrepareFailed;
}
}
var syms: std.ArrayList([]const u8) = .empty;
var sit = sym_set.keyIterator();
while (sit.next()) |k| syms.append(arena, k.*) catch return error.PrepareFailed;
if (syms.items.len > 0) {
var load_result = cli.loadPortfolioPrices(io, svc, syms.items, &.{}, refresh, color);
defer load_result.deinit();
var pit = load_result.prices.iterator();
while (pit.next()) |entry| {
prices.put(entry.key_ptr.*, entry.value_ptr.*) catch return error.PrepareFailed;
}
}
// Load accounts.srf (if present) so opt-in cash-delta
// reclassification fires at diff time. When missing or
// unparseable, computeReport falls back to the default cash_delta
// classification - no account gets the opt-in treatment.
var account_map_opt = svc.loadAccountMap(allocator, portfolio_path);
defer if (account_map_opt) |*am| am.deinit();
// Load transaction_log.srf from BOTH sides of the diff. The
// matcher considers only records that are NEW in the after-side
// log (i.e. added to `transaction_log.srf` since `before_rev`).
// This decouples record matching from the record's
// `transfer::DATE`, allowing back-dated entries to pair with
// the diff that introduced them.
//
// Path is sibling to portfolio.srf in the same git repo.
// Missing-on-either-side is OK: file may not have existed in
// before_rev (treat as empty) or may not exist in working copy
// (treat as no records -> matcher is a no-op).
const tlog_rel_path = blk: {
const dir_end = if (std.mem.lastIndexOfScalar(u8, repo.rel_path, '/')) |idx| idx + 1 else 0;
break :blk std.fmt.allocPrint(arena, "{s}transaction_log.srf", .{repo.rel_path[0..dir_end]}) catch return error.PrepareFailed;
};
var before_tlog_opt: ?transaction_log.TransactionLog = blk: {
const data = git.show(io, arena, env, repo.root, endpoints.range.before_rev, tlog_rel_path) catch break :blk null;
break :blk transaction_log.parseTransactionLogFile(arena, data) catch null;
};
defer if (before_tlog_opt) |*tl| tl.deinit();
var after_tlog_opt: ?transaction_log.TransactionLog = blk: {
if (endpoints.range.after_rev) |rev| {
const data = git.show(io, arena, env, repo.root, rev, tlog_rel_path) catch break :blk null;
break :blk transaction_log.parseTransactionLogFile(arena, data) catch null;
} else {
break :blk svc.loadTransferLog(portfolio_path);
}
};
defer if (after_tlog_opt) |*tl| tl.deinit();
// Diff: keep only the records new in after. The slice borrows
// from after_tlog_opt's record memory; both must outlive the
// matcher call below (they do - same arena scope).
const new_records: ?[]const transaction_log.TransferRecord = blk: {
const after_tl = if (after_tlog_opt) |*tl| tl else break :blk null;
const before_ptr: ?*const transaction_log.TransactionLog = if (before_tlog_opt) |*tl| tl else null;
break :blk diffTransferLogs(arena, before_ptr, after_tl) catch return error.PrepareFailed;
};
const report = computeReport(
arena,
before_pf.portfolio.lots,
after_pf.portfolio.lots,
&prices,
as_of,
.{
.account_map = if (account_map_opt) |*am| am else null,
.transfer_log = new_records,
},
) catch {
if (verbosity == .verbose) cli.stderrPrint(io, "Error computing contributions diff.\n");
return error.PrepareFailed;
};
return .{
.endpoints = endpoints,
.before_pf = before_pf,
.after_pf = after_pf,
.allocator = allocator,
.report = report,
};
}
/// Whether `resolveEndpoints` / `prepareReport` should print
/// explanatory stderr messages when the window can't be resolved. The
/// main `run` command uses `.verbose` so the user sees why the command
/// failed; the internal `computeAttributionSpec` helper uses `.silent`
/// because a missing git window is an expected null-return case, not
/// a hard error.
const Verbosity = enum { verbose, silent };
/// Resolve `since` / `until` flags plus dirty-working-tree state to
/// the pair of git revisions to diff, along with a human-readable
/// label for the report header.
///
/// Pure SHA-level resolution is delegated to `git.resolveCommitRange`;
/// this wrapper adds:
/// - Label formatting (CLI-level presentation concern).
/// - Stderr messages on failure (respecting `verbosity`).
/// - A friendly "resolved to the same commit" warning when
/// `--since` and `--until` collapse.
fn resolveEndpoints(
io: std.Io,
arena: std.mem.Allocator,
env: *const std.process.Environ.Map,
repo: git.RepoInfo,
rel_paths: []const []const u8,
before: ?git.CommitSpec,
after: ?git.CommitSpec,
dirty: bool,
verbosity: Verbosity,
) !Endpoints {
// Snap any requested date to the snapshot that actually exists at-or-before it,
// BEFORE handing it to git. `compare` has always done this and it is the other
// half of making the two commands agree: a bare `--since 1W` names a date one
// week back, which lands on a weekend more often than not, and there is no
// snapshot for a Saturday. Without snapping, `.snapshot_add` finds no file,
// degrades to `commitAtOrBeforeDate`, and the whole point is lost - which is
// exactly what `--since 1W` did while `compare 1W` was already correct.
const before_snapped = snapSpec(io, arena, repo, rel_paths, before, verbosity, "since");
const after_snapped = snapSpec(io, arena, repo, rel_paths, after, verbosity, "until");
const range = git.resolveCommitRangeSpec(io, arena, env, repo, rel_paths, before_snapped, after_snapped, dirty) catch |err| {
if (verbosity == .verbose) {
switch (err) {
error.NoCommitAtOrBefore => {
// Report which spec triggered it. When both are
// date specs we can't easily tell from here;
// covers both by naming the before-side.
var before_buf: [10]u8 = undefined;
const before_str = specDisplayString(before, &before_buf);
var msg_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: no commit of {s} at or before {s}.\n", .{ repo.rel_path, before_str }) catch "Error: no commit at or before requested date.\n";
cli.stderrPrint(io, msg);
},
error.InvalidArg => {
cli.stderrPrint(io, "Error: --commit-before cannot be `working`: diffing the working copy against itself is meaningless.\n");
},
else => {
cli.stderrPrint(io, "Error resolving commit range: ");
cli.stderrPrint(io, @errorName(err));
cli.stderrPrint(io, "\n");
},
}
}
return error.ResolveFailed;
};
// Label the endpoints based on the resolution mode. Matches the
// legacy phrasing where possible so existing test assertions still
// pass.
const label = try buildLabelFromSpecs(arena, range, before_snapped, after_snapped, dirty);
// Same-commit warning for the two-date window case. Legit confusion
// trigger - the user asked for a diff between two dates that both
// snap to the same commit (e.g., no activity in the window).
if (before_snapped != null and after_snapped != null and verbosity == .verbose) {
if (range.after_rev) |after_rev| {
if (std.mem.eql(u8, range.before_rev, after_rev)) {
cli.stderrPrint(io, "Warning: before and after resolve to the same commit; no changes to report.\n");
}
}
}
// Snap-note warning: when the user provided a date-form spec and
// the resolved commit's committer-date is >1 day before the
// requested date, emit a muted hint so the user can verify the
// selected commit matches their intent. See
// `docs/notes/commit-window-edge-case.md` (aka TODO.md) for the
// motivating scenario.
if (verbosity == .verbose) {
try maybeSnapNote(io, arena, env, repo, before_snapped, range.before_rev, "before", range.snapshot_anchor);
if (range.after_rev) |ar| {
try maybeSnapNote(io, arena, env, repo, after_snapped, ar, "after", range.snapshot_anchor_after);
}
}
// Deliberately NOT gated on `verbosity`. `.silent` exists because
// `computeAttributionSpec` calls this speculatively and an unresolvable window
// is an ordinary null return - but that is the `compare` path, and `compare`'s
// headline is exactly what a straddled anchor corrupts. This note only fires
// when a snapshot WAS resolved and disagreed with the copy on disk, so it can
// never fire on the case `.silent` was introduced for.
maybeSnapshotAnchorNote(io, range.snapshot_anchor);
maybeSnapshotAnchorNote(io, range.snapshot_anchor_after);
return .{ .range = range, .label = label };
}
/// Replace a `.snapshot_add` date with the nearest snapshot at-or-before it.
///
/// Returns the spec untouched for every other variant, and untouched when no
/// snapshot resolves at all - the degraded path is `git.resolveSpec`'s to handle,
/// and `maybeSnapNote` reports it from there.
///
/// Announces a snap the way `compare` does, because silently moving the date a
/// user typed is how two commands come to disagree about the same week while both
/// look right.
fn snapSpec(
io: std.Io,
arena: std.mem.Allocator,
repo: git.RepoInfo,
rel_paths: []const []const u8,
spec: ?git.CommitSpec,
verbosity: Verbosity,
flag: []const u8,
) ?git.CommitSpec {
const s = spec orelse return spec;
const requested = switch (s) {
.snapshot_add => |d| d,
else => return spec,
};
const first = if (rel_paths.len > 0) rel_paths[0] else return spec;
const pf_path = std.fs.path.join(arena, &.{ repo.root, first }) catch return spec;
const hist_dir = history.deriveHistoryDir(arena, pf_path) catch return spec;
const resolved = history.resolveSnapshotDate(io, arena, hist_dir, requested) catch return spec;
if (resolved.exact) return spec;
if (verbosity == .verbose) {
var buf: [200]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "(--{s} {f} has no snapshot; using {f}, the nearest at-or-before)\n", .{ flag, requested, resolved.actual }) catch "";
if (msg.len > 0) cli.stderrPrint(io, msg);
}
return .{ .snapshot_add = resolved.actual };
}
/// Say something when the snapshot anchor was not clean.
///
/// Both cases mean the same thing operationally: the value side of a comparison
/// reads `history/<date>-portfolio.srf` off disk while the attribution side reads
/// `portfolio.srf` at a commit, and those two can describe different portfolios.
/// When they do, `gains = delta - contributions` mixes windows and the error is
/// invisible in the output - it looks like a plausible number. The motivating case
/// had a review's contributions and a five-figure outflow counted in two
/// consecutive reports because the anchor predated the reconcile.
///
/// `corrected_from` is a note rather than a warning: the anchor moved and the
/// answer is now right, but the operator should know their history has a commit
/// where the snapshot and the portfolio disagreed, because that is a staging
/// slip that will recur.
fn maybeSnapshotAnchorNote(io: std.Io, anchor: ?git.SnapshotAnchor) void {
const a = anchor orelse return;
var buf: [460]u8 = undefined;
if (a.corrected_from) |from| {
const msg = std.fmt.bufPrint(&buf, "Note: the {f} snapshot was regenerated after it was first committed; anchoring the {s} side on {s} rather than {s}, which described different positions.\n", .{ a.date, a.side.label(), shortSha(a.commit), shortSha(from) }) catch return;
cli.stderrPrint(io, msg);
return;
}
if (a.unmatched) {
const msg = std.fmt.bufPrint(&buf, "Warning: no commit of the {f} snapshot matches the copy on disk, so the {s} side's values and attribution describe different portfolios. Contributions and gains for this window are NOT reliable - re-run `zfin snapshot --force` for that date and commit it alongside portfolio.srf.\n", .{ a.date, a.side.label() }) catch return;
cli.stderrPrint(io, msg);
}
}
/// Render a `CommitSpec` for user-facing error messages. Dates and
/// working-copy sentinels get formatted; refs are passed through.
/// When `spec` is null, returns "(unset)".
fn specDisplayString(spec: ?git.CommitSpec, date_buf: *[10]u8) []const u8 {
const s = spec orelse return "(unset)";
return switch (s) {
.git_ref => |r| r,
.date_at_or_before, .snapshot_add => |d| std.fmt.bufPrint(date_buf, "{f}", .{d}) catch "????-??-??",
.working_copy => "working",
};
}
/// If the user's before spec was a date form and the resolved commit
/// sits more than 1 day earlier than the requested date, print a
/// muted hint. Catches the "I committed after my review date" case
/// where `--since 1W` picks up a commit 7+ days before the cutoff.
fn maybeSnapNote(
io: std.Io,
arena: std.mem.Allocator,
env: *const std.process.Environ.Map,
repo: git.RepoInfo,
spec: ?git.CommitSpec,
resolved_ref: []const u8,
label: []const u8,
/// The resolved anchor for this side, when the spec was `.snapshot_add`. Null
/// means no snapshot for that date was committed and resolution FELL BACK to
/// `commitAtOrBeforeDate` - which is precisely the case this note is for.
anchor: ?git.SnapshotAnchor,
) !void {
const s = spec orelse return;
const requested_date = specDate(s) orelse return;
// A snapshot-anchored side that actually found its snapshot is not drifting -
// it is pinned to the commit that recorded that date, which is the point. Only
// the degraded path, where no snapshot existed and it fell back to a date, can
// land arbitrarily far from what was asked for.
//
// Written as "did the anchor resolve" rather than "is the spec a date", because
// the previous form (`.date_at_or_before => |d| d, else => return`) silenced
// this note outright the moment `--since` began producing `.snapshot_add`.
if (s == .snapshot_add and anchor != null) return;
// Get the committer-date of the resolved commit. `%ct` gives a
// Unix timestamp.
const ts = git.commitTimestamp(io, arena, env, repo.root, resolved_ref) catch return;
const commit_date = zfin.Date.fromEpoch(ts);
if (!commit_date.lessThan(requested_date)) return;
const gap_days = requested_date.days - commit_date.days;
if (gap_days <= 1) return;
var msg_buf: [320]u8 = undefined;
const msg = std.fmt.bufPrint(
&msg_buf,
"(git {s} uses commit {s} from {f}, {d} day{s} before requested {f}; no " ++
"snapshot for that date is committed, so there was nothing to anchor on - " ++
"use --commit-{s} to pin a revision explicitly)\n",
.{
label,
shortSha(resolved_ref),
commit_date,
gap_days,
if (gap_days == 1) "" else "s",
requested_date,
label,
},
) catch return;
cli.stderrPrint(io, msg);
}
/// Abbreviate a commit ref for display. SHAs get shortened to 7
/// chars; symbolic refs (HEAD, HEAD~1) stay as-is.
fn shortSha(ref: []const u8) []const u8 {
if (std.mem.startsWith(u8, ref, "HEAD")) return ref;
if (ref.len > 7) return ref[0..7];
return ref;
}
/// Build a report-header label from the resolved range + the
/// user-provided specs. Date specs render their requested form;
/// refs render verbatim; null falls back to the legacy HEAD~/HEAD
/// naming.
fn buildLabelFromSpecs(
arena: std.mem.Allocator,
range: git.CommitRange,
before: ?git.CommitSpec,
after: ?git.CommitSpec,
dirty: bool,
) ![]const u8 {
// Preserve the original date-based label when both specs are
// date-form (most common user-facing flow). Fall back to a
// spec-agnostic rendering otherwise.
const before_date: ?Date = if (before) |b| switch (b) {
.date_at_or_before => |d| d,
else => null,
} else null;
const after_date: ?Date = if (after) |a| switch (a) {
.date_at_or_before => |d| d,
else => null,
} else null;
// If either spec is a non-date form, emit a label describing
// the resolved range. Otherwise use the legacy date/label path
// for back-compat with snapshot tests.
const has_non_date = (before != null and before_date == null) or
(after != null and after_date == null);
if (has_non_date) {
const before_str = try specLabel(arena, before, range.before_rev);
const after_str = try specLabelAfter(arena, after, range.after_rev);
return std.fmt.allocPrint(arena, "{s} vs {s}", .{ before_str, after_str });
}
return buildLabel(arena, range, before_date, after_date, dirty);
}
/// The date a spec carries, for the two variants that carry one.
///
/// Exists so callers stop pattern-matching a single variant. Both `--since 1W` and
/// `compare`'s snapshot anchoring are "a date the user named"; a check written
/// against `.date_at_or_before` alone goes quietly blind when that mapping changes.
fn specDate(spec: git.CommitSpec) ?zfin.Date {
return switch (spec) {
.date_at_or_before, .snapshot_add => |d| d,
.git_ref, .working_copy => null,
};
}
fn specLabel(arena: std.mem.Allocator, spec: ?git.CommitSpec, resolved_ref: []const u8) ![]const u8 {
const s = spec orelse return arena.dupe(u8, resolved_ref);
return switch (s) {
.git_ref => |r| arena.dupe(u8, r),
.date_at_or_before => |d| std.fmt.allocPrint(arena, "commit at-or-before {f}", .{d}),
// Names the anchor, because "the commit that recorded this snapshot" is a
// materially different claim from "some commit near this date" and the
// header is where a reader checks which window they got.
.snapshot_add => |d| std.fmt.allocPrint(arena, "the commit recording {f}", .{d}),
.working_copy => arena.dupe(u8, "working copy"),
};
}
fn specLabelAfter(arena: std.mem.Allocator, spec: ?git.CommitSpec, resolved_ref: ?[]const u8) ![]const u8 {
if (spec) |s| return specLabel(arena, s, resolved_ref orelse "working");
if (resolved_ref) |r| return arena.dupe(u8, r);
return arena.dupe(u8, "working copy");
}
/// Build the human-readable header label for a resolved range.
fn buildLabel(
arena: std.mem.Allocator,
range: git.CommitRange,
since: ?Date,
until: ?Date,
dirty: bool,
) ![]const u8 {
// No date window -> legacy labels, matches pre-since/--until wording.
if (since == null) {
return if (dirty)
"Comparing working copy against HEAD"
else
"Working tree clean - comparing HEAD~1 against HEAD";
}
var since_buf: [10]u8 = undefined;
const since_str = std.fmt.bufPrint(&since_buf, "{f}", .{since.?}) catch "????-??-??";
if (until) |until_date| {
var until_buf: [10]u8 = undefined;
const until_str = std.fmt.bufPrint(&until_buf, "{f}", .{until_date}) catch "????-??-??";
return std.fmt.allocPrint(arena, "Comparing {s} ({s}) against {s} ({s})", .{
short(range.before_rev),
since_str,
short(range.after_rev.?),
until_str,
});
}
// --since only: after side is HEAD or working copy.
return if (dirty)
std.fmt.allocPrint(arena, "Comparing {s} ({s}) against working copy", .{ short(range.before_rev), since_str })
else
std.fmt.allocPrint(arena, "Comparing {s} ({s}) against HEAD", .{ short(range.before_rev), since_str });
}
/// 7-char short SHA for display. Runtime behavior is already
/// length-agnostic (accepts any `>= 7`), so this works for both SHA-1
/// (40-char) and SHA-256 (64-char) repos without modification.
/// Slices rather than reallocs.
fn short(sha: []const u8) []const u8 {
return if (sha.len >= 7) sha[0..7] else sha;
}
// ── Attribution helper for compare ──────────────────────────
/// Aggregated "money in" totals over a date window, produced by the
/// contributions pipeline but distilled to the numbers needed for
/// the compare-command attribution line.
///
/// "Contributions" in the plain-English sense (what the user wrote a
/// check for or what got DRIP'd back in) = `new_contributions` +
/// `drip`. CD face-value rollovers are *not* here - moving a maturing
/// CD's face value back into cash isn't new money, and `new_cash`
/// records during that transaction don't double-count because the
/// pipeline separates cd_matured from cash_delta.
pub const AttributionSummary = struct {
/// Fresh lots that represent outside money: 401k contributions,
/// DRIP-false stock purchases, CD openings, option opens, cash
/// top-ups. Matches the "New contributions / purchases" section
/// in the full report.
new_contributions: f64,
/// Dividend reinvestments: new `drip::true` lots + share increases
/// on same-key drip lots + rollup share deltas (ambiguous
/// contribution-vs-DRIP cases, treated as DRIP here to avoid
/// double-counting with cash contributions).
drip: f64,
/// Gross value of movements this pipeline deliberately does NOT count, split by
/// direction. Reported, never added to `total()`.
///
/// ## Why these are surfaced at all
///
/// `compare` derives market performance as a RESIDUAL: `gains = delta -
/// contributions`. That makes every classification gap indistinguishable from
/// market movement. Raw cash-balance changes are noise by default (interest,
/// DRIP legs, settlement sweeps), and share reductions are treated as a funding
/// source rather than an outflow - both defensible, and both invisible once
/// folded into a residual.
///
/// On one real week that hid a $25,079.42 cash increase against a $25,111.24
/// share reduction. They were two legs of one internal move and netted to
/// -$31.82, so `gains` was very nearly right - but nothing on screen said so,
/// and an unmatched leg of that size would have been reported as market
/// performance without comment.
///
/// ## Why gross rather than net
///
/// A single net figure would have read "-$31.82" for that week: accurate, and
/// useless. It conceals that $25k moved. Two gross figures say "$25k came in,
/// $25k went out, they cancel", which is the fact worth knowing.
uncounted_in: f64 = 0,
/// Gross uncounted outflows, as a NEGATIVE number so the sign carries meaning
/// at the callsite without a naming convention to remember.
uncounted_out: f64 = 0,
pub fn total(self: AttributionSummary) f64 {
return self.new_contributions + self.drip;
}
/// True when there is any uncounted movement worth showing. A cent threshold,
/// because float noise on a quiet week should not produce a line.
pub fn hasUncounted(self: AttributionSummary) bool {
return @abs(self.uncounted_in) >= 0.005 or @abs(self.uncounted_out) >= 0.005;
}
};
/// Run the contributions pipeline over a commit window and return the
/// aggregated "money in" totals. Returns null on any failure -
/// intended callers (e.g. `compare`) surface the attribution line
/// opportunistically; a missing git repo or no resolvable commits
/// shouldn't break the primary command.
///
/// Parameters mirror `run` but without the writer/color: no output
/// is produced. Failures swallow silently via the shared
/// `prepareReport` helper's `.silent` verbosity.
///
/// Classification logic is SOLELY in `computeReport` - this function
/// just buckets pre-classified change kinds into their totals. In
/// particular, opt-in cash-delta handling is resolved at diff time
/// (cash_delta -> cash_contribution on accounts marked
/// `cash_is_contribution::true`), so the attribution line here and
/// the grand total in the full `zfin contributions` report come out
/// of the same classifier and always agree over the same window.
pub fn computeAttributionSpec(
io: std.Io,
allocator: std.mem.Allocator,
env: *const std.process.Environ.Map,
svc: *zfin.DataService,
paths: []const []const u8,
before: ?git.CommitSpec,
after: ?git.CommitSpec,
as_of: Date,
color: bool,
refresh: framework.RefreshPolicy,
) ?AttributionSummary {
if (before == null and after != null) return null;
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var ctx = prepareReport(io, allocator, arena, env, svc, paths, before, after, as_of, color, refresh, .silent) catch return null;
defer ctx.deinit();
return summarizeAttribution(ctx);
}
// ── Public audit hook ────────────────────────────────────────
/// Default dollar threshold above which a new lot (new_stock /
/// new_drip_lot / new_cash / new_cd / cash_contribution) gets flagged
/// in the audit "Large new lots - confirm source" section. Below this
/// threshold new lots pass silently - the goal is to catch unconfirmed
/// six-figure movements, not flag every payroll contribution.
///
/// $10k is a judgment call: high enough to ignore routine payroll ESPP
/// accruals and $1-$2k weekly deposits, low enough to surface a typical
/// IRA contribution or a genuine transfer. This is only the fallback -
/// an account can override it per-account with an
/// `audit_large_lot_threshold:num:<DOLLARS>` field on its accounts.srf
/// record (see `AccountTaxEntry.audit_large_lot_threshold` /
/// `AccountMap.largeLotThresholdFor`), so a noisy ESPP account can
/// raise its bar without going blind on a quiet brokerage account.
const default_audit_large_lot_threshold: f64 = 10_000.0;
/// Descriptor of a "large new lot" the audit command may want to
/// surface. Emitted by `findUnmatchedLargeLots` for any new-side
/// Change whose `value()` meets the resolved threshold and which
/// was NOT reclassified by the transfer-log matcher. All string
/// fields are caller-arena-owned through the `UnmatchedLargeLotSet`
/// wrapper; the caller frees everything at once via `deinit`.
pub const UnmatchedLargeLot = struct {
/// The account the lot landed on (arena-owned copy).
account: []const u8,
/// Security ticker, or empty string for cash-kind lots.
symbol: []const u8,
/// `.stock`, `.cash`, `.cd`, etc. Drives the dest_lot shape in
/// the suggested template.
security_type: LotType,
/// Dollar value of the lot (`Change.value()`).
value: f64,
/// Lot open_date. Used in the `dest_lot::SYMBOL@OPEN_DATE`
/// template for stock / CD destinations.
open_date: Date,
};
/// Result wrapper that owns both the slice and the arena the string
/// fields live in. Caller must `deinit`.
pub const UnmatchedLargeLotSet = struct {
lots: []UnmatchedLargeLot,
arena: std.heap.ArenaAllocator,
pub fn deinit(self: *UnmatchedLargeLotSet) void {
self.arena.deinit();
}
};
/// Find new-side lots (new_stock / new_drip_lot / new_cash / new_cd
/// / cash_contribution) whose unattributed value meets the audit
/// large-lot threshold but weren't matched to a record in
/// `transaction_log.srf` over the HEAD -> working-copy window.
/// Mirrors the `zfin contributions` zero-flag path - uses
/// `prepareReport`'s shared git + portfolio + transfer plumbing so
/// the classification is identical. Returns null if the pipeline
/// can't resolve a window (not in a git repo, etc.) - the expected
/// reason for audit to skip the section, logged at debug level. Any
/// other failure is returned, never folded into that null: doing so
/// once made the whole section disappear over one undated lot.
///
/// The threshold is resolved per lot: an account's
/// `audit_large_lot_threshold` (from `account_map`) wins, otherwise
/// `default_audit_large_lot_threshold` applies. Pass `account_map =
/// null` to use the default for everything.
///
/// Consumed by `zfin audit` to prompt the user to either confirm
/// the lot as an external contribution or add a transfer record
/// when it was really an internal movement. Works whether or not
/// `transaction_log.srf` exists - when absent, every large lot
/// surfaces since nothing gets reclassified.
pub fn findUnmatchedLargeLots(
io: std.Io,
allocator: std.mem.Allocator,
env: *const std.process.Environ.Map,
svc: *zfin.DataService,
paths: []const []const u8,
account_map: ?*const analysis.AccountMap,
as_of: Date,
color: bool,
refresh: framework.RefreshPolicy,
) !?UnmatchedLargeLotSet {
var arena_state = std.heap.ArenaAllocator.init(allocator);
errdefer arena_state.deinit();
const arena = arena_state.allocator();
// Explicit null/null selects the legacy zero-flag path:
// before=HEAD~1..HEAD if clean, HEAD..working-copy if dirty.
// Audit cares about the dirty-working-copy case in practice
// (that's where "unconfirmed large lots" live), but both
// branches are valid consumers.
//
// Separate allocator here so we can tear the whole thing down
// via `arena_state.deinit` once we've copied out the descriptors.
var ctx = prepareReport(io, allocator, arena, env, svc, paths, null, null, as_of, color, refresh, .silent) catch |err| {
std.log.scoped(.contributions).debug("large-lot check skipped: no comparable window ({t})", .{err});
arena_state.deinit();
return null;
};
defer ctx.deinit();
const lots = try collectUnmatchedLargeLots(arena, ctx.report.changes, account_map);
return .{ .lots = lots, .arena = arena_state };
}
/// Pure filter: pick out new-side Changes whose unattributed value
/// (`attributedValue()`) is at or above `threshold`, and dupe their
/// string fields into `arena`. Split out so tests can feed a
/// synthetic `[]Change` without running the git/IO pipeline.
///
/// Cash-destination transfers don't flip their original `new_cash` /
/// `cash_contribution` Change's kind (a single cash delta can be
/// drained by multiple records, which the kind field can't
/// represent). Instead the matcher accumulates the per-record
/// attribution onto each consumed Change's `transfer_attributed`
/// field, in iteration order. `attributedValue()` then returns the
/// residual: 0 for fully-attributed lots, the unattributed
/// remainder for partials.
///
/// Deliberately excludes `partial_transfer_in`. A partial lot already
/// has an explicit transfer record acknowledging the large movement;
/// the unmatched residual is typically small (pre-existing cash that
/// topped the lot off) and surfacing it again would nag on something
/// the user has already documented. If a residual is large enough to
/// care about independently, the user can review the lot's full value
/// via `zfin contributions` - this filter's job is to catch
/// *unrecorded* large movements, not to re-flag partial ones.
fn collectUnmatchedLargeLots(
arena: std.mem.Allocator,
changes: []const Change,
account_map: ?*const analysis.AccountMap,
) ![]UnmatchedLargeLot {
var out: std.ArrayList(UnmatchedLargeLot) = .empty;
for (changes) |c| {
const is_new_side = switch (c.kind) {
.new_stock, .new_drip_lot, .new_cash, .new_cd, .cash_contribution => true,
else => false,
};
if (!is_new_side) continue;
// Per-account threshold: the lot's account can raise/lower its
// own cutoff (e.g. a noisy ESPP account); otherwise the
// built-in default applies.
const threshold = if (account_map) |am|
(am.largeLotThresholdFor(c.account) orelse default_audit_large_lot_threshold)
else
default_audit_large_lot_threshold;
// Use attributedValue() so a fully-attributed cash lot
// (whose `transfer_attributed` covers the whole `value()`)
// drops out, and a partially-attributed cash lot surfaces
// only on the unattributed remainder.
const residual = c.attributedValue();
if (residual < threshold) continue;
// Pass 1's new-lot branch sets open_date. A `cash_contribution`
// never has one: it is a balance INCREASE on an existing cash
// lot, and that lot's open_date is when the account was set
// up, not when this money arrived. `Date.epoch` is the
// codebase's "no date" (import writes it; `Lot.fromParsed`
// fills it), and `printLargeLotWarning` renders it as "date
// unknown" with a `<DATE>` placeholder rather than a real date.
//
// This used to `return error.MissingOpenDate`, which the caller
// collapsed into "no section" - one large deposit to a
// `cash_is_contribution` account blanked the whole list.
const od = c.open_date orelse Date.epoch;
const account_copy = try arena.dupe(u8, c.account);
const symbol_copy = try arena.dupe(u8, c.symbol);
try out.append(arena, .{
.account = account_copy,
.symbol = symbol_copy,
.security_type = c.security_type,
.value = residual,
.open_date = od,
});
}
return out.toOwnedSlice(arena);
}
/// The kinds that carry real value but are deliberately left OUT of attribution -
/// and the only place that list lives.
///
/// Two consumers read it and they must never disagree: `compare`'s
/// "Uncounted in/out" headline and this report's own "Uncounted flows" total.
/// They would drift if each carried its own list, because these six kinds are
/// scattered across FOUR different print sections below (Cash deltas, Internal
/// purchases, Flagged for review, Lot edits) and nothing about the layout hints
/// that they belong to one bucket. That scattering is exactly why the headline
/// number was untraceable: every row was on screen and no total was.
///
/// `cash_delta` is the raw-balance-change bucket (an opted-in account's positive
/// delta is reclassified to `cash_contribution` at diff time and counted as a
/// contribution, so it cannot be double-counted here). The share-reduction kinds
/// are the other side of the same coin: `attributedValue()` pins them to 0 on the
/// grounds that they are a funding source rather than an outflow, which is right
/// for attribution and still worth SEEING.
///
/// Deliberately NOT here:
/// - `cd_matured` / `cd_removed_early` have their own section and always pair
/// with a cash increase in the same account, so counting both legs would
/// double-report one internal move.
/// - `transfer_in` / `transfer_out` / `unmatched_transfer` are DECLARED internal
/// moves (`transaction_log.srf`). The point of the total is UNdeclared
/// movement; a declared transfer is not a surprise.
/// - `price_only` carries no share change, so its value is zero anyway.
fn isUncountedKind(k: ChangeKind) bool {
return switch (k) {
.cash_delta, .drip_negative, .lot_removed, .position_closed, .lot_edited, .flagged => true,
else => false,
};
}
/// Gross in/out split of the uncounted flows, signed the way `compare` prints
/// them: `out` accumulates negatives, so `in + out` is the net and needs no
/// further sign juggling at either call site.
///
/// Uses `value()`, not `attributedValue()` - the whole point is the value that
/// attribution threw away. Note this is a DIFFERENT basis from the figure
/// `printCollapsedSales` shows for a sale, which uses `face_value` (what the
/// trade realized) and can legitimately differ from a current-price mark. That
/// is why only the Cash deltas section gets a per-section total below: there the
/// two bases coincide, so the section total and this one cannot disagree.
fn uncountedTotals(changes: []const Change) struct { in: f64, out: f64 } {
var in: f64 = 0;
var out: f64 = 0;
for (changes) |c| {
if (!isUncountedKind(c.kind)) continue;
const v = c.value();
if (v >= 0) in += v else out += v;
}
return .{ .in = in, .out = out };
}
fn summarizeAttribution(ctx: ReportContext) AttributionSummary {
// Aggregate. Classification logic matches the full-report sections:
// - New contributions: new_stock + new_cash + new_cd + new_option
// + cash_contribution (opt-in cash_delta)
// + partial_transfer_in residual
// (`value()` - `transfer_attributed`)
// - DRIP: new_drip_lot + drip_confirmed + rollup_delta
// `rollup_delta` is the ambiguous "share increased on a drip::false
// lot" case. Lumping it with DRIP here matches the report's own
// visual grouping (both shown as positive, both under DRIP-ish
// headings) and prevents double-counting against cash_delta.
// `cash_delta` (non-opt-in) is excluded - it's noisy cash movement
// (DRIP legs, interest, CD coupons, settlement sweeps) that the
// user hasn't explicitly flagged as a contribution source.
// `lot_edited` is excluded - it's a reconciliation noise bucket.
// `transfer_in` / `transfer_out` / `unmatched_transfer` -> $0.
// `partial_transfer_in` -> residual only (attributedValue()).
var new_contributions: f64 = 0;
var drip: f64 = 0;
for (ctx.report.changes) |c| switch (c.kind) {
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => new_contributions += c.attributedValue(),
.new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(),
.partial_transfer_in => new_contributions += c.attributedValue(),
// The uncounted kinds fall through to `uncountedTotals` below, which owns
// that list. Everything else contributes nothing.
else => {},
};
const unc = uncountedTotals(ctx.report.changes);
// Cash-dest transfer attribution is already removed by `attributedValue()` on
// the per-Change side: `matchCashDestination` accumulates into
// `transfer_attributed`, so a fully-attributed cash Change contributes zero
// here without any separate per-account subtraction.
return .{
.new_contributions = new_contributions,
.drip = drip,
.uncounted_in = unc.in,
.uncounted_out = unc.out,
};
}
// ── Git discovery / invocation ───────────────────────────────
//
// Git plumbing lives in `src/git.zig` (shared with future snapshot
// features). This module only classifies which revisions to diff and
// how to interpret the result.
// ── Diff algorithm ───────────────────────────────────────────
/// Categorized change on a single lot-key.
const ChangeKind = enum {
new_stock, // lot appeared: stock purchase (drip::false)
new_drip_lot, // lot appeared: stock with drip::true (confirmed DRIP reinvestment)
new_cash, // lot appeared: cash added
new_cd, // lot appeared: CD opened
new_option, // lot appeared: option opened
drip_confirmed, // same key on a drip::true stock lot, Δshares > 0
rollup_delta, // same key on a drip::false stock lot, Δshares > 0 (DRIP or contribution; can't distinguish)
drip_negative, // same key, stock, Δshares < 0 (share sale on the same lot - unusual)
cash_delta, // same key, cash, Δshares (treated as noise - interest, DRIP legs)
/// Positive cash_delta on an account marked
/// `cash_is_contribution::true` in accounts.srf. Reclassified at
/// diff time (inside `computeReport`) so every downstream
/// consumer - the full report, per-account summary, attribution
/// totals in `compare` - sees the same classification. Without
/// this single-point-of-truth, compare's attribution line and the
/// contributions report's grand total could disagree on the same
/// window.
cash_contribution,
cd_matured, // lot disappeared: CD with maturity_date <= today
cd_removed_early, // lot disappeared: CD with maturity_date > today
lot_removed, // lot disappeared: stock/cash/option
/// A lot that gained `close_date` (and normally `close_price`) in
/// place: the position was sold, but the record was kept rather
/// than deleted - either edited where it sat, or archived into a
/// sibling `portfolio_closed.srf` that the same glob picks up.
///
/// Needs its own kind because the strict lot key deliberately
/// excludes `close_date`, so a close leaves the key and the share
/// count untouched. Without this the change fell through the
/// same-shares branch (which only inspects `price` and
/// `maturity_date`) and emitted nothing at all, making the sale
/// invisible to attribution and letting the repurchase read as a
/// fresh contribution.
///
/// Valued at `close_price` - the only figure that records what the
/// sale actually realized, and strictly better than the current-price
/// proxy `lot_removed` has to settle for. Contributes $0 to
/// attribution and acts as a funding source, exactly like
/// `lot_removed`.
position_closed,
/// Strict lot key broke (open_date / open_price / symbol rewritten)
/// but the same (security_type, priceSymbol, account) reappears on
/// the other side with approximately the same share total.
/// Classified as a lot edit - not counted as contribution, not
/// counted as disposal. Fixes the phantom-contribution bug from
/// reconciliation tweaks, CD auto-renewals rewriting `open_date`,
/// and symbol-alias rewrites (`symbol::SPY` -> `symbol::DI-SPX,
/// ticker::SPY`).
///
/// Deliberately does NOT cover account renames: `secondaryKey`
/// includes the account, so renaming one breaks both keys and the
/// rename is indistinguishable from closing one account and opening
/// another with the same positions. See the "account rename is NOT
/// collapsed" test below and the TODO.md entry for the known fix.
lot_edited,
price_only, // same key, price:: field changed, no share change
flagged, // any other shape of edit
// ── Transfer reclassifications (see `matchTransfers`) ────
/// Destination lot or cash_delta fully attributed to a transfer
/// record in `transaction_log.srf`. Contributes $0 to attribution -
/// it's internal money movement, not new contribution. Replaces
/// the `new_stock` / `new_drip_lot` / `new_cash` / `new_cd` /
/// `cash_contribution` classification the lot would otherwise
/// receive.
transfer_in,
/// `lot_removed` or negative `cash_delta` on the sending account,
/// credited against a transfer record. Contributes $0 to
/// attribution. Also replaces the base classification.
transfer_out,
/// Destination lot or cash partially attributed to a transfer.
/// The remaining value (`c.value() - c.transfer_attributed`) is
/// still real new money and flows through attribution under the
/// base classification's bucket (new_contributions or drip).
partial_transfer_in,
/// Transfer record in the window that couldn't be matched to any
/// portfolio-diff Change. Emitted as a standalone Change (not
/// attached to an existing lot) so it surfaces in the Flagged
/// section. `detail` carries the human-readable reason.
/// Contributes $0 to attribution.
unmatched_transfer,
};
const Change = struct {
kind: ChangeKind,
symbol: []const u8,
account: []const u8,
security_type: LotType,
/// Δshares (after - before). Zero for price_only and lot_removed.
delta_shares: f64 = 0,
/// Price used to value delta_shares (open_price, current price, or manual price).
unit_value: f64 = 0,
/// For cd_matured / cd_removed_early / lot_removed: the dollar
/// value that left, i.e. `before_shares * unit_value`. For a cash
/// lot that is shares 1:1; for a CD it is the face value; for a
/// stock it is the sale proceeds at the best available price.
face_value: f64 = 0,
/// For cd_matured / cd_removed_early: maturity_date.
maturity_date: ?Date = null,
/// For price_only: old and new values.
old_price: f64 = 0,
new_price: f64 = 0,
/// Free-form detail for flagged changes.
detail: ?[]const u8 = null,
/// Lot open_date for new_* kinds - carried here so downstream
/// consumers (the audit large-lot warning, the Transfers section
/// printer) can generate `transfer_log.srf` templates without
/// re-reading the after-portfolio. Null for non-new kinds.
open_date: ?Date = null,
// ── Transfer reclassification (see `matchTransfers`) ────
/// Dollar amount of this Change's `value()` that's attributable
/// to a matched transfer record. Set only for `transfer_in`
/// (equals `value()`), `partial_transfer_in` (less than `value()`),
/// and `unmatched_transfer` (equals the record's amount, carried
/// on a synthetic Change). Zero otherwise.
transfer_attributed: f64 = 0,
/// Free-form text carried from the transfer record (its `note::`
/// field) or from the matcher (for `unmatched_transfer`, a
/// reason string).
transfer_note: ?[]const u8 = null,
/// Sending account for transfer_in / partial_transfer_in /
/// unmatched_transfer. Null for non-transfer kinds.
transfer_from: ?[]const u8 = null,
/// Date of the matched transfer record. Null for non-transfer
/// kinds.
transfer_date: ?Date = null,
// ── Intra-account purchase netting (see `matchIntraAccountPurchases`) ────
/// Portion of this Change's `value()` funded by money that was
/// already inside the SAME account - not a fresh contribution.
/// `attributedValue()` subtracts it, so a fully-funded change nets
/// to $0 and drops out of "New contributions", the per-account
/// totals, the compare attribution line, and the audit large-lot
/// nudge. Zero otherwise. Two funding sources, both same-account:
///
/// - **cash that visibly left** (a negative `cash_delta` or a
/// removed cash lot) funding a `new_stock` / `new_cd` buy;
/// - **proceeds of a security sale** - a valued `lot_removed` /
/// `drip_negative`. Proceeds that were spent fund `new_stock` /
/// `new_cd`; proceeds still sitting in cash at window end
/// instead cancel a `cash_contribution` on that account, which
/// would otherwise book the sale as new money (see
/// `matchIntraAccountPurchases` for the spent/resting split).
///
/// So the kinds that can carry this are `new_stock`, `new_cd`, and
/// `cash_contribution`.
///
/// Distinct from `transfer_attributed` (which tracks cross-account
/// movement declared in `transaction_log.srf`); the two are
/// additive on `new_stock` / `new_cd` and never overlap because a
/// transfer-matched lot is reclassified away from those kinds.
internal_funded: f64 = 0,
pub fn value(self: Change) f64 {
return self.delta_shares * self.unit_value;
}
/// Portion of `value()` that counts toward attribution (after
/// transfer reclassification). For `transfer_in` / `transfer_out`
/// / `unmatched_transfer`, the contribution is $0. For
/// `partial_transfer_in`, only the residual counts. For
/// `new_cash`, `cash_contribution`, and `cash_delta`, the
/// matcher may have credited some of the value to a cash-
/// destination transfer record (see `matchCashDestination`);
/// `transfer_attributed` tracks how much. For `new_stock` /
/// `new_cd` / `cash_contribution`, `internal_funded` tracks how
/// much was funded from inside the same account - a cash decrease
/// or the proceeds of a security sale (see
/// `matchIntraAccountPurchases`). The residual is
/// `value() - transfer_attributed - internal_funded`, which is
/// what shows up in "New contributions / purchases" and the audit
/// large-lot filter. Everyone else sees `value()` unchanged.
///
/// Outflows are pinned to 0 rather than falling through to
/// `value()`. They are a *funding source*, never a negative
/// contribution: the money did not leave the portfolio, it changed
/// form. Letting a valued `lot_removed` reach the `else` arm below
/// would subtract the sale from contributions and break the
/// `delta = contributions + gains` identity in the opposite
/// direction from the bug this valuation was added to fix.
pub fn attributedValue(self: Change) f64 {
return switch (self.kind) {
.transfer_in, .transfer_out, .unmatched_transfer => 0,
.lot_removed, .position_closed, .drip_negative, .cd_matured, .cd_removed_early => 0,
.partial_transfer_in => self.value() - self.transfer_attributed,
.new_cash, .cash_delta => self.value() - self.transfer_attributed,
.cash_contribution => self.value() - self.transfer_attributed - self.internal_funded,
.new_stock, .new_cd => self.value() - self.transfer_attributed - self.internal_funded,
else => self.value(),
};
}
};
/// Summary aggregated for the report. All string fields and backing memory
/// live in the caller-supplied arena; there is no explicit deinit.
const Report = struct {
changes: []Change,
/// Per-account rollups for the summary section.
account_totals: std.StringHashMap(AccountTotal),
const AccountTotal = struct {
new_money: f64 = 0, // stock+cd+cash new lots (drip::false)
drip_confirmed: f64 = 0, // confirmed DRIP (drip::true lots: new or shares increased)
rollup: f64 = 0, // share deltas on drip::false aggregate lots (DRIP or contribution; ambiguous)
cd_interest: f64 = 0, // implied interest from matured CDs
cash_delta: f64 = 0, // unclassified cash balance changes
};
};
/// Build a canonical lookup key for matching lots between snapshots.
/// Key: (security_type, symbol, account, open_date, open_price).
fn lotKey(allocator: std.mem.Allocator, lot: Lot) ![]u8 {
return std.fmt.allocPrint(allocator, "{s}|{s}|{s}|{f}|{d:.6}", .{
@tagName(lot.security_type),
lot.symbol,
lot.account orelse "",
lot.open_date,
lot.open_price,
});
}
/// Aggregate duplicate-key lots by summing shares. (Rare in practice but
/// possible.) Returns map key -> (shares, representative Lot).
const LotAgg = struct {
shares: f64,
lot: Lot,
};
fn aggregateByKey(
allocator: std.mem.Allocator,
lots: []const Lot,
) !std.StringHashMap(LotAgg) {
var map = std.StringHashMap(LotAgg).init(allocator);
for (lots) |lot| {
const k = try lotKey(allocator, lot);
const gop = try map.getOrPut(k);
if (gop.found_existing) {
gop.value_ptr.shares += lot.shares;
} else {
gop.value_ptr.* = .{ .shares = lot.shares, .lot = lot };
}
}
return map;
}
/// Secondary key for edit detection: (security_type, priceSymbol, account).
/// Lots with the same secondary key but different strict `lotKey`s are
/// candidates for reclassification as `lot_edited` - the strict key
/// broke because `open_date`, `open_price`, or the underlying symbol
/// string got rewritten, but the position itself continued.
///
/// Uses `priceSymbol()` (ticker-alias-aware) rather than raw `symbol`
/// so that edits like `symbol::SPY` -> `symbol::DI-SPX, ticker::SPY`
/// collapse correctly. Both sides resolve to the same effective
/// ticker (`SPY`) and represent the same underlying exposure; the
/// raw `symbol` changed but the position did not.
fn secondaryKey(allocator: std.mem.Allocator, lot: Lot) ![]u8 {
return std.fmt.allocPrint(allocator, "{s}|{s}|{s}", .{
@tagName(lot.security_type),
lot.priceSymbol(),
lot.account orelse "",
});
}
/// Dollars-per-share for a lot that is LEAVING the portfolio (a whole
/// lot removed in pass 2, or a position closed in place). Multiplied
/// by the share count it gives the sale proceeds, which is what makes
/// a sale usable as a funding source for a same-account purchase.
///
/// A sale's true proceeds are only knowable from `close_price` (see
/// `closeUnitValue`); this is the fallback for a lot that simply
/// vanished from the file with no closing record. Current cached
/// price is the best available proxy - the diff windows are short
/// (typically one commit), so a recent sale prices close to the mark.
/// Manual `price::` then `open_price` follow; `open_price` is a cost
/// basis and understates a long-held winner, so it really is a last
/// resort.
///
/// `prices.get` returns the raw retail-class API price -> ratio
/// applies. `lot.price` and `lot.open_price` are already in the lot's
/// own share-class terms (preadjusted) -> ratio must NOT be applied.
/// See the "Pricing model" doc-block in models/portfolio.zig.
fn outflowUnitValue(lot: Lot, prices: *const std.StringHashMap(f64)) f64 {
return switch (lot.security_type) {
.stock => blk: {
if (prices.get(lot.priceSymbol())) |p| break :blk lot.effectivePrice(p, false);
if (lot.price) |p| break :blk lot.effectivePrice(p, true);
break :blk lot.effectivePrice(lot.open_price, true);
},
// Shares ARE dollars for cash lots.
.cash => 1.0,
// Face value per share.
.cd => lot.open_price,
.option => lot.open_price * lot.multiplier,
else => lot.open_price,
};
}
/// Dollars-per-share realized when a lot was closed in place.
///
/// `close_price` is authoritative - it is what the sale actually got,
/// recorded at the time by whoever closed the lot - so it wins
/// outright. Cost basis would understate a long-held winner badly
/// (on the trade that motivated this, by $48,963.65 on $467k), which
/// is why the fallback defers to `outflowUnitValue`'s current-price
/// proxy rather than reaching for `open_price`.
fn closeUnitValue(lot: Lot, prices: *const std.StringHashMap(f64)) f64 {
if (lot.close_price) |cp| {
return switch (lot.security_type) {
// close_price is in the lot's own share-class terms
// (preadjusted), so the ratio must NOT be applied again.
.stock => lot.effectivePrice(cp, true),
.option => cp * lot.multiplier,
else => cp,
};
}
return outflowUnitValue(lot, prices);
}
/// Tolerance for "did the share total stay the same" check when
/// deciding whether to emit a rollup_delta for any residual share
/// difference alongside a `lot_edited` classification. Anything within
/// this tolerance is treated as rounding/reconciliation noise and
/// suppressed; anything beyond it surfaces as a normal share-delta
/// change (rollup_delta for positive, drip_negative for negative).
///
/// This is NOT a gate on whether the pair collapses - secondary-key
/// matches ALWAYS collapse the identity change into a lot_edited
/// record. The tolerance only decides whether to additionally emit
/// the residual share movement as a distinct change.
const edit_residual_tolerance_rel: f64 = 0.0001; // 0.01%
/// Looser tolerance applied to accounts flagged
/// `direct_indexing::true` in `accounts.srf`. Direct-indexing
/// proxies (a basket of underlying stocks tracked as a single
/// benchmark via `ticker::`) have tracking-error drift that legitimately
/// moves the basket's equivalent share count without any real money
/// flowing. A tighter tolerance flags that drift as contribution-
/// adjacent noise; this looser one treats it as edit-only, so the
/// attribution line stays clean of tracking-error reconciliation.
///
/// 1% is chosen to sit well above typical weekly tracking-error
/// magnitudes (< 0.5% in normal markets) while still catching
/// out-of-band moves - e.g. an actual $100k contribution into a
/// multi-million direct-indexing basket (~1.2% of the account) will
/// fall just over this threshold and surface as a rollup_delta for
/// review. Not configurable per-account today; revisit if anyone's
/// direct-indexing account generates > 1% drift regularly.
const direct_indexing_residual_tolerance_rel: f64 = 0.01; // 1%
/// Identify strict-key pairs that should be reclassified as edits.
///
/// Walks the "only in after" and "only in before" strict keys, groups
/// each by secondary key (security_type, priceSymbol, account), and
/// returns a set of strict keys that should be skipped by the primary
/// classification passes (both `new_*` and `lot_removed` / `cd_*`).
/// Populates `changes` with a single `lot_edited` entry per matched
/// secondary-key group, plus a residual `rollup_delta` / `drip_negative`
/// for any share difference beyond noise tolerance.
///
/// Matching rules:
/// - A secondary key must have at least one unmatched lot on each
/// side (otherwise it's a pure add or a pure remove, which keeps
/// the existing semantics).
/// - All unmatched strict keys for that secondary key group collapse
/// together regardless of share-total magnitude. The thinking:
/// secondary-key agreement (same `(security_type, priceSymbol,
/// account)`) means the user is editing the same underlying
/// position. Any share difference is a separate question -
/// handled by emitting a residual rollup_delta (if positive) or
/// drip_negative (if negative). This matches how share deltas on
/// intact strict keys are classified in Pass 1.
fn detectEdits(
allocator: std.mem.Allocator,
before_map: *const std.StringHashMap(LotAgg),
after_map: *const std.StringHashMap(LotAgg),
prices: *const std.StringHashMap(f64),
account_map: ?*const analysis.AccountMap,
changes: *std.ArrayList(Change),
) !std.StringHashMap(void) {
var skip = std.StringHashMap(void).init(allocator);
errdefer skip.deinit();
// Group unmatched strict keys by secondary key, tagging each entry
// with the side it came from. `strict_keys` alongside the value
// lets us seed the skip set only when a group actually matches.
const SidedEntry = struct {
strict_key: []const u8,
agg: LotAgg,
from_after: bool,
};
var groups = std.StringHashMap(std.ArrayList(SidedEntry)).init(allocator);
defer {
var vit = groups.valueIterator();
while (vit.next()) |list| list.deinit(allocator);
groups.deinit();
}
var ait = after_map.iterator();
while (ait.next()) |entry| {
if (before_map.contains(entry.key_ptr.*)) continue;
const sk = try secondaryKey(allocator, entry.value_ptr.*.lot);
const gop = try groups.getOrPut(sk);
if (!gop.found_existing) {
gop.value_ptr.* = std.ArrayList(SidedEntry).empty;
} else {
allocator.free(sk);
}
try gop.value_ptr.append(allocator, .{
.strict_key = entry.key_ptr.*,
.agg = entry.value_ptr.*,
.from_after = true,
});
}
var bit = before_map.iterator();
while (bit.next()) |entry| {
if (after_map.contains(entry.key_ptr.*)) continue;
const sk = try secondaryKey(allocator, entry.value_ptr.*.lot);
const gop = try groups.getOrPut(sk);
if (!gop.found_existing) {
gop.value_ptr.* = std.ArrayList(SidedEntry).empty;
} else {
allocator.free(sk);
}
try gop.value_ptr.append(allocator, .{
.strict_key = entry.key_ptr.*,
.agg = entry.value_ptr.*,
.from_after = false,
});
}
// Dup helper for string arena storage into `changes`.
const Dup = struct {
a: std.mem.Allocator,
fn of(self: @This(), s: []const u8) ![]const u8 {
return self.a.dupe(u8, s);
}
};
const sdup = Dup{ .a = allocator };
var git_it = groups.iterator();
while (git_it.next()) |entry| {
const list = entry.value_ptr.*;
// Need at least one on each side to be an edit.
var after_shares: f64 = 0;
var before_shares: f64 = 0;
var after_rep: ?LotAgg = null;
var before_rep: ?LotAgg = null;
for (list.items) |e| {
if (e.from_after) {
after_shares += e.agg.shares;
if (after_rep == null) after_rep = e.agg;
} else {
before_shares += e.agg.shares;
if (before_rep == null) before_rep = e.agg;
}
}
if (after_shares == 0 or before_shares == 0) continue;
// Qualified edit: add all strict keys to the skip set. Emit a
// lot_edited record representing the identity continuity.
for (list.items) |e| {
try skip.put(e.strict_key, {});
}
const rep_lot = after_rep.?.lot;
const acct = try sdup.of(rep_lot.account orelse "");
const sym = try sdup.of(rep_lot.symbol);
try changes.append(allocator, .{
.kind = .lot_edited,
.symbol = sym,
.account = acct,
.security_type = rep_lot.security_type,
.delta_shares = 0,
.unit_value = 0,
});
// Emit a residual share-delta change if the totals diverge
// beyond noise. Mirrors Pass 1's same-key share-delta handling
// so the user sees the same classification whether the strict
// key was preserved or rewritten.
//
// Direct-indexing accounts (flagged `direct_indexing::true`
// in accounts.srf) use a looser tolerance - tracking-error
// share reconciliation on a proxy basket isn't real money
// flow and shouldn't land in rollup_delta / drip_negative.
const delta = after_shares - before_shares;
const denom = @max(@abs(after_shares), @abs(before_shares));
const rel = if (denom == 0) 0.0 else @abs(delta) / denom;
const tolerance = if (account_map) |am|
(if (am.isDirectIndexing(rep_lot.account orelse ""))
direct_indexing_residual_tolerance_rel
else
edit_residual_tolerance_rel)
else
edit_residual_tolerance_rel;
if (rel <= tolerance) continue;
const unit_value: f64 = blk: {
if (rep_lot.security_type == .stock) {
// `prices.get` is the raw retail-class API price -> ratio applies.
// `lot.price` (manual override) and `lot.open_price` are both
// in the lot's own share-class terms (preadjusted) -> ratio
// must NOT be applied. See the "Pricing model" doc-block in
// models/portfolio.zig.
if (prices.get(rep_lot.priceSymbol())) |p| break :blk rep_lot.effectivePrice(p, false);
if (rep_lot.price) |p| break :blk rep_lot.effectivePrice(p, true);
break :blk rep_lot.effectivePrice(rep_lot.open_price, true);
}
break :blk 1.0;
};
const is_drip = rep_lot.drip or (before_rep.?.lot.drip);
const kind: ChangeKind = switch (rep_lot.security_type) {
.stock => if (delta > 0)
(if (is_drip) ChangeKind.drip_confirmed else ChangeKind.rollup_delta)
else
ChangeKind.drip_negative,
.cash => .cash_delta,
else => .flagged,
};
try changes.append(allocator, .{
.kind = kind,
.symbol = sym,
.account = acct,
.security_type = rep_lot.security_type,
.delta_shares = delta,
.unit_value = unit_value,
});
}
return skip;
}
/// Optional knobs for `computeReport`. An explicit struct keeps the
/// call-site noise low at the many in-file test call sites (all pass
/// `.{}`) while still letting production callers thread through the
/// account map for opted-in cash-delta reclassification.
const ReportOptions = struct {
/// Account metadata from `accounts.srf`. When present, positive
/// `cash_delta` entries for accounts with
/// `cash_is_contribution::true` are reclassified as
/// `cash_contribution` so every downstream consumer (full
/// report, per-account summary, `compare` attribution) agrees.
account_map: ?*const analysis.AccountMap = null,
/// Transfer records to match against this diff. Typically the
/// records that are NEW in the after-side `transaction_log.srf`
/// relative to the before-side (computed by
/// `diffTransferLogs`). The matcher reclassifies
/// destination/source Changes as `transfer_in` /
/// `partial_transfer_in` / `transfer_out` and emits
/// `unmatched_transfer` entries for records that can't be
/// matched. When null or empty, `matchTransfers` is a no-op.
///
/// Records are not date-window filtered: the caller is
/// responsible for passing only records that should be
/// considered for THIS diff. The expected source is "records
/// added to `transaction_log.srf` since `before_rev`," not
/// "records dated within the diff's git timestamp window."
/// See `diffTransferLogs` and the prepareReport pipeline for
/// how the production caller assembles the slice.
transfer_log: ?[]const transaction_log.TransferRecord = null,
};
fn computeReport(
allocator: std.mem.Allocator,
before: []const Lot,
after: []const Lot,
prices: *const std.StringHashMap(f64),
as_of: Date,
opts: ReportOptions,
) !Report {
var changes: std.ArrayList(Change) = .empty;
var before_map = try aggregateByKey(allocator, before);
var after_map = try aggregateByKey(allocator, after);
// Edit detection: identify strict-key pairs that look like edits
// (reconciliation tweak, CD auto-renewal rewriting `open_date`,
// symbol alias rewrite like SPY->DI-SPX,ticker::SPY) rather than
// real new/removed lots. The returned `skip` set is the list of
// strict keys passes 1 and 2 should ignore - each matched group
// emits its own `lot_edited` change plus a residual rollup for
// any share delta beyond noise.
var skip = try detectEdits(allocator, &before_map, &after_map, prices, opts.account_map, &changes);
defer skip.deinit();
// Helper for duping strings into the arena so Change fields have
// predictable lifetimes even if caller-supplied lot strings go away.
const Dup = struct {
a: std.mem.Allocator,
fn of(self: @This(), s: []const u8) ![]const u8 {
return self.a.dupe(u8, s);
}
};
const sdup = Dup{ .a = allocator };
// Pass 1: keys in after. Classify as new, shares-changed, or (matched
// with price-only or other-metadata changes) edited.
var ait = after_map.iterator();
while (ait.next()) |entry| {
if (skip.contains(entry.key_ptr.*)) continue;
const after_agg = entry.value_ptr.*;
if (before_map.get(entry.key_ptr.*)) |before_agg| {
// Key present in both. Compare shares and other fields.
const delta = after_agg.shares - before_agg.shares;
const lot = after_agg.lot;
const acct = try sdup.of(lot.account orelse "");
const sym = try sdup.of(lot.symbol);
if (@abs(delta) > 0.000001) {
// Direct-indexing suppression: for stock lots in
// flagged accounts, sub-1% share drift is tracking-
// error reconciliation, not real money flow. Skip
// emitting a rollup_delta / drip_negative so the
// attribution stays clean. Must apply the same
// logic here as in `detectEdits` or the treatment
// becomes inconsistent depending on whether the
// strict key broke this week.
if (lot.security_type == .stock) {
const denom = @max(@abs(after_agg.shares), @abs(before_agg.shares));
const rel = if (denom == 0) 0.0 else @abs(delta) / denom;
const is_di = if (opts.account_map) |am| am.isDirectIndexing(lot.account orelse "") else false;
if (is_di and rel <= direct_indexing_residual_tolerance_rel) continue;
}
const before_lot = before_agg.lot;
const is_drip = lot.drip or before_lot.drip;
const base_kind: ChangeKind = switch (lot.security_type) {
.stock => if (delta > 0)
(if (is_drip) ChangeKind.drip_confirmed else ChangeKind.rollup_delta)
else
ChangeKind.drip_negative,
.cash => .cash_delta,
.cd => .flagged, // CD face value shouldn't change on the same key
.option => .flagged,
else => .flagged,
};
// Opt-in reclassification: on accounts marked
// `cash_is_contribution::true` in accounts.srf, a
// positive cash_delta is actually new money arriving
// (payroll ESPP accrual, direct 401k cash deposit,
// etc.). Reclassify at this single point so every
// downstream consumer - full report, per-account
// summary, `compare` attribution - sees the same
// classification. Negative cash_delta stays as noise
// (a real withdrawal would need different semantics).
const kind: ChangeKind = if (base_kind == .cash_delta and delta > 0) blk: {
if (opts.account_map) |am| {
if (am.cashIsContribution(lot.account orelse "")) break :blk .cash_contribution;
}
break :blk .cash_delta;
} else base_kind;
// Determine unit_value for stocks: prefer current cached price;
// fall back to manual price::; fall back to open_price.
//
// `prices.get` is the raw retail-class API price -> ratio applies.
// `lot.price` (manual override) and `lot.open_price` are both
// in the lot's own share-class terms (preadjusted) -> ratio
// must NOT be applied. See the "Pricing model" doc-block in
// models/portfolio.zig.
const unit_value: f64 = blk: {
if (lot.security_type == .stock) {
if (prices.get(lot.priceSymbol())) |p| break :blk lot.effectivePrice(p, false);
if (lot.price) |p| break :blk lot.effectivePrice(p, true);
break :blk lot.effectivePrice(lot.open_price, true);
}
// cash/cd: 1:1 with shares
break :blk 1.0;
};
try changes.append(allocator, .{
.kind = kind,
.symbol = sym,
.account = acct,
.security_type = lot.security_type,
.delta_shares = delta,
.unit_value = unit_value,
});
} else {
// Same shares. A close transition wins over any other
// metadata comparison: a lot that was both closed and
// repriced in the same window is a sale, not a price
// edit. Checked first for exactly that reason.
const before_lot = before_agg.lot;
if (before_lot.close_date == null and lot.close_date != null) {
const unit_value = closeUnitValue(lot, prices);
try changes.append(allocator, .{
.kind = .position_closed,
.symbol = sym,
.account = acct,
.security_type = lot.security_type,
.unit_value = unit_value,
.face_value = after_agg.shares * unit_value,
.delta_shares = -after_agg.shares,
});
continue;
}
const a_price = lot.price;
const b_price = before_lot.price;
if ((a_price != null) != (b_price != null) or
(a_price != null and b_price != null and @abs(a_price.? - b_price.?) > 0.000001))
{
try changes.append(allocator, .{
.kind = .price_only,
.symbol = sym,
.account = acct,
.security_type = lot.security_type,
.old_price = b_price orelse 0,
.new_price = a_price orelse 0,
});
} else if ((lot.maturity_date == null) != (before_lot.maturity_date == null) or
(lot.maturity_date != null and before_lot.maturity_date != null and
!lot.maturity_date.?.eql(before_lot.maturity_date.?)))
{
var old_buf: [10]u8 = undefined;
var new_buf: [10]u8 = undefined;
const old_str = if (before_lot.maturity_date) |d| (std.fmt.bufPrint(&old_buf, "{f}", .{d}) catch "????-??-??") else "(none)";
const new_str = if (lot.maturity_date) |d| (std.fmt.bufPrint(&new_buf, "{f}", .{d}) catch "????-??-??") else "(none)";
const detail = try std.fmt.allocPrint(allocator, "maturity_date {s} -> {s}", .{ old_str, new_str });
try changes.append(allocator, .{
.kind = .flagged,
.symbol = sym,
.account = acct,
.security_type = lot.security_type,
.detail = detail,
});
}
// Other edits (note, rate, etc.) are intentionally ignored to
// keep noise low.
}
} else {
// Key only in after -> new lot.
const lot = after_agg.lot;
const acct = try sdup.of(lot.account orelse "");
const sym = try sdup.of(lot.symbol);
const kind: ChangeKind = switch (lot.security_type) {
.stock => if (lot.drip) ChangeKind.new_drip_lot else ChangeKind.new_stock,
.cash => .new_cash,
.cd => .new_cd,
.option => .new_option,
else => .flagged,
};
// For fresh stock lots: value at open_price (that's literally the
// money that went in). For cash: shares == dollars. For CDs:
// face = shares × open_price.
//
// open_price is in the lot's own share-class terms (preadjusted),
// so route it through effectivePrice with is_preadjusted=true to
// avoid double-applying price_ratio. See the "Pricing model"
// doc-block in models/portfolio.zig.
const unit_value: f64 = switch (lot.security_type) {
.stock => lot.effectivePrice(lot.open_price, true),
.cash => 1.0,
.cd => lot.open_price,
.option => lot.open_price * lot.multiplier,
else => lot.open_price,
};
try changes.append(allocator, .{
.kind = kind,
.symbol = sym,
.account = acct,
.security_type = lot.security_type,
.delta_shares = after_agg.shares,
.unit_value = unit_value,
.open_date = lot.open_date,
});
}
}
// Pass 2: keys in before but not in after -> lot disappeared.
var bit = before_map.iterator();
while (bit.next()) |entry| {
if (after_map.contains(entry.key_ptr.*)) continue;
if (skip.contains(entry.key_ptr.*)) continue;
const before_agg = entry.value_ptr.*;
const lot = before_agg.lot;
const acct = try sdup.of(lot.account orelse "");
const sym = try sdup.of(lot.symbol);
var kind: ChangeKind = .lot_removed;
if (lot.security_type == .cd) {
if (lot.maturity_date) |mat| {
// "matured" if maturity_date <= as_of (i.e. NOT as_of.lessThan(mat))
if (!as_of.lessThan(mat)) {
kind = .cd_matured;
} else {
kind = .cd_removed_early;
}
} else {
kind = .cd_removed_early; // no maturity - treat as flagged-ish
}
}
// Value the outflow. `unit_value` makes `value()` the (negative)
// dollar proceeds, which is what lets a security sale fund a
// same-account purchase in `matchIntraAccountPurchases`. Before
// this was populated, `value()` was 0 for every removal and a
// sale funded nothing - so an intra-account reallocation read as
// a full external contribution.
const unit_value = outflowUnitValue(lot, prices);
try changes.append(allocator, .{
.kind = kind,
.symbol = sym,
.account = acct,
.security_type = lot.security_type,
.unit_value = unit_value,
.face_value = before_agg.shares * unit_value,
.maturity_date = lot.maturity_date,
.delta_shares = -before_agg.shares,
});
}
// Transfer reclassification pass: rewrite destination/source
// Change kinds for records the caller passed in (typically the
// diff between before-side and after-side
// `transaction_log.srf`). No-op when no records are supplied. See
// `matchTransfers` docstring for the matching algorithm.
if (opts.transfer_log) |records| {
try matchTransfers(allocator, &changes, records);
}
// Intra-account purchase netting: a decrease in an account's cash
// (negative cash_delta or a removed cash lot) is presumed to fund
// new_stock / new_cd lots that appeared in the SAME account - a
// plain buy of existing cash, not a fresh contribution. Runs AFTER
// matchTransfers so explicit transfer records win (cash already
// claimed as a transfer_out doesn't double-fund a purchase, and a
// lot already reclassified to transfer_in is no longer new_stock /
// new_cd). See `matchIntraAccountPurchases`.
try matchIntraAccountPurchases(allocator, &changes);
// Build per-account totals.
var acct_totals = std.StringHashMap(Report.AccountTotal).init(allocator);
for (changes.items) |c| {
const gop = try acct_totals.getOrPut(c.account);
if (!gop.found_existing) gop.value_ptr.* = .{};
switch (c.kind) {
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => {
// attributedValue() returns the unattributed residual
// for cash-kind Changes (matchCashDestination drained
// `transfer_attributed`); for non-cash kinds it equals
// value(). Either way, this is "real new money on the
// account."
gop.value_ptr.new_money += c.attributedValue();
},
.new_drip_lot, .drip_confirmed => {
gop.value_ptr.drip_confirmed += c.value();
},
.rollup_delta => {
gop.value_ptr.rollup += c.value();
},
.cash_delta => {
gop.value_ptr.cash_delta += c.attributedValue();
},
.cd_matured => {
// Interest is computed lazily against cash_delta in print();
// we don't add face value to new_money (that's not new money).
},
.partial_transfer_in => {
// Residual (value() - transfer_attributed) flows into
// new_money. The lot-destination matcher is the only
// producer of partial_transfer_in (cash-dest uses the
// per-account attribution bucket), so the residual
// represents pre-existing cash that funded part of
// the lot - a real contribution from the user.
gop.value_ptr.new_money += c.attributedValue();
},
.transfer_in, .transfer_out, .unmatched_transfer => {
// $0 contribution. Deliberately no-op.
},
else => {},
}
}
// Cash-dest transfer attribution is already removed by `attributedValue()` on
// each cash-side Change - the matcher accumulates into `transfer_attributed`,
// so the residual is what reaches these totals. Nothing further to subtract.
return .{
.changes = try changes.toOwnedSlice(allocator),
.account_totals = acct_totals,
};
}
// ── Transfer reclassification ────────────────────────────────
/// Absolute-dollar tolerance for matching a transfer record's `amount`
/// against a Change's `value()`. Within ±tolerance is a full match
/// (`transfer_in`); strictly below is a partial (`partial_transfer_in`);
/// strictly above is unmatched (with "amount exceeds ..." detail).
///
/// $1 is chosen to absorb typical reconciliation rounding (broker
/// statements often show cents, portfolio.srf may round to whole
/// dollars for cash lots) without masking real discrepancies.
const transfer_amount_tolerance: f64 = 1.0;
/// Relative share-count tolerance for confirming an in-kind transfer:
/// the shares removed on the `from` account must match the shares
/// added on the `to` account to within this fraction of the larger
/// of the two. Brokerages sometimes liquidate a fractional share on
/// an in-kind move (whole shares transfer, the residual fraction is
/// swept to cash), so a small relative drift is expected. Mirrors the
/// 1% direct-indexing drift tolerance used elsewhere in the diff.
const in_kind_share_rel_tolerance: f64 = 0.01;
/// Absolute floor for the in-kind share-count tolerance, so a small
/// transfer (a one-share move) still gets a sane float-rounding
/// epsilon even though 1% of one share is tiny.
const in_kind_share_abs_tolerance: f64 = 0.01;
/// Compute the slice of transfer records that are NEW in `after`
/// relative to `before` - i.e. records the matcher should consider
/// for the current diff. Records present in both logs are skipped
/// (they paired in their own diff cycle and shouldn't re-match).
///
/// `before` may be null (e.g. `transaction_log.srf` did not exist in
/// the before-side revision); in that case every record in `after`
/// is treated as new.
///
/// The returned slice is allocated in `arena` and its records
/// borrow from `after`'s record memory. Treat the slice as read-only
/// and tied to `after`'s lifetime.
///
/// Equality is `TransferRecord.eql` (total-field equality including
/// optional `note`). User edits to an existing record's any field
/// produce a "new" record from this function's POV; the matcher
/// then re-attempts pairing. See the `eql` doc comment for rationale.
fn diffTransferLogs(
arena: std.mem.Allocator,
before: ?*const transaction_log.TransactionLog,
after: *const transaction_log.TransactionLog,
) ![]const transaction_log.TransferRecord {
var out: std.ArrayList(transaction_log.TransferRecord) = .empty;
errdefer out.deinit(arena);
for (after.transfers) |a| {
var found = false;
if (before) |b| for (b.transfers) |bef| {
if (a.eql(bef)) {
found = true;
break;
}
};
if (!found) try out.append(arena, a);
}
return try out.toOwnedSlice(arena);
}
/// Reclassify Changes whose lot (or cash_delta) corresponds to a
/// recorded transfer. Walks `report.changes` and the in-window
/// transfer records together:
///
/// - For each record with `type::in_kind`: pair a source share-
/// removal Change on `from` against a destination share-addition
/// Change on `to`, per-symbol (see `matchInKindTransfer`).
///
/// - For each record with `type::cash`:
/// - If `dest_lot` is a specific lot (`SYMBOL@DATE`): find the
/// matching `new_stock` / `new_drip_lot` / `new_cash` /
/// `new_cd` / `cash_contribution` Change with the same
/// (account, symbol) and flip its kind to `transfer_in` (full)
/// or `partial_transfer_in` (partial), recording
/// `transfer_attributed` / `transfer_note`. Emit
/// `unmatched_transfer` if no such Change exists, if another
/// record already consumed it, or if `amount` exceeds the
/// lot's value by more than tolerance.
/// - If `dest_lot::cash`: verify the `to` account's pooled
/// positive cash activity (new_cash + cash_delta +
/// cash_contribution summed) can cover the record's amount
/// (minus any prior cash-dest records on the same account).
/// Success appends a synthetic `transfer_in` Change for
/// display AND accumulates onto the consumed cash Changes'
/// `transfer_attributed`, so `attributedValue()` reports only
/// the unattributed residual. Failure (budget underflow) emits
/// `unmatched_transfer`.
///
/// - For the `from` side: try to find a matching negative
/// `cash_delta` or `lot_removed` on the sending account and
/// credit the transfer amount against it, flipping to
/// `transfer_out`. A missing `from` side is NOT unmatched - the
/// sending account might not appear in portfolio.srf at all
/// (external account) or its outflow may be masked by unrelated
/// activity (dividend posting offsetting the withdrawal). Only
/// destination mismatches surface as unmatched_transfer.
///
/// Records outside the window (`transfer < window_start` or
/// `transfer > window_end`) are NOT filtered here. The caller is
/// responsible for narrowing the slice to records that should be
/// considered for THIS diff. The production path uses
/// `diffTransferLogs` to pass only the records added to
/// `transaction_log.srf` since `before_rev`, regardless of their
/// `transfer::DATE`. This allows a user to back-date a record
/// (e.g. add a `transfer::2026-05-20` entry on 2026-05-23) and
/// have it pair against the working-copy diff that introduced it.
fn matchTransfers(
allocator: std.mem.Allocator,
changes: *std.ArrayList(Change),
records: []const transaction_log.TransferRecord,
) !void {
// Bookkeeping: track which Change indices have already been
// claimed by a transfer record to catch duplicates on the
// lot-destination path.
var consumed_lot_idx: std.AutoHashMap(usize, void) = .init(allocator);
defer consumed_lot_idx.deinit();
// Per-account cash budget: sum of positive cash activity
// (new_cash + positive cash_delta + cash_contribution) on each
// account. Cash-dest records draw from this pool in record
// order. Underflow past tolerance -> unmatched_transfer.
var cash_budget: std.StringHashMap(f64) = .init(allocator);
defer cash_budget.deinit();
// Shortfalls carried out of the cash-destination path, per destination
// account, drawn down against new lots once every record has been seen.
// Deferred to the end so a second transfer into the same account can add to
// the same budget before any of it is spent.
var transfer_funding: std.StringHashMap(FundingShortfall) = .init(allocator);
defer transfer_funding.deinit();
for (changes.items) |c| {
const v = c.value();
switch (c.kind) {
.new_cash => {
const gop = try cash_budget.getOrPut(c.account);
if (!gop.found_existing) gop.value_ptr.* = 0;
gop.value_ptr.* += v;
},
.cash_delta, .cash_contribution => if (v > 0) {
const gop = try cash_budget.getOrPut(c.account);
if (!gop.found_existing) gop.value_ptr.* = 0;
gop.value_ptr.* += v;
},
else => {},
}
}
// Walk transfer records in caller-supplied order. Caller is
// responsible for filtering to only those records that should
// be considered for THIS diff (typically: records new in the
// after-side `transaction_log.srf` vs. the before-side).
for (records) |rec| {
if (rec.type == .in_kind) {
// In-kind transfers move securities, not cash, so they
// take a separate per-symbol matching pass (source
// `lot_removed`/`drip_negative` + destination
// `new_stock`/`new_drip_lot`/`rollup_delta`) rather than
// the cash budget / from-side path below.
try matchInKindTransfer(allocator, changes, rec);
continue;
}
switch (rec.dest_lot) {
.lot => |dl| {
try matchLotDestination(allocator, changes, &consumed_lot_idx, rec, dl);
},
.cash => {
try matchCashDestination(allocator, changes, &cash_budget, &transfer_funding, rec);
},
}
tryMatchFromSide(changes, rec);
}
// After every record, so multiple transfers into one account pool their
// funding before it is drawn against that account's purchases.
try matchTransferFundedPurchases(allocator, changes, &transfer_funding);
}
/// Append a synthetic `unmatched_transfer` Change carrying the record's
/// raw amount + from/to + date + a reason string. Detail lives on
/// `transfer_note`; `amount` encodes into `delta_shares * unit_value`
/// as `amount * 1.0` so `value()` returns the transfer amount.
fn appendUnmatched(
allocator: std.mem.Allocator,
changes: *std.ArrayList(Change),
rec: transaction_log.TransferRecord,
reason: []const u8,
) !void {
const from = try allocator.dupe(u8, rec.from);
const to = try allocator.dupe(u8, rec.to);
const note = try allocator.dupe(u8, reason);
try changes.append(allocator, .{
.kind = .unmatched_transfer,
.symbol = "",
.account = to, // "landed on" side for the Flagged display
.security_type = .cash, // irrelevant - no lot attached
.delta_shares = rec.amount,
.unit_value = 1.0,
.transfer_attributed = rec.amount,
.transfer_note = note,
.transfer_from = from,
.transfer_date = rec.transfer,
});
}
/// Find the Change matching `dest_lot` and flip its kind. Produces
/// an `unmatched_transfer` on any mismatch (not found, already
/// consumed, amount too big).
fn matchLotDestination(
allocator: std.mem.Allocator,
changes: *std.ArrayList(Change),
consumed: *std.AutoHashMap(usize, void),
rec: transaction_log.TransferRecord,
dl: transaction_log.DestLot.LotRef,
) !void {
// Find the first matching Change by (account, symbol, open_date)
// that's:
// - A destination-side kind (new_* or cash_contribution on the
// `to` account); transfer_in / partial already consumed
// counts as "taken".
// - Not yet claimed by another transfer record.
//
// We don't have direct access to the underlying Lot's open_date
// on the Change struct - it's encoded implicitly in the diff
// (a `new_stock` Change has one lot on the `after` side with a
// unique open_date). Since the matcher can't disambiguate
// multiple lots of the same (account, symbol) opened on
// different dates, we leave the matching keyed just on
// (account, symbol) and accept the rare ambiguity. When two
// lots of the same (account, symbol) appear in the same diff,
// the user would need separate transfer records per lot - the
// first record matches the first Change, the second matches the
// second, etc.
//
// TODO: thread open_date through Change. For now, accept the
// (account, symbol) key and first-match-wins.
_ = dl.open_date; // consulted only for display (see report printer)
var found_idx: ?usize = null;
var saw_consumed = false;
for (changes.items, 0..) |c, i| {
if (!std.mem.eql(u8, c.account, rec.to)) continue;
if (!std.mem.eql(u8, c.symbol, dl.symbol)) continue;
const is_dest_kind = switch (c.kind) {
.new_stock, .new_drip_lot, .new_cash, .new_cd, .cash_contribution => true,
else => false,
};
if (!is_dest_kind) continue;
if (consumed.contains(i)) {
saw_consumed = true;
continue;
}
found_idx = i;
break;
}
if (found_idx) |i| {
const c = &changes.items[i];
const lot_value = c.value();
if (rec.amount > lot_value + transfer_amount_tolerance) {
// Amount exceeds lot by more than tolerance: emit
// unmatched, leave the Change untouched.
const buf = try std.fmt.allocPrint(
allocator,
"amount ${d:.2} exceeds destination lot {s}@ value ${d:.2}",
.{ rec.amount, dl.symbol, lot_value },
);
try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf);
return;
}
try consumed.put(i, {});
// Copy the note from the record (if any) onto the Change.
const note_copy: ?[]const u8 = if (rec.note) |n| try allocator.dupe(u8, n) else null;
const from_copy = try allocator.dupe(u8, rec.from);
if (@abs(rec.amount - lot_value) <= transfer_amount_tolerance) {
c.kind = .transfer_in;
c.transfer_attributed = lot_value; // full
} else {
c.kind = .partial_transfer_in;
c.transfer_attributed = rec.amount; // residual = value() - amount
}
c.transfer_note = note_copy;
c.transfer_from = from_copy;
c.transfer_date = rec.transfer;
} else if (saw_consumed) {
const buf = try std.fmt.allocPrint(
allocator,
"destination lot {s}@ on account {s} already claimed by an earlier transfer record",
.{ dl.symbol, rec.to },
);
try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf);
} else {
const buf = try std.fmt.allocPrint(
allocator,
"destination lot {s}@ not found on account {s}",
.{ dl.symbol, rec.to },
);
try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf);
}
}
/// Same as `appendUnmatched` but takes a pre-formatted note the
/// caller already owns (produced via `std.fmt.allocPrint`).
fn appendUnmatchedWithOwnedNote(
allocator: std.mem.Allocator,
changes: *std.ArrayList(Change),
rec: transaction_log.TransferRecord,
owned_note: []const u8,
) !void {
const from = try allocator.dupe(u8, rec.from);
const to = try allocator.dupe(u8, rec.to);
try changes.append(allocator, .{
.kind = .unmatched_transfer,
.symbol = "",
.account = to,
.security_type = .cash,
.delta_shares = rec.amount,
.unit_value = 1.0,
.transfer_attributed = rec.amount,
.transfer_note = owned_note,
.transfer_from = from,
.transfer_date = rec.transfer,
});
}
/// Verify the `to` account's cash budget has capacity for this
/// record, draw from it, and either attach to an existing cash
/// Change or append a synthetic one.
///
/// Attribution is recorded per-Change on `transfer_attributed`, which
/// `attributedValue()` turns into the unattributed residual. That single
/// mechanism drives every consumer - the totals math, the contributions
/// sections, and audit's large-lot filter - so there is no separate
/// per-account bucket to keep in agreement with it.
fn matchCashDestination(
allocator: std.mem.Allocator,
changes: *std.ArrayList(Change),
cash_budget: *std.StringHashMap(f64),
transfer_funding: *std.StringHashMap(FundingShortfall),
rec: transaction_log.TransferRecord,
) !void {
const budget_entry = cash_budget.getPtr(rec.to);
const available = @max(0.0, if (budget_entry) |p| p.* else 0.0);
// Credit whatever cash actually showed up, and carry the rest as a funding
// budget for the destination's new lots.
//
// This used to bail out entirely when the cash increase fell short, which
// meant a transfer whose cash was invested inside the same window credited
// nothing at all and every purchase it funded read as new money. The cash
// is genuinely absent from the snapshot in that case - it arrived and left
// between two commits - so the shortfall is expected, not a discrepancy.
// See `matchTransferFundedPurchases`, which draws it down and flags only
// what the account's new lots cannot absorb.
const credited = @min(available, rec.amount);
const shortfall = rec.amount - credited;
if (shortfall > transfer_amount_tolerance) {
const gop = try transfer_funding.getOrPut(rec.to);
if (!gop.found_existing) gop.value_ptr.* = .{ .rec = rec };
gop.value_ptr.*.amount += shortfall;
gop.value_ptr.*.declared += rec.amount;
}
// Draw from the budget. Running remainder stays on the budget
// so later records on the same account see the correct
// capacity.
if (budget_entry) |p| p.* -= credited;
// Distribute the record amount across the destination account's
// cash-side Changes by accumulating into each Change's
// `transfer_attributed`. We deliberately do NOT flip the
// Change's `kind`: a single cash Change can be drained by
// multiple records (e.g. two transfers landing on the same
// cash lot), and a single record can drain across multiple
// cash Changes (when the user split the inflow into several
// lots). The kind field can only hold one classification.
//
// Bumping `transfer_attributed` makes `attributedValue()`
// return the unattributed residual - fully-attributed Changes
// drop out of the "New contributions" section, audit's
// "Large new lots" filter, and any other consumer that asks
// "how much of this Change is real new money?". Every consumer -
// per-account totals, the contributions sections, audit's large-lot
// filter - reads that same residual, so there is no second view to
// keep in agreement.
var remaining = credited;
for (changes.items) |*c| {
if (remaining <= 0) break;
if (!std.mem.eql(u8, c.account, rec.to)) continue;
const is_cash_kind = switch (c.kind) {
.new_cash, .cash_contribution => true,
.cash_delta => c.value() > 0,
else => false,
};
if (!is_cash_kind) continue;
const unattributed = c.value() - c.transfer_attributed;
if (unattributed <= 0) continue;
const draw = @min(unattributed, remaining);
c.transfer_attributed += draw;
remaining -= draw;
}
// `remaining > 0` here would indicate the budget pre-check
// accepted a record we couldn't actually distribute. Possible
// if the cash-side Changes' `value()` totals don't agree with
// the `cash_budget` we built (they should - both are derived
// from the same Changes). Leave it as silent over-credit on
// the per-account bucket; the user-visible symptom would be
// a small Joint-trust-style residual showing up in audit.
// Append a synthetic transfer_in Change for Transfers-section
// display.
const from = try allocator.dupe(u8, rec.from);
const to = try allocator.dupe(u8, rec.to);
const note_copy: ?[]const u8 = if (rec.note) |n| try allocator.dupe(u8, n) else null;
try changes.append(allocator, .{
.kind = .transfer_in,
.symbol = "",
.account = to,
.security_type = .cash,
.delta_shares = rec.amount,
.unit_value = 1.0,
.transfer_attributed = rec.amount,
.transfer_note = note_copy,
.transfer_from = from,
.transfer_date = rec.transfer,
});
}
/// Best-effort: find the cash that left the `from` account and
/// reclassify it to `transfer_out`. No-op (silent) if no such Change
/// exists - the sending side may not be in portfolio.srf at all, which
/// the format explicitly allows.
///
/// Restricted to CASH outflows: a negative `cash_delta`, or a removed /
/// closed cash lot (a line drained in full). A `type::cash` record says
/// dollars moved, so a security sale is never its sending leg - selling
/// to raise the cash is a separate event, and the cash decrease is the
/// transfer. Securities moving between accounts is what `type::in_kind`
/// and `matchInKindTransfer` are for, matched by symbol on both sides.
///
/// The type gate is load-bearing rather than tidy-up. This scan was
/// unreachable while `value()` was 0 for every removal; once outflows
/// carried dollars it went live, and an unrelated sale in the same
/// account became a candidate. Consuming it here would flip it out of
/// `lot_removed`, drop it from the sale-proceeds budget in
/// `matchIntraAccountPurchases`, and make the repurchase it funded read
/// as new money - reintroducing the bug that budget exists to fix.
fn tryMatchFromSide(
changes: *std.ArrayList(Change),
rec: transaction_log.TransferRecord,
) void {
for (changes.items) |*c| {
if (c.security_type != .cash) continue;
if (!std.mem.eql(u8, c.account, rec.from)) continue;
// Must be money leaving. `cash_delta` is signed, so an account
// that GAINED cash must not be read as a sending leg; removals
// and closes are outflows by construction.
const outflow: f64 = switch (c.kind) {
.cash_delta => if (c.value() < 0) -c.value() else continue,
.lot_removed, .position_closed => c.face_value,
else => continue,
};
if (outflow < rec.amount - transfer_amount_tolerance) continue;
// Match: flip to transfer_out. We don't track
// transfer_attributed on the from side (it's decorative) but we
// record the counterpart date so the Transfers section can
// cross-reference.
c.kind = .transfer_out;
c.transfer_attributed = rec.amount;
c.transfer_date = rec.transfer;
return;
}
}
/// Match a `type::in_kind` transfer record: securities moved between
/// accounts without any cash changing hands. Unlike the cash path,
/// both the source and destination are lot-level Changes:
///
/// - **Destination** (required): a `new_stock` / `new_drip_lot`
/// (the shares landed as a fresh lot on `to`) or a `rollup_delta`
/// (the shares were added to an existing `to` lot). Identified by
/// the record's `dest_lot::SYMBOL@DATE`. Reclassified to
/// `transfer_in` so it contributes $0 to attribution - it's
/// internal movement, not new money.
/// - **Source** (best-effort): a `lot_removed` / `drip_negative` on
/// the `from` account with the same symbol. Reclassified to
/// `transfer_out`. A missing source is NOT an error - the sending
/// account may be untracked (an external rollover origin), the
/// same tolerance the cash from-side path extends.
///
/// When both sides are present the shares removed must match the
/// shares added to within `in_kind_share_*_tolerance`; a mismatch
/// means the declared transfer doesn't cleanly correspond to the
/// portfolio diff (wrong symbol, unexpected partial fill) and is
/// surfaced as `unmatched_transfer` rather than silently swallowing a
/// real contribution.
///
/// `amount` on an in-kind record is informational - the moved value
/// comes from the destination lot's own `value()` (shares x cost
/// basis), which is what `transfer_attributed` records for display.
/// In-kind movements have no "residual new money" concept (no cash
/// funded them), so there is no `partial_transfer_in` outcome: a
/// destination Change is either fully a transfer or not one at all.
fn matchInKindTransfer(
allocator: std.mem.Allocator,
changes: *std.ArrayList(Change),
rec: transaction_log.TransferRecord,
) !void {
const dl: transaction_log.DestLot.LotRef = switch (rec.dest_lot) {
.lot => |l| l,
.cash => {
try appendUnmatched(allocator, changes, rec, "in-kind transfer requires a SYMBOL@DATE destination lot, not cash");
return;
},
};
// ── Destination (required) ───────────────────────────────────
// First share-addition Change on the `to` account for this
// symbol. Keyed on (account, symbol, kind); open_date is
// consulted only for display (Change doesn't carry it for
// rollup_delta). Once matched, a Change is flipped to transfer_in,
// so a second record naming the same lot won't re-match it (it's
// no longer a destination kind) and falls through to not-found.
var dest_idx: ?usize = null;
for (changes.items, 0..) |c, i| {
if (!std.mem.eql(u8, c.account, rec.to)) continue;
if (!std.mem.eql(u8, c.symbol, dl.symbol)) continue;
switch (c.kind) {
.new_stock, .new_drip_lot, .rollup_delta => {},
else => continue,
}
dest_idx = i;
break;
}
const di = dest_idx orelse {
const buf = try std.fmt.allocPrint(
allocator,
"in-kind destination lot {s}@ not found on account {s} (expected a new lot or share increase; an earlier record may have claimed it)",
.{ dl.symbol, rec.to },
);
try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf);
return;
};
// ── Source (best-effort) ─────────────────────────────────────
// First share-removal Change on the `from` account for this
// symbol. A missing source is not an error - the sending account
// may be untracked (external rollover origin).
var src_idx: ?usize = null;
for (changes.items, 0..) |c, i| {
if (!std.mem.eql(u8, c.account, rec.from)) continue;
if (!std.mem.eql(u8, c.symbol, dl.symbol)) continue;
switch (c.kind) {
.lot_removed, .position_closed, .drip_negative => {},
else => continue,
}
src_idx = i;
break;
}
// ── Share-count gate (only when both sides are present) ──────
if (src_idx) |si| {
const dest_shares = @abs(changes.items[di].delta_shares);
const src_shares = @abs(changes.items[si].delta_shares);
const tol = @max(
in_kind_share_abs_tolerance,
in_kind_share_rel_tolerance * @max(dest_shares, src_shares),
);
if (@abs(dest_shares - src_shares) > tol) {
const buf = try std.fmt.allocPrint(
allocator,
"in-kind share mismatch for {s}: {d:.4} removed from {s} vs {d:.4} added to {s}",
.{ dl.symbol, src_shares, rec.from, dest_shares, rec.to },
);
try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf);
return;
}
}
// ── Reclassify ───────────────────────────────────────────────
// Dupe owned strings before mutating so an allocation failure
// doesn't leave a half-rewritten Change. The moved value is the
// destination lot's own value (shares x cost basis), not the
// record's `amount` (which is an informational annotation).
const note_copy: ?[]const u8 = if (rec.note) |n| try allocator.dupe(u8, n) else null;
const from_copy = try allocator.dupe(u8, rec.from);
const moved_value = changes.items[di].value();
const dest = &changes.items[di];
dest.kind = .transfer_in;
dest.transfer_attributed = moved_value;
dest.transfer_note = note_copy;
dest.transfer_from = from_copy;
dest.transfer_date = rec.transfer;
if (src_idx) |si| {
const src = &changes.items[si];
src.kind = .transfer_out;
// The from-side display reads `transfer_attributed` for the
// moved value and `account` for the sending account; it does
// not read `transfer_from` (see `printTransferLine`).
src.transfer_attributed = moved_value;
src.transfer_date = rec.transfer;
}
}
// ── Intra-account purchase netting ───────────────────────────
/// Net same-account internal money movement against the changes that
/// would otherwise read as fresh contributions.
///
/// Two things can fund a purchase without any new money entering the
/// portfolio, and both show up as changes on the SAME account:
///
/// 1. **Cash already in the account.** A plain buy shows a
/// `new_stock` / `new_cd` lot appearing and the account's cash
/// going down (a negative `cash_delta`, or a `lot_removed` if the
/// cash line was fully consumed).
/// 2. **Proceeds of a security sale.** A reallocation - sell A, buy B
/// in the same account - shows a valued outflow (`lot_removed`, or
/// `drip_negative` for a partial sale) and one or more new lots.
///
/// Case 2 was previously invisible: the budget only accepted cash, so
/// a $467k FAGIX -> SPHY/FDVV swap booked the full $467k as a
/// contribution, and because `compare` derives
/// `gains = liquid.delta - contributions` the phantom appeared again as
/// an equal-and-opposite loss.
///
/// ## Where the proceeds went decides what they can fund
///
/// Sale proceeds cannot fund a purchase if they are demonstrably still
/// sitting in cash at the end of the window. Splitting on the
/// account's net cash movement:
///
/// ```
/// proceeds_resting = min(sale_proceeds, max(0, cash_change))
/// proceeds_spent = sale_proceeds - proceeds_resting
/// ```
///
/// - `proceeds_spent` funds `new_stock` / `new_cd`.
/// - `proceeds_resting` instead cancels a `cash_contribution` on the
/// account. That kind only exists on accounts flagged
/// `cash_is_contribution::true`, where a positive cash delta is
/// assumed to be new money - an assumption that is wrong for sale
/// proceeds, and that the flag cannot distinguish on its own.
///
/// Without the split, a window holding both a sale whose proceeds
/// stayed in cash and a genuine deposit that was invested would let
/// the sale swallow the deposit.
///
/// Cash decreases are drawn *after* sale proceeds, so the two cannot
/// double-count the same dollars: proceeds that passed through cash on
/// their way into a security are already absorbed, and the drawdown
/// caps each lot at its remaining unattributed value.
///
/// Runs after `matchTransfers`, so:
/// - Cash already reclassified to `transfer_out` (an outflow to a
/// declared transfer) is NOT in the budget - it can't also fund an
/// intra-account purchase.
/// - Lots already reclassified to `transfer_in` / `partial_transfer_in`
/// are no longer `new_stock` / `new_cd`, so an explicit transfer
/// record always takes priority over this automatic netting.
///
/// Scope on the destination side stays narrow - only brand-new
/// `new_stock` / `new_cd` lots and `cash_contribution`.
/// `new_drip_lot` (a reinvested dividend, not a cash buy),
/// `rollup_delta` / `drip_confirmed` (share adds to an existing lot),
/// and `partial_transfer_in` residuals are left untouched. Same-account
/// only; cross-account movement stays `transaction_log.srf`'s job.
///
/// Budget is drawn in change-iteration order when an account has
/// several purchase lots; the total netted is the same regardless of
/// order, but which specific lot shows a residual can vary. This
/// mirrors `matchCashDestination`'s order-dependent draw.
/// Draw `budget` down against the new purchase lots in `account`, marking the
/// funded portion on each. Returns whatever the account's lots could not
/// absorb.
///
/// Shared by the three things that can fund a purchase without it being new
/// money: cash that visibly left the same account, the proceeds of a
/// same-account security sale, and a declared transfer whose cash was spent
/// before it could be observed. The drawdown is identical; only the meaning of
/// a leftover differs, which is why the callers handle the return value
/// differently rather than this function deciding.
fn drawDownAgainstNewLots(changes: *std.ArrayList(Change), account: []const u8, budget: f64) f64 {
var remaining = budget;
for (changes.items) |*c| {
if (remaining <= 0) break;
switch (c.kind) {
.new_stock, .new_cd => {},
else => continue,
}
if (!std.mem.eql(u8, c.account, account)) continue;
const unattributed = c.attributedValue();
if (unattributed <= 0) continue;
const draw = @min(unattributed, remaining);
c.internal_funded += draw;
remaining -= draw;
}
return remaining;
}
/// Attribute purchases funded by a declared transfer whose cash never appeared
/// in a snapshot.
///
/// A `transfer` record says money moved from A to B. `matchCashDestination`
/// credits it against an observed cash increase in B - but when the cash is
/// invested inside the same reconcile window, no snapshot ever contains it: the
/// diff sees new security lots in B and a few dollars of leftover cash. The
/// transfer then failed its cash check and the purchases counted as fresh
/// money, which is how one 401(k)-to-BrokerageLink move reported $738,814 of
/// contributions that were nothing of the kind.
///
/// So the shortfall becomes a funding budget for that account's new lots -
/// exactly what `matchIntraAccountPurchases` does with an observed cash
/// decrease, seeded from the operator's declaration instead of from an
/// observation. Anything the lots cannot absorb is still flagged: a transfer
/// claiming more than the destination gained is a real discrepancy and must not
/// be silently swallowed.
fn matchTransferFundedPurchases(
allocator: std.mem.Allocator,
changes: *std.ArrayList(Change),
funding: *std.StringHashMap(FundingShortfall),
) !void {
var it = funding.iterator();
while (it.next()) |entry| {
const account = entry.key_ptr.*;
const sf = entry.value_ptr.*;
if (sf.amount <= transfer_amount_tolerance) continue;
const leftover = drawDownAgainstNewLots(changes, account, sf.amount);
if (leftover <= transfer_amount_tolerance) continue;
const buf = try std.fmt.allocPrint(
allocator,
"transfer of ${d:.2} exceeds the destination's cash increase and new lots by ${d:.2}",
.{ sf.declared, leftover },
);
try appendUnmatchedWithOwnedNote(allocator, changes, sf.rec, buf);
}
}
/// Per-account transfer shortfall, plus the record it came from so an
/// unabsorbed remainder can be reported against the right transfer.
const FundingShortfall = struct {
amount: f64 = 0,
declared: f64 = 0,
rec: transaction_log.TransferRecord,
};
/// Per-account internal-funding flows, gathered in one pass over the
/// changes so the spent/resting split can be computed before any
/// drawdown happens.
const AccountFlows = struct {
/// Dollars realized by selling securities in this account.
sale_proceeds: f64 = 0,
/// Signed net movement of the account's cash pool. Positive means
/// the account is holding more cash at the end of the window.
cash_change: f64 = 0,
};
fn matchIntraAccountPurchases(
allocator: std.mem.Allocator,
changes: *std.ArrayList(Change),
) !void {
var flows: std.StringHashMap(AccountFlows) = .init(allocator);
defer flows.deinit();
for (changes.items) |c| {
const gop = try flows.getOrPut(c.account);
if (!gop.found_existing) gop.value_ptr.* = .{};
const f = gop.value_ptr;
switch (c.kind) {
// Cash pool moved. `cash_contribution` is a positive
// cash_delta on a flagged account, so it counts here too -
// it is still cash arriving.
.cash_delta, .cash_contribution, .new_cash => f.cash_change += c.value(),
// A removed lot is a fully-drained line: its dollar amount
// lives in `face_value` because `value()` is negative for
// removals. Cash lines drain the pool; securities realize
// proceeds.
.lot_removed => if (c.security_type == .cash) {
f.cash_change -= c.face_value;
} else {
f.sale_proceeds += c.face_value;
},
// Closed in place, valued at close_price. Cash lines that
// get closed rather than deleted drain the pool instead.
.position_closed => if (c.security_type == .cash) {
f.cash_change -= c.face_value;
} else {
f.sale_proceeds += c.face_value;
},
// Partial sale of an existing stock lot: shares went down
// on an unchanged key, so `value()` is the negative
// proceeds.
.drip_negative => f.sale_proceeds += -c.value(),
else => {},
}
}
var it = flows.iterator();
while (it.next()) |e| {
const account = e.key_ptr.*;
const f = e.value_ptr.*;
// Proceeds still sitting in cash cannot have funded a purchase.
// Split on the account's net cash movement so a sale whose
// proceeds stayed put can't swallow a genuine deposit that was
// invested in the same window.
const resting = @min(f.sale_proceeds, @max(0, f.cash_change));
const spent = f.sale_proceeds - resting;
// Spent proceeds fund new security lots. A leftover is
// unremarkable (proceeds can go anywhere) so it is discarded.
if (spent > 0) _ = drawDownAgainstNewLots(changes, account, spent);
// Resting proceeds cancel a `cash_contribution`, which would
// otherwise book the sale as new money on an account flagged
// `cash_is_contribution::true`.
if (resting > 0) drawDownAgainstCashContribution(changes, account, resting);
// Finally, cash that visibly left funds whatever new lot value
// is still unattributed. Drawn last so proceeds that passed
// through cash on their way into a security aren't counted
// twice.
if (f.cash_change < 0) _ = drawDownAgainstNewLots(changes, account, -f.cash_change);
}
}
/// Draw `budget` down against `cash_contribution` changes in `account`.
///
/// Mirrors `drawDownAgainstNewLots` but targets the opt-in
/// cash-is-contribution kind, whose whole premise - "cash arriving here
/// is new money" - is false for the proceeds of a security sale. Any
/// leftover is discarded: the flag exists precisely because cash can
/// arrive from outside, so an unabsorbed remainder is a real
/// contribution.
fn drawDownAgainstCashContribution(changes: *std.ArrayList(Change), account: []const u8, budget: f64) void {
var remaining = budget;
for (changes.items) |*c| {
if (remaining <= 0) break;
if (c.kind != .cash_contribution) continue;
if (!std.mem.eql(u8, c.account, account)) continue;
const unattributed = c.attributedValue();
if (unattributed <= 0) continue;
const draw = @min(unattributed, remaining);
c.internal_funded += draw;
remaining -= draw;
}
}
// ── Output ───────────────────────────────────────────────────
fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, color: bool) !void {
const h_color = cli.CLR_HEADER;
const pos_color = cli.CLR_POSITIVE;
const mut_color = cli.CLR_MUTED;
const warn_color = cli.CLR_WARNING;
// Header
try cli.setBold(out, color);
try cli.printFg(out, color, h_color, "Portfolio contributions report\n", .{});
try cli.setFg(out, color, mut_color);
try out.writeAll(" ");
try out.writeAll(label);
try out.writeAll("\n\n");
try cli.reset(out, color);
// If nothing changed at all, say so explicitly and return.
if (report.changes.len == 0) {
try cli.printFg(out, color, mut_color, " No changes detected.\n", .{});
return;
}
// ── Section: New contributions / purchases ──
try printSection(out, "New contributions / purchases", color, h_color);
var any = false;
var new_total: f64 = 0;
for (report.changes) |c| switch (c.kind) {
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => {
// Use attributedValue so a cash lot fully covered by a
// transfer record (whose `transfer_attributed` equals
// its `value()`) drops out, and a partially-covered
// lot shows only the unattributed remainder. The same
// residual logic covers `new_stock` / `new_cd` partially
// funded by a same-account cash decrease (internal_funded).
const residual = c.attributedValue();
if (residual <= 0) continue;
any = true;
new_total += residual;
if (c.internal_funded > 0) {
try printCashFundedResidualLine(out, c, color, pos_color, mut_color);
} else {
try printChangeLine(out, c, color, pos_color);
}
},
.partial_transfer_in => {
// Lot partially funded by a transfer: show the residual
// (value() - transfer_attributed) in this section, with
// the full lot value annotated so the user can see where
// the transferred portion went.
const residual = c.attributedValue();
if (residual > 0) {
any = true;
new_total += residual;
try printPartialTransferLine(out, c, color, pos_color, mut_color);
}
},
else => {},
};
if (!any) try printNone(out, color, mut_color);
if (any) try printTotalLine(out, "Total", new_total, color, h_color);
try out.writeAll("\n");
// ── Section: DRIP (confirmed) ──
try printSection(out, "DRIP (confirmed - lots tagged drip::true)", color, h_color);
any = false;
var drip_total: f64 = 0;
for (report.changes) |c| switch (c.kind) {
.new_drip_lot, .drip_confirmed => {
any = true;
drip_total += c.value();
try printChangeLine(out, c, color, pos_color);
},
else => {},
};
if (!any) try printNone(out, color, mut_color);
if (any) try printTotalLine(out, "Total", drip_total, color, h_color);
try out.writeAll("\n");
// ── Section: Rollup share deltas (ambiguous) ──
try printSection(out, "Rollup share deltas (DRIP or contribution)", color, h_color);
any = false;
var rollup_total: f64 = 0;
for (report.changes) |c| switch (c.kind) {
.rollup_delta => {
any = true;
rollup_total += c.value();
try printChangeLine(out, c, color, pos_color);
},
else => {},
};
if (!any) try printNone(out, color, mut_color);
if (any) try printTotalLine(out, "Total", rollup_total, color, h_color);
try out.writeAll("\n");
// ── Section: CD events ──
try printSection(out, "CD events", color, h_color);
any = false;
var cd_interest_total: f64 = 0;
for (report.changes) |c| switch (c.kind) {
.cd_matured, .cd_removed_early => {
any = true;
// Find same-account cash_delta to compute implied interest.
var matched_cash_delta: ?f64 = null;
for (report.changes) |c2| {
if (c2.kind == .cash_delta and std.mem.eql(u8, c2.account, c.account)) {
matched_cash_delta = c2.value();
break;
}
}
const interest: ?f64 = if (c.kind == .cd_matured and matched_cash_delta != null)
matched_cash_delta.? - c.face_value
else
null;
if (interest) |i| if (i > 0) {
cd_interest_total += i;
};
try printCdLine(out, c, interest, color);
},
else => {},
};
if (!any) try printNone(out, color, mut_color);
if (any and cd_interest_total > 0) try printTotalLine(out, "Implied interest captured", cd_interest_total, color, h_color);
try out.writeAll("\n");
// ── Section: Cash deltas ──
//
// Totalled, unlike the other uncounted sections, because this is the one
// that usually carries the bulk of `compare`'s "Uncounted in" and the one a
// reader ends up adding by hand. Safe to total here because these rows print
// `value()`, the same basis `uncountedTotals` uses - see the note there for
// why the sale sections are left alone.
try printSection(out, "Cash deltas (raw balance changes)", color, h_color);
any = false;
var cash_delta_total: f64 = 0;
for (report.changes) |c| switch (c.kind) {
.cash_delta => {
any = true;
cash_delta_total += c.value();
try printCashDeltaLine(out, c, report, color);
},
else => {},
};
if (!any) try printNone(out, color, mut_color);
if (any) try printTotalLine(out, "Total", cash_delta_total, color, h_color);
try out.writeAll("\n");
// ── Section: Internal purchases (internally funded - not counted) ──
//
// Two halves of the same story, kept together so the money can be
// followed:
//
// - Sales that released funds: `position_closed` (closed in
// place, valued at close_price) and non-cash `lot_removed`
// (the record was deleted outright). Collapsed by
// account+symbol, because closing a DRIP-fed position can
// retire hundreds of lots and one line per lot buries the
// report - the real trade that prompted this retired 219.
// - `new_stock` / `new_cd` lots, and `cash_contribution`, whose
// value was funded (wholly or in part) by that money or by a
// decrease in the SAME account's cash, as attributed by
// `matchIntraAccountPurchases`. The funded portion is internal
// movement, not a fresh contribution, so it shows here (muted)
// rather than counted in "New contributions". A partially-funded
// lot also appears there on its unfunded residual.
var any_internal = false;
for (report.changes) |c| {
if (c.internal_funded > 0 or isSaleKind(c)) {
any_internal = true;
break;
}
}
if (any_internal) {
try printSection(out, "Internal purchases (cash / sale proceeds -> securities, not counted)", color, h_color);
try printCollapsedSales(out, report, color, mut_color);
for (report.changes) |c| {
if (c.internal_funded <= 0) continue;
try printInternalPurchaseLine(out, c, color, mut_color);
}
try out.writeAll("\n");
}
// ── Section: Transfers (matched - not counted) ──
//
// Any record from `transaction_log.srf` that matched a
// destination-side Change (or pooled cash on the receiving
// account). These entries contribute $0 to attribution; the
// destination's lot/cash value is reclassified as internal
// money movement, not new contribution. Shown between Cash
// deltas and Lot edits so the user can cross-reference them
// against the raw cash movement above.
var any_xfer = false;
for (report.changes) |c| switch (c.kind) {
.transfer_in, .partial_transfer_in, .transfer_out => {
any_xfer = true;
break;
},
else => {},
};
if (any_xfer) {
try printSection(out, "Transfers (matched - not counted)", color, h_color);
for (report.changes) |c| switch (c.kind) {
.transfer_in, .partial_transfer_in, .transfer_out => {
try printTransferLine(out, c, color, mut_color);
},
else => {},
};
try out.writeAll("\n");
}
// ── Section: Price-only updates ──
var any_price = false;
for (report.changes) |c| if (c.kind == .price_only) {
any_price = true;
break;
};
if (any_price) {
try printSection(out, "Price-only updates (informational)", color, h_color);
for (report.changes) |c| switch (c.kind) {
.price_only => {
try printPriceOnlyLine(out, c, color, mut_color);
},
else => {},
};
try out.writeAll("\n");
}
// ── Section: Lot edits (reclassified, not counted) ──
//
// Broken strict lot keys that matched a secondary key with
// approximately-equal share totals. Shown as a muted section so
// the user can verify the reclassification was correct (e.g. a CD
// auto-renewed `open_date`, an account got renamed, or the
// tax-loss account had shares tweaked during reconciliation).
// Not counted as contributions, DRIP, or removals.
var any_edit = false;
for (report.changes) |c| if (c.kind == .lot_edited) {
any_edit = true;
break;
};
if (any_edit) {
try printSection(out, "Lot edits (same position, key rewritten - not counted)", color, h_color);
for (report.changes) |c| switch (c.kind) {
.lot_edited => {
try cli.setFg(out, color, mut_color);
try writeRowPrefix(out, c.symbol, c.account);
try out.writeAll(" (strict key broke, shares unchanged)\n");
try cli.reset(out, color);
},
else => {},
};
try out.writeAll("\n");
}
// ── Section: Flagged ──
var any_flag = false;
for (report.changes) |c| switch (c.kind) {
.flagged, .unmatched_transfer => any_flag = true,
.lot_removed, .drip_negative => if (!isSaleKind(c)) {
any_flag = true;
},
else => {},
};
if (any_flag) {
try printSection(out, "Flagged for review", color, h_color);
for (report.changes) |c| switch (c.kind) {
.flagged => {
try printFlaggedLine(out, c, color, warn_color);
},
// Security sales are reported under Internal purchases,
// where their proceeds can be read against what they
// funded. Only a vanished cash line lands here.
.lot_removed, .drip_negative => if (!isSaleKind(c)) {
try printFlaggedLine(out, c, color, warn_color);
},
.unmatched_transfer => {
try printUnmatchedTransferLine(out, c, color, warn_color);
},
else => {},
};
try out.writeAll("\n");
}
// ── Summary ──
try printSection(out, "Summary by account", color, h_color);
var ait = report.account_totals.iterator();
var total_new: f64 = 0;
var total_drip: f64 = 0;
var total_rollup: f64 = 0;
var total_cd_int: f64 = 0;
// Print per-account rows, recomputing CD interest as we go.
while (ait.next()) |entry| {
const acct = entry.key_ptr.*;
const t = entry.value_ptr.*;
// Recompute this account's CD interest from change list.
var cd_int: f64 = 0;
var face: f64 = 0;
for (report.changes) |c| {
if (!std.mem.eql(u8, c.account, acct)) continue;
if (c.kind == .cd_matured) face += c.face_value;
}
if (face > 0 and t.cash_delta > 0) {
const i = t.cash_delta - face;
if (i > 0) cd_int = i;
}
total_new += t.new_money;
total_drip += t.drip_confirmed;
total_rollup += t.rollup;
total_cd_int += cd_int;
const acct_label = if (acct.len == 0) "(no account)" else acct;
// Same width and the same gutter guarantee as the per-change rows, minus
// the symbol column this section has no use for.
try out.writeAll(" ");
try padTo(out, acct_label, acct_w);
try printSummaryCell(out, " new", t.new_money, color);
try printSummaryCell(out, " drip", t.drip_confirmed, color);
try printSummaryCell(out, " rollup", t.rollup, color);
try printSummaryCell(out, " cd-int", cd_int, color);
try out.writeAll("\n");
}
try out.writeAll("\n");
// Grand totals
try cli.setBold(out, color);
try cli.printFg(out, color, h_color, "Totals\n", .{});
try out.print(" New contributions / purchases: {f}\n", .{Money.from(total_new)});
try out.print(" DRIP (confirmed): {f}\n", .{Money.from(total_drip)});
try out.print(" Rollup share deltas: {f} (DRIP or contribution; can't distinguish)\n", .{Money.from(total_rollup)});
if (total_cd_int > 0) {
try out.print(" CD interest captured: {f}\n", .{Money.from(total_cd_int)});
}
// Grand total across everything "money in"-ish. CD interest is
// included because it's real return realized during the window,
// even though it originated inside the portfolio.
const grand = total_new + total_drip + total_rollup + total_cd_int;
try cli.printFg(out, color, h_color, " Grand total: {f}\n", .{Money.from(grand)});
// The figures `compare` prints as "Uncounted in/out", restated here because
// that line says "see `zfin contributions`" and until now nothing in this
// report added up to them. Same helper, so they are the same numbers by
// construction rather than by hope. Named sections so the rows behind each
// side can actually be found.
const unc = uncountedTotals(report.changes);
if (@abs(unc.in) >= 0.005 or @abs(unc.out) >= 0.005) {
try out.writeAll("\n");
try cli.printFg(out, color, h_color, "Uncounted flows (inside `Investment gains`, not attributed)\n", .{});
try out.print(" In: {f}\n", .{Money.from(unc.in).signed()});
try out.print(" Out: {f}\n", .{Money.from(unc.out).signed()});
try cli.printFg(out, color, h_color, " Net: {f}\n", .{Money.from(unc.in + unc.out).signed()});
// Name only the sections that actually contributed. Listing all four
// unconditionally would point at headings that were never printed
// (Flagged and Lot edits are both conditional), and it would put their
// titles on screen even when the sections are absent - which is not just
// untidy, it defeats any "this section did not appear" check.
var listed: usize = 0;
for ([_]struct { name: []const u8, hit: bool }{
.{ .name = "Cash deltas", .hit = anyUncountedIn(report.changes, .cash_delta_group) },
.{ .name = "Internal purchases", .hit = anyUncountedIn(report.changes, .sale_group) },
.{ .name = "Flagged for review", .hit = anyUncountedIn(report.changes, .flagged_group) },
}) |g| {
if (!g.hit) continue;
try cli.printFg(out, color, mut_color, "{s}{s}", .{ if (listed == 0) " from: " else ", ", g.name });
listed += 1;
}
if (listed > 0) try out.writeAll("\n");
}
}
/// Which print section a given uncounted change shows up under. Used only to
/// caption the total; `Lot edits` is deliberately absent because `lot_edited`
/// values to zero, so it can never move the figure it would be credited for.
const UncountedGroup = enum { cash_delta_group, sale_group, flagged_group };
fn anyUncountedIn(changes: []const Change, group: UncountedGroup) bool {
for (changes) |c| {
if (!isUncountedKind(c.kind)) continue;
if (@abs(c.value()) < 0.005) continue;
const g: UncountedGroup = switch (c.kind) {
.cash_delta => .cash_delta_group,
.position_closed, .lot_removed, .drip_negative => if (isSaleKind(c)) .sale_group else .flagged_group,
.flagged => .flagged_group,
else => continue,
};
if (g == group) return true;
}
return false;
}
fn printSection(out: *std.Io.Writer, title: []const u8, color: bool, hdr: [3]u8) !void {
try cli.setBold(out, color);
try cli.printFg(out, color, hdr, "== {s} ==\n", .{title});
}
fn printNone(out: *std.Io.Writer, color: bool, muted: [3]u8) !void {
try cli.printFg(out, color, muted, " (none)\n", .{});
}
/// Symbol column. 16 rather than 14, because a portfolio may carry illiquid assets
/// under free-form names instead of tickers, and 15-character names are ordinary.
/// The old width was sized for a population this report no longer only contains.
const sym_w = 16;
/// Account column. 30, because a plan account's full name runs to about 28
/// characters once it carries a plan type and a sub-account qualifier.
const acct_w = 30;
/// Pad `s` into a `w`-wide field, always leaving at least one space behind it.
///
/// The guarantee is the point. `{s:<N}` OVERFLOWS instead of truncating, so a
/// single over-long value both shifted its own row right AND consumed the gutter,
/// welding itself to the next field. And no fixed `N` avoids it here: symbols in
/// this report run from a 3-character ticker to a ~24-character OCC option
/// description, with free-form illiquid asset names in between. So the widths above
/// are chosen for the common case and this keeps the rare long one readable - it
/// loses its alignment, not its whitespace.
fn padTo(out: *std.Io.Writer, s: []const u8, w: usize) !void {
try out.writeAll(s);
try out.splatByteAll(' ', if (s.len >= w) 1 else w - s.len);
}
/// The ` symbol account ` prefix every per-change row opens with.
///
/// One function rather than a format-string literal repeated at fourteen call
/// sites. The literal is how the widths drifted out of step in the first place:
/// twelve rows said `{s:<14}{s:<24}`, the CD continuation said the same with
/// blank arguments, the lot-edit row said `{s: <12} {s: <24}`, and the account
/// summary said `{s:<28}` - four different answers to one question, and each new
/// long value found a different one of them.
///
/// Callers own the single space that follows, so content lands one column past
/// `acct_w`. Keep it: every section has to agree on that column or the report
/// shears between sections instead of within a row, which is harder to spot.
fn writeRowPrefix(out: *std.Io.Writer, symbol: []const u8, account: []const u8) !void {
try out.writeAll(" ");
try padTo(out, symbol, sym_w);
try padTo(out, account, acct_w);
}
test "padTo: pads short values to width and never welds a long one" {
var buf: [128]u8 = undefined;
// Short: padded to the column.
var w1 = std.Io.Writer.fixed(&buf);
try padTo(&w1, "CASH", 16);
try std.testing.expectEqualStrings("CASH ", w1.buffered());
// Exactly one under the width: still a gutter, and the last length that aligns.
var w2 = std.Io.Writer.fixed(&buf);
try padTo(&w2, "Fifteen Chars15", 16);
try std.testing.expectEqualStrings("Fifteen Chars15 ", w2.buffered());
// Over: alignment is gone, whitespace is not. `{s:<16}` produced no space
// here at all, which ran the value into the next column. An OCC option
// description is the realistic overflow.
var w3 = std.Io.Writer.fixed(&buf);
try padTo(&w3, "ZZZZ 01/01/2030 100.00 C", 16);
try std.testing.expectEqualStrings("ZZZZ 01/01/2030 100.00 C ", w3.buffered());
}
fn printTotalLine(out: *std.Io.Writer, label: []const u8, v: f64, color: bool, hdr: [3]u8) !void {
try cli.printFg(out, color, hdr, " {s}: {f}\n", .{ label, Money.from(v) });
}
fn printChangeLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8) !void {
var share_buf: [32]u8 = undefined;
var price_buf: [32]u8 = undefined;
var val_buf: [32]u8 = undefined;
const share_str = std.fmt.bufPrint(&share_buf, "{d:.4}", .{c.delta_shares}) catch "?";
const price_str = std.fmt.bufPrint(&price_buf, "{f}", .{Money.from(c.unit_value)}) catch "$?";
const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.value())}) catch "$?";
const acct = if (c.account.len == 0) "(no account)" else c.account;
try writeRowPrefix(out, c.symbol, acct);
if (c.security_type == .cash) {
try cli.printFg(out, color, pos, " {s}", .{val_str});
} else {
try out.print(" {s} shares × {s} = ", .{ share_str, price_str });
try cli.printFg(out, color, pos, "{s}", .{val_str});
}
try out.writeAll("\n");
}
fn printCdLine(out: *std.Io.Writer, c: Change, implied_interest: ?f64, color: bool) !void {
var mat_buf: [10]u8 = undefined;
const mat_str = if (c.maturity_date) |d| (std.fmt.bufPrint(&mat_buf, "{f}", .{d}) catch "????-??-??") else "(no maturity)";
const acct = if (c.account.len == 0) "(no account)" else c.account;
const verb = switch (c.kind) {
.cd_matured => "matured",
.cd_removed_early => "removed EARLY",
else => "removed",
};
try writeRowPrefix(out, c.symbol, acct);
try out.print(" {s:<16} face {f} maturity {s}\n", .{
verb,
Money.from(c.face_value),
mat_str,
});
if (implied_interest) |i| {
try writeRowPrefix(out, "", "");
try cli.printFg(out, color, cli.CLR_POSITIVE, " implied interest: {f}\n", .{Money.from(i)});
}
}
fn printCashDeltaLine(out: *std.Io.Writer, c: Change, report: *const Report, color: bool) !void {
const v = c.value();
const acct = if (c.account.len == 0) "(no account)" else c.account;
const sign = if (v >= 0) "+" else "-";
try writeRowPrefix(out, c.symbol, acct);
try out.writeAll(" cash ");
try cli.printGainLoss(out, color, v, "{s}{f}", .{ sign, Money.from(@abs(v)) });
// 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)) {
try cli.printFg(out, color, cli.CLR_MUTED, " (may include CD maturity of {f})", .{Money.from(o.face_value)});
break;
}
}
try out.writeAll("\n");
}
fn printPriceOnlyLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void {
const acct = if (c.account.len == 0) "(no account)" else c.account;
try cli.setFg(out, color, muted);
try writeRowPrefix(out, c.symbol, acct);
try out.print(" price {f} -> {f}\n", .{
Money.from(c.old_price),
Money.from(c.new_price),
});
}
fn printFlaggedLine(out: *std.Io.Writer, c: Change, color: bool, warn: [3]u8) !void {
const acct = if (c.account.len == 0) "(no account)" else c.account;
try cli.setFg(out, color, warn);
switch (c.kind) {
.flagged => {
try writeRowPrefix(out, c.symbol, acct);
try out.print(" {s}", .{c.detail orelse "edited"});
},
.lot_removed => {
try writeRowPrefix(out, c.symbol, acct);
try out.print(" {s} lot removed (face {f})", .{
@tagName(c.security_type), Money.from(c.face_value),
});
},
.drip_negative => {
try writeRowPrefix(out, c.symbol, acct);
try out.print(" shares decreased on existing lot ({f})", .{Money.from(@abs(c.value()))});
},
else => {},
}
try cli.reset(out, color);
try out.writeAll("\n");
}
fn printSummaryCell(out: *std.Io.Writer, label: []const u8, v: f64, color: bool) !void {
try out.print("{s} ", .{label});
if (v == 0) {
try cli.printFg(out, color, cli.CLR_MUTED, "{s:>12}", .{"-"});
} else {
try cli.printFg(out, color, cli.CLR_POSITIVE, "{f}", .{Money.from(v).padRight(12)});
}
}
// ── Transfer-related line printers ───────────────────────────
/// Two-line rendering for a matched transfer (either a destination
/// lot/cash match or a from-side match). Muted throughout - these
/// don't count toward attribution so visually step them back.
///
/// ```
/// 2026-05-02 $145,300.00 Acct A -> Acct B (full attribution)
/// -> SYM@2026-05-03
/// ```
fn printTransferLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void {
var date_buf: [10]u8 = undefined;
const date_str = if (c.transfer_date) |d| (std.fmt.bufPrint(&date_buf, "{f}", .{d}) catch "????-??-??") else "????-??-??";
var val_buf: [32]u8 = undefined;
const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.transfer_attributed)}) catch "$?";
const from_str = c.transfer_from orelse "?";
// For transfer_in / partial on the destination side, c.account
// is the `to` account. For transfer_out, c.account is the `from`
// account (the sending Change). Label accordingly.
const arrow_from = if (c.kind == .transfer_out) c.account else from_str;
const arrow_to = if (c.kind == .transfer_out)
"?" // from-side match has no explicit `to` carried through
else
c.account;
const tag: []const u8 = switch (c.kind) {
.transfer_in => "(full attribution)",
.partial_transfer_in => "(partial attribution)",
.transfer_out => "(from side)",
else => "",
};
try cli.printFg(
out,
color,
muted,
" {s} {s} {s} -> {s} {s}\n",
.{ date_str, val_str, arrow_from, arrow_to, tag },
);
// Second line: destination detail. For lot destinations, show
// the SYM@DATE. For cash, show "-> cash". For partial, show the
// lot_value / attributed breakdown.
if (c.kind == .partial_transfer_in) {
const lot_value = c.value();
const residual = lot_value - c.transfer_attributed;
try cli.printFg(
out,
color,
muted,
" -> {s} ({f} of {f} lot - {f} from pre-existing cash)\n",
.{
if (c.symbol.len > 0) c.symbol else "cash",
Money.from(c.transfer_attributed),
Money.from(lot_value),
Money.from(residual),
},
);
} else if (c.symbol.len > 0) {
try cli.printFg(out, color, muted, " -> {s}\n", .{c.symbol});
} else {
try cli.printFg(out, color, muted, " -> cash\n", .{});
}
// Optional note from the record.
if (c.transfer_note) |n| {
if (n.len > 0) {
try cli.printFg(out, color, muted, " ({s})\n", .{n});
}
}
}
/// Single-line rendering for an unmatched transfer record, shown
/// in the Flagged section. Keeps the layout similar to
/// `printFlaggedLine` for visual consistency.
fn printUnmatchedTransferLine(out: *std.Io.Writer, c: Change, color: bool, warn: [3]u8) !void {
var date_buf: [10]u8 = undefined;
const date_str = if (c.transfer_date) |d| (std.fmt.bufPrint(&date_buf, "{f}", .{d}) catch "????-??-??") else "????-??-??";
var val_buf: [32]u8 = undefined;
const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.transfer_attributed)}) catch "$?";
const from_str = c.transfer_from orelse "?";
try cli.setFg(out, color, warn);
try out.print(" ? Transfer {s} {s} {s} -> {s}\n", .{ date_str, val_str, from_str, c.account });
if (c.transfer_note) |n| {
try out.print(" {s}\n", .{n});
}
try cli.reset(out, color);
}
/// Render a `partial_transfer_in` row in the "New contributions"
/// section. Shows the residual value (the portion that wasn't
/// attributable to the transfer) plus an annotation explaining the
/// split so the user can tell at a glance why the number is smaller
/// than the lot's face value.
fn printPartialTransferLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8, muted: [3]u8) !void {
const acct = if (c.account.len == 0) "(no account)" else c.account;
const residual = c.attributedValue();
const lot_value = c.value();
const sym = if (c.symbol.len > 0) c.symbol else "cash";
try writeRowPrefix(out, sym, acct);
try cli.printFg(out, color, pos, " {f}", .{Money.from(residual)});
try cli.printFg(
out,
color,
muted,
" (of {f} total - rest from transfer)\n",
.{Money.from(lot_value)},
);
}
/// Render a `new_stock` / `new_cd` row in the "New contributions"
/// section when the lot was PARTIALLY funded by a same-account cash
/// decrease. Shows the unfunded residual (real new money) plus an
/// annotation breaking out the cash-funded portion, mirroring
/// `printPartialTransferLine`'s shape for transfers.
fn printCashFundedResidualLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8, muted: [3]u8) !void {
const acct = if (c.account.len == 0) "(no account)" else c.account;
const residual = c.attributedValue();
const lot_value = c.value();
const sym = if (c.symbol.len > 0) c.symbol else "cash";
try writeRowPrefix(out, sym, acct);
try cli.printFg(out, color, pos, " {f}", .{Money.from(residual)});
try cli.printFg(
out,
color,
muted,
" (of {f} total - {f} from existing cash)\n",
.{ Money.from(lot_value), Money.from(c.internal_funded) },
);
}
/// Is this Change a security sale, i.e. something that released funds
/// into its account? Cash lines that drain are movement within the
/// cash pool, not a sale, so they are excluded.
fn isSaleKind(c: Change) bool {
return switch (c.kind) {
.position_closed, .lot_removed => c.security_type != .cash,
.drip_negative => true,
else => false,
};
}
/// Print the sales that released funds, one line per (account, symbol)
/// rather than one per lot.
///
/// Collapsing is not cosmetic. A DRIP-fed position accumulates a lot
/// per distribution, so closing one can retire hundreds of records -
/// the trade that motivated this work retired 219, which as individual
/// lines swamped every other section in the report. The lot count is
/// kept in the output so nothing is hidden, just summarized.
fn printCollapsedSales(out: *std.Io.Writer, report: *const Report, color: bool, muted: [3]u8) !void {
for (report.changes, 0..) |c, i| {
if (!isSaleKind(c)) continue;
// Print each group once, at its first member. Quadratic, but
// bounded by the sale count in a single window and it keeps
// this printer allocation-free like every other one here.
var already_printed = false;
for (report.changes[0..i]) |o| {
if (!isSaleKind(o)) continue;
if (std.mem.eql(u8, o.account, c.account) and std.mem.eql(u8, o.symbol, c.symbol)) {
already_printed = true;
break;
}
}
if (already_printed) continue;
var proceeds: f64 = 0;
var lots: usize = 0;
var closed_in_place = false;
for (report.changes) |o| {
if (!isSaleKind(o)) continue;
if (!std.mem.eql(u8, o.account, c.account)) continue;
if (!std.mem.eql(u8, o.symbol, c.symbol)) continue;
proceeds += if (o.kind == .drip_negative) @abs(o.value()) else o.face_value;
lots += 1;
if (o.kind == .position_closed) closed_in_place = true;
}
const acct = if (c.account.len == 0) "(no account)" else c.account;
try cli.setFg(out, color, muted);
// `close_price` is what the sale realized; anything else is a
// current-price proxy, so say which one the reader is looking at.
const basis: []const u8 = if (closed_in_place) "at close" else "at mark";
if (lots == 1) {
try writeRowPrefix(out, c.symbol, acct);
try out.print(" sold {s} ({f})", .{ basis, Money.from(proceeds) });
} else {
try writeRowPrefix(out, c.symbol, acct);
try out.print(" sold {d} lots {s} ({f})", .{ lots, basis, Money.from(proceeds) });
}
try cli.reset(out, color);
try out.writeAll("\n");
}
}
/// Render an "Internal purchases" row: a `new_stock` / `new_cd` lot
/// funded (wholly or in part) by a same-account cash decrease. Muted -
/// these don't count toward attribution. Shows the funded amount and,
/// when only part of the lot was cash-funded, the full lot value so
/// the unfunded residual (shown in "New contributions") reconciles.
fn printInternalPurchaseLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void {
const acct = if (c.account.len == 0) "(no account)" else c.account;
const sym = if (c.symbol.len > 0) c.symbol else "cash";
const lot_value = c.value();
try cli.setFg(out, color, muted);
try writeRowPrefix(out, sym, acct);
try out.print(" {f} from existing cash", .{Money.from(c.internal_funded)});
if (c.internal_funded + 0.005 < lot_value) {
try out.print(" (of {f} lot)", .{Money.from(lot_value)});
}
try cli.reset(out, color);
try out.writeAll("\n");
}
// ── Tests ────────────────────────────────────────────────────
const testing = std.testing;
fn parseArgsForTest(today: zfin.Date, args: []const []const u8) !ParsedArgs {
var ctx: framework.RunCtx = .{
.io = std.testing.io,
.allocator = std.testing.allocator,
.gpa = std.testing.allocator,
// SAFETY: parseArgs doesn't touch environ_map.
.environ_map = undefined,
.config = .{ .cache_dir = "" },
.svc = null,
.globals = .{},
.today = today,
.now_s = 0,
.color = false,
// SAFETY: parseArgs doesn't write to out.
.out = undefined,
};
return parseArgs(&ctx, args);
}
test "parseArgs: empty args produces both null" {
const today = zfin.Date.fromYmd(2026, 5, 9);
const parsed = try parseArgsForTest(today, &.{});
try testing.expect(parsed.before == null);
try testing.expect(parsed.after == null);
}
test "parseArgs: --since populates before as snapshot_add" {
// `.snapshot_add`, not `.date_at_or_before`. This is what makes a date resolve
// the same way `compare` resolves one, so the two commands report the same
// money for the same requested week. It degrades to the old date behaviour
// inside `git.resolveSpec` when no snapshot exists, so nothing is lost.
const today = zfin.Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--since", "2026-04-01" };
const parsed = try parseArgsForTest(today, &args);
switch (parsed.before.?) {
.snapshot_add => |d| try testing.expect(d.eql(zfin.Date.fromYmd(2026, 4, 1))),
else => try testing.expect(false),
}
}
test "parseArgs: --until populates after as snapshot_add too" {
// Both endpoints, because both can be wrong and they fail in opposite
// directions - a before anchor that is too early re-counts a window, an after
// anchor that is too early drops it.
const today = zfin.Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--since", "2026-04-01", "--until", "2026-04-15" };
const parsed = try parseArgsForTest(today, &args);
switch (parsed.after.?) {
.snapshot_add => |d| try testing.expect(d.eql(zfin.Date.fromYmd(2026, 4, 15))),
else => try testing.expect(false),
}
}
test "specDate: reads the date out of either date-carrying variant" {
// The ordering check used to test for `.date_at_or_before` by hand and went
// blind the moment a date started producing `.snapshot_add`. Routing both
// through one accessor is what stops that recurring.
const d = zfin.Date.fromYmd(2026, 4, 1);
try testing.expect(specDate(.{ .date_at_or_before = d }).?.eql(d));
try testing.expect(specDate(.{ .snapshot_add = d }).?.eql(d));
try testing.expect(specDate(.{ .git_ref = "HEAD" }) == null);
try testing.expect(specDate(.working_copy) == null);
}
test "parseArgs: an inverted window is still rejected after the spec change" {
const today = zfin.Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--since", "2026-04-15", "--until", "2026-04-01" };
try testing.expectError(error.InvalidArg, parseArgsForTest(today, &args));
}
test "parseArgs: --since + --until populates both" {
const today = zfin.Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--since", "2026-04-01", "--until", "2026-05-01" };
const parsed = try parseArgsForTest(today, &args);
try testing.expect(parsed.before != null);
try testing.expect(parsed.after != null);
}
test "parseArgs: --commit-after working" {
const today = zfin.Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--commit-after", "working" };
const parsed = try parseArgsForTest(today, &args);
try testing.expect(parsed.after.? == .working_copy);
}
test "parseArgs: --since + --commit-before is duplicate axis" {
const today = zfin.Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--since", "1W", "--commit-before", "HEAD" };
try testing.expectError(error.DuplicateEndpoint, parseArgsForTest(today, &args));
}
test "parseArgs: --since after --until errors" {
const today = zfin.Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--since", "2026-05-01", "--until", "2026-04-01" };
try testing.expectError(error.InvalidArg, parseArgsForTest(today, &args));
}
test "parseArgs: unknown flag errors" {
const today = zfin.Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--bogus", "value" };
try testing.expectError(error.UnexpectedArg, parseArgsForTest(today, &args));
}
test "computeReport: fresh stock purchase counts as new contribution" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("AAPL", 200.0);
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2026, 4, 1), .open_price = 180, .account = "Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind);
try std.testing.expectApproxEqAbs(@as(f64, 1800.0), report.changes[0].value(), 0.01);
}
test "computeReport: rollup_delta when shares increase on untagged lot" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("VBTLX", 9.79);
const before = [_]Lot{
.{ .symbol = "VBTLX", .shares = 1000, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 9.79, .account = "DCP" },
};
const after = [_]Lot{
.{ .symbol = "VBTLX", .shares = 1010, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 9.79, .account = "DCP" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
// drip::false on both sides -> rollup_delta (ambiguous: DRIP or contribution)
try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind);
try std.testing.expectApproxEqAbs(@as(f64, 10.0), report.changes[0].delta_shares, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 97.9), report.changes[0].value(), 0.01);
}
test "computeReport: matured CD with maturity_date <= today" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "Sample IRA", .maturity_date = Date.fromYmd(2026, 4, 17) },
};
const after = [_]Lot{};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.cd_matured, report.changes[0].kind);
try std.testing.expectApproxEqAbs(@as(f64, 58000.0), report.changes[0].face_value, 0.01);
}
test "computeReport: CD removed before maturity flagged as early" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "CD2", .shares = 50000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "Brokerage", .maturity_date = Date.fromYmd(2027, 1, 1) },
};
const after = [_]Lot{};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.cd_removed_early, report.changes[0].kind);
}
test "computeReport: price-only update not classified as cash flow" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "NON40OR52", .shares = 5000, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 161.71, .price_date = Date.fromYmd(2026, 4, 9), .account = "401k" },
};
const after = [_]Lot{
.{ .symbol = "NON40OR52", .shares = 5000, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 169.07, .price_date = Date.fromYmd(2026, 4, 18), .account = "401k" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.price_only, report.changes[0].kind);
try std.testing.expectApproxEqAbs(@as(f64, 161.71), report.changes[0].old_price, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 169.07), report.changes[0].new_price, 0.01);
}
test "computeReport: CD matured + cash increase -> implied interest available" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
// Before: CD $58k + cash $3k. After: no CD, cash $62.5k.
const before = [_]Lot{
.{ .symbol = "CDX", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2026, 4, 17) },
.{ .symbol = "CASH", .shares = 3024.66, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cash, .account = "IRA" },
};
const after = [_]Lot{
.{ .symbol = "CASH", .shares = 62510.95, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cash, .account = "IRA" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
// Expect: 1 cd_matured, 1 cash_delta
var n_matured: usize = 0;
var n_cash: usize = 0;
var cash_delta: f64 = 0;
var face: f64 = 0;
for (report.changes) |c| switch (c.kind) {
.cd_matured => {
n_matured += 1;
face += c.face_value;
},
.cash_delta => {
n_cash += 1;
cash_delta += c.value();
},
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_matured);
try std.testing.expectEqual(@as(usize, 1), n_cash);
try std.testing.expectApproxEqAbs(@as(f64, 58000.0), face, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 62510.95 - 3024.66), cash_delta, 0.01);
// Implied interest: cash_delta - face = 59486.29 - 58000 = 1486.29
try std.testing.expectApproxEqAbs(@as(f64, 1486.29), cash_delta - face, 0.01);
}
test "computeReport: unit_value prefers current price over open_price for rollup delta" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SPY", 461.24);
const before = [_]Lot{
.{ .symbol = "SPY", .shares = 717.34, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.24, .account = "Tax Loss" },
};
const after = [_]Lot{
.{ .symbol = "SPY", .shares = 718.4848, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.24, .account = "Tax Loss" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind);
// 1.1448 * 461.24 ≈ 528.07
try std.testing.expectApproxEqAbs(@as(f64, 528.07), report.changes[0].value(), 0.1);
}
test "computeReport: manual-priced lot (price:: no ticker) uses manual price" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
// No price in the map - should fall back to manual price::.
const before = [_]Lot{
.{ .symbol = "NON40OR52", .shares = 5070.866, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 169.07, .account = "401k" },
};
const after = [_]Lot{
.{ .symbol = "NON40OR52", .shares = 5075.077, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 169.07, .account = "401k" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind);
// (5075.077 - 5070.866) = 4.211 shares × 169.07 = 711.96
try std.testing.expectApproxEqAbs(@as(f64, 711.96), report.changes[0].value(), 0.5);
}
// ── price_ratio regression tests ─────────────────────────────
//
// `lot.open_price` and `lot.price` (manual override) are both in the
// LOT's own share-class terms - i.e. preadjusted. Multiplying either
// by `lot.price_ratio` would double-apply the ratio. Only API-fetched
// prices from `prices.get(...)` (retail share class) need the ratio.
// See the "Pricing model" doc-block at the top of `models/portfolio.zig`.
//
// These four tests cover each of the three unit_value branches in
// `computeReport`'s new-lot and same-key-share-delta paths. Without
// the fix, the new-lot, manual-price, and open-price fallback tests
// inflate value by `price_ratio`× while the live-price test stays
// correct.
test "computeReport: new institutional stock lot uses preadjusted open_price (no double-ratio)" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
// No live price - exercises the open_price fallback branch in
// the new-lot path. open_price is in the LOT's institutional
// share class, so the ratio must NOT be applied.
const before = [_]Lot{};
const after = [_]Lot{
.{
.symbol = "02315N402",
.ticker = "VTTVX",
.shares = 39.249,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 140.92,
.price_ratio = 6.6139,
.account = "Sample 401(k)",
},
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind);
// 39.249 × 140.92 = 5,531.18 (institutional value).
// Buggy version produces 39.249 × 140.92 × 6.6139 ≈ 36,581.
try std.testing.expectApproxEqAbs(@as(f64, 5531.18), report.changes[0].value(), 0.5);
}
test "computeReport: same-key share delta with no live price uses preadjusted open_price" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
// No price in the map - open_price fallback should fire.
const before = [_]Lot{
.{
.symbol = "02315N402",
.ticker = "VTTVX",
.shares = 100,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 140.92,
.price_ratio = 6.6139,
.account = "Sample 401(k)",
},
};
const after = [_]Lot{
.{
.symbol = "02315N402",
.ticker = "VTTVX",
.shares = 110,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 140.92,
.price_ratio = 6.6139,
.account = "Sample 401(k)",
},
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind);
// 10 × 140.92 = 1,409.20 (institutional value of the share delta).
// Buggy version produces 10 × 140.92 × 6.6139 ≈ 9,322.
try std.testing.expectApproxEqAbs(@as(f64, 1409.20), report.changes[0].value(), 0.5);
}
test "computeReport: same-key share delta with manual price:: uses preadjusted manual price" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
// No price in the map - manual `price::` override should fire.
// Manual prices are entered in the LOT's share class (what the
// user sees on their statement), so they're preadjusted.
const before = [_]Lot{
.{
.symbol = "02315N402",
.ticker = "VTTVX",
.shares = 100,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 140.92,
.price = 145.00,
.price_ratio = 6.6139,
.account = "Sample 401(k)",
},
};
const after = [_]Lot{
.{
.symbol = "02315N402",
.ticker = "VTTVX",
.shares = 110,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 140.92,
.price = 145.00,
.price_ratio = 6.6139,
.account = "Sample 401(k)",
},
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind);
// 10 × 145.00 = 1,450.00. Buggy version: 10 × 145.00 × 6.6139 ≈ 9,590.
try std.testing.expectApproxEqAbs(@as(f64, 1450.00), report.changes[0].value(), 0.5);
}
test "computeReport: same-key share delta with live price applies ratio" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
// Live retail-class price - ratio MUST be applied to convert
// to institutional NAV. This is the only branch that's correct
// in the buggy code; lock it in so the fix doesn't regress.
try prices.put("VTTVX", 21.30);
const before = [_]Lot{
.{
.symbol = "02315N402",
.ticker = "VTTVX",
.shares = 100,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 140.92,
.price_ratio = 6.6139,
.account = "Sample 401(k)",
},
};
const after = [_]Lot{
.{
.symbol = "02315N402",
.ticker = "VTTVX",
.shares = 110,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 140.92,
.price_ratio = 6.6139,
.account = "Sample 401(k)",
},
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind);
// 10 × 21.30 × 6.6139 = 1,408.76 (institutional value).
try std.testing.expectApproxEqAbs(@as(f64, 1408.76), report.changes[0].value(), 0.5);
}
test "computeReport: maturity_date change on same CD is flagged" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "CDY", .shares = 87000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2026, 7, 15) },
};
const after = [_]Lot{
.{ .symbol = "CDY", .shares = 87000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2027, 7, 15) },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.flagged, report.changes[0].kind);
}
test "computeReport: CD open_date rewrite reclassified as edit, not new+removed" {
// The classic CD auto-renewal scenario: shares and account stay
// the same, but the `open_date` gets rewritten to the renewal
// date. Prior behavior was a phantom $58k new_cd contribution
// plus a $58k cd_removed_early. After edit detection: one
// lot_edited, no contribution.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2027, 2, 25) },
};
const after = [_]Lot{
// Same CD, rewritten open_date (e.g. renewal) - key broken.
.{ .symbol = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 4, 20), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2027, 4, 20) },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.lot_edited, report.changes[0].kind);
// Attribution must see zero contribution for this CD.
var new_contrib: f64 = 0;
var drip: f64 = 0;
for (report.changes) |c| switch (c.kind) {
.new_stock, .new_cash, .new_cd, .new_option => new_contrib += c.value(),
.new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(),
else => {},
};
try std.testing.expectApproxEqAbs(@as(f64, 0), new_contrib, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0), drip, 0.01);
}
// ── Intra-account purchase netting (matchIntraAccountPurchases) ──
test "computeReport: stock bought with existing cash nets to zero contribution" {
// The user's scenario: $30k of cash already in the account is
// spent on a new stock lot. The diff shows new_stock +$30k plus a
// -$30k cash_delta on the SAME account. Netting funds the buy from
// the cash decrease, so it contributes nothing.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "", .shares = 20_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var new_stock: ?Change = null;
var cash_delta: ?Change = null;
for (report.changes) |c| switch (c.kind) {
.new_stock => new_stock = c,
.cash_delta => cash_delta = c,
else => {},
};
try std.testing.expect(new_stock != null);
try std.testing.expect(cash_delta != null);
// Buy fully funded by the $30k cash decrease.
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.value(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.internal_funded, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_stock.?.attributedValue(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, -30_000.0), cash_delta.?.value(), 0.01);
}
test "computeReport: stock bought with a fully-consumed cash lot (lot_removed) nets to zero" {
// Same as above but the cash line was spent in full and deleted,
// so the cash decrease surfaces as a removed cash lot rather than
// a negative cash_delta. The removed lot's dollar amount lives in
// face_value.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var new_stock: ?Change = null;
var cash_removed = false;
for (report.changes) |c| switch (c.kind) {
.new_stock => new_stock = c,
.lot_removed => if (c.security_type == .cash) {
cash_removed = true;
},
else => {},
};
try std.testing.expect(new_stock != null);
try std.testing.expect(cash_removed);
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.internal_funded, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_stock.?.attributedValue(), 0.01);
}
test "computeReport: buy partly funded by cash, partly new money surfaces the residual" {
// $30k existing cash spent + $20k new money into a single $50k buy.
// Only the $20k unfunded residual counts as a contribution.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
// Cash line fully spent (removed); $50k stock lot appears.
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var new_stock: ?Change = null;
for (report.changes) |c| {
if (c.kind == .new_stock) new_stock = c;
}
try std.testing.expect(new_stock != null);
try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), new_stock.?.value(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.internal_funded, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 20_000.0), new_stock.?.attributedValue(), 0.01);
}
test "computeReport: cross-account cash decrease does NOT fund a purchase" {
// Cash drops in Acct A; a new stock lot appears in Acct B. These
// are different accounts, so netting does not apply - the buy
// still counts (cross-account movement is transaction_log's job).
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" },
};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var new_stock: ?Change = null;
for (report.changes) |c| {
if (c.kind == .new_stock) new_stock = c;
}
try std.testing.expect(new_stock != null);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_stock.?.internal_funded, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.attributedValue(), 0.01);
}
test "computeReport: new-account deposit-and-invest still counts (no prior cash)" {
// Fresh account: cash deposited and partly invested in the same
// window. The cash never existed before, so there is no decrease
// to net against - both the new cash and the new stock are real
// contributions.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "", .shares = 20_000, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var new_money: f64 = 0;
for (report.changes) |c| switch (c.kind) {
.new_stock, .new_cash => {
new_money += c.attributedValue();
try std.testing.expectApproxEqAbs(@as(f64, 0.0), c.internal_funded, 0.01);
},
else => {},
};
// $20k cash + $30k stock = $50k of genuinely new money.
try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), new_money, 0.01);
}
test "computeReport: existing-cash buy nets out of compare attribution too" {
// The classifier is the single source of truth, so the compare
// attribution line (summarizeAttribution) must agree with the
// report: a cash-funded buy contributes $0.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 40_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "", .shares = 10_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
// Replicate summarizeAttribution's new_contributions bucket.
var new_contributions: f64 = 0;
for (report.changes) |c| switch (c.kind) {
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => new_contributions += c.attributedValue(),
.partial_transfer_in => new_contributions += c.attributedValue(),
else => {},
};
try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_contributions, 0.01);
}
test "computeReport: transfer record takes priority over intra-account netting" {
// A buy declared as a cross-account transfer (transaction_log)
// flips to transfer_in BEFORE intra-account netting runs, so it
// must not also be credited with internal_funded even if the
// destination account happens to show a cash decrease.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "", .shares = 10_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-03,type::cash,amount:num:30000,from::Sample Source,to::Sample Brokerage,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
for (report.changes) |c| {
if (std.mem.eql(u8, c.symbol, "SYM")) {
try std.testing.expectEqual(ChangeKind.transfer_in, c.kind);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), c.internal_funded, 0.01);
}
}
}
/// Sum of everything a window reports as new money. Mirrors
/// `summarizeAttribution`'s buckets without needing a ReportContext, so
/// classifier-level tests can assert on the same figure the CLI prints.
fn attributionTotalForTest(report: Report) f64 {
var total: f64 = 0;
for (report.changes) |c| switch (c.kind) {
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution, .partial_transfer_in => total += c.attributedValue(),
.new_drip_lot, .drip_confirmed, .rollup_delta => total += c.value(),
else => {},
};
return total;
}
// ── Security-sale funding (intra-account reallocation) ───────
//
// Case labels A-F below match the scenario table worked out when this
// was designed; each is a distinct shape of "did money actually enter
// the portfolio". They are kept together so the whole matrix is
// visible at once - the bug these fix was caused by reasoning about
// one shape (a sale in the same window as the rebuy) without checking
// the others.
test "computeReport: case A - intra-account reallocation is not a contribution" {
// The reported bug, reduced. A bond fund is sold and the proceeds
// are immediately redeployed into two ETFs in the SAME account.
// Nothing entered the portfolio, so contributions must be $0.
//
// Shapes match the real trade: 41,285.219 sh of the sold fund at
// $11.32 = $467,348.68 of proceeds, redeployed as $467,639.27 of
// new lots, with the $290.59 difference topped up from the
// account's own cash. Every dollar is internal.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("OLDFUND", 11.32);
const before = [_]Lot{
.{ .symbol = "OLDFUND", .shares = 41_285.219, .open_date = Date.fromYmd(2021, 9, 6), .open_price = 8.90, .account = "Sample Brokerage", .drip = true },
.{ .symbol = "", .shares = 309.57, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "NEWA", .shares = 13_747, .open_date = Date.fromYmd(2026, 8, 11), .open_price = 23.25, .account = "Sample Brokerage" },
.{ .symbol = "NEWA", .shares = 1_436, .open_date = Date.fromYmd(2026, 8, 13), .open_price = 23.32, .account = "Sample Brokerage" },
.{ .symbol = "NEWB", .shares = 1_800, .open_date = Date.fromYmd(2026, 8, 11), .open_price = 63.63, .account = "Sample Brokerage" },
.{ .symbol = "", .shares = 18.98, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 8, 17), .{});
var sale: ?Change = null;
var new_total: f64 = 0;
var attributed_total: f64 = 0;
for (report.changes) |c| switch (c.kind) {
.lot_removed => sale = c,
.new_stock => {
new_total += c.value();
attributed_total += c.attributedValue();
},
else => {},
};
// The sale is valued, and is an outflow - never a negative contribution.
try std.testing.expect(sale != null);
try std.testing.expectApproxEqAbs(@as(f64, 467_348.68), sale.?.face_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, -467_348.68), sale.?.value(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), sale.?.attributedValue(), 0.01);
// Proceeds plus the $290.59 of account cash fund the buys exactly.
try std.testing.expectApproxEqAbs(@as(f64, 467_639.27), new_total, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), attributed_total, 0.01);
}
test "computeReport: case B - sale proceeds resting in cash are not a contribution" {
// Window 1 of a two-window reallocation: the position is sold and
// the proceeds just sit in the account's cash line. On an account
// WITHOUT cash_is_contribution the increase is plain cash_delta,
// which never counted - this pins that it stays that way now the
// sale carries dollars.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 1_000, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 60.0, .account = "Sample Brokerage" },
.{ .symbol = "", .shares = 500, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "", .shares = 100_500, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var total: f64 = 0;
for (report.changes) |c| switch (c.kind) {
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => total += c.attributedValue(),
else => {},
};
try std.testing.expectApproxEqAbs(@as(f64, 0.0), total, 0.01);
}
test "computeReport: case C - cash from a prior window's sale funds the rebuy" {
// Window 2: the cash parked in window 1 buys the replacement.
// Already worked before this change (a negative cash_delta funds
// the buy); pinned so the new proceeds path cannot regress it.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 100_500, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "NEWSYM", .shares = 2_000, .open_date = Date.fromYmd(2026, 5, 10), .open_price = 50.0, .account = "Sample Brokerage" },
.{ .symbol = "", .shares = 500, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 11), .{});
var total: f64 = 0;
for (report.changes) |c| switch (c.kind) {
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => total += c.attributedValue(),
else => {},
};
try std.testing.expectApproxEqAbs(@as(f64, 0.0), total, 0.01);
}
test "computeReport: case D - a sale resting in cash must not swallow a real deposit" {
// The case that makes the spent/resting split load-bearing. In one
// window: SYM is sold and the proceeds stay in cash, AND a
// separate $10k of new money arrives and is invested in VTI.
//
// A naive "sale proceeds fund new lots" budget would let the
// $100k sale absorb the $10k VTI purchase and report $0. The
// proceeds are demonstrably still in cash (the pool went UP), so
// they are unavailable for funding and the $10k must survive.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 1_000, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 60.0, .account = "Sample Brokerage" },
.{ .symbol = "", .shares = 1_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
// $100k proceeds land in cash; the $10k deposit passes through
// and out again into VTI, so the pool ends up +$100k.
.{ .symbol = "", .shares = 101_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
.{ .symbol = "VTI", .shares = 40, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 250.0, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var vti: ?Change = null;
for (report.changes) |c| {
if (c.kind == .new_stock and std.mem.eql(u8, c.symbol, "VTI")) vti = c;
}
try std.testing.expect(vti != null);
try std.testing.expectApproxEqAbs(@as(f64, 10_000.0), vti.?.value(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), vti.?.internal_funded, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 10_000.0), vti.?.attributedValue(), 0.01);
}
test "computeReport: case E/F - deposits still count, parked or invested" {
// Guard rails on the other side: with no sale anywhere, a deposit
// is a contribution whether it is left as cash (E) or spent on a
// security in the same window (F).
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
// E: cash pool grows, nothing bought. Plain cash_delta on an
// account without the opt-in flag, so it is deliberately NOT
// counted as a contribution - only the flag makes cash count.
const before_e = [_]Lot{
.{ .symbol = "", .shares = 1_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after_e = [_]Lot{
.{ .symbol = "", .shares = 11_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const rep_e = try computeReport(allocator, &before_e, &after_e, &prices, Date.fromYmd(2026, 5, 4), .{});
var cash_e: ?Change = null;
for (rep_e.changes) |c| {
if (c.kind == .cash_delta) cash_e = c;
}
try std.testing.expect(cash_e != null);
try std.testing.expectApproxEqAbs(@as(f64, 10_000.0), cash_e.?.value(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), cash_e.?.internal_funded, 0.01);
// F: deposit arrives and is invested in the same window - cash
// ends flat, the new lot is real new money.
const before_f = [_]Lot{
.{ .symbol = "", .shares = 1_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after_f = [_]Lot{
.{ .symbol = "", .shares = 1_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
.{ .symbol = "VTI", .shares = 40, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 250.0, .account = "Sample Brokerage" },
};
const rep_f = try computeReport(allocator, &before_f, &after_f, &prices, Date.fromYmd(2026, 5, 4), .{});
var vti: ?Change = null;
for (rep_f.changes) |c| {
if (c.kind == .new_stock) vti = c;
}
try std.testing.expect(vti != null);
try std.testing.expectApproxEqAbs(@as(f64, 10_000.0), vti.?.attributedValue(), 0.01);
}
test "computeReport: a partial sale funds a same-account purchase" {
// Shares reduced on an unchanged lot key (drip_negative) is still
// a sale, so its proceeds are a funding source too.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 1_000, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 60.0, .account = "Sample Brokerage" },
};
const after = [_]Lot{
// 200 shares sold at $100 = $20k, redeployed into NEWSYM.
.{ .symbol = "SYM", .shares = 800, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 60.0, .account = "Sample Brokerage" },
.{ .symbol = "NEWSYM", .shares = 400, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 50.0, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var partial: ?Change = null;
var buy: ?Change = null;
for (report.changes) |c| switch (c.kind) {
.drip_negative => partial = c,
.new_stock => buy = c,
else => {},
};
try std.testing.expect(partial != null);
try std.testing.expect(buy != null);
try std.testing.expectApproxEqAbs(@as(f64, -20_000.0), partial.?.value(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), partial.?.attributedValue(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 20_000.0), buy.?.internal_funded, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), buy.?.attributedValue(), 0.01);
}
test "computeReport: a sale does NOT fund a purchase in a different account" {
// Same-account only. Selling in one account cannot silently
// explain a purchase in another - that is a transfer, and it needs
// a transaction_log.srf record.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 1_000, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 60.0, .account = "Sample IRA" },
};
const after = [_]Lot{
.{ .symbol = "NEWSYM", .shares = 2_000, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 50.0, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var buy: ?Change = null;
for (report.changes) |c| {
if (c.kind == .new_stock) buy = c;
}
try std.testing.expect(buy != null);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), buy.?.internal_funded, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 100_000.0), buy.?.attributedValue(), 0.01);
}
test "computeReport: sale proceeds cancel a cash_is_contribution credit" {
// The latent bug. On an account flagged cash_is_contribution::true
// a positive cash delta is assumed to be new money - true for a
// payroll accrual, false for the proceeds of a sale, and the flag
// cannot tell them apart. Without the resting-proceeds drawdown
// this books a $100k sale as a $100k contribution.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
var am = try analysis.parseAccountsFile(allocator,
\\#!srfv1
\\account::Sample ESPP,tax_type::taxable,cash_is_contribution:bool:true
);
defer am.deinit();
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 1_000, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 60.0, .account = "Sample ESPP" },
.{ .symbol = "", .shares = 500, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample ESPP" },
};
const after = [_]Lot{
// $100k of proceeds land in the cash line, plus $2k of genuine
// payroll accrual that the flag exists to catch.
.{ .symbol = "", .shares = 102_500, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample ESPP" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.account_map = &am,
});
var credit: ?Change = null;
for (report.changes) |c| {
if (c.kind == .cash_contribution) credit = c;
}
try std.testing.expect(credit != null);
// Raw cash arrival is $102k; $100k of it is sale proceeds, so only
// the $2k accrual is a real contribution.
try std.testing.expectApproxEqAbs(@as(f64, 102_000.0), credit.?.value(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 100_000.0), credit.?.internal_funded, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 2_000.0), credit.?.attributedValue(), 0.01);
}
test "computeReport: cash_is_contribution credit survives when there is no sale" {
// The other half of the pin above: with no sale in the window the
// flag must still credit the full cash arrival, or the opt-in
// stops doing its job.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
var am = try analysis.parseAccountsFile(allocator,
\\#!srfv1
\\account::Sample ESPP,tax_type::taxable,cash_is_contribution:bool:true
);
defer am.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 500, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample ESPP" },
};
const after = [_]Lot{
.{ .symbol = "", .shares = 2_500, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample ESPP" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.account_map = &am,
});
var credit: ?Change = null;
for (report.changes) |c| {
if (c.kind == .cash_contribution) credit = c;
}
try std.testing.expect(credit != null);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), credit.?.internal_funded, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 2_000.0), credit.?.attributedValue(), 0.01);
}
// ── Positions closed in place (`position_closed`) ─────────────
test "computeReport: a lot closed in place is a sale valued at close_price" {
// Archiving a sold lot - into a sibling `portfolio_closed.srf` that
// the same glob merges, or edited where it sits - keeps the record
// and adds close_date/close_price. The strict lot key excludes
// close_date and the share count is untouched, so without an
// explicit close check this emitted nothing at all and the sale
// vanished from attribution.
//
// close_price is authoritative: $11.32 realized against an $8.90
// basis. Valuing at basis would leave the difference as a phantom
// contribution.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("OLDFUND", 9.00); // deliberately NOT the close price
const before = [_]Lot{
.{ .symbol = "OLDFUND", .shares = 41_285.219, .open_date = Date.fromYmd(2021, 9, 6), .open_price = 8.90, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "OLDFUND", .shares = 41_285.219, .open_date = Date.fromYmd(2021, 9, 6), .open_price = 8.90, .account = "Sample Brokerage", .close_date = Date.fromYmd(2026, 8, 10), .close_price = 11.32 },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 8, 17), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
const sale = report.changes[0];
try std.testing.expectEqual(ChangeKind.position_closed, sale.kind);
// 41,285.219 x $11.32 - close_price wins over both the current
// price in the map and open_price.
try std.testing.expectApproxEqAbs(@as(f64, 467_348.68), sale.face_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), sale.attributedValue(), 0.01);
}
test "computeReport: closing a position funds a same-account repurchase" {
// The full reallocation as it looks once closed lots are retained
// rather than deleted: one position_closed plus the replacement
// buy, netting to zero new money.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "OLDFUND", .shares = 1_000, .open_date = Date.fromYmd(2021, 9, 6), .open_price = 8.90, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "OLDFUND", .shares = 1_000, .open_date = Date.fromYmd(2021, 9, 6), .open_price = 8.90, .account = "Sample Brokerage", .close_date = Date.fromYmd(2026, 8, 10), .close_price = 11.32 },
.{ .symbol = "NEWA", .shares = 486, .open_date = Date.fromYmd(2026, 8, 11), .open_price = 23.29, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 8, 17), .{});
var buy: ?Change = null;
for (report.changes) |c| {
if (c.kind == .new_stock) buy = c;
}
try std.testing.expect(buy != null);
// $11,320 of proceeds cover the $11,318.94 purchase in full.
try std.testing.expectApproxEqAbs(@as(f64, 11_318.94), buy.?.value(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), buy.?.attributedValue(), 0.01);
}
test "computeReport: a close with no close_price falls back to a price proxy" {
// close_price is optional in the schema. Without it the sale still
// has to be valued or it funds nothing, so fall back to the same
// current-price proxy a deleted lot gets - never to open_price,
// which would understate a winner.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("OLDFUND", 11.00);
const before = [_]Lot{
.{ .symbol = "OLDFUND", .shares = 1_000, .open_date = Date.fromYmd(2021, 9, 6), .open_price = 8.90, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "OLDFUND", .shares = 1_000, .open_date = Date.fromYmd(2021, 9, 6), .open_price = 8.90, .account = "Sample Brokerage", .close_date = Date.fromYmd(2026, 8, 10) },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 8, 17), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.position_closed, report.changes[0].kind);
try std.testing.expectApproxEqAbs(@as(f64, 11_000.0), report.changes[0].face_value, 0.01);
}
test "computeReport: a close that also moves price:: is a sale, not a price edit" {
// Ordering pin. The same-shares branch checks `price::` too, and a
// reconciliation pass can easily stamp a final price on the way
// out. If the price comparison ran first this would classify as
// price_only, emit no outflow, and leave the repurchase looking
// like new money.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "MFUND", .shares = 100, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 10.0, .price = 12.0, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "MFUND", .shares = 100, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 10.0, .price = 13.0, .account = "Sample Brokerage", .close_date = Date.fromYmd(2026, 8, 10), .close_price = 13.0 },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 8, 17), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.position_closed, report.changes[0].kind);
try std.testing.expectApproxEqAbs(@as(f64, 1_300.0), report.changes[0].face_value, 0.01);
}
test "computeReport: an option outflow is valued per contract, not per share" {
// Option lots price at open_price x multiplier (100 shares per
// contract), so an outflow that skipped the multiplier would
// undervalue the proceeds 100x and fund almost nothing.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
// 5 contracts at $3.20 = $1,600.
.{ .symbol = "SYM 260918C00050000", .shares = 5, .open_date = Date.fromYmd(2026, 5, 1), .open_price = 3.20, .security_type = .option, .account = "Sample Brokerage" },
// A closed-in-place option, valued off close_price: 2 x $4.50 x 100.
.{ .symbol = "SYM 261218C00060000", .shares = 2, .open_date = Date.fromYmd(2026, 5, 1), .open_price = 2.00, .security_type = .option, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "SYM 261218C00060000", .shares = 2, .open_date = Date.fromYmd(2026, 5, 1), .open_price = 2.00, .security_type = .option, .account = "Sample Brokerage", .close_date = Date.fromYmd(2026, 8, 10), .close_price = 4.50 },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 8, 17), .{});
var removed: ?Change = null;
var closed: ?Change = null;
for (report.changes) |c| switch (c.kind) {
.lot_removed => removed = c,
.position_closed => closed = c,
else => {},
};
try std.testing.expect(removed != null);
try std.testing.expectApproxEqAbs(@as(f64, 1_600.0), removed.?.face_value, 0.01);
try std.testing.expect(closed != null);
try std.testing.expectApproxEqAbs(@as(f64, 900.0), closed.?.face_value, 0.01);
}
test "computeReport: a cash lot closed in place drains the pool, it is not a sale" {
// Closing a cash line rather than deleting it still means the cash
// left. It must reduce the account's cash pool, not masquerade as
// sale proceeds - otherwise the same dollars would be counted as
// both an outflow and a funding source, funding the buy twice.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage", .close_date = Date.fromYmd(2026, 5, 3) },
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var buy: ?Change = null;
for (report.changes) |c| {
if (c.kind == .new_stock) buy = c;
}
try std.testing.expect(buy != null);
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), buy.?.internal_funded, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), buy.?.attributedValue(), 0.01);
}
test "printCollapsedSales: many retired lots render as one line" {
// A DRIP-fed position closes as one lot per historical
// distribution. The trade that motivated this retired 219 of them;
// printed individually they buried every other section.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
var before: std.ArrayList(Lot) = .empty;
var after: std.ArrayList(Lot) = .empty;
for (0..40) |i| {
const day: u8 = @intCast(1 + i % 28);
const lot: Lot = .{ .symbol = "DRIPX", .shares = 10, .open_date = Date.fromYmd(2020, 1, day), .open_price = 9.0 + @as(f64, @floatFromInt(i)) * 0.01, .account = "Sample Brokerage" };
try before.append(allocator, lot);
var closed = lot;
closed.close_date = Date.fromYmd(2026, 8, 10);
closed.close_price = 12.0;
try after.append(allocator, closed);
}
const report = try computeReport(allocator, before.items, after.items, &prices, Date.fromYmd(2026, 8, 17), .{});
try std.testing.expectEqual(@as(usize, 40), report.changes.len);
var aw: std.Io.Writer.Allocating = .init(allocator);
try printReport(&aw.writer, &report, "test window", false);
const text = aw.written();
// Exactly one DRIPX row, carrying the lot count and the total.
var rows: usize = 0;
var it = std.mem.splitScalar(u8, text, '\n');
while (it.next()) |line| {
if (std.mem.indexOf(u8, line, "DRIPX") != null) rows += 1;
}
try std.testing.expectEqual(@as(usize, 1), rows);
try std.testing.expect(std.mem.indexOf(u8, text, "sold 40 lots at close") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "$4,800.00") != null);
}
test "computeReport: a declared cash transfer out does not consume a security sale" {
// `tryMatchFromSide` looks for the sending leg of a declared
// `type::cash` transfer. Its removal arm was written for a fully
// drained CASH lot, whose dollars live in `face_value` - but it was
// unreachable while `value()` was 0 for every removal, so nothing
// pinned that intent.
//
// Now that outflows carry dollars the arm is live, and an unrelated
// security sale in the same account is a candidate. If it wins, the
// sale flips to `transfer_out`, drops out of the sale-proceeds
// budget, and the rebuy it paid for reads as new money - the exact
// bug this whole change set exists to remove.
//
// Here: $50k of cash is declared as moving out of Sample IRA, and
// separately SYM is sold for $100k and NEWSYM bought with the
// proceeds. The transfer must take the cash; the reallocation must
// still net to zero.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 1_000, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 60.0, .account = "Sample IRA" },
.{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" },
};
const after = [_]Lot{
// SYM sold, proceeds straight into NEWSYM.
.{ .symbol = "NEWSYM", .shares = 2_000, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 50.0, .account = "Sample IRA" },
// The $50k cash left, as declared.
.{ .symbol = "", .shares = 0, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" },
.{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Roth IRA" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:50000,from::Sample IRA,to::Sample Roth IRA,dest_lot::cash
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
// The SYM sale must remain a sale, not be eaten as the transfer's
// sending leg.
var sale: ?Change = null;
var buy: ?Change = null;
for (report.changes) |c| {
if (std.mem.eql(u8, c.symbol, "SYM") and isSaleKind(c)) sale = c;
if (c.kind == .new_stock and std.mem.eql(u8, c.symbol, "NEWSYM")) buy = c;
}
try std.testing.expect(sale != null);
try std.testing.expectApproxEqAbs(@as(f64, 100_000.0), sale.?.face_value, 0.01);
// ...so its proceeds still fund the rebuy, which is not new money.
try std.testing.expect(buy != null);
try std.testing.expectApproxEqAbs(@as(f64, 100_000.0), buy.?.internal_funded, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), buy.?.attributedValue(), 0.01);
}
test "computeReport: an untracked transfer source does not consume a security sale" {
// The dangerous shape. A `type::cash` transfer's sending account is
// allowed to be unmodelled - the docs say a missing source side is
// not an error. But `tryMatchFromSide` scans for ANY qualifying
// outflow on that account, so with no cash change to claim, an
// unrelated security sale is the only candidate left.
//
// Flipping it to `transfer_out` removes it from the sale-proceeds
// budget, and the rebuy it paid for reads as new money again. The
// sending leg of a CASH transfer must be cash: a fully drained cash
// lot (whose dollars live in `face_value`) or a negative
// `cash_delta`, never a security.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 1_000, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 60.0, .account = "Sample IRA" },
};
const after = [_]Lot{
// Reallocation inside Sample IRA: SYM out, NEWSYM in.
.{ .symbol = "NEWSYM", .shares = 2_000, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 50.0, .account = "Sample IRA" },
// The declared transfer's destination. Its source cash is not
// modelled in the portfolio at all.
.{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Sample Roth IRA" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:50000,from::Sample IRA,to::Sample Roth IRA,dest_lot::cash
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var sale: ?Change = null;
var buy: ?Change = null;
for (report.changes) |c| {
if (std.mem.eql(u8, c.symbol, "SYM")) sale = c;
if (c.kind == .new_stock and std.mem.eql(u8, c.symbol, "NEWSYM")) buy = c;
}
// The sale stays a sale.
try std.testing.expect(sale != null);
try std.testing.expectEqual(ChangeKind.lot_removed, sale.?.kind);
// And still funds the rebuy, so no new money is reported.
try std.testing.expect(buy != null);
try std.testing.expectApproxEqAbs(@as(f64, 100_000.0), buy.?.internal_funded, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), buy.?.attributedValue(), 0.01);
}
test "computeReport: a fully drained cash lot is the transfer's sending leg" {
// The shape `tryMatchFromSide`'s removal arm was written for, and
// which never worked: when the cash line is spent to the cent the
// user deletes it, so the outflow arrives as a removed cash lot
// rather than a negative `cash_delta`. Its dollars live in
// `face_value` because `value()` was 0 for removals - which is
// exactly why the arm could never fire.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" },
};
const after = [_]Lot{
.{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Sample Roth IRA" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:50000,from::Sample IRA,to::Sample Roth IRA,dest_lot::cash
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var n_out: usize = 0;
for (report.changes) |c| {
if (c.kind == .transfer_out and std.mem.eql(u8, c.account, "Sample IRA")) n_out += 1;
}
try std.testing.expectEqual(@as(usize, 1), n_out);
// And the whole move contributes nothing.
try std.testing.expectApproxEqAbs(@as(f64, 0.0), attributionTotalForTest(report), 0.01);
}
test "computeReport: an account that GAINED cash is not a transfer's sending leg" {
// `cash_delta` is signed and the match used to take its magnitude,
// so an account whose cash went UP could be reported as the source
// of a transfer out. Money leaving is the whole point, so the sign
// has to be checked.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 10_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" },
};
const after = [_]Lot{
// Sample IRA's cash rose by $60k - it cannot be what funded a
// transfer out of it.
.{ .symbol = "", .shares = 70_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" },
.{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Sample Roth IRA" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:50000,from::Sample IRA,to::Sample Roth IRA,dest_lot::cash
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
for (report.changes) |c| {
try std.testing.expect(c.kind != .transfer_out);
}
}
test "prepareReport: a lot archived into a sibling portfolio file is one sale" {
// End-to-end pin on the multi-file diff. The documented workflow for
// a sale is to move the closed lot out of `portfolio.srf` into a
// sibling `portfolio_closed.srf`, which the `portfolio*.srf` glob
// picks up.
//
// Reading only the first file - what this used to do - showed the
// deletion and nothing else, so the sale's proceeds were invisible
// and the repurchase read as a fresh contribution. Reading the
// merged union instead sees the lot gain close_date/close_price,
// and prices the sale off what it actually realized.
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];
// Before: the position is open, and an empty archive file already
// exists so the glob resolves the same on both sides.
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data =
\\#!srfv1
\\symbol::OLDFUND,shares:num:1000,open_date::2021-09-06,open_price:num:8.90,account::Sample Brokerage
\\
});
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_closed.srf", .data = "#!srfv1\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", "portfolio_closed.srf" });
try test_git.run(allocator, dir, "2026-08-01T12:00:00", &.{ "commit", "-q", "-m", "open" });
// After: sold at 11.32 and archived; proceeds redeployed into NEWA.
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data =
\\#!srfv1
\\symbol::NEWA,shares:num:486,open_date::2026-08-11,open_price:num:23.29,account::Sample Brokerage
\\
});
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_closed.srf", .data =
\\#!srfv1
\\symbol::OLDFUND,close_date::2026-08-10,close_price:num:11.32,shares:num:1000,open_date::2021-09-06,open_price:num:8.90,account::Sample Brokerage
\\
});
try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf", "portfolio_closed.srf" });
try test_git.run(allocator, dir, "2026-08-15T12:00:00", &.{ "commit", "-q", "-m", "sold" });
var env = try std.testing.environ.createMap(allocator);
defer env.deinit();
const open_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
const closed_path = try std.fs.path.join(allocator, &.{ dir, "portfolio_closed.srf" });
// Lexicographic order, matching how the glob resolves ('.' < '_').
const paths: []const []const u8 = &.{ open_path, closed_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, 8, 17), false, .never, .silent) catch return;
defer ctx.deinit();
var sale: ?Change = null;
var buy: ?Change = null;
for (ctx.report.changes) |c| switch (c.kind) {
.position_closed => sale = c,
.new_stock => buy = c,
else => {},
};
// The archived lot reads as a close, not a disappearance...
try std.testing.expect(sale != null);
try std.testing.expectEqualStrings("OLDFUND", sale.?.symbol);
try std.testing.expectApproxEqAbs(@as(f64, 11_320.0), sale.?.face_value, 0.01);
// ...and its proceeds fund the repurchase, so nothing is new money.
try std.testing.expect(buy != null);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), buy.?.attributedValue(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), summarizeAttribution(ctx).total(), 0.01);
}
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.
// Prior behavior: phantom new_stock for the full lot value.
// After edit detection: lot_edited.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("TAXLOSS", 50.0);
const before = [_]Lot{
.{ .symbol = "TAXLOSS", .shares = 100, .open_date = Date.fromYmd(2026, 1, 15), .open_price = 45.0, .account = "Tax Loss" },
};
const after = [_]Lot{
.{ .symbol = "TAXLOSS", .shares = 100, .open_date = Date.fromYmd(2026, 1, 15), .open_price = 48.0, .account = "Tax Loss" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.lot_edited, report.changes[0].kind);
}
test "computeReport: symbol rename with ticker alias collapses to lot_edited" {
// 2026-05-02 regression: user changed a tax-loss lot from
// `symbol::SPY` to `symbol::DI-SPX, ticker::SPY` (direct-indexing
// proxy) and tweaked shares by ~1% during reconciliation. Same
// underlying SPY exposure, same account, same open_date /
// open_price - should collapse to an edit, NOT a ~$327k phantom
// contribution.
//
// Before the `priceSymbol()`-based secondary key, the raw-symbol
// comparison treated "SPY" and "DI-SPX" as different positions
// and emitted a `new_stock` + `lot_removed` pair worth the full
// lot value (~715 × $461 = $330k).
//
// After the fix: one `lot_edited` for the identity continuity,
// plus a `rollup_delta` for the ~6-share residual worth ~$3k.
// The attribution total sees only the residual, not the full lot.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SPY", 461.24);
const before = [_]Lot{
.{
.symbol = "SPY",
.shares = 715.912037,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
.account = "Tax Loss",
},
};
const after = [_]Lot{
.{
.symbol = "DI-SPX",
.ticker = "SPY",
.shares = 709.235272, // ~1% tweak during reconciliation
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
.account = "Tax Loss",
.price_ratio = 1.0,
},
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});
// Expect one lot_edited + one residual share-delta change
// (rollup_delta is used here because delta < 0 would be
// drip_negative - but 709 < 715 means after < before, so
// drip_negative). Actually delta = 709 - 715 = -6, so
// drip_negative.
var n_edit: usize = 0;
var n_rollup: usize = 0;
var n_drip_neg: usize = 0;
var n_new_stock: usize = 0;
var n_removed: usize = 0;
var residual_value: f64 = 0;
for (report.changes) |c| switch (c.kind) {
.lot_edited => n_edit += 1,
.rollup_delta => {
n_rollup += 1;
residual_value = c.value();
},
.drip_negative => {
n_drip_neg += 1;
residual_value = c.value();
},
.new_stock => n_new_stock += 1,
.lot_removed => n_removed += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_edit);
try std.testing.expectEqual(@as(usize, 0), n_new_stock);
try std.testing.expectEqual(@as(usize, 0), n_removed);
// Share count went DOWN, so we get a drip_negative for the
// residual (not rollup_delta).
try std.testing.expectEqual(@as(usize, 1), n_drip_neg);
try std.testing.expectEqual(@as(usize, 0), n_rollup);
// ~6.68 shares × 461.24 ≈ $3,080 - the real reconciliation-scale
// movement, not the ~$330k phantom.
try std.testing.expect(residual_value < 0); // drip_negative sign
try std.testing.expect(@abs(residual_value) < 5_000); // nowhere near $330k
}
test "computeReport: direct_indexing account suppresses sub-1% residual" {
// Tax-loss direct-indexing proxy: same symbol/account/key but
// small share drift (0.5%) from tracking-error reconciliation.
// With no account_map, the default 0.01% tolerance surfaces this
// as a rollup_delta. With account_map flagging the account as
// direct_indexing, the looser 1% tolerance swallows it - only
// the lot_edited marker is emitted, no residual that would land
// in the attribution total.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SPY", 461.24);
const before = [_]Lot{
.{
.symbol = "SPY",
.shares = 1000.0,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
.account = "Tax Loss",
},
};
const after = [_]Lot{
// Broken strict key (symbol+ticker alias rewrite) plus a
// 0.5% share decrease from tracking-error reconciliation.
.{
.symbol = "DI-SPX",
.ticker = "SPY",
.shares = 995.0,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
.account = "Tax Loss",
.price_ratio = 1.0,
},
};
// Build an account_map flagging Tax Loss as direct-indexing.
var am_entries = [_]analysis.AccountTaxEntry{
.{
.account = "Tax Loss",
.tax_type = .taxable,
.direct_indexing = true,
},
};
const account_map = analysis.AccountMap{
.entries = &am_entries,
.allocator = allocator,
};
const report = try computeReport(
allocator,
&before,
&after,
&prices,
Date.fromYmd(2026, 4, 21),
.{ .account_map = &account_map },
);
var n_edit: usize = 0;
var n_rollup_or_drip: usize = 0;
for (report.changes) |c| switch (c.kind) {
.lot_edited => n_edit += 1,
.rollup_delta, .drip_negative, .drip_confirmed => n_rollup_or_drip += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_edit);
// Looser tolerance swallows the 0.5% residual - no rollup/drip
// leak into the attribution total.
try std.testing.expectEqual(@as(usize, 0), n_rollup_or_drip);
}
test "computeReport: direct_indexing same-key drift suppressed in Pass 1" {
// Tax-loss direct-indexing proxy with SAME strict key on both
// sides (no rename this week) but small share drift from
// tracking-error reconciliation. Pass 1 would normally emit a
// drip_negative; with direct_indexing the 1% tolerance suppresses
// it the same way detectEdits does.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SPY", 461.24);
const before = [_]Lot{
.{
.symbol = "DI-SPX",
.ticker = "SPY",
.shares = 1000.0,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
.account = "Tax Loss",
},
};
const after = [_]Lot{
// Same strict key, 0.5% share decrease from tracking-error
// reconciliation.
.{
.symbol = "DI-SPX",
.ticker = "SPY",
.shares = 995.0,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
.account = "Tax Loss",
},
};
var am_entries = [_]analysis.AccountTaxEntry{
.{
.account = "Tax Loss",
.tax_type = .taxable,
.direct_indexing = true,
},
};
const account_map = analysis.AccountMap{
.entries = &am_entries,
.allocator = allocator,
};
const report = try computeReport(
allocator,
&before,
&after,
&prices,
Date.fromYmd(2026, 4, 21),
.{ .account_map = &account_map },
);
// No rollup/drip - the share drift was tracking error and the
// direct_indexing flag suppressed it.
try std.testing.expectEqual(@as(usize, 0), report.changes.len);
}
test "computeReport: same-key drift without direct_indexing still surfaces (sanity)" {
// Sanity check that the flag is what's doing the suppression:
// same setup as above but without the account_map, so Pass 1
// emits a drip_negative for the 0.5% drift.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SPY", 461.24);
const before = [_]Lot{
.{
.symbol = "DI-SPX",
.ticker = "SPY",
.shares = 1000.0,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
.account = "Tax Loss",
},
};
const after = [_]Lot{
.{
.symbol = "DI-SPX",
.ticker = "SPY",
.shares = 995.0,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
.account = "Tax Loss",
},
};
const report = try computeReport(
allocator,
&before,
&after,
&prices,
Date.fromYmd(2026, 4, 21),
.{},
);
var n_drip_neg: usize = 0;
for (report.changes) |c| switch (c.kind) {
.drip_negative => n_drip_neg += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_drip_neg);
}
test "computeReport: direct_indexing tolerance still surfaces real contributions" {
// Regression: the 1% tolerance should still catch a real
// contribution. If the user transfers $100k into a multi-million
// direct-indexing account (~1.2% of value), the residual should
// surface as a rollup_delta so the attribution isn't silent about
// real money flow.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SPY", 461.24);
// 17,361 shares × $461 ≈ $8M (same scale as the user's real tax
// loss account). A $100k contribution = ~217 shares = ~1.25%.
const before = [_]Lot{
.{
.symbol = "DI-SPX",
.ticker = "SPY",
.shares = 17361.0,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
.account = "Tax Loss",
.price_ratio = 1.0,
},
};
const after = [_]Lot{
// Strict-key break (different open_date - e.g. user
// redated after a large contribution) AND a 1.25% share
// increase. Tolerance at 1% fails the "swallow" check so
// the residual surfaces.
.{
.symbol = "DI-SPX",
.ticker = "SPY",
.shares = 17578.0, // +217 shares, ≈ +1.25%
.open_date = Date.fromYmd(2026, 5, 2),
.open_price = 461.240208,
.account = "Tax Loss",
.price_ratio = 1.0,
},
};
var am_entries = [_]analysis.AccountTaxEntry{
.{
.account = "Tax Loss",
.tax_type = .taxable,
.direct_indexing = true,
},
};
const account_map = analysis.AccountMap{
.entries = &am_entries,
.allocator = allocator,
};
const report = try computeReport(
allocator,
&before,
&after,
&prices,
Date.fromYmd(2026, 5, 2),
.{ .account_map = &account_map },
);
var n_edit: usize = 0;
var n_rollup: usize = 0;
for (report.changes) |c| switch (c.kind) {
.lot_edited => n_edit += 1,
.rollup_delta => n_rollup += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_edit);
// 1.25% is over the 1% tolerance, so we get a rollup_delta.
try std.testing.expectEqual(@as(usize, 1), n_rollup);
}
test "computeReport: ticker-alias removed (CUSIP-like -> plain ticker) also collapses" {
// Reverse direction: before has a CUSIP-style symbol with ticker
// alias, after has the plain ticker with no alias. Both resolve
// to the same `priceSymbol()` -> edit, not new+removed.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("VTTHX", 27.78);
const before = [_]Lot{
.{
.symbol = "00766V100", // fake CUSIP-style string
.ticker = "VTTHX",
.shares = 5000,
.open_date = Date.fromYmd(2023, 1, 15),
.open_price = 25.0,
.account = "401k",
},
};
const after = [_]Lot{
.{
.symbol = "VTTHX",
.shares = 5000,
.open_date = Date.fromYmd(2023, 1, 15),
.open_price = 25.0,
.account = "401k",
},
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.lot_edited, report.changes[0].kind);
}
test "computeReport: different tickers stay distinct (no false collapse)" {
// Sanity: VOO -> VTI in the same account with a broken strict key
// is NOT the same underlying position. Must not collapse into an
// edit. Should classify as new + removed.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("VOO", 450.0);
try prices.put("VTI", 230.0);
const before = [_]Lot{
.{
.symbol = "VOO",
.shares = 100,
.open_date = Date.fromYmd(2024, 1, 15),
.open_price = 400.0,
.account = "IRA",
},
};
const after = [_]Lot{
.{
.symbol = "VTI",
.shares = 100,
.open_date = Date.fromYmd(2024, 1, 15),
.open_price = 200.0,
.account = "IRA",
},
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});
var n_edit: usize = 0;
var n_new: usize = 0;
var n_removed: usize = 0;
for (report.changes) |c| switch (c.kind) {
.lot_edited => n_edit += 1,
.new_stock => n_new += 1,
.lot_removed => n_removed += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 0), n_edit);
try std.testing.expectEqual(@as(usize, 1), n_new);
try std.testing.expectEqual(@as(usize, 1), n_removed);
}
test "computeReport: account rename is NOT collapsed (documented limitation)" {
// Renaming an account string in portfolio.srf (e.g. "Brokerage" ->
// "Joint Brokerage") breaks the secondary key too, since that key
// includes the account. Edit detection DOES NOT cover this case
// - the rename looks indistinguishable from a transfer (closing
// one account, opening another with the same positions). Account
// renames must therefore be handled manually: either avoid them
// during a review window, or accept the phantom attribution for
// that week. Tracked in TODO.md; the user-facing consequences are
// documented in docs/guides/set-up-accounts.md ("Renaming an
// account").
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("AAPL", 180.0);
try prices.put("MSFT", 400.0);
const before = [_]Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 150.0, .account = "Brokerage" },
.{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 300.0, .account = "Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 150.0, .account = "Joint Brokerage" },
.{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 300.0, .account = "Joint Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});
// Expectation: account rename collapses into regular new+removed
// classification (NOT lot_edited). If this ever flips to 2/0/0,
// edit detection has gained account-rename awareness - great,
// but update this test and the TODO accordingly.
var n_edit: usize = 0;
var n_new: usize = 0;
var n_removed: usize = 0;
for (report.changes) |c| switch (c.kind) {
.lot_edited => n_edit += 1,
.new_stock => n_new += 1,
.lot_removed => n_removed += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 0), n_edit);
try std.testing.expectEqual(@as(usize, 2), n_new);
try std.testing.expectEqual(@as(usize, 2), n_removed);
}
test "computeReport: big share delta with broken key emits lot_edited + residual rollup" {
// Secondary key matches (same security_type + priceSymbol +
// account) but the share total diverges significantly. This is
// the "user broke the lot key AND had a real contribution in
// the same window" case. The lot identity still collapses to an
// edit, but the share delta surfaces as a rollup_delta so the
// attribution total sees the real inflow - not the full lot
// value as a phantom contribution.
//
// Prior behavior (1% share tolerance): fell through to
// new_stock + lot_removed, over-counting by the full lot value.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("AAPL", 180.0);
const before = [_]Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 150.0, .account = "Brokerage" },
};
const after = [_]Lot{
// Same symbol/account but BIG share delta and different
// open_date. The 400-share delta is real new money; the
// 100-share continuation is an edit.
.{ .symbol = "AAPL", .shares = 500, .open_date = Date.fromYmd(2026, 4, 15), .open_price = 175.0, .account = "Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});
var n_edit: usize = 0;
var n_rollup: usize = 0;
var n_new: usize = 0;
var n_removed: usize = 0;
var rollup_value: f64 = 0;
for (report.changes) |c| switch (c.kind) {
.lot_edited => n_edit += 1,
.rollup_delta => {
n_rollup += 1;
rollup_value += c.value();
},
.new_stock => n_new += 1,
.lot_removed => n_removed += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_edit);
try std.testing.expectEqual(@as(usize, 1), n_rollup);
try std.testing.expectEqual(@as(usize, 0), n_new);
try std.testing.expectEqual(@as(usize, 0), n_removed);
// 400-share delta × $180 current price = $72k
try std.testing.expectApproxEqAbs(@as(f64, 72_000.0), rollup_value, 1.0);
}
test "computeReport: tiny share drift emits lot_edited + residual rollup" {
// Fractional DRIP share-count, e.g. 10.0 -> 10.05 with a
// reconciliation tweak and a key rewrite. Under the always-collapse
// design, this produces lot_edited + a small rollup_delta for
// the 0.05-share residual. The residual survives the 0.01% noise
// tolerance because 0.5% > 0.01%.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SPY", 500.0);
const before = [_]Lot{
.{ .symbol = "SPY", .shares = 10.0, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 420.0, .account = "IRA" },
};
const after = [_]Lot{
.{ .symbol = "SPY", .shares = 10.05, .open_date = Date.fromYmd(2023, 8, 1), .open_price = 430.0, .account = "IRA" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});
var n_edit: usize = 0;
var n_rollup: usize = 0;
for (report.changes) |c| switch (c.kind) {
.lot_edited => n_edit += 1,
.rollup_delta => n_rollup += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_edit);
try std.testing.expectEqual(@as(usize, 1), n_rollup);
}
test "computeReport: sub-noise share drift emits only lot_edited" {
// If the share delta is below the residual-tolerance threshold
// (0.01%), we emit only lot_edited - no spurious rollup_delta
// from floating-point noise.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SPY", 500.0);
const before = [_]Lot{
.{ .symbol = "SPY", .shares = 1000.0, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 420.0, .account = "IRA" },
};
const after = [_]Lot{
// 0.00005% drift (well under 0.01% tolerance)
.{ .symbol = "SPY", .shares = 1000.0000005, .open_date = Date.fromYmd(2023, 8, 1), .open_price = 430.0, .account = "IRA" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.lot_edited, report.changes[0].kind);
}
test "computeReport: new lot with drip::true classified as new_drip_lot" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "FAGIX", .shares = 10.0, .open_date = Date.fromYmd(2026, 4, 10), .open_price = 11.00, .account = "Riley IRA", .drip = true },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.new_drip_lot, report.changes[0].kind);
try std.testing.expectApproxEqAbs(@as(f64, 110.0), report.changes[0].value(), 0.01);
}
test "computeReport: new stock lot without drip flag is new_stock" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2026, 4, 10), .open_price = 180, .account = "Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind);
}
test "computeReport: drip::true existing lot with shares increase is drip_confirmed" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("FAGIX", 11.00);
const before = [_]Lot{
.{ .symbol = "FAGIX", .shares = 100, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "Riley IRA", .drip = true },
};
const after = [_]Lot{
.{ .symbol = "FAGIX", .shares = 105, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "Riley IRA", .drip = true },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.drip_confirmed, report.changes[0].kind);
try std.testing.expectApproxEqAbs(@as(f64, 55.0), report.changes[0].value(), 0.01);
}
test "computeReport: per-account totals separate drip_confirmed from rollup" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("FAGIX", 11.00);
try prices.put("VBTLX", 9.79);
const before = [_]Lot{
.{ .symbol = "FAGIX", .shares = 100, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "AcctA", .drip = true },
.{ .symbol = "VBTLX", .shares = 1000, .open_date = Date.fromYmd(2026, 2, 1), .open_price = 9.79, .account = "AcctA" },
};
const after = [_]Lot{
.{ .symbol = "FAGIX", .shares = 110, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "AcctA", .drip = true },
.{ .symbol = "VBTLX", .shares = 1020, .open_date = Date.fromYmd(2026, 2, 1), .open_price = 9.79, .account = "AcctA" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
const t = report.account_totals.get("AcctA") orelse {
try std.testing.expect(false);
return;
};
// drip_confirmed: 10 * 11 = 110
try std.testing.expectApproxEqAbs(@as(f64, 110.0), t.drip_confirmed, 0.01);
// rollup: 20 * 9.79 = 195.8
try std.testing.expectApproxEqAbs(@as(f64, 195.8), t.rollup, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0), t.new_money, 0.01);
}
// ── resolveEndpoints tests ───────────────────────────────────
//
// Only the legacy (no-flags) and --since-only branches that don't
// shell out to git can be unit-tested cheaply. The full flag paths
// (`--since`, `--since`+`--until`) depend on `git log --until=<DATE>`,
// which requires a real repo and is covered by `src/git.zig` tests
// plus manual smoke-testing.
test "maybeSnapNote fires for a degraded snapshot_add, and stays quiet for a resolved one" {
// The regression this guards was nearly shipped. `maybeSnapNote` read
// `.date_at_or_before => |d| d, else => return`, so the moment `--since` began
// producing `.snapshot_add` the note went silent - including for a date far
// outside the snapshot series, which is the one case it exists to catch.
//
// Snapshot-anchored-and-resolved is not drift: the anchor is deliberately the
// commit that recorded that date. Snapshot-anchored-and-degraded is, because
// resolution fell back to whatever commit happened to precede the date.
const allocator = std.testing.allocator;
if (!test_git.available(allocator)) return;
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(std.testing.io, ".", &path_buf);
const dir = path_buf[0..dir_len];
try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "portfolio.srf", .data = "security_type::cash,shares:num:1.00,account::A\n" });
try test_git.run(allocator, dir, null, &.{ "init", "-q" });
try test_git.run(allocator, dir, null, &.{ "config", "user.email", "t@e.com" });
try test_git.run(allocator, dir, null, &.{ "config", "user.name", "T" });
try test_git.run(allocator, dir, null, &.{ "config", "commit.gpgsign", "false" });
try test_git.run(allocator, dir, null, &.{ "add", "." });
try test_git.run(allocator, dir, "2026-01-05T12:00:00", &.{ "commit", "-q", "-m", "one" });
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var env = try std.testing.environ.createMap(allocator);
defer env.deinit();
const repo = git.findRepo(std.testing.io, arena, &env, dir) catch return;
const far_future = zfin.Date.fromYmd(2026, 6, 1);
// Degraded: no `history/` at all, so `.snapshot_add` fell back to a commit five
// months earlier. `anchor == null` is the signal, and the note must fire.
try maybeSnapNote(std.testing.io, arena, &env, repo, .{ .snapshot_add = far_future }, "HEAD", "before", null);
// Resolved: an anchor came back, so the gap is intentional and there is
// nothing to report. Reaching the timestamp lookup at all would be the bug.
try maybeSnapNote(std.testing.io, arena, &env, repo, .{ .snapshot_add = far_future }, "HEAD", "before", .{
.commit = "HEAD",
.date = far_future,
});
}
test "resolveEndpoints: legacy dirty -> HEAD vs working copy" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
var env = try std.testing.environ.createMap(std.testing.allocator);
defer env.deinit();
const repo: git.RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" };
const eps = try resolveEndpoints(std.testing.io, arena_state.allocator(), &env, repo, &.{repo.rel_path}, null, null, true, .verbose);
try std.testing.expectEqualStrings("HEAD", eps.range.before_rev);
try std.testing.expect(eps.range.after_rev == null);
try std.testing.expect(std.mem.indexOf(u8, eps.label, "working copy against HEAD") != null);
}
test "resolveEndpoints: legacy clean -> HEAD~1 vs HEAD" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
var env = try std.testing.environ.createMap(std.testing.allocator);
defer env.deinit();
const repo: git.RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" };
const eps = try resolveEndpoints(std.testing.io, arena_state.allocator(), &env, repo, &.{repo.rel_path}, null, null, false, .verbose);
try std.testing.expectEqualStrings("HEAD~1", eps.range.before_rev);
try std.testing.expectEqualStrings("HEAD", eps.range.after_rev.?);
try std.testing.expect(std.mem.indexOf(u8, eps.label, "HEAD~1 against HEAD") != null);
}
test "short: long SHA truncates to 7 chars" {
// Works for both SHA-1 (40) and SHA-256 (64). Use a 40-char
// input as the common case; the function only cares that input
// is >= 7 chars.
const sha = "0123456789abcdef0123456789abcdef01234567";
try std.testing.expectEqualStrings("0123456", short(sha));
}
test "short: SHA-256 length also truncates to 7" {
// Forward-compat: same behavior regardless of hash algorithm.
const sha = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
try std.testing.expectEqualStrings("0123456", short(sha));
}
test "short: short input returned as-is" {
try std.testing.expectEqualStrings("abc", short("abc"));
}
// ── matchTransfers tests ─────────────────────────────────────
//
// These exercise the transfer reclassification pipeline end-to-end
// via `computeReport(... .{ .transfer_log = &log, ... })`. Each test
// parses a small SRF fragment into a `TransactionLog`, wires it into
// `ReportOptions`, and asserts the resulting `Report.changes` kinds
// and per-account attribution.
test "diffTransferLogs: empty before, all records new" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const after = try transaction_log.parseTransactionLogFile(arena,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
\\transfer::2026-05-03,type::cash,amount:num:1000,from::Acct C,to::Acct D,dest_lot::cash
\\
);
const new_records = try diffTransferLogs(arena, null, &after);
try std.testing.expectEqual(@as(usize, 2), new_records.len);
}
test "diffTransferLogs: identical before and after returns empty" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const before = try transaction_log.parseTransactionLogFile(arena,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
\\
);
const after = try transaction_log.parseTransactionLogFile(arena,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
\\
);
const new_records = try diffTransferLogs(arena, &before, &after);
try std.testing.expectEqual(@as(usize, 0), new_records.len);
}
test "diffTransferLogs: only the new record returned" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const before = try transaction_log.parseTransactionLogFile(arena,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
\\
);
const after = try transaction_log.parseTransactionLogFile(arena,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
\\transfer::2026-05-03,type::cash,amount:num:1000,from::Acct C,to::Acct D,dest_lot::cash
\\
);
const new_records = try diffTransferLogs(arena, &before, &after);
try std.testing.expectEqual(@as(usize, 1), new_records.len);
try std.testing.expectEqual(Date.fromYmd(2026, 5, 3).days, new_records[0].transfer.days);
}
test "diffTransferLogs: edited record treated as new" {
// User changed the `from` field on a previously-recorded transfer.
// Old form is in `before`, new form is in `after`. The new form
// doesn't equal anything in before -> returned as new. The old
// form is in before but not after -> silently dropped (its diff
// cycle is over).
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const before = try transaction_log.parseTransactionLogFile(arena,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Wrong Acct,to::Acct B,dest_lot::cash
\\
);
const after = try transaction_log.parseTransactionLogFile(arena,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
\\
);
const new_records = try diffTransferLogs(arena, &before, &after);
try std.testing.expectEqual(@as(usize, 1), new_records.len);
try std.testing.expectEqualStrings("Acct A", new_records[0].from);
}
test "diffTransferLogs: out-of-order records still match correctly" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const before = try transaction_log.parseTransactionLogFile(arena,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
\\transfer::2026-05-03,type::cash,amount:num:1000,from::Acct C,to::Acct D,dest_lot::cash
\\
);
// After has the same two records but in reverse order.
const after = try transaction_log.parseTransactionLogFile(arena,
\\#!srfv1
\\transfer::2026-05-03,type::cash,amount:num:1000,from::Acct C,to::Acct D,dest_lot::cash
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
\\
);
const new_records = try diffTransferLogs(arena, &before, &after);
try std.testing.expectEqual(@as(usize, 0), new_records.len);
}
test "diffTransferLogs: back-dated record added on top of existing log" {
// Regression test for the original bug: user records a transfer
// months after it actually happened. The before-side log has
// some prior records; the after-side adds one with an old
// transfer::DATE. That new record must be returned even though
// its date predates everything in before.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const before = try transaction_log.parseTransactionLogFile(arena,
\\#!srfv1
\\transfer::2026-05-15,type::cash,amount:num:1000,from::Acct A,to::Acct B,dest_lot::cash
\\
);
const after = try transaction_log.parseTransactionLogFile(arena,
\\#!srfv1
\\transfer::2026-05-15,type::cash,amount:num:1000,from::Acct A,to::Acct B,dest_lot::cash
\\transfer::2026-01-15,type::cash,amount:num:9999,from::Old Acct,to::New Acct,dest_lot::cash
\\
);
const new_records = try diffTransferLogs(arena, &before, &after);
try std.testing.expectEqual(@as(usize, 1), new_records.len);
try std.testing.expectEqual(@as(f64, 9999), new_records[0].amount);
}
test "matchTransfers: cash-to-cash happy path" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
// One cash lot appearing on Acct B (simulates a $5k cash top-up).
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "cash", .shares = 5000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 3), .{
.transfer_log = tlog.transfers,
});
// The new_cash Change stays as new_cash in the display (we don't
// flip cash-side Changes because a single cash_delta can be
// drained by multiple records). A synthetic transfer_in Change
// is appended for Transfers-section display, and the $5k lands on
// the cash Change's `transfer_attributed` so `attributedValue()`
// reports the residual.
var n_new_cash: usize = 0;
var n_transfer_in: usize = 0;
for (report.changes) |c| switch (c.kind) {
.new_cash => n_new_cash += 1,
.transfer_in => n_transfer_in += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_new_cash);
try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
// Attribution on Acct B: $5k new_cash minus $5k attribution bucket = $0.
const t = report.account_totals.get("Acct B").?;
try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
}
test "matchTransfers: lot destination happy path" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
// Brand new $8k stock lot on Acct B.
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.transfer_in, report.changes[0].kind);
try std.testing.expectApproxEqAbs(@as(f64, 8000.0), report.changes[0].transfer_attributed, 0.01);
// Acct B totals: $0 new_money (transfer_in contributes 0).
const t = report.account_totals.get("Acct B").?;
try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
}
test "matchTransfers: partial attribution - transfer smaller than lot" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
// New $8k lot, but only $7k came from the transfer.
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:7000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.partial_transfer_in, report.changes[0].kind);
try std.testing.expectApproxEqAbs(@as(f64, 7000.0), report.changes[0].transfer_attributed, 0.01);
// Residual = $8k - $7k = $1k, contributed to Acct B new_money.
try std.testing.expectApproxEqAbs(@as(f64, 1000.0), report.changes[0].attributedValue(), 0.01);
const t = report.account_totals.get("Acct B").?;
try std.testing.expectApproxEqAbs(@as(f64, 1000.0), t.new_money, 0.01);
}
test "matchTransfers: sweep - lot destination + cash residual, both match" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 1.0);
// $145,300 stock lot + $4,700 cash residual on Acct B.
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 145300, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 1.0, .account = "Acct B" },
.{ .symbol = "cash", .shares = 4700, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:145300,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\transfer::2026-05-02,type::cash,amount:num:4700,from::Acct A,to::Acct B,dest_lot::cash
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
// Acct B new_money: $0 (both transfers fully attributed).
const t = report.account_totals.get("Acct B").?;
try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
// One transfer_in on the stock lot (kind flipped from new_stock).
var n_stock_transfer_in: usize = 0;
var n_cash_transfer_in: usize = 0;
for (report.changes) |c| switch (c.kind) {
.transfer_in => {
if (c.security_type == .stock) n_stock_transfer_in += 1;
if (c.security_type == .cash) n_cash_transfer_in += 1;
},
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_stock_transfer_in);
// One synthetic cash transfer_in from the cash-dest matcher.
try std.testing.expectEqual(@as(usize, 1), n_cash_transfer_in);
}
test "matchTransfers: duplicate dest_lot emits unmatched" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
};
// Two records pointing at the same (account, symbol). First matches;
// second emits unmatched_transfer with "already claimed".
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\transfer::2026-05-02,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var n_transfer_in: usize = 0;
var n_unmatched: usize = 0;
for (report.changes) |c| switch (c.kind) {
.transfer_in => n_transfer_in += 1,
.unmatched_transfer => n_unmatched += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
}
test "matchTransfers: missing dest_lot emits unmatched" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
// No new lots in the diff at all.
const before = [_]Lot{};
const after = [_]Lot{};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.unmatched_transfer, report.changes[0].kind);
}
test "matchTransfers: amount exceeds lot value emits unmatched" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
};
// $10k transfer against $8k lot.
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:10000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
// Lot stays as new_stock; unmatched record appended.
var n_new_stock: usize = 0;
var n_unmatched: usize = 0;
for (report.changes) |c| switch (c.kind) {
.new_stock => n_new_stock += 1,
.unmatched_transfer => n_unmatched += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_new_stock);
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
}
test "matchTransfers: a transfer spent on securities before any snapshot is not new money" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("VOO", 100.0);
// The real 2026-08 shape, scaled down. $50k moved into an account and was
// invested the same week, so no snapshot ever contains the cash: the diff
// sees a new security lot plus the few dollars that did not get spent.
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "VOO", .shares = 499, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 100.0, .account = "Acct B" },
.{ .symbol = "cash", .shares = 100, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:50000,from::Acct A,to::Acct B,dest_lot::cash
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
// Nothing new entered the portfolio: $49,900 of VOO plus $100 of leftover
// cash is exactly the $50,000 that moved. Before this was handled, the
// purchase counted as a fresh contribution - the mechanism that reported
// $738,814 of contributions for a 401(k)-to-BrokerageLink move.
const t = report.account_totals.get("Acct B").?;
try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
// And it is not reported as a discrepancy either, because it is not one:
// the destination gained precisely what the record declared.
for (report.changes) |c| {
try std.testing.expect(c.kind != .unmatched_transfer);
}
// The purchase is attributed as internally funded rather than being
// dropped, so it still shows under "Internal purchases".
var funded: f64 = 0;
for (report.changes) |c| {
if (c.kind == .new_stock) funded += c.internal_funded;
}
try std.testing.expectApproxEqAbs(@as(f64, 49900.0), funded, 0.01);
}
test "matchTransfers: a short cash increase credits what arrived and flags the gap" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
// Only $3k cash showed up on Acct B, but the record wants $5k.
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "cash", .shares = 3000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
// The $2k the destination never gained is still a real discrepancy.
var n_unmatched: usize = 0;
for (report.changes) |c| if (c.kind == .unmatched_transfer) {
n_unmatched += 1;
};
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
// But the $3k that DID arrive is transferred money, not new money.
// Previously the whole record was abandoned when the amounts disagreed,
// so a correct partial attribution was discarded and the $3k was reported
// as a fresh contribution - which it demonstrably is not, since a transfer
// record says where it came from.
const t = report.account_totals.get("Acct B").?;
try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
}
test "matchTransfers: same-day multi-cash records drain a single cash_delta" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
// $5k cash showed up on Acct B.
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "cash", .shares = 5000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" },
};
// Two records ($2k + $3k = $5k).
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:2000,from::Acct A,to::Acct B,dest_lot::cash
\\transfer::2026-05-02,type::cash,amount:num:3000,from::Acct A,to::Acct B,dest_lot::cash
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
// Both records should match (budget fully consumed); no unmatched.
var n_unmatched: usize = 0;
var n_transfer_in: usize = 0;
for (report.changes) |c| switch (c.kind) {
.unmatched_transfer => n_unmatched += 1,
.transfer_in => n_transfer_in += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 0), n_unmatched);
try std.testing.expectEqual(@as(usize, 2), n_transfer_in);
// Acct B new_money: $5k new_cash - $5k attribution = $0.
const t = report.account_totals.get("Acct B").?;
try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
}
test "matchInKindTransfer: happy path - new lot on dest, lot_removed on source" {
// Roth-conversion shape: 80 shares of SYM move in-kind from
// Acct A (tracked) to Acct B. A's lot disappears (lot_removed),
// B gains a fresh lot (new_stock). Both flip to transfer kinds
// and contribute $0 to attribution.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100, .account = "Acct A" },
};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var n_transfer_in: usize = 0;
var n_transfer_out: usize = 0;
var n_new_stock: usize = 0;
var n_lot_removed: usize = 0;
var n_unmatched: usize = 0;
for (report.changes) |c| switch (c.kind) {
.transfer_in => n_transfer_in += 1,
.transfer_out => n_transfer_out += 1,
.new_stock => n_new_stock += 1,
.lot_removed => n_lot_removed += 1,
.unmatched_transfer => n_unmatched += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
try std.testing.expectEqual(@as(usize, 1), n_transfer_out);
try std.testing.expectEqual(@as(usize, 0), n_new_stock);
try std.testing.expectEqual(@as(usize, 0), n_lot_removed);
try std.testing.expectEqual(@as(usize, 0), n_unmatched);
// Dest moved value is the lot's own value (80 x $100 = $8,000).
for (report.changes) |c| {
if (c.kind == .transfer_in) {
try std.testing.expectApproxEqAbs(@as(f64, 8000.0), c.transfer_attributed, 0.01);
try std.testing.expectEqualStrings("Acct A", c.transfer_from.?);
}
}
// No new money on either account.
try std.testing.expectApproxEqAbs(@as(f64, 0.0), report.account_totals.get("Acct B").?.new_money, 0.01);
}
test "matchInKindTransfer: into existing dest lot (rollup_delta) flips to transfer_in" {
// B already held SYM; the in-kind add shows up as a rollup_delta
// (share increase on the existing lot), not a new_stock.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 90, .account = "Acct A" },
.{ .symbol = "SYM", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 80, .account = "Acct B" },
};
const after = [_]Lot{
// Acct A's lot gone; Acct B's lot grew by 100 shares (same key).
.{ .symbol = "SYM", .shares = 150, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 80, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::in_kind,amount:num:10000,from::Acct A,to::Acct B,dest_lot::SYM@2024-01-01
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var n_transfer_in: usize = 0;
var n_transfer_out: usize = 0;
var n_rollup: usize = 0;
var n_unmatched: usize = 0;
for (report.changes) |c| switch (c.kind) {
.transfer_in => n_transfer_in += 1,
.transfer_out => n_transfer_out += 1,
.rollup_delta => n_rollup += 1,
.unmatched_transfer => n_unmatched += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
try std.testing.expectEqual(@as(usize, 1), n_transfer_out);
try std.testing.expectEqual(@as(usize, 0), n_rollup);
try std.testing.expectEqual(@as(usize, 0), n_unmatched);
// Rollup share delta is no longer counted on Acct B.
try std.testing.expectApproxEqAbs(@as(f64, 0.0), report.account_totals.get("Acct B").?.rollup, 0.01);
}
test "matchInKindTransfer: partial move (drip_negative source) matches" {
// 40 of A's 100 SYM shares move to B. A's lot shrinks
// (drip_negative); B gains a new lot. Share counts match.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 90, .account = "Acct A" },
};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 90, .account = "Acct A" },
.{ .symbol = "SYM", .shares = 40, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 90, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::in_kind,amount:num:4000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var n_transfer_in: usize = 0;
var n_transfer_out: usize = 0;
var n_unmatched: usize = 0;
for (report.changes) |c| switch (c.kind) {
.transfer_in => n_transfer_in += 1,
.transfer_out => n_transfer_out += 1,
.unmatched_transfer => n_unmatched += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
try std.testing.expectEqual(@as(usize, 1), n_transfer_out);
try std.testing.expectEqual(@as(usize, 0), n_unmatched);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), report.account_totals.get("Acct B").?.new_money, 0.01);
}
test "matchInKindTransfer: untracked source still flips destination" {
// The `from` account isn't in the portfolio (external rollover
// origin). The destination still reclassifies to transfer_in -
// the user declared the move, so it isn't new external money.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::in_kind,amount:num:8000,from::External Rollover,to::Acct B,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var n_transfer_in: usize = 0;
var n_transfer_out: usize = 0;
var n_unmatched: usize = 0;
for (report.changes) |c| switch (c.kind) {
.transfer_in => n_transfer_in += 1,
.transfer_out => n_transfer_out += 1,
.unmatched_transfer => n_unmatched += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
try std.testing.expectEqual(@as(usize, 0), n_transfer_out); // no tracked source
try std.testing.expectEqual(@as(usize, 0), n_unmatched);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), report.account_totals.get("Acct B").?.new_money, 0.01);
}
test "matchInKindTransfer: share-count mismatch emits unmatched, leaves Changes counted" {
// A removes 100 SYM but B only gains 73 SYM - the declared
// transfer doesn't cleanly correspond to the diff. The matcher
// refuses to pair them: both Changes keep their base kinds and an
// unmatched_transfer is flagged.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100, .account = "Acct A" },
};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 73, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::in_kind,amount:num:10000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var n_new_stock: usize = 0;
var n_lot_removed: usize = 0;
var n_unmatched: usize = 0;
for (report.changes) |c| switch (c.kind) {
.new_stock => n_new_stock += 1,
.lot_removed => n_lot_removed += 1,
.unmatched_transfer => n_unmatched += 1,
else => {},
};
// Base classifications survive; the bad record is surfaced.
try std.testing.expectEqual(@as(usize, 1), n_new_stock);
try std.testing.expectEqual(@as(usize, 1), n_lot_removed);
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
}
test "matchInKindTransfer: cash destination is rejected as unmatched" {
// An in-kind record must name a SYMBOL@DATE lot. `dest_lot::cash`
// is nonsensical for a securities transfer.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "cash", .shares = 8000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Acct A,to::Acct B,dest_lot::cash
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var n_unmatched: usize = 0;
var unmatched_note: ?[]const u8 = null;
for (report.changes) |c| {
if (c.kind == .unmatched_transfer) {
n_unmatched += 1;
unmatched_note = c.transfer_note;
}
}
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
try std.testing.expect(unmatched_note != null);
try std.testing.expect(std.mem.indexOf(u8, unmatched_note.?, "not cash") != null);
}
test "matchInKindTransfer: destination lot not found emits unmatched" {
// The record names SYM but no SYM lot appeared on the `to`
// account (typo, or the lot didn't materialize this diff). The
// base diff is left untouched and the record is flagged.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{};
const after = [_]Lot{
// A SYM lot landed on Acct C, not the record's `to` (Acct B).
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct C" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var n_new_stock: usize = 0;
var n_unmatched: usize = 0;
var unmatched_note: ?[]const u8 = null;
for (report.changes) |c| switch (c.kind) {
.new_stock => n_new_stock += 1,
.unmatched_transfer => {
n_unmatched += 1;
unmatched_note = c.transfer_note;
},
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_new_stock); // Acct C lot untouched
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
try std.testing.expect(unmatched_note != null);
try std.testing.expect(std.mem.indexOf(u8, unmatched_note.?, "not found") != null);
}
test "matchInKindTransfer: duplicate record - first matches, second unmatched" {
// Two in-kind records name the same destination lot but the diff
// only shows one share-addition. The first claims it (flipped to
// transfer_in); the second can't re-match the now-reclassified
// Change and is flagged.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100, .account = "Acct A" },
};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var n_transfer_in: usize = 0;
var n_unmatched: usize = 0;
for (report.changes) |c| switch (c.kind) {
.transfer_in => n_transfer_in += 1,
.unmatched_transfer => n_unmatched += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
}
test "printReport: in-kind transfer renders in Transfers section, out of totals" {
// End-to-end through the display layer: a report mixing a real
// new contribution, an in-kind transfer (in + out), a cash delta,
// and an unmatched removal should render every section and keep
// the transferred securities out of the grand total.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
try prices.put("NEWX", 200.0);
const before = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100, .account = "Sample IRA" },
.{ .symbol = "OLDX", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50, .account = "Sample HSA" },
.{ .symbol = "cash", .shares = 1000, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Sample Roth IRA" },
.{ .symbol = "NEWX", .shares = 5, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 200, .account = "Sample Brokerage" },
.{ .symbol = "cash", .shares = 1500, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Sample IRA,to::Sample Roth IRA,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
var aw: std.Io.Writer.Allocating = .init(allocator);
const out = &aw.writer;
try printReport(out, &report, "test window", false);
const text = aw.written();
// Sections present.
try std.testing.expect(std.mem.indexOf(u8, text, "New contributions / purchases") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "Transfers (matched - not counted)") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "Cash deltas") != null);
// The OLDX disposal is a security sale, so it reports as released
// funds under Internal purchases rather than as an anomaly under
// Flagged for review.
try std.testing.expect(std.mem.indexOf(u8, text, "Internal purchases") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "OLDX") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "Flagged for review") == null);
try std.testing.expect(std.mem.indexOf(u8, text, "Summary by account") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "Grand total") != null);
// The real new purchase and the unmatched removal show up.
try std.testing.expect(std.mem.indexOf(u8, text, "NEWX") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "OLDX") != null);
// The in-kind transfer's sending account is cross-referenced in
// the Transfers section.
try std.testing.expect(std.mem.indexOf(u8, text, "Sample IRA") != null);
// Grand total = NEWX purchase ($1,000) only; the $8,000 in-kind
// SYM move and the $500 cash delta don't count as contributions.
try std.testing.expect(std.mem.indexOf(u8, text, "New contributions / purchases: $1,000.00") != null);
}
test "printReport: no changes detected renders a single line" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{};
const after = [_]Lot{};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var aw: std.Io.Writer.Allocating = .init(allocator);
const out = &aw.writer;
try printReport(out, &report, "test window", false);
const text = aw.written();
try std.testing.expect(std.mem.indexOf(u8, text, "No changes detected") != null);
}
test "printReport: color=true emits ANSI escapes" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("NEWX", 200.0);
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "NEWX", .shares = 5, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 200, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var aw: std.Io.Writer.Allocating = .init(allocator);
const out = &aw.writer;
try printReport(out, &report, "test window", true);
const text = aw.written();
// ESC (0x1b) appears when color is on.
try std.testing.expect(std.mem.indexOfScalar(u8, text, 0x1b) != null);
}
test "matchTransfers: back-dated record matches regardless of date" {
// The matcher itself is date-agnostic now - the caller (typically
// `prepareReport` via `diffTransferLogs`) is responsible for
// narrowing the slice to records that should be considered for
// this diff cycle. A record dated weeks before any "diff window"
// pairs cleanly when passed to the matcher directly.
//
// This is the regression test for the back-dated-record-rejected
// bug: user adds `transfer::2026-01-15` to transaction_log.srf
// on 2026-05-04 to record a transfer that actually happened
// months ago. The matcher must pair it.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-01-15,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
// Record paired despite the months-earlier date.
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.transfer_in, report.changes[0].kind);
}
test "matchTransfers: null transfer_log is a no-op (backward compat)" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
};
// No transfer_log passed - baseline behavior with no reclassification.
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind);
}
test "matchTransfers: attribution excludes transferred amount" {
// End-to-end: run through the same computeReport path that
// `summarizeAttribution` consumes, and verify the attribution
// line would be $0 for a fully-transferred lot.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
// Replicate summarizeAttribution's logic directly. Uses
// `attributedValue()` on the new-side kinds, exactly as the real
// summary does: the per-Change residual IS the subtraction, so there
// is nothing further to net off. An earlier version of this test
// summed raw `value()` and then subtracted a separate per-account
// bucket - the superseded mechanism - which meant it reproduced an
// old formula rather than exercising the shipped one.
var new_contributions: f64 = 0;
var drip: f64 = 0;
for (report.changes) |c| switch (c.kind) {
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => new_contributions += c.attributedValue(),
.new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(),
.partial_transfer_in => new_contributions += c.attributedValue(),
else => {},
};
try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_contributions, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), drip, 0.01);
}
// ── collectUnmatchedLargeLots tests ──────────────────────────
//
// These exercise the audit large-lot filter by building a `Report`
// via `computeReport` (with or without a transfer log) and feeding
// its changes directly to `collectUnmatchedLargeLots`. That skips
// the git + IO plumbing of `findUnmatchedLargeLots` while still
// covering the classification path the production code uses.
test "collectUnmatchedLargeLots: below threshold is silent" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 100.0);
const before = [_]Lot{};
// $5k lot - under the $10k threshold used by audit.
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 50, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct A" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
test "collectUnmatchedLargeLots: unmatched large stock lot surfaces" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 500.0);
const before = [_]Lot{};
// $50k stock lot, no transfer log, should surface.
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 1), lots.len);
try std.testing.expectEqualStrings("Acct A", lots[0].account);
try std.testing.expectEqualStrings("SYM", lots[0].symbol);
try std.testing.expectEqual(LotType.stock, lots[0].security_type);
try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), lots[0].value, 0.01);
try std.testing.expectEqual(Date.fromYmd(2026, 5, 3).days, lots[0].open_date.days);
}
test "collectUnmatchedLargeLots: per-account threshold suppresses one account, default flags the other" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("ESPPSYM", 300.0);
try prices.put("BRKSYM", 300.0);
const before = [_]Lot{};
// Two new $30k lots in different accounts.
const after = [_]Lot{
.{ .symbol = "ESPPSYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 300, .account = "Sample ESPP" },
.{ .symbol = "BRKSYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 300, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
// Sanity: with no account_map, both $30k lots clear the $10k
// default and surface. This isolates the override as the cause of
// the difference below.
const both = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 2), both.len);
// ESPP raises its own threshold to $50k (routine large accruals);
// Sample Brokerage leaves it at the default. Now only the
// brokerage lot surfaces - the $30k ESPP lot is below its $50k bar.
var am = try analysis.parseAccountsFile(allocator,
\\#!srfv1
\\account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000
\\account::Sample Brokerage,tax_type::taxable
);
defer am.deinit();
const lots = try collectUnmatchedLargeLots(allocator, report.changes, &am);
try std.testing.expectEqual(@as(usize, 1), lots.len);
try std.testing.expectEqualStrings("Sample Brokerage", lots[0].account);
try std.testing.expectEqualStrings("BRKSYM", lots[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), lots[0].value, 0.01);
}
test "collectUnmatchedLargeLots: unmatched large cash lot surfaces" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "cash", .shares = 50_000, .open_date = Date.fromYmd(2026, 5, 10), .open_price = 1.0, .security_type = .cash, .account = "Acct A" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 11), .{});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 1), lots.len);
try std.testing.expectEqual(LotType.cash, lots[0].security_type);
try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), lots[0].value, 0.01);
}
test "collectUnmatchedLargeLots: matched via transfer log is silent" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 500.0);
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" },
};
// Transfer log fully covers the lot -> kind flips to transfer_in.
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:50000,from::Acct B,to::Acct A,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
// Sanity: the lot should have been reclassified.
try std.testing.expectEqual(ChangeKind.transfer_in, report.changes[0].kind);
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
test "collectUnmatchedLargeLots: cash-destination matched is silent" {
// Regression for the user-visible bug: a $73,158.33 cash lot on
// Sample Trust funded by a transfer record dated 2026-05-20 was
// surfacing in audit's "Large new lots - confirm source" because
// the cash matcher doesn't flip the original `new_cash` Change's
// kind (the attribution rides on `transfer_attributed` instead).
// Without subtracting that attribution, the audit filter
// re-flagged a lot that's already explained.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{};
const after = [_]Lot{
.{ .security_type = .cash, .shares = 73158.33, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Sample Trust" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-20,type::cash,amount:num:73158.33,from::Sample Source,to::Sample Trust,dest_lot::cash
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 23), .{
.transfer_log = tlog.transfers,
});
// Cash-dest matching does NOT flip the original new_cash Change
// (a single delta can be drained by multiple records). The
// matcher records the attributed amount in
// `transfer_attributed` instead.
var saw_new_cash = false;
var saw_synthetic_transfer = false;
for (report.changes) |c| switch (c.kind) {
.new_cash => saw_new_cash = true,
.transfer_in => saw_synthetic_transfer = true,
else => {},
};
try std.testing.expect(saw_new_cash);
try std.testing.expect(saw_synthetic_transfer);
// The attribution lands on the consumed cash Change, so the residual - not a
// separate per-account bucket - is what every consumer sees. Assert it there,
// which is also the value the audit filter below acts on.
var residual: f64 = 0;
for (report.changes) |c| {
if (c.kind == .new_cash) residual += c.attributedValue();
}
try std.testing.expectApproxEqAbs(@as(f64, 0.0), residual, 0.01);
// The audit filter must see a fully-attributed lot and stay quiet.
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
test "collectUnmatchedLargeLots: cash-destination partial match surfaces residual only" {
// A $50K cash lot with a $30K transfer attributed against it -
// the residual $20K is "new contribution" and SHOULD surface
// (above the $10K threshold). The filter reports the residual,
// not the gross.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{};
const after = [_]Lot{
.{ .security_type = .cash, .shares = 50000.0, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Sample Trust" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-20,type::cash,amount:num:30000,from::Sample Source,to::Sample Trust,dest_lot::cash
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 23), .{
.transfer_log = tlog.transfers,
});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 1), lots.len);
try std.testing.expectEqual(@as(f64, 20000.0), lots[0].value);
}
test "collectUnmatchedLargeLots: cash-destination partial below threshold is silent" {
// A $15K cash lot with a $10K transfer attributed -> residual
// $5K, below the $10K threshold -> silent.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{};
const after = [_]Lot{
.{ .security_type = .cash, .shares = 15000.0, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Sample Trust" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-20,type::cash,amount:num:10000,from::Sample Source,to::Sample Trust,dest_lot::cash
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 23), .{
.transfer_log = tlog.transfers,
});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
test "collectUnmatchedLargeLots: no new lots -> empty result" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{};
const after = [_]Lot{};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
test "collectUnmatchedLargeLots: buy funded by same-account cash is silent" {
// The user's audit complaint: a $30k stock lot bought with cash
// already in the account. The -$30k cash_delta funds it, netting
// its attributedValue to $0, so audit must not flag it - no
// transaction_log.srf entry required.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "", .shares = 20_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
test "collectUnmatchedLargeLots: partly cash-funded buy surfaces residual only" {
// $50k buy, $30k from existing cash -> $20k of new money remains,
// which is above the $10k threshold and SHOULD still surface.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 1), lots.len);
try std.testing.expectApproxEqAbs(@as(f64, 20_000.0), lots[0].value, 0.01);
}
test "collectUnmatchedLargeLots: partial cash-funded residual below threshold is silent" {
// $35k buy, $30k from existing cash -> $5k residual, below the
// $10k threshold -> silent.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 70, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
test "collectUnmatchedLargeLots: partial transfer still flags residual? No - full lot value counts" {
// Partial transfers leave the Change as `partial_transfer_in`,
// which the audit filter IGNORES (only new_* kinds pass the
// `is_new_side` check). That's the correct behavior: the
// unrecorded portion on a partial is typically small (pre-
// existing cash) and already has an explicit transfer record
// acknowledging the large movement. Surfacing it again would
// double-nag.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SYM", 500.0);
const before = [_]Lot{};
// $50k lot, $45k from a transfer -> partial_transfer_in with
// $5k residual. Residual is below $10k threshold anyway, but
// even if it weren't, the filter skips partial_transfer_in.
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:45000,from::Acct B,to::Acct A,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
try std.testing.expectEqual(ChangeKind.partial_transfer_in, report.changes[0].kind);
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
test "shortSha: HEAD passes through unchanged" {
try std.testing.expectEqualStrings("HEAD", shortSha("HEAD"));
try std.testing.expectEqualStrings("HEAD~", shortSha("HEAD~"));
try std.testing.expectEqualStrings("HEAD~3", shortSha("HEAD~3"));
}
test "shortSha: long SHA truncates to 7 chars" {
try std.testing.expectEqualStrings("abcdef0", shortSha("abcdef0123456789"));
try std.testing.expectEqualStrings("a1b2c3d", shortSha("a1b2c3d4e5f6789012345"));
}
test "shortSha: short input returned as-is" {
try std.testing.expectEqualStrings("abc", shortSha("abc"));
try std.testing.expectEqualStrings("abcdefg", shortSha("abcdefg")); // exactly 7
try std.testing.expectEqualStrings("", shortSha(""));
}
test "specDisplayString: null yields '(unset)'" {
var buf: [10]u8 = undefined;
try std.testing.expectEqualStrings("(unset)", specDisplayString(null, &buf));
}
test "specDisplayString: working_copy yields 'working'" {
var buf: [10]u8 = undefined;
try std.testing.expectEqualStrings("working", specDisplayString(.{ .working_copy = {} }, &buf));
}
test "specDisplayString: git_ref returns ref verbatim" {
var buf: [10]u8 = undefined;
try std.testing.expectEqualStrings("HEAD", specDisplayString(.{ .git_ref = "HEAD" }, &buf));
try std.testing.expectEqualStrings("main", specDisplayString(.{ .git_ref = "main" }, &buf));
}
test "specDisplayString: date_at_or_before formats date YYYY-MM-DD" {
var buf: [10]u8 = undefined;
const d = Date.fromYmd(2024, 3, 15);
try std.testing.expectEqualStrings("2024-03-15", specDisplayString(.{ .date_at_or_before = d }, &buf));
}
test "specLabel: null spec returns resolved ref dup'd" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const result = try specLabel(arena, null, "abc1234");
try std.testing.expectEqualStrings("abc1234", result);
}
test "specLabel: git_ref returns ref dup'd" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const result = try specLabel(arena, .{ .git_ref = "main" }, "ignored");
try std.testing.expectEqualStrings("main", result);
}
test "specLabel: date renders 'commit at-or-before YYYY-MM-DD'" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const d = Date.fromYmd(2024, 3, 15);
const result = try specLabel(arena, .{ .date_at_or_before = d }, "ignored");
try std.testing.expectEqualStrings("commit at-or-before 2024-03-15", result);
}
test "specLabel: working_copy literal" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const result = try specLabel(arena, .{ .working_copy = {} }, "ignored");
try std.testing.expectEqualStrings("working copy", result);
}
test "specLabelAfter: null spec + non-null resolved_ref returns resolved_ref" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const result = try specLabelAfter(arena, null, "HEAD");
try std.testing.expectEqualStrings("HEAD", result);
}
test "specLabelAfter: null spec + null resolved_ref returns 'working copy'" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const result = try specLabelAfter(arena, null, null);
try std.testing.expectEqualStrings("working copy", result);
}
test "specLabelAfter: spec set + null resolved_ref defaults to 'working'" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const result = try specLabelAfter(arena, .{ .working_copy = {} }, null);
try std.testing.expectEqualStrings("working copy", result);
}
test "buildLabel: no date window, dirty -> 'Comparing working copy against HEAD'" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null };
const result = try buildLabel(arena, range, null, null, true);
try std.testing.expectEqualStrings("Comparing working copy against HEAD", result);
}
test "buildLabel: no date window, clean -> HEAD~1 against HEAD" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null };
const result = try buildLabel(arena, range, null, null, false);
try std.testing.expectEqualStrings("Working tree clean - comparing HEAD~1 against HEAD", result);
}
test "buildLabel: --since only, dirty -> against working copy" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null };
const since = Date.fromYmd(2024, 3, 15);
const result = try buildLabel(arena, range, since, null, true);
try std.testing.expect(std.mem.indexOf(u8, result, "abc1234") != null);
try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null);
try std.testing.expect(std.mem.indexOf(u8, result, "working copy") != null);
}
test "buildLabel: --since only, clean -> against HEAD" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null };
const since = Date.fromYmd(2024, 3, 15);
const result = try buildLabel(arena, range, since, null, false);
try std.testing.expect(std.mem.indexOf(u8, result, "against HEAD") != null);
try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null);
}
test "buildLabel: --since + --until renders both dates and short SHAs" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = "def4567890123" };
const since = Date.fromYmd(2024, 1, 15);
const until = Date.fromYmd(2024, 3, 15);
const result = try buildLabel(arena, range, since, until, false);
try std.testing.expect(std.mem.indexOf(u8, result, "2024-01-15") != null);
try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null);
try std.testing.expect(std.mem.indexOf(u8, result, "abc1234") != null);
try std.testing.expect(std.mem.indexOf(u8, result, "def4567") != null);
}
test "buildLabelFromSpecs: both date specs -> falls through to buildLabel" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const range = git.CommitRange{ .before_rev = "aaaaaaa1234567", .after_rev = "bbbbbbb1234567" };
const before_d = Date.fromYmd(2024, 1, 15);
const after_d = Date.fromYmd(2024, 3, 15);
const result = try buildLabelFromSpecs(
arena,
range,
.{ .date_at_or_before = before_d },
.{ .date_at_or_before = after_d },
false,
);
// Date-form path: uses buildLabel formatting
try std.testing.expect(std.mem.indexOf(u8, result, "2024-01-15") != null);
try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null);
}
test "buildLabelFromSpecs: non-date spec -> '<before> vs <after>' format" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const range = git.CommitRange{ .before_rev = "main", .after_rev = "feature" };
const result = try buildLabelFromSpecs(
arena,
range,
.{ .git_ref = "main" },
.{ .git_ref = "feature" },
false,
);
try std.testing.expectEqualStrings("main vs feature", result);
}
test "buildLabelFromSpecs: working_copy after -> 'working copy' literal" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null };
const result = try buildLabelFromSpecs(
arena,
range,
.{ .git_ref = "HEAD~1" },
.{ .working_copy = {} },
true,
);
try std.testing.expectEqualStrings("HEAD~1 vs working copy", result);
}
test "printChangeLine: stock change shows shares × price = value" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const c = Change{
.kind = .new_stock,
.symbol = "AAPL",
.account = "Roth",
.security_type = .stock,
.delta_shares = 10,
.unit_value = 150.0,
};
try printChangeLine(&w, c, false, cli.CLR_POSITIVE);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Roth") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "shares") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "$150.00") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "$1,500.00") != null);
}
test "printChangeLine: cash change shows value only (no shares × price)" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const c = Change{
.kind = .new_cash,
.symbol = "CASH",
.account = "Brokerage",
.security_type = .cash,
.delta_shares = 1000,
.unit_value = 1.0,
};
try printChangeLine(&w, c, false, cli.CLR_POSITIVE);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "CASH") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Brokerage") != null);
// cash shouldn't show "shares ×"
try std.testing.expect(std.mem.indexOf(u8, out, "shares ×") == null);
try std.testing.expect(std.mem.indexOf(u8, out, "$1,000.00") != null);
}
test "printChangeLine: empty account shown as '(no account)'" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const c = Change{
.kind = .new_stock,
.symbol = "VTI",
.account = "",
.security_type = .stock,
.delta_shares = 5,
.unit_value = 200.0,
};
try printChangeLine(&w, c, false, cli.CLR_POSITIVE);
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "(no account)") != null);
}
test "printSummaryCell: zero value renders muted dash" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try printSummaryCell(&w, "Drip", 0, false);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Drip") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "-") != null);
}
test "printSummaryCell: nonzero value renders dollar amount" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try printSummaryCell(&w, "Drip", 250.50, false);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Drip") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "$250.50") != null);
}
test "printSection: emits title with header style" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try printSection(&w, "Contributions", false, cli.CLR_POSITIVE);
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "Contributions") != null);
}
test "printNone: emits muted '(none)' line" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try printNone(&w, false, cli.CLR_MUTED);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "none") != null or std.mem.indexOf(u8, out, "None") != null);
}
test "printTotalLine: emits label and dollar amount" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try printTotalLine(&w, "Total:", 12_345.67, false, cli.CLR_POSITIVE);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Total") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "$12,345.67") != null);
}
test "printPriceOnlyLine: shows old -> new price" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const c = Change{
.kind = .price_only,
.symbol = "VTI",
.account = "Roth",
.security_type = .stock,
.old_price = 100.0,
.new_price = 110.0,
};
try printPriceOnlyLine(&w, c, false, cli.CLR_MUTED);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "VTI") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "$100.00") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "$110.00") != null);
}
test "printChangeLine: no ANSI when color=false" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const c = Change{
.kind = .new_stock,
.symbol = "AAPL",
.account = "Roth",
.security_type = .stock,
.delta_shares = 10,
.unit_value = 150.0,
};
try printChangeLine(&w, c, false, cli.CLR_POSITIVE);
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") == null);
}
test "printReport: empty report says no changes" {
var buf: [512]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var account_totals = std.StringHashMap(Report.AccountTotal).init(testing.allocator);
defer account_totals.deinit();
var changes = [_]Change{};
const report = Report{
.changes = changes[0..],
.account_totals = account_totals,
};
try printReport(&w, &report, "portfolio.srf", false);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "No changes detected.") != null);
}
test "printReport: full report renders every section and sub-printer" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// One change of (nearly) every kind, exercising every section and
// each line-printer. Values are arbitrary but internally consistent
// for the CD-interest recompute (cash_delta - face = implied interest).
var changes = [_]Change{
.{ .kind = .new_stock, .symbol = "AAPL", .account = "Roth", .security_type = .stock, .delta_shares = 10, .unit_value = 150 },
.{ .kind = .new_cash, .symbol = "CASH", .account = "Roth", .security_type = .cash, .delta_shares = 2000, .unit_value = 1 },
.{ .kind = .new_drip_lot, .symbol = "VTI", .account = "Roth", .security_type = .stock, .delta_shares = 2, .unit_value = 200 },
.{ .kind = .drip_confirmed, .symbol = "SCHD", .account = "Roth", .security_type = .stock, .delta_shares = 1, .unit_value = 75 },
.{ .kind = .rollup_delta, .symbol = "VOO", .account = "Brokerage", .security_type = .stock, .delta_shares = 0.5, .unit_value = 400 },
.{ .kind = .cd_matured, .symbol = "CD1", .account = "CD Acct", .security_type = .cd, .face_value = 10000, .maturity_date = Date.fromYmd(2026, 5, 1) },
.{ .kind = .cash_delta, .symbol = "CASH", .account = "CD Acct", .security_type = .cash, .delta_shares = 10500, .unit_value = 1 },
.{ .kind = .cash_contribution, .symbol = "CASH", .account = "HSA", .security_type = .cash, .delta_shares = 300, .unit_value = 1 },
.{ .kind = .transfer_in, .symbol = "VTI", .account = "Acct B", .security_type = .stock, .delta_shares = 5, .unit_value = 100, .transfer_attributed = 500, .transfer_from = "Acct A", .transfer_date = Date.fromYmd(2026, 5, 2), .transfer_note = "rollover" },
.{ .kind = .transfer_out, .symbol = "", .account = "Acct A", .security_type = .cash, .transfer_attributed = 500, .transfer_date = Date.fromYmd(2026, 5, 2) },
.{ .kind = .partial_transfer_in, .symbol = "SPY", .account = "Acct B", .security_type = .stock, .delta_shares = 10, .unit_value = 100, .transfer_attributed = 600, .transfer_from = "Acct A", .transfer_date = Date.fromYmd(2026, 5, 2) },
.{ .kind = .price_only, .symbol = "VTI", .account = "Roth", .security_type = .stock, .old_price = 100, .new_price = 110 },
.{ .kind = .lot_edited, .symbol = "BND", .account = "Trust", .security_type = .stock },
.{ .kind = .flagged, .symbol = "XYZ", .account = "Roth", .security_type = .stock, .detail = "manual edit" },
.{ .kind = .lot_removed, .symbol = "OLD", .account = "Brokerage", .security_type = .stock, .face_value = 2500 },
.{ .kind = .drip_negative, .symbol = "ABC", .account = "Roth", .security_type = .stock, .delta_shares = -1, .unit_value = 50 },
.{ .kind = .unmatched_transfer, .symbol = "", .account = "Acct C", .security_type = .cash, .transfer_attributed = 750, .transfer_from = "Acct D", .transfer_date = Date.fromYmd(2026, 5, 3), .transfer_note = "unmatched wire" },
};
var account_totals = std.StringHashMap(Report.AccountTotal).init(arena);
try account_totals.put("Roth", .{ .new_money = 3800, .drip_confirmed = 475, .rollup = 0, .cash_delta = 0 });
try account_totals.put("CD Acct", .{ .cash_delta = 10500 });
try account_totals.put("", .{}); // exercises "(no account)" label + all-zero summary cells
const report = Report{
.changes = changes[0..],
.account_totals = account_totals,
};
var buf: [16384]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try printReport(&w, &report, "portfolio.srf (+1 more)", false);
const out = w.buffered();
// Section headers.
try testing.expect(std.mem.indexOf(u8, out, "Portfolio contributions report") != null);
try testing.expect(std.mem.indexOf(u8, out, "== New contributions / purchases ==") != null);
try testing.expect(std.mem.indexOf(u8, out, "== DRIP (confirmed") != null);
try testing.expect(std.mem.indexOf(u8, out, "== Rollup share deltas") != null);
try testing.expect(std.mem.indexOf(u8, out, "== CD events ==") != null);
try testing.expect(std.mem.indexOf(u8, out, "== Cash deltas") != null);
try testing.expect(std.mem.indexOf(u8, out, "== Transfers (matched") != null);
try testing.expect(std.mem.indexOf(u8, out, "== Price-only updates") != null);
try testing.expect(std.mem.indexOf(u8, out, "== Lot edits") != null);
try testing.expect(std.mem.indexOf(u8, out, "== Flagged for review ==") != null);
try testing.expect(std.mem.indexOf(u8, out, "== Summary by account ==") != null);
try testing.expect(std.mem.indexOf(u8, out, "Grand total:") != null);
// Sub-printer content.
try testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); // printChangeLine
try testing.expect(std.mem.indexOf(u8, out, "matured") != null); // printCdLine
try testing.expect(std.mem.indexOf(u8, out, "implied interest") != null); // CD interest line
try testing.expect(std.mem.indexOf(u8, out, "Implied interest captured") != null);
try testing.expect(std.mem.indexOf(u8, out, "Acct A -> Acct B") != null); // printTransferLine
try testing.expect(std.mem.indexOf(u8, out, "rollover") != null); // transfer note
try testing.expect(std.mem.indexOf(u8, out, "rest from transfer") != null); // printPartialTransferLine
try testing.expect(std.mem.indexOf(u8, out, "price ") != null); // printPriceOnlyLine
try testing.expect(std.mem.indexOf(u8, out, "manual edit") != null); // printFlaggedLine flagged
try testing.expect(std.mem.indexOf(u8, out, "sold at mark") != null); // printCollapsedSales, no close_price
try testing.expect(std.mem.indexOf(u8, out, "Transfer 2026-05-03") != null); // printUnmatchedTransferLine
try testing.expect(std.mem.indexOf(u8, out, "unmatched wire") != null);
try testing.expect(std.mem.indexOf(u8, out, "(no account)") != null); // summary no-account label
try testing.expect(std.mem.indexOf(u8, out, "may include CD maturity") != null); // printCashDeltaLine hint
// Color path: a second render with color=true emits ANSI escapes.
var cbuf: [16384]u8 = undefined;
var cw: std.Io.Writer = .fixed(&cbuf);
try printReport(&cw, &report, "portfolio.srf", true);
try testing.expect(std.mem.indexOf(u8, cw.buffered(), "\x1b[") != null);
}
test "collectUnmatchedLargeLots: a large cash_contribution surfaces, undated, alongside the rest" {
// Regression. A positive balance change on a `cash_is_contribution`
// account is reclassified to `cash_contribution`, which carries no
// open_date. That returned error.MissingOpenDate, the caller turned
// it into "no section", and audit's entire "Large new lots" list -
// including the unrelated stock lot below - silently vanished.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
var am = try analysis.parseAccountsFile(
allocator,
"#!srfv1\naccount::Sample IRA,tax_type::traditional,cash_is_contribution:bool:true\n",
);
const before = [_]Lot{
.{ .symbol = "cash", .shares = 10_000, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" },
};
const after = [_]Lot{
.{ .symbol = "cash", .shares = 60_000, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" },
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2026, 5, 10), .open_price = 250.0, .account = "Sample IRA" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 11), .{ .account_map = &am });
// Precondition: the balance change really was reclassified.
var saw_contribution = false;
for (report.changes) |c| {
if (c.kind == .cash_contribution) saw_contribution = true;
}
try std.testing.expect(saw_contribution);
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 2), lots.len);
var cash_seen = false;
var stock_seen = false;
for (lots) |l| {
switch (l.security_type) {
.cash => {
cash_seen = true;
try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), l.value, 0.01);
try std.testing.expect(l.open_date.eql(Date.epoch));
},
.stock => stock_seen = true,
else => {},
}
}
try std.testing.expect(cash_seen and stock_seen);
}