zfin/src/commands/review.zig

1001 lines
37 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `zfin review` — per-holding performance and risk dashboard.
//!
//! The CLI surface for the `review` view. Loads the portfolio + sibling
//! files (metadata.srf, accounts.srf), fetches per-symbol prices and
//! cached candles/dividends, builds the renderer-agnostic
//! `views/review.zig` view, and renders it as a wide ANSI table.
//!
//! The TUI has a peer surface (`tui/review_tab.zig`) consuming the same
//! view module — both renderers stay in sync by definition.
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const review_view = @import("../views/review.zig");
const observations_view = @import("../views/observations_view.zig");
const observations = @import("../analytics/observations.zig");
const Journal = @import("../data/Journal.zig");
const portfolio_risk = @import("../analytics/portfolio_risk.zig");
pub const ParsedArgs = struct {
sort: ?review_view.SortField = null,
sort_dir: review_view.SortDirection = .desc,
/// Whether to render acknowledged findings in the findings
/// table. Default false (active findings only).
show_acked: bool = false,
/// Which observation checks to run + display. `.all` runs every
/// registered check; `.fast` runs only short-running ones (none
/// in M2 — every check is fast). `.none` skips the engine
/// entirely (don't render the findings section).
checks: ChecksMode = .all,
};
pub const ChecksMode = enum {
all,
fast,
none,
};
pub const meta: framework.Meta = .{
.name = "review",
.group = .portfolio,
.synopsis = "Per-holding performance and risk dashboard",
.help =
\\Usage: zfin review [opts]
\\
\\Show one row per portfolio holding with sector, tax-status,
\\trailing returns (1Y/3Y/5Y/10Y month-end total return), risk
\\metrics (3Y+10Y vol/Sharpe + 5Y MaxDD), and a true correlation-
\\aware portfolio totals row at the bottom.
\\
\\Default sort: grouped by sector (alphabetical), then weight
\\descending within each sector group.
\\
\\Options:
\\ --sort FIELD Sort by FIELD (overrides default grouping):
\\ sector, symbol, weight, tax,
\\ 1y, 3y, 5y, 10y,
\\ 3y-vol, 10y-vol,
\\ 3y-sharpe, 10y-sharpe,
\\ 5y-maxdd
\\ --asc Sort ascending (default: descending for
\\ numeric fields, ascending for symbol/sector)
\\ --checks=MODE Observation engine mode: all (default),
\\ fast (skip long-running checks), none
\\ (suppress findings section).
\\ --show-acked Include already-acknowledged findings
\\ in the findings table.
\\
\\Reads classifications from `metadata.srf` and account tax types
\\from `accounts.srf`. Tax% is the share of each holding's market
\\value held in taxable accounts.
\\
,
.uppercase_first_arg = false,
.user_errors = error{ UnexpectedArg, InvalidSortField, InvalidChecksMode },
};
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
var parsed: ParsedArgs = .{};
var i: usize = 0;
while (i < cmd_args.len) : (i += 1) {
const arg = cmd_args[i];
if (std.mem.eql(u8, arg, "--sort") and i + 1 < cmd_args.len) {
i += 1;
const value = cmd_args[i];
const field = review_view.parseSortField(value) orelse {
cli.stderrPrint(ctx.io, "Error: --sort must be one of: ");
cli.stderrPrint(ctx.io, joinSortFields());
cli.stderrPrint(ctx.io, "\n");
return error.InvalidSortField;
};
parsed.sort = field;
} else if (std.mem.eql(u8, arg, "--asc")) {
parsed.sort_dir = .asc;
} else if (std.mem.eql(u8, arg, "--desc")) {
parsed.sort_dir = .desc;
} else if (std.mem.eql(u8, arg, "--show-acked")) {
parsed.show_acked = true;
} else if (std.mem.startsWith(u8, arg, "--checks=")) {
const value = arg["--checks=".len..];
parsed.checks = parseChecksMode(value) orelse {
cli.stderrPrint(ctx.io, "Error: --checks must be one of: all, fast, none\n");
return error.InvalidChecksMode;
};
} else {
cli.stderrPrint(ctx.io, "Error: 'review' takes no positional arguments\n");
return error.UnexpectedArg;
}
}
return parsed;
}
fn parseChecksMode(s: []const u8) ?ChecksMode {
if (std.mem.eql(u8, s, "all")) return .all;
if (std.mem.eql(u8, s, "fast")) return .fast;
if (std.mem.eql(u8, s, "none")) return .none;
return null;
}
/// Comma-joined valid sort fields for the user-facing error message.
/// Built once at comptime so we don't allocate at error time.
const joined_sort_fields: []const u8 = blk: {
var s: []const u8 = "";
for (review_view.sort_field_names, 0..) |name, idx| {
if (idx > 0) s = s ++ ", ";
s = s ++ name;
}
break :blk s;
};
fn joinSortFields() []const u8 {
return joined_sort_fields;
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
const io = ctx.io;
const allocator = ctx.allocator;
const out = ctx.out;
const color = ctx.color;
const as_of = ctx.today;
var loaded = cli.loadPortfolio(ctx, as_of) orelse return;
defer loaded.deinit(allocator);
const portfolio = loaded.portfolio;
const positions = loaded.positions;
const syms = loaded.syms;
const anchor_path = loaded.anchor();
// Fetch fresh prices via the parallel loader so risk + return
// figures reflect TTL-current data. Mirrors the analysis command.
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
if (syms.len > 0) {
var load_result = cli.loadPortfolioPrices(io, svc, syms, &.{}, ctx.globals.refresh_policy, color);
defer load_result.deinit();
var it = load_result.prices.iterator();
while (it.next()) |entry| {
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
}
}
var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => {
cli.stderrPrint(io, "Error computing portfolio summary.\n");
return;
},
else => return err,
};
defer pf_data.deinit(allocator);
// Load classifications + account map from sibling files.
const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0;
const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{anchor_path[0..dir_end]}) catch return;
defer allocator.free(meta_path);
const meta_data = std.Io.Dir.cwd().readFileAlloc(io, meta_path, allocator, .limited(1024 * 1024)) catch {
cli.stderrPrint(io, "Error: No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf\n");
return;
};
defer allocator.free(meta_data);
var cm = zfin.classification.parseClassificationFile(allocator, meta_data) catch {
cli.stderrPrint(io, "Error: Cannot parse metadata.srf\n");
return;
};
defer cm.deinit();
var acct_map_opt: ?zfin.analysis.AccountMap = svc.loadAccountMap(allocator, anchor_path);
defer if (acct_map_opt) |*am| am.deinit();
// Per-symbol cached dividends so total-return windows include
// dividend reinvestment when available. Cached-only — no
// network — to keep the command fast on large portfolios.
var dividend_map = std.StringHashMap([]const zfin.Dividend).init(allocator);
defer {
var it = dividend_map.iterator();
while (it.next()) |entry| {
zfin.Dividend.freeSlice(allocator, @constCast(entry.value_ptr.*));
}
dividend_map.deinit();
}
for (pf_data.summary.allocations) |a| {
if (svc.getCachedDividends(allocator, a.symbol)) |divs| {
try dividend_map.put(a.symbol, divs.data);
}
}
var view = try review_view.buildReview(
allocator,
io,
pf_data.summary,
&pf_data.candle_map,
&dividend_map,
portfolio,
cm,
acct_map_opt,
as_of,
anchor_path,
);
defer view.deinit(allocator);
// Sort: explicit --sort overrides the default grouping.
if (parsed.sort) |field| {
review_view.sortRows(view.rows, field, parsed.sort_dir);
} else {
review_view.sortGroupedByDefault(view.rows);
}
try render(allocator, io, out, color, view, anchor_path, parsed);
}
// ── Rendering ─────────────────────────────────────────────────
const Money = @import("../Money.zig");
const fmt = @import("../format.zig");
/// Column widths (display columns). Cells are right-aligned by
/// caller via `format.padLeftToCols`, so the same width spec
/// produces a correctly-padded cell whether the content is `12.3%`,
/// `+22.4%`, or the multibyte em-dash `—` (which Zig's `{s:>N}`
/// byte-padding under-pads by 2 cols).
///
/// Tax% lives at the END of the row, not the start: it's contextual
/// hint, not a primary metric, so it shouldn't anchor the eye. The
/// first numeric column the reader sees is Wt% (how big is this
/// position?), which is the right anchor for "what am I looking at."
const col_symbol: usize = 8;
const col_sector: usize = 20;
const col_weight: usize = 7;
const col_pct: usize = 8; // each return / vol cell
const col_sharpe: usize = 8;
const col_maxdd: usize = 10;
const col_tax: usize = 7;
/// Total numeric-cell widths in display order. Used for separator
/// rendering and for the column-iter loop.
const col_widths = [_]usize{
col_symbol, col_sector, col_weight,
col_pct, col_pct, col_pct,
col_pct, col_pct, col_pct,
col_sharpe, col_sharpe, col_maxdd,
col_tax,
};
fn render(
allocator: std.mem.Allocator,
io: std.Io,
out: *std.Io.Writer,
color: bool,
view: review_view.ReviewView,
anchor_path: []const u8,
parsed: ParsedArgs,
) !void {
try cli.printBold(out, color, "\nPortfolio Review ({s})\n", .{view.portfolio_path});
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" As of {f} Liquid: {f} Holdings: {d}\n\n", .{
view.as_of, Money.from(view.total_liquid), view.rows.len,
});
try cli.reset(out, color);
// Status grid: per-check pass/warn/flag glyphs across the top.
// Mirrors the TUI review tab so the "at a glance" experience
// matches when the user pipes the CLI output.
if (view.observations) |panel| {
try renderStatusGrid(out, color, panel);
try out.print("\n", .{});
}
// Header row. Column order matches `col_widths`.
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" ", .{});
try out.print("{s:<8}", .{"Symbol"});
try out.print(" {s:<20}", .{"Sector"});
try out.print(" {s:>7}", .{"Wt%"});
try out.print(" {s:>8}", .{"1Y"});
try out.print(" {s:>8}", .{"3Y"});
try out.print(" {s:>8}", .{"5Y"});
try out.print(" {s:>8}", .{"10Y"});
try out.print(" {s:>8}", .{"3Y-Vol"});
try out.print(" {s:>8}", .{"10Y-Vol"});
try out.print(" {s:>8}", .{"3Y-SR"});
try out.print(" {s:>8}", .{"10Y-SR"});
try out.print(" {s:>10}", .{"5Y-MaxDD"});
try out.print(" {s:>7}", .{"Tax%"});
try out.print("\n", .{});
try cli.reset(out, color);
try writeSeparator(out, color);
// Rows.
for (view.rows) |r| {
try renderRow(out, color, r);
}
try writeSeparator(out, color);
// Totals row.
try renderTotalsRow(out, color, view.totals);
// Footnote about reweighting.
if (anyReweightFlag(view.totals.reweight_flags)) {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print("\n * Reweighted: at least one holding lacked full-window candle coverage.\n", .{});
try out.print(" Affected metrics renormalized weights across participating holdings.\n", .{});
try cli.reset(out, color);
}
// Findings section. Render unless `--checks=none` was passed.
if (parsed.checks != .none) {
try renderFindings(allocator, io, out, color, &view, anchor_path, parsed.show_acked);
}
try out.print("\n", .{});
}
/// Render the per-check status grid to stdout. Layout mirrors
/// the TUI's `appendStatusGrid`: 3 cells per row, each cell
/// "<right-padded label> <glyph>". Color promoted to the row's
/// worst severity so multi-cell rows still draw the user's eye
/// to the bad ones.
fn renderStatusGrid(
out: *std.Io.Writer,
color: bool,
panel: observations.CheckPanel,
) !void {
if (panel.pending.len == 0) return;
const status_label_cols: usize = 22;
const cells_per_row: usize = 3;
var i: usize = 0;
while (i < panel.pending.len) {
const end = @min(i + cells_per_row, panel.pending.len);
// Row color = worst severity in the row.
var worst: u8 = 0; // 0=pass/skipped, 1=warn, 2=flag/err
var worst_color: [3]u8 = cli.CLR_MUTED;
for (panel.pending[i..end]) |pc| {
const result = pc.state.complete;
const rank: u8 = switch (result) {
.pass, .skipped => 0,
.warn => 1,
.flag, .err => 2,
};
if (rank > worst) {
worst = rank;
worst_color = switch (result) {
.warn => cli.CLR_WARNING,
.flag, .err => cli.CLR_NEGATIVE,
else => cli.CLR_MUTED,
};
}
}
try cli.setFg(out, color, worst_color);
try out.print(" ", .{});
for (panel.pending[i..end], 0..) |pc, col| {
if (col > 0) try out.print(" ", .{});
const label = pc.check.label;
const lbl_cols = label.len; // ASCII labels: byte count == display cols
// Right-pad label.
if (lbl_cols < status_label_cols) {
var k: usize = 0;
while (k < status_label_cols - lbl_cols) : (k += 1) try out.print(" ", .{});
}
try out.print("{s} ", .{label});
const glyph: []const u8 = switch (pc.state.complete) {
.pass => "\u{FE0F}",
.warn => "⚠️",
.flag => "\u{FE0F}",
.skipped => "\u{FE0F}",
.err => "🛑\u{FE0F}",
};
try out.print("{s}", .{glyph});
}
try out.print("\n", .{});
try cli.reset(out, color);
i = end;
}
}
/// Render the findings section to stdout. Loads the journal from
/// the portfolio's directory (missing → empty), joins with the
/// observation panel via `observations_view.build`, and writes a
/// styled findings table similar to the TUI's. The CLI is read-only
/// — acks must come from the TUI.
fn renderFindings(
allocator: std.mem.Allocator,
io: std.Io,
out: *std.Io.Writer,
color: bool,
view: *const review_view.ReviewView,
anchor_path: []const u8,
show_acked: bool,
) !void {
const panel = if (view.observations) |*p| p else return;
// Load the journal. Missing file ⇒ empty journal (first run).
const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0;
const journal_path = try std.fmt.allocPrint(allocator, "{s}acknowledgments.srf", .{anchor_path[0..dir_end]});
defer allocator.free(journal_path);
var j = Journal.load(allocator, io, journal_path) catch |err| blk: {
cli.stderrPrint(io, "Warning: ");
cli.stderrPrint(io, journal_path);
cli.stderrPrint(io, ": ");
cli.stderrPrint(io, @errorName(err));
cli.stderrPrint(io, " — proceeding with empty journal.\n");
const empty = try allocator.alloc(Journal.Entry, 0);
break :blk Journal{ .allocator = allocator, .entries = empty };
};
defer j.deinit();
var fv = try observations_view.build(allocator, panel, &j, show_acked);
defer fv.deinit(allocator);
try out.print("\n", .{});
try cli.setFg(out, color, cli.CLR_HEADER);
try out.print(" Findings ({d} active, {d} acked, {d} resolved)", .{
fv.total_active,
fv.total_acked,
fv.total_resolved,
});
if (show_acked) try out.print(" [showing acked]", .{});
try out.print("\n", .{});
try cli.reset(out, color);
try writeSeparator(out, color);
if (fv.rows.len == 0) {
try cli.setFg(out, color, cli.CLR_MUTED);
if (fv.total_acked > 0 and !show_acked) {
try out.print(" No active findings. Use --show-acked to see acknowledged.\n", .{});
} else {
try out.print(" No findings.\n", .{});
}
try cli.reset(out, color);
return;
}
for (fv.rows) |row| {
const glyph: []const u8 = switch (row.severity) {
.warn => "⚠️",
.flag => "\u{FE0F}",
.err => "🛑\u{FE0F}",
};
const ansi: [3]u8 = if (row.is_acked)
cli.CLR_MUTED
else switch (row.severity) {
.warn => cli.CLR_WARNING,
.flag, .err => cli.CLR_NEGATIVE,
};
try cli.setFg(out, color, ansi);
try out.print(" {s} {s}{s}\n", .{
glyph,
if (row.is_acked) "[acked] " else "",
row.text,
});
try cli.reset(out, color);
}
}
fn writeSeparator(out: *std.Io.Writer, color: bool) !void {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" ", .{});
inline for (col_widths, 0..) |w, idx| {
if (idx > 0) try out.print(" ", .{});
var k: usize = 0;
while (k < w) : (k += 1) try out.print("", .{});
}
try out.print("\n", .{});
try cli.reset(out, color);
}
fn anyReweightFlag(f: portfolio_risk.ReweightFlags) bool {
return f.vol_3y or f.vol_10y or f.sharpe_3y or f.sharpe_10y or f.maxdd_5y or
f.return_1y or f.return_3y or f.return_5y or f.return_10y;
}
fn renderRow(out: *std.Io.Writer, color: bool, r: review_view.ReviewRow) !void {
try out.print(" ", .{});
try out.print("{s:<8}", .{fmt.truncateToCols(r.symbol, col_symbol)});
try out.print(" {s:<20}", .{fmt.truncateToCols(zfin.analysis.abbreviateSector(r.sector_mid), col_sector)});
try out.print(" ", .{});
try renderPctCell(out, color, .normal, r.weight, col_weight, false);
try out.print(" ", .{});
try renderSignedPctCell(out, color, review_view.returnIntent(r.return_1y), r.return_1y, col_pct, false);
try out.print(" ", .{});
try renderSignedPctCell(out, color, review_view.returnIntent(r.return_3y), r.return_3y, col_pct, false);
try out.print(" ", .{});
try renderSignedPctCell(out, color, review_view.returnIntent(r.return_5y), r.return_5y, col_pct, false);
try out.print(" ", .{});
try renderSignedPctCell(out, color, review_view.returnIntent(r.return_10y), r.return_10y, col_pct, false);
try out.print(" ", .{});
try renderPctCellOpt(out, color, review_view.volIntent(r.vol_3y), r.vol_3y, col_pct, false);
try out.print(" ", .{});
try renderPctCellOpt(out, color, review_view.volIntent(r.vol_10y), r.vol_10y, col_pct, false);
try out.print(" ", .{});
try renderSharpeCell(out, color, review_view.sharpeIntent(r.sharpe_3y), r.sharpe_3y, col_sharpe, false);
try out.print(" ", .{});
try renderSharpeCell(out, color, review_view.sharpeIntent(r.sharpe_10y), r.sharpe_10y, col_sharpe, false);
try out.print(" ", .{});
// MaxDD: same green/yellow/red scheme as Vol — magnitude
// determines severity; a small drawdown isn't "bad", and a deep
// one isn't "merely a drawdown" either.
try renderPctCellOpt(out, color, review_view.maxddIntent(r.maxdd_5y), r.maxdd_5y, col_maxdd, false);
try out.print(" ", .{});
// Tax% is contextual; rendered in muted tone so it doesn't draw
// the eye from the primary metrics. Null when account map is
// missing for this holding.
try renderPctCellOpt(out, color, .muted, r.tax_pct, col_tax, false);
try out.print("\n", .{});
}
fn renderTotalsRow(out: *std.Io.Writer, color: bool, t: review_view.ReviewTotals) !void {
try cli.printBold(out, color, " {s:<8} {s:<20}", .{ "Total", "" });
try out.print(" ", .{});
try renderPctCell(out, color, .normal, t.weight, col_weight, false);
try out.print(" ", .{});
try renderSignedPctCell(out, color, review_view.returnIntent(t.return_1y), t.return_1y, col_pct, t.reweight_flags.return_1y);
try out.print(" ", .{});
try renderSignedPctCell(out, color, review_view.returnIntent(t.return_3y), t.return_3y, col_pct, t.reweight_flags.return_3y);
try out.print(" ", .{});
try renderSignedPctCell(out, color, review_view.returnIntent(t.return_5y), t.return_5y, col_pct, t.reweight_flags.return_5y);
try out.print(" ", .{});
try renderSignedPctCell(out, color, review_view.returnIntent(t.return_10y), t.return_10y, col_pct, t.reweight_flags.return_10y);
try out.print(" ", .{});
try renderPctCellOpt(out, color, review_view.volIntent(t.vol_3y), t.vol_3y, col_pct, t.reweight_flags.vol_3y);
try out.print(" ", .{});
try renderPctCellOpt(out, color, review_view.volIntent(t.vol_10y), t.vol_10y, col_pct, t.reweight_flags.vol_10y);
try out.print(" ", .{});
try renderSharpeCell(out, color, review_view.sharpeIntent(t.sharpe_3y), t.sharpe_3y, col_sharpe, t.reweight_flags.sharpe_3y);
try out.print(" ", .{});
try renderSharpeCell(out, color, review_view.sharpeIntent(t.sharpe_10y), t.sharpe_10y, col_sharpe, t.reweight_flags.sharpe_10y);
try out.print(" ", .{});
try renderPctCellOpt(out, color, review_view.maxddIntent(t.maxdd_5y), t.maxdd_5y, col_maxdd, t.reweight_flags.maxdd_5y);
try out.print(" ", .{});
try renderPctCellOpt(out, color, .muted, t.tax_pct, col_tax, false);
try out.print("\n", .{});
}
// ── Cell renderers ────────────────────────────────────────────
//
// Each renderer formats the value into a stack buffer, pads to the
// target display width via `format.padLeftToCols` (so multibyte
// content like `—` aligns correctly — Zig's `{s:>N}` byte-padding
// would under-pad by two cols), then emits with the intent's color.
fn renderPctCellOpt(
out: *std.Io.Writer,
color: bool,
intent: fmt.StyleIntent,
v: ?f64,
width: usize,
flag: bool,
) !void {
if (v) |val| {
try renderPctCell(out, color, intent, val, width, flag);
} else {
try renderEmDashCell(out, color, width);
}
}
fn renderPctCell(
out: *std.Io.Writer,
color: bool,
intent: fmt.StyleIntent,
v: f64,
width: usize,
flag: bool,
) !void {
var content_buf: [32]u8 = undefined;
const content = fmt.fmtPct(&content_buf, v, .{ .asterisk = flag });
var pad_buf: [64]u8 = undefined;
const padded = fmt.padLeftToCols(&pad_buf, content, width);
try cli.printIntent(out, color, intent, "{s}", .{padded});
}
fn renderSignedPctCell(
out: *std.Io.Writer,
color: bool,
intent: fmt.StyleIntent,
v: ?f64,
width: usize,
flag: bool,
) !void {
if (v == null) return renderEmDashCell(out, color, width);
var content_buf: [32]u8 = undefined;
const content = fmt.fmtPctOpt(&content_buf, v, .{ .signed = true, .asterisk = flag });
var pad_buf: [64]u8 = undefined;
const padded = fmt.padLeftToCols(&pad_buf, content, width);
try cli.printIntent(out, color, intent, "{s}", .{padded});
}
fn renderSharpeCell(
out: *std.Io.Writer,
color: bool,
intent: fmt.StyleIntent,
v: ?f64,
width: usize,
flag: bool,
) !void {
if (v == null) return renderEmDashCell(out, color, width);
var content_buf: [32]u8 = undefined;
const content = fmt.fmtSharpeOpt(&content_buf, v, .{ .asterisk = flag });
var pad_buf: [64]u8 = undefined;
const padded = fmt.padLeftToCols(&pad_buf, content, width);
try cli.printIntent(out, color, intent, "{s}", .{padded});
}
fn renderEmDashCell(out: *std.Io.Writer, color: bool, width: usize) !void {
var pad_buf: [64]u8 = undefined;
const padded = fmt.padLeftToCols(&pad_buf, fmt.no_data_sentinel, width);
try cli.printIntent(out, color, .muted, "{s}", .{padded});
}
// ── Tests ─────────────────────────────────────────────────────
const testing = std.testing;
test "parseArgs: empty args returns default" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
const parsed = try parseArgs(&ctx, &args);
try testing.expect(parsed.sort == null);
try testing.expectEqual(review_view.SortDirection.desc, parsed.sort_dir);
}
test "parseArgs: --sort 3y-sharpe is accepted" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "--sort", "3y-sharpe" };
const parsed = try parseArgs(&ctx, &args);
try testing.expectEqual(review_view.SortField.sharpe_3y, parsed.sort.?);
}
test "parseArgs: --sort BOGUS errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "--sort", "bogus-field" };
try testing.expectError(error.InvalidSortField, parseArgs(&ctx, &args));
}
test "parseArgs: --asc flips direction" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "--asc", "--sort", "weight" };
const parsed = try parseArgs(&ctx, &args);
try testing.expectEqual(review_view.SortDirection.asc, parsed.sort_dir);
try testing.expectEqual(review_view.SortField.weight, parsed.sort.?);
}
test "parseArgs: positional arg errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"VTI"};
try testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "parseArgs: --show-acked sets the flag" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--show-acked"};
const parsed = try parseArgs(&ctx, &args);
try testing.expect(parsed.show_acked);
}
test "parseArgs: --checks=fast sets ChecksMode.fast" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--checks=fast"};
const parsed = try parseArgs(&ctx, &args);
try testing.expectEqual(ChecksMode.fast, parsed.checks);
}
test "parseArgs: --checks=none sets ChecksMode.none" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--checks=none"};
const parsed = try parseArgs(&ctx, &args);
try testing.expectEqual(ChecksMode.none, parsed.checks);
}
test "parseArgs: --checks=BOGUS errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--checks=bogus"};
try testing.expectError(error.InvalidChecksMode, parseArgs(&ctx, &args));
}
test "parseChecksMode: covers all variants" {
try testing.expectEqual(ChecksMode.all, parseChecksMode("all").?);
try testing.expectEqual(ChecksMode.fast, parseChecksMode("fast").?);
try testing.expectEqual(ChecksMode.none, parseChecksMode("none").?);
try testing.expect(parseChecksMode("nope") == null);
}
test "joinSortFields: contains all field names" {
const joined = joinSortFields();
for (review_view.sort_field_names) |name| {
try testing.expect(std.mem.indexOf(u8, joined, name) != null);
}
}
test "renderPctCell: writes percent representation, intent-aware" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderPctCell(&w, false, .normal, 0.1234, 8, false);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "12.3%") != null);
}
test "renderPctCell: reweight flag appends asterisk" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderPctCell(&w, false, .normal, 0.1234, 8, true);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "12.3%*") != null);
}
test "renderSignedPctCell: positive number shows + sign" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderSignedPctCell(&w, false, .positive, 0.1, 8, false);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "+10.0%") != null);
}
test "renderSignedPctCell: negative number shows - sign" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderSignedPctCell(&w, false, .negative, -0.05, 8, false);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "-5.0%") != null);
}
test "renderSignedPctCell: null renders em-dash" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderSignedPctCell(&w, false, .muted, null, 8, false);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "") != null);
}
test "renderPctCellOpt: null em-dashes; value pads to width" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderPctCellOpt(&w, false, .muted, null, 8, false);
try testing.expect(std.mem.indexOf(u8, w.buffered(), "") != null);
var buf2: [128]u8 = undefined;
var w2: std.Io.Writer = .fixed(&buf2);
try renderPctCellOpt(&w2, false, .normal, 0.20, 8, false);
try testing.expect(std.mem.indexOf(u8, w2.buffered(), "20.0%") != null);
}
test "renderSharpeCell: two-decimal precision" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderSharpeCell(&w, false, .positive, 1.234, 8, false);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "1.23") != null);
}
test "renderSharpeCell: null renders em-dash" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderSharpeCell(&w, false, .muted, null, 8, false);
try testing.expect(std.mem.indexOf(u8, w.buffered(), "") != null);
}
test "renderRow: writes complete row with all fields" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const r: review_view.ReviewRow = .{
.symbol = "VTI",
.sector_mid = "Diversified",
.tax_pct = 0.40,
.weight = 0.33,
.return_1y = 0.15,
.return_3y = 0.18,
.return_5y = 0.12,
.return_10y = 0.14,
.vol_3y = 0.16,
.vol_10y = 0.17,
.sharpe_3y = 1.10,
.sharpe_10y = 0.85,
.maxdd_5y = 0.25,
};
try renderRow(&w, false, r);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "VTI") != null);
try testing.expect(std.mem.indexOf(u8, out, "Diversified") != null);
try testing.expect(std.mem.indexOf(u8, out, "+15.0%") != null);
try testing.expect(std.mem.indexOf(u8, out, "1.10") != null);
try testing.expect(std.mem.indexOf(u8, out, "25.0%") != null);
// Tax% renders at the END of the row.
try testing.expect(std.mem.indexOf(u8, out, "40.0%") != null);
}
test "renderRow: nulls render as em-dashes" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const r: review_view.ReviewRow = .{
.symbol = "NEW",
.sector_mid = "Bonds",
.tax_pct = null,
.weight = 0.05,
.return_1y = null,
.return_3y = null,
.return_5y = null,
.return_10y = null,
.vol_3y = null,
.vol_10y = null,
.sharpe_3y = null,
.sharpe_10y = null,
.maxdd_5y = null,
};
try renderRow(&w, false, r);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "NEW") != null);
// Many em-dash cells expected on this row (returns + risk + tax%).
try testing.expect(std.mem.count(u8, out, "") >= 8);
}
test "renderTotalsRow: renders Total label and weighted aggregates" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const t: review_view.ReviewTotals = .{
.weight = 1.0,
.return_1y = 0.20,
.return_3y = 0.15,
.return_5y = 0.13,
.return_10y = 0.14,
.vol_3y = 0.13,
.vol_10y = 0.16,
.sharpe_3y = 1.05,
.sharpe_10y = 0.95,
.maxdd_5y = 0.22,
.tax_pct = 0.50,
.reweight_flags = .{},
};
try renderTotalsRow(&w, false, t);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Total") != null);
try testing.expect(std.mem.indexOf(u8, out, "+20.0%") != null);
try testing.expect(std.mem.indexOf(u8, out, "100.0%") != null);
}
test "renderTotalsRow: reweight flag adds asterisk" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const t: review_view.ReviewTotals = .{
.weight = 1.0,
.return_1y = null,
.return_3y = null,
.return_5y = null,
.return_10y = null,
.vol_3y = 0.13,
.vol_10y = null,
.sharpe_3y = null,
.sharpe_10y = null,
.maxdd_5y = null,
.tax_pct = null,
.reweight_flags = .{ .vol_3y = true },
};
try renderTotalsRow(&w, false, t);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "13.0%*") != null);
}
test "anyReweightFlag: detects any flag" {
try testing.expectEqual(false, anyReweightFlag(.{}));
try testing.expectEqual(true, anyReweightFlag(.{ .vol_3y = true }));
try testing.expectEqual(true, anyReweightFlag(.{ .return_10y = true }));
try testing.expectEqual(true, anyReweightFlag(.{ .maxdd_5y = true }));
}
test "render: emits header, separator, rows, and totals" {
var buf: [16384]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var rows = [_]review_view.ReviewRow{
.{
.symbol = "VTI",
.sector_mid = "Diversified",
.tax_pct = 1.0,
.weight = 0.6,
.return_1y = 0.15,
.return_3y = 0.18,
.return_5y = 0.12,
.return_10y = 0.14,
.vol_3y = 0.16,
.vol_10y = 0.17,
.sharpe_3y = 1.10,
.sharpe_10y = 0.85,
.maxdd_5y = 0.25,
},
.{
.symbol = "BND",
.sector_mid = "Bonds",
.tax_pct = 0.0,
.weight = 0.4,
.return_1y = 0.04,
.return_3y = 0.03,
.return_5y = 0.02,
.return_10y = 0.03,
.vol_3y = 0.05,
.vol_10y = 0.06,
.sharpe_3y = 0.20,
.sharpe_10y = 0.15,
.maxdd_5y = 0.08,
},
};
const view: review_view.ReviewView = .{
.rows = rows[0..],
.totals = .{
.weight = 1.0,
.return_1y = 0.10,
.return_3y = 0.12,
.return_5y = 0.08,
.return_10y = 0.10,
.vol_3y = 0.11,
.vol_10y = 0.12,
.sharpe_3y = 0.85,
.sharpe_10y = 0.65,
.maxdd_5y = 0.18,
.tax_pct = 0.6,
.reweight_flags = .{},
},
.as_of = zfin.Date.fromYmd(2026, 6, 4),
.total_liquid = 1_000_000.0,
.portfolio_path = "test_portfolio.srf",
};
try render(testing.allocator, std.testing.io, &w, false, view, "test_portfolio.srf", .{ .checks = .none });
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Portfolio Review") != null);
try testing.expect(std.mem.indexOf(u8, out, "test_portfolio.srf") != null);
try testing.expect(std.mem.indexOf(u8, out, "Symbol") != null);
try testing.expect(std.mem.indexOf(u8, out, "VTI") != null);
try testing.expect(std.mem.indexOf(u8, out, "BND") != null);
try testing.expect(std.mem.indexOf(u8, out, "Total") != null);
// Tax% header should be the LAST column header.
const tax_idx = std.mem.indexOf(u8, out, "Tax%") orelse unreachable;
const dd_idx = std.mem.indexOf(u8, out, "5Y-MaxDD") orelse unreachable;
try testing.expect(tax_idx > dd_idx);
}
test "render: emits reweight footnote when any flag set" {
var buf: [16384]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const view: review_view.ReviewView = .{
.rows = &.{},
.totals = .{
.weight = 0.0,
.return_1y = null,
.return_3y = null,
.return_5y = null,
.return_10y = null,
.vol_3y = null,
.vol_10y = null,
.sharpe_3y = null,
.sharpe_10y = null,
.maxdd_5y = null,
.tax_pct = null,
.reweight_flags = .{ .vol_10y = true },
},
.as_of = zfin.Date.fromYmd(2026, 6, 4),
.total_liquid = 0,
.portfolio_path = "x.srf",
};
try render(testing.allocator, std.testing.io, &w, false, view, "test_portfolio.srf", .{ .checks = .none });
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Reweighted") != null);
}