//! `zfin history` — two modes in one command: //! //! zfin history → candle history for a symbol (legacy) //! zfin history [flags] → portfolio-value timeline from //! history/*-portfolio.srf snapshots //! //! Mode dispatch: if cmd_args[0] exists and doesn't start with `-`, //! treat as symbol mode. Otherwise portfolio mode. //! //! Portfolio-mode flags: //! --since earliest as_of_date (inclusive) //! --until latest as_of_date (inclusive) //! --metric which metric to plot; one of //! net_worth (default), liquid, illiquid //! --rebuild-rollup (re)write history/rollup.srf and exit //! //! The CLI renderer is a thin wrapper over pure analytics. The compute //! layer (`src/analytics/timeline.zig`) and IO layer (`src/history.zig`) //! are fully testable on their own; this module is only responsible for //! flag parsing, path resolution, and text-table presentation. const std = @import("std"); const srf = @import("srf"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const atomic = @import("../atomic.zig"); const timeline = @import("../analytics/timeline.zig"); const history_io = @import("../history.zig"); const snapshot_model = @import("../models/snapshot.zig"); const fmt = cli.fmt; const Date = @import("../models/date.zig").Date; pub const Error = error{ UnexpectedArg, InvalidFlagValue, MissingFlagValue, UnknownMetric, }; /// Parsed portfolio-mode options. Separated from `run` so the parser /// is unit-testable. pub const PortfolioOpts = struct { since: ?Date = null, until: ?Date = null, /// Null means "render all three (net_worth + liquid + illiquid) side /// by side" — the default. A non-null value focuses the display on a /// single metric (user passed `--metric `). metric: ?timeline.Metric = null, rebuild_rollup: bool = false, }; /// Parse the arg list for portfolio-mode flags. Pure function — no IO. /// Returns an error for unknown flags, malformed dates, or unknown /// metric names so the CLI surface can map them to distinct messages. pub fn parsePortfolioOpts(args: []const []const u8) Error!PortfolioOpts { var opts: PortfolioOpts = .{}; var i: usize = 0; while (i < args.len) : (i += 1) { const a = args[i]; if (std.mem.eql(u8, a, "--since")) { i += 1; if (i >= args.len) return error.MissingFlagValue; opts.since = Date.parse(args[i]) catch return error.InvalidFlagValue; } else if (std.mem.eql(u8, a, "--until")) { i += 1; if (i >= args.len) return error.MissingFlagValue; opts.until = Date.parse(args[i]) catch return error.InvalidFlagValue; } else if (std.mem.eql(u8, a, "--metric")) { i += 1; if (i >= args.len) return error.MissingFlagValue; opts.metric = std.meta.stringToEnum(timeline.Metric, args[i]) orelse return error.UnknownMetric; } else if (std.mem.eql(u8, a, "--rebuild-rollup")) { opts.rebuild_rollup = true; } else { return error.UnexpectedArg; } } return opts; } /// Entry point. Dispatches to symbol mode or portfolio mode based on /// the first argument. /// /// `portfolio_path` is the resolved path to portfolio.srf (used only in /// portfolio mode to derive the history directory). Symbol mode ignores /// it. pub fn run( allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, args: []const []const u8, color: bool, out: *std.Io.Writer, ) !void { // Symbol-mode heuristic: cmd_args[0] exists and doesn't look like a flag. if (args.len > 0 and args[0].len > 0 and args[0][0] != '-') { try runSymbol(allocator, svc, args[0], color, out); return; } const opts = parsePortfolioOpts(args) catch |err| { switch (err) { error.UnexpectedArg => try cli.stderrPrint("Error: unknown flag in 'history'. See --help.\n"), error.MissingFlagValue => try cli.stderrPrint("Error: flag requires a value.\n"), error.InvalidFlagValue => try cli.stderrPrint("Error: invalid date (expected YYYY-MM-DD).\n"), error.UnknownMetric => try cli.stderrPrint("Error: unknown --metric. Valid: net_worth, liquid, illiquid.\n"), } return err; }; try runPortfolio(allocator, portfolio_path, opts, color, out); } // ── Symbol mode (legacy) ───────────────────────────────────── fn runSymbol( allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer, ) !void { const result = svc.getCandles(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try cli.stderrPrint("Error: No API key configured for candle data.\n"); 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); }