covered call adjustments per account
This commit is contained in:
parent
097fe68d35
commit
da9982d766
2 changed files with 333 additions and 45 deletions
16
TODO.md
16
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
|
showing all monthlies expanded by default, or filtering by strategy
|
||||||
(covered calls, spreads).
|
(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
|
||||||
|
|
||||||
- **Audit large-lot threshold tuning.** `src/commands/audit.zig` uses
|
- **Audit large-lot threshold tuning.** `src/commands/audit.zig` uses
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,21 @@ 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.
|
||||||
///
|
///
|
||||||
|
/// 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,
|
/// Only currently-open option lots contribute to the cap. Specifically,
|
||||||
/// we skip lots whose `maturity_date` is on or before `as_of` (the
|
/// we skip lots whose `maturity_date` is on or before `as_of` (the
|
||||||
/// option has expired - was either assigned or expired worthless,
|
/// 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
|
/// 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, as_of: Date, 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.
|
|
||||||
for (self.allocations) |*alloc| {
|
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;
|
var total_reduction: f64 = 0;
|
||||||
|
|
||||||
for (lots) |lot| {
|
// Walk each distinct account bucket that has an open ITM sold
|
||||||
if (lot.security_type != .option) continue;
|
// call on this symbol. We dedupe by skipping any matching call
|
||||||
// Past maturity OR explicitly closed -> the contract no
|
// whose bucket an earlier matching call already represented -
|
||||||
// longer covers shares. `lotIsOpenAsOf` handles both
|
// allocation-free, and cheap for personal portfolios.
|
||||||
// cases plus the "not yet opened" edge.
|
for (lots, 0..) |call_lot, i| {
|
||||||
if (!lot.lotIsOpenAsOf(as_of)) continue;
|
if (!isOpenSoldCallOn(call_lot, as_of, alloc.symbol)) continue;
|
||||||
if (lot.option_type != .call) continue;
|
var bucket_seen = false;
|
||||||
if (lot.shares >= 0) continue; // only sold (short) calls
|
for (lots[0..i]) |prev| {
|
||||||
const underlying = lot.underlying orelse continue;
|
if (isOpenSoldCallOn(prev, as_of, alloc.symbol) and
|
||||||
const strike = lot.strike orelse continue;
|
sameAccountBucket(prev.account, call_lot.account))
|
||||||
if (!std.mem.eql(u8, underlying, alloc.symbol)) continue;
|
{
|
||||||
|
bucket_seen = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bucket_seen) continue;
|
||||||
|
|
||||||
const current_price = prices.get(underlying) orelse continue;
|
// Sum this bucket's ITM coverage and the strike-vs-market
|
||||||
if (current_price <= strike) continue; // OTM - no adjustment
|
// 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;
|
// Shares of this symbol held in the SAME account bucket -
|
||||||
total_covered += covered;
|
// the only shares that can be called away. Coverage beyond
|
||||||
// Strike and current_price are both in raw market terms (not ratio-adjusted).
|
// them is naked and caps nothing. Clamp to non-negative so
|
||||||
// Options don't exist on institutional share classes, so price_ratio is irrelevant here.
|
// a net-short stock bucket can't invert the reduction.
|
||||||
total_reduction += covered * (current_price - strike);
|
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) {
|
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
|
// 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_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;
|
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 {
|
pub const Allocation = struct {
|
||||||
/// Ticker symbol or CUSIP identifying this position.
|
/// Ticker symbol or CUSIP identifying this position.
|
||||||
symbol: []const u8,
|
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);
|
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" {
|
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