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
|
A sale is valued at `close_price` when you record one (see
|
||||||
[`portfolio.srf`](../config/portfolio-srf.md)), which is what the sale
|
[`portfolio.srf`](../config/portfolio-srf.md)), which is what the sale
|
||||||
actually realized. If you delete the lot outright instead, there is no
|
actually realized. If you delete the lot outright instead, or record the
|
||||||
`close_price` to read and the current market price stands in -- accurate
|
close without a `close_price`, the current market price stands in --
|
||||||
for a recent sale, less so for one made long before the end of the
|
accurate for a recent sale, less so for one made long before the end of
|
||||||
window. The report labels which was used: `at close` or `at mark`.
|
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -1528,6 +1528,11 @@ const Change = struct {
|
||||||
face_value: f64 = 0,
|
face_value: f64 = 0,
|
||||||
/// For cd_matured / cd_removed_early: maturity_date.
|
/// For cd_matured / cd_removed_early: maturity_date.
|
||||||
maturity_date: ?Date = null,
|
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.
|
/// For price_only: old and new values.
|
||||||
old_price: f64 = 0,
|
old_price: f64 = 0,
|
||||||
new_price: f64 = 0,
|
new_price: f64 = 0,
|
||||||
|
|
@ -1670,6 +1675,9 @@ const LotAgg = struct {
|
||||||
sold_shares: f64 = 0,
|
sold_shares: f64 = 0,
|
||||||
/// What those sold shares realized (`closeUnitValue` x shares).
|
/// What those sold shares realized (`closeUnitValue` x shares).
|
||||||
sold_value: f64 = 0,
|
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
|
/// Representative lot: the first unsold one when there is any, so
|
||||||
/// type-, maturity- and drip-dependent decisions describe the part
|
/// type-, maturity- and drip-dependent decisions describe the part
|
||||||
/// still held.
|
/// still held.
|
||||||
|
|
@ -1702,6 +1710,7 @@ fn aggregateByKey(
|
||||||
if (sold) {
|
if (sold) {
|
||||||
gop.value_ptr.sold_shares += lot.shares;
|
gop.value_ptr.sold_shares += lot.shares;
|
||||||
gop.value_ptr.sold_value += lot.shares * closeUnitValue(lot, prices, as_of);
|
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;
|
return map;
|
||||||
|
|
@ -1923,6 +1932,7 @@ fn appendSale(
|
||||||
security_type: LotType,
|
security_type: LotType,
|
||||||
sold: f64,
|
sold: f64,
|
||||||
proceeds: f64,
|
proceeds: f64,
|
||||||
|
priced_at_close: bool,
|
||||||
) !void {
|
) !void {
|
||||||
try changes.append(allocator, .{
|
try changes.append(allocator, .{
|
||||||
.kind = .position_closed,
|
.kind = .position_closed,
|
||||||
|
|
@ -1932,6 +1942,7 @@ fn appendSale(
|
||||||
.unit_value = proceeds / sold,
|
.unit_value = proceeds / sold,
|
||||||
.face_value = proceeds,
|
.face_value = proceeds,
|
||||||
.delta_shares = -sold,
|
.delta_shares = -sold,
|
||||||
|
.priced_at_close = priced_at_close,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2262,7 +2273,7 @@ fn computeReport(
|
||||||
// metadata comparisons below: a lot both closed and repriced
|
// metadata comparisons below: a lot both closed and repriced
|
||||||
// in one window is a sale, not a price edit.
|
// in one window is a sale, not a price edit.
|
||||||
if (@abs(split.sold) > 0.000001) {
|
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;
|
const delta = split.residual;
|
||||||
|
|
@ -2420,7 +2431,7 @@ fn computeReport(
|
||||||
// - bought from existing cash and sold: the buy is funded,
|
// - bought from existing cash and sold: the buy is funded,
|
||||||
// so nothing counts.
|
// so nothing counts.
|
||||||
if (@abs(after_agg.sold_shares) > 0.000001) {
|
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 proceeds: f64 = 0;
|
||||||
var lots: usize = 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| {
|
for (report.changes) |o| {
|
||||||
if (!isSaleKind(o)) continue;
|
if (!isSaleKind(o)) continue;
|
||||||
if (!std.mem.eql(u8, o.account, c.account)) continue;
|
if (!std.mem.eql(u8, o.account, c.account)) continue;
|
||||||
if (!std.mem.eql(u8, o.symbol, c.symbol)) continue;
|
if (!std.mem.eql(u8, o.symbol, c.symbol)) continue;
|
||||||
proceeds += if (o.kind == .drip_negative) @abs(o.value()) else o.face_value;
|
proceeds += if (o.kind == .drip_negative) @abs(o.value()) else o.face_value;
|
||||||
lots += 1;
|
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;
|
const acct = if (c.account.len == 0) "(no account)" else c.account;
|
||||||
try cli.setFg(out, color, muted);
|
try cli.setFg(out, color, muted);
|
||||||
// `close_price` is what the sale realized; anything else is a
|
// `close_price` is what the sale realized; anything else is a
|
||||||
// current-price proxy, so say which one the reader is looking at.
|
// 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) {
|
if (lots == 1) {
|
||||||
try writeRowPrefix(out, c.symbol, acct);
|
try writeRowPrefix(out, c.symbol, acct);
|
||||||
try out.print(" sold {s} ({f})", .{ basis, Money.from(proceeds) });
|
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
|
/// Render an "Internal purchases" row: a `new_stock` / `new_cd` lot
|
||||||
/// funded (wholly or in part) by a same-account cash decrease. Muted -
|
/// funded (wholly or in part) by a same-account cash decrease. Muted -
|
||||||
/// these don't count toward attribution. Shows the funded amount and,
|
/// these don't count toward attribution. Shows the funded amount and,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue