diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a63f172..a604dab 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,7 +29,7 @@ repos: - id: test name: Run zig build test entry: zig - args: ["build", "coverage", "-Dcoverage-threshold=49"] + args: ["build", "coverage", "-Dcoverage-threshold=52"] language: system types: [file] pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md index 608ae5c..37525ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ zig build # build the zfin binary (output: zig-out/bin/zfin) zig build test # run all tests (single binary, discovers all tests via refAllDeclsRecursive) zig build run -- # build and run CLI zig build docs # generate library documentation -zig build coverage -Dcoverage-threshold=49 # run tests with kcov coverage (Linux only) +zig build coverage -Dcoverage-threshold=52 # run tests with kcov coverage (Linux only) ``` **Tooling** (managed via `.mise.toml`): diff --git a/src/analytics/timeline.zig b/src/analytics/timeline.zig new file mode 100644 index 0000000..407ebef --- /dev/null +++ b/src/analytics/timeline.zig @@ -0,0 +1,755 @@ +//! Portfolio timeline analytics — pure compute over snapshot history. +//! +//! This module takes pre-loaded portfolio snapshots (see `src/history.zig` +//! for the IO layer that produces them) and reduces them to time-series +//! data for display or export. Nothing here touches the filesystem, the +//! network, or a writer — that's by design, so the logic can be tested +//! exhaustively with fixture data. +//! +//! Typical flow: +//! snapshots : []const Snapshot <- history.loadHistoryDir(...) +//! │ +//! ▼ +//! buildSeries(snapshots) -> TimelineSeries (sorted by date) +//! │ +//! ▼ +//! filterByDate(series, since, until) (optional) +//! │ +//! ▼ +//! extractMetric(series, .net_worth) -> []MetricPoint for rendering +//! +//! For rollup generation, `buildRollupRecords` emits a flat slice suitable +//! for `srf.fmtFrom` without any of the per-lot detail — the rollup is a +//! summary cache, not a replacement for the per-day snapshot files. + +const std = @import("std"); +const Date = @import("../models/date.zig").Date; +const snapshot = @import("../models/snapshot.zig"); + +// ── Public types ───────────────────────────────────────────── + +/// A single point on the portfolio timeline. Totals are always present +/// (they're the three `kind::total` rows every snapshot emits); the +/// per-account / per-tax-type maps are only populated when the source +/// snapshot included analysis breakdowns. +/// +/// Values are dollar amounts. Weights aren't stored — callers can +/// compute them cheaply from the totals when rendering. +pub const TimelinePoint = struct { + as_of_date: Date, + net_worth: f64, + liquid: f64, + illiquid: f64, + /// Per-account totals. Empty when the source snapshot had no + /// analysis breakdown (e.g. backfilled-from-.txt snapshots). + /// Keys are account names; values are dollar totals. + accounts: []const NamedValue, + /// Per-tax-type totals. Same caveat as `accounts`. + tax_types: []const NamedValue, + + /// Deinit releases only the owned slices (`accounts`, `tax_types`). + /// The strings inside those slices are borrowed from the source + /// Snapshot's arena and must not be freed here. + pub fn deinit(self: TimelinePoint, allocator: std.mem.Allocator) void { + allocator.free(self.accounts); + allocator.free(self.tax_types); + } +}; + +pub const NamedValue = struct { + name: []const u8, + value: f64, +}; + +/// An ordered series of TimelinePoint sorted ascending by as_of_date. +/// Duplicates by date shouldn't happen (snapshot filenames enforce one +/// per day) but if they do the caller's last-write-wins. +pub const TimelineSeries = struct { + points: []TimelinePoint, + allocator: std.mem.Allocator, + + pub fn deinit(self: TimelineSeries) void { + for (self.points) |p| p.deinit(self.allocator); + self.allocator.free(self.points); + } +}; + +/// Which metric to extract for single-series display. +pub const Metric = enum { + net_worth, + liquid, + illiquid, + + pub fn label(self: Metric) []const u8 { + return switch (self) { + .net_worth => "Net Worth", + .liquid => "Liquid", + .illiquid => "Illiquid", + }; + } +}; + +/// One row of the extracted single-metric series. Used as input to +/// renderers and for mathematical operations (diffs, deltas, sparklines). +pub const MetricPoint = struct { + date: Date, + value: f64, +}; + +// ── Core construction ──────────────────────────────────────── + +/// Build a TimelineSeries from a slice of snapshots. +/// +/// Input snapshots are NOT required to be sorted; this function sorts +/// by `as_of_date` ascending. Each returned point's `accounts` and +/// `tax_types` slices borrow strings from the source Snapshot's arena +/// but own their own outer slice (freed by TimelinePoint.deinit). +pub fn buildSeries( + allocator: std.mem.Allocator, + snapshots: []const snapshot.Snapshot, +) !TimelineSeries { + var points = try allocator.alloc(TimelinePoint, snapshots.len); + errdefer { + // On error, free any already-populated points' nested slices. + allocator.free(points); + } + + var populated: usize = 0; + errdefer { + for (points[0..populated]) |p| p.deinit(allocator); + } + + for (snapshots, 0..) |snap, idx| { + points[idx] = try snapshotToPoint(allocator, snap); + populated = idx + 1; + } + + std.mem.sort(TimelinePoint, points, {}, lessByDate); + + return .{ .points = points, .allocator = allocator }; +} + +fn lessByDate(_: void, a: TimelinePoint, b: TimelinePoint) bool { + return a.as_of_date.lessThan(b.as_of_date); +} + +/// Derive a TimelinePoint from a single Snapshot. Pure — no IO. +/// +/// Exposed for testability. `buildSeries` is the usual entry point. +pub fn snapshotToPoint( + allocator: std.mem.Allocator, + snap: snapshot.Snapshot, +) !TimelinePoint { + var nw: f64 = 0; + var liq: f64 = 0; + var ill: f64 = 0; + for (snap.totals) |t| { + if (std.mem.eql(u8, t.scope, "net_worth")) nw = t.value; + if (std.mem.eql(u8, t.scope, "liquid")) liq = t.value; + if (std.mem.eql(u8, t.scope, "illiquid")) ill = t.value; + } + + const accts = try allocator.alloc(NamedValue, snap.accounts.len); + errdefer allocator.free(accts); + for (snap.accounts, 0..) |a, i| { + accts[i] = .{ .name = a.name, .value = a.value }; + } + + const tts = try allocator.alloc(NamedValue, snap.tax_types.len); + errdefer allocator.free(tts); + for (snap.tax_types, 0..) |t, i| { + tts[i] = .{ .name = t.label, .value = t.value }; + } + + return .{ + .as_of_date = snap.meta.as_of_date, + .net_worth = nw, + .liquid = liq, + .illiquid = ill, + .accounts = accts, + .tax_types = tts, + }; +} + +// ── Filters ────────────────────────────────────────────────── + +/// Return the subset of `points` whose `as_of_date` falls within the +/// inclusive `[since, until]` range. Either bound may be null to leave +/// that end open. The resulting slice is newly allocated; caller owns. +/// +/// This does NOT free the input points — the caller remains responsible +/// for the original TimelineSeries. +pub fn filterByDate( + allocator: std.mem.Allocator, + points: []const TimelinePoint, + since: ?Date, + until: ?Date, +) ![]TimelinePoint { + var kept: std.ArrayList(TimelinePoint) = .empty; + errdefer kept.deinit(allocator); + + for (points) |p| { + if (since) |s| if (p.as_of_date.lessThan(s)) continue; + if (until) |u| if (u.lessThan(p.as_of_date)) continue; + try kept.append(allocator, p); + } + + return kept.toOwnedSlice(allocator); +} + +// ── Metric extraction ──────────────────────────────────────── + +/// Extract a single top-level metric into a flat `[]MetricPoint` ready +/// for rendering or statistical analysis. Result is caller-owned. +pub fn extractMetric( + allocator: std.mem.Allocator, + points: []const TimelinePoint, + metric: Metric, +) ![]MetricPoint { + var out = try allocator.alloc(MetricPoint, points.len); + for (points, 0..) |p, i| { + out[i] = .{ + .date = p.as_of_date, + .value = switch (metric) { + .net_worth => p.net_worth, + .liquid => p.liquid, + .illiquid => p.illiquid, + }, + }; + } + return out; +} + +/// Which collection of per-row named values on `TimelinePoint` to project. +pub const NamedSeriesSource = enum { accounts, tax_types }; + +/// Extract a single-metric series for a named row (an account or a +/// tax-type label) from the timeline. Dates without the named row emit +/// `value = 0` rather than being skipped, so the returned slice has +/// `points.len` entries — suitable for stacked displays that need a row +/// per named entity per date. +/// +/// Caller owns the returned slice. +pub fn extractNamedSeries( + allocator: std.mem.Allocator, + points: []const TimelinePoint, + source: NamedSeriesSource, + name: []const u8, +) ![]MetricPoint { + var out = try allocator.alloc(MetricPoint, points.len); + for (points, 0..) |p, i| { + const rows = switch (source) { + .accounts => p.accounts, + .tax_types => p.tax_types, + }; + var v: f64 = 0; + for (rows) |r| { + if (std.mem.eql(u8, r.name, name)) { + v = r.value; + break; + } + } + out[i] = .{ .date = p.as_of_date, .value = v }; + } + return out; +} + +// ── Deltas / statistics ────────────────────────────────────── + +/// Summary statistics over a single-metric series. +pub const MetricStats = struct { + first: f64, + last: f64, + min: f64, + max: f64, + /// `last - first`. Dollars, not percent. + delta_abs: f64, + /// `(last - first) / first` — or null when `first == 0` (division + /// by zero); callers should render "n/a" or similar. + delta_pct: ?f64, +}; + +/// Compute min/max/first/last/delta over a MetricPoint slice. Returns +/// null on empty input — every field would be meaningless otherwise. +pub fn computeStats(points: []const MetricPoint) ?MetricStats { + if (points.len == 0) return null; + + var min_v = points[0].value; + var max_v = points[0].value; + for (points[1..]) |p| { + if (p.value < min_v) min_v = p.value; + if (p.value > max_v) max_v = p.value; + } + + const first = points[0].value; + const last = points[points.len - 1].value; + const delta = last - first; + + return .{ + .first = first, + .last = last, + .min = min_v, + .max = max_v, + .delta_abs = delta, + .delta_pct = if (first == 0) null else delta / first, + }; +} + +// ── Rollup emission ────────────────────────────────────────── + +/// A single row in `history/rollup.srf`. Deliberately slim: one record +/// per date, carrying the three totals only. Per-account and per- +/// tax-type detail stays in the per-day files. +/// +/// The `kind` discriminator pattern is consistent with the snapshot +/// format. Not strictly required here (the file has only one record +/// type) but future-proof if we ever add other rollup shapes. +pub const RollupRow = struct { + kind: []const u8, + as_of_date: Date, + net_worth: f64, + liquid: f64, + illiquid: f64, +}; + +/// Produce a rollup-row slice from a TimelineSeries. Pure function — +/// caller owns the result, ready to hand to `srf.fmtFrom`. +pub fn buildRollupRecords( + allocator: std.mem.Allocator, + points: []const TimelinePoint, +) ![]RollupRow { + var out = try allocator.alloc(RollupRow, points.len); + for (points, 0..) |p, i| { + out[i] = .{ + .kind = "rollup", + .as_of_date = p.as_of_date, + .net_worth = p.net_worth, + .liquid = p.liquid, + .illiquid = p.illiquid, + }; + } + return out; +} + +// ── Tests ──────────────────────────────────────────────────── +// +// Pure compute — every function here can be exercised with fixture +// structs. No IO, no writer, no colors. + +const testing = std.testing; + +/// Populate `buf` with the three total rows and return a minimal +/// Snapshot fixture for tests. Each test supplies its own `buf` (three +/// `TotalRow`s on the stack) so storage doesn't alias across calls. +fn fixtureSnapshot( + buf: *[3]snapshot.TotalRow, + y: i16, + m: u8, + d: u8, + net_worth: f64, + liquid: f64, + illiquid: f64, +) snapshot.Snapshot { + buf[0] = .{ .kind = "total", .scope = "net_worth", .value = net_worth }; + buf[1] = .{ .kind = "total", .scope = "liquid", .value = liquid }; + buf[2] = .{ .kind = "total", .scope = "illiquid", .value = illiquid }; + + return .{ + .meta = .{ + .kind = "meta", + .snapshot_version = 1, + .as_of_date = Date.fromYmd(y, m, d), + .captured_at = 0, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = buf, + .tax_types = &.{}, + .accounts = &.{}, + .lots = &.{}, + }; +} + +test "snapshotToPoint: extracts the three totals" { + var buf: [3]snapshot.TotalRow = undefined; + const snap = fixtureSnapshot(&buf, 2026, 4, 17, 1000, 800, 200); + const p = try snapshotToPoint(testing.allocator, snap); + defer p.deinit(testing.allocator); + + try testing.expect(p.as_of_date.eql(Date.fromYmd(2026, 4, 17))); + try testing.expectEqual(@as(f64, 1000), p.net_worth); + try testing.expectEqual(@as(f64, 800), p.liquid); + try testing.expectEqual(@as(f64, 200), p.illiquid); + try testing.expectEqual(@as(usize, 0), p.accounts.len); + try testing.expectEqual(@as(usize, 0), p.tax_types.len); +} + +test "snapshotToPoint: missing totals default to zero" { + // Snapshot with empty totals slice — nothing at all to extract. + const snap: snapshot.Snapshot = .{ + .meta = .{ + .kind = "meta", + .snapshot_version = 1, + .as_of_date = Date.fromYmd(2026, 4, 17), + .captured_at = 0, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = &.{}, + .tax_types = &.{}, + .accounts = &.{}, + .lots = &.{}, + }; + const p = try snapshotToPoint(testing.allocator, snap); + defer p.deinit(testing.allocator); + try testing.expectEqual(@as(f64, 0), p.net_worth); + try testing.expectEqual(@as(f64, 0), p.liquid); + try testing.expectEqual(@as(f64, 0), p.illiquid); +} + +test "snapshotToPoint: propagates accounts and tax_types" { + const accts = [_]snapshot.AccountRow{ + .{ .kind = "account", .name = "Emil Roth", .value = 1000 }, + .{ .kind = "account", .name = "Kelly IRA", .value = 500 }, + }; + const taxes = [_]snapshot.TaxTypeRow{ + .{ .kind = "tax_type", .label = "Taxable", .value = 800 }, + }; + const totals = [_]snapshot.TotalRow{ + .{ .kind = "total", .scope = "net_worth", .value = 1500 }, + }; + const snap: snapshot.Snapshot = .{ + .meta = .{ + .kind = "meta", + .snapshot_version = 1, + .as_of_date = Date.fromYmd(2026, 4, 17), + .captured_at = 0, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = @constCast(&totals), + .tax_types = @constCast(&taxes), + .accounts = @constCast(&accts), + .lots = &.{}, + }; + const p = try snapshotToPoint(testing.allocator, snap); + defer p.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 2), p.accounts.len); + try testing.expectEqualStrings("Emil Roth", p.accounts[0].name); + try testing.expectEqual(@as(f64, 1000), p.accounts[0].value); + + try testing.expectEqual(@as(usize, 1), p.tax_types.len); + try testing.expectEqualStrings("Taxable", p.tax_types[0].name); +} + +test "buildSeries: empty input" { + const series = try buildSeries(testing.allocator, &.{}); + defer series.deinit(); + try testing.expectEqual(@as(usize, 0), series.points.len); +} + +test "buildSeries: sorts ascending by date" { + var b1: [3]snapshot.TotalRow = undefined; + var b2: [3]snapshot.TotalRow = undefined; + var b3: [3]snapshot.TotalRow = undefined; + const snaps = [_]snapshot.Snapshot{ + fixtureSnapshot(&b1, 2026, 4, 20, 3000, 0, 0), + fixtureSnapshot(&b2, 2026, 4, 17, 1000, 0, 0), + fixtureSnapshot(&b3, 2026, 4, 18, 2000, 0, 0), + }; + const series = try buildSeries(testing.allocator, &snaps); + defer series.deinit(); + + try testing.expectEqual(@as(usize, 3), series.points.len); + try testing.expect(series.points[0].as_of_date.eql(Date.fromYmd(2026, 4, 17))); + try testing.expect(series.points[1].as_of_date.eql(Date.fromYmd(2026, 4, 18))); + try testing.expect(series.points[2].as_of_date.eql(Date.fromYmd(2026, 4, 20))); + try testing.expectEqual(@as(f64, 1000), series.points[0].net_worth); + try testing.expectEqual(@as(f64, 3000), series.points[2].net_worth); +} + +test "filterByDate: inclusive bounds" { + var b1: [3]snapshot.TotalRow = undefined; + var b2: [3]snapshot.TotalRow = undefined; + var b3: [3]snapshot.TotalRow = undefined; + var b4: [3]snapshot.TotalRow = undefined; + var b5: [3]snapshot.TotalRow = undefined; + const snaps = [_]snapshot.Snapshot{ + fixtureSnapshot(&b1, 2026, 4, 17, 1, 0, 0), + fixtureSnapshot(&b2, 2026, 4, 18, 2, 0, 0), + fixtureSnapshot(&b3, 2026, 4, 19, 3, 0, 0), + fixtureSnapshot(&b4, 2026, 4, 20, 4, 0, 0), + fixtureSnapshot(&b5, 2026, 4, 21, 5, 0, 0), + }; + const series = try buildSeries(testing.allocator, &snaps); + defer series.deinit(); + + const kept = try filterByDate( + testing.allocator, + series.points, + Date.fromYmd(2026, 4, 18), + Date.fromYmd(2026, 4, 20), + ); + defer testing.allocator.free(kept); + + try testing.expectEqual(@as(usize, 3), kept.len); + try testing.expectEqual(@as(f64, 2), kept[0].net_worth); + try testing.expectEqual(@as(f64, 4), kept[2].net_worth); +} + +test "filterByDate: null since leaves left end open" { + var b1: [3]snapshot.TotalRow = undefined; + var b2: [3]snapshot.TotalRow = undefined; + var b3: [3]snapshot.TotalRow = undefined; + const snaps = [_]snapshot.Snapshot{ + fixtureSnapshot(&b1, 2026, 4, 17, 1, 0, 0), + fixtureSnapshot(&b2, 2026, 4, 18, 2, 0, 0), + fixtureSnapshot(&b3, 2026, 4, 19, 3, 0, 0), + }; + const series = try buildSeries(testing.allocator, &snaps); + defer series.deinit(); + + const kept = try filterByDate(testing.allocator, series.points, null, Date.fromYmd(2026, 4, 18)); + defer testing.allocator.free(kept); + try testing.expectEqual(@as(usize, 2), kept.len); +} + +test "filterByDate: null until leaves right end open" { + var b1: [3]snapshot.TotalRow = undefined; + var b2: [3]snapshot.TotalRow = undefined; + var b3: [3]snapshot.TotalRow = undefined; + const snaps = [_]snapshot.Snapshot{ + fixtureSnapshot(&b1, 2026, 4, 17, 1, 0, 0), + fixtureSnapshot(&b2, 2026, 4, 18, 2, 0, 0), + fixtureSnapshot(&b3, 2026, 4, 19, 3, 0, 0), + }; + const series = try buildSeries(testing.allocator, &snaps); + defer series.deinit(); + + const kept = try filterByDate(testing.allocator, series.points, Date.fromYmd(2026, 4, 18), null); + defer testing.allocator.free(kept); + try testing.expectEqual(@as(usize, 2), kept.len); +} + +test "filterByDate: both bounds null returns everything" { + var b1: [3]snapshot.TotalRow = undefined; + var b2: [3]snapshot.TotalRow = undefined; + const snaps = [_]snapshot.Snapshot{ + fixtureSnapshot(&b1, 2026, 4, 17, 1, 0, 0), + fixtureSnapshot(&b2, 2026, 4, 18, 2, 0, 0), + }; + const series = try buildSeries(testing.allocator, &snaps); + defer series.deinit(); + const kept = try filterByDate(testing.allocator, series.points, null, null); + defer testing.allocator.free(kept); + try testing.expectEqual(@as(usize, 2), kept.len); +} + +test "filterByDate: out-of-range bounds return empty" { + var b1: [3]snapshot.TotalRow = undefined; + const snaps = [_]snapshot.Snapshot{ + fixtureSnapshot(&b1, 2026, 4, 17, 1, 0, 0), + }; + const series = try buildSeries(testing.allocator, &snaps); + defer series.deinit(); + const kept = try filterByDate(testing.allocator, series.points, Date.fromYmd(2030, 1, 1), null); + defer testing.allocator.free(kept); + try testing.expectEqual(@as(usize, 0), kept.len); +} + +test "extractMetric: net_worth / liquid / illiquid" { + var b1: [3]snapshot.TotalRow = undefined; + var b2: [3]snapshot.TotalRow = undefined; + const snaps = [_]snapshot.Snapshot{ + fixtureSnapshot(&b1, 2026, 4, 17, 1000, 800, 200), + fixtureSnapshot(&b2, 2026, 4, 18, 2000, 1500, 500), + }; + const series = try buildSeries(testing.allocator, &snaps); + defer series.deinit(); + + inline for (.{ + .{ Metric.net_worth, [_]f64{ 1000, 2000 } }, + .{ Metric.liquid, [_]f64{ 800, 1500 } }, + .{ Metric.illiquid, [_]f64{ 200, 500 } }, + }) |tc| { + const out = try extractMetric(testing.allocator, series.points, tc[0]); + defer testing.allocator.free(out); + try testing.expectEqual(@as(usize, 2), out.len); + try testing.expectEqual(tc[1][0], out[0].value); + try testing.expectEqual(tc[1][1], out[1].value); + } +} + +test "Metric.label: stable strings" { + try testing.expectEqualStrings("Net Worth", Metric.net_worth.label()); + try testing.expectEqualStrings("Liquid", Metric.liquid.label()); + try testing.expectEqualStrings("Illiquid", Metric.illiquid.label()); +} + +test "extractNamedSeries accounts: matches + absent days emit 0" { + // Build three snapshots: day1 has account A; day2 has account B; day3 has both. + // Extracting "A" should see value on day1, 0 on day2, value on day3. + var day1_accts = [_]snapshot.AccountRow{.{ .kind = "account", .name = "A", .value = 100 }}; + var day2_accts = [_]snapshot.AccountRow{.{ .kind = "account", .name = "B", .value = 200 }}; + var day3_accts = [_]snapshot.AccountRow{ + .{ .kind = "account", .name = "A", .value = 300 }, + .{ .kind = "account", .name = "B", .value = 400 }, + }; + + var t1 = [_]snapshot.TotalRow{.{ .kind = "total", .scope = "net_worth", .value = 100 }}; + var t2 = [_]snapshot.TotalRow{.{ .kind = "total", .scope = "net_worth", .value = 200 }}; + var t3 = [_]snapshot.TotalRow{.{ .kind = "total", .scope = "net_worth", .value = 700 }}; + + const mk = struct { + fn f(y: i16, m: u8, d: u8, totals: []snapshot.TotalRow, accts: []snapshot.AccountRow) snapshot.Snapshot { + return .{ + .meta = .{ + .kind = "meta", + .snapshot_version = 1, + .as_of_date = Date.fromYmd(y, m, d), + .captured_at = 0, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = totals, + .tax_types = &.{}, + .accounts = accts, + .lots = &.{}, + }; + } + }.f; + + const snaps = [_]snapshot.Snapshot{ + mk(2026, 4, 17, &t1, &day1_accts), + mk(2026, 4, 18, &t2, &day2_accts), + mk(2026, 4, 19, &t3, &day3_accts), + }; + const series = try buildSeries(testing.allocator, &snaps); + defer series.deinit(); + + const a_series = try extractNamedSeries(testing.allocator, series.points, .accounts, "A"); + defer testing.allocator.free(a_series); + try testing.expectEqual(@as(f64, 100), a_series[0].value); + try testing.expectEqual(@as(f64, 0), a_series[1].value); // absent -> 0 + try testing.expectEqual(@as(f64, 300), a_series[2].value); +} + +test "extractNamedSeries tax_types: absent days emit 0" { + var taxes1 = [_]snapshot.TaxTypeRow{.{ .kind = "tax_type", .label = "Taxable", .value = 500 }}; + var taxes2 = [_]snapshot.TaxTypeRow{}; + var tot1 = [_]snapshot.TotalRow{.{ .kind = "total", .scope = "net_worth", .value = 500 }}; + var tot2 = [_]snapshot.TotalRow{.{ .kind = "total", .scope = "net_worth", .value = 0 }}; + + const mk = struct { + fn f(d: u8, totals: []snapshot.TotalRow, tt: []snapshot.TaxTypeRow) snapshot.Snapshot { + return .{ + .meta = .{ + .kind = "meta", + .snapshot_version = 1, + .as_of_date = Date.fromYmd(2026, 4, d), + .captured_at = 0, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = totals, + .tax_types = tt, + .accounts = &.{}, + .lots = &.{}, + }; + } + }.f; + + const snaps = [_]snapshot.Snapshot{ + mk(17, &tot1, &taxes1), + mk(18, &tot2, &taxes2), + }; + const series = try buildSeries(testing.allocator, &snaps); + defer series.deinit(); + + const s = try extractNamedSeries(testing.allocator, series.points, .tax_types, "Taxable"); + defer testing.allocator.free(s); + try testing.expectEqual(@as(f64, 500), s[0].value); + try testing.expectEqual(@as(f64, 0), s[1].value); +} + +test "computeStats: typical case" { + const pts = [_]MetricPoint{ + .{ .date = Date.fromYmd(2026, 4, 17), .value = 1000 }, + .{ .date = Date.fromYmd(2026, 4, 18), .value = 900 }, + .{ .date = Date.fromYmd(2026, 4, 19), .value = 1500 }, + .{ .date = Date.fromYmd(2026, 4, 20), .value = 1100 }, + }; + const s = computeStats(&pts).?; + try testing.expectEqual(@as(f64, 1000), s.first); + try testing.expectEqual(@as(f64, 1100), s.last); + try testing.expectEqual(@as(f64, 900), s.min); + try testing.expectEqual(@as(f64, 1500), s.max); + try testing.expectEqual(@as(f64, 100), s.delta_abs); + try testing.expect(s.delta_pct != null); + try testing.expectApproxEqAbs(@as(f64, 0.10), s.delta_pct.?, 1e-9); +} + +test "computeStats: empty input returns null" { + const empty: []const MetricPoint = &.{}; + try testing.expect(computeStats(empty) == null); +} + +test "computeStats: single point — all fields equal" { + const pts = [_]MetricPoint{.{ .date = Date.fromYmd(2026, 4, 17), .value = 5000 }}; + const s = computeStats(&pts).?; + try testing.expectEqual(@as(f64, 5000), s.first); + try testing.expectEqual(@as(f64, 5000), s.last); + try testing.expectEqual(@as(f64, 5000), s.min); + try testing.expectEqual(@as(f64, 5000), s.max); + try testing.expectEqual(@as(f64, 0), s.delta_abs); + try testing.expectEqual(@as(f64, 0), s.delta_pct.?); +} + +test "computeStats: first-zero yields null delta_pct (no div-by-zero)" { + const pts = [_]MetricPoint{ + .{ .date = Date.fromYmd(2026, 4, 17), .value = 0 }, + .{ .date = Date.fromYmd(2026, 4, 18), .value = 1000 }, + }; + const s = computeStats(&pts).?; + try testing.expectEqual(@as(f64, 1000), s.delta_abs); + try testing.expect(s.delta_pct == null); +} + +test "computeStats: negative delta" { + const pts = [_]MetricPoint{ + .{ .date = Date.fromYmd(2026, 4, 17), .value = 1000 }, + .{ .date = Date.fromYmd(2026, 4, 18), .value = 800 }, + }; + const s = computeStats(&pts).?; + try testing.expectEqual(@as(f64, -200), s.delta_abs); + try testing.expectApproxEqAbs(@as(f64, -0.20), s.delta_pct.?, 1e-9); +} + +test "buildRollupRecords: one row per point, kind discriminator set" { + var b1: [3]snapshot.TotalRow = undefined; + var b2: [3]snapshot.TotalRow = undefined; + const snaps = [_]snapshot.Snapshot{ + fixtureSnapshot(&b1, 2026, 4, 17, 1000, 800, 200), + fixtureSnapshot(&b2, 2026, 4, 18, 1100, 850, 250), + }; + const series = try buildSeries(testing.allocator, &snaps); + defer series.deinit(); + + const rows = try buildRollupRecords(testing.allocator, series.points); + defer testing.allocator.free(rows); + + try testing.expectEqual(@as(usize, 2), rows.len); + try testing.expectEqualStrings("rollup", rows[0].kind); + try testing.expect(rows[0].as_of_date.eql(Date.fromYmd(2026, 4, 17))); + try testing.expectEqual(@as(f64, 1000), rows[0].net_worth); + try testing.expectEqual(@as(f64, 800), rows[0].liquid); + try testing.expectEqual(@as(f64, 200), rows[0].illiquid); + try testing.expectEqual(@as(f64, 1100), rows[1].net_worth); +} + +test "buildRollupRecords: empty input produces empty slice" { + const rows = try buildRollupRecords(testing.allocator, &.{}); + defer testing.allocator.free(rows); + try testing.expectEqual(@as(usize, 0), rows.len); +} diff --git a/src/commands/history.zig b/src/commands/history.zig index 7c04d15..d7ba3b4 100644 --- a/src/commands/history.zig +++ b/src/commands/history.zig @@ -1,9 +1,125 @@ +//! `zfin history` — two modes in one command: +//! +//! zfin history → candle history for a symbol (legacy) +//! zfin history [flags] → portfolio-value timeline from +//! history/*-portfolio.srf snapshots +//! +//! Mode dispatch: if cmd_args[0] exists and doesn't start with `-`, +//! treat as symbol mode. Otherwise portfolio mode. +//! +//! Portfolio-mode flags: +//! --since earliest as_of_date (inclusive) +//! --until latest as_of_date (inclusive) +//! --metric which metric to plot; one of +//! net_worth (default), liquid, illiquid +//! --rebuild-rollup (re)write history/rollup.srf and exit +//! +//! The CLI renderer is a thin wrapper over pure analytics. The compute +//! layer (`src/analytics/timeline.zig`) and IO layer (`src/history.zig`) +//! are fully testable on their own; this module is only responsible for +//! flag parsing, path resolution, and text-table presentation. + const std = @import("std"); +const srf = @import("srf"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); +const atomic = @import("../atomic.zig"); +const timeline = @import("../analytics/timeline.zig"); +const history_io = @import("../history.zig"); +const snapshot_model = @import("../models/snapshot.zig"); const fmt = cli.fmt; +const Date = @import("../models/date.zig").Date; -pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { +pub const Error = error{ + UnexpectedArg, + InvalidFlagValue, + MissingFlagValue, + UnknownMetric, +}; + +/// Parsed portfolio-mode options. Separated from `run` so the parser +/// is unit-testable. +pub const PortfolioOpts = struct { + since: ?Date = null, + until: ?Date = null, + /// Null means "render all three (net_worth + liquid + illiquid) side + /// by side" — the default. A non-null value focuses the display on a + /// single metric (user passed `--metric `). + metric: ?timeline.Metric = null, + rebuild_rollup: bool = false, +}; + +/// Parse the arg list for portfolio-mode flags. Pure function — no IO. +/// Returns an error for unknown flags, malformed dates, or unknown +/// metric names so the CLI surface can map them to distinct messages. +pub fn parsePortfolioOpts(args: []const []const u8) Error!PortfolioOpts { + var opts: PortfolioOpts = .{}; + var i: usize = 0; + while (i < args.len) : (i += 1) { + const a = args[i]; + if (std.mem.eql(u8, a, "--since")) { + i += 1; + if (i >= args.len) return error.MissingFlagValue; + opts.since = Date.parse(args[i]) catch return error.InvalidFlagValue; + } else if (std.mem.eql(u8, a, "--until")) { + i += 1; + if (i >= args.len) return error.MissingFlagValue; + opts.until = Date.parse(args[i]) catch return error.InvalidFlagValue; + } else if (std.mem.eql(u8, a, "--metric")) { + i += 1; + if (i >= args.len) return error.MissingFlagValue; + opts.metric = std.meta.stringToEnum(timeline.Metric, args[i]) orelse return error.UnknownMetric; + } else if (std.mem.eql(u8, a, "--rebuild-rollup")) { + opts.rebuild_rollup = true; + } else { + return error.UnexpectedArg; + } + } + return opts; +} + +/// Entry point. Dispatches to symbol mode or portfolio mode based on +/// the first argument. +/// +/// `portfolio_path` is the resolved path to portfolio.srf (used only in +/// portfolio mode to derive the history directory). Symbol mode ignores +/// it. +pub fn run( + allocator: std.mem.Allocator, + svc: *zfin.DataService, + portfolio_path: []const u8, + args: []const []const u8, + color: bool, + out: *std.Io.Writer, +) !void { + // Symbol-mode heuristic: cmd_args[0] exists and doesn't look like a flag. + if (args.len > 0 and args[0].len > 0 and args[0][0] != '-') { + try runSymbol(allocator, svc, args[0], color, out); + return; + } + + const opts = parsePortfolioOpts(args) catch |err| { + switch (err) { + error.UnexpectedArg => try cli.stderrPrint("Error: unknown flag in 'history'. See --help.\n"), + error.MissingFlagValue => try cli.stderrPrint("Error: flag requires a value.\n"), + error.InvalidFlagValue => try cli.stderrPrint("Error: invalid date (expected YYYY-MM-DD).\n"), + error.UnknownMetric => try cli.stderrPrint("Error: unknown --metric. Valid: net_worth, liquid, illiquid.\n"), + } + return err; + }; + + try runPortfolio(allocator, portfolio_path, opts, color, out); +} + +// ── Symbol mode (legacy) ───────────────────────────────────── + +fn runSymbol( + allocator: std.mem.Allocator, + svc: *zfin.DataService, + symbol: []const u8, + color: bool, + out: *std.Io.Writer, +) !void { const result = svc.getCandles(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try cli.stderrPrint("Error: No API key configured for candle data.\n"); @@ -26,10 +142,10 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const const c = fmt.filterCandlesFrom(all, one_month_ago); if (c.len == 0) return try cli.stderrPrint("No data available.\n"); - try display(c, symbol, color, out); + try displaySymbol(c, symbol, color, out); } -pub fn display(candles: []const zfin.Candle, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { +pub fn displaySymbol(candles: []const zfin.Candle, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { try cli.setBold(out, color); try out.print("\nPrice History for {s} (last 30 days)\n", .{symbol}); try cli.reset(out, color); @@ -55,31 +171,752 @@ pub fn display(candles: []const zfin.Candle, symbol: []const u8, color: bool, ou try out.print("\n{d} trading days\n\n", .{candles.len}); } +// ── Portfolio mode ─────────────────────────────────────────── + +fn runPortfolio( + allocator: std.mem.Allocator, + portfolio_path: []const u8, + opts: PortfolioOpts, + color: bool, + out: *std.Io.Writer, +) !void { + // Derive history/ from the portfolio path's directory. + const portfolio_dir = std.fs.path.dirname(portfolio_path) orelse "."; + const history_dir = try std.fs.path.join(allocator, &.{ portfolio_dir, "history" }); + defer allocator.free(history_dir); + + var loaded = try history_io.loadHistoryDir(allocator, history_dir); + defer loaded.deinit(); + + if (opts.rebuild_rollup) { + try rebuildRollup(allocator, history_dir, loaded.snapshots, out); + return; + } + + if (loaded.snapshots.len == 0) { + try out.print("No portfolio snapshots found in {s}.\n", .{history_dir}); + try out.print("Run `zfin snapshot` to capture the first one.\n", .{}); + return; + } + + const series = try timeline.buildSeries(allocator, loaded.snapshots); + defer series.deinit(); + + const filtered = try timeline.filterByDate(allocator, series.points, opts.since, opts.until); + defer allocator.free(filtered); + + if (filtered.len == 0) { + try out.print("No snapshots match the requested date range.\n", .{}); + return; + } + + // Default view: all three columns (net_worth + liquid + illiquid) + // side by side, so the user always sees how much of a net-worth + // change came from liquid markets vs. illiquid revaluations. + // `--metric X` explicitly asks for a single-metric focus view. + if (opts.metric) |m| { + const points = try timeline.extractMetric(allocator, filtered, m); + defer allocator.free(points); + try renderPortfolioTimeline(out, color, m, points); + } else { + try renderPortfolioTimelineAll(out, color, filtered); + } +} + +/// Regenerate `history/rollup.srf` from `snapshots`. Uses +/// `timeline.buildRollupRecords` + `srf.fmtFrom` + atomic write. +/// +/// Exposed as `pub` so tests can exercise the full IO path (including +/// directory creation and atomic rename) using `testing.tmpDir`. +pub fn rebuildRollup( + allocator: std.mem.Allocator, + history_dir: []const u8, + snapshots: []const snapshot_model.Snapshot, + out: *std.Io.Writer, +) !void { + const series = try timeline.buildSeries(allocator, snapshots); + defer series.deinit(); + + const rows = try timeline.buildRollupRecords(allocator, series.points); + defer allocator.free(rows); + + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + try aw.writer.print("{f}", .{srf.fmtFrom(timeline.RollupRow, allocator, rows, .{ + .emit_directives = true, + .created = std.time.timestamp(), + })}); + const rendered = aw.written(); + + const rollup_path = try std.fs.path.join(allocator, &.{ history_dir, "rollup.srf" }); + defer allocator.free(rollup_path); + + // Ensure history/ exists — otherwise atomic write fails on fresh repos. + std.fs.cwd().makePath(history_dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + + try atomic.writeFileAtomic(allocator, rollup_path, rendered); + try out.print("rollup rebuilt: {s} ({d} rows)\n", .{ rollup_path, rows.len }); +} + +// ── Rendering ──────────────────────────────────────────────── + +/// Render a single-metric timeline as a three-column text table +/// (Date | Value | Delta-from-first). Pure function — tested with +/// fixed buffers. +/// +/// Includes a footer with summary stats (first, last, min, max, delta) +/// when there are 2+ points. Empty input writes a single-line hint. +pub fn renderPortfolioTimeline( + out: *std.Io.Writer, + color: bool, + metric: timeline.Metric, + points: []const timeline.MetricPoint, +) !void { + try cli.setBold(out, color); + try out.print("\nPortfolio Timeline — {s}\n", .{metric.label()}); + try cli.reset(out, color); + try out.print("========================================\n", .{}); + + if (points.len == 0) { + try out.print("(no data)\n\n", .{}); + return; + } + + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print("{s:>12} {s:>16} {s:>16}\n", .{ "Date", "Value", "Δ from start" }); + try out.print("{s:->12} {s:->16} {s:->16}\n", .{ "", "", "" }); + try cli.reset(out, color); + + const first_value = points[0].value; + for (points) |p| { + var db: [10]u8 = undefined; + var vb: [24]u8 = undefined; + var dvb: [24]u8 = undefined; + const delta = p.value - first_value; + + try out.print("{s:>12} ", .{p.date.format(&db)}); + try out.print("{s:>16} ", .{fmt.fmtMoneyAbs(&vb, p.value)}); + // Color delta by sign (green for gain, red for loss), muted for zero. + if (delta == 0) { + try cli.setFg(out, color, cli.CLR_MUTED); + } else { + try cli.setGainLoss(out, color, delta); + } + // fmtMoneyAbs drops the sign; synthesize the correct one. + const prefix: []const u8 = if (delta > 0) "+" else if (delta < 0) "-" else ""; + try out.print("{s}{s}\n", .{ prefix, fmt.fmtMoneyAbs(&dvb, delta) }); + try cli.reset(out, color); + } + + // Summary footer + if (timeline.computeStats(points)) |s| { + try out.print("\n", .{}); + try cli.setFg(out, color, cli.CLR_MUTED); + var b1: [24]u8 = undefined; + var b2: [24]u8 = undefined; + var b3: [24]u8 = undefined; + var b4: [24]u8 = undefined; + try out.print(" first: {s} last: {s} min: {s} max: {s}\n", .{ + fmt.fmtMoneyAbs(&b1, s.first), + fmt.fmtMoneyAbs(&b2, s.last), + fmt.fmtMoneyAbs(&b3, s.min), + fmt.fmtMoneyAbs(&b4, s.max), + }); + try cli.reset(out, color); + + var db: [24]u8 = undefined; + const prefix: []const u8 = if (s.delta_abs > 0) "+" else if (s.delta_abs < 0) "-" else ""; + try cli.setGainLoss(out, color, s.delta_abs); + if (s.delta_pct) |pct| { + try out.print(" Δ: {s}{s} ({d:.2}%)\n", .{ + prefix, + fmt.fmtMoneyAbs(&db, s.delta_abs), + pct * 100.0, + }); + } else { + try out.print(" Δ: {s}{s} (n/a%)\n", .{ + prefix, + fmt.fmtMoneyAbs(&db, s.delta_abs), + }); + } + try cli.reset(out, color); + } + try out.print("\n{d} snapshots\n\n", .{points.len}); +} + +/// Format a signed money delta as `"+$X.XX"`, `"-$X.XX"`, or `"$0.00"` +/// into `buf`. Returns the slice of `buf` containing the result. +/// +/// Exists because `fmt.fmtMoneyAbs` drops the sign and rebuilding it +/// correctly (no `+` for exactly-zero) is a three-line dance every +/// call site gets wrong at least once. +pub fn fmtSignedMoney(buf: []u8, value: f64) ![]const u8 { + const prefix: []const u8 = if (value > 0) "+" else if (value < 0) "-" else ""; + var tmp: [24]u8 = undefined; + const abs_str = fmt.fmtMoneyAbs(&tmp, value); + return std.fmt.bufPrint(buf, "{s}{s}", .{ prefix, abs_str }); +} + +/// Render a multi-metric timeline: Date | Net Worth | Liquid | Illiquid, +/// with each column showing both current value and Δ from the first row. +/// This is the default view — it answers "how did my net worth change +/// and was it liquid vs. illiquid?" in one glance. +/// +/// Takes a TimelinePoint slice directly (rather than already-extracted +/// MetricPoints) because every column reads from the same source rows. +pub fn renderPortfolioTimelineAll( + out: *std.Io.Writer, + color: bool, + points: []const timeline.TimelinePoint, +) !void { + try cli.setBold(out, color); + try out.print("\nPortfolio Timeline\n", .{}); + try cli.reset(out, color); + try out.print("========================================\n", .{}); + + if (points.len == 0) { + try out.print("(no data)\n\n", .{}); + return; + } + + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print("{s:>12} {s:>28} {s:>28} {s:>28}\n", .{ + "Date", "Net Worth (Δ)", "Liquid (Δ)", "Illiquid (Δ)", + }); + try out.print("{s:->12} {s:->28} {s:->28} {s:->28}\n", .{ "", "", "", "" }); + try cli.reset(out, color); + + const first_nw = points[0].net_worth; + const first_liq = points[0].liquid; + const first_ill = points[0].illiquid; + + for (points) |p| { + var db: [10]u8 = undefined; + try out.print("{s:>12} ", .{p.as_of_date.format(&db)}); + try writeValueDeltaCell(out, color, p.net_worth, p.net_worth - first_nw); + try out.writeAll(" "); + try writeValueDeltaCell(out, color, p.liquid, p.liquid - first_liq); + try out.writeAll(" "); + try writeValueDeltaCell(out, color, p.illiquid, p.illiquid - first_ill); + try out.writeByte('\n'); + } + + // Three-line summary footer: one line per metric, showing first → + // last and absolute/percent change. More readable than stuffing + // everything into one wide line. + try out.print("\n", .{}); + try writeSummaryLine(out, color, " Net Worth", mapMetric(points, .net_worth)); + try writeSummaryLine(out, color, " Liquid ", mapMetric(points, .liquid)); + try writeSummaryLine(out, color, " Illiquid ", mapMetric(points, .illiquid)); + + try out.print("\n{d} snapshots\n\n", .{points.len}); +} + +/// Write one "$value (+$delta)" cell, right-padded to 28 chars, with the +/// Δ colored by sign. +fn writeValueDeltaCell(out: *std.Io.Writer, color: bool, value: f64, delta: f64) !void { + var vb: [24]u8 = undefined; + var dvb: [32]u8 = undefined; + const val_str = fmt.fmtMoneyAbs(&vb, value); + const delta_str = try fmtSignedMoney(&dvb, delta); + + // Build the cell into a stack buffer so the overall width calculation + // is trivial regardless of color escapes mid-print. + var cell_buf: [64]u8 = undefined; + const cell = try std.fmt.bufPrint(&cell_buf, "{s} ({s})", .{ val_str, delta_str }); + + // Right-align to 28 chars. Visible width doesn't include ANSI, but + // since we apply color to the whole cell we can pad plain, then emit + // under color. + const pad = if (cell.len < 28) 28 - cell.len else 0; + for (0..pad) |_| try out.writeByte(' '); + + // Color the cell by delta sign — whole cell, not just Δ, so the + // value+delta reads as one visual unit. + if (delta == 0) { + try cli.setFg(out, color, cli.CLR_MUTED); + } else { + try cli.setGainLoss(out, color, delta); + } + try out.writeAll(cell); + try cli.reset(out, color); +} + +/// Compute stats for one of the three top-level metrics from a slice +/// of TimelinePoint. Pure helper — no allocator needed. +fn mapMetric(points: []const timeline.TimelinePoint, metric: timeline.Metric) ?timeline.MetricStats { + if (points.len == 0) return null; + + const first = extractOne(points[0], metric); + const last = extractOne(points[points.len - 1], metric); + var min_v = first; + var max_v = first; + for (points) |p| { + const v = extractOne(p, metric); + if (v < min_v) min_v = v; + if (v > max_v) max_v = v; + } + + const delta = last - first; + return .{ + .first = first, + .last = last, + .min = min_v, + .max = max_v, + .delta_abs = delta, + .delta_pct = if (first == 0) null else delta / first, + }; +} + +fn extractOne(p: timeline.TimelinePoint, metric: timeline.Metric) f64 { + return switch (metric) { + .net_worth => p.net_worth, + .liquid => p.liquid, + .illiquid => p.illiquid, + }; +} + +fn writeSummaryLine(out: *std.Io.Writer, color: bool, label: []const u8, stats_opt: ?timeline.MetricStats) !void { + const s = stats_opt orelse return; + try cli.setFg(out, color, cli.CLR_MUTED); + var b1: [24]u8 = undefined; + var b2: [24]u8 = undefined; + try out.print("{s} first: {s} last: {s} ", .{ + label, + fmt.fmtMoneyAbs(&b1, s.first), + fmt.fmtMoneyAbs(&b2, s.last), + }); + try cli.reset(out, color); + + var db: [32]u8 = undefined; + const delta_str = try fmtSignedMoney(&db, s.delta_abs); + try cli.setGainLoss(out, color, s.delta_abs); + if (s.delta_pct) |pct| { + try out.print("Δ: {s} ({d:.2}%)\n", .{ delta_str, pct * 100.0 }); + } else { + try out.print("Δ: {s} (n/a%)\n", .{delta_str}); + } + try cli.reset(out, color); +} + // ── Tests ──────────────────────────────────────────────────── -test "display shows header and candle data" { +const testing = std.testing; + +test "parsePortfolioOpts: defaults" { + const o = try parsePortfolioOpts(&.{}); + try testing.expect(o.since == null); + try testing.expect(o.until == null); + // Null metric = "show all three columns" (default view). + try testing.expect(o.metric == null); + try testing.expect(!o.rebuild_rollup); +} + +test "parsePortfolioOpts: --since and --until parse ISO dates" { + const args = [_][]const u8{ "--since", "2026-01-01", "--until", "2026-04-30" }; + const o = try parsePortfolioOpts(&args); + try testing.expect(o.since.?.eql(Date.fromYmd(2026, 1, 1))); + try testing.expect(o.until.?.eql(Date.fromYmd(2026, 4, 30))); +} + +test "parsePortfolioOpts: --metric picks the right enum (non-null when explicit)" { + const a1 = [_][]const u8{ "--metric", "liquid" }; + const o1 = try parsePortfolioOpts(&a1); + try testing.expectEqual(timeline.Metric.liquid, o1.metric.?); + + const a2 = [_][]const u8{ "--metric", "illiquid" }; + const o2 = try parsePortfolioOpts(&a2); + try testing.expectEqual(timeline.Metric.illiquid, o2.metric.?); + + const a3 = [_][]const u8{ "--metric", "net_worth" }; + const o3 = try parsePortfolioOpts(&a3); + try testing.expectEqual(timeline.Metric.net_worth, o3.metric.?); +} + +test "parsePortfolioOpts: --rebuild-rollup boolean" { + const args = [_][]const u8{"--rebuild-rollup"}; + const o = try parsePortfolioOpts(&args); + try testing.expect(o.rebuild_rollup); +} + +test "parsePortfolioOpts: unknown flag -> UnexpectedArg" { + const args = [_][]const u8{"--bogus"}; + try testing.expectError(error.UnexpectedArg, parsePortfolioOpts(&args)); +} + +test "parsePortfolioOpts: missing value after --since" { + const args = [_][]const u8{"--since"}; + try testing.expectError(error.MissingFlagValue, parsePortfolioOpts(&args)); +} + +test "parsePortfolioOpts: malformed date in --since" { + const args = [_][]const u8{ "--since", "not-a-date" }; + try testing.expectError(error.InvalidFlagValue, parsePortfolioOpts(&args)); +} + +test "parsePortfolioOpts: unknown --metric value" { + const args = [_][]const u8{ "--metric", "bogus" }; + try testing.expectError(error.UnknownMetric, parsePortfolioOpts(&args)); +} + +test "renderPortfolioTimeline: empty input says '(no data)'" { + var buf: [1024]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try renderPortfolioTimeline(&w, false, .net_worth, &.{}); + const out = w.buffered(); + try testing.expect(std.mem.indexOf(u8, out, "Portfolio Timeline") != null); + try testing.expect(std.mem.indexOf(u8, out, "Net Worth") != null); + try testing.expect(std.mem.indexOf(u8, out, "(no data)") != null); +} + +test "renderPortfolioTimeline: single point renders without crashing, no delta pct" { + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const pts = [_]timeline.MetricPoint{ + .{ .date = Date.fromYmd(2026, 4, 17), .value = 1000 }, + }; + try renderPortfolioTimeline(&w, false, .net_worth, &pts); + const out = w.buffered(); + try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null); + try testing.expect(std.mem.indexOf(u8, out, "$1,000.00") != null); + try testing.expect(std.mem.indexOf(u8, out, "1 snapshots") != null); +} + +test "renderPortfolioTimeline: multi-point shows dates, deltas, and footer" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const pts = [_]timeline.MetricPoint{ + .{ .date = Date.fromYmd(2026, 4, 17), .value = 1000 }, + .{ .date = Date.fromYmd(2026, 4, 18), .value = 1050 }, + .{ .date = Date.fromYmd(2026, 4, 19), .value = 980 }, + }; + try renderPortfolioTimeline(&w, false, .net_worth, &pts); + const out = w.buffered(); + + // Every date shows up. + try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null); + try testing.expect(std.mem.indexOf(u8, out, "2026-04-18") != null); + try testing.expect(std.mem.indexOf(u8, out, "2026-04-19") != null); + + // Delta column: day 1 should show +$50, day 2 should show -$20. + try testing.expect(std.mem.indexOf(u8, out, "+$50.00") != null); + try testing.expect(std.mem.indexOf(u8, out, "-$20.00") != null); + + // Summary footer: first/last/min/max and Δ with percent. + try testing.expect(std.mem.indexOf(u8, out, "first:") != null); + try testing.expect(std.mem.indexOf(u8, out, "last:") != null); + try testing.expect(std.mem.indexOf(u8, out, "min:") != null); + try testing.expect(std.mem.indexOf(u8, out, "max:") != null); + try testing.expect(std.mem.indexOf(u8, out, "3 snapshots") != null); + // No ANSI when color=false. + try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); +} + +test "renderPortfolioTimeline: color mode emits ANSI" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const pts = [_]timeline.MetricPoint{ + .{ .date = Date.fromYmd(2026, 4, 17), .value = 1000 }, + .{ .date = Date.fromYmd(2026, 4, 18), .value = 1100 }, + }; + try renderPortfolioTimeline(&w, true, .net_worth, &pts); + const out = w.buffered(); + try testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null); +} + +test "renderPortfolioTimeline: metric label reflects chosen metric" { + var buf: [1024]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try renderPortfolioTimeline(&w, false, .liquid, &.{}); + try testing.expect(std.mem.indexOf(u8, w.buffered(), "Liquid") != null); +} + +test "renderPortfolioTimeline: zero delta renders as $0.00 (muted-color branch)" { + // The rendering branch for delta == 0 is distinct from +/− branches. + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const pts = [_]timeline.MetricPoint{ + .{ .date = Date.fromYmd(2026, 4, 17), .value = 500 }, + .{ .date = Date.fromYmd(2026, 4, 18), .value = 500 }, + }; + try renderPortfolioTimeline(&w, false, .net_worth, &pts); + const out = w.buffered(); + // Day 2 delta is $0.00 — no leading + or −. + try testing.expect(std.mem.indexOf(u8, out, "$0.00") != null); +} + +test "renderPortfolioTimeline: first-zero delta renders n/a% footer" { + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const pts = [_]timeline.MetricPoint{ + .{ .date = Date.fromYmd(2026, 4, 17), .value = 0 }, + .{ .date = Date.fromYmd(2026, 4, 18), .value = 1000 }, + }; + try renderPortfolioTimeline(&w, false, .net_worth, &pts); + const out = w.buffered(); + try testing.expect(std.mem.indexOf(u8, out, "n/a%") != null); +} + +// Legacy symbol-mode tests — retained, renamed to match new function. +test "displaySymbol shows header and candle data" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const candles = [_]zfin.Candle{ .{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 103.0, .adj_close = 103.0, .volume = 1_500_000 }, .{ .date = .{ .days = 20001 }, .open = 103.0, .high = 107.0, .low = 102.0, .close = 101.0, .adj_close = 101.0, .volume = 2_000_000 }, }; - try display(&candles, "AAPL", false, &w); + try displaySymbol(&candles, "AAPL", false, &w); const out = w.buffered(); - try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); - try std.testing.expect(std.mem.indexOf(u8, out, "Date") != null); - try std.testing.expect(std.mem.indexOf(u8, out, "Open") != null); - try std.testing.expect(std.mem.indexOf(u8, out, "2 trading days") != null); - // No ANSI when color=false - try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); + try testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); + try testing.expect(std.mem.indexOf(u8, out, "Date") != null); + try testing.expect(std.mem.indexOf(u8, out, "Open") != null); + try testing.expect(std.mem.indexOf(u8, out, "2 trading days") != null); + try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); } -test "display empty candles" { +test "displaySymbol empty candles" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const candles = [_]zfin.Candle{}; - try display(&candles, "XYZ", false, &w); + try displaySymbol(&candles, "XYZ", false, &w); const out = w.buffered(); - try std.testing.expect(std.mem.indexOf(u8, out, "XYZ") != null); - try std.testing.expect(std.mem.indexOf(u8, out, "0 trading days") != null); + try testing.expect(std.mem.indexOf(u8, out, "XYZ") != null); + try testing.expect(std.mem.indexOf(u8, out, "0 trading days") != null); +} + +// ── fmtSignedMoney ────────────────────────────────────────── + +test "fmtSignedMoney: positive gets + prefix" { + var buf: [24]u8 = undefined; + const s = try fmtSignedMoney(&buf, 1234.56); + try testing.expectEqualStrings("+$1,234.56", s); +} + +test "fmtSignedMoney: negative gets - prefix" { + var buf: [24]u8 = undefined; + const s = try fmtSignedMoney(&buf, -1234.56); + try testing.expectEqualStrings("-$1,234.56", s); +} + +test "fmtSignedMoney: zero has no prefix" { + var buf: [24]u8 = undefined; + const s = try fmtSignedMoney(&buf, 0); + try testing.expectEqualStrings("$0.00", s); +} + +// ── renderPortfolioTimelineAll ─────────────────────────────── + +fn makeTimelinePoint(y: i16, m: u8, d: u8, nw: f64, liq: f64, ill: f64) timeline.TimelinePoint { + return .{ + .as_of_date = Date.fromYmd(y, m, d), + .net_worth = nw, + .liquid = liq, + .illiquid = ill, + .accounts = &.{}, + .tax_types = &.{}, + }; +} + +test "renderPortfolioTimelineAll: empty -> (no data)" { + var buf: [1024]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try renderPortfolioTimelineAll(&w, false, &.{}); + const out = w.buffered(); + try testing.expect(std.mem.indexOf(u8, out, "Portfolio Timeline") != null); + try testing.expect(std.mem.indexOf(u8, out, "(no data)") != null); +} + +test "renderPortfolioTimelineAll: shows all three columns and per-metric summary lines" { + var buf: [8192]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const pts = [_]timeline.TimelinePoint{ + makeTimelinePoint(2026, 4, 17, 10_000, 8_000, 2_000), + makeTimelinePoint(2026, 4, 18, 10_500, 8_400, 2_100), + makeTimelinePoint(2026, 4, 19, 9_900, 7_900, 2_000), + }; + try renderPortfolioTimelineAll(&w, false, &pts); + const out = w.buffered(); + + // Header columns + try testing.expect(std.mem.indexOf(u8, out, "Net Worth (Δ)") != null); + try testing.expect(std.mem.indexOf(u8, out, "Liquid (Δ)") != null); + try testing.expect(std.mem.indexOf(u8, out, "Illiquid (Δ)") != null); + + // All three dates shown + try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null); + try testing.expect(std.mem.indexOf(u8, out, "2026-04-18") != null); + try testing.expect(std.mem.indexOf(u8, out, "2026-04-19") != null); + + // Day-18 liquid is +$400, day-19 net worth is −$100. + try testing.expect(std.mem.indexOf(u8, out, "+$400.00") != null); + try testing.expect(std.mem.indexOf(u8, out, "-$100.00") != null); + + // Per-metric summary footer lines + try testing.expect(std.mem.indexOf(u8, out, "Net Worth") != null); + try testing.expect(std.mem.indexOf(u8, out, "Liquid") != null); + try testing.expect(std.mem.indexOf(u8, out, "Illiquid") != null); + + try testing.expect(std.mem.indexOf(u8, out, "3 snapshots") != null); + + // No ANSI when color=false + try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); +} + +test "renderPortfolioTimelineAll: color mode emits ANSI" { + var buf: [8192]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const pts = [_]timeline.TimelinePoint{ + makeTimelinePoint(2026, 4, 17, 100, 80, 20), + makeTimelinePoint(2026, 4, 18, 110, 90, 20), + }; + try renderPortfolioTimelineAll(&w, true, &pts); + try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") != null); +} + +test "renderPortfolioTimelineAll: single point renders without crashing" { + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const pts = [_]timeline.TimelinePoint{ + makeTimelinePoint(2026, 4, 17, 100, 80, 20), + }; + try renderPortfolioTimelineAll(&w, false, &pts); + const out = w.buffered(); + try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null); + try testing.expect(std.mem.indexOf(u8, out, "1 snapshots") != null); +} + +test "renderPortfolioTimelineAll: first-zero metric renders n/a% in summary" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const pts = [_]timeline.TimelinePoint{ + makeTimelinePoint(2026, 4, 17, 100, 80, 0), // illiquid starts at 0 + makeTimelinePoint(2026, 4, 18, 1000, 80, 920), // illiquid grows + }; + try renderPortfolioTimelineAll(&w, false, &pts); + const out = w.buffered(); + // Illiquid line should show n/a% since first=0. + try testing.expect(std.mem.indexOf(u8, out, "n/a%") != null); +} + +// ── rebuildRollup ──────────────────────────────────────────── + +/// Minimal Snapshot fixture for rebuildRollup tests. Mirrors the helper +/// in `analytics/timeline.zig` but kept local so this test file stays +/// self-contained. +fn makeFixtureSnapshot( + totals_buf: *[3]snapshot_model.TotalRow, + y: i16, + m: u8, + d: u8, + nw: f64, + liq: f64, + ill: f64, +) snapshot_model.Snapshot { + totals_buf[0] = .{ .kind = "total", .scope = "net_worth", .value = nw }; + totals_buf[1] = .{ .kind = "total", .scope = "liquid", .value = liq }; + totals_buf[2] = .{ .kind = "total", .scope = "illiquid", .value = ill }; + return .{ + .meta = .{ + .kind = "meta", + .snapshot_version = 1, + .as_of_date = Date.fromYmd(y, m, d), + .captured_at = 0, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = totals_buf, + .tax_types = &.{}, + .accounts = &.{}, + .lots = &.{}, + }; +} + +test "rebuildRollup: writes rollup.srf with one row per snapshot" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const tmp_path = try tmp.dir.realpath(".", &path_buf); + + var b1: [3]snapshot_model.TotalRow = undefined; + var b2: [3]snapshot_model.TotalRow = undefined; + const snaps = [_]snapshot_model.Snapshot{ + makeFixtureSnapshot(&b1, 2026, 4, 17, 1000, 800, 200), + makeFixtureSnapshot(&b2, 2026, 4, 18, 1100, 850, 250), + }; + + var out_buf: [512]u8 = undefined; + var out: std.Io.Writer = .fixed(&out_buf); + + try rebuildRollup(testing.allocator, tmp_path, &snaps, &out); + + // Confirm the status line mentions the right row count. + const out_str = out.buffered(); + try testing.expect(std.mem.indexOf(u8, out_str, "2 rows") != null); + + // Read the emitted rollup.srf and assert on its shape. + const rollup_path = try std.fs.path.join(testing.allocator, &.{ tmp_path, "rollup.srf" }); + defer testing.allocator.free(rollup_path); + const bytes = try std.fs.cwd().readFileAlloc(testing.allocator, rollup_path, 16 * 1024); + defer testing.allocator.free(bytes); + + try testing.expect(std.mem.startsWith(u8, bytes, "#!srfv1")); + try testing.expect(std.mem.indexOf(u8, bytes, "kind::rollup,as_of_date::2026-04-17") != null); + try testing.expect(std.mem.indexOf(u8, bytes, "kind::rollup,as_of_date::2026-04-18") != null); + try testing.expect(std.mem.indexOf(u8, bytes, "net_worth:num:1000") != null); + try testing.expect(std.mem.indexOf(u8, bytes, "net_worth:num:1100") != null); +} + +test "rebuildRollup: creates history dir when it doesn't exist" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const tmp_path = try tmp.dir.realpath(".", &path_buf); + + // Target a subdirectory that doesn't exist yet. + const nested = try std.fs.path.join(testing.allocator, &.{ tmp_path, "nested", "history" }); + defer testing.allocator.free(nested); + + var b1: [3]snapshot_model.TotalRow = undefined; + const snaps = [_]snapshot_model.Snapshot{ + makeFixtureSnapshot(&b1, 2026, 4, 17, 100, 80, 20), + }; + + var out_buf: [256]u8 = undefined; + var out: std.Io.Writer = .fixed(&out_buf); + + // Must succeed — makePath should create the intermediate dirs. + try rebuildRollup(testing.allocator, nested, &snaps, &out); + + // File must now exist under the (previously-missing) nested path. + const rollup_path = try std.fs.path.join(testing.allocator, &.{ nested, "rollup.srf" }); + defer testing.allocator.free(rollup_path); + try std.fs.cwd().access(rollup_path, .{}); +} + +test "rebuildRollup: empty snapshots produces an empty rollup" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const tmp_path = try tmp.dir.realpath(".", &path_buf); + + var out_buf: [256]u8 = undefined; + var out: std.Io.Writer = .fixed(&out_buf); + + try rebuildRollup(testing.allocator, tmp_path, &.{}, &out); + try testing.expect(std.mem.indexOf(u8, out.buffered(), "0 rows") != null); + + const rollup_path = try std.fs.path.join(testing.allocator, &.{ tmp_path, "rollup.srf" }); + defer testing.allocator.free(rollup_path); + const bytes = try std.fs.cwd().readFileAlloc(testing.allocator, rollup_path, 4 * 1024); + defer testing.allocator.free(bytes); + + // Header still emitted; no records. + try testing.expect(std.mem.startsWith(u8, bytes, "#!srfv1")); + try testing.expect(std.mem.indexOf(u8, bytes, "kind::rollup") == null); } diff --git a/src/history.zig b/src/history.zig new file mode 100644 index 0000000..074da6e --- /dev/null +++ b/src/history.zig @@ -0,0 +1,482 @@ +//! History IO — read `history/-portfolio.srf` files produced by +//! `zfin snapshot` back into typed `Snapshot` structs. +//! +//! Two layers, both 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. +//! +//! 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_mod = @import("models/snapshot.zig"); +const Date = @import("models/date.zig").Date; + +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_mod.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_mod.MetaRow = null; + var totals: std.ArrayList(snapshot_mod.TotalRow) = .empty; + errdefer totals.deinit(allocator); + var taxes: std.ArrayList(snapshot_mod.TaxTypeRow) = .empty; + errdefer taxes.deinit(allocator); + var accounts: std.ArrayList(snapshot_mod.AccountRow) = .empty; + errdefer accounts.deinit(allocator); + var lots: std.ArrayList(snapshot_mod.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_mod.MetaRow, + total: snapshot_mod.TotalRow, + tax_type: snapshot_mod.TaxTypeRow, + account: snapshot_mod.AccountRow, + lot: snapshot_mod.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_mod.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_mod.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, + }; +} + +// ── 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_mod.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); +} diff --git a/src/main.zig b/src/main.zig index 487dfb1..11bff4d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -7,25 +7,25 @@ const usage = \\Usage: zfin [global options] [command options] \\ \\Commands: - \\ interactive [opts] Launch interactive TUI - \\ perf Show 1yr/3yr/5yr/10yr trailing returns (Morningstar-style) - \\ quote Show latest quote with chart and history - \\ history Show recent price history - \\ divs Show dividend history - \\ splits Show split history - \\ options Show options chain (all expirations) - \\ earnings Show earnings history and upcoming - \\ etf Show ETF profile (holdings, sectors, expense ratio) - \\ portfolio Load and analyze the portfolio - \\ analysis Show portfolio analysis - \\ contributions Show money added since last commit (git-based diff) - \\ snapshot [opts] Write a daily portfolio snapshot to history/ - \\ enrich Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) - \\ lookup Look up CUSIP to ticker via OpenFIGI - \\ audit [opts] Reconcile portfolio against brokerage export - \\ cache stats Show cache statistics - \\ cache clear Clear all cached data - \\ version [-v] Show zfin version and build info + \\ interactive [opts] Launch interactive TUI + \\ perf Show 1yr/3yr/5yr/10yr trailing returns (Morningstar-style) + \\ quote Show latest quote with chart and history + \\ history [SYMBOL|opts] Show price history (symbol) or portfolio timeline + \\ divs Show dividend history + \\ splits Show split history + \\ options Show options chain (all expirations) + \\ earnings Show earnings history and upcoming + \\ etf Show ETF profile (holdings, sectors, expense ratio) + \\ portfolio Load and analyze the portfolio + \\ analysis Show portfolio analysis + \\ contributions Show money added since last commit (git-based diff) + \\ snapshot [opts] Write a daily portfolio snapshot to history/ + \\ enrich Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) + \\ lookup Look up CUSIP to ticker via OpenFIGI + \\ audit [opts] Reconcile portfolio against brokerage export + \\ cache stats Show cache statistics + \\ cache clear Clear all cached data + \\ version [-v] Show zfin version and build info \\ \\Global options (must appear before the subcommand): \\ --no-color Disable colored output @@ -232,7 +232,13 @@ pub fn main() !u8 { !std.mem.eql(u8, command, "portfolio") and !std.mem.eql(u8, command, "snapshot") and !std.mem.eql(u8, command, "version"); - if (symbol_cmd and cmd_args.len >= 1) { + // Upper-case the first arg for symbol-taking commands, but skip when + // the arg is a flag (starts with '-'). This lets commands like + // `history` have both symbol mode (`zfin history VTI`) and + // flag-driven mode (`zfin history --since 2026-01-01`). + if (symbol_cmd and cmd_args.len >= 1 and + (cmd_args[0].len == 0 or cmd_args[0][0] != '-')) + { for (cmd_args[0]) |*c| c.* = std.ascii.toUpper(c.*); } @@ -249,11 +255,28 @@ pub fn main() !u8 { } try commands.quote.run(allocator, &svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "history")) { - if (cmd_args.len < 1) { - try cli.stderrPrint("Error: 'history' requires a symbol argument\n"); - return 1; + // Two modes in one command: + // zfin history → candle history for a symbol (legacy) + // zfin history [flags] → portfolio timeline from history/*.srf + // + // Only portfolio mode needs portfolio.srf; symbol mode must keep + // working in directories without a configured portfolio. Dispatch + // at this level so that constraint is visible here, not buried + // inside the command. + const is_symbol_mode = cmd_args.len > 0 and cmd_args[0].len > 0 and cmd_args[0][0] != '-'; + if (is_symbol_mode) { + commands.history.run(allocator, &svc, "", cmd_args, color, out) catch |err| switch (err) { + error.UnexpectedArg, error.MissingFlagValue, error.InvalidFlagValue, error.UnknownMetric => return 1, + else => return err, + }; + } else { + const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); + defer if (pf.resolved) |r| r.deinit(allocator); + commands.history.run(allocator, &svc, pf.path, cmd_args, color, out) catch |err| switch (err) { + error.UnexpectedArg, error.MissingFlagValue, error.InvalidFlagValue, error.UnknownMetric => return 1, + else => return err, + }; } - try commands.history.run(allocator, &svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "divs")) { if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'divs' requires a symbol argument\n"); diff --git a/src/models/snapshot.zig b/src/models/snapshot.zig index f313ec1..720c9ee 100644 --- a/src/models/snapshot.zig +++ b/src/models/snapshot.zig @@ -1,10 +1,11 @@ //! Snapshot record types — the wire format for `history/-portfolio.srf`. //! //! Each record kind below is a plain struct suitable for `srf.fmtFrom` -//! on the write side and `srf.parse` → `Record.to(T)` on the read side. -//! Field order in the struct declaration IS the on-disk order: srf's -//! `Record.from` iterates `inline for (info.fields)`. The leading `kind` -//! field is the discriminator readers use to demux heterogeneous records. +//! on the write side and `srf.iterator` + `FieldIterator.to(Union)` on +//! the read side (see `src/history.zig`, which demuxes via a tagged +//! `SnapshotRecord` union whose `srf_tag_field = "kind"`). Field order +//! in the struct declaration IS the on-disk order: srf's `Record.from` +//! iterates `inline for (info.fields)`. //! //! Lives in `src/models/` because these types describe the data format //! itself — they're consumed by the snapshot writer command @@ -13,10 +14,19 @@ //! them in `commands/` would force analytics to depend on a command //! module, which would be backwards. //! -//! IMPORTANT: `kind` does NOT have a default value. SRF elides fields -//! whose value matches the declared default (see `setField` in -//! `srf.zig`), so `kind: []const u8 = "meta"` would vanish from the -//! output. Each construction site supplies the tag explicitly. +//! IMPORTANT: `kind` uses `= ""` as the default — a sentinel that never +//! matches any real discriminator value. This satisfies two constraints +//! simultaneously: +//! - On write, srf elides fields whose value matches the default. Since +//! real tags are "meta"/"total"/... (never `""`), `kind` is always +//! emitted. +//! - On read via the `SnapshotRecord` tagged union (see `history.zig`), +//! srf's union dispatch consumes the `kind` field itself before +//! coercing into a variant struct, so `kind` is absent from the +//! variant's field stream. The default value prevents a +//! `FieldNotFoundOnFieldWithoutDefaultValue` error. The post-parse +//! `kind` field on variant rows is `""` and should not be consulted — +//! the union tag carries the discriminator. //! //! Optional fields default to `null` so they're elided on null values — //! that's the behavior we want for `price`, `quote_date`, etc. @@ -25,7 +35,7 @@ const std = @import("std"); const Date = @import("date.zig").Date; pub const MetaRow = struct { - kind: []const u8, + kind: []const u8 = "", snapshot_version: u32, as_of_date: Date, captured_at: i64, @@ -36,25 +46,25 @@ pub const MetaRow = struct { }; pub const TotalRow = struct { - kind: []const u8, + kind: []const u8 = "", scope: []const u8, value: f64, }; pub const TaxTypeRow = struct { - kind: []const u8, + kind: []const u8 = "", label: []const u8, value: f64, }; pub const AccountRow = struct { - kind: []const u8, + kind: []const u8 = "", name: []const u8, value: f64, }; pub const LotRow = struct { - kind: []const u8, + kind: []const u8 = "", symbol: []const u8, lot_symbol: []const u8, account: []const u8, @@ -73,6 +83,13 @@ pub const LotRow = struct { /// In-memory portfolio snapshot. `meta` is a single record; the four /// slices are per-section record collections. +/// +/// When constructed by `history.parseSnapshotBytes`, all string fields +/// inside the rows (`symbol`, `label`, etc.) are slices into the +/// caller-owned `bytes` buffer, NOT independently-allocated copies. +/// The caller is responsible for keeping that buffer alive at least as +/// long as the `Snapshot` — typical pattern is `defer allocator.free(bytes)` +/// placed AFTER the `defer snap.deinit(allocator)`. pub const Snapshot = struct { meta: MetaRow, totals: []TotalRow,