//! `zfin contributions` — show money added to the portfolio since the //! last recorded state in git. //! //! Compares two snapshots of portfolio.srf: //! - dirty working tree: HEAD vs working copy (default case) //! - clean working tree: HEAD~1 vs HEAD (review last commit) //! //! 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 ─────────────────────────────────────── pub fn run( allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, 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(); // 1. Figure out the git repo and the portfolio's path inside it. const repo = git.findRepo(arena, portfolio_path) catch |err| { switch (err) { error.NotInGitRepo => try cli.stderrPrint("Error: contributions requires portfolio.srf to be in a git repo.\n"), error.GitUnavailable => try cli.stderrPrint("Error: could not run 'git'. Is git installed and on PATH?\n"), else => try cli.stderrPrint("Error locating git repo.\n"), } return; }; // 2. Decide which snapshots to compare. const status = try git.pathStatus(arena, repo.root, repo.rel_path); if (status == .untracked) { try cli.stderrPrint("Error: portfolio.srf is not tracked in git. Add and commit it first.\n"); return; } const dirty = status == .modified; // 3. Pull both snapshots. const before = if (dirty) git.show(arena, repo.root, "HEAD", repo.rel_path) catch |err| { switch (err) { error.PathMissingInRev => try cli.stderrPrint("Error: portfolio.srf not present at HEAD.\n"), else => try cli.stderrPrint("Error reading HEAD:portfolio.srf from git.\n"), } return; } else git.show(arena, repo.root, "HEAD~1", repo.rel_path) catch |err| { switch (err) { error.PathMissingInRev => try cli.stderrPrint("Error: portfolio.srf not present at HEAD~1.\n"), error.UnknownRevision => try cli.stderrPrint("Error: no prior commit to compare against (HEAD~1 does not exist).\n"), else => try cli.stderrPrint("Error reading HEAD~1:portfolio.srf from git.\n"), } return; }; const after = if (dirty) std.fs.cwd().readFileAlloc(arena, portfolio_path, 10 * 1024 * 1024) catch { try cli.stderrPrint("Error reading working-copy portfolio file.\n"); return; } else git.show(arena, repo.root, "HEAD", repo.rel_path) catch { try cli.stderrPrint("Error reading HEAD:portfolio.srf from git.\n"); return; }; // 4. Parse both. Portfolio uses the base allocator; its own deinit frees // its internals independently of the arena. var before_pf = zfin.cache.deserializePortfolio(allocator, before) catch { try cli.stderrPrint("Error parsing before-snapshot portfolio.\n"); return; }; defer before_pf.deinit(); var after_pf = zfin.cache.deserializePortfolio(allocator, after) catch { try cli.stderrPrint("Error parsing after-snapshot portfolio.\n"); return; }; defer after_pf.deinit(); // 5. Fetch current prices (cache-hit preferred) for DRIP/share-delta valuation. var prices = std.StringHashMap(f64).init(arena); // Union of stock symbols from both snapshots. 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)) { try sym_set.put(l.priceSymbol(), {}); } } for (after_pf.lots) |l| { if (l.security_type == .stock and !(l.price != null and l.ticker == null)) { try sym_set.put(l.priceSymbol(), {}); } } var syms: std.ArrayList([]const u8) = .empty; var sit = sym_set.keyIterator(); while (sit.next()) |k| try syms.append(arena, k.*); 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| { try prices.put(entry.key_ptr.*, entry.value_ptr.*); } } // 6. Compute the diff and print the report. The Report's backing memory // lives in the arena; no explicit deinit needed. const report = try computeReport(arena, before_pf.lots, after_pf.lots, &prices, fmt.todayDate()); try printReport(out, &report, dirty, color); try out.flush(); } // ── 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, dirty: bool, 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.setFg(out, color, h_color); try out.writeAll("Portfolio contributions report\n"); try cli.reset(out, color); try cli.setFg(out, color, mut_color); if (dirty) { try out.writeAll(" Comparing working copy against HEAD\n\n"); } else { try out.writeAll(" Working tree clean — comparing HEAD~1 against HEAD\n\n"); } try cli.reset(out, color); // If nothing changed at all, say so explicitly and return. if (report.changes.len == 0) { try cli.setFg(out, color, mut_color); try out.writeAll(" No changes detected.\n"); try cli.reset(out, color); 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.setFg(out, color, h_color); try out.writeAll("Totals\n"); try cli.reset(out, color); 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.setFg(out, color, hdr); try out.writeAll("== "); try out.writeAll(title); try out.writeAll(" ==\n"); try cli.reset(out, color); } fn printNone(out: *std.Io.Writer, color: bool, muted: [3]u8) !void { try cli.setFg(out, color, muted); try out.writeAll(" (none)\n"); try cli.reset(out, color); } fn printTotalLine(out: *std.Io.Writer, label: []const u8, v: f64, color: bool, hdr: [3]u8) !void { var buf: [32]u8 = undefined; try cli.setFg(out, color, hdr); try out.print(" {s}: {s}\n", .{ label, fmt.fmtMoneyAbs(&buf, v) }); try cli.reset(out, color); } 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.setFg(out, color, pos); try out.print(" {s}", .{val_str}); try cli.reset(out, color); } else { try out.print(" {s} shares × {s} = ", .{ share_str, price_str }); try cli.setFg(out, color, pos); try out.print("{s}", .{val_str}); try cli.reset(out, color); } 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.setFg(out, color, cli.CLR_POSITIVE); try out.print(" {s:<14}{s:<24} implied interest: {s}\n", .{ "", "", fmt.fmtMoneyAbs(&int_buf, i) }); try cli.reset(out, color); } } 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.setGainLoss(out, color, v); try out.print("{s}{s}", .{ sign, fmt.fmtMoneyAbs(&val_buf, @abs(v)) }); try cli.reset(out, color); // 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.setFg(out, color, cli.CLR_MUTED); try out.print(" (may include CD maturity of {s})", .{fmt.fmtMoneyAbs(&face_buf, o.face_value)}); try cli.reset(out, color); 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.setFg(out, color, muted); try out.print(" {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), }); try cli.reset(out, color); } 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.setFg(out, color, cli.CLR_MUTED); try out.print("{s:>12}", .{"-"}); try cli.reset(out, color); } else { try cli.setFg(out, color, cli.CLR_POSITIVE); try out.print("{s:>12}", .{fmt.fmtMoneyAbs(&buf, v)}); try cli.reset(out, color); } } // ── 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 = "Kelly IRA", .drip = true }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18)); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.new_drip_lot, report.changes[0].kind); try std.testing.expectApproxEqAbs(@as(f64, 110.0), report.changes[0].value(), 0.01); } test "computeReport: new stock lot without drip flag is new_stock" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); const before = [_]Lot{}; const after = [_]Lot{ .{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2026, 4, 10), .open_price = 180, .account = "Brokerage" }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18)); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind); } test "computeReport: drip::true existing lot with shares increase is drip_confirmed" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("FAGIX", 11.00); const before = [_]Lot{ .{ .symbol = "FAGIX", .shares = 100, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "Kelly IRA", .drip = true }, }; const after = [_]Lot{ .{ .symbol = "FAGIX", .shares = 105, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "Kelly IRA", .drip = true }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18)); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.drip_confirmed, report.changes[0].kind); try std.testing.expectApproxEqAbs(@as(f64, 55.0), report.changes[0].value(), 0.01); } test "computeReport: per-account totals separate drip_confirmed from rollup" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("FAGIX", 11.00); try prices.put("VBTLX", 9.79); const before = [_]Lot{ .{ .symbol = "FAGIX", .shares = 100, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "AcctA", .drip = true }, .{ .symbol = "VBTLX", .shares = 1000, .open_date = Date.fromYmd(2026, 2, 1), .open_price = 9.79, .account = "AcctA" }, }; const after = [_]Lot{ .{ .symbol = "FAGIX", .shares = 110, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "AcctA", .drip = true }, .{ .symbol = "VBTLX", .shares = 1020, .open_date = Date.fromYmd(2026, 2, 1), .open_price = 9.79, .account = "AcctA" }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18)); const t = report.account_totals.get("AcctA") orelse { try std.testing.expect(false); return; }; // drip_confirmed: 10 * 11 = 110 try std.testing.expectApproxEqAbs(@as(f64, 110.0), t.drip_confirmed, 0.01); // rollup: 20 * 9.79 = 195.8 try std.testing.expectApproxEqAbs(@as(f64, 195.8), t.rollup, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0), t.new_money, 0.01); }