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 worthless or was exercised, the premium isn't proceeds. (An exercise
shows up separately, as the stock and cash that moved.) 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 Closing a position that has accumulated a lot per dividend
reinvestment retires many lots at once, so sales collapse to one line reinvestment retires many lots at once, so sales collapse to one line
per account and symbol, carrying the lot count and the total. 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 /// Every lot sharing one `lotKey`, summed. Duplicates are common, not
/// possible.) Returns map key -> (shares, representative Lot). /// 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 { const LotAgg = struct {
/// All shares under the key, sold or not.
shares: f64, 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, lot: Lot,
fn unsold(self: LotAgg) f64 {
return self.shares - self.sold_shares;
}
}; };
fn aggregateByKey( fn aggregateByKey(
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
lots: []const Lot, lots: []const Lot,
prices: *const std.StringHashMap(f64),
as_of: Date,
) !std.StringHashMap(LotAgg) { ) !std.StringHashMap(LotAgg) {
var map = std.StringHashMap(LotAgg).init(allocator); var map = std.StringHashMap(LotAgg).init(allocator);
for (lots) |lot| { for (lots) |lot| {
const k = try lotKey(allocator, lot); const k = try lotKey(allocator, lot);
const gop = try map.getOrPut(k); const gop = try map.getOrPut(k);
if (gop.found_existing) { const sold = lot.isSoldAsOf(as_of);
gop.value_ptr.shares += lot.shares; if (!gop.found_existing) {
gop.value_ptr.* = .{ .shares = 0, .lot = lot };
} else { } 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; 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). /// Secondary key for edit detection: (security_type, priceSymbol, account).
/// Lots with the same secondary key but different strict `lotKey`s are /// Lots with the same secondary key but different strict `lotKey`s are
/// candidates for reclassification as `lot_edited` - the strict key /// 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 /// 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 /// 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 /// 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); 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. /// Dollars-per-share realized when a lot was closed in place.
/// ///
/// `close_price` is authoritative - it is what the sale actually got, /// `close_price` is authoritative - it is what the sale actually got,
@ -1883,7 +2038,7 @@ fn detectEdits(
var ait = after_map.iterator(); var ait = after_map.iterator();
while (ait.next()) |entry| { while (ait.next()) |entry| {
if (before_map.contains(entry.key_ptr.*)) continue; 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); const gop = try groups.getOrPut(sk);
if (!gop.found_existing) { if (!gop.found_existing) {
gop.value_ptr.* = std.ArrayList(SidedEntry).empty; gop.value_ptr.* = std.ArrayList(SidedEntry).empty;
@ -1900,7 +2055,7 @@ fn detectEdits(
var bit = before_map.iterator(); var bit = before_map.iterator();
while (bit.next()) |entry| { while (bit.next()) |entry| {
if (after_map.contains(entry.key_ptr.*)) continue; 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); const gop = try groups.getOrPut(sk);
if (!gop.found_existing) { if (!gop.found_existing) {
gop.value_ptr.* = std.ArrayList(SidedEntry).empty; gop.value_ptr.* = std.ArrayList(SidedEntry).empty;
@ -1927,17 +2082,23 @@ fn detectEdits(
while (git_it.next()) |entry| { while (git_it.next()) |entry| {
const list = entry.value_ptr.*; 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. // Need at least one on each side to be an edit.
var after_shares: f64 = 0; var after_shares: f64 = 0;
var before_shares: f64 = 0; var before_shares: f64 = 0;
var after_rep: ?LotAgg = null; var after_rep: ?LotAgg = null;
var before_rep: ?LotAgg = null; var before_rep: ?LotAgg = null;
for (list.items) |e| { for (list.items) |e| {
const n = if (sold_group) e.agg.sold_shares else e.agg.unsold();
if (e.from_after) { if (e.from_after) {
after_shares += e.agg.shares; after_shares += n;
if (after_rep == null) after_rep = e.agg; if (after_rep == null) after_rep = e.agg;
} else { } else {
before_shares += e.agg.shares; before_shares += n;
if (before_rep == null) before_rep = e.agg; if (before_rep == null) before_rep = e.agg;
} }
} }
@ -1960,6 +2121,7 @@ fn detectEdits(
.delta_shares = 0, .delta_shares = 0,
.unit_value = 0, .unit_value = 0,
}); });
if (sold_group) continue;
// Emit a residual share-delta change if the totals diverge // Emit a residual share-delta change if the totals diverge
// beyond noise. Mirrors Pass 1's same-key share-delta handling // beyond noise. Mirrors Pass 1's same-key share-delta handling
@ -2058,8 +2220,8 @@ fn computeReport(
) !Report { ) !Report {
var changes: std.ArrayList(Change) = .empty; var changes: std.ArrayList(Change) = .empty;
var before_map = try aggregateByKey(allocator, before); var before_map = try aggregateByKey(allocator, before, prices, as_of);
var after_map = try aggregateByKey(allocator, after); var after_map = try aggregateByKey(allocator, after, prices, as_of);
// Edit detection: identify strict-key pairs that look like edits // Edit detection: identify strict-key pairs that look like edits
// (reconciliation tweak, CD auto-renewal rewriting `open_date`, // (reconciliation tweak, CD auto-renewal rewriting `open_date`,
@ -2088,11 +2250,22 @@ fn computeReport(
if (skip.contains(entry.key_ptr.*)) continue; if (skip.contains(entry.key_ptr.*)) continue;
const after_agg = entry.value_ptr.*; const after_agg = entry.value_ptr.*;
if (before_map.get(entry.key_ptr.*)) |before_agg| { if (before_map.get(entry.key_ptr.*)) |before_agg| {
// Key present in both. Compare shares and other fields. // Key present in both. Split the share change into a sale
const delta = after_agg.shares - before_agg.shares; // (shares that became sold) and everything else.
const split = splitShareChange(before_agg, after_agg);
const lot = after_agg.lot; const lot = after_agg.lot;
const acct = try sdup.of(lot.account orelse ""); const acct = try sdup.of(lot.account orelse "");
const sym = try sdup.of(lot.symbol); 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) { if (@abs(delta) > 0.000001) {
// Direct-indexing suppression: for stock lots in // Direct-indexing suppression: for stock lots in
// flagged accounts, sub-1% share drift is tracking- // flagged accounts, sub-1% share drift is tracking-
@ -2161,30 +2334,9 @@ fn computeReport(
.delta_shares = delta, .delta_shares = delta,
.unit_value = unit_value, .unit_value = unit_value,
}); });
} else { } else if (@abs(split.sold) <= 0.000001) {
// Same shares. A close transition wins over any other // Same held shares and no sale: only metadata changed.
// 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`.
const before_lot = before_agg.lot; 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 a_price = lot.price;
const b_price = before_lot.price; const b_price = before_lot.price;
if ((a_price != null) != (b_price != null) or if ((a_price != null) != (b_price != null) or
@ -2254,6 +2406,22 @@ fn computeReport(
.unit_value = unit_value, .unit_value = unit_value,
.open_date = lot.open_date, .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 (after_map.contains(entry.key_ptr.*)) continue;
if (skip.contains(entry.key_ptr.*)) continue; if (skip.contains(entry.key_ptr.*)) continue;
const before_agg = entry.value_ptr.*; 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 lot = before_agg.lot;
const acct = try sdup.of(lot.account orelse ""); const acct = try sdup.of(lot.account orelse "");
const sym = try sdup.of(lot.symbol); const sym = try sdup.of(lot.symbol);
@ -2289,9 +2463,9 @@ fn computeReport(
.account = acct, .account = acct,
.security_type = lot.security_type, .security_type = lot.security_type,
.unit_value = unit_value, .unit_value = unit_value,
.face_value = before_agg.shares * unit_value, .face_value = held * unit_value,
.maturity_date = lot.maturity_date, .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 ──────────────────────────────── // ── Transfer reclassification ────────────────────────────────
/// Absolute-dollar tolerance for matching a transfer record's `amount` /// Absolute-dollar tolerance for matching a transfer record's `amount`