filter non-stock totals by open-as-of
This commit is contained in:
parent
b9f580fa76
commit
bc7605d0c6
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();
|
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.
|
/// Result of a date-targeted candle lookup.
|
||||||
pub const CandleAtDate = struct {
|
pub const CandleAtDate = struct {
|
||||||
close: f64,
|
close: f64,
|
||||||
|
|
@ -864,3 +878,50 @@ test "adjustForCoveredCalls ignores puts" {
|
||||||
// Puts are ignored — no adjustment
|
// Puts are ignored — no adjustment
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
|
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` +
|
// `portfolio.positionsAsOf(as_of)` + `buildFallbackPrices` +
|
||||||
// `portfolioSummary`. The caller owns their lifetimes.
|
// `portfolioSummary`. The caller owns their lifetimes.
|
||||||
|
|
||||||
const illiquid = portfolio.totalIlliquid();
|
const illiquid = portfolio.totalIlliquidAsOf(as_of);
|
||||||
const net_worth = zfin.valuation.netWorth(portfolio.*, summary);
|
const net_worth = zfin.valuation.netWorthAsOf(portfolio.*, summary, as_of);
|
||||||
|
|
||||||
var totals = try allocator.alloc(TotalRow, 3);
|
var totals = try allocator.alloc(TotalRow, 3);
|
||||||
totals[0] = .{ .kind = "total", .scope = "net_worth", .value = net_worth };
|
totals[0] = .{ .kind = "total", .scope = "net_worth", .value = net_worth };
|
||||||
|
|
|
||||||
|
|
@ -617,41 +617,70 @@ pub const Portfolio = struct {
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total cash across all accounts.
|
/// Total cash across all accounts (open lots only).
|
||||||
pub fn totalCash(self: Portfolio) f64 {
|
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;
|
var total: f64 = 0;
|
||||||
for (self.lots) |lot| {
|
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;
|
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 {
|
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;
|
var total: f64 = 0;
|
||||||
for (self.lots) |lot| {
|
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;
|
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 {
|
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;
|
var total: f64 = 0;
|
||||||
for (self.lots) |lot| {
|
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;
|
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 {
|
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;
|
var total: f64 = 0;
|
||||||
for (self.lots) |lot| {
|
for (self.lots) |lot| {
|
||||||
if (lot.security_type == .option) {
|
if (lot.security_type != .option) continue;
|
||||||
// open_price is per-share option price; multiply by contract size
|
if (!lot.lotIsOpenAsOf(as_of)) continue;
|
||||||
total += @abs(lot.shares) * lot.open_price * lot.multiplier;
|
// open_price is per-share option price; multiply by contract size
|
||||||
}
|
total += @abs(lot.shares) * lot.open_price * lot.multiplier;
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
@ -819,6 +848,186 @@ test "Portfolio totals" {
|
||||||
try std.testing.expect(portfolio.hasType(.watch));
|
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" {
|
test "Portfolio watchSymbols" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
var lots = [_]Lot{
|
var lots = [_]Lot{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue