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
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue