zfin/src/commands/history.zig
Emil Lerch 11a282e2db
All checks were successful
Generic zig build / build (push) Successful in 1m45s
Generic zig build / deploy (push) Successful in 16s
dedupe/centralize and add history view
2026-04-23 23:06:02 -07:00

817 lines
33 KiB
Zig

//! `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 focus; one of
//! liquid (default), illiquid, net_worth
//! --resolution <name> daily | weekly | monthly | auto
//! Defaults to auto: daily ≤90d, weekly ≤730d,
//! else monthly.
//! --limit <N> 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_io = @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(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 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(
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 {
var tl = try history_io.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 => try cli.setFg(out, color, cli.CLR_MUTED),
// `normal` is unreachable in the windows block (build
// never emits it); no-op keeps the switch exhaustive.
.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);
}
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 - <effective>)' 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);
}