2286 lines
104 KiB
Zig
2286 lines
104 KiB
Zig
//! 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::<SOURCE>` 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::<SOURCE>,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::<SOURCE>,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::<SOURCE>,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::<SOURCE>,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);
|
||
}
|