fix option valuation in analysis

This commit is contained in:
Emil Lerch 2026-08-27 08:14:18 -07:00
parent a14b1ba4bc
commit ad55d59f5c
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 82 additions and 1 deletions

View file

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

View file

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

View file

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