initial commit for new portfolio history (UI will change)

This commit is contained in:
Emil Lerch 2026-04-22 12:40:54 -07:00
parent 05d2fce9a5
commit ee979eb794
7 changed files with 2168 additions and 54 deletions

View file

@ -29,7 +29,7 @@ repos:
- id: test - id: test
name: Run zig build test name: Run zig build test
entry: zig entry: zig
args: ["build", "coverage", "-Dcoverage-threshold=49"] args: ["build", "coverage", "-Dcoverage-threshold=52"]
language: system language: system
types: [file] types: [file]
pass_filenames: false pass_filenames: false

View file

@ -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 test # run all tests (single binary, discovers all tests via refAllDeclsRecursive)
zig build run -- <args> # build and run CLI zig build run -- <args> # build and run CLI
zig build docs # generate library documentation 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`): **Tooling** (managed via `.mise.toml`):

755
src/analytics/timeline.zig Normal file
View file

@ -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 = "Riley 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);
}

View file

@ -1,9 +1,125 @@
//! `zfin history` two modes in one command:
//!
//! zfin history <SYMBOL> 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 <YYYY-MM-DD> earliest as_of_date (inclusive)
//! --until <YYYY-MM-DD> latest as_of_date (inclusive)
//! --metric <name> 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 std = @import("std");
const srf = @import("srf");
const zfin = @import("../root.zig"); const zfin = @import("../root.zig");
const cli = @import("common.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 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 <name>`).
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) { const result = svc.getCandles(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => { zfin.DataError.NoApiKey => {
try cli.stderrPrint("Error: No API key configured for candle data.\n"); 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); const c = fmt.filterCandlesFrom(all, one_month_ago);
if (c.len == 0) return try cli.stderrPrint("No data available.\n"); 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 cli.setBold(out, color);
try out.print("\nPrice History for {s} (last 30 days)\n", .{symbol}); try out.print("\nPrice History for {s} (last 30 days)\n", .{symbol});
try cli.reset(out, color); 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}); 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 // 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 buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); var w: std.Io.Writer = .fixed(&buf);
const candles = [_]zfin.Candle{ 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 = 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 }, .{ .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(); const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); try testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Date") != null); try testing.expect(std.mem.indexOf(u8, out, "Date") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Open") != null); try testing.expect(std.mem.indexOf(u8, out, "Open") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "2 trading days") != null); try testing.expect(std.mem.indexOf(u8, out, "2 trading days") != null);
// No ANSI when color=false try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
} }
test "display empty candles" { test "displaySymbol empty candles" {
var buf: [4096]u8 = undefined; var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); var w: std.Io.Writer = .fixed(&buf);
const candles = [_]zfin.Candle{}; const candles = [_]zfin.Candle{};
try display(&candles, "XYZ", false, &w); try displaySymbol(&candles, "XYZ", false, &w);
const out = w.buffered(); const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "XYZ") != null); try 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, "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);
} }

482
src/history.zig Normal file
View file

@ -0,0 +1,482 @@
//! History IO read `history/<date>-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::<meta|total|tax_type|account|lot>` 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);
}

View file

@ -7,25 +7,25 @@ const usage =
\\Usage: zfin [global options] <command> [command options] \\Usage: zfin [global options] <command> [command options]
\\ \\
\\Commands: \\Commands:
\\ interactive [opts] Launch interactive TUI \\ interactive [opts] Launch interactive TUI
\\ perf <SYMBOL> Show 1yr/3yr/5yr/10yr trailing returns (Morningstar-style) \\ perf <SYMBOL> Show 1yr/3yr/5yr/10yr trailing returns (Morningstar-style)
\\ quote <SYMBOL> Show latest quote with chart and history \\ quote <SYMBOL> Show latest quote with chart and history
\\ history <SYMBOL> Show recent price history \\ history [SYMBOL|opts] Show price history (symbol) or portfolio timeline
\\ divs <SYMBOL> Show dividend history \\ divs <SYMBOL> Show dividend history
\\ splits <SYMBOL> Show split history \\ splits <SYMBOL> Show split history
\\ options <SYMBOL> Show options chain (all expirations) \\ options <SYMBOL> Show options chain (all expirations)
\\ earnings <SYMBOL> Show earnings history and upcoming \\ earnings <SYMBOL> Show earnings history and upcoming
\\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio) \\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio)
\\ portfolio Load and analyze the portfolio \\ portfolio Load and analyze the portfolio
\\ analysis Show portfolio analysis \\ analysis Show portfolio analysis
\\ contributions Show money added since last commit (git-based diff) \\ contributions Show money added since last commit (git-based diff)
\\ snapshot [opts] Write a daily portfolio snapshot to history/ \\ snapshot [opts] Write a daily portfolio snapshot to history/
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) \\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI \\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
\\ audit [opts] Reconcile portfolio against brokerage export \\ audit [opts] Reconcile portfolio against brokerage export
\\ cache stats Show cache statistics \\ cache stats Show cache statistics
\\ cache clear Clear all cached data \\ cache clear Clear all cached data
\\ version [-v] Show zfin version and build info \\ version [-v] Show zfin version and build info
\\ \\
\\Global options (must appear before the subcommand): \\Global options (must appear before the subcommand):
\\ --no-color Disable colored output \\ --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, "portfolio") and
!std.mem.eql(u8, command, "snapshot") and !std.mem.eql(u8, command, "snapshot") and
!std.mem.eql(u8, command, "version"); !std.mem.eql(u8, command, "version");
if (symbol_cmd and cmd_args.len >= 1) { // 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.*); 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); try commands.quote.run(allocator, &svc, cmd_args[0], color, out);
} else if (std.mem.eql(u8, command, "history")) { } else if (std.mem.eql(u8, command, "history")) {
if (cmd_args.len < 1) { // Two modes in one command:
try cli.stderrPrint("Error: 'history' requires a symbol argument\n"); // zfin history <SYMBOL> candle history for a symbol (legacy)
return 1; // 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")) { } else if (std.mem.eql(u8, command, "divs")) {
if (cmd_args.len < 1) { if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'divs' requires a symbol argument\n"); try cli.stderrPrint("Error: 'divs' requires a symbol argument\n");

View file

@ -1,10 +1,11 @@
//! Snapshot record types the wire format for `history/<date>-portfolio.srf`. //! Snapshot record types the wire format for `history/<date>-portfolio.srf`.
//! //!
//! Each record kind below is a plain struct suitable for `srf.fmtFrom` //! 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. //! on the write side and `srf.iterator` + `FieldIterator.to(Union)` on
//! Field order in the struct declaration IS the on-disk order: srf's //! the read side (see `src/history.zig`, which demuxes via a tagged
//! `Record.from` iterates `inline for (info.fields)`. The leading `kind` //! `SnapshotRecord` union whose `srf_tag_field = "kind"`). Field order
//! field is the discriminator readers use to demux heterogeneous records. //! 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 //! Lives in `src/models/` because these types describe the data format
//! itself they're consumed by the snapshot writer command //! itself they're consumed by the snapshot writer command
@ -13,10 +14,19 @@
//! them in `commands/` would force analytics to depend on a command //! them in `commands/` would force analytics to depend on a command
//! module, which would be backwards. //! module, which would be backwards.
//! //!
//! IMPORTANT: `kind` does NOT have a default value. SRF elides fields //! IMPORTANT: `kind` uses `= ""` as the default a sentinel that never
//! whose value matches the declared default (see `setField` in //! matches any real discriminator value. This satisfies two constraints
//! `srf.zig`), so `kind: []const u8 = "meta"` would vanish from the //! simultaneously:
//! output. Each construction site supplies the tag explicitly. //! - 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 //! Optional fields default to `null` so they're elided on null values
//! that's the behavior we want for `price`, `quote_date`, etc. //! 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; const Date = @import("date.zig").Date;
pub const MetaRow = struct { pub const MetaRow = struct {
kind: []const u8, kind: []const u8 = "",
snapshot_version: u32, snapshot_version: u32,
as_of_date: Date, as_of_date: Date,
captured_at: i64, captured_at: i64,
@ -36,25 +46,25 @@ pub const MetaRow = struct {
}; };
pub const TotalRow = struct { pub const TotalRow = struct {
kind: []const u8, kind: []const u8 = "",
scope: []const u8, scope: []const u8,
value: f64, value: f64,
}; };
pub const TaxTypeRow = struct { pub const TaxTypeRow = struct {
kind: []const u8, kind: []const u8 = "",
label: []const u8, label: []const u8,
value: f64, value: f64,
}; };
pub const AccountRow = struct { pub const AccountRow = struct {
kind: []const u8, kind: []const u8 = "",
name: []const u8, name: []const u8,
value: f64, value: f64,
}; };
pub const LotRow = struct { pub const LotRow = struct {
kind: []const u8, kind: []const u8 = "",
symbol: []const u8, symbol: []const u8,
lot_symbol: []const u8, lot_symbol: []const u8,
account: []const u8, account: []const u8,
@ -73,6 +83,13 @@ pub const LotRow = struct {
/// In-memory portfolio snapshot. `meta` is a single record; the four /// In-memory portfolio snapshot. `meta` is a single record; the four
/// slices are per-section record collections. /// 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 { pub const Snapshot = struct {
meta: MetaRow, meta: MetaRow,
totals: []TotalRow, totals: []TotalRow,