filter non-stock totals by open-as-of
This commit is contained in:
parent
cb77845086
commit
c89e93c243
3 changed files with 283 additions and 13 deletions
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue