zfin/src/analytics/analysis.zig

2110 lines
89 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 srf = @import("srf");
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");
/// 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)
};
/// 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)",
};
}
};
/// 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,
};
/// 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.
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";
}
/// 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;
}
/// Return all entries matching a given institution.
pub fn entriesForInstitution(self: AccountMap, institution: []const u8) []const AccountTaxEntry {
var count: usize = 0;
for (self.entries) |e| {
if (e.institution) |inst| {
if (std.mem.eql(u8, inst, institution)) count += 1;
}
}
if (count == 0) return &.{};
return self.entries;
}
/// 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;
}
};
/// Parse an accounts.srf file into an AccountMap.
/// Each record has: account::<NAME>,tax_type::<TYPE>[,institution::<INST>][,account_number::<NUM>]
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, .{}) catch continue;
try entries.append(allocator, .{
.account = try allocator.dupe(u8, entry.account),
.tax_type = entry.tax_type,
.institution = if (entry.institution) |s| try allocator.dupe(u8, s) else null,
.account_number = if (entry.account_number) |s| try allocator.dupe(u8, s) else null,
.update_cadence = entry.update_cadence,
.cash_is_contribution = entry.cash_is_contribution,
.direct_indexing = entry.direct_indexing,
.shielded = entry.shielded,
});
}
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.
/// - Else if `entry.tax_type == .taxable`, NOT shielded.
/// - Else (Traditional / Roth / HSA), shielded by default.
///
/// 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 is_shielded = accountIsShielded(item.label, account_map);
if (is_shielded) {
shielded += item.value;
} else {
exposed += item.value;
}
}
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,
};
}
/// Look up the shielding decision for one account name.
/// Exposed (returns false) when:
/// - Account is not in the map (defensive default).
/// - Explicit `shielded::false` override.
/// - tax_type is `taxable` and no override.
fn accountIsShielded(account: []const u8, account_map: AccountMap) bool {
for (account_map.entries) |e| {
if (!std.mem.eql(u8, e.account, account)) continue;
if (e.shielded) |explicit| return explicit;
return e.tax_type != .taxable;
}
return false;
}
// ── 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. The income-feels-like-bonds intuition belongs in
// a separate yield-weighted analysis (see TODO.md
// "Dividend equity / income-shaped equity"), not in the
// asset-class taxonomy.
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;
// "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;
// Everything else: derivatives, real property, sentinels
// (TODO/Unknown/empty), unrecognized future labels.
return bucket_other;
}
// ── Sector display granularity ───────────────────────────────
/// Granularity tier for the Sector breakdown display. Lets the
/// user toggle between coarse bucket-level summary and the raw
/// fine-grained NPORT-P decomposition.
pub const Granularity = enum {
/// Four buckets: Equity / Fixed Income / Cash / Other.
/// Same labels as the Asset Category breakdown.
coarse,
/// ~12-16 buckets: collapses NPORT-P sub-flavors but keeps
/// GICS sectors distinct. Default for most users — answers
/// "how exposed am I to bonds vs stocks vs cash, and which
/// sectors within stocks?".
mid,
/// Raw NPORT-P strings: every Debt / X variant, every
/// Asset-Backed / Y variant, every Derivative / Z variant
/// is its own row. Useful for spotting fine-grained
/// concentrations within the bond sleeve (e.g. "am I overweight
/// US Treasury vs Municipal?").
fine,
};
// Mid-granularity bucket labels. Static literals so they can be
// used as stable HashMap keys without duping.
pub const mid_bonds: []const u8 = "Bonds";
pub const mid_equity_corporate: []const u8 = "Equity / Corporate";
pub const mid_equity_preferred: []const u8 = "Equity Preferred";
pub const mid_cash_equivalents: []const u8 = "Cash & Equivalents";
pub const mid_derivatives: []const u8 = "Derivatives";
pub const mid_other: []const u8 = "Other";
/// 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 (or, at fine granularity, the input slice
/// itself) suitable for use as a stable HashMap key.
///
/// Granularity tiers:
///
/// - **coarse**: delegates to `bucketSector` — Equity / Fixed Income
/// / Cash / Other (4 buckets).
/// - **mid**: collapses NPORT-P sub-flavors (all Debt / * → Bonds,
/// all Asset-Backed / * → Bonds, all STIV / * → Cash & Equivalents,
/// all Derivative / * → Derivatives) while keeping GICS sectors
/// distinct (Technology, Healthcare, etc.).
/// - **fine**: passthrough — returns the input unchanged.
pub fn collapseSector(sector: []const u8, granularity: Granularity) []const u8 {
return switch (granularity) {
.fine => sector,
.coarse => bucketSector(sector),
.mid => midBucket(sector),
};
}
/// Mid-granularity bucket lookup. Pure data, no allocation.
fn midBucket(sector: []const u8) []const u8 {
// Bond-shaped (NPORT-P): all Debt / *, Loan / *, Asset-Backed / *.
if (std.mem.startsWith(u8, sector, "Debt")) return mid_bonds;
if (std.mem.startsWith(u8, sector, "Loan")) return mid_bonds;
if (std.mem.startsWith(u8, sector, "Asset-Backed")) return mid_bonds;
// Plain-English bond label from legacy hand-written metadata.
if (std.mem.eql(u8, sector, "Bonds")) return mid_bonds;
// Cash-shaped (NPORT-P): STIV variants + repurchase agreement.
if (std.mem.startsWith(u8, sector, "Short-Term Investment Vehicle")) return mid_cash_equivalents;
if (std.mem.startsWith(u8, sector, "Repurchase Agreement")) return mid_cash_equivalents;
if (std.mem.eql(u8, sector, "Cash")) return mid_cash_equivalents;
if (std.mem.eql(u8, sector, "Cash & CDs")) return mid_cash_equivalents;
// Equity Preferred is distinct at mid because it's a hybrid.
if (std.mem.startsWith(u8, sector, "Equity Preferred")) return mid_equity_preferred;
// Generic Equity / * collapses to a single Equity / Corporate
// bucket. Specifically "Equity / Corporate" / "Equity / Other"
// / "Equity / Registered Fund" all merge here.
if (std.mem.startsWith(u8, sector, "Equity")) return mid_equity_corporate;
// Derivatives.
if (std.mem.startsWith(u8, sector, "Derivative")) return mid_derivatives;
// GICS sector names pass through unchanged. Same exact-match
// list as `bucketSector`.
const gics = [_][]const u8{
"Technology",
"Healthcare",
"Financial Services",
"Financials",
"Consumer Cyclical",
"Consumer Defensive",
"Energy",
"Utilities",
"Real Estate",
"Industrials",
"Basic Materials",
"Communication Services",
"Diversified",
};
for (gics) |g| if (std.mem.eql(u8, sector, g)) return sector;
// Direct Real Property, Direct Credit Risk, sentinels — Other.
return mid_other;
}
/// Compute portfolio analysis from allocations and classification metadata.
/// `allocations` are the stock/ETF positions with market values.
/// `classifications` is the metadata file data.
/// `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
// Try both the raw symbol and display_symbol
var found = false;
for (classifications.entries) |entry| {
if (std.mem.eql(u8, entry.symbol, alloc.symbol) or
std.mem.eql(u8, entry.symbol, alloc.display_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
.option => @abs(lot.shares) * lot.open_price,
.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: map each account's total to its tax type
if (account_map) |am| {
var acct_iter = acct_map.iterator();
while (acct_iter.next()) |kv| {
const tt = am.taxTypeFor(kv.key_ptr.*);
const prev = tax_map.get(tt) orelse 0;
try tax_map.put(tt, prev + kv.value_ptr.*);
}
}
// Convert maps to sorted slices
const total = if (total_portfolio_value > 0) total_portfolio_value else 1.0;
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 = try mapToSortedBreakdown(allocator, acct_map, total),
.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: 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.?);
}
// ── 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 "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());
}
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);
}
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 and unrecognized → 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("Fintech"));
try std.testing.expectEqualStrings(bucket_other, bucketSector("Some Future Label"));
}
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));
}
test "collapseSector .mid: all Debt / * collapse to Bonds" {
const cases = [_][]const u8{
"Debt / Corporate",
"Debt / US Treasury",
"Debt / Municipal",
"Debt / Non-US Sovereign",
"Debt / US Gov Agency",
"Debt / US GSE",
};
for (cases) |s| {
try std.testing.expectEqualStrings(mid_bonds, collapseSector(s, .mid));
}
}
test "collapseSector .mid: all Asset-Backed and Loan variants collapse to Bonds" {
try std.testing.expectEqualStrings(mid_bonds, collapseSector("Asset-Backed / Corporate Mortgage", .mid));
try std.testing.expectEqualStrings(mid_bonds, collapseSector("Asset-Backed CBO/CDO / Corporate", .mid));
try std.testing.expectEqualStrings(mid_bonds, collapseSector("Asset-Backed Other / Corporate", .mid));
try std.testing.expectEqualStrings(mid_bonds, collapseSector("Loan / Corporate", .mid));
try std.testing.expectEqualStrings(mid_bonds, collapseSector("Bonds", .mid));
}
test "collapseSector .mid: all STIV / Repurchase collapse to Cash & Equivalents" {
try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Short-Term Investment Vehicle / Corporate", .mid));
try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Short-Term Investment Vehicle / Registered Fund", .mid));
try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Short-Term Investment Vehicle / Private Fund", .mid));
try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Repurchase Agreement / Other", .mid));
try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Cash", .mid));
try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Cash & CDs", .mid));
}
test "collapseSector .mid: Equity Preferred is its own bucket" {
// Hybrid security — distinct from generic equity at mid.
try std.testing.expectEqualStrings(mid_equity_preferred, collapseSector("Equity Preferred / Corporate", .mid));
}
test "collapseSector .mid: Equity / * (non-Preferred) collapses to Equity / Corporate" {
try std.testing.expectEqualStrings(mid_equity_corporate, collapseSector("Equity / Corporate", .mid));
try std.testing.expectEqualStrings(mid_equity_corporate, collapseSector("Equity / Other", .mid));
try std.testing.expectEqualStrings(mid_equity_corporate, collapseSector("Equity / Registered Fund", .mid));
}
test "collapseSector .mid: GICS sectors stay distinct" {
// The whole point of mid: collapse NPORT-P sub-flavors but
// keep GICS sector breakdown so users see stock concentrations.
try std.testing.expectEqualStrings("Technology", collapseSector("Technology", .mid));
try std.testing.expectEqualStrings("Healthcare", collapseSector("Healthcare", .mid));
try std.testing.expectEqualStrings("Financial Services", collapseSector("Financial Services", .mid));
try std.testing.expectEqualStrings("Financials", collapseSector("Financials", .mid));
try std.testing.expectEqualStrings("Diversified", collapseSector("Diversified", .mid));
try std.testing.expectEqualStrings("Energy", collapseSector("Energy", .mid));
}
test "collapseSector .mid: Derivative variants collapse to Derivatives" {
try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative / Corporate", .mid));
try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative / Other", .mid));
try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative-FX / Other", .mid));
try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative-FX / Corporate", .mid));
}
test "collapseSector .mid: real property and unrecognized -> Other" {
try std.testing.expectEqualStrings(mid_other, collapseSector("Direct Real Property / Other", .mid));
try std.testing.expectEqualStrings(mid_other, collapseSector("Direct Credit Risk / Other", .mid));
try std.testing.expectEqualStrings(mid_other, collapseSector("TODO", .mid));
try std.testing.expectEqualStrings(mid_other, collapseSector("Unknown", .mid));
try std.testing.expectEqualStrings(mid_other, collapseSector("", .mid));
try std.testing.expectEqualStrings(mid_other, collapseSector("Some Future Label", .mid));
}
test "collapseSector .mid: returns same pointer for same bucket (static-string property)" {
// Stable HashMap keys without duping.
const a = collapseSector("Debt / Corporate", .mid);
const b = collapseSector("Debt / US Treasury", .mid);
try std.testing.expectEqual(@intFromPtr(a.ptr), @intFromPtr(b.ptr));
try std.testing.expectEqual(@intFromPtr(mid_bonds.ptr), @intFromPtr(a.ptr));
}
// ── collapseBreakdownAtGranularity ────────────────────────────
test "collapseBreakdownAtGranularity: VBTLX-shape Debt sleeves collapse to Bonds at mid" {
// VBTLX has six different Debt / X rows. At mid granularity
// they should all sum into one Bonds row.
const allocator = std.testing.allocator;
const items = [_]BreakdownItem{
.{ .label = "Debt / Corporate", .weight = 0.40, .value = 40_000.0 },
.{ .label = "Debt / US Treasury", .weight = 0.30, .value = 30_000.0 },
.{ .label = "Debt / Non-US Sovereign", .weight = 0.10, .value = 10_000.0 },
.{ .label = "Debt / Municipal", .weight = 0.05, .value = 5_000.0 },
.{ .label = "Debt / US Gov Agency", .weight = 0.04, .value = 4_000.0 },
.{ .label = "Debt / US GSE", .weight = 0.01, .value = 1_000.0 },
.{ .label = "Short-Term Investment Vehicle / Registered Fund", .weight = 0.10, .value = 10_000.0 },
};
const result = try collapseBreakdownAtGranularity(allocator, &items, .mid, 100_000.0);
defer allocator.free(result);
// Two output buckets: Bonds (sum of all Debt/*) and
// Cash & Equivalents (the STIV row).
try std.testing.expectEqual(@as(usize, 2), result.len);
var bonds_value: f64 = 0;
var cash_value: f64 = 0;
for (result) |item| {
if (std.mem.eql(u8, item.label, mid_bonds)) bonds_value = item.value;
if (std.mem.eql(u8, item.label, mid_cash_equivalents)) cash_value = item.value;
}
try std.testing.expectApproxEqAbs(@as(f64, 90_000), bonds_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 10_000), cash_value, 1.0);
}
test "collapseBreakdownAtGranularity: coarse collapses everything to 4 buckets" {
const allocator = std.testing.allocator;
const items = [_]BreakdownItem{
.{ .label = "Debt / Corporate", .weight = 0.50, .value = 50_000.0 },
.{ .label = "Equity / Corporate", .weight = 0.30, .value = 30_000.0 },
.{ .label = "Technology", .weight = 0.10, .value = 10_000.0 },
.{ .label = "Short-Term Investment Vehicle / Corporate", .weight = 0.05, .value = 5_000.0 },
.{ .label = "Derivative / Other", .weight = 0.05, .value = 5_000.0 },
};
const result = try collapseBreakdownAtGranularity(allocator, &items, .coarse, 100_000.0);
defer allocator.free(result);
// Equity (30k Equity/Corp + 10k Technology) = 40k
// Fixed Income = 50k Debt
// Cash = 5k STIV
// Other = 5k Derivative
var eq: f64 = 0;
var fi: f64 = 0;
var c: f64 = 0;
var o: f64 = 0;
for (result) |item| {
if (std.mem.eql(u8, item.label, bucket_equity)) eq = item.value;
if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi = item.value;
if (std.mem.eql(u8, item.label, bucket_cash)) c = item.value;
if (std.mem.eql(u8, item.label, bucket_other)) o = item.value;
}
try std.testing.expectApproxEqAbs(@as(f64, 40_000), eq, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 50_000), fi, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 5_000), c, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 5_000), o, 1.0);
}
test "collapseBreakdownAtGranularity: fine returns equivalent breakdown unchanged" {
const allocator = std.testing.allocator;
const items = [_]BreakdownItem{
.{ .label = "Debt / Corporate", .weight = 0.50, .value = 50_000.0 },
.{ .label = "Equity / Corporate", .weight = 0.30, .value = 30_000.0 },
.{ .label = "Technology", .weight = 0.20, .value = 20_000.0 },
};
const result = try collapseBreakdownAtGranularity(allocator, &items, .fine, 100_000.0);
defer allocator.free(result);
// 3 input rows → 3 output rows (no collapsing at fine).
try std.testing.expectEqual(@as(usize, 3), result.len);
// Output sorted by value descending.
try std.testing.expectEqualStrings("Debt / Corporate", result[0].label);
try std.testing.expectApproxEqAbs(@as(f64, 50_000), result[0].value, 1.0);
}
test "collapseBreakdownAtGranularity: empty input -> empty output" {
const allocator = std.testing.allocator;
const items = [_]BreakdownItem{};
const result = try collapseBreakdownAtGranularity(allocator, &items, .mid, 100_000.0);
defer allocator.free(result);
try std.testing.expectEqual(@as(usize, 0), result.len);
}
test "collapseBreakdownAtGranularity: total values preserved through collapse" {
// Sum of output values must equal sum of input values
// (modulo float rounding).
const allocator = std.testing.allocator;
const items = [_]BreakdownItem{
.{ .label = "Debt / Corporate", .weight = 0.40, .value = 40.0 },
.{ .label = "Loan / Corporate", .weight = 0.20, .value = 20.0 },
.{ .label = "Asset-Backed / Corporate Mortgage", .weight = 0.15, .value = 15.0 },
.{ .label = "Equity / Corporate", .weight = 0.20, .value = 20.0 },
.{ .label = "Technology", .weight = 0.05, .value = 5.0 },
};
const result = try collapseBreakdownAtGranularity(allocator, &items, .mid, 100.0);
defer allocator.free(result);
var total: f64 = 0;
for (result) |item| total += item.value;
try std.testing.expectApproxEqAbs(@as(f64, 100.0), total, 0.01);
}
/// Map an `asset_class` string to one of the four asset-category
/// buckets. Used as a fallback when a classification entry has
/// no `sector` but does have an `asset_class` (legacy
/// 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: 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(""));
}