diff --git a/TODO.md b/TODO.md index 056d79b..e551ed7 100644 --- a/TODO.md +++ b/TODO.md @@ -255,17 +255,6 @@ taxonomy. The following items are acknowledged but not prioritized. Listed here so they don't get lost; pick up opportunistically. -### Audit - -- **Audit large-lot threshold tuning.** `src/commands/audit.zig` uses - `audit_large_lot_threshold: f64 = 10_000.0` as the cutoff for - "surface this new lot for confirmation." Revisit if $10k proves too - aggressive (ESPP accruals spam the report) or too permissive (large - DRIP confirmations slip past). If runtime tuning becomes necessary, - a `--large-lot ` flag or a global - `audit_large_lot_threshold` field on `accounts.srf` would be - reasonable extensions. - ### Infra / performance - **HTTP connection pooling.** Parallel server sync in `loadAllPrices` diff --git a/docs/guides/set-up-accounts.md b/docs/guides/set-up-accounts.md index f0ae3ee..af9a299 100644 --- a/docs/guides/set-up-accounts.md +++ b/docs/guides/set-up-accounts.md @@ -67,8 +67,8 @@ account::Old Rollover,tax_type::traditional,update_cadence::none ## 4. Advanced flags -Two flags change how analysis treats an account. Both are optional -- -see the reference for details: +Three optional fields change how analysis and the audit treat an +account -- see the reference for details: - **`shielded:bool:false`** -- mark a pre-tax account that is *not* judgment-protected (deferred comp, a weak-state IRA) so it counts @@ -79,6 +79,15 @@ see the reference for details: account as real external contributions in [`zfin contributions`](track-contributions.md), instead of internal noise. +- **`audit_large_lot_threshold:num:50000`** -- raise (or lower) the + dollar cutoff at which a flagless [`zfin audit`](../reference/cli/audit.md) + nudges you to confirm a **new lot**'s source. The default is $10,000; + bump it on a noisy ESPP/payroll account so routine accruals stop + spamming the report, while leaving quieter accounts at the default: + + ```srf + account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000 + ``` ## Example (from `examples/pre-retirement-both`) diff --git a/docs/reference/cli/audit.md b/docs/reference/cli/audit.md index 568025e..934c833 100644 --- a/docs/reference/cli/audit.md +++ b/docs/reference/cli/audit.md @@ -27,6 +27,13 @@ Reconciliation matches export accounts to yours via `institution::` and `account_number::` in [`accounts.srf`](../config/accounts-srf.md); an unmatched account is reported as "unmapped." +The hygiene check also flags newly-appeared lots worth at least +$10,000 in a **Large new lots - confirm source** section, so you can +confirm whether each is a real contribution or an unrecorded transfer. +The cutoff is per account -- raise or lower it on an account's record +via [`audit_large_lot_threshold`](../config/accounts-srf.md#audit_large_lot_threshold) +in `accounts.srf` (e.g. to silence a noisy ESPP account). + ## Example (hygiene check) ```bash diff --git a/docs/reference/config/accounts-srf.md b/docs/reference/config/accounts-srf.md index 2574c08..45f91b8 100644 --- a/docs/reference/config/accounts-srf.md +++ b/docs/reference/config/accounts-srf.md @@ -24,16 +24,17 @@ account::Joint taxable,tax_type::taxable,institution::schwab,account_number::JT0 ## Fields -| Field | Type | Required | Default | Description | -|------------------------|--------|----------|-----------|----------------------------------------------------------------------------------------------------------------------------------| -| `account` | string | Yes | -- | Account name; must match `account::` on lots exactly. | -| `tax_type` | string | Yes | -- | `taxable`, `roth`, `traditional`, or `hsa`. | -| `institution` | string | No | -- | Broker key, e.g. `fidelity`, `schwab`, `vanguard`, `wells_fargo`. Used by [`zfin audit`](../cli/audit.md) to match export files. | -| `account_number` | string | No | -- | Account identifier used with `institution` for audit matching. Use a placeholder, not a full real number. | -| `update_cadence` | string | No | `weekly` | How often you refresh this account's manual data: `weekly`, `monthly`, `quarterly`, or `none`. Drives the audit staleness nag. | -| `cash_is_contribution` | bool | No | `false` | When `true`, raw cash-balance increases on this account count as real external contributions (see below). | -| `direct_indexing` | bool | No | `false` | Marks an account whose lots track a benchmark with tracking-error drift (loosens contribution/audit tolerances). | -| `shielded` | bool | No | (derived) | Umbrella-exposure override (see below). | +| Field | Type | Required | Default | Description | +|-----------------------------|--------|----------|-----------|----------------------------------------------------------------------------------------------------------------------------------| +| `account` | string | Yes | -- | Account name; must match `account::` on lots exactly. | +| `tax_type` | string | Yes | -- | `taxable`, `roth`, `traditional`, or `hsa`. | +| `institution` | string | No | -- | Broker key, e.g. `fidelity`, `schwab`, `vanguard`, `wells_fargo`. Used by [`zfin audit`](../cli/audit.md) to match export files. | +| `account_number` | string | No | -- | Account identifier used with `institution` for audit matching. Use a placeholder, not a full real number. | +| `update_cadence` | string | No | `weekly` | How often you refresh this account's manual data: `weekly`, `monthly`, `quarterly`, or `none`. Drives the audit staleness nag. | +| `cash_is_contribution` | bool | No | `false` | When `true`, raw cash-balance increases on this account count as real external contributions (see below). | +| `direct_indexing` | bool | No | `false` | Marks an account whose lots track a benchmark with tracking-error drift (loosens contribution/audit tolerances). | +| `shielded` | bool | No | (derived) | Umbrella-exposure override (see below). | +| `audit_large_lot_threshold` | num | No | `10000` | Per-account dollar cutoff for the audit "Large new lots" nudge (see below). Must be positive. | ## Tax types @@ -47,6 +48,33 @@ account::Joint taxable,tax_type::taxable,institution::schwab,account_number::JT0 Any other value is shown as-is. Accounts missing from `accounts.srf` appear as "Unknown". +## `audit_large_lot_threshold` + +When [`zfin audit`](../cli/audit.md) runs flagless, its **Large new +lots - confirm source** section flags any newly-appeared lot worth at +least this many dollars, nudging you to confirm whether it's a real +external contribution or an unrecorded internal transfer. Smaller new +lots pass silently so routine payroll/ESPP accruals and weekly deposits +don't spam the report. + +The threshold is **per account**, because the noise it fights is +account-specific: an ESPP or payroll account that accrues routine large +lots wants a high bar, while a taxable brokerage where any sizeable new +lot deserves a look wants the default (or lower). Set it on the +account's own record: + +```srf +#!srfv1 +account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000 +account::Sample Brokerage,tax_type::taxable +``` + +Here the ESPP account stays quiet until a new lot tops $50k, while +`Sample Brokerage` (no override) uses the built-in `$10,000` default. +Accounts you don't list, or list without the field, use that default. +The value must be **positive** -- zero or a negative number is rejected +at load time and the account falls back to the default. + ## `update_cadence` and the audit nag [`zfin audit`](../cli/audit.md) (run flagless) flags accounts you diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index 4884a3d..04a7bc1 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -10,6 +10,8 @@ const ClassificationEntry = @import("../models/classification.zig").Classificati const Portfolio = @import("../models/portfolio.zig").Portfolio; const Date = @import("../Date.zig"); +const log = std.log.scoped(.accounts); + /// A single slice of a breakdown (e.g., "Technology" -> 25.3%) pub const BreakdownItem = struct { label: []const u8, @@ -94,6 +96,22 @@ pub const AccountTaxEntry = struct { /// `shielded:bool:false` on their IRA accounts to get a /// correct umbrella-exposure number. shielded: ?bool = null, + /// Optional per-account override for the dollar threshold above + /// which `zfin audit` flags a new lot in its "Large new lots - + /// confirm source" section. Null means "use the audit's built-in + /// default" (`contributions.default_audit_large_lot_threshold`, $10k). + /// + /// The right knob is per-account because the noise this nudge + /// fights is account-specific: an ESPP/payroll account that + /// accrues routine large lots wants a HIGH threshold to stay + /// quiet, while a taxable brokerage where any sizeable new lot is + /// worth a look wants the default (or lower). Set it higher to cut + /// ESPP spam, lower to catch smaller movements. + /// + /// Must be positive; a zero or negative value is rejected at parse + /// time (warned + treated as unset) since zero would flag every + /// new lot and negative is meaningless. + audit_large_lot_threshold: ?f64 = null, }; /// Update cadence for manual account maintenance. Parsed from accounts.srf. @@ -196,10 +214,26 @@ pub const AccountMap = struct { } return false; } + + /// Per-account override for the audit "Large new lots" dollar + /// threshold. Returns the account's configured value, or null to + /// fall back to the audit's built-in default + /// (`contributions.default_audit_large_lot_threshold`). Null both when + /// the account isn't in the map and when its entry omits the + /// field. Parse guarantees any non-null result is positive. + pub fn largeLotThresholdFor(self: AccountMap, account: []const u8) ?f64 { + for (self.entries) |e| { + if (std.mem.eql(u8, e.account, account)) { + return e.audit_large_lot_threshold; + } + } + return null; + } }; /// Parse an accounts.srf file into an AccountMap. -/// Each record has: account::,tax_type::[,institution::][,account_number::] +/// Each record has: account::,tax_type::[,institution::][,account_number::][,] +/// where the optional flags include `audit_large_lot_threshold:num:`. pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap { var entries = std.ArrayList(AccountTaxEntry).empty; errdefer { @@ -217,6 +251,16 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun while (try it.next()) |fields| { const entry = fields.to(AccountTaxEntry, .{}) catch continue; + + // A zero/negative large-lot threshold is nonsensical (zero + // flags every new lot; negative is meaningless). Reject it and + // treat the account as unset so the audit uses its default. + const lot_threshold: ?f64 = if (entry.audit_large_lot_threshold) |t| blk: { + if (t > 0) break :blk t; + log.warn("accounts.srf: account '{s}': audit_large_lot_threshold must be > 0 (got {d}); ignoring", .{ entry.account, t }); + break :blk null; + } else null; + try entries.append(allocator, .{ .account = try allocator.dupe(u8, entry.account), .tax_type = entry.tax_type, @@ -226,6 +270,7 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun .cash_is_contribution = entry.cash_is_contribution, .direct_indexing = entry.direct_indexing, .shielded = entry.shielded, + .audit_large_lot_threshold = lot_threshold, }); } @@ -945,6 +990,71 @@ test "parseAccountsFile: shielded:bool:true override (rare, e.g. asset-protectio try std.testing.expect(am.entries[0].shielded.?); } +test "parseAccountsFile: audit_large_lot_threshold omitted -> null (use audit default)" { + const data = + \\#!srfv1 + \\account::Sample Roth,tax_type::roth + \\account::Sample Brokerage,tax_type::taxable + ; + const allocator = std.testing.allocator; + var am = try parseAccountsFile(allocator, data); + defer am.deinit(); + + try std.testing.expectEqual(@as(usize, 2), am.entries.len); + // No override on either account -> lookup returns null so the + // audit falls back to its built-in default. + try std.testing.expect(am.entries[0].audit_large_lot_threshold == null); + try std.testing.expect(am.largeLotThresholdFor("Sample Roth") == null); + try std.testing.expect(am.largeLotThresholdFor("Sample Brokerage") == null); +} + +test "parseAccountsFile: per-account audit_large_lot_threshold parses and is looked up by account" { + // Mixed: one account raises its threshold (e.g. a noisy ESPP + // account), a sibling leaves it default. + const data = + \\#!srfv1 + \\account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000 + \\account::Sample Brokerage,tax_type::taxable + ; + const allocator = std.testing.allocator; + var am = try parseAccountsFile(allocator, data); + defer am.deinit(); + + try std.testing.expectEqual(@as(usize, 2), am.entries.len); + try std.testing.expectApproxEqAbs(@as(f64, 50000.0), am.largeLotThresholdFor("Sample ESPP").?, 0.01); + // Sibling account has no override. + try std.testing.expect(am.largeLotThresholdFor("Sample Brokerage") == null); + // Unknown account -> null (falls back to default downstream). + try std.testing.expect(am.largeLotThresholdFor("Nonexistent") == null); +} + +test "parseAccountsFile: audit_large_lot_threshold accepts a fractional value" { + const data = + \\#!srfv1 + \\account::Sample Brokerage,tax_type::taxable,audit_large_lot_threshold:num:7500.5 + ; + const allocator = std.testing.allocator; + var am = try parseAccountsFile(allocator, data); + defer am.deinit(); + + try std.testing.expectApproxEqAbs(@as(f64, 7500.5), am.largeLotThresholdFor("Sample Brokerage").?, 0.001); +} + +test "parseAccountsFile: non-positive audit_large_lot_threshold is rejected -> null" { + // Zero and negative thresholds are nonsensical (zero flags every + // new lot; negative is meaningless). They're dropped at parse + // time so the audit falls back to its built-in default. The + // surrounding account still parses. + inline for (.{ "0", "-5000" }) |bad| { + const data = "#!srfv1\naccount::Sample Brokerage,tax_type::taxable,audit_large_lot_threshold:num:" ++ bad ++ "\n"; + const allocator = std.testing.allocator; + var am = try parseAccountsFile(allocator, data); + defer am.deinit(); + try std.testing.expectEqual(@as(usize, 1), am.entries.len); + try std.testing.expect(am.largeLotThresholdFor("Sample Brokerage") == null); + } +} + // ── umbrellaExposure ───────────────────────────────────────── /// Helper: build an in-memory AccountMap from a literal SRF diff --git a/src/commands/audit/hygiene.zig b/src/commands/audit/hygiene.zig index e428fd5..355d53e 100644 --- a/src/commands/audit/hygiene.zig +++ b/src/commands/audit/hygiene.zig @@ -37,20 +37,6 @@ const audit_file_max_size_non_csv = 512 * 1024; // 512KB, for non-CSV files only pub const default_stale_days: u32 = 3; const stale_warning_multiplier: u32 = 2; // yellow -> red at 2× threshold -/// Dollar threshold above which a new lot (new_stock / new_drip_lot / -/// new_cash / new_cd / cash_contribution) gets flagged in the -/// "Large new lots - confirm source" hygiene section. Below this -/// threshold new lots pass silently - the audit's goal is to catch -/// unconfirmed six-figure movements, not flag every payroll -/// contribution. -/// -/// $10k is a judgment call: high enough to ignore routine payroll -/// ESPP accruals and $1-$2k weekly deposits, low enough to surface -/// a typical IRA contribution or a genuine transfer. Tunable here, -/// per the plan's "revisit if the threshold proves wrong" note in -/// TODO.md. -const audit_large_lot_threshold: f64 = 10_000.0; - /// Type of a discovered brokerage file. const BrokerFileKind = enum { fidelity_csv, @@ -992,9 +978,10 @@ pub fn runHygieneCheck( // // Silent when every large lot matched a transfer record, when // there are no new lots at all, or when the pipeline can't run - // (not in a git repo). Threshold is a judgment call; see - // `audit_large_lot_threshold`. - if (contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_path, audit_large_lot_threshold, as_of, color, refresh)) |found| { + // (not in a git repo). Threshold is per-account: an account's + // `audit_large_lot_threshold` in accounts.srf wins, otherwise the + // filter's built-in default applies. + if (contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_path, &account_map, as_of, color, refresh)) |found| { var found_mut = found; defer found_mut.deinit(); diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index a1f65af..e4a55f5 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -933,9 +933,25 @@ pub fn computeAttributionSpec( // ── Public audit hook ──────────────────────────────────────── +/// Default dollar threshold above which a new lot (new_stock / +/// new_drip_lot / new_cash / new_cd / cash_contribution) gets flagged +/// in the audit "Large new lots - confirm source" section. Below this +/// threshold new lots pass silently - the goal is to catch unconfirmed +/// six-figure movements, not flag every payroll contribution. +/// +/// $10k is a judgment call: high enough to ignore routine payroll ESPP +/// accruals and $1-$2k weekly deposits, low enough to surface a typical +/// IRA contribution or a genuine transfer. This is only the fallback - +/// an account can override it per-account with an +/// `audit_large_lot_threshold:num:` field on its accounts.srf +/// record (see `AccountTaxEntry.audit_large_lot_threshold` / +/// `AccountMap.largeLotThresholdFor`), so a noisy ESPP account can +/// raise its bar without going blind on a quiet brokerage account. +const default_audit_large_lot_threshold: f64 = 10_000.0; + /// Descriptor of a "large new lot" the audit command may want to /// surface. Emitted by `findUnmatchedLargeLots` for any new-side -/// Change whose `value()` meets the caller's threshold and which +/// Change whose `value()` meets the resolved threshold and which /// was NOT reclassified by the transfer-log matcher. All string /// fields are caller-arena-owned through the `UnmatchedLargeLotSet` /// wrapper; the caller frees everything at once via `deinit`. @@ -966,12 +982,18 @@ pub const UnmatchedLargeLotSet = struct { }; /// Find new-side lots (new_stock / new_drip_lot / new_cash / new_cd -/// / cash_contribution) with `value() >= threshold` that weren't -/// matched to a record in `transaction_log.srf` over the HEAD -> -/// working-copy window. Mirrors the `zfin contributions` zero-flag -/// path - uses `prepareReport`'s shared git + portfolio + transfer -/// plumbing so the classification is identical. Returns null if the -/// pipeline can't resolve a window (not in a git repo, etc.). +/// / cash_contribution) whose unattributed value meets the audit +/// large-lot threshold but weren't matched to a record in +/// `transaction_log.srf` over the HEAD -> working-copy window. +/// Mirrors the `zfin contributions` zero-flag path - uses +/// `prepareReport`'s shared git + portfolio + transfer plumbing so +/// the classification is identical. Returns null if the pipeline +/// can't resolve a window (not in a git repo, etc.). +/// +/// The threshold is resolved per lot: an account's +/// `audit_large_lot_threshold` (from `account_map`) wins, otherwise +/// `default_audit_large_lot_threshold` applies. Pass `account_map = +/// null` to use the default for everything. /// /// Consumed by `zfin audit` to prompt the user to either confirm /// the lot as an external contribution or add a transfer record @@ -984,7 +1006,7 @@ pub fn findUnmatchedLargeLots( env: *const std.process.Environ.Map, svc: *zfin.DataService, portfolio_path: []const u8, - threshold: f64, + account_map: ?*const analysis.AccountMap, as_of: Date, color: bool, refresh: framework.RefreshPolicy, @@ -1007,7 +1029,7 @@ pub fn findUnmatchedLargeLots( }; defer ctx.deinit(); - const lots = collectUnmatchedLargeLots(arena, ctx.report.changes, threshold) catch { + const lots = collectUnmatchedLargeLots(arena, ctx.report.changes, account_map) catch { arena_state.deinit(); return null; }; @@ -1061,7 +1083,7 @@ pub fn findUnmatchedLargeLots( fn collectUnmatchedLargeLots( arena: std.mem.Allocator, changes: []const Change, - threshold: f64, + account_map: ?*const analysis.AccountMap, ) ![]UnmatchedLargeLot { var out: std.ArrayList(UnmatchedLargeLot) = .empty; for (changes) |c| { @@ -1071,6 +1093,14 @@ fn collectUnmatchedLargeLots( }; if (!is_new_side) continue; + // Per-account threshold: the lot's account can raise/lower its + // own cutoff (e.g. a noisy ESPP account); otherwise the + // built-in default applies. + const threshold = if (account_map) |am| + (am.largeLotThresholdFor(c.account) orelse default_audit_large_lot_threshold) + else + default_audit_large_lot_threshold; + // Use attributedValue() so a fully-attributed cash lot // (whose `transfer_attributed` covers the whole `value()`) // drops out, and a partially-attributed cash lot surfaces @@ -5684,7 +5714,7 @@ test "collectUnmatchedLargeLots: below threshold is silent" { }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); - const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); + const lots = try collectUnmatchedLargeLots(allocator, report.changes, null); try std.testing.expectEqual(@as(usize, 0), lots.len); } @@ -5704,7 +5734,7 @@ test "collectUnmatchedLargeLots: unmatched large stock lot surfaces" { }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); - const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); + const lots = try collectUnmatchedLargeLots(allocator, report.changes, null); try std.testing.expectEqual(@as(usize, 1), lots.len); try std.testing.expectEqualStrings("Acct A", lots[0].account); @@ -5714,6 +5744,46 @@ test "collectUnmatchedLargeLots: unmatched large stock lot surfaces" { try std.testing.expectEqual(Date.fromYmd(2026, 5, 3).days, lots[0].open_date.days); } +test "collectUnmatchedLargeLots: per-account threshold suppresses one account, default flags the other" { + 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(); + try prices.put("ESPPSYM", 300.0); + try prices.put("BRKSYM", 300.0); + + const before = [_]Lot{}; + // Two new $30k lots in different accounts. + const after = [_]Lot{ + .{ .symbol = "ESPPSYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 300, .account = "Sample ESPP" }, + .{ .symbol = "BRKSYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 300, .account = "Sample Brokerage" }, + }; + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); + + // Sanity: with no account_map, both $30k lots clear the $10k + // default and surface. This isolates the override as the cause of + // the difference below. + const both = try collectUnmatchedLargeLots(allocator, report.changes, null); + try std.testing.expectEqual(@as(usize, 2), both.len); + + // ESPP raises its own threshold to $50k (routine large accruals); + // Sample Brokerage leaves it at the default. Now only the + // brokerage lot surfaces - the $30k ESPP lot is below its $50k bar. + var am = try analysis.parseAccountsFile(allocator, + \\#!srfv1 + \\account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000 + \\account::Sample Brokerage,tax_type::taxable + ); + defer am.deinit(); + + const lots = try collectUnmatchedLargeLots(allocator, report.changes, &am); + try std.testing.expectEqual(@as(usize, 1), lots.len); + try std.testing.expectEqualStrings("Sample Brokerage", lots[0].account); + try std.testing.expectEqualStrings("BRKSYM", lots[0].symbol); + try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), lots[0].value, 0.01); +} + test "collectUnmatchedLargeLots: unmatched large cash lot surfaces" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); @@ -5727,7 +5797,7 @@ test "collectUnmatchedLargeLots: unmatched large cash lot surfaces" { }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 11), .{}); - const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); + const lots = try collectUnmatchedLargeLots(allocator, report.changes, null); try std.testing.expectEqual(@as(usize, 1), lots.len); try std.testing.expectEqual(LotType.cash, lots[0].security_type); @@ -5761,7 +5831,7 @@ test "collectUnmatchedLargeLots: matched via transfer log is silent" { // Sanity: the lot should have been reclassified. try std.testing.expectEqual(ChangeKind.transfer_in, report.changes[0].kind); - const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); + const lots = try collectUnmatchedLargeLots(allocator, report.changes, null); try std.testing.expectEqual(@as(usize, 0), lots.len); } @@ -5811,7 +5881,7 @@ test "collectUnmatchedLargeLots: cash-destination matched is silent" { try std.testing.expectEqual(@as(f64, 73158.33), attributed); // The audit filter must subtract the attribution and stay quiet. - const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); + const lots = try collectUnmatchedLargeLots(allocator, report.changes, null); try std.testing.expectEqual(@as(usize, 0), lots.len); } @@ -5841,7 +5911,7 @@ test "collectUnmatchedLargeLots: cash-destination partial match surfaces residua .transfer_log = tlog.transfers, }); - const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); + const lots = try collectUnmatchedLargeLots(allocator, report.changes, null); try std.testing.expectEqual(@as(usize, 1), lots.len); try std.testing.expectEqual(@as(f64, 20000.0), lots[0].value); } @@ -5870,7 +5940,7 @@ test "collectUnmatchedLargeLots: cash-destination partial below threshold is sil .transfer_log = tlog.transfers, }); - const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); + const lots = try collectUnmatchedLargeLots(allocator, report.changes, null); try std.testing.expectEqual(@as(usize, 0), lots.len); } @@ -5885,7 +5955,7 @@ test "collectUnmatchedLargeLots: no new lots -> empty result" { const after = [_]Lot{}; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); - const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); + const lots = try collectUnmatchedLargeLots(allocator, report.changes, null); try std.testing.expectEqual(@as(usize, 0), lots.len); } @@ -5909,7 +5979,7 @@ test "collectUnmatchedLargeLots: buy funded by same-account cash is silent" { }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); - const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); + const lots = try collectUnmatchedLargeLots(allocator, report.changes, null); try std.testing.expectEqual(@as(usize, 0), lots.len); } @@ -5930,7 +6000,7 @@ test "collectUnmatchedLargeLots: partly cash-funded buy surfaces residual only" }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); - const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); + const lots = try collectUnmatchedLargeLots(allocator, report.changes, null); try std.testing.expectEqual(@as(usize, 1), lots.len); try std.testing.expectApproxEqAbs(@as(f64, 20_000.0), lots[0].value, 0.01); } @@ -5952,7 +6022,7 @@ test "collectUnmatchedLargeLots: partial cash-funded residual below threshold is }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); - const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); + const lots = try collectUnmatchedLargeLots(allocator, report.changes, null); try std.testing.expectEqual(@as(usize, 0), lots.len); } @@ -5991,7 +6061,7 @@ test "collectUnmatchedLargeLots: partial transfer still flags residual? No - ful try std.testing.expectEqual(ChangeKind.partial_transfer_in, report.changes[0].kind); - const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); + const lots = try collectUnmatchedLargeLots(allocator, report.changes, null); try std.testing.expectEqual(@as(usize, 0), lots.len); }