//! `zfin snapshot` — write a daily portfolio snapshot to `history/`. //! //! Flow: //! 1. Locate portfolio.srf via `config.resolveUserFile` (or -p). //! 2. Derive `history/` dir as `dirname(portfolio.srf)/history/`. //! 3. Load portfolio + prices (via `cli.loadPortfolioPrices`, TTL-driven). //! 4. Compute `as_of_date` from cached candle dates of held non-MM //! stock symbols. //! 5. If `history/-portfolio.srf` already exists and //! --force wasn't passed, skip (exit 0, stderr message). //! 6. Build the snapshot records and write them atomically. //! //! 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 atomic = @import("../atomic.zig"); const version = @import("../version.zig"); const portfolio_mod = @import("../models/portfolio.zig"); const Date = @import("../models/date.zig").Date; pub const SnapshotError = error{ PortfolioEmpty, WriteFailed, }; // ── Entry point ────────────────────────────────────────────── /// Run the snapshot command. /// /// `args` is the slice after `zfin snapshot`. Accepted flags: /// --force overwrite existing snapshot for as_of_date /// --out override output path (skips the default derivation) /// --dry-run compute + print, do not write /// /// Exit semantics: /// 0 on success (including duplicate-skip) /// non-zero on any error pub fn run( allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, args: []const []const u8, color: bool, out: *std.Io.Writer, ) !void { // Parse flags. var force = false; var dry_run = false; var out_override: ?[]const u8 = null; var i: usize = 0; while (i < args.len) : (i += 1) { const a = args[i]; if (std.mem.eql(u8, a, "--force")) { force = true; } else if (std.mem.eql(u8, a, "--dry-run")) { dry_run = true; } else if (std.mem.eql(u8, a, "--out")) { i += 1; if (i >= args.len) { try cli.stderrPrint("Error: --out requires a path argument\n"); return error.UnexpectedArg; } out_override = args[i]; } else { try cli.stderrPrint("Error: unknown argument to 'snapshot': "); try cli.stderrPrint(a); try cli.stderrPrint("\n"); return error.UnexpectedArg; } } // Load portfolio. const pf_data = std.fs.cwd().readFileAlloc(allocator, portfolio_path, 10 * 1024 * 1024) catch |err| { try cli.stderrPrint("Error reading portfolio file: "); try cli.stderrPrint(@errorName(err)); try cli.stderrPrint("\n"); return err; }; defer allocator.free(pf_data); var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch { try cli.stderrPrint("Error parsing portfolio file.\n"); return error.WriteFailed; }; defer portfolio.deinit(); if (portfolio.lots.len == 0) { try cli.stderrPrint("Portfolio is empty; nothing to snapshot.\n"); return SnapshotError.PortfolioEmpty; } // Fetch prices via the shared TTL-driven loader. const syms = try portfolio.stockSymbols(allocator); defer allocator.free(syms); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); if (syms.len > 0) { var load_result = cli.loadPortfolioPrices(svc, syms, &.{}, false, color); defer load_result.deinit(); var it = load_result.prices.iterator(); while (it.next()) |entry| { try prices.put(entry.key_ptr.*, entry.value_ptr.*); } } // Manual `price::` overrides from portfolio.srf still win. for (portfolio.lots) |lot| { if (lot.price) |p| { if (!prices.contains(lot.priceSymbol())) { try prices.put(lot.priceSymbol(), p * lot.price_ratio); } } } // Compute as_of_date 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 = computeAsOfDate(qdates.dates) orelse Date.fromEpoch(std.time.timestamp()); // Derive output path. var as_of_buf: [10]u8 = undefined; const as_of_str = as_of.format(&as_of_buf); 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.fs.cwd().access(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"; try cli.stderrPrint(msg); return; } else |_| {} } // Build and render the snapshot. var snap = try buildSnapshot(allocator, &portfolio, portfolio_path, svc, prices, syms, as_of, qdates); 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.fs.cwd().makePath(dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => { try cli.stderrPrint("Error creating history directory: "); try cli.stderrPrint(@errorName(err)); try cli.stderrPrint("\n"); return err; }, }; } atomic.writeFileAtomic(allocator, derived_path, rendered) catch |err| { try cli.stderrPrint("Error writing snapshot: "); try cli.stderrPrint(@errorName(err)); try cli.stderrPrint("\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 }); } // ── 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, }; /// 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 allocator.free(cs); if (cs.len > 0) last_date = cs[cs.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 ───────────────────────────────────────── // // Each record kind below is a plain struct suitable for `srf.fmtFrom`. // Field order in the struct declaration IS the on-disk order — srf's // `Record.from` iterates `inline for (info.fields)`. The leading `kind` // field is the discriminator readers demux on. // // IMPORTANT: `kind` does NOT have a default value. SRF elides fields // whose value matches the declared default (see setField in srf.zig), // so a `kind: []const u8 = "meta"` would vanish from the output. Each // construction site supplies the tag explicitly. // // Optional fields default to `null` so they're elided on null values — // that's the behavior we want for `price`, `quote_date`, etc. pub const MetaRow = struct { kind: []const u8, snapshot_version: u32, as_of_date: Date, captured_at: i64, zfin_version: []const u8, quote_date_min: ?Date = null, quote_date_max: ?Date = null, stale_count: usize, }; pub const TotalRow = struct { kind: []const u8, scope: []const u8, value: f64, }; pub const TaxTypeRow = struct { kind: []const u8, label: []const u8, value: f64, }; pub const AccountRow = struct { kind: []const u8, name: []const u8, value: f64, }; pub const LotRow = struct { kind: []const u8, symbol: []const u8, lot_symbol: []const u8, account: []const u8, security_type: []const u8, shares: f64, open_price: f64, cost_basis: f64, value: f64, /// Null for non-stock lots (cash/CD/illiquid have no per-share price). price: ?f64 = null, /// Null for non-stock lots. quote_date: ?Date = null, /// Emitted only when true (default is false, which srf skips). quote_stale: bool = false, }; pub const Snapshot = struct { meta: MetaRow, totals: []TotalRow, tax_types: []TaxTypeRow, accounts: []AccountRow, lots: []LotRow, pub fn deinit(self: *Snapshot, allocator: std.mem.Allocator) void { allocator.free(self.totals); allocator.free(self.tax_types); allocator.free(self.accounts); allocator.free(self.lots); } }; /// Build the full snapshot in memory. Does not touch disk. fn buildSnapshot( allocator: std.mem.Allocator, portfolio: *zfin.Portfolio, portfolio_path: []const u8, svc: *zfin.DataService, prices: std.StringHashMap(f64), syms: []const []const u8, as_of: Date, qdates: QuoteDates, ) !Snapshot { // Totals const positions = try portfolio.positions(allocator); 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(allocator, portfolio.*, positions, prices, manual_set); defer summary.deinit(allocator); const illiquid = portfolio.totalIlliquid(); const net_worth = zfin.valuation.netWorth(portfolio.*, summary); 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 }; // Analysis (optional — depends on metadata.srf existing). If it // fails we still emit the snapshot with empty tax_type/account // sections rather than failing the whole capture. var tax_types: []TaxTypeRow = &.{}; var accounts: []AccountRow = &.{}; if (runAnalysis(allocator, portfolio, portfolio_path, svc, summary)) |result| { var a = result; defer a.deinit(allocator); 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 }; } } else |_| { // Silent: metadata.srf may legitimately not exist during initial // setup. Header is already emitted; missing-analysis just means // fewer breakdowns in the snapshot. } // 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| { if (!lot.isOpen()) 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 = if (is_manual) raw_price else raw_price * lot.price_ratio; const value = lot.shares * effective_price; var quote_date: ?Date = null; for (qdates.dates) |qi| { if (std.mem.eql(u8, qi.symbol, price_sym)) { quote_date = qi.last_date; break; } } const stale = if (quote_date) |qd| !qd.eql(as_of) else false; 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 = std.time.timestamp(), .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( allocator: std.mem.Allocator, portfolio: *zfin.Portfolio, portfolio_path: []const u8, svc: *zfin.DataService, summary: zfin.valuation.PortfolioSummary, ) !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.fs.cwd().readFileAlloc(allocator, meta_path, 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, ); } // ── 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 "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 "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 = "Emil 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 = "Emil 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 = "Emil 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::Emil 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=")); }