1001 lines
37 KiB
Zig
1001 lines
37 KiB
Zig
//! `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,
|
||
÷nd_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);
|
||
}
|