contributions: expired or closed options do not count
This commit is contained in:
parent
cc13572d7c
commit
8df6bd5c03
2 changed files with 58 additions and 4 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue