const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; /// CLI `analysis` command: show portfolio analysis breakdowns. pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void { var loaded = cli.loadPortfolio(io, allocator, file_path, as_of) orelse return; defer loaded.deinit(allocator); const portfolio = loaded.portfolio; const positions = loaded.positions; const syms = loaded.syms; // Build prices from cache var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); for (positions) |pos| { if (pos.shares <= 0) continue; if (svc.getCachedCandles(pos.symbol)) |cs| { defer cs.deinit(); if (cs.data.len > 0) { try prices.put(pos.symbol, cs.data[cs.data.len - 1].close); } } } // Build summary via shared pipeline var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) { error.NoAllocations, error.SummaryFailed => { try cli.stderrPrint(io, "Error computing portfolio summary.\n"); return; }, else => return err, }; defer pf_data.deinit(allocator); // Load classification metadata const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0; const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{file_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 { try 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 { try cli.stderrPrint(io, "Error: Cannot parse metadata.srf\n"); return; }; defer cm.deinit(); // Load account tax type metadata (optional) var acct_map_opt: ?zfin.analysis.AccountMap = svc.loadAccountMap(file_path); defer if (acct_map_opt) |*am| am.deinit(); var result = zfin.analysis.analyzePortfolio( allocator, pf_data.summary.allocations, cm, portfolio, pf_data.summary.total_value, acct_map_opt, as_of, ) catch { try cli.stderrPrint(io, "Error computing analysis.\n"); return; }; defer result.deinit(allocator); const benchmark = @import("../analytics/benchmark.zig"); const split = benchmark.deriveAllocationSplit( pf_data.summary.allocations, cm.entries, pf_data.summary.total_value, portfolio.totalCash(as_of), portfolio.totalCdFaceValue(as_of), ); try display(result, split.stock_pct, split.bond_pct, pf_data.summary.total_value, file_path, color, out); } pub fn display(result: zfin.analysis.AnalysisResult, stock_pct: f64, bond_pct: f64, total_value: f64, file_path: []const u8, color: bool, out: *std.Io.Writer) !void { const label_width = fmt.analysis_label_width; const bar_width = fmt.analysis_bar_width; try cli.printBold(out, color, "\nPortfolio Analysis ({s})\n", .{file_path}); try out.print("========================================\n\n", .{}); // Equities vs Fixed Income summary { var eq_buf: [24]u8 = undefined; var fi_buf: [24]u8 = undefined; const eq_dollars = fmt.fmtMoneyAbs(&eq_buf, stock_pct * total_value); const fi_dollars = fmt.fmtMoneyAbs(&fi_buf, bond_pct * total_value); try cli.printFg(out, color, cli.CLR_MUTED, " Equities {d:.1}% ({s}) / Fixed Income {d:.1}% ({s})\n\n", .{ stock_pct * 100, eq_dollars, bond_pct * 100, fi_dollars }); } const sections = [_]struct { items: []const zfin.analysis.BreakdownItem, title: []const u8 }{ .{ .items = result.asset_class, .title = " Asset Class" }, .{ .items = result.sector, .title = " Sector (Equities)" }, .{ .items = result.geo, .title = " Geographic" }, .{ .items = result.account, .title = " By Account" }, .{ .items = result.tax_type, .title = " By Tax Type" }, }; for (sections, 0..) |sec, si| { if (si > 0 and sec.items.len == 0) continue; if (si > 0) try out.print("\n", .{}); // Bold + header color — reset at end of printFg clears both. try cli.setBold(out, color); try cli.printFg(out, color, cli.CLR_HEADER, "{s}\n", .{sec.title}); try printBreakdownSection(out, sec.items, label_width, bar_width, color); } // Unclassified if (result.unclassified.len > 0) { try out.print("\n", .{}); try cli.printFg(out, color, cli.CLR_WARNING, " Unclassified (not in metadata.srf)\n", .{}); for (result.unclassified) |sym| { try cli.printFg(out, color, cli.CLR_MUTED, " {s}\n", .{sym}); } } try out.print("\n", .{}); } /// Print a breakdown section with block-element bar charts to the CLI output. pub fn printBreakdownSection(out: *std.Io.Writer, items: []const zfin.analysis.BreakdownItem, label_width: usize, bar_width: usize, color: bool) !void { for (items) |item| { var val_buf: [24]u8 = undefined; const pct = item.weight * 100.0; // Build bar using shared function var bar_buf: [256]u8 = undefined; const bar = fmt.buildBlockBar(&bar_buf, item.weight, bar_width); // Padded label const lbl_len = @min(item.label.len, label_width); try out.print(" ", .{}); try out.writeAll(item.label[0..lbl_len]); if (lbl_len < label_width) { for (0..label_width - lbl_len) |_| try out.writeAll(" "); } try out.writeAll(" "); if (color) try fmt.ansiSetFg(out, cli.CLR_ACCENT[0], cli.CLR_ACCENT[1], cli.CLR_ACCENT[2]); try out.writeAll(bar); if (color) try fmt.ansiReset(out); try out.print(" {d:>5.1}% {s}\n", .{ pct, fmt.fmtMoneyAbs(&val_buf, item.value) }); } } // ── Tests ──────────────────────────────────────────────────── test "printBreakdownSection single item no color" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const items = [_]zfin.analysis.BreakdownItem{ .{ .label = "US Large Cap", .weight = 0.60, .value = 60000.0 }, }; try printBreakdownSection(&w, &items, 24, 30, false); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "US Large Cap") != null); try std.testing.expect(std.mem.indexOf(u8, out, "60.0%") != null); // No ANSI when color=false try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); } test "printBreakdownSection multiple items" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const items = [_]zfin.analysis.BreakdownItem{ .{ .label = "Stocks", .weight = 0.70, .value = 70000.0 }, .{ .label = "Bonds", .weight = 0.20, .value = 20000.0 }, .{ .label = "Cash", .weight = 0.10, .value = 10000.0 }, }; try printBreakdownSection(&w, &items, 24, 30, false); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "Stocks") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Bonds") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Cash") != null); try std.testing.expect(std.mem.indexOf(u8, out, "70.0%") != null); try std.testing.expect(std.mem.indexOf(u8, out, "10.0%") != null); } test "printBreakdownSection zero weight" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const items = [_]zfin.analysis.BreakdownItem{ .{ .label = "Empty", .weight = 0.0, .value = 0.0 }, }; try printBreakdownSection(&w, &items, 24, 30, false); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "0.0%") != null); } test "printBreakdownSection with color emits ANSI" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const items = [_]zfin.analysis.BreakdownItem{ .{ .label = "Test", .weight = 0.50, .value = 50000.0 }, }; try printBreakdownSection(&w, &items, 24, 30, true); const out = w.buffered(); // Should contain ANSI escape for bar color try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null); } test "display shows all sections" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const asset_class = [_]zfin.analysis.BreakdownItem{ .{ .label = "US Large Cap", .weight = 0.60, .value = 60000.0 }, .{ .label = "International", .weight = 0.40, .value = 40000.0 }, }; const sector = [_]zfin.analysis.BreakdownItem{ .{ .label = "Technology", .weight = 0.35, .value = 35000.0 }, }; const geo = [_]zfin.analysis.BreakdownItem{ .{ .label = "US", .weight = 0.80, .value = 80000.0 }, }; const empty = [_]zfin.analysis.BreakdownItem{}; const unclassified = [_][]const u8{"WEIRD"}; const result: zfin.analysis.AnalysisResult = .{ .asset_class = @constCast(&asset_class), .sector = @constCast(§or), .geo = @constCast(&geo), .account = @constCast(&empty), .tax_type = @constCast(&empty), .unclassified = @constCast(&unclassified), .total_value = 100000.0, }; try display(result, 0.80, 0.20, 100000.0, "test.srf", false, &w); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Analysis") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Asset Class") != null); try std.testing.expect(std.mem.indexOf(u8, out, "US Large Cap") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Sector") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Technology") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Geographic") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Unclassified") != null); try std.testing.expect(std.mem.indexOf(u8, out, "WEIRD") != null); // No ANSI when color=false try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); }