add analysis bucketing

This commit is contained in:
Emil Lerch 2026-05-30 11:29:46 -07:00
parent 85c9a48969
commit 2306e1a9c9
4 changed files with 567 additions and 36 deletions

View file

@ -367,6 +367,107 @@ pub fn bucketSector(sector: []const u8) []const u8 {
return bucket_other;
}
// Sector display granularity
/// Granularity tier for the Sector breakdown display. Lets the
/// user toggle between coarse bucket-level summary and the raw
/// fine-grained NPORT-P decomposition.
pub const Granularity = enum {
/// Four buckets: Equity / Fixed Income / Cash / Other.
/// Same labels as the Asset Category breakdown.
coarse,
/// ~12-16 buckets: collapses NPORT-P sub-flavors but keeps
/// GICS sectors distinct. Default for most users answers
/// "how exposed am I to bonds vs stocks vs cash, and which
/// sectors within stocks?".
mid,
/// Raw NPORT-P strings: every Debt / X variant, every
/// Asset-Backed / Y variant, every Derivative / Z variant
/// is its own row. Useful for spotting fine-grained
/// concentrations within the bond sleeve (e.g. "am I overweight
/// US Treasury vs Municipal?").
fine,
};
// Mid-granularity bucket labels. Static literals so they can be
// used as stable HashMap keys without duping.
pub const mid_bonds: []const u8 = "Bonds";
pub const mid_equity_corporate: []const u8 = "Equity / Corporate";
pub const mid_equity_preferred: []const u8 = "Equity Preferred";
pub const mid_cash_equivalents: []const u8 = "Cash & Equivalents";
pub const mid_derivatives: []const u8 = "Derivatives";
pub const mid_other: []const u8 = "Other";
/// Map a sector string through the chosen granularity. Returns
/// a static literal (or, at fine granularity, the input slice
/// itself) suitable for use as a stable HashMap key.
///
/// Granularity tiers:
///
/// - **coarse**: delegates to `bucketSector` Equity / Fixed Income
/// / Cash / Other (4 buckets).
/// - **mid**: collapses NPORT-P sub-flavors (all Debt / * Bonds,
/// all Asset-Backed / * Bonds, all STIV / * Cash & Equivalents,
/// all Derivative / * Derivatives) while keeping GICS sectors
/// distinct (Technology, Healthcare, etc.).
/// - **fine**: passthrough returns the input unchanged.
pub fn collapseSector(sector: []const u8, granularity: Granularity) []const u8 {
return switch (granularity) {
.fine => sector,
.coarse => bucketSector(sector),
.mid => midBucket(sector),
};
}
/// Mid-granularity bucket lookup. Pure data, no allocation.
fn midBucket(sector: []const u8) []const u8 {
// Bond-shaped (NPORT-P): all Debt / *, Loan / *, Asset-Backed / *.
if (std.mem.startsWith(u8, sector, "Debt")) return mid_bonds;
if (std.mem.startsWith(u8, sector, "Loan")) return mid_bonds;
if (std.mem.startsWith(u8, sector, "Asset-Backed")) return mid_bonds;
// Plain-English bond label from legacy hand-written metadata.
if (std.mem.eql(u8, sector, "Bonds")) return mid_bonds;
// Cash-shaped (NPORT-P): STIV variants + repurchase agreement.
if (std.mem.startsWith(u8, sector, "Short-Term Investment Vehicle")) return mid_cash_equivalents;
if (std.mem.startsWith(u8, sector, "Repurchase Agreement")) return mid_cash_equivalents;
if (std.mem.eql(u8, sector, "Cash")) return mid_cash_equivalents;
if (std.mem.eql(u8, sector, "Cash & CDs")) return mid_cash_equivalents;
// Equity Preferred is distinct at mid because it's a hybrid.
if (std.mem.startsWith(u8, sector, "Equity Preferred")) return mid_equity_preferred;
// Generic Equity / * collapses to a single Equity / Corporate
// bucket. Specifically "Equity / Corporate" / "Equity / Other"
// / "Equity / Registered Fund" all merge here.
if (std.mem.startsWith(u8, sector, "Equity")) return mid_equity_corporate;
// Derivatives.
if (std.mem.startsWith(u8, sector, "Derivative")) return mid_derivatives;
// GICS sector names pass through unchanged. Same exact-match
// list as `bucketSector`.
const gics = [_][]const u8{
"Technology",
"Healthcare",
"Financial Services",
"Financials",
"Consumer Cyclical",
"Consumer Defensive",
"Energy",
"Utilities",
"Real Estate",
"Industrials",
"Basic Materials",
"Communication Services",
"Diversified",
};
for (gics) |g| if (std.mem.eql(u8, sector, g)) return sector;
// Direct Real Property, Direct Credit Risk, sentinels Other.
return mid_other;
}
/// Compute portfolio analysis from allocations and classification metadata.
/// `allocations` are the stock/ETF positions with market values.
/// `classifications` is the metadata file data.
@ -564,6 +665,36 @@ fn mapToSortedBreakdown(
return items.toOwnedSlice(allocator);
}
/// Re-aggregate a raw `BreakdownItem` slice through `collapseSector`
/// at the chosen granularity. Multiple input rows that map to the
/// same coarser bucket sum into one output row. Returns a new
/// allocator-owned slice; caller frees with `allocator.free`.
///
/// Use case: `analyze` produces `result.sector` at fine
/// granularity (raw NPORT-P + GICS labels). Display callers
/// (CLI `--sector-detail`, TUI hot-key) call this to re-bucket
/// at their chosen tier. At `.fine`, the function still
/// allocates a fresh slice (so callers always free the result
/// the same way) but the per-row labels and weights are
/// identical to the input.
pub fn collapseBreakdownAtGranularity(
allocator: std.mem.Allocator,
items: []const BreakdownItem,
granularity: Granularity,
total: f64,
) ![]BreakdownItem {
var map = std.StringHashMap(f64).init(allocator);
defer map.deinit();
for (items) |item| {
const label = collapseSector(item.label, granularity);
const prev = map.get(label) orelse 0;
try map.put(label, prev + item.value);
}
return mapToSortedBreakdown(allocator, map, total);
}
test "parseAccountsFile" {
const data =
\\#!srfv1
@ -934,6 +1065,207 @@ test "bucketSector: legacy 'Financials' (with s) → Equity" {
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Financial Services"));
}
// collapseSector / Granularity
test "collapseSector .fine: passthrough — input slice returned unchanged" {
try std.testing.expectEqualStrings("Debt / US Treasury", collapseSector("Debt / US Treasury", .fine));
try std.testing.expectEqualStrings("Equity / Corporate", collapseSector("Equity / Corporate", .fine));
try std.testing.expectEqualStrings("Technology", collapseSector("Technology", .fine));
try std.testing.expectEqualStrings("Bonds", collapseSector("Bonds", .fine));
}
test "collapseSector .coarse: delegates to bucketSector" {
try std.testing.expectEqualStrings(bucket_fixed_income, collapseSector("Debt / US Treasury", .coarse));
try std.testing.expectEqualStrings(bucket_equity, collapseSector("Equity / Corporate", .coarse));
try std.testing.expectEqualStrings(bucket_equity, collapseSector("Technology", .coarse));
try std.testing.expectEqualStrings(bucket_cash, collapseSector("Short-Term Investment Vehicle / Registered Fund", .coarse));
try std.testing.expectEqualStrings(bucket_other, collapseSector("Derivative / Other", .coarse));
}
test "collapseSector .mid: all Debt / * collapse to Bonds" {
const cases = [_][]const u8{
"Debt / Corporate",
"Debt / US Treasury",
"Debt / Municipal",
"Debt / Non-US Sovereign",
"Debt / US Gov Agency",
"Debt / US GSE",
};
for (cases) |s| {
try std.testing.expectEqualStrings(mid_bonds, collapseSector(s, .mid));
}
}
test "collapseSector .mid: all Asset-Backed and Loan variants collapse to Bonds" {
try std.testing.expectEqualStrings(mid_bonds, collapseSector("Asset-Backed / Corporate Mortgage", .mid));
try std.testing.expectEqualStrings(mid_bonds, collapseSector("Asset-Backed CBO/CDO / Corporate", .mid));
try std.testing.expectEqualStrings(mid_bonds, collapseSector("Asset-Backed Other / Corporate", .mid));
try std.testing.expectEqualStrings(mid_bonds, collapseSector("Loan / Corporate", .mid));
try std.testing.expectEqualStrings(mid_bonds, collapseSector("Bonds", .mid));
}
test "collapseSector .mid: all STIV / Repurchase collapse to Cash & Equivalents" {
try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Short-Term Investment Vehicle / Corporate", .mid));
try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Short-Term Investment Vehicle / Registered Fund", .mid));
try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Short-Term Investment Vehicle / Private Fund", .mid));
try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Repurchase Agreement / Other", .mid));
try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Cash", .mid));
try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Cash & CDs", .mid));
}
test "collapseSector .mid: Equity Preferred is its own bucket" {
// Hybrid security distinct from generic equity at mid.
try std.testing.expectEqualStrings(mid_equity_preferred, collapseSector("Equity Preferred / Corporate", .mid));
}
test "collapseSector .mid: Equity / * (non-Preferred) collapses to Equity / Corporate" {
try std.testing.expectEqualStrings(mid_equity_corporate, collapseSector("Equity / Corporate", .mid));
try std.testing.expectEqualStrings(mid_equity_corporate, collapseSector("Equity / Other", .mid));
try std.testing.expectEqualStrings(mid_equity_corporate, collapseSector("Equity / Registered Fund", .mid));
}
test "collapseSector .mid: GICS sectors stay distinct" {
// The whole point of mid: collapse NPORT-P sub-flavors but
// keep GICS sector breakdown so users see stock concentrations.
try std.testing.expectEqualStrings("Technology", collapseSector("Technology", .mid));
try std.testing.expectEqualStrings("Healthcare", collapseSector("Healthcare", .mid));
try std.testing.expectEqualStrings("Financial Services", collapseSector("Financial Services", .mid));
try std.testing.expectEqualStrings("Financials", collapseSector("Financials", .mid));
try std.testing.expectEqualStrings("Diversified", collapseSector("Diversified", .mid));
try std.testing.expectEqualStrings("Energy", collapseSector("Energy", .mid));
}
test "collapseSector .mid: Derivative variants collapse to Derivatives" {
try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative / Corporate", .mid));
try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative / Other", .mid));
try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative-FX / Other", .mid));
try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative-FX / Corporate", .mid));
}
test "collapseSector .mid: real property and unrecognized -> Other" {
try std.testing.expectEqualStrings(mid_other, collapseSector("Direct Real Property / Other", .mid));
try std.testing.expectEqualStrings(mid_other, collapseSector("Direct Credit Risk / Other", .mid));
try std.testing.expectEqualStrings(mid_other, collapseSector("TODO", .mid));
try std.testing.expectEqualStrings(mid_other, collapseSector("Unknown", .mid));
try std.testing.expectEqualStrings(mid_other, collapseSector("", .mid));
try std.testing.expectEqualStrings(mid_other, collapseSector("Some Future Label", .mid));
}
test "collapseSector .mid: returns same pointer for same bucket (static-string property)" {
// Stable HashMap keys without duping.
const a = collapseSector("Debt / Corporate", .mid);
const b = collapseSector("Debt / US Treasury", .mid);
try std.testing.expectEqual(@intFromPtr(a.ptr), @intFromPtr(b.ptr));
try std.testing.expectEqual(@intFromPtr(mid_bonds.ptr), @intFromPtr(a.ptr));
}
// collapseBreakdownAtGranularity
test "collapseBreakdownAtGranularity: VBTLX-shape Debt sleeves collapse to Bonds at mid" {
// VBTLX has six different Debt / X rows. At mid granularity
// they should all sum into one Bonds row.
const allocator = std.testing.allocator;
const items = [_]BreakdownItem{
.{ .label = "Debt / Corporate", .weight = 0.40, .value = 40_000.0 },
.{ .label = "Debt / US Treasury", .weight = 0.30, .value = 30_000.0 },
.{ .label = "Debt / Non-US Sovereign", .weight = 0.10, .value = 10_000.0 },
.{ .label = "Debt / Municipal", .weight = 0.05, .value = 5_000.0 },
.{ .label = "Debt / US Gov Agency", .weight = 0.04, .value = 4_000.0 },
.{ .label = "Debt / US GSE", .weight = 0.01, .value = 1_000.0 },
.{ .label = "Short-Term Investment Vehicle / Registered Fund", .weight = 0.10, .value = 10_000.0 },
};
const result = try collapseBreakdownAtGranularity(allocator, &items, .mid, 100_000.0);
defer allocator.free(result);
// Two output buckets: Bonds (sum of all Debt/*) and
// Cash & Equivalents (the STIV row).
try std.testing.expectEqual(@as(usize, 2), result.len);
var bonds_value: f64 = 0;
var cash_value: f64 = 0;
for (result) |item| {
if (std.mem.eql(u8, item.label, mid_bonds)) bonds_value = item.value;
if (std.mem.eql(u8, item.label, mid_cash_equivalents)) cash_value = item.value;
}
try std.testing.expectApproxEqAbs(@as(f64, 90_000), bonds_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 10_000), cash_value, 1.0);
}
test "collapseBreakdownAtGranularity: coarse collapses everything to 4 buckets" {
const allocator = std.testing.allocator;
const items = [_]BreakdownItem{
.{ .label = "Debt / Corporate", .weight = 0.50, .value = 50_000.0 },
.{ .label = "Equity / Corporate", .weight = 0.30, .value = 30_000.0 },
.{ .label = "Technology", .weight = 0.10, .value = 10_000.0 },
.{ .label = "Short-Term Investment Vehicle / Corporate", .weight = 0.05, .value = 5_000.0 },
.{ .label = "Derivative / Other", .weight = 0.05, .value = 5_000.0 },
};
const result = try collapseBreakdownAtGranularity(allocator, &items, .coarse, 100_000.0);
defer allocator.free(result);
// Equity (30k Equity/Corp + 10k Technology) = 40k
// Fixed Income = 50k Debt
// Cash = 5k STIV
// Other = 5k Derivative
var eq: f64 = 0;
var fi: f64 = 0;
var c: f64 = 0;
var o: f64 = 0;
for (result) |item| {
if (std.mem.eql(u8, item.label, bucket_equity)) eq = item.value;
if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi = item.value;
if (std.mem.eql(u8, item.label, bucket_cash)) c = item.value;
if (std.mem.eql(u8, item.label, bucket_other)) o = item.value;
}
try std.testing.expectApproxEqAbs(@as(f64, 40_000), eq, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 50_000), fi, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 5_000), c, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 5_000), o, 1.0);
}
test "collapseBreakdownAtGranularity: fine returns equivalent breakdown unchanged" {
const allocator = std.testing.allocator;
const items = [_]BreakdownItem{
.{ .label = "Debt / Corporate", .weight = 0.50, .value = 50_000.0 },
.{ .label = "Equity / Corporate", .weight = 0.30, .value = 30_000.0 },
.{ .label = "Technology", .weight = 0.20, .value = 20_000.0 },
};
const result = try collapseBreakdownAtGranularity(allocator, &items, .fine, 100_000.0);
defer allocator.free(result);
// 3 input rows 3 output rows (no collapsing at fine).
try std.testing.expectEqual(@as(usize, 3), result.len);
// Output sorted by value descending.
try std.testing.expectEqualStrings("Debt / Corporate", result[0].label);
try std.testing.expectApproxEqAbs(@as(f64, 50_000), result[0].value, 1.0);
}
test "collapseBreakdownAtGranularity: empty input -> empty output" {
const allocator = std.testing.allocator;
const items = [_]BreakdownItem{};
const result = try collapseBreakdownAtGranularity(allocator, &items, .mid, 100_000.0);
defer allocator.free(result);
try std.testing.expectEqual(@as(usize, 0), result.len);
}
test "collapseBreakdownAtGranularity: total values preserved through collapse" {
// Sum of output values must equal sum of input values
// (modulo float rounding).
const allocator = std.testing.allocator;
const items = [_]BreakdownItem{
.{ .label = "Debt / Corporate", .weight = 0.40, .value = 40.0 },
.{ .label = "Loan / Corporate", .weight = 0.20, .value = 20.0 },
.{ .label = "Asset-Backed / Corporate Mortgage", .weight = 0.15, .value = 15.0 },
.{ .label = "Equity / Corporate", .weight = 0.20, .value = 20.0 },
.{ .label = "Technology", .weight = 0.05, .value = 5.0 },
};
const result = try collapseBreakdownAtGranularity(allocator, &items, .mid, 100.0);
defer allocator.free(result);
var total: f64 = 0;
for (result) |item| total += item.value;
try std.testing.expectApproxEqAbs(@as(f64, 100.0), total, 0.01);
}
/// Map an `asset_class` string to one of the four asset-category
/// buckets. Used as a fallback when a classification entry has
/// no `sector` but does have an `asset_class` (legacy

View file

@ -5,14 +5,16 @@ const framework = @import("framework.zig");
const fmt = cli.fmt;
const Money = @import("../Money.zig");
pub const ParsedArgs = struct {};
pub const ParsedArgs = struct {
sector_detail: zfin.analysis.Granularity = .mid,
};
pub const meta: framework.Meta = .{
.name = "analysis",
.group = .portfolio,
.synopsis = "Show portfolio breakdowns by asset class, sector, geo, account, tax type",
.help =
\\Usage: zfin analysis
\\Usage: zfin analysis [opts]
\\
\\Show portfolio analysis: equities/fixed-income split, plus
\\block-bar breakdowns by asset class, sector, geographic
@ -20,24 +22,47 @@ pub const meta: framework.Meta = .{
\\`metadata.srf` and account tax types from `accounts.srf`
\\(both in the same directory as the portfolio file).
\\
\\Options:
\\ --sector-detail LEVEL Sector display granularity:
\\ coarse - 4 buckets (Equity / Fixed Income / Cash / Other)
\\ mid - ~12 buckets (default; collapses NPORT-P sub-flavors)
\\ fine - raw NPORT-P breakdown (every Debt / X variant separate)
\\
\\Run `zfin enrich <portfolio.srf> > metadata.srf` to bootstrap
\\classifications, then edit by hand.
\\
,
.uppercase_first_arg = false,
.user_errors = error{UnexpectedArg},
.user_errors = error{ UnexpectedArg, InvalidSectorDetail },
};
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len > 0) {
cli.stderrPrint(ctx.io, "Error: 'analysis' takes no arguments\n");
return error.UnexpectedArg;
var parsed: ParsedArgs = .{};
var i: usize = 0;
while (i < cmd_args.len) : (i += 1) {
if (std.mem.eql(u8, cmd_args[i], "--sector-detail") and i + 1 < cmd_args.len) {
i += 1;
const value = cmd_args[i];
if (std.mem.eql(u8, value, "coarse")) {
parsed.sector_detail = .coarse;
} else if (std.mem.eql(u8, value, "mid")) {
parsed.sector_detail = .mid;
} else if (std.mem.eql(u8, value, "fine")) {
parsed.sector_detail = .fine;
} else {
cli.stderrPrint(ctx.io, "Error: --sector-detail must be one of: coarse, mid, fine\n");
return error.InvalidSectorDetail;
}
} else {
cli.stderrPrint(ctx.io, "Error: 'analysis' takes no positional arguments\n");
return error.UnexpectedArg;
}
}
return .{};
return parsed;
}
/// CLI `analysis` command: show portfolio analysis breakdowns.
pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
const io = ctx.io;
const allocator = ctx.allocator;
@ -133,6 +158,20 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
else
anchor_path;
// Re-aggregate the Sector breakdown at the user's chosen
// granularity. `analyze` produces fine-grained NPORT-P + GICS
// labels; the user picks coarse / mid / fine via
// `--sector-detail`. Mid is the default most useful for
// most users.
const collapsed_sector = try zfin.analysis.collapseBreakdownAtGranularity(
allocator,
result.sector,
parsed.sector_detail,
pf_data.summary.total_value,
);
allocator.free(result.sector);
result.sector = collapsed_sector;
try display(result, split.stock_pct, split.bond_pct, split.cash_pct, pf_data.summary.total_value, display_label, color, out);
}

View file

@ -230,19 +230,11 @@ fn buildQuery(allocator: std.mem.Allocator, symbols: []const []const u8) ![]u8 {
/// industry label to one of these buckets so the user gets a
/// stable sector choice rather than whichever sub-industry
/// SPARQL surfaced first.
pub const sector = struct {
pub const technology = "Technology";
pub const communication_services = "Communication Services";
pub const consumer_cyclical = "Consumer Cyclical";
pub const consumer_defensive = "Consumer Defensive";
pub const healthcare = "Healthcare";
pub const financial_services = "Financial Services";
pub const energy = "Energy";
pub const industrials = "Industrials";
pub const basic_materials = "Basic Materials";
pub const real_estate = "Real Estate";
pub const utilities = "Utilities";
};
///
/// Lives in `models/classification.zig` so multiple producers
/// share one taxonomy. Re-exported here for back-compat with
/// existing internal references.
pub const sector = classification.sector;
/// Map a Wikidata `wdt:P452` industry label (lowercase or mixed
/// case) to one of the canonical sectors. Returns null if no

View file

@ -10,11 +10,14 @@ const StyledLine = tui.StyledLine;
// Tab-local action enum
//
// Analysis has no tab-local keybinds today it's a read-only
// breakdown view. Refresh is global. Empty enum is the explicit
// placeholder.
// Cycle the Sector breakdown's display granularity through
// coarse mid fine coarse. Default tier is mid (~12-16
// buckets, NPORT-P sub-flavors collapsed but GICS sectors
// distinct). Coarse delegates to the same 4-bucket shape as
// the Asset Category section. Fine is the raw NPORT-P
// breakdown (every Debt / X variant separate).
pub const Action = enum {};
pub const Action = enum { cycle_sector_granularity };
// Tab-private state
@ -30,15 +33,25 @@ pub const State = struct {
/// tab consumes it. Loaded lazily on first activation; freed
/// in `deinit`.
classification_map: ?zfin.classification.ClassificationMap = null,
/// Sector display granularity. Cycled via `cycle_sector_granularity`
/// action. Default `mid` matches the CLI default
/// (`zfin analysis` without `--sector-detail`).
sector_granularity: zfin.analysis.Granularity = .mid,
};
// Tab framework contract
pub const meta: framework.TabMeta(Action) = .{
.label = "Analysis",
.default_bindings = &.{},
.action_labels = std.enums.EnumArray(Action, []const u8).initFill(""),
.status_hints = &.{},
.default_bindings = &.{
.{ .action = .cycle_sector_granularity, .key = .{ .codepoint = 'm' } },
},
.action_labels = std.enums.EnumArray(Action, []const u8).init(.{
.cycle_sector_granularity = "Cycle sector granularity (coarse / mid / fine)",
}),
.status_hints = &.{
.cycle_sector_granularity,
},
};
pub const tab = struct {
@ -83,9 +96,19 @@ pub const tab = struct {
pub const tick = framework.noopTick(State);
pub fn handleAction(state: *State, app: *App, action: Action) void {
_ = state;
_ = app;
switch (action) {}
switch (action) {
.cycle_sector_granularity => {
// coarse mid fine coarse. The display layer
// re-aggregates `result.sector` through the new
// granularity on the next render.
state.sector_granularity = switch (state.sector_granularity) {
.coarse => .mid,
.mid => .fine,
.fine => .coarse,
};
},
}
}
/// Analysis requires a loaded portfolio file (the breakdown
@ -184,7 +207,7 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c
cash_pct = split.cash_pct;
}
}
return renderAnalysisLines(arena, app.theme, state.result, stock_pct, bond_pct, cash_pct, total_value);
return renderAnalysisLines(arena, app.theme, state.result, stock_pct, bond_pct, cash_pct, total_value, state.sector_granularity);
}
/// Render analysis tab content. Pure function no App dependency.
@ -196,6 +219,7 @@ pub fn renderAnalysisLines(
bond_pct: f64,
cash_pct: f64,
total_value: f64,
sector_granularity: zfin.analysis.Granularity,
) ![]const StyledLine {
var lines: std.ArrayList(StyledLine) = .empty;
@ -231,14 +255,43 @@ pub fn renderAnalysisLines(
const bar_width: usize = 30;
const label_width: usize = 24;
const sections = zfin.analysis.breakdownSections(&result);
// Re-aggregate the Sector breakdown at the chosen granularity.
// The arena allocator owns the result for this frame; freed
// when the frame ends.
const collapsed_sector = try zfin.analysis.collapseBreakdownAtGranularity(
arena,
result.sector,
sector_granularity,
total_value,
);
// Produce a per-frame copy of the result with the rebucketed
// sector slice. Other fields are aliased; we don't free the
// original sector slice that lives on `state.result` and
// gets freed at tab.deinit time.
const display_result: zfin.analysis.AnalysisResult = .{
.asset_category = result.asset_category,
.asset_class = result.asset_class,
.sector = collapsed_sector,
.geo = result.geo,
.account = result.account,
.tax_type = result.tax_type,
.unclassified = result.unclassified,
.total_value = result.total_value,
};
const sections = zfin.analysis.breakdownSections(&display_result);
for (sections, 0..) |sec, si| {
if (si > 0 and sec.items.len == 0) continue;
if (si > 0) try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Indent the title (renderer-level, not baked into the
// section's title string).
const title_text = try std.fmt.allocPrint(arena, " {s}", .{sec.title});
// section's title string). For the Sector section,
// append the current granularity in parens so the user
// knows what the `g` hot-key cycled to.
const title_text = if (std.mem.eql(u8, sec.title, "Sector (Equities)"))
try std.fmt.allocPrint(arena, " Sector ({s} — press 'm' to cycle)", .{granularityLabel(sector_granularity)})
else
try std.fmt.allocPrint(arena, " {s}", .{sec.title});
try lines.append(arena, .{ .text = title_text, .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
for (sec.items) |item| {
@ -259,6 +312,16 @@ pub fn renderAnalysisLines(
return lines.toOwnedSlice(arena);
}
/// Display label for a granularity tier, used in the Sector
/// section title.
fn granularityLabel(g: zfin.analysis.Granularity) []const u8 {
return switch (g) {
.coarse => "coarse",
.mid => "mid",
.fine => "fine",
};
}
pub fn fmtBreakdownLine(arena: std.mem.Allocator, item: zfin.analysis.BreakdownItem, bar_width: usize, label_width: usize) ![]const u8 {
const pct = item.weight * 100.0;
const bar = try buildBlockBar(arena, item.weight, bar_width);
@ -347,7 +410,7 @@ test "renderAnalysisLines with data" {
.unclassified = &.{},
.total_value = 200000,
};
const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.15, 0.05, 200000);
const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.15, 0.05, 200000, .mid);
// Should have header section + asset class items
try testing.expect(lines.len >= 5);
// Find "Portfolio Analysis" header
@ -373,7 +436,7 @@ test "renderAnalysisLines no data" {
const arena = arena_state.allocator();
const th = theme.default_theme;
const lines = try renderAnalysisLines(arena, th, null, 0, 0, 0, 0);
const lines = try renderAnalysisLines(arena, th, null, 0, 0, 0, 0, .mid);
try testing.expectEqual(@as(usize, 5), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null);
}
@ -387,4 +450,109 @@ test "tab.init produces zero-defaulted state" {
try testing.expectEqual(false, state.loaded);
try testing.expect(state.result == null);
try testing.expect(state.classification_map == null);
// Default sector granularity is mid (matches CLI default).
try testing.expectEqual(zfin.analysis.Granularity.mid, state.sector_granularity);
}
test "handleAction cycles sector granularity coarse → mid → fine → coarse" {
var state: State = .{};
var dummy_app: tui.App = undefined; // handleAction doesn't touch app
// mid fine
try testing.expectEqual(zfin.analysis.Granularity.mid, state.sector_granularity);
tab.handleAction(&state, &dummy_app, .cycle_sector_granularity);
try testing.expectEqual(zfin.analysis.Granularity.fine, state.sector_granularity);
// fine coarse
tab.handleAction(&state, &dummy_app, .cycle_sector_granularity);
try testing.expectEqual(zfin.analysis.Granularity.coarse, state.sector_granularity);
// coarse mid (full cycle)
tab.handleAction(&state, &dummy_app, .cycle_sector_granularity);
try testing.expectEqual(zfin.analysis.Granularity.mid, state.sector_granularity);
}
test "renderAnalysisLines: granularity label appears in Sector section title" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const th = theme.default_theme;
var sector = [_]zfin.analysis.BreakdownItem{
.{ .label = "Equity / Corporate", .weight = 0.80, .value = 80_000 },
.{ .label = "Debt / Corporate", .weight = 0.20, .value = 20_000 },
};
const result = zfin.analysis.AnalysisResult{
.asset_category = &.{},
.asset_class = &.{},
.sector = &sector,
.geo = &.{},
.account = &.{},
.tax_type = &.{},
.unclassified = &.{},
.total_value = 100_000,
};
// mid label
{
const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.20, 0.0, 100_000, .mid);
var found_mid = false;
for (lines) |l| {
if (std.mem.indexOf(u8, l.text, "Sector (mid") != null) found_mid = true;
}
try testing.expect(found_mid);
}
// fine label
{
const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.20, 0.0, 100_000, .fine);
var found_fine = false;
for (lines) |l| {
if (std.mem.indexOf(u8, l.text, "Sector (fine") != null) found_fine = true;
}
try testing.expect(found_fine);
}
// coarse label
{
const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.20, 0.0, 100_000, .coarse);
var found_coarse = false;
for (lines) |l| {
if (std.mem.indexOf(u8, l.text, "Sector (coarse") != null) found_coarse = true;
}
try testing.expect(found_coarse);
}
}
test "renderAnalysisLines: mid granularity collapses Debt rows" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const th = theme.default_theme;
// Two Debt rows that should collapse to one Bonds row at mid.
var sector = [_]zfin.analysis.BreakdownItem{
.{ .label = "Debt / Corporate", .weight = 0.40, .value = 40_000 },
.{ .label = "Debt / US Treasury", .weight = 0.30, .value = 30_000 },
.{ .label = "Equity / Corporate", .weight = 0.30, .value = 30_000 },
};
const result = zfin.analysis.AnalysisResult{
.asset_category = &.{},
.asset_class = &.{},
.sector = &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);
// 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);
}