diff --git a/src/atomic.zig b/src/atomic.zig new file mode 100644 index 0000000..378f835 --- /dev/null +++ b/src/atomic.zig @@ -0,0 +1,136 @@ +//! Atomic filesystem writes. +//! +//! `writeFileAtomic` writes to `.tmp`, fsyncs, and renames to +//! ``. 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/-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 `.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"), + ); +} diff --git a/src/commands/snapshot.zig b/src/commands/snapshot.zig new file mode 100644 index 0000000..a640afe --- /dev/null +++ b/src/commands/snapshot.zig @@ -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/-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=")); +} diff --git a/src/main.zig b/src/main.zig index 6a13ee3..487dfb1 100644 --- a/src/main.zig +++ b/src/main.zig @@ -19,6 +19,7 @@ const usage = \\ portfolio Load and analyze the portfolio \\ analysis Show portfolio analysis \\ contributions Show money added since last commit (git-based diff) + \\ snapshot [opts] Write a daily portfolio snapshot to history/ \\ enrich Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) \\ lookup Look up CUSIP to ticker via OpenFIGI \\ 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, "contributions") and !std.mem.eql(u8, command, "portfolio") and + !std.mem.eql(u8, command, "snapshot") and !std.mem.eql(u8, command, "version"); if (symbol_cmd and cmd_args.len >= 1) { 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); defer if (pf.resolved) |r| r.deinit(allocator); 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 { try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n"); return 1; @@ -418,6 +427,7 @@ const commands = struct { const audit = @import("commands/audit.zig"); const enrich = @import("commands/enrich.zig"); const contributions = @import("commands/contributions.zig"); + const snapshot = @import("commands/snapshot.zig"); const version = @import("commands/version.zig"); };