covered call adjustments per account

This commit is contained in:
Emil Lerch 2026-06-25 14:48:20 -07:00
parent 097fe68d35
commit da9982d766
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 333 additions and 45 deletions

16
TODO.md
View file

@ -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

View file

@ -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.