contributions: honest at-close label

This commit is contained in:
Emil Lerch 2026-09-29 06:54:12 -07:00
parent 1f7d2c596e
commit c020f92449
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 58 additions and 9 deletions

View file

@ -60,10 +60,11 @@ rather than counting toward the total:
A sale is valued at `close_price` when you record one (see
[`portfolio.srf`](../config/portfolio-srf.md)), which is what the sale
actually realized. If you delete the lot outright instead, there is no
`close_price` to read and the current market price stands in -- accurate
for a recent sale, less so for one made long before the end of the
window. The report labels which was used: `at close` or `at mark`.
actually realized. If you delete the lot outright instead, or record the
close without a `close_price`, the current market price stands in --
accurate for a recent sale, less so for one made long before the end of
the window. The report labels which was used: `at close` only when every
lot on the line had a `close_price`, otherwise `at mark`.
An option removed after it expired is worth $0 -- whether it expired
worthless or was exercised, the premium isn't proceeds. (An exercise

View file

@ -1528,6 +1528,11 @@ const Change = struct {
face_value: f64 = 0,
/// For cd_matured / cd_removed_early: maturity_date.
maturity_date: ?Date = null,
/// For position_closed: true when every sold share was valued at
/// its own `close_price`, false when any fell back to the current
/// mark because the close was recorded without a price. Decides
/// whether the report may say "at close".
priced_at_close: bool = false,
/// For price_only: old and new values.
old_price: f64 = 0,
new_price: f64 = 0,
@ -1670,6 +1675,9 @@ const LotAgg = struct {
sold_shares: f64 = 0,
/// What those sold shares realized (`closeUnitValue` x shares).
sold_value: f64 = 0,
/// Some sold share had no `close_price`, so `sold_value` is partly
/// a current-price mark rather than what the sale realized.
sold_unpriced: bool = false,
/// Representative lot: the first unsold one when there is any, so
/// type-, maturity- and drip-dependent decisions describe the part
/// still held.
@ -1702,6 +1710,7 @@ fn aggregateByKey(
if (sold) {
gop.value_ptr.sold_shares += lot.shares;
gop.value_ptr.sold_value += lot.shares * closeUnitValue(lot, prices, as_of);
if (lot.closePriceAsOf(as_of) == null) gop.value_ptr.sold_unpriced = true;
}
}
return map;
@ -1923,6 +1932,7 @@ fn appendSale(
security_type: LotType,
sold: f64,
proceeds: f64,
priced_at_close: bool,
) !void {
try changes.append(allocator, .{
.kind = .position_closed,
@ -1932,6 +1942,7 @@ fn appendSale(
.unit_value = proceeds / sold,
.face_value = proceeds,
.delta_shares = -sold,
.priced_at_close = priced_at_close,
});
}
@ -2262,7 +2273,7 @@ fn computeReport(
// 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);
try appendSale(allocator, &changes, sym, acct, lot.security_type, split.sold, after_agg.sold_value - before_agg.sold_value, !after_agg.sold_unpriced);
}
const delta = split.residual;
@ -2420,7 +2431,7 @@ fn computeReport(
// - 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);
try appendSale(allocator, &changes, sym, acct, lot.security_type, after_agg.sold_shares, after_agg.sold_value, !after_agg.sold_unpriced);
}
}
}
@ -4381,21 +4392,25 @@ fn printCollapsedSales(out: *std.Io.Writer, report: *const Report, color: bool,
var proceeds: f64 = 0;
var lots: usize = 0;
var closed_in_place = false;
// "at close" only when every sale in the group was valued at its
// own `close_price`. A close recorded without a price falls back
// to the current mark, and so does every lot_removed /
// drip_negative; saying "at close" for those misstated the figure.
var all_at_close = true;
for (report.changes) |o| {
if (!isSaleKind(o)) continue;
if (!std.mem.eql(u8, o.account, c.account)) continue;
if (!std.mem.eql(u8, o.symbol, c.symbol)) continue;
proceeds += if (o.kind == .drip_negative) @abs(o.value()) else o.face_value;
lots += 1;
if (o.kind == .position_closed) closed_in_place = true;
if (!(o.kind == .position_closed and o.priced_at_close)) all_at_close = false;
}
const acct = if (c.account.len == 0) "(no account)" else c.account;
try cli.setFg(out, color, muted);
// `close_price` is what the sale realized; anything else is a
// current-price proxy, so say which one the reader is looking at.
const basis: []const u8 = if (closed_in_place) "at close" else "at mark";
const basis: []const u8 = if (all_at_close) "at close" else "at mark";
if (lots == 1) {
try writeRowPrefix(out, c.symbol, acct);
try out.print(" sold {s} ({f})", .{ basis, Money.from(proceeds) });
@ -4408,6 +4423,39 @@ fn printCollapsedSales(out: *std.Io.Writer, report: *const Report, color: bool,
}
}
fn collapsedSalesText(buf: []u8, before: []const Lot, after: []const Lot) ![]const u8 {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
var prices = std.StringHashMap(f64).init(a);
try prices.put("VTI", 250.0);
const report = try computeReport(a, before, after, &prices, Date.fromYmd(2026, 1, 20), .{});
var w = std.Io.Writer.fixed(buf);
try printCollapsedSales(&w, &report, false, cli.CLR_MUTED);
return w.buffered();
}
test "printCollapsedSales: a close with a price is 'at close'" {
var buf: [256]u8 = undefined;
const lot: Lot = .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 15), .open_price = 200, .account = "Sample Brokerage" };
var closed = lot;
closed.close_date = Date.fromYmd(2026, 1, 10);
closed.close_price = 260;
const out = try collapsedSalesText(&buf, &.{lot}, &.{closed});
try std.testing.expect(std.mem.indexOf(u8, out, "sold at close ($2,600.00)") != null);
}
test "printCollapsedSales: a close without a price is 'at mark', not 'at close'" {
// Valued at the current $250 mark because no close_price was
// recorded; the label used to claim otherwise.
var buf: [256]u8 = undefined;
const lot: Lot = .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 15), .open_price = 200, .account = "Sample Brokerage" };
var closed = lot;
closed.close_date = Date.fromYmd(2026, 1, 10);
const out = try collapsedSalesText(&buf, &.{lot}, &.{closed});
try std.testing.expect(std.mem.indexOf(u8, out, "sold at mark ($2,500.00)") != null);
}
/// Render an "Internal purchases" row: a `new_stock` / `new_cd` lot
/// funded (wholly or in part) by a same-account cash decrease. Muted -
/// these don't count toward attribution. Shows the funded amount and,