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
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 `<seriesName>`, 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.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.
- **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

View file

@ -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());

View file

@ -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);
}

View file

@ -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);
}