725 lines
29 KiB
Zig
725 lines
29 KiB
Zig
const std = @import("std");
|
|
const zfin = @import("../root.zig");
|
|
const fmt = @import("../format.zig");
|
|
const Money = @import("../Money.zig");
|
|
const theme = @import("theme.zig");
|
|
const tui = @import("../tui.zig");
|
|
const framework = @import("tab_framework.zig");
|
|
const App = tui.App;
|
|
const StyledLine = tui.StyledLine;
|
|
|
|
// ── Tab-local action enum ─────────────────────────────────────
|
|
//
|
|
// Cycle the Sector breakdown's display granularity through
|
|
// coarse → mid → fine → coarse. Default tier is mid (~12-16
|
|
// buckets, NPORT-P sub-flavors collapsed but GICS sectors
|
|
// distinct). Coarse delegates to the same 4-bucket shape as
|
|
// the Asset Category section. Fine is the raw NPORT-P
|
|
// breakdown (every Debt / X variant separate).
|
|
|
|
pub const Action = enum { cycle_sector_granularity };
|
|
|
|
// ── Tab-private state ─────────────────────────────────────────
|
|
|
|
pub const State = struct {
|
|
/// Whether `init`/`activate` has populated `result`. Internal
|
|
/// short-circuit for `activate`; App never reads this.
|
|
loaded: bool = false,
|
|
/// Computed analysis output. Owned by State; freed in
|
|
/// `deinit` and `reload`.
|
|
result: ?zfin.analysis.AnalysisResult = null,
|
|
/// Per-portfolio classification metadata (`metadata.srf`).
|
|
/// Used only by analysis today; lives here because no other
|
|
/// 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 = .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 {
|
|
pub const ActionT = Action;
|
|
pub const StateT = State;
|
|
|
|
pub fn init(state: *State, app: *App) !void {
|
|
_ = app;
|
|
state.* = .{};
|
|
}
|
|
|
|
pub fn deinit(state: *State, app: *App) void {
|
|
if (state.result) |*ar| ar.deinit(app.allocator);
|
|
if (state.classification_map) |*cm| cm.deinit();
|
|
state.* = .{};
|
|
}
|
|
|
|
pub fn activate(state: *State, app: *App) !void {
|
|
if (tab.isDisabled(app)) return;
|
|
if (state.loaded) return;
|
|
loadData(state, app);
|
|
}
|
|
|
|
pub const deactivate = framework.noopDeactivate(State);
|
|
|
|
/// Force re-fetch on user request. Frees the analysis result
|
|
/// AND the shared `account_map` on App (analysis's refresh
|
|
/// also re-reads accounts.srf). The classification_map persists
|
|
/// — it's per-portfolio, not per-symbol or per-refresh.
|
|
pub fn reload(state: *State, app: *App) !void {
|
|
if (state.result) |*ar| ar.deinit(app.allocator);
|
|
state.result = null;
|
|
state.loaded = false;
|
|
// Refresh-analysis intentionally drops the shared account
|
|
// map so the next load re-reads `accounts.srf` from disk
|
|
// (the user may have edited it).
|
|
if (app.portfolio.account_map) |*am| am.deinit();
|
|
app.portfolio.account_map = null;
|
|
loadData(state, app);
|
|
}
|
|
|
|
pub const tick = framework.noopTick(State);
|
|
|
|
pub fn handleAction(state: *State, app: *App, action: Action) void {
|
|
_ = app;
|
|
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
|
|
/// is computed from `app.portfolio.summary.allocations`).
|
|
/// Derived directly from `app.portfolio.file` rather than
|
|
/// stored as a field, so it can't go stale and App never has
|
|
/// to reach across into the tab's State to set it.
|
|
pub fn isDisabled(app: *App) bool {
|
|
return app.portfolio.file == null;
|
|
}
|
|
};
|
|
|
|
// ── Data loading ──────────────────────────────────────────────
|
|
|
|
fn loadData(state: *State, app: *App) void {
|
|
state.loaded = true;
|
|
|
|
// Ensure portfolio is loaded first
|
|
app.ensurePortfolioDataLoaded();
|
|
const pf = app.portfolio.file orelse return;
|
|
const summary = app.portfolio.summary orelse return;
|
|
|
|
// Load classification metadata file
|
|
if (state.classification_map == null) {
|
|
// Look for metadata.srf next to the portfolio file
|
|
if (app.anchorPath()) |ppath| {
|
|
// Derive metadata path: same directory as portfolio, named "metadata.srf"
|
|
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
|
|
const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
|
|
defer app.allocator.free(meta_path);
|
|
|
|
const file_data = std.Io.Dir.cwd().readFileAlloc(app.io, meta_path, app.allocator, .limited(1024 * 1024)) catch {
|
|
app.setStatus("No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf");
|
|
return;
|
|
};
|
|
defer app.allocator.free(file_data);
|
|
|
|
state.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch {
|
|
app.setStatus("Error parsing metadata.srf");
|
|
return;
|
|
};
|
|
}
|
|
}
|
|
|
|
// Load account tax type metadata file (optional)
|
|
app.ensureAccountMap();
|
|
|
|
loadDataFinish(state, app, pf, summary);
|
|
}
|
|
|
|
fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void {
|
|
const cm = state.classification_map orelse {
|
|
app.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf");
|
|
return;
|
|
};
|
|
|
|
// Free previous result
|
|
if (state.result) |*ar| ar.deinit(app.allocator);
|
|
|
|
state.result = zfin.analysis.analyzePortfolio(
|
|
app.allocator,
|
|
summary.allocations,
|
|
cm,
|
|
pf,
|
|
summary.total_value,
|
|
app.portfolio.account_map,
|
|
app.today, // live mode in TUI → resolves to app.today
|
|
) catch {
|
|
app.setStatus("Error computing analysis");
|
|
return;
|
|
};
|
|
}
|
|
|
|
// ── Rendering ─────────────────────────────────────────────────
|
|
|
|
pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
|
// Compute equity/fixed-income/cash split from classification + portfolio
|
|
var stock_pct: f64 = 0;
|
|
var bond_pct: f64 = 0;
|
|
var cash_pct: f64 = 0;
|
|
var total_value: f64 = 0;
|
|
if (app.portfolio.summary) |summary| {
|
|
total_value = summary.total_value;
|
|
if (app.portfolio.file) |pf| {
|
|
const benchmark = @import("../analytics/benchmark.zig");
|
|
const cm_entries = if (state.classification_map) |cm| cm.entries else &.{};
|
|
const split = benchmark.deriveAllocationSplit(
|
|
summary.allocations,
|
|
cm_entries,
|
|
summary.total_value,
|
|
pf.totalCash(app.today),
|
|
pf.totalCdFaceValue(app.today),
|
|
);
|
|
stock_pct = split.stock_pct;
|
|
bond_pct = split.bond_pct;
|
|
cash_pct = split.cash_pct;
|
|
}
|
|
}
|
|
return renderAnalysisLines(arena, app.theme, state.result, stock_pct, bond_pct, cash_pct, total_value, state.sector_granularity, app.portfolio.account_map);
|
|
}
|
|
|
|
/// Render analysis tab content. Pure function — no App dependency.
|
|
pub fn renderAnalysisLines(
|
|
arena: std.mem.Allocator,
|
|
th: theme.Theme,
|
|
analysis_result: ?zfin.analysis.AnalysisResult,
|
|
stock_pct: f64,
|
|
bond_pct: f64,
|
|
cash_pct: f64,
|
|
total_value: f64,
|
|
sector_granularity: zfin.analysis.Granularity,
|
|
account_map: ?zfin.analysis.AccountMap,
|
|
) ![]const StyledLine {
|
|
var lines: std.ArrayList(StyledLine) = .empty;
|
|
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = " Portfolio Analysis", .style = th.headerStyle() });
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
const result = analysis_result orelse {
|
|
try lines.append(arena, .{ .text = " No analysis data. Ensure metadata.srf exists alongside portfolio.", .style = th.mutedStyle() });
|
|
try lines.append(arena, .{ .text = " Run: zfin enrich <portfolio.srf> > metadata.srf", .style = th.mutedStyle() });
|
|
return lines.toOwnedSlice(arena);
|
|
};
|
|
|
|
// Equities / Fixed Income / Cash header summary. The Other
|
|
// bucket (derivatives, real property) is excluded from this
|
|
// summary but appears as its own row in the Asset Category
|
|
// breakdown.
|
|
if (stock_pct > 0 or bond_pct > 0 or cash_pct > 0) {
|
|
try lines.append(arena, .{
|
|
.text = try std.fmt.allocPrint(arena, " Equities {d:.1}% ({f}) / Fixed Income {d:.1}% ({f}) / Cash {d:.1}% ({f})", .{
|
|
stock_pct * 100,
|
|
Money.from(stock_pct * total_value),
|
|
bond_pct * 100,
|
|
Money.from(bond_pct * total_value),
|
|
cash_pct * 100,
|
|
Money.from(cash_pct * total_value),
|
|
}),
|
|
.style = th.mutedStyle(),
|
|
});
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
}
|
|
|
|
const bar_width: usize = 30;
|
|
const label_width: usize = 24;
|
|
|
|
// 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). 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| {
|
|
const text = try fmtBreakdownLine(arena, item, bar_width, label_width);
|
|
try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width });
|
|
}
|
|
}
|
|
|
|
if (result.unclassified.len > 0) {
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = " Unclassified (not in metadata.srf)", .style = th.warningStyle() });
|
|
for (result.unclassified) |sym| {
|
|
const text = try std.fmt.allocPrint(arena, " {s}", .{sym});
|
|
try lines.append(arena, .{ .text = text, .style = th.mutedStyle() });
|
|
}
|
|
}
|
|
|
|
// Umbrella-insurance exposure block at the bottom (mirrors
|
|
// the CLI's `display`). User scrolls to see it. Suppressed
|
|
// when account_map is unavailable — the shielding decision
|
|
// requires per-account tax_type info.
|
|
if (account_map) |am| {
|
|
try renderUmbrellaSection(arena, th, &lines, result.account, am);
|
|
}
|
|
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
/// Append the Umbrella exposure block to `lines`. Mirrors
|
|
/// `commands/analysis.zig:printUmbrellaSection`.
|
|
pub fn renderUmbrellaSection(
|
|
arena: std.mem.Allocator,
|
|
th: theme.Theme,
|
|
lines: *std.ArrayList(StyledLine),
|
|
account_breakdown: []const zfin.analysis.BreakdownItem,
|
|
account_map: zfin.analysis.AccountMap,
|
|
) !void {
|
|
const umbrella = zfin.analysis.umbrellaExposure(account_breakdown, account_map);
|
|
if (umbrella.total_liquid <= 0) return;
|
|
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = " Umbrella exposure", .style = th.headerStyle() });
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
{
|
|
const text = try std.fmt.allocPrint(arena, " Total liquid: {f}", .{Money.from(umbrella.total_liquid)});
|
|
try lines.append(arena, .{ .text = text, .style = th.contentStyle() });
|
|
}
|
|
{
|
|
const text = try std.fmt.allocPrint(arena, " Shielded (retirement accounts): {f}", .{Money.from(umbrella.shielded_value)});
|
|
try lines.append(arena, .{ .text = text, .style = th.contentStyle() });
|
|
}
|
|
{
|
|
const text = try std.fmt.allocPrint(arena, " Exposed (taxable + non-shielded pre-tax): {f} ({d:.1}%)", .{ Money.from(umbrella.exposed_value), umbrella.exposed_pct * 100 });
|
|
try lines.append(arena, .{ .text = text, .style = th.warningStyle() });
|
|
}
|
|
try lines.append(arena, .{ .text = " ↑ approximate umbrella target", .style = th.mutedStyle() });
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = " Note: IRA protections vary by state. Analysis assumes", .style = th.mutedStyle() });
|
|
try lines.append(arena, .{ .text = " protection. Override per-account using `shielded:bool:false`", .style = th.mutedStyle() });
|
|
try lines.append(arena, .{ .text = " in accounts.srf.", .style = th.mutedStyle() });
|
|
}
|
|
|
|
/// 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);
|
|
// Build label padded to label_width
|
|
const lbl = item.label;
|
|
const lbl_len = @min(lbl.len, label_width);
|
|
const padded_label = try arena.alloc(u8, label_width);
|
|
@memcpy(padded_label[0..lbl_len], lbl[0..lbl_len]);
|
|
if (lbl_len < label_width) @memset(padded_label[lbl_len..], ' ');
|
|
return std.fmt.allocPrint(arena, " {s} {s} {d:>5.1}% {f}", .{
|
|
padded_label, bar, pct, Money.from(item.value),
|
|
});
|
|
}
|
|
|
|
/// Build a bar using Unicode block elements for sub-character precision.
|
|
/// Wraps fmt.buildBlockBar into arena-allocated memory.
|
|
pub fn buildBlockBar(arena: std.mem.Allocator, weight: f64, total_chars: usize) ![]const u8 {
|
|
var buf: [256]u8 = undefined;
|
|
const result = fmt.buildBlockBar(&buf, weight, total_chars);
|
|
return arena.dupe(u8, result);
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────
|
|
|
|
const testing = std.testing;
|
|
|
|
test "buildBlockBar empty" {
|
|
const bar = try buildBlockBar(testing.allocator, 0, 10);
|
|
defer testing.allocator.free(bar);
|
|
// All spaces
|
|
try testing.expectEqual(@as(usize, 10), bar.len);
|
|
try testing.expectEqualStrings(" ", bar);
|
|
}
|
|
|
|
test "buildBlockBar full" {
|
|
const bar = try buildBlockBar(testing.allocator, 1.0, 5);
|
|
defer testing.allocator.free(bar);
|
|
// 5 full blocks, each 3 bytes UTF-8 (█ = E2 96 88)
|
|
try testing.expectEqual(@as(usize, 15), bar.len);
|
|
// Verify first block is █
|
|
try testing.expectEqualStrings("\xe2\x96\x88", bar[0..3]);
|
|
}
|
|
|
|
test "buildBlockBar partial" {
|
|
const bar = try buildBlockBar(testing.allocator, 0.5, 10);
|
|
defer testing.allocator.free(bar);
|
|
// 50% of 10 chars = 5 full blocks (no partial)
|
|
// 5 full blocks (15 bytes) + 5 spaces = 20 bytes
|
|
try testing.expectEqual(@as(usize, 20), bar.len);
|
|
}
|
|
|
|
test "fmtBreakdownLine formats correctly" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
const item = zfin.analysis.BreakdownItem{
|
|
.label = "US Stock",
|
|
.weight = 0.65,
|
|
.value = 130000,
|
|
};
|
|
const line = try fmtBreakdownLine(arena, item, 10, 12);
|
|
// Should contain the label, percentage, and dollar amount
|
|
try testing.expect(std.mem.indexOf(u8, line, "US Stock") != null);
|
|
try testing.expect(std.mem.indexOf(u8, line, "65.0%") != null);
|
|
try testing.expect(std.mem.indexOf(u8, line, "$130,000") != null);
|
|
}
|
|
|
|
test "renderAnalysisLines with data" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
const th = theme.default_theme;
|
|
|
|
var asset_class = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "US Stock", .weight = 0.60, .value = 120000 },
|
|
.{ .label = "Int'l Stock", .weight = 0.40, .value = 80000 },
|
|
};
|
|
const result = zfin.analysis.AnalysisResult{
|
|
.asset_category = &.{},
|
|
.asset_class = &asset_class,
|
|
.sector = &.{},
|
|
.geo = &.{},
|
|
.account = &.{},
|
|
.tax_type = &.{},
|
|
.unclassified = &.{},
|
|
.total_value = 200000,
|
|
};
|
|
const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.15, 0.05, 200000, .mid, null);
|
|
// Should have header section + asset class items
|
|
try testing.expect(lines.len >= 5);
|
|
// Find "Portfolio Analysis" header
|
|
var found_header = false;
|
|
var found_cash_in_summary = false;
|
|
for (lines) |l| {
|
|
if (std.mem.indexOf(u8, l.text, "Portfolio Analysis") != null) found_header = true;
|
|
if (std.mem.indexOf(u8, l.text, "Cash 5.0%") != null) found_cash_in_summary = true;
|
|
}
|
|
try testing.expect(found_header);
|
|
try testing.expect(found_cash_in_summary);
|
|
// Find asset class data
|
|
var found_us = false;
|
|
for (lines) |l| {
|
|
if (std.mem.indexOf(u8, l.text, "US Stock") != null) found_us = true;
|
|
}
|
|
try testing.expect(found_us);
|
|
}
|
|
|
|
test "renderAnalysisLines no data" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
const th = theme.default_theme;
|
|
|
|
const lines = try renderAnalysisLines(arena, th, null, 0, 0, 0, 0, .mid, null);
|
|
try testing.expectEqual(@as(usize, 5), lines.len);
|
|
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null);
|
|
}
|
|
|
|
test "tab.init produces zero-defaulted state" {
|
|
var state: State = undefined;
|
|
var dummy_app: tui.App = undefined; // intentionally undefined: init
|
|
// for analysis doesn't touch app fields.
|
|
|
|
try tab.init(&state, &dummy_app);
|
|
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, null);
|
|
var found_mid = false;
|
|
for (lines) |l| {
|
|
if (std.mem.indexOf(u8, l.text, "Sector (mid") != null) found_mid = true;
|
|
}
|
|
try testing.expect(found_mid);
|
|
}
|
|
// fine label
|
|
{
|
|
const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.20, 0.0, 100_000, .fine, null);
|
|
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, null);
|
|
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, null);
|
|
|
|
// At mid, Bonds appears (collapsed) and the individual Debt
|
|
// rows do NOT appear.
|
|
var has_bonds_row = false;
|
|
var has_raw_treasury = false;
|
|
for (lines) |l| {
|
|
if (std.mem.indexOf(u8, l.text, "Bonds") != null and std.mem.indexOf(u8, l.text, "70.0%") != null) has_bonds_row = true;
|
|
if (std.mem.indexOf(u8, l.text, "Debt / US Treasury") != null) has_raw_treasury = true;
|
|
}
|
|
try testing.expect(has_bonds_row);
|
|
try testing.expect(!has_raw_treasury);
|
|
}
|
|
|
|
test "renderAnalysisLines: umbrella section appears at the bottom when account_map provided" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
const th = theme.default_theme;
|
|
|
|
var account = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "Sample IRA", .weight = 0.60, .value = 60_000 },
|
|
.{ .label = "Sample Brokerage", .weight = 0.40, .value = 40_000 },
|
|
};
|
|
const result = zfin.analysis.AnalysisResult{
|
|
.asset_category = &.{},
|
|
.asset_class = &.{},
|
|
.sector = &.{},
|
|
.geo = &.{},
|
|
.account = &account,
|
|
.tax_type = &.{},
|
|
.unclassified = &.{},
|
|
.total_value = 100_000,
|
|
};
|
|
|
|
var am = try zfin.analysis.parseAccountsFile(testing.allocator,
|
|
\\#!srfv1
|
|
\\account::Sample IRA,tax_type::traditional
|
|
\\account::Sample Brokerage,tax_type::taxable
|
|
);
|
|
defer am.deinit();
|
|
|
|
const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.0, 0.0, 100_000, .mid, am);
|
|
|
|
var found_header = false;
|
|
var found_total_liquid = false;
|
|
var found_shielded = false;
|
|
var found_exposed = false;
|
|
var found_note = false;
|
|
for (lines) |l| {
|
|
if (std.mem.indexOf(u8, l.text, "Umbrella exposure") != null) found_header = true;
|
|
if (std.mem.indexOf(u8, l.text, "Total liquid") != null) found_total_liquid = true;
|
|
if (std.mem.indexOf(u8, l.text, "Shielded") != null and std.mem.indexOf(u8, l.text, "$60,000") != null) found_shielded = true;
|
|
if (std.mem.indexOf(u8, l.text, "Exposed") != null and std.mem.indexOf(u8, l.text, "40.0%") != null) found_exposed = true;
|
|
if (std.mem.indexOf(u8, l.text, "IRA protections vary by state") != null) found_note = true;
|
|
}
|
|
try testing.expect(found_header);
|
|
try testing.expect(found_total_liquid);
|
|
try testing.expect(found_shielded);
|
|
try testing.expect(found_exposed);
|
|
try testing.expect(found_note);
|
|
}
|
|
|
|
test "renderAnalysisLines: umbrella section absent when account_map is null" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
const th = theme.default_theme;
|
|
|
|
var account = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "Sample IRA", .weight = 1.0, .value = 100_000 },
|
|
};
|
|
const result = zfin.analysis.AnalysisResult{
|
|
.asset_category = &.{},
|
|
.asset_class = &.{},
|
|
.sector = &.{},
|
|
.geo = &.{},
|
|
.account = &account,
|
|
.tax_type = &.{},
|
|
.unclassified = &.{},
|
|
.total_value = 100_000,
|
|
};
|
|
|
|
const lines = try renderAnalysisLines(arena, th, result, 1.0, 0, 0, 100_000, .mid, null);
|
|
|
|
for (lines) |l| {
|
|
try testing.expect(std.mem.indexOf(u8, l.text, "Umbrella exposure") == null);
|
|
}
|
|
}
|
|
|
|
test "renderAnalysisLines: umbrella respects shielded:bool:false override (DCP case)" {
|
|
// The load-bearing test for the user's DCP scenario: a
|
|
// pre-tax account with `tax_type::traditional` that's NOT
|
|
// ERISA-shielded. Override flips it from shielded → exposed.
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
const th = theme.default_theme;
|
|
|
|
var account = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "Sample IRA", .weight = 0.40, .value = 1_000_000 },
|
|
.{ .label = "Sample DCP", .weight = 0.60, .value = 1_500_000 },
|
|
};
|
|
const result = zfin.analysis.AnalysisResult{
|
|
.asset_category = &.{},
|
|
.asset_class = &.{},
|
|
.sector = &.{},
|
|
.geo = &.{},
|
|
.account = &account,
|
|
.tax_type = &.{},
|
|
.unclassified = &.{},
|
|
.total_value = 2_500_000,
|
|
};
|
|
|
|
var am = try zfin.analysis.parseAccountsFile(testing.allocator,
|
|
\\#!srfv1
|
|
\\account::Sample IRA,tax_type::traditional
|
|
\\account::Sample DCP,tax_type::traditional,shielded:bool:false
|
|
);
|
|
defer am.deinit();
|
|
|
|
const lines = try renderAnalysisLines(arena, th, result, 1.0, 0, 0, 2_500_000, .mid, am);
|
|
|
|
// IRA shielded ($1M); DCP exposed ($1.5M). Total $2.5M.
|
|
var found_shielded_1m = false;
|
|
var found_exposed_1_5m = false;
|
|
var found_60_pct = false;
|
|
for (lines) |l| {
|
|
if (std.mem.indexOf(u8, l.text, "Shielded") != null and std.mem.indexOf(u8, l.text, "$1,000,000") != null) found_shielded_1m = true;
|
|
if (std.mem.indexOf(u8, l.text, "Exposed") != null and std.mem.indexOf(u8, l.text, "$1,500,000") != null) found_exposed_1_5m = true;
|
|
if (std.mem.indexOf(u8, l.text, "60.0%") != null) found_60_pct = true;
|
|
}
|
|
try testing.expect(found_shielded_1m);
|
|
try testing.expect(found_exposed_1_5m);
|
|
try testing.expect(found_60_pct);
|
|
}
|