zfin/src/commands/contributions.zig

4462 lines
188 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 in the window 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.
//!
//! 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 analysis = @import("../analytics/analysis.zig");
const transaction_log = @import("../models/transaction_log.zig");
const fmt = cli.fmt;
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 fn run(
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,
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) {
try 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, .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,
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") catch {},
error.GitUnavailable => cli.stderrPrint(io, "Error: could not run 'git'. Is git installed and on PATH?\n") catch {},
else => cli.stderrPrint(io, "Error locating git repo.\n") catch {},
}
}
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") catch {};
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") catch {};
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) catch {};
}
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) catch {};
}
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") catch {};
return error.PrepareFailed;
};
var before_pf = zfin.cache.deserializePortfolio(allocator, before) catch {
if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing before-snapshot portfolio.\n") catch {};
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") catch {};
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, &.{}, false, 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 (if present) so the matchTransfers
// pass can reclassify destination/source Changes. Absent file →
// no-op. Derive window endpoints from the committer-date of the
// before/after revs; fall back to today when the after side is
// working-copy or when the git timestamp lookup fails (failure
// silently degrades to an unbounded-above window).
var transfer_log_opt = svc.loadTransferLog(portfolio_path);
defer if (transfer_log_opt) |*tl| tl.deinit();
const window_start: ?Date = blk: {
const ts = git.commitTimestamp(io, arena, repo.root, endpoints.range.before_rev) catch break :blk null;
break :blk Date.fromEpoch(ts);
};
const window_end: ?Date = blk: {
if (endpoints.range.after_rev) |rev| {
const ts = git.commitTimestamp(io, arena, repo.root, rev) catch break :blk as_of;
break :blk Date.fromEpoch(ts);
} else {
break :blk as_of;
}
};
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 = if (transfer_log_opt) |*tl| tl else null,
.window_start = window_start,
.window_end = window_end,
},
) catch {
if (verbosity == .verbose) cli.stderrPrint(io, "Error computing contributions diff.\n") catch {};
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";
try cli.stderrPrint(io, msg);
},
error.InvalidArg => {
try cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n");
},
else => {
try cli.stderrPrint(io, "Error resolving commit range: ");
try cli.stderrPrint(io, @errorName(err));
try 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)) {
try 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) catch {};
}
/// 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,
) ?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, .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,
) ?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, .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.
///
/// 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;
if (c.value() < 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 = c.value(),
.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.value(),
.new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(),
.partial_transfer_in => new_contributions += c.attributedValue(),
else => {},
};
// Subtract cash-dest transfer attribution. The matcher populates
// `cash_attributed_by_account` with the per-account total of cash
// transfers; those amounts already flowed into `new_contributions`
// above via their `new_cash` / `cash_contribution` Change and need
// to be taken back out. Single-summed subtraction is safe because
// the matcher's budget check guarantees the bucket doesn't exceed
// the pool of positive cash Changes on each account.
var cait = ctx.report.cash_attributed_by_account.iterator();
while (cait.next()) |entry| {
new_contributions -= entry.value_ptr.*;
}
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. 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,
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 log from `transaction_log.srf`. When present, the
/// `matchTransfers` pass 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, `matchTransfers` is a no-op.
transfer_log: ?*const transaction_log.TransactionLog = null,
/// Window start — inclusive. Only transfer records with
/// `transfer >= window_start` are considered. Null when
/// `transfer_log` is null.
window_start: ?Date = null,
/// Window end — inclusive. Only transfer records with
/// `transfer <= window_end` are considered. Null when
/// `transfer_log` is null.
window_end: ?Date = 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 in `transaction_log.srf` that match
// the window, and accumulate per-account cash attribution so
// transferred cash doesn't double-count in per-account totals.
// No-op when no transfer_log is supplied. See `matchTransfers`
// docstring for the matching algorithm.
var cash_attributed_by_account: std.StringHashMap(f64) = .init(allocator);
if (opts.transfer_log) |tlog| {
try matchTransfers(
allocator,
&changes,
&cash_attributed_by_account,
tlog,
opts.window_start,
opts.window_end,
);
}
// 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 => {
gop.value_ptr.new_money += c.value();
},
.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.value();
},
.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 => {},
}
}
// Subtract cash-dest transfer attribution from each account's
// cash-side totals so transferred cash doesn't inflate
// new_money / cash_delta / cash_contribution. The bucket was
// populated by the cash-dest matcher; each account's entry is
// the sum of matched record amounts landing on that account.
//
// Apply to new_money (new_cash contributed there) and
// cash_delta (pooled unclassified); we don't have visibility
// here into WHICH of those buckets held the attributed dollars,
// so take from new_money first (where cash_contribution lands)
// then cash_delta. This matches how `summarizeAttribution`
// handles the same subtraction at the totals level.
var cait = cash_attributed_by_account.iterator();
while (cait.next()) |entry| {
const acct = entry.key_ptr.*;
var remaining = entry.value_ptr.*;
if (acct_totals.getPtr(acct)) |at| {
const from_new_money = @min(at.new_money, remaining);
at.new_money -= from_new_money;
remaining -= from_new_money;
if (remaining > 0) {
const from_cash_delta = @min(at.cash_delta, remaining);
at.cash_delta -= from_cash_delta;
remaining -= from_cash_delta;
}
// Any leftover `remaining` means the attribution bucket
// exceeded both buckets — shouldn't happen if the
// matcher's budget check holds, but harmless if it does
// (the overage just doesn't land anywhere negative).
}
}
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;
/// 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 silently skipped — they belong to a
/// different diff. When either window endpoint is null, that side
/// is unbounded.
///
/// 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),
tlog: *const transaction_log.TransactionLog,
window_start: ?Date,
window_end: ?Date,
) !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 file order.
const records = try tlog.transfersInWindow(
allocator,
window_start orelse Date{ .days = std.math.minInt(i32) },
window_end orelse Date{ .days = std.math.maxInt(i32) },
);
defer allocator.free(records);
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;
// Append a synthetic transfer_in Change for Transfers-section
// display. We deliberately do NOT flip an existing cash
// Change's kind: a single cash_delta can be drained by multiple
// records, and the kind field can only hold one classification.
// Keeping the original cash Change intact preserves its
// appearance in the "Cash deltas" section of the report; the
// per-account totals reconciliation handles the attribution
// math via `cash_attributed_by_account`.
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 => {
any = true;
new_total += c.value();
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 ────────────────────────────────────────────────────
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 = "Riley IRA", .drip = true },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.new_drip_lot, report.changes[0].kind);
try std.testing.expectApproxEqAbs(@as(f64, 110.0), report.changes[0].value(), 0.01);
}
test "computeReport: new stock lot without drip flag is new_stock" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2026, 4, 10), .open_price = 180, .account = "Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind);
}
test "computeReport: drip::true existing lot with shares increase is drip_confirmed" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("FAGIX", 11.00);
const before = [_]Lot{
.{ .symbol = "FAGIX", .shares = 100, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "Riley IRA", .drip = true },
};
const after = [_]Lot{
.{ .symbol = "FAGIX", .shares = 105, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "Riley IRA", .drip = true },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.drip_confirmed, report.changes[0].kind);
try std.testing.expectApproxEqAbs(@as(f64, 55.0), report.changes[0].value(), 0.01);
}
test "computeReport: per-account totals separate drip_confirmed from rollup" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("FAGIX", 11.00);
try prices.put("VBTLX", 9.79);
const before = [_]Lot{
.{ .symbol = "FAGIX", .shares = 100, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "AcctA", .drip = true },
.{ .symbol = "VBTLX", .shares = 1000, .open_date = Date.fromYmd(2026, 2, 1), .open_price = 9.79, .account = "AcctA" },
};
const after = [_]Lot{
.{ .symbol = "FAGIX", .shares = 110, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "AcctA", .drip = true },
.{ .symbol = "VBTLX", .shares = 1020, .open_date = Date.fromYmd(2026, 2, 1), .open_price = 9.79, .account = "AcctA" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});
const t = report.account_totals.get("AcctA") orelse {
try std.testing.expect(false);
return;
};
// drip_confirmed: 10 * 11 = 110
try std.testing.expectApproxEqAbs(@as(f64, 110.0), t.drip_confirmed, 0.01);
// rollup: 20 * 9.79 = 195.8
try std.testing.expectApproxEqAbs(@as(f64, 195.8), t.rollup, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0), t.new_money, 0.01);
}
// ── resolveEndpoints tests ───────────────────────────────────
//
// Only the legacy (no-flags) and --since-only branches that don't
// shell out to git can be unit-tested cheaply. The full flag paths
// (`--since`, `--since`+`--until`) depend on `git log --until=<DATE>`,
// which requires a real repo and is covered by `src/git.zig` tests
// plus manual smoke-testing.
test "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 "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" },
};
var 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,
.window_start = Date.fromYmd(2026, 1, 1),
.window_end = Date.fromYmd(2026, 12, 31),
});
// 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" },
};
var 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,
.window_start = Date.fromYmd(2026, 1, 1),
.window_end = Date.fromYmd(2026, 12, 31),
});
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" },
};
var 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,
.window_start = Date.fromYmd(2026, 1, 1),
.window_end = Date.fromYmd(2026, 12, 31),
});
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" },
};
var 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,
.window_start = Date.fromYmd(2026, 1, 1),
.window_end = Date.fromYmd(2026, 12, 31),
});
// 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".
var 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,
.window_start = Date.fromYmd(2026, 1, 1),
.window_end = Date.fromYmd(2026, 12, 31),
});
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{};
var 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,
.window_start = Date.fromYmd(2026, 1, 1),
.window_end = Date.fromYmd(2026, 12, 31),
});
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.
var 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,
.window_start = Date.fromYmd(2026, 1, 1),
.window_end = Date.fromYmd(2026, 12, 31),
});
// 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" },
};
var 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,
.window_start = Date.fromYmd(2026, 1, 1),
.window_end = Date.fromYmd(2026, 12, 31),
});
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).
var 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,
.window_start = Date.fromYmd(2026, 1, 1),
.window_end = Date.fromYmd(2026, 12, 31),
});
// 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" },
};
var 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,
.window_start = Date.fromYmd(2026, 1, 1),
.window_end = Date.fromYmd(2026, 12, 31),
});
// 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: transfer outside window is ignored" {
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" },
};
// Record is before the window start — should be silently skipped.
var 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,
.window_start = Date.fromYmd(2026, 4, 1),
.window_end = Date.fromYmd(2026, 12, 31),
});
// Record skipped → lot stays new_stock, no unmatched.
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
try std.testing.expectEqual(ChangeKind.new_stock, 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" },
};
var 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,
.window_start = Date.fromYmd(2026, 1, 1),
.window_end = Date.fromYmd(2026, 12, 31),
});
// 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.
var 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,
.window_start = Date.fromYmd(2026, 1, 1),
.window_end = Date.fromYmd(2026, 12, 31),
});
// 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: 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" },
};
var 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,
.window_start = Date.fromYmd(2026, 1, 1),
.window_end = Date.fromYmd(2026, 12, 31),
});
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);
}