add snapshot command

This commit is contained in:
Emil Lerch 2026-04-21 21:24:54 -07:00
parent cc24bda690
commit edd9fc0c74
3 changed files with 1045 additions and 0 deletions

136
src/atomic.zig Normal file
View file

@ -0,0 +1,136 @@
//! Atomic filesystem writes.
//!
//! `writeFileAtomic` writes to `<path>.tmp`, fsyncs, and renames to
//! `<path>`. Crash-safe replacement for `createFile + writeAll + close`:
//! if the process dies mid-write, the destination file is left at its
//! prior contents (or absent) rather than truncated or half-written.
//!
//! Used by the snapshot writer so a ctrl-C or kernel panic mid-run
//! can't produce a corrupt `history/<date>-portfolio.srf`.
const std = @import("std");
/// Suffix appended to the temp file during atomic writes. Exposed so
/// callers that want to sweep orphan temp files (e.g. from a previous
/// crash) know what to look for.
pub const tmp_suffix = ".tmp";
/// Write `bytes` to `path` atomically.
///
/// Strategy:
/// 1. Write to `<path>.tmp` (truncating any previous tmp file).
/// 2. `fsync` the tmp file so the data is durable before we rename.
/// 3. Rename tmp -> path (atomic on POSIX when src/dst are on the same
/// filesystem, which is guaranteed here because both are the literal
/// path plus `.tmp`).
///
/// On any error the tmp file is best-effort removed so we don't leave
/// clutter behind. The caller's `path` is unchanged unless the final
/// rename succeeds.
///
/// The allocator is used for a short-lived temp-path buffer
/// (`path.len + tmp_suffix.len` bytes) and freed before return.
pub fn writeFileAtomic(
allocator: std.mem.Allocator,
path: []const u8,
bytes: []const u8,
) !void {
const tmp_path = try std.fmt.allocPrint(allocator, "{s}{s}", .{ path, tmp_suffix });
defer allocator.free(tmp_path);
{
var tmp_file = try std.fs.cwd().createFile(tmp_path, .{
.truncate = true,
.exclusive = false,
});
errdefer {
tmp_file.close();
std.fs.cwd().deleteFile(tmp_path) catch {};
}
try tmp_file.writeAll(bytes);
// fsync so the kernel flushes data to disk before the rename
// appears. Without this, a crash between rename() and the data
// hitting disk could leave an empty-but-present file at `path`.
try tmp_file.sync();
tmp_file.close();
}
std.fs.cwd().rename(tmp_path, path) catch |err| {
std.fs.cwd().deleteFile(tmp_path) catch {};
return err;
};
}
// Tests
test "writeFileAtomic creates new file" {
var tmp_dir = std.testing.tmpDir(.{});
defer tmp_dir.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path = try tmp_dir.dir.realpath(".", &path_buf);
const file_path = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "atomic_new.txt" });
defer std.testing.allocator.free(file_path);
try writeFileAtomic(std.testing.allocator, file_path, "hello world\n");
const contents = try std.fs.cwd().readFileAlloc(std.testing.allocator, file_path, 4096);
defer std.testing.allocator.free(contents);
try std.testing.expectEqualStrings("hello world\n", contents);
// Tmp file should have been consumed by rename.
const tmp_path = try std.fmt.allocPrint(std.testing.allocator, "{s}{s}", .{ file_path, tmp_suffix });
defer std.testing.allocator.free(tmp_path);
try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(tmp_path, .{}));
// Clean up for the next test run.
std.fs.cwd().deleteFile(file_path) catch {};
}
test "writeFileAtomic overwrites existing file" {
var tmp_dir = std.testing.tmpDir(.{});
defer tmp_dir.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path = try tmp_dir.dir.realpath(".", &path_buf);
const file_path = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "atomic_over.txt" });
defer std.testing.allocator.free(file_path);
// Seed with old content.
{
var f = try std.fs.cwd().createFile(file_path, .{});
try f.writeAll("old contents");
f.close();
}
try writeFileAtomic(std.testing.allocator, file_path, "new contents");
const contents = try std.fs.cwd().readFileAlloc(std.testing.allocator, file_path, 4096);
defer std.testing.allocator.free(contents);
try std.testing.expectEqualStrings("new contents", contents);
std.fs.cwd().deleteFile(file_path) catch {};
}
test "writeFileAtomic: missing parent directory surfaces FileNotFound" {
// Point at a path whose parent directory doesn't exist. The tmp dir
// itself exists (so the filesystem is fine), but the "missing"
// subdirectory does not createFile on the .tmp file must fail
// with FileNotFound regardless of platform.
var tmp_dir = std.testing.tmpDir(.{});
defer tmp_dir.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path = try tmp_dir.dir.realpath(".", &path_buf);
const bad_path = try std.fs.path.join(
std.testing.allocator,
&.{ dir_path, "missing", "file.txt" },
);
defer std.testing.allocator.free(bad_path);
try std.testing.expectError(
error.FileNotFound,
writeFileAtomic(std.testing.allocator, bad_path, "x"),
);
}

899
src/commands/snapshot.zig Normal file
View file

@ -0,0 +1,899 @@
//! `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="));
}

View file

@ -19,6 +19,7 @@ const usage =
\\ portfolio Load and analyze the portfolio \\ portfolio Load and analyze the portfolio
\\ analysis Show portfolio analysis \\ analysis Show portfolio analysis
\\ contributions Show money added since last commit (git-based diff) \\ contributions Show money added since last commit (git-based diff)
\\ snapshot [opts] Write a daily portfolio snapshot to history/
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) \\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI \\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
\\ audit [opts] Reconcile portfolio against brokerage export \\ audit [opts] Reconcile portfolio against brokerage export
@ -229,6 +230,7 @@ pub fn main() !u8 {
!std.mem.eql(u8, command, "analysis") and !std.mem.eql(u8, command, "analysis") and
!std.mem.eql(u8, command, "contributions") and !std.mem.eql(u8, command, "contributions") and
!std.mem.eql(u8, command, "portfolio") and !std.mem.eql(u8, command, "portfolio") and
!std.mem.eql(u8, command, "snapshot") and
!std.mem.eql(u8, command, "version"); !std.mem.eql(u8, command, "version");
if (symbol_cmd and cmd_args.len >= 1) { if (symbol_cmd and cmd_args.len >= 1) {
for (cmd_args[0]) |*c| c.* = std.ascii.toUpper(c.*); for (cmd_args[0]) |*c| c.* = std.ascii.toUpper(c.*);
@ -347,6 +349,13 @@ pub fn main() !u8 {
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator); defer if (pf.resolved) |r| r.deinit(allocator);
try commands.contributions.run(allocator, &svc, pf.path, color, out); try commands.contributions.run(allocator, &svc, pf.path, color, out);
} else if (std.mem.eql(u8, command, "snapshot")) {
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
commands.snapshot.run(allocator, &svc, pf.path, cmd_args, color, out) catch |err| switch (err) {
error.UnexpectedArg, error.PortfolioEmpty, error.WriteFailed => return 1,
else => return err,
};
} else { } else {
try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n"); try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n");
return 1; return 1;
@ -418,6 +427,7 @@ const commands = struct {
const audit = @import("commands/audit.zig"); const audit = @import("commands/audit.zig");
const enrich = @import("commands/enrich.zig"); const enrich = @import("commands/enrich.zig");
const contributions = @import("commands/contributions.zig"); const contributions = @import("commands/contributions.zig");
const snapshot = @import("commands/snapshot.zig");
const version = @import("commands/version.zig"); const version = @import("commands/version.zig");
}; };