zfin/src/commands/contributions.zig

1392 lines
58 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.
//!
//! Classifies each lot-level change as:
//! - New contribution (new lot, or cash increase for a fresh cash line)
//! - DRIP / reinvestment (same (symbol, account, open_date, open_price), Δshares)
//! - CD matured (CD removed with maturity_date <= today)
//! - CD removed early (CD removed with maturity_date > today — flagged)
//! - Cash delta (cash shares changed on existing line)
//! - Price-only update (manual price:: field changed, no share change)
//! - Flagged (open_price, maturity, account or other edits)
//!
//! 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 fmt = cli.fmt;
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(
allocator: std.mem.Allocator,
svc: *zfin.DataService,
portfolio_path: []const u8,
since: ?Date,
until: ?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 "--until without --since is ambiguous" rule at the
// entry point so `resolveEndpoints`/`git.resolveCommitRange` can
// assume the invariant.
if (since == null and until != null) {
try cli.stderrPrint("Error: --until requires --since. Use `contributions --since <DATE>` or both.\n");
return;
}
var ctx = prepareReport(allocator, arena, svc, portfolio_path, since, until, 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 `computeAttribution`
/// 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(
allocator: std.mem.Allocator,
arena: std.mem.Allocator,
svc: *zfin.DataService,
portfolio_path: []const u8,
since: ?Date,
until: ?Date,
color: bool,
verbosity: Verbosity,
) PrepareError!ReportContext {
const repo = git.findRepo(arena, portfolio_path) catch |err| {
if (verbosity == .verbose) {
switch (err) {
error.NotInGitRepo => cli.stderrPrint("Error: contributions requires portfolio.srf to be in a git repo.\n") catch {},
error.GitUnavailable => cli.stderrPrint("Error: could not run 'git'. Is git installed and on PATH?\n") catch {},
else => cli.stderrPrint("Error locating git repo.\n") catch {},
}
}
return error.PrepareFailed;
};
const status = git.pathStatus(arena, repo.root, repo.rel_path) catch {
if (verbosity == .verbose) cli.stderrPrint("Error: could not determine git status of portfolio.srf.\n") catch {};
return error.PrepareFailed;
};
if (status == .untracked) {
if (verbosity == .verbose) cli.stderrPrint("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(arena, repo, since, until, 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(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(msg) catch {};
}
return error.PrepareFailed;
};
const after = if (endpoints.range.after_rev) |rev|
git.show(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(msg) catch {};
}
return error.PrepareFailed;
}
else
std.fs.cwd().readFileAlloc(arena, portfolio_path, 10 * 1024 * 1024) catch {
if (verbosity == .verbose) cli.stderrPrint("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("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("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(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;
}
}
const report = computeReport(arena, before_pf.lots, after_pf.lots, &prices, fmt.todayDate()) catch {
if (verbosity == .verbose) cli.stderrPrint("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 `computeAttribution` 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(
arena: std.mem.Allocator,
repo: git.RepoInfo,
since: ?Date,
until: ?Date,
dirty: bool,
verbosity: Verbosity,
) !Endpoints {
const range = git.resolveCommitRange(arena, repo, since, until, dirty) catch |err| {
if (verbosity == .verbose) {
switch (err) {
error.NoCommitAtOrBefore => {
// Report which flag triggered it. When both are set
// we can't easily tell from here; message covers both.
var since_buf: [10]u8 = undefined;
const since_str = if (since) |s| s.format(&since_buf) else "(unset)";
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, since_str }) catch "Error: no commit at or before requested date.\n";
try cli.stderrPrint(msg);
},
else => {
try cli.stderrPrint("Error resolving commit range: ");
try cli.stderrPrint(@errorName(err));
try cli.stderrPrint("\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 buildLabel(arena, range, since, until, 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 (since != null and until != null and verbosity == .verbose) {
if (range.after_rev) |after_rev| {
if (std.mem.eql(u8, range.before_rev, after_rev)) {
try cli.stderrPrint("Warning: --since and --until resolve to the same commit; no changes to report.\n");
}
}
}
return .{ .range = range, .label = label };
}
/// 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 = since.?.format(&since_buf);
if (until) |until_date| {
var until_buf: [10]u8 = undefined;
const until_str = until_date.format(&until_buf);
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 date 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.
pub fn computeAttribution(
allocator: std.mem.Allocator,
svc: *zfin.DataService,
portfolio_path: []const u8,
since: ?Date,
until: ?Date,
color: bool,
) ?AttributionSummary {
// `--until` without `--since` is ambiguous; caller is expected to
// enforce this at the entry point, but guard here too — the
// prepareReport path sends it through `git.resolveCommitRange`
// which asserts the invariant.
if (since == null and until != null) return null;
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var ctx = prepareReport(allocator, arena, svc, portfolio_path, since, until, color, .silent) catch return null;
defer ctx.deinit();
// Aggregate. Classification logic matches the full-report sections:
// - New contributions: new_stock + new_cash + new_cd + new_option
// - 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.
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 => new_contributions += c.value(),
.new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(),
else => {},
};
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
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
price_only, // same key, price:: field changed, no share change
flagged, // any other shape of edit
};
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,
pub fn value(self: Change) f64 {
return self.delta_shares * self.unit_value;
}
};
/// Summary aggregated for the report. All string fields and backing memory
/// live in the caller-supplied arena; there is no explicit deinit.
const Report = struct {
changes: []Change,
/// Per-account rollups for the summary section.
account_totals: std.StringHashMap(AccountTotal),
const AccountTotal = struct {
new_money: f64 = 0, // stock+cd+cash new lots (drip::false)
drip_confirmed: f64 = 0, // confirmed DRIP (drip::true lots: new or shares increased)
rollup: f64 = 0, // share deltas on drip::false aggregate lots (DRIP or contribution; ambiguous)
cd_interest: f64 = 0, // implied interest from matured CDs
cash_delta: f64 = 0, // unclassified cash balance changes
};
};
/// Build a canonical lookup key for matching lots between snapshots.
/// Key: (security_type, symbol, account, open_date, open_price).
fn lotKey(allocator: std.mem.Allocator, lot: Lot) ![]u8 {
var open_date_buf: [10]u8 = undefined;
return std.fmt.allocPrint(allocator, "{s}|{s}|{s}|{s}|{d:.6}", .{
@tagName(lot.security_type),
lot.symbol,
lot.account orelse "",
lot.open_date.format(&open_date_buf),
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;
}
fn computeReport(
allocator: std.mem.Allocator,
before: []const Lot,
after: []const Lot,
prices: *const std.StringHashMap(f64),
today: Date,
) !Report {
var changes: std.ArrayList(Change) = .empty;
var before_map = try aggregateByKey(allocator, before);
var after_map = try aggregateByKey(allocator, after);
// 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| {
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) {
const before_lot = before_agg.lot;
const is_drip = lot.drip or before_lot.drip;
const 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,
};
// 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| d.format(&old_buf) else "(none)";
const new_str = if (lot.maturity_date) |d| d.format(&new_buf) 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,
});
}
}
// 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;
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 <= today (i.e. NOT today.lessThan(mat))
if (!today.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,
});
}
// 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 => {
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).
},
else => {},
}
}
return .{
.changes = try changes.toOwnedSlice(allocator),
.account_totals = acct_totals,
};
}
// ── 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 => {
any = true;
new_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", 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: 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: Flagged ──
var any_flag = false;
for (report.changes) |c| switch (c.kind) {
.flagged, .lot_removed, .drip_negative => 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);
},
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", .{});
var buf1: [32]u8 = undefined;
var buf2: [32]u8 = undefined;
var buf3: [32]u8 = undefined;
var buf4: [32]u8 = undefined;
try out.print(" New contributions / purchases: {s}\n", .{fmt.fmtMoneyAbs(&buf1, total_new)});
try out.print(" DRIP (confirmed): {s}\n", .{fmt.fmtMoneyAbs(&buf2, total_drip)});
try out.print(" Rollup share deltas: {s} (DRIP or contribution; can't distinguish)\n", .{fmt.fmtMoneyAbs(&buf3, total_rollup)});
if (total_cd_int > 0) {
try out.print(" CD interest captured: {s}\n", .{fmt.fmtMoneyAbs(&buf4, total_cd_int)});
}
}
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 {
var buf: [32]u8 = undefined;
try cli.printFg(out, color, hdr, " {s}: {s}\n", .{ label, fmt.fmtMoneyAbs(&buf, 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 = fmt.fmtMoneyAbs(&price_buf, c.unit_value);
const val_str = fmt.fmtMoneyAbs(&val_buf, c.value());
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 face_buf: [32]u8 = undefined;
var mat_buf: [10]u8 = undefined;
const mat_str = if (c.maturity_date) |d| d.format(&mat_buf) 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 {s} maturity {s}\n", .{
c.symbol,
acct,
verb,
fmt.fmtMoneyAbs(&face_buf, c.face_value),
mat_str,
});
if (implied_interest) |i| {
var int_buf: [32]u8 = undefined;
try cli.printFg(out, color, cli.CLR_POSITIVE, " {s:<14}{s:<24} implied interest: {s}\n", .{ "", "", fmt.fmtMoneyAbs(&int_buf, i) });
}
}
fn printCashDeltaLine(out: *std.Io.Writer, c: Change, report: *const Report, color: bool) !void {
var val_buf: [32]u8 = undefined;
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}{s}", .{ sign, fmt.fmtMoneyAbs(&val_buf, @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)) {
var face_buf: [32]u8 = undefined;
try cli.printFg(out, color, cli.CLR_MUTED, " (may include CD maturity of {s})", .{fmt.fmtMoneyAbs(&face_buf, o.face_value)});
break;
}
}
try out.writeAll("\n");
}
fn printPriceOnlyLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void {
var old_buf: [32]u8 = undefined;
var new_buf: [32]u8 = undefined;
const acct = if (c.account.len == 0) "(no account)" else c.account;
try cli.printFg(out, color, muted, " {s:<14}{s:<24} price {s} → {s}\n", .{
c.symbol,
acct,
fmt.fmtMoneyAbs(&old_buf, c.old_price),
fmt.fmtMoneyAbs(&new_buf, 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 => {
var face_buf: [32]u8 = undefined;
try out.print(" {s:<14}{s:<24} {s} lot removed (face {s})", .{
c.symbol, acct, @tagName(c.security_type), fmt.fmtMoneyAbs(&face_buf, c.face_value),
});
},
.drip_negative => {
var val_buf: [32]u8 = undefined;
try out.print(" {s:<14}{s:<24} shares decreased on existing lot ({s})", .{
c.symbol, acct, fmt.fmtMoneyAbs(&val_buf, @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 {
var buf: [32]u8 = undefined;
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, "{s:>12}", .{fmt.fmtMoneyAbs(&buf, v)});
}
}
// ── 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: 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(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(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"));
}