/// 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::,tax_type::[,institution::][,account_number::][,] /// where the optional flags include `audit_large_lot_threshold:num:`, /// `harvested:num:`, `harvested_date::`, /// `tax_mix_:num:` and `tax_mix_date::`. 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 /// `" / "` (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("")); }