zfin/src/analytics/analysis.zig

3320 lines
144 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// Portfolio analysis engine.
///
/// Takes portfolio allocations (with market values) and classification metadata,
/// produces breakdowns by asset class, sector, geographic region, account, and tax type.
const std = @import("std");
const builtin = @import("builtin");
const srf = @import("srf");
const srf_opts = @import("../srf_opts.zig");
const Allocation = @import("valuation.zig").Allocation;
const ClassificationMap = @import("../models/classification.zig").ClassificationMap;
const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry;
const Portfolio = @import("../models/portfolio.zig").Portfolio;
const Date = @import("../Date.zig");
const fmt = @import("../format.zig");
const log = std.log.scoped(.accounts);
/// A single slice of a breakdown (e.g., "Technology" -> 25.3%)
pub const BreakdownItem = struct {
label: []const u8,
value: f64, // dollar amount
weight: f64, // fraction of total (0.0 - 1.0)
/// Optional compact suffix rendered after the dollar value.
/// Only the "By Account" breakdown populates it (see
/// `annotateAccountBreakdown`); every other breakdown leaves it
/// null.
annotation: ?Annotation = null,
};
/// Pre-rendered compact annotation for a breakdown row.
///
/// A value type on purpose: `BreakdownItem.label` is *borrowed* (it
/// points into the aggregation map's keys, which point into lot /
/// accounts.srf strings) and `AnalysisResult.deinit` frees only the
/// item slices, never any strings. An allocated annotation would break
/// that invariant and force an asymmetric free loop over one of the
/// five breakdown slices. Inlining the bytes keeps "BreakdownItem owns
/// nothing" true.
pub const Annotation = struct {
/// Inline capacity in bytes. Sized for the longest annotation any
/// breakdown produces today - `format.harvest_annotation_max_len`
/// ("(999.9M 12/31)", 14 bytes) - rounded up with a little
/// headroom. `annotateAccountBreakdown` asserts the fit at comptime,
/// so widening a producer's format without widening this is a build
/// error rather than a silently-dropped annotation.
pub const capacity = 16;
buf: [capacity]u8,
len: u8,
/// Build from a rendered string. Returns null for an empty string
/// (the "nothing to render" signal from the formatters) so callers
/// can assign the result straight into `BreakdownItem.annotation`.
/// Also returns null rather than truncating when `s` doesn't fit.
pub fn from(s: []const u8) ?Annotation {
if (s.len == 0 or s.len > capacity) return null;
var a: Annotation = .{ .buf = @splat(0), .len = @intCast(s.len) };
@memcpy(a.buf[0..s.len], s);
return a;
}
pub fn slice(self: *const Annotation) []const u8 {
return self.buf[0..self.len];
}
};
/// Tax type classification for accounts.
pub const TaxType = enum {
taxable,
roth,
traditional,
hsa,
pub fn label(self: TaxType) []const u8 {
return switch (self) {
.taxable => "Taxable",
.roth => "Roth (Post-Tax)",
.traditional => "Traditional (Pre-Tax)",
.hsa => "HSA (Triple Tax-Free)",
};
}
};
/// How one account's value is distributed across tax types.
///
/// The overwhelmingly common case is a single type at weight 1.0. That
/// is what an account with no `tax_mix_*` carve-outs produces, and it
/// makes every consumer behave exactly as it did before mixed
/// treatment existed.
///
/// The motivating case for anything else is an employer 401(k) that
/// reports one balance for a sleeve holding pre-tax deferrals, the
/// employer match, in-plan Roth, and after-tax/backdoor Roth money all
/// at once. The plan will not break the balance out per source, so
/// declaring "this account is 22.4% Roth" is the only way to get an
/// honest pre-tax vs post-tax picture out of it.
///
/// Weights sum to 1.0 (up to float rounding).
pub const TaxMix = struct {
/// Indexed by `@intFromEnum(TaxType)`.
weights: [type_count]f64,
pub const type_count = @typeInfo(TaxType).@"enum".fields.len;
/// The whole account is one tax type - the no-carve-outs default.
pub fn single(t: TaxType) TaxMix {
var w = [_]f64{0} ** type_count;
w[@intFromEnum(t)] = 1.0;
return .{ .weights = w };
}
pub fn weightOf(self: TaxMix, t: TaxType) f64 {
return self.weights[@intFromEnum(t)];
}
/// Share of the account that is not `.taxable`. This is the
/// umbrella-exposure default rule ("anything but taxable is
/// judgment-shielded") generalized to a fraction.
pub fn shieldedWeight(self: TaxMix) f64 {
return 1.0 - self.weightOf(.taxable);
}
};
/// Why a declared `tax_mix_*` set was rejected.
///
/// The parser logs these and `zfin doctor` reports them. In both cases
/// the account falls back to its bare `tax_type`, so a bad declaration
/// degrades to the pre-mixed-treatment behavior rather than silently
/// mis-splitting a balance.
pub const TaxMixProblem = enum {
/// A carve-out is zero or negative. Zero means "omit the field";
/// negative is meaningless.
non_positive,
/// A carve-out is nan or inf - only reachable by hand-typing it.
not_finite,
/// A carve-out names the account's own `tax_type`. The primary
/// type's share is always the residual, so naming it explicitly is
/// self-contradictory (and invites a set that sums to 90).
redundant_primary,
/// Carve-outs sum to 100 or more, leaving the primary `tax_type` no
/// share of the account at all - so `tax_type` would be a lie.
over_allocated,
/// Short human-readable cause, for parser warnings and `doctor`.
pub fn label(self: TaxMixProblem) []const u8 {
return switch (self) {
.non_positive => "every tax_mix_* carve-out must be > 0",
.not_finite => "every tax_mix_* carve-out must be a finite number",
.redundant_primary => "tax_mix_* must not name the account's own tax_type",
.over_allocated => "tax_mix_* carve-outs must sum to less than 100",
};
}
};
/// A `TaxMix` plus the reason it fell back, if it did.
pub const CheckedTaxMix = struct {
mix: TaxMix,
problem: ?TaxMixProblem,
};
/// Account tax type classification entry, parsed from accounts.srf.
pub const AccountTaxEntry = struct {
account: []const u8,
tax_type: TaxType,
institution: ?[]const u8 = null,
account_number: ?[]const u8 = null,
update_cadence: UpdateCadence = .weekly,
/// When true, raw cash-balance changes (`cash_delta` in the
/// contributions diff) on this account roll up into the
/// attribution total as real contributions.
///
/// Defaults to false because most cash accounts generate
/// `cash_delta` entries from internal movement - interest posting,
/// dividend credit, CD coupon, settlement sweeps - that would
/// inflate the attribution number if counted. Set to true only
/// for accounts whose cash movement is dominated by external
/// contributions (payroll ESPP accrual, direct 401k cash
/// deposits). See TODO.md for the design history.
cash_is_contribution: bool = false,
/// When true, marks the account as a direct-indexing proxy
/// (lots track a benchmark with tracking-error drift rather
/// than holding the benchmark directly). Two behaviors:
///
/// 1. Contributions (`zfin contributions` / `zfin compare`
/// attribution): the edit-detection residual tolerance is
/// loosened from 0.01% (noise floor) to 1% - tracking-
/// error share reconciliation no longer lands in
/// `rollup_delta` / `drip_negative` and the attribution
/// total stays clean.
///
/// 2. Audit (`zfin audit` ratio-suggestions section): lots
/// with `price_ratio == 1.0` in this account get a
/// suggested ratio to bridge the brokerage vs. portfolio
/// value gap. Default audit behavior skips ratio == 1.0
/// lots since there's nothing to adjust; direct-indexing
/// accounts opt out of that skip.
///
/// Not a general "ignore drift" flag - use only for accounts
/// whose underlying lots explicitly track a benchmark (e.g. a
/// basket of 500 individual stocks tracked as SPY via `ticker::`
/// alias).
direct_indexing: bool = false,
/// Optional umbrella-insurance shielding override. When null,
/// the umbrella-exposure calculation defaults to "tax_type !=
/// taxable means shielded" (a rough proxy for retirement-account
/// status). Set explicitly when the default is wrong:
///
/// - `shielded:bool:false` for pre-tax accounts that are NOT
/// ERISA-protected (e.g. deferred-comp plans like Fidelity
/// DCP, non-qualified annuities) - tax_type is `traditional`
/// so they default to shielded, but they're not protected
/// against civil judgments.
/// - `shielded:bool:true` to mark a taxable account as
/// shielded (rare; e.g. some asset-protection trusts).
///
/// IRA state-by-state protection is not modeled. Users in
/// states with weak IRA protection should set
/// `shielded:bool:false` on their IRA accounts to get a
/// correct umbrella-exposure number.
shielded: ?bool = null,
/// Optional per-account override for the dollar threshold above
/// which `zfin audit` flags a new lot in its "Large new lots -
/// confirm source" section. Null means "use the audit's built-in
/// default" (`contributions.default_audit_large_lot_threshold`, $10k).
///
/// The right knob is per-account because the noise this nudge
/// fights is account-specific: an ESPP/payroll account that
/// accrues routine large lots wants a HIGH threshold to stay
/// quiet, while a taxable brokerage where any sizeable new lot is
/// worth a look wants the default (or lower). Set it higher to cut
/// ESPP spam, lower to catch smaller movements.
///
/// Must be positive; a zero or negative value is rejected at parse
/// time (warned + treated as unset) since zero would flag every
/// new lot and negative is meaningless.
audit_large_lot_threshold: ?f64 = null,
/// Cumulative tax-loss-harvested figure for this account, declared
/// by hand because zfin cannot derive it.
///
/// The motivating case is a synthetic direct-indexing account (see
/// `direct_indexing`): the portfolio models it as one aggregate lot
/// with a `ticker::` alias, deliberately NOT enumerating the
/// hundreds of underlying positions. Without per-position detail
/// there are no closed lots, so `Lot.realizedGainLoss` has nothing
/// to work from - yet the harvested total is the whole point of
/// such an account. This field is where you park the number you
/// read off the brokerage site.
///
/// Display-only. It never enters any total, weight, or breakdown
/// value; it is rendered as a compact annotation next to the
/// account name (`format.fmtHarvestAnnotation`). Sign-insensitive
/// on read - `harvested:num:45300` and `harvested:num:-45300` are
/// equivalent, since the annotation's parens already carry the
/// accounting "this is a loss" convention.
///
/// Requires `harvested_date` to render; see that field.
harvested: ?f64 = null,
/// The "as of" date for `harvested` - when you last copied the
/// figure from the brokerage. Required for the annotation to
/// render at all, because a hand-copied number with no date is
/// indistinguishable from a stale one.
///
/// Entries older than 12 months stop rendering: harvest data that
/// old is not decision-useful, and the cutoff is what lets the
/// annotation show a bare `M/D` with no year (within a trailing
/// year, each month/day pair occurs at most once).
harvested_date: ?Date = null,
/// Percentage of this account's value that is actually `taxable`,
/// `roth`, `traditional`, or `hsa` money despite `tax_type` saying
/// otherwise. See `TaxMix` for why this exists and `taxMixChecked`
/// for the rules.
///
/// These are *carve-outs*: `tax_type` keeps whatever percentage is
/// left over, so the common single-Roth-sleeve case needs exactly
/// one number.
///
/// Percentages rather than dollars on purpose: market appreciation
/// applies proportionally across the sleeves, so a percentage stays
/// correct through market moves and only drifts as you contribute.
/// A dollar figure would go stale daily.
tax_mix_taxable: ?f64 = null,
tax_mix_roth: ?f64 = null,
tax_mix_traditional: ?f64 = null,
tax_mix_hsa: ?f64 = null,
/// The "as of" date for the `tax_mix_*` carve-outs - when you last
/// read the source breakdown off the plan's site.
///
/// Unlike `harvested_date`, this is advisory only: a missing or
/// stale date never suppresses the split, because the mix feeds
/// real breakdown totals rather than a display annotation. Silently
/// changing someone's pre-tax vs post-tax picture because a date
/// aged out would be far worse than showing a slightly stale one.
/// `zfin audit` nags instead; see `audit/hygiene.zig`.
tax_mix_date: ?Date = null,
/// The carve-out percentage declared for `t`, if any. Exhaustive on
/// purpose: adding a `TaxType` variant should fail to compile here
/// until a matching `tax_mix_*` field exists.
fn carveOut(self: AccountTaxEntry, t: TaxType) ?f64 {
return switch (t) {
.taxable => self.tax_mix_taxable,
.roth => self.tax_mix_roth,
.traditional => self.tax_mix_traditional,
.hsa => self.tax_mix_hsa,
};
}
/// Does this account declare any `tax_mix_*` carve-out? True even
/// when the declaration is invalid, because callers that report
/// problems need to know the user tried.
pub fn hasTaxMix(self: AccountTaxEntry) bool {
for (std.enums.values(TaxType)) |t| {
if (self.carveOut(t) != null) return true;
}
return false;
}
/// Resolve `tax_type` plus any `tax_mix_*` carve-outs into a
/// normalized `TaxMix`, reporting the first problem found.
///
/// Rules, all of which fall back to `TaxMix.single(tax_type)`:
/// - each carve-out must be finite and strictly positive
/// - no carve-out may name `tax_type` itself
/// - carve-outs must sum to less than 100
///
/// Falling back rather than clamping is deliberate: a
/// half-understood declaration should behave like no declaration,
/// not like a guess at what the user meant.
pub fn taxMixChecked(self: AccountTaxEntry) CheckedTaxMix {
var weights = [_]f64{0} ** TaxMix.type_count;
var carved: f64 = 0;
var problem: ?TaxMixProblem = null;
for (std.enums.values(TaxType)) |t| {
const pct = self.carveOut(t) orelse continue;
if (t == self.tax_type) {
problem = .redundant_primary;
break;
}
if (!std.math.isFinite(pct)) {
problem = .not_finite;
break;
}
if (pct <= 0) {
problem = .non_positive;
break;
}
carved += pct;
weights[@intFromEnum(t)] = pct / 100.0;
}
if (problem == null and carved >= 100) problem = .over_allocated;
if (problem) |p| return .{ .mix = .single(self.tax_type), .problem = p };
if (carved == 0) return .{ .mix = .single(self.tax_type), .problem = null };
// Residual from the summed stored weights rather than from
// `carved / 100`, so the vector sums as close to 1.0 as f64
// allows.
var others: f64 = 0;
for (weights) |w| others += w;
weights[@intFromEnum(self.tax_type)] = 1.0 - others;
return .{ .mix = .{ .weights = weights }, .problem = null };
}
/// `taxMixChecked` without the diagnostic - the shape consumers want.
pub fn taxMix(self: AccountTaxEntry) TaxMix {
return self.taxMixChecked().mix;
}
};
/// Update cadence for manual account maintenance. Parsed from accounts.srf.
/// Default is `weekly` (fail-open: every account nags until explicitly silenced).
pub const UpdateCadence = enum {
weekly,
monthly,
quarterly,
none,
/// Number of calendar days before an account is considered overdue.
pub fn thresholdDays(self: UpdateCadence) ?u32 {
return switch (self) {
.weekly => 7,
.monthly => 30,
.quarterly => 90,
.none => null,
};
}
pub fn label(self: UpdateCadence) []const u8 {
return switch (self) {
.weekly => "weekly",
.monthly => "monthly",
.quarterly => "quarterly",
.none => "none",
};
}
};
/// Parsed account metadata.
pub const AccountMap = struct {
entries: []AccountTaxEntry,
allocator: std.mem.Allocator,
pub fn deinit(self: *AccountMap) void {
for (self.entries) |e| {
self.allocator.free(e.account);
if (e.institution) |s| self.allocator.free(s);
if (e.account_number) |s| self.allocator.free(s);
}
self.allocator.free(self.entries);
}
/// Look up the tax type label for a given account name.
///
/// This is the account's *primary* type. For accounts with
/// `tax_mix_*` carve-outs it is the majority-by-construction
/// residual holder, not the whole story - callers that need to
/// apportion value across types want `taxMixFor` instead.
pub fn taxTypeFor(self: AccountMap, account: []const u8) []const u8 {
for (self.entries) |e| {
if (std.mem.eql(u8, e.account, account)) {
return e.tax_type.label();
}
}
return "Unknown";
}
/// How `account`'s value is distributed across tax types. Null when
/// the account isn't in the map, so each caller picks its own
/// "unclassified" behavior (the tax-type breakdown emits an
/// "Unknown" row; the umbrella calc assumes exposed).
pub fn taxMixFor(self: AccountMap, account: []const u8) ?TaxMix {
for (self.entries) |e| {
if (std.mem.eql(u8, e.account, account)) {
return e.taxMix();
}
}
return null;
}
/// Find the portfolio account name for a given institution + account number.
pub fn findByInstitutionAccount(self: AccountMap, institution: []const u8, account_number: []const u8) ?[]const u8 {
for (self.entries) |e| {
if (e.institution) |inst| {
if (e.account_number) |num| {
if (std.mem.eql(u8, inst, institution) and std.mem.eql(u8, num, account_number))
return e.account;
}
}
}
return null;
}
/// Is cash-balance movement on `account` treated as a real
/// contribution (vs. internal noise) for the attribution total?
/// Defaults to false when the account isn't in the map.
pub fn cashIsContribution(self: AccountMap, account: []const u8) bool {
for (self.entries) |e| {
if (std.mem.eql(u8, e.account, account)) {
return e.cash_is_contribution;
}
}
return false;
}
/// Is `account` flagged as a direct-indexing proxy? See
/// `AccountTaxEntry.direct_indexing` for the two behaviors this
/// drives. Defaults to false when the account isn't in the map.
pub fn isDirectIndexing(self: AccountMap, account: []const u8) bool {
for (self.entries) |e| {
if (std.mem.eql(u8, e.account, account)) {
return e.direct_indexing;
}
}
return false;
}
/// Per-account override for the audit "Large new lots" dollar
/// threshold. Returns the account's configured value, or null to
/// fall back to the audit's built-in default
/// (`contributions.default_audit_large_lot_threshold`). Null both when
/// the account isn't in the map and when its entry omits the
/// field. Parse guarantees any non-null result is positive.
pub fn largeLotThresholdFor(self: AccountMap, account: []const u8) ?f64 {
for (self.entries) |e| {
if (std.mem.eql(u8, e.account, account)) {
return e.audit_large_lot_threshold;
}
}
return null;
}
/// Hand-declared tax-loss-harvested figure for `account`, plus the
/// date it was last refreshed. See `AccountTaxEntry.harvested`.
///
/// Returns null when the account isn't in the map or its entry
/// omits `harvested`. The amount is already sign-normalized by the
/// parser; `as_of` may still be null (the caller decides whether a
/// dateless figure is renderable - `format.fmtHarvestAnnotation`
/// says no).
pub fn harvestedFor(self: AccountMap, account: []const u8) ?struct { amount: f64, as_of: ?Date } {
for (self.entries) |e| {
if (std.mem.eql(u8, e.account, account)) {
const amt = e.harvested orelse return null;
return .{ .amount = amt, .as_of = e.harvested_date };
}
}
return null;
}
};
/// Populate `items[*].annotation` for a "By Account" breakdown with each
/// account's hand-declared tax-loss-harvested figure. Rows for accounts
/// with no `harvested` entry (or a stale / future-dated / dateless one)
/// are left null and render nothing.
///
/// `as_of` is the reference date for the 12-month staleness window, not
/// necessarily today: a back-dated analysis should not surface a harvest
/// figure recorded after the date being analyzed.
pub fn annotateAccountBreakdown(items: []BreakdownItem, account_map: AccountMap, as_of: Date) void {
comptime std.debug.assert(fmt.harvest_annotation_max_len <= Annotation.capacity);
for (items) |*item| {
const h = account_map.harvestedFor(item.label) orelse continue;
var buf: [fmt.harvest_annotation_max_len]u8 = undefined;
item.annotation = Annotation.from(fmt.fmtHarvestAnnotation(&buf, h.amount, h.as_of, as_of));
}
}
/// Parse an accounts.srf file into an AccountMap.
/// Each record has: account::<NAME>,tax_type::<TYPE>[,institution::<INST>][,account_number::<NUM>][,<flags>]
/// where the optional flags include `audit_large_lot_threshold:num:<DOLLARS>`,
/// `harvested:num:<DOLLARS>`, `harvested_date::<YYYY-MM-DD>`,
/// `tax_mix_<TYPE>:num:<PERCENT>` and `tax_mix_date::<YYYY-MM-DD>`.
pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap {
var entries = std.ArrayList(AccountTaxEntry).empty;
errdefer {
for (entries.items) |e| {
allocator.free(e.account);
if (e.institution) |s| allocator.free(s);
if (e.account_number) |s| allocator.free(s);
}
entries.deinit(allocator);
}
var reader = std.Io.Reader.fixed(data);
var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return error.InvalidData;
defer it.deinit();
while (try it.next()) |fields| {
const entry = fields.to(AccountTaxEntry, srf_opts.user_edited) catch |err| {
// Skip the account rather than losing the whole file, but
// name the error: a dropped account loses its tax type,
// cadence and carve-outs, and every consumer then silently
// falls back to defaults. Quiet under `zig build test`,
// where fixtures feed malformed records on purpose.
if (!builtin.is_test) {
log.warn("accounts.srf: skipping malformed record: {s}", .{@errorName(err)});
}
continue;
};
// A zero/negative large-lot threshold is nonsensical (zero
// flags every new lot; negative is meaningless). Reject it and
// treat the account as unset so the audit uses its default.
const lot_threshold: ?f64 = if (entry.audit_large_lot_threshold) |t| blk: {
if (t > 0) break :blk t;
// No-op under `zig build test`: the parser's own tests feed
// invalid thresholds (0, negative) on purpose to verify they
// are rejected, and the warn spam pollutes test output.
if (!builtin.is_test)
log.warn("accounts.srf: account '{s}': audit_large_lot_threshold must be > 0 (got {d}); ignoring", .{ entry.account, t });
break :blk null;
} else null;
// `harvested` is a magnitude: the annotation's parens carry the
// "this is a loss" convention, so accept either sign from the
// user and normalize. A non-finite value can only arrive from a
// hand-typed `inf`/`nan`; there is nothing sensible to display
// for it, so drop the field rather than render garbage.
const harvested: ?f64 = if (entry.harvested) |h| blk: {
if (std.math.isFinite(h)) break :blk @abs(h);
// Silent under `zig build test`: the parser's own tests feed
// non-finite values on purpose, and the warn spam pollutes
// test output.
if (!builtin.is_test)
log.warn("accounts.srf: account '{s}': harvested must be a finite number (got {d}); ignoring", .{ entry.account, h });
break :blk null;
} else null;
// A declared tax mix that breaks the rules falls back to the
// bare `tax_type`. Warn so a typo isn't invisible; the raw
// fields are deliberately left in place so `zfin doctor` can
// report the same problem against the file the user edits.
if (entry.taxMixChecked().problem) |p| {
// Silent under `zig build test`: the parser's own tests feed
// invalid mixes on purpose to verify the fallback, and the
// warn spam pollutes test output.
if (!builtin.is_test)
log.warn("accounts.srf: account '{s}': {s}; ignoring the tax mix", .{ entry.account, p.label() });
}
// Copy the whole parsed record, then override just the fields
// that need duping or validating. Spelling out every field here
// used to be a footgun: a newly added field with a default would
// silently keep that default instead of the parsed value.
var out = entry;
out.account = try allocator.dupe(u8, entry.account);
out.institution = if (entry.institution) |s| try allocator.dupe(u8, s) else null;
out.account_number = if (entry.account_number) |s| try allocator.dupe(u8, s) else null;
out.audit_large_lot_threshold = lot_threshold;
out.harvested = harvested;
try entries.append(allocator, out);
}
return .{
.entries = try entries.toOwnedSlice(allocator),
.allocator = allocator,
};
}
/// Complete portfolio analysis result.
pub const AnalysisResult = struct {
/// Coarse 4-bucket breakdown: Equity / Fixed Income / Cash / Other.
/// Built by mapping each fine-grained sector through `bucketSector`
/// before aggregation. The right field for portfolio-level
/// debt-to-equity analysis.
asset_category: []BreakdownItem,
/// Breakdown by sector bucket (Technology, US Healthcare ETF,
/// US Large Cap, etc.). Aggregates by `entry.bucket` -
/// pre-filled by parseClassificationFile via `deriveBucket`,
/// or curated by the user. Replaces the historical separate
/// "Asset Class" + "Sector" breakdowns: the bucket is a
/// single semantically-meaningful label that combines what
/// each was trying to express.
sector: []BreakdownItem,
/// Breakdown by geographic region (US, International, etc.)
geo: []BreakdownItem,
/// Breakdown by account name
account: []BreakdownItem,
/// Breakdown by tax type (Taxable, Roth, Traditional, HSA)
tax_type: []BreakdownItem,
/// Positions not covered by classification metadata
unclassified: []const []const u8,
/// Total portfolio value used as denominator
total_value: f64,
pub fn deinit(self: *AnalysisResult, allocator: std.mem.Allocator) void {
allocator.free(self.asset_category);
allocator.free(self.sector);
allocator.free(self.geo);
allocator.free(self.account);
allocator.free(self.tax_type);
allocator.free(self.unclassified);
}
};
/// One section of an analysis breakdown for renderer-agnostic
/// display. Both the CLI (`commands/analysis.zig`) and the TUI
/// (`tui/analysis_tab.zig`) walk the section list returned by
/// `breakdownSections` to build their output. The section list
/// is the single source of truth for which breakdowns appear and
/// in what order; renderers apply their own indent and styling.
pub const Section = struct {
items: []const BreakdownItem,
/// Title with no leading whitespace. Renderers indent.
title: []const u8,
};
/// Single source of truth for analysis-output breakdown
/// sections. Both the CLI display and the TUI tab call this so
/// adding/reordering a section is a one-place edit. Order is
/// from coarsest (Asset Category, 4 buckets) to finest
/// (per-account / per-tax-type).
pub fn breakdownSections(r: *const AnalysisResult) [5]Section {
return .{
.{ .items = r.asset_category, .title = "Asset Category" },
.{ .items = r.sector, .title = "Sector" },
.{ .items = r.geo, .title = "Geographic" },
.{ .items = r.account, .title = "By Account" },
.{ .items = r.tax_type, .title = "By Tax Type" },
};
}
// ── Umbrella-insurance exposure ──────────────────────────────
/// Result of computing umbrella-insurance exposure: the portion
/// of the liquid portfolio that's NOT legally shielded against
/// civil judgments / lawsuits, and therefore needs umbrella
/// coverage.
pub const UmbrellaExposure = struct {
/// Total liquid portfolio value summed from `account_breakdown`.
/// Equals shielded + exposed.
total_liquid: f64,
/// Sum of account values where shielding evaluates to true.
shielded_value: f64,
/// Sum of account values where shielding evaluates to false.
/// This is the approximate umbrella-insurance target.
exposed_value: f64,
/// `exposed_value / total_liquid`, or 0 when `total_liquid` is 0.
exposed_pct: f64,
};
/// Compute umbrella-insurance exposure from the per-account
/// breakdown and the account-tax map.
///
/// Shielding decision per account:
/// - If `entry.shielded` is explicitly set, use that for the whole
/// account - a hand-declared legal fact outranks the tax-type proxy.
/// - Else the shielded share is everything that isn't `taxable`,
/// which for an account with `tax_mix_*` carve-outs is a fraction
/// rather than all-or-nothing.
///
/// Accounts not in `account_map` default to NOT shielded
/// (defensive - if we don't know, assume the value is exposed
/// rather than overstate the user's protection).
///
/// Pure data, no allocation. The arithmetic is straightforward
/// summation; the meaningful logic is the per-account
/// shielded-or-not decision.
pub fn umbrellaExposure(
account_breakdown: []const BreakdownItem,
account_map: AccountMap,
) UmbrellaExposure {
var shielded: f64 = 0;
var exposed: f64 = 0;
for (account_breakdown) |item| {
const frac = accountShieldedFraction(item.label, account_map);
shielded += item.value * frac;
exposed += item.value * (1.0 - frac);
}
const total = shielded + exposed;
const pct = if (total > 0) exposed / total else 0;
return .{
.total_liquid = total,
.shielded_value = shielded,
.exposed_value = exposed,
.exposed_pct = pct,
};
}
/// What share of one account's value is judgment-shielded, in [0, 1].
///
/// Returns 0 (fully exposed) when the account is not in the map - the
/// defensive default. An explicit `shielded::false` / `shielded::true`
/// override applies to the whole account and short-circuits the
/// tax-type proxy, including any `tax_mix_*` carve-outs: it is a
/// statement about the account's legal protection, which a split of its
/// *tax* treatment has no business overriding.
fn accountShieldedFraction(account: []const u8, account_map: AccountMap) f64 {
for (account_map.entries) |e| {
if (!std.mem.eql(u8, e.account, account)) continue;
if (e.shielded) |explicit| return if (explicit) 1.0 else 0.0;
return e.taxMix().shieldedWeight();
}
return 0.0;
}
// ── Sector -> asset-category bucket ────────────────────────────
/// The four coarse asset-category buckets. Returned from
/// `bucketSector` as static `[]const u8` literals so callers can
/// use them as stable HashMap keys without duping.
pub const bucket_equity: []const u8 = "Equity";
pub const bucket_fixed_income: []const u8 = "Fixed Income";
pub const bucket_cash: []const u8 = "Cash";
pub const bucket_other: []const u8 = "Other";
/// Map a sector string to one of four coarse asset-category
/// buckets. Handles three input shapes:
///
/// - **NPORT-P fund-decomposition sectors** of the form
/// `"<assetCat> / <issuerCat>"` (e.g. `"Debt / US Treasury"`,
/// `"Equity / Corporate"`, `"Short-Term Investment Vehicle / Registered Fund"`).
/// These come from EDGAR fund-holdings data via `enrich`.
///
/// - **GICS-style stock sector names** (e.g. `"Technology"`,
/// `"Healthcare"`, `"Financial Services"`). These come from
/// Wikidata via `enrich`'s `canonicalizeSector`.
///
/// - **Plain-English asset-class words** (e.g. `"Bonds"`,
/// `"Diversified"`) that hand-written `metadata.srf` files
/// use for legacy entries. `"Bonds"` -> Fixed Income;
/// `"Diversified"` -> Equity (the word in practice means "S&P
/// 500 / total-market index fund holding all sectors", which
/// is overwhelmingly equity).
///
/// Returns one of `bucket_equity`, `bucket_fixed_income`,
/// `bucket_cash`, or `bucket_other`. Anything unrecognized
/// (sentinels like `"TODO"`, empty string, future label
/// changes) falls through to `bucket_other`.
///
/// Note: `Equity Preferred / *` rolls up to Equity, not Fixed
/// Income. Preferreds trade between stocks and bonds; we lean
/// equity to match how most retail asset-allocation views treat
/// them.
pub fn bucketSector(sector: []const u8) []const u8 {
// NPORT-P shapes: prefix-match on the assetCat half.
// `startsWith` covers both `Equity / *` and `Equity Preferred / *`.
//
// Note on dividend-equity ETFs (SCHD, VYM, DGRO, etc.):
// these bucket as Equity, not Fixed Income, despite their
// bond-like income shape. The Asset Category breakdown
// answers "what's exposed to equity drawdowns?" - and
// dividend funds drop with the market in a 2008-style
// crash. Their calmer risk character (lower volatility,
// shallower drawdown) is real but shows up in the
// per-holding vol/drawdown columns of `review`, not in
// the asset-class taxonomy. See
// docs/explanation/returns-and-performance.md, "Risk
// character vs. asset class".
if (std.mem.startsWith(u8, sector, "Equity")) return bucket_equity;
if (std.mem.startsWith(u8, sector, "Debt")) return bucket_fixed_income;
if (std.mem.startsWith(u8, sector, "Loan")) return bucket_fixed_income;
if (std.mem.startsWith(u8, sector, "Asset-Backed")) return bucket_fixed_income;
if (std.mem.startsWith(u8, sector, "Short-Term Investment Vehicle")) return bucket_cash;
if (std.mem.startsWith(u8, sector, "Repurchase Agreement")) return bucket_cash;
// Plain-English asset-class words (hand-written metadata).
if (std.mem.eql(u8, sector, "Bonds")) return bucket_fixed_income;
if (std.mem.eql(u8, sector, "Cash")) return bucket_cash;
if (std.mem.eql(u8, sector, "Cash & CDs")) return bucket_cash;
if (std.mem.eql(u8, sector, "Options")) return bucket_other;
if (std.mem.eql(u8, sector, "Unclassified")) return bucket_other;
// "Diversified" means "broad equity fund holding all
// sectors" - S&P 500 ETF, total-market index, etc.
if (std.mem.eql(u8, sector, "Diversified")) return bucket_equity;
// GICS stock sector names. Exact match over the canonical 11
// returned by `Wikidata.canonicalizeSector`. The legacy
// `"Financials"` (with 's') from old hand-written entries
// also maps here.
const gics = [_][]const u8{
"Technology",
"Healthcare",
"Financial Services",
"Financials",
"Consumer Cyclical",
"Consumer Defensive",
"Energy",
"Utilities",
"Real Estate",
"Industrials",
"Basic Materials",
"Communication Services",
};
for (gics) |g| if (std.mem.eql(u8, sector, g)) return bucket_equity;
// Strings containing `/` are NPORT-P shapes that didn't match
// any prefix above (e.g. "Direct Real Property / Other",
// "Direct Credit Risk / Other", "Other / Corporate"). Bucket
// these as Other - they're real-property, credit derivatives,
// and miscellaneous categories that don't fit the equity /
// fixed-income / cash trichotomy.
if (std.mem.indexOfScalar(u8, sector, '/') != null) return bucket_other;
// Empty string / explicit sentinels -> Other. Explicit
// because the curated-bucket fallback below would otherwise
// assume any non-empty unknown string is equity.
if (sector.len == 0) return bucket_other;
if (std.mem.eql(u8, sector, "TODO")) return bucket_other;
if (std.mem.eql(u8, sector, "Unknown")) return bucket_other;
// Word-content checks for composite bucket strings produced by
// `deriveBucket` (or hand-curated `bucket::` overrides):
// "US Bonds", future "International Bonds" / "EM Bonds" -> Fixed Income
// "US Cash", "Cash & CDs" (handled above) -> Cash
if (std.mem.endsWith(u8, sector, " Bonds") or std.mem.endsWith(u8, sector, " bonds")) {
return bucket_fixed_income;
}
if (std.mem.endsWith(u8, sector, " Cash") or std.mem.endsWith(u8, sector, " cash")) {
return bucket_cash;
}
// Default for any remaining no-`/` non-cruft string: equity.
// Catches curated buckets like "US Large Cap", "US Mid Cap",
// "US Small Cap", "US Dividend Equity", "US Healthcare ETF",
// "International Developed", "Emerging Markets", and any
// future user-defined bucket. The convention is: composite
// buckets describe an equity sleeve unless they explicitly
// say otherwise (Bonds/Cash/Options/Unclassified handled
// above).
return bucket_equity;
}
// ── Sector display granularity ───────────────────────────────
/// Granularity tier for the Sector breakdown display. Two
/// tiers: `coarse` (4 macro buckets - Equity / Fixed Income /
/// Cash / Other) and `fine` (the raw bucket strings the
/// classification layer produced - every "US Large Cap" / "US
/// Bonds" / GICS-sector / etc. row distinct).
///
/// History: this used to be a three-tier enum (coarse / mid /
/// fine). The middle tier collapsed NPORT-P sub-flavors (all
/// Debt / * -> "Bonds", all Asset-Backed / * -> "Bonds", etc.)
/// while keeping GICS sectors distinct. After the bucket
/// commit, classification rows expose a single curated bucket
/// label per entry - so the NPORT-P-flavor collapse the mid
/// tier did is now done at parse time. Mid and fine ended up
/// nearly identical and mid was dropped.
pub const Granularity = enum {
/// Four buckets: Equity / Fixed Income / Cash / Other.
/// Same labels as the Asset Category breakdown.
coarse,
/// One row per distinct bucket label - the raw shape of
/// what `entry.bucket` produces. Default. This is what
/// the user wants for "what are my actual positions?"
fine,
};
/// Display-friendly abbreviations for sector labels that don't fit
/// cleanly in narrow columns. Returns the input unchanged when no
/// abbreviation is registered for it; consumers that need a fixed
/// width should also pass the result through `format.truncateToCols`.
///
/// Single source of truth for both the analysis tab's sector
/// breakdown rows and the review tab's per-holding sector cells.
/// Add new abbreviations here when a sector label keeps overflowing
/// the columns it lives in.
pub fn abbreviateSector(s: []const u8) []const u8 {
if (std.mem.eql(u8, s, "Communication Services")) return "Comm. Services";
return s;
}
/// Map a sector string through the chosen granularity. Returns
/// a static literal (at coarse) or the input slice (at fine)
/// suitable for use as a stable HashMap key.
///
/// Granularity tiers:
///
/// - **coarse**: delegates to `bucketSector` - Equity / Fixed Income
/// / Cash / Other (4 buckets).
/// - **fine**: passthrough - returns the input unchanged.
pub fn collapseSector(sector: []const u8, granularity: Granularity) []const u8 {
return switch (granularity) {
.fine => sector,
.coarse => bucketSector(sector),
};
}
/// Compute portfolio analysis from allocations and classification metadata.
/// `allocations` are the stock/ETF positions with market values.
/// `classifications` is the metadata file data.
/// `portfolio` is the full portfolio (for cash/CD/illiquid totals).
/// `account_map` is optional account tax type metadata.
/// `as_of` is the date against which lot open/closed status is
/// evaluated. Pass `null` to use wall-clock today (the default for
/// interactive commands); historical snapshot backfill passes the
/// target date so lots opened/closed/matured between `as_of` and today
/// are counted correctly.
pub fn analyzePortfolio(
allocator: std.mem.Allocator,
allocations: []const Allocation,
classifications: ClassificationMap,
portfolio: Portfolio,
total_portfolio_value: f64,
account_map: ?AccountMap,
as_of: Date,
) !AnalysisResult {
// Accumulators: label -> dollar amount.
//
// sector_map and asset_cat_map are both keyed by the
// `bucket` field on ClassificationEntry (pre-filled by
// parseClassificationFile via deriveBucket). Buckets are
// either user-curated, GICS-like sectors, or composite
// "{geo} {asset_class}" labels - meaningful units for
// concentration rollup. The raw `entry.sector` is no
// longer used for either map: NPORT-P fund-decomp
// categories ("Equity / Corporate") would lump genuinely
// different funds together.
var sector_map = std.StringHashMap(f64).init(allocator);
defer sector_map.deinit();
// 4-bucket coarse breakdown (Equity/Fixed Income/Cash/Other).
// Keys are static literals from `bucketSector`, no dupe needed.
var asset_cat_map = std.StringHashMap(f64).init(allocator);
defer asset_cat_map.deinit();
var geo_map = std.StringHashMap(f64).init(allocator);
defer geo_map.deinit();
var acct_map = std.StringHashMap(f64).init(allocator);
defer acct_map.deinit();
var tax_map = std.StringHashMap(f64).init(allocator);
defer tax_map.deinit();
var unclassified_list = std.ArrayList([]const u8).empty;
errdefer unclassified_list.deinit(allocator);
// Process each equity allocation (for sector, geo, unclassified)
for (allocations) |alloc| {
const mv = alloc.market_value;
if (mv <= 0) continue;
// Find classification entries for this symbol.
//
// Match on `alloc.symbol` only - the canonical economic
// identity (priceSymbol(): the `ticker::` alias when set,
// else the raw symbol/CUSIP). `display_symbol` is a
// display-only concern (an explicit `label::`, else
// priceSymbol) and must NEVER be a classification key:
// a free-text annotation that silently changed what
// classifies would be a footgun. Keying on `alloc.symbol`
// keeps this engine, review's `bucketForSymbol`, and
// doctor's `classifiableSymbols` all matching on
// priceSymbol().
var found = false;
for (classifications.entries) |entry| {
if (std.mem.eql(u8, entry.symbol, alloc.symbol)) {
found = true;
const frac = entry.pct / 100.0;
const portion = mv * frac;
// Sector breakdown: roll up by bucket (the
// pre-filled deriveBucket result on the entry).
if (entry.bucket) |b| {
const prev = sector_map.get(b) orelse 0;
try sector_map.put(b, prev + portion);
}
// Asset Category 4-bucket coarse breakdown
// (Equity / Fixed Income / Cash / Other) keeps
// using the raw `entry.sector` as input. Reasons:
// 1. `bucketSector` recognizes the NPORT-P
// prefixes ("Equity / *", "Debt / *", etc.)
// directly. The user-facing Sector breakdown
// bucket might be "US ETF" (a composite that
// doesn't carry the asset-type signal),
// but the underlying sector still does.
// 2. The Asset Category breakdown is the
// coarse "what's exposed to equity drawdowns?"
// view - invariant to the user's bucket
// curation, since it's a fundamental property
// of the holding.
if (entry.sector) |s| {
const cat = bucketSector(s);
const cprev = asset_cat_map.get(cat) orelse 0;
try asset_cat_map.put(cat, cprev + portion);
} else if (entry.asset_class) |ac| {
const cat = bucketAssetClass(ac);
const cprev = asset_cat_map.get(cat) orelse 0;
try asset_cat_map.put(cat, cprev + portion);
}
if (entry.geo) |g| {
const prev = geo_map.get(g) orelse 0;
try geo_map.put(g, prev + portion);
}
}
}
if (!found) {
try unclassified_list.append(allocator, alloc.display_symbol);
}
}
// Build symbol -> (current_price, price_ratio) lookup from allocations.
// For unmerged allocations, current_price already includes price_ratio (preadjusted).
// For merged allocations, current_price is the base-ticker price (not preadjusted).
const PriceEntry = struct { price: f64, is_preadjusted: bool };
var price_lookup = std.StringHashMap(PriceEntry).init(allocator);
defer price_lookup.deinit();
for (allocations) |alloc| {
try price_lookup.put(alloc.symbol, .{
.price = alloc.current_price,
.is_preadjusted = alloc.price_ratio != 1.0,
});
}
// Account breakdown from individual lots (avoids "Multiple" aggregation issue).
// Use `lotIsOpenAsOf(as_of)` so backfilled snapshots correctly include/
// exclude lots based on the target date. For "live" callers the right
// thing is to pass today; the resolution happens at the call site.
for (portfolio.lots) |lot| {
if (!lot.lotIsOpenAsOf(as_of)) continue;
const acct = lot.account orelse continue;
const value: f64 = switch (lot.security_type) {
.stock => blk: {
if (price_lookup.get(lot.priceSymbol())) |entry| {
break :blk lot.marketValue(entry.price, entry.is_preadjusted);
} else {
// Fallback to open_price (already in lot-specific terms)
break :blk lot.shares * lot.open_price;
}
},
.cash => lot.shares,
.cd => lot.shares, // face value
// Premium at open. `multiplier` (100 for standard US equity
// options) is NOT optional - omitting it counted 1/100th of
// the premium and left every option-holding account's row
// short by 99% of it, so `zfin analysis`'s By Account
// section disagreed with its own Options sector row and
// with the `kind::account` totals in every snapshot.
//
// Must stay identical to `Portfolio.nonStockValueForAccount`
// and the `.option` arm of `commands/snapshot.zig:buildSnapshot`.
// All three use `@abs`, so a WRITTEN (short) option counts as
// a positive asset rather than a liability. That is a
// deliberate shared convention, not an oversight - do not
// "fix" it at one site alone.
.option => @abs(lot.shares) * lot.open_price * lot.multiplier,
.illiquid, .watch => continue,
};
const prev = acct_map.get(acct) orelse 0;
try acct_map.put(acct, prev + value);
}
// Add non-stock holdings (cash, CDs, options) into the
// coarse asset_category breakdown. They have no entry in
// the classification map (it's keyed by ticker), so we
// route them to coarse buckets directly.
const cash_total = portfolio.totalCash(as_of);
const cd_total = portfolio.totalCdFaceValue(as_of);
const cash_cd_total = cash_total + cd_total;
if (cash_cd_total > 0) {
const gprev = geo_map.get("US") orelse 0;
try geo_map.put("US", gprev + cash_cd_total);
// Literal cash and CDs roll into the coarse Cash bucket.
const bprev = asset_cat_map.get(bucket_cash) orelse 0;
try asset_cat_map.put(bucket_cash, bprev + cash_cd_total);
// Also surface in the Sector breakdown as "Cash & CDs"
// so users with significant cash positions see the
// line. Without this, the Sector breakdown would
// silently omit cash entirely.
const sprev = sector_map.get("Cash & CDs") orelse 0;
try sector_map.put("Cash & CDs", sprev + cash_cd_total);
}
const opt_total = portfolio.totalOptionCost(as_of);
if (opt_total > 0) {
// Options are derivatives; coarse bucket is Other.
const bprev = asset_cat_map.get(bucket_other) orelse 0;
try asset_cat_map.put(bucket_other, bprev + opt_total);
// Surface in Sector breakdown too.
const sprev = sector_map.get("Options") orelse 0;
try sector_map.put("Options", sprev + opt_total);
}
// Tax type breakdown: apportion each account's total across the tax
// types it actually holds (see `TaxMix`). Most accounts are a single
// type at weight 1.0 and land on exactly one row. Accounts absent
// from accounts.srf collapse into a single "Unknown" row.
if (account_map) |am| {
var acct_iter = acct_map.iterator();
while (acct_iter.next()) |kv| {
const value = kv.value_ptr.*;
const mix = am.taxMixFor(kv.key_ptr.*) orelse {
const prev = tax_map.get("Unknown") orelse 0;
try tax_map.put("Unknown", prev + value);
continue;
};
for (std.enums.values(TaxType)) |t| {
const w = mix.weightOf(t);
if (w <= 0) continue;
const label = t.label();
const prev = tax_map.get(label) orelse 0;
try tax_map.put(label, prev + value * w);
}
}
}
// Convert maps to sorted slices
const total = if (total_portfolio_value > 0) total_portfolio_value else 1.0;
// Hoisted so the per-account harvested annotations can be stamped
// in before the result is returned. `analyzePortfolio` already has
// both inputs the annotation needs (`account_map` and `as_of`), so
// no renderer needs to learn about accounts.srf.
const account_items = try mapToSortedBreakdown(allocator, acct_map, total);
if (account_map) |am| annotateAccountBreakdown(account_items, am, as_of);
return .{
.asset_category = try mapToSortedBreakdown(allocator, asset_cat_map, total),
.sector = try mapToSortedBreakdown(allocator, sector_map, total),
.geo = try mapToSortedBreakdown(allocator, geo_map, total),
.account = account_items,
.tax_type = try mapToSortedBreakdown(allocator, tax_map, total),
.unclassified = try unclassified_list.toOwnedSlice(allocator),
.total_value = total_portfolio_value,
};
}
/// Convert a label->value HashMap to a sorted BreakdownItem slice (descending by value).
fn mapToSortedBreakdown(
allocator: std.mem.Allocator,
map: std.StringHashMap(f64),
total: f64,
) ![]BreakdownItem {
var items = std.ArrayList(BreakdownItem).empty;
errdefer items.deinit(allocator);
var iter = map.iterator();
while (iter.next()) |kv| {
try items.append(allocator, .{
.label = kv.key_ptr.*,
.value = kv.value_ptr.*,
.weight = kv.value_ptr.* / total,
});
}
// Sort descending by value
std.mem.sort(BreakdownItem, items.items, {}, struct {
fn f(_: void, a: BreakdownItem, b: BreakdownItem) bool {
return a.value > b.value;
}
}.f);
return items.toOwnedSlice(allocator);
}
/// Re-aggregate a raw `BreakdownItem` slice through `collapseSector`
/// at the chosen granularity. Multiple input rows that map to the
/// same coarser bucket sum into one output row. Returns a new
/// allocator-owned slice; caller frees with `allocator.free`.
///
/// Use case: `analyze` produces `result.sector` at fine
/// granularity (raw NPORT-P + GICS labels). Display callers
/// (CLI `--sector-detail`, TUI hot-key) call this to re-bucket
/// at their chosen tier. At `.fine`, the function still
/// allocates a fresh slice (so callers always free the result
/// the same way) but the per-row labels and weights are
/// identical to the input.
pub fn collapseBreakdownAtGranularity(
allocator: std.mem.Allocator,
items: []const BreakdownItem,
granularity: Granularity,
total: f64,
) ![]BreakdownItem {
var map = std.StringHashMap(f64).init(allocator);
defer map.deinit();
for (items) |item| {
const label = collapseSector(item.label, granularity);
const prev = map.get(label) orelse 0;
try map.put(label, prev + item.value);
}
return mapToSortedBreakdown(allocator, map, total);
}
test "parseAccountsFile" {
const data =
\\#!srfv1
\\account::Sample Roth,tax_type::roth
\\account::Sample Trust,tax_type::taxable
\\account::Sample HSA,tax_type::hsa
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 3), am.entries.len);
try std.testing.expectEqualStrings("Roth (Post-Tax)", am.taxTypeFor("Sample Roth"));
try std.testing.expectEqualStrings("Taxable", am.taxTypeFor("Sample Trust"));
try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", am.taxTypeFor("Sample HSA"));
try std.testing.expectEqualStrings("Unknown", am.taxTypeFor("Nonexistent"));
}
test "parseAccountsFile: institution + account_number round-trip via findByInstitutionAccount" {
// The import command's WF resolver, the audit reconciler's
// schwab/fidelity match logic, and the snapshot writer all
// depend on `findByInstitutionAccount` finding entries that
// were parsed from `accounts.srf`. Pin the round-trip so a
// future change to either parseAccountsFile or
// findByInstitutionAccount can't silently drop the link.
const data =
\\#!srfv1
\\account::Sample Fidelity Brokerage,tax_type::taxable,institution::fidelity,account_number::Z123
\\account::Schwab Trust,tax_type::taxable,institution::schwab,account_number::1234
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
try std.testing.expectEqualStrings("Sample Fidelity Brokerage", am.findByInstitutionAccount("fidelity", "Z123").?);
try std.testing.expectEqualStrings("Schwab Trust", am.findByInstitutionAccount("schwab", "1234").?);
// Wrong institution / wrong number -> null.
try std.testing.expect(am.findByInstitutionAccount("schwab", "Z123") == null);
try std.testing.expect(am.findByInstitutionAccount("fidelity", "ZZZ") == null);
}
test "parseAccountsFile: cash_is_contribution default false, opt-in true" {
const data =
\\#!srfv1
\\account::Riley ESPP,tax_type::taxable,cash_is_contribution:bool:true
\\account::Joint cash,tax_type::taxable
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
// Opted-in account
try std.testing.expect(am.cashIsContribution("Riley ESPP"));
// Default-off account
try std.testing.expect(!am.cashIsContribution("Joint cash"));
// Unknown account defaults to false
try std.testing.expect(!am.cashIsContribution("Nonexistent"));
}
test "parseAccountsFile: a hand-typed string separator on a numeric field still parses" {
// `accounts.srf` is hand-edited, so `harvested::5000` instead of
// `harvested:num:5000` is a slip rather than different intent. Under
// SRF's strict default that string reaches an unchecked
// `val.?.number` - a panic in Debug and undefined behaviour in
// ReleaseFast - which is why this parser opts into
// `srf_opts.user_edited`. Pinned because the option is easy to drop
// and the failure is silent in the build zfin actually ships.
const data =
\\#!srfv1
\\account::Sample Brokerage,tax_type::taxable,harvested::5000,harvested_date::2026-06-01
\\account::Sample IRA,tax_type::traditional,audit_large_lot_threshold::25000
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
try std.testing.expectApproxEqAbs(@as(f64, 5000), am.entries[0].harvested.?, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 25000), am.entries[1].audit_large_lot_threshold.?, 0.001);
}
test "parseAccountsFile: direct_indexing default false, opt-in true" {
const data =
\\#!srfv1
\\account::Tax Loss,tax_type::taxable,direct_indexing:bool:true
\\account::Regular Brokerage,tax_type::taxable
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
try std.testing.expect(am.isDirectIndexing("Tax Loss"));
try std.testing.expect(!am.isDirectIndexing("Regular Brokerage"));
try std.testing.expect(!am.isDirectIndexing("Nonexistent"));
}
test "parseAccountsFile: shielded omitted -> null (use tax_type default)" {
// Default behavior: when no `shielded` override is given,
// the field stays null and the umbrella-exposure calculation
// uses tax_type to decide.
const data =
\\#!srfv1
\\account::Sample IRA,tax_type::traditional
\\account::Sample Brokerage,tax_type::taxable
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
try std.testing.expect(am.entries[0].shielded == null);
try std.testing.expect(am.entries[1].shielded == null);
}
test "parseAccountsFile: shielded:bool:false override parses correctly" {
// Use case: pre-tax deferred-comp account that's NOT
// ERISA-protected (e.g. Fidelity DCP). tax_type stays as
// `traditional` (correct for tax purposes), `shielded` is
// overridden to false (correct for umbrella purposes).
const data =
\\#!srfv1
\\account::Sample DCP,tax_type::traditional,shielded:bool:false
\\account::Sample IRA,tax_type::traditional
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
// DCP: explicit override to false.
try std.testing.expect(am.entries[0].shielded != null);
try std.testing.expect(!am.entries[0].shielded.?);
// IRA: no override, stays null.
try std.testing.expect(am.entries[1].shielded == null);
}
test "parseAccountsFile: shielded:bool:true override (rare, e.g. asset-protection trust)" {
const data =
\\#!srfv1
\\account::Sample Trust,tax_type::taxable,shielded:bool:true
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 1), am.entries.len);
try std.testing.expect(am.entries[0].shielded != null);
try std.testing.expect(am.entries[0].shielded.?);
}
test "parseAccountsFile: audit_large_lot_threshold omitted -> null (use audit default)" {
const data =
\\#!srfv1
\\account::Sample Roth,tax_type::roth
\\account::Sample Brokerage,tax_type::taxable
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
// No override on either account -> lookup returns null so the
// audit falls back to its built-in default.
try std.testing.expect(am.entries[0].audit_large_lot_threshold == null);
try std.testing.expect(am.largeLotThresholdFor("Sample Roth") == null);
try std.testing.expect(am.largeLotThresholdFor("Sample Brokerage") == null);
}
test "parseAccountsFile: per-account audit_large_lot_threshold parses and is looked up by account" {
// Mixed: one account raises its threshold (e.g. a noisy ESPP
// account), a sibling leaves it default.
const data =
\\#!srfv1
\\account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000
\\account::Sample Brokerage,tax_type::taxable
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
try std.testing.expectApproxEqAbs(@as(f64, 50000.0), am.largeLotThresholdFor("Sample ESPP").?, 0.01);
// Sibling account has no override.
try std.testing.expect(am.largeLotThresholdFor("Sample Brokerage") == null);
// Unknown account -> null (falls back to default downstream).
try std.testing.expect(am.largeLotThresholdFor("Nonexistent") == null);
}
test "parseAccountsFile: audit_large_lot_threshold accepts a fractional value" {
const data =
\\#!srfv1
\\account::Sample Brokerage,tax_type::taxable,audit_large_lot_threshold:num:7500.5
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectApproxEqAbs(@as(f64, 7500.5), am.largeLotThresholdFor("Sample Brokerage").?, 0.001);
}
test "parseAccountsFile: non-positive audit_large_lot_threshold is rejected -> null" {
// Zero and negative thresholds are nonsensical (zero flags every
// new lot; negative is meaningless). They're dropped at parse
// time so the audit falls back to its built-in default. The
// surrounding account still parses.
inline for (.{ "0", "-5000" }) |bad| {
const data = "#!srfv1\naccount::Sample Brokerage,tax_type::taxable,audit_large_lot_threshold:num:" ++ bad ++ "\n";
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 1), am.entries.len);
try std.testing.expect(am.largeLotThresholdFor("Sample Brokerage") == null);
}
}
// ── harvested / harvested_date ───────────────────────────────
test "parseAccountsFile: harvested omitted -> harvestedFor returns null" {
const data =
\\#!srfv1
\\account::Sample Brokerage,tax_type::taxable
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expect(am.entries[0].harvested == null);
try std.testing.expect(am.entries[0].harvested_date == null);
try std.testing.expect(am.harvestedFor("Sample Brokerage") == null);
}
test "parseAccountsFile: harvested + harvested_date parse and look up by account" {
const data =
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,direct_indexing:bool:true,harvested:num:45300,harvested_date::2026-06-24
\\account::Sample Brokerage,tax_type::taxable
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
const h = am.harvestedFor("Sample Tax Loss").?;
try std.testing.expectApproxEqAbs(@as(f64, 45_300), h.amount, 0.001);
try std.testing.expect(h.as_of.?.eql(Date.fromYmd(2026, 6, 24)));
// Sibling account is unaffected.
try std.testing.expect(am.harvestedFor("Sample Brokerage") == null);
// Unknown account.
try std.testing.expect(am.harvestedFor("Nope") == null);
}
test "parseAccountsFile: negative harvested is normalized to a magnitude" {
// The annotation's parens carry the "this is a loss" convention, so
// a user writing the figure as a negative gets the same result.
inline for (.{ "45300", "-45300" }) |written| {
const data = "#!srfv1\naccount::Sample Tax Loss,tax_type::taxable,harvested:num:" ++ written ++ ",harvested_date::2026-06-24\n";
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectApproxEqAbs(@as(f64, 45_300), am.harvestedFor("Sample Tax Loss").?.amount, 0.001);
}
}
test "parseAccountsFile: zero harvested is kept (means nothing harvested yet)" {
const data =
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:0,harvested_date::2026-06-24
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectApproxEqAbs(@as(f64, 0), am.harvestedFor("Sample Tax Loss").?.amount, 0.001);
}
test "parseAccountsFile: harvested without harvested_date parses but is not renderable" {
// Kept in the map (doctor nags about the missing date); the
// annotation formatter is what refuses to render it.
const data =
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
const h = am.harvestedFor("Sample Tax Loss").?;
try std.testing.expectApproxEqAbs(@as(f64, 45_300), h.amount, 0.001);
try std.testing.expect(h.as_of == null);
var buf: [fmt.harvest_annotation_max_len]u8 = undefined;
try std.testing.expectEqualStrings("", fmt.fmtHarvestAnnotation(&buf, h.amount, h.as_of, Date.fromYmd(2026, 7, 25)));
}
test "parseAccountsFile: harvested_date without harvested is inert" {
const data =
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested_date::2026-06-24
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expect(am.harvestedFor("Sample Tax Loss") == null);
try std.testing.expect(am.entries[0].harvested_date != null);
}
test "parseAccountsFile: non-finite harvested is rejected -> null, account still parses" {
inline for (.{ "nan", "inf", "-inf" }) |bad| {
const data = "#!srfv1\naccount::Sample Tax Loss,tax_type::taxable,harvested:num:" ++ bad ++ ",harvested_date::2026-06-24\n";
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 1), am.entries.len);
try std.testing.expectEqual(TaxType.taxable, am.entries[0].tax_type);
try std.testing.expect(am.harvestedFor("Sample Tax Loss") == null);
}
}
test "Annotation.from: empty string yields null, round-trips otherwise" {
try std.testing.expect(Annotation.from("") == null);
// Longer than the inline buffer is dropped rather than truncated.
try std.testing.expect(Annotation.from("x" ** (Annotation.capacity + 1)) == null);
const a = Annotation.from("(45k 6/24)").?;
try std.testing.expectEqualStrings("(45k 6/24)", a.slice());
// Exactly-buffer-sized input fits.
const full = Annotation.from("x" ** Annotation.capacity).?;
try std.testing.expectEqualStrings("x" ** Annotation.capacity, full.slice());
// Every producer's widest output must fit - mirrored by the comptime
// assert in annotateAccountBreakdown.
try std.testing.expect(fmt.harvest_annotation_max_len <= Annotation.capacity);
}
test "annotateAccountBreakdown: stamps only accounts with fresh harvested data" {
var am = try testParseAccountMap(
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2026-06-24
\\account::Sample Stale,tax_type::taxable,harvested:num:12000,harvested_date::2024-01-15
\\account::Sample Dateless,tax_type::taxable,harvested:num:9000
\\account::Sample Brokerage,tax_type::taxable
);
defer am.deinit();
var items = [_]BreakdownItem{
.{ .label = "Sample Tax Loss", .value = 412_300, .weight = 0.4 },
.{ .label = "Sample Stale", .value = 100_000, .weight = 0.2 },
.{ .label = "Sample Dateless", .value = 100_000, .weight = 0.2 },
.{ .label = "Sample Brokerage", .value = 100_000, .weight = 0.1 },
.{ .label = "Not In accounts.srf", .value = 50_000, .weight = 0.1 },
};
annotateAccountBreakdown(&items, am, Date.fromYmd(2026, 7, 25));
try std.testing.expectEqualStrings("(45k 6/24)", items[0].annotation.?.slice());
try std.testing.expect(items[1].annotation == null); // >12 months old
try std.testing.expect(items[2].annotation == null); // no date
try std.testing.expect(items[3].annotation == null); // no harvested field
try std.testing.expect(items[4].annotation == null); // not in accounts.srf
}
test "annotateAccountBreakdown: as_of is the reference date, not necessarily today" {
// A back-dated analysis must not surface a harvest figure recorded
// after the date being analyzed.
var am = try testParseAccountMap(
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2026-06-24
);
defer am.deinit();
var items = [_]BreakdownItem{.{ .label = "Sample Tax Loss", .value = 1000, .weight = 1.0 }};
annotateAccountBreakdown(&items, am, Date.fromYmd(2026, 1, 1));
try std.testing.expect(items[0].annotation == null);
}
// ── umbrellaExposure ─────────────────────────────────────────
/// Helper: build an in-memory AccountMap from a literal SRF
/// string. Keeps each test compact while exercising the real
/// parsing path.
fn testParseAccountMap(comptime data: []const u8) !AccountMap {
return parseAccountsFile(std.testing.allocator, data);
}
test "umbrellaExposure: traditional/roth/hsa default to shielded; taxable to exposed" {
var am = try testParseAccountMap(
\\#!srfv1
\\account::IRA,tax_type::traditional
\\account::Roth IRA,tax_type::roth
\\account::HSA,tax_type::hsa
\\account::Brokerage,tax_type::taxable
);
defer am.deinit();
const accounts = [_]BreakdownItem{
.{ .label = "IRA", .value = 1_000_000, .weight = 0.40 },
.{ .label = "Roth IRA", .value = 500_000, .weight = 0.20 },
.{ .label = "HSA", .value = 100_000, .weight = 0.04 },
.{ .label = "Brokerage", .value = 900_000, .weight = 0.36 },
};
const u = umbrellaExposure(&accounts, am);
try std.testing.expectApproxEqAbs(@as(f64, 2_500_000), u.total_liquid, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 1_600_000), u.shielded_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 900_000), u.exposed_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0.36), u.exposed_pct, 0.001);
}
test "umbrellaExposure: shielded:bool:false override flips traditional account to exposed" {
// The DCP case: pre-tax payroll account, traditional tax
// treatment, but NOT ERISA-shielded. Override with
// `shielded:bool:false` and the umbrella math counts it
// toward exposure.
var am = try testParseAccountMap(
\\#!srfv1
\\account::IRA,tax_type::traditional
\\account::DCP,tax_type::traditional,shielded:bool:false
);
defer am.deinit();
const accounts = [_]BreakdownItem{
.{ .label = "IRA", .value = 1_000_000, .weight = 0.50 },
.{ .label = "DCP", .value = 1_000_000, .weight = 0.50 },
};
const u = umbrellaExposure(&accounts, am);
try std.testing.expectApproxEqAbs(@as(f64, 2_000_000), u.total_liquid, 1.0);
// IRA shielded (default), DCP exposed (override).
try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), u.shielded_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), u.exposed_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0.50), u.exposed_pct, 0.001);
}
test "umbrellaExposure: shielded:bool:true override flips taxable account to shielded" {
// Asset-protection trust, taxable for tax purposes but
// legally shielded.
var am = try testParseAccountMap(
\\#!srfv1
\\account::Trust,tax_type::taxable,shielded:bool:true
\\account::Brokerage,tax_type::taxable
);
defer am.deinit();
const accounts = [_]BreakdownItem{
.{ .label = "Trust", .value = 500_000, .weight = 0.50 },
.{ .label = "Brokerage", .value = 500_000, .weight = 0.50 },
};
const u = umbrellaExposure(&accounts, am);
try std.testing.expectApproxEqAbs(@as(f64, 500_000), u.shielded_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 500_000), u.exposed_value, 1.0);
}
test "umbrellaExposure: account not in map defaults to exposed (defensive)" {
// Defensive default: if an account name in the breakdown
// doesn't appear in accounts.srf, treat it as exposed
// rather than silently shielding it. The user will see the
// overstated exposure and notice the missing entry.
var am = try testParseAccountMap(
\\#!srfv1
\\account::IRA,tax_type::traditional
);
defer am.deinit();
const accounts = [_]BreakdownItem{
.{ .label = "IRA", .value = 100_000, .weight = 0.50 },
.{ .label = "Mystery Account", .value = 100_000, .weight = 0.50 },
};
const u = umbrellaExposure(&accounts, am);
try std.testing.expectApproxEqAbs(@as(f64, 100_000), u.shielded_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 100_000), u.exposed_value, 1.0);
}
test "umbrellaExposure: empty breakdown returns zeros and avoids divide-by-zero" {
var am = try testParseAccountMap(
\\#!srfv1
\\account::IRA,tax_type::traditional
);
defer am.deinit();
const u = umbrellaExposure(&.{}, am);
try std.testing.expectEqual(@as(f64, 0), u.total_liquid);
try std.testing.expectEqual(@as(f64, 0), u.shielded_value);
try std.testing.expectEqual(@as(f64, 0), u.exposed_value);
try std.testing.expectEqual(@as(f64, 0), u.exposed_pct);
}
test "umbrellaExposure: HSA counts as shielded" {
var am = try testParseAccountMap(
\\#!srfv1
\\account::HSA,tax_type::hsa
);
defer am.deinit();
const accounts = [_]BreakdownItem{
.{ .label = "HSA", .value = 50_000, .weight = 1.0 },
};
const u = umbrellaExposure(&accounts, am);
try std.testing.expectApproxEqAbs(@as(f64, 50_000), u.shielded_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0), u.exposed_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0), u.exposed_pct, 0.001);
}
test "umbrellaExposure: realistic mixed portfolio" {
// Approximation of the user's actual portfolio shape:
// mostly Traditional 401k (shielded), some IRAs (shielded),
// taxable brokerage (exposed), and one DCP that LOOKS
// traditional but isn't ERISA-shielded.
var am = try testParseAccountMap(
\\#!srfv1
\\account::Sample 401k,tax_type::traditional
\\account::Sample DCP,tax_type::traditional,shielded:bool:false
\\account::Sample IRA,tax_type::traditional
\\account::Sample Roth,tax_type::roth
\\account::Sample HSA,tax_type::hsa
\\account::Sample Trust,tax_type::taxable
);
defer am.deinit();
const accounts = [_]BreakdownItem{
.{ .label = "Sample 401k", .value = 900_000, .weight = 0.30 },
.{ .label = "Sample DCP", .value = 1_500_000, .weight = 0.50 },
.{ .label = "Sample IRA", .value = 200_000, .weight = 0.067 },
.{ .label = "Sample Roth", .value = 100_000, .weight = 0.033 },
.{ .label = "Sample HSA", .value = 50_000, .weight = 0.017 },
.{ .label = "Sample Trust", .value = 250_000, .weight = 0.083 },
};
const u = umbrellaExposure(&accounts, am);
// Shielded: 401k + IRA + Roth + HSA = 900k + 200k + 100k + 50k = 1,250,000
// Exposed: DCP + Trust = 1,500,000 + 250,000 = 1,750,000
try std.testing.expectApproxEqAbs(@as(f64, 3_000_000), u.total_liquid, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 1_250_000), u.shielded_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 1_750_000), u.exposed_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0.5833), u.exposed_pct, 0.001);
}
test "umbrellaExposure: a taxable carve-out exposes a fraction of one account" {
// A 401(k) with an after-tax non-Roth sleeve: the pre-tax and Roth
// money is ERISA-shielded, but the taxable-basis slice is not, so the
// account splits rather than landing wholly on one side.
var am = try testParseAccountMap(
\\#!srfv1
\\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:20,tax_mix_taxable:num:10
\\account::Sample Brokerage,tax_type::taxable
);
defer am.deinit();
const accounts = [_]BreakdownItem{
.{ .label = "Sample 401k", .value = 1_000_000, .weight = 0.8 },
.{ .label = "Sample Brokerage", .value = 250_000, .weight = 0.2 },
};
const u = umbrellaExposure(&accounts, am);
// 401k: 90% shielded (traditional 70% + roth 20%) = 900,000
// 10% exposed (taxable carve-out) = 100,000
// Brokerage: fully exposed = 250,000
try std.testing.expectApproxEqAbs(@as(f64, 1_250_000), u.total_liquid, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 900_000), u.shielded_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 350_000), u.exposed_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0.28), u.exposed_pct, 0.0001);
}
test "umbrellaExposure: a traditional/roth mix changes nothing" {
// Both halves are shielded, so splitting a 401(k) between pre-tax and
// Roth must leave the umbrella numbers exactly where they were. This
// pins the claim that the common mix is inert here.
var mixed = try testParseAccountMap(
\\#!srfv1
\\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4
);
defer mixed.deinit();
var plain = try testParseAccountMap(
\\#!srfv1
\\account::Sample 401k,tax_type::traditional
);
defer plain.deinit();
const accounts = [_]BreakdownItem{
.{ .label = "Sample 401k", .value = 455_000, .weight = 1.0 },
};
const a = umbrellaExposure(&accounts, mixed);
const b = umbrellaExposure(&accounts, plain);
try std.testing.expectEqual(b.shielded_value, a.shielded_value);
try std.testing.expectEqual(b.exposed_value, a.exposed_value);
try std.testing.expectEqual(b.exposed_pct, a.exposed_pct);
}
test "umbrellaExposure: an explicit shielded override outranks the tax mix" {
// `shielded` is a statement about legal protection; a split of the
// account's *tax* treatment has no business overriding it. So the
// override applies wholesale in both directions.
var am = try testParseAccountMap(
\\#!srfv1
\\account::Sample DCP,tax_type::traditional,tax_mix_roth:num:30,shielded:bool:false
\\account::Sample Trust,tax_type::taxable,tax_mix_traditional:num:40,shielded:bool:true
);
defer am.deinit();
const accounts = [_]BreakdownItem{
.{ .label = "Sample DCP", .value = 600_000, .weight = 0.6 },
.{ .label = "Sample Trust", .value = 400_000, .weight = 0.4 },
};
const u = umbrellaExposure(&accounts, am);
// DCP fully exposed despite 100% of its mix being non-taxable;
// Trust fully shielded despite 60% of its mix being taxable.
try std.testing.expectApproxEqAbs(@as(f64, 400_000), u.shielded_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 600_000), u.exposed_value, 1.0);
}
test "umbrellaExposure: an invalid tax mix falls back without disturbing the split" {
// A rejected carve-out must behave exactly like no carve-out - not
// like a partial or clamped one.
var am = try testParseAccountMap(
\\#!srfv1
\\account::Sample 401k,tax_type::traditional,tax_mix_taxable:num:100
);
defer am.deinit();
const accounts = [_]BreakdownItem{
.{ .label = "Sample 401k", .value = 500_000, .weight = 1.0 },
};
const u = umbrellaExposure(&accounts, am);
try std.testing.expectApproxEqAbs(@as(f64, 500_000), u.shielded_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0), u.exposed_value, 1.0);
}
test "TaxType.label" {
try std.testing.expectEqualStrings("Taxable", TaxType.taxable.label());
try std.testing.expectEqualStrings("Roth (Post-Tax)", TaxType.roth.label());
try std.testing.expectEqualStrings("Traditional (Pre-Tax)", TaxType.traditional.label());
try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", TaxType.hsa.label());
}
// ── TaxMix ───────────────────────────────────────────────────
/// Helper: total weight, which every valid mix must drive to 1.0.
fn testMixSum(mix: TaxMix) f64 {
var sum: f64 = 0;
for (mix.weights) |w| sum += w;
return sum;
}
test "TaxMix.shieldedWeight: excludes only the taxable share" {
// The umbrella default rule, generalized. Roth / traditional / HSA
// are all shielded, so only a taxable slice reduces the weight.
try std.testing.expectEqual(@as(f64, 1.0), TaxMix.single(.traditional).shieldedWeight());
try std.testing.expectEqual(@as(f64, 1.0), TaxMix.single(.roth).shieldedWeight());
try std.testing.expectEqual(@as(f64, 1.0), TaxMix.single(.hsa).shieldedWeight());
try std.testing.expectEqual(@as(f64, 0), TaxMix.single(.taxable).shieldedWeight());
const split: AccountTaxEntry = .{
.account = "Sample 401k",
.tax_type = .traditional,
.tax_mix_taxable = 15,
};
try std.testing.expectApproxEqAbs(@as(f64, 0.85), split.taxMix().shieldedWeight(), 1e-12);
}
test "AccountTaxEntry.taxMix: no carve-outs -> single mix on tax_type" {
// The backward-compatibility guarantee: an account that says nothing
// about a mix behaves exactly as it did before mixed treatment.
for (std.enums.values(TaxType)) |t| {
const e: AccountTaxEntry = .{ .account = "Sample Account", .tax_type = t };
const checked = e.taxMixChecked();
try std.testing.expectEqual(@as(?TaxMixProblem, null), checked.problem);
try std.testing.expectEqual(@as(f64, 1.0), checked.mix.weightOf(t));
try std.testing.expect(!e.hasTaxMix());
}
}
test "AccountTaxEntry.taxMix: one carve-out leaves the residual on tax_type" {
// The motivating case: a 401(k) reporting one balance that is really
// 77.6% pre-tax (deferrals + match) and 22.4% Roth.
const e: AccountTaxEntry = .{
.account = "Sample 401k",
.tax_type = .traditional,
.tax_mix_roth = 22.4,
};
const checked = e.taxMixChecked();
try std.testing.expectEqual(@as(?TaxMixProblem, null), checked.problem);
try std.testing.expect(e.hasTaxMix());
try std.testing.expectApproxEqAbs(@as(f64, 0.224), checked.mix.weightOf(.roth), 1e-12);
try std.testing.expectApproxEqAbs(@as(f64, 0.776), checked.mix.weightOf(.traditional), 1e-12);
try std.testing.expectEqual(@as(f64, 0), checked.mix.weightOf(.taxable));
try std.testing.expectEqual(@as(f64, 0), checked.mix.weightOf(.hsa));
try std.testing.expectApproxEqAbs(@as(f64, 1.0), testMixSum(checked.mix), 1e-12);
}
test "AccountTaxEntry.taxMix: multiple carve-outs across three types" {
// Pre-tax primary, plus an in-plan Roth sleeve and an after-tax
// non-Roth sleeve whose basis is taxable money.
const e: AccountTaxEntry = .{
.account = "Sample 401k",
.tax_type = .traditional,
.tax_mix_roth = 20,
.tax_mix_taxable = 5,
};
const mix = e.taxMix();
try std.testing.expectApproxEqAbs(@as(f64, 0.20), mix.weightOf(.roth), 1e-12);
try std.testing.expectApproxEqAbs(@as(f64, 0.05), mix.weightOf(.taxable), 1e-12);
try std.testing.expectApproxEqAbs(@as(f64, 0.75), mix.weightOf(.traditional), 1e-12);
try std.testing.expectApproxEqAbs(@as(f64, 1.0), testMixSum(mix), 1e-12);
}
test "AccountTaxEntry.taxMix: weights sum to 1.0 for every valid shape" {
const cases = [_]AccountTaxEntry{
.{ .account = "A", .tax_type = .traditional },
.{ .account = "B", .tax_type = .traditional, .tax_mix_roth = 22.4 },
.{ .account = "C", .tax_type = .roth, .tax_mix_traditional = 33.3333 },
.{ .account = "D", .tax_type = .taxable, .tax_mix_roth = 1, .tax_mix_hsa = 2, .tax_mix_traditional = 3 },
.{ .account = "E", .tax_type = .hsa, .tax_mix_taxable = 99.9999 },
// Rejected shapes still produce a normalized single mix.
.{ .account = "F", .tax_type = .traditional, .tax_mix_roth = 150 },
.{ .account = "G", .tax_type = .traditional, .tax_mix_traditional = 40 },
};
for (cases) |e| {
try std.testing.expectApproxEqAbs(@as(f64, 1.0), testMixSum(e.taxMix()), 1e-12);
}
}
test "AccountTaxEntry.taxMixChecked: non-positive carve-out falls back to tax_type" {
// Zero means "omit the field"; negative is meaningless. Neither is a
// guess worth honoring, so both degrade to the bare tax_type.
for ([_]f64{ 0, -10 }) |bad| {
const e: AccountTaxEntry = .{
.account = "Sample 401k",
.tax_type = .traditional,
.tax_mix_roth = bad,
};
const checked = e.taxMixChecked();
try std.testing.expectEqual(@as(?TaxMixProblem, .non_positive), checked.problem);
try std.testing.expectEqual(@as(f64, 1.0), checked.mix.weightOf(.traditional));
try std.testing.expectEqual(@as(f64, 0), checked.mix.weightOf(.roth));
// The user still declared something; doctor needs to know.
try std.testing.expect(e.hasTaxMix());
}
}
test "AccountTaxEntry.taxMixChecked: non-finite carve-out falls back to tax_type" {
for ([_]f64{ std.math.nan(f64), std.math.inf(f64), -std.math.inf(f64) }) |bad| {
const e: AccountTaxEntry = .{
.account = "Sample 401k",
.tax_type = .traditional,
.tax_mix_roth = bad,
};
const checked = e.taxMixChecked();
try std.testing.expectEqual(@as(?TaxMixProblem, .not_finite), checked.problem);
try std.testing.expectEqual(@as(f64, 1.0), checked.mix.weightOf(.traditional));
}
}
test "AccountTaxEntry.taxMixChecked: carve-out naming tax_type itself is rejected" {
// The primary's share is always the residual, so naming it is
// self-contradictory - and would invite a set that sums to 90.
const e: AccountTaxEntry = .{
.account = "Sample 401k",
.tax_type = .traditional,
.tax_mix_traditional = 77.6,
.tax_mix_roth = 22.4,
};
const checked = e.taxMixChecked();
try std.testing.expectEqual(@as(?TaxMixProblem, .redundant_primary), checked.problem);
try std.testing.expectEqual(@as(f64, 1.0), checked.mix.weightOf(.traditional));
try std.testing.expectEqual(@as(f64, 0), checked.mix.weightOf(.roth));
}
test "AccountTaxEntry.taxMixChecked: carve-outs summing to 100 or more are rejected" {
// Exactly 100 leaves the primary tax_type no share at all, which
// would make tax_type a lie rather than a residual holder.
const exact: AccountTaxEntry = .{
.account = "Sample 401k",
.tax_type = .traditional,
.tax_mix_roth = 60,
.tax_mix_taxable = 40,
};
try std.testing.expectEqual(@as(?TaxMixProblem, .over_allocated), exact.taxMixChecked().problem);
try std.testing.expectEqual(@as(f64, 1.0), exact.taxMix().weightOf(.traditional));
const over: AccountTaxEntry = .{
.account = "Sample 401k",
.tax_type = .traditional,
.tax_mix_roth = 130,
};
try std.testing.expectEqual(@as(?TaxMixProblem, .over_allocated), over.taxMixChecked().problem);
// Just under 100 is fine, however little is left for the primary.
const under: AccountTaxEntry = .{
.account = "Sample 401k",
.tax_type = .traditional,
.tax_mix_roth = 99.5,
};
try std.testing.expectEqual(@as(?TaxMixProblem, null), under.taxMixChecked().problem);
try std.testing.expectApproxEqAbs(@as(f64, 0.005), under.taxMix().weightOf(.traditional), 1e-12);
}
test "parseAccountsFile: tax_mix carve-out and tax_mix_date round-trip" {
var am = try parseAccountsFile(std.testing.allocator,
\\#!srfv1
\\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4,tax_mix_date::2026-08-01
\\account::Sample Brokerage,tax_type::taxable
);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
try std.testing.expectEqual(@as(?f64, 22.4), am.entries[0].tax_mix_roth);
try std.testing.expectEqual(@as(?Date, Date.fromYmd(2026, 8, 1)), am.entries[0].tax_mix_date);
try std.testing.expect(am.entries[0].hasTaxMix());
const mix = am.taxMixFor("Sample 401k").?;
try std.testing.expectApproxEqAbs(@as(f64, 0.224), mix.weightOf(.roth), 1e-12);
try std.testing.expectApproxEqAbs(@as(f64, 0.776), mix.weightOf(.traditional), 1e-12);
// An account with no carve-outs is untouched.
try std.testing.expect(!am.entries[1].hasTaxMix());
try std.testing.expectEqual(@as(?Date, null), am.entries[1].tax_mix_date);
try std.testing.expectEqual(@as(f64, 1.0), am.taxMixFor("Sample Brokerage").?.weightOf(.taxable));
}
test "parseAccountsFile: an invalid tax mix is preserved raw so doctor can report it" {
// The parser deliberately does NOT scrub a rejected carve-out: it
// warns and leaves the field alone, because `zfin doctor` reads back
// through this same parser and has to be able to see what the user
// actually wrote.
var am = try parseAccountsFile(std.testing.allocator,
\\#!srfv1
\\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:140
);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 1), am.entries.len);
try std.testing.expectEqual(@as(?f64, 140), am.entries[0].tax_mix_roth);
try std.testing.expectEqual(@as(?TaxMixProblem, .over_allocated), am.entries[0].taxMixChecked().problem);
// ...but the resolved mix falls back to the bare tax_type.
try std.testing.expectEqual(@as(f64, 1.0), am.taxMixFor("Sample 401k").?.weightOf(.traditional));
}
test "parseAccountsFile: every non-string field survives the copy-then-override append" {
// Regression guard for the append path in `parseAccountsFile`. It
// copies the parsed record wholesale and overrides only the duped /
// validated fields, precisely so a newly added field can't silently
// keep its default. Assert one value per field.
var am = try parseAccountsFile(std.testing.allocator,
\\#!srfv1
\\account::Sample Everything,tax_type::roth,institution::fidelity,account_number::1234,update_cadence::quarterly,cash_is_contribution:bool:true,direct_indexing:bool:true,shielded:bool:false,audit_large_lot_threshold:num:50000,harvested:num:-4500,harvested_date::2026-06-24,tax_mix_traditional:num:10,tax_mix_taxable:num:5,tax_mix_hsa:num:1,tax_mix_date::2026-08-01
);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 1), am.entries.len);
const e = am.entries[0];
try std.testing.expectEqualStrings("Sample Everything", e.account);
try std.testing.expectEqual(TaxType.roth, e.tax_type);
try std.testing.expectEqualStrings("fidelity", e.institution.?);
try std.testing.expectEqualStrings("1234", e.account_number.?);
try std.testing.expectEqual(UpdateCadence.quarterly, e.update_cadence);
try std.testing.expect(e.cash_is_contribution);
try std.testing.expect(e.direct_indexing);
try std.testing.expectEqual(@as(?bool, false), e.shielded);
try std.testing.expectEqual(@as(?f64, 50_000), e.audit_large_lot_threshold);
try std.testing.expectEqual(@as(?f64, 4500), e.harvested); // sign-normalized
try std.testing.expectEqual(@as(?Date, Date.fromYmd(2026, 6, 24)), e.harvested_date);
try std.testing.expectEqual(@as(?f64, 10), e.tax_mix_traditional);
try std.testing.expectEqual(@as(?f64, 5), e.tax_mix_taxable);
try std.testing.expectEqual(@as(?f64, 1), e.tax_mix_hsa);
try std.testing.expectEqual(@as(?f64, null), e.tax_mix_roth);
try std.testing.expectEqual(@as(?Date, Date.fromYmd(2026, 8, 1)), e.tax_mix_date);
}
test "taxMixFor: unknown account returns null, distinct from a zero mix" {
var am = try parseAccountsFile(std.testing.allocator,
\\#!srfv1
\\account::Sample Brokerage,tax_type::taxable
);
defer am.deinit();
try std.testing.expectEqual(@as(?TaxMix, null), am.taxMixFor("Sample Nowhere"));
try std.testing.expect(am.taxMixFor("Sample Brokerage") != null);
// taxTypeFor keeps its own "Unknown" sentinel for label callers.
try std.testing.expectEqualStrings("Unknown", am.taxTypeFor("Sample Nowhere"));
}
test "mapToSortedBreakdown" {
const allocator = std.testing.allocator;
var map = std.StringHashMap(f64).init(allocator);
defer map.deinit();
try map.put("Technology", 50_000);
try map.put("Healthcare", 30_000);
try map.put("Energy", 20_000);
const total = 100_000.0;
const breakdown = try mapToSortedBreakdown(allocator, map, total);
defer allocator.free(breakdown);
try std.testing.expectEqual(@as(usize, 3), breakdown.len);
// Should be sorted descending by value
try std.testing.expectEqualStrings("Technology", breakdown[0].label);
try std.testing.expectApproxEqAbs(@as(f64, 50_000), breakdown[0].value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.5), breakdown[0].weight, 0.001);
try std.testing.expectEqualStrings("Healthcare", breakdown[1].label);
try std.testing.expectEqualStrings("Energy", breakdown[2].label);
}
test "mapToSortedBreakdown empty" {
const allocator = std.testing.allocator;
var map = std.StringHashMap(f64).init(allocator);
defer map.deinit();
const breakdown = try mapToSortedBreakdown(allocator, map, 100_000.0);
defer allocator.free(breakdown);
try std.testing.expectEqual(@as(usize, 0), breakdown.len);
}
test "parseAccountsFile empty" {
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, "#!srfv1\n");
defer am.deinit();
try std.testing.expectEqual(@as(usize, 0), am.entries.len);
}
test "parseAccountsFile missing fields" {
// Line with only account but no tax_type -> skipped via Record.to() error.
// Override log level to suppress expected srf log.err output that
// would otherwise cause the test runner to report failure.
const prev_level = std.testing.log_level;
std.testing.log_level = .err;
defer std.testing.log_level = prev_level;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, "#!srfv1\naccount::Test Account\n# comment\n");
defer am.deinit();
try std.testing.expectEqual(@as(usize, 0), am.entries.len);
}
/// Helper: pull one breakdown row's dollar value out by label.
/// Returns null when no row carries that label, which lets a test
/// distinguish "row absent" from "row present with value 0".
fn testBreakdownValue(items: []const BreakdownItem, label: []const u8) ?f64 {
for (items) |it| {
if (std.mem.eql(u8, it.label, label)) return it.value;
}
return null;
}
/// Helper: a single-symbol portfolio priced at $100/share, with `lots`
/// distributing shares across accounts. Keeps the tax-rollup tests
/// focused on the apportionment rather than on pricing mechanics.
fn testTaxRollup(
account_map: ?AccountMap,
lots: []@import("../models/portfolio.zig").Lot,
total_value: f64,
) !AnalysisResult {
const allocator = std.testing.allocator;
const portfolio = Portfolio{ .lots = lots, .allocator = allocator };
const allocations = [_]Allocation{.{
.symbol = "SPY",
.display_symbol = "SPY",
.shares = total_value / 100,
.avg_cost = 100,
.current_price = 100,
.market_value = total_value,
.cost_basis = total_value,
.weight = 1.0,
.unrealized_gain_loss = 0,
.unrealized_return = 0,
.price_ratio = 1.0,
}};
const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator };
return analyzePortfolio(
allocator,
&allocations,
cm,
portfolio,
total_value,
account_map,
Date.fromYmd(2026, 8, 1),
);
}
test "analyzePortfolio: an option's account value includes the contract multiplier" {
// The regression. This arm used to be `@abs(shares) * open_price`
// with no `multiplier`, so it counted $2.05 where every other site
// counted $205.00 - leaving each option-holding account short by 99%
// of its premium, and making `zfin analysis`'s By Account section
// disagree with its own Options sector row and with the
// `kind::account` rows written into every snapshot.
const Lot = @import("../models/portfolio.zig").Lot;
var lots = [_]Lot{
.{
.symbol = "SPY",
.shares = 1000,
.open_date = Date.fromYmd(2020, 1, 1),
.open_price = 100,
.account = "Sample Brokerage",
},
// A written call: -2 contracts at $2.05 premium, multiplier 100.
// Premium = |-2| * 2.05 * 100 = $410.00, NOT $4.10.
// Opened before `testTaxRollup`'s as_of (2026-08-01) and maturing
// after it, so `lotIsOpenAsOf` includes it.
.{
.security_type = .option,
.symbol = "SPY 09/18/2026 700.00 C",
.shares = -2,
.open_date = Date.fromYmd(2026, 7, 21),
.maturity_date = Date.fromYmd(2026, 9, 18),
.open_price = 2.05,
.option_type = .call,
.underlying = "SPY",
.strike = 700,
.account = "Sample Brokerage",
},
};
var am = try testParseAccountMap(
\\#!srfv1
\\account::Sample Brokerage,tax_type::taxable
);
defer am.deinit();
var result = try testTaxRollup(am, &lots, 100_000);
defer result.deinit(std.testing.allocator);
const acct = testBreakdownValue(result.account, "Sample Brokerage").?;
// $100,000 of stock + $410.00 of premium. Under the bug: $100,004.10.
try std.testing.expectApproxEqAbs(@as(f64, 100_410.0), acct, 0.005);
// And it must agree with the other two copies of this formula. This is
// the invariant that actually matters - the three sites drifted once.
const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
const non_stock = portfolio.nonStockValueForAccount(Date.fromYmd(2026, 8, 26), "Sample Brokerage");
try std.testing.expectApproxEqAbs(@as(f64, 410.0), non_stock, 0.005);
try std.testing.expectApproxEqAbs(acct, 100_000.0 + non_stock, 0.005);
}
test "analyzePortfolio: a mixed account splits its value across tax-type rows" {
// The headline case. One 401(k) reporting a single $400k balance that
// is really 75% pre-tax and 25% Roth, plus a $100k taxable brokerage.
// Before mixed treatment the whole $400k landed on Traditional.
const Lot = @import("../models/portfolio.zig").Lot;
var lots = [_]Lot{
.{
.symbol = "SPY",
.shares = 4000,
.open_date = Date.fromYmd(2020, 1, 1),
.open_price = 100,
.account = "Sample 401k",
},
.{
.symbol = "SPY",
.shares = 1000,
.open_date = Date.fromYmd(2020, 1, 1),
.open_price = 100,
.account = "Sample Brokerage",
},
};
var am = try testParseAccountMap(
\\#!srfv1
\\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:25
\\account::Sample Brokerage,tax_type::taxable
);
defer am.deinit();
var result = try testTaxRollup(am, &lots, 500_000);
defer result.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(usize, 3), result.tax_type.len);
try std.testing.expectApproxEqAbs(@as(f64, 300_000), testBreakdownValue(result.tax_type, "Traditional (Pre-Tax)").?, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 100_000), testBreakdownValue(result.tax_type, "Roth (Post-Tax)").?, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 100_000), testBreakdownValue(result.tax_type, "Taxable").?, 1.0);
// The split must conserve dollars: the tax-type rows still sum to the
// portfolio total, and the account breakdown is untouched by it.
var tax_sum: f64 = 0;
for (result.tax_type) |it| tax_sum += it.value;
try std.testing.expectApproxEqAbs(@as(f64, 500_000), tax_sum, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 400_000), testBreakdownValue(result.account, "Sample 401k").?, 1.0);
// Weights are shares of the portfolio total, not of the account.
for (result.tax_type) |it| {
try std.testing.expectApproxEqAbs(it.value / 500_000, it.weight, 1e-9);
}
}
test "analyzePortfolio: an account with no carve-outs still lands on one row" {
// Backward-compatibility regression: the same fixture without a mix
// must produce exactly two rows with the full account values.
const Lot = @import("../models/portfolio.zig").Lot;
var lots = [_]Lot{
.{ .symbol = "SPY", .shares = 4000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 100, .account = "Sample 401k" },
.{ .symbol = "SPY", .shares = 1000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 100, .account = "Sample Brokerage" },
};
var am = try testParseAccountMap(
\\#!srfv1
\\account::Sample 401k,tax_type::traditional
\\account::Sample Brokerage,tax_type::taxable
);
defer am.deinit();
var result = try testTaxRollup(am, &lots, 500_000);
defer result.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(usize, 2), result.tax_type.len);
try std.testing.expectApproxEqAbs(@as(f64, 400_000), testBreakdownValue(result.tax_type, "Traditional (Pre-Tax)").?, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 100_000), testBreakdownValue(result.tax_type, "Taxable").?, 1.0);
try std.testing.expectEqual(@as(?f64, null), testBreakdownValue(result.tax_type, "Roth (Post-Tax)"));
}
test "analyzePortfolio: an unmapped account still lands wholly in Unknown" {
// The mix loop replaced `taxTypeFor`, which owned the "Unknown"
// sentinel. Pin that the sentinel survived the move and does not
// get apportioned.
const Lot = @import("../models/portfolio.zig").Lot;
var lots = [_]Lot{
.{ .symbol = "SPY", .shares = 3000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 100, .account = "Sample 401k" },
.{ .symbol = "SPY", .shares = 2000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 100, .account = "Sample Unlisted" },
};
var am = try testParseAccountMap(
\\#!srfv1
\\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:40
);
defer am.deinit();
var result = try testTaxRollup(am, &lots, 500_000);
defer result.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(usize, 3), result.tax_type.len);
try std.testing.expectApproxEqAbs(@as(f64, 200_000), testBreakdownValue(result.tax_type, "Unknown").?, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 180_000), testBreakdownValue(result.tax_type, "Traditional (Pre-Tax)").?, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 120_000), testBreakdownValue(result.tax_type, "Roth (Post-Tax)").?, 1.0);
}
test "analyzePortfolio: two mixed accounts accumulate into shared tax-type rows" {
// Each account contributes to several rows, and several accounts
// contribute to each row. Guards the accumulate-don't-overwrite
// behavior of the nested rollup loop.
const Lot = @import("../models/portfolio.zig").Lot;
var lots = [_]Lot{
.{ .symbol = "SPY", .shares = 2000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 100, .account = "Sample 401k A" },
.{ .symbol = "SPY", .shares = 2000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 100, .account = "Sample 401k B" },
};
var am = try testParseAccountMap(
\\#!srfv1
\\account::Sample 401k A,tax_type::traditional,tax_mix_roth:num:25
\\account::Sample 401k B,tax_type::roth,tax_mix_traditional:num:10
);
defer am.deinit();
var result = try testTaxRollup(am, &lots, 400_000);
defer result.deinit(std.testing.allocator);
// A: 200k -> 150k traditional + 50k roth
// B: 200k -> 20k traditional + 180k roth
try std.testing.expectEqual(@as(usize, 2), result.tax_type.len);
try std.testing.expectApproxEqAbs(@as(f64, 170_000), testBreakdownValue(result.tax_type, "Traditional (Pre-Tax)").?, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 230_000), testBreakdownValue(result.tax_type, "Roth (Post-Tax)").?, 1.0);
}
test "account breakdown applies price_ratio" {
const allocator = std.testing.allocator;
const Lot = @import("../models/portfolio.zig").Lot;
// Three lots across two accounts:
// - Brokerage: direct SPY (ratio 1.0)
// - 401(k): CIT mapped to SPY (ratio 0.25, merged allocation)
// - 401(k): CUSIP with ticker=VTTHX (ratio 5.0, unmerged allocation)
var lots = [_]Lot{
.{
.symbol = "SPY",
.shares = 100,
.open_date = Date.fromYmd(2020, 1, 1),
.open_price = 400,
.account = "Brokerage",
},
.{
.symbol = "CIT-SPY",
.shares = 500,
.open_date = Date.fromYmd(2020, 1, 1),
.open_price = 100,
.ticker = "SPY",
.price_ratio = 0.25,
.account = "401(k)",
},
.{
.symbol = "CUSIP123",
.shares = 200,
.open_date = Date.fromYmd(2020, 1, 1),
.open_price = 50,
.ticker = "VTTHX",
.price_ratio = 5.0,
.account = "401(k)",
},
};
const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
// Allocations as produced by portfolioSummary + mergeAllocsBySymbol:
// SPY: merged (direct + CIT). current_price = base SPY price = 500, price_ratio = 1.0
// VTTHX: unmerged. current_price = 30 * 5.0 = 150 (already includes ratio), price_ratio = 5.0
const allocations = [_]Allocation{
.{
.symbol = "SPY",
.display_symbol = "SPY",
.shares = 225, // 100 + 500*0.25
.avg_cost = 300,
.current_price = 500, // base-ticker price (merged, ratio=1.0)
.market_value = 112_500,
.cost_basis = 67_500,
.weight = 0.789,
.unrealized_gain_loss = 45_000,
.unrealized_return = 0.667,
.price_ratio = 1.0, // merged
},
.{
.symbol = "VTTHX",
.display_symbol = "VTTHX",
.shares = 200,
.avg_cost = 50,
.current_price = 150, // already includes price_ratio (30 * 5.0)
.market_value = 30_000, // 200 * 150
.cost_basis = 10_000,
.weight = 0.211,
.unrealized_gain_loss = 20_000,
.unrealized_return = 2.0,
.price_ratio = 5.0, // unmerged, ratio preserved
},
};
const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator };
var result = try analyzePortfolio(
allocator,
&allocations,
cm,
portfolio,
142_500,
null,
Date.fromYmd(2024, 6, 1),
);
defer result.deinit(allocator);
// Expected account values:
// Brokerage: SPY direct, 100 shares * $500 * 1.0 = $50,000
// 401(k): CIT-SPY 500 shares * $500 * 0.25 = $62,500
// + CUSIP123 200 shares * $150 (already includes ratio) = $30,000
// = $92,500
// Total: $142,500
for (result.account) |item| {
if (std.mem.eql(u8, item.label, "Brokerage")) {
try std.testing.expectApproxEqAbs(@as(f64, 50_000), item.value, 1.0);
} else if (std.mem.eql(u8, item.label, "401(k)")) {
try std.testing.expectApproxEqAbs(@as(f64, 92_500), item.value, 1.0);
}
}
// Sum of accounts must equal total portfolio value
var account_sum: f64 = 0;
for (result.account) |item| {
account_sum += item.value;
}
try std.testing.expectApproxEqAbs(@as(f64, 142_500), account_sum, 1.0);
}
// ── bucketSector ──────────────────────────────────────────────
test "bucketSector: NPORT-P Debt / * -> Fixed Income" {
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(bucket_fixed_income, bucketSector(s));
}
}
test "bucketSector: NPORT-P Equity / * and Equity Preferred / * -> Equity" {
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Corporate"));
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Other"));
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Registered Fund"));
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity Preferred / Corporate"));
}
test "bucketSector: NPORT-P Loan / * -> Fixed Income" {
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Loan / Corporate"));
}
test "bucketSector: NPORT-P Asset-Backed variants -> Fixed Income" {
// All three asset-backed prefixes should bucket the same
// way. Asset-backed securities are bond-like by structure.
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed / Corporate Mortgage"));
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed / US GSE Mortgage"));
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed CBO/CDO / Corporate"));
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed Other / Corporate"));
}
test "bucketSector: Short-Term Investment Vehicle / * -> Cash" {
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Corporate"));
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Registered Fund"));
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Private Fund"));
}
test "bucketSector: Repurchase Agreement / * -> Cash" {
// PTY-style leverage liability sleeve. Bucket is Cash; the
// negative pct flows through honestly into bucket math.
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Repurchase Agreement / Other"));
}
test "bucketSector: Derivative variants -> Other" {
try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative / Corporate"));
try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative / Other"));
try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative-FX / Other"));
try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative-FX / Corporate"));
}
test "bucketSector: Direct Real Property and Direct Credit Risk -> Other" {
try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Real Property / Other"));
try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Credit Risk / Other"));
}
test "bucketSector: GICS sector names -> Equity" {
const gics = [_][]const u8{
"Technology",
"Healthcare",
"Financial Services",
"Consumer Cyclical",
"Consumer Defensive",
"Energy",
"Utilities",
"Real Estate",
"Industrials",
"Basic Materials",
"Communication Services",
};
for (gics) |s| {
try std.testing.expectEqualStrings(bucket_equity, bucketSector(s));
}
}
test "bucketSector: sentinels stay Other" {
try std.testing.expectEqualStrings(bucket_other, bucketSector("TODO"));
try std.testing.expectEqualStrings(bucket_other, bucketSector("Unknown"));
try std.testing.expectEqualStrings(bucket_other, bucketSector(""));
try std.testing.expectEqualStrings(bucket_other, bucketSector("Unclassified"));
}
test "bucketSector: curated-bucket-shaped unknown strings default to Equity" {
// After the bucket commit, `bucketSector` is called with
// either NPORT-P-shaped strings, GICS sector names, or
// composite/curated bucket labels (from `deriveBucket` or
// user-curated `bucket::` overrides). For composite-shaped
// strings that don't match any explicit Bonds/Cash/Options
// pattern, the default is Equity - composite buckets
// describe equity sleeves unless they say otherwise. This
// is the right default because:
// 1. The user's primary use of the Asset Category
// breakdown is "what fraction is exposed to equity
// drawdowns?" - a curated bucket like "US Large Cap"
// definitely IS equity.
// 2. The cost of the wrong default is asymmetric: a real
// bond bucket mis-bucketed as Equity will show in the
// 4-bucket coarse breakdown as overweight equity (very
// visible bug). A real equity bucket mis-bucketed as
// Other will silently disappear from the
// stocks/bonds/cash header (very subtle bug).
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Fintech"));
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Some Future Label"));
try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Large Cap"));
try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Mid Cap"));
try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Small Cap"));
try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Dividend Equity"));
try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Healthcare ETF"));
try std.testing.expectEqualStrings(bucket_equity, bucketSector("International Developed"));
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Emerging Markets"));
}
test "bucketSector: composite Bonds buckets -> Fixed Income" {
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("US Bonds"));
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("International Bonds"));
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("EM Bonds"));
}
test "bucketSector: composite Cash buckets -> Cash" {
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Cash & CDs"));
}
test "bucketSector: Options keyword -> Other" {
try std.testing.expectEqualStrings(bucket_other, bucketSector("Options"));
}
test "bucketSector: NPORT-P fallthrough (slash without recognized prefix) -> Other" {
// Strings containing `/` that didn't match any specific
// NPORT-P prefix branch are real-property / credit-risk /
// miscellaneous categories. Bucket as Other.
try std.testing.expectEqualStrings(bucket_other, bucketSector("Other / Corporate"));
try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Real Property / Other"));
}
test "bucketSector: returns same pointer for repeated calls (static-string property)" {
// Both callers use the result as a HashMap key. Stability of
// the pointer (not just equality of bytes) is what makes
// this safe without any dupe.
const a = bucketSector("Debt / Corporate");
const b = bucketSector("Debt / US Treasury");
try std.testing.expectEqual(@intFromPtr(a.ptr), @intFromPtr(b.ptr));
try std.testing.expectEqual(@intFromPtr(bucketSector("Equity / Corporate").ptr), @intFromPtr(bucket_equity.ptr));
try std.testing.expectEqual(@intFromPtr(bucketSector("TODO").ptr), @intFromPtr(bucket_other.ptr));
}
test "bucketSector: case-sensitive (defensive - bad input lands in Other, not crash)" {
// We don't normalize case. "debt / corporate" doesn't match
// "Debt / Corporate" so it falls through to Other. Tests the
// contract: only canonical strings are recognized.
try std.testing.expectEqualStrings(bucket_other, bucketSector("debt / corporate"));
try std.testing.expectEqualStrings(bucket_other, bucketSector("EQUITY / CORPORATE"));
}
test "bucketSector: legacy hand-written 'Bonds' -> Fixed Income" {
// metadata.srf entries that pre-date EDGAR fund decomposition
// use the literal word `Bonds` as the sector. Map to Fixed
// Income so the Asset Category breakdown picks them up
// alongside the NPORT-P `Debt / *` rows.
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Bonds"));
}
test "bucketSector: legacy hand-written 'Cash' -> Cash" {
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Cash"));
}
test "bucketSector: legacy 'Diversified' -> Equity (broad equity fund)" {
// "Diversified" in practice means an S&P 500 / total-market
// index fund holding all sectors - overwhelmingly equity.
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Diversified"));
}
test "bucketSector: legacy 'Financials' (with s) -> Equity" {
// Wikidata's canonical name is "Financial Services"; older
// hand-written entries use "Financials". Both must map to
// Equity so legacy data doesn't silently land in Other.
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Financials"));
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));
}
// ── collapseBreakdownAtGranularity ────────────────────────────
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, .fine, 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, .fine, 100.0);
defer allocator.free(result);
var total: f64 = 0;
for (result) |item| total += item.value;
try std.testing.expectApproxEqAbs(@as(f64, 100.0), total, 0.01);
}
/// Map an `asset_class` string to one of the four asset-category
/// buckets. Used as a fallback when a classification entry has
/// no `sector` but does have an `asset_class` (legacy
/// hand-written entries for CITs / CUSIPs / blended funds where
/// the user wrote `asset_class::Bonds,pct:num:30` without
/// a sector). Returns `bucket_other` for unrecognized values.
pub fn bucketAssetClass(asset_class: []const u8) []const u8 {
if (std.mem.eql(u8, asset_class, "Bonds")) return bucket_fixed_income;
if (std.mem.eql(u8, asset_class, "Cash")) return bucket_cash;
if (std.mem.eql(u8, asset_class, "Cash & CDs")) return bucket_cash;
// US size buckets and international/EM buckets are all equity.
if (std.mem.eql(u8, asset_class, "US Large Cap")) return bucket_equity;
if (std.mem.eql(u8, asset_class, "US Mid Cap")) return bucket_equity;
if (std.mem.eql(u8, asset_class, "US Small Cap")) return bucket_equity;
if (std.mem.eql(u8, asset_class, "International Developed")) return bucket_equity;
if (std.mem.eql(u8, asset_class, "Emerging Markets")) return bucket_equity;
// Mutual Fund / ETF / Fund are too generic to bucket without
// sector data - fall through to Other rather than guess
// wrong. The companion `sector` field should already have
// bucketed these via `bucketSector`; if it didn't, that's a
// metadata-quality signal (TODO sector that needs filling
// in) and Other is the right label.
return bucket_other;
}
// ── bucketAssetClass ──────────────────────────────────────────
test "bucketAssetClass: Bonds -> Fixed Income" {
try std.testing.expectEqualStrings(bucket_fixed_income, bucketAssetClass("Bonds"));
}
test "bucketAssetClass: Cash variants -> Cash" {
try std.testing.expectEqualStrings(bucket_cash, bucketAssetClass("Cash"));
try std.testing.expectEqualStrings(bucket_cash, bucketAssetClass("Cash & CDs"));
}
test "bucketAssetClass: US size buckets -> Equity" {
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Large Cap"));
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Mid Cap"));
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Small Cap"));
}
test "bucketAssetClass: international + EM -> Equity" {
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("International Developed"));
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("Emerging Markets"));
}
test "bucketAssetClass: generic Fund/ETF/Mutual Fund -> Other (not enough info)" {
// The companion `sector` field is what disambiguates Fund-typed
// entries. If sector is missing too, calling these "Equity"
// would be a guess; Other is the honest label that signals
// a metadata-quality issue (sector::TODO needs filling in).
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Fund"));
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("ETF"));
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Mutual Fund"));
}
test "bucketAssetClass: unknown / sentinels -> Other" {
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass(""));
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("TODO"));
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Unknown"));
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Some Future Class"));
}
test "bucketAssetClass: case-sensitive - bad case lands in Other" {
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("bonds"));
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("US LARGE CAP"));
}
test "bucketAssetClass: returns same pointer for same bucket (static-string property)" {
// Same invariant as bucketSector - result is a stable
// HashMap key without dupe.
try std.testing.expectEqual(@intFromPtr(bucketAssetClass("US Large Cap").ptr), @intFromPtr(bucket_equity.ptr));
try std.testing.expectEqual(@intFromPtr(bucketAssetClass("Bonds").ptr), @intFromPtr(bucket_fixed_income.ptr));
try std.testing.expectEqual(@intFromPtr(bucketAssetClass("Cash").ptr), @intFromPtr(bucket_cash.ptr));
try std.testing.expectEqual(@intFromPtr(bucketAssetClass("Fund").ptr), @intFromPtr(bucket_other.ptr));
}
// ── breakdownSections ─────────────────────────────────────────
test "breakdownSections: returns 5 sections" {
var ac_cat = [_]BreakdownItem{};
var sec = [_]BreakdownItem{};
var geo = [_]BreakdownItem{};
var acct = [_]BreakdownItem{};
var tax = [_]BreakdownItem{};
const result = AnalysisResult{
.asset_category = &ac_cat,
.sector = &sec,
.geo = &geo,
.account = &acct,
.tax_type = &tax,
.unclassified = &.{},
.total_value = 0,
};
const sections = breakdownSections(&result);
try std.testing.expectEqual(@as(usize, 5), sections.len);
}
test "breakdownSections: titles in expected order, no leading whitespace, unique" {
var ac_cat = [_]BreakdownItem{};
var sec = [_]BreakdownItem{};
var geo = [_]BreakdownItem{};
var acct = [_]BreakdownItem{};
var tax = [_]BreakdownItem{};
const result = AnalysisResult{
.asset_category = &ac_cat,
.sector = &sec,
.geo = &geo,
.account = &acct,
.tax_type = &tax,
.unclassified = &.{},
.total_value = 0,
};
const sections = breakdownSections(&result);
const expected = [_][]const u8{
"Asset Category",
"Sector",
"Geographic",
"By Account",
"By Tax Type",
};
for (sections, expected) |s, want| {
try std.testing.expectEqualStrings(want, s.title);
// No leading whitespace baked into the title - renderers
// own indent.
try std.testing.expect(s.title.len > 0);
try std.testing.expect(s.title[0] != ' ');
try std.testing.expect(s.title[0] != '\t');
}
// Titles must be unique.
for (sections, 0..) |a, i| {
for (sections[i + 1 ..]) |b| {
try std.testing.expect(!std.mem.eql(u8, a.title, b.title));
}
}
}
test "breakdownSections: items.ptr points to AnalysisResult fields" {
// The single-source-of-truth promise: each section borrows
// from the corresponding AnalysisResult field. Catches anyone
// sliding in a copy or reordering the fields.
var ac_cat = [_]BreakdownItem{
.{ .label = "Equity", .weight = 1.0, .value = 100.0 },
};
var sec = [_]BreakdownItem{
.{ .label = "Technology", .weight = 0.5, .value = 50.0 },
};
var geo = [_]BreakdownItem{};
var acct = [_]BreakdownItem{};
var tax = [_]BreakdownItem{};
const result = AnalysisResult{
.asset_category = &ac_cat,
.sector = &sec,
.geo = &geo,
.account = &acct,
.tax_type = &tax,
.unclassified = &.{},
.total_value = 100,
};
const sections = breakdownSections(&result);
try std.testing.expectEqual(result.asset_category.ptr, sections[0].items.ptr);
try std.testing.expectEqual(result.sector.ptr, sections[1].items.ptr);
try std.testing.expectEqual(result.geo.ptr, sections[2].items.ptr);
try std.testing.expectEqual(result.account.ptr, sections[3].items.ptr);
try std.testing.expectEqual(result.tax_type.ptr, sections[4].items.ptr);
}
test "breakdownSections: Asset Category is first (coarse-to-fine ordering)" {
var ac_cat = [_]BreakdownItem{};
var sec = [_]BreakdownItem{};
var geo = [_]BreakdownItem{};
var acct = [_]BreakdownItem{};
var tax = [_]BreakdownItem{};
const result = AnalysisResult{
.asset_category = &ac_cat,
.sector = &sec,
.geo = &geo,
.account = &acct,
.tax_type = &tax,
.unclassified = &.{},
.total_value = 0,
};
const sections = breakdownSections(&result);
// Asset Category (4 buckets) is the coarsest view; should
// come first so the user sees the headline number before
// the finer breakdowns.
try std.testing.expectEqualStrings("Asset Category", sections[0].title);
}
// ── analyzePortfolio: asset_category aggregation ──────────────
/// Helper: minimal Allocation for asset-category tests. Only
/// the fields read by `analyzePortfolio`'s sector loop matter.
fn mkAlloc(symbol: []const u8, mv: f64) Allocation {
return .{
.symbol = symbol,
.display_symbol = symbol,
.shares = 1,
.avg_cost = mv,
.current_price = mv,
.market_value = mv,
.cost_basis = mv,
.weight = 1.0,
.unrealized_gain_loss = 0.0,
.unrealized_return = 0.0,
};
}
test "analyzePortfolio: multi-sector fund (FAGIX shape) splits asset_category buckets" {
const allocator = std.testing.allocator;
const allocations = [_]Allocation{mkAlloc("FAGIX", 100_000)};
var entries = [_]ClassificationEntry{
.{ .symbol = "FAGIX", .sector = "Debt / Corporate", .pct = 47.69 },
.{ .symbol = "FAGIX", .sector = "Equity / Corporate", .pct = 22.49 },
.{ .symbol = "FAGIX", .sector = "Short-Term Investment Vehicle / Registered Fund", .pct = 13.37 },
.{ .symbol = "FAGIX", .sector = "Loan / Corporate", .pct = 9.99 },
.{ .symbol = "FAGIX", .sector = "Equity Preferred / Corporate", .pct = 3.59 },
};
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
var result = try analyzePortfolio(
allocator,
&allocations,
cm,
portfolio,
100_000,
null,
Date.fromYmd(2024, 6, 1),
);
defer result.deinit(allocator);
// Find each bucket's value.
var equity_val: f64 = 0;
var fi_val: f64 = 0;
var cash_val: f64 = 0;
for (result.asset_category) |item| {
if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value;
if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi_val = item.value;
if (std.mem.eql(u8, item.label, bucket_cash)) cash_val = item.value;
}
// Equity = 22.49 + 3.59 = 26.08% of $100K = $26,080
try std.testing.expectApproxEqAbs(@as(f64, 26_080), equity_val, 1.0);
// Fixed Income = 47.69 + 9.99 = 57.68% of $100K = $57,680
try std.testing.expectApproxEqAbs(@as(f64, 57_680), fi_val, 1.0);
// Cash = 13.37% of $100K = $13,370
try std.testing.expectApproxEqAbs(@as(f64, 13_370), cash_val, 1.0);
}
test "analyzePortfolio: pure-stock fund (SCHD shape) lands in Equity + tiny Cash" {
const allocator = std.testing.allocator;
const allocations = [_]Allocation{mkAlloc("SCHD", 100_000)};
var entries = [_]ClassificationEntry{
.{ .symbol = "SCHD", .sector = "Equity / Corporate", .pct = 99.70 },
.{ .symbol = "SCHD", .sector = "Short-Term Investment Vehicle / Registered Fund", .pct = 0.19 },
};
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
var result = try analyzePortfolio(
allocator,
&allocations,
cm,
portfolio,
100_000,
null,
Date.fromYmd(2024, 6, 1),
);
defer result.deinit(allocator);
var equity_val: f64 = 0;
var cash_val: f64 = 0;
for (result.asset_category) |item| {
if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value;
if (std.mem.eql(u8, item.label, bucket_cash)) cash_val = item.value;
}
try std.testing.expectApproxEqAbs(@as(f64, 99_700), equity_val, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 190), cash_val, 1.0);
}
test "analyzePortfolio: GICS-sectored stock lands in Equity bucket" {
const allocator = std.testing.allocator;
const allocations = [_]Allocation{mkAlloc("NVDA", 50_000)};
var entries = [_]ClassificationEntry{
.{ .symbol = "NVDA", .sector = "Technology" },
};
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
var result = try analyzePortfolio(
allocator,
&allocations,
cm,
portfolio,
50_000,
null,
Date.fromYmd(2024, 6, 1),
);
defer result.deinit(allocator);
try std.testing.expectEqual(@as(usize, 1), result.asset_category.len);
try std.testing.expectEqualStrings(bucket_equity, result.asset_category[0].label);
try std.testing.expectApproxEqAbs(@as(f64, 50_000), result.asset_category[0].value, 1.0);
}
test "analyzePortfolio: display_symbol is never a classification key" {
// Regression: a note/label-derived `display_symbol` must not
// classify. The engine keys on `alloc.symbol` (priceSymbol())
// only, so a metadata entry written against the display label
// classifies nothing - editing a label or note can't move a
// single breakdown dollar.
const allocator = std.testing.allocator;
const allocations = [_]Allocation{
.{
.symbol = "02315N600", // bare CUSIP: the economic identity
.display_symbol = "TGT2035", // human label: the would-be footgun
.shares = 1,
.avg_cost = 50_000,
.current_price = 50_000,
.market_value = 50_000,
.cost_basis = 50_000,
.weight = 1.0,
.unrealized_gain_loss = 0.0,
.unrealized_return = 0.0,
},
};
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
// Metadata keyed on the display label must NOT classify; the
// holding falls through to unclassified (where display_symbol is
// still the friendly label shown to the user).
{
var entries = [_]ClassificationEntry{
.{ .symbol = "TGT2035", .sector = "Technology" },
};
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
var result = try analyzePortfolio(allocator, &allocations, cm, portfolio, 50_000, null, Date.fromYmd(2024, 6, 1));
defer result.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), result.asset_category.len);
try std.testing.expectEqual(@as(usize, 1), result.unclassified.len);
try std.testing.expectEqualStrings("TGT2035", result.unclassified[0]);
}
// Metadata keyed on the CUSIP (the economic identity) DOES classify.
{
var entries = [_]ClassificationEntry{
.{ .symbol = "02315N600", .sector = "Technology" },
};
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
var result = try analyzePortfolio(allocator, &allocations, cm, portfolio, 50_000, null, Date.fromYmd(2024, 6, 1));
defer result.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), result.unclassified.len);
try std.testing.expectEqual(@as(usize, 1), result.asset_category.len);
try std.testing.expectEqualStrings(bucket_equity, result.asset_category[0].label);
}
}
test "analyzePortfolio: empty portfolio produces empty asset_category" {
const allocator = std.testing.allocator;
const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator };
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
var result = try analyzePortfolio(
allocator,
&.{},
cm,
portfolio,
0,
null,
Date.fromYmd(2024, 6, 1),
);
defer result.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), result.asset_category.len);
}
test "analyzePortfolio: PTY-shape negative repo flows honestly into Cash bucket" {
// Portfolio has only PTY. Repo line is negative; bucket math
// sums it honestly. Cash bucket value is the (negative)
// repo contribution alone, since this fund has no Cash
// SIV sleeve.
const allocator = std.testing.allocator;
const allocations = [_]Allocation{mkAlloc("PTY", 10_000)};
var entries = [_]ClassificationEntry{
.{ .symbol = "PTY", .sector = "Debt / Corporate", .pct = 41.65 },
.{ .symbol = "PTY", .sector = "Loan / Corporate", .pct = 40.05 },
.{ .symbol = "PTY", .sector = "Equity / Corporate", .pct = 5.78 },
.{ .symbol = "PTY", .sector = "Repurchase Agreement / Other", .pct = -29.72 },
};
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
var result = try analyzePortfolio(
allocator,
&allocations,
cm,
portfolio,
10_000,
null,
Date.fromYmd(2024, 6, 1),
);
defer result.deinit(allocator);
var cash_val: f64 = 0;
var fi_val: f64 = 0;
var equity_val: f64 = 0;
for (result.asset_category) |item| {
if (std.mem.eql(u8, item.label, bucket_cash)) cash_val = item.value;
if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi_val = item.value;
if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value;
}
// Cash = -29.72% × $10,000 = -$2,972 (honest negative).
try std.testing.expectApproxEqAbs(@as(f64, -2_972), cash_val, 1.0);
// Fixed Income = (41.65 + 40.05)% × $10,000 = $8,170.
try std.testing.expectApproxEqAbs(@as(f64, 8_170), fi_val, 1.0);
// Equity = 5.78% × $10,000 = $578.
try std.testing.expectApproxEqAbs(@as(f64, 578), equity_val, 1.0);
}
test "analyzePortfolio: asset_category includes literal cash + CD totals in Cash bucket" {
// Literal cash and CDs should add to the Cash bucket's
// value, not just Cash & CDs in the asset_class breakdown.
const allocator = std.testing.allocator;
const Lot = @import("../models/portfolio.zig").Lot;
var lots = [_]Lot{
.{
.symbol = "CASH",
.shares = 50_000,
.open_date = Date.fromYmd(2020, 1, 1),
.open_price = 1.0,
.security_type = .cash,
.account = "Brokerage",
},
.{
.symbol = "CD-1",
.shares = 10_000, // face value
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 1.0,
.security_type = .cd,
.account = "Brokerage",
.maturity_date = Date.fromYmd(2027, 1, 1),
},
};
const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator };
var result = try analyzePortfolio(
allocator,
&.{},
cm,
portfolio,
60_000,
null,
Date.fromYmd(2024, 6, 1),
);
defer result.deinit(allocator);
var cash_val: f64 = 0;
for (result.asset_category) |item| {
if (std.mem.eql(u8, item.label, bucket_cash)) cash_val = item.value;
}
try std.testing.expectApproxEqAbs(@as(f64, 60_000), cash_val, 1.0);
}
test "analyzePortfolio: legacy entry (asset_class only, no sector) buckets via fallback" {
// Hand-written CIT/CUSIP entries in metadata.srf often have
// `asset_class::Bonds,pct:num:30` with no sector. The
// fallback path through `bucketAssetClass` must pick these
// up so they land in Fixed Income, not Other.
const allocator = std.testing.allocator;
const allocations = [_]Allocation{mkAlloc("LEGACY-CIT", 100_000)};
var entries = [_]ClassificationEntry{
.{ .symbol = "LEGACY-CIT", .asset_class = "Bonds", .pct = 60 },
.{ .symbol = "LEGACY-CIT", .asset_class = "US Large Cap", .pct = 40 },
};
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
var result = try analyzePortfolio(
allocator,
&allocations,
cm,
portfolio,
100_000,
null,
Date.fromYmd(2024, 6, 1),
);
defer result.deinit(allocator);
var equity_val: f64 = 0;
var fi_val: f64 = 0;
for (result.asset_category) |item| {
if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value;
if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi_val = item.value;
}
// 60% Bonds -> Fixed Income = $60,000.
try std.testing.expectApproxEqAbs(@as(f64, 60_000), fi_val, 1.0);
// 40% US Large Cap -> Equity = $40,000.
try std.testing.expectApproxEqAbs(@as(f64, 40_000), equity_val, 1.0);
}
test "analyzePortfolio: sector wins over asset_class when both present" {
// Defensive: we should not double-count. If both fields are
// present, only the sector-based bucket fires.
const allocator = std.testing.allocator;
const allocations = [_]Allocation{mkAlloc("FOO", 100_000)};
var entries = [_]ClassificationEntry{
// sector says Fixed Income (Debt / *), asset_class says
// Equity (US Large Cap). sector should win.
.{ .symbol = "FOO", .sector = "Debt / Corporate", .asset_class = "US Large Cap" },
};
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
var result = try analyzePortfolio(
allocator,
&allocations,
cm,
portfolio,
100_000,
null,
Date.fromYmd(2024, 6, 1),
);
defer result.deinit(allocator);
// Exactly one row, in Fixed Income.
try std.testing.expectEqual(@as(usize, 1), result.asset_category.len);
try std.testing.expectEqualStrings(bucket_fixed_income, result.asset_category[0].label);
try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.asset_category[0].value, 1.0);
}
test "abbreviateSector: known long labels collapse, others pass through" {
try std.testing.expectEqualStrings("Comm. Services", abbreviateSector("Communication Services"));
try std.testing.expectEqualStrings("Technology", abbreviateSector("Technology"));
try std.testing.expectEqualStrings("Bonds", abbreviateSector("Bonds"));
try std.testing.expectEqualStrings("Equity / Corporate", abbreviateSector("Equity / Corporate"));
try std.testing.expectEqualStrings("", abbreviateSector(""));
}