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