update sector bucket logic, remove "mid" tier on analysis
This commit is contained in:
parent
88df0fe9ad
commit
7fffca04c3
4 changed files with 143 additions and 273 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue