contributions to consider shares purchased with cash in account

This commit is contained in:
Emil Lerch 2026-06-27 11:40:58 -07:00
parent d59555bf92
commit 0882e6321f
Signed by: lobo
GPG key ID: A7B62D657EF764F8

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