924 lines
36 KiB
Zig
924 lines
36 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 plot; one of
|
||
//! net_worth (default), liquid, illiquid
|
||
//! --rebuild-rollup (re)write history/rollup.srf and exit
|
||
//!
|
||
//! The CLI renderer is a thin wrapper over pure analytics. The compute
|
||
//! layer (`src/analytics/timeline.zig`) and IO layer (`src/history.zig`)
|
||
//! are fully testable on their own; this module is only responsible for
|
||
//! flag parsing, path resolution, and text-table presentation.
|
||
|
||
const std = @import("std");
|
||
const srf = @import("srf");
|
||
const zfin = @import("../root.zig");
|
||
const cli = @import("common.zig");
|
||
const atomic = @import("../atomic.zig");
|
||
const timeline = @import("../analytics/timeline.zig");
|
||
const history_io = @import("../history.zig");
|
||
const snapshot_model = @import("../models/snapshot.zig");
|
||
const fmt = cli.fmt;
|
||
const Date = @import("../models/date.zig").Date;
|
||
|
||
pub const Error = error{
|
||
UnexpectedArg,
|
||
InvalidFlagValue,
|
||
MissingFlagValue,
|
||
UnknownMetric,
|
||
};
|
||
|
||
/// Parsed portfolio-mode options. Separated from `run` so the parser
|
||
/// is unit-testable.
|
||
pub const PortfolioOpts = struct {
|
||
since: ?Date = null,
|
||
until: ?Date = null,
|
||
/// Null means "render all three (net_worth + liquid + illiquid) side
|
||
/// by side" — the default. A non-null value focuses the display on a
|
||
/// single metric (user passed `--metric <name>`).
|
||
metric: ?timeline.Metric = null,
|
||
rebuild_rollup: bool = false,
|
||
};
|
||
|
||
/// Parse the arg list for portfolio-mode flags. Pure function — no IO.
|
||
/// Returns an error for unknown flags, malformed dates, or unknown
|
||
/// metric names so the CLI surface can map them to distinct messages.
|
||
pub fn parsePortfolioOpts(args: []const []const u8) Error!PortfolioOpts {
|
||
var opts: PortfolioOpts = .{};
|
||
var i: usize = 0;
|
||
while (i < args.len) : (i += 1) {
|
||
const a = args[i];
|
||
if (std.mem.eql(u8, a, "--since")) {
|
||
i += 1;
|
||
if (i >= args.len) return error.MissingFlagValue;
|
||
opts.since = Date.parse(args[i]) catch return error.InvalidFlagValue;
|
||
} else if (std.mem.eql(u8, a, "--until")) {
|
||
i += 1;
|
||
if (i >= args.len) return error.MissingFlagValue;
|
||
opts.until = Date.parse(args[i]) catch return error.InvalidFlagValue;
|
||
} else if (std.mem.eql(u8, a, "--metric")) {
|
||
i += 1;
|
||
if (i >= args.len) return error.MissingFlagValue;
|
||
opts.metric = std.meta.stringToEnum(timeline.Metric, args[i]) orelse return error.UnknownMetric;
|
||
} else if (std.mem.eql(u8, a, "--rebuild-rollup")) {
|
||
opts.rebuild_rollup = true;
|
||
} else {
|
||
return error.UnexpectedArg;
|
||
}
|
||
}
|
||
return opts;
|
||
}
|
||
|
||
/// Entry point. Dispatches to symbol mode or portfolio mode based on
|
||
/// the first argument.
|
||
///
|
||
/// `portfolio_path` is the resolved path to portfolio.srf (used only in
|
||
/// portfolio mode to derive the history directory). Symbol mode ignores
|
||
/// it.
|
||
pub fn run(
|
||
allocator: std.mem.Allocator,
|
||
svc: *zfin.DataService,
|
||
portfolio_path: []const u8,
|
||
args: []const []const u8,
|
||
color: bool,
|
||
out: *std.Io.Writer,
|
||
) !void {
|
||
// Symbol-mode heuristic: cmd_args[0] exists and doesn't look like a flag.
|
||
if (args.len > 0 and args[0].len > 0 and args[0][0] != '-') {
|
||
try runSymbol(allocator, svc, args[0], color, out);
|
||
return;
|
||
}
|
||
|
||
const opts = parsePortfolioOpts(args) catch |err| {
|
||
switch (err) {
|
||
error.UnexpectedArg => try cli.stderrPrint("Error: unknown flag in 'history'. See --help.\n"),
|
||
error.MissingFlagValue => try cli.stderrPrint("Error: flag requires a value.\n"),
|
||
error.InvalidFlagValue => try cli.stderrPrint("Error: invalid date (expected YYYY-MM-DD).\n"),
|
||
error.UnknownMetric => try cli.stderrPrint("Error: unknown --metric. Valid: net_worth, liquid, illiquid.\n"),
|
||
}
|
||
return err;
|
||
};
|
||
|
||
try runPortfolio(allocator, portfolio_path, opts, color, out);
|
||
}
|
||
|
||
// ── Symbol mode (legacy) ─────────────────────────────────────
|
||
|
||
fn runSymbol(
|
||
allocator: std.mem.Allocator,
|
||
svc: *zfin.DataService,
|
||
symbol: []const u8,
|
||
color: bool,
|
||
out: *std.Io.Writer,
|
||
) !void {
|
||
const result = svc.getCandles(symbol) catch |err| switch (err) {
|
||
zfin.DataError.NoApiKey => {
|
||
try cli.stderrPrint("Error: No API key configured for candle data.\n");
|
||
return;
|
||
},
|
||
else => {
|
||
try cli.stderrPrint("Error fetching data.\n");
|
||
return;
|
||
},
|
||
};
|
||
defer allocator.free(result.data);
|
||
|
||
if (result.source == .cached) try cli.stderrPrint("(using cached data)\n");
|
||
|
||
const all = result.data;
|
||
if (all.len == 0) return try cli.stderrPrint("No data available.\n");
|
||
|
||
const today = fmt.todayDate();
|
||
const one_month_ago = today.addDays(-30);
|
||
const c = fmt.filterCandlesFrom(all, one_month_ago);
|
||
if (c.len == 0) return try cli.stderrPrint("No data available.\n");
|
||
|
||
try displaySymbol(c, symbol, color, out);
|
||
}
|
||
|
||
pub fn displaySymbol(candles: []const zfin.Candle, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
|
||
try cli.setBold(out, color);
|
||
try out.print("\nPrice History for {s} (last 30 days)\n", .{symbol});
|
||
try cli.reset(out, color);
|
||
try out.print("========================================\n", .{});
|
||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||
try out.print("{s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}\n", .{
|
||
"Date", "Open", "High", "Low", "Close", "Volume",
|
||
});
|
||
try out.print("{s:->12} {s:->10} {s:->10} {s:->10} {s:->10} {s:->12}\n", .{
|
||
"", "", "", "", "", "",
|
||
});
|
||
try cli.reset(out, color);
|
||
|
||
for (candles) |candle| {
|
||
var db: [10]u8 = undefined;
|
||
var vb: [32]u8 = undefined;
|
||
try cli.setGainLoss(out, color, if (candle.close >= candle.open) 1.0 else -1.0);
|
||
try out.print("{s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}\n", .{
|
||
candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume),
|
||
});
|
||
try cli.reset(out, color);
|
||
}
|
||
try out.print("\n{d} trading days\n\n", .{candles.len});
|
||
}
|
||
|
||
// ── Portfolio mode ───────────────────────────────────────────
|
||
|
||
fn runPortfolio(
|
||
allocator: std.mem.Allocator,
|
||
portfolio_path: []const u8,
|
||
opts: PortfolioOpts,
|
||
color: bool,
|
||
out: *std.Io.Writer,
|
||
) !void {
|
||
// Derive history/ from the portfolio path's directory.
|
||
const portfolio_dir = std.fs.path.dirname(portfolio_path) orelse ".";
|
||
const history_dir = try std.fs.path.join(allocator, &.{ portfolio_dir, "history" });
|
||
defer allocator.free(history_dir);
|
||
|
||
var loaded = try history_io.loadHistoryDir(allocator, history_dir);
|
||
defer loaded.deinit();
|
||
|
||
if (opts.rebuild_rollup) {
|
||
try rebuildRollup(allocator, history_dir, loaded.snapshots, out);
|
||
return;
|
||
}
|
||
|
||
if (loaded.snapshots.len == 0) {
|
||
try out.print("No portfolio snapshots found in {s}.\n", .{history_dir});
|
||
try out.print("Run `zfin snapshot` to capture the first one.\n", .{});
|
||
return;
|
||
}
|
||
|
||
const series = try timeline.buildSeries(allocator, loaded.snapshots);
|
||
defer series.deinit();
|
||
|
||
const filtered = try timeline.filterByDate(allocator, series.points, opts.since, opts.until);
|
||
defer allocator.free(filtered);
|
||
|
||
if (filtered.len == 0) {
|
||
try out.print("No snapshots match the requested date range.\n", .{});
|
||
return;
|
||
}
|
||
|
||
// Default view: all three columns (net_worth + liquid + illiquid)
|
||
// side by side, so the user always sees how much of a net-worth
|
||
// change came from liquid markets vs. illiquid revaluations.
|
||
// `--metric X` explicitly asks for a single-metric focus view.
|
||
if (opts.metric) |m| {
|
||
const points = try timeline.extractMetric(allocator, filtered, m);
|
||
defer allocator.free(points);
|
||
try renderPortfolioTimeline(out, color, m, points);
|
||
} else {
|
||
try renderPortfolioTimelineAll(out, color, filtered);
|
||
}
|
||
}
|
||
|
||
/// Regenerate `history/rollup.srf` from `snapshots`. Uses
|
||
/// `timeline.buildRollupRecords` + `srf.fmtFrom` + atomic write.
|
||
///
|
||
/// Exposed as `pub` so tests can exercise the full IO path (including
|
||
/// directory creation and atomic rename) using `testing.tmpDir`.
|
||
pub fn rebuildRollup(
|
||
allocator: std.mem.Allocator,
|
||
history_dir: []const u8,
|
||
snapshots: []const snapshot_model.Snapshot,
|
||
out: *std.Io.Writer,
|
||
) !void {
|
||
const series = try timeline.buildSeries(allocator, snapshots);
|
||
defer series.deinit();
|
||
|
||
const rows = try timeline.buildRollupRecords(allocator, series.points);
|
||
defer allocator.free(rows);
|
||
|
||
var aw: std.Io.Writer.Allocating = .init(allocator);
|
||
defer aw.deinit();
|
||
try aw.writer.print("{f}", .{srf.fmtFrom(timeline.RollupRow, allocator, rows, .{
|
||
.emit_directives = true,
|
||
.created = std.time.timestamp(),
|
||
})});
|
||
const rendered = aw.written();
|
||
|
||
const rollup_path = try std.fs.path.join(allocator, &.{ history_dir, "rollup.srf" });
|
||
defer allocator.free(rollup_path);
|
||
|
||
// Ensure history/ exists — otherwise atomic write fails on fresh repos.
|
||
std.fs.cwd().makePath(history_dir) catch |err| switch (err) {
|
||
error.PathAlreadyExists => {},
|
||
else => return err,
|
||
};
|
||
|
||
try atomic.writeFileAtomic(allocator, rollup_path, rendered);
|
||
try out.print("rollup rebuilt: {s} ({d} rows)\n", .{ rollup_path, rows.len });
|
||
}
|
||
|
||
// ── Rendering ────────────────────────────────────────────────
|
||
|
||
/// Render a single-metric timeline as a three-column text table
|
||
/// (Date | Value | Delta-from-first). Pure function — tested with
|
||
/// fixed buffers.
|
||
///
|
||
/// Includes a footer with summary stats (first, last, min, max, delta)
|
||
/// when there are 2+ points. Empty input writes a single-line hint.
|
||
pub fn renderPortfolioTimeline(
|
||
out: *std.Io.Writer,
|
||
color: bool,
|
||
metric: timeline.Metric,
|
||
points: []const timeline.MetricPoint,
|
||
) !void {
|
||
try cli.setBold(out, color);
|
||
try out.print("\nPortfolio Timeline — {s}\n", .{metric.label()});
|
||
try cli.reset(out, color);
|
||
try out.print("========================================\n", .{});
|
||
|
||
if (points.len == 0) {
|
||
try out.print("(no data)\n\n", .{});
|
||
return;
|
||
}
|
||
|
||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||
try out.print("{s:>12} {s:>16} {s:>16}\n", .{ "Date", "Value", "Δ from start" });
|
||
try out.print("{s:->12} {s:->16} {s:->16}\n", .{ "", "", "" });
|
||
try cli.reset(out, color);
|
||
|
||
const first_value = points[0].value;
|
||
for (points) |p| {
|
||
var db: [10]u8 = undefined;
|
||
var vb: [24]u8 = undefined;
|
||
var dvb: [24]u8 = undefined;
|
||
const delta = p.value - first_value;
|
||
|
||
try out.print("{s:>12} ", .{p.date.format(&db)});
|
||
try out.print("{s:>16} ", .{fmt.fmtMoneyAbs(&vb, p.value)});
|
||
// Color delta by sign (green for gain, red for loss), muted for zero.
|
||
if (delta == 0) {
|
||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||
} else {
|
||
try cli.setGainLoss(out, color, delta);
|
||
}
|
||
// fmtMoneyAbs drops the sign; synthesize the correct one.
|
||
const prefix: []const u8 = if (delta > 0) "+" else if (delta < 0) "-" else "";
|
||
try out.print("{s}{s}\n", .{ prefix, fmt.fmtMoneyAbs(&dvb, delta) });
|
||
try cli.reset(out, color);
|
||
}
|
||
|
||
// Summary footer
|
||
if (timeline.computeStats(points)) |s| {
|
||
try out.print("\n", .{});
|
||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||
var b1: [24]u8 = undefined;
|
||
var b2: [24]u8 = undefined;
|
||
var b3: [24]u8 = undefined;
|
||
var b4: [24]u8 = undefined;
|
||
try out.print(" first: {s} last: {s} min: {s} max: {s}\n", .{
|
||
fmt.fmtMoneyAbs(&b1, s.first),
|
||
fmt.fmtMoneyAbs(&b2, s.last),
|
||
fmt.fmtMoneyAbs(&b3, s.min),
|
||
fmt.fmtMoneyAbs(&b4, s.max),
|
||
});
|
||
try cli.reset(out, color);
|
||
|
||
var db: [24]u8 = undefined;
|
||
const prefix: []const u8 = if (s.delta_abs > 0) "+" else if (s.delta_abs < 0) "-" else "";
|
||
try cli.setGainLoss(out, color, s.delta_abs);
|
||
if (s.delta_pct) |pct| {
|
||
try out.print(" Δ: {s}{s} ({d:.2}%)\n", .{
|
||
prefix,
|
||
fmt.fmtMoneyAbs(&db, s.delta_abs),
|
||
pct * 100.0,
|
||
});
|
||
} else {
|
||
try out.print(" Δ: {s}{s} (n/a%)\n", .{
|
||
prefix,
|
||
fmt.fmtMoneyAbs(&db, s.delta_abs),
|
||
});
|
||
}
|
||
try cli.reset(out, color);
|
||
}
|
||
try out.print("\n{d} snapshots\n\n", .{points.len});
|
||
}
|
||
|
||
/// Format a signed money delta as `"+$X.XX"`, `"-$X.XX"`, or `"$0.00"`
|
||
/// into `buf`. Returns the slice of `buf` containing the result.
|
||
///
|
||
/// Exists because `fmt.fmtMoneyAbs` drops the sign and rebuilding it
|
||
/// correctly (no `+` for exactly-zero) is a three-line dance every
|
||
/// call site gets wrong at least once.
|
||
pub fn fmtSignedMoney(buf: []u8, value: f64) ![]const u8 {
|
||
const prefix: []const u8 = if (value > 0) "+" else if (value < 0) "-" else "";
|
||
var tmp: [24]u8 = undefined;
|
||
const abs_str = fmt.fmtMoneyAbs(&tmp, value);
|
||
return std.fmt.bufPrint(buf, "{s}{s}", .{ prefix, abs_str });
|
||
}
|
||
|
||
/// Render a multi-metric timeline: Date | Illiquid | Liquid | Net Worth,
|
||
/// with each column showing both current value and Δ from the first row.
|
||
/// Columns read left-to-right as "components summing to the total" —
|
||
/// illiquid + liquid = net worth. Answers "how did my net worth change
|
||
/// and was it liquid vs. illiquid?" in one glance.
|
||
///
|
||
/// Takes a TimelinePoint slice directly (rather than already-extracted
|
||
/// MetricPoints) because every column reads from the same source rows.
|
||
pub fn renderPortfolioTimelineAll(
|
||
out: *std.Io.Writer,
|
||
color: bool,
|
||
points: []const timeline.TimelinePoint,
|
||
) !void {
|
||
try cli.setBold(out, color);
|
||
try out.print("\nPortfolio Timeline\n", .{});
|
||
try cli.reset(out, color);
|
||
try out.print("========================================\n", .{});
|
||
|
||
if (points.len == 0) {
|
||
try out.print("(no data)\n\n", .{});
|
||
return;
|
||
}
|
||
|
||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||
try out.print("{s:>12} {s:>28} {s:>28} {s:>28}\n", .{
|
||
"Date", "Illiquid (Δ)", "Liquid (Δ)", "Net Worth (Δ)",
|
||
});
|
||
try out.print("{s:->12} {s:->28} {s:->28} {s:->28}\n", .{ "", "", "", "" });
|
||
try cli.reset(out, color);
|
||
|
||
const first_nw = points[0].net_worth;
|
||
const first_liq = points[0].liquid;
|
||
const first_ill = points[0].illiquid;
|
||
|
||
for (points) |p| {
|
||
var db: [10]u8 = undefined;
|
||
try out.print("{s:>12} ", .{p.as_of_date.format(&db)});
|
||
try writeValueDeltaCell(out, color, p.illiquid, p.illiquid - first_ill);
|
||
try out.writeAll(" ");
|
||
try writeValueDeltaCell(out, color, p.liquid, p.liquid - first_liq);
|
||
try out.writeAll(" ");
|
||
try writeValueDeltaCell(out, color, p.net_worth, p.net_worth - first_nw);
|
||
try out.writeByte('\n');
|
||
}
|
||
|
||
// Three-line summary footer: one line per metric, showing first →
|
||
// last and absolute/percent change. More readable than stuffing
|
||
// everything into one wide line. Ordered to match the column layout
|
||
// above (components first, total last).
|
||
try out.print("\n", .{});
|
||
try writeSummaryLine(out, color, " Illiquid ", mapMetric(points, .illiquid));
|
||
try writeSummaryLine(out, color, " Liquid ", mapMetric(points, .liquid));
|
||
try writeSummaryLine(out, color, " Net Worth", mapMetric(points, .net_worth));
|
||
|
||
try out.print("\n{d} snapshots\n\n", .{points.len});
|
||
}
|
||
|
||
/// Write one "$value (+$delta)" cell, right-padded to 28 chars, with the
|
||
/// Δ colored by sign.
|
||
fn writeValueDeltaCell(out: *std.Io.Writer, color: bool, value: f64, delta: f64) !void {
|
||
var vb: [24]u8 = undefined;
|
||
var dvb: [32]u8 = undefined;
|
||
const val_str = fmt.fmtMoneyAbs(&vb, value);
|
||
const delta_str = try fmtSignedMoney(&dvb, delta);
|
||
|
||
// Build the cell into a stack buffer so the overall width calculation
|
||
// is trivial regardless of color escapes mid-print.
|
||
var cell_buf: [64]u8 = undefined;
|
||
const cell = try std.fmt.bufPrint(&cell_buf, "{s} ({s})", .{ val_str, delta_str });
|
||
|
||
// Right-align to 28 chars. Visible width doesn't include ANSI, but
|
||
// since we apply color to the whole cell we can pad plain, then emit
|
||
// under color.
|
||
const pad = if (cell.len < 28) 28 - cell.len else 0;
|
||
for (0..pad) |_| try out.writeByte(' ');
|
||
|
||
// Color the cell by delta sign — whole cell, not just Δ, so the
|
||
// value+delta reads as one visual unit.
|
||
if (delta == 0) {
|
||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||
} else {
|
||
try cli.setGainLoss(out, color, delta);
|
||
}
|
||
try out.writeAll(cell);
|
||
try cli.reset(out, color);
|
||
}
|
||
|
||
/// Compute stats for one of the three top-level metrics from a slice
|
||
/// of TimelinePoint. Pure helper — no allocator needed.
|
||
fn mapMetric(points: []const timeline.TimelinePoint, metric: timeline.Metric) ?timeline.MetricStats {
|
||
if (points.len == 0) return null;
|
||
|
||
const first = extractOne(points[0], metric);
|
||
const last = extractOne(points[points.len - 1], metric);
|
||
var min_v = first;
|
||
var max_v = first;
|
||
for (points) |p| {
|
||
const v = extractOne(p, metric);
|
||
if (v < min_v) min_v = v;
|
||
if (v > max_v) max_v = v;
|
||
}
|
||
|
||
const delta = last - first;
|
||
return .{
|
||
.first = first,
|
||
.last = last,
|
||
.min = min_v,
|
||
.max = max_v,
|
||
.delta_abs = delta,
|
||
.delta_pct = if (first == 0) null else delta / first,
|
||
};
|
||
}
|
||
|
||
fn extractOne(p: timeline.TimelinePoint, metric: timeline.Metric) f64 {
|
||
return switch (metric) {
|
||
.net_worth => p.net_worth,
|
||
.liquid => p.liquid,
|
||
.illiquid => p.illiquid,
|
||
};
|
||
}
|
||
|
||
fn writeSummaryLine(out: *std.Io.Writer, color: bool, label: []const u8, stats_opt: ?timeline.MetricStats) !void {
|
||
const s = stats_opt orelse return;
|
||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||
var b1: [24]u8 = undefined;
|
||
var b2: [24]u8 = undefined;
|
||
try out.print("{s} first: {s} last: {s} ", .{
|
||
label,
|
||
fmt.fmtMoneyAbs(&b1, s.first),
|
||
fmt.fmtMoneyAbs(&b2, s.last),
|
||
});
|
||
try cli.reset(out, color);
|
||
|
||
var db: [32]u8 = undefined;
|
||
const delta_str = try fmtSignedMoney(&db, s.delta_abs);
|
||
try cli.setGainLoss(out, color, s.delta_abs);
|
||
if (s.delta_pct) |pct| {
|
||
try out.print("Δ: {s} ({d:.2}%)\n", .{ delta_str, pct * 100.0 });
|
||
} else {
|
||
try out.print("Δ: {s} (n/a%)\n", .{delta_str});
|
||
}
|
||
try cli.reset(out, color);
|
||
}
|
||
|
||
// ── Tests ────────────────────────────────────────────────────
|
||
|
||
const testing = std.testing;
|
||
|
||
test "parsePortfolioOpts: defaults" {
|
||
const o = try parsePortfolioOpts(&.{});
|
||
try testing.expect(o.since == null);
|
||
try testing.expect(o.until == null);
|
||
// Null metric = "show all three columns" (default view).
|
||
try testing.expect(o.metric == null);
|
||
try testing.expect(!o.rebuild_rollup);
|
||
}
|
||
|
||
test "parsePortfolioOpts: --since and --until parse ISO dates" {
|
||
const args = [_][]const u8{ "--since", "2026-01-01", "--until", "2026-04-30" };
|
||
const o = try parsePortfolioOpts(&args);
|
||
try testing.expect(o.since.?.eql(Date.fromYmd(2026, 1, 1)));
|
||
try testing.expect(o.until.?.eql(Date.fromYmd(2026, 4, 30)));
|
||
}
|
||
|
||
test "parsePortfolioOpts: --metric picks the right enum (non-null when explicit)" {
|
||
const a1 = [_][]const u8{ "--metric", "liquid" };
|
||
const o1 = try parsePortfolioOpts(&a1);
|
||
try testing.expectEqual(timeline.Metric.liquid, o1.metric.?);
|
||
|
||
const a2 = [_][]const u8{ "--metric", "illiquid" };
|
||
const o2 = try parsePortfolioOpts(&a2);
|
||
try testing.expectEqual(timeline.Metric.illiquid, o2.metric.?);
|
||
|
||
const a3 = [_][]const u8{ "--metric", "net_worth" };
|
||
const o3 = try parsePortfolioOpts(&a3);
|
||
try testing.expectEqual(timeline.Metric.net_worth, o3.metric.?);
|
||
}
|
||
|
||
test "parsePortfolioOpts: --rebuild-rollup boolean" {
|
||
const args = [_][]const u8{"--rebuild-rollup"};
|
||
const o = try parsePortfolioOpts(&args);
|
||
try testing.expect(o.rebuild_rollup);
|
||
}
|
||
|
||
test "parsePortfolioOpts: unknown flag -> UnexpectedArg" {
|
||
const args = [_][]const u8{"--bogus"};
|
||
try testing.expectError(error.UnexpectedArg, parsePortfolioOpts(&args));
|
||
}
|
||
|
||
test "parsePortfolioOpts: missing value after --since" {
|
||
const args = [_][]const u8{"--since"};
|
||
try testing.expectError(error.MissingFlagValue, parsePortfolioOpts(&args));
|
||
}
|
||
|
||
test "parsePortfolioOpts: malformed date in --since" {
|
||
const args = [_][]const u8{ "--since", "not-a-date" };
|
||
try testing.expectError(error.InvalidFlagValue, parsePortfolioOpts(&args));
|
||
}
|
||
|
||
test "parsePortfolioOpts: unknown --metric value" {
|
||
const args = [_][]const u8{ "--metric", "bogus" };
|
||
try testing.expectError(error.UnknownMetric, parsePortfolioOpts(&args));
|
||
}
|
||
|
||
test "renderPortfolioTimeline: empty input says '(no data)'" {
|
||
var buf: [1024]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
try renderPortfolioTimeline(&w, false, .net_worth, &.{});
|
||
const out = w.buffered();
|
||
try testing.expect(std.mem.indexOf(u8, out, "Portfolio Timeline") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "Net Worth") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "(no data)") != null);
|
||
}
|
||
|
||
test "renderPortfolioTimeline: single point renders without crashing, no delta pct" {
|
||
var buf: [2048]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
const pts = [_]timeline.MetricPoint{
|
||
.{ .date = Date.fromYmd(2026, 4, 17), .value = 1000 },
|
||
};
|
||
try renderPortfolioTimeline(&w, false, .net_worth, &pts);
|
||
const out = w.buffered();
|
||
try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "$1,000.00") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "1 snapshots") != null);
|
||
}
|
||
|
||
test "renderPortfolioTimeline: multi-point shows dates, deltas, and footer" {
|
||
var buf: [4096]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
const pts = [_]timeline.MetricPoint{
|
||
.{ .date = Date.fromYmd(2026, 4, 17), .value = 1000 },
|
||
.{ .date = Date.fromYmd(2026, 4, 18), .value = 1050 },
|
||
.{ .date = Date.fromYmd(2026, 4, 19), .value = 980 },
|
||
};
|
||
try renderPortfolioTimeline(&w, false, .net_worth, &pts);
|
||
const out = w.buffered();
|
||
|
||
// Every date shows up.
|
||
try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "2026-04-18") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "2026-04-19") != null);
|
||
|
||
// Delta column: day 1 should show +$50, day 2 should show -$20.
|
||
try testing.expect(std.mem.indexOf(u8, out, "+$50.00") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "-$20.00") != null);
|
||
|
||
// Summary footer: first/last/min/max and Δ with percent.
|
||
try testing.expect(std.mem.indexOf(u8, out, "first:") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "last:") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "min:") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "max:") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "3 snapshots") != null);
|
||
// No ANSI when color=false.
|
||
try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
||
}
|
||
|
||
test "renderPortfolioTimeline: color mode emits ANSI" {
|
||
var buf: [4096]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
const pts = [_]timeline.MetricPoint{
|
||
.{ .date = Date.fromYmd(2026, 4, 17), .value = 1000 },
|
||
.{ .date = Date.fromYmd(2026, 4, 18), .value = 1100 },
|
||
};
|
||
try renderPortfolioTimeline(&w, true, .net_worth, &pts);
|
||
const out = w.buffered();
|
||
try testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null);
|
||
}
|
||
|
||
test "renderPortfolioTimeline: metric label reflects chosen metric" {
|
||
var buf: [1024]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
try renderPortfolioTimeline(&w, false, .liquid, &.{});
|
||
try testing.expect(std.mem.indexOf(u8, w.buffered(), "Liquid") != null);
|
||
}
|
||
|
||
test "renderPortfolioTimeline: zero delta renders as $0.00 (muted-color branch)" {
|
||
// The rendering branch for delta == 0 is distinct from +/− branches.
|
||
var buf: [2048]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
const pts = [_]timeline.MetricPoint{
|
||
.{ .date = Date.fromYmd(2026, 4, 17), .value = 500 },
|
||
.{ .date = Date.fromYmd(2026, 4, 18), .value = 500 },
|
||
};
|
||
try renderPortfolioTimeline(&w, false, .net_worth, &pts);
|
||
const out = w.buffered();
|
||
// Day 2 delta is $0.00 — no leading + or −.
|
||
try testing.expect(std.mem.indexOf(u8, out, "$0.00") != null);
|
||
}
|
||
|
||
test "renderPortfolioTimeline: first-zero delta renders n/a% footer" {
|
||
var buf: [2048]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
const pts = [_]timeline.MetricPoint{
|
||
.{ .date = Date.fromYmd(2026, 4, 17), .value = 0 },
|
||
.{ .date = Date.fromYmd(2026, 4, 18), .value = 1000 },
|
||
};
|
||
try renderPortfolioTimeline(&w, false, .net_worth, &pts);
|
||
const out = w.buffered();
|
||
try testing.expect(std.mem.indexOf(u8, out, "n/a%") != null);
|
||
}
|
||
|
||
// Legacy symbol-mode tests — retained, renamed to match new function.
|
||
test "displaySymbol shows header and candle data" {
|
||
var buf: [4096]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
const candles = [_]zfin.Candle{
|
||
.{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 103.0, .adj_close = 103.0, .volume = 1_500_000 },
|
||
.{ .date = .{ .days = 20001 }, .open = 103.0, .high = 107.0, .low = 102.0, .close = 101.0, .adj_close = 101.0, .volume = 2_000_000 },
|
||
};
|
||
try displaySymbol(&candles, "AAPL", false, &w);
|
||
const out = w.buffered();
|
||
try testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "Date") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "Open") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "2 trading days") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
||
}
|
||
|
||
test "displaySymbol empty candles" {
|
||
var buf: [4096]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
const candles = [_]zfin.Candle{};
|
||
try displaySymbol(&candles, "XYZ", false, &w);
|
||
const out = w.buffered();
|
||
try testing.expect(std.mem.indexOf(u8, out, "XYZ") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "0 trading days") != null);
|
||
}
|
||
|
||
// ── fmtSignedMoney ──────────────────────────────────────────
|
||
|
||
test "fmtSignedMoney: positive gets + prefix" {
|
||
var buf: [24]u8 = undefined;
|
||
const s = try fmtSignedMoney(&buf, 1234.56);
|
||
try testing.expectEqualStrings("+$1,234.56", s);
|
||
}
|
||
|
||
test "fmtSignedMoney: negative gets - prefix" {
|
||
var buf: [24]u8 = undefined;
|
||
const s = try fmtSignedMoney(&buf, -1234.56);
|
||
try testing.expectEqualStrings("-$1,234.56", s);
|
||
}
|
||
|
||
test "fmtSignedMoney: zero has no prefix" {
|
||
var buf: [24]u8 = undefined;
|
||
const s = try fmtSignedMoney(&buf, 0);
|
||
try testing.expectEqualStrings("$0.00", s);
|
||
}
|
||
|
||
// ── renderPortfolioTimelineAll ───────────────────────────────
|
||
|
||
fn makeTimelinePoint(y: i16, m: u8, d: u8, nw: f64, liq: f64, ill: f64) timeline.TimelinePoint {
|
||
return .{
|
||
.as_of_date = Date.fromYmd(y, m, d),
|
||
.net_worth = nw,
|
||
.liquid = liq,
|
||
.illiquid = ill,
|
||
.accounts = &.{},
|
||
.tax_types = &.{},
|
||
};
|
||
}
|
||
|
||
test "renderPortfolioTimelineAll: empty -> (no data)" {
|
||
var buf: [1024]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
try renderPortfolioTimelineAll(&w, false, &.{});
|
||
const out = w.buffered();
|
||
try testing.expect(std.mem.indexOf(u8, out, "Portfolio Timeline") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "(no data)") != null);
|
||
}
|
||
|
||
test "renderPortfolioTimelineAll: shows all three columns and per-metric summary lines" {
|
||
var buf: [8192]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
const pts = [_]timeline.TimelinePoint{
|
||
makeTimelinePoint(2026, 4, 17, 10_000, 8_000, 2_000),
|
||
makeTimelinePoint(2026, 4, 18, 10_500, 8_400, 2_100),
|
||
makeTimelinePoint(2026, 4, 19, 9_900, 7_900, 2_000),
|
||
};
|
||
try renderPortfolioTimelineAll(&w, false, &pts);
|
||
const out = w.buffered();
|
||
|
||
// Header columns
|
||
try testing.expect(std.mem.indexOf(u8, out, "Net Worth (Δ)") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "Liquid (Δ)") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "Illiquid (Δ)") != null);
|
||
|
||
// All three dates shown
|
||
try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "2026-04-18") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "2026-04-19") != null);
|
||
|
||
// Day-18 liquid is +$400, day-19 net worth is −$100.
|
||
try testing.expect(std.mem.indexOf(u8, out, "+$400.00") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "-$100.00") != null);
|
||
|
||
// Per-metric summary footer lines
|
||
try testing.expect(std.mem.indexOf(u8, out, "Net Worth") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "Liquid") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "Illiquid") != null);
|
||
|
||
try testing.expect(std.mem.indexOf(u8, out, "3 snapshots") != null);
|
||
|
||
// No ANSI when color=false
|
||
try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
||
}
|
||
|
||
test "renderPortfolioTimelineAll: color mode emits ANSI" {
|
||
var buf: [8192]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
const pts = [_]timeline.TimelinePoint{
|
||
makeTimelinePoint(2026, 4, 17, 100, 80, 20),
|
||
makeTimelinePoint(2026, 4, 18, 110, 90, 20),
|
||
};
|
||
try renderPortfolioTimelineAll(&w, true, &pts);
|
||
try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") != null);
|
||
}
|
||
|
||
test "renderPortfolioTimelineAll: single point renders without crashing" {
|
||
var buf: [2048]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
const pts = [_]timeline.TimelinePoint{
|
||
makeTimelinePoint(2026, 4, 17, 100, 80, 20),
|
||
};
|
||
try renderPortfolioTimelineAll(&w, false, &pts);
|
||
const out = w.buffered();
|
||
try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null);
|
||
try testing.expect(std.mem.indexOf(u8, out, "1 snapshots") != null);
|
||
}
|
||
|
||
test "renderPortfolioTimelineAll: first-zero metric renders n/a% in summary" {
|
||
var buf: [4096]u8 = undefined;
|
||
var w: std.Io.Writer = .fixed(&buf);
|
||
const pts = [_]timeline.TimelinePoint{
|
||
makeTimelinePoint(2026, 4, 17, 100, 80, 0), // illiquid starts at 0
|
||
makeTimelinePoint(2026, 4, 18, 1000, 80, 920), // illiquid grows
|
||
};
|
||
try renderPortfolioTimelineAll(&w, false, &pts);
|
||
const out = w.buffered();
|
||
// Illiquid line should show n/a% since first=0.
|
||
try testing.expect(std.mem.indexOf(u8, out, "n/a%") != null);
|
||
}
|
||
|
||
// ── rebuildRollup ────────────────────────────────────────────
|
||
|
||
/// Minimal Snapshot fixture for rebuildRollup tests. Mirrors the helper
|
||
/// in `analytics/timeline.zig` but kept local so this test file stays
|
||
/// self-contained.
|
||
fn makeFixtureSnapshot(
|
||
totals_buf: *[3]snapshot_model.TotalRow,
|
||
y: i16,
|
||
m: u8,
|
||
d: u8,
|
||
nw: f64,
|
||
liq: f64,
|
||
ill: f64,
|
||
) snapshot_model.Snapshot {
|
||
totals_buf[0] = .{ .kind = "total", .scope = "net_worth", .value = nw };
|
||
totals_buf[1] = .{ .kind = "total", .scope = "liquid", .value = liq };
|
||
totals_buf[2] = .{ .kind = "total", .scope = "illiquid", .value = ill };
|
||
return .{
|
||
.meta = .{
|
||
.kind = "meta",
|
||
.snapshot_version = 1,
|
||
.as_of_date = Date.fromYmd(y, m, d),
|
||
.captured_at = 0,
|
||
.zfin_version = "test",
|
||
.stale_count = 0,
|
||
},
|
||
.totals = totals_buf,
|
||
.tax_types = &.{},
|
||
.accounts = &.{},
|
||
.lots = &.{},
|
||
};
|
||
}
|
||
|
||
test "rebuildRollup: writes rollup.srf with one row per snapshot" {
|
||
var tmp = testing.tmpDir(.{});
|
||
defer tmp.cleanup();
|
||
|
||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||
const tmp_path = try tmp.dir.realpath(".", &path_buf);
|
||
|
||
var b1: [3]snapshot_model.TotalRow = undefined;
|
||
var b2: [3]snapshot_model.TotalRow = undefined;
|
||
const snaps = [_]snapshot_model.Snapshot{
|
||
makeFixtureSnapshot(&b1, 2026, 4, 17, 1000, 800, 200),
|
||
makeFixtureSnapshot(&b2, 2026, 4, 18, 1100, 850, 250),
|
||
};
|
||
|
||
var out_buf: [512]u8 = undefined;
|
||
var out: std.Io.Writer = .fixed(&out_buf);
|
||
|
||
try rebuildRollup(testing.allocator, tmp_path, &snaps, &out);
|
||
|
||
// Confirm the status line mentions the right row count.
|
||
const out_str = out.buffered();
|
||
try testing.expect(std.mem.indexOf(u8, out_str, "2 rows") != null);
|
||
|
||
// Read the emitted rollup.srf and assert on its shape.
|
||
const rollup_path = try std.fs.path.join(testing.allocator, &.{ tmp_path, "rollup.srf" });
|
||
defer testing.allocator.free(rollup_path);
|
||
const bytes = try std.fs.cwd().readFileAlloc(testing.allocator, rollup_path, 16 * 1024);
|
||
defer testing.allocator.free(bytes);
|
||
|
||
try testing.expect(std.mem.startsWith(u8, bytes, "#!srfv1"));
|
||
try testing.expect(std.mem.indexOf(u8, bytes, "kind::rollup,as_of_date::2026-04-17") != null);
|
||
try testing.expect(std.mem.indexOf(u8, bytes, "kind::rollup,as_of_date::2026-04-18") != null);
|
||
try testing.expect(std.mem.indexOf(u8, bytes, "net_worth:num:1000") != null);
|
||
try testing.expect(std.mem.indexOf(u8, bytes, "net_worth:num:1100") != null);
|
||
}
|
||
|
||
test "rebuildRollup: creates history dir when it doesn't exist" {
|
||
var tmp = testing.tmpDir(.{});
|
||
defer tmp.cleanup();
|
||
|
||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||
const tmp_path = try tmp.dir.realpath(".", &path_buf);
|
||
|
||
// Target a subdirectory that doesn't exist yet.
|
||
const nested = try std.fs.path.join(testing.allocator, &.{ tmp_path, "nested", "history" });
|
||
defer testing.allocator.free(nested);
|
||
|
||
var b1: [3]snapshot_model.TotalRow = undefined;
|
||
const snaps = [_]snapshot_model.Snapshot{
|
||
makeFixtureSnapshot(&b1, 2026, 4, 17, 100, 80, 20),
|
||
};
|
||
|
||
var out_buf: [256]u8 = undefined;
|
||
var out: std.Io.Writer = .fixed(&out_buf);
|
||
|
||
// Must succeed — makePath should create the intermediate dirs.
|
||
try rebuildRollup(testing.allocator, nested, &snaps, &out);
|
||
|
||
// File must now exist under the (previously-missing) nested path.
|
||
const rollup_path = try std.fs.path.join(testing.allocator, &.{ nested, "rollup.srf" });
|
||
defer testing.allocator.free(rollup_path);
|
||
try std.fs.cwd().access(rollup_path, .{});
|
||
}
|
||
|
||
test "rebuildRollup: empty snapshots produces an empty rollup" {
|
||
var tmp = testing.tmpDir(.{});
|
||
defer tmp.cleanup();
|
||
|
||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||
const tmp_path = try tmp.dir.realpath(".", &path_buf);
|
||
|
||
var out_buf: [256]u8 = undefined;
|
||
var out: std.Io.Writer = .fixed(&out_buf);
|
||
|
||
try rebuildRollup(testing.allocator, tmp_path, &.{}, &out);
|
||
try testing.expect(std.mem.indexOf(u8, out.buffered(), "0 rows") != null);
|
||
|
||
const rollup_path = try std.fs.path.join(testing.allocator, &.{ tmp_path, "rollup.srf" });
|
||
defer testing.allocator.free(rollup_path);
|
||
const bytes = try std.fs.cwd().readFileAlloc(testing.allocator, rollup_path, 4 * 1024);
|
||
defer testing.allocator.free(bytes);
|
||
|
||
// Header still emitted; no records.
|
||
try testing.expect(std.mem.startsWith(u8, bytes, "#!srfv1"));
|
||
try testing.expect(std.mem.indexOf(u8, bytes, "kind::rollup") == null);
|
||
}
|