surface suspected account cash transfers through audit command

This commit is contained in:
Emil Lerch 2026-05-07 14:32:36 -07:00
parent 7729ac8d7e
commit 88de7a9882
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 437 additions and 2 deletions

View file

@ -4,6 +4,7 @@ const cli = @import("common.zig");
const fmt = cli.fmt;
const analysis = @import("../analytics/analysis.zig");
const portfolio_mod = @import("../models/portfolio.zig");
const contributions = @import("contributions.zig");
// Brokerage position (normalized from any source)
@ -1446,6 +1447,20 @@ const audit_file_max_size_non_csv = 512 * 1024; // 512KB, for non-CSV files only
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,
@ -1651,6 +1666,68 @@ fn stalenessColor(age_days: i32, threshold: u32) [3]u8 {
return cli.CLR_NEGATIVE;
}
/// Render one unmatched large-lot warning. Formats the line the
/// user needs to paste into `transaction_log.srf` if the lot was
/// an internal movement rather than a real external contribution.
/// Leaves `from::<SOURCE>` as a placeholder the audit doesn't
/// know which account the money came from.
///
/// Stock / CD destinations use `dest_lot::SYMBOL@OPEN_DATE`; cash
/// (or cash_contribution) destinations use `dest_lot::cash`. The
/// template always uses `type::cash` since `type::in_kind` is
/// rejected downstream in v1.
fn printLargeLotWarning(
out: *std.Io.Writer,
lot: contributions.UnmatchedLargeLot,
color: bool,
) !void {
var val_buf: [32]u8 = undefined;
var date_buf: [10]u8 = undefined;
const value_str = fmt.fmtMoneyAbs(&val_buf, lot.value);
const date_str = lot.open_date.format(&date_buf);
const kind_label: []const u8 = switch (lot.security_type) {
.stock => "STOCK",
.cash => "CASH",
.cd => "CD",
.option => "OPTION",
else => "LOT",
};
const sym_for_display = if (lot.symbol.len > 0) lot.symbol else "cash";
try out.print(
" {s}: new {s} lot {s} ",
.{ lot.account, kind_label, sym_for_display },
);
try cli.printFg(out, color, cli.CLR_POSITIVE, "+{s}", .{value_str});
try out.print(" on {s}\n", .{date_str});
try cli.printFg(out, color, cli.CLR_MUTED, " If this was an external contribution: no action needed.\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " If this was an internal transfer, add to transaction_log.srf:\n", .{});
// Amount formatted as a whole-dollar number for the `num:`
// encoding; precise-to-the-cent values are rare in practice
// and callers can edit the template if needed.
const amount_int: i64 = @intFromFloat(@round(lot.value));
if (lot.security_type == .cash) {
try cli.printFg(
out,
color,
cli.CLR_MUTED,
" transfer::{s},type::cash,amount:num:{d},from::<SOURCE>,to::{s},dest_lot::cash\n",
.{ date_str, amount_int, lot.account },
);
} else {
try cli.printFg(
out,
color,
cli.CLR_MUTED,
" transfer::{s},type::cash,amount:num:{d},from::<SOURCE>,to::{s},dest_lot::{s}@{s}\n",
.{ date_str, amount_int, lot.account, lot.symbol, date_str },
);
}
}
/// Run the flagless portfolio hygiene check.
fn runHygieneCheck(
allocator: std.mem.Allocator,
@ -2044,6 +2121,31 @@ fn runHygieneCheck(
}
}
// Section 5: Large new lots confirm source
//
// Cross-check any new_* Change with value >= threshold against
// `transaction_log.srf` (via the shared contributions pipeline).
// Surfaces lots that look like significant external contributions
// OR unrecorded internal transfers nudges the user to either
// confirm or add a transfer record.
//
// 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(allocator, svc, portfolio_path, audit_large_lot_threshold, color)) |found| {
var found_mut = found;
defer found_mut.deinit();
if (found_mut.lots.len > 0) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Large new lots — confirm source\n", .{});
for (found_mut.lots) |lot| {
try printLargeLotWarning(out, lot, color);
}
}
}
try out.print("\n", .{});
}
@ -3003,3 +3105,47 @@ test "discoverBrokerFiles: nonexistent directory returns empty" {
try std.testing.expectEqual(@as(usize, 0), files.len);
}
test "printLargeLotWarning: cash destination emits dest_lot::cash template" {
var buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&buf);
const lot: contributions.UnmatchedLargeLot = .{
.account = "Acct A",
.symbol = "",
.security_type = .cash,
.value = 50_000.0,
.open_date = @import("../models/date.zig").Date.fromYmd(2026, 5, 10),
};
try printLargeLotWarning(&writer, lot, false); // color=false no ANSI escapes
const output = writer.buffered();
// Header line with account + value + date.
try std.testing.expect(std.mem.indexOf(u8, output, "Acct A: new CASH lot cash") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "+$50,000.00") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "on 2026-05-10") != null);
// Template line with the expected SRF shape.
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-10,type::cash,amount:num:50000,from::<SOURCE>,to::Acct A,dest_lot::cash") != null);
}
test "printLargeLotWarning: stock destination emits dest_lot::SYM@DATE template" {
var buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&buf);
const lot: contributions.UnmatchedLargeLot = .{
.account = "Acct B",
.symbol = "SYM",
.security_type = .stock,
.value = 25_000.0,
.open_date = @import("../models/date.zig").Date.fromYmd(2026, 5, 3),
};
try printLargeLotWarning(&writer, lot, false);
const output = writer.buffered();
try std.testing.expect(std.mem.indexOf(u8, output, "Acct B: new STOCK lot SYM") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "+$25,000.00") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-03,type::cash,amount:num:25000,from::<SOURCE>,to::Acct B,dest_lot::SYM@2026-05-03") != null);
}

View file

@ -693,6 +693,127 @@ pub fn computeAttributionSpec(
return summarizeAttribution(ctx);
}
// Public audit hook
/// 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
/// 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`.
pub const UnmatchedLargeLot = struct {
/// The account the lot landed on (arena-owned copy).
account: []const u8,
/// Security ticker, or empty string for cash-kind lots.
symbol: []const u8,
/// `.stock`, `.cash`, `.cd`, etc. Drives the dest_lot shape in
/// the suggested template.
security_type: LotType,
/// Dollar value of the lot (`Change.value()`).
value: f64,
/// Lot open_date. Used in the `dest_lot::SYMBOL@OPEN_DATE`
/// template for stock / CD destinations.
open_date: Date,
};
/// Result wrapper that owns both the slice and the arena the string
/// fields live in. Caller must `deinit`.
pub const UnmatchedLargeLotSet = struct {
lots: []UnmatchedLargeLot,
arena: std.heap.ArenaAllocator,
pub fn deinit(self: *UnmatchedLargeLotSet) void {
self.arena.deinit();
}
};
/// 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.).
///
/// Consumed by `zfin audit` to prompt the user to either confirm
/// the lot as an external contribution or add a transfer record
/// when it was really an internal movement. Works whether or not
/// `transaction_log.srf` exists when absent, every large lot
/// surfaces since nothing gets reclassified.
pub fn findUnmatchedLargeLots(
allocator: std.mem.Allocator,
svc: *zfin.DataService,
portfolio_path: []const u8,
threshold: f64,
color: bool,
) ?UnmatchedLargeLotSet {
var arena_state = std.heap.ArenaAllocator.init(allocator);
errdefer arena_state.deinit();
const arena = arena_state.allocator();
// Explicit null/null selects the legacy zero-flag path:
// before=HEAD~1..HEAD if clean, HEAD..working-copy if dirty.
// Audit cares about the dirty-working-copy case in practice
// (that's where "unconfirmed large lots" live), but both
// branches are valid consumers.
//
// Separate allocator here so we can tear the whole thing down
// via `arena_state.deinit` once we've copied out the descriptors.
var ctx = prepareReport(allocator, arena, svc, portfolio_path, null, null, color, .silent) catch {
arena_state.deinit();
return null;
};
defer ctx.deinit();
const lots = collectUnmatchedLargeLots(arena, ctx.report.changes, threshold) catch {
arena_state.deinit();
return null;
};
return .{ .lots = lots, .arena = arena_state };
}
/// Pure filter: pick out new-side Changes with `value() >= threshold`
/// and dupe their string fields into `arena`. Split out so tests can
/// feed a synthetic `[]Change` without running the git/IO pipeline.
///
/// Deliberately excludes `partial_transfer_in`. A partial lot already
/// has an explicit transfer record acknowledging the large movement;
/// the unmatched residual is typically small (pre-existing cash that
/// topped the lot off) and surfacing it again would nag on something
/// the user has already documented. If a residual is large enough to
/// care about independently, the user can review the lot's full value
/// via `zfin contributions` this filter's job is to catch
/// *unrecorded* large movements, not to re-flag partial ones.
fn collectUnmatchedLargeLots(
arena: std.mem.Allocator,
changes: []const Change,
threshold: f64,
) ![]UnmatchedLargeLot {
var out: std.ArrayList(UnmatchedLargeLot) = .empty;
for (changes) |c| {
const is_new_side = switch (c.kind) {
.new_stock, .new_drip_lot, .new_cash, .new_cd, .cash_contribution => true,
else => false,
};
if (!is_new_side) continue;
if (c.value() < threshold) continue;
// open_date is populated in Pass 1's new-lot branch; absence
// here would be a pipeline bug.
const od = c.open_date orelse return error.MissingOpenDate;
const account_copy = try arena.dupe(u8, c.account);
const symbol_copy = try arena.dupe(u8, c.symbol);
try out.append(arena, .{
.account = account_copy,
.symbol = symbol_copy,
.security_type = c.security_type,
.value = c.value(),
.open_date = od,
});
}
return out.toOwnedSlice(arena);
}
fn summarizeAttribution(ctx: ReportContext) AttributionSummary {
// Aggregate. Classification logic matches the full-report sections:
@ -822,6 +943,11 @@ const Change = struct {
new_price: f64 = 0,
/// Free-form detail for flagged changes.
detail: ?[]const u8 = null,
/// Lot open_date for new_* kinds carried here so downstream
/// consumers (the audit large-lot warning, the Transfers section
/// printer) can generate `transfer_log.srf` templates without
/// re-reading the after-portfolio. Null for non-new kinds.
open_date: ?Date = null,
// Transfer reclassification (see `matchTransfers`)
/// Dollar amount of this Change's `value()` that's attributable
@ -1348,6 +1474,7 @@ fn computeReport(
.security_type = lot.security_type,
.delta_shares = after_agg.shares,
.unit_value = unit_value,
.open_date = lot.open_date,
});
}
}
@ -3352,7 +3479,7 @@ test "short: short input returned as-is" {
try std.testing.expectEqualStrings("abc", short("abc"));
}
// matchTransfers tests (M2)
// matchTransfers tests
//
// These exercise the transfer reclassification pipeline end-to-end
// via `computeReport(... .{ .transfer_log = &log, ... })`. Each test
@ -3788,7 +3915,7 @@ test "matchTransfers: null transfer_log is a no-op (backward compat)" {
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
};
// No transfer_log passed same behavior as before M2.
// No transfer_log passed baseline behavior with no reclassification.
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
@ -3838,3 +3965,165 @@ test "matchTransfers: attribution excludes transferred amount" {
try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_contributions, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), drip, 0.01);
}
// collectUnmatchedLargeLots tests
//
// These exercise the audit large-lot filter by building a `Report`
// via `computeReport` (with or without a transfer log) and feeding
// its changes directly to `collectUnmatchedLargeLots`. That skips
// the git + IO plumbing of `findUnmatchedLargeLots` while still
// covering the classification path the production code uses.
test "collectUnmatchedLargeLots: below threshold is 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();
try prices.put("SYM", 100.0);
const before = [_]Lot{};
// $5k lot under the $10k threshold used by audit.
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 50, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct A" },
};
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: unmatched large stock lot surfaces" {
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("SYM", 500.0);
const before = [_]Lot{};
// $50k stock lot, no transfer log, should surface.
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" },
};
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.expectEqualStrings("Acct A", lots[0].account);
try std.testing.expectEqualStrings("SYM", lots[0].symbol);
try std.testing.expectEqual(LotType.stock, lots[0].security_type);
try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), lots[0].value, 0.01);
try std.testing.expectEqual(Date.fromYmd(2026, 5, 3).days, lots[0].open_date.days);
}
test "collectUnmatchedLargeLots: unmatched large cash lot surfaces" {
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 = "cash", .shares = 50_000, .open_date = Date.fromYmd(2026, 5, 10), .open_price = 1.0, .security_type = .cash, .account = "Acct A" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 11), .{});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
try std.testing.expectEqual(@as(usize, 1), lots.len);
try std.testing.expectEqual(LotType.cash, lots[0].security_type);
try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), lots[0].value, 0.01);
}
test "collectUnmatchedLargeLots: matched via transfer log is 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();
try prices.put("SYM", 500.0);
const before = [_]Lot{};
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" },
};
// Transfer log fully covers the lot kind flips to transfer_in.
var tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:50000,from::Acct B,to::Acct A,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = &tlog,
.window_start = Date.fromYmd(2026, 1, 1),
.window_end = Date.fromYmd(2026, 12, 31),
});
// 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);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
test "collectUnmatchedLargeLots: no new lots → empty result" {
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{};
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
// `is_new_side` check). That's the correct behavior: the
// unrecorded portion on a partial is typically small (pre-
// existing cash) and already has an explicit transfer record
// acknowledging the large movement. Surfacing it again would
// double-nag.
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("SYM", 500.0);
const before = [_]Lot{};
// $50k lot, $45k from a transfer partial_transfer_in with
// $5k residual. Residual is below $10k threshold anyway, but
// even if it weren't, the filter skips partial_transfer_in.
const after = [_]Lot{
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" },
};
var tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1
\\transfer::2026-05-02,type::cash,amount:num:45000,from::Acct B,to::Acct A,dest_lot::SYM@2026-05-03
\\
);
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
.transfer_log = &tlog,
.window_start = Date.fromYmd(2026, 1, 1),
.window_end = Date.fromYmd(2026, 12, 31),
});
try std.testing.expectEqual(ChangeKind.partial_transfer_in, report.changes[0].kind);
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}