umbrella insurance in analysis command

This commit is contained in:
Emil Lerch 2026-05-31 10:04:31 -07:00
parent 2306e1a9c9
commit 195933176f
4 changed files with 649 additions and 336 deletions

361
TODO.md
View file

@ -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 breakdown, income coverage) — not a change to the asset-class
taxonomy. 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 **v1 shipped (May 2026)**: `analysis` command and TUI tab now show an
through to the EDGAR ticker-map fallback, the auto-emitted "Umbrella exposure" section that splits the liquid portfolio into
metadata line carries a generic `sector::Equity / Corporate, Shielded vs Exposed dollars based on per-account `tax_type` from
geo::US,asset_class::Fund` triple. That's mechanically correct `accounts.srf`, with an optional per-account `shielded:bool:false`
(NPORT-P really does say "this fund holds equity in corporate (or `:true`) override for cases the tax-type default gets wrong
issuers, US-domiciled fund") but loses information the user (e.g. pre-tax deferred-comp accounts that aren't ERISA-protected).
actually cares about: sector-themed ETFs (XLV → Healthcare),
geo-themed ETFs (FRDM → Emerging Markets, IDMO/HFXI/IVLU →
International Developed).
The fund's *title* often carries the answer unambiguously. We The shielding decision is intentionally simple: tax_type != taxable
already plumb `series_name` (NPORT-P `<seriesName>`, falling defaults to shielded; the `shielded` field overrides per-account.
back to the company-tickers `title`) through to That covers the realistic cases (401k vs taxable brokerage; DCP-style
`emitMissingClassification`. Add a keyword-inference pass that non-ERISA pre-tax accounts) without a state-by-state lookup table.
overrides the default sector and geo when the title contains
unambiguous keywords.
### Sector inference ### What's deferred
Trigger: when the lookup is `.managed_fund` or `.company_or_uit` These were in the original v1 design but skipped to keep the
AND the fund has a single dominant `Equity / Corporate` sector shipping scope tight. Pick up only if real user demand surfaces.
(>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.
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 - **`account_type` distinction**. v1 uses `tax_type` (taxable / roth
- "Semiconductor" → Technology (not "Technology" alone — too generic) / traditional / hsa) as the umbrella proxy because that's what
- "Software" → Technology exists. A more granular `account_type` (`401k`, `403b`, `roth_ira`,
- "Financial" → Financial Services (careful: "Financial Select Sector SPDR") `traditional_ira`, `sep_ira`, `inherited_ira`, ...) would let the
- "Energy" → Energy default shielding decision be more nuanced (e.g. inherited_ira
- "Oil & Gas" / "Oil and Gas" → Energy defaults to NOT shielded post-Clark v. Rameker). Not necessary
- "Real Estate" / "REIT" → Real Estate while the override exists.
- "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
Reuse `Wikidata.canonicalizeSector`'s sector constants so the - **Joint-account / community-property nuance**. State-specific.
two taxonomies don't drift. v1 treats each account holistically. Probably never needed.
The "single dominant Equity / Corporate" guard prevents the - **Inherited-IRA flag**. Currently the user adds
inference from misclassifying multi-asset funds (FAGIX-shape: `shielded:bool:false` on inherited IRAs as the workaround. A
Debt + Equity + Loan), pure-debt funds (VBTLX), or sector-fund dedicated flag would let the section call them out by name in
edge cases like a hypothetical "Vanguard Healthcare Income Fund" the output. Cosmetic.
(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.01.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.
@ -1054,39 +806,6 @@ so they don't get lost; pick up opportunistically.
`App`; on symbol change, stash the previous. Useful for `App`; on symbol change, stash the previous. Useful for
eyeball-comparing performance/risk data between two symbols. 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 ### Options / valuation
- **Per-account covered call adjustment.** `adjustForCoveredCalls` in - **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 (<1000 lots). Pre-indexing options by underlying would help if
someone had a very large options-heavy portfolio. 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
- **Audit large-lot threshold tuning.** `src/commands/audit.zig` uses - **Audit large-lot threshold tuning.** `src/commands/audit.zig` uses

View file

@ -76,6 +76,24 @@ pub const AccountTaxEntry = struct {
/// basket of 500 individual stocks tracked as SPY via `ticker::` /// basket of 500 individual stocks tracked as SPY via `ticker::`
/// alias). /// alias).
direct_indexing: bool = false, 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. /// 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, .update_cadence = entry.update_cadence,
.cash_is_contribution = entry.cash_is_contribution, .cash_is_contribution = entry.cash_is_contribution,
.direct_indexing = entry.direct_indexing, .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 // Sector asset-category bucket
/// The four coarse asset-category buckets. Returned from /// 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")); 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" { test "TaxType.label" {
try std.testing.expectEqualStrings("Taxable", TaxType.taxable.label()); try std.testing.expectEqualStrings("Taxable", TaxType.taxable.label());
try std.testing.expectEqualStrings("Roth (Post-Tax)", TaxType.roth.label()); try std.testing.expectEqualStrings("Roth (Post-Tax)", TaxType.roth.label());

View file

@ -172,10 +172,10 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
allocator.free(result.sector); allocator.free(result.sector);
result.sector = collapsed_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 label_width = fmt.analysis_label_width;
const bar_width = fmt.analysis_bar_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", .{}); 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. /// 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 { pub fn printBreakdownSection(out: *std.Io.Writer, items: []const zfin.analysis.BreakdownItem, label_width: usize, bar_width: usize, color: bool) !void {
for (items) |item| { for (items) |item| {
@ -344,7 +375,7 @@ test "display shows all sections" {
.unclassified = @constCast(&unclassified), .unclassified = @constCast(&unclassified),
.total_value = 100000.0, .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(); const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Analysis") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Analysis") != null);
// 3-up header includes Cash. // 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); try std.testing.expect(std.mem.indexOf(u8, out, "WEIRD") != null);
// No ANSI when color=false // No ANSI when color=false
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); 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);
} }

View file

@ -207,7 +207,7 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c
cash_pct = split.cash_pct; 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. /// Render analysis tab content. Pure function no App dependency.
@ -220,6 +220,7 @@ pub fn renderAnalysisLines(
cash_pct: f64, cash_pct: f64,
total_value: f64, total_value: f64,
sector_granularity: zfin.analysis.Granularity, sector_granularity: zfin.analysis.Granularity,
account_map: ?zfin.analysis.AccountMap,
) ![]const StyledLine { ) ![]const StyledLine {
var lines: std.ArrayList(StyledLine) = .empty; 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); 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 /// Display label for a granularity tier, used in the Sector
/// section title. /// section title.
fn granularityLabel(g: zfin.analysis.Granularity) []const u8 { fn granularityLabel(g: zfin.analysis.Granularity) []const u8 {
@ -410,7 +454,7 @@ test "renderAnalysisLines with data" {
.unclassified = &.{}, .unclassified = &.{},
.total_value = 200000, .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 // Should have header section + asset class items
try testing.expect(lines.len >= 5); try testing.expect(lines.len >= 5);
// Find "Portfolio Analysis" header // Find "Portfolio Analysis" header
@ -436,7 +480,7 @@ test "renderAnalysisLines no data" {
const arena = arena_state.allocator(); const arena = arena_state.allocator();
const th = theme.default_theme; 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.expectEqual(@as(usize, 5), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null); 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 // 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; var found_mid = false;
for (lines) |l| { for (lines) |l| {
if (std.mem.indexOf(u8, l.text, "Sector (mid") != null) found_mid = true; 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 // 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; var found_fine = false;
for (lines) |l| { for (lines) |l| {
if (std.mem.indexOf(u8, l.text, "Sector (fine") != null) found_fine = true; 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 // 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; var found_coarse = false;
for (lines) |l| { for (lines) |l| {
if (std.mem.indexOf(u8, l.text, "Sector (coarse") != null) found_coarse = true; 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, .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 // At mid, Bonds appears (collapsed) and the individual Debt
// rows do NOT appear. // 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_bonds_row);
try testing.expect(!has_raw_treasury); 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);
}