do not allow matured/closed options to clamp open positions
This commit is contained in:
parent
60e2f438c2
commit
c5bb43dfad
1 changed files with 240 additions and 11 deletions
|
|
@ -50,9 +50,19 @@ pub const PortfolioSummary = struct {
|
|||
/// shares should be valued at the strike price, not the market price.
|
||||
/// 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
|
||||
/// 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.
|
||||
// For each underlying, compute total covered shares and the
|
||||
// value reduction if the calls are ITM.
|
||||
|
|
@ -62,6 +72,10 @@ pub const PortfolioSummary = struct {
|
|||
|
||||
for (lots) |lot| {
|
||||
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.shares >= 0) continue; // only sold (short) calls
|
||||
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.
|
||||
current_price: f64,
|
||||
/// 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,
|
||||
/// Total cost basis: sum of (lot.shares * lot.open_price) across all lots.
|
||||
cost_basis: f64,
|
||||
|
|
@ -421,7 +437,7 @@ pub fn portfolioSummary(
|
|||
.allocations = try allocs.toOwnedSlice(allocator),
|
||||
};
|
||||
|
||||
summary.adjustForCoveredCalls(portfolio.lots, prices);
|
||||
summary.adjustForCoveredCalls(as_of, portfolio.lots, prices);
|
||||
summary.adjustForNonStockAssets(as_of, portfolio);
|
||||
|
||||
return summary;
|
||||
|
|
@ -996,6 +1012,7 @@ test "portfolioSummary skips price_ratio for manual/fallback prices" {
|
|||
test "adjustForCoveredCalls ITM sold call" {
|
||||
const Lot = portfolio_mod.Lot;
|
||||
const alloc = std.testing.allocator;
|
||||
const as_of = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
// AMZN at $225, with 3 sold $220 calls
|
||||
var allocs = [_]Allocation{
|
||||
|
|
@ -1012,14 +1029,14 @@ test "adjustForCoveredCalls ITM sold call" {
|
|||
|
||||
var lots = [_]Lot{
|
||||
.{ .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);
|
||||
defer prices.deinit();
|
||||
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
|
||||
// Reduction = 300 * (225 - 220) = 1500
|
||||
|
|
@ -1032,6 +1049,7 @@ test "adjustForCoveredCalls ITM sold call" {
|
|||
test "adjustForCoveredCalls OTM — no adjustment" {
|
||||
const Lot = portfolio_mod.Lot;
|
||||
const alloc = std.testing.allocator;
|
||||
const as_of = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
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 },
|
||||
|
|
@ -1047,14 +1065,14 @@ test "adjustForCoveredCalls OTM — no adjustment" {
|
|||
|
||||
var lots = [_]Lot{
|
||||
.{ .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);
|
||||
defer prices.deinit();
|
||||
try prices.put("AMZN", 215.0);
|
||||
|
||||
summary.adjustForCoveredCalls(&lots, prices);
|
||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
||||
|
||||
// OTM (215 < 220) — no adjustment
|
||||
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" {
|
||||
const Lot = portfolio_mod.Lot;
|
||||
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.
|
||||
var allocs = [_]Allocation{
|
||||
|
|
@ -1079,14 +1098,14 @@ test "adjustForCoveredCalls partial coverage" {
|
|||
|
||||
var lots = [_]Lot{
|
||||
.{ .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);
|
||||
defer prices.deinit();
|
||||
try prices.put("AMZN", 225.0);
|
||||
|
||||
summary.adjustForCoveredCalls(&lots, prices);
|
||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
||||
|
||||
// 300 covered but only 200 shares → scale reduction
|
||||
// Full reduction would be 300 * 5 = 1500, scaled to 200/300 = 1000
|
||||
|
|
@ -1097,6 +1116,7 @@ test "adjustForCoveredCalls partial coverage" {
|
|||
test "adjustForCoveredCalls ignores puts" {
|
||||
const Lot = portfolio_mod.Lot;
|
||||
const alloc = std.testing.allocator;
|
||||
const as_of = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
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 },
|
||||
|
|
@ -1112,19 +1132,228 @@ test "adjustForCoveredCalls ignores puts" {
|
|||
|
||||
var lots = [_]Lot{
|
||||
.{ .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);
|
||||
defer prices.deinit();
|
||||
try prices.put("AMZN", 225.0);
|
||||
|
||||
summary.adjustForCoveredCalls(&lots, prices);
|
||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
||||
|
||||
// Puts are ignored — no adjustment
|
||||
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" {
|
||||
// Illiquid property closed on 2026-03-15. Net worth before the sale
|
||||
// should include it; after shouldn't.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue