diff --git a/docs/reference/cli/contributions.md b/docs/reference/cli/contributions.md index 4245950..17faee8 100644 --- a/docs/reference/cli/contributions.md +++ b/docs/reference/cli/contributions.md @@ -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 diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 9c2b4b8..071a160 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -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,