do not allow matured/closed options to clamp open positions

This commit is contained in:
Emil Lerch 2026-05-23 10:03:43 -07:00
parent 60e2f438c2
commit c5bb43dfad
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

@ -50,9 +50,19 @@ pub const PortfolioSummary = struct {
/// shares should be valued at the strike price, not the market price. /// shares should be valued at the strike price, not the market price.
/// This reflects the realistic assignment value of the position. /// This reflects the realistic assignment value of the position.
/// ///
/// Only currently-open option lots contribute to the cap. Specifically,
/// we skip lots whose `maturity_date` is on or before `as_of` (the
/// option has expired was either assigned or expired worthless,
/// either way it no longer covers anything) and lots whose `close_date`
/// is on or before `as_of` (user manually closed the position before
/// expiry, e.g. recorded an assignment by hand). `Lot.lotIsOpenAsOf`
/// is the single source of truth for that check; bugs in either case
/// would otherwise cap the underlying's market value FOREVER, every
/// time we run a portfolio summary, even though the contract is gone.
///
/// Must be called BEFORE `adjustForNonStockAssets`, which adds cash/CD/option /// Must be called BEFORE `adjustForNonStockAssets`, which adds cash/CD/option
/// totals on top of the recomputed stock totals. /// totals on top of the recomputed stock totals.
fn adjustForCoveredCalls(self: *PortfolioSummary, lots: []const portfolio_mod.Lot, prices: std.StringHashMap(f64)) void { fn adjustForCoveredCalls(self: *PortfolioSummary, as_of: Date, lots: []const portfolio_mod.Lot, prices: std.StringHashMap(f64)) void {
// Collect sold call adjustments grouped by underlying symbol. // Collect sold call adjustments grouped by underlying symbol.
// For each underlying, compute total covered shares and the // For each underlying, compute total covered shares and the
// value reduction if the calls are ITM. // value reduction if the calls are ITM.
@ -62,6 +72,10 @@ pub const PortfolioSummary = struct {
for (lots) |lot| { for (lots) |lot| {
if (lot.security_type != .option) continue; if (lot.security_type != .option) continue;
// Past maturity OR explicitly closed the contract no
// longer covers shares. `lotIsOpenAsOf` handles both
// cases plus the "not yet opened" edge.
if (!lot.lotIsOpenAsOf(as_of)) continue;
if (lot.option_type != .call) continue; if (lot.option_type != .call) continue;
if (lot.shares >= 0) continue; // only sold (short) calls if (lot.shares >= 0) continue; // only sold (short) calls
const underlying = lot.underlying orelse continue; const underlying = lot.underlying orelse continue;
@ -123,7 +137,9 @@ pub const Allocation = struct {
/// Latest price from API (or manual fallback), before price_ratio adjustment. /// Latest price from API (or manual fallback), before price_ratio adjustment.
current_price: f64, current_price: f64,
/// Total current value: shares * current_price * price_ratio. /// Total current value: shares * current_price * price_ratio.
/// May be reduced by adjustForCoveredCalls for ITM sold calls. /// May be reduced by adjustForCoveredCalls for ITM sold calls
/// that are still open as of the summary's `as_of` date
/// matured / closed contracts no longer cap the underlying.
market_value: f64, market_value: f64,
/// Total cost basis: sum of (lot.shares * lot.open_price) across all lots. /// Total cost basis: sum of (lot.shares * lot.open_price) across all lots.
cost_basis: f64, cost_basis: f64,
@ -421,7 +437,7 @@ pub fn portfolioSummary(
.allocations = try allocs.toOwnedSlice(allocator), .allocations = try allocs.toOwnedSlice(allocator),
}; };
summary.adjustForCoveredCalls(portfolio.lots, prices); summary.adjustForCoveredCalls(as_of, portfolio.lots, prices);
summary.adjustForNonStockAssets(as_of, portfolio); summary.adjustForNonStockAssets(as_of, portfolio);
return summary; return summary;
@ -996,6 +1012,7 @@ test "portfolioSummary skips price_ratio for manual/fallback prices" {
test "adjustForCoveredCalls ITM sold call" { test "adjustForCoveredCalls ITM sold call" {
const Lot = portfolio_mod.Lot; const Lot = portfolio_mod.Lot;
const alloc = std.testing.allocator; const alloc = std.testing.allocator;
const as_of = Date.fromYmd(2026, 5, 8);
// AMZN at $225, with 3 sold $220 calls // AMZN at $225, with 3 sold $220 calls
var allocs = [_]Allocation{ var allocs = [_]Allocation{
@ -1012,14 +1029,14 @@ test "adjustForCoveredCalls ITM sold call" {
var lots = [_]Lot{ var lots = [_]Lot{
.{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 }, .{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 },
.{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0 }, .{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20) },
}; };
var prices = std.StringHashMap(f64).init(alloc); var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit(); defer prices.deinit();
try prices.put("AMZN", 225.0); try prices.put("AMZN", 225.0);
summary.adjustForCoveredCalls(&lots, prices); summary.adjustForCoveredCalls(as_of, &lots, prices);
// 300 shares covered (3 contracts × 100), ITM by $5 each // 300 shares covered (3 contracts × 100), ITM by $5 each
// Reduction = 300 * (225 - 220) = 1500 // Reduction = 300 * (225 - 220) = 1500
@ -1032,6 +1049,7 @@ test "adjustForCoveredCalls ITM sold call" {
test "adjustForCoveredCalls OTM — no adjustment" { test "adjustForCoveredCalls OTM — no adjustment" {
const Lot = portfolio_mod.Lot; const Lot = portfolio_mod.Lot;
const alloc = std.testing.allocator; const alloc = std.testing.allocator;
const as_of = Date.fromYmd(2026, 5, 8);
var allocs = [_]Allocation{ var allocs = [_]Allocation{
.{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 215.0, .market_value = 107500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 7500.0, .unrealized_return = 0.075 }, .{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 215.0, .market_value = 107500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 7500.0, .unrealized_return = 0.075 },
@ -1047,14 +1065,14 @@ test "adjustForCoveredCalls OTM — no adjustment" {
var lots = [_]Lot{ var lots = [_]Lot{
.{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 }, .{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 },
.{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0 }, .{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20) },
}; };
var prices = std.StringHashMap(f64).init(alloc); var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit(); defer prices.deinit();
try prices.put("AMZN", 215.0); try prices.put("AMZN", 215.0);
summary.adjustForCoveredCalls(&lots, prices); summary.adjustForCoveredCalls(as_of, &lots, prices);
// OTM (215 < 220) no adjustment // OTM (215 < 220) no adjustment
try std.testing.expectApproxEqAbs(@as(f64, 107500), summary.allocations[0].market_value, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 107500), summary.allocations[0].market_value, 0.01);
@ -1063,6 +1081,7 @@ test "adjustForCoveredCalls OTM — no adjustment" {
test "adjustForCoveredCalls partial coverage" { test "adjustForCoveredCalls partial coverage" {
const Lot = portfolio_mod.Lot; const Lot = portfolio_mod.Lot;
const alloc = std.testing.allocator; const alloc = std.testing.allocator;
const as_of = Date.fromYmd(2026, 5, 8);
// Only 200 shares but 3 calls (300 shares covered). Should cap at 200. // Only 200 shares but 3 calls (300 shares covered). Should cap at 200.
var allocs = [_]Allocation{ var allocs = [_]Allocation{
@ -1079,14 +1098,14 @@ test "adjustForCoveredCalls partial coverage" {
var lots = [_]Lot{ var lots = [_]Lot{
.{ .symbol = "AMZN", .shares = 200, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 }, .{ .symbol = "AMZN", .shares = 200, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 },
.{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0 }, .{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20) },
}; };
var prices = std.StringHashMap(f64).init(alloc); var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit(); defer prices.deinit();
try prices.put("AMZN", 225.0); try prices.put("AMZN", 225.0);
summary.adjustForCoveredCalls(&lots, prices); summary.adjustForCoveredCalls(as_of, &lots, prices);
// 300 covered but only 200 shares scale reduction // 300 covered but only 200 shares scale reduction
// Full reduction would be 300 * 5 = 1500, scaled to 200/300 = 1000 // Full reduction would be 300 * 5 = 1500, scaled to 200/300 = 1000
@ -1097,6 +1116,7 @@ test "adjustForCoveredCalls partial coverage" {
test "adjustForCoveredCalls ignores puts" { test "adjustForCoveredCalls ignores puts" {
const Lot = portfolio_mod.Lot; const Lot = portfolio_mod.Lot;
const alloc = std.testing.allocator; const alloc = std.testing.allocator;
const as_of = Date.fromYmd(2026, 5, 8);
var allocs = [_]Allocation{ var allocs = [_]Allocation{
.{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 }, .{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 },
@ -1112,19 +1132,228 @@ test "adjustForCoveredCalls ignores puts" {
var lots = [_]Lot{ var lots = [_]Lot{
.{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 }, .{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 },
.{ .symbol = "AMZN 260620P00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .put, .underlying = "AMZN", .strike = 220.0 }, .{ .symbol = "AMZN 260620P00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .put, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20) },
}; };
var prices = std.StringHashMap(f64).init(alloc); var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit(); defer prices.deinit();
try prices.put("AMZN", 225.0); try prices.put("AMZN", 225.0);
summary.adjustForCoveredCalls(&lots, prices); summary.adjustForCoveredCalls(as_of, &lots, prices);
// Puts are ignored no adjustment // Puts are ignored no adjustment
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
} }
// Regression: matured / closed options must NOT cap shares
//
// The bug these tests pin: prior to the fix, `adjustForCoveredCalls`
// only filtered by security_type / option_type / shares-sign /
// underlying / strike / ITM. It did NOT check whether the option
// was still open. So a sold call that had passed `maturity_date`
// (assigned or expired worthless either way, gone) or had been
// manually closed via `close_date::` would FOREVER cap the
// underlying's market value, every time we ran a portfolio
// summary.
//
// Real example from the field: 300 shares of NVDA + 2 sold calls
// covering 200 shares. After the calls expired, the user was
// still seeing the market value of 200 NVDA shares capped at
// strike. These tests pin the fix and prevent the regression.
test "adjustForCoveredCalls: matured ITM call no longer caps the underlying" {
const Lot = portfolio_mod.Lot;
const alloc = std.testing.allocator;
const as_of = Date.fromYmd(2026, 5, 8);
// 500 NVDA shares + 3 sold $220 calls that EXPIRED on
// 2025-12-19 (well before as_of). With the bug, these still
// capped 300 shares at $220 even today. With the fix, the
// matured calls are skipped and market value = 500 * $225.
var allocs = [_]Allocation{
.{ .symbol = "NVDA", .display_symbol = "NVDA", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 },
};
var summary = PortfolioSummary{
.total_value = 112500,
.total_cost = 100000,
.unrealized_gain_loss = 12500,
.unrealized_return = 0.125,
.realized_gain_loss = 0,
.allocations = &allocs,
};
var lots = [_]Lot{
.{ .symbol = "NVDA", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 },
.{
.symbol = "NVDA 251219C00220000",
.shares = -3,
.open_date = Date.fromYmd(2025, 6, 1),
.open_price = 8.35,
.security_type = .option,
.option_type = .call,
.underlying = "NVDA",
.strike = 220.0,
.maturity_date = Date.fromYmd(2025, 12, 19), // EXPIRED before as_of
},
};
var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit();
try prices.put("NVDA", 225.0);
summary.adjustForCoveredCalls(as_of, &lots, prices);
// No cap applied market value unchanged from the original
// un-adjusted value. With the bug, this would have been
// 112500 - 1500 = 111000.
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 12500), summary.unrealized_gain_loss, 0.01);
}
test "adjustForCoveredCalls: maturity_date == as_of treated as closed" {
// Pin the end-of-day-on-expiry semantics from
// `Lot.lotIsOpenAsOf`: maturity_date <= as_of means the
// contract is gone. Drift between modules on this rule
// would cause subtle off-by-one valuation bugs on expiry
// day, so we pin the exact boundary.
const Lot = portfolio_mod.Lot;
const alloc = std.testing.allocator;
const as_of = Date.fromYmd(2026, 5, 8);
var allocs = [_]Allocation{
.{ .symbol = "NVDA", .display_symbol = "NVDA", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 },
};
var summary = PortfolioSummary{
.total_value = 112500,
.total_cost = 100000,
.unrealized_gain_loss = 12500,
.unrealized_return = 0.125,
.realized_gain_loss = 0,
.allocations = &allocs,
};
var lots = [_]Lot{
.{ .symbol = "NVDA", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 },
.{
.symbol = "NVDA 260508C00220000",
.shares = -3,
.open_date = Date.fromYmd(2025, 6, 1),
.open_price = 8.35,
.security_type = .option,
.option_type = .call,
.underlying = "NVDA",
.strike = 220.0,
.maturity_date = as_of, // expires on as_of itself closed
},
};
var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit();
try prices.put("NVDA", 225.0);
summary.adjustForCoveredCalls(as_of, &lots, prices);
// Treated as closed at as_of no cap.
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
}
test "adjustForCoveredCalls: lot with close_date set does not cap" {
// The user manually marked the call as closed (e.g. recorded
// an early assignment by setting `close_date::` and
// `close_price::` in the portfolio file). The contract no
// longer exists; stop applying the cap.
const Lot = portfolio_mod.Lot;
const alloc = std.testing.allocator;
const as_of = Date.fromYmd(2026, 5, 8);
var allocs = [_]Allocation{
.{ .symbol = "NVDA", .display_symbol = "NVDA", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 },
};
var summary = PortfolioSummary{
.total_value = 112500,
.total_cost = 100000,
.unrealized_gain_loss = 12500,
.unrealized_return = 0.125,
.realized_gain_loss = 0,
.allocations = &allocs,
};
var lots = [_]Lot{
.{ .symbol = "NVDA", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 },
.{
.symbol = "NVDA 260620C00220000",
.shares = -3,
.open_date = Date.fromYmd(2025, 6, 1),
.open_price = 8.35,
.security_type = .option,
.option_type = .call,
.underlying = "NVDA",
.strike = 220.0,
// maturity_date is well in the future, but...
.maturity_date = Date.fromYmd(2026, 6, 20),
// ...the user closed early.
.close_date = Date.fromYmd(2026, 3, 15),
.close_price = 7.10,
},
};
var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit();
try prices.put("NVDA", 225.0);
summary.adjustForCoveredCalls(as_of, &lots, prices);
// close_date is before as_of contract gone no cap.
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
}
test "adjustForCoveredCalls: open call still caps — sanity counter-test" {
// Counter-test for the regressions above: with everything
// else the same as the matured-call test but maturity_date
// moved to AFTER as_of, the cap DOES apply. This pins that
// the new filter doesn't accidentally over-cull and break
// the happy path.
const Lot = portfolio_mod.Lot;
const alloc = std.testing.allocator;
const as_of = Date.fromYmd(2026, 5, 8);
var allocs = [_]Allocation{
.{ .symbol = "NVDA", .display_symbol = "NVDA", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 },
};
var summary = PortfolioSummary{
.total_value = 112500,
.total_cost = 100000,
.unrealized_gain_loss = 12500,
.unrealized_return = 0.125,
.realized_gain_loss = 0,
.allocations = &allocs,
};
var lots = [_]Lot{
.{ .symbol = "NVDA", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 },
.{
.symbol = "NVDA 260620C00220000",
.shares = -3,
.open_date = Date.fromYmd(2025, 6, 1),
.open_price = 8.35,
.security_type = .option,
.option_type = .call,
.underlying = "NVDA",
.strike = 220.0,
.maturity_date = Date.fromYmd(2026, 6, 20), // AFTER as_of still open
},
};
var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit();
try prices.put("NVDA", 225.0);
summary.adjustForCoveredCalls(as_of, &lots, prices);
// Cap applies: 300 shares × $5 ITM = $1500 reduction.
try std.testing.expectApproxEqAbs(@as(f64, 111000), summary.allocations[0].market_value, 0.01);
}
test "netWorth / netWorthAsOf: illiquid respects target date" { test "netWorth / netWorthAsOf: illiquid respects target date" {
// Illiquid property closed on 2026-03-15. Net worth before the sale // Illiquid property closed on 2026-03-15. Net worth before the sale
// should include it; after shouldn't. // should include it; after shouldn't.