diff --git a/docs/reference/cli/contributions.md b/docs/reference/cli/contributions.md index 06f7c3b..4245950 100644 --- a/docs/reference/cli/contributions.md +++ b/docs/reference/cli/contributions.md @@ -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. diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 618e317..9c2b4b8 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -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`