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;
|
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.
|
/// Compute portfolio analysis from allocations and classification metadata.
|
||||||
/// `allocations` are the stock/ETF positions with market values.
|
/// `allocations` are the stock/ETF positions with market values.
|
||||||
/// `classifications` is the metadata file data.
|
/// `classifications` is the metadata file data.
|
||||||
|
|
@ -564,6 +665,36 @@ fn mapToSortedBreakdown(
|
||||||
return items.toOwnedSlice(allocator);
|
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" {
|
test "parseAccountsFile" {
|
||||||
const data =
|
const data =
|
||||||
\\#!srfv1
|
\\#!srfv1
|
||||||
|
|
@ -934,6 +1065,207 @@ test "bucketSector: legacy 'Financials' (with s) → Equity" {
|
||||||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Financial Services"));
|
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
|
/// Map an `asset_class` string to one of the four asset-category
|
||||||
/// buckets. Used as a fallback when a classification entry has
|
/// buckets. Used as a fallback when a classification entry has
|
||||||
/// no `sector` but does have an `asset_class` (legacy
|
/// no `sector` but does have an `asset_class` (legacy
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,16 @@ const framework = @import("framework.zig");
|
||||||
const fmt = cli.fmt;
|
const fmt = cli.fmt;
|
||||||
const Money = @import("../Money.zig");
|
const Money = @import("../Money.zig");
|
||||||
|
|
||||||
pub const ParsedArgs = struct {};
|
pub const ParsedArgs = struct {
|
||||||
|
sector_detail: zfin.analysis.Granularity = .mid,
|
||||||
|
};
|
||||||
|
|
||||||
pub const meta: framework.Meta = .{
|
pub const meta: framework.Meta = .{
|
||||||
.name = "analysis",
|
.name = "analysis",
|
||||||
.group = .portfolio,
|
.group = .portfolio,
|
||||||
.synopsis = "Show portfolio breakdowns by asset class, sector, geo, account, tax type",
|
.synopsis = "Show portfolio breakdowns by asset class, sector, geo, account, tax type",
|
||||||
.help =
|
.help =
|
||||||
\\Usage: zfin analysis
|
\\Usage: zfin analysis [opts]
|
||||||
\\
|
\\
|
||||||
\\Show portfolio analysis: equities/fixed-income split, plus
|
\\Show portfolio analysis: equities/fixed-income split, plus
|
||||||
\\block-bar breakdowns by asset class, sector, geographic
|
\\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`
|
\\`metadata.srf` and account tax types from `accounts.srf`
|
||||||
\\(both in the same directory as the portfolio file).
|
\\(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
|
\\Run `zfin enrich <portfolio.srf> > metadata.srf` to bootstrap
|
||||||
\\classifications, then edit by hand.
|
\\classifications, then edit by hand.
|
||||||
\\
|
\\
|
||||||
,
|
,
|
||||||
.uppercase_first_arg = false,
|
.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 {
|
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||||
if (cmd_args.len > 0) {
|
var parsed: ParsedArgs = .{};
|
||||||
cli.stderrPrint(ctx.io, "Error: 'analysis' takes no arguments\n");
|
var i: usize = 0;
|
||||||
return error.UnexpectedArg;
|
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.
|
/// 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 svc = ctx.svc orelse return error.MissingDataService;
|
||||||
const io = ctx.io;
|
const io = ctx.io;
|
||||||
const allocator = ctx.allocator;
|
const allocator = ctx.allocator;
|
||||||
|
|
@ -133,6 +158,20 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
|
||||||
else
|
else
|
||||||
anchor_path;
|
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);
|
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
|
/// industry label to one of these buckets so the user gets a
|
||||||
/// stable sector choice rather than whichever sub-industry
|
/// stable sector choice rather than whichever sub-industry
|
||||||
/// SPARQL surfaced first.
|
/// SPARQL surfaced first.
|
||||||
pub const sector = struct {
|
///
|
||||||
pub const technology = "Technology";
|
/// Lives in `models/classification.zig` so multiple producers
|
||||||
pub const communication_services = "Communication Services";
|
/// share one taxonomy. Re-exported here for back-compat with
|
||||||
pub const consumer_cyclical = "Consumer Cyclical";
|
/// existing internal references.
|
||||||
pub const consumer_defensive = "Consumer Defensive";
|
pub const sector = classification.sector;
|
||||||
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";
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Map a Wikidata `wdt:P452` industry label (lowercase or mixed
|
/// Map a Wikidata `wdt:P452` industry label (lowercase or mixed
|
||||||
/// case) to one of the canonical sectors. Returns null if no
|
/// case) to one of the canonical sectors. Returns null if no
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,14 @@ const StyledLine = tui.StyledLine;
|
||||||
|
|
||||||
// ── Tab-local action enum ─────────────────────────────────────
|
// ── Tab-local action enum ─────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Analysis has no tab-local keybinds today — it's a read-only
|
// Cycle the Sector breakdown's display granularity through
|
||||||
// breakdown view. Refresh is global. Empty enum is the explicit
|
// coarse → mid → fine → coarse. Default tier is mid (~12-16
|
||||||
// placeholder.
|
// 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 ─────────────────────────────────────────
|
// ── Tab-private state ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -30,15 +33,25 @@ pub const State = struct {
|
||||||
/// tab consumes it. Loaded lazily on first activation; freed
|
/// tab consumes it. Loaded lazily on first activation; freed
|
||||||
/// in `deinit`.
|
/// in `deinit`.
|
||||||
classification_map: ?zfin.classification.ClassificationMap = null,
|
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 ────────────────────────────────────
|
// ── Tab framework contract ────────────────────────────────────
|
||||||
|
|
||||||
pub const meta: framework.TabMeta(Action) = .{
|
pub const meta: framework.TabMeta(Action) = .{
|
||||||
.label = "Analysis",
|
.label = "Analysis",
|
||||||
.default_bindings = &.{},
|
.default_bindings = &.{
|
||||||
.action_labels = std.enums.EnumArray(Action, []const u8).initFill(""),
|
.{ .action = .cycle_sector_granularity, .key = .{ .codepoint = 'm' } },
|
||||||
.status_hints = &.{},
|
},
|
||||||
|
.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 {
|
pub const tab = struct {
|
||||||
|
|
@ -83,9 +96,19 @@ pub const tab = struct {
|
||||||
pub const tick = framework.noopTick(State);
|
pub const tick = framework.noopTick(State);
|
||||||
|
|
||||||
pub fn handleAction(state: *State, app: *App, action: Action) void {
|
pub fn handleAction(state: *State, app: *App, action: Action) void {
|
||||||
_ = state;
|
|
||||||
_ = app;
|
_ = 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
|
/// 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;
|
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.
|
/// Render analysis tab content. Pure function — no App dependency.
|
||||||
|
|
@ -196,6 +219,7 @@ pub fn renderAnalysisLines(
|
||||||
bond_pct: f64,
|
bond_pct: f64,
|
||||||
cash_pct: f64,
|
cash_pct: f64,
|
||||||
total_value: f64,
|
total_value: f64,
|
||||||
|
sector_granularity: zfin.analysis.Granularity,
|
||||||
) ![]const StyledLine {
|
) ![]const StyledLine {
|
||||||
var lines: std.ArrayList(StyledLine) = .empty;
|
var lines: std.ArrayList(StyledLine) = .empty;
|
||||||
|
|
||||||
|
|
@ -231,14 +255,43 @@ pub fn renderAnalysisLines(
|
||||||
const bar_width: usize = 30;
|
const bar_width: usize = 30;
|
||||||
const label_width: usize = 24;
|
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| {
|
for (sections, 0..) |sec, si| {
|
||||||
if (si > 0 and sec.items.len == 0) continue;
|
if (si > 0 and sec.items.len == 0) continue;
|
||||||
if (si > 0) try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
if (si > 0) try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
// Indent the title (renderer-level, not baked into the
|
// Indent the title (renderer-level, not baked into the
|
||||||
// section's title string).
|
// section's title string). For the Sector section,
|
||||||
const title_text = try std.fmt.allocPrint(arena, " {s}", .{sec.title});
|
// 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 = title_text, .style = th.headerStyle() });
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
for (sec.items) |item| {
|
for (sec.items) |item| {
|
||||||
|
|
@ -259,6 +312,16 @@ pub fn renderAnalysisLines(
|
||||||
return lines.toOwnedSlice(arena);
|
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 {
|
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 pct = item.weight * 100.0;
|
||||||
const bar = try buildBlockBar(arena, item.weight, bar_width);
|
const bar = try buildBlockBar(arena, item.weight, bar_width);
|
||||||
|
|
@ -347,7 +410,7 @@ test "renderAnalysisLines with data" {
|
||||||
.unclassified = &.{},
|
.unclassified = &.{},
|
||||||
.total_value = 200000,
|
.total_value = 200000,
|
||||||
};
|
};
|
||||||
const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.15, 0.05, 200000);
|
const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.15, 0.05, 200000, .mid);
|
||||||
// Should have header section + asset class items
|
// Should have header section + asset class items
|
||||||
try testing.expect(lines.len >= 5);
|
try testing.expect(lines.len >= 5);
|
||||||
// Find "Portfolio Analysis" header
|
// Find "Portfolio Analysis" header
|
||||||
|
|
@ -373,7 +436,7 @@ test "renderAnalysisLines no data" {
|
||||||
const arena = arena_state.allocator();
|
const arena = arena_state.allocator();
|
||||||
const th = theme.default_theme;
|
const th = theme.default_theme;
|
||||||
|
|
||||||
const lines = try renderAnalysisLines(arena, th, null, 0, 0, 0, 0);
|
const lines = try renderAnalysisLines(arena, th, null, 0, 0, 0, 0, .mid);
|
||||||
try testing.expectEqual(@as(usize, 5), lines.len);
|
try testing.expectEqual(@as(usize, 5), lines.len);
|
||||||
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null);
|
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null);
|
||||||
}
|
}
|
||||||
|
|
@ -387,4 +450,109 @@ test "tab.init produces zero-defaulted state" {
|
||||||
try testing.expectEqual(false, state.loaded);
|
try testing.expectEqual(false, state.loaded);
|
||||||
try testing.expect(state.result == null);
|
try testing.expect(state.result == null);
|
||||||
try testing.expect(state.classification_map == 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