stock split handling

This commit is contained in:
Emil Lerch 2026-07-06 12:29:50 -07:00
parent b28a59163d
commit 48b422cba4
Signed by: lobo
GPG key ID: A7B62D657EF764F8
19 changed files with 1084 additions and 40 deletions

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", .{});
}

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

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

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

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