//! `zfin snapshot` — write a daily portfolio snapshot to `history/`. //! //! Flow: //! 1. Locate portfolio.srf via `config.resolveUserFile` (or -p). //! The first resolved file's directory determines the //! `history/` location (sibling-of-first convention). //! 2. Derive `history/` dir as `dirname(portfolio.srf)/history/`. //! 3. Load portfolio composition as the **union of all matched //! portfolio files** (multi-file glob support). Normally from //! the working tree via `cli.loadPortfolio`; with `--as-of`, //! from git history at the repo-wide latest sha ≤ the //! requested date via `loadPortfolioFromPathsAtRev`. Files //! that didn't exist at that sha are silently skipped — the //! union just doesn't include those lots. Falls back to //! working copy if git is unavailable. //! 4. Refresh the candle cache via `cli.loadPortfolioPrices` //! (skipped under `--as-of` — past candles don't change). //! 5. Compute `as_of_date`: explicit `--as-of` wins; otherwise mode //! of cached candle dates of held non-MM stock symbols. //! 6. For each symbol, look up the close price ≤ `as_of_date` from //! its candle cache. Carry-forward gets flagged as //! `quote_stale = true`. //! 7. If `history/-portfolio.srf` already exists and //! `--force` wasn't passed, skip (exit 0, stderr message). //! 8. Build the snapshot records and write them atomically. //! //! The snapshot file itself is a SINGLE union'd record set — //! consumers (compare, projections --vs, TUI history tab) read one //! `-portfolio.srf` per date and don't need to know how many //! source files contributed to it. //! //! Flags: //! --force overwrite existing snapshot for as_of_date //! --dry-run compute + print to stdout, do not write //! --out override output path //! --as-of write a snapshot for a historical date //! (uses git to recover portfolio state and //! candle cache for pricing) //! //! The output format is discriminated SRF: every record starts with //! `kind::`. Readers demux on that //! field. See finance/README.md for the full schema. const std = @import("std"); const srf = @import("srf"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const framework = @import("framework.zig"); const atomic = @import("../atomic.zig"); const version = @import("../version.zig"); const portfolio_mod = @import("../models/portfolio.zig"); const Date = @import("../Date.zig"); const model = @import("../models/snapshot.zig"); const git = @import("../git.zig"); const portfolio_loader = @import("../portfolio_loader.zig"); // Re-export record types so callers that reach `commands/snapshot.zig` // (tests, mostly) still see the familiar names. New code should prefer // `@import("models/snapshot.zig")` directly. pub const MetaRow = model.MetaRow; pub const TotalRow = model.TotalRow; pub const TaxTypeRow = model.TaxTypeRow; pub const AccountRow = model.AccountRow; pub const LotRow = model.LotRow; pub const Snapshot = model.Snapshot; pub const SnapshotError = error{ PortfolioEmpty, WriteFailed, }; pub const ParsedArgs = struct { force: bool = false, dry_run: bool = false, out_override: ?[]const u8 = null, as_of_override: ?Date = null, }; pub const meta: framework.Meta = .{ .name = "snapshot", .group = .timeseries, .synopsis = "Write a daily portfolio snapshot to history/", .help = \\Usage: zfin snapshot [opts] \\ \\Compute a portfolio snapshot for today (or a historical date with \\`--as-of`) and write it as a discriminated SRF file under \\`history/-portfolio.srf`. The output records start with \\`kind::` so readers can demux on \\that field. \\ \\Default mode: refresh the candle cache for held symbols, derive \\`as_of_date` from the mode of cached candle dates, look up \\close-on-or-before for each lot, and write atomically. \\ \\Options: \\ --force overwrite existing snapshot for as_of_date \\ --dry-run compute + print to stdout, do not write \\ --out override output path \\ --as-of write a snapshot for a historical date \\ (uses git to recover portfolio state and \\ candle cache for pricing). DATE accepts \\ YYYY-MM-DD or relative shortcuts \\ (1W/1M/1Q/1Y). \\ \\If `history/-portfolio.srf` already exists and `--force` \\wasn't passed, the run skips with a stderr message. \\ , .uppercase_first_arg = false, .user_errors = error{ UnexpectedArg, BadMetadata, NoCommitBeforeDate, NoMetadata, PathMissingInRev, WriteFailed }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { var parsed: ParsedArgs = .{}; var i: usize = 0; while (i < cmd_args.len) : (i += 1) { const a = cmd_args[i]; if (std.mem.eql(u8, a, "--force")) { parsed.force = true; } else if (std.mem.eql(u8, a, "--dry-run")) { parsed.dry_run = true; } else if (std.mem.eql(u8, a, "--out")) { i += 1; if (i >= cmd_args.len) { cli.stderrPrint(ctx.io, "Error: --out requires a path argument\n"); return error.UnexpectedArg; } parsed.out_override = cmd_args[i]; } else if (std.mem.eql(u8, a, "--as-of")) { i += 1; if (i >= cmd_args.len) { cli.stderrPrint(ctx.io, "Error: --as-of requires a date (YYYY-MM-DD or shortcut like 1W/1M/1Q/1Y)\n"); return error.UnexpectedArg; } // Reference date for resolving relative forms in `--as-of` // (e.g. "1W" → 7 days before this anchor). const flag_anchor = Date.fromEpoch(ctx.now_s); parsed.as_of_override = cli.parseRequiredDateOrStderr(ctx.io, cmd_args[i], flag_anchor, "--as-of") catch |err| switch (err) { error.InvalidDate => return error.UnexpectedArg, }; } else { cli.stderrPrint(ctx.io, "Error: unknown argument to 'snapshot': "); cli.stderrPrint(ctx.io, a); cli.stderrPrint(ctx.io, "\n"); return error.UnexpectedArg; } } return parsed; } // ── Entry point ────────────────────────────────────────────── /// Run the snapshot command. /// /// Exit semantics: /// 0 on success (including duplicate-skip) /// non-zero on any error pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const svc = ctx.svc orelse return error.MissingDataService; const io = ctx.io; const allocator = ctx.allocator; const out = ctx.out; const color = ctx.color; const now_s = ctx.now_s; const today = ctx.today; const force = parsed.force; const dry_run = parsed.dry_run; const out_override = parsed.out_override; const as_of_override = parsed.as_of_override; const pf = ctx.resolvePortfolioPath(); defer pf.deinit(allocator); const portfolio_path = pf.path; // Load portfolio. In normal (no --as-of) mode this is the // current working-copy union of all matched portfolio files. // With --as-of, we first try to retrieve the portfolio state // from git history at or before the target date — that gives // accurate composition for past snapshots. If git lookup fails // (portfolio not tracked, no commits before the date, git // unavailable), we warn and fall back to the working-copy // union, which at least approximates "positions the user // currently holds" and is better than erroring out. // // `portfolio_path` (singular, first-resolved) is used only for // sibling-derivation (history dir, snapshot file path); the // portfolio CONTENT comes from the multi-file glob. var loaded = try loadPortfolioForSnapshot(ctx, today, as_of_override); defer loaded.deinit(allocator); var portfolio = loaded.portfolio; // We don't deinit `portfolio` separately — `loaded.deinit` // handles it. if (portfolio.lots.len == 0) { cli.stderrPrint(io, "Portfolio is empty; nothing to snapshot.\n"); return SnapshotError.PortfolioEmpty; } const syms = try portfolio.stockSymbols(allocator); defer allocator.free(syms); // Early duplicate-skip: if the cache is fully fresh, we can compute // as_of_date without touching the network or doing a full price load, // then short-circuit when today's snapshot already exists. Critically, // this only applies when ALL non-MM symbols have fresh metadata — a // single stale symbol means a refresh might bring forward a newer // `last_date`, which would change as_of_date and make the existing // snapshot file no longer a duplicate. // // Only runs when as_of is inferred from the cache (the common path). // With --as-of, the target date is explicit and might not even // correspond to "latest cache state," so we skip the fast path and // let the normal flow handle the duplicate-skip check against the // explicit date below. if (!force and out_override == null and as_of_override == null) { if (try probeFreshAsOfDate(allocator, svc, syms)) |candidate| { var cand_buf: [10]u8 = undefined; const cand_str = std.fmt.bufPrint(&cand_buf, "{f}", .{candidate}) catch "????-??-??"; const candidate_path = try deriveSnapshotPath(allocator, portfolio_path, cand_str); defer allocator.free(candidate_path); if (std.Io.Dir.cwd().access(io, candidate_path, .{})) |_| { var msg_buf: [256]u8 = undefined; const msg = std.fmt.bufPrint( &msg_buf, "snapshot for {s} already exists: {s} (cache fresh, skipped without refresh)\n", .{ cand_str, candidate_path }, ) catch "snapshot already exists\n"; cli.stderrPrint(io, msg); if (!dry_run) return; // --dry-run falls through: the user probably wants to see // what would be written. } else |_| {} } } // Candle-native pricing: // // 1. Run the shared price loader to refresh the candle cache. We // ignore its `prices` output (which is "last candle close per // symbol") because we want a specific-date lookup instead. // Skipped under --as-of: we're backfilling a historical date, // so refreshing won't add data for the past. // 2. Compute `as_of_date` from the refreshed cache (or the // user-provided --as-of). // 3. For every stock symbol, look up the close-on-or-before // `as_of_date` in its candles. This is semantically stronger // than "latest close": it means every lot's price reflects // market state at the same instant, and backfill to a past // date is just a matter of passing a different `as_of`. // // The duplicate-skip fast path above already handled the common // "cache is fresh, snapshot exists" case without any of this work. if (syms.len > 0 and as_of_override == null) { var load_result = cli.loadPortfolioPrices(io, svc, syms, &.{}, ctx.globals.refresh_policy, color); load_result.deinit(); } // Compute as_of_date. Explicit --as-of wins; otherwise derive from // the cached candle dates of held non-MM stock symbols (MM symbols // are excluded because their quote dates are often weeks stale — // dollar impact is nil, but they'd pollute the mode calculation). const qdates = try collectQuoteDates(allocator, svc, syms); defer allocator.free(qdates.dates); const as_of = as_of_override orelse (computeAsOfDate(qdates.dates) orelse Date.fromEpoch(now_s)); // Under --as-of, skip days with no market activity (weekends, US // market holidays). Detection is cache-based: if NO non-MM symbol // has a candle dated exactly `as_of`, no market data was published // for that date. Emitting a snapshot would just carry Friday's // close forward with every row flagged stale — useless and // polluting to the timeline. // // Not applied in auto mode: auto mode's as_of already comes from // cache mode and is guaranteed to be a trading day. if (as_of_override != null and !hasAnyTradingDayCandle(svc, syms, as_of)) { var msg_buf: [256]u8 = undefined; const msg = std.fmt.bufPrint( &msg_buf, "skipping {f}: no market data (weekend or holiday)\n", .{as_of}, ) catch "skipping non-trading day\n"; cli.stderrPrint(io, msg); return; } // Per-symbol candle close lookup keyed on `as_of`. Owns no string // memory (keys borrow from the caller's `syms`). var symbol_prices = std.StringHashMap(zfin.valuation.CandleAtDate).init(allocator); defer symbol_prices.deinit(); for (syms) |sym| { if (svc.getCachedCandles(sym)) |cs| { defer cs.deinit(); if (zfin.valuation.candleCloseOnOrBefore(cs.data, as_of)) |cad| { try symbol_prices.put(sym, cad); } } } // Build the flat-price map that `portfolioSummary` expects, plus // apply manual `price::` overrides from portfolio.srf (which win // over the candle lookup). var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); var sp_it = symbol_prices.iterator(); while (sp_it.next()) |entry| { try prices.put(entry.key_ptr.*, entry.value_ptr.close); } for (portfolio.lots) |lot| { if (lot.price) |p| { if (!prices.contains(lot.priceSymbol())) { // Pre-multiply manual overrides so the shared `prices` // map holds share-class-correct values — see the // "Pricing model / caching pre-multiply pattern" note // in models/portfolio.zig. try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false)); } } } // Derive output path. var as_of_buf: [10]u8 = undefined; const as_of_str = std.fmt.bufPrint(&as_of_buf, "{f}", .{as_of}) catch "????-??-??"; const derived_path = if (out_override) |p| try allocator.dupe(u8, p) else try deriveSnapshotPath(allocator, portfolio_path, as_of_str); defer allocator.free(derived_path); // Duplicate-skip check. if (!force and !dry_run) { if (std.Io.Dir.cwd().access(io, derived_path, .{})) |_| { var msg_buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "snapshot for {s} already exists: {s} (use --force to overwrite)\n", .{ as_of_str, derived_path }) catch "snapshot already exists\n"; cli.stderrPrint(io, msg); return; } else |_| {} } // Build and render the snapshot. var snap = try captureSnapshot(io, allocator, &portfolio, portfolio_path, svc, prices, symbol_prices, syms, as_of, qdates, now_s); defer snap.deinit(allocator); const rendered = try renderSnapshot(allocator, snap); defer allocator.free(rendered); if (dry_run) { try out.writeAll(rendered); return; } // Ensure history/ directory exists. if (std.fs.path.dirname(derived_path)) |dir| { std.Io.Dir.cwd().createDirPath(io, dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => { cli.stderrPrint(io, "Error creating history directory: "); cli.stderrPrint(io, @errorName(err)); cli.stderrPrint(io, "\n"); return err; }, }; } atomic.writeFileAtomic(io, allocator, derived_path, rendered) catch |err| { cli.stderrPrint(io, "Error writing snapshot: "); cli.stderrPrint(io, @errorName(err)); cli.stderrPrint(io, "\n"); return err; }; try out.print("snapshot written: {s}\n", .{derived_path}); } // ── Path helpers ───────────────────────────────────────────── /// Derive `/history/-portfolio.srf`. /// Caller owns returned memory. pub fn deriveSnapshotPath( allocator: std.mem.Allocator, portfolio_path: []const u8, as_of_str: []const u8, ) ![]const u8 { const dir = std.fs.path.dirname(portfolio_path) orelse "."; const filename = try std.fmt.allocPrint(allocator, "{s}-portfolio.srf", .{as_of_str}); defer allocator.free(filename); return std.fs.path.join(allocator, &.{ dir, "history", filename }); } // ── Portfolio-at-date loader ───────────────────────────────── /// Load portfolio bytes for the given `as_of` date. When `as_of` is /// null, simply reads the current working-copy of `portfolio_path`. /// /// When `as_of` is provided, attempts to find the latest git commit /// touching the portfolio file at or before the end of `as_of`, then /// `git show`s that revision's content. Falls back to the working copy /// (with a stderr warning) on: /// - git not available /// - portfolio.srf not tracked in git /// - no commit exists at or before as_of (pre-git dates) /// /// The working-copy fallback is intentional: the user's note is that /// pre-git dates "need either mtime fallback or get skipped (not /// errored)," and mtime-fallback is equivalent to "use the current /// file" — the file's current state IS its mtime state. A clean exit /// lets bulk-backfill loops keep moving. /// /// Caller owns the returned `LoadedPortfolio`. /// /// In normal (no `--as-of`) mode this loads the working-copy union /// of all matched portfolio files via `cli.loadPortfolio`. With /// `--as-of`, it discovers the repo-wide latest sha at or before /// the target date (`git.shaAtOrBefore`) and reads each file in /// the glob at that sha (`portfolio_loader.loadPortfolioFromPathsAtRev`). /// Files that didn't exist at that sha are silently skipped — the /// union just doesn't include those lots, which is correct for /// "snapshot of state on this date." /// /// On git lookup failure (not in a repo, no commit before the /// target, etc.), warns and falls back to the working-copy union /// — better to capture today's state than fail entirely. fn loadPortfolioForSnapshot( ctx: *framework.RunCtx, today: Date, as_of: ?Date, ) !portfolio_loader.LoadedPortfolio { const io = ctx.io; const allocator = ctx.allocator; const target = as_of orelse { // Normal mode — load working-copy union via the shared // multi-file loader. `today` is used as the as_of for // position computation. return cli.loadPortfolio(ctx, today) orelse return error.WriteFailed; }; // --as-of mode: find the repo-wide sha at or before target. // Need a single concrete path to discover the repo from; use // the first resolved portfolio path. const pf = ctx.resolvePortfolioPath(); defer pf.deinit(allocator); const portfolio_path = pf.path; const info = git.findRepo(io, allocator, portfolio_path) catch |err| switch (err) { error.NotInGitRepo, error.GitUnavailable => { warnGitFallback(io, target, "no git repo"); return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed; }, else => return err, }; defer allocator.free(info.root); defer allocator.free(info.rel_path); var date_buf: [10]u8 = undefined; const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{target}) catch "????-??-??"; const sha_opt = git.shaAtOrBefore(io, allocator, info.root, date_str) catch |err| switch (err) { error.GitUnavailable, error.GitLogFailed => { warnGitFallback(io, target, "git lookup failed"); return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed; }, else => return err, }; const sha = sha_opt orelse { warnGitFallback(io, target, "no commit at or before target date"); return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed; }; defer allocator.free(sha); // Resolve the full glob and load each file at `sha`. var resolved = ctx.resolvePortfolioPaths() catch { warnGitFallback(io, target, "could not resolve portfolio glob"); return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed; }; defer resolved.deinit(); return portfolio_loader.loadPortfolioFromPathsAtRev(io, allocator, resolved.paths, sha, target) orelse { warnGitFallback(io, target, "could not load portfolio at rev"); return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed; }; } fn warnGitFallback(io: std.Io, target: Date, reason: []const u8) void { var buf: [256]u8 = undefined; const msg = std.fmt.bufPrint( &buf, "warning: {s} for portfolio at {f}; using working copy as approximation\n", .{ reason, target }, ) catch "warning: git history unavailable; using working copy as approximation\n"; cli.stderrPrint(io, msg); } // ── Quote-date / as_of_date helpers ────────────────────────── /// Per-symbol quote-date info gathered from the candle cache. pub const QuoteInfo = struct { symbol: []const u8, // borrowed from caller /// Most recent candle date in cache, if any candles exist. last_date: ?Date, /// True when the symbol is classified money-market (excluded from /// as_of_date computation because MM candles are often stale but /// dollar-impact is nil). is_money_market: bool, }; pub const QuoteDates = struct { /// Per-symbol info (same order as input `symbols`). dates: []QuoteInfo, }; /// Probe the cache to see if we can safely compute `as_of_date` without /// doing a full price load. Returns the candidate date only if EVERY /// non-MM held symbol has fresh cache metadata — a single stale symbol /// means a refresh could bring forward a newer `last_date` and change /// the answer, so we must do the full load in that case. /// /// This exists purely as a fast path for the duplicate-skip check: /// callers that get a non-null result may safely consult /// `history/-portfolio.srf` for an existing file without spending /// the ~15s network round-trip of `loadPortfolioPrices`. /// /// MM symbols are allowed to be stale — their `last_date` is excluded /// from the mode calculation anyway. pub fn probeFreshAsOfDate( allocator: std.mem.Allocator, svc: *zfin.DataService, symbols: []const []const u8, ) !?Date { if (symbols.len == 0) return null; var infos = try allocator.alloc(QuoteInfo, symbols.len); defer allocator.free(infos); for (symbols, 0..) |sym, idx| { const is_mm = portfolio_mod.isMoneyMarketSymbol(sym); // MM symbols are excluded from as_of_date computation regardless // of freshness, so their TTL state doesn't matter here. if (!is_mm and !svc.isCandleCacheFresh(sym)) return null; infos[idx] = .{ .symbol = sym, .last_date = svc.getCachedLastDate(sym), .is_money_market = is_mm, }; } return computeAsOfDate(infos); } /// Check whether any non-MM held symbol has a candle dated exactly /// `date` in the cache. Used to detect non-trading days (weekends, /// holidays) so `--as-of` backfill can skip them. /// /// "Any" rather than "all" because US market holidays may still have /// international or money-market trading that'd show up on a few /// symbols. The absence of US equity candles across the board is what /// signals a non-trading day for our purposes. pub fn hasAnyTradingDayCandle( svc: *zfin.DataService, symbols: []const []const u8, date: Date, ) bool { for (symbols) |sym| { if (portfolio_mod.isMoneyMarketSymbol(sym)) continue; const cs = svc.getCachedCandles(sym) orelse continue; defer cs.deinit(); // Linear scan from the end — recent dates are where `date` is // most likely to land for a backfill. var i: usize = cs.data.len; while (i > 0) { i -= 1; if (cs.data[i].date.eql(date)) return true; // Candles are sorted ascending; bail early once we're past. if (cs.data[i].date.lessThan(date)) break; } } return false; } /// Gather quote-date info for each symbol from the cache. Does not /// fetch; relies on whatever the cache has. Symbols with no candles at /// all get `last_date = null`. pub fn collectQuoteDates( allocator: std.mem.Allocator, svc: *zfin.DataService, symbols: []const []const u8, ) !QuoteDates { var list = try allocator.alloc(QuoteInfo, symbols.len); errdefer allocator.free(list); for (symbols, 0..) |sym, idx| { const is_mm = portfolio_mod.isMoneyMarketSymbol(sym); var last_date: ?Date = null; if (svc.getCachedCandles(sym)) |cs| { defer cs.deinit(); if (cs.data.len > 0) last_date = cs.data[cs.data.len - 1].date; } list[idx] = .{ .symbol = sym, .last_date = last_date, .is_money_market = is_mm }; } return .{ .dates = list }; } /// Compute the snapshot's `as_of_date` from per-symbol quote info. /// /// Rule: take the **mode** of `last_date` across non-MM symbols with a /// known date; break ties by picking the maximum. If no non-MM symbol /// has a date (portfolio is all cash/MM, or totally uncached), return /// null and the caller falls back to the capture date. pub fn computeAsOfDate(infos: []const QuoteInfo) ?Date { // Two-pass mode: count occurrences, then pick max-count/max-date. // With typical portfolios (~30 symbols) O(n²) is fine. var best: ?Date = null; var best_count: usize = 0; for (infos) |a| { if (a.is_money_market) continue; const a_date = a.last_date orelse continue; var count: usize = 0; for (infos) |b| { if (b.is_money_market) continue; const b_date = b.last_date orelse continue; if (a_date.eql(b_date)) count += 1; } if (count > best_count or (count == best_count and best != null and best.?.lessThan(a_date))) { best = a_date; best_count = count; } } return best; } /// Return (min, max) of non-MM symbol quote dates, for metadata. /// Returns null if no non-MM symbol has a known date. pub fn quoteDateRange(infos: []const QuoteInfo) ?struct { min: Date, max: Date } { var min_d: ?Date = null; var max_d: ?Date = null; for (infos) |info| { if (info.is_money_market) continue; const d = info.last_date orelse continue; if (min_d == null or d.lessThan(min_d.?)) min_d = d; if (max_d == null or max_d.?.lessThan(d)) max_d = d; } if (min_d == null) return null; return .{ .min = min_d.?, .max = max_d.? }; } // ── Snapshot records ───────────────────────────────────────── // // Record structs live in `src/models/snapshot.zig` — see the re-exports // near the top of this file. The types are separated from this command // module so analytics code (`src/analytics/timeline.zig`) can reference // them without depending on a `commands/` module. /// I/O-edged orchestration wrapper around `buildSnapshot`. /// /// Assembles the dependencies that require disk or service access — /// positions, portfolio summary, manual-price set, analysis result /// (loaded from metadata.srf + accounts.srf) — and hands them to the /// pure `buildSnapshot` builder. /// /// This is the path taken by the `zfin snapshot` command. Tests can /// call `buildSnapshot` directly with hand-built fixtures instead of /// going through here. fn captureSnapshot( io: std.Io, allocator: std.mem.Allocator, portfolio: *zfin.Portfolio, portfolio_path: []const u8, svc: *zfin.DataService, prices: std.StringHashMap(f64), symbol_prices: std.StringHashMap(zfin.valuation.CandleAtDate), syms: []const []const u8, as_of: Date, qdates: QuoteDates, now_s: i64, ) !Snapshot { // 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. const positions = try portfolio.positionsAsOf(allocator, as_of); defer allocator.free(positions); var manual_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, @constCast(&prices)); defer manual_set.deinit(); var summary = try zfin.valuation.portfolioSummary(as_of, allocator, portfolio.*, positions, prices, manual_set); defer summary.deinit(allocator); // Analysis is optional — metadata.srf may not exist during initial // setup, in which case `runAnalysis` returns an error and we pass // null through to `buildSnapshot`, which emits empty // tax_type/account sections. var analysis_opt: ?zfin.analysis.AnalysisResult = runAnalysis( io, allocator, portfolio, portfolio_path, svc, summary, as_of, ) catch null; defer if (analysis_opt) |*a| a.deinit(allocator); return buildSnapshot( allocator, portfolio, summary, prices, manual_set, symbol_prices, syms, as_of, qdates, analysis_opt, now_s, ); } /// Build the full snapshot in memory. Pure: no disk, no network, no /// service calls. All dependencies are explicit parameters; the caller /// is responsible for assembling them (see `captureSnapshot` for the /// I/O-edged orchestration). /// /// Inputs: /// - `portfolio`, `as_of`: the what and when. /// - `summary`, `manual_set`: stock aggregates + totals + the set of /// symbols whose price is already share-class-adjusted. See the /// "Pricing model" block in `models/portfolio.zig`. /// - `prices`: flat `symbol -> price` map. /// - `symbol_prices`: richer per-symbol candle match info for the /// per-lot `quote_date` / `quote_stale` fields. /// - `qdates`: per-symbol latest cached candle dates, used only /// for the meta row's `quote_date_min`/`_max` span. /// - `analysis_result`: optional per-account / per-tax-type rollup. /// Null when metadata.srf was absent; yields empty section slices. fn buildSnapshot( allocator: std.mem.Allocator, portfolio: *zfin.Portfolio, summary: zfin.valuation.PortfolioSummary, prices: std.StringHashMap(f64), manual_set: std.StringHashMap(void), symbol_prices: std.StringHashMap(zfin.valuation.CandleAtDate), syms: []const []const u8, as_of: Date, qdates: QuoteDates, analysis_result: ?zfin.analysis.AnalysisResult, now_s: i64, ) !Snapshot { // `summary` and `manual_set` are caller-provided — see // `captureSnapshot` for how they're assembled from // `portfolio.positionsAsOf(as_of)` + `buildFallbackPrices` + // `portfolioSummary`. The caller owns their lifetimes. const illiquid = portfolio.totalIlliquidAsOf(as_of); const net_worth = zfin.valuation.netWorthAsOf(portfolio.*, summary, as_of); var totals = try allocator.alloc(TotalRow, 3); totals[0] = .{ .kind = "total", .scope = "net_worth", .value = net_worth }; totals[1] = .{ .kind = "total", .scope = "liquid", .value = summary.total_value }; totals[2] = .{ .kind = "total", .scope = "illiquid", .value = illiquid }; // Per-account / per-tax-type roll-ups come from the caller — // `run()` invokes `runAnalysis` (which reads metadata.srf and // loads the account map) before calling us. Null means // metadata.srf was absent; we emit empty tax_type/account // sections rather than failing the whole capture. // // `analyzePortfolio` was given `as_of` upstream so per-lot // filtering via `lotIsOpenAsOf(as_of)` matches the headline totals. var tax_types: []TaxTypeRow = &.{}; var accounts: []AccountRow = &.{}; if (analysis_result) |a| { tax_types = try allocator.alloc(TaxTypeRow, a.tax_type.len); for (a.tax_type, 0..) |t, idx| { tax_types[idx] = .{ .kind = "tax_type", .label = t.label, .value = t.value }; } errdefer allocator.free(tax_types); accounts = try allocator.alloc(AccountRow, a.account.len); for (a.account, 0..) |acc, idx| { accounts[idx] = .{ .kind = "account", .name = acc.label, .value = acc.value }; } } // Per-lot rows (open lots only). Stock lots get current price + // stale flag; non-stock lots get face value. var lots_list = std.ArrayList(LotRow).empty; errdefer lots_list.deinit(allocator); var stale_count: usize = 0; _ = syms; for (portfolio.lots) |lot| { // Use as_of (not wall-clock today) so backfill snapshots // correctly include lots that had opened by then and exclude // ones that had already closed or matured. See // `Lot.lotIsOpenAsOf` for semantics. if (!lot.lotIsOpenAsOf(as_of)) continue; const sec_label = lot.security_type.label(); const lot_sym = lot.symbol; const price_sym = lot.priceSymbol(); const acct = lot.account orelse ""; switch (lot.security_type) { .stock => { const raw_price = prices.get(price_sym) orelse lot.open_price; const is_manual = manual_set.contains(price_sym); const effective_price = lot.effectivePrice(raw_price, is_manual); const value = lot.marketValue(raw_price, is_manual); // Candle-native quote_date/quote_stale: `symbol_prices` // already ran `candleCloseOnOrBefore(as_of)` per symbol, // which records both the matched candle's actual date // and whether it was a carry-forward. Manual price // overrides don't appear in symbol_prices and have no // quote_date (explicitly nil, not stale). var quote_date: ?Date = null; var stale = false; if (!is_manual) { if (symbol_prices.get(price_sym)) |cad| { quote_date = cad.date; stale = cad.stale; } } if (stale and !portfolio_mod.isMoneyMarketSymbol(price_sym)) stale_count += 1; try lots_list.append(allocator, .{ .kind = "lot", .symbol = price_sym, .lot_symbol = lot_sym, .account = acct, .security_type = sec_label, .shares = lot.shares, .open_price = lot.open_price, .cost_basis = lot.costBasis(), .price = effective_price, .value = value, .quote_date = quote_date, .quote_stale = stale, }); }, .cash, .cd, .illiquid => { // `shares` is the face/dollar value for these types. try lots_list.append(allocator, .{ .kind = "lot", .symbol = lot_sym, .lot_symbol = lot_sym, .account = acct, .security_type = sec_label, .shares = lot.shares, .open_price = 0, .cost_basis = 0, .value = lot.shares, }); }, .option => { const opt_value = @abs(lot.shares) * lot.open_price * lot.multiplier; try lots_list.append(allocator, .{ .kind = "lot", .symbol = lot_sym, .lot_symbol = lot_sym, .account = acct, .security_type = sec_label, .shares = lot.shares, .open_price = lot.open_price, .cost_basis = opt_value, .value = opt_value, }); }, .watch => { // Watchlist lots aren't positions — skip. }, } } const range = quoteDateRange(qdates.dates); return .{ .meta = .{ .kind = "meta", .snapshot_version = 1, .as_of_date = as_of, .captured_at = now_s, .zfin_version = version.version_string, .quote_date_min = if (range) |r| r.min else null, .quote_date_max = if (range) |r| r.max else null, .stale_count = stale_count, }, .totals = totals, .tax_types = tax_types, .accounts = accounts, .lots = try lots_list.toOwnedSlice(allocator), }; } fn runAnalysis( io: std.Io, allocator: std.mem.Allocator, portfolio: *zfin.Portfolio, portfolio_path: []const u8, svc: *zfin.DataService, summary: zfin.valuation.PortfolioSummary, as_of: Date, ) !zfin.analysis.AnalysisResult { const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0; const meta_path = try std.fmt.allocPrint(allocator, "{s}metadata.srf", .{portfolio_path[0..dir_end]}); defer allocator.free(meta_path); const meta_data = std.Io.Dir.cwd().readFileAlloc(io, meta_path, allocator, .limited(1024 * 1024)) catch return error.NoMetadata; defer allocator.free(meta_data); var cm = zfin.classification.parseClassificationFile(allocator, meta_data) catch return error.BadMetadata; defer cm.deinit(); var acct_map_opt: ?zfin.analysis.AccountMap = svc.loadAccountMap(portfolio_path); defer if (acct_map_opt) |*am| am.deinit(); return zfin.analysis.analyzePortfolio( allocator, summary.allocations, cm, portfolio.*, summary.total_value, acct_map_opt, as_of, ); } // ── SRF rendering ──────────────────────────────────────────── /// Render a snapshot to SRF bytes. Caller owns result. /// /// Each section is emitted as a homogeneous record slice via /// `srf.fmtFrom`. The first section (meta) carries `emit_directives = /// true` so the `#!srfv1` header and `#!created=...` line are written /// once at the top; subsequent sections set `emit_directives = false` /// to suppress a duplicate header. pub fn renderSnapshot(allocator: std.mem.Allocator, snap: Snapshot) ![]const u8 { var aw: std.Io.Writer.Allocating = .init(allocator); errdefer aw.deinit(); const w = &aw.writer; // Single-element slice so we can route the meta row through the // same `fmtFrom` pipeline as the rest of the sections. This also // puts the `#!created=...` header at the top of the file. const meta_rows: [1]MetaRow = .{snap.meta}; try w.print("{f}", .{srf.fmtFrom(MetaRow, allocator, &meta_rows, .{ .emit_directives = true, .created = snap.meta.captured_at, })}); // Subsequent sections: records only (no header). const tail_opts: srf.FormatOptions = .{ .emit_directives = false }; try w.print("{f}", .{srf.fmtFrom(TotalRow, allocator, snap.totals, tail_opts)}); try w.print("{f}", .{srf.fmtFrom(TaxTypeRow, allocator, snap.tax_types, tail_opts)}); try w.print("{f}", .{srf.fmtFrom(AccountRow, allocator, snap.accounts, tail_opts)}); try w.print("{f}", .{srf.fmtFrom(LotRow, allocator, snap.lots, tail_opts)}); return aw.toOwnedSlice(); } // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; test "parseArgs: defaults" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; ctx.now_s = 0; const args = [_][]const u8{}; const parsed = try parseArgs(&ctx, &args); try std.testing.expect(!parsed.force); try std.testing.expect(!parsed.dry_run); try std.testing.expect(parsed.out_override == null); try std.testing.expect(parsed.as_of_override == null); } test "parseArgs: --force --dry-run" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; ctx.now_s = 0; const args = [_][]const u8{ "--force", "--dry-run" }; const parsed = try parseArgs(&ctx, &args); try std.testing.expect(parsed.force); try std.testing.expect(parsed.dry_run); } test "parseArgs: --out captures path" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; ctx.now_s = 0; const args = [_][]const u8{ "--out", "/tmp/snap.srf" }; const parsed = try parseArgs(&ctx, &args); try std.testing.expectEqualStrings("/tmp/snap.srf", parsed.out_override.?); } test "parseArgs: --out without value errors" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; ctx.now_s = 0; const args = [_][]const u8{"--out"}; try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); } test "parseArgs: --as-of with explicit date" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; ctx.now_s = 0; const args = [_][]const u8{ "--as-of", "2026-01-15" }; const parsed = try parseArgs(&ctx, &args); try std.testing.expect(parsed.as_of_override.?.eql(Date.fromYmd(2026, 1, 15))); } test "parseArgs: --as-of without value errors" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; ctx.now_s = 0; const args = [_][]const u8{"--as-of"}; try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); } test "parseArgs: unknown arg errors" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; ctx.now_s = 0; const args = [_][]const u8{"--bogus"}; try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); } test "deriveSnapshotPath: standard layout" { // Build the input portfolio path and expected output from // path-joined components so the test runs on both POSIX and Windows. const portfolio_path = try std.fs.path.join( testing.allocator, &.{ "home", "lobo", "finance", "portfolio.srf" }, ); defer testing.allocator.free(portfolio_path); const expected = try std.fs.path.join( testing.allocator, &.{ "home", "lobo", "finance", "history", "2026-04-20-portfolio.srf" }, ); defer testing.allocator.free(expected); const p = try deriveSnapshotPath(testing.allocator, portfolio_path, "2026-04-20"); defer testing.allocator.free(p); try testing.expectEqualStrings(expected, p); } test "deriveSnapshotPath: bare filename (no dir) falls back to cwd" { const expected = try std.fs.path.join( testing.allocator, &.{ ".", "history", "2026-04-20-portfolio.srf" }, ); defer testing.allocator.free(expected); const p = try deriveSnapshotPath(testing.allocator, "portfolio.srf", "2026-04-20"); defer testing.allocator.free(p); try testing.expectEqualStrings(expected, p); } test "computeAsOfDate: mode of non-MM dates, ties broken by max" { const d1 = Date.fromYmd(2026, 4, 17); const d2 = Date.fromYmd(2026, 4, 20); const infos = [_]QuoteInfo{ .{ .symbol = "VTI", .last_date = d2, .is_money_market = false }, .{ .symbol = "AAPL", .last_date = d2, .is_money_market = false }, .{ .symbol = "MSFT", .last_date = d1, .is_money_market = false }, // Money-market with stale date — must not win the mode. .{ .symbol = "SWVXX", .last_date = Date.fromYmd(2025, 1, 1), .is_money_market = true }, }; const result = computeAsOfDate(&infos); try testing.expect(result != null); try testing.expect(result.?.eql(d2)); } test "computeAsOfDate: ties break toward max date" { const d1 = Date.fromYmd(2026, 4, 17); const d2 = Date.fromYmd(2026, 4, 20); const infos = [_]QuoteInfo{ .{ .symbol = "A", .last_date = d1, .is_money_market = false }, .{ .symbol = "B", .last_date = d2, .is_money_market = false }, }; const result = computeAsOfDate(&infos).?; try testing.expect(result.eql(d2)); } test "computeAsOfDate: all MM returns null" { const infos = [_]QuoteInfo{ .{ .symbol = "SWVXX", .last_date = Date.fromYmd(2026, 4, 20), .is_money_market = true }, .{ .symbol = "VMFXX", .last_date = Date.fromYmd(2026, 4, 20), .is_money_market = true }, }; try testing.expect(computeAsOfDate(&infos) == null); } test "computeAsOfDate: symbols with no cached date are ignored" { const d = Date.fromYmd(2026, 4, 20); const infos = [_]QuoteInfo{ .{ .symbol = "VTI", .last_date = d, .is_money_market = false }, .{ .symbol = "UNCACHED", .last_date = null, .is_money_market = false }, }; const result = computeAsOfDate(&infos).?; try testing.expect(result.eql(d)); } test "computeAsOfDate: empty input returns null" { try testing.expect(computeAsOfDate(&.{}) == null); } test "probeFreshAsOfDate: empty symbol list returns null without touching the service" { // No symbols means no refresh is needed and no date to compute. A // null service pointer here would be dereferenced if the function // touched it, so this also proves the early-return path. var svc: zfin.DataService = undefined; // Pointer not dereferenced because the function returns before the // loop. Using @constCast to produce a pointer of the right type // without zero-initializing DataService internals. try testing.expect((try probeFreshAsOfDate(testing.allocator, &svc, &.{})) == null); } test "quoteDateRange: min and max skip MM symbols" { const d_old = Date.fromYmd(2026, 4, 17); const d_new = Date.fromYmd(2026, 4, 20); const d_ancient = Date.fromYmd(2025, 1, 1); const infos = [_]QuoteInfo{ .{ .symbol = "A", .last_date = d_new, .is_money_market = false }, .{ .symbol = "B", .last_date = d_old, .is_money_market = false }, // MM way older — must be excluded from the range. .{ .symbol = "SWVXX", .last_date = d_ancient, .is_money_market = true }, }; const r = quoteDateRange(&infos).?; try testing.expect(r.min.eql(d_old)); try testing.expect(r.max.eql(d_new)); } test "quoteDateRange: returns null when no non-MM data" { const infos = [_]QuoteInfo{ .{ .symbol = "SWVXX", .last_date = Date.fromYmd(2026, 4, 20), .is_money_market = true }, }; try testing.expect(quoteDateRange(&infos) == null); } test "renderSnapshot: minimal snapshot shape" { const totals = [_]TotalRow{ .{ .kind = "total", .scope = "net_worth", .value = 1000.0 }, .{ .kind = "total", .scope = "liquid", .value = 800.0 }, .{ .kind = "total", .scope = "illiquid", .value = 200.0 }, }; const snap: Snapshot = .{ .meta = .{ .kind = "meta", .snapshot_version = 1, .as_of_date = Date.fromYmd(2026, 4, 20), .captured_at = 1_745_222_400, .zfin_version = "testver", .stale_count = 0, }, .totals = @constCast(&totals), .tax_types = &.{}, .accounts = &.{}, .lots = &.{}, }; const rendered = try renderSnapshot(testing.allocator, snap); defer testing.allocator.free(rendered); // Header + front-matter from the first fmtFrom call. try testing.expect(std.mem.startsWith(u8, rendered, "#!srfv1\n")); try testing.expect(std.mem.indexOf(u8, rendered, "#!created=1745222400") != null); // Meta record fields (discriminator, version, date, captured_at). try testing.expect(std.mem.indexOf(u8, rendered, "kind::meta") != null); try testing.expect(std.mem.indexOf(u8, rendered, "as_of_date::2026-04-20") != null); try testing.expect(std.mem.indexOf(u8, rendered, "zfin_version::testver") != null); // Totals records use kind::total plus scope+value. try testing.expect(std.mem.indexOf(u8, rendered, "kind::total,scope::net_worth") != null); try testing.expect(std.mem.indexOf(u8, rendered, "kind::total,scope::liquid") != null); try testing.expect(std.mem.indexOf(u8, rendered, "kind::total,scope::illiquid") != null); } test "renderSnapshot: includes quote_date_min/max when present, elided when null" { const snap_with: Snapshot = .{ .meta = .{ .kind = "meta", .snapshot_version = 1, .as_of_date = Date.fromYmd(2026, 4, 20), .captured_at = 0, .zfin_version = "x", .quote_date_min = Date.fromYmd(2026, 4, 17), .quote_date_max = Date.fromYmd(2026, 4, 20), .stale_count = 2, }, .totals = &.{}, .tax_types = &.{}, .accounts = &.{}, .lots = &.{}, }; const rendered_with = try renderSnapshot(testing.allocator, snap_with); defer testing.allocator.free(rendered_with); try testing.expect(std.mem.indexOf(u8, rendered_with, "quote_date_min::2026-04-17") != null); try testing.expect(std.mem.indexOf(u8, rendered_with, "quote_date_max::2026-04-20") != null); try testing.expect(std.mem.indexOf(u8, rendered_with, "stale_count:num:2") != null); // Same structure with nulls — srf elides optional fields matching // their `null` default, so those keys must NOT appear. const snap_without: Snapshot = .{ .meta = .{ .kind = "meta", .snapshot_version = 1, .as_of_date = Date.fromYmd(2026, 4, 20), .captured_at = 0, .zfin_version = "x", .stale_count = 0, }, .totals = &.{}, .tax_types = &.{}, .accounts = &.{}, .lots = &.{}, }; const rendered_without = try renderSnapshot(testing.allocator, snap_without); defer testing.allocator.free(rendered_without); try testing.expect(std.mem.indexOf(u8, rendered_without, "quote_date_min") == null); try testing.expect(std.mem.indexOf(u8, rendered_without, "quote_date_max") == null); } test "renderSnapshot: lot rendering elides price/quote_date/stale when default" { const lots = [_]LotRow{ // Stock lot — all three optional fields populated. .{ .kind = "lot", .symbol = "VTI", .lot_symbol = "VTI", .account = "Sample Roth", .security_type = "Stock", .shares = 100, .open_price = 200.0, .cost_basis = 20000.0, .value = 31002.0, .price = 310.02, .quote_date = Date.fromYmd(2026, 4, 17), .quote_stale = true, }, // Cash lot — optionals left at default (null / false), so srf // elides them. .{ .kind = "lot", .symbol = "Savings", .lot_symbol = "Savings", .account = "Sample Roth", .security_type = "Cash", .shares = 50000, .open_price = 0, .cost_basis = 0, .value = 50000, }, }; const snap: Snapshot = .{ .meta = .{ .kind = "meta", .snapshot_version = 1, .as_of_date = Date.fromYmd(2026, 4, 20), .captured_at = 0, .zfin_version = "x", .stale_count = 1, }, .totals = &.{}, .tax_types = &.{}, .accounts = &.{}, .lots = @constCast(&lots), }; const rendered = try renderSnapshot(testing.allocator, snap); defer testing.allocator.free(rendered); // Stock lot line: extract it so we can check in isolation. const vti_start = std.mem.indexOf(u8, rendered, "kind::lot,symbol::VTI").?; const vti_end = std.mem.indexOfScalarPos(u8, rendered, vti_start, '\n').?; const vti_line = rendered[vti_start..vti_end]; // All three optional-on-stock fields present. try testing.expect(std.mem.indexOf(u8, vti_line, ",price:num:") != null); try testing.expect(std.mem.indexOf(u8, vti_line, "quote_date::2026-04-17") != null); try testing.expect(std.mem.indexOf(u8, vti_line, "quote_stale") != null); // Cash lot line: the three optional fields must be elided because // they match their declared defaults (null, null, false). const cash_start = std.mem.indexOf(u8, rendered, "kind::lot,symbol::Savings").?; const cash_end = std.mem.indexOfScalarPos(u8, rendered, cash_start, '\n').?; const cash_line = rendered[cash_start..cash_end]; try testing.expect(std.mem.indexOf(u8, cash_line, ",price:num:") == null); try testing.expect(std.mem.indexOf(u8, cash_line, "quote_date") == null); try testing.expect(std.mem.indexOf(u8, cash_line, "quote_stale") == null); } test "renderSnapshot: tax_type and account rows carry kind discriminator" { const tax = [_]TaxTypeRow{ .{ .kind = "tax_type", .label = "Taxable", .value = 5000 }, .{ .kind = "tax_type", .label = "Roth (Post-Tax)", .value = 3000 }, }; const accts = [_]AccountRow{ .{ .kind = "account", .name = "Sample Roth", .value = 2500 }, }; const snap: Snapshot = .{ .meta = .{ .kind = "meta", .snapshot_version = 1, .as_of_date = Date.fromYmd(2026, 4, 20), .captured_at = 0, .zfin_version = "x", .stale_count = 0, }, .totals = &.{}, .tax_types = @constCast(&tax), .accounts = @constCast(&accts), .lots = &.{}, }; const rendered = try renderSnapshot(testing.allocator, snap); defer testing.allocator.free(rendered); try testing.expect(std.mem.indexOf(u8, rendered, "kind::tax_type,label::Taxable") != null); try testing.expect(std.mem.indexOf(u8, rendered, "kind::tax_type,label::Roth (Post-Tax)") != null); try testing.expect(std.mem.indexOf(u8, rendered, "kind::account,name::Sample Roth") != null); } test "renderSnapshot: front-matter emitted exactly once" { // All four tail sections use emit_directives=false; only the meta // call produces the #!srfv1 + #!created lines. Make sure we don't // regress into duplicate headers. const totals = [_]TotalRow{ .{ .kind = "total", .scope = "net_worth", .value = 1000 }, }; const snap: Snapshot = .{ .meta = .{ .kind = "meta", .snapshot_version = 1, .as_of_date = Date.fromYmd(2026, 4, 20), .captured_at = 1, .zfin_version = "x", .stale_count = 0, }, .totals = @constCast(&totals), .tax_types = &.{}, .accounts = &.{}, .lots = &.{}, }; const rendered = try renderSnapshot(testing.allocator, snap); defer testing.allocator.free(rendered); try testing.expectEqual(@as(usize, 1), std.mem.count(u8, rendered, "#!srfv1")); try testing.expectEqual(@as(usize, 1), std.mem.count(u8, rendered, "#!created=")); } // ── buildSnapshot integration test ────────────────────────────── // // Covers the full pure pipeline: construct fixture inputs, call // `buildSnapshot`, render, assert on the resulting bytes. Catches // regressions in: // - pricing rules (effectivePrice / marketValue / price_ratio) // - per-lot quote_date / quote_stale propagation // - manual-price flag handling (is_preadjusted) // - meta row field assembly // - totals ordering (net_worth, liquid, illiquid) // - analysis result → tax_type/account row mapping // // We assert on semantic properties rather than byte-identical golden // output to avoid brittleness on float formatting and HashMap // iteration order. If a future change reshuffles record order or // precision, these asserts should still pass. test "buildSnapshot: price_ratio applied to live prices, skipped for manual" { const allocator = testing.allocator; // Portfolio: three lots, three scenarios. // 1. AAPL — plain retail-class, live price from candle. // 2. VTTHX — institutional share class (ratio 5.185), live price. // 3. NON40OR52 — manual price:: override (is_manual=true). var lots = [_]portfolio_mod.Lot{ .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .security_type = .stock, .account = "Roth", }, .{ .symbol = "VTTHX", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .security_type = .stock, .account = "401k", .price_ratio = 5.185, }, .{ .symbol = "NON40OR52", .shares = 1000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 95.0, .security_type = .stock, .account = "401k", .price = 100.0, // manual override }, }; var portfolio = zfin.Portfolio{ .lots = &lots, .allocator = allocator }; // Positions — the caller assembles these via `positionsAsOf`. const positions = try portfolio.positionsAsOf(allocator, Date.fromYmd(2026, 4, 17)); defer allocator.free(positions); // Prices — constructed the same way `captureSnapshot` does: live // candle closes for AAPL/VTTHX, manual override pre-multiplied for // NON40OR52 (ratio is 1.0 here so pre-multiply is a no-op). var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("AAPL", 200.0); try prices.put("VTTHX", 27.78); // retail-class close; ratio applied downstream try prices.put("NON40OR52", 100.0); // manual, already pre-multiplied // buildFallbackPrices populates manual_set with symbols whose // price is already share-class-adjusted. We mimic that here. var manual_set = std.StringHashMap(void).init(allocator); defer manual_set.deinit(); try manual_set.put("NON40OR52", {}); // PortfolioSummary normally comes from valuation.portfolioSummary; // build the equivalent inline. Total value reflects the pricing // rules: AAPL 10×200=2000, VTTHX 100×27.78×5.185=14,403.93, // NON40OR52 1000×100=100,000. Total = 116,403.93. var allocations = [_]zfin.valuation.Allocation{ .{ .symbol = "AAPL", .display_symbol = "AAPL", .shares = 10, .avg_cost = 150, .current_price = 200, .market_value = 2000, .cost_basis = 1500, .weight = 0, .unrealized_gain_loss = 500, .unrealized_return = 0.333, .account = "Roth", }, .{ .symbol = "VTTHX", .display_symbol = "VTTHX", .shares = 100, .avg_cost = 140, .current_price = 144.04, .market_value = 14404, .cost_basis = 14000, .weight = 0, .unrealized_gain_loss = 404, .unrealized_return = 0.028, .price_ratio = 5.185, .account = "401k", }, .{ .symbol = "NON40OR52", .display_symbol = "NON40OR52", .shares = 1000, .avg_cost = 95, .current_price = 100, .market_value = 100000, .cost_basis = 95000, .weight = 0, .unrealized_gain_loss = 5000, .unrealized_return = 0.053, .is_manual_price = true, .account = "401k", }, }; const summary: zfin.valuation.PortfolioSummary = .{ .total_value = 116404.0, .total_cost = 110500.0, .unrealized_gain_loss = 5904.0, .unrealized_return = 0.0534, .realized_gain_loss = 0, .allocations = &allocations, }; // symbol_prices: AAPL exact match, VTTHX exact, NON40OR52 absent // (manual price doesn't have a candle lookup — `quote_date` should // be null and `quote_stale` should be false). var symbol_prices = std.StringHashMap(zfin.valuation.CandleAtDate).init(allocator); defer symbol_prices.deinit(); try symbol_prices.put("AAPL", .{ .close = 200.0, .date = Date.fromYmd(2026, 4, 17), .stale = false }); try symbol_prices.put("VTTHX", .{ .close = 27.78, .date = Date.fromYmd(2026, 4, 17), .stale = false }); const syms = [_][]const u8{ "AAPL", "VTTHX", "NON40OR52" }; const qdates_data = [_]QuoteInfo{ .{ .symbol = "AAPL", .last_date = Date.fromYmd(2026, 4, 17), .is_money_market = false }, .{ .symbol = "VTTHX", .last_date = Date.fromYmd(2026, 4, 17), .is_money_market = false }, }; const qdates: QuoteDates = .{ .dates = @constCast(&qdates_data) }; var snap = try buildSnapshot( allocator, &portfolio, summary, prices, manual_set, symbol_prices, &syms, Date.fromYmd(2026, 4, 17), qdates, null, // no classification — tax_types/accounts empty 1_745_222_400, ); defer snap.deinit(allocator); const rendered = try renderSnapshot(allocator, snap); defer allocator.free(rendered); // ── Meta row ──────────────────────────────────────────── try testing.expect(std.mem.indexOf(u8, rendered, "as_of_date::2026-04-17") != null); try testing.expect(std.mem.indexOf(u8, rendered, "stale_count:num:0") != null); // ── Totals ────────────────────────────────────────────── // Emitted in fixed order: net_worth, liquid, illiquid. const nw_pos = std.mem.indexOf(u8, rendered, "scope::net_worth").?; const liq_pos = std.mem.indexOf(u8, rendered, "scope::liquid").?; const ill_pos = std.mem.indexOf(u8, rendered, "scope::illiquid").?; try testing.expect(nw_pos < liq_pos); try testing.expect(liq_pos < ill_pos); // ── Per-lot pricing assertions ────────────────────────── // AAPL: retail, no ratio. 10 shares * 200 = 2000. try testing.expect(std.mem.indexOf(u8, rendered, "symbol::AAPL") != null); try testing.expect(std.mem.indexOf(u8, rendered, "value:num:2000") != null); // VTTHX: institutional, ratio 5.185 applied. value ≈ 100 * 27.78 * 5.185 = 14403.93 try testing.expect(std.mem.indexOf(u8, rendered, "symbol::VTTHX") != null); // Value is 14404.23 due to 5.185 × 27.78 × 100 float rounding; check within tolerance. try testing.expect(std.mem.indexOf(u8, rendered, "value:num:14403") != null or std.mem.indexOf(u8, rendered, "value:num:14404") != null); // NON40OR52: manual, ratio skipped. 1000 * 100 = 100000. try testing.expect(std.mem.indexOf(u8, rendered, "symbol::NON40OR52") != null); try testing.expect(std.mem.indexOf(u8, rendered, "value:num:100000") != null); // NON40OR52 has no candle lookup → no quote_date on its row. // (We can't easily assert a field is absent on a specific row // without parsing, but we can assert the manual lot has no // quote_stale flag.) const nm_pos = std.mem.indexOf(u8, rendered, "symbol::NON40OR52").?; const nm_end = std.mem.indexOfScalarPos(u8, rendered, nm_pos, '\n') orelse rendered.len; const nm_row = rendered[nm_pos..nm_end]; try testing.expect(std.mem.indexOf(u8, nm_row, "quote_stale") == null); // ── No tax_type/account rows when analysis_result is null ── try testing.expect(std.mem.indexOf(u8, rendered, "kind::tax_type") == null); try testing.expect(std.mem.indexOf(u8, rendered, "kind::account") == null); } test "buildSnapshot: stale carry-forward flagged on lot row" { const allocator = testing.allocator; var lots = [_]portfolio_mod.Lot{ .{ .symbol = "MSFT", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .security_type = .stock, .account = "Roth", }, }; var portfolio = zfin.Portfolio{ .lots = &lots, .allocator = allocator }; const positions = try portfolio.positionsAsOf(allocator, Date.fromYmd(2026, 4, 20)); defer allocator.free(positions); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("MSFT", 400.0); var manual_set = std.StringHashMap(void).init(allocator); defer manual_set.deinit(); var allocations = [_]zfin.valuation.Allocation{ .{ .symbol = "MSFT", .display_symbol = "MSFT", .shares = 5, .avg_cost = 300, .current_price = 400, .market_value = 2000, .cost_basis = 1500, .weight = 0, .unrealized_gain_loss = 500, .unrealized_return = 0.333, .account = "Roth", }, }; const summary: zfin.valuation.PortfolioSummary = .{ .total_value = 2000, .total_cost = 1500, .unrealized_gain_loss = 500, .unrealized_return = 0.333, .realized_gain_loss = 0, .allocations = &allocations, }; // Target as_of = 2026-04-20, but MSFT's matched candle is 04-17 // (e.g., a stale cache). Lot row should carry quote_date::2026-04-17 // and quote_stale:bool:true. var symbol_prices = std.StringHashMap(zfin.valuation.CandleAtDate).init(allocator); defer symbol_prices.deinit(); try symbol_prices.put("MSFT", .{ .close = 400.0, .date = Date.fromYmd(2026, 4, 17), .stale = true }); const syms = [_][]const u8{"MSFT"}; const qdates_data = [_]QuoteInfo{ .{ .symbol = "MSFT", .last_date = Date.fromYmd(2026, 4, 17), .is_money_market = false }, }; const qdates: QuoteDates = .{ .dates = @constCast(&qdates_data) }; var snap = try buildSnapshot( allocator, &portfolio, summary, prices, manual_set, symbol_prices, &syms, Date.fromYmd(2026, 4, 20), qdates, null, 1_745_222_400, ); defer snap.deinit(allocator); const rendered = try renderSnapshot(allocator, snap); defer allocator.free(rendered); try testing.expect(std.mem.indexOf(u8, rendered, "quote_date::2026-04-17") != null); try testing.expect(std.mem.indexOf(u8, rendered, "quote_stale:bool:true") != null); // stale_count on meta should be 1. try testing.expect(std.mem.indexOf(u8, rendered, "stale_count:num:1") != null); }