From 195933176fb38df4c5115c3f998cb142d810efa8 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sun, 31 May 2026 10:04:31 -0700 Subject: [PATCH] umbrella insurance in analysis command --- TODO.md | 361 ++++--------------------------------- src/analytics/analysis.zig | 312 ++++++++++++++++++++++++++++++++ src/commands/analysis.zig | 131 +++++++++++++- src/tui/analysis_tab.zig | 181 ++++++++++++++++++- 4 files changed, 649 insertions(+), 336 deletions(-) diff --git a/TODO.md b/TODO.md index 08d0f5f..c151dfc 100644 --- a/TODO.md +++ b/TODO.md @@ -744,297 +744,49 @@ If a fix lands, it's probably a separate analysis section (yield breakdown, income coverage) — not a change to the asset-class taxonomy. -## Enrich: title-keyword classification inference for ETFs — priority MEDIUM +## Analysis: umbrella-insurance exposure — future enhancements — priority LOW -When Wikidata returns no entry for a fund symbol and we fall -through to the EDGAR ticker-map fallback, the auto-emitted -metadata line carries a generic `sector::Equity / Corporate, -geo::US,asset_class::Fund` triple. That's mechanically correct -(NPORT-P really does say "this fund holds equity in corporate -issuers, US-domiciled fund") but loses information the user -actually cares about: sector-themed ETFs (XLV → Healthcare), -geo-themed ETFs (FRDM → Emerging Markets, IDMO/HFXI/IVLU → -International Developed). +**v1 shipped (May 2026)**: `analysis` command and TUI tab now show an +"Umbrella exposure" section that splits the liquid portfolio into +Shielded vs Exposed dollars based on per-account `tax_type` from +`accounts.srf`, with an optional per-account `shielded:bool:false` +(or `:true`) override for cases the tax-type default gets wrong +(e.g. pre-tax deferred-comp accounts that aren't ERISA-protected). -The fund's *title* often carries the answer unambiguously. We -already plumb `series_name` (NPORT-P ``, falling -back to the company-tickers `title`) through to -`emitMissingClassification`. Add a keyword-inference pass that -overrides the default sector and geo when the title contains -unambiguous keywords. +The shielding decision is intentionally simple: tax_type != taxable +defaults to shielded; the `shielded` field overrides per-account. +That covers the realistic cases (401k vs taxable brokerage; DCP-style +non-ERISA pre-tax accounts) without a state-by-state lookup table. -### Sector inference +### What's deferred -Trigger: when the lookup is `.managed_fund` or `.company_or_uit` -AND the fund has a single dominant `Equity / Corporate` sector -(>95% of holdings), AND the title carries one of the keywords -below. Emit a single GICS-tagged line in place of the NPORT-P -breakdown. +These were in the original v1 design but skipped to keep the +shipping scope tight. Pick up only if real user demand surfaces. -Conservative keyword set (matches one GICS sector unambiguously): +- **State-by-state IRA protection lookup table**. Civil-judgment IRA + shielding varies by state (TX/FL full, some none, most partial). + v1 punts to the manual override; users in weak-state IRA + jurisdictions add `shielded:bool:false` on their IRA accounts + themselves. A built-in state table would automate this — needs a + `state` field at the user level (per-portfolio-file or global + config) and a maintained taxonomy. Doable but high-friction relative + to the manual override. -- "Health Care" / "Healthcare" → Healthcare -- "Semiconductor" → Technology (not "Technology" alone — too generic) -- "Software" → Technology -- "Financial" → Financial Services (careful: "Financial Select Sector SPDR") -- "Energy" → Energy -- "Oil & Gas" / "Oil and Gas" → Energy -- "Real Estate" / "REIT" → Real Estate -- "Utilities" → Utilities -- "Consumer Discretionary" → Consumer Cyclical -- "Consumer Staples" → Consumer Defensive -- "Industrial" → Industrials (careful: "Industrial Materials" — match - whole phrase) -- "Materials" → Basic Materials -- "Communication" / "Telecom" → Communication Services +- **`account_type` distinction**. v1 uses `tax_type` (taxable / roth + / traditional / hsa) as the umbrella proxy because that's what + exists. A more granular `account_type` (`401k`, `403b`, `roth_ira`, + `traditional_ira`, `sep_ira`, `inherited_ira`, ...) would let the + default shielding decision be more nuanced (e.g. inherited_ira + defaults to NOT shielded post-Clark v. Rameker). Not necessary + while the override exists. -Reuse `Wikidata.canonicalizeSector`'s sector constants so the -two taxonomies don't drift. +- **Joint-account / community-property nuance**. State-specific. + v1 treats each account holistically. Probably never needed. -The "single dominant Equity / Corporate" guard prevents the -inference from misclassifying multi-asset funds (FAGIX-shape: -Debt + Equity + Loan), pure-debt funds (VBTLX), or sector-fund -edge cases like a hypothetical "Vanguard Healthcare Income Fund" -(if the breakdown is multi-sleeve, leave the NPORT-P decomposition -alone). - -### Geo inference - -Trigger: when the lookup is `.managed_fund` or `.company_or_uit` -AND the title carries an unambiguous geo keyword. Override the -default `geo::US` to the inferred bucket. - -This one is more important than sector inference because the -default is *factually wrong* for international/emerging funds, -not just imprecise. FRDM holds Taiwanese, South Korean, -Chilean, Polish equities; tagging it `geo::US` overstates US -exposure and understates EM exposure proportionally to the -fund's weight in the portfolio. - -Conservative keyword set: - -- "Emerging Markets" / "Emerging Market" → Emerging Markets -- "Frontier Markets" → Emerging Markets (or own bucket if added) -- "International Developed" → International Developed -- "International" / "Intl" / "Intl." → International Developed - (careful: only when not paired with "+ US" or similar mixing - modifier) -- (Skip country-specific keywords for now — "China" / "Japan" / - "Europe" are unambiguous but we'd be designing per-country - buckets that don't exist in the current taxonomy) - -False-positive risk for "International": fund names like -"Vanguard Total International + US Equity Index" would mis-tag -as International. Audit your portfolio's titles before locking -in the keyword. The conservative version may need to be -"International" only when the title contains no US-related -keyword, or might need explicit phrase matching. - -### Tests - -- `inferSectorFromTitle("State Street(R) Health Care Select Sector SPDR(R) ETF")` → "Healthcare" -- `inferSectorFromTitle("iShares Semiconductor ETF")` → "Technology" -- `inferSectorFromTitle("Schwab U.S. Dividend Equity ETF")` → null (broad-market, no sector word) -- `inferSectorFromTitle("Vanguard Total Bond Market Index Fund")` → null -- `inferGeoFromTitle("Freedom 100 Emerging Markets ETF")` → "Emerging Markets" -- `inferGeoFromTitle("iShares MSCI Intl Value Factor ETF")` → "International Developed" -- `inferGeoFromTitle("Schwab U.S. Dividend Equity ETF")` → null -- Plus integration tests against `emitMissingClassification` confirming the override only fires when the dominant-sector / single-geo guards are satisfied. - -### User's portfolio coverage - -After this work, the funds in the user's metadata.srf that -currently need hand-editing for sector/geo would be auto-tagged: - -- Sector: XLV (Healthcare), SOXX (Technology), QTUM (Technology — "Quantum" is borderline; might require explicit add) -- Geo: FRDM (Emerging Markets), IDMO (International Developed), HFXI (International Developed), IVLU (International Developed) - -## Analysis: collapse fine-grained NPORT-P sector strings at display time — priority MEDIUM - -The Sector (Equities) section in `analysis` output currently -shows raw NPORT-P sector strings. For a portfolio with -multi-asset funds (FAGIX, VBTLX, PTY) this means six different -"Debt / *" rows (Debt / Corporate, Debt / US Treasury, -Debt / Municipal, Debt / Non-US Sovereign, Debt / US Gov Agency, -Debt / US GSE), three "Asset-Backed / *" rows, three -"Derivative / *" rows, etc. — too granular to scan. - -The user's framing: "sometimes I'd be interested in 'roll up -all my debt investments to a single bucket', sometimes I'd want -to see split between federal government, munis and corporate." -That argues for **multiple display granularity levels** with a -TUI hot-key to toggle, not a one-time collapse decision. - -### Design - -Three display granularity tiers: - -1. **Coarse** (4 buckets): Equity / Fixed Income / Cash / Other. - Already implemented as the Asset Category section. Could be - a granularity option for the Sector section too. -2. **Mid** (~12-16 buckets): collapses NPORT-P sub-flavors but - keeps GICS sectors distinct. Roughly: - - "Bonds" (collapses all `Debt / *` + `Loan / *`) - - "Asset-Backed Securities" (collapses all `Asset-Backed / *`) - - "Cash & Equivalents" (collapses STIV variants + Repurchase Agreement) - - "Equity / Corporate" (the dominant equity bucket) - - "Equity / Other" (small equity sleeves) - - "Derivatives & Other" (collapses Derivative / Derivative-FX / Direct Real Property / etc.) - - The 11 GICS sectors (Technology, Healthcare, etc.) for stock-level entries -3. **Fine** (current behavior): raw NPORT-P strings — Debt / - US Treasury vs Debt / Municipal vs Debt / Non-US Sovereign, - etc. - -User toggles between tiers. Default: probably Mid. - -### Implementation - -Build a pure mapping function `collapseSector(sector, granularity) -[]const u8` parallel to `bucketSector`. Display layer chooses -granularity. Aggregation can either: - -- **(a)** Run all three aggregations every time and pick at - display. Memory cost ~3x for the sector breakdown but the - data is small (dozens of rows). -- **(b)** Re-aggregate when granularity changes. Cheaper memory, - costs a single pass over the classifications on toggle. TUI - toggle latency is fine — it's a hashmap rebuild over <50 rows. - -Option (b) is probably right for the TUI. CLI can pick one -granularity at command-line time (default Mid; `--sector-detail -fine|mid|coarse` to override). - -### Dependency - -**Lands AFTER the title-keyword inference work above**, so the -collapse logic is designed against the post-inference content -shape (where XLV is `Healthcare` rather than `Equity / Corporate`, -FRDM is `Equity / Corporate` + `geo::Emerging Markets`, etc.) -rather than today's pre-inference shape. - -### TUI integration - -Hot-key cycles between coarse / mid / fine on the analysis tab. -Status bar shows current granularity. State persists across -re-renders within a session; no need to persist across sessions. - -### Tests - -- `collapseSector("Debt / US Treasury", .mid)` → "Bonds" -- `collapseSector("Debt / US Treasury", .fine)` → "Debt / US Treasury" -- `collapseSector("Debt / US Treasury", .coarse)` → "Fixed Income" (delegates to bucketSector) -- `collapseSector("Technology", .mid)` → "Technology" (GICS sectors stay distinct at mid) -- `collapseSector("Technology", .coarse)` → "Equity" -- TUI hot-key cycles through three granularities and updates display. - -## Analysis: umbrella-insurance exposure indicator — priority MEDIUM - -In the `analysis` command and TUI tab, surface how much of the -liquid portfolio is *exposed to lawsuit / creditor risk* and -therefore should be covered by umbrella insurance. The number -the user actually wants is "how much could be lost if I'm sued -and lose" — which is liquid assets minus the legally-shielded -buckets. - -### Shielding rules (US, broad strokes) - -- **401(k) and other ERISA-qualified employer plans**: shielded - by ERISA, federal-law-level protection. Effectively untouchable - in lawsuits. Always exclude from the umbrella-coverage figure. -- **IRAs (traditional + Roth)**: protection is **state-by-state**. - - Federal bankruptcy protection up to ~$1.5M (BAPCPA, indexed) - applies in bankruptcy court only. - - Outside bankruptcy (i.e., civil judgments), it's state law. - Some states give full protection (e.g., TX, FL); some give - none; many give partial / "reasonably necessary for support." -- **Sample Inherited IRAs**: a separate category. Less protected than - contributory IRAs in many states post-*Clark v. Rameker* (2014). - Ideally tracked separately if relevant. -- **HSAs**: typically protected if held in an HSA-qualified - account; varies by state. -- **Pensions / annuities**: usually protected, varies. -- **Trusts**: depends on trust type; generally outside the scope - of this TODO unless we get clean trust metadata. -- **Brokerage / taxable**: never shielded. Always counts as - exposed. -- **Cash in personal bank accounts**: never shielded. - -### Implementation options - -Two extremes: - -1. **Fully automatic** — infer shielding from `account_type` in - `accounts.srf` (e.g., `401k`, `roth_ira`, `traditional_ira`) - plus a static state-default table. Risk: state-by-state IRA - law is genuinely complex, and a wrong default could produce - a misleading number. The user would need to KNOW the table's - assumptions. -2. **Fully manual** — add a `shielded` boolean (or - `shielded_fraction`: 0.0–1.0) to each account in `accounts.srf`. - User decides per account. Most accurate, more upfront work. - -### Recommended hybrid - -- Add an `account_type` field if not already present (the - metadata for tax-treatment is likely already there for - contributions tracking). Map types to a default shielding: - - `401k`, `403b`, `457`, `pension` → `shielded = true` - - `roth_401k`, `roth_403b` → `shielded = true` - - `traditional_ira`, `roth_ira`, `sep_ira`, `simple_ira` → - `shielded = depends_on_state` (unknown without state info) - - `hsa` → `shielded = true` (with caveat) - - `brokerage`, `bank`, `joint`, `trust` → `shielded = false` -- Add an optional per-account `shielded` override in - `accounts.srf` (e.g. `shielded::true` / `shielded::false` / - `shielded::partial`) that wins over the default. This is the - escape hatch for "my state of residence treats IRAs as - shielded" or "this trust isn't protected." -- Add a `state` field at the user level (perhaps - `metadata.srf` or a config), with a built-in lookup table of - state IRA protection (`full`, `partial`, `none`) that - populates the default for IRA-type accounts when no override - is set. - -### Output - -In the analysis tab / command, add a section like: - -``` -Umbrella exposure - Total liquid value: $X,XXX,XXX - Shielded (401k/ERISA): $XXX,XXX - Shielded (IRA, NY default = partial): $XXX,XXX (per state default) - Shielded (override): $XXX,XXX - Net exposure: $X,XXX,XXX ← umbrella target -``` - -Show it both as an absolute dollar amount and as a percent of -liquid total. Include the assumption text (state, default for -that state) inline so the user knows what the number means and -when it might be wrong. - -### Tests - -- Default shielding for each `account_type`. -- Override wins over default. -- State table consistency (no missing states). -- "I don't know my state" path should refuse to guess for IRAs - and instead report the IRA-shielded portion as `unknown`, - forcing the user to choose between "treat as shielded" / - "treat as exposed" via override. -- Sample Inherited IRAs flagged separately if we track that. - -### Open questions - -- Where should the state field live — `metadata.srf` (per - portfolio file) or a global user config (one user, one state)? -- Should we surface a "needed umbrella amount = net_exposure + - [home equity, vehicles]" figure, or strictly stay liquid? The - user asked about liquid assets specifically, so default to that - but leave a note. -- How to handle joint accounts where one spouse's lawsuit could - reach the other's share. State-specific (community property - vs separate property states). Probably out of scope for v1. +- **Inherited-IRA flag**. Currently the user adds + `shielded:bool:false` on inherited IRAs as the workaround. A + dedicated flag would let the section call them out by name in + the output. Cosmetic. @@ -1054,39 +806,6 @@ so they don't get lost; pick up opportunistically. `App`; on symbol change, stash the previous. Useful for eyeball-comparing performance/risk data between two symbols. -### Data quality - -- **Fix `enrich` command for international funds.** `deriveMetadata` - in `src/commands/enrich.zig` misclassifies international ETFs: - 1. `geo` uses Alpha Vantage's `Country` field, which is the *fund - issuer's* domicile (USA for all US-listed ETFs), not the fund's - investment geography. Every US-domiciled international fund gets - `geo::US`. - 2. `asset_class` short-circuits to `"ETF"` when - `asset_type == "ETF"`, or falls through to a US-market-cap - heuristic that always produces `"US Large Cap"` / - `"US Mid Cap"` / `"US Small Cap"`. - - Known misclassified tickers (all came back as - `geo::US, asset_class::US Large Cap`): - - **FRDM** — Freedom 100 Emerging Markets ETF → should be - `geo::Emerging Markets, asset_class::Emerging Markets` - - **HFXI** — NYLI FTSE International Equity Currency Neutral ETF - → should be - `geo::International Developed, asset_class::International Developed` - - **IDMO** — Invesco S&P International Developed Momentum ETF → - should be - `geo::International Developed, asset_class::International Developed` - - **IVLU** — iShares MSCI International Developed Value Factor - ETF → should be - `geo::International Developed, asset_class::International Developed` - - The Alpha Vantage OVERVIEW endpoint doesn't provide fund geography - data. Options: use the ETF_PROFILE holdings/country data to infer - geography, parse the fund name for keywords ("International", - "Emerging", "ex-US"), or accept that `enrich` is a scaffold and - emit a `# TODO` comment for ETFs instead of silently misclassifying. - ### Options / valuation - **Per-account covered call adjustment.** `adjustForCoveredCalls` in @@ -1103,16 +822,6 @@ so they don't get lost; pick up opportunistically. (<1000 lots). Pre-indexing options by underlying would help if someone had a very large options-heavy portfolio. -### Analysis / correctness - -- **Analysis account/asset-class total mismatch.** The "By Account" - and "By Tax Type" sections in the analysis command sum to slightly - more than "Asset Class" (~0.6% error). Likely a discrepancy between - how the lot-level account loop values cash, CDs, or options vs how - the asset-class section computes them via `portfolio.totalCash()` / - `totalCdFaceValue()`. Per-account values themselves are correct - after the price_ratio fix. - ### Audit - **Audit large-lot threshold tuning.** `src/commands/audit.zig` uses diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index c507016..197aa3a 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -76,6 +76,24 @@ pub const AccountTaxEntry = struct { /// 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. @@ -207,6 +225,7 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun .update_cadence = entry.update_cadence, .cash_is_contribution = entry.cash_is_contribution, .direct_indexing = entry.direct_indexing, + .shielded = entry.shielded, }); } @@ -277,6 +296,81 @@ pub fn breakdownSections(r: *const AnalysisResult) [6]Section { }; } +// ── 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 @@ -772,6 +866,224 @@ test "parseAccountsFile: direct_indexing default false, opt-in true" { 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()); diff --git a/src/commands/analysis.zig b/src/commands/analysis.zig index ac05d70..5d5bd08 100644 --- a/src/commands/analysis.zig +++ b/src/commands/analysis.zig @@ -172,10 +172,10 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { allocator.free(result.sector); result.sector = collapsed_sector; - try display(result, split.stock_pct, split.bond_pct, split.cash_pct, pf_data.summary.total_value, display_label, color, out); + try display(result, split.stock_pct, split.bond_pct, split.cash_pct, pf_data.summary.total_value, display_label, acct_map_opt, color, out); } -fn display(result: zfin.analysis.AnalysisResult, stock_pct: f64, bond_pct: f64, cash_pct: f64, total_value: f64, file_path: []const u8, color: bool, out: *std.Io.Writer) !void { +fn display(result: zfin.analysis.AnalysisResult, stock_pct: f64, bond_pct: f64, cash_pct: f64, total_value: f64, file_path: []const u8, account_map: ?zfin.analysis.AccountMap, color: bool, out: *std.Io.Writer) !void { const label_width = fmt.analysis_label_width; const bar_width = fmt.analysis_bar_width; @@ -217,9 +217,40 @@ fn display(result: zfin.analysis.AnalysisResult, stock_pct: f64, bond_pct: f64, } } + // Umbrella-insurance exposure section. Computed from the + // already-aggregated account breakdown plus the per-account + // tax-type / shielded overrides in accounts.srf. Lives at + // the bottom because it's a derived summary, not a + // breakdown — gives the user the load-bearing "what's my + // umbrella target?" number after the supporting detail. + if (account_map) |am| { + try printUmbrellaSection(out, result.account, am, color); + } + try out.print("\n", .{}); } +/// Print the Umbrella exposure section. Shielding decision is +/// computed per-account by `umbrellaExposure`; this function +/// only formats the result. +pub fn printUmbrellaSection(out: *std.Io.Writer, account_breakdown: []const zfin.analysis.BreakdownItem, account_map: zfin.analysis.AccountMap, color: bool) !void { + const umbrella = zfin.analysis.umbrellaExposure(account_breakdown, account_map); + if (umbrella.total_liquid <= 0) return; // no accounts -> nothing to display + + try out.print("\n", .{}); + try cli.setBold(out, color); + try cli.printFg(out, color, cli.CLR_HEADER, " Umbrella exposure\n", .{}); + + try out.print(" Total liquid: {f}\n", .{Money.from(umbrella.total_liquid)}); + try out.print(" Shielded (retirement accounts): {f}\n", .{Money.from(umbrella.shielded_value)}); + try cli.printFg(out, color, cli.CLR_WARNING, " Exposed (taxable + non-shielded pre-tax): {f} ({d:.1}%)\n", .{ Money.from(umbrella.exposed_value), umbrella.exposed_pct * 100 }); + try cli.printFg(out, color, cli.CLR_MUTED, " ↑ approximate umbrella target\n", .{}); + try out.print("\n", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " Note: IRA protections vary by state. Analysis assumes\n", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " protection. Override per-account using `shielded:bool:false`\n", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " in accounts.srf.\n", .{}); +} + /// Print a breakdown section with block-element bar charts to the CLI output. pub fn printBreakdownSection(out: *std.Io.Writer, items: []const zfin.analysis.BreakdownItem, label_width: usize, bar_width: usize, color: bool) !void { for (items) |item| { @@ -344,7 +375,7 @@ test "display shows all sections" { .unclassified = @constCast(&unclassified), .total_value = 100000.0, }; - try display(result, 0.80, 0.15, 0.05, 100000.0, "test.srf", false, &w); + try display(result, 0.80, 0.15, 0.05, 100000.0, "test.srf", null, false, &w); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Analysis") != null); // 3-up header includes Cash. @@ -361,4 +392,98 @@ test "display shows all sections" { try std.testing.expect(std.mem.indexOf(u8, out, "WEIRD") != null); // No ANSI when color=false try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); + // Umbrella section is suppressed when account_map is null + // (no shielded/exposed split possible without tax_type info). + try std.testing.expect(std.mem.indexOf(u8, out, "Umbrella exposure") == null); +} + +test "printUmbrellaSection: emits Umbrella exposure block with shielded + exposed dollars" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + + var am = try zfin.analysis.parseAccountsFile(std.testing.allocator, + \\#!srfv1 + \\account::Sample IRA,tax_type::traditional + \\account::Sample DCP,tax_type::traditional,shielded:bool:false + \\account::Sample Brokerage,tax_type::taxable + ); + defer am.deinit(); + + const accounts = [_]zfin.analysis.BreakdownItem{ + .{ .label = "Sample IRA", .value = 1_000_000, .weight = 0.50 }, + .{ .label = "Sample DCP", .value = 500_000, .weight = 0.25 }, + .{ .label = "Sample Brokerage", .value = 500_000, .weight = 0.25 }, + }; + + try printUmbrellaSection(&w, &accounts, am, false); + + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "Umbrella exposure") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Total liquid") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Shielded") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Exposed") != null); + // Shielded = IRA only = $1M; exposed = DCP + Brokerage = $1M. + try std.testing.expect(std.mem.indexOf(u8, out, "$1,000,000") != null); + // Exposed pct = 50%. + try std.testing.expect(std.mem.indexOf(u8, out, "50.0%") != null); + // Note text appears. + try std.testing.expect(std.mem.indexOf(u8, out, "IRA protections vary by state") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "shielded:bool:false") != null); +} + +test "printUmbrellaSection: empty accounts produces no output" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + + var am = try zfin.analysis.parseAccountsFile(std.testing.allocator, + \\#!srfv1 + \\account::Sample IRA,tax_type::traditional + ); + defer am.deinit(); + + try printUmbrellaSection(&w, &.{}, am, false); + // No "Umbrella exposure" line should appear when there's + // nothing to show. + try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "Umbrella exposure") == null); +} + +test "display: includes umbrella section when account_map is provided" { + var buf: [8192]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + + const asset_category = [_]zfin.analysis.BreakdownItem{ + .{ .label = "Equity", .weight = 0.80, .value = 80_000 }, + }; + const account = [_]zfin.analysis.BreakdownItem{ + .{ .label = "Sample IRA", .weight = 0.60, .value = 60_000 }, + .{ .label = "Sample Brokerage", .weight = 0.40, .value = 40_000 }, + }; + const empty = [_]zfin.analysis.BreakdownItem{}; + + const result: zfin.analysis.AnalysisResult = .{ + .asset_category = @constCast(&asset_category), + .asset_class = @constCast(&empty), + .sector = @constCast(&empty), + .geo = @constCast(&empty), + .account = @constCast(&account), + .tax_type = @constCast(&empty), + .unclassified = &.{}, + .total_value = 100_000, + }; + + var am = try zfin.analysis.parseAccountsFile(std.testing.allocator, + \\#!srfv1 + \\account::Sample IRA,tax_type::traditional + \\account::Sample Brokerage,tax_type::taxable + ); + defer am.deinit(); + + try display(result, 0.80, 0.0, 0.0, 100_000, "test.srf", am, false, &w); + + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "Umbrella exposure") != null); + // Shielded = IRA $60k; exposed = Brokerage $40k. + try std.testing.expect(std.mem.indexOf(u8, out, "$60,000") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "$40,000") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "40.0%") != null); } diff --git a/src/tui/analysis_tab.zig b/src/tui/analysis_tab.zig index c9af8e7..66d3548 100644 --- a/src/tui/analysis_tab.zig +++ b/src/tui/analysis_tab.zig @@ -207,7 +207,7 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c cash_pct = split.cash_pct; } } - return renderAnalysisLines(arena, app.theme, state.result, stock_pct, bond_pct, cash_pct, total_value, state.sector_granularity); + return renderAnalysisLines(arena, app.theme, state.result, stock_pct, bond_pct, cash_pct, total_value, state.sector_granularity, app.portfolio.account_map); } /// Render analysis tab content. Pure function — no App dependency. @@ -220,6 +220,7 @@ pub fn renderAnalysisLines( cash_pct: f64, total_value: f64, sector_granularity: zfin.analysis.Granularity, + account_map: ?zfin.analysis.AccountMap, ) ![]const StyledLine { var lines: std.ArrayList(StyledLine) = .empty; @@ -309,9 +310,52 @@ pub fn renderAnalysisLines( } } + // Umbrella-insurance exposure block at the bottom (mirrors + // the CLI's `display`). User scrolls to see it. Suppressed + // when account_map is unavailable — the shielding decision + // requires per-account tax_type info. + if (account_map) |am| { + try renderUmbrellaSection(arena, th, &lines, result.account, am); + } + return lines.toOwnedSlice(arena); } +/// Append the Umbrella exposure block to `lines`. Mirrors +/// `commands/analysis.zig:printUmbrellaSection`. +pub fn renderUmbrellaSection( + arena: std.mem.Allocator, + th: theme.Theme, + lines: *std.ArrayList(StyledLine), + account_breakdown: []const zfin.analysis.BreakdownItem, + account_map: zfin.analysis.AccountMap, +) !void { + const umbrella = zfin.analysis.umbrellaExposure(account_breakdown, account_map); + if (umbrella.total_liquid <= 0) return; + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Umbrella exposure", .style = th.headerStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + { + const text = try std.fmt.allocPrint(arena, " Total liquid: {f}", .{Money.from(umbrella.total_liquid)}); + try lines.append(arena, .{ .text = text, .style = th.contentStyle() }); + } + { + const text = try std.fmt.allocPrint(arena, " Shielded (retirement accounts): {f}", .{Money.from(umbrella.shielded_value)}); + try lines.append(arena, .{ .text = text, .style = th.contentStyle() }); + } + { + const text = try std.fmt.allocPrint(arena, " Exposed (taxable + non-shielded pre-tax): {f} ({d:.1}%)", .{ Money.from(umbrella.exposed_value), umbrella.exposed_pct * 100 }); + try lines.append(arena, .{ .text = text, .style = th.warningStyle() }); + } + try lines.append(arena, .{ .text = " ↑ approximate umbrella target", .style = th.mutedStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Note: IRA protections vary by state. Analysis assumes", .style = th.mutedStyle() }); + try lines.append(arena, .{ .text = " protection. Override per-account using `shielded:bool:false`", .style = th.mutedStyle() }); + try lines.append(arena, .{ .text = " in accounts.srf.", .style = th.mutedStyle() }); +} + /// Display label for a granularity tier, used in the Sector /// section title. fn granularityLabel(g: zfin.analysis.Granularity) []const u8 { @@ -410,7 +454,7 @@ test "renderAnalysisLines with data" { .unclassified = &.{}, .total_value = 200000, }; - const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.15, 0.05, 200000, .mid); + const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.15, 0.05, 200000, .mid, null); // Should have header section + asset class items try testing.expect(lines.len >= 5); // Find "Portfolio Analysis" header @@ -436,7 +480,7 @@ test "renderAnalysisLines no data" { const arena = arena_state.allocator(); const th = theme.default_theme; - const lines = try renderAnalysisLines(arena, th, null, 0, 0, 0, 0, .mid); + const lines = try renderAnalysisLines(arena, th, null, 0, 0, 0, 0, .mid, null); try testing.expectEqual(@as(usize, 5), lines.len); try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null); } @@ -493,7 +537,7 @@ test "renderAnalysisLines: granularity label appears in Sector section title" { // mid label { - const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.20, 0.0, 100_000, .mid); + const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.20, 0.0, 100_000, .mid, null); var found_mid = false; for (lines) |l| { if (std.mem.indexOf(u8, l.text, "Sector (mid") != null) found_mid = true; @@ -502,7 +546,7 @@ test "renderAnalysisLines: granularity label appears in Sector section title" { } // fine label { - const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.20, 0.0, 100_000, .fine); + const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.20, 0.0, 100_000, .fine, null); var found_fine = false; for (lines) |l| { if (std.mem.indexOf(u8, l.text, "Sector (fine") != null) found_fine = true; @@ -511,7 +555,7 @@ test "renderAnalysisLines: granularity label appears in Sector section title" { } // coarse label { - const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.20, 0.0, 100_000, .coarse); + const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.20, 0.0, 100_000, .coarse, null); var found_coarse = false; for (lines) |l| { if (std.mem.indexOf(u8, l.text, "Sector (coarse") != null) found_coarse = true; @@ -543,7 +587,7 @@ test "renderAnalysisLines: mid granularity collapses Debt rows" { .total_value = 100_000, }; - const lines = try renderAnalysisLines(arena, th, result, 0.30, 0.70, 0.0, 100_000, .mid); + const lines = try renderAnalysisLines(arena, th, result, 0.30, 0.70, 0.0, 100_000, .mid, null); // At mid, Bonds appears (collapsed) and the individual Debt // rows do NOT appear. @@ -556,3 +600,126 @@ test "renderAnalysisLines: mid granularity collapses Debt rows" { try testing.expect(has_bonds_row); try testing.expect(!has_raw_treasury); } + +test "renderAnalysisLines: umbrella section appears at the bottom when account_map provided" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const th = theme.default_theme; + + var account = [_]zfin.analysis.BreakdownItem{ + .{ .label = "Sample IRA", .weight = 0.60, .value = 60_000 }, + .{ .label = "Sample Brokerage", .weight = 0.40, .value = 40_000 }, + }; + const result = zfin.analysis.AnalysisResult{ + .asset_category = &.{}, + .asset_class = &.{}, + .sector = &.{}, + .geo = &.{}, + .account = &account, + .tax_type = &.{}, + .unclassified = &.{}, + .total_value = 100_000, + }; + + var am = try zfin.analysis.parseAccountsFile(testing.allocator, + \\#!srfv1 + \\account::Sample IRA,tax_type::traditional + \\account::Sample Brokerage,tax_type::taxable + ); + defer am.deinit(); + + const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.0, 0.0, 100_000, .mid, am); + + var found_header = false; + var found_total_liquid = false; + var found_shielded = false; + var found_exposed = false; + var found_note = false; + for (lines) |l| { + if (std.mem.indexOf(u8, l.text, "Umbrella exposure") != null) found_header = true; + if (std.mem.indexOf(u8, l.text, "Total liquid") != null) found_total_liquid = true; + if (std.mem.indexOf(u8, l.text, "Shielded") != null and std.mem.indexOf(u8, l.text, "$60,000") != null) found_shielded = true; + if (std.mem.indexOf(u8, l.text, "Exposed") != null and std.mem.indexOf(u8, l.text, "40.0%") != null) found_exposed = true; + if (std.mem.indexOf(u8, l.text, "IRA protections vary by state") != null) found_note = true; + } + try testing.expect(found_header); + try testing.expect(found_total_liquid); + try testing.expect(found_shielded); + try testing.expect(found_exposed); + try testing.expect(found_note); +} + +test "renderAnalysisLines: umbrella section absent when account_map is null" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const th = theme.default_theme; + + var account = [_]zfin.analysis.BreakdownItem{ + .{ .label = "Sample IRA", .weight = 1.0, .value = 100_000 }, + }; + const result = zfin.analysis.AnalysisResult{ + .asset_category = &.{}, + .asset_class = &.{}, + .sector = &.{}, + .geo = &.{}, + .account = &account, + .tax_type = &.{}, + .unclassified = &.{}, + .total_value = 100_000, + }; + + const lines = try renderAnalysisLines(arena, th, result, 1.0, 0, 0, 100_000, .mid, null); + + for (lines) |l| { + try testing.expect(std.mem.indexOf(u8, l.text, "Umbrella exposure") == null); + } +} + +test "renderAnalysisLines: umbrella respects shielded:bool:false override (DCP case)" { + // The load-bearing test for the user's DCP scenario: a + // pre-tax account with `tax_type::traditional` that's NOT + // ERISA-shielded. Override flips it from shielded → exposed. + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const th = theme.default_theme; + + var account = [_]zfin.analysis.BreakdownItem{ + .{ .label = "Sample IRA", .weight = 0.40, .value = 1_000_000 }, + .{ .label = "Sample DCP", .weight = 0.60, .value = 1_500_000 }, + }; + const result = zfin.analysis.AnalysisResult{ + .asset_category = &.{}, + .asset_class = &.{}, + .sector = &.{}, + .geo = &.{}, + .account = &account, + .tax_type = &.{}, + .unclassified = &.{}, + .total_value = 2_500_000, + }; + + var am = try zfin.analysis.parseAccountsFile(testing.allocator, + \\#!srfv1 + \\account::Sample IRA,tax_type::traditional + \\account::Sample DCP,tax_type::traditional,shielded:bool:false + ); + defer am.deinit(); + + const lines = try renderAnalysisLines(arena, th, result, 1.0, 0, 0, 2_500_000, .mid, am); + + // IRA shielded ($1M); DCP exposed ($1.5M). Total $2.5M. + var found_shielded_1m = false; + var found_exposed_1_5m = false; + var found_60_pct = false; + for (lines) |l| { + if (std.mem.indexOf(u8, l.text, "Shielded") != null and std.mem.indexOf(u8, l.text, "$1,000,000") != null) found_shielded_1m = true; + if (std.mem.indexOf(u8, l.text, "Exposed") != null and std.mem.indexOf(u8, l.text, "$1,500,000") != null) found_exposed_1_5m = true; + if (std.mem.indexOf(u8, l.text, "60.0%") != null) found_60_pct = true; + } + try testing.expect(found_shielded_1m); + try testing.expect(found_exposed_1_5m); + try testing.expect(found_60_pct); +}