//! Portfolio file loading + per-portfolio data pipeline. //! //! Single home for "read N portfolio_*.srf files and merge them into //! one Portfolio for both surfaces (CLI commands, TUI App)." Both //! surfaces import this module directly; neither depends on the //! other for portfolio loading. Pre-extraction, the same logic //! lived in `commands/common.zig` and the TUI either called into //! that file (which had a "TUI calls into commands/" code smell) //! or - worse - rolled its own parallel single-file path that //! drifted from the CLI's multi-file logic. //! //! The split is meaningful in only one direction: this module knows //! about pattern resolution (via `commands/framework.resolvePatterns`) //! and the `cache` deserializer. It does NOT know about RunCtx, //! Globals, or any CLI-shape concerns. The CLI-specific //! `loadPortfolio(ctx, as_of)` convenience wrapper that bridges //! a `RunCtx` to `loadPortfolioFromConfig` lives in //! `commands/common.zig` where it belongs. //! //! ## Surface //! //! - `LoadedPortfolio` - merged Portfolio + computed positions/syms //! + the resolved path slice the lots came from. Carries an //! `anchor()` accessor for sibling-file derivation //! (`accounts.srf`, `metadata.srf`, history dir). //! //! - `loadPortfolioFromConfig(io, alloc, config, patterns, as_of)` //! - the workhorse. Resolves `-p` patterns through //! `framework.resolvePatterns`, reads + deserializes + merges, //! returns a fully-populated `LoadedPortfolio`. Used by the //! CLI (via `commands.common.loadPortfolio` wrapping it with a //! `RunCtx`) and directly by the TUI. //! //! - `loadPortfolioFromPaths(io, alloc, paths, as_of)` - caller //! has already resolved patterns; load the given files. Used by //! the TUI's reload-button path (re-uses the original resolved //! path slice without re-globbing). //! //! - `loadPortfolioFromPathsAtRev(io, alloc, paths, rev, as_of)` - //! git-historical variant: read each file at `rev` via //! `git.show` instead of from the working tree. Files that don't //! exist at the rev are silently skipped (the union just doesn't //! include those lots). Used by `zfin snapshot --as-of` to //! capture point-in-time portfolio state across a multi-file //! glob from git history. //! //! All three loaders share a single private `loadFromBytes` core //! that does the deserialize + union-merge + position-compute + //! symbol-extract pass. Tests target `loadFromBytes` directly with //! synthetic byte literals to avoid filesystem I/O. //! //! - `PortfolioData` + `buildPortfolioData(...)` - second-stage //! pipeline: turn a `LoadedPortfolio` (or its parts) plus a //! `prices` map into a `PortfolioSummary` with allocations, //! candle map, and historical snapshots. const std = @import("std"); const zfin = @import("root.zig"); const framework = @import("commands/framework.zig"); const stderr = @import("stderr.zig"); const git = @import("git.zig"); const test_git = @import("testutil/git.zig"); const enrichSplits = @import("models/portfolio.zig").enrichSplits; // ── Portfolio loading ──────────────────────────────────────── /// Result of loading and parsing one or more portfolio files. The /// returned `portfolio` holds the union of all lots across every /// resolved file; `positions` and `syms` are computed against that /// merged view. Caller must call deinit(). pub const LoadedPortfolio = struct { /// Resolved paths the lots came from, sorted lexicographically /// (by `Config.resolveUserFiles`). `paths[0]` is the *anchor* /// path used for sibling-file derivation (`accounts.srf`, /// `metadata.srf`, `transaction_log.srf`, history dir). /// Display labels typically render `paths[0]` plus /// "(+N more)" when `paths.len > 1`. Owned. paths: []const []const u8, /// Optional `ResolvedPaths` handle for the same set of paths. /// When the loader resolved patterns through `RunCtx`, the /// `Config.ResolvedPaths` is captured here so `deinit()` can /// release the owned path strings. When the loader was given /// pre-resolved paths directly (test path, snapshot fallback), /// this is null and the `paths` slice is shallow-copied bytes /// the caller still owns. resolved_paths: ?zfin.Config.ResolvedPaths, /// Raw bytes of every file we read. One entry per portfolio /// file. Owned. file_datas: []const []const u8, portfolio: zfin.Portfolio, positions: []const zfin.Position, syms: []const []const u8, pub fn deinit(self: *LoadedPortfolio, allocator: std.mem.Allocator) void { allocator.free(self.syms); allocator.free(self.positions); self.portfolio.deinit(); for (self.file_datas) |d| allocator.free(d); allocator.free(self.file_datas); // Path-string ownership: `resolved_paths` (if present) owns // the underlying path strings. The `paths` slice is the // borrowed view; free only its outer storage. allocator.free(self.paths); if (self.resolved_paths) |rp| rp.deinit(); } /// Convenience: returns `paths[0]`, the first / anchor path. /// Sibling-file derivation (accounts.srf, metadata.srf, etc.) /// hangs off this directory. pub fn anchor(self: LoadedPortfolio) []const u8 { return self.paths[0]; } }; /// Resolve `patterns` against `config` (cwd -> ZFIN_HOME), then load /// the union of all matched portfolio files. The TUI uses this /// directly (no `RunCtx`); CLI commands go through /// `commands.common.loadPortfolio(ctx, ...)` which is a thin /// wrapper. /// /// `patterns` is the user-supplied `-p` slice; pass an empty slice /// (`&.{}`) for the default `portfolio*.srf` behavior. /// /// Returns `null` on any error path (a stderr message has already /// been printed). Caller must `deinit(allocator)` the returned /// struct. pub fn loadPortfolioFromConfig( io: std.Io, allocator: std.mem.Allocator, config: zfin.Config, patterns: []const []const u8, as_of: zfin.Date, ) ?LoadedPortfolio { var resolved = framework.resolvePatterns(io, allocator, config, patterns) catch |err| switch (err) { error.MixedPortfolioDirs => { stderr.print(io, "Error: portfolio files resolved to multiple directories.\n"); stderr.print(io, " Sibling files (accounts.srf, metadata.srf, transaction_log.srf) live\n"); stderr.print(io, " next to the portfolio, so all portfolio files must share a directory.\n"); return null; }, else => { stderr.print(io, "Error: failed to resolve portfolio path(s)\n"); return null; }, }; if (resolved.paths.len == 0) { resolved.deinit(); // The error message names the searched location explicitly // so the user can verify it against their expectations. // ZFIN_HOME is exclusive when set: we never look at cwd // in that case, so the message would be misleading if it // mentioned cwd as a possibility. if (config.zfin_home) |home| { var msg_buf: [512]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: no portfolio file found in ZFIN_HOME ({s}). Looked for portfolio*.srf.\n", .{home}) catch "Error: no portfolio file found in ZFIN_HOME\n"; stderr.print(io, msg); } else { stderr.print(io, "Error: no portfolio file found in cwd. Looked for portfolio*.srf. (ZFIN_HOME is unset.)\n"); } return null; } // Snapshot the path-string view as our own owned slice. Backing // strings stay live as long as `resolved.inner` does - we // hand `inner` off to LoadedPortfolio (it'll be freed by // `LoadedPortfolio.deinit`). The framework-level `resolved.paths` // view slice is allocator-owned but redundant after the dupe; // free it before discarding the wrapper. const paths_owned = allocator.dupe([]const u8, resolved.paths) catch { resolved.deinit(); return null; }; allocator.free(resolved.paths); return loadFromPaths(io, allocator, paths_owned, resolved.inner, as_of); } /// Lower-level loader: caller has already resolved the path list and /// owns the path strings. Used by the TUI's manual reload (re-loads /// the same files without re-globbing) and by tests. /// /// Strings inside `paths` are NOT freed by `LoadedPortfolio.deinit` /// - caller retains ownership of them. The slice `paths` itself IS /// freed by deinit (the LoadedPortfolio takes ownership of just the /// slice). pub fn loadPortfolioFromPaths(io: std.Io, allocator: std.mem.Allocator, paths: []const []const u8, as_of: zfin.Date) ?LoadedPortfolio { if (paths.len == 0) { stderr.print(io, "Error: No portfolio file found\n"); return null; } // Dupe the slice so deinit can free it without touching the // caller's storage. Path strings remain caller-owned and are // borrowed by the returned struct (resolved_paths = null // signals "no Config.ResolvedPaths to deinit"). const paths_owned = allocator.dupe([]const u8, paths) catch return null; return loadFromPaths(io, allocator, paths_owned, null, as_of); } /// Like `loadPortfolioFromPaths`, but reads each file from a git /// revision instead of the working tree. The result is the union /// of all files at that rev, deserialized + union-merged the same /// way the live loader does. /// /// Files that don't exist at the requested rev (e.g. /// `portfolio_other.srf` was added later than `rev`) are silently /// skipped - the union just doesn't include those lots. This is the /// right behavior for `zfin snapshot --as-of ` against a /// portfolio that's been split into multiple files over time. /// /// All `paths` must live within the same git repository as the /// first path. Repository discovery uses `git.findRepo` on the /// first path; the rest derive their rel-paths by trimming the /// repo-root prefix. /// /// Returns null on: /// - empty paths slice (no portfolio file to load); /// - first path not in any git repo; /// - any deserialize / merge / position-compute failure (same as /// the working-tree loader). /// /// `git.show` failures other than `PathMissingInRev` (e.g. /// `UnknownRevision`) propagate as null; the function prints a /// stderr message describing which file/rev failed. pub fn loadPortfolioFromPathsAtRev( io: std.Io, allocator: std.mem.Allocator, env: *const std.process.Environ.Map, paths: []const []const u8, rev: []const u8, as_of: zfin.Date, ) ?LoadedPortfolio { if (paths.len == 0) { stderr.print(io, "Error: No portfolio file found\n"); return null; } // Discover the repo from the first path. All other paths are // assumed to live in the same repo (typical for sibling // portfolio files in the same directory); we derive their // rel-paths below. const info = git.findRepo(io, allocator, env, paths[0]) catch { var msg_buf: [512]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: portfolio path not in a git repo: {s}\n", .{paths[0]}) catch "Error: portfolio path not in a git repo\n"; stderr.print(io, msg); return null; }; defer allocator.free(info.root); defer allocator.free(info.rel_path); // Read each file at `rev`. On `PathMissingInRev` we substitute // empty bytes; `loadFromBytes` knows to treat that as "file // absent at this rev, skip without parsing." Any other git // error is fatal. var datas: std.ArrayList([]const u8) = .empty; var datas_consumed = false; defer if (!datas_consumed) { for (datas.items) |d| allocator.free(d); datas.deinit(allocator); }; for (paths) |abs_path| { // Derive rel_path from this file's absolute path. The first // file's rel_path was computed by findRepo; for the rest we // strip the repo root prefix manually. realpath ensures // canonicalization (the user could pass a non-canonical // path). const real = std.Io.Dir.cwd().realPathFileAlloc(io, abs_path, allocator) catch { var msg_buf: [512]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: cannot resolve real path for: {s}\n", .{abs_path}) catch "Error: cannot resolve real path\n"; stderr.print(io, msg); return null; }; defer allocator.free(real); const rel = if (std.mem.startsWith(u8, real, info.root) and real.len > info.root.len) std.mem.trimStart(u8, real[info.root.len..], "/") else std.fs.path.basename(real); const data = git.show(io, allocator, env, info.root, rev, rel) catch |err| switch (err) { error.PathMissingInRev => empty_blk: { // File didn't exist at this rev. Substitute empty // bytes; loadFromBytes skips empty entries. break :empty_blk allocator.dupe(u8, "") catch return null; }, else => { var msg_buf: [512]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: git show {s}:{s}: {t}\n", .{ rev, rel, err }) catch "Error: git show failed\n"; stderr.print(io, msg); return null; }, }; datas.append(allocator, data) catch { allocator.free(data); return null; }; } const datas_owned = datas.toOwnedSlice(allocator) catch return null; datas_consumed = true; // Hand off ownership of the slice we just dupe'd from the // caller. `loadFromBytes` frees both `paths_owned` and // `datas_owned` on any failure. const paths_owned = allocator.dupe([]const u8, paths) catch { for (datas_owned) |d| allocator.free(d); allocator.free(datas_owned); return null; }; return loadFromBytes(io, allocator, paths_owned, null, datas_owned, as_of); } /// Internal: load+merge given a pre-resolved paths slice. The slice /// `paths_owned` is taken (will be freed by `LoadedPortfolio.deinit`). /// `resolved_paths_opt` is the optional `Config.ResolvedPaths` to /// hand off ownership of the path strings to the returned struct; /// when null, path strings are caller-owned. fn loadFromPaths( io: std.Io, allocator: std.mem.Allocator, paths_owned: []const []const u8, resolved_paths_opt: ?zfin.Config.ResolvedPaths, as_of: zfin.Date, ) ?LoadedPortfolio { // Read every file up front; bail on first error. var file_datas: std.ArrayList([]const u8) = .empty; for (paths_owned) |p| { const data = std.Io.Dir.cwd().readFileAlloc(io, p, allocator, .limited(10 * 1024 * 1024)) catch { var msg_buf: [512]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read portfolio file: {s}\n", .{p}) catch "Error: Cannot read portfolio file\n"; stderr.print(io, msg); for (file_datas.items) |d| allocator.free(d); file_datas.deinit(allocator); allocator.free(paths_owned); if (resolved_paths_opt) |rp| rp.deinit(); return null; }; file_datas.append(allocator, data) catch { allocator.free(data); for (file_datas.items) |d| allocator.free(d); file_datas.deinit(allocator); allocator.free(paths_owned); if (resolved_paths_opt) |rp| rp.deinit(); return null; }; } const file_datas_owned = file_datas.toOwnedSlice(allocator) catch { for (file_datas.items) |d| allocator.free(d); file_datas.deinit(allocator); allocator.free(paths_owned); if (resolved_paths_opt) |rp| rp.deinit(); return null; }; // Hand off to the bytes-only path. `loadFromBytes` takes // ownership of all four slices and frees them on any error. return loadFromBytes(io, allocator, paths_owned, resolved_paths_opt, file_datas_owned, as_of); } /// Bytes-only union-merge core. Takes ownership of `paths_owned`, /// `resolved_paths_opt` (if non-null), and `file_datas_owned` (each /// element must be `allocator`-owned). Frees them on any error. /// /// Tests use this directly with synthetic byte literals; production /// callers come through `loadFromPaths` (working-tree reads) or /// `loadPortfolioFromPathsAtRev` (git-historical reads). All three /// share this single deserialize-merge-positions-syms pass. /// /// `paths_owned` and `file_datas_owned` must have the same length; /// each `file_datas_owned[i]` is the bytes loaded from /// `paths_owned[i]`. Path strings are used only for diagnostics /// (which file failed to parse) and in the returned struct. fn loadFromBytes( io: std.Io, allocator: std.mem.Allocator, paths_owned: []const []const u8, resolved_paths_opt: ?zfin.Config.ResolvedPaths, file_datas_owned: []const []const u8, as_of: zfin.Date, ) ?LoadedPortfolio { std.debug.assert(paths_owned.len == file_datas_owned.len); // Cleanup is staged: lots live on `merged` until // `toOwnedSlice`, then move into `combined` (which has its // own deinit). The two cleanup paths are mutually exclusive, // tracked by `lots_owner`. On success, neither fires. var merged: std.ArrayList(zfin.Lot) = .empty; var combined: zfin.Portfolio = .{ .lots = &.{}, .allocator = allocator }; const LotsOwner = enum { merged_list, combined_struct, none }; var lots_owner: LotsOwner = .merged_list; var success = false; defer if (!success) { switch (lots_owner) { .merged_list => { for (merged.items) |lot| { allocator.free(lot.symbol); if (lot.note) |n| allocator.free(n); if (lot.label) |l| allocator.free(l); if (lot.account) |a| allocator.free(a); if (lot.ticker) |t| allocator.free(t); if (lot.underlying) |u| allocator.free(u); } merged.deinit(allocator); }, .combined_struct => combined.deinit(), .none => {}, } for (file_datas_owned) |d| allocator.free(d); allocator.free(file_datas_owned); allocator.free(paths_owned); if (resolved_paths_opt) |rp| rp.deinit(); }; // Deserialize each into an owned Portfolio, then merge their // lot slices into a single combined slice. We can't simply // concat the underlying slices because each Portfolio expects // to free its own lots in `deinit()`; instead, we steal each // Portfolio's lots[] (string fields are already dupe'd into // `allocator`) and free only the empty Portfolio struct. for (file_datas_owned, 0..) |data, idx| { // Empty bytes are a legitimate "file absent" signal - // `loadPortfolioFromPathsAtRev` uses this to indicate a // file that didn't exist at the requested git rev. Skip // without trying to parse. if (data.len == 0) continue; var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch { var msg_buf: [512]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse portfolio file: {s}\n", .{paths_owned[idx]}) catch "Error: Cannot parse portfolio file\n"; stderr.print(io, msg); return null; }; for (portfolio.lots) |lot| { merged.append(allocator, lot) catch { portfolio.deinit(); return null; }; } // Free the now-empty Portfolio's lots slice without freeing // the per-lot strings - they were transferred to `merged`. allocator.free(portfolio.lots); } const merged_slice = merged.toOwnedSlice(allocator) catch return null; combined = .{ .lots = merged_slice, .allocator = allocator, }; lots_owner = .combined_struct; const positions = combined.positions(as_of, allocator) catch { stderr.print(io, "Error: Cannot compute positions\n"); return null; }; const syms = combined.stockSymbols(allocator) catch { allocator.free(positions); stderr.print(io, "Error: Cannot get stock symbols\n"); return null; }; success = true; return .{ .paths = paths_owned, .resolved_paths = resolved_paths_opt, .file_datas = file_datas_owned, .portfolio = combined, .positions = positions, .syms = syms, }; } // ── Portfolio data pipeline ────────────────────────────────── /// Result of the shared portfolio data pipeline. Caller must call deinit(). pub const PortfolioData = struct { summary: zfin.valuation.PortfolioSummary, candle_map: std.StringHashMap([]const zfin.Candle), snapshots: ?[6]zfin.valuation.HistoricalSnapshot, pub fn deinit(self: *PortfolioData, allocator: std.mem.Allocator) void { self.summary.deinit(allocator); var it = self.candle_map.valueIterator(); while (it.next()) |v| allocator.free(v.*); self.candle_map.deinit(); } }; /// Build portfolio summary, candle map, and historical snapshots from /// pre-populated prices. Shared between CLI `portfolio` command, TUI /// `loadPortfolioData`, and TUI `reloadPortfolioFile`. /// /// Callers are responsible for populating `prices` (via network fetch, /// cache read, or pre-fetched map) before calling this. /// /// Returns error.NoAllocations if the summary produces no positions /// (e.g. no cached prices available). pub fn buildPortfolioData( allocator: std.mem.Allocator, portfolio: zfin.Portfolio, positions: []const zfin.Position, syms: []const []const u8, prices: *std.StringHashMap(f64), svc: *zfin.DataService, as_of: zfin.Date, ) !PortfolioData { var manual_price_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, prices); defer manual_price_set.deinit(); var summary = zfin.valuation.portfolioSummary(as_of, allocator, portfolio, positions, prices.*, manual_price_set) catch return error.SummaryFailed; errdefer summary.deinit(allocator); if (summary.allocations.len == 0) { summary.deinit(allocator); return error.NoAllocations; } var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator); errdefer { var it = candle_map.valueIterator(); while (it.next()) |v| allocator.free(v.*); candle_map.deinit(); } for (syms) |sym| { if (svc.getCachedCandles(allocator, sym)) |cs| { // cs.data is owned by the caller's `allocator`. Store // the raw slice; PortfolioData.deinit (or the arena // reset, in TUI) below frees via the same allocator. try candle_map.put(sym, cs.data); } } const snapshots = zfin.valuation.computeHistoricalSnapshots( as_of, positions, prices.*, candle_map, ); return .{ .summary = summary, .candle_map = candle_map, .snapshots = snapshots, }; } /// 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 // them through `loadFromBytes` directly. Both production callers // (working-tree `loadFromPaths` and git-historical // `loadPortfolioFromPathsAtRev`) share this core, so unit-testing // it covers the union-merge / parse-error / empty-bytes behavior // without spinning up temp filesystems or git repos. const testing = std.testing; /// Test helper: dupe a slice of byte-string literals into the /// allocator so `loadFromBytes` can free them on its own. fn dupeBytes(allocator: std.mem.Allocator, datas: []const []const u8) ![]const []const u8 { var owned = try allocator.alloc([]const u8, datas.len); errdefer { for (owned[0..]) |d| allocator.free(d); allocator.free(owned); } var filled: usize = 0; for (datas, 0..) |d, i| { owned[i] = try allocator.dupe(u8, d); filled = i + 1; } return owned; } test "loadFromBytes: union of two synthetic SRF files" { const allocator = testing.allocator; const file_a = \\#!srfv1 \\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA \\ ; const file_b = \\#!srfv1 \\symbol::MSFT,shares:num:5,open_date::2024-02-01,open_price:num:300,account::Sample Roth \\ ; const paths = try allocator.dupe([]const u8, &.{ "a.srf", "b.srf" }); const datas = try dupeBytes(allocator, &.{ file_a, file_b }); var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse { try testing.expect(false); return; }; defer loaded.deinit(allocator); try testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len); // Lots are appended in file order. try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol); try testing.expectEqualStrings("MSFT", loaded.portfolio.lots[1].symbol); } test "loadFromBytes: single file with valid contents" { const allocator = testing.allocator; const file_a = \\#!srfv1 \\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA \\ ; const paths = try allocator.dupe([]const u8, &.{"a.srf"}); const datas = try dupeBytes(allocator, &.{file_a}); var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse { try testing.expect(false); return; }; defer loaded.deinit(allocator); try testing.expectEqual(@as(usize, 1), loaded.portfolio.lots.len); try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol); } test "loadFromBytes: empty-bytes entry is treated as absent file" { // Production use case: `loadPortfolioFromPathsAtRev` returns // empty bytes for a path that didn't exist at the requested // git rev. The union-merge silently skips it instead of // failing - matches "snapshot at a date before this file // was added" semantics. const allocator = testing.allocator; const file_a = \\#!srfv1 \\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA \\ ; const file_b: []const u8 = ""; const paths = try allocator.dupe([]const u8, &.{ "a.srf", "b.srf" }); const datas = try dupeBytes(allocator, &.{ file_a, file_b }); var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse { try testing.expect(false); return; }; defer loaded.deinit(allocator); try testing.expectEqual(@as(usize, 1), loaded.portfolio.lots.len); try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol); } test "loadFromBytes: all-empty bytes returns empty portfolio" { // Production use case: snapshot at a rev that predates ALL // tracked portfolio files. No lots; no error. const allocator = testing.allocator; const paths = try allocator.dupe([]const u8, &.{ "a.srf", "b.srf" }); const datas = try dupeBytes(allocator, &.{ "", "" }); var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse { try testing.expect(false); return; }; defer loaded.deinit(allocator); try testing.expectEqual(@as(usize, 0), loaded.portfolio.lots.len); } // ── loadPortfolioFromPathsAtRev tests (disk + git) ─────────── // // These spin up a temp git repo so they exercise the full // `git.show + skip-on-PathMissingInRev` contract end-to-end. // Kept minimal (one happy-path, one missing-file case); the // bulk of the logic is in `loadFromBytes`, covered above. test "loadPortfolioFromPathsAtRev: union of two committed files at HEAD" { const allocator = testing.allocator; // Skip if `git` isn't on PATH (CI sandbox without git). if (!test_git.available(allocator)) return; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPathFile(testing.io, ".", &path_buf); const dir = path_buf[0..dir_len]; const file_a = \\#!srfv1 \\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA \\ ; const file_b = \\#!srfv1 \\symbol::MSFT,shares:num:5,open_date::2024-02-01,open_price:num:300,account::Sample Roth \\ ; try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio.srf", .data = file_a }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio_other.srf", .data = file_b }); try test_git.run(allocator, dir, null, &.{ "init", "-q" }); try test_git.run(allocator, dir, null, &.{ "config", "user.email", "test@example.com" }); try test_git.run(allocator, dir, null, &.{ "config", "user.name", "Test" }); try test_git.run(allocator, dir, null, &.{ "config", "commit.gpgsign", "false" }); try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf", "portfolio_other.srf" }); try test_git.run(allocator, dir, null, &.{ "commit", "-q", "-m", "initial" }); const p1 = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" }); defer allocator.free(p1); const p2 = try std.fs.path.join(allocator, &.{ dir, "portfolio_other.srf" }); defer allocator.free(p2); const paths = [_][]const u8{ p1, p2 }; // Pass the RAW process env (GIT_* included): `git.zig` strips them // internally, so this exercises that scrubbing - which is what // lets this test pass under a git hook where GIT_DIR points at the // outer repo (see `git.scrubbedEnv`). var env = try testing.environ.createMap(allocator); defer env.deinit(); var loaded = loadPortfolioFromPathsAtRev(testing.io, allocator, &env, &paths, "HEAD", zfin.Date.fromYmd(2026, 5, 23)) orelse { try testing.expect(false); return; }; defer loaded.deinit(allocator); try testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len); } test "loadPortfolioFromPathsAtRev: file added later is silently skipped at earlier rev" { const allocator = testing.allocator; // Skip if `git` isn't on PATH. if (!test_git.available(allocator)) return; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPathFile(testing.io, ".", &path_buf); const dir = path_buf[0..dir_len]; const file_a = \\#!srfv1 \\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA \\ ; try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio.srf", .data = file_a }); try test_git.run(allocator, dir, null, &.{ "init", "-q" }); try test_git.run(allocator, dir, null, &.{ "config", "user.email", "test@example.com" }); try test_git.run(allocator, dir, null, &.{ "config", "user.name", "Test" }); try test_git.run(allocator, dir, null, &.{ "config", "commit.gpgsign", "false" }); try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf" }); try test_git.run(allocator, dir, null, &.{ "commit", "-q", "-m", "initial" }); // Add and commit a second portfolio file. After this commit, // commit 1 (where portfolio_other.srf doesn't exist yet) is // reachable as HEAD~1 - no need to capture its SHA explicitly, // which also avoids a direct `git` call that would inherit the // hook's GIT_* env. `git show HEAD~1:` and `git show // :` exercise the identical code path in `git.show`. const file_b = \\#!srfv1 \\symbol::MSFT,shares:num:5,open_date::2024-02-01,open_price:num:300,account::Sample Roth \\ ; try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio_other.srf", .data = file_b }); try test_git.run(allocator, dir, null, &.{ "add", "portfolio_other.srf" }); try test_git.run(allocator, dir, null, &.{ "commit", "-q", "-m", "add second" }); const p1 = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" }); defer allocator.free(p1); const p2 = try std.fs.path.join(allocator, &.{ dir, "portfolio_other.srf" }); defer allocator.free(p2); const paths = [_][]const u8{ p1, p2 }; // Load at commit 1 (HEAD~1) - second file didn't exist yet. // Expect only AAPL from portfolio.srf, no error. Pass the RAW // process env; `git.zig` strips GIT_* internally so this works // under a git hook (see `git.scrubbedEnv`). var env = try testing.environ.createMap(allocator); defer env.deinit(); var loaded = loadPortfolioFromPathsAtRev(testing.io, allocator, &env, &paths, "HEAD~1", zfin.Date.fromYmd(2026, 5, 23)) orelse { try testing.expect(false); return; }; defer loaded.deinit(allocator); 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); }