contributions: handle partial sale of lots

This commit is contained in:
Emil Lerch 2026-09-29 06:50:58 -07:00
parent 8df6bd5c03
commit 1f7d2c596e
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 383 additions and 38 deletions

View file

@ -69,6 +69,15 @@ An option removed after it expired is worth $0 -- whether it expired
worthless or was exercised, the premium isn't proceeds. (An exercise
shows up separately, as the stock and cash that moved.)
A partial sale is recorded by splitting the lot: keep the shares you
still hold on one line, and put the sold shares on another with
`close_date` and `close_price`. Only the sold shares count, at their
`close_price`, whichever order the lines are in and whether the sold one
stays in `portfolio.srf` or moves to `portfolio_closed.srf`. Deleting a
lot you had already closed changes nothing, and neither does adding old
closed lots to the file -- though an added one appears in the report as
a purchase funded by its own sale.
Closing a position that has accumulated a lot per dividend
reinvestment retires many lots at once, so sales collapse to one line
per account and symbol, carrying the lot count and the total.

View file

@ -1652,31 +1652,141 @@ fn lotKey(allocator: std.mem.Allocator, lot: Lot) ![]u8 {
});
}
/// Aggregate duplicate-key lots by summing shares. (Rare in practice but
/// possible.) Returns map key -> (shares, representative Lot).
/// Every lot sharing one `lotKey`, summed. Duplicates are common, not
/// rare: recording a partial sale SPLITS a lot into a held part and a
/// sold part, and both keep the original key (the key excludes
/// `close_date` on purpose - a close must not look like a new lot). The
/// sold part is often archived to `portfolio_closed.srf`, which the
/// same glob merges back in.
///
/// So the sold shares are tracked separately. Folding them into one
/// count made a split either invisible or valued at the full share
/// count, depending on which half happened to come first in the file.
const LotAgg = struct {
/// All shares under the key, sold or not.
shares: f64,
/// The part of `shares` sold on or before `as_of`
/// (`Lot.isSoldAsOf`).
sold_shares: f64 = 0,
/// What those sold shares realized (`closeUnitValue` x shares).
sold_value: f64 = 0,
/// Representative lot: the first unsold one when there is any, so
/// type-, maturity- and drip-dependent decisions describe the part
/// still held.
lot: Lot,
fn unsold(self: LotAgg) f64 {
return self.shares - self.sold_shares;
}
};
fn aggregateByKey(
allocator: std.mem.Allocator,
lots: []const Lot,
prices: *const std.StringHashMap(f64),
as_of: Date,
) !std.StringHashMap(LotAgg) {
var map = std.StringHashMap(LotAgg).init(allocator);
for (lots) |lot| {
const k = try lotKey(allocator, lot);
const gop = try map.getOrPut(k);
if (gop.found_existing) {
gop.value_ptr.shares += lot.shares;
const sold = lot.isSoldAsOf(as_of);
if (!gop.found_existing) {
gop.value_ptr.* = .{ .shares = 0, .lot = lot };
} else {
gop.value_ptr.* = .{ .shares = lot.shares, .lot = lot };
allocator.free(k);
if (!sold and gop.value_ptr.lot.isSoldAsOf(as_of)) gop.value_ptr.lot = lot;
}
gop.value_ptr.shares += lot.shares;
if (sold) {
gop.value_ptr.sold_shares += lot.shares;
gop.value_ptr.sold_value += lot.shares * closeUnitValue(lot, prices, as_of);
}
}
return map;
}
/// How one key's shares changed between the two sides, split into the
/// part that was SOLD and whatever else happened.
///
/// sold shares that moved into the sold bucket - a sale event,
/// valued from `sold_value`. Signed like the lots (negative
/// for a written option). Zero when no sale happened.
/// residual every other change to the held shares - a buy, a DRIP
/// top-up, an unrecorded reduction - classified exactly as
/// a share change always was.
///
/// Sold shares that DISAPPEAR are not a sale in reverse. Deleting a
/// closed lot is housekeeping, and removing a mistyped `close_date`
/// ("reopening") moves shares back to held with nothing bought; both
/// net out to nothing.
fn splitShareChange(before: LotAgg, after: LotAgg) struct { sold: f64, residual: f64 } {
const held_delta = after.unsold() - before.unsold();
if (@abs(after.sold_shares) > @abs(before.sold_shares)) {
const moved = after.sold_shares - before.sold_shares;
// The sale drew `moved` shares out of held; only what's left
// over is a separate change.
return .{ .sold = moved, .residual = held_delta + moved };
}
// Sold shares that vanished (`gone`) either reopened - came back as
// held - or were deleted. Only the part of the held change moving
// the same way can be a reopen, and at most `gone` of it.
const gone = before.sold_shares - after.sold_shares;
const reopened = if (std.math.sign(held_delta) == std.math.sign(gone))
std.math.sign(gone) * @min(@abs(held_delta), @abs(gone))
else
0;
return .{ .sold = 0, .residual = held_delta - reopened };
}
fn testAgg(shares: f64, sold: f64) LotAgg {
return .{ .shares = shares, .sold_shares = sold, .lot = .{ .symbol = "X", .shares = shares, .open_date = Date.epoch, .open_price = 1 } };
}
test "splitShareChange: a partial sale by splitting a lot is a sale and nothing else" {
const r = splitShareChange(testAgg(100, 0), testAgg(100, 40));
try std.testing.expectApproxEqAbs(@as(f64, 40), r.sold, 1e-9);
try std.testing.expectApproxEqAbs(@as(f64, 0), r.residual, 1e-9);
}
test "splitShareChange: a whole-lot close" {
const r = splitShareChange(testAgg(100, 0), testAgg(100, 100));
try std.testing.expectApproxEqAbs(@as(f64, 100), r.sold, 1e-9);
try std.testing.expectApproxEqAbs(@as(f64, 0), r.residual, 1e-9);
}
test "splitShareChange: a written option closing keeps its sign" {
const r = splitShareChange(testAgg(-2, 0), testAgg(-2, -2));
try std.testing.expectApproxEqAbs(@as(f64, -2), r.sold, 1e-9);
try std.testing.expectApproxEqAbs(@as(f64, 0), r.residual, 1e-9);
}
test "splitShareChange: deleting a sold part is housekeeping" {
const r = splitShareChange(testAgg(100, 40), testAgg(60, 0));
try std.testing.expectApproxEqAbs(@as(f64, 0), r.sold, 1e-9);
try std.testing.expectApproxEqAbs(@as(f64, 0), r.residual, 1e-9);
}
test "splitShareChange: removing a mistyped close_date is a reopen, not a buy" {
const r = splitShareChange(testAgg(100, 40), testAgg(100, 0));
try std.testing.expectApproxEqAbs(@as(f64, 0), r.sold, 1e-9);
try std.testing.expectApproxEqAbs(@as(f64, 0), r.residual, 1e-9);
}
test "splitShareChange: a sale and a separate top-up in one window" {
// 40 sold, and 5 more shares bought into the held part.
const r = splitShareChange(testAgg(100, 0), testAgg(105, 40));
try std.testing.expectApproxEqAbs(@as(f64, 40), r.sold, 1e-9);
try std.testing.expectApproxEqAbs(@as(f64, 5), r.residual, 1e-9);
}
test "splitShareChange: no sale leaves an ordinary share change alone" {
const r = splitShareChange(testAgg(100, 0), testAgg(90, 0));
try std.testing.expectApproxEqAbs(@as(f64, 0), r.sold, 1e-9);
try std.testing.expectApproxEqAbs(@as(f64, -10), r.residual, 1e-9);
}
/// Secondary key for edit detection: (security_type, priceSymbol, account).
/// Lots with the same secondary key but different strict `lotKey`s are
/// candidates for reclassification as `lot_edited` - the strict key
@ -1696,6 +1806,24 @@ fn secondaryKey(allocator: std.mem.Allocator, lot: Lot) ![]u8 {
});
}
/// Suffix marking an edit group made of fully-sold keys. See
/// `editGroupKey`.
const sold_group_suffix = "|sold";
/// `secondaryKey`, split by whether the key still holds any shares.
///
/// Held and fully-sold keys must never pair as an edit. Deleting a
/// long-closed AMZN lot while buying new AMZN has one of each under the
/// same secondary key, and pairing them read as "one position edited" -
/// so the new buy vanished from attribution. Kept apart, the closed lot
/// is housekeeping (see `splitShareChange`) and the buy is a buy.
fn editGroupKey(allocator: std.mem.Allocator, agg: LotAgg) ![]u8 {
const sk = try secondaryKey(allocator, agg.lot);
defer allocator.free(sk);
const sold_only = @abs(agg.unsold()) <= 0.000001;
return std.fmt.allocPrint(allocator, "{s}{s}", .{ sk, if (sold_only) sold_group_suffix else "|held" });
}
/// Dollars-per-share for a lot that is LEAVING the portfolio (a whole
/// lot removed in pass 2, or a position closed in place). Multiplied
/// by the share count it gives the sale proceeds, which is what makes
@ -1780,6 +1908,33 @@ test "outflowUnitValue: an option deleted BEFORE expiry still funds at its premi
try std.testing.expectApproxEqAbs(@as(f64, 1500), attributionTotalForTest(report), 0.01);
}
/// Emit a `position_closed` for `sold` shares that realized `proceeds`.
///
/// The one place a sale Change is built, whether it came from a lot
/// closed in place, a lot split into held and sold parts, or a lot that
/// arrived already sold. `sold` is signed like the lots (negative for a
/// written option); `unit_value` is recovered so `value()` stays the
/// negative proceeds, as for every other outflow.
fn appendSale(
allocator: std.mem.Allocator,
changes: *std.ArrayList(Change),
symbol: []const u8,
account: []const u8,
security_type: LotType,
sold: f64,
proceeds: f64,
) !void {
try changes.append(allocator, .{
.kind = .position_closed,
.symbol = symbol,
.account = account,
.security_type = security_type,
.unit_value = proceeds / sold,
.face_value = proceeds,
.delta_shares = -sold,
});
}
/// Dollars-per-share realized when a lot was closed in place.
///
/// `close_price` is authoritative - it is what the sale actually got,
@ -1883,7 +2038,7 @@ fn detectEdits(
var ait = after_map.iterator();
while (ait.next()) |entry| {
if (before_map.contains(entry.key_ptr.*)) continue;
const sk = try secondaryKey(allocator, entry.value_ptr.*.lot);
const sk = try editGroupKey(allocator, entry.value_ptr.*);
const gop = try groups.getOrPut(sk);
if (!gop.found_existing) {
gop.value_ptr.* = std.ArrayList(SidedEntry).empty;
@ -1900,7 +2055,7 @@ fn detectEdits(
var bit = before_map.iterator();
while (bit.next()) |entry| {
if (after_map.contains(entry.key_ptr.*)) continue;
const sk = try secondaryKey(allocator, entry.value_ptr.*.lot);
const sk = try editGroupKey(allocator, entry.value_ptr.*);
const gop = try groups.getOrPut(sk);
if (!gop.found_existing) {
gop.value_ptr.* = std.ArrayList(SidedEntry).empty;
@ -1927,17 +2082,23 @@ fn detectEdits(
while (git_it.next()) |entry| {
const list = entry.value_ptr.*;
// A group of fully-sold keys is an edit to history (a closed
// lot's cost corrected, say). It pairs by sold shares and never
// carries a residual: nothing held changed.
const sold_group = std.mem.endsWith(u8, entry.key_ptr.*, sold_group_suffix);
// Need at least one on each side to be an edit.
var after_shares: f64 = 0;
var before_shares: f64 = 0;
var after_rep: ?LotAgg = null;
var before_rep: ?LotAgg = null;
for (list.items) |e| {
const n = if (sold_group) e.agg.sold_shares else e.agg.unsold();
if (e.from_after) {
after_shares += e.agg.shares;
after_shares += n;
if (after_rep == null) after_rep = e.agg;
} else {
before_shares += e.agg.shares;
before_shares += n;
if (before_rep == null) before_rep = e.agg;
}
}
@ -1960,6 +2121,7 @@ fn detectEdits(
.delta_shares = 0,
.unit_value = 0,
});
if (sold_group) continue;
// Emit a residual share-delta change if the totals diverge
// beyond noise. Mirrors Pass 1's same-key share-delta handling
@ -2058,8 +2220,8 @@ fn computeReport(
) !Report {
var changes: std.ArrayList(Change) = .empty;
var before_map = try aggregateByKey(allocator, before);
var after_map = try aggregateByKey(allocator, after);
var before_map = try aggregateByKey(allocator, before, prices, as_of);
var after_map = try aggregateByKey(allocator, after, prices, as_of);
// Edit detection: identify strict-key pairs that look like edits
// (reconciliation tweak, CD auto-renewal rewriting `open_date`,
@ -2088,11 +2250,22 @@ fn computeReport(
if (skip.contains(entry.key_ptr.*)) continue;
const after_agg = entry.value_ptr.*;
if (before_map.get(entry.key_ptr.*)) |before_agg| {
// Key present in both. Compare shares and other fields.
const delta = after_agg.shares - before_agg.shares;
// Key present in both. Split the share change into a sale
// (shares that became sold) and everything else.
const split = splitShareChange(before_agg, after_agg);
const lot = after_agg.lot;
const acct = try sdup.of(lot.account orelse "");
const sym = try sdup.of(lot.symbol);
// The sale, valued at what the sold shares realized. It is
// checked independently of the residual and wins over the
// metadata comparisons below: a lot both closed and repriced
// in one window is a sale, not a price edit.
if (@abs(split.sold) > 0.000001) {
try appendSale(allocator, &changes, sym, acct, lot.security_type, split.sold, after_agg.sold_value - before_agg.sold_value);
}
const delta = split.residual;
if (@abs(delta) > 0.000001) {
// Direct-indexing suppression: for stock lots in
// flagged accounts, sub-1% share drift is tracking-
@ -2161,30 +2334,9 @@ fn computeReport(
.delta_shares = delta,
.unit_value = unit_value,
});
} else {
// Same shares. A close transition wins over any other
// metadata comparison: a lot that was both closed and
// repriced in the same window is a sale, not a price
// edit. Checked first for exactly that reason.
//
// "Sold", not "ended": an expired option or matured CD
// has already ended on both sides, so the documented
// record-the-close-after-expiry workflow would never
// show a transition. See `Lot.isSoldAsOf`.
} else if (@abs(split.sold) <= 0.000001) {
// Same held shares and no sale: only metadata changed.
const before_lot = before_agg.lot;
if (!before_lot.isSoldAsOf(as_of) and lot.isSoldAsOf(as_of)) {
const unit_value = closeUnitValue(lot, prices, as_of);
try changes.append(allocator, .{
.kind = .position_closed,
.symbol = sym,
.account = acct,
.security_type = lot.security_type,
.unit_value = unit_value,
.face_value = after_agg.shares * unit_value,
.delta_shares = -after_agg.shares,
});
continue;
}
const a_price = lot.price;
const b_price = before_lot.price;
if ((a_price != null) != (b_price != null) or
@ -2254,6 +2406,22 @@ fn computeReport(
.unit_value = unit_value,
.open_date = lot.open_date,
});
// Arrived already (partly) sold: a round trip, emitted as the
// buy above plus this sale. Without the sale, backfilling an
// old closed lot booked its whole cost as a new contribution.
// With it, the funding matcher (`matchIntraAccountPurchases`)
// nets each case out correctly - and without window dates it
// can't tell a backfill from a trade made this week:
// - backfill: no cash moved, so the proceeds fund the buy
// and nothing counts;
// - bought and sold this week with new money: the proceeds
// are resting in cash, so the cost counts;
// - bought from existing cash and sold: the buy is funded,
// so nothing counts.
if (@abs(after_agg.sold_shares) > 0.000001) {
try appendSale(allocator, &changes, sym, acct, lot.security_type, after_agg.sold_shares, after_agg.sold_value);
}
}
}
@ -2263,6 +2431,12 @@ fn computeReport(
if (after_map.contains(entry.key_ptr.*)) continue;
if (skip.contains(entry.key_ptr.*)) continue;
const before_agg = entry.value_ptr.*;
// Shares already sold were accounted for when the sale was
// recorded; deleting their line now is housekeeping. Valuing
// them at today's price invented proceeds that funded real
// purchases, hiding those contributions.
const held = before_agg.unsold();
if (@abs(held) <= 0.000001) continue;
const lot = before_agg.lot;
const acct = try sdup.of(lot.account orelse "");
const sym = try sdup.of(lot.symbol);
@ -2289,9 +2463,9 @@ fn computeReport(
.account = acct,
.security_type = lot.security_type,
.unit_value = unit_value,
.face_value = before_agg.shares * unit_value,
.face_value = held * unit_value,
.maturity_date = lot.maturity_date,
.delta_shares = -before_agg.shares,
.delta_shares = -held,
});
}
@ -2368,6 +2542,168 @@ fn computeReport(
};
}
// ── Sold-share tracking (see `LotAgg`, `splitShareChange`) ──
/// Run `computeReport` over `before`/`after` with VTI and AMZN priced,
/// in a caller-owned arena.
fn soldReport(a: std.mem.Allocator, before: []const Lot, after: []const Lot, opts: ReportOptions) !Report {
var prices = std.StringHashMap(f64).init(a);
try prices.put("VTI", 250.0);
try prices.put("AMZN", 200.0);
return computeReport(a, before, after, &prices, Date.fromYmd(2026, 1, 20), opts);
}
fn countKind(report: Report, kind: ChangeKind) usize {
var n: usize = 0;
for (report.changes) |c| {
if (c.kind == kind) n += 1;
}
return n;
}
fn findKind(report: Report, kind: ChangeKind) ?Change {
for (report.changes) |c| {
if (c.kind == kind) return c;
}
return null;
}
const split_whole: Lot = .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 15), .open_price = 200, .account = "Sample Brokerage" };
fn splitHalves() [2]Lot {
var held = split_whole;
held.shares = 60;
var sold = split_whole;
sold.shares = 40;
sold.close_date = Date.fromYmd(2026, 1, 10);
sold.close_price = 260;
return .{ held, sold };
}
test "computeReport: a partial sale recorded by splitting a lot is a sale at close" {
// Both halves keep the original lot key. Summed as one count, the
// sale was invisible (held half first) or valued at all 100 shares,
// $26,000 (sold half first). It is 40 shares at $260.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const h = splitHalves();
for ([_][2]Lot{ h, .{ h[1], h[0] } }) |order| {
const report = try soldReport(a, &.{split_whole}, &order, .{});
try std.testing.expectEqual(@as(usize, 1), report.changes.len);
const sale = findKind(report, .position_closed) orelse return error.NoSale;
try std.testing.expectApproxEqAbs(@as(f64, 10_400), sale.face_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, -40), sale.delta_shares, 1e-9);
}
}
test "computeReport: partial-sale proceeds fund a same-account buy" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const h = splitHalves();
const buy: Lot = .{ .symbol = "AMZN", .shares = 50, .open_date = Date.fromYmd(2026, 1, 12), .open_price = 200, .account = "Sample Brokerage" };
const report = try soldReport(a, &.{split_whole}, &.{ h[0], h[1], buy }, .{});
try std.testing.expectApproxEqAbs(@as(f64, 0), attributionTotalForTest(report), 0.01);
}
test "computeReport: archiving the sold half to a sibling file changes nothing" {
// The union-merged view is the same whether both halves sit in one
// file or the sold one was moved to `portfolio_closed.srf`, so a
// later window that only moves it must be silent.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const h = splitHalves();
const report = try soldReport(a, &.{ h[0], h[1] }, &.{ h[1], h[0] }, .{});
try std.testing.expectEqual(@as(usize, 0), report.changes.len);
}
test "computeReport: deleting a lot closed long ago is housekeeping" {
// It used to be revalued at today's price and treated as sale
// proceeds, which "funded" a real $2,000 fresh buy the same week.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const old: Lot = .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2022, 3, 15), .open_price = 150, .close_date = Date.fromYmd(2024, 1, 15), .close_price = 185.5, .account = "Sample Brokerage" };
const buy: Lot = .{ .symbol = "VTI", .shares = 8, .open_date = Date.fromYmd(2026, 1, 12), .open_price = 250, .account = "Sample Brokerage" };
const report = try soldReport(a, &.{old}, &.{buy}, .{});
try std.testing.expectEqual(@as(usize, 0), countKind(report, .lot_removed));
try std.testing.expectApproxEqAbs(@as(f64, 2000), attributionTotalForTest(report), 0.01);
}
test "computeReport: deleting a closed lot and buying the same symbol again isn't an edit" {
// Same secondary key (stock|AMZN|account). Paired as one edited
// position, the new buy disappeared.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const old: Lot = .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2022, 3, 15), .open_price = 150, .close_date = Date.fromYmd(2024, 1, 15), .close_price = 185.5, .account = "Sample Brokerage" };
const buy: Lot = .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2026, 1, 12), .open_price = 200, .account = "Sample Brokerage" };
const report = try soldReport(a, &.{old}, &.{buy}, .{});
try std.testing.expectEqual(@as(usize, 0), countKind(report, .lot_edited));
try std.testing.expectApproxEqAbs(@as(f64, 2000), attributionTotalForTest(report), 0.01);
}
test "computeReport: correcting a closed lot's cost is a history edit, not a trade" {
// Both sides are fully sold under the same secondary key but a
// different strict key. That pairs as an edit with no residual; as
// a delete + new already-sold lot it would book the loss-making
// round trip's shortfall as new money.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const was: Lot = .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2022, 3, 15), .open_price = 150, .close_date = Date.fromYmd(2024, 1, 15), .close_price = 100, .account = "Sample Brokerage" };
var fixed = was;
fixed.open_price = 160;
const report = try soldReport(a, &.{was}, &.{fixed}, .{});
try std.testing.expectEqual(@as(usize, 1), countKind(report, .lot_edited));
try std.testing.expectEqual(@as(usize, 0), countKind(report, .position_closed));
try std.testing.expectApproxEqAbs(@as(f64, 0), attributionTotalForTest(report), 0.01);
}
test "computeReport: removing a mistyped close_date reopens the lot, buying nothing" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const h = splitHalves();
const report = try soldReport(a, &.{ h[0], h[1] }, &.{split_whole}, .{});
try std.testing.expectEqual(@as(usize, 0), report.changes.len);
}
test "computeReport: backfilling an old closed lot is not a contribution" {
// It used to count its whole $1,500 cost as new money.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const hist: Lot = .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2022, 3, 15), .open_price = 150, .close_date = Date.fromYmd(2024, 1, 15), .close_price = 185.5, .account = "Sample Brokerage" };
const report = try soldReport(a, &.{}, &.{hist}, .{});
try std.testing.expectApproxEqAbs(@as(f64, 0), attributionTotalForTest(report), 0.01);
}
test "computeReport: a round trip inside the window counts the cost only when new money paid for it" {
// Emitted as buy + sale, the existing funding rules tell these apart
// by where the cash went - no window dates needed.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const trade: Lot = .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2026, 1, 12), .open_price = 150, .close_date = Date.fromYmd(2026, 1, 16), .close_price = 185.5, .account = "Sample Brokerage" };
const cash: Lot = .{ .symbol = "CASH", .shares = 2000, .open_date = Date.epoch, .open_price = 1, .security_type = .cash, .account = "Sample Brokerage" };
// New money: the $1,855 proceeds are resting in cash; the $1,500
// cost came from outside.
var cash_up = cash;
cash_up.shares = 2000 + 1855;
const new_money = try soldReport(a, &.{cash}, &.{ trade, cash_up }, .{});
try std.testing.expectApproxEqAbs(@as(f64, 1500), attributionTotalForTest(new_money), 0.01);
// Existing cash: -$1,500 then +$1,855 nets to +$355.
var cash_net = cash;
cash_net.shares = 2000 + 355;
const from_cash = try soldReport(a, &.{cash}, &.{ trade, cash_net }, .{});
try std.testing.expectApproxEqAbs(@as(f64, 0), attributionTotalForTest(from_cash), 0.01);
}
// ── Transfer reclassification ────────────────────────────────
/// Absolute-dollar tolerance for matching a transfer record's `amount`