diff --git a/TODO.md b/TODO.md index 6718dfd..bb8b156 100644 --- a/TODO.md +++ b/TODO.md @@ -359,22 +359,6 @@ so they don't get lost; pick up opportunistically. showing all monthlies expanded by default, or filtering by strategy (covered calls, spreads). -### Options / valuation - -- **Per-account covered call adjustment.** `adjustForCoveredCalls` in - `valuation.zig` operates on portfolio-wide aggregated allocations. - It matches sold calls against total underlying shares across all - accounts. This is wrong - calls in one account can only cover - shares in that same account. Fixing means restructuring - `portfolioSummary`, since `Allocation` is currently - account-agnostic. Low priority - naked calls are rare, and calls - are typically in the same account as the underlying. -- **Covered call adjustment O(N*M) loop.** `adjustForCoveredCalls` - has a nested loop - for each allocation, it iterates all lots to - find matching option contracts. Fine for personal portfolios - (<1000 lots). Pre-indexing options by underlying would help if - someone had a very large options-heavy portfolio. - ### Audit - **Audit large-lot threshold tuning.** `src/commands/audit.zig` uses diff --git a/src/analytics/valuation.zig b/src/analytics/valuation.zig index 5199ed9..e5bc448 100644 --- a/src/analytics/valuation.zig +++ b/src/analytics/valuation.zig @@ -50,6 +50,21 @@ 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. /// + /// Coverage is matched PER ACCOUNT: a sold call can only be covered by + /// shares of the underlying held in the same account (you can't deliver + /// Sample IRA shares against a Sample Brokerage call). Allocations are + /// account-agnostic by the time we get here - `positionsAsOf` aggregates + /// lots across accounts - so we recover the per-account share counts + /// straight from `lots`, cap each account's coverage at that account's + /// shares, and sum the per-account reductions back onto the + /// (account-agnostic) allocation. Calls written against shares sitting in + /// a different account are effectively naked and cap nothing. + /// + /// Untagged lots follow a "null is its own bucket" rule: a lot with no + /// `account::` shares one bucket with every other untagged lot, and a + /// call in a named account never draws on untagged shares (or vice + /// versa). See `sameAccountBucket`. + /// /// 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, @@ -63,44 +78,74 @@ pub const PortfolioSummary = struct { /// Must be called BEFORE `adjustForNonStockAssets`, which adds cash/CD/option /// totals on top of the recomputed stock totals. 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. for (self.allocations) |*alloc| { - var total_covered: f64 = 0; + // Underlying and option strikes are both raw market prices; the + // allocation's market_value is in ratio-adjusted terms, so the + // summed reduction gets the `price_ratio` multiply at the end. + // (Options don't exist on institutional share classes, so the + // strike-vs-market math itself stays ratio-free.) + const current_price = prices.get(alloc.symbol) orelse continue; + var total_reduction: f64 = 0; - 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; - const strike = lot.strike orelse continue; - if (!std.mem.eql(u8, underlying, alloc.symbol)) continue; + // Walk each distinct account bucket that has an open ITM sold + // call on this symbol. We dedupe by skipping any matching call + // whose bucket an earlier matching call already represented - + // allocation-free, and cheap for personal portfolios. + for (lots, 0..) |call_lot, i| { + if (!isOpenSoldCallOn(call_lot, as_of, alloc.symbol)) continue; + var bucket_seen = false; + for (lots[0..i]) |prev| { + if (isOpenSoldCallOn(prev, as_of, alloc.symbol) and + sameAccountBucket(prev.account, call_lot.account)) + { + bucket_seen = true; + break; + } + } + if (bucket_seen) continue; - const current_price = prices.get(underlying) orelse continue; - if (current_price <= strike) continue; // OTM - no adjustment + // Sum this bucket's ITM coverage and the strike-vs-market + // value reduction it implies. + var covered: f64 = 0; + var reduction: f64 = 0; + for (lots) |l| { + if (!isOpenSoldCallOn(l, as_of, alloc.symbol)) continue; + if (!sameAccountBucket(l.account, call_lot.account)) continue; + const strike = l.strike orelse continue; + if (current_price <= strike) continue; // OTM - no adjustment + const c = @abs(l.shares) * l.multiplier; + covered += c; + reduction += c * (current_price - strike); + } + if (reduction <= 0) continue; - const covered = @abs(lot.shares) * lot.multiplier; - total_covered += covered; - // Strike and current_price are both in raw market terms (not ratio-adjusted). - // Options don't exist on institutional share classes, so price_ratio is irrelevant here. - total_reduction += covered * (current_price - strike); + // Shares of this symbol held in the SAME account bucket - + // the only shares that can be called away. Coverage beyond + // them is naked and caps nothing. Clamp to non-negative so + // a net-short stock bucket can't invert the reduction. + var shares_in_bucket: f64 = 0; + for (lots) |l| { + if (l.security_type != .stock) continue; + if (!l.lotIsOpenAsOf(as_of)) continue; + if (!std.mem.eql(u8, l.priceSymbol(), alloc.symbol)) continue; + if (!sameAccountBucket(l.account, call_lot.account)) continue; + shares_in_bucket += l.shares; + } + if (shares_in_bucket < 0) shares_in_bucket = 0; + + // Scale the reduction proportionally when over-covered + // (same rule as the prior portfolio-wide cap, now per bucket). + const effective = if (covered > shares_in_bucket) + reduction * (shares_in_bucket / covered) + else + reduction; + total_reduction += effective; } if (total_reduction > 0) { - // Don't cover more shares than the position holds - const effective_reduction = if (total_covered > alloc.shares) - total_reduction * (alloc.shares / total_covered) - else - total_reduction; - // Apply price_ratio to the reduction since alloc.market_value is in ratio-adjusted terms - alloc.market_value -= effective_reduction * alloc.price_ratio; + alloc.market_value -= total_reduction * alloc.price_ratio; alloc.unrealized_gain_loss = alloc.market_value - alloc.cost_basis; alloc.unrealized_return = if (alloc.cost_basis > 0) (alloc.market_value / alloc.cost_basis) - 1.0 else 0; } @@ -124,6 +169,30 @@ pub const PortfolioSummary = struct { } }; +/// True when `lot` is an open-as-of-`as_of` sold (short) call whose +/// underlying matches `symbol`. The shared predicate behind per-account +/// covered-call matching - every place that decides "does this lot cap +/// `symbol`'s value?" routes through here so the open/closed, call/put, +/// and sign rules can't drift apart. +fn isOpenSoldCallOn(lot: portfolio_mod.Lot, as_of: Date, symbol: []const u8) bool { + if (lot.security_type != .option) return false; + // Past maturity OR explicitly closed -> the contract no longer covers + // shares. `lotIsOpenAsOf` handles both plus the "not yet opened" edge. + if (!lot.lotIsOpenAsOf(as_of)) return false; + if (lot.option_type != .call) return false; + if (lot.shares >= 0) return false; // only sold (short) calls + const underlying = lot.underlying orelse return false; + return std.mem.eql(u8, underlying, symbol); +} + +/// Account-bucket equality with null normalized to "". Implements the +/// "null is its own bucket" rule: every untagged lot lands in one shared +/// bucket, and a tagged call only matches shares in its own named account. +/// See `adjustForCoveredCalls`. +fn sameAccountBucket(a: ?[]const u8, b: ?[]const u8) bool { + return std.mem.eql(u8, a orelse "", b orelse ""); +} + pub const Allocation = struct { /// Ticker symbol or CUSIP identifying this position. symbol: []const u8, @@ -1346,6 +1415,241 @@ test "adjustForCoveredCalls: open call still caps - sanity counter-test" { try std.testing.expectApproxEqAbs(@as(f64, 111000), summary.allocations[0].market_value, 0.01); } +// ── Per-account covered-call coverage ───────────────────────── +// +// A sold call can only be covered by shares of the underlying held in +// the SAME account; shares in a different account can't be delivered +// against it. These tests pin that the coverage cap is computed per +// account bucket rather than against the portfolio-wide share total +// (the old behavior, which over-capped naked calls). + +test "adjustForCoveredCalls: sold call in a different account is naked - no cap" { + // Sample IRA holds 500 AMZN with no calls. Sample Brokerage wrote 3 + // $220 calls but holds zero AMZN. The Brokerage calls are naked - they + // cannot be covered by IRA shares - so the underlying is NOT capped. + // Pre-fix (portfolio-wide matching) wrongly capped 300 shares. + 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, .account = "Sample IRA" }, + }; + 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 = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" }, + .{ .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), .account = "Sample Brokerage" }, + }; + + var prices = std.StringHashMap(f64).init(alloc); + defer prices.deinit(); + try prices.put("AMZN", 225.0); + + summary.adjustForCoveredCalls(as_of, &lots, prices); + + // Naked in Sample Brokerage (0 AMZN there) -> no cap. Pre-fix: 111000. + try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.total_value, 0.01); +} + +test "adjustForCoveredCalls: covered call in the same account still caps" { + // Both the 500 AMZN shares and the 3 $220 calls live in Sample + // Brokerage. Same-account coverage caps exactly as it did before the + // per-account change - the common, correct case. + 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, .account = "Sample Brokerage" }, + }; + 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 = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" }, + .{ .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), .account = "Sample Brokerage" }, + }; + + var prices = std.StringHashMap(f64).init(alloc); + defer prices.deinit(); + try prices.put("AMZN", 225.0); + + summary.adjustForCoveredCalls(as_of, &lots, prices); + + // 300 shares ITM by $5 -> 1500 reduction. + try std.testing.expectApproxEqAbs(@as(f64, 111000), summary.allocations[0].market_value, 0.01); +} + +test "adjustForCoveredCalls: shares split across accounts cap only same-account coverage" { + // Sample IRA: 500 AMZN, no calls. Sample Brokerage: 100 AMZN + 3 $220 + // calls (covering 300). Only the 100 Brokerage shares back the calls; + // the other 200 contracts are naked. Reduction scales to 100/300. + 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 = 600, .avg_cost = 200.0, .current_price = 225.0, .market_value = 135000.0, .cost_basis = 120000.0, .weight = 1.0, .unrealized_gain_loss = 15000.0, .unrealized_return = 0.125, .account = "Multiple" }, + }; + var summary = PortfolioSummary{ + .total_value = 135000, + .total_cost = 120000, + .unrealized_gain_loss = 15000, + .unrealized_return = 0.125, + .realized_gain_loss = 0, + .allocations = &allocs, + }; + + var lots = [_]Lot{ + .{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" }, + .{ .symbol = "AMZN", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" }, + .{ .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), .account = "Sample Brokerage" }, + }; + + var prices = std.StringHashMap(f64).init(alloc); + defer prices.deinit(); + try prices.put("AMZN", 225.0); + + summary.adjustForCoveredCalls(as_of, &lots, prices); + + // Brokerage: covered 300 capped at 100 shares -> 1500 * (100/300) = 500. + // Pre-fix (portfolio-wide): full 1500 -> 133500. + try std.testing.expectApproxEqAbs(@as(f64, 134500), summary.allocations[0].market_value, 0.01); +} + +test "adjustForCoveredCalls: per-account caps sum independently" { + // Sample IRA: 50 AMZN + 1 call (covers 100) -> over-covered, capped at + // 50 shares. Sample Brokerage: 300 AMZN + 2 calls (covers 200) -> fully + // covered. Each bucket caps against its own shares and the reductions + // sum. Pre-fix lumped all 300 covered against the 350 total. + 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 = 350, .avg_cost = 200.0, .current_price = 225.0, .market_value = 78750.0, .cost_basis = 70000.0, .weight = 1.0, .unrealized_gain_loss = 8750.0, .unrealized_return = 0.125, .account = "Multiple" }, + }; + var summary = PortfolioSummary{ + .total_value = 78750, + .total_cost = 70000, + .unrealized_gain_loss = 8750, + .unrealized_return = 0.125, + .realized_gain_loss = 0, + .allocations = &allocs, + }; + + var lots = [_]Lot{ + .{ .symbol = "AMZN", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" }, + .{ .symbol = "AMZN 260620C00220000", .shares = -1, .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), .account = "Sample IRA" }, + .{ .symbol = "AMZN", .shares = 300, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" }, + .{ .symbol = "AMZN 260620C00220000", .shares = -2, .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), .account = "Sample Brokerage" }, + }; + + var prices = std.StringHashMap(f64).init(alloc); + defer prices.deinit(); + try prices.put("AMZN", 225.0); + + summary.adjustForCoveredCalls(as_of, &lots, prices); + + // IRA: 100 covered capped at 50 -> 500 * (50/100) = 250. Brokerage: 200 + // covered, 300 shares -> 1000. Total 1250 -> 77500. Pre-fix: 300 < 350 + // total -> full 1500 -> 77250. + try std.testing.expectApproxEqAbs(@as(f64, 77500), summary.allocations[0].market_value, 0.01); +} + +test "adjustForCoveredCalls: null account is its own bucket - tagged call ignores untagged shares" { + // 500 AMZN with NO account:: (untagged bucket). The 3 $220 calls are + // tagged Sample Brokerage. Under "null is its own bucket" the tagged + // call finds zero AMZN in Sample Brokerage and caps nothing. + 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 }, + }; + 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{ + // No account:: on the shares -> untagged bucket. + .{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 }, + // Call tagged to a named account. + .{ .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), .account = "Sample Brokerage" }, + }; + + var prices = std.StringHashMap(f64).init(alloc); + defer prices.deinit(); + try prices.put("AMZN", 225.0); + + summary.adjustForCoveredCalls(as_of, &lots, prices); + + // Tagged call draws on no untagged shares -> no cap. + try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01); +} + +test "adjustForCoveredCalls: net-short stock bucket caps nothing" { + // Edge: a written call in an account whose stock position is net + // SHORT cannot be covered - there are no deliverable shares there. + // The negative per-bucket share count clamps to zero coverage. + // Sample IRA is short 100 AMZN with 1 written call; the real long + // 600 shares (and the positive aggregate) live in Sample Brokerage. + 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, .account = "Multiple" }, + }; + 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{ + // Net-short bucket: -100 AMZN in Sample IRA. + .{ .symbol = "AMZN", .shares = -100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" }, + // Written call in that same short bucket - nothing to cover. + .{ .symbol = "AMZN 260620C00220000", .shares = -1, .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), .account = "Sample IRA" }, + // The real long position lives elsewhere, with no calls. + .{ .symbol = "AMZN", .shares = 600, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" }, + }; + + var prices = std.StringHashMap(f64).init(alloc); + defer prices.deinit(); + try prices.put("AMZN", 225.0); + + summary.adjustForCoveredCalls(as_of, &lots, prices); + + // Short bucket clamps to 0 coverable shares -> no cap anywhere. + try std.testing.expectApproxEqAbs(@as(f64, 112500), 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.