zfin/docs/reference/config/accounts-srf.md
Emil Lerch 4f1ca3d34d
All checks were successful
Generic zig build / build (push) Successful in 5m19s
Generic zig build / publish-macos (push) Successful in 12s
Generic zig build / deploy (push) Successful in 22s
add ability to handle opaque 401(k)s with new tax_mix fields
2026-08-01 15:32:33 -07:00

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_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 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 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_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 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 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.

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: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_*:

  • 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)

#!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


Documentation home