/// Portfolio analysis engine. /// /// Takes portfolio allocations (with market values) and classification metadata, /// produces breakdowns by asset class, sector, geographic region, account, and tax type. const std = @import("std"); const srf = @import("srf"); const Allocation = @import("valuation.zig").Allocation; const ClassificationMap = @import("../models/classification.zig").ClassificationMap; const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry; const Portfolio = @import("../models/portfolio.zig").Portfolio; const Date = @import("../Date.zig"); /// A single slice of a breakdown (e.g., "Technology" -> 25.3%) pub const BreakdownItem = struct { label: []const u8, value: f64, // dollar amount weight: f64, // fraction of total (0.0 - 1.0) }; /// Tax type classification for accounts. pub const TaxType = enum { taxable, roth, traditional, hsa, pub fn label(self: TaxType) []const u8 { return switch (self) { .taxable => "Taxable", .roth => "Roth (Post-Tax)", .traditional => "Traditional (Pre-Tax)", .hsa => "HSA (Triple Tax-Free)", }; } }; /// Account tax type classification entry, parsed from accounts.srf. pub const AccountTaxEntry = struct { account: []const u8, tax_type: TaxType, institution: ?[]const u8 = null, account_number: ?[]const u8 = null, update_cadence: UpdateCadence = .weekly, /// When true, raw cash-balance changes (`cash_delta` in the /// contributions diff) on this account roll up into the /// attribution total as real contributions. /// /// Defaults to false because most cash accounts generate /// `cash_delta` entries from internal movement — interest posting, /// dividend credit, CD coupon, settlement sweeps — that would /// inflate the attribution number if counted. Set to true only /// for accounts whose cash movement is dominated by external /// contributions (payroll ESPP accrual, direct 401k cash /// deposits). See TODO.md for the design history. cash_is_contribution: bool = false, /// When true, marks the account as a direct-indexing proxy /// (lots track a benchmark with tracking-error drift rather /// than holding the benchmark directly). Two behaviors: /// /// 1. Contributions (`zfin contributions` / `zfin compare` /// attribution): the edit-detection residual tolerance is /// loosened from 0.01% (noise floor) to 1% — tracking- /// error share reconciliation no longer lands in /// `rollup_delta` / `drip_negative` and the attribution /// total stays clean. /// /// 2. Audit (`zfin audit` ratio-suggestions section): lots /// with `price_ratio == 1.0` in this account get a /// suggested ratio to bridge the brokerage vs. portfolio /// value gap. Default audit behavior skips ratio == 1.0 /// lots since there's nothing to adjust; direct-indexing /// accounts opt out of that skip. /// /// Not a general "ignore drift" flag — use only for accounts /// whose underlying lots explicitly track a benchmark (e.g. a /// basket of 500 individual stocks tracked as SPY via `ticker::` /// alias). direct_indexing: bool = false, /// Optional umbrella-insurance shielding override. When null, /// the umbrella-exposure calculation defaults to "tax_type != /// taxable means shielded" (a rough proxy for retirement-account /// status). Set explicitly when the default is wrong: /// /// - `shielded:bool:false` for pre-tax accounts that are NOT /// ERISA-protected (e.g. deferred-comp plans like Fidelity /// DCP, non-qualified annuities) — tax_type is `traditional` /// so they default to shielded, but they're not protected /// against civil judgments. /// - `shielded:bool:true` to mark a taxable account as /// shielded (rare; e.g. some asset-protection trusts). /// /// IRA state-by-state protection is not modeled. Users in /// states with weak IRA protection should set /// `shielded:bool:false` on their IRA accounts to get a /// correct umbrella-exposure number. shielded: ?bool = null, }; /// Update cadence for manual account maintenance. Parsed from accounts.srf. /// Default is `weekly` (fail-open: every account nags until explicitly silenced). pub const UpdateCadence = enum { weekly, monthly, quarterly, none, /// Number of calendar days before an account is considered overdue. pub fn thresholdDays(self: UpdateCadence) ?u32 { return switch (self) { .weekly => 7, .monthly => 30, .quarterly => 90, .none => null, }; } pub fn label(self: UpdateCadence) []const u8 { return switch (self) { .weekly => "weekly", .monthly => "monthly", .quarterly => "quarterly", .none => "none", }; } }; /// Parsed account metadata. pub const AccountMap = struct { entries: []AccountTaxEntry, allocator: std.mem.Allocator, pub fn deinit(self: *AccountMap) void { for (self.entries) |e| { self.allocator.free(e.account); if (e.institution) |s| self.allocator.free(s); if (e.account_number) |s| self.allocator.free(s); } self.allocator.free(self.entries); } /// Look up the tax type label for a given account name. pub fn taxTypeFor(self: AccountMap, account: []const u8) []const u8 { for (self.entries) |e| { if (std.mem.eql(u8, e.account, account)) { return e.tax_type.label(); } } return "Unknown"; } /// Find the portfolio account name for a given institution + account number. pub fn findByInstitutionAccount(self: AccountMap, institution: []const u8, account_number: []const u8) ?[]const u8 { for (self.entries) |e| { if (e.institution) |inst| { if (e.account_number) |num| { if (std.mem.eql(u8, inst, institution) and std.mem.eql(u8, num, account_number)) return e.account; } } } return null; } /// Return all entries matching a given institution. pub fn entriesForInstitution(self: AccountMap, institution: []const u8) []const AccountTaxEntry { var count: usize = 0; for (self.entries) |e| { if (e.institution) |inst| { if (std.mem.eql(u8, inst, institution)) count += 1; } } if (count == 0) return &.{}; return self.entries; } /// Is cash-balance movement on `account` treated as a real /// contribution (vs. internal noise) for the attribution total? /// Defaults to false when the account isn't in the map. pub fn cashIsContribution(self: AccountMap, account: []const u8) bool { for (self.entries) |e| { if (std.mem.eql(u8, e.account, account)) { return e.cash_is_contribution; } } return false; } /// Is `account` flagged as a direct-indexing proxy? See /// `AccountTaxEntry.direct_indexing` for the two behaviors this /// drives. Defaults to false when the account isn't in the map. pub fn isDirectIndexing(self: AccountMap, account: []const u8) bool { for (self.entries) |e| { if (std.mem.eql(u8, e.account, account)) { return e.direct_indexing; } } return false; } }; /// Parse an accounts.srf file into an AccountMap. /// Each record has: account::,tax_type::[,institution::][,account_number::] pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap { var entries = std.ArrayList(AccountTaxEntry).empty; errdefer { for (entries.items) |e| { allocator.free(e.account); if (e.institution) |s| allocator.free(s); if (e.account_number) |s| allocator.free(s); } entries.deinit(allocator); } var reader = std.Io.Reader.fixed(data); var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return error.InvalidData; defer it.deinit(); while (try it.next()) |fields| { const entry = fields.to(AccountTaxEntry, .{}) catch continue; try entries.append(allocator, .{ .account = try allocator.dupe(u8, entry.account), .tax_type = entry.tax_type, .institution = if (entry.institution) |s| try allocator.dupe(u8, s) else null, .account_number = if (entry.account_number) |s| try allocator.dupe(u8, s) else null, .update_cadence = entry.update_cadence, .cash_is_contribution = entry.cash_is_contribution, .direct_indexing = entry.direct_indexing, .shielded = entry.shielded, }); } return .{ .entries = try entries.toOwnedSlice(allocator), .allocator = allocator, }; } /// Complete portfolio analysis result. pub const AnalysisResult = struct { /// Coarse 4-bucket breakdown: Equity / Fixed Income / Cash / Other. /// Built by mapping each fine-grained sector through `bucketSector` /// before aggregation. The right field for portfolio-level /// debt-to-equity analysis. asset_category: []BreakdownItem, /// Breakdown by sector bucket (Technology, US Healthcare ETF, /// US Large Cap, etc.). Aggregates by `entry.bucket` — /// pre-filled by parseClassificationFile via `deriveBucket`, /// or curated by the user. Replaces the historical separate /// "Asset Class" + "Sector" breakdowns: the bucket is a /// single semantically-meaningful label that combines what /// each was trying to express. sector: []BreakdownItem, /// Breakdown by geographic region (US, International, etc.) geo: []BreakdownItem, /// Breakdown by account name account: []BreakdownItem, /// Breakdown by tax type (Taxable, Roth, Traditional, HSA) tax_type: []BreakdownItem, /// Positions not covered by classification metadata unclassified: []const []const u8, /// Total portfolio value used as denominator total_value: f64, pub fn deinit(self: *AnalysisResult, allocator: std.mem.Allocator) void { allocator.free(self.asset_category); allocator.free(self.sector); allocator.free(self.geo); allocator.free(self.account); allocator.free(self.tax_type); allocator.free(self.unclassified); } }; /// One section of an analysis breakdown for renderer-agnostic /// display. Both the CLI (`commands/analysis.zig`) and the TUI /// (`tui/analysis_tab.zig`) walk the section list returned by /// `breakdownSections` to build their output. The section list /// is the single source of truth for which breakdowns appear and /// in what order; renderers apply their own indent and styling. pub const Section = struct { items: []const BreakdownItem, /// Title with no leading whitespace. Renderers indent. title: []const u8, }; /// Single source of truth for analysis-output breakdown /// sections. Both the CLI display and the TUI tab call this so /// adding/reordering a section is a one-place edit. Order is /// from coarsest (Asset Category, 4 buckets) to finest /// (per-account / per-tax-type). pub fn breakdownSections(r: *const AnalysisResult) [5]Section { return .{ .{ .items = r.asset_category, .title = "Asset Category" }, .{ .items = r.sector, .title = "Sector" }, .{ .items = r.geo, .title = "Geographic" }, .{ .items = r.account, .title = "By Account" }, .{ .items = r.tax_type, .title = "By Tax Type" }, }; } // ── Umbrella-insurance exposure ────────────────────────────── /// Result of computing umbrella-insurance exposure: the portion /// of the liquid portfolio that's NOT legally shielded against /// civil judgments / lawsuits, and therefore needs umbrella /// coverage. pub const UmbrellaExposure = struct { /// Total liquid portfolio value summed from `account_breakdown`. /// Equals shielded + exposed. total_liquid: f64, /// Sum of account values where shielding evaluates to true. shielded_value: f64, /// Sum of account values where shielding evaluates to false. /// This is the approximate umbrella-insurance target. exposed_value: f64, /// `exposed_value / total_liquid`, or 0 when `total_liquid` is 0. exposed_pct: f64, }; /// Compute umbrella-insurance exposure from the per-account /// breakdown and the account-tax map. /// /// Shielding decision per account: /// - If `entry.shielded` is explicitly set, use that. /// - Else if `entry.tax_type == .taxable`, NOT shielded. /// - Else (Traditional / Roth / HSA), shielded by default. /// /// Accounts not in `account_map` default to NOT shielded /// (defensive — if we don't know, assume the value is exposed /// rather than overstate the user's protection). /// /// Pure data, no allocation. The arithmetic is straightforward /// summation; the meaningful logic is the per-account /// shielded-or-not decision. pub fn umbrellaExposure( account_breakdown: []const BreakdownItem, account_map: AccountMap, ) UmbrellaExposure { var shielded: f64 = 0; var exposed: f64 = 0; for (account_breakdown) |item| { const is_shielded = accountIsShielded(item.label, account_map); if (is_shielded) { shielded += item.value; } else { exposed += item.value; } } const total = shielded + exposed; const pct = if (total > 0) exposed / total else 0; return .{ .total_liquid = total, .shielded_value = shielded, .exposed_value = exposed, .exposed_pct = pct, }; } /// Look up the shielding decision for one account name. /// Exposed (returns false) when: /// - Account is not in the map (defensive default). /// - Explicit `shielded::false` override. /// - tax_type is `taxable` and no override. fn accountIsShielded(account: []const u8, account_map: AccountMap) bool { for (account_map.entries) |e| { if (!std.mem.eql(u8, e.account, account)) continue; if (e.shielded) |explicit| return explicit; return e.tax_type != .taxable; } return false; } // ── Sector → asset-category bucket ──────────────────────────── /// The four coarse asset-category buckets. Returned from /// `bucketSector` as static `[]const u8` literals so callers can /// use them as stable HashMap keys without duping. pub const bucket_equity: []const u8 = "Equity"; pub const bucket_fixed_income: []const u8 = "Fixed Income"; pub const bucket_cash: []const u8 = "Cash"; pub const bucket_other: []const u8 = "Other"; /// Map a sector string to one of four coarse asset-category /// buckets. Handles three input shapes: /// /// - **NPORT-P fund-decomposition sectors** of the form /// `" / "` (e.g. `"Debt / US Treasury"`, /// `"Equity / Corporate"`, `"Short-Term Investment Vehicle / Registered Fund"`). /// These come from EDGAR fund-holdings data via `enrich`. /// /// - **GICS-style stock sector names** (e.g. `"Technology"`, /// `"Healthcare"`, `"Financial Services"`). These come from /// Wikidata via `enrich`'s `canonicalizeSector`. /// /// - **Plain-English asset-class words** (e.g. `"Bonds"`, /// `"Diversified"`) that hand-written `metadata.srf` files /// use for legacy entries. `"Bonds"` → Fixed Income; /// `"Diversified"` → Equity (the word in practice means "S&P /// 500 / total-market index fund holding all sectors", which /// is overwhelmingly equity). /// /// Returns one of `bucket_equity`, `bucket_fixed_income`, /// `bucket_cash`, or `bucket_other`. Anything unrecognized /// (sentinels like `"TODO"`, empty string, future label /// changes) falls through to `bucket_other`. /// /// Note: `Equity Preferred / *` rolls up to Equity, not Fixed /// Income. Preferreds trade between stocks and bonds; we lean /// equity to match how most retail asset-allocation views treat /// them. pub fn bucketSector(sector: []const u8) []const u8 { // NPORT-P shapes: prefix-match on the assetCat half. // `startsWith` covers both `Equity / *` and `Equity Preferred / *`. // // Note on dividend-equity ETFs (SCHD, VYM, DGRO, etc.): // these bucket as Equity, not Fixed Income, despite their // bond-like income shape. The Asset Category breakdown // answers "what's exposed to equity drawdowns?" — and // dividend funds drop with the market in a 2008-style // crash. The income-feels-like-bonds intuition belongs in // a separate yield-weighted analysis (see TODO.md // "Dividend equity / income-shaped equity"), not in the // asset-class taxonomy. if (std.mem.startsWith(u8, sector, "Equity")) return bucket_equity; if (std.mem.startsWith(u8, sector, "Debt")) return bucket_fixed_income; if (std.mem.startsWith(u8, sector, "Loan")) return bucket_fixed_income; if (std.mem.startsWith(u8, sector, "Asset-Backed")) return bucket_fixed_income; if (std.mem.startsWith(u8, sector, "Short-Term Investment Vehicle")) return bucket_cash; if (std.mem.startsWith(u8, sector, "Repurchase Agreement")) return bucket_cash; // Plain-English asset-class words (hand-written metadata). if (std.mem.eql(u8, sector, "Bonds")) return bucket_fixed_income; if (std.mem.eql(u8, sector, "Cash")) return bucket_cash; // "Diversified" means "broad equity fund holding all // sectors" — S&P 500 ETF, total-market index, etc. if (std.mem.eql(u8, sector, "Diversified")) return bucket_equity; // GICS stock sector names. Exact match over the canonical 11 // returned by `Wikidata.canonicalizeSector`. The legacy // `"Financials"` (with 's') from old hand-written entries // also maps here. const gics = [_][]const u8{ "Technology", "Healthcare", "Financial Services", "Financials", "Consumer Cyclical", "Consumer Defensive", "Energy", "Utilities", "Real Estate", "Industrials", "Basic Materials", "Communication Services", }; for (gics) |g| if (std.mem.eql(u8, sector, g)) return bucket_equity; // Everything else: derivatives, real property, sentinels // (TODO/Unknown/empty), unrecognized future labels. return bucket_other; } // ── Sector display granularity ─────────────────────────────── /// Granularity tier for the Sector breakdown display. Lets the /// user toggle between coarse bucket-level summary and the raw /// fine-grained NPORT-P decomposition. pub const Granularity = enum { /// Four buckets: Equity / Fixed Income / Cash / Other. /// Same labels as the Asset Category breakdown. coarse, /// ~12-16 buckets: collapses NPORT-P sub-flavors but keeps /// GICS sectors distinct. Default for most users — answers /// "how exposed am I to bonds vs stocks vs cash, and which /// sectors within stocks?". mid, /// Raw NPORT-P strings: every Debt / X variant, every /// Asset-Backed / Y variant, every Derivative / Z variant /// is its own row. Useful for spotting fine-grained /// concentrations within the bond sleeve (e.g. "am I overweight /// US Treasury vs Municipal?"). fine, }; // Mid-granularity bucket labels. Static literals so they can be // used as stable HashMap keys without duping. pub const mid_bonds: []const u8 = "Bonds"; pub const mid_equity_corporate: []const u8 = "Equity / Corporate"; pub const mid_equity_preferred: []const u8 = "Equity Preferred"; pub const mid_cash_equivalents: []const u8 = "Cash & Equivalents"; pub const mid_derivatives: []const u8 = "Derivatives"; pub const mid_other: []const u8 = "Other"; /// Display-friendly abbreviations for sector labels that don't fit /// cleanly in narrow columns. Returns the input unchanged when no /// abbreviation is registered for it; consumers that need a fixed /// width should also pass the result through `format.truncateToCols`. /// /// Single source of truth for both the analysis tab's sector /// breakdown rows and the review tab's per-holding sector cells. /// Add new abbreviations here when a sector label keeps overflowing /// the columns it lives in. pub fn abbreviateSector(s: []const u8) []const u8 { if (std.mem.eql(u8, s, "Communication Services")) return "Comm. Services"; return s; } /// Map a sector string through the chosen granularity. Returns /// a static literal (or, at fine granularity, the input slice /// itself) suitable for use as a stable HashMap key. /// /// Granularity tiers: /// /// - **coarse**: delegates to `bucketSector` — Equity / Fixed Income /// / Cash / Other (4 buckets). /// - **mid**: collapses NPORT-P sub-flavors (all Debt / * → Bonds, /// all Asset-Backed / * → Bonds, all STIV / * → Cash & Equivalents, /// all Derivative / * → Derivatives) while keeping GICS sectors /// distinct (Technology, Healthcare, etc.). /// - **fine**: passthrough — returns the input unchanged. pub fn collapseSector(sector: []const u8, granularity: Granularity) []const u8 { return switch (granularity) { .fine => sector, .coarse => bucketSector(sector), .mid => midBucket(sector), }; } /// Mid-granularity bucket lookup. Pure data, no allocation. fn midBucket(sector: []const u8) []const u8 { // Bond-shaped (NPORT-P): all Debt / *, Loan / *, Asset-Backed / *. if (std.mem.startsWith(u8, sector, "Debt")) return mid_bonds; if (std.mem.startsWith(u8, sector, "Loan")) return mid_bonds; if (std.mem.startsWith(u8, sector, "Asset-Backed")) return mid_bonds; // Plain-English bond label from legacy hand-written metadata. if (std.mem.eql(u8, sector, "Bonds")) return mid_bonds; // Cash-shaped (NPORT-P): STIV variants + repurchase agreement. if (std.mem.startsWith(u8, sector, "Short-Term Investment Vehicle")) return mid_cash_equivalents; if (std.mem.startsWith(u8, sector, "Repurchase Agreement")) return mid_cash_equivalents; if (std.mem.eql(u8, sector, "Cash")) return mid_cash_equivalents; if (std.mem.eql(u8, sector, "Cash & CDs")) return mid_cash_equivalents; // Equity Preferred is distinct at mid because it's a hybrid. if (std.mem.startsWith(u8, sector, "Equity Preferred")) return mid_equity_preferred; // Generic Equity / * collapses to a single Equity / Corporate // bucket. Specifically "Equity / Corporate" / "Equity / Other" // / "Equity / Registered Fund" all merge here. if (std.mem.startsWith(u8, sector, "Equity")) return mid_equity_corporate; // Derivatives. if (std.mem.startsWith(u8, sector, "Derivative")) return mid_derivatives; // GICS sector names pass through unchanged. Same exact-match // list as `bucketSector`. const gics = [_][]const u8{ "Technology", "Healthcare", "Financial Services", "Financials", "Consumer Cyclical", "Consumer Defensive", "Energy", "Utilities", "Real Estate", "Industrials", "Basic Materials", "Communication Services", "Diversified", }; for (gics) |g| if (std.mem.eql(u8, sector, g)) return sector; // Direct Real Property, Direct Credit Risk, sentinels — Other. return mid_other; } /// Compute portfolio analysis from allocations and classification metadata. /// `allocations` are the stock/ETF positions with market values. /// `classifications` is the metadata file data. /// `portfolio` is the full portfolio (for cash/CD/illiquid totals). /// `account_map` is optional account tax type metadata. /// `as_of` is the date against which lot open/closed status is /// evaluated. Pass `null` to use wall-clock today (the default for /// interactive commands); historical snapshot backfill passes the /// target date so lots opened/closed/matured between `as_of` and today /// are counted correctly. pub fn analyzePortfolio( allocator: std.mem.Allocator, allocations: []const Allocation, classifications: ClassificationMap, portfolio: Portfolio, total_portfolio_value: f64, account_map: ?AccountMap, as_of: Date, ) !AnalysisResult { // Accumulators: label -> dollar amount. // // sector_map and asset_cat_map are both keyed by the // `bucket` field on ClassificationEntry (pre-filled by // parseClassificationFile via deriveBucket). Buckets are // either user-curated, GICS-like sectors, or composite // "{geo} {asset_class}" labels — meaningful units for // concentration rollup. The raw `entry.sector` is no // longer used for either map: NPORT-P fund-decomp // categories ("Equity / Corporate") would lump genuinely // different funds together. var sector_map = std.StringHashMap(f64).init(allocator); defer sector_map.deinit(); // 4-bucket coarse breakdown (Equity/Fixed Income/Cash/Other). // Keys are static literals from `bucketSector`, no dupe needed. var asset_cat_map = std.StringHashMap(f64).init(allocator); defer asset_cat_map.deinit(); var geo_map = std.StringHashMap(f64).init(allocator); defer geo_map.deinit(); var acct_map = std.StringHashMap(f64).init(allocator); defer acct_map.deinit(); var tax_map = std.StringHashMap(f64).init(allocator); defer tax_map.deinit(); var unclassified_list = std.ArrayList([]const u8).empty; errdefer unclassified_list.deinit(allocator); // Process each equity allocation (for sector, geo, unclassified) for (allocations) |alloc| { const mv = alloc.market_value; if (mv <= 0) continue; // Find classification entries for this symbol // Try both the raw symbol and display_symbol var found = false; for (classifications.entries) |entry| { if (std.mem.eql(u8, entry.symbol, alloc.symbol) or std.mem.eql(u8, entry.symbol, alloc.display_symbol)) { found = true; const frac = entry.pct / 100.0; const portion = mv * frac; // Sector breakdown: roll up by bucket (the // pre-filled deriveBucket result on the entry). if (entry.bucket) |b| { const prev = sector_map.get(b) orelse 0; try sector_map.put(b, prev + portion); } // Asset Category 4-bucket coarse breakdown // (Equity / Fixed Income / Cash / Other) keeps // using the raw `entry.sector` as input. Reasons: // 1. `bucketSector` recognizes the NPORT-P // prefixes ("Equity / *", "Debt / *", etc.) // directly. The user-facing Sector breakdown // bucket might be "US ETF" (a composite that // doesn't carry the asset-type signal), // but the underlying sector still does. // 2. The Asset Category breakdown is the // coarse "what's exposed to equity drawdowns?" // view — invariant to the user's bucket // curation, since it's a fundamental property // of the holding. if (entry.sector) |s| { const cat = bucketSector(s); const cprev = asset_cat_map.get(cat) orelse 0; try asset_cat_map.put(cat, cprev + portion); } else if (entry.asset_class) |ac| { const cat = bucketAssetClass(ac); const cprev = asset_cat_map.get(cat) orelse 0; try asset_cat_map.put(cat, cprev + portion); } if (entry.geo) |g| { const prev = geo_map.get(g) orelse 0; try geo_map.put(g, prev + portion); } } } if (!found) { try unclassified_list.append(allocator, alloc.display_symbol); } } // Build symbol -> (current_price, price_ratio) lookup from allocations. // For unmerged allocations, current_price already includes price_ratio (preadjusted). // For merged allocations, current_price is the base-ticker price (not preadjusted). const PriceEntry = struct { price: f64, is_preadjusted: bool }; var price_lookup = std.StringHashMap(PriceEntry).init(allocator); defer price_lookup.deinit(); for (allocations) |alloc| { try price_lookup.put(alloc.symbol, .{ .price = alloc.current_price, .is_preadjusted = alloc.price_ratio != 1.0, }); } // Account breakdown from individual lots (avoids "Multiple" aggregation issue). // Use `lotIsOpenAsOf(as_of)` so backfilled snapshots correctly include/ // exclude lots based on the target date. For "live" callers the right // thing is to pass today; the resolution happens at the call site. for (portfolio.lots) |lot| { if (!lot.lotIsOpenAsOf(as_of)) continue; const acct = lot.account orelse continue; const value: f64 = switch (lot.security_type) { .stock => blk: { if (price_lookup.get(lot.priceSymbol())) |entry| { break :blk lot.marketValue(entry.price, entry.is_preadjusted); } else { // Fallback to open_price (already in lot-specific terms) break :blk lot.shares * lot.open_price; } }, .cash => lot.shares, .cd => lot.shares, // face value .option => @abs(lot.shares) * lot.open_price, .illiquid, .watch => continue, }; const prev = acct_map.get(acct) orelse 0; try acct_map.put(acct, prev + value); } // Add non-stock holdings (cash, CDs, options) into the // coarse asset_category breakdown. They have no entry in // the classification map (it's keyed by ticker), so we // route them to coarse buckets directly. const cash_total = portfolio.totalCash(as_of); const cd_total = portfolio.totalCdFaceValue(as_of); const cash_cd_total = cash_total + cd_total; if (cash_cd_total > 0) { const gprev = geo_map.get("US") orelse 0; try geo_map.put("US", gprev + cash_cd_total); // Literal cash and CDs roll into the coarse Cash bucket. const bprev = asset_cat_map.get(bucket_cash) orelse 0; try asset_cat_map.put(bucket_cash, bprev + cash_cd_total); // Also surface in the Sector breakdown as "Cash & CDs" // so users with significant cash positions see the // line. Without this, the Sector breakdown would // silently omit cash entirely. const sprev = sector_map.get("Cash & CDs") orelse 0; try sector_map.put("Cash & CDs", sprev + cash_cd_total); } const opt_total = portfolio.totalOptionCost(as_of); if (opt_total > 0) { // Options are derivatives; coarse bucket is Other. const bprev = asset_cat_map.get(bucket_other) orelse 0; try asset_cat_map.put(bucket_other, bprev + opt_total); // Surface in Sector breakdown too. const sprev = sector_map.get("Options") orelse 0; try sector_map.put("Options", sprev + opt_total); } // Tax type breakdown: map each account's total to its tax type if (account_map) |am| { var acct_iter = acct_map.iterator(); while (acct_iter.next()) |kv| { const tt = am.taxTypeFor(kv.key_ptr.*); const prev = tax_map.get(tt) orelse 0; try tax_map.put(tt, prev + kv.value_ptr.*); } } // Convert maps to sorted slices const total = if (total_portfolio_value > 0) total_portfolio_value else 1.0; return .{ .asset_category = try mapToSortedBreakdown(allocator, asset_cat_map, total), .sector = try mapToSortedBreakdown(allocator, sector_map, total), .geo = try mapToSortedBreakdown(allocator, geo_map, total), .account = try mapToSortedBreakdown(allocator, acct_map, total), .tax_type = try mapToSortedBreakdown(allocator, tax_map, total), .unclassified = try unclassified_list.toOwnedSlice(allocator), .total_value = total_portfolio_value, }; } /// Convert a label->value HashMap to a sorted BreakdownItem slice (descending by value). fn mapToSortedBreakdown( allocator: std.mem.Allocator, map: std.StringHashMap(f64), total: f64, ) ![]BreakdownItem { var items = std.ArrayList(BreakdownItem).empty; errdefer items.deinit(allocator); var iter = map.iterator(); while (iter.next()) |kv| { try items.append(allocator, .{ .label = kv.key_ptr.*, .value = kv.value_ptr.*, .weight = kv.value_ptr.* / total, }); } // Sort descending by value std.mem.sort(BreakdownItem, items.items, {}, struct { fn f(_: void, a: BreakdownItem, b: BreakdownItem) bool { return a.value > b.value; } }.f); return items.toOwnedSlice(allocator); } /// Re-aggregate a raw `BreakdownItem` slice through `collapseSector` /// at the chosen granularity. Multiple input rows that map to the /// same coarser bucket sum into one output row. Returns a new /// allocator-owned slice; caller frees with `allocator.free`. /// /// Use case: `analyze` produces `result.sector` at fine /// granularity (raw NPORT-P + GICS labels). Display callers /// (CLI `--sector-detail`, TUI hot-key) call this to re-bucket /// at their chosen tier. At `.fine`, the function still /// allocates a fresh slice (so callers always free the result /// the same way) but the per-row labels and weights are /// identical to the input. pub fn collapseBreakdownAtGranularity( allocator: std.mem.Allocator, items: []const BreakdownItem, granularity: Granularity, total: f64, ) ![]BreakdownItem { var map = std.StringHashMap(f64).init(allocator); defer map.deinit(); for (items) |item| { const label = collapseSector(item.label, granularity); const prev = map.get(label) orelse 0; try map.put(label, prev + item.value); } return mapToSortedBreakdown(allocator, map, total); } test "parseAccountsFile" { const data = \\#!srfv1 \\account::Sample Roth,tax_type::roth \\account::Sample Trust,tax_type::taxable \\account::Sample HSA,tax_type::hsa ; const allocator = std.testing.allocator; var am = try parseAccountsFile(allocator, data); defer am.deinit(); try std.testing.expectEqual(@as(usize, 3), am.entries.len); try std.testing.expectEqualStrings("Roth (Post-Tax)", am.taxTypeFor("Sample Roth")); try std.testing.expectEqualStrings("Taxable", am.taxTypeFor("Sample Trust")); try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", am.taxTypeFor("Sample HSA")); try std.testing.expectEqualStrings("Unknown", am.taxTypeFor("Nonexistent")); } test "parseAccountsFile: institution + account_number round-trip via findByInstitutionAccount" { // The import command's WF resolver, the audit reconciler's // schwab/fidelity match logic, and the snapshot writer all // depend on `findByInstitutionAccount` finding entries that // were parsed from `accounts.srf`. Pin the round-trip so a // future change to either parseAccountsFile or // findByInstitutionAccount can't silently drop the link. const data = \\#!srfv1 \\account::Sample Fidelity Brokerage,tax_type::taxable,institution::fidelity,account_number::Z123 \\account::Schwab Trust,tax_type::taxable,institution::schwab,account_number::1234 ; const allocator = std.testing.allocator; var am = try parseAccountsFile(allocator, data); defer am.deinit(); try std.testing.expectEqual(@as(usize, 2), am.entries.len); try std.testing.expectEqualStrings("Sample Fidelity Brokerage", am.findByInstitutionAccount("fidelity", "Z123").?); try std.testing.expectEqualStrings("Schwab Trust", am.findByInstitutionAccount("schwab", "1234").?); // Wrong institution / wrong number → null. try std.testing.expect(am.findByInstitutionAccount("schwab", "Z123") == null); try std.testing.expect(am.findByInstitutionAccount("fidelity", "ZZZ") == null); } test "parseAccountsFile: cash_is_contribution default false, opt-in true" { const data = \\#!srfv1 \\account::Riley ESPP,tax_type::taxable,cash_is_contribution:bool:true \\account::Joint cash,tax_type::taxable ; const allocator = std.testing.allocator; var am = try parseAccountsFile(allocator, data); defer am.deinit(); try std.testing.expectEqual(@as(usize, 2), am.entries.len); // Opted-in account try std.testing.expect(am.cashIsContribution("Riley ESPP")); // Default-off account try std.testing.expect(!am.cashIsContribution("Joint cash")); // Unknown account defaults to false try std.testing.expect(!am.cashIsContribution("Nonexistent")); } test "parseAccountsFile: direct_indexing default false, opt-in true" { const data = \\#!srfv1 \\account::Tax Loss,tax_type::taxable,direct_indexing:bool:true \\account::Regular Brokerage,tax_type::taxable ; const allocator = std.testing.allocator; var am = try parseAccountsFile(allocator, data); defer am.deinit(); try std.testing.expectEqual(@as(usize, 2), am.entries.len); try std.testing.expect(am.isDirectIndexing("Tax Loss")); try std.testing.expect(!am.isDirectIndexing("Regular Brokerage")); try std.testing.expect(!am.isDirectIndexing("Nonexistent")); } test "parseAccountsFile: shielded omitted -> null (use tax_type default)" { // Default behavior: when no `shielded` override is given, // the field stays null and the umbrella-exposure calculation // uses tax_type to decide. const data = \\#!srfv1 \\account::Sample IRA,tax_type::traditional \\account::Sample Brokerage,tax_type::taxable ; const allocator = std.testing.allocator; var am = try parseAccountsFile(allocator, data); defer am.deinit(); try std.testing.expectEqual(@as(usize, 2), am.entries.len); try std.testing.expect(am.entries[0].shielded == null); try std.testing.expect(am.entries[1].shielded == null); } test "parseAccountsFile: shielded:bool:false override parses correctly" { // Use case: pre-tax deferred-comp account that's NOT // ERISA-protected (e.g. Fidelity DCP). tax_type stays as // `traditional` (correct for tax purposes), `shielded` is // overridden to false (correct for umbrella purposes). const data = \\#!srfv1 \\account::Sample DCP,tax_type::traditional,shielded:bool:false \\account::Sample IRA,tax_type::traditional ; const allocator = std.testing.allocator; var am = try parseAccountsFile(allocator, data); defer am.deinit(); try std.testing.expectEqual(@as(usize, 2), am.entries.len); // DCP: explicit override to false. try std.testing.expect(am.entries[0].shielded != null); try std.testing.expect(!am.entries[0].shielded.?); // IRA: no override, stays null. try std.testing.expect(am.entries[1].shielded == null); } test "parseAccountsFile: shielded:bool:true override (rare, e.g. asset-protection trust)" { const data = \\#!srfv1 \\account::Sample Trust,tax_type::taxable,shielded:bool:true ; const allocator = std.testing.allocator; var am = try parseAccountsFile(allocator, data); defer am.deinit(); try std.testing.expectEqual(@as(usize, 1), am.entries.len); try std.testing.expect(am.entries[0].shielded != null); try std.testing.expect(am.entries[0].shielded.?); } // ── umbrellaExposure ───────────────────────────────────────── /// Helper: build an in-memory AccountMap from a literal SRF /// string. Keeps each test compact while exercising the real /// parsing path. fn testParseAccountMap(comptime data: []const u8) !AccountMap { return parseAccountsFile(std.testing.allocator, data); } test "umbrellaExposure: traditional/roth/hsa default to shielded; taxable to exposed" { var am = try testParseAccountMap( \\#!srfv1 \\account::IRA,tax_type::traditional \\account::Roth IRA,tax_type::roth \\account::HSA,tax_type::hsa \\account::Brokerage,tax_type::taxable ); defer am.deinit(); const accounts = [_]BreakdownItem{ .{ .label = "IRA", .value = 1_000_000, .weight = 0.40 }, .{ .label = "Roth IRA", .value = 500_000, .weight = 0.20 }, .{ .label = "HSA", .value = 100_000, .weight = 0.04 }, .{ .label = "Brokerage", .value = 900_000, .weight = 0.36 }, }; const u = umbrellaExposure(&accounts, am); try std.testing.expectApproxEqAbs(@as(f64, 2_500_000), u.total_liquid, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 1_600_000), u.shielded_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 900_000), u.exposed_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 0.36), u.exposed_pct, 0.001); } test "umbrellaExposure: shielded:bool:false override flips traditional account to exposed" { // The DCP case: pre-tax payroll account, traditional tax // treatment, but NOT ERISA-shielded. Override with // `shielded:bool:false` and the umbrella math counts it // toward exposure. var am = try testParseAccountMap( \\#!srfv1 \\account::IRA,tax_type::traditional \\account::DCP,tax_type::traditional,shielded:bool:false ); defer am.deinit(); const accounts = [_]BreakdownItem{ .{ .label = "IRA", .value = 1_000_000, .weight = 0.50 }, .{ .label = "DCP", .value = 1_000_000, .weight = 0.50 }, }; const u = umbrellaExposure(&accounts, am); try std.testing.expectApproxEqAbs(@as(f64, 2_000_000), u.total_liquid, 1.0); // IRA shielded (default), DCP exposed (override). try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), u.shielded_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), u.exposed_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 0.50), u.exposed_pct, 0.001); } test "umbrellaExposure: shielded:bool:true override flips taxable account to shielded" { // Asset-protection trust, taxable for tax purposes but // legally shielded. var am = try testParseAccountMap( \\#!srfv1 \\account::Trust,tax_type::taxable,shielded:bool:true \\account::Brokerage,tax_type::taxable ); defer am.deinit(); const accounts = [_]BreakdownItem{ .{ .label = "Trust", .value = 500_000, .weight = 0.50 }, .{ .label = "Brokerage", .value = 500_000, .weight = 0.50 }, }; const u = umbrellaExposure(&accounts, am); try std.testing.expectApproxEqAbs(@as(f64, 500_000), u.shielded_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 500_000), u.exposed_value, 1.0); } test "umbrellaExposure: account not in map defaults to exposed (defensive)" { // Defensive default: if an account name in the breakdown // doesn't appear in accounts.srf, treat it as exposed // rather than silently shielding it. The user will see the // overstated exposure and notice the missing entry. var am = try testParseAccountMap( \\#!srfv1 \\account::IRA,tax_type::traditional ); defer am.deinit(); const accounts = [_]BreakdownItem{ .{ .label = "IRA", .value = 100_000, .weight = 0.50 }, .{ .label = "Mystery Account", .value = 100_000, .weight = 0.50 }, }; const u = umbrellaExposure(&accounts, am); try std.testing.expectApproxEqAbs(@as(f64, 100_000), u.shielded_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 100_000), u.exposed_value, 1.0); } test "umbrellaExposure: empty breakdown returns zeros and avoids divide-by-zero" { var am = try testParseAccountMap( \\#!srfv1 \\account::IRA,tax_type::traditional ); defer am.deinit(); const u = umbrellaExposure(&.{}, am); try std.testing.expectEqual(@as(f64, 0), u.total_liquid); try std.testing.expectEqual(@as(f64, 0), u.shielded_value); try std.testing.expectEqual(@as(f64, 0), u.exposed_value); try std.testing.expectEqual(@as(f64, 0), u.exposed_pct); } test "umbrellaExposure: HSA counts as shielded" { var am = try testParseAccountMap( \\#!srfv1 \\account::HSA,tax_type::hsa ); defer am.deinit(); const accounts = [_]BreakdownItem{ .{ .label = "HSA", .value = 50_000, .weight = 1.0 }, }; const u = umbrellaExposure(&accounts, am); try std.testing.expectApproxEqAbs(@as(f64, 50_000), u.shielded_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 0), u.exposed_value, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0), u.exposed_pct, 0.001); } test "umbrellaExposure: realistic mixed portfolio" { // Approximation of the user's actual portfolio shape: // mostly Traditional 401k (shielded), some IRAs (shielded), // taxable brokerage (exposed), and one DCP that LOOKS // traditional but isn't ERISA-shielded. var am = try testParseAccountMap( \\#!srfv1 \\account::Sample 401k,tax_type::traditional \\account::Sample DCP,tax_type::traditional,shielded:bool:false \\account::Sample IRA,tax_type::traditional \\account::Sample Roth,tax_type::roth \\account::Sample HSA,tax_type::hsa \\account::Sample Trust,tax_type::taxable ); defer am.deinit(); const accounts = [_]BreakdownItem{ .{ .label = "Sample 401k", .value = 900_000, .weight = 0.30 }, .{ .label = "Sample DCP", .value = 1_500_000, .weight = 0.50 }, .{ .label = "Sample IRA", .value = 200_000, .weight = 0.067 }, .{ .label = "Sample Roth", .value = 100_000, .weight = 0.033 }, .{ .label = "Sample HSA", .value = 50_000, .weight = 0.017 }, .{ .label = "Sample Trust", .value = 250_000, .weight = 0.083 }, }; const u = umbrellaExposure(&accounts, am); // Shielded: 401k + IRA + Roth + HSA = 900k + 200k + 100k + 50k = 1,250,000 // Exposed: DCP + Trust = 1,500,000 + 250,000 = 1,750,000 try std.testing.expectApproxEqAbs(@as(f64, 3_000_000), u.total_liquid, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 1_250_000), u.shielded_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 1_750_000), u.exposed_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 0.5833), u.exposed_pct, 0.001); } test "TaxType.label" { try std.testing.expectEqualStrings("Taxable", TaxType.taxable.label()); try std.testing.expectEqualStrings("Roth (Post-Tax)", TaxType.roth.label()); try std.testing.expectEqualStrings("Traditional (Pre-Tax)", TaxType.traditional.label()); try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", TaxType.hsa.label()); } test "mapToSortedBreakdown" { const allocator = std.testing.allocator; var map = std.StringHashMap(f64).init(allocator); defer map.deinit(); try map.put("Technology", 50_000); try map.put("Healthcare", 30_000); try map.put("Energy", 20_000); const total = 100_000.0; const breakdown = try mapToSortedBreakdown(allocator, map, total); defer allocator.free(breakdown); try std.testing.expectEqual(@as(usize, 3), breakdown.len); // Should be sorted descending by value try std.testing.expectEqualStrings("Technology", breakdown[0].label); try std.testing.expectApproxEqAbs(@as(f64, 50_000), breakdown[0].value, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0.5), breakdown[0].weight, 0.001); try std.testing.expectEqualStrings("Healthcare", breakdown[1].label); try std.testing.expectEqualStrings("Energy", breakdown[2].label); } test "mapToSortedBreakdown empty" { const allocator = std.testing.allocator; var map = std.StringHashMap(f64).init(allocator); defer map.deinit(); const breakdown = try mapToSortedBreakdown(allocator, map, 100_000.0); defer allocator.free(breakdown); try std.testing.expectEqual(@as(usize, 0), breakdown.len); } test "parseAccountsFile empty" { const allocator = std.testing.allocator; var am = try parseAccountsFile(allocator, "#!srfv1\n"); defer am.deinit(); try std.testing.expectEqual(@as(usize, 0), am.entries.len); } test "parseAccountsFile missing fields" { // Line with only account but no tax_type -> skipped via Record.to() error. // Override log level to suppress expected srf log.err output that // would otherwise cause the test runner to report failure. const prev_level = std.testing.log_level; std.testing.log_level = .err; defer std.testing.log_level = prev_level; const allocator = std.testing.allocator; var am = try parseAccountsFile(allocator, "#!srfv1\naccount::Test Account\n# comment\n"); defer am.deinit(); try std.testing.expectEqual(@as(usize, 0), am.entries.len); } test "account breakdown applies price_ratio" { const allocator = std.testing.allocator; const Lot = @import("../models/portfolio.zig").Lot; // Three lots across two accounts: // - Brokerage: direct SPY (ratio 1.0) // - 401(k): CIT mapped to SPY (ratio 0.25, merged allocation) // - 401(k): CUSIP with ticker=VTTHX (ratio 5.0, unmerged allocation) var lots = [_]Lot{ .{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 400, .account = "Brokerage", }, .{ .symbol = "CIT-SPY", .shares = 500, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 100, .ticker = "SPY", .price_ratio = 0.25, .account = "401(k)", }, .{ .symbol = "CUSIP123", .shares = 200, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 50, .ticker = "VTTHX", .price_ratio = 5.0, .account = "401(k)", }, }; const portfolio = Portfolio{ .lots = &lots, .allocator = allocator }; // Allocations as produced by portfolioSummary + mergeAllocsBySymbol: // SPY: merged (direct + CIT). current_price = base SPY price = 500, price_ratio = 1.0 // VTTHX: unmerged. current_price = 30 * 5.0 = 150 (already includes ratio), price_ratio = 5.0 const allocations = [_]Allocation{ .{ .symbol = "SPY", .display_symbol = "SPY", .shares = 225, // 100 + 500*0.25 .avg_cost = 300, .current_price = 500, // base-ticker price (merged, ratio=1.0) .market_value = 112_500, .cost_basis = 67_500, .weight = 0.789, .unrealized_gain_loss = 45_000, .unrealized_return = 0.667, .price_ratio = 1.0, // merged }, .{ .symbol = "VTTHX", .display_symbol = "VTTHX", .shares = 200, .avg_cost = 50, .current_price = 150, // already includes price_ratio (30 * 5.0) .market_value = 30_000, // 200 * 150 .cost_basis = 10_000, .weight = 0.211, .unrealized_gain_loss = 20_000, .unrealized_return = 2.0, .price_ratio = 5.0, // unmerged, ratio preserved }, }; const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator }; var result = try analyzePortfolio( allocator, &allocations, cm, portfolio, 142_500, null, Date.fromYmd(2024, 6, 1), ); defer result.deinit(allocator); // Expected account values: // Brokerage: SPY direct, 100 shares * $500 * 1.0 = $50,000 // 401(k): CIT-SPY 500 shares * $500 * 0.25 = $62,500 // + CUSIP123 200 shares * $150 (already includes ratio) = $30,000 // = $92,500 // Total: $142,500 for (result.account) |item| { if (std.mem.eql(u8, item.label, "Brokerage")) { try std.testing.expectApproxEqAbs(@as(f64, 50_000), item.value, 1.0); } else if (std.mem.eql(u8, item.label, "401(k)")) { try std.testing.expectApproxEqAbs(@as(f64, 92_500), item.value, 1.0); } } // Sum of accounts must equal total portfolio value var account_sum: f64 = 0; for (result.account) |item| { account_sum += item.value; } try std.testing.expectApproxEqAbs(@as(f64, 142_500), account_sum, 1.0); } // ── bucketSector ────────────────────────────────────────────── test "bucketSector: NPORT-P Debt / * → Fixed Income" { const cases = [_][]const u8{ "Debt / Corporate", "Debt / US Treasury", "Debt / Municipal", "Debt / Non-US Sovereign", "Debt / US Gov Agency", "Debt / US GSE", }; for (cases) |s| { try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector(s)); } } test "bucketSector: NPORT-P Equity / * and Equity Preferred / * → Equity" { try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Corporate")); try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Other")); try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Registered Fund")); try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity Preferred / Corporate")); } test "bucketSector: NPORT-P Loan / * → Fixed Income" { try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Loan / Corporate")); } test "bucketSector: NPORT-P Asset-Backed variants → Fixed Income" { // All three asset-backed prefixes should bucket the same // way. Asset-backed securities are bond-like by structure. try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed / Corporate Mortgage")); try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed / US GSE Mortgage")); try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed CBO/CDO / Corporate")); try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed Other / Corporate")); } test "bucketSector: Short-Term Investment Vehicle / * → Cash" { try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Corporate")); try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Registered Fund")); try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Private Fund")); } test "bucketSector: Repurchase Agreement / * → Cash" { // PTY-style leverage liability sleeve. Bucket is Cash; the // negative pct flows through honestly into bucket math. try std.testing.expectEqualStrings(bucket_cash, bucketSector("Repurchase Agreement / Other")); } test "bucketSector: Derivative variants → Other" { try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative / Corporate")); try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative / Other")); try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative-FX / Other")); try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative-FX / Corporate")); } test "bucketSector: Direct Real Property and Direct Credit Risk → Other" { try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Real Property / Other")); try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Credit Risk / Other")); } test "bucketSector: GICS sector names → Equity" { const gics = [_][]const u8{ "Technology", "Healthcare", "Financial Services", "Consumer Cyclical", "Consumer Defensive", "Energy", "Utilities", "Real Estate", "Industrials", "Basic Materials", "Communication Services", }; for (gics) |s| { try std.testing.expectEqualStrings(bucket_equity, bucketSector(s)); } } test "bucketSector: sentinels and unrecognized → Other" { try std.testing.expectEqualStrings(bucket_other, bucketSector("TODO")); try std.testing.expectEqualStrings(bucket_other, bucketSector("Unknown")); try std.testing.expectEqualStrings(bucket_other, bucketSector("")); try std.testing.expectEqualStrings(bucket_other, bucketSector("Fintech")); try std.testing.expectEqualStrings(bucket_other, bucketSector("Some Future Label")); } test "bucketSector: returns same pointer for repeated calls (static-string property)" { // Both callers use the result as a HashMap key. Stability of // the pointer (not just equality of bytes) is what makes // this safe without any dupe. const a = bucketSector("Debt / Corporate"); const b = bucketSector("Debt / US Treasury"); try std.testing.expectEqual(@intFromPtr(a.ptr), @intFromPtr(b.ptr)); try std.testing.expectEqual(@intFromPtr(bucketSector("Equity / Corporate").ptr), @intFromPtr(bucket_equity.ptr)); try std.testing.expectEqual(@intFromPtr(bucketSector("TODO").ptr), @intFromPtr(bucket_other.ptr)); } test "bucketSector: case-sensitive (defensive — bad input lands in Other, not crash)" { // We don't normalize case. "debt / corporate" doesn't match // "Debt / Corporate" so it falls through to Other. Tests the // contract: only canonical strings are recognized. try std.testing.expectEqualStrings(bucket_other, bucketSector("debt / corporate")); try std.testing.expectEqualStrings(bucket_other, bucketSector("EQUITY / CORPORATE")); } test "bucketSector: legacy hand-written 'Bonds' → Fixed Income" { // metadata.srf entries that pre-date EDGAR fund decomposition // use the literal word `Bonds` as the sector. Map to Fixed // Income so the Asset Category breakdown picks them up // alongside the NPORT-P `Debt / *` rows. try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Bonds")); } test "bucketSector: legacy hand-written 'Cash' → Cash" { try std.testing.expectEqualStrings(bucket_cash, bucketSector("Cash")); } test "bucketSector: legacy 'Diversified' → Equity (broad equity fund)" { // "Diversified" in practice means an S&P 500 / total-market // index fund holding all sectors — overwhelmingly equity. try std.testing.expectEqualStrings(bucket_equity, bucketSector("Diversified")); } test "bucketSector: legacy 'Financials' (with s) → Equity" { // Wikidata's canonical name is "Financial Services"; older // hand-written entries use "Financials". Both must map to // Equity so legacy data doesn't silently land in Other. try std.testing.expectEqualStrings(bucket_equity, bucketSector("Financials")); try std.testing.expectEqualStrings(bucket_equity, bucketSector("Financial Services")); } // ── collapseSector / Granularity ────────────────────────────── test "collapseSector .fine: passthrough — input slice returned unchanged" { try std.testing.expectEqualStrings("Debt / US Treasury", collapseSector("Debt / US Treasury", .fine)); try std.testing.expectEqualStrings("Equity / Corporate", collapseSector("Equity / Corporate", .fine)); try std.testing.expectEqualStrings("Technology", collapseSector("Technology", .fine)); try std.testing.expectEqualStrings("Bonds", collapseSector("Bonds", .fine)); } test "collapseSector .coarse: delegates to bucketSector" { try std.testing.expectEqualStrings(bucket_fixed_income, collapseSector("Debt / US Treasury", .coarse)); try std.testing.expectEqualStrings(bucket_equity, collapseSector("Equity / Corporate", .coarse)); try std.testing.expectEqualStrings(bucket_equity, collapseSector("Technology", .coarse)); try std.testing.expectEqualStrings(bucket_cash, collapseSector("Short-Term Investment Vehicle / Registered Fund", .coarse)); try std.testing.expectEqualStrings(bucket_other, collapseSector("Derivative / Other", .coarse)); } test "collapseSector .mid: all Debt / * collapse to Bonds" { const cases = [_][]const u8{ "Debt / Corporate", "Debt / US Treasury", "Debt / Municipal", "Debt / Non-US Sovereign", "Debt / US Gov Agency", "Debt / US GSE", }; for (cases) |s| { try std.testing.expectEqualStrings(mid_bonds, collapseSector(s, .mid)); } } test "collapseSector .mid: all Asset-Backed and Loan variants collapse to Bonds" { try std.testing.expectEqualStrings(mid_bonds, collapseSector("Asset-Backed / Corporate Mortgage", .mid)); try std.testing.expectEqualStrings(mid_bonds, collapseSector("Asset-Backed CBO/CDO / Corporate", .mid)); try std.testing.expectEqualStrings(mid_bonds, collapseSector("Asset-Backed Other / Corporate", .mid)); try std.testing.expectEqualStrings(mid_bonds, collapseSector("Loan / Corporate", .mid)); try std.testing.expectEqualStrings(mid_bonds, collapseSector("Bonds", .mid)); } test "collapseSector .mid: all STIV / Repurchase collapse to Cash & Equivalents" { try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Short-Term Investment Vehicle / Corporate", .mid)); try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Short-Term Investment Vehicle / Registered Fund", .mid)); try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Short-Term Investment Vehicle / Private Fund", .mid)); try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Repurchase Agreement / Other", .mid)); try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Cash", .mid)); try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Cash & CDs", .mid)); } test "collapseSector .mid: Equity Preferred is its own bucket" { // Hybrid security — distinct from generic equity at mid. try std.testing.expectEqualStrings(mid_equity_preferred, collapseSector("Equity Preferred / Corporate", .mid)); } test "collapseSector .mid: Equity / * (non-Preferred) collapses to Equity / Corporate" { try std.testing.expectEqualStrings(mid_equity_corporate, collapseSector("Equity / Corporate", .mid)); try std.testing.expectEqualStrings(mid_equity_corporate, collapseSector("Equity / Other", .mid)); try std.testing.expectEqualStrings(mid_equity_corporate, collapseSector("Equity / Registered Fund", .mid)); } test "collapseSector .mid: GICS sectors stay distinct" { // The whole point of mid: collapse NPORT-P sub-flavors but // keep GICS sector breakdown so users see stock concentrations. try std.testing.expectEqualStrings("Technology", collapseSector("Technology", .mid)); try std.testing.expectEqualStrings("Healthcare", collapseSector("Healthcare", .mid)); try std.testing.expectEqualStrings("Financial Services", collapseSector("Financial Services", .mid)); try std.testing.expectEqualStrings("Financials", collapseSector("Financials", .mid)); try std.testing.expectEqualStrings("Diversified", collapseSector("Diversified", .mid)); try std.testing.expectEqualStrings("Energy", collapseSector("Energy", .mid)); } test "collapseSector .mid: Derivative variants collapse to Derivatives" { try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative / Corporate", .mid)); try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative / Other", .mid)); try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative-FX / Other", .mid)); try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative-FX / Corporate", .mid)); } test "collapseSector .mid: real property and unrecognized -> Other" { try std.testing.expectEqualStrings(mid_other, collapseSector("Direct Real Property / Other", .mid)); try std.testing.expectEqualStrings(mid_other, collapseSector("Direct Credit Risk / Other", .mid)); try std.testing.expectEqualStrings(mid_other, collapseSector("TODO", .mid)); try std.testing.expectEqualStrings(mid_other, collapseSector("Unknown", .mid)); try std.testing.expectEqualStrings(mid_other, collapseSector("", .mid)); try std.testing.expectEqualStrings(mid_other, collapseSector("Some Future Label", .mid)); } test "collapseSector .mid: returns same pointer for same bucket (static-string property)" { // Stable HashMap keys without duping. const a = collapseSector("Debt / Corporate", .mid); const b = collapseSector("Debt / US Treasury", .mid); try std.testing.expectEqual(@intFromPtr(a.ptr), @intFromPtr(b.ptr)); try std.testing.expectEqual(@intFromPtr(mid_bonds.ptr), @intFromPtr(a.ptr)); } // ── collapseBreakdownAtGranularity ──────────────────────────── test "collapseBreakdownAtGranularity: VBTLX-shape Debt sleeves collapse to Bonds at mid" { // VBTLX has six different Debt / X rows. At mid granularity // they should all sum into one Bonds row. const allocator = std.testing.allocator; const items = [_]BreakdownItem{ .{ .label = "Debt / Corporate", .weight = 0.40, .value = 40_000.0 }, .{ .label = "Debt / US Treasury", .weight = 0.30, .value = 30_000.0 }, .{ .label = "Debt / Non-US Sovereign", .weight = 0.10, .value = 10_000.0 }, .{ .label = "Debt / Municipal", .weight = 0.05, .value = 5_000.0 }, .{ .label = "Debt / US Gov Agency", .weight = 0.04, .value = 4_000.0 }, .{ .label = "Debt / US GSE", .weight = 0.01, .value = 1_000.0 }, .{ .label = "Short-Term Investment Vehicle / Registered Fund", .weight = 0.10, .value = 10_000.0 }, }; const result = try collapseBreakdownAtGranularity(allocator, &items, .mid, 100_000.0); defer allocator.free(result); // Two output buckets: Bonds (sum of all Debt/*) and // Cash & Equivalents (the STIV row). try std.testing.expectEqual(@as(usize, 2), result.len); var bonds_value: f64 = 0; var cash_value: f64 = 0; for (result) |item| { if (std.mem.eql(u8, item.label, mid_bonds)) bonds_value = item.value; if (std.mem.eql(u8, item.label, mid_cash_equivalents)) cash_value = item.value; } try std.testing.expectApproxEqAbs(@as(f64, 90_000), bonds_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 10_000), cash_value, 1.0); } test "collapseBreakdownAtGranularity: coarse collapses everything to 4 buckets" { const allocator = std.testing.allocator; const items = [_]BreakdownItem{ .{ .label = "Debt / Corporate", .weight = 0.50, .value = 50_000.0 }, .{ .label = "Equity / Corporate", .weight = 0.30, .value = 30_000.0 }, .{ .label = "Technology", .weight = 0.10, .value = 10_000.0 }, .{ .label = "Short-Term Investment Vehicle / Corporate", .weight = 0.05, .value = 5_000.0 }, .{ .label = "Derivative / Other", .weight = 0.05, .value = 5_000.0 }, }; const result = try collapseBreakdownAtGranularity(allocator, &items, .coarse, 100_000.0); defer allocator.free(result); // Equity (30k Equity/Corp + 10k Technology) = 40k // Fixed Income = 50k Debt // Cash = 5k STIV // Other = 5k Derivative var eq: f64 = 0; var fi: f64 = 0; var c: f64 = 0; var o: f64 = 0; for (result) |item| { if (std.mem.eql(u8, item.label, bucket_equity)) eq = item.value; if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi = item.value; if (std.mem.eql(u8, item.label, bucket_cash)) c = item.value; if (std.mem.eql(u8, item.label, bucket_other)) o = item.value; } try std.testing.expectApproxEqAbs(@as(f64, 40_000), eq, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 50_000), fi, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 5_000), c, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 5_000), o, 1.0); } test "collapseBreakdownAtGranularity: fine returns equivalent breakdown unchanged" { const allocator = std.testing.allocator; const items = [_]BreakdownItem{ .{ .label = "Debt / Corporate", .weight = 0.50, .value = 50_000.0 }, .{ .label = "Equity / Corporate", .weight = 0.30, .value = 30_000.0 }, .{ .label = "Technology", .weight = 0.20, .value = 20_000.0 }, }; const result = try collapseBreakdownAtGranularity(allocator, &items, .fine, 100_000.0); defer allocator.free(result); // 3 input rows → 3 output rows (no collapsing at fine). try std.testing.expectEqual(@as(usize, 3), result.len); // Output sorted by value descending. try std.testing.expectEqualStrings("Debt / Corporate", result[0].label); try std.testing.expectApproxEqAbs(@as(f64, 50_000), result[0].value, 1.0); } test "collapseBreakdownAtGranularity: empty input -> empty output" { const allocator = std.testing.allocator; const items = [_]BreakdownItem{}; const result = try collapseBreakdownAtGranularity(allocator, &items, .mid, 100_000.0); defer allocator.free(result); try std.testing.expectEqual(@as(usize, 0), result.len); } test "collapseBreakdownAtGranularity: total values preserved through collapse" { // Sum of output values must equal sum of input values // (modulo float rounding). const allocator = std.testing.allocator; const items = [_]BreakdownItem{ .{ .label = "Debt / Corporate", .weight = 0.40, .value = 40.0 }, .{ .label = "Loan / Corporate", .weight = 0.20, .value = 20.0 }, .{ .label = "Asset-Backed / Corporate Mortgage", .weight = 0.15, .value = 15.0 }, .{ .label = "Equity / Corporate", .weight = 0.20, .value = 20.0 }, .{ .label = "Technology", .weight = 0.05, .value = 5.0 }, }; const result = try collapseBreakdownAtGranularity(allocator, &items, .mid, 100.0); defer allocator.free(result); var total: f64 = 0; for (result) |item| total += item.value; try std.testing.expectApproxEqAbs(@as(f64, 100.0), total, 0.01); } /// Map an `asset_class` string to one of the four asset-category /// buckets. Used as a fallback when a classification entry has /// no `sector` but does have an `asset_class` (legacy /// hand-written entries for CITs / CUSIPs / blended funds where /// the user wrote `asset_class::Bonds,pct:num:30` without /// a sector). Returns `bucket_other` for unrecognized values. pub fn bucketAssetClass(asset_class: []const u8) []const u8 { if (std.mem.eql(u8, asset_class, "Bonds")) return bucket_fixed_income; if (std.mem.eql(u8, asset_class, "Cash")) return bucket_cash; if (std.mem.eql(u8, asset_class, "Cash & CDs")) return bucket_cash; // US size buckets and international/EM buckets are all equity. if (std.mem.eql(u8, asset_class, "US Large Cap")) return bucket_equity; if (std.mem.eql(u8, asset_class, "US Mid Cap")) return bucket_equity; if (std.mem.eql(u8, asset_class, "US Small Cap")) return bucket_equity; if (std.mem.eql(u8, asset_class, "International Developed")) return bucket_equity; if (std.mem.eql(u8, asset_class, "Emerging Markets")) return bucket_equity; // Mutual Fund / ETF / Fund are too generic to bucket without // sector data — fall through to Other rather than guess // wrong. The companion `sector` field should already have // bucketed these via `bucketSector`; if it didn't, that's a // metadata-quality signal (TODO sector that needs filling // in) and Other is the right label. return bucket_other; } // ── bucketAssetClass ────────────────────────────────────────── test "bucketAssetClass: Bonds → Fixed Income" { try std.testing.expectEqualStrings(bucket_fixed_income, bucketAssetClass("Bonds")); } test "bucketAssetClass: Cash variants → Cash" { try std.testing.expectEqualStrings(bucket_cash, bucketAssetClass("Cash")); try std.testing.expectEqualStrings(bucket_cash, bucketAssetClass("Cash & CDs")); } test "bucketAssetClass: US size buckets → Equity" { try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Large Cap")); try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Mid Cap")); try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Small Cap")); } test "bucketAssetClass: international + EM → Equity" { try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("International Developed")); try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("Emerging Markets")); } test "bucketAssetClass: generic Fund/ETF/Mutual Fund → Other (not enough info)" { // The companion `sector` field is what disambiguates Fund-typed // entries. If sector is missing too, calling these "Equity" // would be a guess; Other is the honest label that signals // a metadata-quality issue (sector::TODO needs filling in). try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Fund")); try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("ETF")); try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Mutual Fund")); } test "bucketAssetClass: unknown / sentinels → Other" { try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("")); try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("TODO")); try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Unknown")); try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Some Future Class")); } test "bucketAssetClass: case-sensitive — bad case lands in Other" { try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("bonds")); try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("US LARGE CAP")); } test "bucketAssetClass: returns same pointer for same bucket (static-string property)" { // Same invariant as bucketSector — result is a stable // HashMap key without dupe. try std.testing.expectEqual(@intFromPtr(bucketAssetClass("US Large Cap").ptr), @intFromPtr(bucket_equity.ptr)); try std.testing.expectEqual(@intFromPtr(bucketAssetClass("Bonds").ptr), @intFromPtr(bucket_fixed_income.ptr)); try std.testing.expectEqual(@intFromPtr(bucketAssetClass("Cash").ptr), @intFromPtr(bucket_cash.ptr)); try std.testing.expectEqual(@intFromPtr(bucketAssetClass("Fund").ptr), @intFromPtr(bucket_other.ptr)); } // ── breakdownSections ───────────────────────────────────────── test "breakdownSections: returns 5 sections" { var ac_cat = [_]BreakdownItem{}; var sec = [_]BreakdownItem{}; var geo = [_]BreakdownItem{}; var acct = [_]BreakdownItem{}; var tax = [_]BreakdownItem{}; const result = AnalysisResult{ .asset_category = &ac_cat, .sector = &sec, .geo = &geo, .account = &acct, .tax_type = &tax, .unclassified = &.{}, .total_value = 0, }; const sections = breakdownSections(&result); try std.testing.expectEqual(@as(usize, 5), sections.len); } test "breakdownSections: titles in expected order, no leading whitespace, unique" { var ac_cat = [_]BreakdownItem{}; var sec = [_]BreakdownItem{}; var geo = [_]BreakdownItem{}; var acct = [_]BreakdownItem{}; var tax = [_]BreakdownItem{}; const result = AnalysisResult{ .asset_category = &ac_cat, .sector = &sec, .geo = &geo, .account = &acct, .tax_type = &tax, .unclassified = &.{}, .total_value = 0, }; const sections = breakdownSections(&result); const expected = [_][]const u8{ "Asset Category", "Sector", "Geographic", "By Account", "By Tax Type", }; for (sections, expected) |s, want| { try std.testing.expectEqualStrings(want, s.title); // No leading whitespace baked into the title — renderers // own indent. try std.testing.expect(s.title.len > 0); try std.testing.expect(s.title[0] != ' '); try std.testing.expect(s.title[0] != '\t'); } // Titles must be unique. for (sections, 0..) |a, i| { for (sections[i + 1 ..]) |b| { try std.testing.expect(!std.mem.eql(u8, a.title, b.title)); } } } test "breakdownSections: items.ptr points to AnalysisResult fields" { // The single-source-of-truth promise: each section borrows // from the corresponding AnalysisResult field. Catches anyone // sliding in a copy or reordering the fields. var ac_cat = [_]BreakdownItem{ .{ .label = "Equity", .weight = 1.0, .value = 100.0 }, }; var sec = [_]BreakdownItem{ .{ .label = "Technology", .weight = 0.5, .value = 50.0 }, }; var geo = [_]BreakdownItem{}; var acct = [_]BreakdownItem{}; var tax = [_]BreakdownItem{}; const result = AnalysisResult{ .asset_category = &ac_cat, .sector = &sec, .geo = &geo, .account = &acct, .tax_type = &tax, .unclassified = &.{}, .total_value = 100, }; const sections = breakdownSections(&result); try std.testing.expectEqual(result.asset_category.ptr, sections[0].items.ptr); try std.testing.expectEqual(result.sector.ptr, sections[1].items.ptr); try std.testing.expectEqual(result.geo.ptr, sections[2].items.ptr); try std.testing.expectEqual(result.account.ptr, sections[3].items.ptr); try std.testing.expectEqual(result.tax_type.ptr, sections[4].items.ptr); } test "breakdownSections: Asset Category is first (coarse-to-fine ordering)" { var ac_cat = [_]BreakdownItem{}; var sec = [_]BreakdownItem{}; var geo = [_]BreakdownItem{}; var acct = [_]BreakdownItem{}; var tax = [_]BreakdownItem{}; const result = AnalysisResult{ .asset_category = &ac_cat, .sector = &sec, .geo = &geo, .account = &acct, .tax_type = &tax, .unclassified = &.{}, .total_value = 0, }; const sections = breakdownSections(&result); // Asset Category (4 buckets) is the coarsest view; should // come first so the user sees the headline number before // the finer breakdowns. try std.testing.expectEqualStrings("Asset Category", sections[0].title); } // ── analyzePortfolio: asset_category aggregation ────────────── /// Helper: minimal Allocation for asset-category tests. Only /// the fields read by `analyzePortfolio`'s sector loop matter. fn mkAlloc(symbol: []const u8, mv: f64) Allocation { return .{ .symbol = symbol, .display_symbol = symbol, .shares = 1, .avg_cost = mv, .current_price = mv, .market_value = mv, .cost_basis = mv, .weight = 1.0, .unrealized_gain_loss = 0.0, .unrealized_return = 0.0, }; } test "analyzePortfolio: multi-sector fund (FAGIX shape) splits asset_category buckets" { const allocator = std.testing.allocator; const allocations = [_]Allocation{mkAlloc("FAGIX", 100_000)}; var entries = [_]ClassificationEntry{ .{ .symbol = "FAGIX", .sector = "Debt / Corporate", .pct = 47.69 }, .{ .symbol = "FAGIX", .sector = "Equity / Corporate", .pct = 22.49 }, .{ .symbol = "FAGIX", .sector = "Short-Term Investment Vehicle / Registered Fund", .pct = 13.37 }, .{ .symbol = "FAGIX", .sector = "Loan / Corporate", .pct = 9.99 }, .{ .symbol = "FAGIX", .sector = "Equity Preferred / Corporate", .pct = 3.59 }, }; const cm = ClassificationMap{ .entries = &entries, .allocator = allocator }; const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator }; var result = try analyzePortfolio( allocator, &allocations, cm, portfolio, 100_000, null, Date.fromYmd(2024, 6, 1), ); defer result.deinit(allocator); // Find each bucket's value. var equity_val: f64 = 0; var fi_val: f64 = 0; var cash_val: f64 = 0; for (result.asset_category) |item| { if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value; if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi_val = item.value; if (std.mem.eql(u8, item.label, bucket_cash)) cash_val = item.value; } // Equity = 22.49 + 3.59 = 26.08% of $100K = $26,080 try std.testing.expectApproxEqAbs(@as(f64, 26_080), equity_val, 1.0); // Fixed Income = 47.69 + 9.99 = 57.68% of $100K = $57,680 try std.testing.expectApproxEqAbs(@as(f64, 57_680), fi_val, 1.0); // Cash = 13.37% of $100K = $13,370 try std.testing.expectApproxEqAbs(@as(f64, 13_370), cash_val, 1.0); } test "analyzePortfolio: pure-stock fund (SCHD shape) lands in Equity + tiny Cash" { const allocator = std.testing.allocator; const allocations = [_]Allocation{mkAlloc("SCHD", 100_000)}; var entries = [_]ClassificationEntry{ .{ .symbol = "SCHD", .sector = "Equity / Corporate", .pct = 99.70 }, .{ .symbol = "SCHD", .sector = "Short-Term Investment Vehicle / Registered Fund", .pct = 0.19 }, }; const cm = ClassificationMap{ .entries = &entries, .allocator = allocator }; const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator }; var result = try analyzePortfolio( allocator, &allocations, cm, portfolio, 100_000, null, Date.fromYmd(2024, 6, 1), ); defer result.deinit(allocator); var equity_val: f64 = 0; var cash_val: f64 = 0; for (result.asset_category) |item| { if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value; if (std.mem.eql(u8, item.label, bucket_cash)) cash_val = item.value; } try std.testing.expectApproxEqAbs(@as(f64, 99_700), equity_val, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 190), cash_val, 1.0); } test "analyzePortfolio: GICS-sectored stock lands in Equity bucket" { const allocator = std.testing.allocator; const allocations = [_]Allocation{mkAlloc("NVDA", 50_000)}; var entries = [_]ClassificationEntry{ .{ .symbol = "NVDA", .sector = "Technology" }, }; const cm = ClassificationMap{ .entries = &entries, .allocator = allocator }; const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator }; var result = try analyzePortfolio( allocator, &allocations, cm, portfolio, 50_000, null, Date.fromYmd(2024, 6, 1), ); defer result.deinit(allocator); try std.testing.expectEqual(@as(usize, 1), result.asset_category.len); try std.testing.expectEqualStrings(bucket_equity, result.asset_category[0].label); try std.testing.expectApproxEqAbs(@as(f64, 50_000), result.asset_category[0].value, 1.0); } test "analyzePortfolio: empty portfolio produces empty asset_category" { const allocator = std.testing.allocator; const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator }; const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator }; var result = try analyzePortfolio( allocator, &.{}, cm, portfolio, 0, null, Date.fromYmd(2024, 6, 1), ); defer result.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), result.asset_category.len); } test "analyzePortfolio: PTY-shape negative repo flows honestly into Cash bucket" { // Portfolio has only PTY. Repo line is negative; bucket math // sums it honestly. Cash bucket value is the (negative) // repo contribution alone, since this fund has no Cash // SIV sleeve. const allocator = std.testing.allocator; const allocations = [_]Allocation{mkAlloc("PTY", 10_000)}; var entries = [_]ClassificationEntry{ .{ .symbol = "PTY", .sector = "Debt / Corporate", .pct = 41.65 }, .{ .symbol = "PTY", .sector = "Loan / Corporate", .pct = 40.05 }, .{ .symbol = "PTY", .sector = "Equity / Corporate", .pct = 5.78 }, .{ .symbol = "PTY", .sector = "Repurchase Agreement / Other", .pct = -29.72 }, }; const cm = ClassificationMap{ .entries = &entries, .allocator = allocator }; const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator }; var result = try analyzePortfolio( allocator, &allocations, cm, portfolio, 10_000, null, Date.fromYmd(2024, 6, 1), ); defer result.deinit(allocator); var cash_val: f64 = 0; var fi_val: f64 = 0; var equity_val: f64 = 0; for (result.asset_category) |item| { if (std.mem.eql(u8, item.label, bucket_cash)) cash_val = item.value; if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi_val = item.value; if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value; } // Cash = -29.72% × $10,000 = -$2,972 (honest negative). try std.testing.expectApproxEqAbs(@as(f64, -2_972), cash_val, 1.0); // Fixed Income = (41.65 + 40.05)% × $10,000 = $8,170. try std.testing.expectApproxEqAbs(@as(f64, 8_170), fi_val, 1.0); // Equity = 5.78% × $10,000 = $578. try std.testing.expectApproxEqAbs(@as(f64, 578), equity_val, 1.0); } test "analyzePortfolio: asset_category includes literal cash + CD totals in Cash bucket" { // Literal cash and CDs should add to the Cash bucket's // value, not just Cash & CDs in the asset_class breakdown. const allocator = std.testing.allocator; const Lot = @import("../models/portfolio.zig").Lot; var lots = [_]Lot{ .{ .symbol = "CASH", .shares = 50_000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Brokerage", }, .{ .symbol = "CD-1", .shares = 10_000, // face value .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .account = "Brokerage", .maturity_date = Date.fromYmd(2027, 1, 1), }, }; const portfolio = Portfolio{ .lots = &lots, .allocator = allocator }; const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator }; var result = try analyzePortfolio( allocator, &.{}, cm, portfolio, 60_000, null, Date.fromYmd(2024, 6, 1), ); defer result.deinit(allocator); var cash_val: f64 = 0; for (result.asset_category) |item| { if (std.mem.eql(u8, item.label, bucket_cash)) cash_val = item.value; } try std.testing.expectApproxEqAbs(@as(f64, 60_000), cash_val, 1.0); } test "analyzePortfolio: legacy entry (asset_class only, no sector) buckets via fallback" { // Hand-written CIT/CUSIP entries in metadata.srf often have // `asset_class::Bonds,pct:num:30` with no sector. The // fallback path through `bucketAssetClass` must pick these // up so they land in Fixed Income, not Other. const allocator = std.testing.allocator; const allocations = [_]Allocation{mkAlloc("LEGACY-CIT", 100_000)}; var entries = [_]ClassificationEntry{ .{ .symbol = "LEGACY-CIT", .asset_class = "Bonds", .pct = 60 }, .{ .symbol = "LEGACY-CIT", .asset_class = "US Large Cap", .pct = 40 }, }; const cm = ClassificationMap{ .entries = &entries, .allocator = allocator }; const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator }; var result = try analyzePortfolio( allocator, &allocations, cm, portfolio, 100_000, null, Date.fromYmd(2024, 6, 1), ); defer result.deinit(allocator); var equity_val: f64 = 0; var fi_val: f64 = 0; for (result.asset_category) |item| { if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value; if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi_val = item.value; } // 60% Bonds → Fixed Income = $60,000. try std.testing.expectApproxEqAbs(@as(f64, 60_000), fi_val, 1.0); // 40% US Large Cap → Equity = $40,000. try std.testing.expectApproxEqAbs(@as(f64, 40_000), equity_val, 1.0); } test "analyzePortfolio: sector wins over asset_class when both present" { // Defensive: we should not double-count. If both fields are // present, only the sector-based bucket fires. const allocator = std.testing.allocator; const allocations = [_]Allocation{mkAlloc("FOO", 100_000)}; var entries = [_]ClassificationEntry{ // sector says Fixed Income (Debt / *), asset_class says // Equity (US Large Cap). sector should win. .{ .symbol = "FOO", .sector = "Debt / Corporate", .asset_class = "US Large Cap" }, }; const cm = ClassificationMap{ .entries = &entries, .allocator = allocator }; const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator }; var result = try analyzePortfolio( allocator, &allocations, cm, portfolio, 100_000, null, Date.fromYmd(2024, 6, 1), ); defer result.deinit(allocator); // Exactly one row, in Fixed Income. try std.testing.expectEqual(@as(usize, 1), result.asset_category.len); try std.testing.expectEqualStrings(bucket_fixed_income, result.asset_category[0].label); try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.asset_category[0].value, 1.0); } test "abbreviateSector: known long labels collapse, others pass through" { try std.testing.expectEqualStrings("Comm. Services", abbreviateSector("Communication Services")); try std.testing.expectEqualStrings("Technology", abbreviateSector("Technology")); try std.testing.expectEqualStrings("Bonds", abbreviateSector("Bonds")); try std.testing.expectEqualStrings("Equity / Corporate", abbreviateSector("Equity / Corporate")); try std.testing.expectEqualStrings("", abbreviateSector("")); }