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 a7e8dc6030
commit 8561a130e0
Signed by: lobo
GPG key ID: A7B62D657EF764F8
7 changed files with 2168 additions and 54 deletions

View file

@ -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

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 run -- <args> # 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`):

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

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 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 <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) {
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);
}

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]
\\
\\Commands:
\\ interactive [opts] Launch interactive TUI
\\ perf <SYMBOL> Show 1yr/3yr/5yr/10yr trailing returns (Morningstar-style)
\\ quote <SYMBOL> Show latest quote with chart and history
\\ history <SYMBOL> Show recent price history
\\ divs <SYMBOL> Show dividend history
\\ splits <SYMBOL> Show split history
\\ options <SYMBOL> Show options chain (all expirations)
\\ earnings <SYMBOL> Show earnings history and upcoming
\\ etf <SYMBOL> 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 <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> 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 <SYMBOL> Show 1yr/3yr/5yr/10yr trailing returns (Morningstar-style)
\\ quote <SYMBOL> Show latest quote with chart and history
\\ history [SYMBOL|opts] Show price history (symbol) or portfolio timeline
\\ divs <SYMBOL> Show dividend history
\\ splits <SYMBOL> Show split history
\\ options <SYMBOL> Show options chain (all expirations)
\\ earnings <SYMBOL> Show earnings history and upcoming
\\ etf <SYMBOL> 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 <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> 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 <SYMBOL> 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");

View file

@ -1,10 +1,11 @@
//! Snapshot record types the wire format for `history/<date>-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,