zfin/src/commands/contributions.zig

5031 lines
213 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.)
//!
//! ### 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 |
//! | Record with no match (bad dest, in_kind, …) | `unmatched_transfer` | Flagged | no | no |
//!
//! 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 records the attributed amount in
//! `Report.cash_attributed_by_account`, which the per-account totals
//! and attribution summary subtract from cash-side contributions.
//!
//! ### 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 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> commit-at-or-before(DATE) vs HEAD (or
\\ working copy when dirty)
\\ --since <D1> --until <D2> commit-at-or-before(D1) vs
\\ commit-at-or-before(D2)
\\ --until <DATE> alone rejected; window is ambiguous
\\
\\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y).
\\
\\Options:
\\ --since <DATE> Earliest side (resolves to commit-at-
\\ or-before).
\\ --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, MissingOpenDate, 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.
var parsed: ParsedArgs = .{};
if (tr_result.range.before) |ep| switch (ep) {
.date => |d| parsed.before = .{ .date_at_or_before = d },
.commit_spec => |s| parsed.before = s,
.live => unreachable,
};
if (tr_result.range.after) |ep| switch (ep) {
.date => |d| parsed.after = .{ .date_at_or_before = d },
.commit_spec => |s| parsed.after = s,
.live => unreachable,
};
// Validate `--since on or before --until` ordering for the
// date-only form, matching legacy behavior. We can only check
// when BOTH sides are date_at_or_before (the commit-spec form
// is opaque until git resolves it).
if (parsed.before) |b| if (parsed.after) |a| {
if (b == .date_at_or_before and a == .date_at_or_before) {
if (b.date_at_or_before.days > a.date_at_or_before.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;
const pf = ctx.resolvePortfolioPath();
defer pf.deinit(allocator);
const portfolio_path = pf.path;
return runImpl(io, allocator, svc, portfolio_path, before, after, as_of, color, ctx.globals.refresh_policy, out);
}
fn runImpl(
io: std.Io,
allocator: std.mem.Allocator,
svc: *zfin.DataService,
portfolio_path: []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, svc, portfolio_path, 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` use the base allocator (their own
/// `deinit` frees internals).
/// - `endpoints`, `report`, and the snapshot blobs 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: zfin.Portfolio,
after_pf: zfin.Portfolio,
report: Report,
fn deinit(self: *ReportContext) void {
self.before_pf.deinit();
self.after_pf.deinit();
}
};
const PrepareError = error{PrepareFailed};
/// Run the common pipeline — resolve endpoints, read both blobs,
/// parse both portfolios, 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.
///
/// 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,
svc: *zfin.DataService,
portfolio_path: []const u8,
before_spec: ?git.CommitSpec,
after_spec: ?git.CommitSpec,
as_of: Date,
color: bool,
refresh: framework.RefreshPolicy,
verbosity: Verbosity,
) PrepareError!ReportContext {
const repo = git.findRepo(io, arena, 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;
};
const status = git.pathStatus(io, arena, repo.root, repo.rel_path) 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) cli.stderrPrint(io, "Error: portfolio.srf is not tracked in git. Add and commit it first.\n");
return error.PrepareFailed;
}
const dirty = status == .modified;
const endpoints = resolveEndpoints(io, arena, repo, before_spec, after_spec, dirty, verbosity) catch return error.PrepareFailed;
// Pull both sides: before is always from git; after is either
// from git (at some revision) or from the working copy.
const before = git.show(io, arena, repo.root, endpoints.range.before_rev, repo.rel_path) catch |err| {
if (verbosity == .verbose) {
var buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "Error reading {s}:portfolio.srf from git: {s}\n", .{ endpoints.range.before_rev, @errorName(err) }) catch "Error reading before-side portfolio.\n";
cli.stderrPrint(io, msg);
}
return error.PrepareFailed;
};
const after = if (endpoints.range.after_rev) |rev|
git.show(io, arena, repo.root, rev, repo.rel_path) catch |err| {
if (verbosity == .verbose) {
var buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "Error reading {s}:portfolio.srf from git: {s}\n", .{ rev, @errorName(err) }) catch "Error reading after-side portfolio.\n";
cli.stderrPrint(io, msg);
}
return error.PrepareFailed;
}
else
std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, arena, .limited(10 * 1024 * 1024)) catch {
if (verbosity == .verbose) cli.stderrPrint(io, "Error reading working-copy portfolio file.\n");
return error.PrepareFailed;
};
var before_pf = zfin.cache.deserializePortfolio(allocator, before) catch {
if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing before-snapshot portfolio.\n");
return error.PrepareFailed;
};
errdefer before_pf.deinit();
var after_pf = zfin.cache.deserializePortfolio(allocator, after) catch {
if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing after-snapshot portfolio.\n");
return error.PrepareFailed;
};
errdefer after_pf.deinit();
// 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.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.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(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, 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, 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.lots,
after_pf.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,
.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,
repo: git.RepoInfo,
before: ?git.CommitSpec,
after: ?git.CommitSpec,
dirty: bool,
verbosity: Verbosity,
) !Endpoints {
const range = git.resolveCommitRangeSpec(io, arena, repo, before, after, 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, after, 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 != null and after != 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, repo, before, range.before_rev, "before");
}
return .{ .range = range, .label = label };
}
/// 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 => |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,
repo: git.RepoInfo,
spec: ?git.CommitSpec,
resolved_ref: []const u8,
label: []const u8,
) !void {
const s = spec orelse return;
const requested_date = switch (s) {
.date_at_or_before => |d| d,
else => return,
};
// Get the committer-date of the resolved commit. `%ct` gives a
// Unix timestamp.
const ts = git.commitTimestamp(io, arena, 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} — " ++
"use --commit-{s} HEAD or a later date to pin to your latest reconciliation commit)\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);
}
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}),
.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,
pub fn total(self: AttributionSummary) f64 {
return self.new_contributions + self.drip;
}
};
/// 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,
svc: *zfin.DataService,
portfolio_path: []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, svc, portfolio_path, before, after, as_of, color, refresh, .silent) catch return null;
defer ctx.deinit();
return summarizeAttribution(ctx);
}
// ── Public audit hook ────────────────────────────────────────
/// 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 caller's 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) with `value() >= threshold` that 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.).
///
/// 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,
svc: *zfin.DataService,
portfolio_path: []const u8,
threshold: f64,
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, svc, portfolio_path, null, null, as_of, color, refresh, .silent) catch {
arena_state.deinit();
return null;
};
defer ctx.deinit();
const lots = collectUnmatchedLargeLots(arena, ctx.report.changes, threshold) catch {
arena_state.deinit();
return null;
};
return .{ .lots = lots, .arena = arena_state };
}
/// Pure filter: pick out new-side Changes with `value() >= 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 (a single cash delta can be drained by
/// multiple records, which the kind field can't represent). Instead
/// the matcher records the attributed amount in
/// `cash_attributed_by_account`. Subtract that here so a fully-
/// attributed cash lot doesn't re-surface as "unmatched large lot."
/// A partially-attributed cash lot surfaces only on the residual,
/// matching the user's mental model: "this much of the lot is
/// already documented, check the rest."
///
/// 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.
/// 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,
threshold: f64,
) ![]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;
// 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;
// open_date is populated in Pass 1's new-lot branch; absence
// here would be a pipeline bug.
const od = c.open_date orelse return error.MissingOpenDate;
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);
}
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`)
// cash-dest transfer totals
// (from `cash_attributed_by_account`)
// - 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(),
else => {},
};
// Note: cash-dest transfer attribution is already removed by
// `attributedValue()` on the per-Change side (matchCashDestination
// accumulates into `transfer_attributed`). No second subtraction
// off `cash_attributed_by_account` needed here.
return .{ .new_contributions = new_contributions, .drip = drip };
}
// ── 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
/// Strict lot key broke (open_date / open_price / account rewritten)
/// but the same (security_type, symbol, 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 account
/// renames that shift every lot in the account under a new name.
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 before-lot's shares.
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,
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. The residual is
/// `value() transfer_attributed`, which is what shows up in
/// "New contributions / purchases" and the audit large-lot
/// filter. Everyone else sees `value()` unchanged.
pub fn attributedValue(self: Change) f64 {
return switch (self.kind) {
.transfer_in, .transfer_out, .unmatched_transfer => 0,
.partial_transfer_in => self.value() - self.transfer_attributed,
.new_cash, .cash_contribution, .cash_delta => self.value() - self.transfer_attributed,
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),
/// Per-account cash amounts matched to transfer records. Subtracted
/// from cash-side totals in the per-account summary so transferred
/// cash doesn't double-count. Keys borrow from Change.account
/// strings (arena-owned, same lifetime as the Report).
cash_attributed_by_account: std.StringHashMap(f64),
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 "",
});
}
/// 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) {
if (prices.get(rep_lot.priceSymbol())) |p| break :blk p * rep_lot.price_ratio;
if (rep_lot.price) |p| break :blk p * rep_lot.price_ratio;
break :blk rep_lot.open_price * rep_lot.price_ratio;
}
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.
const unit_value: f64 = blk: {
if (lot.security_type == .stock) {
if (prices.get(lot.priceSymbol())) |p| break :blk p * lot.price_ratio;
if (lot.price) |p| break :blk p * lot.price_ratio;
break :blk lot.open_price * lot.price_ratio;
}
// 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. Check for price:: field or other metadata changes.
const before_lot = before_agg.lot;
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.
const unit_value: f64 = switch (lot.security_type) {
.stock => lot.open_price * lot.price_ratio,
.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
}
}
try changes.append(allocator, .{
.kind = kind,
.symbol = sym,
.account = acct,
.security_type = lot.security_type,
.face_value = before_agg.shares * (if (lot.security_type == .cd) lot.open_price else 1.0),
.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`), and accumulate per-account cash
// attribution so transferred cash doesn't double-count in
// per-account totals. No-op when no records are supplied. See
// `matchTransfers` docstring for the matching algorithm.
var cash_attributed_by_account: std.StringHashMap(f64) = .init(allocator);
if (opts.transfer_log) |records| {
try matchTransfers(
allocator,
&changes,
&cash_attributed_by_account,
records,
);
}
// 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 => {},
}
}
// Note: cash-dest transfer attribution is already removed by
// `attributedValue()` on each cash-side Change (the matcher
// accumulates into `transfer_attributed`). No second subtraction
// off `cash_attributed_by_account` needed here. The bucket is
// still populated for downstream consumers (e.g. callers that
// want a per-account view of attributed transfers) but isn't
// used in the totals math.
return .{
.changes = try changes.toOwnedSlice(allocator),
.account_totals = acct_totals,
.cash_attributed_by_account = cash_attributed_by_account,
};
}
// ── 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;
/// 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::cash` (v1 only; `in_kind` always
/// emits `unmatched_transfer`):
/// - 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 into
/// `cash_attributed_by_account[to]`, which the caller
/// subtracts from cash-side per-account totals. 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.
///
/// Populates `cash_attributed_by_account` (caller-owned) with the
/// per-account total of amounts matched to cash-destination records;
/// these amounts are subtracted from the cash bucket in the
/// per-account totals pass so transferred cash doesn't double-count.
fn matchTransfers(
allocator: std.mem.Allocator,
changes: *std.ArrayList(Change),
cash_attributed_by_account: *std.StringHashMap(f64),
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();
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) {
try appendUnmatched(allocator, changes, rec, "in-kind transfers not yet supported in v1");
continue;
}
switch (rec.dest_lot) {
.lot => |dl| {
try matchLotDestination(allocator, changes, &consumed_lot_idx, rec, dl);
},
.cash => {
try matchCashDestination(allocator, changes, &cash_budget, cash_attributed_by_account, rec);
},
}
tryMatchFromSide(changes, rec);
}
}
/// 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. The per-account attribution
/// bucket (`cash_attributed_by_account`) is what actually drives
/// totals math — the Change-level reclassification is for display.
fn matchCashDestination(
allocator: std.mem.Allocator,
changes: *std.ArrayList(Change),
cash_budget: *std.StringHashMap(f64),
cash_attributed_by_account: *std.StringHashMap(f64),
rec: transaction_log.TransferRecord,
) !void {
const budget_entry = cash_budget.getPtr(rec.to);
const available = if (budget_entry) |p| p.* else 0.0;
if (available < rec.amount - transfer_amount_tolerance) {
const buf = try std.fmt.allocPrint(
allocator,
"destination cash increase ${d:.2} insufficient for transfer ${d:.2}",
.{ available, rec.amount },
);
try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf);
return;
}
// 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.* -= rec.amount;
// Accumulate into per-account attribution bucket. The per-
// account totals pass subtracts this from cash-side totals so
// transferred cash doesn't double-count.
const gop = try cash_attributed_by_account.getOrPut(rec.to);
if (!gop.found_existing) gop.value_ptr.* = 0;
gop.value_ptr.* += rec.amount;
// 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?". The summary
// pass continues to use `cash_attributed_by_account` for its
// per-account math — the two views agree because the same
// amount is subtracted on both sides.
var remaining = rec.amount;
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 a negative cash_delta or lot_removed on the
/// `from` account with |value| >= amount tolerance; reclassify to
/// transfer_out. No-op (silent) if no such Change exists — the
/// sending side may not be in portfolio.srf.
fn tryMatchFromSide(
changes: *std.ArrayList(Change),
rec: transaction_log.TransferRecord,
) void {
for (changes.items) |*c| switch (c.kind) {
.cash_delta, .lot_removed => {
if (!std.mem.eql(u8, c.account, rec.from)) continue;
const abs_val = @abs(c.value());
if (abs_val < 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;
},
else => {},
};
}
// ── 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.
const residual = c.attributedValue();
if (residual <= 0) continue;
any = true;
new_total += residual;
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 ──
try printSection(out, "Cash deltas (raw balance changes)", color, h_color);
any = false;
for (report.changes) |c| switch (c.kind) {
.cash_delta => {
any = true;
try printCashDeltaLine(out, c, report, color);
},
else => {},
};
if (!any) try printNone(out, 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 => {
var buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(
&buf,
" {s: <12} {s: <24} (strict key broke, shares unchanged)\n",
.{ c.symbol, c.account },
) catch " (lot edit)\n";
try cli.printFg(out, color, mut_color, "{s}", .{msg});
},
else => {},
};
try out.writeAll("\n");
}
// ── Section: Flagged ──
var any_flag = false;
for (report.changes) |c| switch (c.kind) {
.flagged, .lot_removed, .drip_negative, .unmatched_transfer => any_flag = true,
else => {},
};
if (any_flag) {
try printSection(out, "Flagged for review", color, h_color);
for (report.changes) |c| switch (c.kind) {
.flagged, .lot_removed, .drip_negative => {
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;
try out.print(" {s:<28}", .{acct_label});
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)});
}
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", .{});
}
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 out.print(" {s:<14}{s:<24}", .{ 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 out.print(" {s:<14}{s:<24} {s:<16} face {f} maturity {s}\n", .{
c.symbol,
acct,
verb,
Money.from(c.face_value),
mat_str,
});
if (implied_interest) |i| {
try cli.printFg(out, color, cli.CLR_POSITIVE, " {s:<14}{s:<24} 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 out.print(" {s:<14}{s:<24} cash ", .{ c.symbol, acct });
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.printFg(out, color, muted, " {s:<14}{s:<24} price {f} → {f}\n", .{
c.symbol,
acct,
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 out.print(" {s:<14}{s:<24} {s}", .{ c.symbol, acct, c.detail orelse "edited" });
},
.lot_removed => {
try out.print(" {s:<14}{s:<24} {s} lot removed (face {f})", .{
c.symbol, acct, @tagName(c.security_type), Money.from(c.face_value),
});
},
.drip_negative => {
try out.print(" {s:<14}{s:<24} shares decreased on existing lot ({f})", .{
c.symbol, acct, 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 out.print(" {s:<14}{s:<24}", .{ 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)},
);
}
// ── 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 date_at_or_before" {
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.?) {
.date_at_or_before => |d| try testing.expect(d.eql(zfin.Date.fromYmd(2026, 4, 1))),
else => try testing.expect(false),
}
}
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 = "Emil 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);
}
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);
}
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.
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 = "Kelly 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 = "Kelly IRA", .drip = true },
};
const after = [_]Lot{
.{ .symbol = "FAGIX", .shares = 105, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "Kelly 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 "resolveEndpoints: legacy dirty → HEAD vs working copy" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const repo: git.RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" };
const eps = try resolveEndpoints(std.testing.io, arena_state.allocator(), repo, 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();
const repo: git.RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" };
const eps = try resolveEndpoints(std.testing.io, arena_state.allocator(), repo, 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
// `cash_attributed_by_account` carries the $5k subtraction for
// attribution math.
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: cash insufficient 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();
// 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,
});
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);
// new_cash stays unchanged; $3k still counts as new_money.
const t = report.account_totals.get("Acct B").?;
try std.testing.expectApproxEqAbs(@as(f64, 3000.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 "matchTransfers: type::in_kind always 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);
// Even if a matching lot exists, in_kind is rejected in v1.
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::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,
});
// new_stock stays as new_stock (not flipped); unmatched 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: 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.
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.value(),
.new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(),
.partial_transfer_in => new_contributions += c.attributedValue(),
else => {},
};
var cait = report.cash_attributed_by_account.iterator();
while (cait.next()) |entry| new_contributions -= entry.value_ptr.*;
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, 10_000.0);
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, 10_000.0);
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: 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, 10_000.0);
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, 10_000.0);
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
// Joint 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 (it draws from `cash_attributed_by_account` 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 = "Joint trust" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-20,type::cash,amount:num:73158.33,from::Fidelity Emil,to::Joint 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
// `cash_attributed_by_account` 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);
const attributed = report.cash_attributed_by_account.get("Joint trust") orelse 0;
try std.testing.expectEqual(@as(f64, 73158.33), attributed);
// The audit filter must subtract the attribution and stay quiet.
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
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 = "Joint trust" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-20,type::cash,amount:num:30000,from::Fidelity Emil,to::Joint 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, 10_000.0);
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 = "Joint trust" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-20,type::cash,amount:num:10000,from::Fidelity Emil,to::Joint 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, 10_000.0);
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, 10_000.0);
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, 10_000.0);
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);
}