817 lines
33 KiB
Zig
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);
|
|
}
|