surface suspected account cash transfers through audit command
This commit is contained in:
parent
78c9afc2d8
commit
d4c944f1bb
2 changed files with 437 additions and 2 deletions
|
|
@ -4,6 +4,7 @@ const cli = @import("common.zig");
|
||||||
const fmt = cli.fmt;
|
const fmt = cli.fmt;
|
||||||
const analysis = @import("../analytics/analysis.zig");
|
const analysis = @import("../analytics/analysis.zig");
|
||||||
const portfolio_mod = @import("../models/portfolio.zig");
|
const portfolio_mod = @import("../models/portfolio.zig");
|
||||||
|
const contributions = @import("contributions.zig");
|
||||||
|
|
||||||
// ── Brokerage position (normalized from any source) ─────────
|
// ── 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 default_stale_days: u32 = 3;
|
||||||
const stale_warning_multiplier: u32 = 2; // yellow → red at 2× threshold
|
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.
|
/// Type of a discovered brokerage file.
|
||||||
const BrokerFileKind = enum {
|
const BrokerFileKind = enum {
|
||||||
fidelity_csv,
|
fidelity_csv,
|
||||||
|
|
@ -1651,6 +1666,68 @@ fn stalenessColor(age_days: i32, threshold: u32) [3]u8 {
|
||||||
return cli.CLR_NEGATIVE;
|
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.
|
/// Run the flagless portfolio hygiene check.
|
||||||
fn runHygieneCheck(
|
fn runHygieneCheck(
|
||||||
allocator: std.mem.Allocator,
|
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", .{});
|
try out.print("\n", .{});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3003,3 +3105,47 @@ test "discoverBrokerFiles: nonexistent directory returns empty" {
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 0), files.len);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -693,6 +693,127 @@ pub fn computeAttributionSpec(
|
||||||
return summarizeAttribution(ctx);
|
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 {
|
fn summarizeAttribution(ctx: ReportContext) AttributionSummary {
|
||||||
|
|
||||||
// Aggregate. Classification logic matches the full-report sections:
|
// Aggregate. Classification logic matches the full-report sections:
|
||||||
|
|
@ -822,6 +943,11 @@ const Change = struct {
|
||||||
new_price: f64 = 0,
|
new_price: f64 = 0,
|
||||||
/// Free-form detail for flagged changes.
|
/// Free-form detail for flagged changes.
|
||||||
detail: ?[]const u8 = null,
|
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`) ────
|
// ── Transfer reclassification (see `matchTransfers`) ────
|
||||||
/// Dollar amount of this Change's `value()` that's attributable
|
/// Dollar amount of this Change's `value()` that's attributable
|
||||||
|
|
@ -1348,6 +1474,7 @@ fn computeReport(
|
||||||
.security_type = lot.security_type,
|
.security_type = lot.security_type,
|
||||||
.delta_shares = after_agg.shares,
|
.delta_shares = after_agg.shares,
|
||||||
.unit_value = unit_value,
|
.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"));
|
try std.testing.expectEqualStrings("abc", short("abc"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── matchTransfers tests (M2) ────────────────────────────────
|
// ── matchTransfers tests ─────────────────────────────────────
|
||||||
//
|
//
|
||||||
// These exercise the transfer reclassification pipeline end-to-end
|
// These exercise the transfer reclassification pipeline end-to-end
|
||||||
// via `computeReport(... .{ .transfer_log = &log, ... })`. Each test
|
// 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" },
|
.{ .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), .{});
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
|
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), new_contributions, 0.01);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), drip, 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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue