Compare commits

...

5 commits

Author SHA1 Message Date
9aaa374f12
address uaf in portfolio command watchlist
All checks were successful
Generic zig build / build (push) Successful in 5m45s
Generic zig build / publish-macos (push) Successful in 12s
Generic zig build / deploy (push) Successful in 2m33s
2026-07-06 13:12:10 -07:00
473a94976d
reverse sorting on quote tab to match history tab 2026-07-06 12:47:57 -07:00
550fa4fd88
add end to end hygiene test 2026-07-06 12:46:57 -07:00
48b422cba4
stock split handling 2026-07-06 12:29:50 -07:00
b28a59163d
remove remaining todo 2026-07-02 08:51:35 -07:00
21 changed files with 1176 additions and 83 deletions

28
TODO.md
View file

@ -5,34 +5,6 @@ ordered roughly by priority within each section. Priority labels
(`HIGH` / `MEDIUM` / `LOW`) mark items that deserve explicit
ranking; unlabeled items are "someday, if the mood strikes."
## Investigate: detailed 401(k) contributions data source
Found a more detailed contributions screen on at least one
employer-sponsored 401(k) provider portal - distinct from the
standard positions/holdings view we already pull from. Worth
investigating whether this unlocks better attribution than what
we get from the positions CSV alone, and whether other 401(k)
providers expose similar screens.
Open questions to answer when picking this up:
- Which screen specifically (path / URL within the portal)? Is there
an export option, or is it view-only / scrape territory?
- What fields does it expose (employee pre-tax, employer match,
after-tax / mega-backdoor, by-pay-period dates, per-fund
allocations)?
- Refresh cadence - per-paycheck, daily, on-demand?
- Can it be auto-discovered like the existing audit CSVs, or
is it manual-entry territory?
If the export is structured and recurring, this could feed a
401(k)-specific contributions classifier that bypasses the lot-diff
heuristic for that account, similar to how `cash_is_contribution`
opts ESPP/HSA accounts into cash-based attribution.
Related: ESPP-style accrual blind spot in the "Audit: manual-check
accounts mechanism" section above.
## Infra / performance
- **HTTP connection pooling.** Parallel server sync in `loadAllPrices`

View file

@ -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

View file

@ -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;

16
src/cache/store.zig vendored
View file

@ -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

View file

@ -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", .{});
}
@ -1612,3 +1633,67 @@ test "findPriceDateMismatches: duplicate HEAD identity collapses to one entry" {
try std.testing.expectApproxEqAbs(@as(f64, 150), mismatches.items[0].old_price, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 160), mismatches.items[0].new_price, 0.01);
}
test "runHygieneCheck: Section 6 flags an un-opted-in symbol's split, not an opted-in one" {
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// NVDA: held across its 2024 split, NOT opted in -> should flag.
// AMZN: held across its 2022 split, opted in via metadata -> should not.
try tmp.dir.writeFile(io, .{
.sub_path = "portfolio.srf",
.data =
\\#!srfv1
\\symbol::NVDA,shares:num:100,open_date::2020-01-01,open_price:num:40.00,account::Sample Brokerage
\\symbol::AMZN,shares:num:30,open_date::2019-01-01,open_price:num:90.00,account::Sample Brokerage
\\
,
});
// accounts.srf must exist and parse (an empty map is fine).
try tmp.dir.writeFile(io, .{ .sub_path = "accounts.srf", .data = "#!srfv1\n" });
// Per-symbol opt-in: AMZN yes, NVDA absent (not opted in).
try tmp.dir.writeFile(io, .{
.sub_path = "metadata.srf",
.data = "#!srfv1\nsymbol::AMZN,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 the split cache: NVDA 10:1 (2024-06-10), AMZN 20:1 (2022-06-06).
var store = zfin.cache.Store.init(io, allocator, dir);
var nvda = [_]zfin.Split{.{ .date = zfin.Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }};
store.write(zfin.Split, "NVDA", nvda[0..], .{ .seconds = zfin.cache.Ttl.splits });
var amzn = [_]zfin.Split{.{ .date = zfin.Date.fromYmd(2022, 6, 6), .numerator = 20, .denominator = 1 }};
store.write(zfin.Split, "AMZN", amzn[0..], .{ .seconds = zfin.cache.Ttl.splits });
// No API keys / no server -> hermetic (fetches can't reach a network).
var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
defer svc.deinit();
var env = try std.testing.environ.createMap(allocator);
defer env.deinit();
const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
defer allocator.free(pf_path);
var aw: std.Io.Writer.Allocating = .init(allocator);
defer aw.deinit();
try runHygieneCheck(io, allocator, &env, &svc, pf_path, 3, false, zfin.Date.fromYmd(2026, 1, 1), 1_767_225_600, false, .never, &aw.writer);
const output = aw.written();
try std.testing.expect(std.mem.indexOf(u8, output, "Portfolio hygiene") != null);
// Assert against Section 6 specifically (it's the last section, so
// slice from its header to end) - robust even if NVDA/AMZN surface
// in an earlier section. The un-opted-in NVDA is listed; the
// opted-in AMZN is not.
const sec6_start = std.mem.indexOf(u8, output, "Unhandled stock splits") orelse return error.Section6Missing;
const sec6 = output[sec6_start..];
try std.testing.expect(std.mem.indexOf(u8, sec6, "NVDA") != null);
try std.testing.expect(std.mem.indexOf(u8, sec6, "AMZN") == null);
}

View file

@ -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)

View file

@ -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

View file

@ -181,8 +181,23 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
}
}.f);
// Separate watchlist file (backward compat). Loaded here at the run
// scope - NOT inside the collection block below - so its symbol
// strings outlive the `display` call at the end of `run`.
// `watch_list` (and `watch_prices`' keys) borrow these slices;
// freeing them before display - the previous behavior, where the
// load + `defer freeWatchlist` lived inside the collection block -
// rendered the file's watch symbols as freed-memory garbage on the
// CLI. The TUI is unaffected: it keeps watchlist symbols in its
// long-lived arena.
const wl_syms: ?[][]const u8 = if (watchlist_path) |wl_path|
cli.loadWatchlist(io, allocator, wl_path)
else
null;
defer cli.freeWatchlist(allocator, wl_syms);
// Collect watch symbols and their prices for display.
// Includes watch lots from portfolio + symbols from separate watchlist file.
// Includes watch lots from portfolio + symbols from the watchlist file.
var watch_list: std.ArrayList([]const u8) = .empty;
defer watch_list.deinit(allocator);
var watch_prices = std.StringHashMap(f64).init(allocator);
@ -208,18 +223,14 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
}
}
// Separate watchlist file (backward compat)
if (watchlist_path) |wl_path| {
const wl_syms = cli.loadWatchlist(io, allocator, wl_path);
defer cli.freeWatchlist(allocator, wl_syms);
if (wl_syms) |syms_list| {
for (syms_list) |sym| {
if (watch_seen.contains(sym)) continue;
try watch_seen.put(sym, {});
try watch_list.append(allocator, sym);
if (svc.getCachedLastClose(sym)) |close| {
try watch_prices.put(sym, close);
}
// Symbols from the separate watchlist file (loaded above).
if (wl_syms) |syms_list| {
for (syms_list) |sym| {
if (watch_seen.contains(sym)) continue;
try watch_seen.put(sym, {});
try watch_list.append(allocator, sym);
if (svc.getCachedLastClose(sym)) |close| {
try watch_prices.put(sym, close);
}
}
}
@ -640,7 +651,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 +662,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 +1304,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);
}

View file

@ -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);

View file

@ -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);
}

View file

@ -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{

View file

@ -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" },

View file

@ -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);
}

View file

@ -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,
);
}

View file

@ -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);
}

View file

@ -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

View file

@ -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,

View file

@ -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;

View file

@ -875,8 +875,10 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
try lines.append(arena, .{ .text = " Recent History:", .style = th.headerStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}", .{ "Date", "Open", "High", "Low", "Close", "Volume" }), .style = th.mutedStyle() });
const start_idx = if (c.len > 20) c.len - 20 else 0;
for (c[start_idx..]) |candle| {
const end_idx = if (c.len > 20) c.len - 20 else 0;
var curr = c.len - 1;
while (curr >= end_idx) : (curr -= 1) {
const candle = c[curr];
var row_buf: [128]u8 = undefined;
const day_change = if (candle.close >= candle.open) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = try arena.dupe(u8, fmt.fmtCandleRow(&row_buf, candle)), .style = day_change });

View file

@ -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();

View file

@ -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));
}