contributions: handle partial sale of lots
This commit is contained in:
parent
8df6bd5c03
commit
1f7d2c596e
2 changed files with 383 additions and 38 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue