filter non-stock totals by open-as-of

This commit is contained in:
Emil Lerch 2026-04-23 07:24:49 -07:00
parent cb77845086
commit c89e93c243
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 283 additions and 13 deletions

View file

@ -154,6 +154,20 @@ pub fn netWorth(portfolio: portfolio_mod.Portfolio, summary: PortfolioSummary) f
return summary.total_value + portfolio.totalIlliquid();
}
/// `netWorth` evaluated against an arbitrary date used by historical
/// snapshot backfill so the illiquid component matches the target-date
/// composition (e.g., before/after a property sale). `summary` is
/// computed from `portfolio.positionsAsOf(as_of)` upstream, so the
/// liquid side is already as-of-scoped; this helper only differs from
/// `netWorth` in how it pulls the illiquid total.
pub fn netWorthAsOf(
portfolio: portfolio_mod.Portfolio,
summary: PortfolioSummary,
as_of: Date,
) f64 {
return summary.total_value + portfolio.totalIlliquidAsOf(as_of);
}
/// Result of a date-targeted candle lookup.
pub const CandleAtDate = struct {
close: f64,
@ -864,3 +878,50 @@ test "adjustForCoveredCalls ignores puts" {
// Puts are ignored no adjustment
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
}
test "netWorth / netWorthAsOf: illiquid respects target date" {
// Illiquid property closed on 2026-03-15. Net worth before the sale
// should include it; after shouldn't.
var lots = [_]portfolio_mod.Lot{
.{
.symbol = "House",
.shares = 800000,
.open_date = Date.fromYmd(2020, 5, 1),
.open_price = 0,
.security_type = .illiquid,
.close_date = Date.fromYmd(2026, 3, 15),
},
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
// Liquid side: pretend summary says $100k.
const summary: PortfolioSummary = .{
.total_value = 100_000,
.total_cost = 100_000,
.unrealized_gain_loss = 0,
.unrealized_return = 0,
.realized_gain_loss = 0,
.allocations = &.{},
};
// Before sale: 100k liquid + 800k illiquid = 900k.
try std.testing.expectApproxEqAbs(
@as(f64, 900_000.0),
netWorthAsOf(portfolio, summary, Date.fromYmd(2026, 1, 1)),
0.01,
);
// After sale: illiquid excluded, net worth is just the 100k liquid.
try std.testing.expectApproxEqAbs(
@as(f64, 100_000.0),
netWorthAsOf(portfolio, summary, Date.fromYmd(2026, 4, 1)),
0.01,
);
// netWorth (wall-clock today) today is after the sale, so the
// illiquid is excluded. Asserts the no-arg form delegates correctly.
try std.testing.expectApproxEqAbs(
@as(f64, 100_000.0),
netWorth(portfolio, summary),
0.01,
);
}

View file

@ -683,8 +683,8 @@ fn buildSnapshot(
// `portfolio.positionsAsOf(as_of)` + `buildFallbackPrices` +
// `portfolioSummary`. The caller owns their lifetimes.
const illiquid = portfolio.totalIlliquid();
const net_worth = zfin.valuation.netWorth(portfolio.*, summary);
const illiquid = portfolio.totalIlliquidAsOf(as_of);
const net_worth = zfin.valuation.netWorthAsOf(portfolio.*, summary, as_of);
var totals = try allocator.alloc(TotalRow, 3);
totals[0] = .{ .kind = "total", .scope = "net_worth", .value = net_worth };

View file

@ -617,41 +617,70 @@ pub const Portfolio = struct {
return total;
}
/// Total cash across all accounts.
/// Total cash across all accounts (open lots only).
pub fn totalCash(self: Portfolio) f64 {
return self.totalCashAsOf(Date.fromEpoch(std.time.timestamp()));
}
/// `totalCash` evaluated against an arbitrary date used by
/// historical snapshot backfill. See `Lot.lotIsOpenAsOf`.
pub fn totalCashAsOf(self: Portfolio, as_of: Date) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.security_type == .cash) total += lot.shares;
if (lot.security_type != .cash) continue;
if (!lot.lotIsOpenAsOf(as_of)) continue;
total += lot.shares;
}
return total;
}
/// Total illiquid asset value across all accounts.
/// Total illiquid asset value across all accounts (open lots only).
pub fn totalIlliquid(self: Portfolio) f64 {
return self.totalIlliquidAsOf(Date.fromEpoch(std.time.timestamp()));
}
/// `totalIlliquid` evaluated against an arbitrary date.
pub fn totalIlliquidAsOf(self: Portfolio, as_of: Date) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.security_type == .illiquid) total += lot.shares;
if (lot.security_type != .illiquid) continue;
if (!lot.lotIsOpenAsOf(as_of)) continue;
total += lot.shares;
}
return total;
}
/// Total CD face value across all accounts.
/// Total CD face value across all accounts (open lots only
/// matured CDs are excluded).
pub fn totalCdFaceValue(self: Portfolio) f64 {
return self.totalCdFaceValueAsOf(Date.fromEpoch(std.time.timestamp()));
}
/// `totalCdFaceValue` evaluated against an arbitrary date.
pub fn totalCdFaceValueAsOf(self: Portfolio, as_of: Date) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.security_type == .cd) total += lot.shares;
if (lot.security_type != .cd) continue;
if (!lot.lotIsOpenAsOf(as_of)) continue;
total += lot.shares;
}
return total;
}
/// Total option cost basis (absolute value of shares * open_price).
/// Total option cost basis (|shares| * open_price * multiplier)
/// open lots only. Closed/matured options are excluded.
pub fn totalOptionCost(self: Portfolio) f64 {
return self.totalOptionCostAsOf(Date.fromEpoch(std.time.timestamp()));
}
/// `totalOptionCost` evaluated against an arbitrary date.
pub fn totalOptionCostAsOf(self: Portfolio, as_of: Date) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.security_type == .option) {
// open_price is per-share option price; multiply by contract size
total += @abs(lot.shares) * lot.open_price * lot.multiplier;
}
if (lot.security_type != .option) continue;
if (!lot.lotIsOpenAsOf(as_of)) continue;
// open_price is per-share option price; multiply by contract size
total += @abs(lot.shares) * lot.open_price * lot.multiplier;
}
return total;
}
@ -819,6 +848,186 @@ test "Portfolio totals" {
try std.testing.expect(portfolio.hasType(.watch));
}
// Portfolio totals: open-lot filtering
//
// The four non-stock totals (cash, cd, illiquid, option) now filter
// by `lotIsOpenAsOf` rather than counting every lot of the given type.
// Motivating scenario: user leaves a matured CD in portfolio.srf with
// `maturity_date` set (for historical context). Pre-fix, totalCdFaceValue
// would include it and over-report cash-equivalents. Post-fix, the
// matured CD is correctly excluded from "right now" totals.
test "Portfolio.totalOptionCost: excludes closed options" {
var lots = [_]Lot{
.{
.symbol = "CALL_OPEN",
.shares = -5,
.open_date = Date.fromYmd(2026, 3, 1),
.open_price = 2.00,
.security_type = .option,
.maturity_date = Date.fromYmd(2099, 1, 1),
},
.{
.symbol = "CALL_CLOSED",
.shares = -3,
.open_date = Date.fromYmd(2026, 3, 1),
.open_price = 4.00,
.security_type = .option,
.close_date = Date.fromYmd(2026, 3, 15),
.close_price = 0.01,
.maturity_date = Date.fromYmd(2099, 1, 1),
},
};
const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
// Only CALL_OPEN contributes: |-5| * 2.00 * 100 = 1000.
// Pre-fix would have been 1000 + |-3| * 4.00 * 100 = 2200.
try std.testing.expectApproxEqAbs(@as(f64, 1000.0), portfolio.totalOptionCost(), 0.01);
}
test "Portfolio.totalOptionCost: excludes matured options" {
var lots = [_]Lot{
.{
.symbol = "CALL_OPEN",
.shares = -5,
.open_date = Date.fromYmd(2026, 3, 1),
.open_price = 2.00,
.security_type = .option,
.maturity_date = Date.fromYmd(2099, 1, 1),
},
.{
.symbol = "CALL_MATURED",
.shares = -3,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 4.00,
.security_type = .option,
.maturity_date = Date.fromYmd(2024, 6, 1), // long expired
},
};
const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
try std.testing.expectApproxEqAbs(@as(f64, 1000.0), portfolio.totalOptionCost(), 0.01);
}
test "Portfolio.totalCdFaceValue: excludes matured CDs" {
var lots = [_]Lot{
.{
.symbol = "CD_ACTIVE",
.shares = 50000,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 1.00,
.security_type = .cd,
.maturity_date = Date.fromYmd(2099, 1, 1),
},
.{
.symbol = "CD_MATURED",
.shares = 75000,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 1.00,
.security_type = .cd,
.maturity_date = Date.fromYmd(2025, 12, 31),
},
};
const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
// Pre-fix would have been 50000 + 75000 = 125000.
try std.testing.expectApproxEqAbs(@as(f64, 50000.0), portfolio.totalCdFaceValue(), 0.01);
}
test "Portfolio.totalCash: excludes closed cash lots" {
var lots = [_]Lot{
.{
.symbol = "ACTIVE_CASH",
.shares = 10000,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 1.00,
.security_type = .cash,
},
.{
.symbol = "MOVED_CASH",
.shares = 25000,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 1.00,
.security_type = .cash,
.close_date = Date.fromYmd(2026, 1, 15), // cash was swept out
},
};
const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
try std.testing.expectApproxEqAbs(@as(f64, 10000.0), portfolio.totalCash(), 0.01);
}
test "Portfolio.totalIlliquidAsOf: respects as_of for backfill" {
// Illiquid lots rarely "close," but a property sale would set
// close_date. Backfill to before the sale should include it;
// backfill to after should not.
var lots = [_]Lot{
.{
.symbol = "House",
.shares = 800000,
.open_date = Date.fromYmd(2020, 5, 1),
.open_price = 0,
.security_type = .illiquid,
.close_date = Date.fromYmd(2026, 3, 15), // sold
},
.{
.symbol = "Other",
.shares = 200000,
.open_date = Date.fromYmd(2022, 1, 1),
.open_price = 0,
.security_type = .illiquid,
},
};
const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
// Before the sale: both count.
try std.testing.expectApproxEqAbs(
@as(f64, 1_000_000.0),
portfolio.totalIlliquidAsOf(Date.fromYmd(2026, 1, 1)),
0.01,
);
// After the sale: only Other counts.
try std.testing.expectApproxEqAbs(
@as(f64, 200_000.0),
portfolio.totalIlliquidAsOf(Date.fromYmd(2026, 4, 1)),
0.01,
);
}
test "Portfolio totals: AsOf excludes not-yet-opened lots" {
// Backfill to a date before a lot's open_date should exclude it.
var lots = [_]Lot{
.{
.symbol = "EarlyCash",
.shares = 1000,
.open_date = Date.fromYmd(2026, 1, 1),
.open_price = 1.00,
.security_type = .cash,
},
.{
.symbol = "LateCash",
.shares = 5000,
.open_date = Date.fromYmd(2026, 4, 1),
.open_price = 1.00,
.security_type = .cash,
},
};
const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
// 2026-02-15 is after EarlyCash's open but before LateCash's.
try std.testing.expectApproxEqAbs(
@as(f64, 1000.0),
portfolio.totalCashAsOf(Date.fromYmd(2026, 2, 15)),
0.01,
);
// 2026-04-15 is after both.
try std.testing.expectApproxEqAbs(
@as(f64, 6000.0),
portfolio.totalCashAsOf(Date.fromYmd(2026, 4, 15)),
0.01,
);
}
test "Portfolio watchSymbols" {
const allocator = std.testing.allocator;
var lots = [_]Lot{