diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index 4912dc3..9c6cb64 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -1082,7 +1082,20 @@ pub fn analyzePortfolio( }, .cash => lot.shares, .cd => lot.shares, // face value - .option => @abs(lot.shares) * lot.open_price, + // Premium at open. `multiplier` (100 for standard US equity + // options) is NOT optional - omitting it counted 1/100th of + // the premium and left every option-holding account's row + // short by 99% of it, so `zfin analysis`'s By Account + // section disagreed with its own Options sector row and + // with the `kind::account` totals in every snapshot. + // + // Must stay identical to `Portfolio.nonStockValueForAccount` + // and the `.option` arm of `commands/snapshot.zig:buildSnapshot`. + // All three use `@abs`, so a WRITTEN (short) option counts as + // a positive asset rather than a liability. That is a + // deliberate shared convention, not an oversight - do not + // "fix" it at one site alone. + .option => @abs(lot.shares) * lot.open_price * lot.multiplier, .illiquid, .watch => continue, }; const prev = acct_map.get(acct) orelse 0; @@ -2200,6 +2213,61 @@ fn testTaxRollup( ); } +test "analyzePortfolio: an option's account value includes the contract multiplier" { + // The regression. This arm used to be `@abs(shares) * open_price` + // with no `multiplier`, so it counted $2.05 where every other site + // counted $205.00 - leaving each option-holding account short by 99% + // of its premium, and making `zfin analysis`'s By Account section + // disagree with its own Options sector row and with the + // `kind::account` rows written into every snapshot. + const Lot = @import("../models/portfolio.zig").Lot; + var lots = [_]Lot{ + .{ + .symbol = "SPY", + .shares = 1000, + .open_date = Date.fromYmd(2020, 1, 1), + .open_price = 100, + .account = "Sample Brokerage", + }, + // A written call: -2 contracts at $2.05 premium, multiplier 100. + // Premium = |-2| * 2.05 * 100 = $410.00, NOT $4.10. + // Opened before `testTaxRollup`'s as_of (2026-08-01) and maturing + // after it, so `lotIsOpenAsOf` includes it. + .{ + .security_type = .option, + .symbol = "SPY 09/18/2026 700.00 C", + .shares = -2, + .open_date = Date.fromYmd(2026, 7, 21), + .maturity_date = Date.fromYmd(2026, 9, 18), + .open_price = 2.05, + .option_type = .call, + .underlying = "SPY", + .strike = 700, + .account = "Sample Brokerage", + }, + }; + + var am = try testParseAccountMap( + \\#!srfv1 + \\account::Sample Brokerage,tax_type::taxable + ); + defer am.deinit(); + + var result = try testTaxRollup(am, &lots, 100_000); + defer result.deinit(std.testing.allocator); + + const acct = testBreakdownValue(result.account, "Sample Brokerage").?; + // $100,000 of stock + $410.00 of premium. Under the bug: $100,004.10. + try std.testing.expectApproxEqAbs(@as(f64, 100_410.0), acct, 0.005); + + // And it must agree with the other two copies of this formula. This is + // the invariant that actually matters - the three sites drifted once. + const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator }; + const non_stock = portfolio.nonStockValueForAccount(Date.fromYmd(2026, 8, 26), "Sample Brokerage"); + try std.testing.expectApproxEqAbs(@as(f64, 410.0), non_stock, 0.005); + try std.testing.expectApproxEqAbs(acct, 100_000.0 + non_stock, 0.005); +} + test "analyzePortfolio: a mixed account splits its value across tax-type rows" { // The headline case. One 401(k) reporting a single $400k balance that // is really 75% pre-tax and 25% Roth, plus a $100k taxable brokerage. diff --git a/src/commands/snapshot.zig b/src/commands/snapshot.zig index 731b583..61c6a3c 100644 --- a/src/commands/snapshot.zig +++ b/src/commands/snapshot.zig @@ -870,6 +870,9 @@ fn buildSnapshot( }); }, .option => { + // One of three copies of this premium formula - see + // `Portfolio.nonStockValueForAccount` for the invariant + // and why `@abs` is deliberate. const opt_value = @abs(lot.shares) * lot.open_price * lot.multiplier; try lots_list.append(allocator, .{ .kind = "lot", diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index 57dc835..261ec3b 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -799,6 +799,16 @@ pub const Portfolio = struct { /// Total value of non-stock holdings (cash, CDs, options) for a single account. /// Only includes open lots (respects close_date and maturity_date). + /// + /// The option arm below is one of THREE copies of the same premium + /// formula - the others are `analytics/analysis.zig`'s account + /// breakdown and the `.option` arm of + /// `commands/snapshot.zig:buildSnapshot`. They must agree; `analysis` + /// once dropped `multiplier` and silently under-reported every + /// option-holding account by 99% of its premium. All three use + /// `@abs`, so a written (short) option counts as a positive asset + /// rather than a liability - a deliberate shared convention. Change + /// either detail at all three sites or none. pub fn nonStockValueForAccount(self: Portfolio, as_of: Date, account_name: []const u8) f64 { var total: f64 = 0; for (self.lots) |lot| {