update sector bucket logic, remove "mid" tier on analysis

This commit is contained in:
Emil Lerch 2026-06-10 16:20:03 -07:00
parent 88df0fe9ad
commit 7fffca04c3
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 143 additions and 273 deletions

View file

@ -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;

View file

@ -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 <portfolio.srf> > 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 {

View file

@ -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 = &sector,
.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;

View file

@ -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