Compare commits

...

2 commits

Author SHA1 Message Date
a966eed026
audit/contributions: avoid loading prices twice
All checks were successful
Generic zig build / build (push) Successful in 5m47s
Generic zig build / publish-macos (push) Successful in 12s
Generic zig build / deploy (push) Successful in 20s
2026-10-13 17:55:05 -07:00
d7917473e8
audit: avoid displaying reconciled accounts within multi-account files (other accounts same file have discrepancies) 2026-10-13 08:51:53 -07:00
9 changed files with 814 additions and 61 deletions

View file

@ -338,7 +338,21 @@ Portfolio Audit (brokerage is source of truth)
- **Brokerage-only** rows are holdings missing from your portfolio. - **Brokerage-only** rows are holdings missing from your portfolio.
- A share or value **delta** flags a count or price mismatch. - A share or value **delta** flags a count or price mismatch.
`--verbose` prints the full comparison even when everything reconciles. When the flagless audit finds an export that covers several accounts, it
only shows a table for the accounts with something to look at. That
includes muted rows: an account whose only difference is a stale price
still gets its table. The other accounts are named on one line, so you
can see they were checked, and the totals still include them:
```
2 more accounts, no discrepancies: Sample IRA, Sample Trust
Total: portfolio $412,350.00 brokerage $412,670.40 delta +$320.40
```
`--verbose` shows a table for every account, and prints the full
comparison even when everything reconciles. The explicit flags
(`--schwab FILE` etc.) always show every account in the file.
### Discrepancies, and which ones are muted ### Discrepancies, and which ones are muted

View file

@ -18,7 +18,7 @@ discrepancies.
| Flag | Effect | | Flag | Effect |
|-----------------------|---------------------------------------------------------------------------------------------| |-----------------------|---------------------------------------------------------------------------------------------|
| `--verbose` | Show full reconciliation output even when clean. | | `--verbose` | Show a table for every account, not just mismatched ones, even when clean. |
| `--stale-days <N>` | Manual-price staleness threshold (default 3). | | `--stale-days <N>` | Manual-price staleness threshold (default 3). |
| `--fidelity <CSV>` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). | | `--fidelity <CSV>` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). |
| `--schwab <CSV>` | Schwab positions CSV: one account, or "All Brokerage Accounts". | | `--schwab <CSV>` | Schwab positions CSV: one account, or "All Brokerage Accounts". |
@ -34,6 +34,12 @@ unmatched accounts on one "not in accounts.srf, not reconciled" line,
and one with no matched accounts at all is skipped as another and one with no matched accounts at all is skipped as another
portfolio's. See [how accounts are matched](../../guides/audit-against-brokerage.md#how-accounts-are-matched). portfolio's. See [how accounts are matched](../../guides/audit-against-brokerage.md#how-accounts-are-matched).
When the flagless audit reconciles a discovered export, only accounts
with a discrepancy get a full table. The rest of that export's accounts
are named on one "N more accounts, no discrepancies" line, and the
totals still cover all of them. `--verbose` and the explicit brokerage
flags show a table for every account.
The hygiene check also flags newly-appeared lots worth at least The hygiene check also flags newly-appeared lots worth at least
$10,000 in a **Large new lots - confirm source** section, so you can $10,000 in a **Large new lots - confirm source** section, so you can
confirm whether each is a real contribution or an unrecorded transfer. confirm whether each is a real contribution or an unrecorded transfer.

View file

@ -488,6 +488,15 @@ pub fn compareAccounts(
const br_price: ?f64 = if (bp.quantity) |q| if (bp.current_value) |v| if (q != 0) v / q else null else null else null; const br_price: ?f64 = if (bp.quantity) |q| if (bp.current_value) |v| if (q != 0) v / q else null else null else null;
// Nothing in the portfolio for this row. A broker cash line
// that is itself (near) zero is agreement, not a holding the
// portfolio is missing: Schwab always emits a "Cash & Cash
// Investments" row, even at $0.00, and cash rows carry no
// quantity, so without this a fully-invested account with no
// cash lot showed a "Brokerage only" mismatch that
// `has_discrepancies` (value delta $0) disagreed with.
const zero_cash_row = bp.is_cash and @abs(bp_value) < cash_tolerance;
try comparisons.append(allocator, .{ try comparisons.append(allocator, .{
.symbol = bp.symbol, .symbol = bp.symbol,
.portfolio_shares = pf_shares, .portfolio_shares = pf_shares,
@ -502,7 +511,7 @@ pub fn compareAccounts(
.is_option = is_option, .is_option = is_option,
.is_cd = is_cd, .is_cd = is_cd,
.cd_allowance = cd_allowance, .cd_allowance = cd_allowance,
.only_in_brokerage = pf_shares == 0 and pf_value == 0, .only_in_brokerage = pf_shares == 0 and pf_value == 0 and !zero_cash_row,
.only_in_portfolio = false, .only_in_portfolio = false,
}); });
} }
@ -1366,6 +1375,58 @@ test "compareAccounts: no cash row and no portfolio cash stays silent" {
} }
} }
test "compareAccounts: a $0.00 broker cash row with no cash lot is agreement, not brokerage-only" {
const allocator = std.testing.allocator;
// Schwab emits its "Cash & Cash Investments" line even for a fully
// invested account. With no cash lot in the portfolio, both sides
// say zero - which used to render as a counted "Brokerage only"
// mismatch while `has_discrepancies` said the account was clean.
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200, .account = "Sample IRA" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample IRA", .tax_type = .traditional, .institution = "schwab", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("VTI", 300);
const Case = struct { cash: f64, flagged: bool };
// $0.00 and sub-penny agree; a real balance with no lot is still
// brokerage-only cash the portfolio is missing.
const cases = [_]Case{
.{ .cash = 0, .flagged = false },
.{ .cash = 0.004, .flagged = false },
.{ .cash = 50, .flagged = true },
};
for (cases) |c| {
var brokerage = [_]BrokeragePosition{
.{ .account_number = "1234", .account_name = "Sample_IRA", .symbol = "VTI", .description = "", .quantity = 100, .current_value = 30000, .cost_basis = 20000, .is_cash = false },
.{ .account_number = "1234", .account_name = "Sample_IRA", .symbol = "Cash & Cash Investments", .description = "", .quantity = null, .current_value = c.cash, .cost_basis = null, .is_cash = true },
};
const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, Date.fromYmd(2026, 8, 1));
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqual(c.flagged, results[0].has_discrepancies);
var found = false;
for (results[0].comparisons) |cmp| {
if (!cmp.is_cash) continue;
found = true;
try std.testing.expectEqual(c.flagged, cmp.only_in_brokerage);
}
try std.testing.expect(found);
}
}
test "compareAccounts: portfolio-only stock position is flagged only_in_portfolio" { test "compareAccounts: portfolio-only stock position is flagged only_in_portfolio" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;

View file

@ -347,6 +347,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
} }
/// Show one explicitly-requested per-position export: the comparison /// Show one explicitly-requested per-position export: the comparison
/// (a table for every account, clean or not - you asked about this file)
/// and ratio suggestions for its mapped accounts, then its unmapped ones /// and ratio suggestions for its mapped accounts, then its unmapped ones
/// (see `common.splitUnmapped`). The unmapped list is printed even for an /// (see `common.splitUnmapped`). The unmapped list is printed even for an
/// export skipped as another portfolio's - you pointed audit at this /// export skipped as another portfolio's - you pointed audit at this
@ -365,7 +366,7 @@ fn showAccountExport(
) !void { ) !void {
const view = common.splitUnmapped(common.AccountComparison, results, scope); const view = common.splitUnmapped(common.AccountComparison, results, scope);
if (!view.skipped()) { if (!view.skipped()) {
try common.displayResults(view.shown, color, out); try common.displayResults(view.shown, .all, color, out);
try common.displayRatioSuggestions(allocator, view.shown, portfolio, prices, account_map, color, out); try common.displayRatioSuggestions(allocator, view.shown, portfolio, prices, account_map, color, out);
} }
try common.displayUnmapped(common.AccountComparison, label, view, true, color, out); try common.displayUnmapped(common.AccountComparison, label, view, true, color, out);

View file

@ -23,7 +23,6 @@ pub const accountValueExpectation = reconcile.accountValueExpectation;
pub const SymbolComparison = reconcile.SymbolComparison; pub const SymbolComparison = reconcile.SymbolComparison;
pub const AccountComparison = reconcile.AccountComparison; pub const AccountComparison = reconcile.AccountComparison;
pub const compareAccounts = reconcile.compareAccounts; pub const compareAccounts = reconcile.compareAccounts;
pub const hasAccountDiscrepancies = reconcile.hasAccountDiscrepancies;
pub const ExportScope = reconcile.ExportScope; pub const ExportScope = reconcile.ExportScope;
pub const ExportView = reconcile.ExportView; pub const ExportView = reconcile.ExportView;
pub const splitUnmapped = reconcile.splitUnmapped; pub const splitUnmapped = reconcile.splitUnmapped;
@ -129,7 +128,84 @@ pub fn displayRatioSuggestions(
// ── Display ───────────────────────────────────────────────── // ── Display ─────────────────────────────────────────────────
pub fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.Writer) !void { /// How one comparison row reads in the audit table. Shared by the
/// renderer and by `accountNeedsDetail`, so the rows counted "to
/// investigate" and the accounts given a table can't drift apart.
const RowClass = struct {
shares_ok: bool,
is_cash_mismatch: bool,
/// A warning-coloured, counted discrepancy - as opposed to a muted
/// stale value or in-band CD mark, which is shown but not counted.
real_mismatch: bool,
};
fn classifyRow(cmp: SymbolComparison) RowClass {
const shares_ok = if (cmp.shares_delta) |d| @abs(d) < 0.01 else !cmp.only_in_brokerage;
const is_cash_mismatch = cmp.is_cash and (if (cmp.value_delta) |d| @abs(d) >= cash_tolerance else false);
// A CD's value delta is an expected dealer mark up to its
// band; only a delta beyond what its terms can explain is a
// real discrepancy. Stale stock prices (shares match, no CD)
// stay muted, as before.
const cd_band = @max(value_tolerance, cmp.cd_allowance);
const cd_value_exceeded = cmp.is_cd and (if (cmp.value_delta) |d| @abs(d) > cd_band else false);
return .{
.shares_ok = shares_ok,
.is_cash_mismatch = is_cash_mismatch,
.real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch or cd_value_exceeded,
};
}
/// True when the row is one of the "N mismatches to investigate".
pub fn isRealMismatch(cmp: SymbolComparison) bool {
return classifyRow(cmp).real_mismatch;
}
/// Does this account warrant its own table in the routine audit?
///
/// True when the reconciler flagged anything (`has_discrepancies`: a row
/// off beyond its tolerance, which includes the muted stale-price and
/// in-band CD rows - muted is shown, never hidden), or when any row is a
/// counted mismatch. The second half is what guarantees the "to
/// investigate" count never includes a row in an account that isn't
/// shown: the two predicates disagree for a brokerage-only row with no
/// quantity and a value under the $1 securities tolerance.
///
/// An account's summed sub-tolerance rounding (many rows each a few
/// cents off) does not qualify on its own, matching what the per-file
/// gate this replaced did.
pub fn accountNeedsDetail(acct: AccountComparison) bool {
if (acct.has_discrepancies) return true;
for (acct.comparisons) |cmp| {
if (isRealMismatch(cmp)) return true;
}
return false;
}
/// True when any account in `results` needs a table (see
/// `accountNeedsDetail`).
pub fn anyAccountNeedsDetail(results: []const AccountComparison) bool {
for (results) |acct| {
if (accountNeedsDetail(acct)) return true;
}
return false;
}
/// How much of an export `displayResults` renders as tables.
pub const Detail = enum {
/// Every account gets a table: `--verbose`, and an export audit was
/// pointed at explicitly (`--schwab FILE` etc.).
all,
/// Only accounts that `accountNeedsDetail`; the others are named on
/// one line above the totals. The flagless audit's routine view, so
/// a single bad account in an all-accounts export doesn't print a
/// screenful of matching ones.
discrepancies_only,
};
/// Render one export's comparison. The totals and the "to investigate"
/// count always cover every account in `results`, whatever `detail`
/// leaves out - an account left out has no counted rows by construction.
pub fn displayResults(results: []const AccountComparison, detail: Detail, color: bool, out: *std.Io.Writer) !void {
try cli.printBold(out, color, "\nPortfolio Audit", .{}); try cli.printBold(out, color, "\nPortfolio Audit", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\n", .{}); try cli.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\n", .{});
try out.print("========================================\n\n", .{}); try out.print("========================================\n\n", .{});
@ -138,8 +214,20 @@ pub fn displayResults(results: []const AccountComparison, color: bool, out: *std
var total_brokerage: f64 = 0; var total_brokerage: f64 = 0;
var total_option_delta: f64 = 0; var total_option_delta: f64 = 0;
var discrepancy_count: usize = 0; var discrepancy_count: usize = 0;
var shown: usize = 0;
var hidden: usize = 0;
for (results) |acct| { for (results) |acct| {
total_portfolio += acct.portfolio_total;
total_brokerage += acct.brokerage_total;
total_option_delta += acct.option_value_delta;
if (detail == .discrepancies_only and !accountNeedsDetail(acct)) {
hidden += 1;
continue;
}
shown += 1;
// Account header // Account header
if (acct.account_name.len > 0) { if (acct.account_name.len > 0) {
try cli.printBold(out, color, " {s}", .{acct.account_name}); try cli.printBold(out, color, " {s}", .{acct.account_name});
@ -161,15 +249,10 @@ pub fn displayResults(results: []const AccountComparison, color: bool, out: *std
var br_price_buf: [16]u8 = undefined; var br_price_buf: [16]u8 = undefined;
// Classify this row // Classify this row
const shares_ok = if (cmp.shares_delta) |d| @abs(d) < 0.01 else !cmp.only_in_brokerage; const class = classifyRow(cmp);
const is_cash_mismatch = cmp.is_cash and (if (cmp.value_delta) |d| @abs(d) >= cash_tolerance else false); const shares_ok = class.shares_ok;
// A CD's value delta is an expected dealer mark up to its const is_cash_mismatch = class.is_cash_mismatch;
// band; only a delta beyond what its terms can explain is a const is_real_mismatch = class.real_mismatch;
// real discrepancy. Stale stock prices (shares match, no CD)
// stay muted, as before.
const cd_band = @max(value_tolerance, cmp.cd_allowance);
const cd_value_exceeded = cmp.is_cd and (if (cmp.value_delta) |d| @abs(d) > cd_band else false);
const is_real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch or cd_value_exceeded;
if (is_real_mismatch) discrepancy_count += 1; if (is_real_mismatch) discrepancy_count += 1;
@ -296,10 +379,24 @@ pub fn displayResults(results: []const AccountComparison, color: bool, out: *std
try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{f})", .{ opt_sign, Money.from(@abs(acct.option_value_delta)) }); try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{f})", .{ opt_sign, Money.from(@abs(acct.option_value_delta)) });
} }
try out.print("\n\n", .{}); try out.print("\n\n", .{});
}
total_portfolio += acct.portfolio_total; // Accounts left out of a `.discrepancies_only` view, named so you can
total_brokerage += acct.brokerage_total; // see they were covered (an account missing from the export entirely
total_option_delta += acct.option_value_delta; // is the "not found in any export" advisory's job, not this line's).
if (hidden > 0) {
const more: []const u8 = if (shown > 0) " more" else "";
try cli.printFg(out, color, cli.CLR_POSITIVE, " {d}{s} {s}, no discrepancies: ", .{ hidden, more, accountsNoun(hidden) });
var first = true;
for (results) |acct| {
if (accountNeedsDetail(acct)) continue;
if (!first) try cli.printFg(out, color, cli.CLR_POSITIVE, ", ", .{});
first = false;
// Always non-empty: an unmapped account is a discrepancy, so
// it never lands here.
try cli.printFg(out, color, cli.CLR_POSITIVE, "{s}", .{acct.account_name});
}
try out.print("\n\n", .{});
} }
// Grand totals // Grand totals
@ -385,7 +482,7 @@ test "displayResults: renders every row classification and the totals block" {
var buf: [8192]u8 = undefined; var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); var w: std.Io.Writer = .fixed(&buf);
try displayResults(&results, false, &w); try displayResults(&results, .all, false, &w);
const out = w.buffered(); const out = w.buffered();
// Header + account identity // Header + account identity
@ -422,7 +519,7 @@ test "displayResults: color=true emits ANSI and singular mismatch label" {
var buf: [4096]u8 = undefined; var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); var w: std.Io.Writer = .fixed(&buf);
try displayResults(&results, true, &w); try displayResults(&results, .all, true, &w);
const out = w.buffered(); const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Audit") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Audit") != null);
@ -443,7 +540,7 @@ test "displayResults: CD value delta within band is muted+uncounted, beyond band
}; };
var buf: [4096]u8 = undefined; var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); var w: std.Io.Writer = .fixed(&buf);
try displayResults(&results, false, &w); try displayResults(&results, .all, false, &w);
const out = w.buffered(); const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Value -") != null); // shown, never hidden try std.testing.expect(std.mem.indexOf(u8, out, "Value -") != null); // shown, never hidden
try std.testing.expect(std.mem.indexOf(u8, out, "to investigate") == null); // not counted try std.testing.expect(std.mem.indexOf(u8, out, "to investigate") == null); // not counted
@ -458,13 +555,132 @@ test "displayResults: CD value delta within band is muted+uncounted, beyond band
}; };
var buf: [4096]u8 = undefined; var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); var w: std.Io.Writer = .fixed(&buf);
try displayResults(&results, false, &w); try displayResults(&results, .all, false, &w);
const out = w.buffered(); const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Value -") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Value -") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null); // counted try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null); // counted
} }
} }
// ── displayResults: per-account detail ───────────────────────
/// A matched, clean row: shares and value agree.
fn cleanRow(symbol: []const u8, value: f64) SymbolComparison {
return .{ .symbol = symbol, .portfolio_shares = 10, .brokerage_shares = 10, .portfolio_price = value / 10, .brokerage_price = value / 10, .portfolio_value = value, .brokerage_value = value, .shares_delta = 0, .value_delta = 0, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false };
}
fn cleanAccount(name: []const u8, number: []const u8, cmps: []const SymbolComparison, total: f64) AccountComparison {
return .{ .account_name = name, .brokerage_name = "Sample_Account", .account_number = number, .comparisons = cmps, .portfolio_total = total, .brokerage_total = total, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false };
}
/// The line of `out` starting at the first `prefix`, or "" if none.
fn lineStarting(out: []const u8, prefix: []const u8) []const u8 {
const rest = out[std.mem.indexOf(u8, out, prefix) orelse out.len ..];
return rest[0 .. std.mem.indexOfScalar(u8, rest, '\n') orelse rest.len];
}
test "displayResults: discrepancies_only tables the accounts that need it and names the rest" {
// One share mismatch, one muted stale value (flagged by the
// reconciler, so it keeps its table: muted is shown, never hidden),
// and two clean accounts.
const dirty_cmps = [_]SymbolComparison{
.{ .symbol = "MSFT", .portfolio_shares = 5, .brokerage_shares = 7, .portfolio_price = 300, .brokerage_price = 300, .portfolio_value = 1500, .brokerage_value = 2100, .shares_delta = 2, .value_delta = 600, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
};
const stale_cmps = [_]SymbolComparison{
.{ .symbol = "QTUM", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 116.0, .brokerage_price = 116.5, .portfolio_value = 11600, .brokerage_value = 11650, .shares_delta = 0, .value_delta = 50, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
};
const roth_cmps = [_]SymbolComparison{cleanRow("VTI", 3000)};
const trust_cmps = [_]SymbolComparison{cleanRow("SCHD", 4000)};
const results = [_]AccountComparison{
.{ .account_name = "Sample IRA", .brokerage_name = "Sample_IRA", .account_number = "1234", .comparisons = &dirty_cmps, .portfolio_total = 1500, .brokerage_total = 2100, .total_delta = 600, .option_value_delta = 0, .has_discrepancies = true },
cleanAccount("Sample Roth", "5678", &roth_cmps, 3000),
.{ .account_name = "Sample HSA", .brokerage_name = "Sample_HSA", .account_number = "9012", .comparisons = &stale_cmps, .portfolio_total = 11600, .brokerage_total = 11650, .total_delta = 50, .option_value_delta = 0, .has_discrepancies = true },
cleanAccount("Sample Trust", "3456", &trust_cmps, 4000),
};
var only_buf: [8192]u8 = undefined;
var only_w: std.Io.Writer = .fixed(&only_buf);
try displayResults(&results, .discrepancies_only, false, &only_w);
const only = only_w.buffered();
var all_buf: [8192]u8 = undefined;
var all_w: std.Io.Writer = .fixed(&all_buf);
try displayResults(&results, .all, false, &all_w);
const all = all_w.buffered();
// Tables: the share mismatch and the muted stale value.
try std.testing.expect(std.mem.indexOf(u8, only, " Sample IRA (Sample_IRA, #1234)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, only, " Sample HSA (Sample_HSA, #9012)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, only, "Value +$50.00") != null);
// The clean two: no table, named once on the summary line.
try std.testing.expect(std.mem.indexOf(u8, only, "#5678") == null);
try std.testing.expect(std.mem.indexOf(u8, only, "#3456") == null);
try std.testing.expect(std.mem.indexOf(u8, only, "VTI") == null);
try std.testing.expect(std.mem.indexOf(u8, only, "\n 2 more accounts, no discrepancies: Sample Roth, Sample Trust\n\n Total:") != null);
// `.all` tables everyone and has no summary line.
try std.testing.expect(std.mem.indexOf(u8, all, " Sample Roth (Sample_Account, #5678)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, all, " Sample Trust (Sample_Account, #3456)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, all, "no discrepancies") == null);
// Totals and the "to investigate" count cover every account, so
// hiding the clean ones changes neither.
const only_total = lineStarting(only, " Total:");
try std.testing.expect(std.mem.indexOf(u8, only_total, "portfolio $20,100.00 brokerage $20,750.00") != null);
try std.testing.expectEqualStrings(lineStarting(all, " Total:"), only_total);
try std.testing.expectEqualStrings(" 1 mismatch to investigate", lineStarting(only, " 1 mismatch"));
try std.testing.expectEqualStrings(lineStarting(all, " 1 mismatch"), lineStarting(only, " 1 mismatch"));
}
test "displayResults: a counted row keeps its table even when the reconciler saw nothing" {
// A brokerage-only row with no quantity and a value under the $1
// securities tolerance: `has_discrepancies` stays false, but the
// table counts it. Hiding it would leave "1 mismatch to investigate"
// pointing at an account that isn't shown.
const odd_cmps = [_]SymbolComparison{
.{ .symbol = "SMPLX", .portfolio_shares = 0, .brokerage_shares = null, .portfolio_price = null, .brokerage_price = null, .portfolio_value = 0, .brokerage_value = 0.5, .shares_delta = null, .value_delta = 0.5, .is_cash = false, .is_option = false, .only_in_brokerage = true, .only_in_portfolio = false },
};
const roth_cmps = [_]SymbolComparison{cleanRow("VTI", 3000)};
const results = [_]AccountComparison{
cleanAccount("Sample IRA", "1234", &odd_cmps, 0),
cleanAccount("Sample Roth", "5678", &roth_cmps, 3000),
};
try std.testing.expect(accountNeedsDetail(results[0]));
try std.testing.expect(!accountNeedsDetail(results[1]));
try std.testing.expect(anyAccountNeedsDetail(&results));
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displayResults(&results, .discrepancies_only, false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Brokerage only") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null);
// Singular.
try std.testing.expect(std.mem.indexOf(u8, out, "\n 1 more account, no discrepancies: Sample Roth\n") != null);
}
test "displayResults: discrepancies_only with nothing to table drops the \"more\"" {
// Callers gate on `anyAccountNeedsDetail` so this shouldn't happen,
// but the summary line must still read correctly if it does.
const ira_cmps = [_]SymbolComparison{cleanRow("VTI", 3000)};
const roth_cmps = [_]SymbolComparison{cleanRow("SCHD", 4000)};
const results = [_]AccountComparison{
cleanAccount("Sample IRA", "1234", &ira_cmps, 3000),
cleanAccount("Sample Roth", "5678", &roth_cmps, 4000),
};
try std.testing.expect(!anyAccountNeedsDetail(&results));
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displayResults(&results, .discrepancies_only, true, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "2 accounts, no discrepancies: ") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "more") == null);
try std.testing.expect(std.mem.indexOf(u8, out, "Sample IRA") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Roth") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "to investigate") == null);
}
// ── displayRatioSuggestions ────────────────────────────────── // ── displayRatioSuggestions ──────────────────────────────────
test "displayRatioSuggestions: emits a suggestion when broker NAV drifts from configured ratio" { test "displayRatioSuggestions: emits a suggestion when broker NAV drifts from configured ratio" {

View file

@ -738,7 +738,8 @@ fn accumulatePresent(
} }
/// Report one discovered per-position export (Fidelity, a Schwab CSV, /// Report one discovered per-position export (Fidelity, a Schwab CSV,
/// Wells Fargo): the full comparison when `verbose` or anything is off, /// Wells Fargo): a table for each account with something to look at
/// (every account when `verbose`) and the clean rest named on one line,
/// else a one-line all-clear; then the export's unmapped accounts (see /// else a one-line all-clear; then the export's unmapped accounts (see
/// `common.splitUnmapped`). Returns false when the export was skipped as /// `common.splitUnmapped`). Returns false when the export was skipped as
/// another portfolio's, so the caller leaves its accounts out of the /// another portfolio's, so the caller leaves its accounts out of the
@ -761,9 +762,11 @@ fn reportAccountExport(
return false; return false;
} }
if (verbose or common.hasAccountDiscrepancies(view.shown)) { // Same predicate as `.discrepancies_only` filters by, so that mode
// always has at least one table to show.
if (verbose or common.anyAccountNeedsDetail(view.shown)) {
try out.print("\n", .{}); try out.print("\n", .{});
try common.displayResults(view.shown, color, out); try common.displayResults(view.shown, if (verbose) .all else .discrepancies_only, color, out);
} else { } else {
try cli.printFg(out, color, cli.CLR_POSITIVE, " {s}: {d} {s}, no discrepancies\n", .{ label, view.shown.len, common.accountsNoun(view.shown.len) }); try cli.printFg(out, color, cli.CLR_POSITIVE, " {s}: {d} {s}, no discrepancies\n", .{ label, view.shown.len, common.accountsNoun(view.shown.len) });
} }
@ -1133,17 +1136,33 @@ pub fn runHygieneCheck(
// ── Section 5: Auto-reconcile discovered files ── // ── Section 5: Auto-reconcile discovered files ──
// Section 5's price load, kept for Section 6: the large-lot check
// needs prices for the same symbols, and loading them again re-scanned
// the candle cache and printed a second "Loaded N symbols" line - one
// that landed in the middle of the reconciliation table. Stays null
// when no export was discovered, and Section 6 loads its own.
var recon_syms: [][]const u8 = &.{};
defer allocator.free(recon_syms);
var recon_load: ?zfin.DataService.LoadAllResult = null;
defer if (recon_load) |*r| r.deinit();
if (all_files.items.len > 0) { if (all_files.items.len > 0) {
// Build prices map (shared by all reconciliations) // Flush before anything that writes to stderr. Audit, unlike the
// commands that load prices before printing, has already
// buffered several sections by now; stderr is unbuffered, so its
// lines otherwise land wherever the 4 KB stdout buffer last
// happened to fill - mid-line, mid-table.
try out.flush();
// Build prices map (shared by all reconciliations): the loader's
// closes, then manual prices for anything it didn't cover.
var prices = std.StringHashMap(f64).init(allocator); var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit(); defer prices.deinit();
{ {
const pos_syms = try portfolio.stockSymbols(allocator); recon_syms = try portfolio.stockSymbols(allocator);
defer allocator.free(pos_syms); if (recon_syms.len > 0) {
if (pos_syms.len > 0) { recon_load = cli.loadPortfolioPrices(io, svc, recon_syms, &.{}, refresh, color);
var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, refresh, color); var pit = recon_load.?.prices.iterator();
defer load_result.deinit();
var pit = load_result.prices.iterator();
while (pit.next()) |entry| { while (pit.next()) |entry| {
try prices.put(entry.key_ptr.*, entry.value_ptr.*); try prices.put(entry.key_ptr.*, entry.value_ptr.*);
} }
@ -1292,7 +1311,14 @@ pub fn runHygieneCheck(
// (not in a git repo). Threshold is per-account: an account's // (not in a git repo). Threshold is per-account: an account's
// `audit_large_lot_threshold` in accounts.srf wins, otherwise the // `audit_large_lot_threshold` in accounts.srf wins, otherwise the
// filter's built-in default applies. // filter's built-in default applies.
if (try contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_paths, &account_map, as_of, color, refresh)) |found| { //
// Reuses Section 5's prices; the pipeline loads only the symbols that
// covers nothing for (lots sold since HEAD, sibling portfolio files).
// Flush first, as before Section 5's load: that top-up, and the
// snapshot-anchor note, go to stderr.
try out.flush();
const known: ?contributions.KnownPrices = if (recon_load) |*r| .{ .requested = recon_syms, .closes = &r.prices } else null;
if (try contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_paths, &account_map, as_of, color, refresh, known)) |found| {
var found_mut = found; var found_mut = found;
defer found_mut.deinit(); defer found_mut.deinit();
@ -2805,3 +2831,234 @@ test "runHygieneCheck: discovered exports set unmapped accounts aside unless one
try std.testing.expect(std.mem.indexOf(u8, out, " schwab summary: 1 account, no discrepancies\n") != null); try std.testing.expect(std.mem.indexOf(u8, out, " schwab summary: 1 account, no discrepancies\n") != null);
try std.testing.expect(std.mem.indexOf(u8, out, " schwab summary: 1 account not in accounts.srf, not reconciled\n Sample Account #9012\n") != null); try std.testing.expect(std.mem.indexOf(u8, out, " schwab summary: 1 account not in accounts.srf, not reconciled\n Sample Account #9012\n") != null);
} }
/// Two Schwab accounts in one all-accounts export, written into `dir`:
/// "Sample IRA" (#1234) reconciles clean, "Sample Roth" (#5678) is 10
/// SCHD shares short. Discovered from the portfolio's own `audit/`
/// directory. `vti_extra` is appended to the VTI lot's record (e.g. a
/// typo'd field, to make Section 8 report something).
fn writeTwoAccountFixture(io: std.Io, dir: std.Io.Dir, comptime vti_extra: []const u8) !void {
try dir.writeFile(io, .{
.sub_path = "portfolio.srf",
.data = "#!srfv1\n" ++
"symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:200,price:num:300,account::Sample IRA" ++ vti_extra ++ "\n" ++
"symbol::SCHD,shares:num:200,open_date::2024-01-15,open_price:num:25,price:num:30,account::Sample Roth\n",
});
try dir.writeFile(io, .{
.sub_path = "accounts.srf",
.data = "#!srfv1\n" ++
"account::Sample IRA,tax_type::traditional,institution::schwab,account_number::1234\n" ++
"account::Sample Roth,tax_type::roth,institution::schwab,account_number::5678\n",
});
const header = "\"Symbol\",\"Description\",\"Price Chng $ (Price Change $)\",\"Price Chng % (Price Change %)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n";
const vti = "\"VTI\",\"VANGUARD TOTAL STOCK MARKET ETF\",\"1.10\",\"0.37%\",\"300.00\",\"100\",\"$110.00\",\"0.37%\",\"$30,000.00\",\"$20,000.00\",\"$10,000.00\",\"50%\",\"--\",\"No\",\"N/A\",\"100%\",\"ETFs & Closed End Funds\",\n";
// 210 shares at the broker vs 200 in the portfolio.
const schd = "\"SCHD\",\"SCHWAB US DIVIDEND EQUITY ETF\",\"0.10\",\"0.33%\",\"30.00\",\"210\",\"$21.00\",\"0.33%\",\"$6,300.00\",\"$5,250.00\",\"$1,050.00\",\"20%\",\"--\",\"No\",\"N/A\",\"100%\",\"ETFs & Closed End Funds\",\n";
try dir.createDirPath(io, "audit");
try dir.writeFile(io, .{
.sub_path = "audit/all.csv",
.data = "\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n" ++
"\"Sample_IRA ...1234\"\n" ++ header ++ vti ++ "\n\n" ++
"\"Sample_Roth ...5678\"\n" ++ header ++ schd,
});
}
test "runHygieneCheck: reconciliation prices come from the shared load" {
// The other fixtures price every lot by hand, which `stockSymbols`
// excludes, so Section 5 never actually loads. Here VTI has no manual
// price and only a cached close: the IRA reconciles clean only if the
// load ran and its result reached the comparison. (The same result
// is what Section 6 reuses; that hand-off is pinned in
// contributions' "values share changes at the caller's closes".)
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try writeTwoAccountFixture(io, tmp.dir, "");
try tmp.dir.writeFile(io, .{
.sub_path = "portfolio.srf",
.data = "#!srfv1\n" ++
"symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:200,account::Sample IRA\n" ++
"symbol::SCHD,shares:num:200,open_date::2024-01-15,open_price:num:25,price:num:30,account::Sample Roth\n",
});
// `{cache_dir}/{SYMBOL}/candles_meta.srf`, unexpired: a fresh cache
// hit. `provider` has no default, so a meta without it is a miss.
try tmp.dir.createDirPath(io, "VTI");
try tmp.dir.writeFile(io, .{ .sub_path = "VTI/candles_meta.srf", .data = "#!srfv1\n#!expires=99999999999\nlast_close:num:300,last_date::2026-10-05,provider::tiingo\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];
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);
// wall-clock required: discovery keeps only exports modified in the
// last 24 hours, judged against the real mtimes of the files above.
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
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, 10, 6), now_s, false, .never, &aw.writer);
const out = aw.written();
try std.testing.expect(std.mem.indexOf(u8, out, "\n 1 more account, no discrepancies: Sample IRA\n") != null);
try std.testing.expect(std.mem.indexOf(u8, out, " Total: portfolio $36,000.00 brokerage $36,300.00") != null);
}
test "runHygieneCheck: one bad account in an all-accounts export tables only that account unless verbose" {
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try writeTwoAccountFixture(io, tmp.dir, "");
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];
// No API keys / no server / no ZFIN_AUDIT_FILES -> hermetic.
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);
// wall-clock required: discovery keeps only exports modified in the
// last 24 hours, judged against the real mtimes of the files above.
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
// Routine audit: the bad account gets its table, the clean one is
// named on one line, and the totals still cover both.
{
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, 10, 6), now_s, false, .never, &aw.writer);
const out = aw.written();
try std.testing.expect(std.mem.indexOf(u8, out, " Sample Roth (Sample_Roth, #5678)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Shares +10.000") != null);
try std.testing.expect(std.mem.indexOf(u8, out, " Sample IRA (Sample_IRA, #1234)") == null);
try std.testing.expect(std.mem.indexOf(u8, out, "\n 1 more account, no discrepancies: Sample IRA\n") != null);
try std.testing.expect(std.mem.indexOf(u8, out, " Total: portfolio $36,000.00 brokerage $36,300.00") != null);
try std.testing.expect(std.mem.indexOf(u8, out, " 1 mismatch to investigate\n") != null);
}
// --verbose: every account gets its table.
{
var aw: std.Io.Writer.Allocating = .init(allocator);
defer aw.deinit();
try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, true, zfin.Date.fromYmd(2026, 10, 6), now_s, false, .never, &aw.writer);
const out = aw.written();
try std.testing.expect(std.mem.indexOf(u8, out, " Sample Roth (Sample_Roth, #5678)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, out, " Sample IRA (Sample_IRA, #1234)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "no discrepancies: ") == null);
try std.testing.expect(std.mem.indexOf(u8, out, " Total: portfolio $36,000.00 brokerage $36,300.00") != null);
}
}
/// Test writer that records how many bytes had been written at each
/// `flush`, so a test can check *where* `runHygieneCheck` flushes.
/// Must not move once `writer` is in use (it finds itself via
/// `@fieldParentPtr`).
const FlushLog = struct {
gpa: std.mem.Allocator,
writer: std.Io.Writer,
written: std.ArrayList(u8) = .empty,
/// `written.items.len` at each flush.
marks: std.ArrayList(usize) = .empty,
fn init(gpa: std.mem.Allocator, buffer: []u8) FlushLog {
return .{ .gpa = gpa, .writer = .{ .vtable = &.{ .drain = drain, .flush = flush }, .buffer = buffer } };
}
fn deinit(self: *FlushLog) void {
self.written.deinit(self.gpa);
self.marks.deinit(self.gpa);
}
fn take(self: *FlushLog, bytes: []const u8) std.Io.Writer.Error!void {
self.written.appendSlice(self.gpa, bytes) catch return error.WriteFailed;
}
fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize {
const self: *FlushLog = @fieldParentPtr("writer", w);
try self.take(w.buffered());
w.end = 0;
// Every element once, except the last, which repeats `splat` times.
var n: usize = 0;
for (data, 0..) |bytes, i| {
const reps = if (i + 1 == data.len) splat else 1;
for (0..reps) |_| try self.take(bytes);
n += bytes.len * reps;
}
return n;
}
fn flush(w: *std.Io.Writer) std.Io.Writer.Error!void {
const self: *FlushLog = @fieldParentPtr("writer", w);
try self.take(w.buffered());
w.end = 0;
self.marks.append(self.gpa, self.written.items.len) catch return error.WriteFailed;
}
};
test "runHygieneCheck: stdout is flushed before each step that can write to stderr" {
// Price loads and the contributions pipeline write progress to
// unbuffered stderr. Unflushed, those lines landed wherever the 4 KB
// stdout buffer last filled - observed mid-row in a reconciliation
// table. Pin both flush points to the section boundaries.
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// A typo'd field gives Section 8 output, so "flushed before Section
// 6" can't be satisfied by a flush at the very end.
try writeTwoAccountFixture(io, tmp.dir, ",price_dat::2026-10-01");
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 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);
// wall-clock required: discovery keeps only exports modified in the
// last 24 hours, judged against the real mtimes of the files above.
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
// Small buffer so the drain path is exercised too.
var buf: [256]u8 = undefined;
var log = FlushLog.init(allocator, &buf);
defer log.deinit();
try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, false, zfin.Date.fromYmd(2026, 10, 6), now_s, false, .never, &log.writer);
try log.writer.flush(); // what main does at exit
const out = log.written.items;
// Section 5: everything before the price load is out, nothing of
// the reconciliation is.
const recon = std.mem.indexOf(u8, out, "\n Reconciliation\n") orelse return error.ReconciliationMissing;
try std.testing.expect(std.mem.indexOfScalar(usize, log.marks.items, recon) != null);
// Section 6: a flush after the whole reconciliation section and
// before Section 8's report.
const recon_end = (std.mem.indexOf(u8, out, " 1 mismatch to investigate\n") orelse return error.MismatchLineMissing) + " 1 mismatch to investigate\n".len;
const section8 = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing;
var found = false;
for (log.marks.items) |m| {
if (m >= recon_end and m < section8) found = true;
}
try std.testing.expect(found);
}

View file

@ -492,7 +492,9 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Attribution uses the resolved CommitSpecs so --commit-* // Attribution uses the resolved CommitSpecs so --commit-*
// overrides + date fallbacks share one classifier. The caller // overrides + date fallbacks share one classifier. The caller
// adapts dates to `CommitSpec.date_at_or_before` upstream. // adapts dates to `CommitSpec.date_at_or_before` upstream.
const attribution = contributions.computeAttributionSpec(io, allocator, ctx.environ_map, svc, attr_paths, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy); // It reuses the live side's price load rather than repeating it.
const known: ?contributions.KnownPrices = if (live_data) |*ld| ld.knownPrices() else null;
const attribution = contributions.computeAttributionSpec(io, allocator, ctx.environ_map, svc, attr_paths, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy, known);
try renderFromParts(out, color, allocator, .{ try renderFromParts(out, color, allocator, .{
.then_date = then_date, .then_date = then_date,
@ -509,7 +511,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
var now_side = try compare_core.loadSnapshotSide(io, allocator, hist_dir, now_date); var now_side = try compare_core.loadSnapshotSide(io, allocator, hist_dir, now_date);
defer now_side.deinit(allocator); defer now_side.deinit(allocator);
const attribution = contributions.computeAttributionSpec(io, allocator, ctx.environ_map, svc, attr_paths, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy); // No live price load on this side to reuse.
const attribution = contributions.computeAttributionSpec(io, allocator, ctx.environ_map, svc, attr_paths, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy, null);
try renderFromParts(out, color, allocator, .{ try renderFromParts(out, color, allocator, .{
.then_date = then_date, .then_date = then_date,

View file

@ -459,7 +459,7 @@ fn runImpl(
return; return;
} }
var ctx = prepareReport(io, allocator, arena, env, svc, paths, before, after, as_of, color, refresh, .verbose) catch return; var ctx = prepareReport(io, allocator, arena, env, svc, paths, before, after, as_of, color, refresh, .verbose, null) catch return;
defer ctx.deinit(); defer ctx.deinit();
try printReport(out, &ctx.report, ctx.endpoints.label, color); try printReport(out, &ctx.report, ctx.endpoints.label, color);
@ -490,6 +490,28 @@ const ReportContext = struct {
const PrepareError = error{PrepareFailed}; const PrepareError = error{PrepareFailed};
/// Prices a caller already loaded through `cli.loadPortfolioPrices`,
/// handed to the pipeline so it doesn't load them a second time. `audit`
/// and `compare` call this pipeline as a library right after loading
/// prices for their own output; loading again re-scanned every candle
/// file and printed a second "Loaded N symbols" line, which in `audit`
/// landed mid-table.
///
/// Only symbols missing from `requested` are loaded - in practice the
/// ones held at the before-side commit but sold since, or held in a
/// portfolio file the caller didn't load.
pub const KnownPrices = struct {
/// Every symbol the caller asked the loader for. One that is absent
/// from `closes` failed to load; asking again under the same refresh
/// policy fails the same way, so it is not retried.
requested: []const []const u8,
/// The loader's raw closes (`LoadAllResult.prices`). NOT a map that
/// `buildFallbackPrices` or a manual-price pass has filled in: this
/// pipeline applies its own fallbacks (`outflowUnitValue`), and a
/// manual or avg-cost entry posing as a close would change them.
closes: *const std.StringHashMap(f64),
};
/// Run the common pipeline - resolve endpoints, load the merged /// Run the common pipeline - resolve endpoints, load the merged
/// portfolio at both revisions, fetch prices, build the report. /// portfolio at both revisions, fetch prices, build the report.
/// ///
@ -510,6 +532,9 @@ const PrepareError = error{PrepareFailed};
/// Stderr output is gated by `verbosity`: `.verbose` is the `run` /// Stderr output is gated by `verbosity`: `.verbose` is the `run`
/// path (user sees why things failed); `.silent` is the attribution /// path (user sees why things failed); `.silent` is the attribution
/// path (failure just means "no attribution line", don't nag). /// path (failure just means "no attribution line", don't nag).
///
/// `known` is prices the caller has already loaded (see `KnownPrices`);
/// null loads everything.
fn prepareReport( fn prepareReport(
io: std.Io, io: std.Io,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
@ -523,6 +548,7 @@ fn prepareReport(
color: bool, color: bool,
refresh: framework.RefreshPolicy, refresh: framework.RefreshPolicy,
verbosity: Verbosity, verbosity: Verbosity,
known: ?KnownPrices,
) PrepareError!ReportContext { ) PrepareError!ReportContext {
if (paths.len == 0) { if (paths.len == 0) {
if (verbosity == .verbose) cli.stderrPrint(io, "Error: No portfolio file found\n"); if (verbosity == .verbose) cli.stderrPrint(io, "Error: No portfolio file found\n");
@ -601,12 +627,11 @@ fn prepareReport(
sym_set.put(l.priceSymbol(), {}) catch return error.PrepareFailed; sym_set.put(l.priceSymbol(), {}) catch return error.PrepareFailed;
} }
} }
var syms: std.ArrayList([]const u8) = .empty; // Take what the caller already loaded; load only the rest.
var sit = sym_set.keyIterator(); const missing = takeKnownPrices(arena, &sym_set, known, &prices) catch return error.PrepareFailed;
while (sit.next()) |k| syms.append(arena, k.*) catch return error.PrepareFailed;
if (syms.items.len > 0) { if (missing.len > 0) {
var load_result = cli.loadPortfolioPrices(io, svc, syms.items, &.{}, refresh, color); var load_result = cli.loadPortfolioPrices(io, svc, missing, &.{}, refresh, color);
defer load_result.deinit(); defer load_result.deinit();
var pit = load_result.prices.iterator(); var pit = load_result.prices.iterator();
while (pit.next()) |entry| { while (pit.next()) |entry| {
@ -692,6 +717,34 @@ fn prepareReport(
}; };
} }
/// Split the symbols `prepareReport` needs (`needed`) into those the
/// caller already has a load result for and those still to load. Known
/// closes are copied into `prices`; a symbol the caller asked for but
/// got no close for is dropped, not retried (see `KnownPrices`). Returns
/// the symbols to load, allocated in `arena`. With `known` null, that is
/// all of them.
fn takeKnownPrices(
arena: std.mem.Allocator,
needed: *const std.StringHashMap(void),
known: ?KnownPrices,
prices: *std.StringHashMap(f64),
) ![]const []const u8 {
var asked = std.StringHashMap(void).init(arena);
if (known) |k| for (k.requested) |sym| try asked.put(sym, {});
var missing: std.ArrayList([]const u8) = .empty;
var it = needed.keyIterator();
while (it.next()) |sym| {
if (known) |k| if (k.closes.get(sym.*)) |close| {
try prices.put(sym.*, close);
continue;
};
if (asked.contains(sym.*)) continue;
try missing.append(arena, sym.*);
}
return missing.items;
}
/// Whether `resolveEndpoints` / `prepareReport` should print /// Whether `resolveEndpoints` / `prepareReport` should print
/// explanatory stderr messages when the window can't be resolved. The /// explanatory stderr messages when the window can't be resolved. The
/// main `run` command uses `.verbose` so the user sees why the command /// main `run` command uses `.verbose` so the user sees why the command
@ -1211,7 +1264,8 @@ pub const AttributionSummary = struct {
/// ///
/// Parameters mirror `run` but without the writer/color: no output /// Parameters mirror `run` but without the writer/color: no output
/// is produced. Failures swallow silently via the shared /// is produced. Failures swallow silently via the shared
/// `prepareReport` helper's `.silent` verbosity. /// `prepareReport` helper's `.silent` verbosity. Pass `known` when the
/// caller has just loaded prices itself (live-mode `compare` has).
/// ///
/// Classification logic is SOLELY in `computeReport` - this function /// Classification logic is SOLELY in `computeReport` - this function
/// just buckets pre-classified change kinds into their totals. In /// just buckets pre-classified change kinds into their totals. In
@ -1231,6 +1285,7 @@ pub fn computeAttributionSpec(
as_of: Date, as_of: Date,
color: bool, color: bool,
refresh: framework.RefreshPolicy, refresh: framework.RefreshPolicy,
known: ?KnownPrices,
) ?AttributionSummary { ) ?AttributionSummary {
if (before == null and after != null) return null; if (before == null and after != null) return null;
@ -1238,7 +1293,7 @@ pub fn computeAttributionSpec(
defer arena_state.deinit(); defer arena_state.deinit();
const arena = arena_state.allocator(); const arena = arena_state.allocator();
var ctx = prepareReport(io, allocator, arena, env, svc, paths, before, after, as_of, color, refresh, .silent) catch return null; var ctx = prepareReport(io, allocator, arena, env, svc, paths, before, after, as_of, color, refresh, .silent, known) catch return null;
defer ctx.deinit(); defer ctx.deinit();
return summarizeAttribution(ctx); return summarizeAttribution(ctx);
@ -1315,7 +1370,9 @@ pub const UnmatchedLargeLotSet = struct {
/// the lot as an external contribution or add a transfer record /// the lot as an external contribution or add a transfer record
/// when it was really an internal movement. Works whether or not /// when it was really an internal movement. Works whether or not
/// `transaction_log.srf` exists - when absent, every large lot /// `transaction_log.srf` exists - when absent, every large lot
/// surfaces since nothing gets reclassified. /// surfaces since nothing gets reclassified. `known` is the prices
/// audit loaded for its reconciliation, if it ran one (see
/// `KnownPrices`).
pub fn findUnmatchedLargeLots( pub fn findUnmatchedLargeLots(
io: std.Io, io: std.Io,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
@ -1326,6 +1383,7 @@ pub fn findUnmatchedLargeLots(
as_of: Date, as_of: Date,
color: bool, color: bool,
refresh: framework.RefreshPolicy, refresh: framework.RefreshPolicy,
known: ?KnownPrices,
) !?UnmatchedLargeLotSet { ) !?UnmatchedLargeLotSet {
var arena_state = std.heap.ArenaAllocator.init(allocator); var arena_state = std.heap.ArenaAllocator.init(allocator);
errdefer arena_state.deinit(); errdefer arena_state.deinit();
@ -1339,7 +1397,7 @@ pub fn findUnmatchedLargeLots(
// //
// Separate allocator here so we can tear the whole thing down // Separate allocator here so we can tear the whole thing down
// via `arena_state.deinit` once we've copied out the descriptors. // via `arena_state.deinit` once we've copied out the descriptors.
var ctx = prepareReport(io, allocator, arena, env, svc, paths, null, null, as_of, color, refresh, .silent) catch |err| { var ctx = prepareReport(io, allocator, arena, env, svc, paths, null, null, as_of, color, refresh, .silent, known) catch |err| {
std.log.scoped(.contributions).debug("large-lot check skipped: no comparable window ({t})", .{err}); std.log.scoped(.contributions).debug("large-lot check skipped: no comparable window ({t})", .{err});
arena_state.deinit(); arena_state.deinit();
return null; return null;
@ -6890,7 +6948,7 @@ test "prepareReport: a lot archived into a sibling portfolio file is one sale" {
var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir }); var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir });
defer svc.deinit(); defer svc.deinit();
var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 8, 17), false, .never, .silent) catch return; var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 8, 17), false, .never, .silent, null) catch return;
defer ctx.deinit(); defer ctx.deinit();
var sale: ?Change = null; var sale: ?Change = null;
@ -6956,7 +7014,7 @@ test "prepareReport: a CD archived unchanged pays out in the commits that span i
var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir }); var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir });
defer svc.deinit(); defer svc.deinit();
var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 3, 10), false, .never, .silent) catch return error.PrepareFailed; var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 3, 10), false, .never, .silent, null) catch return error.PrepareFailed;
defer ctx.deinit(); defer ctx.deinit();
try std.testing.expectEqual(@as(usize, 1), countKind(ctx.report, .cd_matured)); try std.testing.expectEqual(@as(usize, 1), countKind(ctx.report, .cd_matured));
@ -7018,7 +7076,7 @@ test "prepareReport: a cached dividend on an opted-in account leaves attribution
var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir }); var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir });
defer svc.deinit(); defer svc.deinit();
var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 9, 28), false, .never, .silent) catch return error.PrepareFailed; var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 9, 28), false, .never, .silent, null) catch return error.PrepareFailed;
defer ctx.deinit(); defer ctx.deinit();
try std.testing.expectApproxEqAbs(@as(f64, 364.58), summarizeAttribution(ctx).total(), 0.01); try std.testing.expectApproxEqAbs(@as(f64, 364.58), summarizeAttribution(ctx).total(), 0.01);
@ -7052,13 +7110,104 @@ test "prepareReport: with no dividend data the report runs and counts the cash a
var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir }); var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir });
defer svc.deinit(); defer svc.deinit();
var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 9, 28), false, .never, .silent) catch return error.PrepareFailed; var ctx = prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 9, 28), false, .never, .silent, null) catch return error.PrepareFailed;
defer ctx.deinit(); defer ctx.deinit();
try std.testing.expectApproxEqAbs(@as(f64, 470.98), summarizeAttribution(ctx).total(), 0.01); try std.testing.expectApproxEqAbs(@as(f64, 470.98), summarizeAttribution(ctx).total(), 0.01);
try std.testing.expect(cashDeltaIn(ctx.report, "Sample HSA") == null); try std.testing.expect(cashDeltaIn(ctx.report, "Sample HSA") == null);
} }
test "takeKnownPrices: copies known closes, skips asked-but-failed, loads the rest" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var needed = std.StringHashMap(void).init(arena);
for ([_][]const u8{ "VTI", "SCHD", "SOLD" }) |s| try needed.put(s, {});
// No caller load: everything is still to load.
{
var prices = std.StringHashMap(f64).init(arena);
const missing = try takeKnownPrices(arena, &needed, null, &prices);
try std.testing.expectEqual(@as(usize, 3), missing.len);
try std.testing.expectEqual(@as(u32, 0), prices.count());
}
// The caller asked for VTI and SCHD; SCHD failed. SOLD (held only at
// the before-side commit) was never asked for.
{
var closes = std.StringHashMap(f64).init(arena);
try closes.put("VTI", 300);
const known: KnownPrices = .{ .requested = &.{ "VTI", "SCHD" }, .closes = &closes };
var prices = std.StringHashMap(f64).init(arena);
const missing = try takeKnownPrices(arena, &needed, known, &prices);
try std.testing.expectEqual(@as(usize, 1), missing.len);
try std.testing.expectEqualStrings("SOLD", missing[0]);
try std.testing.expectEqual(@as(f64, 300), prices.get("VTI").?);
// Failed once under this refresh policy -> not retried, no price.
try std.testing.expect(!prices.contains("SCHD"));
}
}
test "prepareReport: values share changes at the caller's closes instead of loading again" {
// End to end through git. The cache is empty and the policy is
// `.never`, so a fresh load here finds nothing and a share change is
// valued at open_price; a caller-supplied close must be used instead.
if (!test_git.available(std.testing.allocator)) return;
const io = std.testing.io;
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
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];
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = "#!srfv1\nsymbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:200,account::Sample IRA\n" });
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-09-19T12:00:00", &.{ "commit", "-q", "-m", "before" });
// Working copy: 10 more shares on the same lot.
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = "#!srfv1\nsymbol::VTI,shares:num:110,open_date::2024-01-15,open_price:num:200,account::Sample IRA\n" });
var env = try std.testing.environ.createMap(allocator);
defer env.deinit();
const port_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
const paths: []const []const u8 = &.{port_path};
var svc = zfin.DataService.init(io, std.testing.allocator, .{ .cache_dir = dir });
defer svc.deinit();
const Run = struct {
fn vtiUnitValue(ctx: ReportContext) !f64 {
for (ctx.report.changes) |c| {
if (std.mem.eql(u8, c.symbol, "VTI")) return c.unit_value;
}
return error.NoVtiChange;
}
};
// Without a caller load: nothing cached, so open_price.
{
var ctx = try prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 9, 28), false, .never, .silent, null);
defer ctx.deinit();
try std.testing.expectApproxEqAbs(@as(f64, 200), try Run.vtiUnitValue(ctx), 0.001);
}
// With one: the caller's close.
{
var closes = std.StringHashMap(f64).init(allocator);
try closes.put("VTI", 300);
var ctx = try prepareReport(io, std.testing.allocator, allocator, &env, &svc, paths, null, null, Date.fromYmd(2026, 9, 28), false, .never, .silent, .{ .requested = &.{"VTI"}, .closes = &closes });
defer ctx.deinit();
try std.testing.expectApproxEqAbs(@as(f64, 300), try Run.vtiUnitValue(ctx), 0.001);
}
}
test "computeReport: stock open_price renormalized reclassified as edit" { test "computeReport: stock open_price renormalized reclassified as edit" {
// Reconciliation tweak: user updates `open_price` to match the // Reconciliation tweak: user updates `open_price` to match the
// institutional-share-class NAV, leaving everything else alone. // institutional-share-class NAV, leaving everything else alone.

View file

@ -12,6 +12,7 @@ const std = @import("std");
const zfin = @import("../root.zig"); const zfin = @import("../root.zig");
const cli = @import("common.zig"); const cli = @import("common.zig");
const framework = @import("framework.zig"); const framework = @import("framework.zig");
const contributions = @import("contributions.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
const Date = zfin.Date; const Date = zfin.Date;
const Money = @import("../Money.zig"); const Money = @import("../Money.zig");
@ -370,7 +371,10 @@ const AsOfResolution = struct {
/// `prices` is retained alongside the summary so callers that /// `prices` is retained alongside the summary so callers that
/// need per-symbol price lookups (e.g. `compare`'s LiveSide via /// need per-symbol price lookups (e.g. `compare`'s LiveSide via
/// `compare_core.aggregateLiveStocks`) can borrow without /// `compare_core.aggregateLiveStocks`) can borrow without
/// re-fetching. /// re-fetching. It includes the manual and avg-cost fallbacks
/// `buildPortfolioData` adds; `closes` is the loader's output before
/// them, for consumers that apply their own fallbacks (see
/// `knownPrices`).
/// ///
/// Caller owns the fields and must call `deinit` after the helper /// Caller owns the fields and must call `deinit` after the helper
/// returns. /// returns.
@ -378,12 +382,20 @@ pub const LiveData = struct {
loaded: cli.LoadedPortfolio, loaded: cli.LoadedPortfolio,
pf_data: cli.PortfolioData, pf_data: cli.PortfolioData,
prices: std.StringHashMap(f64), prices: std.StringHashMap(f64),
closes: std.StringHashMap(f64),
pub fn deinit(self: *LiveData, allocator: std.mem.Allocator) void { pub fn deinit(self: *LiveData, allocator: std.mem.Allocator) void {
self.closes.deinit();
self.prices.deinit(); self.prices.deinit();
self.pf_data.deinit(allocator); self.pf_data.deinit(allocator);
self.loaded.deinit(allocator); self.loaded.deinit(allocator);
} }
/// The price load this was built from, for the contributions
/// pipeline to reuse instead of loading again.
pub fn knownPrices(self: *const LiveData) contributions.KnownPrices {
return .{ .requested = self.loaded.syms, .closes = &self.closes };
}
}; };
/// Load the live portfolio (multi-file union-merge), fetch prices /// Load the live portfolio (multi-file union-merge), fetch prices
@ -408,26 +420,27 @@ pub fn loadLiveData(
var loaded = cli.loadPortfolio(ctx, today) orelse return null; var loaded = cli.loadPortfolio(ctx, today) orelse return null;
errdefer loaded.deinit(allocator); errdefer loaded.deinit(allocator);
var prices = std.StringHashMap(f64).init(allocator); // Keep the loader's own map as `closes`; `prices` is a copy that
// `buildPortfolioData` fills in.
var closes = cli.loadPortfolioPrices(io, svc, loaded.syms, &.{}, ctx.globals.refresh_policy, color).prices;
errdefer closes.deinit();
var prices = try closes.clone();
errdefer prices.deinit(); errdefer prices.deinit();
{
var load_result = cli.loadPortfolioPrices(io, svc, loaded.syms, &.{}, ctx.globals.refresh_policy, color);
defer load_result.deinit();
var it = load_result.prices.iterator();
while (it.next()) |entry| {
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
}
}
const pf_data = cli.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, today) catch |err| switch (err) { const pf_data = cli.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, today) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => { error.NoAllocations, error.SummaryFailed => {
cli.stderrPrint(io, "Error computing portfolio summary.\n"); cli.stderrPrint(io, "Error computing portfolio summary.\n");
// A null return isn't an error return: the errdefers above
// don't run on it.
prices.deinit();
closes.deinit();
loaded.deinit(allocator);
return null; return null;
}, },
else => return err, else => return err,
}; };
return .{ .loaded = loaded, .pf_data = pf_data, .prices = prices }; return .{ .loaded = loaded, .pf_data = pf_data, .prices = prices, .closes = closes };
} }
/// Per-call configuration for `runBands`. Bundled because the /// Per-call configuration for `runBands`. Bundled because the
@ -2164,10 +2177,12 @@ fn makeTestLiveData(io: std.Io, svc: *zfin.DataService, pf_path: []const u8, tod
var prices = std.StringHashMap(f64).init(allocator); var prices = std.StringHashMap(f64).init(allocator);
errdefer prices.deinit(); errdefer prices.deinit();
try prices.put("VTI", 200.0); try prices.put("VTI", 200.0);
var closes = try prices.clone();
errdefer closes.deinit();
const pf_data = try portfolio_loader.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, today); const pf_data = try portfolio_loader.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, today);
return .{ .loaded = loaded, .pf_data = pf_data, .prices = prices }; return .{ .loaded = loaded, .pf_data = pf_data, .prices = prices, .closes = closes };
} }
/// Write a `history/imported_values.srf` with the given body into /// Write a `history/imported_values.srf` with the given body into
@ -2179,6 +2194,37 @@ fn writeFixtureImported(io: std.Io, tmp: *std.testing.TmpDir, body: []const u8)
try hist_dir.writeFile(io, .{ .sub_path = "imported_values.srf", .data = body }); try hist_dir.writeFile(io, .{ .sub_path = "imported_values.srf", .data = body });
} }
test "LiveData.knownPrices hands over the loader's closes, not the fallback-filled prices" {
// The contributions pipeline applies its own fallbacks, so an
// avg-cost entry posing as a close would change what it reports.
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
// VTI has a close (makeTestLiveData supplies 200); SCHD has none.
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data =
\\#!srfv1
\\symbol::VTI,shares:num:100,open_date::2020-01-15,open_price:num:200,account::Sample Roth
\\symbol::SCHD,shares:num:10,open_date::2020-01-15,open_price:num:25,account::Sample Roth
\\
});
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
var ld = try makeTestLiveData(io, &svc, pf, Date.fromYmd(2026, 3, 13));
defer ld.deinit(testing.allocator);
// `buildPortfolioData` filled SCHD in at its average cost...
try testing.expectApproxEqAbs(@as(f64, 25), ld.prices.get("SCHD").?, 0.001);
// ...but the hand-off carries only what the loader produced.
const known = ld.knownPrices();
try testing.expectApproxEqAbs(@as(f64, 200), known.closes.get("VTI").?, 0.001);
try testing.expect(!known.closes.contains("SCHD"));
try testing.expectEqual(ld.loaded.syms.len, known.requested.len);
}
test "runBands: imported-only as_of scales today's composition and renders body" { test "runBands: imported-only as_of scales today's composition and renders body" {
const io = std.testing.io; const io = std.testing.io;
var tmp = std.testing.tmpDir(.{}); var tmp = std.testing.tmpDir(.{});