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,
|
.cash => lot.shares,
|
||||||
.cd => lot.shares, // face value
|
.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,
|
.illiquid, .watch => continue,
|
||||||
};
|
};
|
||||||
const prev = acct_map.get(acct) orelse 0;
|
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" {
|
test "analyzePortfolio: a mixed account splits its value across tax-type rows" {
|
||||||
// The headline case. One 401(k) reporting a single $400k balance that
|
// 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.
|
// is really 75% pre-tax and 25% Roth, plus a $100k taxable brokerage.
|
||||||
|
|
|
||||||
|
|
@ -870,6 +870,9 @@ fn buildSnapshot(
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
.option => {
|
.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;
|
const opt_value = @abs(lot.shares) * lot.open_price * lot.multiplier;
|
||||||
try lots_list.append(allocator, .{
|
try lots_list.append(allocator, .{
|
||||||
.kind = "lot",
|
.kind = "lot",
|
||||||
|
|
|
||||||
|
|
@ -799,6 +799,16 @@ pub const Portfolio = struct {
|
||||||
|
|
||||||
/// Total value of non-stock holdings (cash, CDs, options) for a single account.
|
/// Total value of non-stock holdings (cash, CDs, options) for a single account.
|
||||||
/// Only includes open lots (respects close_date and maturity_date).
|
/// 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 {
|
pub fn nonStockValueForAccount(self: Portfolio, as_of: Date, account_name: []const u8) f64 {
|
||||||
var total: f64 = 0;
|
var total: f64 = 0;
|
||||||
for (self.lots) |lot| {
|
for (self.lots) |lot| {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue