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). // 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, "Bonds")) return bucket_fixed_income;
if (std.mem.eql(u8, sector, "Cash")) return bucket_cash; 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 // "Diversified" means "broad equity fund holding all
// sectors" — S&P 500 ETF, total-market index, etc. // sectors" — S&P 500 ETF, total-market index, etc.
if (std.mem.eql(u8, sector, "Diversified")) return bucket_equity; 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; for (gics) |g| if (std.mem.eql(u8, sector, g)) return bucket_equity;
// Everything else: derivatives, real property, sentinels // Strings containing `/` are NPORT-P shapes that didn't match
// (TODO/Unknown/empty), unrecognized future labels. // any prefix above (e.g. "Direct Real Property / Other",
return bucket_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 // Sector display granularity
/// Granularity tier for the Sector breakdown display. Lets the /// Granularity tier for the Sector breakdown display. Two
/// user toggle between coarse bucket-level summary and the raw /// tiers: `coarse` (4 macro buckets Equity / Fixed Income /
/// fine-grained NPORT-P decomposition. /// 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 { pub const Granularity = enum {
/// Four buckets: Equity / Fixed Income / Cash / Other. /// Four buckets: Equity / Fixed Income / Cash / Other.
/// Same labels as the Asset Category breakdown. /// Same labels as the Asset Category breakdown.
coarse, coarse,
/// ~12-16 buckets: collapses NPORT-P sub-flavors but keeps /// One row per distinct bucket label the raw shape of
/// GICS sectors distinct. Default for most users answers /// what `entry.bucket` produces. Default. This is what
/// "how exposed am I to bonds vs stocks vs cash, and which /// the user wants for "what are my actual positions?"
/// 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, 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 /// Display-friendly abbreviations for sector labels that don't fit
/// cleanly in narrow columns. Returns the input unchanged when no /// cleanly in narrow columns. Returns the input unchanged when no
/// abbreviation is registered for it; consumers that need a fixed /// 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 /// Map a sector string through the chosen granularity. Returns
/// a static literal (or, at fine granularity, the input slice /// a static literal (at coarse) or the input slice (at fine)
/// itself) suitable for use as a stable HashMap key. /// suitable for use as a stable HashMap key.
/// ///
/// Granularity tiers: /// Granularity tiers:
/// ///
/// - **coarse**: delegates to `bucketSector` Equity / Fixed Income /// - **coarse**: delegates to `bucketSector` Equity / Fixed Income
/// / Cash / Other (4 buckets). /// / 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. /// - **fine**: passthrough returns the input unchanged.
pub fn collapseSector(sector: []const u8, granularity: Granularity) []const u8 { pub fn collapseSector(sector: []const u8, granularity: Granularity) []const u8 {
return switch (granularity) { return switch (granularity) {
.fine => sector, .fine => sector,
.coarse => bucketSector(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. /// Compute portfolio analysis from allocations and classification metadata.
/// `allocations` are the stock/ETF positions with market values. /// `allocations` are the stock/ETF positions with market values.
/// `classifications` is the metadata file data. /// `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("TODO"));
try std.testing.expectEqualStrings(bucket_other, bucketSector("Unknown")); try std.testing.expectEqualStrings(bucket_other, bucketSector("Unknown"));
try std.testing.expectEqualStrings(bucket_other, bucketSector("")); try std.testing.expectEqualStrings(bucket_other, bucketSector(""));
try std.testing.expectEqualStrings(bucket_other, bucketSector("Fintech")); try std.testing.expectEqualStrings(bucket_other, bucketSector("Unclassified"));
try std.testing.expectEqualStrings(bucket_other, bucketSector("Some Future Label")); }
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)" { 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)); 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 // 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" { test "collapseBreakdownAtGranularity: coarse collapses everything to 4 buckets" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const items = [_]BreakdownItem{ const items = [_]BreakdownItem{
@ -1595,7 +1516,7 @@ test "collapseBreakdownAtGranularity: fine returns equivalent breakdown unchange
test "collapseBreakdownAtGranularity: empty input -> empty output" { test "collapseBreakdownAtGranularity: empty input -> empty output" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const items = [_]BreakdownItem{}; 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); defer allocator.free(result);
try std.testing.expectEqual(@as(usize, 0), result.len); 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 = "Equity / Corporate", .weight = 0.20, .value = 20.0 },
.{ .label = "Technology", .weight = 0.05, .value = 5.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); defer allocator.free(result);
var total: f64 = 0; var total: f64 = 0;

View file

@ -6,7 +6,7 @@ const fmt = cli.fmt;
const Money = @import("../Money.zig"); const Money = @import("../Money.zig");
pub const ParsedArgs = struct { pub const ParsedArgs = struct {
sector_detail: zfin.analysis.Granularity = .mid, sector_detail: zfin.analysis.Granularity = .fine,
}; };
pub const meta: framework.Meta = .{ pub const meta: framework.Meta = .{
@ -25,8 +25,7 @@ pub const meta: framework.Meta = .{
\\Options: \\Options:
\\ --sector-detail LEVEL Sector display granularity: \\ --sector-detail LEVEL Sector display granularity:
\\ coarse - 4 buckets (Equity / Fixed Income / Cash / Other) \\ coarse - 4 buckets (Equity / Fixed Income / Cash / Other)
\\ mid - ~12 buckets (default; collapses NPORT-P sub-flavors) \\ fine - one row per bucket label (default)
\\ fine - raw NPORT-P breakdown (every Debt / X variant separate)
\\ \\
\\Run `zfin enrich <portfolio.srf> > metadata.srf` to bootstrap \\Run `zfin enrich <portfolio.srf> > metadata.srf` to bootstrap
\\classifications, then edit by hand. \\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]; const value = cmd_args[i];
if (std.mem.eql(u8, value, "coarse")) { if (std.mem.eql(u8, value, "coarse")) {
parsed.sector_detail = .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")) { } else if (std.mem.eql(u8, value, "fine")) {
parsed.sector_detail = .fine; parsed.sector_detail = .fine;
} else { } 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; return error.InvalidSectorDetail;
} }
} else { } else {

View file

@ -10,12 +10,11 @@ const StyledLine = tui.StyledLine;
// Tab-local action enum // Tab-local action enum
// //
// Cycle the Sector breakdown's display granularity through // Toggle the Sector breakdown's display granularity between
// coarse mid fine coarse. Default tier is mid (~12-16 // coarse (4 macro buckets Equity / Fixed Income / Cash /
// buckets, NPORT-P sub-flavors collapsed but GICS sectors // Other) and fine (one row per bucket label, the default).
// distinct). Coarse delegates to the same 4-bucket shape as // Coarse delegates to the same 4-bucket shape as the Asset
// the Asset Category section. Fine is the raw NPORT-P // Category section.
// breakdown (every Debt / X variant separate).
pub const Action = enum { cycle_sector_granularity }; pub const Action = enum { cycle_sector_granularity };
@ -28,10 +27,10 @@ pub const State = struct {
/// Computed analysis output. Owned by State; freed in /// Computed analysis output. Owned by State; freed in
/// `deinit` and `reload`. /// `deinit` and `reload`.
result: ?zfin.analysis.AnalysisResult = null, result: ?zfin.analysis.AnalysisResult = null,
/// Sector display granularity. Cycled via `cycle_sector_granularity` /// Sector display granularity. Toggled via `cycle_sector_granularity`
/// action. Default `mid` matches the CLI default /// action. Default `fine` matches the CLI default
/// (`zfin analysis` without `--sector-detail`). /// (`zfin analysis` without `--sector-detail`).
sector_granularity: zfin.analysis.Granularity = .mid, sector_granularity: zfin.analysis.Granularity = .fine,
}; };
// Tab framework contract // Tab framework contract
@ -42,7 +41,7 @@ pub const meta: framework.TabMeta(Action) = .{
.{ .action = .cycle_sector_granularity, .key = .{ .codepoint = 'm' } }, .{ .action = .cycle_sector_granularity, .key = .{ .codepoint = 'm' } },
}, },
.action_labels = std.enums.EnumArray(Action, []const u8).init(.{ .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 = &.{ .status_hints = &.{
.cycle_sector_granularity, .cycle_sector_granularity,
@ -91,12 +90,11 @@ pub const tab = struct {
_ = app; _ = app;
switch (action) { switch (action) {
.cycle_sector_granularity => { .cycle_sector_granularity => {
// coarse mid fine coarse. The display layer // Binary toggle: coarse fine. The display layer
// re-aggregates `result.sector` through the new // re-aggregates `result.sector` through the new
// granularity on the next render. // granularity on the next render.
state.sector_granularity = switch (state.sector_granularity) { state.sector_granularity = switch (state.sector_granularity) {
.coarse => .mid, .coarse => .fine,
.mid => .fine,
.fine => .coarse, .fine => .coarse,
}; };
}, },
@ -368,7 +366,6 @@ pub fn renderUmbrellaSection(
fn granularityLabel(g: zfin.analysis.Granularity) []const u8 { fn granularityLabel(g: zfin.analysis.Granularity) []const u8 {
return switch (g) { return switch (g) {
.coarse => "coarse", .coarse => "coarse",
.mid => "mid",
.fine => "fine", .fine => "fine",
}; };
} }
@ -456,7 +453,8 @@ test "renderAnalysisLines with data" {
// Use sector breakdown (the main fine-grained slice) as the // Use sector breakdown (the main fine-grained slice) as the
// populated section for this test. asset_class is gone its // populated section for this test. asset_class is gone its
// role is subsumed by the bucket-driven `sector` field. // 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{ var sector = [_]zfin.analysis.BreakdownItem{
.{ .label = "Technology", .weight = 0.60, .value = 120000 }, .{ .label = "Technology", .weight = 0.60, .value = 120000 },
.{ .label = "Healthcare", .weight = 0.40, .value = 80000 }, .{ .label = "Healthcare", .weight = 0.40, .value = 80000 },
@ -470,7 +468,7 @@ test "renderAnalysisLines with data" {
.unclassified = &.{}, .unclassified = &.{},
.total_value = 200000, .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 // Should have header section + sector items
try testing.expect(lines.len >= 5); try testing.expect(lines.len >= 5);
// Find "Portfolio Analysis" header // Find "Portfolio Analysis" header
@ -496,7 +494,7 @@ test "renderAnalysisLines no data" {
const arena = arena_state.allocator(); const arena = arena_state.allocator();
const th = theme.default_theme; 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.expectEqual(@as(usize, 5), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null); 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.expectEqual(false, state.loaded);
try testing.expect(state.result == null); try testing.expect(state.result == null);
// classification_map lives on PortfolioData now (not on tab state). // classification_map lives on PortfolioData now (not on tab state).
// Default sector granularity is mid (matches CLI default). // Default sector granularity is fine (matches CLI default).
try testing.expectEqual(zfin.analysis.Granularity.mid, state.sector_granularity); try testing.expectEqual(zfin.analysis.Granularity.fine, state.sector_granularity);
} }
test "onPortfolioReload clears state without eager rebuild" { test "onPortfolioReload clears state without eager rebuild" {
@ -537,20 +535,18 @@ test "onPortfolioReload clears state without eager rebuild" {
try testing.expect(state.result == null); 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 state: State = .{};
var dummy_app: tui.App = undefined; // handleAction doesn't touch app var dummy_app: tui.App = undefined; // handleAction doesn't touch app
// mid fine // Default is 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); try testing.expectEqual(zfin.analysis.Granularity.fine, state.sector_granularity);
// fine coarse // fine coarse
tab.handleAction(&state, &dummy_app, .cycle_sector_granularity); tab.handleAction(&state, &dummy_app, .cycle_sector_granularity);
try testing.expectEqual(zfin.analysis.Granularity.coarse, state.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); 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" { 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, .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 // fine label
{ {
const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.20, 0.0, 100_000, .fine, null); 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" { test "renderAnalysisLines: umbrella section appears at the bottom when account_map provided" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator); var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit(); defer arena_state.deinit();
@ -665,7 +616,7 @@ test "renderAnalysisLines: umbrella section appears at the bottom when account_m
); );
defer am.deinit(); 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_header = false;
var found_total_liquid = false; var found_total_liquid = false;
@ -705,7 +656,7 @@ test "renderAnalysisLines: umbrella section absent when account_map is null" {
.total_value = 100_000, .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| { for (lines) |l| {
try testing.expect(std.mem.indexOf(u8, l.text, "Umbrella exposure") == null); 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(); 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. // IRA shielded ($1M); DCP exposed ($1.5M). Total $2.5M.
var found_shielded_1m = false; var found_shielded_1m = false;

View file

@ -77,8 +77,9 @@ pub const SortDirection = enum {
pub const ReviewRow = struct { pub const ReviewRow = struct {
/// Display ticker same convention as `Allocation.display_symbol`. /// Display ticker same convention as `Allocation.display_symbol`.
symbol: []const u8, symbol: []const u8,
/// Mid-granularity sector label (`analytics/analysis.midBucket` /// Sector bucket label (`entry.bucket` from the user's
/// applied to the user's `metadata.srf` classification). Falls back /// `metadata.srf` classification, populated by
/// `parseClassificationFile` via `deriveBucket`). Falls back
/// to "Unclassified" when the symbol has no classification entry. /// to "Unclassified" when the symbol has no classification entry.
bucket: []const u8, bucket: []const u8,
/// Fraction of the holding's market value in taxable accounts /// Fraction of the holding's market value in taxable accounts