//! Flagless portfolio hygiene check for the `audit` command. //! //! When `zfin audit` runs with no brokerage flag, this is what //! executes: stale-manual-price detection, working-tree-vs-HEAD //! price/date mismatch detection, account-cadence overdue checks, //! stale hand-declared `harvested` figures, automatic brokerage-file //! discovery + reconciliation, and the large-new-lot transfer nudge. //! //! The reconciliation in Section 5 delegates to the per-broker //! modules (`fidelity.zig`, `schwab.zig`) and the shared display in //! `common.zig`; this file owns only the hygiene logic, so a future //! `zfin doctor` can reuse it without pulling in the reconciler //! surface. const std = @import("std"); const zfin = @import("../../root.zig"); const cli = @import("../common.zig"); const framework = @import("../framework.zig"); const contributions = @import("../contributions.zig"); const Money = @import("../../Money.zig"); const analysis = @import("../../analytics/analysis.zig"); const portfolio_mod = @import("../../models/portfolio.zig"); const Date = @import("../../Date.zig"); const srf = @import("srf"); const git = @import("../../git.zig"); const test_git = @import("../../testutil/git.zig"); const common = @import("common.zig"); const fidelity = @import("../../analytics/reconcile/fidelity.zig"); const schwab = @import("schwab.zig"); const discover = zfin.brokerage.discover; const fmt = cli.fmt; // ── Hygiene check (flagless audit) ────────────────────────── /// Constants for hygiene check behavior. Kept as named constants for /// easy future tuning. /// How recent a brokerage export must be for the hygiene check to pick it /// up. Passed to `discover.brokerFiles` rather than living inside it, because /// callers disagree: a hygiene report wants only today's downloads, while a /// reconciler is better served by seeing a stale file and saying so. const audit_file_max_age_hours = 24; pub const default_stale_days: u32 = 3; const stale_warning_multiplier: u32 = 2; // yellow -> red at 2× threshold /// Safety cap on how many portfolio commits the account-cadence walk /// scans when resolving each account's last-update timestamp. The walk /// normally early-exits once every account resolves; this bounds the /// pathological case where an account never changed in tracked history /// (so it never resolves) from forcing a scan of the entire repo. const max_history_commits_scanned: usize = 500; /// Age at which `zfin audit` starts nagging that an account's /// hand-declared `harvested` figure (see `AccountTaxEntry.harvested`) /// needs a refresh. /// /// Not configurable, and deliberately so: unlike `update_cadence` - /// which nags every account by default and therefore *needs* a /// per-account silence knob - this section only ever considers accounts /// that explicitly declare `harvested`. Not wanting the nag and not /// wanting the field are the same choice, so the opt-out already exists. /// /// 90 days is the same interval as `UpdateCadence.quarterly`, but is /// deliberately an independent constant: retuning the account /// reconciliation cadence shouldn't silently move this. const harvested_stale_days: u32 = 90; /// Age at which `zfin audit` starts nagging that an account's declared /// `tax_mix_*` carve-outs (see `AccountTaxEntry.tax_mix_taxable`) need a /// refresh. /// /// Same opt-in-by-declaring logic as `harvested_stale_days`, and the /// same 90 days, but again an independent constant: the two figures /// drift for unrelated reasons. A tax mix drifts as payroll /// contributions land in different sleeves, which is roughly quarterly /// in effect; a harvested total drifts whenever the sleeve churns. /// /// Note this nag is the *only* consequence of a stale mix. Unlike the /// harvested annotation, the mix never stops applying - see /// `AccountTaxEntry.tax_mix_date`. const tax_mix_stale_days: u32 = 90; /// Compute which accounts have been modified between two parsed portfolios. /// Returns a set of account names that have any lot-level differences. /// Compares by serializing each lot to a canonical string per account, /// sorting, and checking for equality. Simple and robust -- any field /// change in a lot produces a different string. fn findModifiedAccounts( allocator: std.mem.Allocator, old_portfolio: zfin.Portfolio, new_portfolio: zfin.Portfolio, ) !std.StringHashMap(void) { var modified = std.StringHashMap(void).init(allocator); errdefer modified.deinit(); // Collect serialized lot strings grouped by account var old_accts = std.StringHashMap(std.ArrayList([]const u8)).init(allocator); defer { var it = old_accts.valueIterator(); while (it.next()) |v| { for (v.items) |s| allocator.free(s); v.deinit(allocator); } old_accts.deinit(); } var new_accts = std.StringHashMap(std.ArrayList([]const u8)).init(allocator); defer { var it = new_accts.valueIterator(); while (it.next()) |v| { for (v.items) |s| allocator.free(s); v.deinit(allocator); } new_accts.deinit(); } for (old_portfolio.lots) |lot| { const acct = lot.account orelse continue; const entry = try old_accts.getOrPut(acct); if (!entry.found_existing) entry.value_ptr.* = std.ArrayList([]const u8).empty; try entry.value_ptr.append(allocator, try lotToString(allocator, lot)); } for (new_portfolio.lots) |lot| { const acct = lot.account orelse continue; const entry = try new_accts.getOrPut(acct); if (!entry.found_existing) entry.value_ptr.* = std.ArrayList([]const u8).empty; try entry.value_ptr.append(allocator, try lotToString(allocator, lot)); } // Compare per account: sort both lists, then check equality var all = std.StringHashMap(void).init(allocator); defer all.deinit(); { var it = old_accts.keyIterator(); while (it.next()) |k| try all.put(k.*, {}); } { var it = new_accts.keyIterator(); while (it.next()) |k| try all.put(k.*, {}); } var acct_it = all.keyIterator(); while (acct_it.next()) |acct_key| { const acct = acct_key.*; const old_ptr = old_accts.getPtr(acct); const new_ptr = new_accts.getPtr(acct); const old_len = if (old_ptr) |p| p.items.len else 0; const new_len = if (new_ptr) |p| p.items.len else 0; if (old_len != new_len) { try modified.put(acct, {}); continue; } if (old_len == 0) continue; const old_items = old_ptr.?.items; const new_items = new_ptr.?.items; std.mem.sort([]const u8, old_items, {}, strLessThan); std.mem.sort([]const u8, new_items, {}, strLessThan); var differs = false; for (old_items, new_items) |a, b| { if (!std.mem.eql(u8, a, b)) { differs = true; break; } } if (differs) try modified.put(acct, {}); } return modified; } fn strLessThan(_: void, a: []const u8, b: []const u8) bool { return std.mem.order(u8, a, b) == .lt; } /// Serialize a lot to a canonical SRF string for comparison. /// Uses the SRF serializer with comptime reflection, so any new /// field added to Lot is automatically included. fn lotToString(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) ![]const u8 { const lots = [_]portfolio_mod.Lot{lot}; return std.fmt.allocPrint(allocator, "{f}", .{srf.fmt(portfolio_mod.Lot, &lots, .{ .emit_directives = false })}); } /// Resolve, per account, the committer timestamp (Unix epoch seconds) /// of the newest commit in which that account's lots changed in /// `ri.rel_path`. Walks the file's git history newest-to-oldest, /// diffing adjacent commit pairs and attributing each change to the /// newer commit of the pair; results are written into `out`. /// /// The walk covers full history (no cadence-derived cutoff): an /// account more than 2x its cadence overdue - exactly the ones we most /// want an age readout for - would otherwise always fall outside a /// 2x-cadence window and degrade to "no update history found". It /// stops early once every account in `all_accounts` has a timestamp, /// or after scanning `max_history_commits_scanned` commits (the safety /// valve for a never-changed account). /// /// Accounts with no detectable change in tracked history are simply /// absent from `out` (e.g. an account present since before the first /// portfolio commit, whose initial add has no parent commit to diff /// against); the caller renders those as "no update history found". /// /// Keys written into `out` are borrowed from `all_accounts` (stable /// working-copy account-name pointers), never from the transient /// historical portfolio strings. Git unavailability degrades to an /// empty result rather than an error. fn findLastUpdateTimestamps( io: std.Io, allocator: std.mem.Allocator, env: *const std.process.Environ.Map, ri: git.RepoInfo, all_accounts: *const std.StringHashMap(void), out: *std.StringHashMap(i64), ) !void { const commits = git.listCommitsTouching(io, allocator, env, ri.root, ri.rel_path, null) catch &.{}; defer git.freeCommitTouches(allocator, commits); var prev_data: ?[]const u8 = null; defer if (prev_data) |pd| allocator.free(pd); for (commits, 0..) |ct, ci| { // Stop once every account is resolved, or at the scan cap. if (out.count() >= all_accounts.count()) break; if (ci >= max_history_commits_scanned) break; const rev_data = git.show(io, allocator, env, ri.root, ct.commit, ri.rel_path) catch continue; if (ci > 0) { if (prev_data) |pd| { // rev_data is older, pd is newer (commits are newest-first). var old_pf = zfin.cache.deserializePortfolio(allocator, rev_data) catch { allocator.free(rev_data); continue; }; defer old_pf.deinit(); var new_pf = zfin.cache.deserializePortfolio(allocator, pd) catch { allocator.free(rev_data); continue; }; defer new_pf.deinit(); var mods = findModifiedAccounts(allocator, old_pf, new_pf) catch { allocator.free(rev_data); continue; }; defer mods.deinit(); // The newer commit's timestamp is when these accounts changed. const update_ts = commits[ci - 1].timestamp; // Match against stable working-copy account names. var acct_iter = all_accounts.keyIterator(); while (acct_iter.next()) |stable_name| { if (out.contains(stable_name.*)) continue; if (mods.contains(stable_name.*)) { try out.put(stable_name.*, update_ts); } } } } if (prev_data) |pd| allocator.free(pd); prev_data = rev_data; } } /// Staleness color based on age vs threshold. /// Returns CLR_MUTED for within threshold, warning for 1-2x, negative for >2x. fn stalenessColor(age_days: i32, threshold: u32) [3]u8 { const t: i32 = @intCast(threshold); if (age_days <= t) return cli.CLR_MUTED; if (age_days <= t * @as(i32, stale_warning_multiplier)) return cli.CLR_WARNING; return cli.CLR_NEGATIVE; } /// One stale (or undated) manual price found during the hygiene scan. /// String fields borrow from the scanned portfolio's lots and are /// valid for the lifetime of that portfolio. const StaleManualPrice = struct { account: []const u8, symbol: []const u8, note: ?[]const u8, price: f64, /// `null` when the lot carries a manual `price` but no /// `price_date` - the most-stale case (it can't even be aged), /// not the least. price_date: ?Date, /// Days since `price_date`; `null` when undated. age_days: ?i32, }; /// Collect manual-priced lots that are stale (older than `stale_days`) /// or undated, for the "Stale manual prices" hygiene section. /// /// Staleness is purely a property of the `price` / `price_date` the /// user typed on the lot - it has nothing to do with the account's /// `update_cadence` (that's the reconciliation cadence, a separate /// concept handled by the "Accounts overdue" section). The single /// threshold is `stale_days` (the `--stale-days` flag, default 3). /// /// Restricted to open `security_type == .stock` lots: CDs/cash/options /// carry `price` as a fixed face value that never goes "stale by age," /// and closed lots aren't worth nagging about. Undated manual prices /// are always included regardless of `stale_days` - a manual price /// with no `price_date` can't be aged, which is the worst case, not a /// pass. Caller owns the returned list; string fields borrow from /// `portfolio`. fn collectStaleManualPrices( allocator: std.mem.Allocator, portfolio: zfin.Portfolio, as_of: Date, stale_days: u32, ) !std.ArrayList(StaleManualPrice) { var out = std.ArrayList(StaleManualPrice).empty; errdefer out.deinit(allocator); const threshold: i32 = @intCast(stale_days); for (portfolio.lots) |lot| { if (lot.security_type != .stock) continue; const price = lot.price orelse continue; if (!lot.isOpen(as_of)) continue; const account = lot.account orelse "(no account)"; if (lot.price_date) |pd| { const age = as_of.days - pd.days; if (age <= threshold) continue; // fresh enough try out.append(allocator, .{ .account = account, .symbol = lot.symbol, .note = lot.note, .price = price, .price_date = pd, .age_days = age, }); } else { try out.append(allocator, .{ .account = account, .symbol = lot.symbol, .note = lot.note, .price = price, .price_date = null, .age_days = null, }); } } return out; } /// Sort stale manual prices by account, then symbol - so the display /// can group lines under per-account headers. fn staleLessThan(_: void, a: StaleManualPrice, b: StaleManualPrice) bool { const acc = std.mem.order(u8, a.account, b.account); if (acc != .eq) return acc == .lt; return std.mem.order(u8, a.symbol, b.symbol) == .lt; } /// One account whose hand-declared, hand-dated figure has gone stale /// (or was never dated). Shared by the "Stale harvested figures" and /// "Stale tax-mix figures" sections, which have identical shape: one /// row per account, worst first. `account` borrows from the /// `AccountMap` it was collected from and is valid for that map's /// lifetime. const StaleDeclared = struct { account: []const u8, /// Days since the figure's date; `null` when the figure was declared /// with no date at all - the worst case, since an undated figure /// can't be aged. age_days: ?i32, /// Extra clause appended after the age, or `""` for none. /// /// Lets the harvested section explain that its annotation has /// retired without the renderer needing to know what an annotation /// is. Static strings only. note: []const u8 = "", }; /// Collect accounts whose `harvested` figure is older than /// `harvested_stale_days`, or that declare `harvested` with no /// `harvested_date`, for the "Stale harvested figures" hygiene section. /// /// Only accounts that explicitly declare `harvested` are considered - /// that declaration IS the opt-in to being nagged, which is why the /// threshold needs no per-account override. /// /// Future-dated entries fall out as fresh (their age is negative). /// That's deliberate: `zfin doctor` already reports a future /// `harvested_date` as the config typo it is, and surfacing the same /// mistake a second time in staleness clothing would be worse than /// reporting it once. /// /// Caller owns the returned list; `account` fields borrow from /// `account_map`. fn collectStaleHarvested( allocator: std.mem.Allocator, account_map: analysis.AccountMap, as_of: Date, ) !std.ArrayList(StaleDeclared) { var out = std.ArrayList(StaleDeclared).empty; errdefer out.deinit(allocator); const threshold: i32 = @intCast(harvested_stale_days); for (account_map.entries) |e| { if (e.harvested == null) continue; // Whether the figure still renders anywhere is derived by asking // `format.fmtHarvestAnnotation` whether it would emit anything, // NOT by comparing the age against 365. Those two disagree at // leap-year boundaries, because the formatter gates on the // calendar-exact `as_of.subtractYears(1)`. Going through the // formatter makes the "no longer displayed" note definitionally // true - the nag cannot contradict what the user sees. // // SAFETY: immediately overwritten by fmtHarvestAnnotation below. var ann_buf: [fmt.harvest_annotation_max_len]u8 = undefined; const hidden = fmt.fmtHarvestAnnotation(&ann_buf, e.harvested, e.harvested_date, as_of).len == 0; const note: []const u8 = if (hidden) " - no longer displayed" else ""; if (e.harvested_date) |on| { const age = as_of.days - on.days; if (age <= threshold) continue; // fresh enough (or future-dated) try out.append(allocator, .{ .account = e.account, .age_days = age, .note = note }); } else { try out.append(allocator, .{ .account = e.account, .age_days = null, .note = note }); } } return out; } /// Collect accounts whose `tax_mix_*` carve-outs are older than /// `tax_mix_stale_days`, or that declare a mix with no `tax_mix_date`, /// for the "Stale tax-mix figures" hygiene section. /// /// Same opt-in shape as `collectStaleHarvested`: only accounts that /// declare a mix are considered, and future-dated entries fall out as /// fresh so `doctor` owns reporting that typo. /// /// Accounts whose mix was *rejected* still count. The user clearly /// meant to declare one, and `doctor` explains why it didn't take - /// silently dropping the row would hide the account from both reports. /// /// Caller owns the returned list; `account` fields borrow from /// `account_map`. fn collectStaleTaxMix( allocator: std.mem.Allocator, account_map: analysis.AccountMap, as_of: Date, ) !std.ArrayList(StaleDeclared) { var out = std.ArrayList(StaleDeclared).empty; errdefer out.deinit(allocator); const threshold: i32 = @intCast(tax_mix_stale_days); for (account_map.entries) |e| { if (!e.hasTaxMix()) continue; // No `note` counterpart to harvested's "no longer displayed": // a stale tax mix keeps applying, by design. See // `AccountTaxEntry.tax_mix_date`. if (e.tax_mix_date) |on| { const age = as_of.days - on.days; if (age <= threshold) continue; // fresh enough (or future-dated) try out.append(allocator, .{ .account = e.account, .age_days = age }); } else { try out.append(allocator, .{ .account = e.account, .age_days = null }); } } return out; } /// Sort stale hand-declared figures worst-first: undated entries (which /// can't be aged at all), then oldest, then account name as a stable /// tiebreak. /// /// Deliberately unlike `staleLessThan`, which sorts alphabetically: /// that section emits many rows per account and needs them grouped, /// while these emit exactly one row per account, so severity order /// is strictly more useful. fn staleDeclaredLessThan(_: void, a: StaleDeclared, b: StaleDeclared) bool { if (a.age_days == null and b.age_days != null) return true; if (a.age_days != null and b.age_days == null) return false; if (a.age_days) |ad| { const bd = b.age_days.?; if (ad != bd) return ad > bd; } return std.mem.order(u8, a.account, b.account) == .lt; } /// How many accounts declare a `harvested` figure at all. Lets the /// display distinguish "nothing to report because you don't use this /// feature" (print nothing) from "nothing to report because everything /// is current" (print a reassuring `(none)`). fn countHarvestedAccounts(account_map: analysis.AccountMap) usize { var n: usize = 0; for (account_map.entries) |e| { if (e.harvested != null) n += 1; } return n; } /// How many accounts declare `tax_mix_*` carve-outs at all. Same /// use-the-feature-or-stay-quiet gate as `countHarvestedAccounts`. fn countTaxMixAccounts(account_map: analysis.AccountMap) usize { var n: usize = 0; for (account_map.entries) |e| { if (e.hasTaxMix()) n += 1; } return n; } /// Render one "stale hand-declared figure" hygiene section. /// /// The two callers have identical shape - one row per account, worst /// first - so the layout, the age coloring, and the reassuring `(none)` /// live here. `title` is the whole section header; `undated_msg` is what /// a row with no date says instead of an age. /// /// `rows` is expected pre-sorted by `staleDeclaredLessThan`. fn printStaleDeclaredSection( out: *std.Io.Writer, color: bool, rows: []const StaleDeclared, threshold_days: u32, title: []const u8, undated_msg: []const u8, ) !void { try out.print("\n", .{}); try cli.printFg(out, color, cli.CLR_MUTED, " {s}\n", .{title}); if (rows.len == 0) { try cli.printFg(out, color, cli.CLR_POSITIVE, " (none)\n", .{}); return; } for (rows) |e| { try out.print(" {s:<32} ", .{e.account}); if (e.age_days) |ad| { const clr = stalenessColor(ad, threshold_days); try cli.printFg(out, color, clr, "last updated {d} days ago{s}\n", .{ @as(u32, @intCast(ad)), e.note }); } else { try cli.printFg(out, color, cli.CLR_NEGATIVE, "{s}\n", .{undated_msg}); } } } /// A lot whose manual `price` moved between HEAD and the working tree /// while its `price_date` stayed identical - the "bumped the price, /// forgot the date" mistake. String fields borrow from the working- /// tree portfolio. const PriceDateMismatch = struct { account: []const u8, symbol: []const u8, old_price: f64, new_price: f64, price_date: Date, }; /// Build a lot identity that survives a manual-price edit: a lot keeps /// its symbol, account, open_date, and open_price when you only change /// `price`/`price_date`. Used to pair HEAD lots with working-tree lots. /// Caller owns the returned slice. fn lotIdentityKey(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) ![]const u8 { return std.fmt.allocPrint(allocator, "{s}\x00{s}\x00{d}\x00{d:.6}", .{ lot.symbol, lot.account orelse "", lot.open_date.days, lot.open_price, }); } /// Find lots whose manual `price` changed between `committed` (HEAD) /// and `working` (on-disk) while `price_date` stayed identical. /// /// This is the working-tree-vs-HEAD detector for the recurring "I /// updated the price but forgot the date" mistake, run when `audit` /// fires before a commit. Only open `security_type == .stock` lots /// with a non-null `price_date` on both sides participate: the undated /// case is reported by `collectStaleManualPrices`, and legitimate /// back-dating (moving the date to a past close) is *not* flagged /// because the date field changed. Lots are paired by /// `lotIdentityKey`; newly-added or removed lots are ignored. Caller /// owns the returned list; string fields borrow from `working`. fn findPriceDateMismatches( allocator: std.mem.Allocator, committed: zfin.Portfolio, working: zfin.Portfolio, as_of: Date, ) !std.ArrayList(PriceDateMismatch) { var out = std.ArrayList(PriceDateMismatch).empty; errdefer out.deinit(allocator); // Index HEAD lots by stable identity -> their (price, price_date). const HeadLot = struct { price: ?f64, price_date: ?Date }; var head = std.StringHashMap(HeadLot).init(allocator); defer { var it = head.keyIterator(); while (it.next()) |k| allocator.free(k.*); head.deinit(); } for (committed.lots) |lot| { if (lot.security_type != .stock) continue; const key = try lotIdentityKey(allocator, lot); const gop = try head.getOrPut(key); if (gop.found_existing) { // Duplicate identity (rare) - ambiguous, don't guess. allocator.free(key); continue; } gop.value_ptr.* = .{ .price = lot.price, .price_date = lot.price_date }; } for (working.lots) |lot| { if (lot.security_type != .stock) continue; if (!lot.isOpen(as_of)) continue; const new_price = lot.price orelse continue; const new_date = lot.price_date orelse continue; // undated -> stale-price section's job const key = try lotIdentityKey(allocator, lot); defer allocator.free(key); const prior = head.get(key) orelse continue; // newly-added lot const old_price = prior.price orelse continue; // price added, not moved const old_date = prior.price_date orelse continue; // was undated before // The mistake: price moved, date did not. if (old_date.days == new_date.days and @abs(old_price - new_price) >= 0.005) { try out.append(allocator, .{ .account = lot.account orelse "(no account)", .symbol = lot.symbol, .old_price = old_price, .new_price = new_price, .price_date = new_date, }); } } return out; } /// Sort price/date mismatches by account, then symbol - for grouped /// per-account display. fn mismatchLessThan(_: void, a: PriceDateMismatch, b: PriceDateMismatch) bool { const acc = std.mem.order(u8, a.account, b.account); if (acc != .eq) return acc == .lt; return std.mem.order(u8, a.symbol, b.symbol) == .lt; } /// Render one unmatched large-lot warning. Formats the line the /// user needs to paste into `transaction_log.srf` if the lot was /// an internal movement rather than a real external contribution. /// Leaves `from::` as a placeholder - the audit doesn't /// know which account the money came from. /// /// Stock / CD destinations use `dest_lot::SYMBOL@OPEN_DATE`; cash /// (or cash_contribution) destinations use `dest_lot::cash`. The /// template defaults to `type::cash` (cash moved in, then invested - /// the common case). If the securities themselves were moved between /// accounts, change it to `type::in_kind` and fill in the `from::` /// account that the shares left. fn printLargeLotWarning( out: *std.Io.Writer, lot: contributions.UnmatchedLargeLot, color: bool, ) !void { var val_buf: [32]u8 = undefined; var date_buf: [10]u8 = undefined; const value_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(lot.value)}) catch "$?"; const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{lot.open_date}) catch "????-??-??"; const kind_label: []const u8 = switch (lot.security_type) { .stock => "STOCK", .cash => "CASH", .cd => "CD", .option => "OPTION", else => "LOT", }; const sym_for_display = if (lot.symbol.len > 0) lot.symbol else "cash"; try out.print( " {s}: new {s} lot {s} ", .{ lot.account, kind_label, sym_for_display }, ); try cli.printFg(out, color, cli.CLR_POSITIVE, "+{s}", .{value_str}); try out.print(" on {s}\n", .{date_str}); try cli.printFg(out, color, cli.CLR_MUTED, " If this was an external contribution: no action needed.\n", .{}); try cli.printFg(out, color, cli.CLR_MUTED, " If this was an internal transfer, add to transaction_log.srf:\n", .{}); // Amount formatted with cents precision so the suggested // `amount:num:N` exactly matches the lot's value. The matcher // has a $1 tolerance so a whole-dollar suggestion would usually // pair, but pasting a value that lies about the actual lot is // a poor user experience - `transaction_log.srf` should record // what actually moved. if (lot.security_type == .cash) { try cli.printFg( out, color, cli.CLR_MUTED, " transfer::{s},type::cash,amount:num:{d:.2},from::,to::{s},dest_lot::cash\n", .{ date_str, lot.value, lot.account }, ); } else { try cli.printFg( out, color, cli.CLR_MUTED, " transfer::{s},type::cash,amount:num:{d:.2},from::,to::{s},dest_lot::{s}@{s}\n", .{ date_str, lot.value, lot.account, lot.symbol, date_str }, ); } } /// Append the account numbers present in `results` to `dst`, duping /// each string into `allocator` so it outlives the per-file buffer the /// reconciler's result slices borrow from. Used by the flagless audit /// to union the present accounts across every discovered export before /// computing the "accounts not found in any export" advisory once, so a /// single-account positions CSV no longer flags every sibling account /// as missing. `T` is the reconciler's comparison type /// (`common.AccountComparison` or `schwab.SchwabAccountComparison`) - /// both expose an `account_number` field, which `common.presentNumbers` /// collects. fn accumulatePresent( allocator: std.mem.Allocator, dst: *std.ArrayList([]const u8), comptime T: type, results: []const T, ) !void { const present = try common.presentNumbers(allocator, T, results); defer allocator.free(present); for (present) |num| try dst.append(allocator, try allocator.dupe(u8, num)); } /// Run the flagless portfolio hygiene check. pub fn runHygieneCheck( io: std.Io, allocator: std.mem.Allocator, env: *const std.process.Environ.Map, svc: *zfin.DataService, portfolio_path: []const u8, /// Every file in the `portfolio*.srf` glob. Only the large-lot /// check needs this: it diffs portfolio CONTENT and so must see /// the merged view. The rest of the hygiene report is deliberately /// single-file (git blame, commit SHAs) and uses `portfolio_path`. portfolio_paths: []const []const u8, stale_days: u32, verbose: bool, as_of: Date, now_s: i64, color: bool, refresh: framework.RefreshPolicy, out: *std.Io.Writer, ) !void { // Load portfolio const pf_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch { cli.stderrPrint(io, "Error: Cannot read portfolio file\n"); return; }; defer allocator.free(pf_data); var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch { cli.stderrPrint(io, "Error: Cannot parse portfolio file\n"); return; }; defer portfolio.deinit(); // Load accounts.srf var account_map = svc.loadAccountMap(allocator, portfolio_path) orelse { cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account mapping)\n"); return; }; defer account_map.deinit(); try cli.printBold(out, color, " Portfolio hygiene\n", .{}); // ── Section 1: Stale manual prices ── // // Manual prices on stock/fund lots whose `price_date` is older than // `--stale-days` (default 3), plus manual prices with no // `price_date` at all (the most-stale case). Staleness is a // property of the price the user typed on the lot - not of the // account; grouping by account here is display-only organization. // CDs/cash/options are excluded - their `price` is a fixed face // value, not an age-stale quote - as are closed lots. { var stale = try collectStaleManualPrices(allocator, portfolio, as_of, stale_days); defer stale.deinit(allocator); std.mem.sort(StaleManualPrice, stale.items, {}, staleLessThan); try out.print("\n", .{}); try cli.printFg(out, color, cli.CLR_MUTED, " Stale manual prices (>{d} days - --stale-days to configure)\n", .{stale_days}); if (stale.items.len == 0) { try cli.printFg(out, color, cli.CLR_POSITIVE, " (none)\n", .{}); } else { var current_account: ?[]const u8 = null; for (stale.items) |e| { if (current_account == null or !std.mem.eql(u8, current_account.?, e.account)) { current_account = e.account; try cli.printFg(out, color, cli.CLR_HEADER, " {s}\n", .{e.account}); } var price_buf: [24]u8 = undefined; const price_str = std.fmt.bufPrint(&price_buf, "{f}", .{Money.from(e.price)}) catch "$?"; const note_display = e.note orelse ""; if (e.price_date) |pd| { var date_buf: [10]u8 = undefined; const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{pd}) catch "????-??-??"; try out.print(" {s:<14} {s:<16} {s:>12} {s} ", .{ e.symbol, note_display, price_str, date_str }); const clr = stalenessColor(e.age_days.?, stale_days); try cli.printFg(out, color, clr, "({d} days)\n", .{@as(u32, @intCast(e.age_days.?))}); } else { try out.print(" {s:<14} {s:<16} {s:>12} ", .{ e.symbol, note_display, price_str }); try cli.printFg(out, color, cli.CLR_NEGATIVE, "(no price_date set)\n", .{}); } } } } // ── Section 2: Account cadence check ── { // Try to get committed version via git const repo_info: ?git.RepoInfo = git.findRepo(io, allocator, env, portfolio_path) catch null; defer if (repo_info) |ri| { allocator.free(ri.root); allocator.free(ri.rel_path); }; // Parse committed portfolio for diff (working copy vs HEAD) var committed_portfolio: ?zfin.Portfolio = null; defer if (committed_portfolio) |*cp| cp.deinit(); var committed_data: ?[]const u8 = null; defer if (committed_data) |d| allocator.free(d); if (repo_info) |ri| { committed_data = git.show(io, allocator, env, ri.root, "HEAD", ri.rel_path) catch null; if (committed_data) |cd| { committed_portfolio = zfin.cache.deserializePortfolio(allocator, cd) catch null; } } // ── Section 1b: manual price changed without bumping price_date ── // // Catches the recurring "I updated the price but forgot the // date" mistake at the moment it matters - when `audit` runs // against the working tree before a commit. Diffs the on-disk // portfolio against HEAD; a lot whose `price` moved while its // `price_date` stayed put is flagged. Silent when there's no // committed version to compare against (not a repo / new file) // and when no such mismatch exists. if (committed_portfolio) |cp| { var mismatches = try findPriceDateMismatches(allocator, cp, portfolio, as_of); defer mismatches.deinit(allocator); std.mem.sort(PriceDateMismatch, mismatches.items, {}, mismatchLessThan); if (mismatches.items.len > 0) { try out.print("\n", .{}); try cli.printFg(out, color, cli.CLR_MUTED, " Manual price changed without updating price_date (working tree vs HEAD)\n", .{}); var current_account: ?[]const u8 = null; for (mismatches.items) |m| { if (current_account == null or !std.mem.eql(u8, current_account.?, m.account)) { current_account = m.account; try cli.printFg(out, color, cli.CLR_HEADER, " {s}\n", .{m.account}); } var old_buf: [24]u8 = undefined; var new_buf: [24]u8 = undefined; var date_buf: [10]u8 = undefined; const old_str = std.fmt.bufPrint(&old_buf, "{f}", .{Money.from(m.old_price)}) catch "$?"; const new_str = std.fmt.bufPrint(&new_buf, "{f}", .{Money.from(m.new_price)}) catch "$?"; const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{m.price_date}) catch "????-??-??"; try out.print(" {s:<14} {s} -> {s} ", .{ m.symbol, old_str, new_str }); try cli.printFg(out, color, cli.CLR_WARNING, "price_date still {s} - bump it\n", .{date_str}); } } } // Find accounts modified in working copy (uncommitted changes) var working_copy_modified = std.StringHashMap(void).init(allocator); defer working_copy_modified.deinit(); if (committed_portfolio) |cp| { working_copy_modified = findModifiedAccounts(allocator, cp, portfolio) catch std.StringHashMap(void).init(allocator); } // Collect all unique account names from working copy portfolio // (these pointers are stable for the lifetime of the function) var all_accounts = std.StringHashMap(void).init(allocator); defer all_accounts.deinit(); for (portfolio.lots) |lot| { if (lot.account) |acct| { try all_accounts.put(acct, {}); } } // Find last update time for each account via git history. // Walks the portfolio file's full history (no cadence-derived // cutoff), keyed by stable working-copy account names. Absent // entries render as "no update history found" below. var last_update_ts = std.StringHashMap(i64).init(allocator); defer last_update_ts.deinit(); if (repo_info) |ri| { try findLastUpdateTimestamps(io, allocator, env, ri, &all_accounts, &last_update_ts); } // Display overdue accounts var overdue_header_shown = false; var updated_accounts = std.ArrayList([]const u8).empty; defer updated_accounts.deinit(allocator); // Check accounts updated in working copy var wc_it = working_copy_modified.keyIterator(); while (wc_it.next()) |key| { try updated_accounts.append(allocator, key.*); } // Check overdue accounts var acct_it = all_accounts.keyIterator(); while (acct_it.next()) |acct_key| { const acct_name = acct_key.*; // Skip if already updated in working copy if (working_copy_modified.contains(acct_name)) continue; // Look up cadence from accounts.srf var cadence = analysis.UpdateCadence.weekly; // default for (account_map.entries) |entry| { if (std.mem.eql(u8, entry.account, acct_name)) { cadence = entry.update_cadence; break; } } const threshold_days = cadence.thresholdDays() orelse continue; // skip 'none' // Find last update time var age_days: ?i32 = null; if (last_update_ts.get(acct_name)) |ts| { const age_s = now_s - ts; age_days = @intCast(@divFloor(age_s, std.time.s_per_day)); } // If we have no git history for this account, it's definitely overdue const days = age_days orelse @as(i32, @intCast(threshold_days + 1)); if (days <= @as(i32, @intCast(threshold_days))) continue; if (!overdue_header_shown) { try out.print("\n", .{}); try cli.printFg(out, color, cli.CLR_MUTED, " Accounts overdue for update (weekly default - set update_cadence in accounts.srf)\n", .{}); overdue_header_shown = true; } try out.print(" {s:<32} {s:<10}", .{ acct_name, cadence.label() }); if (age_days) |ad| { const clr = stalenessColor(ad, threshold_days); try cli.printFg(out, color, clr, "last updated {d} days ago\n", .{@as(u32, @intCast(ad))}); } else { try cli.printFg(out, color, cli.CLR_NEGATIVE, "no update history found\n", .{}); } } // Display accounts updated in working copy if (updated_accounts.items.len > 0) { try out.print("\n", .{}); try cli.printFg(out, color, cli.CLR_MUTED, " Accounts updated (working copy)\n", .{}); for (updated_accounts.items) |acct| { try cli.printFg(out, color, cli.CLR_POSITIVE, " {s}\n", .{acct}); } } } // ── Section 3: Stale harvested figures ── // // Accounts whose hand-declared `harvested` figure (accounts.srf) is // older than `harvested_stale_days`, or that declare it with no // `harvested_date`. Unlike Section 2 this ages an explicit date // field rather than walking git history, so the two are separate // sections - one "days ago" column with two meanings would mislead. // // The nag has no upper bound on purpose. Past 12 months the // annotation stops rendering (see `format.fmtHarvestAnnotation`), // and this section is then the only thing that explains where it // went - so it escalates its wording instead of going quiet. { const declared = countHarvestedAccounts(account_map); // Silent for portfolios that don't use the feature at all; once // an account declares a figure, say so either way. if (declared > 0) { var stale_harvest = try collectStaleHarvested(allocator, account_map, as_of); defer stale_harvest.deinit(allocator); std.mem.sort(StaleDeclared, stale_harvest.items, {}, staleDeclaredLessThan); var title_buf: [128]u8 = undefined; const title = try std.fmt.bufPrint( &title_buf, "Stale harvested figures (>{d} days - refresh 'harvested' in accounts.srf)", .{harvested_stale_days}, ); try printStaleDeclaredSection( out, color, stale_harvest.items, harvested_stale_days, title, "no harvested_date set", ); } } // ── Section 3b: Stale tax-mix figures ── // // Same shape as Section 3, for the `tax_mix_*` carve-outs (see // `analysis.TaxMix`). Kept separate from harvested for the same // reason Section 3 is kept separate from Section 2: two different // "days ago" meanings in one column mislead. // // Worth nagging about precisely because a stale mix never stops // applying - it quietly feeds the By Tax Type breakdown forever. The // alternative (retiring it like the harvested annotation) would // silently move the user's pre-tax vs post-tax picture, which is // worse than showing a slightly stale split. { const declared = countTaxMixAccounts(account_map); if (declared > 0) { var stale_mix = try collectStaleTaxMix(allocator, account_map, as_of); defer stale_mix.deinit(allocator); std.mem.sort(StaleDeclared, stale_mix.items, {}, staleDeclaredLessThan); var title_buf: [128]u8 = undefined; const title = try std.fmt.bufPrint( &title_buf, "Stale tax-mix figures (>{d} days - refresh 'tax_mix_*' in accounts.srf)", .{tax_mix_stale_days}, ); try printStaleDeclaredSection( out, color, stale_mix.items, tax_mix_stale_days, title, "no tax_mix_date set (mix still applies)", ); } } // ── Section 4: Discover brokerage files ── // Resolve audit directories const portfolio_dir = std.fs.path.dirnamePosix(portfolio_path) orelse "."; var all_files = std.ArrayList(discover.DiscoveredFile).empty; defer { for (all_files.items) |f| allocator.free(f.path); all_files.deinit(allocator); } // Check $ZFIN_AUDIT_FILES first const env_audit_dir = if (svc.config.environ_map) |em| em.get("ZFIN_AUDIT_FILES") else null; if (env_audit_dir) |edir| { const env_files = try discover.brokerFiles(io, allocator, edir, "$ZFIN_AUDIT_FILES", now_s, audit_file_max_age_hours); defer allocator.free(env_files); for (env_files) |f| try all_files.append(allocator, f); } // Then check {portfolio_dir}/audit/ const default_audit_dir = std.fs.path.join(allocator, &.{ portfolio_dir, "audit" }) catch null; defer if (default_audit_dir) |d| allocator.free(d); if (default_audit_dir) |adir| { const dir_files = try discover.brokerFiles(io, allocator, adir, "audit/", now_s, audit_file_max_age_hours); defer allocator.free(dir_files); for (dir_files) |f| try all_files.append(allocator, f); } // Display discovered files if (all_files.items.len > 0) { try out.print("\n", .{}); try cli.printFg(out, color, cli.CLR_MUTED, " Brokerage files (last {d} hours)\n", .{audit_file_max_age_hours}); for (all_files.items) |f| { const kind_label: []const u8 = switch (f.kind) { .fidelity_csv => "fidelity", .schwab_csv => "schwab csv", .schwab_summary => "schwab summary", }; try out.print(" {s:<52} {s}\n", .{ f.path, kind_label }); } } // ── Section 5: Auto-reconcile discovered files ── if (all_files.items.len > 0) { // Build prices map (shared by all reconciliations) var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); { const pos_syms = try portfolio.stockSymbols(allocator); defer allocator.free(pos_syms); if (pos_syms.len > 0) { var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, refresh, color); defer load_result.deinit(); var pit = load_result.prices.iterator(); while (pit.next()) |entry| { try prices.put(entry.key_ptr.*, entry.value_ptr.*); } } for (portfolio.lots) |lot| { if (lot.price) |p| { if (!prices.contains(lot.priceSymbol())) { try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false)); } } } } try out.print("\n", .{}); try cli.printBold(out, color, " Reconciliation\n", .{}); // Present account numbers per institution, unioned across every // discovered file. The "accounts not found" advisory is computed // once from these unions AFTER the loop - not per file - so a // single-account positions CSV no longer flags every other // account in the institution as missing (it's present in a // sibling export or the summary). Strings are duped because the // borrowed account-number slices point into each file's // `file_data`, which is freed per loop iteration. var fidelity_present: std.ArrayList([]const u8) = .empty; var schwab_present: std.ArrayList([]const u8) = .empty; defer { for (fidelity_present.items) |s| allocator.free(s); fidelity_present.deinit(allocator); for (schwab_present.items) |s| allocator.free(s); schwab_present.deinit(allocator); } for (all_files.items) |f| { const file_data = std.Io.Dir.cwd().readFileAlloc(io, f.path, allocator, .limited(10 * 1024 * 1024)) catch continue; defer allocator.free(file_data); switch (f.kind) { .schwab_summary => { const results = schwab.reconcileSummary(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| { try cli.printFg(out, color, cli.CLR_WARNING, " {s}: detected as schwab summary but could not parse ({s}); skipped\n", .{ f.path, @errorName(err) }); continue; }; defer allocator.free(results); if (verbose or schwab.hasSchwabDiscrepancies(results)) { try out.print("\n", .{}); try schwab.displaySchwabResults(results, color, out); try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out); } else { var acct_count: usize = 0; for (results) |r| { if (r.account_name.len > 0) acct_count += 1; } try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab summary: {d} accounts, no discrepancies\n", .{acct_count}); // Always show ratio suggestions even in compact // mode - direct-indexing drift may cause a // non-zero delta that still deserves a nudge. try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out); } try accumulatePresent(allocator, &schwab_present, schwab.SchwabAccountComparison, results); }, .fidelity_csv => { const results = fidelity.reconcile(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| { try cli.printFg(out, color, cli.CLR_WARNING, " {s}: detected as fidelity CSV but could not parse ({s}); skipped\n", .{ f.path, @errorName(err) }); continue; }; defer { for (results) |r| allocator.free(r.comparisons); allocator.free(results); } if (verbose or common.hasAccountDiscrepancies(results)) { try out.print("\n", .{}); try common.displayResults(results, color, out); try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out); } else { try cli.printFg(out, color, cli.CLR_POSITIVE, " fidelity: {d} accounts, no discrepancies\n", .{results.len}); // Always show ratio suggestions even in compact mode try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out); } try accumulatePresent(allocator, &fidelity_present, common.AccountComparison, results); }, .schwab_csv => { const results = schwab.reconcileCsv(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| { try cli.printFg(out, color, cli.CLR_WARNING, " {s}: detected as schwab CSV but could not parse ({s}); skipped\n", .{ f.path, @errorName(err) }); continue; }; defer { for (results) |r| allocator.free(r.comparisons); allocator.free(results); } if (verbose or common.hasAccountDiscrepancies(results)) { try out.print("\n", .{}); try common.displayResults(results, color, out); try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out); } else { try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab: {d} accounts, no discrepancies\n", .{results.len}); try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out); } try accumulatePresent(allocator, &schwab_present, common.AccountComparison, results); }, } } // One advisory per institution that contributed an export, // computed against the unioned present-set. "any export" // reflects that absence is now relative to ALL discovered files // (see `displayAbsentAccounts`), so an account covered by any // sibling CSV or the summary no longer surfaces here. if (fidelity_present.items.len > 0) { const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", fidelity_present.items, prices, as_of); defer allocator.free(absent); try common.displayAbsentAccounts(absent, color, "any export", out); } if (schwab_present.items.len > 0) { const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", schwab_present.items, prices, as_of); defer allocator.free(absent); try common.displayAbsentAccounts(absent, color, "any export", out); } } // ── Section 6: Large new lots - confirm source ── // // Cross-check any new_* Change with value >= threshold against // `transaction_log.srf` (via the shared contributions pipeline). // Surfaces lots that look like significant external contributions // OR unrecorded internal transfers - nudges the user to either // confirm or add a transfer record. // // Silent when every large lot matched a transfer record, when // there are no new lots at all, or when the pipeline can't run // (not in a git repo). Threshold is per-account: an account's // `audit_large_lot_threshold` in accounts.srf wins, otherwise the // filter's built-in default applies. if (contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_paths, &account_map, as_of, color, refresh)) |found| { var found_mut = found; defer found_mut.deinit(); if (found_mut.lots.len > 0) { try out.print("\n", .{}); try cli.printFg(out, color, cli.CLR_MUTED, " Large new lots - confirm source\n", .{}); for (found_mut.lots) |lot| { try printLargeLotWarning(out, lot, color); } } } // ── Section 7: Unhandled stock splits ── // // Held symbols with a split AFTER a lot's purchase date that haven't // opted into automatic split adjustment. Each needs a // `splits_current_through` on its metadata.srf row, or its shares // (and every value derived from them) are misstated across the // split. Cache-only detection; silent when everything is handled. if (portfolio.stockSymbols(allocator)) |split_syms| { defer allocator.free(split_syms); const unhandled = cli.findUnhandledSplits(svc, allocator, portfolio.lots, split_syms, portfolio_path, as_of, cli.fetchOptionsFromPolicy(refresh)); defer allocator.free(unhandled); if (unhandled.len > 0) { try out.print("\n", .{}); try cli.printFg(out, color, cli.CLR_MUTED, " Unhandled stock splits\n", .{}); for (unhandled) |nudge| { try out.print(" {s}: stock split on {f}\n", .{ nudge.symbol, nudge.date }); try cli.printFg(out, color, cli.CLR_MUTED, " Add 'splits_current_through::YYYY-MM-DD' (your reconcile date) to {s}'s metadata.srf row.\n", .{nudge.symbol}); } } } else |_| {} try out.print("\n", .{}); } // ── Tests ──────────────────────────────────────────────────── test "accumulatePresent: unions account numbers across calls" { const allocator = std.testing.allocator; var dst: std.ArrayList([]const u8) = .empty; defer { for (dst.items) |s| allocator.free(s); dst.deinit(allocator); } const batch1 = [_]common.AccountComparison{ .{ .account_name = "Sample IRA", .brokerage_name = "IRA", .account_number = "1234", .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false }, }; const batch2 = [_]common.AccountComparison{ .{ .account_name = "Sample Brokerage", .brokerage_name = "Brokerage", .account_number = "5678", .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false }, }; try accumulatePresent(allocator, &dst, common.AccountComparison, &batch1); try accumulatePresent(allocator, &dst, common.AccountComparison, &batch2); try std.testing.expectEqual(@as(usize, 2), dst.items.len); try std.testing.expectEqualStrings("1234", dst.items[0]); try std.testing.expectEqualStrings("5678", dst.items[1]); } test "accumulatePresent: result strings are owned copies (survive source free)" { // The whole point of duping: the borrowed account_number points into // a per-file buffer freed each loop iteration. Prove the accumulator // keeps its own copy by freeing the source out from under it. const allocator = std.testing.allocator; var dst: std.ArrayList([]const u8) = .empty; defer { for (dst.items) |s| allocator.free(s); dst.deinit(allocator); } const num = try allocator.dupe(u8, "9012"); { const batch = [_]common.AccountComparison{ .{ .account_name = "X", .brokerage_name = "X", .account_number = num, .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false }, }; try accumulatePresent(allocator, &dst, common.AccountComparison, &batch); } allocator.free(num); // source gone; dst must hold its own copy try std.testing.expectEqual(@as(usize, 1), dst.items.len); try std.testing.expectEqualStrings("9012", dst.items[0]); } test "stalenessColor: within threshold" { try std.testing.expectEqual(cli.CLR_MUTED, stalenessColor(2, 3)); try std.testing.expectEqual(cli.CLR_MUTED, stalenessColor(3, 3)); } test "stalenessColor: warning zone (1-2x threshold)" { try std.testing.expectEqual(cli.CLR_WARNING, stalenessColor(4, 3)); try std.testing.expectEqual(cli.CLR_WARNING, stalenessColor(6, 3)); } test "stalenessColor: critical zone (>2x threshold)" { try std.testing.expectEqual(cli.CLR_NEGATIVE, stalenessColor(7, 3)); try std.testing.expectEqual(cli.CLR_NEGATIVE, stalenessColor(30, 3)); } test "UpdateCadence thresholdDays" { try std.testing.expectEqual(@as(?u32, 7), analysis.UpdateCadence.weekly.thresholdDays()); try std.testing.expectEqual(@as(?u32, 30), analysis.UpdateCadence.monthly.thresholdDays()); try std.testing.expectEqual(@as(?u32, 90), analysis.UpdateCadence.quarterly.thresholdDays()); try std.testing.expect(analysis.UpdateCadence.none.thresholdDays() == null); } test "findModifiedAccounts: detects share changes" { const allocator = std.testing.allocator; var old_lots = [_]portfolio_mod.Lot{ .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, .{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "Acct B" }, }; var new_lots = [_]portfolio_mod.Lot{ .{ .symbol = "AAPL", .shares = 110, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, // shares changed .{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "Acct B" }, // unchanged }; const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator }; const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator }; var modified = try findModifiedAccounts(allocator, old_pf, new_pf); defer modified.deinit(); try std.testing.expect(modified.contains("Acct A")); try std.testing.expect(!modified.contains("Acct B")); } test "findModifiedAccounts: detects new lots" { const allocator = std.testing.allocator; var old_lots = [_]portfolio_mod.Lot{ .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, }; var new_lots = [_]portfolio_mod.Lot{ .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, .{ .symbol = "VTI", .shares = 200, .open_date = Date.fromYmd(2025, 3, 1), .open_price = 200.0, .account = "Acct A" }, }; const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator }; const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator }; var modified = try findModifiedAccounts(allocator, old_pf, new_pf); defer modified.deinit(); try std.testing.expect(modified.contains("Acct A")); } test "findModifiedAccounts: detects price changes" { const allocator = std.testing.allocator; var old_lots = [_]portfolio_mod.Lot{ .{ .symbol = "NON40OR52", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 97.24, .account = "401k", .price = 161.71, .price_date = Date.fromYmd(2026, 4, 9) }, }; var new_lots = [_]portfolio_mod.Lot{ .{ .symbol = "NON40OR52", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 97.24, .account = "401k", .price = 169.07, .price_date = Date.fromYmd(2026, 4, 18) }, }; const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator }; const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator }; var modified = try findModifiedAccounts(allocator, old_pf, new_pf); defer modified.deinit(); try std.testing.expect(modified.contains("401k")); } test "findModifiedAccounts: detects removed lots" { const allocator = std.testing.allocator; var old_lots = [_]portfolio_mod.Lot{ .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, .{ .symbol = "VTI", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Acct A" }, }; var new_lots = [_]portfolio_mod.Lot{ .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, // VTI removed }; const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator }; const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator }; var modified = try findModifiedAccounts(allocator, old_pf, new_pf); defer modified.deinit(); try std.testing.expect(modified.contains("Acct A")); } test "findModifiedAccounts: no changes" { const allocator = std.testing.allocator; var lots = [_]portfolio_mod.Lot{ .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, }; const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; var modified = try findModifiedAccounts(allocator, pf, pf); defer modified.deinit(); try std.testing.expectEqual(@as(u32, 0), modified.count()); } // ── collectStaleManualPrices ───────────────────────────────── test "collectStaleManualPrices: dated stale stock lot is flagged with account + age" { const allocator = std.testing.allocator; const as_of = Date.fromYmd(2026, 6, 1); var lots = [_]portfolio_mod.Lot{ .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) }, }; const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; var stale = try collectStaleManualPrices(allocator, pf, as_of, 3); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 1), stale.items.len); try std.testing.expectEqualStrings("Sample 529", stale.items[0].account); try std.testing.expectEqualStrings("F529A", stale.items[0].symbol); try std.testing.expectEqual(@as(?i32, 31), stale.items[0].age_days); } test "collectStaleManualPrices: price within threshold is skipped" { const allocator = std.testing.allocator; const as_of = Date.fromYmd(2026, 6, 1); var lots = [_]portfolio_mod.Lot{ .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 30) }, }; const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; var stale = try collectStaleManualPrices(allocator, pf, as_of, 3); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), stale.items.len); } test "collectStaleManualPrices: undated manual price is always flagged" { const allocator = std.testing.allocator; const as_of = Date.fromYmd(2026, 6, 1); var lots = [_]portfolio_mod.Lot{ .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = null }, }; const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; var stale = try collectStaleManualPrices(allocator, pf, as_of, 3); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 1), stale.items.len); try std.testing.expectEqual(@as(?Date, null), stale.items[0].price_date); try std.testing.expectEqual(@as(?i32, null), stale.items[0].age_days); } test "collectStaleManualPrices: CDs and cash are excluded" { const allocator = std.testing.allocator; const as_of = Date.fromYmd(2026, 6, 1); var lots = [_]portfolio_mod.Lot{ .{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10000.0, .price_date = Date.fromYmd(2020, 1, 1) }, .{ .symbol = "", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cash, .price = 5000.0, .price_date = Date.fromYmd(2020, 1, 1) }, }; const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; var stale = try collectStaleManualPrices(allocator, pf, as_of, 3); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), stale.items.len); } test "collectStaleManualPrices: closed lot is excluded" { const allocator = std.testing.allocator; const as_of = Date.fromYmd(2026, 6, 1); var lots = [_]portfolio_mod.Lot{ .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2020, 1, 1), .close_date = Date.fromYmd(2026, 1, 1), .close_price = 26.0 }, }; const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; var stale = try collectStaleManualPrices(allocator, pf, as_of, 3); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), stale.items.len); } test "collectStaleManualPrices: lot without a manual price is skipped" { const allocator = std.testing.allocator; const as_of = Date.fromYmd(2026, 6, 1); var lots = [_]portfolio_mod.Lot{ .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" }, }; const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; var stale = try collectStaleManualPrices(allocator, pf, as_of, 3); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), stale.items.len); } // ── collectStaleHarvested ──────────────────────────────────── /// Build an in-memory AccountMap from a literal SRF string, mirroring /// the helper in `analytics/analysis.zig`. Exercises the real parse /// path so these tests can't drift from how `accounts.srf` is read. fn testAccountMap(comptime data: []const u8) !analysis.AccountMap { return analysis.parseAccountsFile(std.testing.allocator, data); } test "collectStaleHarvested: no account declares harvested -> empty" { const allocator = std.testing.allocator; var am = try testAccountMap( \\#!srfv1 \\account::Sample Brokerage,tax_type::taxable ); defer am.deinit(); var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25)); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), stale.items.len); try std.testing.expectEqual(@as(usize, 0), countHarvestedAccounts(am)); } test "collectStaleHarvested: figure within the window is fresh" { const allocator = std.testing.allocator; var am = try testAccountMap( \\#!srfv1 \\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2026-06-24 ); defer am.deinit(); // 31 days old. var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25)); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), stale.items.len); // ...but it IS declared, so the section still prints "(none)". try std.testing.expectEqual(@as(usize, 1), countHarvestedAccounts(am)); } test "collectStaleHarvested: exactly at the threshold is still fresh" { const allocator = std.testing.allocator; var am = try testAccountMap( \\#!srfv1 \\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2026-01-01 ); defer am.deinit(); const on = Date.fromYmd(2026, 1, 1); const at_threshold = on.addDays(@intCast(harvested_stale_days)); var stale = try collectStaleHarvested(allocator, am, at_threshold); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), stale.items.len); // One day later it trips. var stale2 = try collectStaleHarvested(allocator, am, at_threshold.addDays(1)); defer stale2.deinit(allocator); try std.testing.expectEqual(@as(usize, 1), stale2.items.len); try std.testing.expectEqual(@as(?i32, @intCast(harvested_stale_days + 1)), stale2.items[0].age_days); try std.testing.expectEqualStrings("", stale2.items[0].note); } test "collectStaleHarvested: undated figure is flagged with a null age" { const allocator = std.testing.allocator; var am = try testAccountMap( \\#!srfv1 \\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300 ); defer am.deinit(); var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25)); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 1), stale.items.len); try std.testing.expectEqualStrings("Sample Tax Loss", stale.items[0].account); try std.testing.expectEqual(@as(?i32, null), stale.items[0].age_days); // An undated figure never renders, so it earns the retired note too. try std.testing.expect(stale.items[0].note.len > 0); } test "collectStaleHarvested: future-dated figure is left to doctor" { // A future `harvested_date` is a config typo, and `zfin doctor` // already reports it as one. Surfacing the same mistake here in // staleness clothing would double-nag for a single error. const allocator = std.testing.allocator; var am = try testAccountMap( \\#!srfv1 \\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2027-06-24 ); defer am.deinit(); var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25)); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), stale.items.len); } test "collectStaleHarvested: note agrees with fmtHarvestAnnotation across the 12-month edge" { // The load-bearing invariant: the "no longer displayed" message must // be true. The note is derived from the formatter rather than from a // day count precisely so the two can't disagree at a leap-year // boundary. Walk a window that straddles the cutoff and assert // agreement on every day. const allocator = std.testing.allocator; var am = try testAccountMap( \\#!srfv1 \\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2024-02-29 ); defer am.deinit(); const on = Date.fromYmd(2024, 2, 29); var offset: i32 = 300; while (offset <= 430) : (offset += 1) { const as_of = on.addDays(offset); var stale = try collectStaleHarvested(allocator, am, as_of); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 1), stale.items.len); // always past 90d here // SAFETY: immediately overwritten by fmtHarvestAnnotation. var buf: [fmt.harvest_annotation_max_len]u8 = undefined; const rendered = fmt.fmtHarvestAnnotation(&buf, 45_300, on, as_of); try std.testing.expectEqual(rendered.len == 0, stale.items[0].note.len > 0); } } test "collectStaleHarvested: note is set once the annotation retires" { const allocator = std.testing.allocator; var am = try testAccountMap( \\#!srfv1 \\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2024-06-24 ); defer am.deinit(); // ~400 days later: stale AND no longer rendered. var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2025, 7, 29)); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 1), stale.items.len); try std.testing.expectEqual(@as(?i32, 400), stale.items[0].age_days); try std.testing.expectEqualStrings(" - no longer displayed", stale.items[0].note); } test "staleDeclaredLessThan: undated first, then oldest, then account name" { const undated_b: StaleDeclared = .{ .account = "B", .age_days = null }; const undated_a: StaleDeclared = .{ .account = "A", .age_days = null }; const old: StaleDeclared = .{ .account = "C", .age_days = 400 }; const newer: StaleDeclared = .{ .account = "D", .age_days = 100 }; // Undated outranks any dated entry, however old. try std.testing.expect(staleDeclaredLessThan({}, undated_b, old)); try std.testing.expect(!staleDeclaredLessThan({}, old, undated_b)); // Among dated, older first. try std.testing.expect(staleDeclaredLessThan({}, old, newer)); try std.testing.expect(!staleDeclaredLessThan({}, newer, old)); // Ties break on account name, so output is stable. try std.testing.expect(staleDeclaredLessThan({}, undated_a, undated_b)); try std.testing.expect(!staleDeclaredLessThan({}, undated_b, undated_a)); } test "collectStaleHarvested + sort: mixed accounts come out worst-first" { const allocator = std.testing.allocator; var am = try testAccountMap( \\#!srfv1 \\account::Sample Fresh,tax_type::taxable,harvested:num:1000,harvested_date::2026-07-01 \\account::Sample Mild,tax_type::taxable,harvested:num:2000,harvested_date::2026-01-01 \\account::Sample Ancient,tax_type::taxable,harvested:num:3000,harvested_date::2024-01-01 \\account::Sample Undated,tax_type::taxable,harvested:num:4000 \\account::Sample None,tax_type::taxable ); defer am.deinit(); var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25)); defer stale.deinit(allocator); std.mem.sort(StaleDeclared, stale.items, {}, staleDeclaredLessThan); try std.testing.expectEqual(@as(usize, 4), countHarvestedAccounts(am)); try std.testing.expectEqual(@as(usize, 3), stale.items.len); // Fresh and None excluded try std.testing.expectEqualStrings("Sample Undated", stale.items[0].account); try std.testing.expectEqualStrings("Sample Ancient", stale.items[1].account); try std.testing.expectEqualStrings("Sample Mild", stale.items[2].account); // Only the ancient one has retired from display. try std.testing.expect(stale.items[1].note.len > 0); try std.testing.expectEqualStrings("", stale.items[2].note); } // ── collectStaleTaxMix ─────────────────────────────────────── test "collectStaleTaxMix: no account declares a mix -> empty" { const allocator = std.testing.allocator; var am = try testAccountMap( \\#!srfv1 \\account::Sample 401k,tax_type::traditional \\account::Sample Brokerage,tax_type::taxable ); defer am.deinit(); var stale = try collectStaleTaxMix(allocator, am, Date.fromYmd(2026, 7, 25)); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), stale.items.len); try std.testing.expectEqual(@as(usize, 0), countTaxMixAccounts(am)); } test "collectStaleTaxMix: a mix within the window is fresh" { const allocator = std.testing.allocator; var am = try testAccountMap( \\#!srfv1 \\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4,tax_mix_date::2026-07-01 ); defer am.deinit(); var stale = try collectStaleTaxMix(allocator, am, Date.fromYmd(2026, 7, 25)); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), stale.items.len); try std.testing.expectEqual(@as(usize, 1), countTaxMixAccounts(am)); } test "collectStaleTaxMix: exactly at the threshold is still fresh" { const allocator = std.testing.allocator; var am = try testAccountMap( \\#!srfv1 \\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4,tax_mix_date::2026-01-01 ); defer am.deinit(); const on = Date.fromYmd(2026, 1, 1); const at_threshold = on.addDays(@intCast(tax_mix_stale_days)); var stale = try collectStaleTaxMix(allocator, am, at_threshold); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), stale.items.len); // One day later it trips. var stale2 = try collectStaleTaxMix(allocator, am, at_threshold.addDays(1)); defer stale2.deinit(allocator); try std.testing.expectEqual(@as(usize, 1), stale2.items.len); try std.testing.expectEqual(@as(?i32, @intCast(tax_mix_stale_days + 1)), stale2.items[0].age_days); // No "no longer displayed" counterpart: a stale mix keeps applying. try std.testing.expectEqualStrings("", stale2.items[0].note); } test "collectStaleTaxMix: an undated mix is flagged but never retires" { // The load-bearing difference from `harvested`: an undated or ancient // tax mix still feeds the By Tax Type breakdown. It gets a null age so // it sorts first, but never a note claiming it stopped applying. const allocator = std.testing.allocator; var am = try testAccountMap( \\#!srfv1 \\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4 ); defer am.deinit(); var stale = try collectStaleTaxMix(allocator, am, Date.fromYmd(2026, 7, 25)); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 1), stale.items.len); try std.testing.expectEqualStrings("Sample 401k", stale.items[0].account); try std.testing.expectEqual(@as(?i32, null), stale.items[0].age_days); try std.testing.expectEqualStrings("", stale.items[0].note); // Years later, still applying and still noteless. var ancient = try collectStaleTaxMix(allocator, am, Date.fromYmd(2030, 7, 25)); defer ancient.deinit(allocator); try std.testing.expectEqual(@as(usize, 1), ancient.items.len); try std.testing.expectEqualStrings("", ancient.items[0].note); try std.testing.expectApproxEqAbs( @as(f64, 0.224), am.entries[0].taxMix().weightOf(.roth), 1e-12, ); } test "collectStaleTaxMix: future-dated mix is left to doctor" { // Mirrors `collectStaleHarvested`: a future date is a config typo that // `zfin doctor` already reports, so ageing it here would double-report. const allocator = std.testing.allocator; var am = try testAccountMap( \\#!srfv1 \\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4,tax_mix_date::2027-01-01 ); defer am.deinit(); var stale = try collectStaleTaxMix(allocator, am, Date.fromYmd(2026, 7, 25)); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), stale.items.len); } test "collectStaleTaxMix: a rejected mix is still collected" { // The user meant to declare a mix, so the account belongs in the // report even though the declaration didn't take. Dropping it would // hide the account from the staleness nag AND leave doctor as the // only mention. const allocator = std.testing.allocator; var am = try testAccountMap( \\#!srfv1 \\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:140 ); defer am.deinit(); try std.testing.expectEqual(@as(usize, 1), countTaxMixAccounts(am)); var stale = try collectStaleTaxMix(allocator, am, Date.fromYmd(2026, 7, 25)); defer stale.deinit(allocator); try std.testing.expectEqual(@as(usize, 1), stale.items.len); try std.testing.expectEqual(@as(?i32, null), stale.items[0].age_days); } test "collectStaleTaxMix + sort: mixed accounts come out worst-first" { const allocator = std.testing.allocator; var am = try testAccountMap( \\#!srfv1 \\account::Sample Fresh,tax_type::traditional,tax_mix_roth:num:10,tax_mix_date::2026-07-01 \\account::Sample Mild,tax_type::traditional,tax_mix_roth:num:20,tax_mix_date::2026-01-01 \\account::Sample Ancient,tax_type::traditional,tax_mix_roth:num:30,tax_mix_date::2024-01-01 \\account::Sample Undated,tax_type::traditional,tax_mix_roth:num:40 \\account::Sample None,tax_type::traditional ); defer am.deinit(); var stale = try collectStaleTaxMix(allocator, am, Date.fromYmd(2026, 7, 25)); defer stale.deinit(allocator); std.mem.sort(StaleDeclared, stale.items, {}, staleDeclaredLessThan); try std.testing.expectEqual(@as(usize, 4), countTaxMixAccounts(am)); try std.testing.expectEqual(@as(usize, 3), stale.items.len); // Fresh and None excluded try std.testing.expectEqualStrings("Sample Undated", stale.items[0].account); try std.testing.expectEqualStrings("Sample Ancient", stale.items[1].account); try std.testing.expectEqualStrings("Sample Mild", stale.items[2].account); } // ── printStaleDeclaredSection ──────────────────────────────── test "printStaleDeclaredSection: no rows emits a reassuring (none)" { // "You use this feature and everything is current" must be visibly // different from the section being absent entirely. var buf: [1024]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try printStaleDeclaredSection(&w, false, &.{}, 90, "Stale widgets (>90 days)", "no widget_date set"); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "Stale widgets (>90 days)") != null); try std.testing.expect(std.mem.indexOf(u8, out, "(none)") != null); } test "printStaleDeclaredSection: dated rows print an age, undated print the fallback" { var buf: [2048]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const rows = [_]StaleDeclared{ .{ .account = "Sample Undated", .age_days = null }, .{ .account = "Sample Ancient", .age_days = 400, .note = " - no longer displayed" }, .{ .account = "Sample Mild", .age_days = 120 }, }; try printStaleDeclaredSection(&w, false, &rows, 90, "Stale harvested figures", "no harvested_date set"); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "Stale harvested figures") != null); try std.testing.expect(std.mem.indexOf(u8, out, "(none)") == null); // Undated rows say why instead of showing a nonsense age. try std.testing.expect(std.mem.indexOf(u8, out, "Sample Undated") != null); try std.testing.expect(std.mem.indexOf(u8, out, "no harvested_date set") != null); // Dated rows show the age, and carry the note when one is set. try std.testing.expect(std.mem.indexOf(u8, out, "last updated 400 days ago - no longer displayed") != null); try std.testing.expect(std.mem.indexOf(u8, out, "last updated 120 days ago\n") != null); } test "printStaleDeclaredSection: an empty note appends nothing" { // Regression guard for the tax-mix caller, which never sets a note: // the age must not pick up stray trailing text. var buf: [1024]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const rows = [_]StaleDeclared{.{ .account = "Sample 401k", .age_days = 577 }}; try printStaleDeclaredSection(&w, false, &rows, tax_mix_stale_days, "Stale tax-mix figures", "no tax_mix_date set (mix still applies)"); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "last updated 577 days ago\n") != null); try std.testing.expect(std.mem.indexOf(u8, out, "no longer displayed") == null); } // ── findPriceDateMismatches ────────────────────────────────── test "findPriceDateMismatches: price moved but date unchanged is flagged" { const allocator = std.testing.allocator; const as_of = Date.fromYmd(2026, 6, 1); var head = [_]portfolio_mod.Lot{ .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) }, }; var work = [_]portfolio_mod.Lot{ .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 1) }, }; const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator }; const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator }; var m = try findPriceDateMismatches(allocator, cp, wp, as_of); defer m.deinit(allocator); try std.testing.expectEqual(@as(usize, 1), m.items.len); try std.testing.expectEqualStrings("F529A", m.items[0].symbol); try std.testing.expectEqual(@as(f64, 25.0), m.items[0].old_price); try std.testing.expectEqual(@as(f64, 27.5), m.items[0].new_price); } test "findPriceDateMismatches: price moved AND date moved (back-date) is not flagged" { const allocator = std.testing.allocator; const as_of = Date.fromYmd(2026, 6, 1); var head = [_]portfolio_mod.Lot{ .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) }, }; var work = [_]portfolio_mod.Lot{ .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 29) }, }; const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator }; const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator }; var m = try findPriceDateMismatches(allocator, cp, wp, as_of); defer m.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), m.items.len); } test "findPriceDateMismatches: unchanged price is not flagged" { const allocator = std.testing.allocator; const as_of = Date.fromYmd(2026, 6, 1); var lots = [_]portfolio_mod.Lot{ .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) }, }; const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; var m = try findPriceDateMismatches(allocator, pf, pf, as_of); defer m.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), m.items.len); } test "findPriceDateMismatches: newly-added lot is not flagged" { const allocator = std.testing.allocator; const as_of = Date.fromYmd(2026, 6, 1); var head = [_]portfolio_mod.Lot{}; var work = [_]portfolio_mod.Lot{ .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 1) }, }; const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator }; const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator }; var m = try findPriceDateMismatches(allocator, cp, wp, as_of); defer m.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), m.items.len); } test "findPriceDateMismatches: undated working lot is left to the stale-price section" { const allocator = std.testing.allocator; const as_of = Date.fromYmd(2026, 6, 1); var head = [_]portfolio_mod.Lot{ .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) }, }; var work = [_]portfolio_mod.Lot{ .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = null }, }; const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator }; const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator }; var m = try findPriceDateMismatches(allocator, cp, wp, as_of); defer m.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), m.items.len); } test "findPriceDateMismatches: CD price change is ignored" { const allocator = std.testing.allocator; const as_of = Date.fromYmd(2026, 6, 1); var head = [_]portfolio_mod.Lot{ .{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10000.0, .price_date = Date.fromYmd(2026, 5, 1) }, }; var work = [_]portfolio_mod.Lot{ .{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10100.0, .price_date = Date.fromYmd(2026, 5, 1) }, }; const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator }; const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator }; var m = try findPriceDateMismatches(allocator, cp, wp, as_of); defer m.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), m.items.len); } test "UpdateCadence label" { try std.testing.expectEqualStrings("weekly", analysis.UpdateCadence.weekly.label()); try std.testing.expectEqualStrings("monthly", analysis.UpdateCadence.monthly.label()); try std.testing.expectEqualStrings("quarterly", analysis.UpdateCadence.quarterly.label()); try std.testing.expectEqualStrings("none", analysis.UpdateCadence.none.label()); } test "printLargeLotWarning: cash destination emits dest_lot::cash template" { var buf: [1024]u8 = undefined; var writer = std.Io.Writer.fixed(&buf); const lot: contributions.UnmatchedLargeLot = .{ .account = "Acct A", .symbol = "", .security_type = .cash, .value = 50_000.0, .open_date = Date.fromYmd(2026, 5, 10), }; try printLargeLotWarning(&writer, lot, false); // color=false -> no ANSI escapes const output = writer.buffered(); // Header line with account + value + date. try std.testing.expect(std.mem.indexOf(u8, output, "Acct A: new CASH lot cash") != null); try std.testing.expect(std.mem.indexOf(u8, output, "+$50,000.00") != null); try std.testing.expect(std.mem.indexOf(u8, output, "on 2026-05-10") != null); // Template line with the expected SRF shape. try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-10,type::cash,amount:num:50000.00,from::,to::Acct A,dest_lot::cash") != null); } test "printLargeLotWarning: stock destination emits dest_lot::SYM@DATE template" { var buf: [1024]u8 = undefined; var writer = std.Io.Writer.fixed(&buf); const lot: contributions.UnmatchedLargeLot = .{ .account = "Acct B", .symbol = "SYM", .security_type = .stock, .value = 25_000.0, .open_date = Date.fromYmd(2026, 5, 3), }; try printLargeLotWarning(&writer, lot, false); const output = writer.buffered(); try std.testing.expect(std.mem.indexOf(u8, output, "Acct B: new STOCK lot SYM") != null); try std.testing.expect(std.mem.indexOf(u8, output, "+$25,000.00") != null); try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-03,type::cash,amount:num:25000.00,from::,to::Acct B,dest_lot::SYM@2026-05-03") != null); } test "printLargeLotWarning: cents are preserved in template" { // Regression: previously the template rounded to whole dollars, // so a $73,158.33 lot suggested `amount:num:73158`. Pasting that // verbatim into transaction_log.srf records a fictitious amount // and (with $1 matcher tolerance) only barely pairs. The fix // prints two-decimal precision so the suggested record exactly // describes the lot it's offering to attribute. var buf: [1024]u8 = undefined; var writer = std.Io.Writer.fixed(&buf); const lot: contributions.UnmatchedLargeLot = .{ .account = "Sample Trust", .symbol = "", .security_type = .cash, .value = 73_158.33, .open_date = Date.fromYmd(2026, 5, 20), }; try printLargeLotWarning(&writer, lot, false); const output = writer.buffered(); try std.testing.expect(std.mem.indexOf(u8, output, "amount:num:73158.33") != null); try std.testing.expect(std.mem.indexOf(u8, output, "amount:num:73158,") == null); } test "strLessThan: orders strings lexicographically" { try std.testing.expect(strLessThan({}, "AAPL", "MSFT")); try std.testing.expect(!strLessThan({}, "MSFT", "AAPL")); try std.testing.expect(!strLessThan({}, "AAPL", "AAPL")); try std.testing.expect(strLessThan({}, "AAPL", "AAPLE")); } test "lotToString: stock lot includes symbol, shares, date" { const allocator = std.testing.allocator; const lot = portfolio_mod.Lot{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 3, 15), .open_price = 150.50, }; const s = try lotToString(allocator, lot); defer allocator.free(s); try std.testing.expect(std.mem.indexOf(u8, s, "AAPL") != null); try std.testing.expect(std.mem.indexOf(u8, s, "100") != null); try std.testing.expect(std.mem.indexOf(u8, s, "2024-03-15") != null); } test "staleLessThan: orders by account, then symbol" { const a = StaleManualPrice{ .account = "Sample IRA", .symbol = "AAPL", .note = null, .price = 1, .price_date = null, .age_days = null }; const b = StaleManualPrice{ .account = "Sample IRA", .symbol = "MSFT", .note = null, .price = 1, .price_date = null, .age_days = null }; const c = StaleManualPrice{ .account = "Sample Roth", .symbol = "AAA", .note = null, .price = 1, .price_date = null, .age_days = null }; // Same account -> symbol breaks the tie. try std.testing.expect(staleLessThan({}, a, b)); try std.testing.expect(!staleLessThan({}, b, a)); // Different account -> account wins regardless of symbol. try std.testing.expect(staleLessThan({}, b, c)); try std.testing.expect(!staleLessThan({}, c, b)); } test "mismatchLessThan: orders by account, then symbol" { const d = Date.fromYmd(2026, 1, 1); const a = PriceDateMismatch{ .account = "Sample IRA", .symbol = "AAPL", .old_price = 1, .new_price = 2, .price_date = d }; const b = PriceDateMismatch{ .account = "Sample IRA", .symbol = "MSFT", .old_price = 1, .new_price = 2, .price_date = d }; const c = PriceDateMismatch{ .account = "Sample Roth", .symbol = "AAA", .old_price = 1, .new_price = 2, .price_date = d }; try std.testing.expect(mismatchLessThan({}, a, b)); try std.testing.expect(!mismatchLessThan({}, b, a)); try std.testing.expect(mismatchLessThan({}, b, c)); try std.testing.expect(!mismatchLessThan({}, c, b)); } test "findPriceDateMismatches: duplicate HEAD identity collapses to one entry" { const allocator = std.testing.allocator; // Two committed lots share an identity key (same symbol/account/ // open_date/open_price) -> the second is ambiguous and skipped, but // the first still anchors the comparison. var committed_lots = [_]portfolio_mod.Lot{ .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .account = "Sample IRA", .price = 150, .price_date = Date.fromYmd(2026, 1, 1) }, .{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .account = "Sample IRA", .price = 150, .price_date = Date.fromYmd(2026, 1, 1) }, }; const committed = portfolio_mod.Portfolio{ .lots = &committed_lots, .allocator = allocator }; // Working tree bumps the price but leaves price_date untouched. var working_lots = [_]portfolio_mod.Lot{ .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .account = "Sample IRA", .price = 160, .price_date = Date.fromYmd(2026, 1, 1) }, }; const working = portfolio_mod.Portfolio{ .lots = &working_lots, .allocator = allocator }; var mismatches = try findPriceDateMismatches(allocator, committed, working, Date.fromYmd(2026, 6, 1)); defer mismatches.deinit(allocator); // Dup HEAD identity collapsed to one; the price-without-date bump still flags. try std.testing.expectEqual(@as(usize, 1), mismatches.items.len); try std.testing.expectEqualStrings("AAPL", mismatches.items[0].symbol); try std.testing.expectApproxEqAbs(@as(f64, 150), mismatches.items[0].old_price, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 160), mismatches.items[0].new_price, 0.01); } test "runHygieneCheck: Section 7 flags an un-opted-in symbol's split, not an opted-in one" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // NVDA: held across its 2024 split, NOT opted in -> should flag. // AMZN: held across its 2022 split, opted in via metadata -> should not. try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = \\#!srfv1 \\symbol::NVDA,shares:num:100,open_date::2020-01-01,open_price:num:40.00,account::Sample Brokerage \\symbol::AMZN,shares:num:30,open_date::2019-01-01,open_price:num:90.00,account::Sample Brokerage \\ , }); // accounts.srf must exist and parse (an empty map is fine). try tmp.dir.writeFile(io, .{ .sub_path = "accounts.srf", .data = "#!srfv1\n" }); // Per-symbol opt-in: AMZN yes, NVDA absent (not opted in). try tmp.dir.writeFile(io, .{ .sub_path = "metadata.srf", .data = "#!srfv1\nsymbol::AMZN,splits_current_through::2024-01-01\n", }); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf); const dir = path_buf[0..dir_len]; // Seed the split cache: NVDA 10:1 (2024-06-10), AMZN 20:1 (2022-06-06). var store = zfin.cache.Store.init(io, allocator, dir); var nvda = [_]zfin.Split{.{ .date = zfin.Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }}; store.write(zfin.Split, "NVDA", nvda[0..], .{ .seconds = zfin.cache.Ttl.splits }); var amzn = [_]zfin.Split{.{ .date = zfin.Date.fromYmd(2022, 6, 6), .numerator = 20, .denominator = 1 }}; store.write(zfin.Split, "AMZN", amzn[0..], .{ .seconds = zfin.cache.Ttl.splits }); // No API keys / no server -> hermetic (fetches can't reach a network). var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir }); defer svc.deinit(); var env = try std.testing.environ.createMap(allocator); defer env.deinit(); const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" }); defer allocator.free(pf_path); var aw: std.Io.Writer.Allocating = .init(allocator); defer aw.deinit(); try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, false, zfin.Date.fromYmd(2026, 1, 1), 1_767_225_600, false, .never, &aw.writer); const output = aw.written(); try std.testing.expect(std.mem.indexOf(u8, output, "Portfolio hygiene") != null); // Assert against Section 7 specifically (it's the last section, so // slice from its header to end) - robust even if NVDA/AMZN surface // in an earlier section. The un-opted-in NVDA is listed; the // opted-in AMZN is not. const sec6_start = std.mem.indexOf(u8, output, "Unhandled stock splits") orelse return error.Section6Missing; const sec6 = output[sec6_start..]; try std.testing.expect(std.mem.indexOf(u8, sec6, "NVDA") != null); try std.testing.expect(std.mem.indexOf(u8, sec6, "AMZN") == null); } /// Format a two-account portfolio.srf for the git-history test. Only /// `shares` varies between revisions, which is enough for /// findModifiedAccounts to flag the account. fn testPortfolioSrf(buf: []u8, ira_shares: []const u8, roth_shares: []const u8) ![]const u8 { return std.fmt.bufPrint(buf, "#!srfv1\n" ++ "symbol::VOO,shares:num:{s},open_date::2026-01-01,open_price:num:1.00,account::Sample IRA\n" ++ "symbol::BND,shares:num:{s},open_date::2026-01-01,open_price:num:1.00,account::Sample Roth\n", .{ ira_shares, roth_shares }); } test "findLastUpdateTimestamps: resolves an account changed in a non-newest commit" { const allocator = std.testing.allocator; const io = std.testing.io; // Skip if `git` isn't on PATH (CI sandbox without git). if (!test_git.available(allocator)) return; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf); const dir = path_buf[0..dir_len]; var buf: [512]u8 = undefined; // Commit A (oldest): IRA=100, Roth=50. try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = try testPortfolioSrf(&buf, "100", "50") }); try test_git.run(allocator, dir, null, &.{ "init", "-q" }); try test_git.run(allocator, dir, null, &.{ "config", "user.email", "test@example.com" }); try test_git.run(allocator, dir, null, &.{ "config", "user.name", "Test" }); try test_git.run(allocator, dir, null, &.{ "config", "commit.gpgsign", "false" }); try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf" }); try test_git.run(allocator, dir, "2026-01-10T12:00:00", &.{ "commit", "-q", "-m", "A" }); // Commit B: IRA changes to 200 (Roth unchanged). This is the change // the old 2x-cadence lookback window could exclude, leaving IRA as // "no update history found". try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = try testPortfolioSrf(&buf, "200", "50") }); try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf" }); try test_git.run(allocator, dir, "2026-02-15T12:00:00", &.{ "commit", "-q", "-m", "B" }); // Commit C (newest): Roth changes to 60 (IRA unchanged). try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = try testPortfolioSrf(&buf, "200", "60") }); try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf" }); try test_git.run(allocator, dir, "2026-07-01T12:00:00", &.{ "commit", "-q", "-m", "C" }); var env = try std.testing.environ.createMap(allocator); defer env.deinit(); const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" }); defer allocator.free(pf_path); const ri = try git.findRepo(io, allocator, &env, pf_path); defer { allocator.free(ri.root); allocator.free(ri.rel_path); } // Authoritative committer timestamps, newest-first: [0]=C, [1]=B, [2]=A. const commits = try git.listCommitsTouching(io, allocator, &env, ri.root, ri.rel_path, null); defer git.freeCommitTouches(allocator, commits); try std.testing.expectEqual(@as(usize, 3), commits.len); var all_accounts = std.StringHashMap(void).init(allocator); defer all_accounts.deinit(); try all_accounts.put("Sample IRA", {}); try all_accounts.put("Sample Roth", {}); var out = std.StringHashMap(i64).init(allocator); defer out.deinit(); try findLastUpdateTimestamps(io, allocator, &env, ri, &all_accounts, &out); // Both accounts resolve. Critically "Sample IRA" - last changed in // commit B, NOT the newest commit - is found rather than absent. const ira_ts = out.get("Sample IRA") orelse return error.IraUnresolved; const roth_ts = out.get("Sample Roth") orelse return error.RothUnresolved; // Each change attributed to the commit that actually made it. try std.testing.expectEqual(commits[1].timestamp, ira_ts); // B try std.testing.expectEqual(commits[0].timestamp, roth_ts); // C try std.testing.expect(ira_ts < roth_ts); }