single definition of closed lot

This commit is contained in:
Emil Lerch 2026-09-24 15:28:25 -07:00
parent b3239eefda
commit c0dca12623
Signed by: lobo
GPG key ID: A7B62D657EF764F8
13 changed files with 617 additions and 238 deletions

View file

@ -133,6 +133,13 @@ already exist and have caught me out:
branches are load-bearing. Render it via `cli.printDirection`
(CLI) or `theme.directionStyle` (TUI) - never map the enum by
hand at a call site.
- `Lot.lotIsOpenAsOf` / `isSoldAsOf` / `hasMaturedAsOf` /
`hasEndedAsOf` / `closePriceAsOf` / `realizedGainLossAsOf` - **the**
answers to "is this lot held / sold / over, and at what price". They
are different questions on purpose: `contributions` needs *sold* (an
expired option recorded with a close is still a sale), holdings need
*held*. There used to be three definitions of "closed" that disagreed;
never test `close_date` or `close_price` directly outside `Lot`.
**Never open-code a per-day up/down comparison.** `close >= open` is
unconditionally true for anything priced once per day at NAV - mutual
@ -177,6 +184,11 @@ grep -rn "Timestamp.now\|fromEpoch\|toEpoch" src/
# Per-day up/down direction - there must be exactly one rule.
# Any hit outside models/candle.zig is a bug:
grep -rn "close >= .*open\|close > .*open" src/
# Lot liveness - held/sold/ended/matured are defined ONCE on `Lot`.
# Any close_date/close_price read outside models/portfolio.zig is a bug
# (maturity_date reads for sorting, display or identity are fine):
grep -rn "\.close_date\|\.close_price" src/ | grep -v "models/portfolio.zig" | grep -v "test \"\|expect\|close_date = \|close_price = \|close_date:\|close_price:"
```
If the search turns up an existing helper that does what you need,

View file

@ -174,18 +174,16 @@ cannot be out of date.
#### 2. Lot dates (`portfolio*.srf`)
These fields parse fine but describe a lot that can't exist. They matter
more than they look: most of zfin decides whether a lot is held by
comparing `close_date` to today, but `zfin contributions` treats a lot
as sold the moment it has any `close_date` at all -- so a bad date makes
the two disagree.
These fields parse fine but describe a lot that can't exist. Each one
changes what zfin counts -- a future `close_date` keeps the lot held
until that day -- and each is almost certainly not what you meant.
| Report | What zfin does with it |
|--------------------------------------|--------------------------------------------------------------------------------------------|
| `close_date ... is in the future` | Counts the lot as held until that date. Usually a mistyped year. |
| `close_date ... is before open_date` | The lot is never counted as held on any date. |
| `has close_date but no close_price` | Stock lots only. The sale's realized gain is recorded as 0. |
| `has close_price but no close_date` | The lot stays held. On a stock lot, its row shows the close price instead of the live one. |
| `has close_price but no close_date` | The lot stays held, and the close price is ignored. |
| `open_date ... is in the future` | The lot is left out of your positions until that date. |
| `price_date ... is in the future` | Stock lots only. The manual price counts as fresh, so it is never flagged as stale. |

View file

@ -132,9 +132,12 @@ two things beyond bookkeeping:
enough for a recent sale and wrong for an old one.
A `close_date` must not be in the future. A close dated today is already
closed, but a later date leaves the lot counted as held until then --
while `zfin contributions` treats it as sold the moment the field
appears. For a scheduled end (a CD or option), use `maturity_date`.
closed, but a later date leaves the lot counted as held -- everywhere,
including `zfin contributions` -- until that day. For a scheduled end (a
CD or option), use `maturity_date`.
A `close_price` only counts once its `close_date` has arrived. Until
then the lot is held and priced live, like any other held lot.
`zfin audit` and `zfin doctor` flag these lot-lifecycle mistakes:

View file

@ -353,7 +353,7 @@ pub fn compareAccounts(
if (bp.is_cash) {
saw_cash_row = true;
pf_shares = portfolio.cashForAccount(portfolio_acct_name.?);
pf_shares = portfolio.cashForAccount(as_of, portfolio_acct_name.?);
pf_value = pf_shares;
} else {
const acct_positions = portfolio.positionsForAccount(as_of, allocator, portfolio_acct_name.?) catch &.{};
@ -554,7 +554,7 @@ pub fn compareAccounts(
// the balance is zero. Absence is strong evidence, not proof, and
// `audit` exists to surface things for a human to decide.
if (!saw_cash_row) {
const pf_cash = portfolio.cashForAccount(pa);
const pf_cash = portfolio.cashForAccount(as_of, pa);
if (@abs(pf_cash) > cash_tolerance) {
portfolio_total += pf_cash;
has_discrepancies = true;
@ -1719,3 +1719,38 @@ test "ratioSuggestions: cash/option/only rows and missing prices are skipped" {
defer allocator.free(sugg);
try std.testing.expectEqual(@as(usize, 0), sugg.len);
}
test "compareAccounts: a cash lot closed in the portfolio is not flagged portfolio-only" {
// The companion to "cash the broker no longer reports": once the
// user records the close, the lot is gone as of today. It used to
// still be counted by `cashForAccount`, so closing it didn't clear
// the flag - only deleting the line did.
const allocator = std.testing.allocator;
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "FDRXX", .shares = 39.08, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 1.0, .security_type = .cash, .account = "Sample 401k BL", .close_date = Date.fromYmd(2026, 7, 1) },
.{ .symbol = "FDSCX", .shares = 3018.809, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 33.82, .account = "Sample 401k BL" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var brokerage = [_]BrokeragePosition{
.{ .account_number = "1234", .account_name = "BrokerageLink", .symbol = "FDSCX", .description = "FIDELITY STOCK SELECTOR SMALL CAP", .quantity = 3018.809, .current_value = 152963.05, .cost_basis = 104700.15, .is_cash = false },
};
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample 401k BL", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("FDSCX", 50.67);
const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "fidelity", prices, Date.fromYmd(2026, 8, 1));
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 1), results.len);
for (results[0].comparisons) |cmp| {
try std.testing.expect(!(cmp.is_cash and cmp.only_in_portfolio));
}
}

View file

@ -105,7 +105,7 @@ pub fn compareSchwabSummary(
var expectation: common.AccountValueExpectation = .{};
if (portfolio_acct) |pa| {
pf_cash = portfolio.cashForAccount(pa);
pf_cash = portfolio.cashForAccount(as_of, pa);
pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices);
expectation = common.accountValueExpectation(portfolio, as_of, pa);
}

View file

@ -487,6 +487,14 @@ pub fn portfolioSummary(
var total_realized: f64 = 0;
for (positions) |pos| {
// Realized P&L first, before either skip below. It is already
// known from the closed lots and needs no price - and the
// positions it matters most for are the ones those skips drop:
// a fully sold symbol (`shares == 0`) exists here ONLY to carry
// its realized gain, and used to be discarded along with it.
// Same for a held symbol with no current price.
total_realized += pos.realized_gain_loss;
if (pos.shares <= 0) continue;
const raw_price = prices.get(pos.symbol) orelse continue;
const is_manual = if (manual_prices) |mp| mp.contains(pos.symbol) else false;
@ -494,7 +502,6 @@ pub fn portfolioSummary(
const mv = pos.marketValue(raw_price, is_manual);
total_value += mv;
total_cost += pos.total_cost;
total_realized += pos.realized_gain_loss;
try allocs.append(allocator, .{
.symbol = pos.symbol,
@ -2614,3 +2621,30 @@ test "mergeAllocsBySymbol rolls up same-ticker different-ratio allocations" {
}
}
}
test "portfolioSummary: realized P&L includes fully sold and unpriced symbols" {
// Regression: the `shares <= 0` and no-price skips ran BEFORE the
// realized sum, so a symbol you had sold entirely - the most common
// source of realized gains - never reached "Realized P&L".
const alloc = std.testing.allocator;
var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit();
try prices.put("HELD", 20.0);
const positions = [_]portfolio_mod.Position{
// Held and priced, with a partial sale behind it.
.{ .symbol = "HELD", .shares = 10, .avg_cost = 10, .total_cost = 100, .open_lots = 1, .closed_lots = 1, .realized_gain_loss = 30 },
// Fully sold: exists only to carry its realized gain.
.{ .symbol = "SOLD", .shares = 0, .avg_cost = 0, .total_cost = 0, .open_lots = 0, .closed_lots = 2, .realized_gain_loss = 500 },
// Held but no current price; its realized loss still counts.
.{ .symbol = "NOPRICE", .shares = 5, .avg_cost = 8, .total_cost = 40, .open_lots = 1, .closed_lots = 1, .realized_gain_loss = -20 },
};
const empty_pf = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = alloc };
var summary = try portfolioSummary(Date.fromYmd(2026, 5, 8), alloc, empty_pf, &positions, prices, null);
defer summary.deinit(alloc);
try std.testing.expectApproxEqAbs(@as(f64, 510), summary.realized_gain_loss, 0.001);
// The skips still apply to everything else: only HELD is valued.
try std.testing.expectEqual(@as(usize, 1), summary.allocations.len);
try std.testing.expectApproxEqAbs(@as(f64, 200), summary.total_value, 0.001);
}

View file

@ -1737,8 +1737,8 @@ fn outflowUnitValue(lot: Lot, prices: *const std.StringHashMap(f64)) f64 {
/// (on the trade that motivated this, by $48,963.65 on $467k), which
/// is why the fallback defers to `outflowUnitValue`'s current-price
/// proxy rather than reaching for `open_price`.
fn closeUnitValue(lot: Lot, prices: *const std.StringHashMap(f64)) f64 {
if (lot.close_price) |cp| {
fn closeUnitValue(lot: Lot, prices: *const std.StringHashMap(f64), as_of: Date) f64 {
if (lot.closePriceAsOf(as_of)) |cp| {
return switch (lot.security_type) {
// close_price is in the lot's own share-class terms
// (preadjusted), so the ratio must NOT be applied again.
@ -2115,9 +2115,14 @@ fn computeReport(
// metadata comparison: a lot that was both closed and
// repriced in the same window is a sale, not a price
// edit. Checked first for exactly that reason.
//
// "Sold", not "ended": an expired option or matured CD
// has already ended on both sides, so the documented
// record-the-close-after-expiry workflow would never
// show a transition. See `Lot.isSoldAsOf`.
const before_lot = before_agg.lot;
if (before_lot.close_date == null and lot.close_date != null) {
const unit_value = closeUnitValue(lot, prices);
if (!before_lot.isSoldAsOf(as_of) and lot.isSoldAsOf(as_of)) {
const unit_value = closeUnitValue(lot, prices, as_of);
try changes.append(allocator, .{
.kind = .position_closed,
.symbol = sym,
@ -2213,16 +2218,10 @@ fn computeReport(
var kind: ChangeKind = .lot_removed;
if (lot.security_type == .cd) {
if (lot.maturity_date) |mat| {
// "matured" if maturity_date <= as_of (i.e. NOT as_of.lessThan(mat))
if (!as_of.lessThan(mat)) {
kind = .cd_matured;
} else {
kind = .cd_removed_early;
}
} else {
kind = .cd_removed_early; // no maturity - treat as flagged-ish
}
// Matured specifically, not "ended": a CD redeemed early
// (a past `close_date`) and then deleted was still removed
// early. No maturity at all is treated the same way.
kind = if (lot.hasMaturedAsOf(as_of)) .cd_matured else .cd_removed_early;
}
// Value the outflow. `unit_value` makes `value()` the (negative)
@ -8332,3 +8331,49 @@ test "collectUnmatchedLargeLots: a large cash_contribution surfaces, undated, al
}
try std.testing.expect(cash_seen and stock_seen);
}
test "computeReport: an expired option recorded with a close afterwards is still a sale" {
// Why the close transition tests "sold", not "ended": the option
// ended at maturity on BOTH sides, so an ended-based test would see
// no transition and the documented record-the-close-after-expiry
// workflow would emit nothing.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const opt: Lot = .{ .symbol = "AAPL 06/20/2025 200.00 C", .shares = -2, .open_date = Date.fromYmd(2025, 1, 15), .open_price = 12.5, .security_type = .option, .maturity_date = Date.fromYmd(2025, 6, 20), .underlying = "AAPL", .strike = 200, .account = "Sample Brokerage" };
var closed = opt;
closed.close_date = Date.fromYmd(2025, 6, 20);
closed.close_price = 0;
const before = [_]Lot{opt};
const after = [_]Lot{closed};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2025, 7, 1), .{});
var saw_close = false;
for (report.changes) |c| {
if (c.kind == .position_closed) saw_close = true;
}
try std.testing.expect(saw_close);
}
test "computeReport: a close_date that hasn't arrived is not yet a sale" {
// The other side of the same definition. The lint flags a future
// close_date; until it arrives, the lot is held and no sale is booked.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const lot: Lot = .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 15), .open_price = 200, .account = "Sample Brokerage" };
var later = lot;
later.close_date = Date.fromYmd(2062, 3, 14);
later.close_price = 300;
const before = [_]Lot{lot};
const after = [_]Lot{later};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 11), .{});
for (report.changes) |c| try std.testing.expect(c.kind != .position_closed);
}

View file

@ -415,7 +415,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
var prior_lookup_opt: ?PriorLotsLookup = null;
defer if (prior_lookup_opt) |*p| p.deinit();
if (prior_portfolio_opt) |pf| {
prior_lookup_opt = try PriorLotsLookup.init(allocator, pf.lots);
prior_lookup_opt = try PriorLotsLookup.init(allocator, pf.lots, ctx.today);
}
// ── Synthesize lots ───────────────────────────────────────
@ -585,7 +585,7 @@ const PriorLotsLookup = struct {
map: std.StringHashMap(*const portfolio_mod.Lot),
allocator: std.mem.Allocator,
fn init(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot) !PriorLotsLookup {
fn init(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot, today: Date) !PriorLotsLookup {
var map = std.StringHashMap(*const portfolio_mod.Lot).init(allocator);
errdefer {
var it = map.keyIterator();
@ -594,12 +594,14 @@ const PriorLotsLookup = struct {
}
for (lots) |*lot| {
// Skip closed lots: they shouldn't anchor a re-import's
// Skip sold lots: they shouldn't anchor a re-import's
// open_date for a position the brokerage shows as held.
// (Today's import doesn't write `close_date`/`close_price`
// anyway, so this is also defensive against hand-edited
// closed lots in the file.)
if (lot.close_date != null) continue;
// closed lots in the file.) "Sold", not "ended": import
// carries `maturity_date` forward, so a brokered CD
// re-imported on its maturity day must still anchor.
if (lot.isSoldAsOf(today)) continue;
// Cash lots have no symbol/account-meaningful identity
// for matching across imports - skip.
if (lot.security_type == .cash) continue;
@ -994,7 +996,7 @@ test "synthesizeLots: lots are byte-identical across imports when prior_lookup m
.note = "imported fidelity 2024-01-15",
},
};
var prior_lookup = try PriorLotsLookup.init(allocator, &prior_lots);
var prior_lookup = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
defer prior_lookup.deinit();
const lots_a = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), prior_lookup);
@ -1135,7 +1137,7 @@ test "synthesizeLots: prior lot for (symbol, account) preserves open_date and op
.note = "imported fidelity 2024-06-01",
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
defer prior.deinit();
// Export shows 120 shares now (user bought more) at avg
@ -1188,7 +1190,7 @@ test "synthesizeLots: every hand-edited field is preserved on re-import" {
.drip = true,
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
defer prior.deinit();
const positions = [_]BrokeragePosition{
@ -1258,7 +1260,7 @@ test "synthesizeLots: new position with no prior match gets sentinel + today's n
.security_type = .stock,
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
defer prior.deinit();
const positions = [_]BrokeragePosition{
@ -1320,7 +1322,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e
.security_type = .stock,
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
defer prior.deinit();
const positions = [_]BrokeragePosition{
@ -1360,7 +1362,7 @@ test "synthesizeLots: prior closed lot does NOT anchor a held position" {
.security_type = .stock,
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
defer prior.deinit();
const positions = [_]BrokeragePosition{
@ -1407,7 +1409,7 @@ test "synthesizeLots: positions dropped from new export are excluded (closed-lot
.security_type = .stock,
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
defer prior.deinit();
// Export only shows AAPL (user sold all GOOG).
@ -1440,7 +1442,7 @@ test "PriorLotsLookup: cash lots are excluded from the lookup" {
.security_type = .cash,
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
defer prior.deinit();
try testing.expect((try prior.find("FZFXX", "Sample Brokerage")) == null);

View file

@ -364,6 +364,7 @@ pub fn display(
summary.unrealized_gain_loss,
watch_symbols,
watch_prices,
as_of,
);
try out.print("\n", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
@ -436,10 +437,10 @@ pub fn display(
// Lot detail rows (always expanded for CLI)
if (is_multi) {
// Check if any lots are DRIP
// Check if any lots go into the DRIP summary
var has_drip = false;
for (lots_for_sym.items) |lot| {
if (lot.drip) {
if (fmt.isDripSummarized(as_of, lot)) {
has_drip = true;
break;
}
@ -448,13 +449,13 @@ pub fn display(
if (!has_drip) {
// No DRIP: show all individually
for (lots_for_sym.items) |lot| {
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot), w);
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot, as_of), w);
}
} else {
// Show non-DRIP lots individually
// Show every lot the summary doesn't cover individually
for (lots_for_sym.items) |lot| {
if (!lot.drip) {
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot), w);
if (!fmt.isDripSummarized(as_of, lot)) {
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot, as_of), w);
}
}
@ -605,8 +606,10 @@ pub fn display(
try out.print("{s}\n", .{fmt.fmtCashSep(&cash_sep_buf)});
try cli.reset(out, color);
// Held lots only, so the rows add up to the TOTAL below.
for (portfolio.lots) |lot| {
if (lot.security_type != .cash) continue;
if (!lot.lotIsOpenAsOf(as_of)) continue;
const acct2: []const u8 = lot.account orelse "Unknown";
var row_buf: [160]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtCashRow(&row_buf, acct2, lot.shares, lot.note)});
@ -629,8 +632,10 @@ pub fn display(
try out.print("{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf1)});
try cli.reset(out, color);
// Held lots only, so the rows add up to the TOTAL below.
for (portfolio.lots) |lot| {
if (lot.security_type != .illiquid) continue;
if (!lot.lotIsOpenAsOf(as_of)) continue;
var il_row_buf: [160]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtIlliquidRow(&il_row_buf, lot.displaySymbol(), lot.shares, lot.note)});
}
@ -683,7 +688,7 @@ pub fn display(
/// Render one lot-detail row under its position row.
///
/// `effective_price` is the LOT's price, not its position's: pass
/// `views.effectivePriceFor(allocations, lot)`, which applies the lot's
/// `views.effectivePriceFor(allocations, lot, as_of)`, which applies the lot's
/// `price_ratio` and resolves `close_price`. Passing an
/// `Allocation.current_price` straight through is the bug this
/// parameter name exists to prevent - that price is the raw base-ticker
@ -1471,7 +1476,7 @@ test "printLotRow: renders effective (split-adjusted) shares, cost, and value" {
};
const no_allocs: []const zfin.valuation.Allocation = &.{};
const no_watch: []const []const u8 = &.{};
const widths = views.computeWidths(no_allocs, &lots, 0, 0, no_watch, null);
const widths = views.computeWidths(no_allocs, &lots, 0, 0, no_watch, null, zfin.Date.fromYmd(2026, 6, 1));
var buf: [512]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
@ -1517,11 +1522,11 @@ test "printLotRow: a ratio'd lot values off its effective price and shows it" {
.unrealized_return = 0.875,
}};
const no_watch: []const []const u8 = &.{};
const widths = views.computeWidths(&allocs, &lots, 927824.086, 433004.078, no_watch, null);
const widths = views.computeWidths(&allocs, &lots, 927824.086, 433004.078, no_watch, null, zfin.Date.fromYmd(2026, 6, 1));
var buf: [512]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
const eff_price = views.effectivePriceFor(&allocs, lots[0]);
const eff_price = views.effectivePriceFor(&allocs, lots[0], zfin.Date.fromYmd(2026, 6, 1));
try printLotRow(zfin.Date.fromYmd(2026, 8, 26), &w, false, lots[0], eff_price, widths);
const out = w.buffered();

View file

@ -742,10 +742,19 @@ pub const DripAggregation = struct {
/// Aggregate DRIP lots into short-term and long-term buckets.
/// Classifies using `capitalGainsIndicator` (LT if held > 1 year).
/// Is `lot` folded into the DRIP ST/LT summary rather than shown as its
/// own row? Only held DRIP lots are: a sold one is a closed lot like
/// any other, so it gets an individual row with its closed status, and
/// the summary's share count stays what you actually hold. The one test
/// for the aggregator and both renderers (CLI `portfolio`, TUI tab).
pub fn isDripSummarized(as_of: Date, lot: Lot) bool {
return lot.drip and lot.lotIsOpenAsOf(as_of);
}
pub fn aggregateDripLots(as_of: Date, lots: []const Lot) DripAggregation {
var result: DripAggregation = .{};
for (lots) |lot| {
if (!lot.drip) continue;
if (!isDripSummarized(as_of, lot)) continue;
const is_lt = std.mem.eql(u8, capitalGainsIndicator(as_of, lot.open_date), "LT");
const bucket: *DripSummary = if (is_lt) &result.lt else &result.st;
bucket.lot_count += 1;
@ -1786,3 +1795,31 @@ test "fmtSharpeOpt: asterisk and decimals" {
try std.testing.expectEqualStrings("1.25*", fmtSharpeOpt(&buf, 1.25, .{ .asterisk = true }));
try std.testing.expectEqualStrings("1.3*", fmtSharpeOpt(&buf, 1.25, .{ .asterisk = true, .decimals = 1 }));
}
test "aggregateDripLots: sold DRIP lots stay out of the summary" {
// The summary is a count of what you hold. A sold DRIP lot used to
// be folded in, inflating shares and cost; it now gets its own
// closed row instead (`isDripSummarized`).
const as_of = Date.fromYmd(2026, 1, 1);
const lots = [_]Lot{
.{ .symbol = "VTI", .shares = 1.5, .open_date = Date.fromYmd(2023, 3, 1), .open_price = 200, .drip = true },
.{ .symbol = "VTI", .shares = 2.0, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 210, .drip = true, .close_date = Date.fromYmd(2025, 12, 1), .close_price = 280 },
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2023, 1, 1), .open_price = 190 },
};
const agg = aggregateDripLots(as_of, &lots);
try std.testing.expectEqual(@as(usize, 1), agg.lt.lot_count);
try std.testing.expectApproxEqAbs(@as(f64, 1.5), agg.lt.shares, 0.001);
try std.testing.expect(agg.st.isEmpty());
}
test "isDripSummarized: held DRIP only" {
const as_of = Date.fromYmd(2026, 1, 1);
const held: Lot = .{ .symbol = "VTI", .shares = 1, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1, .drip = true };
try std.testing.expect(isDripSummarized(as_of, held));
var sold = held;
sold.close_date = Date.fromYmd(2025, 6, 1);
try std.testing.expect(!isDripSummarized(as_of, sold));
var not_drip = held;
not_drip.drip = false;
try std.testing.expect(!isDripSummarized(as_of, not_drip));
}

View file

@ -118,7 +118,7 @@ const srf_lint = @import("../srf_lint.zig");
// a merged group.
//
// So: per-lot display sites MUST price through
// `views/portfolio_sections.zig:effectivePriceFor(allocations, lot)`,
// `views/portfolio_sections.zig:effectivePriceFor(allocations, lot, as_of)`,
// which resolves `close_price`, discriminates on `price_ratio`, and
// applies the ratio via `Lot.effectivePrice`. Never read
// `Allocation.current_price` into a per-lot calculation, and never
@ -367,30 +367,66 @@ pub const Lot = struct {
return self.lotIsOpenAsOf(as_of);
}
/// Was the lot held at end-of-day on `as_of`?
// ── Lot lifecycle: THE definition of open, sold, and ended ──
//
// Every "is this lot closed?" question in zfin is one of these
// three. Nothing outside this struct reads `close_date`,
// `close_price` or `maturity_date` to decide liveness - that was
// how three different definitions of "closed" came to disagree
// (holdings went by date, `contributions` and `import` by whether
// `close_date` was present at all, lot rows by `close_price`). The
// AGENTS.md grep rule keeps it that way.
//
// End-of-day semantics throughout: a date equal to `as_of` has
// already happened. They are deliberately different questions:
//
// hasMaturedAsOf `maturity_date` arrived (options, CDs).
// isSoldAsOf a SALE happened. `close_date` only. What
// `contributions` needs: an option that expired
// and is then recorded with a close is a sale, but
// it had already ended at maturity, so testing
// "ended" would never see the transition.
// hasEndedAsOf sold OR matured. The lot is over, whether or
// not it was ever bought (a not-yet-bought lot
// hasn't ended).
// lotIsOpenAsOf held: bought, and not ended.
/// Has a sale happened on or before `as_of`? True once `close_date`
/// arrives; maturity doesn't count.
pub fn isSoldAsOf(self: Lot, as_of: Date) bool {
const cd = self.close_date orelse return false;
return !as_of.lessThan(cd);
}
/// Has `maturity_date` arrived (options, CDs)? A sale doesn't count.
pub fn hasMaturedAsOf(self: Lot, as_of: Date) bool {
const mat = self.maturity_date orelse return false;
return !as_of.lessThan(mat);
}
/// Is the lot over as of `as_of` - sold, or matured?
pub fn hasEndedAsOf(self: Lot, as_of: Date) bool {
return self.isSoldAsOf(as_of) or self.hasMaturedAsOf(as_of);
}
/// Was the lot held at end-of-day on `as_of`? Bought, and not
/// ended (see `hasEndedAsOf`).
///
/// Used by historical snapshot backfill (`zfin snapshot --as-of`)
/// where "open" must be evaluated against the target date rather
/// than wall-clock today. `isOpen()` delegates to this with
/// today as `as_of`.
///
/// End-of-day semantics (see tests):
/// - `open_date > as_of` -> not yet bought -> false
/// - `close_date` on/before as_of -> sold that day or earlier -> false
/// - `maturity_date` on/before as_of -> matured that day or earlier -> false
/// - otherwise -> true
/// than wall-clock today. `isOpen()` delegates to this.
pub fn lotIsOpenAsOf(self: Lot, as_of: Date) bool {
// Not yet bought on `as_of`.
if (as_of.lessThan(self.open_date)) return false;
// Sold on or before `as_of`.
if (self.close_date) |cd| {
if (!as_of.lessThan(cd)) return false;
}
// Matured on or before `as_of` (options, CDs).
if (self.maturity_date) |mat| {
if (!as_of.lessThan(mat)) return false;
}
return true;
return !self.hasEndedAsOf(as_of);
}
/// The price the lot was sold at, once the sale has happened.
/// Null before `close_date` arrives even when `close_price` is set:
/// a close price on a lot that's still held is not a price at all.
pub fn closePriceAsOf(self: Lot, as_of: Date) ?f64 {
if (!self.isSoldAsOf(as_of)) return null;
return self.close_price;
}
/// Cost basis: RAW shares x RAW open_price. Split-invariant
@ -428,12 +464,13 @@ pub const Lot = struct {
return self.effectiveShares() * self.effectivePrice(raw_price, is_preadjusted);
}
/// Realized gain/loss for a closed lot: shares * (close_price - open_price).
/// Returns null if the lot is still open. Stays on RAW shares - a
/// closed lot is a completed round-trip whose recorded open/close are
/// consistent; `enrichSplits` leaves closed lots at `split_factor 1.0`.
pub fn realizedGainLoss(self: Lot) ?f64 {
const cp = self.close_price orelse return null;
/// Realized gain/loss once the lot is sold: shares * (close_price -
/// open_price). Null until the sale happens, or when it was recorded
/// without a `close_price`. Stays on RAW shares - a sold lot is a
/// completed round-trip whose recorded open/close are consistent;
/// `enrichSplits` leaves closed lots at `split_factor 1.0`.
pub fn realizedGainLossAsOf(self: Lot, as_of: Date) ?f64 {
const cp = self.closePriceAsOf(as_of) orelse return null;
return self.shares * (cp - self.open_price);
}
@ -442,9 +479,11 @@ pub const Lot = struct {
return self.effectiveShares() * (current_price - self.effectiveOpenPrice());
}
pub fn returnPct(self: Lot, current_price: f64) f64 {
/// Return on the lot: against the sale price once sold, otherwise
/// against `current_price`.
pub fn returnPct(self: Lot, current_price: f64, as_of: Date) f64 {
if (self.open_price == 0) return 0;
const price = if (self.close_price) |cp| cp else current_price;
const price = self.closePriceAsOf(as_of) orelse current_price;
return (price / self.effectiveOpenPrice()) - 1.0;
}
@ -623,21 +662,12 @@ pub const srf_schema = struct {
/// Lot lifecycle rules - dates and close fields that parse fine but
/// describe a lot that cannot exist.
///
/// These matter more than they look because zfin has THREE
/// definitions of "closed", and bad lifecycle data is exactly what
/// makes them disagree:
///
/// - `lotIsOpenAsOf` (holdings, snapshots, analysis): closed iff
/// `close_date <= as_of`.
/// - `commands/contributions.zig`'s lot differ and
/// `commands/import.zig`: closed iff `close_date != null`.
/// - `views/portfolio_sections.zig`'s `effectivePriceFor` (lot
/// rows): priced at `close_price` whenever it is set.
///
/// A future `close_date` is therefore held by the first, sold by the
/// second, and priced as sold by the third. Stopping the input here
/// is the cheap fix; reconciling the three definitions is separate
/// work.
/// zfin now reads all of these the same way everywhere (see the
/// lifecycle block on `Lot`: `isSoldAsOf`, `hasEndedAsOf`,
/// `lotIsOpenAsOf`), so none of them makes the program disagree
/// with itself any more. They are still flagged because each one
/// almost certainly isn't what you meant: a future `close_date` is
/// usually a mistyped year, and it leaves the lot held until then.
///
/// Watch lots are skipped throughout: `Portfolio.watchSymbols` reads
/// only `symbol`, so their dates are inert.
@ -676,16 +706,12 @@ pub const srf_schema = struct {
));
}
} else if (rec.close_price != null) {
// Only a stock lot's row consults `close_price` while open
// (`effectivePriceFor`); for every other type it is inert.
const consequence: []const u8 = if (rec.security_type == .stock)
"the lot is still held, and its row shows the close price instead of the live one"
else
"the lot is still held and the close price is ignored";
// `Lot.closePriceAsOf` ignores a close price until the sale
// happens, so without a `close_date` it is never used.
try sink.addOwned(.semantic, "close_price", try std.fmt.allocPrint(
a,
"{s} has close_price but no close_date - {s}",
.{ name, consequence },
"{s} has close_price but no close_date - the lot is still held and the close price is ignored",
.{name},
));
}
@ -975,7 +1001,7 @@ pub const Portfolio = struct {
const not_yet_opened = as_of.lessThan(lot.open_date);
if (!not_yet_opened) {
pos.closed_lots += 1;
pos.realized_gain_loss += lot.realizedGainLoss() orelse 0;
pos.realized_gain_loss += lot.realizedGainLossAsOf(as_of) orelse 0;
}
}
}
@ -1036,9 +1062,12 @@ pub const Portfolio = struct {
pos.shares += lot.effectiveShares();
pos.total_cost += lot.costBasis();
pos.open_lots += 1;
} else {
} else if (!as_of.lessThan(lot.open_date)) {
// Not held and already bought, so it has ended. A
// not-yet-bought lot is neither - `positionsAsOf` skips
// it the same way instead of counting it as closed.
pos.closed_lots += 1;
pos.realized_gain_loss += lot.realizedGainLoss() orelse 0;
pos.realized_gain_loss += lot.realizedGainLossAsOf(as_of) orelse 0;
}
}
@ -1057,11 +1086,15 @@ pub const Portfolio = struct {
return final.toOwnedSlice(allocator);
}
/// Total cash for a single account.
pub fn cashForAccount(self: Portfolio, account_name: []const u8) f64 {
/// Total cash held by a single account on `as_of`. Closed cash
/// lots are excluded, like every other total - this one used to
/// count them, and audit's reconciliation then reported a
/// discrepancy against a brokerage that (correctly) didn't.
pub fn cashForAccount(self: Portfolio, as_of: Date, account_name: []const u8) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.security_type != .cash) continue;
if (!lot.lotIsOpenAsOf(as_of)) continue;
const lot_acct = lot.account orelse continue;
if (std.mem.eql(u8, lot_acct, account_name)) total += lot.shares;
}
@ -1146,12 +1179,12 @@ pub const Portfolio = struct {
return total;
}
/// Total realized P&L from all closed stock lots.
pub fn totalRealizedGainLoss(self: Portfolio) f64 {
/// Total realized P&L from stock lots sold on or before `as_of`.
pub fn totalRealizedGainLoss(self: Portfolio, as_of: Date) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.security_type == .stock) {
if (lot.realizedGainLoss()) |pnl| total += pnl;
if (lot.realizedGainLossAsOf(as_of)) |pnl| total += pnl;
}
}
return total;
@ -1413,7 +1446,7 @@ test "lot basics" {
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), lot.costBasis(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 2000.0), lot.marketValue(200.0, true), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.unrealizedGainLoss(200.0), 0.01);
try std.testing.expect(lot.realizedGainLoss() == null);
try std.testing.expect(lot.realizedGainLossAsOf(Date.fromYmd(2026, 5, 8)) == null);
}
test "closed lot" {
@ -1426,8 +1459,8 @@ test "closed lot" {
.close_price = 200.0,
};
try std.testing.expect(!lot.isOpen(Date.fromYmd(2026, 5, 8)));
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.realizedGainLoss().?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.3333), lot.returnPct(0), 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.realizedGainLossAsOf(Date.fromYmd(2026, 5, 8)).?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.3333), lot.returnPct(0, Date.fromYmd(2026, 5, 8)), 0.001);
}
test "portfolio positions" {
@ -1499,15 +1532,18 @@ test "Position.displaySymbol: label orelse symbol" {
}
test "Lot.returnPct" {
const as_of = Date.fromYmd(2026, 5, 8);
// Open lot: uses current_price param
const open_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100 };
try std.testing.expectApproxEqAbs(@as(f64, 0.5), open_lot.returnPct(150), 0.001);
// Closed lot: uses close_price, ignores current_price
try std.testing.expectApproxEqAbs(@as(f64, 0.5), open_lot.returnPct(150, as_of), 0.001);
// Sold lot: uses close_price, ignores current_price
const closed_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .close_date = Date.fromYmd(2024, 6, 1), .close_price = 120 };
try std.testing.expectApproxEqAbs(@as(f64, 0.2), closed_lot.returnPct(999), 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.2), closed_lot.returnPct(999, as_of), 0.001);
// Sale not yet happened: still priced live, close_price ignored
try std.testing.expectApproxEqAbs(@as(f64, 0.5), closed_lot.returnPct(150, Date.fromYmd(2024, 5, 31)), 0.001);
// Zero open_price: returns 0
const zero_lot = Lot{ .symbol = "X", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0 };
try std.testing.expectApproxEqAbs(@as(f64, 0.0), zero_lot.returnPct(100), 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), zero_lot.returnPct(100, as_of), 0.001);
}
test "Portfolio totals" {
@ -1525,7 +1561,7 @@ test "Portfolio totals" {
// totalCostBasis: only open stock lots -> 10 * 150 = 1500
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), portfolio.totalCostBasis(Date.fromYmd(2026, 5, 8)), 0.01);
// totalRealizedGainLoss: closed stock lots -> 5 * (160-140) = 100
try std.testing.expectApproxEqAbs(@as(f64, 100.0), portfolio.totalRealizedGainLoss(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 100.0), portfolio.totalRealizedGainLoss(Date.fromYmd(2026, 5, 8)), 0.01);
// totalCash
try std.testing.expectApproxEqAbs(@as(f64, 50000.0), portfolio.totalCash(Date.fromYmd(2026, 5, 8)), 0.01);
// totalIlliquid
@ -2546,20 +2582,16 @@ test "lifecycle: cash and CD lots closed without a price are fine" {
, &.{});
}
test "lifecycle: close_price without close_date - stock row shows the wrong price" {
test "lifecycle: close_price without close_date is ignored, for every type" {
try expectLifecycle(
\\#!srfv1
\\symbol::SPY,shares:num:5,open_date::2024-01-15,open_price:num:470,close_price:num:560
\\security_type::cd,symbol::CD1,shares:num:10000,open_date::2024-01-01,open_price:num:1,close_price:num:1
\\
, &.{"2: SPY has close_price but no close_date - the lot is still held, and its row shows the close price instead of the live one"});
}
test "lifecycle: close_price without close_date - non-stock says it is ignored" {
try expectLifecycle(
\\#!srfv1
\\security_type::cd,symbol::CD1,shares:num:10000,open_date::2024-01-01,open_price:num:10000,close_price:num:10000
\\
, &.{"2: CD1 has close_price but no close_date - the lot is still held and the close price is ignored"});
, &.{
"2: SPY has close_price but no close_date - the lot is still held and the close price is ignored",
"3: CD1 has close_price but no close_date - the lot is still held and the close price is ignored",
});
}
test "lifecycle: future open_date is flagged" {
@ -2709,3 +2741,84 @@ test "fromParsed: every other type requires all three, and names the missing one
}
}
}
test "cashForAccount: counts only cash held on as_of" {
var lots = [_]Lot{
.{ .symbol = "CASH", .shares = 1000, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1, .security_type = .cash, .account = "Sample IRA" },
.{ .symbol = "CASH", .shares = 250, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1, .security_type = .cash, .account = "Sample IRA", .close_date = Date.fromYmd(2026, 3, 1) },
.{ .symbol = "CASH", .shares = 99, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1, .security_type = .cash, .account = "Sample Roth" },
};
const pf = testPortfolio(&lots);
// Before the close, both Sample IRA lots count; after, only one.
try std.testing.expectApproxEqAbs(@as(f64, 1250), pf.cashForAccount(Date.fromYmd(2026, 2, 1), "Sample IRA"), 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 1000), pf.cashForAccount(Date.fromYmd(2026, 3, 1), "Sample IRA"), 0.001);
// And it agrees with the portfolio-wide total that already filtered.
try std.testing.expectApproxEqAbs(pf.totalCash(Date.fromYmd(2026, 3, 1)), pf.cashForAccount(Date.fromYmd(2026, 3, 1), "Sample IRA") + pf.cashForAccount(Date.fromYmd(2026, 3, 1), "Sample Roth"), 0.001);
}
// ── Lot lifecycle ──
test "lifecycle predicates: sold, matured, ended, held" {
const d = Date.fromYmd;
const bought = d(2024, 1, 1);
const plain: Lot = .{ .symbol = "X", .shares = 1, .open_date = bought, .open_price = 1 };
// Held, nothing scheduled.
try std.testing.expect(!plain.isSoldAsOf(d(2025, 1, 1)));
try std.testing.expect(!plain.hasMaturedAsOf(d(2025, 1, 1)));
try std.testing.expect(!plain.hasEndedAsOf(d(2025, 1, 1)));
try std.testing.expect(plain.lotIsOpenAsOf(d(2025, 1, 1)));
// Sold: end-of-day, so the close date itself is already sold.
var sold = plain;
sold.close_date = d(2025, 6, 1);
try std.testing.expect(!sold.isSoldAsOf(d(2025, 5, 31)));
try std.testing.expect(sold.isSoldAsOf(d(2025, 6, 1)));
try std.testing.expect(sold.hasEndedAsOf(d(2025, 6, 1)));
try std.testing.expect(!sold.hasMaturedAsOf(d(2025, 6, 1)));
try std.testing.expect(!sold.lotIsOpenAsOf(d(2025, 6, 1)));
// Matured: ended and not held, but NOT sold - this is the case
// `contributions` depends on, so an expired option recorded with a
// close afterwards is still seen as a sale.
var matured = plain;
matured.maturity_date = d(2025, 3, 1);
try std.testing.expect(matured.hasMaturedAsOf(d(2025, 3, 1)));
try std.testing.expect(matured.hasEndedAsOf(d(2025, 3, 1)));
try std.testing.expect(!matured.isSoldAsOf(d(2025, 3, 1)));
try std.testing.expect(!matured.lotIsOpenAsOf(d(2025, 3, 1)));
// Not yet bought: neither held nor ended.
try std.testing.expect(!plain.lotIsOpenAsOf(d(2023, 12, 31)));
try std.testing.expect(!plain.hasEndedAsOf(d(2023, 12, 31)));
}
test "closePriceAsOf / realizedGainLossAsOf: only once sold" {
const d = Date.fromYmd;
var lot: Lot = .{ .symbol = "X", .shares = 10, .open_date = d(2024, 1, 1), .open_price = 100, .close_price = 130 };
// A close price with no close date is not a sale.
try std.testing.expectEqual(@as(?f64, null), lot.closePriceAsOf(d(2026, 1, 1)));
try std.testing.expectEqual(@as(?f64, null), lot.realizedGainLossAsOf(d(2026, 1, 1)));
// Nor is one whose date hasn't arrived.
lot.close_date = d(2025, 6, 1);
try std.testing.expectEqual(@as(?f64, null), lot.realizedGainLossAsOf(d(2025, 5, 31)));
try std.testing.expectApproxEqAbs(@as(f64, 300), lot.realizedGainLossAsOf(d(2025, 6, 1)).?, 0.001);
// Sold but recorded without a price: nothing to realize from.
lot.close_price = null;
try std.testing.expectEqual(@as(?f64, null), lot.realizedGainLossAsOf(d(2026, 1, 1)));
}
test "positionsForAccount: a not-yet-bought lot isn't counted as closed" {
// It used to land in `closed_lots` because "not held" was taken to
// mean "closed"; `positionsAsOf` already skipped it.
var lots = [_]Lot{
.{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200, .account = "Sample IRA" },
.{ .symbol = "VTI", .shares = 5, .open_date = Date.fromYmd(2027, 1, 1), .open_price = 250, .account = "Sample IRA" },
};
const pf = testPortfolio(&lots);
const positions = try pf.positionsForAccount(Date.fromYmd(2026, 1, 1), std.testing.allocator, "Sample IRA");
defer std.testing.allocator.free(positions);
try std.testing.expectEqual(@as(usize, 1), positions.len);
try std.testing.expectEqual(@as(usize, 0), positions[0].closed_lots);
try std.testing.expectEqual(@as(usize, 1), positions[0].open_lots);
}

View file

@ -1071,10 +1071,10 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
}
std.mem.sort(zfin.Lot, matching.items, app.today, fmt.lotSortFn);
// Check if any lots are DRIP
// Check if any lots go into the DRIP summary
var has_drip = false;
for (matching.items) |lot| {
if (lot.drip) {
if (fmt.isDripSummarized(app.today, lot)) {
has_drip = true;
break;
}
@ -1091,9 +1091,9 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
}) catch continue;
}
} else {
// Has DRIP lots: show non-DRIP individually, summarize DRIP as ST/LT
// Has DRIP lots: show the rest individually, summarize held DRIP as ST/LT
for (matching.items) |lot| {
if (!lot.drip) {
if (!fmt.isDripSummarized(app.today, lot)) {
state.rows.append(app.allocator, .{
.kind = .lot,
.symbol = lot.symbol,
@ -1273,7 +1273,7 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
var cash_lots: std.ArrayList(zfin.Lot) = .empty;
defer cash_lots.deinit(app.allocator);
for (pf.lots) |lot| {
if (lot.security_type == .cash and matchesAccountFilter(state, lot.account)) {
if (lot.security_type == .cash and lot.lotIsOpenAsOf(app.today) and matchesAccountFilter(state, lot.account)) {
cash_lots.append(app.allocator, lot) catch continue;
}
}
@ -1302,7 +1302,8 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
});
if (state.cash_expanded) {
for (pf.lots) |lot| {
if (lot.security_type == .cash) {
// Held lots only, so the rows add up to the total.
if (lot.security_type == .cash and lot.lotIsOpenAsOf(app.today)) {
state.rows.append(app.allocator, .{
.kind = .cash_row,
.symbol = lot.account orelse "Unknown",
@ -1327,7 +1328,7 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
});
if (state.illiquid_expanded) {
for (pf.lots) |lot| {
if (lot.security_type == .illiquid) {
if (lot.security_type == .illiquid and lot.lotIsOpenAsOf(app.today)) {
state.rows.append(app.allocator, .{
.kind = .illiquid_row,
.symbol = lot.symbol,
@ -1867,6 +1868,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
s.unrealized_gain_loss,
watch_syms.items,
app.portfolio.watchlist_prices,
app.today,
);
} else break :blk views.PositionsWidths{};
};
@ -2028,7 +2030,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
// base-ticker price, so a ratio'd lot must go
// through `effectivePriceFor` or its Value and
// Gain/Loss come out scaled by its ratio.
const use_price = views.effectivePriceFor(s.allocations, lot);
const use_price = views.effectivePriceFor(s.allocations, lot, app.today);
const gl = lot.effectiveShares() * (use_price - lot.effectiveOpenPrice());
lot_positive = gl >= 0;
lot_gl_str = try std.fmt.allocPrint(arena, "{s}{f}", .{

View file

@ -101,6 +101,7 @@ pub fn computeWidths(
total_gl: f64,
watch_syms: []const []const u8,
watch_prices: ?std.StringHashMap(f64),
as_of: Date,
) PositionsWidths {
var w: PositionsWidths = .{};
@ -117,7 +118,7 @@ pub fn computeWidths(
if (lot.security_type != .stock) continue;
w.shares_w = @max(w.shares_w, sharesCols(lot.effectiveShares()));
w.price_w = @max(w.price_w, moneyCols(lot.effectiveOpenPrice()));
const use_price = effectivePriceFor(allocations, lot);
const use_price = effectivePriceFor(allocations, lot, as_of);
// A ratio'd lot renders its own effective price in the Price
// cell (see `hasOwnPrice`), and that price can exceed every
// allocation's raw price - an 8.6x institutional ratio puts a
@ -270,10 +271,13 @@ fn padCell(arena: std.mem.Allocator, content: []const u8, cols: usize, justify:
/// block in `models/portfolio.zig`. The signal is `price_ratio`, and it
/// is answering ONE question: has this allocation been merged?
///
/// - `close_price` short-circuits first. It is the price the lot
/// actually closed at, already in the lot's own terms, so the ratio
/// is NOT reapplied. Matches how the contributions pipeline values
/// closed lots (`effectivePrice(close_price, true)`).
/// - A sold lot's `close_price` short-circuits first
/// (`Lot.closePriceAsOf`). It is the price the lot actually closed
/// at, already in the lot's own terms, so the ratio is NOT
/// reapplied. Matches how the contributions pipeline values closed
/// lots (`effectivePrice(close_price, true)`). A `close_price` on a
/// lot whose `close_date` hasn't arrived is ignored: that lot is
/// still held, and its row says so.
/// - `a.price_ratio != 1.0` means the allocation is UNMERGED and
/// carries this lot's own ratio (`positionsAsOf` groups by
/// `(priceSymbol, price_ratio)` and propagates the ratio through).
@ -306,8 +310,8 @@ fn padCell(arena: std.mem.Allocator, content: []const u8, cols: usize, justify:
/// only case a ratio'd alias produces - are exact: with every
/// component live off the same raw price `r`, `total_mv / norm_shares`
/// reduces to `r`.
pub fn effectivePriceFor(allocations: []const Allocation, lot: Lot) f64 {
if (lot.close_price) |cp| return lot.effectivePrice(cp, true);
pub fn effectivePriceFor(allocations: []const Allocation, lot: Lot, as_of: Date) f64 {
if (lot.closePriceAsOf(as_of)) |cp| return lot.effectivePrice(cp, true);
for (allocations) |a| {
if (!std.mem.eql(u8, a.symbol, lot.priceSymbol())) continue;
return lot.effectivePrice(a.current_price, a.price_ratio != 1.0);
@ -479,12 +483,13 @@ pub const Options = struct {
expired_count: usize,
allocator: std.mem.Allocator,
/// Expired option rows (matured strictly before `as_of`).
/// Ended option rows: expired or closed on or before `as_of`
/// (`Lot.hasEndedAsOf`).
pub fn expiredItems(self: Options) []const Option {
return self.items[0..self.expired_count];
}
/// Active option rows (not yet expired, including null-maturity).
/// Active option rows (not ended, including null-maturity).
pub fn activeItems(self: Options) []const Option {
return self.items[self.expired_count..];
}
@ -509,49 +514,56 @@ pub const Options = struct {
}
std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturityThenSymbolSortFn);
// Ended rows first, then active, each in maturity order. A
// maturity sort alone no longer yields that prefix: an option
// closed early (past `close_date`, future maturity) has ended
// but sorts among the active ones.
var expired_count: usize = 0;
for (tmp.items) |lot| {
const qty = lot.shares;
const cost_per = lot.open_price;
const premium = @abs(qty) * cost_per * lot.multiplier;
const is_expired = if (lot.maturity_date) |md| md.lessThan(as_of) else false;
if (is_expired) expired_count += 1;
const received = qty < 0;
for ([_]bool{ true, false }) |want_ended| {
for (tmp.items) |lot| {
const is_expired = lot.hasEndedAsOf(as_of);
if (is_expired != want_ended) continue;
const qty = lot.shares;
const cost_per = lot.open_price;
const premium = @abs(qty) * cost_per * lot.multiplier;
if (is_expired) expired_count += 1;
const received = qty < 0;
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
const premium_style: fmt.StyleIntent = if (is_expired) .muted else if (received) .positive else .negative;
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
const premium_style: fmt.StyleIntent = if (is_expired) .muted else if (received) .positive else .negative;
var cost_buf: [24]u8 = undefined;
var prem_val_buf: [24]u8 = undefined;
const prem_money = std.fmt.bufPrint(&prem_val_buf, "{f}", .{Money.from(premium)}) catch "$?";
var prem_buf: [20]u8 = undefined;
const prem_str = if (received)
std.fmt.bufPrint(&prem_buf, "+{s}", .{prem_money}) catch "?"
else
std.fmt.bufPrint(&prem_buf, "-{s}", .{prem_money}) catch "?";
const acct = lot.account orelse "";
var cost_buf: [24]u8 = undefined;
var prem_val_buf: [24]u8 = undefined;
const prem_money = std.fmt.bufPrint(&prem_val_buf, "{f}", .{Money.from(premium)}) catch "$?";
var prem_buf: [20]u8 = undefined;
const prem_str = if (received)
std.fmt.bufPrint(&prem_buf, "+{s}", .{prem_money}) catch "?"
else
std.fmt.bufPrint(&prem_buf, "-{s}", .{prem_money}) catch "?";
const acct = lot.account orelse "";
const text = try std.fmt.allocPrint(allocator, OptionsLayout.data_row, .{
lot.displaySymbol(),
qty,
std.fmt.bufPrint(&cost_buf, "{f}", .{Money.from(cost_per)}) catch "$?",
prem_str,
acct,
});
const text = try std.fmt.allocPrint(allocator, OptionsLayout.data_row, .{
lot.displaySymbol(),
qty,
std.fmt.bufPrint(&cost_buf, "{f}", .{Money.from(cost_per)}) catch "$?",
prem_str,
acct,
});
try list.append(allocator, .{
.lot = lot,
.premium = premium,
.received = received,
.is_expired = is_expired,
.row_style = row_style,
.premium_style = premium_style,
.columns = .{
.{ .text = text, .style = row_style },
.{ .text = prem_str, .style = premium_style },
},
.premium_col_start = OptionsLayout.premium_col_start,
});
try list.append(allocator, .{
.lot = lot,
.premium = premium,
.received = received,
.is_expired = is_expired,
.row_style = row_style,
.premium_style = premium_style,
.columns = .{
.{ .text = text, .style = row_style },
.{ .text = prem_str, .style = premium_style },
},
.premium_col_start = OptionsLayout.premium_col_start,
});
}
}
return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator };
}
@ -607,12 +619,13 @@ pub const CDs = struct {
expired_count: usize,
allocator: std.mem.Allocator,
/// Matured CD rows (maturity strictly before `as_of`).
/// Ended CD rows: matured or closed on or before `as_of`
/// (`Lot.hasEndedAsOf`).
pub fn expiredItems(self: CDs) []const CD {
return self.items[0..self.expired_count];
}
/// Active CD rows (not yet matured, including null-maturity).
/// Active CD rows (not ended, including null-maturity).
pub fn activeItems(self: CDs) []const CD {
return self.items[self.expired_count..];
}
@ -637,39 +650,43 @@ pub const CDs = struct {
}
std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturitySortFn);
// Ended rows first, then active - see `Options.init`.
var expired_count: usize = 0;
for (tmp.items) |lot| {
const is_expired = if (lot.maturity_date) |md| md.lessThan(as_of) else false;
if (is_expired) expired_count += 1;
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
for ([_]bool{ true, false }) |want_ended| {
for (tmp.items) |lot| {
const is_expired = lot.hasEndedAsOf(as_of);
if (is_expired != want_ended) continue;
if (is_expired) expired_count += 1;
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
var face_buf: [24]u8 = undefined;
var mat_buf: [10]u8 = undefined;
const mat_str: []const u8 = if (lot.maturity_date) |md| (std.fmt.bufPrint(&mat_buf, "{f}", .{md}) catch "????-??-??") else "--";
var rate_buf: [10]u8 = undefined;
const rate_str: []const u8 = if (lot.rate) |r|
std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--"
else
"--";
const note_str: []const u8 = lot.note orelse "";
const note_display = if (note_str.len > 40) note_str[0..40] else note_str;
const acct = lot.account orelse "";
var face_buf: [24]u8 = undefined;
var mat_buf: [10]u8 = undefined;
const mat_str: []const u8 = if (lot.maturity_date) |md| (std.fmt.bufPrint(&mat_buf, "{f}", .{md}) catch "????-??-??") else "--";
var rate_buf: [10]u8 = undefined;
const rate_str: []const u8 = if (lot.rate) |r|
std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--"
else
"--";
const note_str: []const u8 = lot.note orelse "";
const note_display = if (note_str.len > 40) note_str[0..40] else note_str;
const acct = lot.account orelse "";
const text = try std.fmt.allocPrint(allocator, CDsLayout.data_row, .{
lot.displaySymbol(),
std.fmt.bufPrint(&face_buf, "{f}", .{Money.from(lot.shares)}) catch "$?",
rate_str,
mat_str,
note_display,
acct,
});
const text = try std.fmt.allocPrint(allocator, CDsLayout.data_row, .{
lot.displaySymbol(),
std.fmt.bufPrint(&face_buf, "{f}", .{Money.from(lot.shares)}) catch "$?",
rate_str,
mat_str,
note_display,
acct,
});
try list.append(allocator, .{
.lot = lot,
.is_expired = is_expired,
.row_style = row_style,
.text = text,
});
try list.append(allocator, .{
.lot = lot,
.is_expired = is_expired,
.row_style = row_style,
.text = text,
});
}
}
return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator };
}
@ -691,6 +708,9 @@ const testing = std.testing;
const portfolio_mod = @import("../models/portfolio.zig");
const valuation = @import("../analytics/valuation.zig");
/// `as_of` for lot-pricing tests: after every fixture date, so a lot
/// with a `close_date` is sold and one without is held.
const test_as_of = Date.fromYmd(2026, 6, 1);
test "Options.init: expired rows form a prefix; active/expired slices split correctly" {
const as_of = Date.fromYmd(2024, 6, 1);
const lots = [_]Lot{
@ -941,7 +961,7 @@ test "effectivePriceFor: live price gets the lot's ratio applied" {
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
};
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&allocs, lot), 1e-6);
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&allocs, lot, test_as_of), 1e-6);
}
test "effectivePriceFor: ratio below 1.0 scales the price down, not up" {
@ -958,7 +978,7 @@ test "effectivePriceFor: ratio below 1.0 scales the price down, not up" {
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 97.50,
};
const eff_price = effectivePriceFor(&allocs, lot);
const eff_price = effectivePriceFor(&allocs, lot, test_as_of);
try testing.expectApproxEqRel(@as(f64, 182.8197), eff_price, 1e-6);
// The whole point: value must land on the real figure, not the
// ratio-skipped one.
@ -969,7 +989,7 @@ test "effectivePriceFor: ratio below 1.0 scales the price down, not up" {
test "effectivePriceFor: ratio 1.0 passes the raw price straight through" {
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
const lot = Lot{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50 };
try testing.expectEqual(@as(f64, 60), effectivePriceFor(&allocs, lot));
try testing.expectEqual(@as(f64, 60), effectivePriceFor(&allocs, lot, test_as_of));
}
test "effectivePriceFor: unmerged live price is already effective, ratio NOT reapplied" {
@ -992,11 +1012,11 @@ test "effectivePriceFor: unmerged live price is already effective, ratio NOT rea
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 106.99,
};
try testing.expectEqual(@as(f64, 144.04), effectivePriceFor(&allocs, lot));
try testing.expectEqual(@as(f64, 144.04), effectivePriceFor(&allocs, lot, test_as_of));
// And the lot row must reconcile with its own position row.
try testing.expectApproxEqRel(
allocs[0].market_value,
lot.effectiveShares() * effectivePriceFor(&allocs, lot),
lot.effectiveShares() * effectivePriceFor(&allocs, lot, test_as_of),
1e-9,
);
}
@ -1019,7 +1039,7 @@ test "effectivePriceFor: unmerged manual price is also already effective" {
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 18.15,
};
try testing.expectEqual(@as(f64, 19.01), effectivePriceFor(&allocs, lot));
try testing.expectEqual(@as(f64, 19.01), effectivePriceFor(&allocs, lot, test_as_of));
}
test "effectivePriceFor: a merged group's raw price DOES get the lot's ratio" {
@ -1038,7 +1058,7 @@ test "effectivePriceFor: a merged group's raw price DOES get the lot's ratio" {
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
};
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&merged, lot), 1e-6);
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&merged, lot, test_as_of), 1e-6);
}
test "effectivePriceFor: close_price wins over the allocation and skips the ratio" {
@ -1053,25 +1073,46 @@ test "effectivePriceFor: close_price wins over the allocation and skips the rati
.shares = 2412.601,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 106.99,
.close_date = Date.fromYmd(2026, 5, 1),
.close_price = 150.39,
};
try testing.expectEqual(@as(f64, 150.39), effectivePriceFor(&allocs, lot));
try testing.expectEqual(@as(f64, 150.39), effectivePriceFor(&allocs, lot, test_as_of));
}
test "effectivePriceFor: close_price is ignored until the sale happens" {
// A `close_price` with no `close_date` - or one still in the future
// - is on a lot that's still held (`Lot.closePriceAsOf`), so its row
// is priced live like any other held lot. It used to show the close
// price while the status column said "open".
const allocs = [_]Allocation{mkAlloc("BENCH", 100, 50, 90.15, 9015, 4015)};
var lot = Lot{
.symbol = "BENCH",
.shares = 10,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 50,
.close_price = 150.39,
};
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, lot, test_as_of));
lot.close_date = Date.fromYmd(2027, 1, 1);
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, lot, test_as_of));
// ...and takes over the day it arrives.
try testing.expectEqual(@as(f64, 150.39), effectivePriceFor(&allocs, lot, Date.fromYmd(2027, 1, 1)));
}
test "effectivePriceFor: orphan lot with no matching allocation yields 0" {
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
const lot = Lot{ .symbol = "ORPHAN", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 7 };
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, lot));
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, lot, test_as_of));
}
test "effectivePriceFor: matches on priceSymbol, not the lot's own symbol" {
// A CUSIP lot must find its allocation under the ticker alias.
const allocs = [_]Allocation{mkAlloc("BENCH", 100, 50, 90.15, 9015, 4015)};
const by_cusip = Lot{ .symbol = "02315N600", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50 };
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, by_cusip));
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, by_cusip, test_as_of));
var aliased = by_cusip;
aliased.ticker = "BENCH";
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, aliased));
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, aliased, test_as_of));
}
test "hasOwnPrice: only ratio'd lots occupy the Price cell" {
@ -1144,7 +1185,7 @@ test "effectivePriceFor: lot rows sum to their merged position's market value" {
var lot_mv_total: f64 = 0;
var lot_gl_total: f64 = 0;
for (lots) |lot| {
const eff_price = effectivePriceFor(summary.allocations, lot);
const eff_price = effectivePriceFor(summary.allocations, lot, test_as_of);
lot_mv_total += lot.effectiveShares() * eff_price;
lot_gl_total += lot.effectiveShares() * (eff_price - lot.effectiveOpenPrice());
}
@ -1199,7 +1240,7 @@ test "effectivePriceFor: a LONE ratio'd lot's row reconciles with its position"
try testing.expectEqual(ratio, a.price_ratio);
try testing.expectApproxEqRel(raw_price * ratio, a.current_price, 1e-9);
const eff_price = effectivePriceFor(summary.allocations, lots[0]);
const eff_price = effectivePriceFor(summary.allocations, lots[0], test_as_of);
try testing.expectApproxEqRel(raw_price * ratio, eff_price, 1e-9);
try testing.expectApproxEqRel(a.market_value, lots[0].effectiveShares() * eff_price, 1e-9);
// Magnitude pin: 1200 * 144.04 = $172,848, NOT 1200 * 720.20 = $864,240.
@ -1275,7 +1316,7 @@ test "computeWidths: a ratio'd lot's effective price widens the Price column" {
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 100.0,
}};
const w = computeWidths(&allocs, &lots, 9015, 1000, &.{}, null);
const w = computeWidths(&allocs, &lots, 9015, 1000, &.{}, null, test_as_of);
try testing.expectEqual(@as(usize, 10), w.price_w);
}
@ -1291,20 +1332,21 @@ test "computeWidths: the Price cell is measured only when the lot occupies it" {
.shares = 10,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 1.0,
.close_date = Date.fromYmd(2026, 5, 1),
.close_price = 99999.99, // "$99,999.99" = 10 cols
}};
// Ratio 1.0: cell stays blank, so the 10-col price must not leak in.
// $50.00 / $60.00 / $1.00 are all under the 8-col "Avg Cost" floor.
try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null).price_w);
try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null, test_as_of).price_w);
// Ratio'd: the cell is occupied, so the same price now widens it.
lots[0].price_ratio = 2.0;
try testing.expectEqual(@as(usize, 10), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null).price_w);
try testing.expectEqual(@as(usize, 10), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null, test_as_of).price_w);
}
test "computeWidths: empty portfolio sits at the header-label floors" {
const w = computeWidths(&.{}, &.{}, 0, 0, &.{}, null);
const w = computeWidths(&.{}, &.{}, 0, 0, &.{}, null, test_as_of);
try testing.expectEqual(@as(usize, PositionsLayout.min_symbol_w), w.symbol_w);
try testing.expectEqual(@as(usize, PositionsLayout.min_shares_w), w.shares_w);
try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), w.price_w);
@ -1321,7 +1363,7 @@ test "computeWidths: a small portfolio stays at the floors (tightens, no waste)"
// at its minimum. This is the "don't waste space" half of
// fit-to-content.
const allocs = [_]Allocation{mkAlloc("IBM", 10, 150, 155, 1550, 50)};
const w = computeWidths(&allocs, &.{}, 1550, 50, &.{}, null);
const w = computeWidths(&allocs, &.{}, 1550, 50, &.{}, null, test_as_of);
try testing.expectEqual(@as(usize, 6), w.symbol_w); // "Symbol"
try testing.expectEqual(@as(usize, 6), w.shares_w); // "Shares"
try testing.expectEqual(@as(usize, 8), w.price_w); // "Avg Cost"
@ -1333,7 +1375,7 @@ test "computeWidths: large crypto-scale values grow each column" {
// DOGE-USD(8) / 10000.0(7) / $42,000.00(10) / $420,000,000.00(15)
// / +$5,000,000.00(14, sign + $5,000,000.00).
const allocs = [_]Allocation{mkAlloc("DOGE-USD", 10000, 41500, 42000, 420000000, 5000000)};
const w = computeWidths(&allocs, &.{}, 420000000, 5000000, &.{}, null);
const w = computeWidths(&allocs, &.{}, 420000000, 5000000, &.{}, null, test_as_of);
try testing.expectEqual(@as(usize, 8), w.symbol_w);
try testing.expectEqual(@as(usize, 7), w.shares_w);
try testing.expectEqual(@as(usize, 10), w.price_w); // max("$41,500.00","$42,000.00") = 10
@ -1352,10 +1394,10 @@ test "computeWidths: a single lot's gain/loss can exceed the netted position" {
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 199.0 },
};
// Allocations only: stays at the 9-col floor.
const no_lots = computeWidths(&allocs, &.{}, 20000, 0, &.{}, null);
const no_lots = computeWidths(&allocs, &.{}, 20000, 0, &.{}, null, test_as_of);
try testing.expectEqual(@as(usize, 9), no_lots.gainloss_w);
// With lots: grows to fit the per-lot swing.
const w = computeWidths(&allocs, &lots, 20000, 0, &.{}, null);
const w = computeWidths(&allocs, &lots, 20000, 0, &.{}, null, test_as_of);
try testing.expectEqual(@as(usize, 10), w.gainloss_w);
}
@ -1368,13 +1410,13 @@ test "computeWidths: non-stock lots skipped, close_price honored, orphan lot tol
// Closed stock lot: market value / gain-loss use close_price
// (1234), not the allocation's current price (60). gl =
// 100 * (1234 - 10) = 122400 -> "+$122,400.00" = 12 cols.
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .close_price = 1234.0 },
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 1234.0 },
// Stock lot whose symbol has no matching allocation and no
// close_price: currentPriceFor falls back to 0 (exercises the
// not-found path), contributing nothing.
.{ .symbol = "ORPHAN", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 7.0 },
};
const w = computeWidths(&allocs, &lots, 6000, 1000, &.{}, null);
const w = computeWidths(&allocs, &lots, 6000, 1000, &.{}, null, test_as_of);
// Non-stock lot skipped -> Shares stays at the 6-col floor (the
// 999999.0 = 8-col share count was ignored).
try testing.expectEqual(@as(usize, 6), w.shares_w);
@ -1391,7 +1433,7 @@ test "computeWidths: watchlist symbols and prices widen Symbol / Price" {
// "NOPRICE" has no map entry, so it widens Symbol but not Price
// (exercises the price-absent branch).
const watch = [_][]const u8{ "VERYLONGSYM", "NOPRICE" };
const w = computeWidths(&.{}, &.{}, 0, 0, &watch, wp);
const w = computeWidths(&.{}, &.{}, 0, 0, &watch, wp, test_as_of);
try testing.expectEqual(@as(usize, 11), w.symbol_w);
try testing.expectEqual(@as(usize, 9), w.price_w);
}
@ -1430,7 +1472,58 @@ test "computeWidths: lot columns size to effective (split-adjusted) shares" {
};
const no_allocs: []const Allocation = &.{};
const no_watch: []const []const u8 = &.{};
const w = computeWidths(no_allocs, &lots, 0, 0, no_watch, null);
const w = computeWidths(no_allocs, &lots, 0, 0, no_watch, null, test_as_of);
try testing.expectEqual(sharesCols(1000.0), w.shares_w);
try testing.expect(w.shares_w >= sharesCols(100.0));
}
test "Options.init: an option on its maturity day has ended" {
// End-of-day semantics, the same as the summary totals
// (`Lot.hasEndedAsOf`). This section used `maturity < as_of` and
// showed it active - and in its TOTAL - for that one day.
const as_of = Date.fromYmd(2024, 6, 21);
const lots = [_]Lot{
.{ .symbol = "AAA 2024-06-21 C100", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 6, 21) },
};
var opts = try Options.init(as_of, testing.allocator, &lots, null);
defer opts.deinit();
try testing.expectEqual(@as(usize, 1), opts.expiredItems().len);
try testing.expectEqual(@as(usize, 0), opts.activeItems().len);
}
test "Options.init: an option closed before maturity has ended, and ended rows stay a prefix" {
// Closed early: past close_date, future maturity. It sorts among the
// active rows by maturity, so the partition - not the sort - has to
// move it into the ended prefix, or `activeItems` (and the section
// TOTAL built from it) would still include it.
const as_of = Date.fromYmd(2024, 6, 1);
const lots = [_]Lot{
.{ .symbol = "AAA 2024-09-01 C100", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 9, 1) },
.{ .symbol = "BBB 2024-12-01 C50", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 12, 1), .close_date = Date.fromYmd(2024, 3, 1), .close_price = 0.10 },
.{ .symbol = "CCC 2025-03-01 C75", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 3.0, .security_type = .option, .maturity_date = Date.fromYmd(2025, 3, 1) },
};
var opts = try Options.init(as_of, testing.allocator, &lots, null);
defer opts.deinit();
try testing.expectEqual(@as(usize, 1), opts.expired_count);
try testing.expectEqualStrings("BBB 2024-12-01 C50", opts.expiredItems()[0].lot.symbol);
try testing.expectEqual(@as(usize, 2), opts.activeItems().len);
// Active rows keep their maturity order.
try testing.expectEqualStrings("AAA 2024-09-01 C100", opts.activeItems()[0].lot.symbol);
try testing.expectEqualStrings("CCC 2025-03-01 C75", opts.activeItems()[1].lot.symbol);
for (opts.items[0..opts.expired_count]) |o| try testing.expect(o.is_expired);
for (opts.items[opts.expired_count..]) |o| try testing.expect(!o.is_expired);
}
test "CDs.init: a CD closed before maturity has ended; one maturing today has too" {
const as_of = Date.fromYmd(2024, 6, 1);
const lots = [_]Lot{
.{ .symbol = "CD-TODAY", .shares = 5000, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2024, 6, 1) },
.{ .symbol = "CD-BROKEN", .shares = 8000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2025, 1, 1), .close_date = Date.fromYmd(2024, 4, 1) },
.{ .symbol = "CD-ACTIVE", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2025, 6, 1) },
};
var cds = try CDs.init(as_of, testing.allocator, &lots, null);
defer cds.deinit();
try testing.expectEqual(@as(usize, 2), cds.expiredItems().len);
try testing.expectEqual(@as(usize, 1), cds.activeItems().len);
try testing.expectEqualStrings("CD-ACTIVE", cds.activeItems()[0].lot.symbol);
}