# `accounts.srf` reference `accounts.srf` describes each account referenced by your portfolio: its tax treatment, the institution it lives at, and a few flags that tune analysis and reconciliation. It powers the **By Tax Type** and **By Account** breakdowns in [`zfin analysis`](../cli/analysis.md), the umbrella-exposure estimate, the audit staleness checks, and broker reconciliation. zfin loads `accounts.srf` from the same directory as the resolved portfolio file. It is optional -- without it, accounts show up as "Unknown" in the tax-type breakdown and everything else still works. ## File format One record per account. The `account` name must match the `account::` value used on your portfolio lots **exactly**. ```srf #!srfv1 account::Pat 401k,tax_type::traditional,institution::fidelity,account_number::P401 account::Joint taxable,tax_type::taxable,institution::schwab,account_number::JT01 ``` ## Fields | Field | Type | Required | Default | Description | |-----------------------------|--------|----------|-----------|----------------------------------------------------------------------------------------------------------------------------------| | `account` | string | Yes | -- | Account name; must match `account::` on lots exactly. | | `tax_type` | string | Yes | -- | `taxable`, `roth`, `traditional`, or `hsa`. | | `institution` | string | No | -- | Broker key, e.g. `fidelity`, `schwab`, `vanguard`, `wells_fargo`. Used by [`zfin audit`](../cli/audit.md) to match export files. | | `account_number` | string | No | -- | Account identifier used with `institution` for audit matching. Use a placeholder, not a full real number. | | `update_cadence` | string | No | `weekly` | How often you refresh this account's manual data: `weekly`, `monthly`, `quarterly`, or `none`. Drives the audit staleness nag. | | `cash_is_contribution` | bool | No | `false` | When `true`, raw cash-balance increases on this account count as real external contributions (see below). | | `direct_indexing` | bool | No | `false` | Marks an account whose lots track a benchmark with tracking-error drift (loosens contribution/audit tolerances). | | `shielded` | bool | No | (derived) | Umbrella-exposure override (see below). | | `audit_large_lot_threshold` | num | No | `10000` | Per-account dollar cutoff for the audit "Large new lots" nudge (see below). Must be positive. | | `harvested` | num | No | -- | Hand-declared cumulative tax-loss-harvested figure, for accounts whose realized P&L zfin cannot derive (see below). | | `harvested_date` | date | No | -- | `YYYY-MM-DD` you last refreshed `harvested`. Required for it to display. | | `tax_mix_taxable` | num | No | -- | Percent of this account that is really taxable money despite `tax_type` (see below). Must be > 0. | | `tax_mix_roth` | num | No | -- | Percent of this account that is really Roth money despite `tax_type` (see below). Must be > 0. | | `tax_mix_traditional` | num | No | -- | Percent of this account that is really pre-tax money despite `tax_type` (see below). Must be > 0. | | `tax_mix_hsa` | num | No | -- | Percent of this account that is really HSA money despite `tax_type` (see below). Must be > 0. | | `tax_mix_date` | date | No | -- | `YYYY-MM-DD` you last refreshed the `tax_mix_*` values. Advisory: the mix applies with or without it. | ## Tax types | Value | Display label | |---------------|-----------------------| | `taxable` | Taxable | | `roth` | Roth (Post-Tax) | | `traditional` | Traditional (Pre-Tax) | | `hsa` | HSA (Triple Tax-Free) | These four are the whole list -- an unrecognized value is a hard error, not a passthrough, so watch for typos and capitals. Accounts missing from `accounts.srf` entirely appear as "Unknown". ## Mixed tax treatment `tax_type` says an account is one thing. Some accounts are not. The case that forces this is an employer 401(k). A single Fidelity or Vanguard balance routinely lumps together pre-tax deferrals, the employer match, in-plan Roth, and after-tax/backdoor Roth conversions, and the plan's own site often will not break out the sources on the balance page at all. Calling the whole thing `traditional` overstates your future tax bill; calling it `roth` understates it. Neither answer helps you decide anything. So: declare the parts you know as **carve-out percentages**, and `tax_type` keeps the rest. ```srf #!srfv1 # 22.4% of this balance is Roth money; the other 77.6% is pre-tax. account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4,tax_mix_date::2026-08-01 ``` That splits the account across two rows in the **By Tax Type** breakdown of [`zfin analysis`](../cli/analysis.md) and the TUI's Analysis tab, weighted by value: ``` By Tax Type Traditional (Pre-Tax) ██████████████████▌ 62.1% $1,142,923.81 Taxable ██████ 20.0% $368,209.99 Roth (Post-Tax) █████▍ 17.9% $329,916.15 ``` You can carve out more than one type at a time -- an after-tax non-Roth sleeve is taxable-basis money, so `tax_type::traditional,tax_mix_roth:num:20,tax_mix_taxable:num:5` reads as 75% pre-tax, 20% Roth, 5% taxable. ### Rules - **Carve-outs are what `tax_type` is *not*.** The percentage left over belongs to `tax_type`, so the common one-Roth-sleeve case needs exactly one number. Declaring `tax_mix_traditional` on an account whose `tax_type` is already `traditional` is rejected -- the primary type's share is always the residual, and spelling it out invites a set that quietly sums to 90. - **They must sum to less than 100.** Exactly 100 would leave `tax_type` no share at all, which means `tax_type` is wrong rather than residual. Each value must also be greater than zero (to omit a type, omit the field). - **A rejected mix falls back to the bare `tax_type`,** i.e. to the behavior you'd get with no `tax_mix_*` fields at all. zfin warns on load and [`zfin doctor`](../cli/doctor.md) reports the specific cause, because a half-understood declaration silently reshaping your breakdown would be worse than ignoring it. - **Omitting all of them changes nothing.** An account with no `tax_mix_*` fields is 100% its `tax_type`, exactly as before. ### Why percentages and not dollars Because market appreciation applies proportionally across the sleeves. A percentage stays correct through every market move and only drifts as you contribute; a dollar figure would be stale by the next close. The assumption behind that: all the sleeves hold the same investments. For a core 401(k) allocation this is nearly always true. If your Roth sleeve holds materially different funds than your pre-tax sleeve, the percentage drifts faster than the model implies -- zfin has no way to know which lot sits in which sleeve, since the plan reports one balance. ### `tax_mix_date` and staleness Contributions shift the mix, so it needs an occasional refresh. `tax_mix_date` records when you last read the breakdown off the plan's site, and a flagless [`zfin audit`](../cli/audit.md) lists any account whose figure is over 90 days old under **Stale tax-mix figures**. Unlike [`harvested`](#harvested-and-harvested_date), the date is purely advisory: **a stale or undated mix keeps applying.** The mix feeds real breakdown totals, and silently moving your pre-tax vs post-tax picture because a date aged out would be far worse than showing a slightly stale split. So zfin nags and never suppresses. `doctor` does warn about a missing date, since an undated mix is one `audit` can never age. ## `audit_large_lot_threshold` When [`zfin audit`](../cli/audit.md) runs flagless, its **Large new lots - confirm source** section flags any newly-appeared lot worth at least this many dollars, nudging you to confirm whether it's a real external contribution or an unrecorded internal transfer. Smaller new lots pass silently so routine payroll/ESPP accruals and weekly deposits don't spam the report. The threshold is **per account**, because the noise it fights is account-specific: an ESPP or payroll account that accrues routine large lots wants a high bar, while a taxable brokerage where any sizeable new lot deserves a look wants the default (or lower). Set it on the account's own record: ```srf #!srfv1 account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000 account::Sample Brokerage,tax_type::taxable ``` Here the ESPP account stays quiet until a new lot tops $50k, while `Sample Brokerage` (no override) uses the built-in `$10,000` default. Accounts you don't list, or list without the field, use that default. The value must be **positive** -- zero or a negative number is rejected at load time and the account falls back to the default. ## `harvested` and `harvested_date` Some accounts hold a number that matters to you but that zfin has no way to compute. The motivating case is a **synthetic direct-indexing account**: a tax-loss-harvesting sleeve holding hundreds of individual positions that churn constantly. Rather than model all of them, you track the sleeve as a single aggregate lot with a `ticker::` alias: ```srf #!srfv1 symbol::DI-SPX,ticker::SPY,shares:num:1000,open_date::2024-01-15,open_price:num:400,account::Sample Tax Loss ``` That keeps the portfolio readable, but it means there are no closed lots, so zfin cannot derive realized gain/loss -- and the harvested total is the entire point of such an account. `harvested` is where you park the figure you read off the brokerage site: ```srf #!srfv1 account::Sample Tax Loss,tax_type::taxable,direct_indexing:bool:true,harvested:num:45300,harvested_date::2026-06-24 ``` It renders as a compact annotation next to the account name: ``` By Account Sample Tax Loss █████████████████████████████ 96.7% $738,930.00 (45k 6/24) ``` It appears in four places: - the **By Account** breakdown of [`zfin analysis`](../cli/analysis.md) - the same breakdown in the TUI's Analysis tab - the TUI's account picker (`a`), beside the account number - the TUI Portfolio tab header when an account filter is active ### Rules - **`harvested_date` is required for it to display.** A hand-copied figure with no date is indistinguishable from a stale one, so zfin refuses to show it. [`zfin doctor`](../cli/doctor.md) warns when `harvested` is set without a date, and when a date is in the future. - **`zfin audit` nags after 90 days.** A flagless [`zfin audit`](../cli/audit.md) lists any account whose figure is more than 90 days old under **Stale harvested figures**, so it's part of your normal maintenance sweep. The threshold isn't configurable, and doesn't need to be: only accounts that declare `harvested` are ever considered, so not wanting the nag and not wanting the field are the same choice. - **Entries older than 12 months stop displaying.** Harvest data that old isn't decision-useful. This is designed behavior, not an error -- `doctor` stays quiet about it. Refresh the figure and the date to bring it back. The audit nag keeps firing past 12 months, and says `no longer displayed` once the annotation has retired -- otherwise the annotation would simply vanish with nothing to explain why. - **The sign doesn't matter.** `harvested:num:45300` and `harvested:num:-45300` are equivalent; the annotation's parentheses already carry the accounting "this is a loss" convention. - **It is display-only.** It never enters a total, a weight, a breakdown value, or the contributions attribution. It is an annotation, not an input. The two thresholds together: fresh for 90 days, nagged from 90 to 365, nagged *and* hidden past 365. ### Why not put it in the account name? Because the account name is a **join key**, not a label. It is matched byte-exactly against `account::` on every lot, and it is written into every historical snapshot. Encoding a changing number in it means renaming the account every time the number changes, and every rename costs you history -- see [Renaming an account](../../guides/set-up-accounts.md#renaming-an-account). ## `update_cadence` and the audit nag [`zfin audit`](../cli/audit.md) (run flagless) flags accounts you haven't refreshed within their cadence window: `weekly` = 7 days, `monthly` = 30, `quarterly` = 90, `none` = never nag. The default is `weekly`, so every account reminds you until you silence it -- set `update_cadence::none` for accounts that update themselves (a live brokerage feed) or that you simply don't track closely. ## `cash_is_contribution` Most cash-balance movement is internal noise -- interest postings, dividend credits, CD coupons, settlement sweeps -- which would inflate the [`zfin contributions`](../cli/contributions.md) attribution total if counted as new money. So cash deltas are ignored by default. Set `cash_is_contribution:bool:true` only on accounts whose cash movement is dominated by external deposits (payroll ESPP accrual, direct 401k cash contributions). ## `shielded` (umbrella exposure) The umbrella-exposure estimate in [`zfin analysis`](../cli/analysis.md) splits your liquid net worth into "shielded" (retirement accounts, assumed judgment-protected) and "exposed" (taxable). The default proxy is "anything not `taxable` is shielded." Override it when that's wrong: - `shielded:bool:false` on a pre-tax account that is **not** ERISA-protected (deferred-comp plans, non-qualified annuities), or on IRAs in states with weak IRA protection. - `shielded:bool:true` to mark a taxable account as shielded (rare; e.g. some asset-protection trusts). IRA protection varies by state and is not modeled automatically; set this explicitly if it matters to you. Two interactions with [`tax_mix_*`](#mixed-tax-treatment): - With no `shielded` override, an account's shielded share is whatever isn't `taxable` -- so `tax_mix_taxable:num:10` on a pre-tax account exposes 10% of its value rather than none of it. A Roth/pre-tax mix changes nothing here, since both are shielded. - An explicit `shielded` override applies to the **whole** account and ignores the mix. It's a statement about the account's legal protection, which a split of its *tax* treatment has no business overriding. ## Example (from `examples/pre-retirement-both`) ```srf #!srfv1 account::Pat 401k,tax_type::traditional,institution::fidelity,account_number::P401,tax_mix_roth:num:18,tax_mix_date::2024-04-01 account::Pat Roth,tax_type::roth,institution::fidelity,account_number::PROTH account::Sam 401k,tax_type::traditional,institution::vanguard,account_number::S401 account::Sam Roth,tax_type::roth,institution::vanguard,account_number::SROTH account::Joint taxable,tax_type::taxable,institution::schwab,account_number::JT01 account::Family HSA,tax_type::hsa,institution::fidelity,account_number::HSA01 account::Kids 529,tax_type::taxable,institution::vanguard,account_number::C529 ``` ## See also - [Map your accounts](../../guides/set-up-accounts.md) -- the walkthrough. - [`zfin analysis`](../cli/analysis.md) -- tax-type and account breakdowns. - [`zfin audit`](../cli/audit.md) -- staleness checks and broker reconciliation. --- [Documentation home](../../README.md#reference)