contributions: honest at-close label
This commit is contained in:
parent
1f7d2c596e
commit
c020f92449
2 changed files with 58 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue