const std = @import("std"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); const theme = @import("theme.zig"); const tui = @import("../tui.zig"); const framework = @import("tab_framework.zig"); const App = tui.App; const StyledLine = tui.StyledLine; // ── Tab-local action enum ───────────────────────────────────── // // Cycle the Sector breakdown's display granularity through // coarse → mid → fine → coarse. Default tier is mid (~12-16 // buckets, NPORT-P sub-flavors collapsed but GICS sectors // distinct). Coarse delegates to the same 4-bucket shape as // the Asset Category section. Fine is the raw NPORT-P // breakdown (every Debt / X variant separate). pub const Action = enum { cycle_sector_granularity }; // ── Tab-private state ───────────────────────────────────────── pub const State = struct { /// Whether `init`/`activate` has populated `result`. Internal /// short-circuit for `activate`; App never reads this. loaded: bool = false, /// Computed analysis output. Owned by State; freed in /// `deinit` and `reload`. result: ?zfin.analysis.AnalysisResult = null, /// Per-portfolio classification metadata (`metadata.srf`). /// Used only by analysis today; lives here because no other /// tab consumes it. Loaded lazily on first activation; freed /// in `deinit`. classification_map: ?zfin.classification.ClassificationMap = null, /// Sector display granularity. Cycled via `cycle_sector_granularity` /// action. Default `mid` matches the CLI default /// (`zfin analysis` without `--sector-detail`). sector_granularity: zfin.analysis.Granularity = .mid, }; // ── Tab framework contract ──────────────────────────────────── pub const meta: framework.TabMeta(Action) = .{ .label = "Analysis", .default_bindings = &.{ .{ .action = .cycle_sector_granularity, .key = .{ .codepoint = 'm' } }, }, .action_labels = std.enums.EnumArray(Action, []const u8).init(.{ .cycle_sector_granularity = "Cycle sector granularity (coarse / mid / fine)", }), .status_hints = &.{ .cycle_sector_granularity, }, }; pub const tab = struct { pub const ActionT = Action; pub const StateT = State; pub fn init(state: *State, app: *App) !void { _ = app; state.* = .{}; } pub fn deinit(state: *State, app: *App) void { if (state.result) |*ar| ar.deinit(app.allocator); if (state.classification_map) |*cm| cm.deinit(); state.* = .{}; } pub fn activate(state: *State, app: *App) !void { if (tab.isDisabled(app)) return; if (state.loaded) return; loadData(state, app); } pub const deactivate = framework.noopDeactivate(State); /// Force re-fetch on user request. Frees the analysis result /// AND the shared `account_map` on App (analysis's refresh /// also re-reads accounts.srf). The classification_map persists /// — it's per-portfolio, not per-symbol or per-refresh. pub fn reload(state: *State, app: *App) !void { if (state.result) |*ar| ar.deinit(app.allocator); state.result = null; state.loaded = false; // Refresh-analysis intentionally drops the shared account // map so the next load re-reads `accounts.srf` from disk // (the user may have edited it). if (app.portfolio.account_map) |*am| am.deinit(); app.portfolio.account_map = null; loadData(state, app); } pub const tick = framework.noopTick(State); pub fn handleAction(state: *State, app: *App, action: Action) void { _ = app; switch (action) { .cycle_sector_granularity => { // coarse → mid → fine → coarse. The display layer // re-aggregates `result.sector` through the new // granularity on the next render. state.sector_granularity = switch (state.sector_granularity) { .coarse => .mid, .mid => .fine, .fine => .coarse, }; }, } } /// Analysis requires a loaded portfolio file (the breakdown /// is computed from `app.portfolio.summary.allocations`). /// Derived directly from `app.portfolio.file` rather than /// stored as a field, so it can't go stale and App never has /// to reach across into the tab's State to set it. pub fn isDisabled(app: *App) bool { return app.portfolio.file == null; } }; // ── Data loading ────────────────────────────────────────────── fn loadData(state: *State, app: *App) void { state.loaded = true; // Ensure portfolio is loaded first app.ensurePortfolioDataLoaded(); const pf = app.portfolio.file orelse return; const summary = app.portfolio.summary orelse return; // Load classification metadata file if (state.classification_map == null) { // Look for metadata.srf next to the portfolio file if (app.anchorPath()) |ppath| { // Derive metadata path: same directory as portfolio, named "metadata.srf" const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0; const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return; defer app.allocator.free(meta_path); const file_data = std.Io.Dir.cwd().readFileAlloc(app.io, meta_path, app.allocator, .limited(1024 * 1024)) catch { app.setStatus("No metadata.srf found. Run: zfin enrich > metadata.srf"); return; }; defer app.allocator.free(file_data); state.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch { app.setStatus("Error parsing metadata.srf"); return; }; } } // Load account tax type metadata file (optional) app.ensureAccountMap(); loadDataFinish(state, app, pf, summary); } fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void { const cm = state.classification_map orelse { app.setStatus("No classification data. Run: zfin enrich > metadata.srf"); return; }; // Free previous result if (state.result) |*ar| ar.deinit(app.allocator); state.result = zfin.analysis.analyzePortfolio( app.allocator, summary.allocations, cm, pf, summary.total_value, app.portfolio.account_map, app.today, // live mode in TUI → resolves to app.today ) catch { app.setStatus("Error computing analysis"); return; }; } // ── Rendering ───────────────────────────────────────────────── pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine { // Compute equity/fixed-income/cash split from classification + portfolio var stock_pct: f64 = 0; var bond_pct: f64 = 0; var cash_pct: f64 = 0; var total_value: f64 = 0; if (app.portfolio.summary) |summary| { total_value = summary.total_value; if (app.portfolio.file) |pf| { const benchmark = @import("../analytics/benchmark.zig"); const cm_entries = if (state.classification_map) |cm| cm.entries else &.{}; const split = benchmark.deriveAllocationSplit( summary.allocations, cm_entries, summary.total_value, pf.totalCash(app.today), pf.totalCdFaceValue(app.today), ); stock_pct = split.stock_pct; bond_pct = split.bond_pct; cash_pct = split.cash_pct; } } return renderAnalysisLines(arena, app.theme, state.result, stock_pct, bond_pct, cash_pct, total_value, state.sector_granularity); } /// Render analysis tab content. Pure function — no App dependency. pub fn renderAnalysisLines( arena: std.mem.Allocator, th: theme.Theme, analysis_result: ?zfin.analysis.AnalysisResult, stock_pct: f64, bond_pct: f64, cash_pct: f64, total_value: f64, sector_granularity: zfin.analysis.Granularity, ) ![]const StyledLine { var lines: std.ArrayList(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = " Portfolio Analysis", .style = th.headerStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const result = analysis_result orelse { try lines.append(arena, .{ .text = " No analysis data. Ensure metadata.srf exists alongside portfolio.", .style = th.mutedStyle() }); try lines.append(arena, .{ .text = " Run: zfin enrich > metadata.srf", .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); }; // Equities / Fixed Income / Cash header summary. The Other // bucket (derivatives, real property) is excluded from this // summary but appears as its own row in the Asset Category // breakdown. if (stock_pct > 0 or bond_pct > 0 or cash_pct > 0) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Equities {d:.1}% ({f}) / Fixed Income {d:.1}% ({f}) / Cash {d:.1}% ({f})", .{ stock_pct * 100, Money.from(stock_pct * total_value), bond_pct * 100, Money.from(bond_pct * total_value), cash_pct * 100, Money.from(cash_pct * total_value), }), .style = th.mutedStyle(), }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); } const bar_width: usize = 30; const label_width: usize = 24; // Re-aggregate the Sector breakdown at the chosen granularity. // The arena allocator owns the result for this frame; freed // when the frame ends. const collapsed_sector = try zfin.analysis.collapseBreakdownAtGranularity( arena, result.sector, sector_granularity, total_value, ); // Produce a per-frame copy of the result with the rebucketed // sector slice. Other fields are aliased; we don't free the // original sector slice — that lives on `state.result` and // gets freed at tab.deinit time. const display_result: zfin.analysis.AnalysisResult = .{ .asset_category = result.asset_category, .asset_class = result.asset_class, .sector = collapsed_sector, .geo = result.geo, .account = result.account, .tax_type = result.tax_type, .unclassified = result.unclassified, .total_value = result.total_value, }; const sections = zfin.analysis.breakdownSections(&display_result); for (sections, 0..) |sec, si| { if (si > 0 and sec.items.len == 0) continue; if (si > 0) try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // Indent the title (renderer-level, not baked into the // section's title string). For the Sector section, // append the current granularity in parens so the user // knows what the `g` hot-key cycled to. const title_text = if (std.mem.eql(u8, sec.title, "Sector (Equities)")) try std.fmt.allocPrint(arena, " Sector ({s} — press 'm' to cycle)", .{granularityLabel(sector_granularity)}) else try std.fmt.allocPrint(arena, " {s}", .{sec.title}); try lines.append(arena, .{ .text = title_text, .style = th.headerStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); for (sec.items) |item| { const text = try fmtBreakdownLine(arena, item, bar_width, label_width); try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width }); } } if (result.unclassified.len > 0) { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = " Unclassified (not in metadata.srf)", .style = th.warningStyle() }); for (result.unclassified) |sym| { const text = try std.fmt.allocPrint(arena, " {s}", .{sym}); try lines.append(arena, .{ .text = text, .style = th.mutedStyle() }); } } return lines.toOwnedSlice(arena); } /// Display label for a granularity tier, used in the Sector /// section title. fn granularityLabel(g: zfin.analysis.Granularity) []const u8 { return switch (g) { .coarse => "coarse", .mid => "mid", .fine => "fine", }; } pub fn fmtBreakdownLine(arena: std.mem.Allocator, item: zfin.analysis.BreakdownItem, bar_width: usize, label_width: usize) ![]const u8 { const pct = item.weight * 100.0; const bar = try buildBlockBar(arena, item.weight, bar_width); // Build label padded to label_width const lbl = item.label; const lbl_len = @min(lbl.len, label_width); const padded_label = try arena.alloc(u8, label_width); @memcpy(padded_label[0..lbl_len], lbl[0..lbl_len]); if (lbl_len < label_width) @memset(padded_label[lbl_len..], ' '); return std.fmt.allocPrint(arena, " {s} {s} {d:>5.1}% {f}", .{ padded_label, bar, pct, Money.from(item.value), }); } /// Build a bar using Unicode block elements for sub-character precision. /// Wraps fmt.buildBlockBar into arena-allocated memory. pub fn buildBlockBar(arena: std.mem.Allocator, weight: f64, total_chars: usize) ![]const u8 { var buf: [256]u8 = undefined; const result = fmt.buildBlockBar(&buf, weight, total_chars); return arena.dupe(u8, result); } // ── Tests ───────────────────────────────────────────────────────────── const testing = std.testing; test "buildBlockBar empty" { const bar = try buildBlockBar(testing.allocator, 0, 10); defer testing.allocator.free(bar); // All spaces try testing.expectEqual(@as(usize, 10), bar.len); try testing.expectEqualStrings(" ", bar); } test "buildBlockBar full" { const bar = try buildBlockBar(testing.allocator, 1.0, 5); defer testing.allocator.free(bar); // 5 full blocks, each 3 bytes UTF-8 (█ = E2 96 88) try testing.expectEqual(@as(usize, 15), bar.len); // Verify first block is █ try testing.expectEqualStrings("\xe2\x96\x88", bar[0..3]); } test "buildBlockBar partial" { const bar = try buildBlockBar(testing.allocator, 0.5, 10); defer testing.allocator.free(bar); // 50% of 10 chars = 5 full blocks (no partial) // 5 full blocks (15 bytes) + 5 spaces = 20 bytes try testing.expectEqual(@as(usize, 20), bar.len); } test "fmtBreakdownLine formats correctly" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const item = zfin.analysis.BreakdownItem{ .label = "US Stock", .weight = 0.65, .value = 130000, }; const line = try fmtBreakdownLine(arena, item, 10, 12); // Should contain the label, percentage, and dollar amount try testing.expect(std.mem.indexOf(u8, line, "US Stock") != null); try testing.expect(std.mem.indexOf(u8, line, "65.0%") != null); try testing.expect(std.mem.indexOf(u8, line, "$130,000") != null); } test "renderAnalysisLines with data" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const th = theme.default_theme; var asset_class = [_]zfin.analysis.BreakdownItem{ .{ .label = "US Stock", .weight = 0.60, .value = 120000 }, .{ .label = "Int'l Stock", .weight = 0.40, .value = 80000 }, }; const result = zfin.analysis.AnalysisResult{ .asset_category = &.{}, .asset_class = &asset_class, .sector = &.{}, .geo = &.{}, .account = &.{}, .tax_type = &.{}, .unclassified = &.{}, .total_value = 200000, }; const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.15, 0.05, 200000, .mid); // Should have header section + asset class items try testing.expect(lines.len >= 5); // Find "Portfolio Analysis" header var found_header = false; var found_cash_in_summary = false; for (lines) |l| { if (std.mem.indexOf(u8, l.text, "Portfolio Analysis") != null) found_header = true; if (std.mem.indexOf(u8, l.text, "Cash 5.0%") != null) found_cash_in_summary = true; } try testing.expect(found_header); try testing.expect(found_cash_in_summary); // Find asset class data var found_us = false; for (lines) |l| { if (std.mem.indexOf(u8, l.text, "US Stock") != null) found_us = true; } try testing.expect(found_us); } test "renderAnalysisLines no data" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const th = theme.default_theme; const lines = try renderAnalysisLines(arena, th, null, 0, 0, 0, 0, .mid); try testing.expectEqual(@as(usize, 5), lines.len); try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null); } test "tab.init produces zero-defaulted state" { var state: State = undefined; var dummy_app: tui.App = undefined; // intentionally undefined: init // for analysis doesn't touch app fields. try tab.init(&state, &dummy_app); try testing.expectEqual(false, state.loaded); try testing.expect(state.result == null); try testing.expect(state.classification_map == null); // Default sector granularity is mid (matches CLI default). try testing.expectEqual(zfin.analysis.Granularity.mid, state.sector_granularity); } test "handleAction cycles sector granularity coarse → mid → fine → coarse" { var state: State = .{}; var dummy_app: tui.App = undefined; // handleAction doesn't touch app // mid → fine try testing.expectEqual(zfin.analysis.Granularity.mid, state.sector_granularity); tab.handleAction(&state, &dummy_app, .cycle_sector_granularity); try testing.expectEqual(zfin.analysis.Granularity.fine, state.sector_granularity); // fine → coarse tab.handleAction(&state, &dummy_app, .cycle_sector_granularity); try testing.expectEqual(zfin.analysis.Granularity.coarse, state.sector_granularity); // coarse → mid (full cycle) tab.handleAction(&state, &dummy_app, .cycle_sector_granularity); try testing.expectEqual(zfin.analysis.Granularity.mid, state.sector_granularity); } test "renderAnalysisLines: granularity label appears in Sector section title" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const th = theme.default_theme; var sector = [_]zfin.analysis.BreakdownItem{ .{ .label = "Equity / Corporate", .weight = 0.80, .value = 80_000 }, .{ .label = "Debt / Corporate", .weight = 0.20, .value = 20_000 }, }; const result = zfin.analysis.AnalysisResult{ .asset_category = &.{}, .asset_class = &.{}, .sector = §or, .geo = &.{}, .account = &.{}, .tax_type = &.{}, .unclassified = &.{}, .total_value = 100_000, }; // mid label { const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.20, 0.0, 100_000, .mid); var found_mid = false; for (lines) |l| { if (std.mem.indexOf(u8, l.text, "Sector (mid") != null) found_mid = true; } try testing.expect(found_mid); } // fine label { const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.20, 0.0, 100_000, .fine); var found_fine = false; for (lines) |l| { if (std.mem.indexOf(u8, l.text, "Sector (fine") != null) found_fine = true; } try testing.expect(found_fine); } // coarse label { const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.20, 0.0, 100_000, .coarse); var found_coarse = false; for (lines) |l| { if (std.mem.indexOf(u8, l.text, "Sector (coarse") != null) found_coarse = true; } try testing.expect(found_coarse); } } test "renderAnalysisLines: mid granularity collapses Debt rows" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const th = theme.default_theme; // Two Debt rows that should collapse to one Bonds row at mid. var sector = [_]zfin.analysis.BreakdownItem{ .{ .label = "Debt / Corporate", .weight = 0.40, .value = 40_000 }, .{ .label = "Debt / US Treasury", .weight = 0.30, .value = 30_000 }, .{ .label = "Equity / Corporate", .weight = 0.30, .value = 30_000 }, }; const result = zfin.analysis.AnalysisResult{ .asset_category = &.{}, .asset_class = &.{}, .sector = §or, .geo = &.{}, .account = &.{}, .tax_type = &.{}, .unclassified = &.{}, .total_value = 100_000, }; const lines = try renderAnalysisLines(arena, th, result, 0.30, 0.70, 0.0, 100_000, .mid); // At mid, Bonds appears (collapsed) and the individual Debt // rows do NOT appear. var has_bonds_row = false; var has_raw_treasury = false; for (lines) |l| { if (std.mem.indexOf(u8, l.text, "Bonds") != null and std.mem.indexOf(u8, l.text, "70.0%") != null) has_bonds_row = true; if (std.mem.indexOf(u8, l.text, "Debt / US Treasury") != null) has_raw_treasury = true; } try testing.expect(has_bonds_row); try testing.expect(!has_raw_treasury); }