zfin/src/commands/history.zig

1070 lines
44 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 framework = @import("framework.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("../Date.zig");
/// Parsed args for `zfin history`. Tagged union since the command has
/// two genuinely different modes:
///
/// - Symbol mode: `zfin history <SYMBOL>` shows the last 30 days of
/// candles for one symbol. Triggered when `args[0]` exists and
/// doesn't start with `-`.
/// - Portfolio mode: `zfin history [flags]` shows the portfolio-value
/// timeline derived from `history/*-portfolio.srf` snapshots.
/// Triggered when there are no positional args (or only flags).
pub const ParsedArgs = union(enum) {
symbol: []const u8,
portfolio: PortfolioOpts,
};
pub const meta: framework.Meta = .{
.name = "history",
.group = .symbol_lookup,
.synopsis = "Price history (symbol) or portfolio value timeline",
.uppercase_first_arg = true,
.help =
\\Usage:
\\ zfin history <SYMBOL> # last 30 days of candles
\\ zfin history [flags] # portfolio-value timeline
\\
\\Two modes in one command. Symbol mode (positional symbol)
\\shows the last 30 trading days of candles for that symbol.
\\Portfolio mode (no positional, optionally with flags) reads
\\`history/*-portfolio.srf` snapshots and renders rolling-windows
\\returns + a braille chart + a recent-snapshots table.
\\
\\Portfolio-mode flags:
\\ --since <DATE> earliest as-of date (inclusive)
\\ --until <DATE> latest as-of date (inclusive)
\\ --metric <name> liquid (default), illiquid, or net_worth
\\ --resolution <name> daily | weekly | monthly | auto
\\ (auto: daily ≤90d, weekly ≤730d, else monthly)
\\ --limit <N> cap recent-snapshots table to N rows (default 40)
\\ --rebuild-rollup regenerate history/rollup.srf and exit
\\
\\DATE accepts YYYY-MM-DD or relative shortcuts (1W/1M/1Q/1Y).
\\
,
.user_errors = error{ UnexpectedArg, MissingFlagValue, InvalidFlagValue, UnknownMetric, UnknownResolution },
};
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 + `resolution_auto = false` means
/// "use the cascading default." Null + `resolution_auto = true`
/// means "use the legacy auto-pick (one of daily/weekly/monthly
/// based on span)."
resolution: ?timeline.Resolution = null,
/// True iff the user passed `--resolution auto`. Distinguishes
/// "user explicitly asked for auto" from "user said nothing."
resolution_auto: bool = false,
/// 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.
///
/// `--since` and `--until` accept the same grammar as other commands:
/// `YYYY-MM-DD` or a relative shortcut like `1W`, `1M`, `1Q`, `1Y`.
/// Relative forms resolve against `as_of` (passed in by the caller, so
/// tests can pin it). Pass explicit ISO dates for test determinism.
pub fn parsePortfolioOpts(as_of: zfin.Date, 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 = cli.parseRequiredDate(args[i], as_of) catch return error.InvalidFlagValue;
} else if (std.mem.eql(u8, a, "--until")) {
i += 1;
if (i >= args.len) return error.MissingFlagValue;
opts.until = cli.parseRequiredDate(args[i], as_of) 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;
opts.resolution_auto = true;
} else {
opts.resolution = std.meta.stringToEnum(timeline.Resolution, args[i]) orelse return error.UnknownResolution;
opts.resolution_auto = false;
}
} 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;
}
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len > 0 and cmd_args[0].len > 0 and cmd_args[0][0] != '-') {
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'history' symbol mode takes only the symbol argument\n");
return error.UnexpectedArg;
}
return .{ .symbol = cmd_args[0] };
}
const opts = parsePortfolioOpts(ctx.today, cmd_args) catch |err| {
switch (err) {
error.UnexpectedArg => try cli.stderrPrint(ctx.io, "Error: unknown flag in 'history'. See --help.\n"),
error.MissingFlagValue => try cli.stderrPrint(ctx.io, "Error: flag requires a value.\n"),
error.InvalidFlagValue => try cli.stderrPrint(ctx.io, "Error: invalid flag value.\n"),
error.UnknownMetric => try cli.stderrPrint(ctx.io, "Error: unknown --metric. Valid: net_worth, liquid, illiquid.\n"),
error.UnknownResolution => try cli.stderrPrint(ctx.io, "Error: unknown --resolution. Valid: daily, weekly, monthly, auto.\n"),
}
return err;
};
return .{ .portfolio = opts };
}
/// Entry point. Dispatches to symbol mode or portfolio mode based on
/// the parsed args.
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
const fetch_opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
switch (parsed) {
.symbol => |sym| try runSymbol(ctx.io, svc, sym, ctx.today, ctx.color, ctx.out, fetch_opts),
.portfolio => |opts| {
const pf = ctx.resolvePortfolioPath();
defer pf.deinit(ctx.allocator);
try runPortfolio(ctx.io, ctx.allocator, pf.path, opts, ctx.color, ctx.out);
},
}
}
// ── Symbol mode (legacy) ─────────────────────────────────────
fn runSymbol(
io: std.Io,
svc: *zfin.DataService,
symbol: []const u8,
as_of: zfin.Date,
color: bool,
out: *std.Io.Writer,
opts: zfin.FetchOptions,
) !void {
const result = svc.getCandles(symbol, opts) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint(io, "Error: No API key configured for candle data.\n");
return;
},
else => {
try cli.stderrPrint(io, "Error fetching data.\n");
return;
},
};
defer result.deinit();
if (result.source == .cached) try cli.stderrPrint(io, "(using cached data)\n");
const all = result.data;
if (all.len == 0) return try cli.stderrPrint(io, "No data available.\n");
const one_month_ago = as_of.addDays(-30);
const c = fmt.filterCandlesFrom(all, one_month_ago);
if (c.len == 0) return try cli.stderrPrint(io, "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.printBold(out, color, "\nPrice History for {s} (last 30 days)\n", .{symbol});
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 vb: [32]u8 = undefined;
try cli.printGainLoss(out, color, if (candle.close >= candle.open) 1.0 else -1.0, "{f} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}\n", .{
candle.date.padLeft(12), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume),
});
}
try out.print("\n{d} trading days\n\n", .{candles.len});
}
// ── Portfolio mode ───────────────────────────────────────────
fn runPortfolio(
io: std.Io,
allocator: std.mem.Allocator,
portfolio_path: []const u8,
opts: PortfolioOpts,
color: bool,
out: *std.Io.Writer,
) !void {
var tl = try history.loadTimeline(io, allocator, portfolio_path);
defer tl.deinit();
if (opts.rebuild_rollup) {
// wall-clock required: the rollup.srf `#!created=` directive
// captures when this rebuild happened. Single read per command.
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
try rebuildRollup(io, allocator, tl.history_dir, tl.loaded.snapshots, now_s, 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;
}
// Resolve the effective resolution:
// - explicit `--resolution daily/weekly/monthly/cascading` →
// use as-is.
// - `--resolution auto` → pick one of daily/weekly/monthly
// based on the series span (legacy behavior).
// - omitted → `cascading` (the human-facing default).
const resolution: timeline.Resolution = if (opts.resolution) |r|
r
else if (opts.resolution_auto)
timeline.selectResolution(filtered)
else
.cascading;
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.
fn rebuildRollup(
io: std.Io,
allocator: std.mem.Allocator,
history_dir: []const u8,
snapshots: []const snapshot_model.Snapshot,
now_s: i64,
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 = now_s,
})});
const rendered = aw.written();
const rollup_path = try std.fs.path.join(allocator, &.{ history_dir, "rollup.srf" });
defer allocator.free(rollup_path);
std.Io.Dir.cwd().createDirPath(io, history_dir) catch |err| switch (err) {
error.PathAlreadyExists => {},
else => return err,
};
try atomic.writeFileAtomic(io, 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.printBold(out, color, "\nPortfolio Timeline — {s}\n", .{focus_metric.label()});
try out.print("========================================\n", .{});
// ── Windows block ─────────────────────────────────────────
const as_of = points[points.len - 1].as_of_date;
const ws = try timeline.computeWindowSet(allocator, points, focus_metric, as_of);
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 ────────────────────────────────────────────────
if (resolution == .cascading) {
try out.print("\n", .{});
const ts = try timeline.aggregateCascading(allocator, points, as_of);
defer ts.deinit();
try renderCascadingTable(allocator, out, color, ts, focus_metric, row_limit);
return;
}
// Flat aggregation (daily/weekly/monthly/auto).
// 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;
// Methodology note. The values in this block are
// snapshot-to-snapshot Liquid deltas (or whichever metric is
// focused) — they include contributions, withdrawals, and
// weight drift, distinct from the `projections` benchmark
// table which reports price-only weighted returns and so will
// disagree on weeks with significant cash movement or
// rebalancing.
try cli.printFg(out, color, cli.CLR_MUTED, " (snapshot-to-snapshot Δ; includes contributions, withdrawals, weight drift)\n", .{});
// Header row: " Change Δ % % / yr"
// Widths pinned to view.windows_*_width constants (12 / 18 / 10 / 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} {s:>10}\n", .{ "Change", "Δ", "%", "% / yr" });
try out.print(" {s:-<12} {s:->18} {s:->10} {s:->10}\n", .{ "", "", "", "" });
try cli.reset(out, color);
for (ws.rows) |row| {
var dbuf: [32]u8 = undefined;
var pbuf: [16]u8 = undefined;
var abuf: [16]u8 = undefined;
const cells = view.buildWindowRowCells(row, &dbuf, &pbuf, &abuf);
// 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),
.accent => try cli.setFg(out, color, cli.CLR_HEADER),
.info => try cli.setFg(out, color, cli.CLR_INFO),
.normal => {},
}
try out.print(" {s:<12} {s:>18} {s:>10} {s:>10}", .{
cells.label,
cells.delta_str,
cells.pct_str,
cells.ann_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. For
// illiquid / net_worth, skip imported-only points so the
// line is visually absent in the imported-only range rather
// than hugging zero.
var candles_list: std.ArrayList(zfin.Candle) = .empty;
defer candles_list.deinit(allocator);
try candles_list.ensureTotalCapacity(allocator, points.len);
const skip_imported = (metric == .illiquid) or (metric == .net_worth);
for (points) |p| {
if (skip_imported and p.source == .imported) continue;
const v = switch (metric) {
.net_worth => p.net_worth,
.liquid => p.liquid,
.illiquid => p.illiquid,
};
try candles_list.append(allocator, .{
.date = p.as_of_date,
.open = v,
.high = v,
.low = v,
.close = v,
.adj_close = v,
.volume = 0,
});
}
const candles = candles_list.items;
if (candles.len < 2) return;
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.printBold(out, color, " Recent snapshots {s}\n", .{rlabel});
try cli.setFg(out, color, cli.CLR_MUTED);
// Column order: Liquid → Illiquid → Net Worth (components sum to total).
try out.print(" {s:>10} {s:>31} {s:>31} {s:>31}\n", .{
"Date",
"Liquid (Δ)",
"Illiquid (Δ)",
"Net Worth (Δ)",
});
try out.print(" {s:->10} {s:->31} {s:->31} {s:->31}\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 cbuf_l: [64]u8 = undefined;
var cbuf_i: [64]u8 = undefined;
var cbuf_n: [64]u8 = undefined;
try out.print(" {f} ", .{row.date.padLeft(10)});
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');
}
// ── Cascading-mode rendering ─────────────────────────────────
/// Render the cascading recent-snapshots view.
///
/// Each bucket renders as a row. The date column shows
/// granularity-appropriate labels ("W of YYYY-MM-DD", "Mar 2026",
/// "Q1 2025", "2024"). There are no tier-separator rows; the
/// hierarchy is implicit in the row labels.
///
/// Imported-only buckets render `—` for illiquid and net_worth
/// since those metrics aren't recorded in `imported_values.srf`.
fn renderCascadingTable(
allocator: std.mem.Allocator,
out: *std.Io.Writer,
color: bool,
ts: timeline.TieredSeries,
focus_metric: timeline.Metric,
row_limit: usize,
) !void {
try cli.printBold(out, color, " Recent snapshots\n", .{});
if (ts.buckets.len == 0) {
try cli.setStyleIntent(out, color, .muted);
try out.writeAll(" No history data available.\n");
try cli.reset(out, color);
return;
}
// Header row.
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:>16} {s:>31} {s:>31} {s:>31}\n", .{
"Date",
"Liquid (Δ)",
"Illiquid (Δ)",
"Net Worth (Δ)",
});
try out.print(" {s:->16} {s:->31} {s:->31} {s:->31}\n", .{ "", "", "", "" });
try cli.reset(out, color);
// Pre-compute per-bucket Δ. Δ on bucket `i` is "current
// minus older neighbor" (`buckets[i].value - buckets[i+1].value`),
// matching the daily flat-table convention.
const deltas = try timeline.computeBucketDeltas(allocator, ts.buckets);
defer allocator.free(deltas);
var emitted: usize = 0;
for (ts.buckets, 0..) |b, idx| {
if (emitted >= row_limit) break;
const d = deltas[idx];
// Date label varies by tier.
var date_buf: [32]u8 = undefined;
const date_label = timeline.formatBucketLabel(&date_buf, b.tier, b.bucket_start);
// Row color: same convention as flat table — focused-metric Δ.
const focus_delta_opt: ?f64 = switch (focus_metric) {
.liquid => d.delta_liquid,
.illiquid => d.delta_illiquid,
.net_worth => d.delta_net_worth,
};
if (focus_delta_opt) |fd| {
if (fd == 0) {
try cli.setFg(out, color, cli.CLR_MUTED);
} else {
try cli.setGainLoss(out, color, fd);
}
} else {
try cli.setFg(out, color, cli.CLR_MUTED);
}
var cbuf_l: [64]u8 = undefined;
var cbuf_i: [64]u8 = undefined;
var cbuf_n: [64]u8 = undefined;
try out.print(" {s:>16} ", .{date_label});
try out.writeAll(view.fmtValueDeltaCell(&cbuf_l, b.liquid, d.delta_liquid, view.table_cell_width));
try out.writeAll(" ");
if (b.imported_only) {
// Render illiquid/net_worth as `—` to signal "no data."
try out.writeAll(fmt.centerDash(&cbuf_i, view.table_cell_width));
try out.writeAll(" ");
try out.writeAll(fmt.centerDash(&cbuf_n, view.table_cell_width));
} else {
try out.writeAll(view.fmtValueDeltaCell(&cbuf_i, b.illiquid, d.delta_illiquid, view.table_cell_width));
try out.writeAll(" ");
try out.writeAll(view.fmtValueDeltaCell(&cbuf_n, b.net_worth, d.delta_net_worth, view.table_cell_width));
}
try cli.reset(out, color);
try out.writeByte('\n');
emitted += 1;
}
try out.print("\n {d} buckets shown\n\n", .{emitted});
}
// ── Tests ────────────────────────────────────────────────────
const testing = std.testing;
test "parseArgs: positional symbol → .symbol variant" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{"AAPL"};
const parsed = try parseArgs(&ctx, &args);
switch (parsed) {
.symbol => |s| try std.testing.expectEqualStrings("AAPL", s),
.portfolio => try std.testing.expect(false),
}
}
test "parseArgs: empty args → .portfolio variant with defaults" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{};
const parsed = try parseArgs(&ctx, &args);
switch (parsed) {
.symbol => try std.testing.expect(false),
.portfolio => |opts| {
try std.testing.expect(opts.since == null);
try std.testing.expect(opts.until == null);
try std.testing.expectEqual(timeline.Metric.liquid, opts.metric);
},
}
}
test "parseArgs: --since flag → .portfolio variant" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--since", "2026-01-01" };
const parsed = try parseArgs(&ctx, &args);
switch (parsed) {
.symbol => try std.testing.expect(false),
.portfolio => |opts| try std.testing.expect(opts.since.?.eql(Date.fromYmd(2026, 1, 1))),
}
}
test "parseArgs: symbol mode rejects extra positional" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "AAPL", "MSFT" };
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "parsePortfolioOpts: defaults" {
const o = try parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &.{});
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(zfin.Date.fromYmd(2026, 5, 8), &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(zfin.Date.fromYmd(2026, 5, 8), &a1);
try testing.expectEqual(timeline.Metric.illiquid, o1.metric);
const a2 = [_][]const u8{ "--metric", "net_worth" };
const o2 = try parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &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(zfin.Date.fromYmd(2026, 5, 8), &ad)).resolution.?);
const aw = [_][]const u8{ "--resolution", "weekly" };
try testing.expectEqual(timeline.Resolution.weekly, (try parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &aw)).resolution.?);
const am = [_][]const u8{ "--resolution", "monthly" };
try testing.expectEqual(timeline.Resolution.monthly, (try parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &am)).resolution.?);
// "auto" resolves to null (defer to selectResolution at render time).
const aa = [_][]const u8{ "--resolution", "auto" };
try testing.expect((try parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &aa)).resolution == null);
}
test "parsePortfolioOpts: --limit parses integer" {
const args = [_][]const u8{ "--limit", "25" };
const o = try parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &args);
try testing.expectEqual(@as(usize, 25), o.limit.?);
}
test "parsePortfolioOpts: --rebuild-rollup boolean" {
const args = [_][]const u8{"--rebuild-rollup"};
const o = try parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &args);
try testing.expect(o.rebuild_rollup);
}
test "parsePortfolioOpts: unknown flag / value errors" {
try testing.expectError(error.UnexpectedArg, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]const u8{"--bogus"}));
try testing.expectError(error.MissingFlagValue, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]const u8{"--since"}));
try testing.expectError(error.InvalidFlagValue, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]const u8{ "--since", "not-a-date" }));
try testing.expectError(error.UnknownMetric, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]const u8{ "--metric", "bogus" }));
try testing.expectError(error.UnknownResolution, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]const u8{ "--resolution", "bogus" }));
try testing.expectError(error.InvalidFlagValue, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]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" {
const io = std.testing.io;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const tmp_path_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const tmp_path = path_buf[0..tmp_path_len];
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(std.testing.io, testing.allocator, tmp_path, &snaps, 1_700_000_000, &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.Io.Dir.cwd().readFileAlloc(io, rollup_path, testing.allocator, .limited(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" {
const io = std.testing.io;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const tmp_path_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const tmp_path = path_buf[0..tmp_path_len];
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(std.testing.io, testing.allocator, nested, &snaps, 1_700_000_000, &out);
const rollup_path = try std.fs.path.join(testing.allocator, &.{ nested, "rollup.srf" });
defer testing.allocator.free(rollup_path);
try std.Io.Dir.cwd().access(io, rollup_path, .{});
}
test "rebuildRollup: empty snapshots produces an empty rollup" {
const io = std.testing.io;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const tmp_path_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const tmp_path = path_buf[0..tmp_path_len];
var out_buf: [256]u8 = undefined;
var out: std.Io.Writer = .fixed(&out_buf);
try rebuildRollup(std.testing.io, testing.allocator, tmp_path, &.{}, 1_700_000_000, &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.Io.Dir.cwd().readFileAlloc(io, rollup_path, testing.allocator, .limited(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);
}