zfin/src/commands/history.zig
2026-04-22 15:34:43 -07:00

924 lines
36 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

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

//! `zfin 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 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");
return;
},
else => {
try cli.stderrPrint("Error fetching data.\n");
return;
},
};
defer allocator.free(result.data);
if (result.source == .cached) try cli.stderrPrint("(using cached data)\n");
const all = result.data;
if (all.len == 0) return try cli.stderrPrint("No data available.\n");
const today = fmt.todayDate();
const one_month_ago = today.addDays(-30);
const c = fmt.filterCandlesFrom(all, one_month_ago);
if (c.len == 0) return try cli.stderrPrint("No data available.\n");
try displaySymbol(c, symbol, color, out);
}
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);
try out.print("========================================\n", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print("{s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}\n", .{
"Date", "Open", "High", "Low", "Close", "Volume",
});
try out.print("{s:->12} {s:->10} {s:->10} {s:->10} {s:->10} {s:->12}\n", .{
"", "", "", "", "", "",
});
try cli.reset(out, color);
for (candles) |candle| {
var db: [10]u8 = undefined;
var vb: [32]u8 = undefined;
try cli.setGainLoss(out, color, if (candle.close >= candle.open) 1.0 else -1.0);
try out.print("{s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}\n", .{
candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume),
});
try cli.reset(out, color);
}
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 | Illiquid | Liquid | Net Worth,
/// with each column showing both current value and Δ from the first row.
/// Columns read left-to-right as "components summing to the total" —
/// illiquid + liquid = net worth. 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", "Illiquid (Δ)", "Liquid (Δ)", "Net Worth (Δ)",
});
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.illiquid, p.illiquid - first_ill);
try out.writeAll(" ");
try writeValueDeltaCell(out, color, p.liquid, p.liquid - first_liq);
try out.writeAll(" ");
try writeValueDeltaCell(out, color, p.net_worth, p.net_worth - first_nw);
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. Ordered to match the column layout
// above (components first, total last).
try out.print("\n", .{});
try writeSummaryLine(out, color, " Illiquid ", mapMetric(points, .illiquid));
try writeSummaryLine(out, color, " Liquid ", mapMetric(points, .liquid));
try writeSummaryLine(out, color, " Net Worth", mapMetric(points, .net_worth));
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 ────────────────────────────────────────────────────
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 displaySymbol(&candles, "AAPL", false, &w);
const out = w.buffered();
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 "displaySymbol empty candles" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const candles = [_]zfin.Candle{};
try displaySymbol(&candles, "XYZ", false, &w);
const out = w.buffered();
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);
}