diff --git a/docs/reference/cli/contributions.md b/docs/reference/cli/contributions.md index 880a83b..06f7c3b 100644 --- a/docs/reference/cli/contributions.md +++ b/docs/reference/cli/contributions.md @@ -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. diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index cc244ea..618e317 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -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,