//! `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 focus; one of //! liquid (default), illiquid, net_worth //! --resolution daily | weekly | monthly | auto //! Defaults to auto: daily ≤90d, weekly ≤730d, //! else monthly. //! --limit cap the "Recent snapshots" table to N rows //! --rebuild-rollup (re)write history/rollup.srf and exit //! //! Portfolio layout, top-to-bottom: //! 1. Rolling-windows block for the focused metric //! (1D / 1W / 1M / YTD / 1Y / 3Y / 5Y / 10Y / All-time) — anchored //! via `timeline.pointAtOrBefore`, the same snap primitive used by //! candle pricing. //! 2. Braille chart for the focused metric (same primitive as `quote`). //! 3. "Recent snapshots" table: Liquid | Illiquid | Net Worth with //! per-row Δ vs. previous row. Newest-first. Row colored by the //! sign of that row's Δ on the focused metric. 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 = @import("../history.zig"); const snapshot_model = @import("../models/snapshot.zig"); const view = @import("../views/history.zig"); const fmt = cli.fmt; const Date = @import("../models/date.zig").Date; pub const Error = error{ UnexpectedArg, InvalidFlagValue, MissingFlagValue, UnknownMetric, UnknownResolution, }; /// Parsed portfolio-mode options. Separated from `run` so the parser /// is unit-testable. pub const PortfolioOpts = struct { since: ?Date = null, until: ?Date = null, /// Which metric to focus the windows block and chart on. /// Defaults to `.liquid` — matches the TUI history-tab default and /// is the most common reading ("how are my markets doing?"). metric: timeline.Metric = .liquid, /// User-forced resolution. Null means "auto" (derive from span). resolution: ?timeline.Resolution = null, /// Max rows shown in the recent-snapshots table. Null means default (40). limit: ?usize = null, rebuild_rollup: bool = false, }; /// Parse the arg list for portfolio-mode flags. Pure function — no IO. 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, "--resolution")) { i += 1; if (i >= args.len) return error.MissingFlagValue; if (std.mem.eql(u8, args[i], "auto")) { opts.resolution = null; } else { opts.resolution = std.meta.stringToEnum(timeline.Resolution, args[i]) orelse return error.UnknownResolution; } } else if (std.mem.eql(u8, a, "--limit")) { i += 1; if (i >= args.len) return error.MissingFlagValue; opts.limit = std.fmt.parseInt(usize, args[i], 10) catch return error.InvalidFlagValue; } 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. 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 { if (args.len > 0 and args[0].len > 0 and args[0][0] != '-') { try runSymbol(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 flag value.\n"), error.UnknownMetric => try cli.stderrPrint("Error: unknown --metric. Valid: net_worth, liquid, illiquid.\n"), error.UnknownResolution => try cli.stderrPrint("Error: unknown --resolution. Valid: daily, weekly, monthly, auto.\n"), } return err; }; try runPortfolio(allocator, portfolio_path, opts, color, out); } // ── Symbol mode (legacy) ───────────────────────────────────── fn runSymbol( 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 result.deinit(); 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 { var tl = try history.loadTimeline(allocator, portfolio_path); defer tl.deinit(); if (opts.rebuild_rollup) { try rebuildRollup(allocator, tl.history_dir, tl.loaded.snapshots, out); return; } if (tl.loaded.snapshots.len == 0) { try out.print("No portfolio snapshots found in {s}.\n", .{tl.history_dir}); try out.print("Run `zfin snapshot` to capture the first one.\n", .{}); return; } const filtered = try timeline.filterByDate(allocator, tl.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; } const resolution = opts.resolution orelse timeline.selectResolution(filtered); try renderPortfolio(allocator, out, color, filtered, opts.metric, resolution, opts.resolution, opts.limit orelse 40); } /// Regenerate `history/rollup.srf` from `snapshots`. Uses /// `timeline.buildRollupRecords` + `srf.fmtFrom` + atomic write. 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); 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 ──────────────────────────────────────────────── /// Top-level portfolio renderer: windows block → chart → table. /// /// `focus_metric` drives the windows block and chart. The table always /// shows all three metrics in `Liquid → Illiquid → Net Worth` order /// (components sum to total, left-to-right). /// /// `resolution` is the effective (already-resolved) resolution used for /// aggregation. `resolution_override` is the user's `--resolution` /// choice — null means "auto" (the label in the table header will /// reflect that). Both params decoupled because they serve different /// roles: one drives behavior, the other drives labeling. /// /// Row color in the table follows the focused metric's period-over-period /// Δ — so when viewing "liquid", row color reflects "did my liquid /// portfolio go up or down that period?" Period here means the /// resolution of the aggregated table (daily / weekly / monthly). pub fn renderPortfolio( allocator: std.mem.Allocator, out: *std.Io.Writer, color: bool, points: []const timeline.TimelinePoint, focus_metric: timeline.Metric, resolution: timeline.Resolution, resolution_override: ?timeline.Resolution, row_limit: usize, ) !void { try cli.setBold(out, color); try out.print("\nPortfolio Timeline — {s}\n", .{focus_metric.label()}); try cli.reset(out, color); try out.print("========================================\n", .{}); // ── Windows block ───────────────────────────────────────── const today = points[points.len - 1].as_of_date; const ws = try timeline.computeWindowSet(allocator, points, focus_metric, today); defer ws.deinit(); try renderWindowsBlock(out, color, ws); // ── Chart (synthetic candles from focused-metric values) ─ try out.print("\n", .{}); try renderBrailleChart(allocator, out, color, points, focus_metric); // ── Table ──────────────────────────────────────────────── // Aggregate first, then compute per-row deltas on the aggregated // series — this way row color matches the Δ column shown. const aggregated = try timeline.aggregatePoints(allocator, points, resolution); defer allocator.free(aggregated); const deltas = try timeline.computeRowDeltas(allocator, aggregated); defer allocator.free(deltas); try out.print("\n", .{}); try renderTable(out, color, deltas, focus_metric, resolution, resolution_override, row_limit); } /// Render the rolling-windows block. Uses `view/history.zig` helpers so /// CLI and TUI produce aligned output from the same source of truth. /// /// Column widths are the shared constants in `view.windows_*_width`; /// both n/a rows and numeric rows honor them so alignment holds across /// the whole block. fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet) !void { if (ws.rows.len == 0) return; // Header row: " Change Δ %" // Widths pinned to view.windows_*_width constants (12 / 18 / 10). // Hard-coded here for format-string brevity; changes to those // constants must be mirrored in the literal widths below. try cli.setFg(out, color, cli.CLR_MUTED); try out.print(" {s:<12} {s:>18} {s:>10}\n", .{ "Change", "Δ", "%" }); try out.print(" {s:-<12} {s:->18} {s:->10}\n", .{ "", "", "" }); try cli.reset(out, color); for (ws.rows) |row| { var dbuf: [32]u8 = undefined; var pbuf: [16]u8 = undefined; const cells = view.buildWindowRowCells(row, &dbuf, &pbuf); // Whole row colored by style intent. `muted` covers both // zero and missing-anchor rows — neither deserves a // green/red shout. switch (cells.style) { .positive => try cli.setFg(out, color, cli.CLR_POSITIVE), .negative => try cli.setFg(out, color, cli.CLR_NEGATIVE), .muted, .warning => try cli.setFg(out, color, cli.CLR_MUTED), .normal => {}, } try out.print(" {s:<12} {s:>18} {s:>10}", .{ cells.label, cells.delta_str, cells.pct_str, }); try cli.reset(out, color); try out.writeByte('\n'); } } fn renderBrailleChart( allocator: std.mem.Allocator, out: *std.Io.Writer, color: bool, points: []const timeline.TimelinePoint, metric: timeline.Metric, ) !void { if (points.len < 2) return; // Synthesize candles from the focused metric's value. Same pattern // the TUI history tab uses — keeps the chart primitive agnostic of // portfolio-specific types. const candles = try allocator.alloc(zfin.Candle, points.len); defer allocator.free(candles); for (points, 0..) |p, i| { const v = switch (metric) { .net_worth => p.net_worth, .liquid => p.liquid, .illiquid => p.illiquid, }; candles[i] = .{ .date = p.as_of_date, .open = v, .high = v, .low = v, .close = v, .adj_close = v, .volume = 0, }; } var chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return; defer chart.deinit(allocator); try fmt.writeBrailleAnsi(out, &chart, color, cli.CLR_MUTED, false); } fn renderTable( out: *std.Io.Writer, color: bool, deltas: []const timeline.RowDelta, focus_metric: timeline.Metric, resolution: timeline.Resolution, resolution_override: ?timeline.Resolution, row_limit: usize, ) !void { var rlabel_buf: [32]u8 = undefined; const rlabel = view.fmtResolutionLabel(&rlabel_buf, resolution_override, resolution); try cli.setBold(out, color); try out.print(" Recent snapshots {s}\n", .{rlabel}); try cli.reset(out, color); try cli.setFg(out, color, cli.CLR_MUTED); // Column order: Liquid → Illiquid → Net Worth (components sum to total). try out.print(" {s:>10} {s:>28} {s:>28} {s:>28}\n", .{ "Date", "Liquid (Δ)", "Illiquid (Δ)", "Net Worth (Δ)", }); try out.print(" {s:->10} {s:->28} {s:->28} {s:->28}\n", .{ "", "", "", "" }); try cli.reset(out, color); // Newest-first iteration. `row_limit` caps how many rows we emit. var emitted: usize = 0; var i: usize = deltas.len; while (i > 0) { i -= 1; if (emitted >= row_limit) break; try writeTableRow(out, color, deltas[i], focus_metric); emitted += 1; } try out.print("\n {d} snapshots ({d} shown)\n\n", .{ deltas.len, emitted }); } fn writeTableRow( out: *std.Io.Writer, color: bool, row: timeline.RowDelta, focus_metric: timeline.Metric, ) !void { // Row color follows the focused metric's delta. First row has null // deltas → muted. const focus_delta_opt: ?f64 = switch (focus_metric) { .liquid => row.d_liquid, .illiquid => row.d_illiquid, .net_worth => row.d_net_worth, }; if (focus_delta_opt) |d| { if (d == 0) { try cli.setFg(out, color, cli.CLR_MUTED); } else { try cli.setGainLoss(out, color, d); } } else { try cli.setFg(out, color, cli.CLR_MUTED); } // Composite cells are built via the shared `view.fmtValueDeltaCell` // so the TUI's cells align byte-for-byte with what's emitted here. var db: [10]u8 = undefined; var cbuf_l: [64]u8 = undefined; var cbuf_i: [64]u8 = undefined; var cbuf_n: [64]u8 = undefined; try out.print(" {s:>10} ", .{row.date.format(&db)}); try out.writeAll(view.fmtValueDeltaCell(&cbuf_l, row.liquid, row.d_liquid, view.table_cell_width)); try out.writeAll(" "); try out.writeAll(view.fmtValueDeltaCell(&cbuf_i, row.illiquid, row.d_illiquid, view.table_cell_width)); try out.writeAll(" "); try out.writeAll(view.fmtValueDeltaCell(&cbuf_n, row.net_worth, row.d_net_worth, view.table_cell_width)); try cli.reset(out, color); try out.writeByte('\n'); } // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; test "parsePortfolioOpts: defaults" { const o = try parsePortfolioOpts(&.{}); try testing.expect(o.since == null); try testing.expect(o.until == null); // Default metric is liquid (matches TUI default). try testing.expectEqual(timeline.Metric.liquid, o.metric); try testing.expect(o.resolution == null); // auto try testing.expect(o.limit == null); try testing.expect(!o.rebuild_rollup); } test "parsePortfolioOpts: --since / --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" { const a1 = [_][]const u8{ "--metric", "illiquid" }; const o1 = try parsePortfolioOpts(&a1); try testing.expectEqual(timeline.Metric.illiquid, o1.metric); const a2 = [_][]const u8{ "--metric", "net_worth" }; const o2 = try parsePortfolioOpts(&a2); try testing.expectEqual(timeline.Metric.net_worth, o2.metric); } test "parsePortfolioOpts: --resolution parses all four forms" { const ad = [_][]const u8{ "--resolution", "daily" }; try testing.expectEqual(timeline.Resolution.daily, (try parsePortfolioOpts(&ad)).resolution.?); const aw = [_][]const u8{ "--resolution", "weekly" }; try testing.expectEqual(timeline.Resolution.weekly, (try parsePortfolioOpts(&aw)).resolution.?); const am = [_][]const u8{ "--resolution", "monthly" }; try testing.expectEqual(timeline.Resolution.monthly, (try parsePortfolioOpts(&am)).resolution.?); // "auto" resolves to null (defer to selectResolution at render time). const aa = [_][]const u8{ "--resolution", "auto" }; try testing.expect((try parsePortfolioOpts(&aa)).resolution == null); } test "parsePortfolioOpts: --limit parses integer" { const args = [_][]const u8{ "--limit", "25" }; const o = try parsePortfolioOpts(&args); try testing.expectEqual(@as(usize, 25), o.limit.?); } 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 / value errors" { try testing.expectError(error.UnexpectedArg, parsePortfolioOpts(&[_][]const u8{"--bogus"})); try testing.expectError(error.MissingFlagValue, parsePortfolioOpts(&[_][]const u8{"--since"})); try testing.expectError(error.InvalidFlagValue, parsePortfolioOpts(&[_][]const u8{ "--since", "not-a-date" })); try testing.expectError(error.UnknownMetric, parsePortfolioOpts(&[_][]const u8{ "--metric", "bogus" })); try testing.expectError(error.UnknownResolution, parsePortfolioOpts(&[_][]const u8{ "--resolution", "bogus" })); try testing.expectError(error.InvalidFlagValue, parsePortfolioOpts(&[_][]const u8{ "--limit", "not-a-number" })); } // ── renderPortfolio (end-to-end) ───────────────────────────── fn makeTimelinePoint(y: i16, m: u8, d: u8, liq: f64, ill: f64, nw: f64) timeline.TimelinePoint { return .{ .as_of_date = Date.fromYmd(y, m, d), .net_worth = nw, .liquid = liq, .illiquid = ill, .accounts = &.{}, .tax_types = &.{}, }; } test "renderPortfolio: shows header, windows block, chart, and table" { var buf: [32 * 1024]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const pts = [_]timeline.TimelinePoint{ makeTimelinePoint(2026, 4, 17, 700, 300, 1000), makeTimelinePoint(2026, 4, 18, 750, 350, 1100), makeTimelinePoint(2026, 4, 21, 800, 400, 1200), }; try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 40); const out = w.buffered(); // Header try testing.expect(std.mem.indexOf(u8, out, "Portfolio Timeline") != null); try testing.expect(std.mem.indexOf(u8, out, "Liquid") != null); // Windows block — "1 day" row exists (anchored to prior snapshot) try testing.expect(std.mem.indexOf(u8, out, "1 day") != null); try testing.expect(std.mem.indexOf(u8, out, "All-time") != null); // Windows-block header reads "Change" (not "Change over") try testing.expect(std.mem.indexOf(u8, out, "Change") != null); // Long-horizon windows show n/a (only 4 days of history) try testing.expect(std.mem.indexOf(u8, out, "1 year") != null); try testing.expect(std.mem.indexOf(u8, out, "n/a") != null); // Table header: column order Liquid → Illiquid → Net Worth const liq_idx = std.mem.indexOf(u8, out, "Liquid (Δ)") orelse return error.TestExpectedMatch; const ill_idx = std.mem.indexOf(u8, out, "Illiquid (Δ)") orelse return error.TestExpectedMatch; const nw_idx = std.mem.indexOf(u8, out, "Net Worth (Δ)") orelse return error.TestExpectedMatch; try testing.expect(liq_idx < ill_idx); try testing.expect(ill_idx < nw_idx); // Newest-first: 2026-04-21 appears before 2026-04-17 in the output. const d_new = std.mem.indexOf(u8, out, "2026-04-21") orelse return error.TestExpectedMatch; const d_old = std.mem.indexOf(u8, out, "2026-04-17") orelse return error.TestExpectedMatch; try testing.expect(d_new < d_old); // Table count line try testing.expect(std.mem.indexOf(u8, out, "3 snapshots") != null); // Resolution label explicit → "(daily)", not "(auto - daily)" try testing.expect(std.mem.indexOf(u8, out, "(daily)") != null); try testing.expect(std.mem.indexOf(u8, out, "auto") == null); // No ANSI when color=false try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); } test "renderPortfolio: auto resolution shows '(auto - )' label" { var buf: [32 * 1024]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const pts = [_]timeline.TimelinePoint{ makeTimelinePoint(2026, 4, 17, 700, 300, 1000), makeTimelinePoint(2026, 4, 18, 750, 350, 1100), }; // resolution_override = null → auto. Effective is daily (span ≤ 90d). try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, null, 40); const out = w.buffered(); try testing.expect(std.mem.indexOf(u8, out, "(auto - daily)") != null); } test "renderPortfolio: color mode emits ANSI" { var buf: [32 * 1024]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const pts = [_]timeline.TimelinePoint{ makeTimelinePoint(2026, 4, 17, 700, 300, 1000), makeTimelinePoint(2026, 4, 18, 750, 350, 1100), }; try renderPortfolio(testing.allocator, &w, true, &pts, .liquid, .daily, .daily, 40); try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") != null); } test "renderPortfolio: single point renders without crashing" { var buf: [16 * 1024]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const pts = [_]timeline.TimelinePoint{ makeTimelinePoint(2026, 4, 17, 700, 300, 1000), }; try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 40); const out = w.buffered(); try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null); // Chart requires >= 2 points; confirm no crash, table shows one row. try testing.expect(std.mem.indexOf(u8, out, "1 snapshots") != null); // First row has no prior row → focused-metric delta is em-dash. try testing.expect(std.mem.indexOf(u8, out, "—") != null); } test "renderPortfolio: row_limit caps table rows" { var buf: [32 * 1024]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const pts = [_]timeline.TimelinePoint{ makeTimelinePoint(2026, 4, 17, 700, 300, 1000), makeTimelinePoint(2026, 4, 18, 750, 350, 1100), makeTimelinePoint(2026, 4, 19, 760, 360, 1120), makeTimelinePoint(2026, 4, 20, 770, 370, 1140), makeTimelinePoint(2026, 4, 21, 780, 380, 1160), }; try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 2); const out = w.buffered(); // 5 snapshots total, 2 shown. try testing.expect(std.mem.indexOf(u8, out, "5 snapshots") != null); try testing.expect(std.mem.indexOf(u8, out, "2 shown") != null); // Newest two are 04-20 and 04-21 — both present. 04-17 must be absent. try testing.expect(std.mem.indexOf(u8, out, "2026-04-21") != null); try testing.expect(std.mem.indexOf(u8, out, "2026-04-20") != null); try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") == null); } test "renderPortfolio: monthly resolution labels the table accordingly" { var buf: [32 * 1024]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const pts = [_]timeline.TimelinePoint{ makeTimelinePoint(2026, 2, 28, 700, 300, 1000), makeTimelinePoint(2026, 3, 31, 800, 400, 1200), makeTimelinePoint(2026, 4, 21, 900, 500, 1400), }; try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .monthly, .monthly, 40); const out = w.buffered(); try testing.expect(std.mem.indexOf(u8, out, "(monthly)") != null); } // Legacy symbol-mode tests — retained. 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); } // ── rebuildRollup ──────────────────────────────────────────── 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); const out_str = out.buffered(); try testing.expect(std.mem.indexOf(u8, out_str, "2 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, 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); 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); try rebuildRollup(testing.allocator, nested, &snaps, &out); 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); try testing.expect(std.mem.startsWith(u8, bytes, "#!srfv1")); try testing.expect(std.mem.indexOf(u8, bytes, "kind::rollup") == null); }