zfin/src/commands/contributions.zig

1104 lines
46 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.
//!
//! 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);
}