zfin/src/commands/snapshot.zig
Emil Lerch b9d2ee4bd3
All checks were successful
Generic zig build / build (push) Successful in 2m14s
Generic zig build / deploy (push) Successful in 19s
track multiple portfolio files in past revisions as well
2026-05-24 09:44:35 -07:00

1646 lines
66 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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