16 KiB
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, 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.
#!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 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.
#!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 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_typeis not. The percentage left over belongs totax_type, so the common one-Roth-sleeve case needs exactly one number. Declaringtax_mix_traditionalon an account whosetax_typeis alreadytraditionalis 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_typeno share at all, which meanstax_typeis 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 notax_mix_*fields at all. zfin warns on load andzfin doctorreports 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% itstax_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 lists any account
whose figure is over 90 days old under Stale tax-mix figures.
Unlike harvested, 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 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:
#!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:
#!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:
#!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 - 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_dateis 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 doctorwarns whenharvestedis set without a date, and when a date is in the future. -
zfin auditnags after 90 days. A flaglesszfin auditlists 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 declareharvestedare 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 --
doctorstays 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 displayedonce the annotation has retired -- otherwise the annotation would simply vanish with nothing to explain why. -
The sign doesn't matter.
harvested:num:45300andharvested:num:-45300are 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.
update_cadence and the audit nag
zfin audit (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 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
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:falseon 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:trueto 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_*:
- With no
shieldedoverride, an account's shielded share is whatever isn'ttaxable-- sotax_mix_taxable:num:10on 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
shieldedoverride 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)
#!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 -- the walkthrough.
zfin analysis-- tax-type and account breakdowns.zfin audit-- staleness checks and broker reconciliation.