diff --git a/src/analytics/valuation.zig b/src/analytics/valuation.zig index 80b8264..08abc90 100644 --- a/src/analytics/valuation.zig +++ b/src/analytics/valuation.zig @@ -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.