diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index 0115001..c507016 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -367,6 +367,107 @@ pub fn bucketSector(sector: []const u8) []const u8 { return bucket_other; } +// ── Sector display granularity ─────────────────────────────── + +/// Granularity tier for the Sector breakdown display. Lets the +/// user toggle between coarse bucket-level summary and the raw +/// fine-grained NPORT-P decomposition. +pub const Granularity = enum { + /// Four buckets: Equity / Fixed Income / Cash / Other. + /// Same labels as the Asset Category breakdown. + coarse, + /// ~12-16 buckets: collapses NPORT-P sub-flavors but keeps + /// GICS sectors distinct. Default for most users — answers + /// "how exposed am I to bonds vs stocks vs cash, and which + /// sectors within stocks?". + mid, + /// Raw NPORT-P strings: every Debt / X variant, every + /// Asset-Backed / Y variant, every Derivative / Z variant + /// is its own row. Useful for spotting fine-grained + /// concentrations within the bond sleeve (e.g. "am I overweight + /// US Treasury vs Municipal?"). + fine, +}; + +// Mid-granularity bucket labels. Static literals so they can be +// used as stable HashMap keys without duping. +pub const mid_bonds: []const u8 = "Bonds"; +pub const mid_equity_corporate: []const u8 = "Equity / Corporate"; +pub const mid_equity_preferred: []const u8 = "Equity Preferred"; +pub const mid_cash_equivalents: []const u8 = "Cash & Equivalents"; +pub const mid_derivatives: []const u8 = "Derivatives"; +pub const mid_other: []const u8 = "Other"; + +/// Map a sector string through the chosen granularity. Returns +/// a static literal (or, at fine granularity, the input slice +/// itself) suitable for use as a stable HashMap key. +/// +/// Granularity tiers: +/// +/// - **coarse**: delegates to `bucketSector` — Equity / Fixed Income +/// / Cash / Other (4 buckets). +/// - **mid**: collapses NPORT-P sub-flavors (all Debt / * → Bonds, +/// all Asset-Backed / * → Bonds, all STIV / * → Cash & Equivalents, +/// all Derivative / * → Derivatives) while keeping GICS sectors +/// distinct (Technology, Healthcare, etc.). +/// - **fine**: passthrough — returns the input unchanged. +pub fn collapseSector(sector: []const u8, granularity: Granularity) []const u8 { + return switch (granularity) { + .fine => sector, + .coarse => bucketSector(sector), + .mid => midBucket(sector), + }; +} + +/// Mid-granularity bucket lookup. Pure data, no allocation. +fn midBucket(sector: []const u8) []const u8 { + // Bond-shaped (NPORT-P): all Debt / *, Loan / *, Asset-Backed / *. + if (std.mem.startsWith(u8, sector, "Debt")) return mid_bonds; + if (std.mem.startsWith(u8, sector, "Loan")) return mid_bonds; + if (std.mem.startsWith(u8, sector, "Asset-Backed")) return mid_bonds; + // Plain-English bond label from legacy hand-written metadata. + if (std.mem.eql(u8, sector, "Bonds")) return mid_bonds; + + // Cash-shaped (NPORT-P): STIV variants + repurchase agreement. + if (std.mem.startsWith(u8, sector, "Short-Term Investment Vehicle")) return mid_cash_equivalents; + if (std.mem.startsWith(u8, sector, "Repurchase Agreement")) return mid_cash_equivalents; + if (std.mem.eql(u8, sector, "Cash")) return mid_cash_equivalents; + if (std.mem.eql(u8, sector, "Cash & CDs")) return mid_cash_equivalents; + + // Equity Preferred is distinct at mid because it's a hybrid. + if (std.mem.startsWith(u8, sector, "Equity Preferred")) return mid_equity_preferred; + + // Generic Equity / * collapses to a single Equity / Corporate + // bucket. Specifically "Equity / Corporate" / "Equity / Other" + // / "Equity / Registered Fund" all merge here. + if (std.mem.startsWith(u8, sector, "Equity")) return mid_equity_corporate; + + // Derivatives. + if (std.mem.startsWith(u8, sector, "Derivative")) return mid_derivatives; + + // GICS sector names pass through unchanged. Same exact-match + // list as `bucketSector`. + const gics = [_][]const u8{ + "Technology", + "Healthcare", + "Financial Services", + "Financials", + "Consumer Cyclical", + "Consumer Defensive", + "Energy", + "Utilities", + "Real Estate", + "Industrials", + "Basic Materials", + "Communication Services", + "Diversified", + }; + for (gics) |g| if (std.mem.eql(u8, sector, g)) return sector; + + // Direct Real Property, Direct Credit Risk, sentinels — Other. + return mid_other; +} + /// Compute portfolio analysis from allocations and classification metadata. /// `allocations` are the stock/ETF positions with market values. /// `classifications` is the metadata file data. @@ -564,6 +665,36 @@ fn mapToSortedBreakdown( return items.toOwnedSlice(allocator); } +/// Re-aggregate a raw `BreakdownItem` slice through `collapseSector` +/// at the chosen granularity. Multiple input rows that map to the +/// same coarser bucket sum into one output row. Returns a new +/// allocator-owned slice; caller frees with `allocator.free`. +/// +/// Use case: `analyze` produces `result.sector` at fine +/// granularity (raw NPORT-P + GICS labels). Display callers +/// (CLI `--sector-detail`, TUI hot-key) call this to re-bucket +/// at their chosen tier. At `.fine`, the function still +/// allocates a fresh slice (so callers always free the result +/// the same way) but the per-row labels and weights are +/// identical to the input. +pub fn collapseBreakdownAtGranularity( + allocator: std.mem.Allocator, + items: []const BreakdownItem, + granularity: Granularity, + total: f64, +) ![]BreakdownItem { + var map = std.StringHashMap(f64).init(allocator); + defer map.deinit(); + + for (items) |item| { + const label = collapseSector(item.label, granularity); + const prev = map.get(label) orelse 0; + try map.put(label, prev + item.value); + } + + return mapToSortedBreakdown(allocator, map, total); +} + test "parseAccountsFile" { const data = \\#!srfv1 @@ -934,6 +1065,207 @@ test "bucketSector: legacy 'Financials' (with s) → Equity" { try std.testing.expectEqualStrings(bucket_equity, bucketSector("Financial Services")); } +// ── collapseSector / Granularity ────────────────────────────── + +test "collapseSector .fine: passthrough — input slice returned unchanged" { + try std.testing.expectEqualStrings("Debt / US Treasury", collapseSector("Debt / US Treasury", .fine)); + try std.testing.expectEqualStrings("Equity / Corporate", collapseSector("Equity / Corporate", .fine)); + try std.testing.expectEqualStrings("Technology", collapseSector("Technology", .fine)); + try std.testing.expectEqualStrings("Bonds", collapseSector("Bonds", .fine)); +} + +test "collapseSector .coarse: delegates to bucketSector" { + try std.testing.expectEqualStrings(bucket_fixed_income, collapseSector("Debt / US Treasury", .coarse)); + try std.testing.expectEqualStrings(bucket_equity, collapseSector("Equity / Corporate", .coarse)); + try std.testing.expectEqualStrings(bucket_equity, collapseSector("Technology", .coarse)); + try std.testing.expectEqualStrings(bucket_cash, collapseSector("Short-Term Investment Vehicle / Registered Fund", .coarse)); + try std.testing.expectEqualStrings(bucket_other, collapseSector("Derivative / Other", .coarse)); +} + +test "collapseSector .mid: all Debt / * collapse to Bonds" { + const cases = [_][]const u8{ + "Debt / Corporate", + "Debt / US Treasury", + "Debt / Municipal", + "Debt / Non-US Sovereign", + "Debt / US Gov Agency", + "Debt / US GSE", + }; + for (cases) |s| { + try std.testing.expectEqualStrings(mid_bonds, collapseSector(s, .mid)); + } +} + +test "collapseSector .mid: all Asset-Backed and Loan variants collapse to Bonds" { + try std.testing.expectEqualStrings(mid_bonds, collapseSector("Asset-Backed / Corporate Mortgage", .mid)); + try std.testing.expectEqualStrings(mid_bonds, collapseSector("Asset-Backed CBO/CDO / Corporate", .mid)); + try std.testing.expectEqualStrings(mid_bonds, collapseSector("Asset-Backed Other / Corporate", .mid)); + try std.testing.expectEqualStrings(mid_bonds, collapseSector("Loan / Corporate", .mid)); + try std.testing.expectEqualStrings(mid_bonds, collapseSector("Bonds", .mid)); +} + +test "collapseSector .mid: all STIV / Repurchase collapse to Cash & Equivalents" { + try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Short-Term Investment Vehicle / Corporate", .mid)); + try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Short-Term Investment Vehicle / Registered Fund", .mid)); + try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Short-Term Investment Vehicle / Private Fund", .mid)); + try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Repurchase Agreement / Other", .mid)); + try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Cash", .mid)); + try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Cash & CDs", .mid)); +} + +test "collapseSector .mid: Equity Preferred is its own bucket" { + // Hybrid security — distinct from generic equity at mid. + try std.testing.expectEqualStrings(mid_equity_preferred, collapseSector("Equity Preferred / Corporate", .mid)); +} + +test "collapseSector .mid: Equity / * (non-Preferred) collapses to Equity / Corporate" { + try std.testing.expectEqualStrings(mid_equity_corporate, collapseSector("Equity / Corporate", .mid)); + try std.testing.expectEqualStrings(mid_equity_corporate, collapseSector("Equity / Other", .mid)); + try std.testing.expectEqualStrings(mid_equity_corporate, collapseSector("Equity / Registered Fund", .mid)); +} + +test "collapseSector .mid: GICS sectors stay distinct" { + // The whole point of mid: collapse NPORT-P sub-flavors but + // keep GICS sector breakdown so users see stock concentrations. + try std.testing.expectEqualStrings("Technology", collapseSector("Technology", .mid)); + try std.testing.expectEqualStrings("Healthcare", collapseSector("Healthcare", .mid)); + try std.testing.expectEqualStrings("Financial Services", collapseSector("Financial Services", .mid)); + try std.testing.expectEqualStrings("Financials", collapseSector("Financials", .mid)); + try std.testing.expectEqualStrings("Diversified", collapseSector("Diversified", .mid)); + try std.testing.expectEqualStrings("Energy", collapseSector("Energy", .mid)); +} + +test "collapseSector .mid: Derivative variants collapse to Derivatives" { + try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative / Corporate", .mid)); + try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative / Other", .mid)); + try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative-FX / Other", .mid)); + try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative-FX / Corporate", .mid)); +} + +test "collapseSector .mid: real property and unrecognized -> Other" { + try std.testing.expectEqualStrings(mid_other, collapseSector("Direct Real Property / Other", .mid)); + try std.testing.expectEqualStrings(mid_other, collapseSector("Direct Credit Risk / Other", .mid)); + try std.testing.expectEqualStrings(mid_other, collapseSector("TODO", .mid)); + try std.testing.expectEqualStrings(mid_other, collapseSector("Unknown", .mid)); + try std.testing.expectEqualStrings(mid_other, collapseSector("", .mid)); + try std.testing.expectEqualStrings(mid_other, collapseSector("Some Future Label", .mid)); +} + +test "collapseSector .mid: returns same pointer for same bucket (static-string property)" { + // Stable HashMap keys without duping. + const a = collapseSector("Debt / Corporate", .mid); + const b = collapseSector("Debt / US Treasury", .mid); + try std.testing.expectEqual(@intFromPtr(a.ptr), @intFromPtr(b.ptr)); + try std.testing.expectEqual(@intFromPtr(mid_bonds.ptr), @intFromPtr(a.ptr)); +} + +// ── collapseBreakdownAtGranularity ──────────────────────────── + +test "collapseBreakdownAtGranularity: VBTLX-shape Debt sleeves collapse to Bonds at mid" { + // VBTLX has six different Debt / X rows. At mid granularity + // they should all sum into one Bonds row. + const allocator = std.testing.allocator; + const items = [_]BreakdownItem{ + .{ .label = "Debt / Corporate", .weight = 0.40, .value = 40_000.0 }, + .{ .label = "Debt / US Treasury", .weight = 0.30, .value = 30_000.0 }, + .{ .label = "Debt / Non-US Sovereign", .weight = 0.10, .value = 10_000.0 }, + .{ .label = "Debt / Municipal", .weight = 0.05, .value = 5_000.0 }, + .{ .label = "Debt / US Gov Agency", .weight = 0.04, .value = 4_000.0 }, + .{ .label = "Debt / US GSE", .weight = 0.01, .value = 1_000.0 }, + .{ .label = "Short-Term Investment Vehicle / Registered Fund", .weight = 0.10, .value = 10_000.0 }, + }; + const result = try collapseBreakdownAtGranularity(allocator, &items, .mid, 100_000.0); + defer allocator.free(result); + + // Two output buckets: Bonds (sum of all Debt/*) and + // Cash & Equivalents (the STIV row). + try std.testing.expectEqual(@as(usize, 2), result.len); + var bonds_value: f64 = 0; + var cash_value: f64 = 0; + for (result) |item| { + if (std.mem.eql(u8, item.label, mid_bonds)) bonds_value = item.value; + if (std.mem.eql(u8, item.label, mid_cash_equivalents)) cash_value = item.value; + } + try std.testing.expectApproxEqAbs(@as(f64, 90_000), bonds_value, 1.0); + try std.testing.expectApproxEqAbs(@as(f64, 10_000), cash_value, 1.0); +} + +test "collapseBreakdownAtGranularity: coarse collapses everything to 4 buckets" { + const allocator = std.testing.allocator; + const items = [_]BreakdownItem{ + .{ .label = "Debt / Corporate", .weight = 0.50, .value = 50_000.0 }, + .{ .label = "Equity / Corporate", .weight = 0.30, .value = 30_000.0 }, + .{ .label = "Technology", .weight = 0.10, .value = 10_000.0 }, + .{ .label = "Short-Term Investment Vehicle / Corporate", .weight = 0.05, .value = 5_000.0 }, + .{ .label = "Derivative / Other", .weight = 0.05, .value = 5_000.0 }, + }; + const result = try collapseBreakdownAtGranularity(allocator, &items, .coarse, 100_000.0); + defer allocator.free(result); + + // Equity (30k Equity/Corp + 10k Technology) = 40k + // Fixed Income = 50k Debt + // Cash = 5k STIV + // Other = 5k Derivative + var eq: f64 = 0; + var fi: f64 = 0; + var c: f64 = 0; + var o: f64 = 0; + for (result) |item| { + if (std.mem.eql(u8, item.label, bucket_equity)) eq = item.value; + if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi = item.value; + if (std.mem.eql(u8, item.label, bucket_cash)) c = item.value; + if (std.mem.eql(u8, item.label, bucket_other)) o = item.value; + } + try std.testing.expectApproxEqAbs(@as(f64, 40_000), eq, 1.0); + try std.testing.expectApproxEqAbs(@as(f64, 50_000), fi, 1.0); + try std.testing.expectApproxEqAbs(@as(f64, 5_000), c, 1.0); + try std.testing.expectApproxEqAbs(@as(f64, 5_000), o, 1.0); +} + +test "collapseBreakdownAtGranularity: fine returns equivalent breakdown unchanged" { + const allocator = std.testing.allocator; + const items = [_]BreakdownItem{ + .{ .label = "Debt / Corporate", .weight = 0.50, .value = 50_000.0 }, + .{ .label = "Equity / Corporate", .weight = 0.30, .value = 30_000.0 }, + .{ .label = "Technology", .weight = 0.20, .value = 20_000.0 }, + }; + const result = try collapseBreakdownAtGranularity(allocator, &items, .fine, 100_000.0); + defer allocator.free(result); + + // 3 input rows → 3 output rows (no collapsing at fine). + try std.testing.expectEqual(@as(usize, 3), result.len); + // Output sorted by value descending. + try std.testing.expectEqualStrings("Debt / Corporate", result[0].label); + try std.testing.expectApproxEqAbs(@as(f64, 50_000), result[0].value, 1.0); +} + +test "collapseBreakdownAtGranularity: empty input -> empty output" { + const allocator = std.testing.allocator; + const items = [_]BreakdownItem{}; + const result = try collapseBreakdownAtGranularity(allocator, &items, .mid, 100_000.0); + defer allocator.free(result); + try std.testing.expectEqual(@as(usize, 0), result.len); +} + +test "collapseBreakdownAtGranularity: total values preserved through collapse" { + // Sum of output values must equal sum of input values + // (modulo float rounding). + const allocator = std.testing.allocator; + const items = [_]BreakdownItem{ + .{ .label = "Debt / Corporate", .weight = 0.40, .value = 40.0 }, + .{ .label = "Loan / Corporate", .weight = 0.20, .value = 20.0 }, + .{ .label = "Asset-Backed / Corporate Mortgage", .weight = 0.15, .value = 15.0 }, + .{ .label = "Equity / Corporate", .weight = 0.20, .value = 20.0 }, + .{ .label = "Technology", .weight = 0.05, .value = 5.0 }, + }; + const result = try collapseBreakdownAtGranularity(allocator, &items, .mid, 100.0); + defer allocator.free(result); + + var total: f64 = 0; + for (result) |item| total += item.value; + try std.testing.expectApproxEqAbs(@as(f64, 100.0), total, 0.01); +} + /// Map an `asset_class` string to one of the four asset-category /// buckets. Used as a fallback when a classification entry has /// no `sector` but does have an `asset_class` (legacy diff --git a/src/commands/analysis.zig b/src/commands/analysis.zig index 93ab330..ac05d70 100644 --- a/src/commands/analysis.zig +++ b/src/commands/analysis.zig @@ -5,14 +5,16 @@ const framework = @import("framework.zig"); const fmt = cli.fmt; const Money = @import("../Money.zig"); -pub const ParsedArgs = struct {}; +pub const ParsedArgs = struct { + sector_detail: zfin.analysis.Granularity = .mid, +}; pub const meta: framework.Meta = .{ .name = "analysis", .group = .portfolio, .synopsis = "Show portfolio breakdowns by asset class, sector, geo, account, tax type", .help = - \\Usage: zfin analysis + \\Usage: zfin analysis [opts] \\ \\Show portfolio analysis: equities/fixed-income split, plus \\block-bar breakdowns by asset class, sector, geographic @@ -20,24 +22,47 @@ pub const meta: framework.Meta = .{ \\`metadata.srf` and account tax types from `accounts.srf` \\(both in the same directory as the portfolio file). \\ + \\Options: + \\ --sector-detail LEVEL Sector display granularity: + \\ coarse - 4 buckets (Equity / Fixed Income / Cash / Other) + \\ mid - ~12 buckets (default; collapses NPORT-P sub-flavors) + \\ fine - raw NPORT-P breakdown (every Debt / X variant separate) + \\ \\Run `zfin enrich > metadata.srf` to bootstrap \\classifications, then edit by hand. \\ , .uppercase_first_arg = false, - .user_errors = error{UnexpectedArg}, + .user_errors = error{ UnexpectedArg, InvalidSectorDetail }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { - if (cmd_args.len > 0) { - cli.stderrPrint(ctx.io, "Error: 'analysis' takes no arguments\n"); - return error.UnexpectedArg; + var parsed: ParsedArgs = .{}; + var i: usize = 0; + while (i < cmd_args.len) : (i += 1) { + if (std.mem.eql(u8, cmd_args[i], "--sector-detail") and i + 1 < cmd_args.len) { + i += 1; + const value = cmd_args[i]; + if (std.mem.eql(u8, value, "coarse")) { + parsed.sector_detail = .coarse; + } else if (std.mem.eql(u8, value, "mid")) { + parsed.sector_detail = .mid; + } else if (std.mem.eql(u8, value, "fine")) { + parsed.sector_detail = .fine; + } else { + cli.stderrPrint(ctx.io, "Error: --sector-detail must be one of: coarse, mid, fine\n"); + return error.InvalidSectorDetail; + } + } else { + cli.stderrPrint(ctx.io, "Error: 'analysis' takes no positional arguments\n"); + return error.UnexpectedArg; + } } - return .{}; + return parsed; } /// CLI `analysis` command: show portfolio analysis breakdowns. -pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void { +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; @@ -133,6 +158,20 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void { else anchor_path; + // Re-aggregate the Sector breakdown at the user's chosen + // granularity. `analyze` produces fine-grained NPORT-P + GICS + // labels; the user picks coarse / mid / fine via + // `--sector-detail`. Mid is the default — most useful for + // most users. + const collapsed_sector = try zfin.analysis.collapseBreakdownAtGranularity( + allocator, + result.sector, + parsed.sector_detail, + pf_data.summary.total_value, + ); + allocator.free(result.sector); + result.sector = collapsed_sector; + try display(result, split.stock_pct, split.bond_pct, split.cash_pct, pf_data.summary.total_value, display_label, color, out); } diff --git a/src/providers/Wikidata.zig b/src/providers/Wikidata.zig index 8ce9c1d..847400b 100644 --- a/src/providers/Wikidata.zig +++ b/src/providers/Wikidata.zig @@ -230,19 +230,11 @@ fn buildQuery(allocator: std.mem.Allocator, symbols: []const []const u8) ![]u8 { /// industry label to one of these buckets so the user gets a /// stable sector choice rather than whichever sub-industry /// SPARQL surfaced first. -pub const sector = struct { - pub const technology = "Technology"; - pub const communication_services = "Communication Services"; - pub const consumer_cyclical = "Consumer Cyclical"; - pub const consumer_defensive = "Consumer Defensive"; - pub const healthcare = "Healthcare"; - pub const financial_services = "Financial Services"; - pub const energy = "Energy"; - pub const industrials = "Industrials"; - pub const basic_materials = "Basic Materials"; - pub const real_estate = "Real Estate"; - pub const utilities = "Utilities"; -}; +/// +/// Lives in `models/classification.zig` so multiple producers +/// share one taxonomy. Re-exported here for back-compat with +/// existing internal references. +pub const sector = classification.sector; /// Map a Wikidata `wdt:P452` industry label (lowercase or mixed /// case) to one of the canonical sectors. Returns null if no diff --git a/src/tui/analysis_tab.zig b/src/tui/analysis_tab.zig index 3857bdb..c9af8e7 100644 --- a/src/tui/analysis_tab.zig +++ b/src/tui/analysis_tab.zig @@ -10,11 +10,14 @@ const StyledLine = tui.StyledLine; // ── Tab-local action enum ───────────────────────────────────── // -// Analysis has no tab-local keybinds today — it's a read-only -// breakdown view. Refresh is global. Empty enum is the explicit -// placeholder. +// 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 {}; +pub const Action = enum { cycle_sector_granularity }; // ── Tab-private state ───────────────────────────────────────── @@ -30,15 +33,25 @@ pub const State = struct { /// 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_labels = std.enums.EnumArray(Action, []const u8).initFill(""), - .status_hints = &.{}, + .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 { @@ -83,9 +96,19 @@ pub const tab = struct { pub const tick = framework.noopTick(State); pub fn handleAction(state: *State, app: *App, action: Action) void { - _ = state; _ = app; - switch (action) {} + 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 @@ -184,7 +207,7 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c cash_pct = split.cash_pct; } } - return renderAnalysisLines(arena, app.theme, state.result, stock_pct, bond_pct, cash_pct, total_value); + 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. @@ -196,6 +219,7 @@ pub fn renderAnalysisLines( bond_pct: f64, cash_pct: f64, total_value: f64, + sector_granularity: zfin.analysis.Granularity, ) ![]const StyledLine { var lines: std.ArrayList(StyledLine) = .empty; @@ -231,14 +255,43 @@ pub fn renderAnalysisLines( const bar_width: usize = 30; const label_width: usize = 24; - const sections = zfin.analysis.breakdownSections(&result); + // 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). - const title_text = try std.fmt.allocPrint(arena, " {s}", .{sec.title}); + // 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| { @@ -259,6 +312,16 @@ pub fn renderAnalysisLines( 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); @@ -347,7 +410,7 @@ test "renderAnalysisLines with data" { .unclassified = &.{}, .total_value = 200000, }; - const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.15, 0.05, 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 @@ -373,7 +436,7 @@ test "renderAnalysisLines no data" { const arena = arena_state.allocator(); const th = theme.default_theme; - const lines = try renderAnalysisLines(arena, th, null, 0, 0, 0, 0); + 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); } @@ -387,4 +450,109 @@ test "tab.init produces zero-defaulted state" { 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); }