stock split handling
This commit is contained in:
parent
b28a59163d
commit
48b422cba4
19 changed files with 1084 additions and 40 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
16
src/cache/store.zig
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", .{});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) ──
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
244
src/compare.zig
244
src/compare.zig
|
|
@ -37,6 +37,7 @@ const zfin = @import("root.zig");
|
|||
const history = @import("history.zig");
|
||||
const snapshot_model = @import("models/snapshot.zig");
|
||||
const view = @import("views/compare.zig");
|
||||
const cumulativeSplitRatio = @import("models/split.zig").cumulativeSplitRatio;
|
||||
|
||||
pub const Date = zfin.Date;
|
||||
|
||||
|
|
@ -88,9 +89,22 @@ pub fn loadSnapshotSide(
|
|||
// ── Stock aggregation (compare-view shape) ───────────────────
|
||||
|
||||
/// Walk a snapshot's lot rows, filter to `security_type == "Stock"`,
|
||||
/// and group by symbol into `out_map`. Shares are summed; price is
|
||||
/// taken from the first lot seen (all stock lots of a symbol share
|
||||
/// the same `price` field in a given snapshot).
|
||||
/// and group by symbol into `out_map`.
|
||||
///
|
||||
/// Shares are the EFFECTIVE (split-adjusted) count, recovered as
|
||||
/// `value / price` rather than read from the raw `.shares` field. A
|
||||
/// snapshot stores `.shares` as the immutable as-transacted count but
|
||||
/// `.value` as the split-adjusted market value (see the "Share model"
|
||||
/// block in `models/portfolio.zig` and `snapshot.buildSnapshot`), so
|
||||
/// once a split has been applied at capture, `raw_shares * price` no
|
||||
/// longer equals the stored value. Deriving `value / price` keeps this
|
||||
/// side self-consistent (shares x price == value) and in the snapshot's
|
||||
/// own date-epoch effective basis, which `adjustThenForSplits` then
|
||||
/// brings forward. For a plain pre-feature snapshot `value == shares x
|
||||
/// price`, so this degrades exactly to the raw share count.
|
||||
///
|
||||
/// Price is taken from the first lot seen (all stock lots of a symbol
|
||||
/// share the same `price` field in a given snapshot).
|
||||
///
|
||||
/// Lives here rather than in `history.zig` because it emits a
|
||||
/// `view.HoldingMap` - a compare-view-shaped type. The projection-
|
||||
|
|
@ -106,11 +120,15 @@ pub fn aggregateSnapshotStocks(
|
|||
for (snap.lots) |lot| {
|
||||
if (!std.mem.eql(u8, lot.security_type, "Stock")) continue;
|
||||
const price = lot.price orelse continue;
|
||||
// Effective shares = value / price (split-adjusted). Guard a
|
||||
// zero price (bad data) by falling back to the raw count so we
|
||||
// never divide by zero.
|
||||
const eff_shares = if (price != 0) lot.value / price else lot.shares;
|
||||
if (out_map.getPtr(lot.symbol)) |h| {
|
||||
h.shares += lot.shares;
|
||||
h.shares += eff_shares;
|
||||
// price is already set from first-seen; leave it.
|
||||
} else {
|
||||
try out_map.put(lot.symbol, .{ .shares = lot.shares, .price = price });
|
||||
try out_map.put(lot.symbol, .{ .shares = eff_shares, .price = price });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -137,13 +155,75 @@ pub fn aggregateLiveStocks(
|
|||
const raw_price = prices.get(sym) orelse continue;
|
||||
const eff_price = lot.effectivePrice(raw_price, false);
|
||||
if (out_map.getPtr(sym)) |h| {
|
||||
h.shares += lot.shares;
|
||||
h.shares += lot.effectiveShares();
|
||||
} else {
|
||||
try out_map.put(sym, .{ .shares = lot.shares, .price = eff_price });
|
||||
try out_map.put(sym, .{ .shares = lot.effectiveShares(), .price = eff_price });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opt-in: bring a "then" snapshot map into the "now" split basis so a
|
||||
/// split between the two compared dates doesn't read as a phantom
|
||||
/// position change (or a phantom price crash) against the "now" side.
|
||||
///
|
||||
/// Both snapshot sides already carry EFFECTIVE shares in their own
|
||||
/// date-epoch (see `aggregateSnapshotStocks`), so all this does is
|
||||
/// bridge the epoch gap between `then_date` and `now_date`: it multiplies
|
||||
/// shares by, and divides price by, the cumulative split ratio in the
|
||||
/// window `(max(cutover, then_date), now_date]`. Starting at `then_date`
|
||||
/// (not the cutover) is what makes a snapshot captured AFTER a split a
|
||||
/// no-op instead of double-applying that split; the cutover only clamps
|
||||
/// the window so we never reach behind the user's reconcile baseline.
|
||||
/// A held-throughout position then nets to zero share change.
|
||||
///
|
||||
/// Per-symbol: only symbols that carry a `splits_current_through`
|
||||
/// cutover are adjusted; the rest are left untouched. No-op when nothing
|
||||
/// has opted in or no split applies. Shared by the CLI `compare` command
|
||||
/// and the TUI history tab. Only meaningful for a SNAPSHOT "then" side;
|
||||
/// when "then" is the live portfolio it is already in the current basis,
|
||||
/// so callers skip it.
|
||||
///
|
||||
/// Known limitation: a "then" snapshot from BEFORE the cutover, or a
|
||||
/// stale pre-feature snapshot that under-recorded value, can't be
|
||||
/// perfectly reconciled from recorded data - those fall back to
|
||||
/// cutover-basis / recorded value.
|
||||
pub fn adjustThenForSplits(
|
||||
svc: *zfin.DataService,
|
||||
allocator: std.mem.Allocator,
|
||||
then_map: *view.HoldingMap,
|
||||
portfolio_path: []const u8,
|
||||
then_date: zfin.Date,
|
||||
now_date: zfin.Date,
|
||||
) void {
|
||||
var cutovers = svc.loadSplitsCutovers(allocator, portfolio_path);
|
||||
defer {
|
||||
var kit = cutovers.keyIterator();
|
||||
while (kit.next()) |k| allocator.free(k.*);
|
||||
cutovers.deinit();
|
||||
}
|
||||
if (cutovers.count() == 0) return;
|
||||
|
||||
var factors = std.StringHashMap(f64).init(allocator);
|
||||
defer factors.deinit();
|
||||
var it = then_map.iterator();
|
||||
while (it.next()) |e| {
|
||||
const sym = e.key_ptr.*;
|
||||
const cutover = cutovers.get(sym) orelse continue; // per-symbol opt-in
|
||||
const fr = svc.getSplits(sym, .{}) catch continue;
|
||||
defer fr.deinit();
|
||||
// Bring the "then" holdings forward from the snapshot's own date
|
||||
// to `now_date`. Start the window at max(cutover, then_date): the
|
||||
// snapshot already reflects every split up to then_date, and raw
|
||||
// shares are current only through the cutover, so a split before
|
||||
// either boundary must not be re-applied. Mirrors enrichSplits'
|
||||
// max(cutover, open_date) clamp so the two never diverge.
|
||||
const after = if (cutover.lessThan(then_date)) then_date else cutover;
|
||||
const f = cumulativeSplitRatio(fr.data, after, now_date);
|
||||
if (f != 1.0) factors.put(sym, f) catch continue;
|
||||
}
|
||||
view.forwardAdjustThen(then_map, &factors);
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
|
|
@ -226,6 +306,52 @@ test "aggregateSnapshotStocks: sums shares, filters non-stock, takes first price
|
|||
try testing.expectEqual(@as(f64, 25), (map.get("MSFT") orelse unreachable).shares);
|
||||
}
|
||||
|
||||
test "aggregateSnapshotStocks: derives effective shares from value/price (split-captured snapshot)" {
|
||||
// A snapshot captured with split adjustment on stores RAW shares
|
||||
// (100) but the split-adjusted market VALUE (1000 effective shares
|
||||
// x $120 = $120,000). The compare map must reflect the effective
|
||||
// 1000 shares (value / price), not the raw 100 - otherwise a split
|
||||
// reads as a phantom 10x position change against the effective-share
|
||||
// "now" side. For a plain (pre-feature) snapshot value == shares x
|
||||
// price, so this degrades to raw shares and is backward-compatible.
|
||||
var map: view.HoldingMap = .init(testing.allocator);
|
||||
defer map.deinit();
|
||||
|
||||
const lots = [_]snapshot_model.LotRow{
|
||||
.{
|
||||
.symbol = "NVDA",
|
||||
.lot_symbol = "NVDA",
|
||||
.account = "Sample Brokerage",
|
||||
.security_type = "Stock",
|
||||
.shares = 100, // RAW, as transacted (immutable historical fact)
|
||||
.open_price = 40,
|
||||
.cost_basis = 4000,
|
||||
.value = 120000, // effective: 1000 shares x $120
|
||||
.price = 120.0,
|
||||
},
|
||||
};
|
||||
const snap = snapshot_model.Snapshot{
|
||||
.meta = .{
|
||||
.snapshot_version = 1,
|
||||
.as_of_date = Date.fromYmd(2024, 9, 1),
|
||||
.captured_at = 0,
|
||||
.zfin_version = "test",
|
||||
.stale_count = 0,
|
||||
},
|
||||
.totals = &.{},
|
||||
.tax_types = &.{},
|
||||
.accounts = &.{},
|
||||
.lots = @constCast(&lots),
|
||||
};
|
||||
|
||||
try aggregateSnapshotStocks(&snap, &map);
|
||||
|
||||
const h = map.get("NVDA") orelse return error.TestUnexpectedResult;
|
||||
// Effective shares = value / price = 120000 / 120 = 1000, not raw 100.
|
||||
try testing.expectApproxEqAbs(@as(f64, 1000), h.shares, 0.001);
|
||||
try testing.expectApproxEqAbs(@as(f64, 120.0), h.price, 0.001);
|
||||
}
|
||||
|
||||
test "loadSnapshotSide: happy path builds a SnapshotSide with aggregated holdings" {
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
|
|
@ -417,3 +543,107 @@ test "aggregateLiveStocks: empty portfolio yields empty map" {
|
|||
try aggregateLiveStocks(today, &portfolio, &prices, &map);
|
||||
try testing.expectEqual(@as(u32, 0), map.count());
|
||||
}
|
||||
|
||||
test "adjustThenForSplits: forward-adjusts then map from seeded cache + cutover" {
|
||||
const allocator = testing.allocator;
|
||||
const io = testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
// metadata.srf with a per-symbol cutover before the split -> applied.
|
||||
try tmp.dir.writeFile(io, .{
|
||||
.sub_path = "metadata.srf",
|
||||
.data = "#!srfv1\nsymbol::NVDA,splits_current_through::2024-01-01\n",
|
||||
});
|
||||
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir = path_buf[0..dir_len];
|
||||
|
||||
// Seed NVDA 10:1 (2024-06-10).
|
||||
var store = zfin.cache.Store.init(io, allocator, dir);
|
||||
var splits = [_]zfin.Split{.{ .date = zfin.Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }};
|
||||
store.write(zfin.Split, "NVDA", splits[0..], .{ .seconds = zfin.cache.Ttl.splits });
|
||||
|
||||
var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
|
||||
defer svc.deinit();
|
||||
|
||||
const anchor = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
|
||||
defer allocator.free(anchor);
|
||||
|
||||
var then_map: view.HoldingMap = .init(allocator);
|
||||
defer then_map.deinit();
|
||||
try then_map.put("NVDA", .{ .shares = 100, .price = 600 });
|
||||
|
||||
// then snapshot taken 2024-03-01 (before the 2024-06-10 split);
|
||||
// now_date after the split: factor 10 -> 100 -> 1000 shares, $600 -> $60.
|
||||
adjustThenForSplits(&svc, allocator, &then_map, anchor, zfin.Date.fromYmd(2024, 3, 1), zfin.Date.fromYmd(2026, 1, 1));
|
||||
try testing.expectApproxEqAbs(@as(f64, 1000), then_map.get("NVDA").?.shares, 0.01);
|
||||
try testing.expectApproxEqAbs(@as(f64, 60.0), then_map.get("NVDA").?.price, 0.01);
|
||||
}
|
||||
|
||||
test "adjustThenForSplits: no cutover leaves then map untouched" {
|
||||
const allocator = testing.allocator;
|
||||
const io = testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
// No metadata.srf at all -> opt-in off.
|
||||
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir = path_buf[0..dir_len];
|
||||
|
||||
var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
|
||||
defer svc.deinit();
|
||||
|
||||
const anchor = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
|
||||
defer allocator.free(anchor);
|
||||
|
||||
var then_map: view.HoldingMap = .init(allocator);
|
||||
defer then_map.deinit();
|
||||
try then_map.put("NVDA", .{ .shares = 100, .price = 600 });
|
||||
|
||||
adjustThenForSplits(&svc, allocator, &then_map, anchor, zfin.Date.fromYmd(2024, 3, 1), zfin.Date.fromYmd(2026, 1, 1));
|
||||
try testing.expectApproxEqAbs(@as(f64, 100), then_map.get("NVDA").?.shares, 0.01);
|
||||
try testing.expectApproxEqAbs(@as(f64, 600.0), then_map.get("NVDA").?.price, 0.01);
|
||||
}
|
||||
|
||||
test "adjustThenForSplits: then snapshot captured after the split is not double-applied" {
|
||||
const allocator = testing.allocator;
|
||||
const io = testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
// Same opt-in + seeded 10:1 split (2024-06-10) as the forward case.
|
||||
try tmp.dir.writeFile(io, .{
|
||||
.sub_path = "metadata.srf",
|
||||
.data = "#!srfv1\nsymbol::NVDA,splits_current_through::2024-01-01\n",
|
||||
});
|
||||
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir = path_buf[0..dir_len];
|
||||
|
||||
var store = zfin.cache.Store.init(io, allocator, dir);
|
||||
var splits = [_]zfin.Split{.{ .date = zfin.Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }};
|
||||
store.write(zfin.Split, "NVDA", splits[0..], .{ .seconds = zfin.cache.Ttl.splits });
|
||||
|
||||
var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
|
||||
defer svc.deinit();
|
||||
|
||||
const anchor = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
|
||||
defer allocator.free(anchor);
|
||||
|
||||
// A "then" snapshot captured 2024-08-01 - AFTER the split - already
|
||||
// holds effective shares (1000) at the post-split price ($120). The
|
||||
// split predates this snapshot, so bringing it forward to now must
|
||||
// be a no-op: the window starts at the snapshot's own date, not the
|
||||
// cutover. Applying the split again would 10x it into a phantom.
|
||||
var then_map: view.HoldingMap = .init(allocator);
|
||||
defer then_map.deinit();
|
||||
try then_map.put("NVDA", .{ .shares = 1000, .price = 120 });
|
||||
|
||||
adjustThenForSplits(&svc, allocator, &then_map, anchor, zfin.Date.fromYmd(2024, 8, 1), zfin.Date.fromYmd(2026, 1, 1));
|
||||
try testing.expectApproxEqAbs(@as(f64, 1000), then_map.get("NVDA").?.shares, 0.01);
|
||||
try testing.expectApproxEqAbs(@as(f64, 120.0), then_map.get("NVDA").?.price, 0.01);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue