umbrella insurance in analysis command
This commit is contained in:
parent
2306e1a9c9
commit
195933176f
4 changed files with 649 additions and 336 deletions
361
TODO.md
361
TODO.md
|
|
@ -744,297 +744,49 @@ If a fix lands, it's probably a separate analysis section (yield
|
|||
breakdown, income coverage) — not a change to the asset-class
|
||||
taxonomy.
|
||||
|
||||
## Enrich: title-keyword classification inference for ETFs — priority MEDIUM
|
||||
## Analysis: umbrella-insurance exposure — future enhancements — priority LOW
|
||||
|
||||
When Wikidata returns no entry for a fund symbol and we fall
|
||||
through to the EDGAR ticker-map fallback, the auto-emitted
|
||||
metadata line carries a generic `sector::Equity / Corporate,
|
||||
geo::US,asset_class::Fund` triple. That's mechanically correct
|
||||
(NPORT-P really does say "this fund holds equity in corporate
|
||||
issuers, US-domiciled fund") but loses information the user
|
||||
actually cares about: sector-themed ETFs (XLV → Healthcare),
|
||||
geo-themed ETFs (FRDM → Emerging Markets, IDMO/HFXI/IVLU →
|
||||
International Developed).
|
||||
**v1 shipped (May 2026)**: `analysis` command and TUI tab now show an
|
||||
"Umbrella exposure" section that splits the liquid portfolio into
|
||||
Shielded vs Exposed dollars based on per-account `tax_type` from
|
||||
`accounts.srf`, with an optional per-account `shielded:bool:false`
|
||||
(or `:true`) override for cases the tax-type default gets wrong
|
||||
(e.g. pre-tax deferred-comp accounts that aren't ERISA-protected).
|
||||
|
||||
The fund's *title* often carries the answer unambiguously. We
|
||||
already plumb `series_name` (NPORT-P `<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.0–1.0) to each account in `accounts.srf`.
|
||||
User decides per account. Most accurate, more upfront work.
|
||||
|
||||
### Recommended hybrid
|
||||
|
||||
- Add an `account_type` field if not already present (the
|
||||
metadata for tax-treatment is likely already there for
|
||||
contributions tracking). Map types to a default shielding:
|
||||
- `401k`, `403b`, `457`, `pension` → `shielded = true`
|
||||
- `roth_401k`, `roth_403b` → `shielded = true`
|
||||
- `traditional_ira`, `roth_ira`, `sep_ira`, `simple_ira` →
|
||||
`shielded = depends_on_state` (unknown without state info)
|
||||
- `hsa` → `shielded = true` (with caveat)
|
||||
- `brokerage`, `bank`, `joint`, `trust` → `shielded = false`
|
||||
- Add an optional per-account `shielded` override in
|
||||
`accounts.srf` (e.g. `shielded::true` / `shielded::false` /
|
||||
`shielded::partial`) that wins over the default. This is the
|
||||
escape hatch for "my state of residence treats IRAs as
|
||||
shielded" or "this trust isn't protected."
|
||||
- Add a `state` field at the user level (perhaps
|
||||
`metadata.srf` or a config), with a built-in lookup table of
|
||||
state IRA protection (`full`, `partial`, `none`) that
|
||||
populates the default for IRA-type accounts when no override
|
||||
is set.
|
||||
|
||||
### Output
|
||||
|
||||
In the analysis tab / command, add a section like:
|
||||
|
||||
```
|
||||
Umbrella exposure
|
||||
Total liquid value: $X,XXX,XXX
|
||||
Shielded (401k/ERISA): $XXX,XXX
|
||||
Shielded (IRA, NY default = partial): $XXX,XXX (per state default)
|
||||
Shielded (override): $XXX,XXX
|
||||
Net exposure: $X,XXX,XXX ← umbrella target
|
||||
```
|
||||
|
||||
Show it both as an absolute dollar amount and as a percent of
|
||||
liquid total. Include the assumption text (state, default for
|
||||
that state) inline so the user knows what the number means and
|
||||
when it might be wrong.
|
||||
|
||||
### Tests
|
||||
|
||||
- Default shielding for each `account_type`.
|
||||
- Override wins over default.
|
||||
- State table consistency (no missing states).
|
||||
- "I don't know my state" path should refuse to guess for IRAs
|
||||
and instead report the IRA-shielded portion as `unknown`,
|
||||
forcing the user to choose between "treat as shielded" /
|
||||
"treat as exposed" via override.
|
||||
- Sample Inherited IRAs flagged separately if we track that.
|
||||
|
||||
### Open questions
|
||||
|
||||
- Where should the state field live — `metadata.srf` (per
|
||||
portfolio file) or a global user config (one user, one state)?
|
||||
- Should we surface a "needed umbrella amount = net_exposure +
|
||||
[home equity, vehicles]" figure, or strictly stay liquid? The
|
||||
user asked about liquid assets specifically, so default to that
|
||||
but leave a note.
|
||||
- How to handle joint accounts where one spouse's lawsuit could
|
||||
reach the other's share. State-specific (community property
|
||||
vs separate property states). Probably out of scope for v1.
|
||||
- **Inherited-IRA flag**. Currently the user adds
|
||||
`shielded:bool:false` on inherited IRAs as the workaround. A
|
||||
dedicated flag would let the section call them out by name in
|
||||
the output. Cosmetic.
|
||||
|
||||
|
||||
|
||||
|
|
@ -1054,39 +806,6 @@ so they don't get lost; pick up opportunistically.
|
|||
`App`; on symbol change, stash the previous. Useful for
|
||||
eyeball-comparing performance/risk data between two symbols.
|
||||
|
||||
### Data quality
|
||||
|
||||
- **Fix `enrich` command for international funds.** `deriveMetadata`
|
||||
in `src/commands/enrich.zig` misclassifies international ETFs:
|
||||
1. `geo` uses Alpha Vantage's `Country` field, which is the *fund
|
||||
issuer's* domicile (USA for all US-listed ETFs), not the fund's
|
||||
investment geography. Every US-domiciled international fund gets
|
||||
`geo::US`.
|
||||
2. `asset_class` short-circuits to `"ETF"` when
|
||||
`asset_type == "ETF"`, or falls through to a US-market-cap
|
||||
heuristic that always produces `"US Large Cap"` /
|
||||
`"US Mid Cap"` / `"US Small Cap"`.
|
||||
|
||||
Known misclassified tickers (all came back as
|
||||
`geo::US, asset_class::US Large Cap`):
|
||||
- **FRDM** — Freedom 100 Emerging Markets ETF → should be
|
||||
`geo::Emerging Markets, asset_class::Emerging Markets`
|
||||
- **HFXI** — NYLI FTSE International Equity Currency Neutral ETF
|
||||
→ should be
|
||||
`geo::International Developed, asset_class::International Developed`
|
||||
- **IDMO** — Invesco S&P International Developed Momentum ETF →
|
||||
should be
|
||||
`geo::International Developed, asset_class::International Developed`
|
||||
- **IVLU** — iShares MSCI International Developed Value Factor
|
||||
ETF → should be
|
||||
`geo::International Developed, asset_class::International Developed`
|
||||
|
||||
The Alpha Vantage OVERVIEW endpoint doesn't provide fund geography
|
||||
data. Options: use the ETF_PROFILE holdings/country data to infer
|
||||
geography, parse the fund name for keywords ("International",
|
||||
"Emerging", "ex-US"), or accept that `enrich` is a scaffold and
|
||||
emit a `# TODO` comment for ETFs instead of silently misclassifying.
|
||||
|
||||
### Options / valuation
|
||||
|
||||
- **Per-account covered call adjustment.** `adjustForCoveredCalls` in
|
||||
|
|
@ -1103,16 +822,6 @@ so they don't get lost; pick up opportunistically.
|
|||
(<1000 lots). Pre-indexing options by underlying would help if
|
||||
someone had a very large options-heavy portfolio.
|
||||
|
||||
### Analysis / correctness
|
||||
|
||||
- **Analysis account/asset-class total mismatch.** The "By Account"
|
||||
and "By Tax Type" sections in the analysis command sum to slightly
|
||||
more than "Asset Class" (~0.6% error). Likely a discrepancy between
|
||||
how the lot-level account loop values cash, CDs, or options vs how
|
||||
the asset-class section computes them via `portfolio.totalCash()` /
|
||||
`totalCdFaceValue()`. Per-account values themselves are correct
|
||||
after the price_ratio fix.
|
||||
|
||||
### Audit
|
||||
|
||||
- **Audit large-lot threshold tuning.** `src/commands/audit.zig` uses
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue