diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index 8a0be13..75767e3 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -434,6 +434,9 @@ pub fn bucketSector(sector: []const u8) []const u8 { // Plain-English asset-class words (hand-written metadata). if (std.mem.eql(u8, sector, "Bonds")) return bucket_fixed_income; if (std.mem.eql(u8, sector, "Cash")) return bucket_cash; + if (std.mem.eql(u8, sector, "Cash & CDs")) return bucket_cash; + if (std.mem.eql(u8, sector, "Options")) return bucket_other; + if (std.mem.eql(u8, sector, "Unclassified")) return bucket_other; // "Diversified" means "broad equity fund holding all // sectors" — S&P 500 ETF, total-market index, etc. if (std.mem.eql(u8, sector, "Diversified")) return bucket_equity; @@ -458,42 +461,69 @@ pub fn bucketSector(sector: []const u8) []const u8 { }; for (gics) |g| if (std.mem.eql(u8, sector, g)) return bucket_equity; - // Everything else: derivatives, real property, sentinels - // (TODO/Unknown/empty), unrecognized future labels. - return bucket_other; + // Strings containing `/` are NPORT-P shapes that didn't match + // any prefix above (e.g. "Direct Real Property / Other", + // "Direct Credit Risk / Other", "Other / Corporate"). Bucket + // these as Other — they're real-property, credit derivatives, + // and miscellaneous categories that don't fit the equity / + // fixed-income / cash trichotomy. + if (std.mem.indexOfScalar(u8, sector, '/') != null) return bucket_other; + + // Empty string / explicit sentinels → Other. Explicit + // because the curated-bucket fallback below would otherwise + // assume any non-empty unknown string is equity. + if (sector.len == 0) return bucket_other; + if (std.mem.eql(u8, sector, "TODO")) return bucket_other; + if (std.mem.eql(u8, sector, "Unknown")) return bucket_other; + + // Word-content checks for composite bucket strings produced by + // `deriveBucket` (or hand-curated `bucket::` overrides): + // "US Bonds", future "International Bonds" / "EM Bonds" → Fixed Income + // "US Cash", "Cash & CDs" (handled above) → Cash + if (std.mem.endsWith(u8, sector, " Bonds") or std.mem.endsWith(u8, sector, " bonds")) { + return bucket_fixed_income; + } + if (std.mem.endsWith(u8, sector, " Cash") or std.mem.endsWith(u8, sector, " cash")) { + return bucket_cash; + } + + // Default for any remaining no-`/` non-cruft string: equity. + // Catches curated buckets like "US Large Cap", "US Mid Cap", + // "US Small Cap", "US Dividend Equity", "US Healthcare ETF", + // "International Developed", "Emerging Markets", and any + // future user-defined bucket. The convention is: composite + // buckets describe an equity sleeve unless they explicitly + // say otherwise (Bonds/Cash/Options/Unclassified handled + // above). + return bucket_equity; } // ── 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. +/// Granularity tier for the Sector breakdown display. Two +/// tiers: `coarse` (4 macro buckets — Equity / Fixed Income / +/// Cash / Other) and `fine` (the raw bucket strings the +/// classification layer produced — every "US Large Cap" / "US +/// Bonds" / GICS-sector / etc. row distinct). +/// +/// History: this used to be a three-tier enum (coarse / mid / +/// fine). The middle tier collapsed NPORT-P sub-flavors (all +/// Debt / * → "Bonds", all Asset-Backed / * → "Bonds", etc.) +/// while keeping GICS sectors distinct. After the bucket +/// commit, classification rows expose a single curated bucket +/// label per entry — so the NPORT-P-flavor collapse the mid +/// tier did is now done at parse time. Mid and fine ended up +/// nearly identical and mid was dropped. 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?"). + /// One row per distinct bucket label — the raw shape of + /// what `entry.bucket` produces. Default. This is what + /// the user wants for "what are my actual positions?" 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"; - /// Display-friendly abbreviations for sector labels that don't fit /// cleanly in narrow columns. Returns the input unchanged when no /// abbreviation is registered for it; consumers that need a fixed @@ -509,75 +539,21 @@ pub fn abbreviateSector(s: []const u8) []const u8 { } /// 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. +/// a static literal (at coarse) or the input slice (at fine) +/// 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. @@ -1365,12 +1341,63 @@ test "bucketSector: GICS sector names → Equity" { } } -test "bucketSector: sentinels and unrecognized → Other" { +test "bucketSector: sentinels stay Other" { try std.testing.expectEqualStrings(bucket_other, bucketSector("TODO")); try std.testing.expectEqualStrings(bucket_other, bucketSector("Unknown")); try std.testing.expectEqualStrings(bucket_other, bucketSector("")); - try std.testing.expectEqualStrings(bucket_other, bucketSector("Fintech")); - try std.testing.expectEqualStrings(bucket_other, bucketSector("Some Future Label")); + try std.testing.expectEqualStrings(bucket_other, bucketSector("Unclassified")); +} + +test "bucketSector: curated-bucket-shaped unknown strings default to Equity" { + // After the bucket commit, `bucketSector` is called with + // either NPORT-P-shaped strings, GICS sector names, or + // composite/curated bucket labels (from `deriveBucket` or + // user-curated `bucket::` overrides). For composite-shaped + // strings that don't match any explicit Bonds/Cash/Options + // pattern, the default is Equity — composite buckets + // describe equity sleeves unless they say otherwise. This + // is the right default because: + // 1. The user's primary use of the Asset Category + // breakdown is "what fraction is exposed to equity + // drawdowns?" — a curated bucket like "US Large Cap" + // definitely IS equity. + // 2. The cost of the wrong default is asymmetric: a real + // bond bucket mis-bucketed as Equity will show in the + // 4-bucket coarse breakdown as overweight equity (very + // visible bug). A real equity bucket mis-bucketed as + // Other will silently disappear from the + // stocks/bonds/cash header (very subtle bug). + try std.testing.expectEqualStrings(bucket_equity, bucketSector("Fintech")); + try std.testing.expectEqualStrings(bucket_equity, bucketSector("Some Future Label")); + try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Large Cap")); + try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Mid Cap")); + try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Small Cap")); + try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Dividend Equity")); + try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Healthcare ETF")); + try std.testing.expectEqualStrings(bucket_equity, bucketSector("International Developed")); + try std.testing.expectEqualStrings(bucket_equity, bucketSector("Emerging Markets")); +} + +test "bucketSector: composite Bonds buckets → Fixed Income" { + try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("US Bonds")); + try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("International Bonds")); + try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("EM Bonds")); +} + +test "bucketSector: composite Cash buckets → Cash" { + try std.testing.expectEqualStrings(bucket_cash, bucketSector("Cash & CDs")); +} + +test "bucketSector: Options keyword → Other" { + try std.testing.expectEqualStrings(bucket_other, bucketSector("Options")); +} + +test "bucketSector: NPORT-P fallthrough (slash without recognized prefix) → Other" { + // Strings containing `/` that didn't match any specific + // NPORT-P prefix branch are real-property / credit-risk / + // miscellaneous categories. Bucket as Other. + try std.testing.expectEqualStrings(bucket_other, bucketSector("Other / Corporate")); + try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Real Property / Other")); } test "bucketSector: returns same pointer for repeated calls (static-string property)" { @@ -1435,114 +1462,8 @@ test "collapseSector .coarse: delegates to bucketSector" { 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{ @@ -1595,7 +1516,7 @@ test "collapseBreakdownAtGranularity: fine returns equivalent breakdown unchange test "collapseBreakdownAtGranularity: empty input -> empty output" { const allocator = std.testing.allocator; const items = [_]BreakdownItem{}; - const result = try collapseBreakdownAtGranularity(allocator, &items, .mid, 100_000.0); + const result = try collapseBreakdownAtGranularity(allocator, &items, .fine, 100_000.0); defer allocator.free(result); try std.testing.expectEqual(@as(usize, 0), result.len); } @@ -1611,7 +1532,7 @@ test "collapseBreakdownAtGranularity: total values preserved through collapse" { .{ .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); + const result = try collapseBreakdownAtGranularity(allocator, &items, .fine, 100.0); defer allocator.free(result); var total: f64 = 0; diff --git a/src/commands/analysis.zig b/src/commands/analysis.zig index f47e030..676fa37 100644 --- a/src/commands/analysis.zig +++ b/src/commands/analysis.zig @@ -6,7 +6,7 @@ const fmt = cli.fmt; const Money = @import("../Money.zig"); pub const ParsedArgs = struct { - sector_detail: zfin.analysis.Granularity = .mid, + sector_detail: zfin.analysis.Granularity = .fine, }; pub const meta: framework.Meta = .{ @@ -25,8 +25,7 @@ pub const meta: framework.Meta = .{ \\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) + \\ fine - one row per bucket label (default) \\ \\Run `zfin enrich > metadata.srf` to bootstrap \\classifications, then edit by hand. @@ -45,12 +44,10 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr 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"); + cli.stderrPrint(ctx.io, "Error: --sector-detail must be one of: coarse, fine\n"); return error.InvalidSectorDetail; } } else { diff --git a/src/tui/analysis_tab.zig b/src/tui/analysis_tab.zig index dbdfcff..d7ef3e1 100644 --- a/src/tui/analysis_tab.zig +++ b/src/tui/analysis_tab.zig @@ -10,12 +10,11 @@ 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). +// Toggle the Sector breakdown's display granularity between +// coarse (4 macro buckets — Equity / Fixed Income / Cash / +// Other) and fine (one row per bucket label, the default). +// Coarse delegates to the same 4-bucket shape as the Asset +// Category section. pub const Action = enum { cycle_sector_granularity }; @@ -28,10 +27,10 @@ pub const State = struct { /// Computed analysis output. Owned by State; freed in /// `deinit` and `reload`. result: ?zfin.analysis.AnalysisResult = null, - /// Sector display granularity. Cycled via `cycle_sector_granularity` - /// action. Default `mid` matches the CLI default + /// Sector display granularity. Toggled via `cycle_sector_granularity` + /// action. Default `fine` matches the CLI default /// (`zfin analysis` without `--sector-detail`). - sector_granularity: zfin.analysis.Granularity = .mid, + sector_granularity: zfin.analysis.Granularity = .fine, }; // ── Tab framework contract ──────────────────────────────────── @@ -42,7 +41,7 @@ pub const meta: framework.TabMeta(Action) = .{ .{ .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)", + .cycle_sector_granularity = "Toggle sector granularity (coarse / fine)", }), .status_hints = &.{ .cycle_sector_granularity, @@ -91,12 +90,11 @@ pub const tab = struct { _ = app; switch (action) { .cycle_sector_granularity => { - // coarse → mid → fine → coarse. The display layer + // Binary toggle: coarse ↔ fine. 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, + .coarse => .fine, .fine => .coarse, }; }, @@ -368,7 +366,6 @@ pub fn renderUmbrellaSection( fn granularityLabel(g: zfin.analysis.Granularity) []const u8 { return switch (g) { .coarse => "coarse", - .mid => "mid", .fine => "fine", }; } @@ -456,7 +453,8 @@ test "renderAnalysisLines with data" { // Use sector breakdown (the main fine-grained slice) as the // populated section for this test. asset_class is gone — its // role is subsumed by the bucket-driven `sector` field. - // Use GICS-style labels that survive `midBucket` collapse. + // GICS-style labels are stable through `bucketSector` and + // are the natural shape for direct GICS-tagged equities. var sector = [_]zfin.analysis.BreakdownItem{ .{ .label = "Technology", .weight = 0.60, .value = 120000 }, .{ .label = "Healthcare", .weight = 0.40, .value = 80000 }, @@ -470,7 +468,7 @@ test "renderAnalysisLines with data" { .unclassified = &.{}, .total_value = 200000, }; - const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.15, 0.05, 200000, .mid, null); + const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.15, 0.05, 200000, .fine, null); // Should have header section + sector items try testing.expect(lines.len >= 5); // Find "Portfolio Analysis" header @@ -496,7 +494,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, .mid, null); + const lines = try renderAnalysisLines(arena, th, null, 0, 0, 0, 0, .fine, null); try testing.expectEqual(@as(usize, 5), lines.len); try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null); } @@ -510,8 +508,8 @@ test "tab.init produces zero-defaulted state" { try testing.expectEqual(false, state.loaded); try testing.expect(state.result == null); // classification_map lives on PortfolioData now (not on tab state). - // Default sector granularity is mid (matches CLI default). - try testing.expectEqual(zfin.analysis.Granularity.mid, state.sector_granularity); + // Default sector granularity is fine (matches CLI default). + try testing.expectEqual(zfin.analysis.Granularity.fine, state.sector_granularity); } test "onPortfolioReload clears state without eager rebuild" { @@ -537,20 +535,18 @@ test "onPortfolioReload clears state without eager rebuild" { try testing.expect(state.result == null); } -test "handleAction cycles sector granularity coarse → mid → fine → coarse" { +test "handleAction toggles sector granularity 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); + // Default is fine. 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) + // coarse → fine (full cycle) tab.handleAction(&state, &dummy_app, .cycle_sector_granularity); - try testing.expectEqual(zfin.analysis.Granularity.mid, state.sector_granularity); + try testing.expectEqual(zfin.analysis.Granularity.fine, state.sector_granularity); } test "renderAnalysisLines: granularity label appears in Sector section title" { @@ -573,15 +569,6 @@ test "renderAnalysisLines: granularity label appears in Sector section title" { .total_value = 100_000, }; - // mid label - { - const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.20, 0.0, 100_000, .mid, null); - 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, null); @@ -602,42 +589,6 @@ test "renderAnalysisLines: granularity label appears in Sector section title" { } } -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 = &.{}, - .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, null); - - // 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); -} - test "renderAnalysisLines: umbrella section appears at the bottom when account_map provided" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); @@ -665,7 +616,7 @@ test "renderAnalysisLines: umbrella section appears at the bottom when account_m ); defer am.deinit(); - const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.0, 0.0, 100_000, .mid, am); + const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.0, 0.0, 100_000, .fine, am); var found_header = false; var found_total_liquid = false; @@ -705,7 +656,7 @@ test "renderAnalysisLines: umbrella section absent when account_map is null" { .total_value = 100_000, }; - const lines = try renderAnalysisLines(arena, th, result, 1.0, 0, 0, 100_000, .mid, null); + const lines = try renderAnalysisLines(arena, th, result, 1.0, 0, 0, 100_000, .fine, null); for (lines) |l| { try testing.expect(std.mem.indexOf(u8, l.text, "Umbrella exposure") == null); @@ -742,7 +693,7 @@ test "renderAnalysisLines: umbrella respects shielded:bool:false override (DCP c ); defer am.deinit(); - const lines = try renderAnalysisLines(arena, th, result, 1.0, 0, 0, 2_500_000, .mid, am); + const lines = try renderAnalysisLines(arena, th, result, 1.0, 0, 0, 2_500_000, .fine, am); // IRA shielded ($1M); DCP exposed ($1.5M). Total $2.5M. var found_shielded_1m = false; diff --git a/src/views/review.zig b/src/views/review.zig index bc71c90..2fe8b73 100644 --- a/src/views/review.zig +++ b/src/views/review.zig @@ -77,8 +77,9 @@ pub const SortDirection = enum { pub const ReviewRow = struct { /// Display ticker — same convention as `Allocation.display_symbol`. symbol: []const u8, - /// Mid-granularity sector label (`analytics/analysis.midBucket` - /// applied to the user's `metadata.srf` classification). Falls back + /// Sector bucket label (`entry.bucket` from the user's + /// `metadata.srf` classification, populated by + /// `parseClassificationFile` via `deriveBucket`). Falls back /// to "Unclassified" when the symbol has no classification entry. bucket: []const u8, /// Fraction of the holding's market value in taxable accounts