implement per-account audit large lot threshold overrides

This commit is contained in:
Emil Lerch 2026-06-27 14:01:29 -07:00
parent 354a7e7799
commit f1f665758c
Signed by: lobo
GPG key ID: A7B62D657EF764F8
7 changed files with 263 additions and 63 deletions

11
TODO.md
View file

@ -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 <amount>` 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`

View file

@ -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`)

View file

@ -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

View file

@ -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

View file

@ -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::<NAME>,tax_type::<TYPE>[,institution::<INST>][,account_number::<NUM>]
/// Each record has: account::<NAME>,tax_type::<TYPE>[,institution::<INST>][,account_number::<NUM>][,<flags>]
/// where the optional flags include `audit_large_lot_threshold:num:<DOLLARS>`.
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

View file

@ -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();

View file

@ -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:<DOLLARS>` 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);
}