make sure large new lots section remains without open_date
This commit is contained in:
parent
60a42dd4ba
commit
b3239eefda
2 changed files with 134 additions and 12 deletions
|
|
@ -1244,7 +1244,7 @@ pub fn runHygieneCheck(
|
||||||
// (not in a git repo). Threshold is per-account: an account's
|
// (not in a git repo). Threshold is per-account: an account's
|
||||||
// `audit_large_lot_threshold` in accounts.srf wins, otherwise the
|
// `audit_large_lot_threshold` in accounts.srf wins, otherwise the
|
||||||
// filter's built-in default applies.
|
// filter's built-in default applies.
|
||||||
if (contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_paths, &account_map, as_of, color, refresh)) |found| {
|
if (try contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_paths, &account_map, as_of, color, refresh)) |found| {
|
||||||
var found_mut = found;
|
var found_mut = found;
|
||||||
defer found_mut.deinit();
|
defer found_mut.deinit();
|
||||||
|
|
||||||
|
|
@ -2318,6 +2318,68 @@ test "runHygieneCheck: Section 7 flags an un-opted-in symbol's split, not an opt
|
||||||
try std.testing.expect(std.mem.indexOf(u8, sec6, "AMZN") == null);
|
try std.testing.expect(std.mem.indexOf(u8, sec6, "AMZN") == null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "runHygieneCheck: a large deposit on a cash_is_contribution account doesn't blank Large new lots" {
|
||||||
|
// End-to-end regression for the `MissingOpenDate` bug: the deposit
|
||||||
|
// becomes a `cash_contribution` with no open_date, which used to
|
||||||
|
// error out of `collectUnmatchedLargeLots` and suppress the whole
|
||||||
|
// section - taking the unrelated VTI lot down with it.
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const io = std.testing.io;
|
||||||
|
if (!test_git.available(allocator)) return;
|
||||||
|
|
||||||
|
var tmp = std.testing.tmpDir(.{});
|
||||||
|
defer tmp.cleanup();
|
||||||
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||||
|
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||||
|
const dir = path_buf[0..dir_len];
|
||||||
|
|
||||||
|
// Committed state: $10k cash on an account whose cash deposits are
|
||||||
|
// contributions.
|
||||||
|
try tmp.dir.writeFile(io, .{
|
||||||
|
.sub_path = "portfolio.srf",
|
||||||
|
.data = "#!srfv1\nsecurity_type::cash,shares:num:10000,open_date::2025-01-01,open_price:num:1,account::Sample IRA\n",
|
||||||
|
});
|
||||||
|
try tmp.dir.writeFile(io, .{
|
||||||
|
.sub_path = "accounts.srf",
|
||||||
|
.data = "#!srfv1\naccount::Sample IRA,tax_type::traditional,cash_is_contribution:bool:true\n",
|
||||||
|
});
|
||||||
|
try test_git.run(allocator, dir, null, &.{ "init", "-q" });
|
||||||
|
try test_git.run(allocator, dir, null, &.{ "config", "user.email", "test@example.com" });
|
||||||
|
try test_git.run(allocator, dir, null, &.{ "config", "user.name", "Test" });
|
||||||
|
try test_git.run(allocator, dir, null, &.{ "config", "commit.gpgsign", "false" });
|
||||||
|
try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf", "accounts.srf" });
|
||||||
|
try test_git.run(allocator, dir, "2026-01-10T12:00:00", &.{ "commit", "-q", "-m", "A" });
|
||||||
|
|
||||||
|
// Working copy: a $50k deposit, plus a $25k new stock lot.
|
||||||
|
try tmp.dir.writeFile(io, .{
|
||||||
|
.sub_path = "portfolio.srf",
|
||||||
|
.data =
|
||||||
|
\\#!srfv1
|
||||||
|
\\security_type::cash,shares:num:60000,open_date::2025-01-01,open_price:num:1,account::Sample IRA
|
||||||
|
\\symbol::VTI,shares:num:100,open_date::2026-05-10,open_price:num:250,account::Sample IRA
|
||||||
|
\\
|
||||||
|
,
|
||||||
|
});
|
||||||
|
|
||||||
|
var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
|
||||||
|
defer svc.deinit();
|
||||||
|
var env = try std.testing.environ.createMap(allocator);
|
||||||
|
defer env.deinit();
|
||||||
|
const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
|
||||||
|
defer allocator.free(pf_path);
|
||||||
|
|
||||||
|
var aw: std.Io.Writer.Allocating = .init(allocator);
|
||||||
|
defer aw.deinit();
|
||||||
|
try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, false, zfin.Date.fromYmd(2026, 5, 11), 1_778_500_000, false, .never, &aw.writer);
|
||||||
|
|
||||||
|
const out = aw.written();
|
||||||
|
const start = std.mem.indexOf(u8, out, "Large new lots") orelse return error.LargeLotSectionMissing;
|
||||||
|
const sec = out[start..];
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, sec, "VTI") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, sec, "+$50,000.00 (date unknown)") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, sec, "transfer::<DATE>,type::cash,amount:num:50000.00") != null);
|
||||||
|
}
|
||||||
|
|
||||||
/// Format a two-account portfolio.srf for the git-history test. Only
|
/// Format a two-account portfolio.srf for the git-history test. Only
|
||||||
/// `shares` varies between revisions, which is enough for
|
/// `shares` varies between revisions, which is enough for
|
||||||
/// findModifiedAccounts to flag the account.
|
/// findModifiedAccounts to flag the account.
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,7 @@ pub const meta: framework.Meta = .{
|
||||||
\\
|
\\
|
||||||
,
|
,
|
||||||
.uppercase_first_arg = false,
|
.uppercase_first_arg = false,
|
||||||
.user_errors = error{ DuplicateEndpoint, InvalidArg, MissingOpenDate, PrepareFailed, ResolveFailed, UnexpectedArg },
|
.user_errors = error{ DuplicateEndpoint, InvalidArg, PrepareFailed, ResolveFailed, UnexpectedArg },
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||||
|
|
@ -1199,7 +1199,10 @@ pub const UnmatchedLargeLotSet = struct {
|
||||||
/// Mirrors the `zfin contributions` zero-flag path - uses
|
/// Mirrors the `zfin contributions` zero-flag path - uses
|
||||||
/// `prepareReport`'s shared git + portfolio + transfer plumbing so
|
/// `prepareReport`'s shared git + portfolio + transfer plumbing so
|
||||||
/// the classification is identical. Returns null if the pipeline
|
/// the classification is identical. Returns null if the pipeline
|
||||||
/// can't resolve a window (not in a git repo, etc.).
|
/// can't resolve a window (not in a git repo, etc.) - the expected
|
||||||
|
/// reason for audit to skip the section, logged at debug level. Any
|
||||||
|
/// other failure is returned, never folded into that null: doing so
|
||||||
|
/// once made the whole section disappear over one undated lot.
|
||||||
///
|
///
|
||||||
/// The threshold is resolved per lot: an account's
|
/// The threshold is resolved per lot: an account's
|
||||||
/// `audit_large_lot_threshold` (from `account_map`) wins, otherwise
|
/// `audit_large_lot_threshold` (from `account_map`) wins, otherwise
|
||||||
|
|
@ -1221,7 +1224,7 @@ pub fn findUnmatchedLargeLots(
|
||||||
as_of: Date,
|
as_of: Date,
|
||||||
color: bool,
|
color: bool,
|
||||||
refresh: framework.RefreshPolicy,
|
refresh: framework.RefreshPolicy,
|
||||||
) ?UnmatchedLargeLotSet {
|
) !?UnmatchedLargeLotSet {
|
||||||
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
||||||
errdefer arena_state.deinit();
|
errdefer arena_state.deinit();
|
||||||
const arena = arena_state.allocator();
|
const arena = arena_state.allocator();
|
||||||
|
|
@ -1234,16 +1237,14 @@ pub fn findUnmatchedLargeLots(
|
||||||
//
|
//
|
||||||
// Separate allocator here so we can tear the whole thing down
|
// Separate allocator here so we can tear the whole thing down
|
||||||
// via `arena_state.deinit` once we've copied out the descriptors.
|
// via `arena_state.deinit` once we've copied out the descriptors.
|
||||||
var ctx = prepareReport(io, allocator, arena, env, svc, paths, null, null, as_of, color, refresh, .silent) catch {
|
var ctx = prepareReport(io, allocator, arena, env, svc, paths, null, null, as_of, color, refresh, .silent) catch |err| {
|
||||||
|
std.log.scoped(.contributions).debug("large-lot check skipped: no comparable window ({t})", .{err});
|
||||||
arena_state.deinit();
|
arena_state.deinit();
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
defer ctx.deinit();
|
defer ctx.deinit();
|
||||||
|
|
||||||
const lots = collectUnmatchedLargeLots(arena, ctx.report.changes, account_map) catch {
|
const lots = try collectUnmatchedLargeLots(arena, ctx.report.changes, account_map);
|
||||||
arena_state.deinit();
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
return .{ .lots = lots, .arena = arena_state };
|
return .{ .lots = lots, .arena = arena_state };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1297,9 +1298,18 @@ fn collectUnmatchedLargeLots(
|
||||||
const residual = c.attributedValue();
|
const residual = c.attributedValue();
|
||||||
if (residual < threshold) continue;
|
if (residual < threshold) continue;
|
||||||
|
|
||||||
// open_date is populated in Pass 1's new-lot branch; absence
|
// Pass 1's new-lot branch sets open_date. A `cash_contribution`
|
||||||
// here would be a pipeline bug.
|
// never has one: it is a balance INCREASE on an existing cash
|
||||||
const od = c.open_date orelse return error.MissingOpenDate;
|
// lot, and that lot's open_date is when the account was set
|
||||||
|
// up, not when this money arrived. `Date.epoch` is the
|
||||||
|
// codebase's "no date" (import writes it; `Lot.fromParsed`
|
||||||
|
// fills it), and `printLargeLotWarning` renders it as "date
|
||||||
|
// unknown" with a `<DATE>` placeholder rather than a real date.
|
||||||
|
//
|
||||||
|
// This used to `return error.MissingOpenDate`, which the caller
|
||||||
|
// collapsed into "no section" - one large deposit to a
|
||||||
|
// `cash_is_contribution` account blanked the whole list.
|
||||||
|
const od = c.open_date orelse Date.epoch;
|
||||||
const account_copy = try arena.dupe(u8, c.account);
|
const account_copy = try arena.dupe(u8, c.account);
|
||||||
const symbol_copy = try arena.dupe(u8, c.symbol);
|
const symbol_copy = try arena.dupe(u8, c.symbol);
|
||||||
try out.append(arena, .{
|
try out.append(arena, .{
|
||||||
|
|
@ -8272,3 +8282,53 @@ test "printReport: full report renders every section and sub-printer" {
|
||||||
try printReport(&cw, &report, "portfolio.srf", true);
|
try printReport(&cw, &report, "portfolio.srf", true);
|
||||||
try testing.expect(std.mem.indexOf(u8, cw.buffered(), "\x1b[") != null);
|
try testing.expect(std.mem.indexOf(u8, cw.buffered(), "\x1b[") != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "collectUnmatchedLargeLots: a large cash_contribution surfaces, undated, alongside the rest" {
|
||||||
|
// Regression. A positive balance change on a `cash_is_contribution`
|
||||||
|
// account is reclassified to `cash_contribution`, which carries no
|
||||||
|
// open_date. That returned error.MissingOpenDate, the caller turned
|
||||||
|
// it into "no section", and audit's entire "Large new lots" list -
|
||||||
|
// including the unrelated stock lot below - silently vanished.
|
||||||
|
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();
|
||||||
|
|
||||||
|
var am = try analysis.parseAccountsFile(
|
||||||
|
allocator,
|
||||||
|
"#!srfv1\naccount::Sample IRA,tax_type::traditional,cash_is_contribution:bool:true\n",
|
||||||
|
);
|
||||||
|
const before = [_]Lot{
|
||||||
|
.{ .symbol = "cash", .shares = 10_000, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" },
|
||||||
|
};
|
||||||
|
const after = [_]Lot{
|
||||||
|
.{ .symbol = "cash", .shares = 60_000, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" },
|
||||||
|
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2026, 5, 10), .open_price = 250.0, .account = "Sample IRA" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 11), .{ .account_map = &am });
|
||||||
|
// Precondition: the balance change really was reclassified.
|
||||||
|
var saw_contribution = false;
|
||||||
|
for (report.changes) |c| {
|
||||||
|
if (c.kind == .cash_contribution) saw_contribution = true;
|
||||||
|
}
|
||||||
|
try std.testing.expect(saw_contribution);
|
||||||
|
|
||||||
|
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), lots.len);
|
||||||
|
var cash_seen = false;
|
||||||
|
var stock_seen = false;
|
||||||
|
for (lots) |l| {
|
||||||
|
switch (l.security_type) {
|
||||||
|
.cash => {
|
||||||
|
cash_seen = true;
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), l.value, 0.01);
|
||||||
|
try std.testing.expect(l.open_date.eql(Date.epoch));
|
||||||
|
},
|
||||||
|
.stock => stock_seen = true,
|
||||||
|
else => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try std.testing.expect(cash_seen and stock_seen);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue