fix option valuation in analysis
This commit is contained in:
parent
a14b1ba4bc
commit
ad55d59f5c
3 changed files with 82 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue