//! `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 > 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 /// " ". 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); }