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
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
reinvestment retires many lots at once, so sales collapse to one line
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
/// own share-class terms (preadjusted) -> ratio must NOT be applied.
/// 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) {
.stock => blk: {
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,
// Face value per share.
.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,
};
}
// ── 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.
///
/// `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,
};
}
return outflowUnitValue(lot, prices);
return outflowUnitValue(lot, prices, as_of);
}
/// 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
// sale funded nothing - so an intra-account reallocation read as
// a full external contribution.
const unit_value = outflowUnitValue(lot, prices);
const unit_value = outflowUnitValue(lot, prices, as_of);
try changes.append(allocator, .{
.kind = kind,