//! History IO — read `history/-portfolio.srf` files produced by //! `zfin snapshot` back into typed `Snapshot` structs. Also the //! pure-domain aggregation helpers that turn a parsed snapshot into //! the shapes downstream views consume. //! //! Three layers, all pure of rendering concerns: //! //! - `parseSnapshotBytes(bytes)` — parse an SRF blob into a `Snapshot`. //! The snapshot's string fields slice directly into `bytes`, so the //! caller MUST keep that buffer alive as long as the snapshot. //! - `loadHistoryDir(dir)` — enumerate `*-portfolio.srf` in a directory //! and parse each. The returned `LoadedHistory` owns both the //! snapshots and their backing byte buffers as matched pairs. //! - `liquidFromSnapshot(snap)`, `aggregateSnapshotAllocations(...)` — //! pure-domain transforms on a parsed snapshot, used by the //! projection view. The compare-view-specific aggregator //! (`aggregateSnapshotStocks`, producing a view-layer `HoldingMap`) //! lives in `src/compare.zig` to avoid inverting the layer direction. //! //! The snapshot reader is discriminator-driven: every record must carry //! a `kind::` field. Records whose //! `kind` is set to something this version doesn't recognize are //! skipped (forward-compatibility). Malformed records — missing `kind`, //! missing required fields within a known kind, coercion failures — are //! treated as parse errors, not silently dropped. //! //! Lives at `src/history.zig` rather than `src/commands/history.zig` //! because the IO is used by more than the CLI history command (the //! rollup builder, future TUI history tab, and any external consumer //! all go through here). The command module stays a thin CLI wrapper. const std = @import("std"); const srf = @import("srf"); const snapshot = @import("models/snapshot.zig"); const Date = @import("models/date.zig").Date; const Candle = @import("models/candle.zig").Candle; const timeline = @import("analytics/timeline.zig"); const valuation = @import("analytics/valuation.zig"); pub const Error = error{ /// The file didn't open a `#!srfv1` directive or couldn't be /// iterated as SRF. InvalidSrf, /// The file parsed as SRF but had no `kind::meta` record, so we /// can't identify it as a snapshot. NoMetaRecord, /// Allocator returned OOM somewhere during parsing. OutOfMemory, }; /// Suffix that identifies snapshot files in `history/` directory. pub const snapshot_suffix = "-portfolio.srf"; // ── Single-file parsing ────────────────────────────────────── /// Parse an SRF blob into a `Snapshot`. The snapshot's string fields /// borrow directly from `bytes` (zero-copy), so the caller MUST keep /// `bytes` alive for at least as long as the returned snapshot. /// /// Typical call pattern: /// ``` /// const bytes = try readFileAlloc(...); /// defer allocator.free(bytes); /// var snap = try parseSnapshotBytes(allocator, bytes); /// defer snap.deinit(allocator); /// ``` pub fn parseSnapshotBytes( allocator: std.mem.Allocator, bytes: []const u8, ) Error!snapshot.Snapshot { var reader = std.Io.Reader.fixed(bytes); // `alloc_strings = false` tells srf to return string values as // slices into `bytes` rather than duping into its own arena. var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidSrf; defer it.deinit(); var meta_opt: ?snapshot.MetaRow = null; var totals: std.ArrayList(snapshot.TotalRow) = .empty; errdefer totals.deinit(allocator); var taxes: std.ArrayList(snapshot.TaxTypeRow) = .empty; errdefer taxes.deinit(allocator); var accounts: std.ArrayList(snapshot.AccountRow) = .empty; errdefer accounts.deinit(allocator); var lots: std.ArrayList(snapshot.LotRow) = .empty; errdefer lots.deinit(allocator); while (it.next() catch return error.InvalidSrf) |field_it| { // `to(SnapshotRecord)` reads the `kind` discriminator first, then // coerces the remaining fields into the matching variant struct. // // We skip ONLY `ActiveTagDoesNotExist` — that's the genuine // forward-compatibility case (a future snapshot version wrote a // record kind we don't know about). Every other srf error // indicates malformed data in a record we SHOULD understand, so // we propagate it up rather than silently losing rows. const rec = field_it.to(SnapshotRecord) catch |err| switch (err) { error.ActiveTagDoesNotExist => continue, else => return error.InvalidSrf, }; switch (rec) { .meta => |m| { if (meta_opt == null) meta_opt = m; }, .total => |r| try totals.append(allocator, r), .tax_type => |r| try taxes.append(allocator, r), .account => |r| try accounts.append(allocator, r), .lot => |r| try lots.append(allocator, r), } } const meta = meta_opt orelse return error.NoMetaRecord; return .{ .meta = meta, .totals = try totals.toOwnedSlice(allocator), .tax_types = try taxes.toOwnedSlice(allocator), .accounts = try accounts.toOwnedSlice(allocator), .lots = try lots.toOwnedSlice(allocator), }; } /// Discriminated snapshot record. SRF's `FieldIterator.to(T)` dispatches /// on the `kind` field (per `srf_tag_field`), consumes it, and then /// coerces remaining fields into the matching variant struct. Variant /// names here MUST match the wire-format `kind` values literally. const SnapshotRecord = union(enum) { meta: snapshot.MetaRow, total: snapshot.TotalRow, tax_type: snapshot.TaxTypeRow, account: snapshot.AccountRow, lot: snapshot.LotRow, pub const srf_tag_field = "kind"; }; // ── Directory loading ──────────────────────────────────────── /// Result of `loadHistoryDir` — caller owns. /// /// Holds snapshots and their backing byte buffers as parallel slices /// (same length, matched by index). The buffers are kept alive here /// because each snapshot borrows strings from its corresponding buffer. /// `deinit` frees both in the right order. pub const LoadedHistory = struct { snapshots: []snapshot.Snapshot, /// Per-snapshot backing buffers, parallel to `snapshots`. Empty /// slice when `snapshots` is empty. buffers: [][]u8, allocator: std.mem.Allocator, pub fn deinit(self: *LoadedHistory) void { for (self.snapshots) |*s| s.deinit(self.allocator); self.allocator.free(self.snapshots); for (self.buffers) |b| self.allocator.free(b); self.allocator.free(self.buffers); } }; /// Enumerate `*-portfolio.srf` in `history_dir` and parse each into a /// `Snapshot`. Files that fail to parse are skipped with a stderr /// warning; callers get back only the ones that loaded cleanly. /// /// Returned snapshots are in filesystem enumeration order — NOT sorted. /// Consumers that want chronological order should feed through /// `analytics.timeline.buildSeries` (which sorts) rather than relying /// on the loader's order. pub fn loadHistoryDir( allocator: std.mem.Allocator, history_dir: []const u8, ) !LoadedHistory { var dir = std.fs.cwd().openDir(history_dir, .{ .iterate = true }) catch |err| switch (err) { error.FileNotFound => { // Missing history dir isn't fatal — it just means no // snapshots captured yet. return .{ .snapshots = &.{}, .buffers = &.{}, .allocator = allocator }; }, else => return err, }; defer dir.close(); var snapshots: std.ArrayList(snapshot.Snapshot) = .empty; var buffers: std.ArrayList([]u8) = .empty; errdefer { for (snapshots.items) |*s| s.deinit(allocator); snapshots.deinit(allocator); for (buffers.items) |b| allocator.free(b); buffers.deinit(allocator); } var it = dir.iterate(); while (try it.next()) |entry| { if (entry.kind != .file) continue; if (!std.mem.endsWith(u8, entry.name, snapshot_suffix)) continue; const full_path = try std.fs.path.join(allocator, &.{ history_dir, entry.name }); defer allocator.free(full_path); const bytes = std.fs.cwd().readFileAlloc(allocator, full_path, 16 * 1024 * 1024) catch |err| { std.log.warn("history: failed to read {s}: {s}", .{ full_path, @errorName(err) }); continue; }; // `bytes` is freed either by LoadedHistory.deinit on success or // by the branch below on parse failure — no defer-free here. const snap = parseSnapshotBytes(allocator, bytes) catch |err| { std.log.warn("history: failed to parse {s}: {s}", .{ full_path, @errorName(err) }); allocator.free(bytes); continue; }; try snapshots.append(allocator, snap); try buffers.append(allocator, bytes); } return .{ .snapshots = try snapshots.toOwnedSlice(allocator), .buffers = try buffers.toOwnedSlice(allocator), .allocator = allocator, }; } /// Derive `/history` and return the joined /// path (caller-owned). Thin helper, but exposed so CLI and TUI agree /// on the convention (history/ is always a sibling of portfolio.srf). pub fn deriveHistoryDir( allocator: std.mem.Allocator, portfolio_path: []const u8, ) ![]u8 { const portfolio_dir = std.fs.path.dirname(portfolio_path) orelse "."; return std.fs.path.join(allocator, &.{ portfolio_dir, "history" }); } /// Result of `loadTimeline` — bundles the raw snapshot collection and /// the derived timeline series so callers can reach either without /// re-parsing. /// /// `series.points` is sorted ascending by date; `loaded.snapshots` is /// in filesystem enumeration order. Both are kept alive together — /// `series.points` references dates that live inside `loaded`'s /// snapshot rows, and the callers may want `loaded.snapshots` directly /// for non-timeline uses (e.g. rollup building). pub const LoadedTimeline = struct { loaded: LoadedHistory, series: timeline.TimelineSeries, /// Directory we loaded from, caller-owned. Carried through for /// callers that want to print diagnostics or locate sibling files /// (rollup.srf, etc.). history_dir: []u8, allocator: std.mem.Allocator, pub fn deinit(self: *LoadedTimeline) void { self.series.deinit(); self.loaded.deinit(); self.allocator.free(self.history_dir); } }; /// End-to-end snapshot timeline loader: derives history/, reads every /// `*-portfolio.srf` file, and builds the sorted timeline series. The /// single entry point used by both the CLI `zfin history` command and /// the TUI history tab — their earlier copies had subtle divergences /// (different dir-split logic, slightly different empty-state ordering) /// that a shared helper rules out. /// /// Returns `loaded.snapshots.len == 0` on an empty history dir rather /// than erroring; callers check and produce their own "no snapshots" /// message. Parse failures on individual files are logged to stderr by /// `loadHistoryDir` and the offending file is skipped. pub fn loadTimeline( allocator: std.mem.Allocator, portfolio_path: []const u8, ) !LoadedTimeline { const history_dir = try deriveHistoryDir(allocator, portfolio_path); errdefer allocator.free(history_dir); var loaded = try loadHistoryDir(allocator, history_dir); errdefer loaded.deinit(); const series = try timeline.buildSeries(allocator, loaded.snapshots); return .{ .loaded = loaded, .series = series, .history_dir = history_dir, .allocator = allocator, }; } // ── Single-snapshot loading ────────────────────────────────── /// Owned snapshot + its backing byte buffer. Strings inside `snap` /// borrow from `bytes`, so the two must be freed in order: snap first /// (releases its arrays), then bytes (releases the string storage). /// `deinit` handles the order. pub const LoadedSnapshot = struct { snap: snapshot.Snapshot, bytes: []u8, pub fn deinit(self: *LoadedSnapshot, allocator: std.mem.Allocator) void { self.snap.deinit(allocator); allocator.free(self.bytes); } }; /// Read + parse `history_dir/-portfolio.srf`. Returns /// `error.FileNotFound` if the exact file doesn't exist; the caller is /// responsible for deciding how to surface that (CLI prints a /// suggestion via `findNearestSnapshot`, TUI just won't offer missing /// dates as selectable rows). pub fn loadSnapshotAt( allocator: std.mem.Allocator, history_dir: []const u8, date: Date, ) !LoadedSnapshot { var date_buf: [10]u8 = undefined; const date_str = date.format(&date_buf); const filename = try std.fmt.allocPrint(allocator, "{s}{s}", .{ date_str, snapshot_suffix }); defer allocator.free(filename); const full_path = try std.fs.path.join(allocator, &.{ history_dir, filename }); defer allocator.free(full_path); const bytes = try std.fs.cwd().readFileAlloc(allocator, full_path, 16 * 1024 * 1024); errdefer allocator.free(bytes); const snap = try parseSnapshotBytes(allocator, bytes); return .{ .snap = snap, .bytes = bytes }; } /// Nearest-snapshot search result. `earlier` and `later` are /// independently null if no snapshot exists on that side of `target`. pub const Nearest = struct { earlier: ?Date, later: ?Date, }; /// Scan `history_dir` for `YYYY-MM-DD-portfolio.srf` filenames and /// return the closest date strictly earlier than `target` and the /// closest date strictly later than `target`. Files whose name doesn't /// parse as an ISO date + the snapshot suffix are ignored. /// /// Pure function — no stderr side effects. CLI callers that want to /// print a "no snapshot for X; nearest is Y" hint compose this with /// their own output pass. pub fn findNearestSnapshot( history_dir: []const u8, target: Date, ) !Nearest { var dir = std.fs.cwd().openDir(history_dir, .{ .iterate = true }) catch |err| switch (err) { error.FileNotFound => return .{ .earlier = null, .later = null }, else => return err, }; defer dir.close(); var earlier: ?Date = null; var later: ?Date = null; var it = dir.iterate(); while (try it.next()) |entry| { if (entry.kind != .file) continue; if (!std.mem.endsWith(u8, entry.name, snapshot_suffix)) continue; const expected_len = 10 + snapshot_suffix.len; if (entry.name.len != expected_len) continue; const d = Date.parse(entry.name[0..10]) catch continue; if (d.days < target.days) { if (earlier == null or d.days > earlier.?.days) earlier = d; } else if (d.days > target.days) { if (later == null or d.days < later.?.days) later = d; } // Exact hit (d == target) is ignored — this function only reports // neighbors. Callers with an exact match use loadSnapshotAt. } return .{ .earlier = earlier, .later = later }; } /// Result of resolving a requested snapshot date against the history /// directory. `exact` is true when the requested date had its own /// snapshot file; false when we auto-snapped to the nearest earlier. pub const ResolvedSnapshot = struct { requested: Date, actual: Date, exact: bool, }; pub const ResolveSnapshotError = error{ /// No snapshot file exists at or before the requested date. NoSnapshotAtOrBefore, } || std.mem.Allocator.Error || std.fs.Dir.AccessError || std.fs.File.OpenError; /// Resolve a requested snapshot date against `hist_dir`: /// - If `hist_dir/-portfolio.srf` exists, return it as /// an exact match. /// - Otherwise, look up the nearest earlier snapshot via /// `findNearestSnapshot`. Return it as an inexact match. /// - If nothing exists at or before `requested`, return /// `error.NoSnapshotAtOrBefore` — the caller decides how to /// surface that to the user (CLI: stderr; TUI: status bar). /// /// Shared between the CLI (`zfin projections --as-of `) and TUI /// (projections tab date popup) to avoid duplicating the /// exact-then-fallback resolution logic. /// /// Uses `arena` for the two small intermediate strings (filename, /// full path). Pass a short-lived arena; the returned struct has no /// borrowed references. pub fn resolveSnapshotDate( arena: std.mem.Allocator, hist_dir: []const u8, requested: Date, ) ResolveSnapshotError!ResolvedSnapshot { var date_buf: [10]u8 = undefined; const date_str = requested.format(&date_buf); const filename = try std.fmt.allocPrint(arena, "{s}{s}", .{ date_str, snapshot_suffix }); const full_path = try std.fs.path.join(arena, &.{ hist_dir, filename }); std.fs.cwd().access(full_path, .{}) catch |err| switch (err) { error.FileNotFound => { const nearest = findNearestSnapshot(hist_dir, requested) catch |e| return e; if (nearest.earlier) |earlier| { return .{ .requested = requested, .actual = earlier, .exact = false }; } return error.NoSnapshotAtOrBefore; }, else => |e| return e, }; return .{ .requested = requested, .actual = requested, .exact = true }; } // ── Pure-domain aggregation ─────────────────────────────────── /// Return the prefix of `candles` whose dates are `<= as_of`. /// /// When `as_of` is null, returns the full slice unchanged (live mode /// pass-through). When set, binary-searches for the first index /// strictly after `as_of` and slices up to it. Zero-length slice /// when `as_of` precedes all cached candles. /// /// Candles are assumed sorted by date ascending. Used to truncate /// benchmark and per-symbol price history for historical projections — /// `performance.trailingReturns` uses the last candle's date as the /// endpoint, so trimming the tail is equivalent to "compute as of /// that date". pub fn sliceCandlesAsOf(candles: []const Candle, as_of: ?Date) []const Candle { const d = as_of orelse return candles; if (candles.len == 0) return candles; var lo: usize = 0; var hi: usize = candles.len; while (lo < hi) { const mid = lo + (hi - lo) / 2; const cd = candles[mid].date; if (cd.lessThan(d) or cd.eql(d)) { lo = mid + 1; } else { hi = mid; } } return candles[0..lo]; } /// Find the `scope=="liquid"` total in a snapshot. Returns 0.0 if not /// present (old snapshots from before the liquid/illiquid split — /// shouldn't happen in practice). pub fn liquidFromSnapshot(snap: *const snapshot.Snapshot) f64 { for (snap.totals) |t| { if (std.mem.eql(u8, t.scope, "liquid")) return t.value; } return 0.0; } /// Per-symbol allocations derived from a snapshot's lot rows, plus /// the totals needed to feed `benchmark.deriveAllocationSplit`. /// /// String fields inside `allocations` (`symbol`, `display_symbol`) /// borrow from the snapshot's backing buffer. Keep the snapshot (and /// its bytes) alive for the lifetime of these allocations. pub const SnapshotAllocations = struct { allocations: []valuation.Allocation, total_value: f64, cash_value: f64, cd_value: f64, /// Free the `allocations` slice. `alloc` MUST be the same allocator /// passed to `aggregateSnapshotAllocations` — the slice is owned /// by that allocator, not tracked internally. pub fn deinit(self: *SnapshotAllocations, alloc: std.mem.Allocator) void { alloc.free(self.allocations); } }; /// Aggregate a snapshot's `LotRow`s into per-symbol `Allocation`s. /// /// Matches the lot aggregation that `valuation.portfolioSummary` does /// for live portfolios: sum `value` per `symbol`, compute weight /// against the snapshot's `liquid` total. Non-stock lots contribute /// to `cash_value` / `cd_value` instead of the allocation list. /// /// Security-type strings come from `LotType.label()` in the snapshot /// writer — "Stock", "Cash", "CD", "Option", "Illiquid". Match is /// case-sensitive, consistent with `aggregateSnapshotStocks` in /// `src/compare.zig`. /// /// The returned `Allocation`s only populate `symbol`, `display_symbol`, /// `market_value`, and `weight` — every other field is zero. This is /// enough for `deriveAllocationSplit` and the per-position trailing /// returns loop; nothing downstream reads cost basis or shares here. pub fn aggregateSnapshotAllocations( alloc: std.mem.Allocator, snap: *const snapshot.Snapshot, ) !SnapshotAllocations { const total_value = liquidFromSnapshot(snap); var map = std.StringHashMap(f64).init(alloc); defer map.deinit(); var cash_value: f64 = 0; var cd_value: f64 = 0; for (snap.lots) |lot| { if (std.mem.eql(u8, lot.security_type, "Cash")) { cash_value += lot.value; continue; } if (std.mem.eql(u8, lot.security_type, "CD")) { cd_value += lot.value; continue; } if (std.mem.eql(u8, lot.security_type, "Illiquid")) { // Illiquid lots aren't in the liquid total and don't feed // benchmark projections. Skip. continue; } // Stock + Option: aggregate by `symbol`. For stocks the // snapshot writer stores `symbol = lot.priceSymbol()` (the // pricing ticker used for cache lookups, e.g. "BRK-B"), // distinct from `lot_symbol` which preserves the user's // original form (e.g. "BRK.B"). For options, `symbol` is the // contract identifier — options won't have candles in the // cache, so they're silently dropped from the per-position // trailing returns loop downstream; they still count toward // total market value and allocation weight. const gop = try map.getOrPut(lot.symbol); if (gop.found_existing) { gop.value_ptr.* += lot.value; } else { gop.value_ptr.* = lot.value; } } var allocations = try alloc.alloc(valuation.Allocation, map.count()); errdefer alloc.free(allocations); var i: usize = 0; var it = map.iterator(); while (it.next()) |e| : (i += 1) { const mv = e.value_ptr.*; allocations[i] = .{ .symbol = e.key_ptr.*, .display_symbol = e.key_ptr.*, .shares = 0, .avg_cost = 0, .current_price = 0, .market_value = mv, .cost_basis = 0, .weight = if (total_value > 0) mv / total_value else 0, .unrealized_gain_loss = 0, .unrealized_return = 0.0, }; } return .{ .allocations = allocations, .total_value = total_value, .cash_value = cash_value, .cd_value = cd_value, }; } // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; /// Test helper: dupe a string literal into testing.allocator and parse /// it. Returns the snapshot AND the owned bytes so the test can free /// both in the correct order (snapshot first, then bytes). const ParsedLiteral = struct { snap: snapshot.Snapshot, bytes: []u8, fn deinit(self: *ParsedLiteral) void { self.snap.deinit(testing.allocator); testing.allocator.free(self.bytes); } }; fn parseLiteral(input: []const u8) !ParsedLiteral { const bytes = try testing.allocator.dupe(u8, input); errdefer testing.allocator.free(bytes); const snap = try parseSnapshotBytes(testing.allocator, bytes); return .{ .snap = snap, .bytes = bytes }; } test "parseSnapshotBytes: minimal meta + totals round-trip" { const input = \\#!srfv1 \\#!created=1700000000 \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:1700000000,zfin_version::v0.1.0,stale_count:num:0 \\kind::total,scope::net_worth,value:num:1000 \\kind::total,scope::liquid,value:num:800 \\kind::total,scope::illiquid,value:num:200 \\ ; var parsed = try parseLiteral(input); defer parsed.deinit(); const snap = parsed.snap; // Note: `snap.meta.kind` is `""` post-parse — the `kind` discriminator // is consumed by union dispatch (see `SnapshotRecord`). The union tag // is the source of truth for record type, not `.kind`. try testing.expectEqual(@as(u32, 1), snap.meta.snapshot_version); try testing.expect(snap.meta.as_of_date.eql(Date.fromYmd(2026, 4, 17))); try testing.expectEqualStrings("v0.1.0", snap.meta.zfin_version); try testing.expectEqual(@as(i64, 1_700_000_000), snap.meta.captured_at); try testing.expectEqual(@as(usize, 0), snap.meta.stale_count); try testing.expect(snap.meta.quote_date_min == null); try testing.expect(snap.meta.quote_date_max == null); try testing.expectEqual(@as(usize, 3), snap.totals.len); try testing.expectEqualStrings("net_worth", snap.totals[0].scope); try testing.expectEqual(@as(f64, 1000), snap.totals[0].value); try testing.expectEqualStrings("liquid", snap.totals[1].scope); try testing.expectEqual(@as(f64, 800), snap.totals[1].value); try testing.expectEqualStrings("illiquid", snap.totals[2].scope); try testing.expectEqual(@as(f64, 200), snap.totals[2].value); try testing.expectEqual(@as(usize, 0), snap.tax_types.len); try testing.expectEqual(@as(usize, 0), snap.accounts.len); try testing.expectEqual(@as(usize, 0), snap.lots.len); } test "parseSnapshotBytes: with tax_type, account, and lot records" { const input = \\#!srfv1 \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:0,zfin_version::x,stale_count:num:0 \\kind::total,scope::net_worth,value:num:1500 \\kind::tax_type,label::Taxable,value:num:1000 \\kind::tax_type,label::Roth (Post-Tax),value:num:500 \\kind::account,name::Emil Roth,value:num:800 \\kind::lot,symbol::VTI,lot_symbol::VTI,account::Emil Roth,security_type::Stock,shares:num:10,open_price:num:200,cost_basis:num:2000,value:num:2500,price:num:250,quote_date::2026-04-17 \\ ; var parsed = try parseLiteral(input); defer parsed.deinit(); const snap = parsed.snap; try testing.expectEqual(@as(usize, 2), snap.tax_types.len); try testing.expectEqualStrings("Taxable", snap.tax_types[0].label); try testing.expectEqualStrings("Roth (Post-Tax)", snap.tax_types[1].label); try testing.expectEqual(@as(usize, 1), snap.accounts.len); try testing.expectEqualStrings("Emil Roth", snap.accounts[0].name); try testing.expectEqual(@as(f64, 800), snap.accounts[0].value); try testing.expectEqual(@as(usize, 1), snap.lots.len); try testing.expectEqualStrings("VTI", snap.lots[0].symbol); try testing.expect(snap.lots[0].price != null); try testing.expectEqual(@as(f64, 250), snap.lots[0].price.?); try testing.expect(snap.lots[0].quote_date != null); try testing.expect(snap.lots[0].quote_date.?.eql(Date.fromYmd(2026, 4, 17))); try testing.expect(!snap.lots[0].quote_stale); } test "parseSnapshotBytes: lot with stale flag and optional price absent" { const input = \\#!srfv1 \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:0,zfin_version::x,stale_count:num:1 \\kind::lot,symbol::CASH,lot_symbol::CASH,account::Checking,security_type::Cash,shares:num:500,open_price:num:0,cost_basis:num:0,value:num:500 \\kind::lot,symbol::OLDQ,lot_symbol::OLDQ,account::IRA,security_type::Stock,shares:num:1,open_price:num:100,cost_basis:num:100,value:num:95,price:num:95,quote_date::2026-04-15,quote_stale:bool:true \\ ; var parsed = try parseLiteral(input); defer parsed.deinit(); const snap = parsed.snap; try testing.expectEqual(@as(usize, 2), snap.lots.len); // Cash: no price, no quote_date, no stale flag try testing.expect(snap.lots[0].price == null); try testing.expect(snap.lots[0].quote_date == null); try testing.expect(!snap.lots[0].quote_stale); // Stale stock lot try testing.expect(snap.lots[1].quote_stale); try testing.expect(snap.lots[1].quote_date != null); try testing.expect(snap.lots[1].quote_date.?.eql(Date.fromYmd(2026, 4, 15))); } test "parseSnapshotBytes: quote_date_min/max in meta round-trip" { const input = \\#!srfv1 \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:0,zfin_version::x,quote_date_min::2026-04-14,quote_date_max::2026-04-17,stale_count:num:3 \\ ; var parsed = try parseLiteral(input); defer parsed.deinit(); const snap = parsed.snap; try testing.expect(snap.meta.quote_date_min != null); try testing.expect(snap.meta.quote_date_min.?.eql(Date.fromYmd(2026, 4, 14))); try testing.expect(snap.meta.quote_date_max != null); try testing.expect(snap.meta.quote_date_max.?.eql(Date.fromYmd(2026, 4, 17))); try testing.expectEqual(@as(usize, 3), snap.meta.stale_count); } test "parseSnapshotBytes: unknown kind is silently skipped" { const input = \\#!srfv1 \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:0,zfin_version::x,stale_count:num:0 \\kind::future_extension,some_field::some_value \\kind::total,scope::net_worth,value:num:100 \\ ; var parsed = try parseLiteral(input); defer parsed.deinit(); const snap = parsed.snap; try testing.expectEqual(@as(usize, 1), snap.totals.len); } test "parseSnapshotBytes: record without kind field is a parse error" { // A record missing the `kind` discriminator is malformed data, not // forward-compat. We must not silently drop it. const input = \\#!srfv1 \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:0,zfin_version::x,stale_count:num:0 \\random_field::random_value \\kind::total,scope::net_worth,value:num:100 \\ ; try testing.expectError(error.InvalidSrf, parseLiteral(input)); } test "parseSnapshotBytes: missing meta record returns error" { const input = \\#!srfv1 \\kind::total,scope::net_worth,value:num:100 \\ ; try testing.expectError(error.NoMetaRecord, parseLiteral(input)); } test "parseSnapshotBytes: totally malformed input returns error" { // Not valid srf at all. const input = "this is not srf data\x00\xff\x00"; const result = parseLiteral(input); // Either InvalidSrf (iterator failed) or NoMetaRecord (iterator // returned nothing). Both are acceptable failure modes; the test // just asserts we don't panic or succeed. try testing.expect(std.meta.isError(result)); } test "loadHistoryDir: missing directory returns empty result" { // No dir created; should silently yield an empty list rather than // raising FileNotFound to the caller. var result = try loadHistoryDir(testing.allocator, "/nonexistent/path/for/testing"); defer result.deinit(); try testing.expectEqual(@as(usize, 0), result.snapshots.len); } test "loadHistoryDir: loads snapshots and skips non-matching files" { var tmp_dir = testing.tmpDir(.{}); defer tmp_dir.cleanup(); // Seed three files: // 2026-04-17-portfolio.srf — valid // 2026-04-18-portfolio.srf — valid // readme.txt — non-matching extension, should be skipped const snap_bytes = \\#!srfv1 \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:0,zfin_version::x,stale_count:num:0 \\kind::total,scope::net_worth,value:num:1000 \\ ; const snap2_bytes = \\#!srfv1 \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-18,captured_at:num:0,zfin_version::x,stale_count:num:0 \\kind::total,scope::net_worth,value:num:1100 \\ ; { var f = try tmp_dir.dir.createFile("2026-04-17-portfolio.srf", .{}); try f.writeAll(snap_bytes); f.close(); } { var f = try tmp_dir.dir.createFile("2026-04-18-portfolio.srf", .{}); try f.writeAll(snap2_bytes); f.close(); } { var f = try tmp_dir.dir.createFile("readme.txt", .{}); try f.writeAll("not a snapshot"); f.close(); } var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_path = try tmp_dir.dir.realpath(".", &path_buf); var result = try loadHistoryDir(testing.allocator, dir_path); defer result.deinit(); try testing.expectEqual(@as(usize, 2), result.snapshots.len); // Each loaded snapshot has a meta and one total. Values differ so we // can tell them apart regardless of filesystem enumeration order. var saw_1000 = false; var saw_1100 = false; for (result.snapshots) |s| { try testing.expectEqual(@as(usize, 1), s.totals.len); if (s.totals[0].value == 1000) saw_1000 = true; if (s.totals[0].value == 1100) saw_1100 = true; } try testing.expect(saw_1000); try testing.expect(saw_1100); } test "loadHistoryDir: corrupt files are skipped, others still load" { var tmp_dir = testing.tmpDir(.{}); defer tmp_dir.cleanup(); const good_bytes = \\#!srfv1 \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:0,zfin_version::x,stale_count:num:0 \\ ; { var f = try tmp_dir.dir.createFile("2026-04-17-portfolio.srf", .{}); try f.writeAll(good_bytes); f.close(); } { var f = try tmp_dir.dir.createFile("2026-04-18-portfolio.srf", .{}); try f.writeAll("totally-not-srf\n"); f.close(); } var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_path = try tmp_dir.dir.realpath(".", &path_buf); var result = try loadHistoryDir(testing.allocator, dir_path); defer result.deinit(); // Only the good one lands. try testing.expectEqual(@as(usize, 1), result.snapshots.len); } // ── findNearestSnapshot / loadSnapshotAt tests ───────────────── test "findNearestSnapshot: empty dir returns both null" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const path = try tmp.dir.realpathAlloc(testing.allocator, "."); defer testing.allocator.free(path); const result = try findNearestSnapshot(path, Date.fromYmd(2024, 3, 15)); try testing.expectEqual(@as(?Date, null), result.earlier); try testing.expectEqual(@as(?Date, null), result.later); } test "findNearestSnapshot: non-existent dir returns both null" { const result = try findNearestSnapshot( "/tmp/zfin-history-nearest-never-exists-91823", Date.fromYmd(2024, 3, 15), ); try testing.expectEqual(@as(?Date, null), result.earlier); try testing.expectEqual(@as(?Date, null), result.later); } test "findNearestSnapshot: earlier and later around gap" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(.{ .sub_path = "2024-03-10-portfolio.srf", .data = "" }); try tmp.dir.writeFile(.{ .sub_path = "2024-03-12-portfolio.srf", .data = "" }); try tmp.dir.writeFile(.{ .sub_path = "2024-03-15-portfolio.srf", .data = "" }); try tmp.dir.writeFile(.{ .sub_path = "2024-03-20-portfolio.srf", .data = "" }); // Noise files that should be ignored. try tmp.dir.writeFile(.{ .sub_path = "random.txt", .data = "" }); try tmp.dir.writeFile(.{ .sub_path = "rollup.srf", .data = "" }); try tmp.dir.writeFile(.{ .sub_path = "bogus-date-portfolio.srf", .data = "" }); const path = try tmp.dir.realpathAlloc(testing.allocator, "."); defer testing.allocator.free(path); const result = try findNearestSnapshot(path, Date.fromYmd(2024, 3, 14)); try testing.expect(result.earlier != null); try testing.expect(result.later != null); try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 12).days), result.earlier.?.days); try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 15).days), result.later.?.days); } test "findNearestSnapshot: before earliest — only later set" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(.{ .sub_path = "2024-03-10-portfolio.srf", .data = "" }); try tmp.dir.writeFile(.{ .sub_path = "2024-03-12-portfolio.srf", .data = "" }); const path = try tmp.dir.realpathAlloc(testing.allocator, "."); defer testing.allocator.free(path); const result = try findNearestSnapshot(path, Date.fromYmd(2024, 1, 1)); try testing.expectEqual(@as(?Date, null), result.earlier); try testing.expect(result.later != null); try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 10).days), result.later.?.days); } test "findNearestSnapshot: after latest — only earlier set" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(.{ .sub_path = "2024-03-10-portfolio.srf", .data = "" }); try tmp.dir.writeFile(.{ .sub_path = "2024-03-12-portfolio.srf", .data = "" }); const path = try tmp.dir.realpathAlloc(testing.allocator, "."); defer testing.allocator.free(path); const result = try findNearestSnapshot(path, Date.fromYmd(2025, 1, 1)); try testing.expect(result.earlier != null); try testing.expectEqual(@as(?Date, null), result.later); try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 12).days), result.earlier.?.days); } test "findNearestSnapshot: target hits a file exactly — returns neighbors, not self" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(.{ .sub_path = "2024-03-10-portfolio.srf", .data = "" }); try tmp.dir.writeFile(.{ .sub_path = "2024-03-12-portfolio.srf", .data = "" }); try tmp.dir.writeFile(.{ .sub_path = "2024-03-15-portfolio.srf", .data = "" }); const path = try tmp.dir.realpathAlloc(testing.allocator, "."); defer testing.allocator.free(path); const result = try findNearestSnapshot(path, Date.fromYmd(2024, 3, 12)); try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 10).days), result.earlier.?.days); try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 15).days), result.later.?.days); } test "loadSnapshotAt: missing file returns FileNotFound" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const path = try tmp.dir.realpathAlloc(testing.allocator, "."); defer testing.allocator.free(path); const result = loadSnapshotAt(testing.allocator, path, Date.fromYmd(2024, 3, 15)); try testing.expectError(error.FileNotFound, result); } test "loadSnapshotAt: happy path loads and parses" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // Minimal valid snapshot const snap_bytes = \\#!srfv1 \\#!created=1777589000 \\kind::meta,snapshot_version:num:1,as_of_date::2024-03-15,captured_at:num:1777589000,zfin_version::test,stale_count:num:0 \\kind::total,scope::liquid,value:num:1000 \\kind::total,scope::net_worth,value:num:1000 \\kind::total,scope::illiquid,value:num:0 \\ ; try tmp.dir.writeFile(.{ .sub_path = "2024-03-15-portfolio.srf", .data = snap_bytes }); const path = try tmp.dir.realpathAlloc(testing.allocator, "."); defer testing.allocator.free(path); var loaded = try loadSnapshotAt(testing.allocator, path, Date.fromYmd(2024, 3, 15)); defer loaded.deinit(testing.allocator); try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 15).days), loaded.snap.meta.as_of_date.days); try testing.expectEqual(@as(usize, 3), loaded.snap.totals.len); } // ── Aggregation tests ───────────────────────────────────────── test "liquidFromSnapshot: finds liquid scope" { const totals = [_]snapshot.TotalRow{ .{ .scope = "net_worth", .value = 1100 }, .{ .scope = "liquid", .value = 1000 }, .{ .scope = "illiquid", .value = 100 }, }; const snap = snapshot.Snapshot{ .meta = .{ .snapshot_version = 1, .as_of_date = Date.fromYmd(2024, 3, 15), .captured_at = 0, .zfin_version = "test", .stale_count = 0, }, .totals = @constCast(&totals), .tax_types = &.{}, .accounts = &.{}, .lots = &.{}, }; try testing.expectEqual(@as(f64, 1000), liquidFromSnapshot(&snap)); } test "liquidFromSnapshot: returns 0 when no liquid scope" { const totals = [_]snapshot.TotalRow{ .{ .scope = "net_worth", .value = 1100 }, }; const snap = snapshot.Snapshot{ .meta = .{ .snapshot_version = 1, .as_of_date = Date.fromYmd(2024, 3, 15), .captured_at = 0, .zfin_version = "test", .stale_count = 0, }, .totals = @constCast(&totals), .tax_types = &.{}, .accounts = &.{}, .lots = &.{}, }; try testing.expectEqual(@as(f64, 0.0), liquidFromSnapshot(&snap)); } test "aggregateSnapshotAllocations: stocks grouped, cash and CD separated" { var lots = [_]snapshot.LotRow{ .{ .kind = "lot", .symbol = "AAPL", .lot_symbol = "AAPL", .account = "Brokerage", .security_type = "Stock", .shares = 100, .open_price = 120, .cost_basis = 12_000, .value = 15_000, }, .{ .kind = "lot", .symbol = "AAPL", .lot_symbol = "AAPL", .account = "Roth", .security_type = "Stock", .shares = 50, .open_price = 150, .cost_basis = 7_500, .value = 7_500, }, .{ .kind = "lot", .symbol = "CASH", .lot_symbol = "CASH", .account = "Brokerage", .security_type = "Cash", .shares = 10_000, .open_price = 1, .cost_basis = 10_000, .value = 10_000, }, .{ .kind = "lot", .symbol = "CD-1Y", .lot_symbol = "CD-1Y", .account = "Roth", .security_type = "CD", .shares = 50_000, .open_price = 1, .cost_basis = 50_000, .value = 50_000, }, // Illiquid lots get skipped entirely — they aren't in the // liquid total and don't affect benchmark projections. .{ .kind = "lot", .symbol = "House", .lot_symbol = "House", .account = "Joint", .security_type = "Illiquid", .shares = 1, .open_price = 500_000, .cost_basis = 500_000, .value = 500_000, }, }; var totals = [_]snapshot.TotalRow{ .{ .kind = "total", .scope = "liquid", .value = 82_500 }, .{ .kind = "total", .scope = "net_worth", .value = 582_500 }, }; const snap: snapshot.Snapshot = .{ .meta = .{ .kind = "meta", .snapshot_version = 1, .as_of_date = Date.fromYmd(2026, 4, 2), .captured_at = 0, .zfin_version = "test", .stale_count = 0, }, .totals = &totals, .tax_types = &.{}, .accounts = &.{}, .lots = &lots, }; var sa = try aggregateSnapshotAllocations(testing.allocator, &snap); defer sa.deinit(testing.allocator); // One aggregated AAPL row (two lots merged), no illiquid row. try testing.expectEqual(@as(usize, 1), sa.allocations.len); try testing.expectEqualStrings("AAPL", sa.allocations[0].symbol); try testing.expectApproxEqAbs(@as(f64, 22_500), sa.allocations[0].market_value, 0.01); // weight = 22,500 / 82,500 ≈ 0.2727 try testing.expectApproxEqAbs(@as(f64, 22_500.0 / 82_500.0), sa.allocations[0].weight, 0.0001); try testing.expectApproxEqAbs(@as(f64, 82_500), sa.total_value, 0.01); try testing.expectApproxEqAbs(@as(f64, 10_000), sa.cash_value, 0.01); try testing.expectApproxEqAbs(@as(f64, 50_000), sa.cd_value, 0.01); } test "aggregateSnapshotAllocations: no liquid total defaults to zero weights" { // If the snapshot somehow lacks a `liquid` row, the function // should still succeed — weights just come out as 0. var lots = [_]snapshot.LotRow{ .{ .kind = "lot", .symbol = "AAPL", .lot_symbol = "AAPL", .account = "A", .security_type = "Stock", .shares = 1, .open_price = 150, .cost_basis = 150, .value = 150, }, }; const snap: snapshot.Snapshot = .{ .meta = .{ .kind = "meta", .snapshot_version = 1, .as_of_date = Date.fromYmd(2026, 4, 2), .captured_at = 0, .zfin_version = "test", .stale_count = 0, }, .totals = &.{}, .tax_types = &.{}, .accounts = &.{}, .lots = &lots, }; var sa = try aggregateSnapshotAllocations(testing.allocator, &snap); defer sa.deinit(testing.allocator); try testing.expectEqual(@as(usize, 1), sa.allocations.len); try testing.expectEqual(@as(f64, 0), sa.allocations[0].weight); try testing.expectEqual(@as(f64, 0), sa.total_value); } test "aggregateSnapshotAllocations: aggregates by `symbol` (pricing), not `lot_symbol`" { // Snapshot writer stores `symbol = priceSymbol()` (e.g. "BRK-B") // and `lot_symbol = lot.symbol` (user's form, e.g. "BRK.B"). The // candle cache is keyed by the pricing symbol, so aggregation // must use `symbol` to match downstream `getCachedCandles` lookups. // // This test constructs two lots with the same `symbol` (pricing) // but different `lot_symbol` values — they should collapse into a // single allocation. var lots = [_]snapshot.LotRow{ .{ .kind = "lot", .symbol = "BRK-B", .lot_symbol = "BRK.B", .account = "Brokerage", .security_type = "Stock", .shares = 10, .open_price = 400, .cost_basis = 4_000, .value = 4_500, }, .{ .kind = "lot", .symbol = "BRK-B", .lot_symbol = "BRK-B", .account = "Roth", .security_type = "Stock", .shares = 5, .open_price = 430, .cost_basis = 2_150, .value = 2_250, }, }; var totals = [_]snapshot.TotalRow{ .{ .kind = "total", .scope = "liquid", .value = 6_750 }, }; const snap: snapshot.Snapshot = .{ .meta = .{ .kind = "meta", .snapshot_version = 1, .as_of_date = Date.fromYmd(2026, 4, 2), .captured_at = 0, .zfin_version = "test", .stale_count = 0, }, .totals = &totals, .tax_types = &.{}, .accounts = &.{}, .lots = &lots, }; var sa = try aggregateSnapshotAllocations(testing.allocator, &snap); defer sa.deinit(testing.allocator); // Single entry — two lots merged by pricing symbol "BRK-B". try testing.expectEqual(@as(usize, 1), sa.allocations.len); try testing.expectEqualStrings("BRK-B", sa.allocations[0].symbol); try testing.expectApproxEqAbs(@as(f64, 6_750), sa.allocations[0].market_value, 0.01); } // ── sliceCandlesAsOf tests ──────────────────────────────────── fn makeTestCandle(y: i16, m: u8, d: u8, close: f64) Candle { return .{ .date = Date.fromYmd(y, m, d), .open = close, .high = close, .low = close, .close = close, .adj_close = close, .volume = 0, }; } test "sliceCandlesAsOf: null as_of returns everything" { const candles = [_]Candle{ makeTestCandle(2024, 1, 1, 100), makeTestCandle(2024, 1, 2, 101), }; const sliced = sliceCandlesAsOf(&candles, null); try testing.expectEqual(@as(usize, 2), sliced.len); } test "sliceCandlesAsOf: empty input" { const candles = [_]Candle{}; const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2024, 1, 1)); try testing.expectEqual(@as(usize, 0), sliced.len); } test "sliceCandlesAsOf: empty input with null as_of" { const candles = [_]Candle{}; const sliced = sliceCandlesAsOf(&candles, null); try testing.expectEqual(@as(usize, 0), sliced.len); } test "sliceCandlesAsOf: exact date match included" { const candles = [_]Candle{ makeTestCandle(2024, 1, 1, 100), makeTestCandle(2024, 1, 2, 101), makeTestCandle(2024, 1, 3, 102), makeTestCandle(2024, 1, 4, 103), }; const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2024, 1, 2)); try testing.expectEqual(@as(usize, 2), sliced.len); try testing.expectApproxEqAbs(@as(f64, 101), sliced[sliced.len - 1].close, 0.001); } test "sliceCandlesAsOf: no exact match snaps to earlier" { const candles = [_]Candle{ makeTestCandle(2024, 1, 1, 100), makeTestCandle(2024, 1, 3, 102), // gap — no candle on the 2nd makeTestCandle(2024, 1, 4, 103), }; // Asking for Jan 2 returns everything through Jan 1 (nothing at/after Jan 2). const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2024, 1, 2)); try testing.expectEqual(@as(usize, 1), sliced.len); try testing.expectApproxEqAbs(@as(f64, 100), sliced[0].close, 0.001); } test "sliceCandlesAsOf: as_of before all candles returns empty" { const candles = [_]Candle{ makeTestCandle(2024, 1, 1, 100), makeTestCandle(2024, 1, 2, 101), }; const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2023, 12, 31)); try testing.expectEqual(@as(usize, 0), sliced.len); } test "sliceCandlesAsOf: as_of after all candles returns everything" { const candles = [_]Candle{ makeTestCandle(2024, 1, 1, 100), makeTestCandle(2024, 1, 2, 101), }; const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2026, 1, 1)); try testing.expectEqual(@as(usize, 2), sliced.len); } // ── resolveSnapshotDate tests ───────────────────────────────── test "resolveSnapshotDate: exact match returns exact=true" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(.{ .sub_path = "2024-03-15-portfolio.srf", .data = "" }); const hist_dir = try tmp.dir.realpathAlloc(testing.allocator, "."); defer testing.allocator.free(hist_dir); var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const resolved = try resolveSnapshotDate(arena.allocator(), hist_dir, Date.fromYmd(2024, 3, 15)); try testing.expect(resolved.exact); try testing.expectEqual(Date.fromYmd(2024, 3, 15).days, resolved.actual.days); try testing.expectEqual(Date.fromYmd(2024, 3, 15).days, resolved.requested.days); } test "resolveSnapshotDate: no exact match snaps to nearest earlier" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(.{ .sub_path = "2024-03-10-portfolio.srf", .data = "" }); try tmp.dir.writeFile(.{ .sub_path = "2024-03-20-portfolio.srf", .data = "" }); const hist_dir = try tmp.dir.realpathAlloc(testing.allocator, "."); defer testing.allocator.free(hist_dir); var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const resolved = try resolveSnapshotDate(arena.allocator(), hist_dir, Date.fromYmd(2024, 3, 15)); try testing.expect(!resolved.exact); try testing.expectEqual(Date.fromYmd(2024, 3, 10).days, resolved.actual.days); try testing.expectEqual(Date.fromYmd(2024, 3, 15).days, resolved.requested.days); } test "resolveSnapshotDate: no earlier snapshot returns NoSnapshotAtOrBefore" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // Only a later snapshot — can't satisfy a request for an earlier date. try tmp.dir.writeFile(.{ .sub_path = "2024-04-01-portfolio.srf", .data = "" }); const hist_dir = try tmp.dir.realpathAlloc(testing.allocator, "."); defer testing.allocator.free(hist_dir); var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const result = resolveSnapshotDate(arena.allocator(), hist_dir, Date.fromYmd(2024, 3, 15)); try testing.expectError(error.NoSnapshotAtOrBefore, result); } test "resolveSnapshotDate: empty history dir returns NoSnapshotAtOrBefore" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const hist_dir = try tmp.dir.realpathAlloc(testing.allocator, "."); defer testing.allocator.free(hist_dir); var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const result = resolveSnapshotDate(arena.allocator(), hist_dir, Date.fromYmd(2024, 3, 15)); try testing.expectError(error.NoSnapshotAtOrBefore, result); }