diff --git a/AGENTS.md b/AGENTS.md index 768ba0d..348e359 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, diff --git a/docs/guides/audit-against-brokerage.md b/docs/guides/audit-against-brokerage.md index e3dd6fe..eb29560 100644 --- a/docs/guides/audit-against-brokerage.md +++ b/docs/guides/audit-against-brokerage.md @@ -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. | diff --git a/docs/reference/config/portfolio-srf.md b/docs/reference/config/portfolio-srf.md index f8064a6..be6d987 100644 --- a/docs/reference/config/portfolio-srf.md +++ b/docs/reference/config/portfolio-srf.md @@ -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: diff --git a/src/analytics/reconcile/common.zig b/src/analytics/reconcile/common.zig index 4ef4bea..9c85096 100644 --- a/src/analytics/reconcile/common.zig +++ b/src/analytics/reconcile/common.zig @@ -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)); + } +} diff --git a/src/analytics/reconcile/schwab.zig b/src/analytics/reconcile/schwab.zig index 60485fb..a922de0 100644 --- a/src/analytics/reconcile/schwab.zig +++ b/src/analytics/reconcile/schwab.zig @@ -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); } diff --git a/src/analytics/valuation.zig b/src/analytics/valuation.zig index 498f2d0..c328c69 100644 --- a/src/analytics/valuation.zig +++ b/src/analytics/valuation.zig @@ -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); +} diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index bebb2e6..5e833ed 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -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); +} diff --git a/src/commands/import.zig b/src/commands/import.zig index 575d70b..0abcc9b 100644 --- a/src/commands/import.zig +++ b/src/commands/import.zig @@ -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); diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index ebbae91..3abe53f 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -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(); diff --git a/src/format.zig b/src/format.zig index 21af11e..ff1d815 100644 --- a/src/format.zig +++ b/src/format.zig @@ -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)); +} diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index 0d09435..009a228 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -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); +} diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 8ef9456..a004bb1 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -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}", .{ diff --git a/src/views/portfolio_sections.zig b/src/views/portfolio_sections.zig index 92aa8d0..99bb20c 100644 --- a/src/views/portfolio_sections.zig +++ b/src/views/portfolio_sections.zig @@ -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); +}