Compare commits

...

2 commits

Author SHA1 Message Date
378c0d0e84
aggregate "missing accounts" check for flagless audit
Some checks failed
Generic zig build / build (push) Failing after 31s
Generic zig build / deploy (push) Has been skipped
Generic zig build / publish-macos (push) Has been skipped
2026-06-27 12:23:54 -07:00
0882e6321f
contributions to consider shares purchased with cash in account 2026-06-27 11:40:58 -07:00
4 changed files with 649 additions and 32 deletions

View file

@ -205,7 +205,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer allocator.free(present);
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of);
defer allocator.free(absent);
try common.displayAbsentAccounts(absent, color, out);
try common.displayAbsentAccounts(absent, color, "this export", out);
}
// Fidelity CSV
@ -237,7 +237,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer allocator.free(present);
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", present, prices, as_of);
defer allocator.free(absent);
try common.displayAbsentAccounts(absent, color, out);
try common.displayAbsentAccounts(absent, color, "this export", out);
}
// Schwab per-account CSV
@ -269,7 +269,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer allocator.free(present);
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of);
defer allocator.free(absent);
try common.displayAbsentAccounts(absent, color, out);
try common.displayAbsentAccounts(absent, color, "this export", out);
}
}

View file

@ -863,14 +863,19 @@ pub fn findAbsentAccounts(
return results.toOwnedSlice(allocator);
}
/// Render the "portfolio accounts not found in this export" advisory.
/// Render the "portfolio accounts not found in <scope>" advisory.
/// Silent when `absent` is empty, so it composes cleanly after both
/// the verbose audit table and the compact "no discrepancies" line.
pub fn displayAbsentAccounts(absent: []const AbsentAccount, color: bool, out: *std.Io.Writer) !void {
///
/// `scope_phrase` names what the absence is relative to: the explicit
/// single-file command passes "this export"; the flagless audit, which
/// unions the present accounts across every discovered file before
/// computing absence, passes "any export".
pub fn displayAbsentAccounts(absent: []const AbsentAccount, color: bool, scope_phrase: []const u8, out: *std.Io.Writer) !void {
if (absent.len == 0) return;
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_WARNING, " Portfolio accounts not found in this export", .{});
try cli.printFg(out, color, cli.CLR_WARNING, " Portfolio accounts not found in {s}", .{scope_phrase});
try cli.printFg(out, color, cli.CLR_MUTED, " (stale, or not exported?)\n", .{});
for (absent) |a| {
try out.print(" ", .{});
@ -1652,7 +1657,7 @@ test "displayAbsentAccounts: silent when empty, renders names + totals otherwise
// Empty -> no output.
{
var w = std.Io.Writer.fixed(&buf);
try displayAbsentAccounts(&.{}, false, &w);
try displayAbsentAccounts(&.{}, false, "this export", &w);
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}
@ -1662,11 +1667,21 @@ test "displayAbsentAccounts: silent when empty, renders names + totals otherwise
const absent = [_]AbsentAccount{
.{ .account_name = "Sample IRA", .account_number = "1234", .portfolio_total = 2100.0 },
};
try displayAbsentAccounts(&absent, false, &w);
try displayAbsentAccounts(&absent, false, "this export", &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "not found in this export") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Sample IRA") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "#1234") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "$2,100") != null);
}
// The scope phrase is caller-supplied (flagless audit passes "any export").
{
var w = std.Io.Writer.fixed(&buf);
const absent = [_]AbsentAccount{
.{ .account_name = "Sample IRA", .account_number = "1234", .portfolio_total = 2100.0 },
};
try displayAbsentAccounts(&absent, false, "any export", &w);
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "not found in any export") != null);
}
}

View file

@ -499,6 +499,27 @@ fn printLargeLotWarning(
}
}
/// 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,
@ -860,6 +881,23 @@ pub fn runHygieneCheck(
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);
@ -869,11 +907,6 @@ pub fn runHygieneCheck(
const results = schwab.reconcileSummary(allocator, portfolio, file_data, account_map, prices, as_of) catch continue;
defer allocator.free(results);
const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results);
defer allocator.free(present);
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of);
defer allocator.free(absent);
if (verbose or schwab.hasSchwabDiscrepancies(results)) {
try out.print("\n", .{});
try schwab.displaySchwabResults(results, color, out);
@ -889,7 +922,8 @@ pub fn runHygieneCheck(
// non-zero delta that still deserves a nudge.
try schwab.displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
try common.displayAbsentAccounts(absent, 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 continue;
@ -898,11 +932,6 @@ pub fn runHygieneCheck(
allocator.free(results);
}
const present = try common.presentNumbers(allocator, common.AccountComparison, results);
defer allocator.free(present);
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", present, prices, as_of);
defer allocator.free(absent);
if (verbose or common.hasAccountDiscrepancies(results)) {
try out.print("\n", .{});
try common.displayResults(results, color, out);
@ -912,7 +941,8 @@ pub fn runHygieneCheck(
// Always show ratio suggestions even in compact mode
try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
try common.displayAbsentAccounts(absent, 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 continue;
@ -921,11 +951,6 @@ pub fn runHygieneCheck(
allocator.free(results);
}
const present = try common.presentNumbers(allocator, common.AccountComparison, results);
defer allocator.free(present);
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of);
defer allocator.free(absent);
if (verbose or common.hasAccountDiscrepancies(results)) {
try out.print("\n", .{});
try common.displayResults(results, color, out);
@ -934,10 +959,27 @@ pub fn runHygieneCheck(
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab: {d} accounts, no discrepancies\n", .{results.len});
try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
try common.displayAbsentAccounts(absent, 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 5: Large new lots - confirm source
@ -970,6 +1012,53 @@ pub fn runHygieneCheck(
// 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 "detectBrokerFileKind: fidelity csv" {
const fidelity_header = "Account Number,Account Name,Symbol,Description";
try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_header).?);

View file

@ -46,6 +46,34 @@
//! - `price_only` - same-key, only the `price::` field changed
//! - `flagged` - any other edit shape (maturity_date change, etc.)
//!
//! ### Intra-account purchase netting (`matchIntraAccountPurchases`)
//!
//! A plain buy made with cash already in the account is not a fresh
//! contribution - the cash was counted when it arrived, and investing
//! it just changes its form. In the diff such a buy is two changes on
//! the SAME account: a `new_stock` / `new_cd` lot appearing, and the
//! account's cash going down (a negative `cash_delta`, or a cash
//! `lot_removed` if the line was fully spent). `matchIntraAccountPurchases`
//! builds a per-account cash-outflow budget from those decreases and
//! draws it down against the account's `new_stock` / `new_cd` lots,
//! recording the funded amount on each Change's `internal_funded`.
//! `attributedValue()` subtracts it, so the funded portion leaves
//! attribution everywhere at once (report, per-account totals, compare,
//! audit large-lot nudge) - no `transaction_log.srf` entry required.
//!
//! | Scenario | Kind | Section | In Grand Total | In Attribution |
//! |------------------------------------------------|--------------|------------------------|:--------------:|:--------------:|
//! | Buy fully funded by same-account cash decrease | `new_stock` | Internal purchases | no | no |
//! | Buy partly funded (cash + new money) | `new_stock` | New contributions (residual) + Internal purchases | residual | residual |
//!
//! Runs AFTER the transfer matcher so explicit `transaction_log.srf`
//! records win: cash already credited to a `transfer_out` isn't in the
//! budget, and a lot already flipped to `transfer_in` is no longer
//! `new_stock` / `new_cd`. Scope is deliberately narrow - only brand-new
//! `new_stock` / `new_cd` lots, same account. `new_drip_lot`,
//! `rollup_delta` / `drip_confirmed`, and `partial_transfer_in`
//! residuals are left untouched (see `matchIntraAccountPurchases`).
//!
//! ### Cash-account opt-in (`cash_is_contribution::true` in accounts.srf)
//!
//! Most cash-account activity is internal flow - DRIP cash legs,
@ -1211,6 +1239,24 @@ const Change = struct {
/// kinds.
transfer_date: ?Date = null,
// Intra-account purchase netting (see `matchIntraAccountPurchases`)
/// Portion of this Change's `value()` funded by a decrease in the
/// SAME account's cash during the window - i.e. a plain buy of a
/// new lot using cash that was already sitting in the account
/// (cash -> security). Set only for `new_stock` / `new_cd`. The
/// matched cash leaves as a negative `cash_delta` (or a removed
/// cash lot) on the same account, so funding the purchase with it
/// is internal movement, not a fresh contribution. `attributedValue()`
/// subtracts it; a fully-funded buy nets to $0 and drops out of
/// "New contributions", the per-account totals, the compare
/// attribution line, and the audit large-lot nudge. Zero otherwise.
///
/// Distinct from `transfer_attributed` (which tracks cross-account
/// movement declared in `transaction_log.srf`); the two are
/// additive on `new_stock` / `new_cd` and never overlap because a
/// transfer-matched lot is reclassified away from those kinds.
internal_funded: f64 = 0,
pub fn value(self: Change) f64 {
return self.delta_shares * self.unit_value;
}
@ -1222,15 +1268,19 @@ const Change = struct {
/// `new_cash`, `cash_contribution`, and `cash_delta`, the
/// matcher may have credited some of the value to a cash-
/// destination transfer record (see `matchCashDestination`);
/// `transfer_attributed` tracks how much. The residual is
/// `value() - transfer_attributed`, which is what shows up in
/// "New contributions / purchases" and the audit large-lot
/// filter. Everyone else sees `value()` unchanged.
/// `transfer_attributed` tracks how much. For `new_stock` /
/// `new_cd`, `internal_funded` tracks how much of the purchase was
/// funded by a same-account cash decrease (see
/// `matchIntraAccountPurchases`). The residual is
/// `value() - transfer_attributed - internal_funded`, which is
/// what shows up in "New contributions / purchases" and the audit
/// large-lot filter. Everyone else sees `value()` unchanged.
pub fn attributedValue(self: Change) f64 {
return switch (self.kind) {
.transfer_in, .transfer_out, .unmatched_transfer => 0,
.partial_transfer_in => self.value() - self.transfer_attributed,
.new_cash, .cash_contribution, .cash_delta => self.value() - self.transfer_attributed,
.new_stock, .new_cd => self.value() - self.transfer_attributed - self.internal_funded,
else => self.value(),
};
}
@ -1800,6 +1850,16 @@ fn computeReport(
);
}
// Intra-account purchase netting: a decrease in an account's cash
// (negative cash_delta or a removed cash lot) is presumed to fund
// new_stock / new_cd lots that appeared in the SAME account - a
// plain buy of existing cash, not a fresh contribution. Runs AFTER
// matchTransfers so explicit transfer records win (cash already
// claimed as a transfer_out doesn't double-fund a purchase, and a
// lot already reclassified to transfer_in is no longer new_stock /
// new_cd). See `matchIntraAccountPurchases`.
try matchIntraAccountPurchases(allocator, &changes);
// Build per-account totals.
var acct_totals = std.StringHashMap(Report.AccountTotal).init(allocator);
@ -2440,6 +2500,91 @@ fn matchInKindTransfer(
}
}
// Intra-account purchase netting
/// Net same-account cash decreases against new purchase lots.
///
/// A plain buy made with cash already in the account shows up in the
/// diff as two changes on the SAME account: a `new_stock` / `new_cd`
/// lot appearing, and the account's cash going down (a negative
/// `cash_delta`, or a `lot_removed` if the cash line was fully
/// consumed). No new money entered the portfolio - the cash was
/// already counted when it arrived - so the purchase shouldn't read
/// as a fresh contribution.
///
/// This pass builds a per-account "cash outflow" budget from those
/// decreases and draws it down against the account's `new_stock` /
/// `new_cd` changes, accumulating the funded amount onto each Change's
/// `internal_funded`. `attributedValue()` subtracts it, so a fully-
/// funded buy nets to $0 (drops out of "New contributions", per-account
/// totals, the compare attribution line, and the audit large-lot
/// nudge) while a partially-funded buy keeps the unfunded residual as
/// real new money.
///
/// Runs after `matchTransfers`, so:
/// - Cash already reclassified to `transfer_out` (an outflow to a
/// declared transfer) is NOT in the budget - it can't also fund an
/// intra-account purchase.
/// - Lots already reclassified to `transfer_in` / `partial_transfer_in`
/// are no longer `new_stock` / `new_cd`, so an explicit transfer
/// record always takes priority over this automatic netting.
///
/// Scope is deliberately narrow - only brand-new `new_stock` / `new_cd`
/// lots. `new_drip_lot` (a reinvested dividend, not a cash buy),
/// `rollup_delta` / `drip_confirmed` (share adds to an existing lot),
/// and `partial_transfer_in` residuals are left untouched. Same-account
/// only; cross-account movement stays `transaction_log.srf`'s job.
///
/// Budget is drawn in change-iteration order when an account has
/// several purchase lots; the total netted is the same regardless of
/// order, but which specific lot shows a residual can vary. This
/// mirrors `matchCashDestination`'s order-dependent draw.
fn matchIntraAccountPurchases(
allocator: std.mem.Allocator,
changes: *std.ArrayList(Change),
) !void {
// Per-account cash outflow: magnitude of net cash that left the
// account's cash pool this window. Negative cash_delta is a partial
// spend; a removed cash lot is a fully-drained line (its dollar
// amount lives in `face_value`, since `value()` is 0 for removals).
var outflow: std.StringHashMap(f64) = .init(allocator);
defer outflow.deinit();
for (changes.items) |c| {
switch (c.kind) {
.cash_delta => {
const v = c.value();
if (v < 0) {
const gop = try outflow.getOrPut(c.account);
if (!gop.found_existing) gop.value_ptr.* = 0;
gop.value_ptr.* += -v;
}
},
.lot_removed => if (c.security_type == .cash) {
const gop = try outflow.getOrPut(c.account);
if (!gop.found_existing) gop.value_ptr.* = 0;
gop.value_ptr.* += c.face_value;
},
else => {},
}
}
if (outflow.count() == 0) return;
// Draw each account's outflow down against its new purchase lots.
for (changes.items) |*c| {
switch (c.kind) {
.new_stock, .new_cd => {},
else => continue,
}
const budget = outflow.getPtr(c.account) orelse continue;
if (budget.* <= 0) continue;
const unattributed = c.attributedValue(); // value() minus any prior attribution
if (unattributed <= 0) continue;
const draw = @min(unattributed, budget.*);
c.internal_funded += draw;
budget.* -= draw;
}
}
// Output
fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, color: bool) !void {
@ -2472,12 +2617,18 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co
// Use attributedValue so a cash lot fully covered by a
// transfer record (whose `transfer_attributed` equals
// its `value()`) drops out, and a partially-covered
// lot shows only the unattributed remainder.
// lot shows only the unattributed remainder. The same
// residual logic covers `new_stock` / `new_cd` partially
// funded by a same-account cash decrease (internal_funded).
const residual = c.attributedValue();
if (residual <= 0) continue;
any = true;
new_total += residual;
try printChangeLine(out, c, color, pos_color);
if (c.internal_funded > 0) {
try printCashFundedResidualLine(out, c, color, pos_color, mut_color);
} else {
try printChangeLine(out, c, color, pos_color);
}
},
.partial_transfer_in => {
// Lot partially funded by a transfer: show the residual
@ -2572,6 +2723,31 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co
if (!any) try printNone(out, color, mut_color);
try out.writeAll("\n");
// Section: Internal purchases (cash-funded - not counted)
//
// new_stock / new_cd lots whose value was funded (wholly or in
// part) by a decrease in the SAME account's cash this window - a
// plain buy of existing cash, reclassified by
// `matchIntraAccountPurchases`. The funded portion is internal
// movement, not a fresh contribution, so it's shown here (muted)
// rather than counted in "New contributions". A partially-funded
// lot also appears in "New contributions" on its unfunded residual.
var any_internal = false;
for (report.changes) |c| {
if (c.internal_funded > 0) {
any_internal = true;
break;
}
}
if (any_internal) {
try printSection(out, "Internal purchases (cash -> securities, not counted)", color, h_color);
for (report.changes) |c| {
if (c.internal_funded <= 0) continue;
try printInternalPurchaseLine(out, c, color, mut_color);
}
try out.writeAll("\n");
}
// Section: Transfers (matched - not counted)
//
// Any record from `transaction_log.srf` that matched a
@ -2945,6 +3121,47 @@ fn printPartialTransferLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3
);
}
/// Render a `new_stock` / `new_cd` row in the "New contributions"
/// section when the lot was PARTIALLY funded by a same-account cash
/// decrease. Shows the unfunded residual (real new money) plus an
/// annotation breaking out the cash-funded portion, mirroring
/// `printPartialTransferLine`'s shape for transfers.
fn printCashFundedResidualLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8, muted: [3]u8) !void {
const acct = if (c.account.len == 0) "(no account)" else c.account;
const residual = c.attributedValue();
const lot_value = c.value();
const sym = if (c.symbol.len > 0) c.symbol else "cash";
try out.print(" {s:<14}{s:<24}", .{ sym, acct });
try cli.printFg(out, color, pos, " {f}", .{Money.from(residual)});
try cli.printFg(
out,
color,
muted,
" (of {f} total - {f} from existing cash)\n",
.{ Money.from(lot_value), Money.from(c.internal_funded) },
);
}
/// Render an "Internal purchases" row: a `new_stock` / `new_cd` lot
/// funded (wholly or in part) by a same-account cash decrease. Muted -
/// these don't count toward attribution. Shows the funded amount and,
/// when only part of the lot was cash-funded, the full lot value so
/// the unfunded residual (shown in "New contributions") reconciles.
fn printInternalPurchaseLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void {
const acct = if (c.account.len == 0) "(no account)" else c.account;
const sym = if (c.symbol.len > 0) c.symbol else "cash";
const lot_value = c.value();
try cli.setFg(out, color, muted);
try out.print(" {s:<14}{s:<24} {f} from existing cash", .{ sym, acct, Money.from(c.internal_funded) });
if (c.internal_funded + 0.005 < lot_value) {
try out.print(" (of {f} lot)", .{Money.from(lot_value)});
}
try cli.reset(out, color);
try out.writeAll("\n");
}
// Tests
const testing = std.testing;
@ -3437,6 +3654,235 @@ test "computeReport: CD open_date rewrite reclassified as edit, not new+removed"
try std.testing.expectApproxEqAbs(@as(f64, 0), drip, 0.01);
}
// Intra-account purchase netting (matchIntraAccountPurchases)
test "computeReport: stock bought with existing cash nets to zero contribution" {
// The user's scenario: $30k of cash already in the account is
// spent on a new stock lot. The diff shows new_stock +$30k plus a
// -$30k cash_delta on the SAME account. Netting funds the buy from
// the cash decrease, so it contributes nothing.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "", .shares = 20_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var new_stock: ?Change = null;
var cash_delta: ?Change = null;
for (report.changes) |c| switch (c.kind) {
.new_stock => new_stock = c,
.cash_delta => cash_delta = c,
else => {},
};
try std.testing.expect(new_stock != null);
try std.testing.expect(cash_delta != null);
// Buy fully funded by the $30k cash decrease.
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.value(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.internal_funded, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_stock.?.attributedValue(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, -30_000.0), cash_delta.?.value(), 0.01);
}
test "computeReport: stock bought with a fully-consumed cash lot (lot_removed) nets to zero" {
// Same as above but the cash line was spent in full and deleted,
// so the cash decrease surfaces as a removed cash lot rather than
// a negative cash_delta. The removed lot's dollar amount lives in
// face_value.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var new_stock: ?Change = null;
var cash_removed = false;
for (report.changes) |c| switch (c.kind) {
.new_stock => new_stock = c,
.lot_removed => if (c.security_type == .cash) {
cash_removed = true;
},
else => {},
};
try std.testing.expect(new_stock != null);
try std.testing.expect(cash_removed);
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.internal_funded, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_stock.?.attributedValue(), 0.01);
}
test "computeReport: buy partly funded by cash, partly new money surfaces the residual" {
// $30k existing cash spent + $20k new money into a single $50k buy.
// Only the $20k unfunded residual counts as a contribution.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
// Cash line fully spent (removed); $50k stock lot appears.
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var new_stock: ?Change = null;
for (report.changes) |c| {
if (c.kind == .new_stock) new_stock = c;
}
try std.testing.expect(new_stock != null);
try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), new_stock.?.value(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.internal_funded, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 20_000.0), new_stock.?.attributedValue(), 0.01);
}
test "computeReport: cross-account cash decrease does NOT fund a purchase" {
// Cash drops in Acct A; a new stock lot appears in Acct B. These
// are different accounts, so netting does not apply - the buy
// still counts (cross-account movement is transaction_log's job).
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" },
};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var new_stock: ?Change = null;
for (report.changes) |c| {
if (c.kind == .new_stock) new_stock = c;
}
try std.testing.expect(new_stock != null);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_stock.?.internal_funded, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.attributedValue(), 0.01);
}
test "computeReport: new-account deposit-and-invest still counts (no prior cash)" {
// Fresh account: cash deposited and partly invested in the same
// window. The cash never existed before, so there is no decrease
// to net against - both the new cash and the new stock are real
// contributions.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "", .shares = 20_000, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
var new_money: f64 = 0;
for (report.changes) |c| switch (c.kind) {
.new_stock, .new_cash => {
new_money += c.attributedValue();
try std.testing.expectApproxEqAbs(@as(f64, 0.0), c.internal_funded, 0.01);
},
else => {},
};
// $20k cash + $30k stock = $50k of genuinely new money.
try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), new_money, 0.01);
}
test "computeReport: existing-cash buy nets out of compare attribution too" {
// The classifier is the single source of truth, so the compare
// attribution line (summarizeAttribution) must agree with the
// report: a cash-funded buy contributes $0.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 40_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "", .shares = 10_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
// Replicate summarizeAttribution's new_contributions bucket.
var new_contributions: f64 = 0;
for (report.changes) |c| switch (c.kind) {
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => new_contributions += c.attributedValue(),
.partial_transfer_in => new_contributions += c.attributedValue(),
else => {},
};
try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_contributions, 0.01);
}
test "computeReport: transfer record takes priority over intra-account netting" {
// A buy declared as a cross-account transfer (transaction_log)
// flips to transfer_in BEFORE intra-account netting runs, so it
// must not also be credited with internal_funded even if the
// destination account happens to show a cash decrease.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "", .shares = 10_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-03,type::cash,amount:num:30000,from::Sample Source,to::Sample Brokerage,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = tlog.transfers,
});
for (report.changes) |c| {
if (std.mem.eql(u8, c.symbol, "SYM")) {
try std.testing.expectEqual(ChangeKind.transfer_in, c.kind);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), c.internal_funded, 0.01);
}
}
}
test "computeReport: stock open_price renormalized reclassified as edit" {
// Reconciliation tweak: user updates `open_price` to match the
// institutional-share-class NAV, leaving everything else alone.
@ -5443,6 +5889,73 @@ test "collectUnmatchedLargeLots: no new lots -> empty result" {
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
test "collectUnmatchedLargeLots: buy funded by same-account cash is silent" {
// The user's audit complaint: a $30k stock lot bought with cash
// already in the account. The -$30k cash_delta funds it, netting
// its attributedValue to $0, so audit must not flag it - no
// transaction_log.srf entry required.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "", .shares = 20_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
test "collectUnmatchedLargeLots: partly cash-funded buy surfaces residual only" {
// $50k buy, $30k from existing cash -> $20k of new money remains,
// which is above the $10k threshold and SHOULD still surface.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
try std.testing.expectEqual(@as(usize, 1), lots.len);
try std.testing.expectApproxEqAbs(@as(f64, 20_000.0), lots[0].value, 0.01);
}
test "collectUnmatchedLargeLots: partial cash-funded residual below threshold is silent" {
// $35k buy, $30k from existing cash -> $5k residual, below the
// $10k threshold -> silent.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const before = [_]Lot{
.{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 70, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
test "collectUnmatchedLargeLots: partial transfer still flags residual? No - full lot value counts" {
// Partial transfers leave the Change as `partial_transfer_in`,
// which the audit filter IGNORES (only new_* kinds pass the