516 lines
22 KiB
Zig
516 lines
22 KiB
Zig
const std = @import("std");
|
|
const zfin = @import("../root.zig");
|
|
const cli = @import("common.zig");
|
|
const framework = @import("framework.zig");
|
|
const fmt = cli.fmt;
|
|
const Money = @import("../Money.zig");
|
|
|
|
pub const ParsedArgs = struct {
|
|
sector_detail: zfin.analysis.Granularity = .fine,
|
|
};
|
|
|
|
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 [opts]
|
|
\\
|
|
\\Show portfolio analysis: equities/fixed-income split, plus
|
|
\\block-bar breakdowns by asset class, sector, geographic
|
|
\\region, account, and tax type. Reads classifications from
|
|
\\`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)
|
|
\\ fine - one row per bucket label (default)
|
|
\\
|
|
\\Run `zfin enrich <portfolio.srf> > metadata.srf` to bootstrap
|
|
\\classifications, then edit by hand.
|
|
\\
|
|
,
|
|
.uppercase_first_arg = false,
|
|
.user_errors = error{ UnexpectedArg, InvalidSectorDetail },
|
|
};
|
|
|
|
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
|
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, "fine")) {
|
|
parsed.sector_detail = .fine;
|
|
} else {
|
|
cli.stderrPrint(ctx.io, "Error: --sector-detail must be one of: coarse, fine\n");
|
|
return error.InvalidSectorDetail;
|
|
}
|
|
} else {
|
|
cli.stderrPrint(ctx.io, "Error: 'analysis' takes no positional arguments\n");
|
|
return error.UnexpectedArg;
|
|
}
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
/// CLI `analysis` command: show portfolio analysis breakdowns.
|
|
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;
|
|
const out = ctx.out;
|
|
const color = ctx.color;
|
|
const as_of = ctx.today;
|
|
|
|
var loaded = cli.loadPortfolio(ctx, as_of) orelse return;
|
|
defer loaded.deinit(allocator);
|
|
|
|
const portfolio = loaded.portfolio;
|
|
const positions = loaded.positions;
|
|
const syms = loaded.syms;
|
|
const anchor_path = loaded.anchor();
|
|
|
|
// Refresh per-symbol prices via the parallel loader so analysis
|
|
// works on TTL-fresh data by default. Previously this read
|
|
// `getCachedCandles` directly, which silently used stale data
|
|
// after long weekends or when the cache hadn't been refreshed.
|
|
// The loader emits a stderr summary line ("Loaded N symbols
|
|
// (X cached, Y server, Z provider)").
|
|
var prices = std.StringHashMap(f64).init(allocator);
|
|
defer prices.deinit();
|
|
if (syms.len > 0) {
|
|
var load_result = cli.loadPortfolioPrices(io, svc, syms, &.{}, ctx.globals.refresh_policy, color);
|
|
defer load_result.deinit();
|
|
var it = load_result.prices.iterator();
|
|
while (it.next()) |entry| {
|
|
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
|
|
}
|
|
}
|
|
|
|
// Build summary via shared pipeline
|
|
var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) {
|
|
error.NoAllocations, error.SummaryFailed => {
|
|
cli.stderrPrint(io, "Error computing portfolio summary.\n");
|
|
return;
|
|
},
|
|
else => return err,
|
|
};
|
|
defer pf_data.deinit(allocator);
|
|
|
|
// Load classification metadata. Sibling files (metadata.srf,
|
|
// accounts.srf) live next to the anchor portfolio file, even
|
|
// when multiple portfolio_*.srf files are loaded.
|
|
const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0;
|
|
const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{anchor_path[0..dir_end]}) catch return;
|
|
defer allocator.free(meta_path);
|
|
|
|
const meta_data = std.Io.Dir.cwd().readFileAlloc(io, meta_path, allocator, .limited(1024 * 1024)) catch {
|
|
cli.stderrPrint(io, "Error: No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf\n");
|
|
return;
|
|
};
|
|
defer allocator.free(meta_data);
|
|
|
|
var cm = zfin.classification.parseClassificationFile(allocator, meta_data) catch {
|
|
cli.stderrPrint(io, "Error: Cannot parse metadata.srf\n");
|
|
return;
|
|
};
|
|
defer cm.deinit();
|
|
|
|
// Load account tax type metadata (optional). Anchor-derived path
|
|
// is correct: accounts.srf is one-per-portfolio-set.
|
|
var acct_map_opt: ?zfin.analysis.AccountMap = svc.loadAccountMap(allocator, anchor_path);
|
|
defer if (acct_map_opt) |*am| am.deinit();
|
|
|
|
var result = zfin.analysis.analyzePortfolio(
|
|
allocator,
|
|
pf_data.summary.allocations,
|
|
cm,
|
|
portfolio,
|
|
pf_data.summary.total_value,
|
|
acct_map_opt,
|
|
as_of,
|
|
) catch {
|
|
cli.stderrPrint(io, "Error computing analysis.\n");
|
|
return;
|
|
};
|
|
defer result.deinit(allocator);
|
|
|
|
const benchmark = @import("../analytics/benchmark.zig");
|
|
const split = benchmark.deriveAllocationSplit(
|
|
pf_data.summary.allocations,
|
|
cm.entries,
|
|
pf_data.summary.total_value,
|
|
portfolio.totalCash(as_of),
|
|
portfolio.totalCdFaceValue(as_of),
|
|
);
|
|
|
|
var label_buf: [512]u8 = undefined;
|
|
const display_label: []const u8 = if (loaded.paths.len > 1)
|
|
std.fmt.bufPrint(&label_buf, "{s} (+{d} more)", .{ anchor_path, loaded.paths.len - 1 }) catch anchor_path
|
|
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, acct_map_opt, color, out);
|
|
}
|
|
|
|
fn display(result: zfin.analysis.AnalysisResult, stock_pct: f64, bond_pct: f64, cash_pct: f64, total_value: f64, file_path: []const u8, account_map: ?zfin.analysis.AccountMap, color: bool, out: *std.Io.Writer) !void {
|
|
const label_width = fmt.analysis_label_width;
|
|
const bar_width = fmt.analysis_bar_width;
|
|
|
|
try cli.printBold(out, color, "\nPortfolio Analysis ({s})\n", .{file_path});
|
|
try out.print("========================================\n\n", .{});
|
|
|
|
// Equities / Fixed Income / Cash header summary. The Other
|
|
// bucket (derivatives, real property, sentinels) is excluded
|
|
// from this header but appears as its own row in the
|
|
// Asset Category breakdown below.
|
|
{
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " Equities {d:.1}% ({f}) / Fixed Income {d:.1}% ({f}) / Cash {d:.1}% ({f})\n\n", .{
|
|
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),
|
|
});
|
|
}
|
|
|
|
const sections = zfin.analysis.breakdownSections(&result);
|
|
|
|
for (sections, 0..) |sec, si| {
|
|
if (si > 0 and sec.items.len == 0) continue;
|
|
if (si > 0) try out.print("\n", .{});
|
|
// Bold + header color - reset at end of printFg clears both.
|
|
try cli.setBold(out, color);
|
|
try cli.printFg(out, color, cli.CLR_HEADER, " {s}\n", .{sec.title});
|
|
try printBreakdownSection(out, sec.items, label_width, bar_width, color);
|
|
}
|
|
|
|
// Unclassified
|
|
if (result.unclassified.len > 0) {
|
|
try out.print("\n", .{});
|
|
try cli.printFg(out, color, cli.CLR_WARNING, " Unclassified (not in metadata.srf)\n", .{});
|
|
for (result.unclassified) |sym| {
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " {s}\n", .{sym});
|
|
}
|
|
}
|
|
|
|
// Umbrella-insurance exposure section. Computed from the
|
|
// already-aggregated account breakdown plus the per-account
|
|
// tax-type / shielded overrides in accounts.srf. Lives at
|
|
// the bottom because it's a derived summary, not a
|
|
// breakdown - gives the user the load-bearing "what's my
|
|
// umbrella target?" number after the supporting detail.
|
|
if (account_map) |am| {
|
|
try printUmbrellaSection(out, result.account, am, color);
|
|
}
|
|
|
|
try out.print("\n", .{});
|
|
}
|
|
|
|
/// Print the Umbrella exposure section. Shielding decision is
|
|
/// computed per-account by `umbrellaExposure`; this function
|
|
/// only formats the result.
|
|
pub fn printUmbrellaSection(out: *std.Io.Writer, account_breakdown: []const zfin.analysis.BreakdownItem, account_map: zfin.analysis.AccountMap, color: bool) !void {
|
|
const umbrella = zfin.analysis.umbrellaExposure(account_breakdown, account_map);
|
|
if (umbrella.total_liquid <= 0) return; // no accounts -> nothing to display
|
|
|
|
try out.print("\n", .{});
|
|
try cli.setBold(out, color);
|
|
try cli.printFg(out, color, cli.CLR_HEADER, " Umbrella exposure\n", .{});
|
|
|
|
try out.print(" Total liquid: {f}\n", .{Money.from(umbrella.total_liquid)});
|
|
try out.print(" Shielded (retirement accounts): {f}\n", .{Money.from(umbrella.shielded_value)});
|
|
try cli.printFg(out, color, cli.CLR_WARNING, " Exposed (taxable + non-shielded pre-tax): {f} ({d:.1}%)\n", .{ Money.from(umbrella.exposed_value), umbrella.exposed_pct * 100 });
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " ↑ approximate umbrella target\n", .{});
|
|
try out.print("\n", .{});
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " Note: IRA protections vary by state. Analysis assumes\n", .{});
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " protection. Override per-account using `shielded:bool:false`\n", .{});
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " in accounts.srf.\n", .{});
|
|
}
|
|
|
|
/// Print a breakdown section with block-element bar charts to the CLI output.
|
|
pub fn printBreakdownSection(out: *std.Io.Writer, items: []const zfin.analysis.BreakdownItem, label_width: usize, bar_width: usize, color: bool) !void {
|
|
for (items) |item| {
|
|
const pct = item.weight * 100.0;
|
|
|
|
// Build bar using shared function
|
|
var bar_buf: [256]u8 = undefined;
|
|
const bar = fmt.buildBlockBar(&bar_buf, item.weight, bar_width);
|
|
|
|
// Padded label
|
|
const lbl_len = @min(item.label.len, label_width);
|
|
try out.print(" ", .{});
|
|
try out.writeAll(item.label[0..lbl_len]);
|
|
if (lbl_len < label_width) {
|
|
for (0..label_width - lbl_len) |_| try out.writeAll(" ");
|
|
}
|
|
try out.writeAll(" ");
|
|
if (color) try fmt.ansiSetFg(out, cli.CLR_ACCENT[0], cli.CLR_ACCENT[1], cli.CLR_ACCENT[2]);
|
|
try out.writeAll(bar);
|
|
if (color) try fmt.ansiReset(out);
|
|
try out.print(" {d:>5.1}% {f}", .{ pct, Money.from(item.value) });
|
|
// Optional trailing annotation (the "By Account" breakdown's
|
|
// per-account harvested figure). Appended after the value so it
|
|
// can't disturb the label or bar alignment. Unstyled - the
|
|
// parens are the affordance.
|
|
if (item.annotation) |ann| try out.print(" {s}", .{ann.slice()});
|
|
try out.print("\n", .{});
|
|
}
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────
|
|
|
|
test "parseArgs: no args produces empty ParsedArgs" {
|
|
var ctx: framework.RunCtx = undefined;
|
|
ctx.io = std.testing.io;
|
|
const args = [_][]const u8{};
|
|
_ = try parseArgs(&ctx, &args);
|
|
}
|
|
|
|
test "parseArgs: any positional is rejected" {
|
|
var ctx: framework.RunCtx = undefined;
|
|
ctx.io = std.testing.io;
|
|
const args = [_][]const u8{"unexpected"};
|
|
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
|
|
}
|
|
|
|
test "printBreakdownSection single item no color" {
|
|
var buf: [4096]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
const items = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "US Large Cap", .weight = 0.60, .value = 60000.0 },
|
|
};
|
|
try printBreakdownSection(&w, &items, 24, 30, false);
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "US Large Cap") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "60.0%") != null);
|
|
// No ANSI when color=false
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
|
}
|
|
|
|
test "printBreakdownSection multiple items" {
|
|
var buf: [4096]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
const items = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "Stocks", .weight = 0.70, .value = 70000.0 },
|
|
.{ .label = "Bonds", .weight = 0.20, .value = 20000.0 },
|
|
.{ .label = "Cash", .weight = 0.10, .value = 10000.0 },
|
|
};
|
|
try printBreakdownSection(&w, &items, 24, 30, false);
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Stocks") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Bonds") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Cash") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "70.0%") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "10.0%") != null);
|
|
}
|
|
|
|
test "printBreakdownSection zero weight" {
|
|
var buf: [4096]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
const items = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "Empty", .weight = 0.0, .value = 0.0 },
|
|
};
|
|
try printBreakdownSection(&w, &items, 24, 30, false);
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "0.0%") != null);
|
|
}
|
|
|
|
test "printBreakdownSection with color emits ANSI" {
|
|
var buf: [4096]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
const items = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "Test", .weight = 0.50, .value = 50000.0 },
|
|
};
|
|
try printBreakdownSection(&w, &items, 24, 30, true);
|
|
const out = w.buffered();
|
|
// Should contain ANSI escape for bar color
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null);
|
|
}
|
|
|
|
test "printBreakdownSection: annotation renders after the value" {
|
|
var buf: [4096]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
const items = [_]zfin.analysis.BreakdownItem{
|
|
.{
|
|
.label = "Sample Tax Loss",
|
|
.weight = 0.40,
|
|
.value = 412_300.0,
|
|
.annotation = zfin.analysis.Annotation.from("(45k 6/24)"),
|
|
},
|
|
};
|
|
try printBreakdownSection(&w, &items, 24, 30, false);
|
|
const out = w.buffered();
|
|
const value_at = std.mem.indexOf(u8, out, "$412,300.00").?;
|
|
const ann_at = std.mem.indexOf(u8, out, "(45k 6/24)").?;
|
|
try std.testing.expect(ann_at > value_at);
|
|
// Exactly one row, annotation last before the newline.
|
|
try std.testing.expect(std.mem.endsWith(u8, std.mem.trimEnd(u8, out, "\n"), "(45k 6/24)"));
|
|
}
|
|
|
|
test "printBreakdownSection: null annotation adds nothing" {
|
|
var buf: [4096]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
const items = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "Sample Brokerage", .weight = 0.40, .value = 412_300.0 },
|
|
};
|
|
try printBreakdownSection(&w, &items, 24, 30, false);
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "(") == null);
|
|
try std.testing.expect(std.mem.endsWith(u8, std.mem.trimEnd(u8, out, "\n"), "$412,300.00"));
|
|
}
|
|
|
|
test "display shows all sections" {
|
|
var buf: [8192]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
const asset_category = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "Equity", .weight = 0.80, .value = 80000.0 },
|
|
.{ .label = "Fixed Income", .weight = 0.15, .value = 15000.0 },
|
|
.{ .label = "Cash", .weight = 0.05, .value = 5000.0 },
|
|
};
|
|
const sector = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "Technology", .weight = 0.35, .value = 35000.0 },
|
|
};
|
|
const geo = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "US", .weight = 0.80, .value = 80000.0 },
|
|
};
|
|
const empty = [_]zfin.analysis.BreakdownItem{};
|
|
const unclassified = [_][]const u8{"WEIRD"};
|
|
const result: zfin.analysis.AnalysisResult = .{
|
|
.asset_category = @constCast(&asset_category),
|
|
.sector = @constCast(§or),
|
|
.geo = @constCast(&geo),
|
|
.account = @constCast(&empty),
|
|
.tax_type = @constCast(&empty),
|
|
.unclassified = @constCast(&unclassified),
|
|
.total_value = 100000.0,
|
|
};
|
|
try display(result, 0.80, 0.15, 0.05, 100000.0, "test.srf", null, false, &w);
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Analysis") != null);
|
|
// 3-up header includes Cash.
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Equities 80.0%") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Fixed Income 15.0%") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Cash 5.0%") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Asset Category") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Sector") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Technology") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Geographic") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Unclassified") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "WEIRD") != null);
|
|
// No ANSI when color=false
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
|
// Umbrella section is suppressed when account_map is null
|
|
// (no shielded/exposed split possible without tax_type info).
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Umbrella exposure") == null);
|
|
}
|
|
|
|
test "printUmbrellaSection: emits Umbrella exposure block with shielded + exposed dollars" {
|
|
var buf: [4096]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
|
|
var am = try zfin.analysis.parseAccountsFile(std.testing.allocator,
|
|
\\#!srfv1
|
|
\\account::Sample IRA,tax_type::traditional
|
|
\\account::Sample DCP,tax_type::traditional,shielded:bool:false
|
|
\\account::Sample Brokerage,tax_type::taxable
|
|
);
|
|
defer am.deinit();
|
|
|
|
const accounts = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "Sample IRA", .value = 1_000_000, .weight = 0.50 },
|
|
.{ .label = "Sample DCP", .value = 500_000, .weight = 0.25 },
|
|
.{ .label = "Sample Brokerage", .value = 500_000, .weight = 0.25 },
|
|
};
|
|
|
|
try printUmbrellaSection(&w, &accounts, am, false);
|
|
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Umbrella exposure") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Total liquid") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Shielded") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Exposed") != null);
|
|
// Shielded = IRA only = $1M; exposed = DCP + Brokerage = $1M.
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "$1,000,000") != null);
|
|
// Exposed pct = 50%.
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "50.0%") != null);
|
|
// Note text appears.
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "IRA protections vary by state") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "shielded:bool:false") != null);
|
|
}
|
|
|
|
test "printUmbrellaSection: empty accounts produces no output" {
|
|
var buf: [4096]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
|
|
var am = try zfin.analysis.parseAccountsFile(std.testing.allocator,
|
|
\\#!srfv1
|
|
\\account::Sample IRA,tax_type::traditional
|
|
);
|
|
defer am.deinit();
|
|
|
|
try printUmbrellaSection(&w, &.{}, am, false);
|
|
// No "Umbrella exposure" line should appear when there's
|
|
// nothing to show.
|
|
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "Umbrella exposure") == null);
|
|
}
|
|
|
|
test "display: includes umbrella section when account_map is provided" {
|
|
var buf: [8192]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
|
|
const asset_category = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "Equity", .weight = 0.80, .value = 80_000 },
|
|
};
|
|
const account = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "Sample IRA", .weight = 0.60, .value = 60_000 },
|
|
.{ .label = "Sample Brokerage", .weight = 0.40, .value = 40_000 },
|
|
};
|
|
const empty = [_]zfin.analysis.BreakdownItem{};
|
|
|
|
const result: zfin.analysis.AnalysisResult = .{
|
|
.asset_category = @constCast(&asset_category),
|
|
.sector = @constCast(&empty),
|
|
.geo = @constCast(&empty),
|
|
.account = @constCast(&account),
|
|
.tax_type = @constCast(&empty),
|
|
.unclassified = &.{},
|
|
.total_value = 100_000,
|
|
};
|
|
|
|
var am = try zfin.analysis.parseAccountsFile(std.testing.allocator,
|
|
\\#!srfv1
|
|
\\account::Sample IRA,tax_type::traditional
|
|
\\account::Sample Brokerage,tax_type::taxable
|
|
);
|
|
defer am.deinit();
|
|
|
|
try display(result, 0.80, 0.0, 0.0, 100_000, "test.srf", am, false, &w);
|
|
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Umbrella exposure") != null);
|
|
// Shielded = IRA $60k; exposed = Brokerage $40k.
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "$60,000") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "$40,000") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "40.0%") != null);
|
|
}
|