add analysis bucketing
This commit is contained in:
parent
85c9a48969
commit
2306e1a9c9
4 changed files with 567 additions and 36 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = §or,
|
||||
.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 = §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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue