From 48b422cba419827f0ac2825bd4c9cc5cbe5d10f3 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 6 Jul 2026 12:29:50 -0700 Subject: [PATCH] stock split handling --- src/PortfolioData.zig | 14 +- src/analytics/performance.zig | 11 +- src/cache/store.zig | 16 ++ src/commands/audit/hygiene.zig | 21 +++ src/commands/common.zig | 15 +- src/commands/compare.zig | 6 + src/commands/portfolio.zig | 28 +++- src/commands/snapshot.zig | 8 + src/compare.zig | 244 ++++++++++++++++++++++++++++- src/format.zig | 14 +- src/models/classification.zig | 46 ++++++ src/models/portfolio.zig | 213 ++++++++++++++++++++++++- src/models/split.zig | 87 +++++++++++ src/portfolio_loader.zig | 258 +++++++++++++++++++++++++++++++ src/service.zig | 62 ++++++++ src/tui/history_tab.zig | 8 + src/tui/portfolio_tab.zig | 8 +- src/views/compare.zig | 43 ++++++ src/views/portfolio_sections.zig | 22 ++- 19 files changed, 1084 insertions(+), 40 deletions(-) diff --git a/src/PortfolioData.zig b/src/PortfolioData.zig index c4ea16f..b02f6a5 100644 --- a/src/PortfolioData.zig +++ b/src/PortfolioData.zig @@ -634,13 +634,19 @@ pub fn load( const pf = self.file.?; - // ── Compute positions + symbols ────────────────────────── - const positions = pf.positions(today, gpa) catch return error.NoAllocations; - defer gpa.free(positions); - + // ── Compute symbols + positions ────────────────────────── + // Symbols first: the split corpus is fetched per-symbol. const syms = pf.stockSymbols(gpa) catch return error.NoAllocations; defer gpa.free(syms); + // Opt-in split adjustment before positions are aggregated, so the + // TUI's positions and valuation carry effective shares. No-op + // unless `splits_current_through` is set in metadata.srf. + portfolio_loader.enrichLotsSplits(self.svc, gpa, pf.lots, syms, self.paths[0], today); + + const positions = pf.positions(today, gpa) catch return error.NoAllocations; + defer gpa.free(positions); + // ── Compute watchlist symbols ──────────────────────────── // // Union of caller-supplied watchlist syms (typically from diff --git a/src/analytics/performance.zig b/src/analytics/performance.zig index b4b4c20..fe3ee3a 100644 --- a/src/analytics/performance.zig +++ b/src/analytics/performance.zig @@ -3,6 +3,7 @@ const Date = @import("../Date.zig"); const Candle = @import("../models/candle.zig").Candle; const Dividend = @import("../models/dividend.zig").Dividend; const Split = @import("../models/split.zig").Split; +const cumulativeSplitRatio = @import("../models/split.zig").cumulativeSplitRatio; const portfolio = @import("../models/portfolio.zig"); /// Minimum holding period (in years) before annualizing returns. @@ -302,13 +303,9 @@ fn priceReturnSnap( // Cumulative split adjustment: for each split between start.date // (exclusive) and end.date (inclusive), the start price needs to // be divided by the cumulative ratio to make it post-split- - // equivalent with the end price. - var cum_ratio: f64 = 1.0; - for (splits) |s| { - if (s.date.lessThan(start.date) or s.date.eql(start.date)) continue; - if (end.date.lessThan(s.date)) continue; - cum_ratio *= s.ratio(); - } + // equivalent with the end price. Shared with lot effective-share + // enrichment via `cumulativeSplitRatio` so the two never diverge. + const cum_ratio = cumulativeSplitRatio(splits, start.date, end.date); const adj_start_close = start.close / cum_ratio; if (adj_start_close == 0) return null; diff --git a/src/cache/store.zig b/src/cache/store.zig index c0443ec..bc0ad30 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -2879,6 +2879,22 @@ test "portfolio serialize/deserialize round-trip" { try std.testing.expectEqualStrings("VTI", portfolio.lots[2].symbol); } +test "portfolio: derived split_factor is never serialized into portfolio.srf" { + const allocator = std.testing.allocator; + // A plain lot (default split_factor == 1.0), as `zfin import` writes. + const lots = [_]Lot{ + .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 180.0 }, + }; + const data = try serializePortfolio(allocator, &lots); + defer allocator.free(data); + + // split_factor is a DERIVED, read-time-only field. At its 1.0 default + // it must be omitted from serialization so the user's hand-maintained + // portfolio.srf is never polluted with it. (This is the invariant the + // whole "immutable lots" design rests on.) + try std.testing.expect(std.mem.indexOf(u8, data, "split_factor") == null); +} + test "portfolio: cash lots without symbol get CASH placeholder" { const allocator = std.testing.allocator; // Raw SRF with a cash lot that has no symbol field diff --git a/src/commands/audit/hygiene.zig b/src/commands/audit/hygiene.zig index 355d53e..d325268 100644 --- a/src/commands/audit/hygiene.zig +++ b/src/commands/audit/hygiene.zig @@ -994,6 +994,27 @@ pub fn runHygieneCheck( } } + // ── Section 6: Unhandled stock splits ── + // + // Held symbols with a split AFTER a lot's purchase date that haven't + // opted into automatic split adjustment. Each needs a + // `splits_current_through` on its metadata.srf row, or its shares + // (and every value derived from them) are misstated across the + // split. Cache-only detection; silent when everything is handled. + if (portfolio.stockSymbols(allocator)) |split_syms| { + defer allocator.free(split_syms); + const unhandled = cli.findUnhandledSplits(svc, allocator, portfolio.lots, split_syms, portfolio_path, as_of); + defer allocator.free(unhandled); + if (unhandled.len > 0) { + try out.print("\n", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " Unhandled stock splits\n", .{}); + for (unhandled) |nudge| { + try out.print(" {s}: stock split on {f}\n", .{ nudge.symbol, nudge.date }); + try cli.printFg(out, color, cli.CLR_MUTED, " Add 'splits_current_through::YYYY-MM-DD' (your reconcile date) to {s}'s metadata.srf row.\n", .{nudge.symbol}); + } + } + } else |_| {} + try out.print("\n", .{}); } diff --git a/src/commands/common.zig b/src/commands/common.zig index f432ccf..07d8d04 100644 --- a/src/commands/common.zig +++ b/src/commands/common.zig @@ -398,6 +398,7 @@ pub const PortfolioData = portfolio_loader.PortfolioData; pub const loadPortfolioFromConfig = portfolio_loader.loadPortfolioFromConfig; pub const loadPortfolioFromPaths = portfolio_loader.loadPortfolioFromPaths; pub const buildPortfolioData = portfolio_loader.buildPortfolioData; +pub const findUnhandledSplits = portfolio_loader.findUnhandledSplits; /// Resolve `-p`/`--portfolio` patterns through `ctx`, then load the /// union of all matched portfolio files. The one-stop loader for @@ -414,13 +415,23 @@ pub const buildPortfolioData = portfolio_loader.buildPortfolioData; /// `loadPortfolioFromConfig`, so the resulting `LoadedPortfolio` /// is byte-identical regardless of which surface invoked it. pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio { - return portfolio_loader.loadPortfolioFromConfig( + var loaded = portfolio_loader.loadPortfolioFromConfig( ctx.io, ctx.allocator, ctx.config, ctx.globals.portfolio_patterns, as_of, - ); + ) orelse return null; + + // Opt-in stock-split adjustment: makes `loaded.positions` (and the + // shared lot slice) carry effective shares when the user has set + // `splits_current_through` in metadata.srf. No-op otherwise, so + // every existing portfolio behaves exactly as before. + if (ctx.svc) |svc| { + portfolio_loader.applySplitAdjustment(svc, ctx.allocator, &loaded, as_of); + } + + return loaded; } // ── As-of date parsing (shared by CLI --as-of and TUI date popup) ── diff --git a/src/commands/compare.zig b/src/commands/compare.zig index b888819..cb039ea 100644 --- a/src/commands/compare.zig +++ b/src/commands/compare.zig @@ -448,6 +448,12 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { else .{ .date_at_or_before = now_date_requested }; + // Opt-in: bring the "then" snapshot into the current split basis so + // a split between the two dates doesn't read as a phantom position + // change against the (already effective-share) live side. No-op + // unless `splits_current_through` is set in metadata.srf. + compare_core.adjustThenForSplits(svc, allocator, &then_side.map, portfolio_path, then_date, now_date); + if (now_is_live) { // Borrow the pre-loaded live_data to build the LiveSide // map. When `live_data` is null (loadLiveData returned diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index 30dc800..f2f7c7d 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -640,7 +640,7 @@ pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin const acct_col: []const u8 = lot.account orelse ""; const use_price = lot.close_price orelse current_price; - const gl = lot.shares * (use_price - lot.open_price); + const gl = lot.effectiveShares() * (use_price - lot.effectiveOpenPrice()); const lot_gl_abs = if (gl >= 0) gl else -gl; const lot_sign: []const u8 = if (gl >= 0) "+" else "-"; @@ -651,14 +651,14 @@ pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin try views.writeCol(out, status_str, w.symbol_w, false); try out.writeByte(' '); var shares_buf: [48]u8 = undefined; - const shares_str = std.fmt.bufPrint(&shares_buf, "{d:.1}", .{lot.shares}) catch "?"; + const shares_str = std.fmt.bufPrint(&shares_buf, "{d:.1}", .{lot.effectiveShares()}) catch "?"; try views.writeCol(out, shares_str, w.shares_w, true); try out.writeByte(' '); - try out.print("{f}", .{Money.from(lot.open_price).padRight(w.price_w)}); + try out.print("{f}", .{Money.from(lot.effectiveOpenPrice()).padRight(w.price_w)}); try out.writeByte(' '); try views.writeCol(out, "", w.price_w, true); // blank current-price cell try out.writeByte(' '); - try out.print("{f}", .{Money.from(lot.shares * use_price).padRight(w.value_w)}); + try out.print("{f}", .{Money.from(lot.effectiveShares() * use_price).padRight(w.value_w)}); try out.writeByte(' '); try cli.reset(out, color); // Colored gain/loss cell. @@ -1293,3 +1293,23 @@ test "parseArgs: --expired without value errors" { const args = [_][]const u8{"--expired"}; try std.testing.expectError(error.InvalidExpiredValue, parseArgs(&ctx, &args)); } + +test "printLotRow: renders effective (split-adjusted) shares, cost, and value" { + var lots = [_]zfin.Lot{ + .{ .symbol = "NVDA", .shares = 100, .open_date = zfin.Date.fromYmd(2020, 1, 1), .open_price = 40, .split_factor = 10.0 }, + }; + 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); + + var buf: [512]u8 = undefined; + var w = std.Io.Writer.fixed(&buf); + try printLotRow(zfin.Date.fromYmd(2026, 1, 1), &w, false, lots[0], 120.0, widths); + const out = w.buffered(); + + // Effective shares 1000.0 (not raw 100.0), effective cost $4.00 + // (40 / 10), market value 1000 * 120 = $120,000. + try std.testing.expect(std.mem.indexOf(u8, out, "1000.0") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "$4.00") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "120,000") != null); +} diff --git a/src/commands/snapshot.zig b/src/commands/snapshot.zig index 726ea39..619b9e0 100644 --- a/src/commands/snapshot.zig +++ b/src/commands/snapshot.zig @@ -682,6 +682,14 @@ fn captureSnapshot( // Use `positionsAsOf(as_of)` rather than `positions()` so historical // backfills correctly count lots that were held on `as_of` // regardless of whether they're open today. + + // Opt-in split adjustment, as-of the snapshot date so a back-dated + // capture applies only the splits that had occurred by then. + // Effective shares flow into the summary (via positionsAsOf) and + // into each per-lot `value` (via marketValue); the stored `.shares` + // field stays RAW - a snapshot is a frozen historical record. + portfolio_loader.enrichLotsSplits(svc, allocator, portfolio.lots, syms, portfolio_path, as_of); + const positions = try portfolio.positionsAsOf(allocator, as_of); defer allocator.free(positions); diff --git a/src/compare.zig b/src/compare.zig index a2147a0..1a4c94d 100644 --- a/src/compare.zig +++ b/src/compare.zig @@ -37,6 +37,7 @@ const zfin = @import("root.zig"); const history = @import("history.zig"); const snapshot_model = @import("models/snapshot.zig"); const view = @import("views/compare.zig"); +const cumulativeSplitRatio = @import("models/split.zig").cumulativeSplitRatio; pub const Date = zfin.Date; @@ -88,9 +89,22 @@ pub fn loadSnapshotSide( // ── Stock aggregation (compare-view shape) ─────────────────── /// Walk a snapshot's lot rows, filter to `security_type == "Stock"`, -/// and group by symbol into `out_map`. Shares are summed; price is -/// taken from the first lot seen (all stock lots of a symbol share -/// the same `price` field in a given snapshot). +/// and group by symbol into `out_map`. +/// +/// Shares are the EFFECTIVE (split-adjusted) count, recovered as +/// `value / price` rather than read from the raw `.shares` field. A +/// snapshot stores `.shares` as the immutable as-transacted count but +/// `.value` as the split-adjusted market value (see the "Share model" +/// block in `models/portfolio.zig` and `snapshot.buildSnapshot`), so +/// once a split has been applied at capture, `raw_shares * price` no +/// longer equals the stored value. Deriving `value / price` keeps this +/// side self-consistent (shares x price == value) and in the snapshot's +/// own date-epoch effective basis, which `adjustThenForSplits` then +/// brings forward. For a plain pre-feature snapshot `value == shares x +/// price`, so this degrades exactly to the raw share count. +/// +/// Price is taken from the first lot seen (all stock lots of a symbol +/// share the same `price` field in a given snapshot). /// /// Lives here rather than in `history.zig` because it emits a /// `view.HoldingMap` - a compare-view-shaped type. The projection- @@ -106,11 +120,15 @@ pub fn aggregateSnapshotStocks( for (snap.lots) |lot| { if (!std.mem.eql(u8, lot.security_type, "Stock")) continue; const price = lot.price orelse continue; + // Effective shares = value / price (split-adjusted). Guard a + // zero price (bad data) by falling back to the raw count so we + // never divide by zero. + const eff_shares = if (price != 0) lot.value / price else lot.shares; if (out_map.getPtr(lot.symbol)) |h| { - h.shares += lot.shares; + h.shares += eff_shares; // price is already set from first-seen; leave it. } else { - try out_map.put(lot.symbol, .{ .shares = lot.shares, .price = price }); + try out_map.put(lot.symbol, .{ .shares = eff_shares, .price = price }); } } } @@ -137,13 +155,75 @@ pub fn aggregateLiveStocks( const raw_price = prices.get(sym) orelse continue; const eff_price = lot.effectivePrice(raw_price, false); if (out_map.getPtr(sym)) |h| { - h.shares += lot.shares; + h.shares += lot.effectiveShares(); } else { - try out_map.put(sym, .{ .shares = lot.shares, .price = eff_price }); + try out_map.put(sym, .{ .shares = lot.effectiveShares(), .price = eff_price }); } } } +/// Opt-in: bring a "then" snapshot map into the "now" split basis so a +/// split between the two compared dates doesn't read as a phantom +/// position change (or a phantom price crash) against the "now" side. +/// +/// Both snapshot sides already carry EFFECTIVE shares in their own +/// date-epoch (see `aggregateSnapshotStocks`), so all this does is +/// bridge the epoch gap between `then_date` and `now_date`: it multiplies +/// shares by, and divides price by, the cumulative split ratio in the +/// window `(max(cutover, then_date), now_date]`. Starting at `then_date` +/// (not the cutover) is what makes a snapshot captured AFTER a split a +/// no-op instead of double-applying that split; the cutover only clamps +/// the window so we never reach behind the user's reconcile baseline. +/// A held-throughout position then nets to zero share change. +/// +/// Per-symbol: only symbols that carry a `splits_current_through` +/// cutover are adjusted; the rest are left untouched. No-op when nothing +/// has opted in or no split applies. Shared by the CLI `compare` command +/// and the TUI history tab. Only meaningful for a SNAPSHOT "then" side; +/// when "then" is the live portfolio it is already in the current basis, +/// so callers skip it. +/// +/// Known limitation: a "then" snapshot from BEFORE the cutover, or a +/// stale pre-feature snapshot that under-recorded value, can't be +/// perfectly reconciled from recorded data - those fall back to +/// cutover-basis / recorded value. +pub fn adjustThenForSplits( + svc: *zfin.DataService, + allocator: std.mem.Allocator, + then_map: *view.HoldingMap, + portfolio_path: []const u8, + then_date: zfin.Date, + now_date: zfin.Date, +) void { + var cutovers = svc.loadSplitsCutovers(allocator, portfolio_path); + defer { + var kit = cutovers.keyIterator(); + while (kit.next()) |k| allocator.free(k.*); + cutovers.deinit(); + } + if (cutovers.count() == 0) return; + + var factors = std.StringHashMap(f64).init(allocator); + defer factors.deinit(); + var it = then_map.iterator(); + while (it.next()) |e| { + const sym = e.key_ptr.*; + const cutover = cutovers.get(sym) orelse continue; // per-symbol opt-in + const fr = svc.getSplits(sym, .{}) catch continue; + defer fr.deinit(); + // Bring the "then" holdings forward from the snapshot's own date + // to `now_date`. Start the window at max(cutover, then_date): the + // snapshot already reflects every split up to then_date, and raw + // shares are current only through the cutover, so a split before + // either boundary must not be re-applied. Mirrors enrichSplits' + // max(cutover, open_date) clamp so the two never diverge. + const after = if (cutover.lessThan(then_date)) then_date else cutover; + const f = cumulativeSplitRatio(fr.data, after, now_date); + if (f != 1.0) factors.put(sym, f) catch continue; + } + view.forwardAdjustThen(then_map, &factors); +} + // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; @@ -226,6 +306,52 @@ test "aggregateSnapshotStocks: sums shares, filters non-stock, takes first price try testing.expectEqual(@as(f64, 25), (map.get("MSFT") orelse unreachable).shares); } +test "aggregateSnapshotStocks: derives effective shares from value/price (split-captured snapshot)" { + // A snapshot captured with split adjustment on stores RAW shares + // (100) but the split-adjusted market VALUE (1000 effective shares + // x $120 = $120,000). The compare map must reflect the effective + // 1000 shares (value / price), not the raw 100 - otherwise a split + // reads as a phantom 10x position change against the effective-share + // "now" side. For a plain (pre-feature) snapshot value == shares x + // price, so this degrades to raw shares and is backward-compatible. + var map: view.HoldingMap = .init(testing.allocator); + defer map.deinit(); + + const lots = [_]snapshot_model.LotRow{ + .{ + .symbol = "NVDA", + .lot_symbol = "NVDA", + .account = "Sample Brokerage", + .security_type = "Stock", + .shares = 100, // RAW, as transacted (immutable historical fact) + .open_price = 40, + .cost_basis = 4000, + .value = 120000, // effective: 1000 shares x $120 + .price = 120.0, + }, + }; + const snap = snapshot_model.Snapshot{ + .meta = .{ + .snapshot_version = 1, + .as_of_date = Date.fromYmd(2024, 9, 1), + .captured_at = 0, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = &.{}, + .tax_types = &.{}, + .accounts = &.{}, + .lots = @constCast(&lots), + }; + + try aggregateSnapshotStocks(&snap, &map); + + const h = map.get("NVDA") orelse return error.TestUnexpectedResult; + // Effective shares = value / price = 120000 / 120 = 1000, not raw 100. + try testing.expectApproxEqAbs(@as(f64, 1000), h.shares, 0.001); + try testing.expectApproxEqAbs(@as(f64, 120.0), h.price, 0.001); +} + test "loadSnapshotSide: happy path builds a SnapshotSide with aggregated holdings" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); @@ -417,3 +543,107 @@ test "aggregateLiveStocks: empty portfolio yields empty map" { try aggregateLiveStocks(today, &portfolio, &prices, &map); try testing.expectEqual(@as(u32, 0), map.count()); } + +test "adjustThenForSplits: forward-adjusts then map from seeded cache + cutover" { + const allocator = testing.allocator; + const io = testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + // metadata.srf with a per-symbol cutover before the split -> applied. + try tmp.dir.writeFile(io, .{ + .sub_path = "metadata.srf", + .data = "#!srfv1\nsymbol::NVDA,splits_current_through::2024-01-01\n", + }); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf); + const dir = path_buf[0..dir_len]; + + // Seed NVDA 10:1 (2024-06-10). + var store = zfin.cache.Store.init(io, allocator, dir); + var splits = [_]zfin.Split{.{ .date = zfin.Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }}; + store.write(zfin.Split, "NVDA", splits[0..], .{ .seconds = zfin.cache.Ttl.splits }); + + var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir }); + defer svc.deinit(); + + const anchor = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" }); + defer allocator.free(anchor); + + var then_map: view.HoldingMap = .init(allocator); + defer then_map.deinit(); + try then_map.put("NVDA", .{ .shares = 100, .price = 600 }); + + // then snapshot taken 2024-03-01 (before the 2024-06-10 split); + // now_date after the split: factor 10 -> 100 -> 1000 shares, $600 -> $60. + adjustThenForSplits(&svc, allocator, &then_map, anchor, zfin.Date.fromYmd(2024, 3, 1), zfin.Date.fromYmd(2026, 1, 1)); + try testing.expectApproxEqAbs(@as(f64, 1000), then_map.get("NVDA").?.shares, 0.01); + try testing.expectApproxEqAbs(@as(f64, 60.0), then_map.get("NVDA").?.price, 0.01); +} + +test "adjustThenForSplits: no cutover leaves then map untouched" { + const allocator = testing.allocator; + const io = testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + // No metadata.srf at all -> opt-in off. + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf); + const dir = path_buf[0..dir_len]; + + var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir }); + defer svc.deinit(); + + const anchor = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" }); + defer allocator.free(anchor); + + var then_map: view.HoldingMap = .init(allocator); + defer then_map.deinit(); + try then_map.put("NVDA", .{ .shares = 100, .price = 600 }); + + adjustThenForSplits(&svc, allocator, &then_map, anchor, zfin.Date.fromYmd(2024, 3, 1), zfin.Date.fromYmd(2026, 1, 1)); + try testing.expectApproxEqAbs(@as(f64, 100), then_map.get("NVDA").?.shares, 0.01); + try testing.expectApproxEqAbs(@as(f64, 600.0), then_map.get("NVDA").?.price, 0.01); +} + +test "adjustThenForSplits: then snapshot captured after the split is not double-applied" { + const allocator = testing.allocator; + const io = testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + // Same opt-in + seeded 10:1 split (2024-06-10) as the forward case. + try tmp.dir.writeFile(io, .{ + .sub_path = "metadata.srf", + .data = "#!srfv1\nsymbol::NVDA,splits_current_through::2024-01-01\n", + }); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf); + const dir = path_buf[0..dir_len]; + + var store = zfin.cache.Store.init(io, allocator, dir); + var splits = [_]zfin.Split{.{ .date = zfin.Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }}; + store.write(zfin.Split, "NVDA", splits[0..], .{ .seconds = zfin.cache.Ttl.splits }); + + var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir }); + defer svc.deinit(); + + const anchor = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" }); + defer allocator.free(anchor); + + // A "then" snapshot captured 2024-08-01 - AFTER the split - already + // holds effective shares (1000) at the post-split price ($120). The + // split predates this snapshot, so bringing it forward to now must + // be a no-op: the window starts at the snapshot's own date, not the + // cutover. Applying the split again would 10x it into a phantom. + var then_map: view.HoldingMap = .init(allocator); + defer then_map.deinit(); + try then_map.put("NVDA", .{ .shares = 1000, .price = 120 }); + + adjustThenForSplits(&svc, allocator, &then_map, anchor, zfin.Date.fromYmd(2024, 8, 1), zfin.Date.fromYmd(2026, 1, 1)); + try testing.expectApproxEqAbs(@as(f64, 1000), then_map.get("NVDA").?.shares, 0.01); + try testing.expectApproxEqAbs(@as(f64, 120.0), then_map.get("NVDA").?.price, 0.01); +} diff --git a/src/format.zig b/src/format.zig index 00f038f..cec3b0a 100644 --- a/src/format.zig +++ b/src/format.zig @@ -696,7 +696,7 @@ pub fn aggregateDripLots(as_of: Date, lots: []const Lot) DripAggregation { 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; - bucket.shares += lot.shares; + bucket.shares += lot.effectiveShares(); bucket.cost += lot.costBasis(); if (bucket.first_date == null or lot.open_date.days < bucket.first_date.?.days) bucket.first_date = lot.open_date; @@ -1230,6 +1230,18 @@ test "aggregateDripLots empty" { try std.testing.expect(agg.lt.isEmpty()); } +test "aggregateDripLots: uses split-adjusted effective shares, raw cost basis" { + var lots = [_]Lot{ + .{ .symbol = "NVDA", .shares = 2, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 40, .drip = true, .split_factor = 10.0 }, + }; + const agg = aggregateDripLots(Date.fromYmd(2026, 1, 1), &lots); + // Held > 1yr -> LT. Effective shares = 2 * 10 = 20; cost basis stays + // raw = 2 * 40 = 80; avgCost = 80/20 = 4.0 (effective per-share). + try std.testing.expectApproxEqAbs(@as(f64, 20), agg.lt.shares, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 80), agg.lt.cost, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 4.0), agg.lt.avgCost(), 0.01); +} + test "fmtContractLine" { var buf: [128]u8 = undefined; const contract = OptionContract{ diff --git a/src/models/classification.zig b/src/models/classification.zig index cbc9fa4..a3fc18a 100644 --- a/src/models/classification.zig +++ b/src/models/classification.zig @@ -11,6 +11,7 @@ /// symbol::02315N600,asset_class::Bonds,pct:num:15 const std = @import("std"); const srf = @import("srf"); +const Date = @import("../Date.zig"); /// A single classification entry for a symbol. pub const ClassificationEntry = struct { @@ -36,6 +37,16 @@ pub const ClassificationEntry = struct { asset_class: ?[]const u8 = null, /// Percentage weight for this entry (0-100). Default 100 for single-class assets. pct: f64 = 100.0, + /// Per-symbol opt-in for automatic stock-split adjustment. When set, + /// `enrichSplits` auto-applies splits for THIS symbol that occur + /// AFTER this date (splits on/before it are assumed already baked + /// into the recorded share counts - e.g. a lot entered post-split). + /// Null (the default, and the state of every metadata.srf that + /// predates this feature) means split adjustment is OFF for the + /// symbol - today's exact behavior. It lives per-symbol because + /// "when did I last make these shares current" is a fact about the + /// individual holding, not the whole portfolio. + splits_current_through: ?Date = null, }; /// Parsed classification data for the entire portfolio. @@ -95,6 +106,7 @@ pub fn parseClassificationFile(allocator: std.mem.Allocator, data: []const u8) ! .geo = if (entry.geo) |g| try allocator.dupe(u8, g) else null, .asset_class = if (entry.asset_class) |a| try allocator.dupe(u8, a) else null, .pct = entry.pct, + .splits_current_through = entry.splits_current_through, }); } @@ -272,6 +284,40 @@ test "parse classification file: bucket round-trips" { try std.testing.expectEqualStrings("Equity / Corporate", cm.entries[0].sector.?); } +test "parse classification file: per-symbol splits_current_through parses; absent stays null" { + const data = + \\#!srfv1 + \\symbol::AMZN,name::Amazon,sector::Technology,geo::US,asset_class::US Large Cap,splits_current_through::2024-06-01 + \\symbol::NVDA,sector::Technology,geo::US,asset_class::US Large Cap + ; + const allocator = std.testing.allocator; + var cm = try parseClassificationFile(allocator, data); + defer cm.deinit(); + + try std.testing.expectEqual(@as(usize, 2), cm.entries.len); + + // AMZN carries a per-symbol cutover; NVDA (no field) stays null. + try std.testing.expectEqualStrings("AMZN", cm.entries[0].symbol); + try std.testing.expect(cm.entries[0].splits_current_through != null); + try std.testing.expect(cm.entries[0].splits_current_through.?.eql(Date.fromYmd(2024, 6, 1))); + + try std.testing.expectEqualStrings("NVDA", cm.entries[1].symbol); + try std.testing.expect(cm.entries[1].splits_current_through == null); +} + +test "parse classification file: legacy row without the field parses (backward compat)" { + const data = + \\#!srfv1 + \\symbol::AMZN,sector::Technology,geo::US,asset_class::US Large Cap + ; + const allocator = std.testing.allocator; + var cm = try parseClassificationFile(allocator, data); + defer cm.deinit(); + + try std.testing.expectEqual(@as(usize, 1), cm.entries.len); + try std.testing.expect(cm.entries[0].splits_current_through == null); +} + test "resolveSecurityName: metadata name wins" { var entries = [_]ClassificationEntry{ .{ .symbol = "AMZN", .name = "Amazon" }, diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index fb48a8c..e785d8b 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -1,6 +1,8 @@ const std = @import("std"); const Date = @import("../Date.zig"); const Candle = @import("candle.zig").Candle; +const split = @import("split.zig"); +const Split = split.Split; // ── Pricing model ──────────────────────────────────────────── // @@ -64,6 +66,52 @@ const Candle = @import("candle.zig").Candle; // + `manual_set`, audit via inline `prices.get(sym) orelse avg_cost` // with a matching `is_preadjusted` flag per branch. +// ── Share model (split adjustment) ────────────────────────── +// +// `lot.shares` and `lot.open_price` are IMMUTABLE HISTORICAL FACTS: +// the count and per-share price exactly as the lot was transacted, as +// written in portfolio.srf. We never rewrite them for a stock split +// (that would corrupt the git history the contributions/compare/audit +// commands read). Instead, `lot.split_factor` is a DERIVED multiplier, +// populated on read by `enrichSplits` from the fetched split corpus: +// +// effective_shares = shares * split_factor +// effective_open_price = open_price / split_factor +// +// so cost basis stays split-invariant +// (`effective_shares * effective_open_price == shares * open_price`). +// +// `split_factor` is analogous to `price_ratio`: a derived multiplier, +// default 1.0 (no adjustment), applied via accessors. It is set ONLY +// by `enrichSplits`, and ONLY for symbols the user has opted in with a +// per-symbol `splits_current_through::DATE` on that symbol's +// metadata.srf row. Symbols without it stay at 1.0 everywhere and every +// path below behaves exactly as it did before this feature existed. +// +// ## The rule - when to use which +// +// Use `effectiveShares()` / `effectiveOpenPrice()` (NOT raw +// `shares`/`open_price`) anywhere a share count is: +// - multiplied by a current-or-later price (market value, gain/loss +// vs current price), +// - summed into a current holdings/position total, or +// - shown on screen. +// `marketValue` already routes through `effectiveShares()`, and +// `positionsAsOf`/`positionsForAccount` sum `effectiveShares()`, so +// anything flowing through positions or `marketValue` is correct for +// free. +// +// Use RAW `shares`/`open_price` (they are correct precisely because +// they are split-invariant or historical) for: +// - cost basis and realized P&L (`costBasis`, `realizedGainLoss`), +// - the contributions diff (a split must NOT read as a contribution; +// it diffs raw declared shares across git revisions), +// - the frozen `.shares` field written into a snapshot record (a +// historical fact; its `.value` is computed effective instead). +// +// If you are reading `lot.shares` directly in NEW code, you had +// better be in the RAW list above - otherwise use `effectiveShares()`. + // ── Money-market / stable-NAV classification ──────────────── // // Centralized so that audit/, the Fidelity/Schwab parsers, and the @@ -206,6 +254,16 @@ pub const Lot = struct { /// institutional NAV. E.g. if VTTHX (investor) is $27.78 and the institutional /// class trades at $144.04, price_ratio = 144.04 / 27.78 ≈ 5.185. price_ratio: f64 = 1.0, + /// DERIVED split-adjustment multiplier - NOT hand-edited, NOT a + /// pricing/classification key. Default 1.0 = no adjustment. + /// Populated on read by `enrichSplits` (opt-in via a per-symbol + /// `splits_current_through` on the symbol's metadata.srf row); see + /// the "Share model" + /// block at the top of this file. `effectiveShares()` multiplies by + /// it; `effectiveOpenPrice()` divides by it. Left at its 1.0 default + /// it is omitted from SRF serialization, so it never touches the + /// user's portfolio.srf (guarded by a round-trip test in store.zig). + split_factor: f64 = 1.0, /// Underlying stock symbol for option lots (e.g. "AMZN"). underlying: ?[]const u8 = null, /// Strike price for option lots. @@ -289,10 +347,28 @@ pub const Lot = struct { return true; } + /// Cost basis: RAW shares x RAW open_price. Split-invariant + /// (`effectiveShares * effectiveOpenPrice == shares * open_price`), + /// so this deliberately stays on the raw fields. See "Share model". pub fn costBasis(self: Lot) f64 { return self.shares * self.open_price; } + /// Split-adjusted share count: raw `shares` scaled by the derived + /// `split_factor`. Use this (never raw `shares`) anywhere shares get + /// multiplied by a current-or-later price, summed into current + /// holdings, or displayed. See the "Share model" block above. + pub fn effectiveShares(self: Lot) f64 { + return self.shares * self.split_factor; + } + + /// Split-adjusted per-share cost: raw `open_price` divided by + /// `split_factor`. Pairs with `effectiveShares()` so cost basis is + /// preserved. Use for per-share cost display on a split-spanning lot. + pub fn effectiveOpenPrice(self: Lot) f64 { + return self.open_price / self.split_factor; + } + /// Apply the share-class `price_ratio` to `raw_price`. See the /// "Pricing model" block at the top of this file for the full /// semantics of `is_preadjusted`. @@ -300,13 +376,16 @@ pub const Lot = struct { return if (is_preadjusted) raw_price else raw_price * self.price_ratio; } - /// Market value of the lot at `raw_price`: `shares * effectivePrice`. + /// Market value of the lot at `raw_price`: + /// `effectiveShares * effectivePrice` (split- and share-class-aware). pub fn marketValue(self: Lot, raw_price: f64, is_preadjusted: bool) f64 { - return self.shares * self.effectivePrice(raw_price, is_preadjusted); + 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. + /// 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; return self.shares * (cp - self.open_price); @@ -314,16 +393,49 @@ pub const Lot = struct { /// Unrealized gain/loss for an open lot at the given market price. pub fn unrealizedGainLoss(self: Lot, current_price: f64) f64 { - return self.shares * (current_price - self.open_price); + return self.effectiveShares() * (current_price - self.effectiveOpenPrice()); } pub fn returnPct(self: Lot, current_price: f64) f64 { if (self.open_price == 0) return 0; const price = if (self.close_price) |cp| cp else current_price; - return (price / self.open_price) - 1.0; + return (price / self.effectiveOpenPrice()) - 1.0; } }; +/// Populate each open stock lot's `split_factor` from the fetched split +/// `corpus` (keyed by `priceSymbol()`), in place. +/// +/// OPT-IN, PER SYMBOL: `cutovers` maps a symbol to the date through +/// which its recorded shares are already split-adjusted (the +/// `splits_current_through` field on that symbol's metadata.srf row). A +/// symbol ABSENT from `cutovers` is left untouched - `split_factor` +/// stays 1.0 and valuation behaves exactly as it did before splits +/// existed. For a symbol present, a split is applied to a lot only if it +/// occurred AFTER both the lot's purchase and the symbol's cutover (so +/// already-restated legacy lots, whose splits predate the cutover, are +/// left alone) and on/before `as_of`. Closed lots are skipped - their +/// realized P&L stays on raw shares. Non-stock lots never split. +/// +/// `as_of` is the reference date: today for live valuation, the +/// snapshot date for a back-dated snapshot capture. +pub fn enrichSplits( + lots: []Lot, + corpus: *const std.StringHashMap([]const Split), + cutovers: *const std.StringHashMap(Date), + as_of: Date, +) void { + for (lots) |*lot| { + if (lot.security_type != .stock) continue; + if (!lot.lotIsOpenAsOf(as_of)) continue; + const cutover = cutovers.get(lot.priceSymbol()) orelse continue; // per-symbol opt-in + const splits = corpus.get(lot.priceSymbol()) orelse continue; + // Apply splits after BOTH the purchase and the symbol's cutover. + const after = if (cutover.lessThan(lot.open_date)) lot.open_date else cutover; + lot.split_factor = split.cumulativeSplitRatio(splits, after, as_of); + } +} + /// Aggregated position for a single symbol across multiple lots. pub const Position = struct { symbol: []const u8, @@ -529,7 +641,7 @@ pub const Portfolio = struct { const pos = found.?; if (lot.lotIsOpenAsOf(as_of)) { - pos.shares += lot.shares; + pos.shares += lot.effectiveShares(); pos.total_cost += lot.costBasis(); pos.open_lots += 1; } else { @@ -594,7 +706,7 @@ pub const Portfolio = struct { const pos = found.?; if (lot.isOpen(as_of)) { - pos.shares += lot.shares; + pos.shares += lot.effectiveShares(); pos.total_cost += lot.costBasis(); pos.open_lots += 1; } else { @@ -1553,3 +1665,90 @@ test "stableNavCandle: fills all fields at $1" { try std.testing.expectEqual(@as(f64, 1), c.adj_close); try std.testing.expectEqual(@as(u64, 0), c.volume); } + +// ── Split-adjustment (effectiveShares / enrichSplits) tests ── + +test "effectiveShares/effectiveOpenPrice default to raw at factor 1.0" { + const lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 180.0 }; + try std.testing.expectApproxEqAbs(@as(f64, 10), lot.effectiveShares(), 0.0001); + try std.testing.expectApproxEqAbs(@as(f64, 180.0), lot.effectiveOpenPrice(), 0.0001); + // Cost basis is invariant under the split factor. + try std.testing.expectApproxEqAbs(lot.costBasis(), lot.effectiveShares() * lot.effectiveOpenPrice(), 0.0001); +} + +test "enrichSplits: per-symbol opt-in gate, forward split, post-split lot, legacy already-restated" { + const allocator = std.testing.allocator; + const as_of = Date.fromYmd(2026, 1, 1); + + var corpus = std.StringHashMap([]const Split).init(allocator); + defer corpus.deinit(); + const nvda_splits = [_]Split{.{ .date = Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }}; + const amzn_splits = [_]Split{.{ .date = Date.fromYmd(2022, 6, 6), .numerator = 20, .denominator = 1 }}; + const tsla_splits = [_]Split{.{ .date = Date.fromYmd(2024, 8, 1), .numerator = 3, .denominator = 1 }}; + try corpus.put("NVDA", &nvda_splits); + try corpus.put("AMZN", &amzn_splits); + try corpus.put("TSLA", &tsla_splits); + + // Per-symbol opt-in: NVDA and AMZN carry a cutover; TSLA does NOT. + var cutovers = std.StringHashMap(Date).init(allocator); + defer cutovers.deinit(); + try cutovers.put("NVDA", Date.fromYmd(2024, 1, 1)); + try cutovers.put("AMZN", Date.fromYmd(2024, 1, 1)); + + var lots = [_]Lot{ + // NVDA held across the post-cutover split -> factor 10. + .{ .symbol = "NVDA", .shares = 100, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 40.0 }, + // NVDA opened AFTER the split -> already post-split -> factor 1. + .{ .symbol = "NVDA", .shares = 50, .open_date = Date.fromYmd(2024, 8, 1), .open_price = 110.0 }, + // AMZN split predates its cutover (already restated) -> factor 1. + .{ .symbol = "AMZN", .shares = 30, .open_date = Date.fromYmd(2019, 3, 1), .open_price = 90.0 }, + // TSLA has a real post-purchase split but is NOT opted in -> factor 1. + .{ .symbol = "TSLA", .shares = 20, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 30.0 }, + // Non-stock never splits. + .{ .symbol = "CASH", .shares = 5000, .open_date = Date.fromYmd(2019, 1, 1), .open_price = 1.0, .security_type = .cash }, + }; + + enrichSplits(&lots, &corpus, &cutovers, as_of); + + try std.testing.expectApproxEqAbs(@as(f64, 1000), lots[0].effectiveShares(), 0.0001); + try std.testing.expectApproxEqAbs(@as(f64, 4.0), lots[0].effectiveOpenPrice(), 0.0001); // 40 / 10 + try std.testing.expectApproxEqAbs(lots[0].costBasis(), lots[0].effectiveShares() * lots[0].effectiveOpenPrice(), 0.0001); + try std.testing.expectApproxEqAbs(@as(f64, 50), lots[1].effectiveShares(), 0.0001); + try std.testing.expectApproxEqAbs(@as(f64, 30), lots[2].effectiveShares(), 0.0001); + // TSLA not opted in -> untouched despite a real post-purchase split. + try std.testing.expectApproxEqAbs(@as(f64, 20), lots[3].effectiveShares(), 0.0001); + try std.testing.expectApproxEqAbs(@as(f64, 5000), lots[4].effectiveShares(), 0.0001); + + // Empty cutovers map -> every factor stays 1.0 (today's behavior). + var lots2 = [_]Lot{ + .{ .symbol = "NVDA", .shares = 100, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 40.0 }, + }; + var empty = std.StringHashMap(Date).init(allocator); + defer empty.deinit(); + enrichSplits(&lots2, &corpus, &empty, as_of); + try std.testing.expectApproxEqAbs(@as(f64, 100), lots2[0].effectiveShares(), 0.0001); +} + +test "positionsAsOf reflects split_factor: effective shares, invariant basis, effective market value" { + const allocator = std.testing.allocator; + var lots = [_]Lot{ + .{ .symbol = "NVDA", .shares = 100, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 40.0, .account = "Sample Brokerage" }, + }; + // Simulate enrichment applying a 10:1 split. + lots[0].split_factor = 10.0; + + const pf = Portfolio{ .lots = &lots, .allocator = allocator }; + const positions = try pf.positionsAsOf(allocator, Date.fromYmd(2026, 1, 1)); + defer allocator.free(positions); + + try std.testing.expectEqual(@as(usize, 1), positions.len); + // Effective shares: 100 * 10 = 1000. + try std.testing.expectApproxEqAbs(@as(f64, 1000), positions[0].shares, 0.001); + // Cost basis stays raw/invariant: 100 * 40 = 4000. + try std.testing.expectApproxEqAbs(@as(f64, 4000), positions[0].total_cost, 0.001); + // avg_cost = total_cost / effective_shares = 4.0 (effective per-share). + try std.testing.expectApproxEqAbs(@as(f64, 4.0), positions[0].avg_cost, 0.001); + // Market value at the post-split price $120: 1000 * 120 = 120,000 + // (the whole point - raw 100 * 120 would undercount 10x). + try std.testing.expectApproxEqAbs(@as(f64, 120000), positions[0].marketValue(120.0, false), 0.01); +} diff --git a/src/models/split.zig b/src/models/split.zig index 5833ac5..ffaa5b3 100644 --- a/src/models/split.zig +++ b/src/models/split.zig @@ -14,6 +14,31 @@ pub const Split = struct { } }; +/// Cumulative product of split ratios for splits in the window +/// `(after, through]` - start-EXCLUSIVE, end-INCLUSIVE. Returns 1.0 +/// when no split falls in the window. +/// +/// This is the single source of truth for the split-window walk. Two +/// call sites depend on it and must never diverge: +/// - price-return math (`analytics/performance.zig`) DIVIDES an +/// earlier-epoch price by this to bring it into the later epoch. +/// - lot effective-share enrichment (`enrichSplits` below) MULTIPLIES +/// an earlier-epoch share count by this for the same reason. +/// +/// The boundary convention matches the original inline walk in +/// `performance.priceReturnSnap`: a split exactly on `after` is excluded +/// (it predates the window's opening value) and one exactly on `through` +/// is included. +pub fn cumulativeSplitRatio(splits: []const Split, after: Date, through: Date) f64 { + var cum: f64 = 1.0; + for (splits) |s| { + if (s.date.lessThan(after) or s.date.eql(after)) continue; // after-exclusive + if (through.lessThan(s.date)) continue; // through-inclusive + cum *= s.ratio(); + } + return cum; +} + const std = @import("std"); test "split ratio" { @@ -24,3 +49,65 @@ test "split ratio" { const two_for_one = Split{ .date = Date{ .days = 19000 }, .numerator = 2, .denominator = 1 }; try std.testing.expectApproxEqAbs(@as(f64, 2.0), two_for_one.ratio(), 0.001); } + +test "cumulativeSplitRatio window boundaries and compounding" { + const s2021 = Split{ .date = Date.fromYmd(2021, 7, 20), .numerator = 4, .denominator = 1 }; + const s2024 = Split{ .date = Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }; + const reverse = Split{ .date = Date.fromYmd(2023, 5, 1), .numerator = 1, .denominator = 10 }; + + // No splits at all -> identity. + try std.testing.expectApproxEqAbs( + @as(f64, 1.0), + cumulativeSplitRatio(&.{}, Date.fromYmd(2020, 1, 1), Date.fromYmd(2026, 1, 1)), + 0.0001, + ); + + // Single split strictly inside the window. + try std.testing.expectApproxEqAbs( + @as(f64, 4.0), + cumulativeSplitRatio(&.{s2021}, Date.fromYmd(2021, 1, 1), Date.fromYmd(2022, 1, 1)), + 0.0001, + ); + + // Two splits inside -> product (bringing pre-2021 shares to today). + try std.testing.expectApproxEqAbs( + @as(f64, 40.0), + cumulativeSplitRatio(&.{ s2021, s2024 }, Date.fromYmd(2020, 1, 1), Date.fromYmd(2026, 1, 1)), + 0.0001, + ); + + // Split before the window is excluded. + try std.testing.expectApproxEqAbs( + @as(f64, 1.0), + cumulativeSplitRatio(&.{s2021}, Date.fromYmd(2022, 1, 1), Date.fromYmd(2026, 1, 1)), + 0.0001, + ); + + // Split after the window is excluded. + try std.testing.expectApproxEqAbs( + @as(f64, 1.0), + cumulativeSplitRatio(&.{s2024}, Date.fromYmd(2020, 1, 1), Date.fromYmd(2023, 1, 1)), + 0.0001, + ); + + // `after` boundary is EXCLUSIVE: split exactly on `after` is not applied. + try std.testing.expectApproxEqAbs( + @as(f64, 1.0), + cumulativeSplitRatio(&.{s2021}, Date.fromYmd(2021, 7, 20), Date.fromYmd(2026, 1, 1)), + 0.0001, + ); + + // `through` boundary is INCLUSIVE: split exactly on `through` is applied. + try std.testing.expectApproxEqAbs( + @as(f64, 4.0), + cumulativeSplitRatio(&.{s2021}, Date.fromYmd(2020, 1, 1), Date.fromYmd(2021, 7, 20)), + 0.0001, + ); + + // Reverse split composes correctly. + try std.testing.expectApproxEqAbs( + @as(f64, 0.1), + cumulativeSplitRatio(&.{reverse}, Date.fromYmd(2020, 1, 1), Date.fromYmd(2026, 1, 1)), + 0.0001, + ); +} diff --git a/src/portfolio_loader.zig b/src/portfolio_loader.zig index 1f5bedc..23f84c7 100644 --- a/src/portfolio_loader.zig +++ b/src/portfolio_loader.zig @@ -59,6 +59,7 @@ const zfin = @import("root.zig"); const framework = @import("commands/framework.zig"); const stderr = @import("stderr.zig"); const git = @import("git.zig"); +const enrichSplits = @import("models/portfolio.zig").enrichSplits; // ── Portfolio loading ──────────────────────────────────────── @@ -543,6 +544,138 @@ pub fn buildPortfolioData( }; } +/// Opt-in stock-split adjustment: set each open stock lot's +/// `split_factor` from the per-symbol metadata.srf `splits_current_through` +/// cutovers and the fetched split corpus. A no-op (and skips the corpus +/// fetch entirely) when no symbol has opted in. +/// +/// This mutates only `lots[].split_factor`; callers that hold an +/// already-computed positions slice must recompute it afterward (the +/// CLI wrapper `applySplitAdjustment` does this). Used directly by the +/// TUI, snapshot, and audit paths, which recompute positions on their own. +/// +/// Failures degrade gracefully to raw shares. Contributions never +/// calls this (it diffs raw declared shares from git), so a split can +/// never masquerade as a contribution. +pub fn enrichLotsSplits( + svc: *zfin.DataService, + allocator: std.mem.Allocator, + lots: []zfin.Lot, + syms: []const []const u8, + anchor_path: []const u8, + as_of: zfin.Date, +) void { + var cutovers = svc.loadSplitsCutovers(allocator, anchor_path); + defer freeCutovers(allocator, &cutovers); + if (cutovers.count() == 0) return; // nothing opted in -> skip the corpus fetch + + var corpus = svc.loadAllSplits(allocator, syms, .{}); + defer { + var it = corpus.valueIterator(); + while (it.next()) |v| allocator.free(v.*); + corpus.deinit(); + } + + enrichSplits(lots, &corpus, &cutovers, as_of); +} + +/// Free a `symbol -> cutover` map produced by `loadSplitsCutovers` +/// (owned keys, then the map itself). +fn freeCutovers(allocator: std.mem.Allocator, cutovers: *std.StringHashMap(zfin.Date)) void { + var it = cutovers.keyIterator(); + while (it.next()) |k| allocator.free(k.*); + cutovers.deinit(); +} + +/// A held stock symbol that split AFTER a lot was purchased while that +/// symbol has NOT opted into split adjustment - i.e. an unhandled split +/// that would change the position once the user enables the feature for +/// it. Surfaced as an `audit` hygiene finding. `symbol` borrows from the +/// caller's lots; use before the portfolio is freed. +pub const SplitNudge = struct { + symbol: []const u8, + date: zfin.Date, +}; + +/// Collect every held stock symbol that has NOT opted into split +/// adjustment yet has a split after a lot's purchase date, in the CACHED +/// split data (no network). One entry per symbol (first qualifying +/// split). Powers the `audit` hygiene "unhandled stock splits" section. +/// +/// Caller owns the returned slice (`allocator.free` it); each `symbol` +/// borrows from `lots`, so use the result before the portfolio is freed. +/// Cheap: cache-only reads. Returns an empty slice on any allocation +/// failure (hygiene is best-effort). +pub fn findUnhandledSplits( + svc: *zfin.DataService, + allocator: std.mem.Allocator, + lots: []const zfin.Lot, + syms: []const []const u8, + anchor_path: []const u8, + as_of: zfin.Date, +) []SplitNudge { + var cutovers = svc.loadSplitsCutovers(allocator, anchor_path); + defer freeCutovers(allocator, &cutovers); + + var corpus = svc.loadAllSplits(allocator, syms, .{ .skip_network = true }); + defer { + var it = corpus.valueIterator(); + while (it.next()) |v| allocator.free(v.*); + corpus.deinit(); + } + + var found: std.ArrayList(SplitNudge) = .empty; + defer found.deinit(allocator); + + for (lots) |lot| { + if (lot.security_type != .stock) continue; + if (!lot.lotIsOpenAsOf(as_of)) continue; + const sym = lot.priceSymbol(); + if (cutovers.contains(sym)) continue; // this symbol already opted in + // Dedup: one finding per symbol. Findings are rare, so a linear + // scan over what we've collected is cheaper than a side map. + var already = false; + for (found.items) |f| { + if (std.mem.eql(u8, f.symbol, sym)) { + already = true; + break; + } + } + if (already) continue; + const splits = corpus.get(sym) orelse continue; + for (splits) |s| { + // Split strictly after purchase and on/before the as-of date. + if (lot.open_date.lessThan(s.date) and !as_of.lessThan(s.date)) { + found.append(allocator, .{ .symbol = sym, .date = s.date }) catch break; + break; + } + } + } + return found.toOwnedSlice(allocator) catch &.{}; +} + +/// CLI wrapper: enrich a freshly loaded `LoadedPortfolio` in place and +/// recompute `positions` from the (possibly) enriched lots so every +/// downstream consumer (summary, per-lot rows, account breakdown) sees +/// effective shares. The recompute is unconditional and cheap +/// (in-memory, no I/O); it yields identical positions when nothing has +/// opted in. +pub fn applySplitAdjustment( + svc: *zfin.DataService, + allocator: std.mem.Allocator, + loaded: *LoadedPortfolio, + as_of: zfin.Date, +) void { + enrichLotsSplits(svc, allocator, loaded.portfolio.lots, loaded.syms, loaded.anchor(), as_of); + + // Positions were aggregated from raw lots at load time; recompute + // from the (now possibly enriched) lots. On failure, keep the + // existing positions. + const new_positions = loaded.portfolio.positions(as_of, allocator) catch return; + allocator.free(loaded.positions); + loaded.positions = new_positions; +} + // ── loadFromBytes tests (in-memory; no disk I/O) ───────────── // // All tests here construct synthetic SRF byte literals and route @@ -884,3 +1017,128 @@ test "loadPortfolioFromPathsAtRev: file added later is silently skipped at earli try testing.expectEqual(@as(usize, 1), loaded.portfolio.lots.len); try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol); } + +// ── Split adjustment wiring (seeded cache + metadata) ──────── + +/// Write a portfolio file, a metadata.srf (optionally with a per-symbol +/// cutover row), and seed one NVDA 10:1 split into the cache under +/// `dir`. Returns the realpath of the temp dir (borrows `path_buf`). +fn seedSplitFixture(io: std.Io, tmp: *std.testing.TmpDir, path_buf: []u8, cutover_line: []const u8) ![]const u8 { + try tmp.dir.writeFile(io, .{ + .sub_path = "zfintest_split_pf.srf", + .data = + \\#!srfv1 + \\symbol::NVDA,shares:num:100,open_date::2020-01-01,open_price:num:40.00,account::Sample Brokerage + \\ + , + }); + var meta_buf: [128]u8 = undefined; + const meta = try std.fmt.bufPrint(&meta_buf, "#!srfv1\n{s}", .{cutover_line}); + try tmp.dir.writeFile(io, .{ .sub_path = "metadata.srf", .data = meta }); + + const dir_len = try tmp.dir.realPathFile(io, ".", path_buf); + const dir = path_buf[0..dir_len]; + + // Seed an NVDA 10:1 split (2024-06-10) into the split cache. + var store = zfin.cache.Store.init(io, testing.allocator, dir); + var splits = [_]zfin.Split{.{ .date = zfin.Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }}; + store.write(zfin.Split, "NVDA", splits[0..], .{ .seconds = zfin.cache.Ttl.splits }); + return dir; +} + +test "applySplitAdjustment: end-to-end enriches loaded positions from seeded cache + cutover" { + const allocator = testing.allocator; + const io = testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + // Per-symbol cutover before the 2024 split -> the split IS applied. + const dir = try seedSplitFixture(io, &tmp, &path_buf, "symbol::NVDA,splits_current_through::2024-01-01\n"); + + var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir }); + defer svc.deinit(); + + const pf_path = try std.fs.path.join(allocator, &.{ dir, "zfintest_split_pf.srf" }); + defer allocator.free(pf_path); + const paths = try allocator.dupe([]const u8, &.{pf_path}); + defer allocator.free(paths); + var loaded = loadPortfolioFromPaths(io, allocator, paths, zfin.Date.fromYmd(2026, 1, 1)) orelse + return error.TestUnexpectedResult; + defer loaded.deinit(allocator); + + // Before enrichment: raw 100 shares. + try testing.expectApproxEqAbs(@as(f64, 100), loaded.positions[0].shares, 0.001); + + applySplitAdjustment(&svc, allocator, &loaded, zfin.Date.fromYmd(2026, 1, 1)); + + // After: 100 * 10 = 1000 effective shares; factor stamped on the lot; + // cost basis stays invariant (100 * 40 = 4000). + try testing.expectApproxEqAbs(@as(f64, 10.0), loaded.portfolio.lots[0].split_factor, 0.001); + try testing.expectApproxEqAbs(@as(f64, 1000), loaded.positions[0].shares, 0.001); + try testing.expectApproxEqAbs(@as(f64, 4000), loaded.positions[0].total_cost, 0.001); +} + +test "applySplitAdjustment: no cutover is a no-op (raw shares preserved)" { + const allocator = testing.allocator; + const io = testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + // metadata.srf with NO cutover row -> opt-in off. + const dir = try seedSplitFixture(io, &tmp, &path_buf, ""); + + var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir }); + defer svc.deinit(); + + const pf_path = try std.fs.path.join(allocator, &.{ dir, "zfintest_split_pf.srf" }); + defer allocator.free(pf_path); + const paths = try allocator.dupe([]const u8, &.{pf_path}); + defer allocator.free(paths); + var loaded = loadPortfolioFromPaths(io, allocator, paths, zfin.Date.fromYmd(2026, 1, 1)) orelse + return error.TestUnexpectedResult; + defer loaded.deinit(allocator); + + applySplitAdjustment(&svc, allocator, &loaded, zfin.Date.fromYmd(2026, 1, 1)); + + // Opt-in off -> factor stays 1.0, shares stay raw. + try testing.expectApproxEqAbs(@as(f64, 1.0), loaded.portfolio.lots[0].split_factor, 0.001); + try testing.expectApproxEqAbs(@as(f64, 100), loaded.positions[0].shares, 0.001); +} + +test "findUnhandledSplits: flags post-purchase splits for un-opted-in symbols only" { + const allocator = testing.allocator; + const io = testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir = try seedSplitFixture(io, &tmp, &path_buf, ""); // opt-in OFF + + var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir }); + defer svc.deinit(); + + const anchor = try std.fs.path.join(allocator, &.{ dir, "zfintest_split_pf.srf" }); + defer allocator.free(anchor); + + var lots = [_]zfin.Lot{ + .{ .symbol = "NVDA", .shares = 100, .open_date = zfin.Date.fromYmd(2020, 1, 1), .open_price = 40 }, + }; + const syms = [_][]const u8{"NVDA"}; + + // Opt-in off + a split after purchase -> one finding. + const found = findUnhandledSplits(&svc, allocator, &lots, &syms, anchor, zfin.Date.fromYmd(2026, 1, 1)); + defer allocator.free(found); + try testing.expectEqual(@as(usize, 1), found.len); + try testing.expectEqualStrings("NVDA", found[0].symbol); + try testing.expect(found[0].date.eql(zfin.Date.fromYmd(2024, 6, 10))); + + // A lot opened AFTER the split -> no finding for it. + var lots_after = [_]zfin.Lot{ + .{ .symbol = "NVDA", .shares = 5, .open_date = zfin.Date.fromYmd(2025, 1, 1), .open_price = 120 }, + }; + const none = findUnhandledSplits(&svc, allocator, &lots_after, &syms, anchor, zfin.Date.fromYmd(2026, 1, 1)); + defer allocator.free(none); + try testing.expectEqual(@as(usize, 0), none.len); +} diff --git a/src/service.zig b/src/service.zig index 45b300d..9ba89f3 100644 --- a/src/service.zig +++ b/src/service.zig @@ -3176,6 +3176,68 @@ pub const DataService = struct { return transaction_log.parseTransactionLogFile(self.allocator, data) catch null; } + + /// Read the per-symbol `splits_current_through` opt-in from the + /// metadata.srf sibling of `portfolio_path` into a `symbol -> cutover` + /// map. A symbol absent from the map has NOT opted in - its lots keep + /// `split_factor` 1.0 (today's behavior). An empty map (no file, no + /// symbol carries the field) means split adjustment is fully off. + /// Caller owns the map AND each key: free every key, then `deinit`. + /// See the "Share model" block in models/portfolio.zig. + pub fn loadSplitsCutovers(self: *DataService, allocator: std.mem.Allocator, portfolio_path: []const u8) std.StringHashMap(Date) { + var map = std.StringHashMap(Date).init(allocator); + + const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0; + const path = std.fmt.allocPrint(self.allocator, "{s}metadata.srf", .{portfolio_path[0..dir_end]}) catch return map; + defer self.allocator.free(path); + + const data = std.Io.Dir.cwd().readFileAlloc(self.io, path, self.allocator, .limited(1024 * 1024)) catch return map; + defer self.allocator.free(data); + + var cm = classification.parseClassificationFile(self.allocator, data) catch return map; + defer cm.deinit(); + + for (cm.entries) |e| { + const cutover = e.splits_current_through orelse continue; + // A symbol can repeat across blended-fund rows; keep the first + // cutover and dupe its key exactly once (no leak on repeats). + const gop = map.getOrPut(e.symbol) catch continue; + if (!gop.found_existing) { + gop.key_ptr.* = allocator.dupe(u8, e.symbol) catch { + _ = map.remove(e.symbol); + continue; + }; + gop.value_ptr.* = cutover; + } + } + return map; + } + + /// Fetch split history for each symbol into a corpus keyed by + /// symbol, for lot effective-share enrichment (`enrichSplits`). + /// Symbols whose fetch fails or carry no splits are simply absent + /// (their lots keep `split_factor` 1.0 - a safe no-adjustment + /// fallback). Caller owns the returned map AND each value slice: + /// free every value, then `deinit` the map. + pub fn loadAllSplits( + self: *DataService, + allocator: std.mem.Allocator, + syms: []const []const u8, + opts: FetchOptions, + ) std.StringHashMap([]const Split) { + var corpus = std.StringHashMap([]const Split).init(allocator); + for (syms) |sym| { + const fr = self.getSplits(sym, opts) catch continue; + defer fr.deinit(); + if (fr.data.len == 0) continue; + const owned = allocator.dupe(Split, fr.data) catch continue; + corpus.put(sym, owned) catch { + allocator.free(owned); + continue; + }; + } + return corpus; + } }; // ── Tests ───────────────────────────────────────────────────────── diff --git a/src/tui/history_tab.zig b/src/tui/history_tab.zig index 573d8cf..ee89fd6 100644 --- a/src/tui/history_tab.zig +++ b/src/tui/history_tab.zig @@ -726,6 +726,14 @@ fn buildCompareFromSelections(state: *State, app: *App, sel_a: usize, sel_b: usi // "now is live" only when the NEWER endpoint is the live row. const now_is_live = newer.is_live; + // Opt-in: bring a snapshot "then" side into the current split basis + // (mirrors the CLI). Skipped when "then" is the live portfolio, + // which is already effective-share. No-op unless the split opt-in + // is set in metadata.srf. + if (!older.is_live) { + compare_core.adjustThenForSplits(app.svc, app.allocator, then_map_ptr, portfolio_path, older.date, newer.date); + } + const cv = try compare_view.buildCompareView( app.allocator, older.date, diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 4759eb6..9ca67e1 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -1874,18 +1874,18 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va if (row.pos_idx < s.allocations.len) { const price = s.allocations[row.pos_idx].current_price; const use_price = lot.close_price orelse price; - const gl = lot.shares * (use_price - lot.open_price); + const gl = lot.effectiveShares() * (use_price - lot.effectiveOpenPrice()); lot_positive = gl >= 0; lot_gl_str = try std.fmt.allocPrint(arena, "{s}{f}", .{ if (gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), Money.from(if (gl >= 0) gl else -gl), }); - lot_mv_str = try std.fmt.allocPrint(arena, "{f}", .{Money.from(lot.shares * use_price)}); + lot_mv_str = try std.fmt.allocPrint(arena, "{f}", .{Money.from(lot.effectiveShares() * use_price)}); } } var price_str2: [24]u8 = undefined; - const lot_price_str = std.fmt.bufPrint(&price_str2, "{f}", .{Money.from(lot.open_price)}) catch "$?"; + const lot_price_str = std.fmt.bufPrint(&price_str2, "{f}", .{Money.from(lot.effectiveOpenPrice())}) catch "$?"; const status_str: []const u8 = if (lot.isOpen(app.today)) "open" else "closed"; const indicator = fmt.capitalGainsIndicator(app.today, lot.open_date); const lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator }); @@ -1897,7 +1897,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va break :blk fmt.padRightToCols(&lot_sym_buf, sym_src, cw.symbol_w); }; var lot_shr_raw: [48]u8 = undefined; - const lot_shr_raw_s = std.fmt.bufPrint(&lot_shr_raw, "{d:.1}", .{lot.shares}) catch "?"; + const lot_shr_raw_s = std.fmt.bufPrint(&lot_shr_raw, "{d:.1}", .{lot.effectiveShares()}) catch "?"; var lot_shr_buf: [64]u8 = undefined; const lot_shr_cell = fmt.padLeftToCols(&lot_shr_buf, lot_shr_raw_s, cw.shares_w); var lot_cost_pad: [64]u8 = undefined; diff --git a/src/views/compare.zig b/src/views/compare.zig index f4e3e1e..0bcc644 100644 --- a/src/views/compare.zig +++ b/src/views/compare.zig @@ -278,6 +278,29 @@ pub fn buildTotalsRow(then: f64, now: f64) TotalsRow { }; } +/// Bring a "then" snapshot's holdings into the "now" split basis, in +/// place. For each symbol present in `factors`, multiplies shares by +/// and divides price by the cumulative split ratio, so a split between +/// the two epochs doesn't read as a phantom share-count jump or a +/// price crash in `buildSymbolChange`. +/// +/// Pure: the caller resolves `factors` (symbol -> cumulative split +/// ratio in the window `(max(cutover, then_date), now_date]`) from the +/// split corpus. This bridges the epoch gap between the "then" snapshot +/// and "now" so a held-throughout position nets to zero share change +/// across the split. A factor of 1.0 (or a symbol absent from `factors`) +/// is a no-op, so this is inert unless the split opt-in is on and a +/// split actually applies. +pub fn forwardAdjustThen(then_map: *HoldingMap, factors: *const std.StringHashMap(f64)) void { + var it = then_map.iterator(); + while (it.next()) |e| { + const f = factors.get(e.key_ptr.*) orelse continue; + if (f == 1.0 or f == 0.0) continue; + e.value_ptr.shares *= f; + e.value_ptr.price /= f; + } +} + /// Primary view builder. Intersects `then_map` with `now_map`, computes /// per-symbol changes for held-throughout symbols, counts added/removed, /// and sorts descending by `pct_change`. @@ -521,6 +544,26 @@ test "buildTotalsRow: zero then doesn't NaN" { try testing.expectEqual(StyleIntent.positive, t.style); // delta > 0 so positive } +test "forwardAdjustThen: split factor scales shares up, price down; absent symbols untouched" { + var then_map: HoldingMap = .init(testing.allocator); + defer then_map.deinit(); + try then_map.put("NVDA", .{ .shares = 100, .price = 600.0 }); + try then_map.put("AAPL", .{ .shares = 50, .price = 180.0 }); + + var factors = std.StringHashMap(f64).init(testing.allocator); + defer factors.deinit(); + try factors.put("NVDA", 10.0); // 10:1 split between then and now + + forwardAdjustThen(&then_map, &factors); + + // NVDA brought into the post-split basis: 100 -> 1000 shares, $600 -> $60. + try testing.expectApproxEqAbs(@as(f64, 1000), then_map.get("NVDA").?.shares, 0.001); + try testing.expectApproxEqAbs(@as(f64, 60.0), then_map.get("NVDA").?.price, 0.001); + // AAPL absent from factors -> untouched. + try testing.expectApproxEqAbs(@as(f64, 50), then_map.get("AAPL").?.shares, 0.001); + try testing.expectApproxEqAbs(@as(f64, 180.0), then_map.get("AAPL").?.price, 0.001); +} + test "buildCompareView: intersection with added and removed" { var then_map: HoldingMap = .init(testing.allocator); defer then_map.deinit(); diff --git a/src/views/portfolio_sections.zig b/src/views/portfolio_sections.zig index 010937b..4aa626b 100644 --- a/src/views/portfolio_sections.zig +++ b/src/views/portfolio_sections.zig @@ -115,11 +115,11 @@ pub fn computeWidths( for (lots) |lot| { if (lot.security_type != .stock) continue; - w.shares_w = @max(w.shares_w, sharesCols(lot.shares)); - w.price_w = @max(w.price_w, moneyCols(lot.open_price)); + w.shares_w = @max(w.shares_w, sharesCols(lot.effectiveShares())); + w.price_w = @max(w.price_w, moneyCols(lot.effectiveOpenPrice())); const use_price = lot.close_price orelse currentPriceFor(allocations, lot.priceSymbol()); - w.value_w = @max(w.value_w, moneyCols(lot.shares * use_price)); - w.gainloss_w = @max(w.gainloss_w, gainLossCols(lot.shares * (use_price - lot.open_price))); + w.value_w = @max(w.value_w, moneyCols(lot.effectiveShares() * use_price)); + w.gainloss_w = @max(w.gainloss_w, gainLossCols(lot.effectiveShares() * (use_price - lot.effectiveOpenPrice()))); } w.value_w = @max(w.value_w, moneyCols(total_value)); @@ -729,3 +729,17 @@ test "writeHeader / writeSeparator render the same column count as the widths" { fmt.displayCols(sep[0 .. sep.len - 1]), ); } + +test "computeWidths: lot columns size to effective (split-adjusted) shares" { + // A pre-split lot enriched with a 10:1 factor renders 1000 shares, + // so the shares column must be sized for the effective count, not + // the raw 100. + var lots = [_]Lot{ + .{ .symbol = "NVDA", .shares = 100, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 40, .split_factor = 10.0 }, + }; + const no_allocs: []const Allocation = &.{}; + const no_watch: []const []const u8 = &.{}; + const w = computeWidths(no_allocs, &lots, 0, 0, no_watch, null); + try testing.expectEqual(sharesCols(1000.0), w.shares_w); + try testing.expect(w.shares_w >= sharesCols(100.0)); +}