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