zfin/src/commands/snapshot.zig
Emil Lerch 6ed2ff1f20
All checks were successful
Generic zig build / build (push) Successful in 1m55s
Generic zig build / deploy (push) Successful in 51s
add snapshot command
2026-04-21 21:24:54 -07:00

899 lines
33 KiB
Zig

//! `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/<as_of_date>-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::<meta|total|tax_type|account|lot>`. 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 <path> 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 `<dir(portfolio_path)>/history/<as_of>-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="));
}