From c89e93c243c893e5707e0a2d83e9677c7634b09d Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 23 Apr 2026 07:24:49 -0700 Subject: [PATCH] filter non-stock totals by open-as-of --- src/analytics/valuation.zig | 61 ++++++++++ src/commands/snapshot.zig | 4 +- src/models/portfolio.zig | 231 ++++++++++++++++++++++++++++++++++-- 3 files changed, 283 insertions(+), 13 deletions(-) diff --git a/src/analytics/valuation.zig b/src/analytics/valuation.zig index 6f9967b..034a2a5 100644 --- a/src/analytics/valuation.zig +++ b/src/analytics/valuation.zig @@ -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, + ); +} diff --git a/src/commands/snapshot.zig b/src/commands/snapshot.zig index fbdb035..1a3a298 100644 --- a/src/commands/snapshot.zig +++ b/src/commands/snapshot.zig @@ -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 }; diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index 9f933f1..1a10d6a 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -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{