contributions: expired or closed options do not count

This commit is contained in:
Emil Lerch 2026-09-29 06:48:30 -07:00
parent cc13572d7c
commit 8df6bd5c03
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 58 additions and 4 deletions

View file

@ -65,6 +65,10 @@ actually realized. If you delete the lot outright instead, there is no
for a recent sale, less so for one made long before the end of the 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`. window. The report labels which was used: `at close` or `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
shows up separately, as the stock and cash that moved.)
Closing a position that has accumulated a lot per dividend Closing a position that has accumulated a lot per dividend
reinvestment retires many lots at once, so sales collapse to one line reinvestment retires many lots at once, so sales collapse to one line
per account and symbol, carrying the lot count and the total. per account and symbol, carrying the lot count and the total.

View file

@ -1714,7 +1714,7 @@ fn secondaryKey(allocator: std.mem.Allocator, lot: Lot) ![]u8 {
/// applies. `lot.price` and `lot.open_price` are already in the lot's /// applies. `lot.price` and `lot.open_price` are already in the lot's
/// own share-class terms (preadjusted) -> ratio must NOT be applied. /// own share-class terms (preadjusted) -> ratio must NOT be applied.
/// See the "Pricing model" doc-block in models/portfolio.zig. /// See the "Pricing model" doc-block in models/portfolio.zig.
fn outflowUnitValue(lot: Lot, prices: *const std.StringHashMap(f64)) f64 { fn outflowUnitValue(lot: Lot, prices: *const std.StringHashMap(f64), as_of: Date) f64 {
return switch (lot.security_type) { return switch (lot.security_type) {
.stock => blk: { .stock => blk: {
if (prices.get(lot.priceSymbol())) |p| break :blk lot.effectivePrice(p, false); if (prices.get(lot.priceSymbol())) |p| break :blk lot.effectivePrice(p, false);
@ -1725,11 +1725,61 @@ fn outflowUnitValue(lot: Lot, prices: *const std.StringHashMap(f64)) f64 {
.cash => 1.0, .cash => 1.0,
// Face value per share. // Face value per share.
.cd => lot.open_price, .cd => lot.open_price,
.option => lot.open_price * lot.multiplier, // An option removed on or after its maturity expired, and an
// expired option is worth nothing: valuing it at its opening
// premium invented sale proceeds (a long call that died
// worthless "funded" part of the next buy) or, for a written
// one, a negative sale. If it was exercised or assigned, the
// stock and cash that moved show up as their own changes.
.option => if (lot.hasMaturedAsOf(as_of)) 0 else lot.open_price * lot.multiplier,
else => lot.open_price, else => lot.open_price,
}; };
} }
// ── Expired options (see `outflowUnitValue`) ──
fn testOption(shares: f64, account: []const u8) Lot {
return .{ .symbol = "SPY 01/16/2026 700 C", .shares = shares, .open_date = Date.fromYmd(2025, 6, 1), .open_price = 5, .security_type = .option, .maturity_date = Date.fromYmd(2026, 1, 16), .underlying = "SPY", .strike = 700, .account = account };
}
test "outflowUnitValue: an option deleted after it expired funds nothing" {
// A long call that died worthless used to be valued at its $500
// opening premium, and that phantom "sale" funded part of a real
// $2,000 fresh-money buy the same week - so only $1,500 counted.
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);
const buy: Lot = .{ .symbol = "VTI", .shares = 8, .open_date = Date.fromYmd(2026, 1, 20), .open_price = 250, .account = "Sample Brokerage" };
const report = try computeReport(a, &.{testOption(1, "Sample Brokerage")}, &.{buy}, &prices, Date.fromYmd(2026, 1, 25), .{});
try std.testing.expectApproxEqAbs(@as(f64, 2000), attributionTotalForTest(report), 0.01);
}
test "outflowUnitValue: a written option expiring is no inflow either" {
// The mirror image. Negative shares made the old valuation a
// +$500 "uncounted inflow" and a "sold at mark (-$500.00)" line.
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);
const report = try computeReport(a, &.{testOption(-1, "Sample Brokerage")}, &.{}, &prices, Date.fromYmd(2026, 1, 25), .{});
const unc = uncountedTotals(report.changes);
try std.testing.expectApproxEqAbs(@as(f64, 0), unc.in, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0), unc.out, 0.01);
}
test "outflowUnitValue: an option deleted BEFORE expiry still funds at its premium" {
// Only expiry zeroes it. Closing a position early (deleting it
// before maturity) keeps the existing cost-basis proxy.
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);
const buy: Lot = .{ .symbol = "VTI", .shares = 8, .open_date = Date.fromYmd(2026, 1, 10), .open_price = 250, .account = "Sample Brokerage" };
const report = try computeReport(a, &.{testOption(1, "Sample Brokerage")}, &.{buy}, &prices, Date.fromYmd(2026, 1, 12), .{});
try std.testing.expectApproxEqAbs(@as(f64, 1500), attributionTotalForTest(report), 0.01);
}
/// Dollars-per-share realized when a lot was closed in place. /// Dollars-per-share realized when a lot was closed in place.
/// ///
/// `close_price` is authoritative - it is what the sale actually got, /// `close_price` is authoritative - it is what the sale actually got,
@ -1748,7 +1798,7 @@ fn closeUnitValue(lot: Lot, prices: *const std.StringHashMap(f64), as_of: Date)
else => cp, else => cp,
}; };
} }
return outflowUnitValue(lot, prices); return outflowUnitValue(lot, prices, as_of);
} }
/// Tolerance for "did the share total stay the same" check when /// Tolerance for "did the share total stay the same" check when
@ -2231,7 +2281,7 @@ fn computeReport(
// this was populated, `value()` was 0 for every removal and a // this was populated, `value()` was 0 for every removal and a
// sale funded nothing - so an intra-account reallocation read as // sale funded nothing - so an intra-account reallocation read as
// a full external contribution. // a full external contribution.
const unit_value = outflowUnitValue(lot, prices); const unit_value = outflowUnitValue(lot, prices, as_of);
try changes.append(allocator, .{ try changes.append(allocator, .{
.kind = kind, .kind = kind,