add docs/guides
All checks were successful
Generic zig build / build (push) Successful in 5m48s
Generic zig build / publish-macos (push) Successful in 11s
Generic zig build / deploy (push) Successful in 23s

This commit is contained in:
Emil Lerch 2026-06-22 14:53:53 -07:00
parent c832ecf1bf
commit 74fc219afd
Signed by: lobo
GPG key ID: A7B62D657EF764F8
59 changed files with 5154 additions and 837 deletions

876
README.md
View file

@ -51,15 +51,37 @@ zfin i -s AAPL # start with a symbol, no portfolio
Building from source requires Zig 0.16.0.
## Documentation
Full user documentation lives in [`docs/`](docs/README.md) and is built
around the runnable example portfolios in [`examples/`](examples/).
- **New here?** [Getting started](docs/getting-started.md) -- install,
configure, and run your first commands.
- **Learn the model:** [Core concepts](docs/explanation/concepts.md).
- **Do a task (the user manual):**
- [Build your portfolio](docs/guides/set-up-your-portfolio.md)
- [Classify your holdings](docs/guides/classify-holdings.md) and [map your accounts](docs/guides/set-up-accounts.md)
- [Read your portfolio](docs/guides/read-your-portfolio.md)
- [Plan for retirement](docs/guides/plan-retirement.md)
- [Snapshots and history](docs/guides/snapshots-and-history.md), [track contributions](docs/guides/track-contributions.md)
- [Audit against your brokerage](docs/guides/audit-against-brokerage.md)
- [Customize the TUI](docs/guides/customize-the-tui.md), [offline use and refreshing data](docs/guides/offline-and-refresh.md)
- **Look something up (reference):**
- [CLI commands](docs/reference/cli/index.md)
- Config files: [`portfolio.srf`](docs/reference/config/portfolio-srf.md), [`accounts.srf`](docs/reference/config/accounts-srf.md), [`metadata.srf`](docs/reference/config/metadata-srf.md), [`projections.srf`](docs/reference/config/projections-srf.md), and [more](docs/reference/config/environment.md)
- [The interactive TUI](docs/reference/tui.md)
- [Data providers and API keys](docs/reference/providers.md)
- **Understand the why (explanation):** [caching](docs/explanation/caching.md), [data providers](docs/explanation/data-providers.md), [returns](docs/explanation/returns-and-performance.md), [the projection model](docs/explanation/projections-model.md), [FAQ](docs/explanation/faq-troubleshooting.md).
## Data providers
zfin aggregates data from multiple free-tier APIs. Each provider is used for the data it does best, and aggressive caching keeps usage well within free-tier limits.
### Provider summary - primary providers
zfin aggregates data from multiple free-tier APIs, using each for what
it does best, with aggressive caching to stay within free-tier limits.
| Data type | Provider | Auth | Free-tier limit | Cache TTL |
|-----------------------|------------------|----------------------|----------------------------|--------------|
| Daily candles (OHLCV) | Tiingo | `TIINGO_API_KEY` | 1,000 req/day | 23h45m |
| Daily candles (OHLCV) | Tiingo | `TIINGO_API_KEY` | 1,000 req/day, 50 req/hour | 23h45m |
| Real-time quotes | Yahoo Finance | None required | Unofficial | Never cached |
| Quote fallback | TwelveData | `TWELVEDATA_API_KEY` | 8 req/min, 800/day | Never cached |
| Dividends | Polygon | `POLYGON_API_KEY` | 5 req/min | 14 days |
@ -69,832 +91,12 @@ zfin aggregates data from multiple free-tier APIs. Each provider is used for the
| ETF profiles | SEC EDGAR | `ZFIN_USER_EMAIL` | 10 req/sec | ~90 days |
| Classification | Wikidata + EDGAR | `ZFIN_USER_EMAIL` | No per-day quota | Long-lived |
### Tiingo
**Used for:** daily candles (primary provider for all symbols), supplementary dividend and split data.
- Endpoint: `https://api.tiingo.com/tiingo/daily/{symbol}/prices`
- Free tier: 1,000 requests per day, no per-minute restriction.
- Covers stocks, ETFs, and mutual funds. Mutual fund NAVs are available after midnight ET.
- Candles are fetched with a fixed 2000-01-01 start date so the cache supports `--as-of` projections back to the earliest imported portfolio data (typically 2014) with full 10Y trailing-return windows.
- The same response carries per-row `divCash` and `splitFactor`. We extract these as a free side benefit and merge them into the dividend/split caches alongside Polygon's primary view -- this rescues entries Polygon's reference endpoints miss (e.g. SPYM's 2017-10-16 4:1 split).
### TwelveData
**Used for:** real-time quote fallback (after Yahoo).
- Endpoint: `https://api.twelvedata.com/quote`
- Free tier: 8 API credits per minute, 800 per day.
- TwelveData was previously used for candles but is no longer in the candle pipeline -- its `adj_close` values were unreliable for split-adjustment math. Yahoo is the candle fallback now.
### Polygon
**Used for:** dividend and stock split data, both historical and forward-looking.
- Endpoints: `https://api.polygon.io/v3/reference/dividends` and `/v3/reference/splits`
- Free tier: 5 requests per minute, unlimited daily. Full historical data.
- Dividend endpoint uses cursor-based pagination (automatically followed).
- Provides dividend type classification (regular, special, supplemental) and richer metadata than Tiingo (`pay_date`, `record_date`, `currency`).
- Polygon is **primary** for dividends and splits because it carries forward-looking declared events (e.g. ARCC's next ex-dividend date several months out) that Tiingo's price-series response cannot provide. Tiingo merges in supplementary entries for historical events Polygon's reference endpoints occasionally miss.
### CBOE
**Used for:** options chains.
- Endpoint: `https://cdn.cboe.com/api/global/delayed_quotes/options/{SYMBOL}.json`
- No API key required. Data is 15-minute delayed during market hours.
- Returns all expirations with full chains including greeks (delta, gamma, theta, vega), bid/ask, volume, open interest, and implied volatility.
- OCC option symbols are parsed to extract expiration, strike, and contract type.
### FMP (Financial Modeling Prep)
**Used for:** earnings history (historical actuals + analyst consensus estimates + upcoming).
- Endpoint: `https://financialmodelingprep.com/stable/earnings?symbol={SYMBOL}`
- Free tier: 250 requests per day. With the 30-day cache TTL, a 50-symbol portfolio averages ~2 requests/day.
- History depth: full — often back to the 1980s for long-listed tickers.
- Coverage: US stocks with real earnings. ETFs, mutual funds, CUSIPs, and some dual-class shares (BRK.B, GOOG) return 402 on the free tier and show up as "no earnings data" in the UI — a documented limitation, not a bug.
### SEC EDGAR + Wikidata
**Used for:** ETF profiles (NPORT-P holdings, sector weights, AUM, inception
dates) and the `enrich` flow that bootstraps `metadata.srf` (sector / industry
/ country / asset-class classification).
- Endpoints:
- `https://data.sec.gov/...` -- XBRL company facts, NPORT-P primary
documents, mutual-fund ticker map.
- `https://www.wikidata.org/sparql` -- sector / industry / country
statements.
- Free, but the SEC requires a contact email in the User-Agent header. zfin
reads this from `ZFIN_USER_EMAIL` (`.env` or environment). Without it, ETF
profiles and `enrich` are unavailable; everything else still works.
- The SEC caps requests at 10/sec; the rate limiter respects this. Wikidata
has no per-day quota.
- The `enrich` command queries Wikidata first (rich classification metadata);
when Wikidata has no entry for a symbol (common for managed funds, UITs),
EDGAR's mutual-fund ticker map is the fallback. Symbols that miss both fall
through as TODO entries the user fills in by hand.
## API keys
Set keys as environment variables or in a `.env` file (searched in the executable's parent directory, then cwd):
```bash
TIINGO_API_KEY=your_key # Required for candles (primary provider)
TWELVEDATA_API_KEY=your_key # Quote fallback (after Yahoo)
POLYGON_API_KEY=your_key # Required for dividends/splits (total returns)
FMP_API_KEY=your_key # Required for earnings data
ZFIN_USER_EMAIL=you@example.com # Required for ETF profiles + `enrich`
# (SEC EDGAR mandates a User-Agent contact)
```
The cache directory defaults to `~/.cache/zfin` and can be overridden with `ZFIN_CACHE_DIR`.
Not all keys are required. Without a key, the corresponding data simply won't be available:
| Key | Without it |
|----------------------|-------------------------------------------------------------------------------------------|
| `TIINGO_API_KEY` | Candles fall back to Yahoo only; some symbols (especially mutual funds) won't work |
| `TWELVEDATA_API_KEY` | No quote fallback after Yahoo |
| `POLYGON_API_KEY` | No forward-looking dividends; trailing total returns may use only Tiingo's view |
| `FMP_API_KEY` | No earnings data (tab disabled) |
| `ZFIN_USER_EMAIL` | No ETF profiles; `enrich` cannot bootstrap metadata. (Used as the EDGAR User-Agent value) |
CBOE options require no API key.
## Caching strategy
Every data fetch follows the same pattern:
1. Check local cache (`~/.cache/zfin/{SYMBOL}/{data_type}.srf`)
2. If cached file exists and is within TTL -- deserialize and return (no network)
3. Otherwise -- fetch from provider -- serialize to cache -- return
Cache files use [SRF](https://github.com/lobo/srf) (Simple Record Format), a line-oriented key-value format. Freshness is determined by file modification time vs. the TTL for that data type.
| Data type | TTL | Rationale |
|---------------|--------------|-----------------------------------------------------|
| Daily candles | 23h45m | Slightly under 24h for cron jitter tolerance |
| Dividends | 14 days | Declared well in advance |
| Splits | 14 days | Rare corporate events |
| Options | 1 hour | Prices change continuously during market hours |
| Earnings | 30 days* | Quarterly events; smart refresh after announcements |
| ETF profiles | 30 days | Holdings/weights change slowly |
| Quotes | Never cached | Intended for live price checks |
**Earnings smart refresh:** Even within the 30-day TTL, cached earnings are automatically re-fetched when an earnings date has passed but the cache still has no actual results for it. This ensures results appear promptly after an announcement without wasteful daily polling.
Manual refresh (`r` / `F5` in TUI) invalidates the cache for the current tab's data before re-fetching.
### Rate limiting
Each provider has a client-side token-bucket rate limiter that prevents exceeding free-tier limits:
| Provider | Rate limit |
|---------------|---------------------|
| Tiingo | 1,000/day |
| TwelveData | 8/minute |
| Polygon | 5/minute |
| FMP | 250/day |
| CBOE | 30/minute |
| SEC EDGAR | 10/second |
| Wikidata | (no enforced limit) |
The limiter blocks until a token is available, spreading bursts of requests automatically rather than failing with 429 errors.
## CLI commands
```
zfin <command> [args]
Commands:
perf <SYMBOL> Trailing returns (1yr/3yr/5yr/10yr, price + total)
quote <SYMBOL> Real-time quote
history <SYMBOL> Last 30 days price history
divs <SYMBOL> Dividend history with TTM yield
splits <SYMBOL> Split history
options <SYMBOL> Options chains (all expirations)
earnings <SYMBOL> Earnings history and upcoming events
etf <SYMBOL> ETF profile (expense ratio, holdings, sectors)
portfolio <FILE> Portfolio analysis from .srf file
snapshot [opts] Write a daily portfolio snapshot to history/
compare <DATE> [<DATE>]
Compare portfolio state across two dates
cache stats Show cached symbols
cache clear Delete all cached data
interactive, i Launch interactive TUI
help Show usage
```
### compare
Compare the portfolio at two points in time. Useful for answering
"how am I doing since X" without the noise of the full portfolio
display.
```
zfin compare <DATE> Compare snapshot at DATE vs current live portfolio
zfin compare <DATE1> <DATE2> Compare two historical snapshots
```
Arguments can be given in any order — the command always displays
older → newer. Dates are `YYYY-MM-DD`. Snapshots come from
`history/YYYY-MM-DD-portfolio.srf` files produced by
`zfin snapshot` (typically run via cron).
**Output:**
- **Liquid:** raw value change — includes any contributions or
withdrawals made between the two dates (adjusting for flows is
out of scope).
- **Per-symbol price change:** for symbols held on *both* dates.
Sorted by % change descending (biggest winners first). The dollar
column uses the shares-held-throughout floor (`min(shares_then,
shares_now)`) so newly-added shares don't inflate it and sold
shares don't deflate it.
- **Hidden count:** positions added or removed between the two dates
are counted but not rendered.
On a missing snapshot date, the command prints the nearest earlier
and later available dates to stderr and exits 1 — no silent
snapping.
Example output shape (values illustrative):
```
$ zfin compare 2024-01-15 2024-03-15
Portfolio comparison: 2024-01-15 → 2024-03-15 (60 days)
Liquid: $100,000.00 → $105,000.00 +$5,000.00 +5.00%
Per-symbol price change (5 held throughout)
FOO $40.00 → $44.00 +10.00% +$400.00
BAR $100.00 → $105.00 +5.00% +$250.00
...
BAZ $50.00 → $48.00 -4.00% -$80.00
(1 added, 1 removed since 2024-01-15 — hidden)
```
### Chart export (`--export-chart <PATH>`)
The `quote` and `projections` commands support `--export-chart <PATH>` to render their charts as PNG files (1920x1080) instead of emitting text. Useful for write-ups, sharing, or capturing a back-dated projection without screenshot-and-crop.
```
zfin quote AAPL --export-chart aapl.png
zfin projections --as-of 1Y --overlay-actuals --export-chart proj.png
```
The exported image uses the TUI's default theme. When `--export-chart` is set, no other text output is emitted — the command exits after writing the file. Only the default `projections` mode is supported; `--convergence`, `--return-backtest`, and `--vs` reject the flag (their charts still need PNG plumbing). The `history` command's portfolio-value chart is also not yet exportable — it uses a single-series braille format that doesn't share the z2d pipeline used by `quote` and `projections`.
### Interactive TUI flags
```
zfin i [options]
-p, --portfolio <FILE> Portfolio file (.srf format)
-w, --watchlist <FILE> Watchlist file (default: watchlist.srf if present)
-s, --symbol <SYMBOL> Start with a specific symbol
--default-keys Print default keybindings config to stdout
--default-theme Print default theme config to stdout
```
If no portfolio or symbol is specified and `portfolio.srf` exists in the current directory, it is loaded automatically.
## Interactive TUI
The TUI has eight tabs: Portfolio, Analysis, Projections, History, Quote, Performance, Earnings, and Options.
### Tabs
**Portfolio** -- navigable list of positions with market value, gain/loss, weight, and purchase date. Multi-lot positions can be expanded to show individual lots with per-lot gain/loss, capital gains indicator (ST/LT), and account name. Press `a` to open an account-filter picker (with `/` search).
**Analysis** -- portfolio breakdown by asset class, sector, geographic region, account, and tax type. Uses classification data from `metadata.srf` and account tax types from `accounts.srf`. Displays horizontal bar charts with sub-character precision using Unicode block elements.
**Projections** -- Monte Carlo retirement projection with percentile bands. Press `d` to set an as-of date (back-date the projection to a historical snapshot), `o` to overlay realized actuals from snapshots / `imported_values.srf` on top of the bands, `z` to toggle auto-zoom on the overlay (chart x-axis defaults to roughly `[as_of, today + actuals_span]` so a short actuals line isn't squashed into the start of a 50-year horizon), `v` to toggle the chart vs the text-only report, and `e` to toggle simulated lifecycle events (RMDs, lump-sum withdrawals). Esc clears an active as-of override.
**History** -- portfolio value over time, sourced from snapshot files in `<portfolio-dir>/history/` plus optional `imported_values.srf`. Cycle the metric column with `m` (liquid / total / contributions / etc.) and the time-bucket resolution with `t` (week / month / quarter / year). Press `s` (or space) to mark a row for compare; mark a second row, then `c` to commit a side-by-side compare against the live portfolio. Esc cancels an in-flight compare.
**Quote** -- current price, OHLCV, daily change, and a 60-day ASCII chart with recent history table.
**Performance** -- trailing returns using two methodologies (as-of-date and month-end), matching Morningstar's "Trailing Returns" and "Performance" pages respectively. Shows price-only and total return (with dividend reinvestment) using whichever dividend data is available -- Polygon (richer metadata, forward-looking entries) and Tiingo (extracted from candle responses, historical only) are merged. Also shows risk metrics (volatility, Sharpe ratio, max drawdown).
**Earnings** -- historical and upcoming earnings events with EPS estimate/actual, surprise amount and percentage. Future events are dimmed. Tab is disabled for ETFs.
**Options** -- all expirations in a navigable list. Expand any expiration to see calls and puts inline. Calls and puts sections are independently collapsible. Near-the-money filter limits strikes shown (default +/- 8, adjustable with Ctrl+1-9). ITM strikes are marked with `|`. Monthly expirations display in normal color, weeklies are dimmed.
### Keybindings
All keybindings are configurable via `~/.config/zfin/keys.srf`. Generate the default config:
```bash
zfin i --default-keys > ~/.config/zfin/keys.srf
```
The generated file has two parts: a global section (keys that work in every tab) and a per-tab section (keys that only fire when that tab is active). Tab-local bindings cannot override globally-bound keys — zfin refuses to start if your config creates that conflict.
Default global keybindings:
| Key | Action |
|------------------------|------------------------------------------------------|
| `q`, `Ctrl+c` | Quit |
| `r`, `F5` | Refresh current tab (invalidates cache) |
| `R` | Reload portfolio from disk (no network) |
| `h`, Left, Shift+Tab | Previous tab |
| `l`, Right, Tab | Next tab |
| `1`-`8` | Jump to tab N |
| `j`, Down | Select next row |
| `k`, Up | Select previous row |
| `g` | Scroll to top |
| `G` | Scroll to bottom |
| `Ctrl+d` | Half-page down |
| `Ctrl+u` | Half-page up |
| `PageDown`, `Ctrl+f` | Page down |
| `PageUp`, `Ctrl+b` | Page up |
| `/` | Symbol input prompt |
| `?` | Help screen |
Default tab-local keybindings (only active on the matching tab):
| Tab | Key | Action |
|-------------|--------------------|----------------------------------------------------------|
| Portfolio | `Enter` | Expand/collapse position |
| Portfolio | `>` / `<` | Sort by next / previous column |
| Portfolio | `o` | Reverse sort direction |
| Portfolio | `a` | Open account-filter picker (`/` to search inside picker) |
| Portfolio | `Esc` | Clear active account filter |
| Portfolio | `s`, `Space` | Select symbol (sets active symbol for other tabs) |
| Quote | `[` / `]` | Previous / next chart timeframe |
| Options | `Enter` | Expand/collapse expiration or section |
| Options | `c` / `p` | Toggle all calls / puts collapsed |
| Options | `Ctrl+1`-`Ctrl+9` | Set near-the-money filter to +/- N strikes |
| History | `Enter` | Expand/collapse tier |
| History | `m` | Cycle metric column |
| History | `t` | Cycle time-bucket resolution |
| History | `s`, `Space` | Mark / unmark row for compare |
| History | `c` | Commit compare (after two rows marked) |
| History | `Esc` | Cancel in-flight compare selection |
| Projections | `d` | Set as-of date prompt |
| Projections | `Esc` | Clear as-of date |
| Projections | `o` | Toggle realized-actuals overlay |
| Projections | `z` | Toggle overlay auto-zoom (clamp x-axis to overlay span) |
| Projections | `v` | Toggle chart vs text-only report |
| Projections | `e` | Toggle simulated lifecycle events |
Mouse: scroll wheel navigates, left-click selects rows and switches tabs.
### Theme
The TUI uses a dark theme inspired by Monokai/opencode. Customize via `~/.config/zfin/theme.srf`:
```bash
zfin i --default-theme > ~/.config/zfin/theme.srf
```
Colors are specified as `#rrggbb` hex values. The theme uses RGB colors (not terminal color indices) to work correctly with transparent terminal backgrounds.
## Portfolio format
Portfolios are [SRF](https://github.com/lobo/srf) files with one lot per line. Each lot is a comma-separated list of `key::value` pairs (numbers use `key:num:value`).
```
#!srfv1
# Stocks/ETFs
symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,account::Brokerage
symbol::AAPL,shares:num:50,open_date::2024-03-01,open_price:num:170.00,account::Roth IRA
symbol::AAPL,shares:num:25,open_date::2023-06-15,open_price:num:155.00,account::Roth IRA
# Closed lot (sold)
symbol::AMZN,shares:num:10,open_date::2022-03-15,open_price:num:150.25,close_date::2024-01-15,close_price:num:185.50
# DRIP lots (summarized as ST/LT groups in the UI)
symbol::VTI,shares:num:0.234,open_date::2024-06-15,open_price:num:267.50,drip::true,account::Brokerage
# CUSIP with ticker alias (401k CIT share class)
symbol::02315N600,shares:num:1200,open_date::2022-01-01,open_price:num:140.00,ticker::VTTHX,account::Fidelity 401k,note::VANGUARD TARGET 2035
# Manual price override (for securities without API coverage)
symbol::NON40OR52,shares:num:500,open_date::2023-01-01,open_price:num:155.00,price:num:163.636,price_date::2026-02-27,account::Fidelity 401k,note::CIT SHARE CLASS
# Options
security_type::option,symbol::AAPL 250321C00200000,shares:num:-2,open_date::2025-01-15,open_price:num:12.50,account::Brokerage
# CDs
security_type::cd,symbol::912797KR0,shares:num:10000,open_date::2024-06-01,open_price:num:10000,maturity_date::2025-06-01,rate:num:5.25,account::Brokerage,note::6-Month T-Bill
# Cash
security_type::cash,shares:num:15000,account::Brokerage
security_type::cash,shares:num:5200.50,account::Roth IRA,note::Money market settlement
# Illiquid assets
security_type::illiquid,symbol::HOME,shares:num:450000,open_date::2020-06-01,open_price:num:350000,note::Primary residence (Zillow est.)
# Watchlist (track price only, no position)
security_type::watch,symbol::NVDA
security_type::watch,symbol::TSLA
```
### Lot fields
| Field | Type | Required | Description |
|-----------------|--------|----------|--------------------------------------------------------------------------|
| `symbol` | string | Yes* | Ticker symbol or CUSIP. *Optional for `cash` lots. |
| `shares` | number | Yes | Number of shares (or face value for cash/CDs) |
| `open_date` | string | Yes** | Purchase date (YYYY-MM-DD). **Not required for cash/watch. |
| `open_price` | number | Yes** | Purchase price per share. **Not required for cash/watch. |
| `close_date` | string | No | Sale date (null = open lot) |
| `close_price` | number | No | Sale price per share |
| `security_type` | string | No | `stock` (default), `option`, `cd`, `cash`, `illiquid`, `watch` |
| `account` | string | No | Account name (e.g. "Roth IRA", "Brokerage") |
| `note` | string | No | Descriptive note (shown in cash/CD/illiquid tables) |
| `ticker` | string | No | Ticker alias for price fetching (overrides `symbol` for API calls) |
| `price` | number | No | Manual price override (fallback when API has no coverage) |
| `price_date` | string | No | Date of the manual price (YYYY-MM-DD, for staleness display) |
| `drip` | string | No | `true` if lot is from dividend reinvestment (summarized as ST/LT groups) |
| `maturity_date` | string | No | CD maturity date (YYYY-MM-DD) |
| `rate` | number | No | Interest rate for CDs (e.g. 5.25 = 5.25%) |
### Security types
- **stock** (default) -- Stocks, ETFs, and mutual funds. Prices are fetched from Tiingo (primary) or Yahoo (candle fallback). Positions are aggregated by symbol and shown with gain/loss.
- **option** -- Option contracts. Shown in a separate "Options" section. Shares can be negative for short positions.
- **cd** -- Certificates of deposit. Shown sorted by maturity date with rate and face value.
- **cash** -- Cash, money market, and settlement balances. Shown grouped by account with optional notes.
- **illiquid** -- Illiquid assets (real estate, vehicles, etc.). Shown in a separate section. Not included in the liquid portfolio total; contributes to Net Worth.
- **watch** -- Watchlist items. No position, just tracks the price. Shown at the bottom of the portfolio tab.
### Price resolution
For stock lots, prices are resolved in this order:
1. **Live API** -- Latest close from cached candles (Tiingo, with Yahoo as candle fallback)
2. **Manual price** -- `price::` field on the lot (for securities without API coverage, e.g. 401k CIT share classes)
3. **Average cost** -- Falls back to the position's `open_price` as a last resort
Manual-priced rows are shown in warning color (yellow) so you know the price may be stale. The `price_date::` field helps you track when the price was last updated.
### Ticker aliases
Some securities (like 401k CIT share classes) use CUSIPs as identifiers but have a retail equivalent ticker for price fetching. Use `ticker::` to specify the API ticker:
```
symbol::02315N600,ticker::VTTHX,...
```
The `symbol::` is used as the display identifier and for classification lookups. The `ticker::` is used for API price fetching. If the CUSIP and retail ticker have different NAVs (common for CIT vs retail fund), use `price::` instead.
### CUSIP lookup
Use the `lookup` command to resolve CUSIPs to tickers via OpenFIGI:
```bash
zfin lookup 459200101 # -> IWM (iShares Russell 2000 ETF)
```
### DRIP lots
Lots marked with `drip::true` are summarized as ST (short-term) and LT (long-term) groups in the position detail view, rather than listing every small reinvestment lot individually. The grouping is based on the 1-year capital gains threshold.
### Watchlist
Watchlist symbols can be defined as `security_type::watch` lots in the portfolio file, or in a separate watchlist file (`-w` flag). They appear at the bottom of the portfolio tab showing the cached price.
## Classification metadata (metadata.srf)
The `metadata.srf` file provides classification data for portfolio analysis. It maps symbols to asset class, sector, and geographic region. Place it in the same directory as the portfolio file.
```
#!srfv1
# Individual stock: single classification at 100%
symbol::AMZN,sector::Technology,geo::US,asset_class::US Large Cap
# ETF: inherits sector from holdings, but classified by asset class
symbol::VTI,asset_class::US Large Cap,geo::US
# International ETF
symbol::VXUS,asset_class::International Developed,geo::International Developed
# Target date fund: blended allocation (percentages should sum to ~100)
symbol::02315N600,asset_class::US Large Cap,pct:num:55
symbol::02315N600,asset_class::International Developed,pct:num:20
symbol::02315N600,asset_class::Bonds,pct:num:15
symbol::02315N600,asset_class::Emerging Markets,pct:num:10
# BDC / REIT / specialty
symbol::ARCC,sector::Financials,geo::US,asset_class::US Large Cap
```
### Classification fields
| Field | Type | Required | Description |
|---------------|--------|----------|---------------------------------------------------------------------------|
| `symbol` | string | Yes | Ticker symbol or CUSIP (must match `symbol::` or `ticker::` in portfolio) |
| `asset_class` | string | No | e.g. "US Large Cap", "Bonds", "Cash & CDs", "Emerging Markets" |
| `sector` | string | No | e.g. "Technology", "Healthcare", "Financials" |
| `geo` | string | No | e.g. "US", "International Developed", "Emerging Markets" |
| `pct` | number | No | Percentage weight for this entry (default 100). Use for blended funds. |
For single-asset-class securities (individual stocks, single-focus ETFs), one line at the default 100% is sufficient. For multi-asset-class funds (target date, balanced), add multiple lines for the same symbol with `pct:num:` values that sum to approximately 100.
Cash and CD lots are automatically classified as "Cash & CDs" without needing metadata entries.
### Bootstrapping metadata
Use the `enrich` command to generate a starting `metadata.srf` from Wikidata
+ SEC EDGAR data. Requires `ZFIN_USER_EMAIL` set so EDGAR will accept the
request.
```bash
# Enrich an entire portfolio (generates full metadata.srf)
zfin enrich portfolio.srf > metadata.srf
# Enrich a single symbol and append to existing metadata.srf
zfin enrich SCHD >> metadata.srf
```
When given a file path, it fetches all stock symbols and outputs a complete
SRF file with headers. When given a symbol, it outputs just the
classification lines (no header), so you can append directly with `>>`.
Wikidata is queried first; symbols not found in Wikidata fall through to
EDGAR's mutual-fund ticker map. Anything that misses both shows up as a
TODO entry to fill in by hand.
## Account metadata (accounts.srf)
The `accounts.srf` file maps account names to tax types for the tax type breakdown in portfolio analysis. Place it in the same directory as the portfolio file.
```
#!srfv1
account::Brokerage,tax_type::taxable
account::Roth IRA,tax_type::roth
account::Traditional IRA,tax_type::traditional
account::Fidelity 401k,tax_type::traditional
account::HSA,tax_type::hsa
```
### Account fields
| Field | Type | Required | Description |
|------------|--------|----------|-----------------------------------------------------------------|
| `account` | string | Yes | Account name (must match `account::` in portfolio lots exactly) |
| `tax_type` | string | Yes | Tax classification (see below) |
### Tax types
| Value | Display label |
|---------------|-----------------------|
| `taxable` | Taxable |
| `roth` | Roth (Post-Tax) |
| `traditional` | Traditional (Pre-Tax) |
| `hsa` | HSA (Triple Tax-Free) |
| (other) | Shown as-is |
Accounts not listed in `accounts.srf` appear as "Unknown" in the tax type breakdown.
## Projections configuration (projections.srf)
The `projections` command runs Monte-Carlo-style historical
simulations of your retirement portfolio over the Shiller dataset
(1871present). Configuration lives in `projections.srf` next to
`portfolio.srf`. The file is optional — without it, the command
runs with sensible defaults (20/30/45-year horizons, 90/95/99%
confidence levels, no accumulation phase).
```srf
#!srfv1
# Asset allocation target (drives sim stock/bond blend)
type::config,target_stock_pct:num:80
# Distribution-phase horizons (years to project past retirement)
type::config,horizon:num:25
type::config,horizon:num:35
# Or: horizon-by-age, resolves to "years until oldest hits this age"
type::config,horizon_age:num:95
# Birthdates (drive horizon_age + life-event timing + retirement_age)
type::birthdate,date::1981-04-12
type::birthdate,date::1983-09-08,person:num:2
# Life events (positive = income, negative = expense). See below.
type::event,name::Social Security (Pat),start_age:num:70,person:num:1,amount:num:38400
type::event,name::College Tuition,start_age:num:50,person:num:1,duration:num:4,amount:num:-55000
```
### How the simulation runs
The simulation always operates in two phases:
- **Accumulation phase** — contributions added each year, no
spending. Length is determined by your retirement-date input
(see below). When you have no input configured, this phase has
zero years (already-retired view).
- **Distribution phase** — annual spending withdrawn (CPI-adjusted
by default), no contributions. Length is the configured
`horizon`.
Life events apply to both phases.
### Two ways to ask the question
`projections.srf` accepts two retirement-planning inputs that can
be set independently or together. Each shapes a different display
block:
#### Target retirement date
Set either `retirement_age` (resolved against the oldest
birthdate) or `retirement_at` (an absolute calendar date) to
anchor a retirement boundary. The display answers
"given my retirement date, what can I spend?"
```srf
type::config,retirement_age:num:65 # oldest person hits 65
# or
type::config,retirement_at::2046-04-12
type::config,annual_contribution:num:80000
type::config,contribution_inflation_adjusted:bool:true
```
When both are set, `retirement_at` wins. Output renders the
**Accumulation phase** block: median portfolio at retirement,
p10p90 range, and the dated headline "Years until possible
retirement: N (DATE, ages A/B)" line.
#### Target spending
Set `target_spending` to anchor a desired retirement income. The
display answers "given my desired spending, when can I retire?"
```srf
type::config,target_spending:num:80000
type::config,target_spending_inflation_adjusted:bool:true
```
Output renders an **Earliest retirement** grid (one cell per
horizon × confidence) showing the earliest year that supports the
target spending at that confidence over that distribution
horizon. One cell from the grid is **promoted** into the
Accumulation phase block as the headline retirement line.
The default promotion rule walks horizons longest → shortest at
99% confidence (most conservative), preferring the longest
horizon whose end year keeps the oldest configured person under
age 100. Override the default with a per-horizon annotation:
```srf
type::config,horizon:num:35,retirement_target:num:95
```
This forces "use the 35yr × 95% cell as the headline." Allowed
values are `90`, `95`, `99`. At most one horizon may carry the
annotation; configuring more than one drops them all and falls
back to the default rule.
When the promoted cell is infeasible (no value of
`accumulation_years` ≤ 50 sustains the target spending), the
headline renders "Years until possible retirement: not feasible"
and the contribution / median lines are suppressed. Cells in the
grid that hit the same wall render "infeasible" in red.
#### Both inputs configured
When both a target retirement date and a target spending are
configured, both display blocks render back-to-back. The
configured retirement date wins for the headline; the Earliest
retirement grid is rendered below for comparison ("you set 2046;
at 95% confidence over 30 years you could retire as early as
YYYY").
#### Neither configured
Distribution-only mode — appropriate for already-retired users.
The Accumulation phase block reduces to a single soft
"Years until possible retirement: none" line; everything else
behaves like the legacy projection display.
### Realized actuals overlay (`--overlay-actuals`)
Run `zfin projections --as-of <DATE> --overlay-actuals` to plot the
**realized portfolio trajectory** from `<DATE>` up to today on top of
the projected percentile bands. Answers the question "how accurate
were my past projections compared to reality?"
The TUI is the high-fidelity surface — open the projections tab,
press `d` to set the as-of date, then press `o` to toggle the
overlay. The CLI prints a tip pointing at the TUI; the braille chart
itself doesn't render the overlay (the resolution doesn't do justice
to a 12+ year actuals trajectory laid against percentile bands).
**The overlay reads from two sources, snapshot-precedence:**
1. **Native snapshots** in `<portfolio-dir>/history/*-portfolio.srf`
— produced by `zfin snapshot`. Highest fidelity (full lot-level
state, exact totals).
2. **Imported values** in `<portfolio-dir>/history/imported_values.srf`
— a hand-maintained back-history file with one `liquid::` total
per date. Useful for backfilling a historical record from
spreadsheet data, statements, etc. Snapshots win on overlapping
dates.
**As-of resolution against either source.** `--as-of <DATE>`
resolves first against the snapshot directory (nearest-earlier
`*-portfolio.srf`); if no snapshot exists at or before the date,
it falls back to `imported_values.srf`. This means you can run
`zfin projections --as-of 2018-06-01` even with zero snapshot
files, as long as the imported back-history covers that date.
When the resolution lands on an imported value, the projection
bands are computed using **today's allocations scaled to the
imported liquid total** — we can't reconstruct the historical lot
composition from just a `liquid::` row, so today's stock/bond
split is substituted as the best-available approximation. The
header line reads `Projections (as of YYYY-MM-DD, imported value)`
and a muted note flags the scaling.
**How to read the chart:**
- **Actuals line stays inside the bands** → the model was
directionally honest.
- **Actuals line punches through the top band** → the model was
conservative (good problem to have).
- **Actuals line punches through the bottom band** → the model was
optimistic (bands need wider envelope, or a bear scenario was
underweighted).
- **A thin vertical "today" line** marks where the actuals end and
the projected future begins.
**Critical caveat (must be loud, by design):**
> This overlay shows whether the model was **directionally honest**,
> not whether the SWR claim was **accurate**. The SWR claim is a
> 30-year claim. We have at most ~12 years of weekly history
> (post-import) and 1+ years of native snapshots. The overlay tells
> you "did my actual trajectory fall within the bands the model would
> have drawn." It does **not** tell you "did the safe withdrawal rate
> hold up over a full retirement horizon." We will not have data to
> answer that within either of our lifetimes.
The TUI surfaces this caveat on a status line whenever the overlay is
active.
### Forecast-vs-actual evaluation (`--convergence`, `--return-backtest`)
Two evaluation views over the spreadsheet's historical forecasts in
`imported_values.srf`. They complement `--overlay-actuals`:
- **Overlay** answers "did the actual trajectory fall inside the
model's bands?"
- **Convergence** answers "did the model converge on a retirement
date as data accumulated?"
- **Return back-test** answers "was the model's expected-return
assumption honest, in hindsight?"
```bash
zfin projections --convergence
zfin projections --return-backtest [--real]
```
`--convergence` reads each historical row's `projected_retirement`
field — the date the spreadsheet predicted you could retire — and
emits a table of `(observation_date, projected_date, years-until)`.
A flat downward slope of ~1y/year means the model was honestly
counting down. A flat-line at constant N means each year passes with
no progress (the prediction always says "N years away"). A `reached`
sentinel marks rows where the model said "you're already
retirement-ready."
`--return-backtest` reads each row's `expected_return` claim and
compares it to the realized 1y/3y/5y forward CAGR of the `liquid`
series. Useful for gut-checking whether the spreadsheet's
`min(1y,3y,5y,10y)`-weighted formula systematically over- or
under-predicted. Pair with `--real` to compare against
inflation-deflated realized returns (the expected column stays
nominal — it's a return rate the source spreadsheet captured as
nominal).
The CLI emits sampled tables (every Nth observation/anchor) for
quick scanning. The TUI projections tab renders the same data as
high-fidelity Kitty-graphics line charts; press `c` (convergence)
or `r` (return back-test) on the projections tab to switch views.
Both views are also subject to the "directional honesty, not SWR
validity" caveat: they evaluate the model's inputs and outputs over
time, not whether the underlying SWR claim will hold up over a
30-year retirement.
### Life events
Life events modify the simulation's annual cash flow. Positive
amounts are income (offset withdrawals); negative amounts are
expenses (added to withdrawals). Events are CPI-adjusted by
default; set `inflation_adjusted:bool:false` for nominal events.
```srf
# Permanent income starting at age 70
type::event,name::Social Security,start_age:num:70,amount:num:38400
# 4-year expense starting at age 50
type::event,name::College Tuition,start_age:num:50,duration:num:4,amount:num:-55000
# Per-person events (defaults to person 1)
type::event,name::Pension,start_age:num:65,person:num:2,amount:num:24000
```
### Configuration field reference
| Field | Type | Description |
| -------------------------------------- | ---- | --------------------------------------------------------------------------------- |
| `target_stock_pct` | num | Asset-allocation target (0100). Used for sim stock/bond blend. |
| `horizon` | num | Distribution-phase length in years. Repeat for multiple horizons. |
| `horizon_age` | num | Resolves to `target_age oldest_current_age`. Repeat for multiple. |
| `retirement_target` (on `horizon[_age]`) | num | Override the default earliest-retirement promotion. Allowed: 90, 95, 99. |
| `retirement_age` | num | Years old the OLDEST configured person must be to retire. |
| `retirement_at` | date | Absolute calendar date for retirement. Wins over `retirement_age` if both set. |
| `annual_contribution` | num | Yearly contribution during accumulation, in today's dollars. |
| `contribution_inflation_adjusted` | bool | If true (default), contributions grow with CPI year-over-year. |
| `target_spending` | num | Target retirement spending in today's dollars. |
| `target_spending_inflation_adjusted` | bool | If true (default), target spending grows with CPI in the distribution phase. |
See `examples/pre-retirement-{age,spending,spending-target,both}/`
and `examples/post-retirement/` for fully-configured walkthroughs
of each combination.
## CLI commands
```
zfin <command> [args]
Commands:
perf <SYMBOL> Trailing returns (1yr/3yr/5yr/10yr, price + total)
quote <SYMBOL> Real-time quote with chart
history <SYMBOL> Last 30 days price history
divs <SYMBOL> Dividend history with TTM yield
splits <SYMBOL> Split history
options <SYMBOL> Options chains (all expirations)
earnings <SYMBOL> Earnings history and upcoming events
etf <SYMBOL> ETF profile (expense ratio, holdings, sectors)
portfolio [FILE] Portfolio summary (default: portfolio.srf)
analysis [FILE] Portfolio analysis breakdowns (default: portfolio.srf)
snapshot [opts] Write a daily portfolio snapshot to history/
compare <D1> [<D2>] Compare portfolio state across two dates
enrich <FILE|SYMBOL> Generate metadata.srf from Wikidata + SEC EDGAR
lookup <CUSIP> CUSIP to ticker lookup via OpenFIGI
cache stats Show cached symbols
cache clear Delete all cached data
interactive, i Launch interactive TUI
help Show usage
Global options:
--no-color Disable colored output (also respects NO_COLOR env)
Portfolio options:
--refresh Force re-fetch all prices (ignore cache)
-w, --watchlist <FILE> Watchlist file
```
Not all keys are required; without a given key, that data type is
simply unavailable. For per-provider notes, signup links, the full
caching/TTL model, and the complete environment-variable list, see
[Data providers and API keys](docs/reference/providers.md),
[Caching and data freshness](docs/explanation/caching.md), and
[Environment variables](docs/reference/config/environment.md).
## Architecture
@ -958,11 +160,11 @@ accounts.srf Account to tax type mapping for analysis
### Dependencies
| Dependency | Source | Purpose |
|----------------------------------------------------|----------------|---------------------------------------------------|
| [SRF](https://git.lerch.org/lobo/srf) | Git | Cache file format and portfolio/watchlist parsing |
| [libvaxis](https://github.com/rockorager/libvaxis) | Git (v0.6.0) | Terminal UI rendering |
| [z2d](https://github.com/vancluever/z2d) | Git (v0.11.0) | Pixel chart rendering (Kitty graphics protocol) |
| Dependency | Source | Purpose |
|----------------------------------------------------|---------------|---------------------------------------------------|
| [SRF](https://git.lerch.org/lobo/srf) | Git | Cache file format and portfolio/watchlist parsing |
| [libvaxis](https://github.com/rockorager/libvaxis) | Git (v0.6.0) | Terminal UI rendering |
| [z2d](https://github.com/vancluever/z2d) | Git (v0.11.0) | Pixel chart rendering (Kitty graphics protocol) |
## Building
@ -986,8 +188,8 @@ A small amount of third-party source is vendored directly into the
tree (rather than added as a Zig package dependency) where the
upstream is small, stable, and not packaged for `build.zig.zon`:
| File | Source | Purpose |
|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|
| File | Source | Purpose |
|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------|
| `src/providers/xml.zig` | [Snektron/vulkan-zig](https://github.com/Snektron/vulkan-zig/blob/797ae8af88e84753af9640266de61a985b76b580/generator/xml.zig), via [aws-zig](https://github.com/elerch/aws-sdk-for-zig) | XML DOM parser used by the EDGAR provider for NPORT-P primary documents. |
Each vendored file carries a `// VENDORED - see README.md` header

26
TODO.md
View file

@ -99,6 +99,32 @@ ranking; unlabeled items are "someday, if the mood strikes."
faithfulness one notch. Pick whichever has the highest
payoff vs. complexity when this gets revisited.
## FIRECalc parity audit (priority LOW)
`analytics/projections.zig` re-implements the FIRECalc algorithm over
the Shiller dataset (`data/shiller.zig`, 1871-present). In practice the
outputs land close to FIRECalc.com, but there is no formal cross-check,
and the user docs only claim results "track FIRECalc closely"
(`docs/guides/plan-retirement.md`). Stand up a parity audit so that
claim is backed by evidence.
Do it:
- Pick a handful of representative inputs (portfolio value, allocation,
horizon, spending, with and without contributions) and run each
through both FIRECalc.com and `zfin projections`.
- Compare success rate, safe-withdrawal dollars, and terminal-value
percentiles; record the deltas and an acceptable tolerance.
- Where they diverge, pin down why. Usual suspects: withdrawal timing
(start- vs end-of-year), inflation / CPI handling, rebalancing
assumptions, fees, and how a partial final year is treated.
There is already a single FIRECalc reference assertion in the tests
(~$305K at 99% / 45yr on $7.7M, around `projections.zig:1696`). Extend
that into a small documented parity suite rather than a lone magic
number, and note any known, accepted differences in
`docs/explanation/projections-model.md`.
## `--export-chart` follow-ups — priority LOW
V1 of `--export-chart <PATH>` shipped for `quote` and `projections`

103
docs/README.md Normal file
View file

@ -0,0 +1,103 @@
# zfin documentation
zfin is a financial-data CLI and terminal UI for tracking a real
portfolio, analyzing trailing returns and risk, and running
retirement projections -- all from the terminal.
These docs are **task-first**. Almost every example here is runnable
against the fictional example portfolios that ship in the repo under
[`examples/`](../examples/), so you can follow along verbatim and see
the exact output shown. Set `ZFIN_HOME` to an example directory and
any command reads that portfolio:
```bash
ZFIN_HOME=examples/pre-retirement-both zfin portfolio
ZFIN_HOME=examples/post-retirement zfin projections
```
If you are reading these on Forgejo, every link below is a relative
path you can click.
## Start here
New to zfin? Read these in order:
1. [Getting started](getting-started.md) -- install, set up API keys,
create your first portfolio, run your first commands, open the TUI.
2. [Core concepts](explanation/concepts.md) -- the handful of ideas
(the `.srf` files, `ZFIN_HOME`, live vs. snapshot data) that make
everything else click.
## The user manual (workflows)
Goal-oriented guides. Each one walks an end-to-end task against a real
example portfolio and links to the reference for exhaustive detail.
| Guide | What you'll accomplish |
|-------------------------------------------------------------------|--------------------------------------------------------------------|
| [Build your portfolio](guides/set-up-your-portfolio.md) | Write a `portfolio.srf` from scratch: lots, cash, options, CDs |
| [Classify your holdings](guides/classify-holdings.md) | Create `metadata.srf` (by hand or with `enrich`) so analysis works |
| [Map your accounts](guides/set-up-accounts.md) | Tag accounts with tax type and institution in `accounts.srf` |
| [Read your portfolio](guides/read-your-portfolio.md) | Interpret `portfolio`, `analysis`, `exposure`, `review`, `perf` |
| [Track contributions](guides/track-contributions.md) | See money added over time and tag internal transfers |
| [Snapshots and history](guides/snapshots-and-history.md) | Record daily snapshots and compare your portfolio over time |
| [Plan for retirement](guides/plan-retirement.md) | Configure `projections.srf` for accumulation and drawdown |
| [Audit against your brokerage](guides/audit-against-brokerage.md) | Reconcile zfin against Fidelity/Schwab/Wells exports |
| [A periodic review](guides/periodic-review.md) | Reconcile, see what changed, then commit the new baseline |
| [Customize the TUI](guides/customize-the-tui.md) | Rebind keys and recolor the interface |
| [Offline use and refreshing data](guides/offline-and-refresh.md) | Control caching and provider calls |
## Reference
Look-it-up material. Exhaustive, terse, and kept in step with the
shipped binary.
- [CLI command reference](reference/cli/index.md) -- every command,
its flags, and sample output.
- Configuration files:
- [`portfolio.srf`](reference/config/portfolio-srf.md) -- positions, lots, cash, options, CDs
- [`metadata.srf`](reference/config/metadata-srf.md) -- sector / geography / asset-class classification
- [`accounts.srf`](reference/config/accounts-srf.md) -- tax type and institution per account
- [`projections.srf`](reference/config/projections-srf.md) -- retirement projection inputs
- [`watchlist.srf`](reference/config/watchlist-srf.md) -- price-only symbols
- [`transaction_log.srf`](reference/config/transaction-log-srf.md) -- declared transfers
- [`keys.srf`](reference/config/keys-srf.md) -- TUI keybindings
- [`theme.srf`](reference/config/theme-srf.md) -- TUI colors
- [Environment variables](reference/config/environment.md) -- API keys, `ZFIN_HOME`, `ZFIN_CACHE_DIR`, and more
- [The interactive TUI](reference/tui.md) -- tabs, keybindings, theming.
- [Data providers and API keys](reference/providers.md) -- who supplies
what, free-tier limits, and where to get keys.
## Explanation
Background and the "why" behind zfin's behavior.
- [Core concepts](explanation/concepts.md)
- [Caching and data freshness](explanation/caching.md)
- [Why multiple data providers](explanation/data-providers.md)
- [Returns and performance](explanation/returns-and-performance.md)
- [The retirement projection model](explanation/projections-model.md)
- [FAQ and troubleshooting](explanation/faq-troubleshooting.md)
## The example portfolios
The guides lean on five bundled, fictional households. They are safe
to read, copy, and run against:
| `ZFIN_HOME=examples/...` | Household | Demonstrates |
|----------------------------------|------------------------------------------|---------------------------------------------------------------|
| `pre-retirement-both` | Pat & Sam, ~45, ~$1.3M, contributing | A target retirement date **and** a target spending level |
| `pre-retirement-age` | same household | A target retirement **date** only |
| `pre-retirement-spending` | same household | A target **spending** level only (earliest-retirement search) |
| `pre-retirement-spending-target` | same household | An explicit, deliberately infeasible planning anchor |
| `post-retirement` | Robin & Jamie, ~68, ~$2.5M, drawing down | Distribution-only planning, with snapshot history |
See [`examples/README.md`](../examples/README.md) for the full tour of
what differs between them.
## A note on the examples and your real data
All names, share counts, account numbers, and prices in `examples/`
are fictional. Nothing in these docs is financial advice. When you
point zfin at your own data, keep it outside the repository (set
`ZFIN_HOME` to a private directory) so you never commit real holdings.

121
docs/explanation/caching.md Normal file
View file

@ -0,0 +1,121 @@
# Caching and data freshness
zfin makes a lot of API calls on your behalf -- prices, dividends,
earnings, ETF holdings -- against providers with strict free-tier
limits. Aggressive caching is what keeps it fast and keeps you well
under those limits. This page explains how it works so the
[`--refresh-data`](../guides/offline-and-refresh.md) flag makes sense.
## The fetch path
Every data request walks the same tiers, stopping at the first one that
can satisfy it:
1. **Local cache.** Look for `~/.cache/zfin/<SYMBOL>/<type>.srf`. If the
file exists and is within its TTL, deserialize and return -- no
network at all.
2. **Shared server** *(optional)*. On a miss or stale entry, if
`ZFIN_SERVER` is set, zfin asks that server before any provider; a
hit is written into your local cache and served from there, so no
provider call happens. See [Server sync](#server-sync-zfin_server).
3. **Provider.** Otherwise zfin fetches from the upstream provider,
writes the result to the cache, and returns it.
Freshness is decided by the cache file's modification time versus the
TTL for that data type. The cache directory defaults to `~/.cache/zfin`
and is set with `ZFIN_CACHE_DIR`.
The `--refresh-data` policy decides which tiers run:
- `auto` (default) walks all three.
- `force` skips the local cache and the server, going straight to the
provider, then re-caches the result.
- `never` stops at the local cache: it returns cached data even if
stale, and never touches the server or a provider.
## Time-to-live by data type
Different data ages at different rates, so each type has its own TTL:
| Data type | TTL | Why |
|---------------|---------------|-------------------------------------------------------------|
| Daily candles | ~24h (23h45m) | One bar per trading day; slightly under 24h for cron jitter |
| Dividends | 14 days | Declared well in advance |
| Splits | 14 days | Rare corporate events |
| Options | 1 hour | Prices move continuously when markets are open |
| Earnings | 30 days\* | Quarterly; smart-refreshed around announcements |
| ETF profiles | ~30 days | Holdings and weights change slowly |
| Quotes | never cached | Meant to be a live price check |
\* **Earnings smart refresh:** even inside the 30-day window, cached
earnings re-fetch automatically once an earnings date has passed but
the cache still lacks the actual result -- so numbers appear promptly
after an announcement without daily polling.
## Quotes are never cached
Because quotes exist to give you a live price, they're never served
from cache. The practical consequence: in offline mode
(`--refresh-data=never`) the [`quote`](../reference/cli/quote.md)
command has nothing to serve, while candle-based commands like
[`perf`](../reference/cli/perf.md) work fine from cached history.
## Incremental candle updates
Price history isn't re-downloaded wholesale. On a cache miss, zfin
fetches only candles newer than the last cached date and appends them,
using a small `candles_meta.srf` companion file to track the last date
and source provider. A ten-year history costs one big fetch the first
time and tiny top-ups thereafter.
## Negative caching
When a provider permanently fails for a symbol -- a nonexistent
ticker, say -- zfin records a negative cache entry so it doesn't retry
the same dead lookup on every run. (Transient failures like rate limits
are not cached this way; they're retried.)
## Rate limiting
Each provider has a client-side token-bucket limiter sized to its
free-tier ceiling (e.g. Polygon 5/min, FMP 250/day). When you'd exceed
the rate, zfin blocks until a token is available rather than firing a
request that would 429. This is why a `--refresh-data=force` run across
many symbols can pace itself instead of failing. Limits are listed in
[Data providers and API keys](../reference/providers.md).
## Server sync (`ZFIN_SERVER`)
`ZFIN_SERVER` points zfin at an optional
[zfin-server](https://git.lerch.org/lobo/zfin-server) instance -- a
shared cache that sits between your local cache and the upstream
providers, and is the second tier of [the fetch path](#the-fetch-path).
On a local miss, zfin requests `GET {ZFIN_SERVER}/<SYMBOL>/<type>`
(candles, dividends, splits, options, earnings, classification, ETF
metrics, and EDGAR entity facts), and a hit is written straight into
your local cache.
Why bother: the server is warmed once -- say by a cron job on one
machine -- and then every client draws from it instead of each spending
its own provider quota, so a household or a fleet of machines shares one
set of API-key budgets and gets faster cold starts. For the portfolio
price load, the server is queried in parallel across symbols, with
per-symbol provider fallback only for what it can't supply.
It is entirely optional: when `ZFIN_SERVER` is unset, every server-sync
path silently no-ops and zfin runs local-cache-then-provider. Live
quotes are never served by the server (they aren't cached anywhere), and
`--refresh-data=force` bypasses the server to re-fetch from the provider.
## Controlling it
You rarely need to intervene -- `auto` does the right thing. When you
do:
- `--refresh-data=force` re-fetches everything (after a close, or to
clear suspected bad data).
- `--refresh-data=never` goes fully offline.
- [`zfin cache stats`](../reference/cli/cache.md) shows what's cached;
`zfin cache clear` wipes it (everything re-fetches next run).
See [Offline use and refreshing data](../guides/offline-and-refresh.md).

View file

@ -0,0 +1,126 @@
# Core concepts
A handful of ideas explain how zfin is put together. Once they click,
the rest of the tool is predictable.
## zfin is a reader, not a database
zfin doesn't store your portfolio. You own a few plain-text files; zfin
reads them, fetches market data, and computes. There's no hidden state,
no import step, no lock-in -- your data is text you can read, diff, and
version-control.
This is why almost everything is grounded in files you edit directly,
and why the docs can ship runnable [examples](../../examples/).
## The `.srf` files
You don't need any of these to look up a quote, trailing returns, or
earnings for a symbol -- those commands need only an API key. The files
matter only when you want zfin to track *your* portfolio, and you add
them one at a time as you go. In practice you start with **one** file,
`portfolio.srf`; everything else is optional, and zfin degrades
gracefully when a file is absent (the last column says what you give
up).
They all share one format: [SRF](https://git.lerch.org/lobo/srf)
(Simple Record Format) -- line-oriented, comma-separated `key::value`
pairs (`key:num:` and `key:bool:` for typed values), `#` comments, and
a `#!srfv1` header.
| File | Holds | When you need it |
|---------------------------------------------------------------------|----------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------|
| [`portfolio.srf`](../reference/config/portfolio-srf.md) | Your lots (positions, cash, options, CDs) | The one file to start with: required for any portfolio, analysis, or projection view. |
| [`accounts.srf`](../reference/config/accounts-srf.md) | Tax type / institution / account number / update cadence per account | Optional. Without it, accounts show as "Unknown" in the tax-type breakdown; everything else works. |
| [`metadata.srf`](../reference/config/metadata-srf.md) | Sector / geo / asset-class per symbol | Optional. Without it, the asset-class / sector / geography breakdowns have nothing to group by; valuation still works. |
| [`projections.srf`](../reference/config/projections-srf.md) | Retirement projection inputs | Only for `zfin projections`, which otherwise runs with sensible defaults. |
| [`watchlist.srf`](../reference/config/watchlist-srf.md) | Price-only symbols | Only if you want a watchlist. |
| [`transaction_log.srf`](../reference/config/transaction-log-srf.md) | Declared transfers | Only to refine contribution attribution; missing means it's simply not applied. |
A few more files show up only when you opt into a feature:
`history/*-portfolio.srf` snapshots are written for you by
[`zfin snapshot`](../reference/cli/snapshot.md), and
`~/.config/zfin/keys.srf` / `theme.srf` customize the TUI. The same SRF
format is used internally for the cache and snapshots, so those are
inspectable too.
## `ZFIN_HOME`: where your data lives
zfin resolves your files one of two ways -- never a mix of both:
1. **`ZFIN_HOME` is set** -- zfin reads from that directory, and only
there. The current directory is never consulted, so a stray file in
cwd can't silently shadow your real data.
2. **`ZFIN_HOME` is unset** -- zfin reads from the current directory.
To make a single run read the current directory while `ZFIN_HOME` is
set, unset it just for that command: `env -u ZFIN_HOME zfin portfolio`.
`accounts.srf` and `metadata.srf` are always loaded from the same
directory as the resolved `portfolio.srf`. Pointing `ZFIN_HOME` at an
example directory is what makes the guides runnable:
```bash
ZFIN_HOME=examples/post-retirement zfin portfolio
```
Keep your real data in a private `ZFIN_HOME` outside any repo so you
never commit holdings. See
[environment variables](../reference/config/environment.md).
## Live data vs. snapshots
zfin deals with two kinds of "your portfolio," and the distinction
matters:
- **Live (current) portfolio** -- computed from `portfolio.srf` plus
the latest prices. This is what `portfolio`, `analysis`, `review`,
and friends show. It always reflects what you hold *now*.
- **Snapshots** -- immutable records of your totals on a past date,
written by [`zfin snapshot`](../reference/cli/snapshot.md) into
`history/<date>-portfolio.srf`. These are the time series that
`history` and `compare` read.
The live portfolio is mutable (you edit it); snapshots are historical
facts (you don't). See
[Snapshots and history](../guides/snapshots-and-history.md).
## The data service and caching
All market data flows through one path: check the local cache, and if
the entry is fresh enough, use it; otherwise fetch from a provider,
write it to the cache, and return it. You rarely think about providers
directly -- you think about *freshness*, controlled by the
`--refresh-data` flag. See
[Caching and data freshness](caching.md) and
[Why multiple data providers](data-providers.md).
## CLI and TUI are two faces of one engine
The CLI and the interactive TUI (`zfin i`) sit on the same engine:
they read the same files, share the same cache, and run the same
analytics, so a figure you see in one matches the other. Reach for the
CLI for quick checks and scripting; reach for the TUI for browsing and
high-fidelity charts.
They overlap heavily but aren't a one-to-one mirror -- and aren't
trying to be:
- **CLI-only.** Several commands have no TUI tab: the data-hygiene and
journaling ones (`audit`, `snapshot`, `import`, `enrich`, `compare`,
`contributions`), plus a few like `exposure` and `milestones`.
- **TUI-only.** Interactive touches have no CLI counterpart: live
charts, in-place refresh, the `?` keybinding overlay, and
conveniences like the overlay that shows a holding's full name behind
its ticker.
The nine TUI tabs -- Portfolio, Analysis, Review, Projections, History,
Quote, Performance, Earnings, Options -- cover the browsing and
analysis surfaces; the rest lives on the CLI. See
[The interactive TUI](../reference/tui.md).
## Where to go next
- [Getting started](../getting-started.md) if you haven't installed yet.
- [Build your portfolio](../guides/set-up-your-portfolio.md) to create your first file.
- [Caching and data freshness](caching.md) for the fetch model.

View file

@ -0,0 +1,72 @@
# Why multiple data providers
No single free data source does everything well. zfin aggregates
several, using each for what it's best at, and falls back gracefully
when one is unavailable. This page explains the design; for signup
links, limits, and the full table, see
[Data providers and API keys](../reference/providers.md).
## One source per job
Each data type has a primary provider chosen for coverage and quality:
| Data | Primary | Notes |
|-------------------------------|----------------------|-----------------------------------------------|
| Daily candles | Tiingo | Deep history; stocks, ETFs, mutual funds |
| Real-time quotes | Yahoo | No key required |
| Dividends / splits | Polygon | Carries forward-looking declared events |
| Options chains | CBOE | No key; 15-minute delayed |
| Earnings | FMP | Actuals + analyst estimates |
| ETF profiles / classification | SEC EDGAR + Wikidata | Authoritative holdings; needs a contact email |
The [data service](concepts.md#the-data-service-and-caching) hides this
behind one interface -- commands ask for "candles for VTI," not "call
Tiingo." That's also what makes the cache and rate limiting uniform
across providers.
## Fallback, not single-point-of-failure
Where a second source can stand in, zfin uses it:
- **Candles:** Tiingo is primary; **Yahoo** is the fallback if Tiingo
is unavailable or lacks the symbol.
- **Quotes:** Yahoo is primary; **TwelveData** is the fallback.
- **Dividends/splits:** Polygon is primary, but Tiingo's price-series
response carries per-row dividend and split data that zfin merges in
to rescue events Polygon's reference endpoints occasionally miss.
How a failure propagates depends on its kind. **Transient** errors
(server 5xx, connection drops) stop a refresh so you don't get a
half-updated view. **Permanent** errors (not-found, parse failures)
fall through to the next provider, and a rate-limit hit triggers a
single backoff-and-retry.
## Why some data needs a key and some doesn't
Quotes (Yahoo) and options (CBOE) are unauthenticated. The rest want a
free API key, and SEC EDGAR wants a contact email in its `User-Agent`
(set via `ZFIN_USER_EMAIL`) rather than a key. None are individually
required -- missing a key just removes that one data type, and
everything else keeps working. See
[which key unlocks what](../reference/config/environment.md#api-keys).
## A few deliberate quirks
- **Yahoo is unofficial.** Yahoo Finance has no public API; zfin reads
an undocumented endpoint that needs no key but carries no guarantees
-- it can change shape or stop responding at any time. Quotes (and
the candle fallback) ride on it, so keeping `TIINGO_API_KEY` set for
candles and `TWELVEDATA_API_KEY` for quote fallback cushions a Yahoo
outage.
- **TwelveData is no longer used for candles.** Its split-adjusted
closes proved unreliable for the return math, so it's quote-fallback
only now. Yahoo is the candle fallback.
- **Mutual-fund and dual-class coverage varies.** Some symbols (ETFs,
CUSIPs, a few dual-class shares like BRK.B) have no earnings on the
free FMP tier and show "no earnings data" -- a documented limitation,
not a bug. See the [FAQ](faq-troubleshooting.md).
## See also
- [Data providers and API keys](../reference/providers.md) -- the reference table.
- [Caching and data freshness](caching.md) -- how fetched data is reused.

View file

@ -0,0 +1,105 @@
# FAQ and troubleshooting
Common questions and the quick fixes. If something here doesn't cover
your case, the relevant reference page usually does.
## Setup and keys
**"Error: No API key set."**
zfin needs at least `TIINGO_API_KEY` to fetch price history. Set it in
your environment or `.env`. See [Getting started](../getting-started.md)
and [environment variables](../reference/config/environment.md#api-keys).
**Total-return columns are blank or match price-only.**
Total return needs dividend data from Polygon -- set `POLYGON_API_KEY`.
Without it you get price-only returns. See
[Returns and performance](returns-and-performance.md#price-only-vs-total-return).
**ETF profiles or `enrich` don't work.**
SEC EDGAR requires a contact email. Set `ZFIN_USER_EMAIL` to your
address (it's not a key). See
[`zfin enrich`](../reference/cli/enrich.md).
## Missing or odd data
**A holding shows up as "Unclassified."**
It has no [`metadata.srf`](../reference/config/metadata-srf.md) entry,
or the entry's `symbol::` doesn't match the lot's `symbol::`/`ticker::`.
Add a classification line; see
[Classify your holdings](../guides/classify-holdings.md).
**An account shows as "Unknown" in the tax-type breakdown.**
It's missing from [`accounts.srf`](../reference/config/accounts-srf.md),
or the `account::` name doesn't match the lot's `account::` exactly
(names must match character-for-character). See
[Map your accounts](../guides/set-up-accounts.md).
**A stock or ETF shows "no earnings data."**
ETFs, mutual funds, CUSIPs, and some dual-class shares (e.g. BRK.B)
return no earnings on FMP's free tier. This is an expected limitation,
not a bug. See [Why multiple data providers](data-providers.md).
**A mutual fund price looks like yesterday's.**
Mutual-fund NAVs publish after market close (after midnight ET for some
funds), so intraday you'll see the prior NAV until the new one posts.
**`quote` says the symbol is unavailable, but `perf` works.**
You're probably in offline mode (`--refresh-data=never`). Quotes are
never cached, so offline there's nothing to serve; candle-based
commands still work from cache. See
[Caching and data freshness](caching.md#quotes-are-never-cached).
**A row is shown in warning (yellow) color.**
That's a manual-priced lot -- its price came from the lot's `price::`
field, not a live feed, so it may be stale. Update `price::` /
`price_date::`, or run [`zfin audit`](../reference/cli/audit.md) to find
stale ones.
## Behavior
**A run printed "(using cached data)."**
zfin served fresh-enough cached data instead of hitting the network --
normal and fast. Force a refetch with `--refresh-data=force`. See
[Offline use and refreshing data](../guides/offline-and-refresh.md).
**A refresh seems slow / pauses.**
The rate limiter is spacing requests to stay under a provider's
free-tier limit (e.g. Polygon 5/min). It blocks rather than failing.
See [rate limiting](caching.md#rate-limiting).
**zfin can't find my portfolio.**
It looks in `ZFIN_HOME` if set, otherwise the current directory. Set
`ZFIN_HOME` to your data directory, or `cd` into it. `accounts.srf` and
`metadata.srf` load from the same directory as the resolved
`portfolio.srf`. See
[core concepts](concepts.md#zfin_home-where-your-data-lives).
**CLI and TUI show different totals.**
They shouldn't -- both union-merge every `portfolio*.srf` in
`ZFIN_HOME`. If they differ, check whether you passed a narrowing `-p`
pattern to one but not the other, or whether one is using a stale
cache vs. live prices.
**`contributions` reports "No changes detected."**
It diffs git revisions of your portfolio. With a clean tree it compares
`HEAD~1..HEAD`; if your last commit didn't change holdings, there's
nothing to show. Use `--since` to widen the window. See
[Track contributions](../guides/track-contributions.md).
## Data hygiene
**Lots of "overdue for update" accounts in `audit`.**
The default cadence is weekly and nags until satisfied. Set
`update_cadence::monthly|quarterly|none` per account in
[`accounts.srf`](../reference/config/accounts-srf.md#update_cadence-and-the-audit-nag).
**A transfer between my accounts inflated my contributions.**
Declare it in
[`transaction_log.srf`](../reference/config/transaction-log-srf.md) so
it isn't counted as new money. See
[Track contributions](../guides/track-contributions.md#dont-double-count-transfers).
## See also
- [Core concepts](concepts.md) -- the mental model.
- [Documentation home](../README.md) -- everything else.

View file

@ -0,0 +1,141 @@
# The retirement projection model
[`zfin projections`](../reference/cli/projections.md) simulates your
retirement portfolio against real market history. This page explains
the model so you can trust -- and correctly distrust -- its output. For
how to configure it, see the
[`projections.srf` reference](../reference/config/projections-srf.md)
and [Plan for retirement](../guides/plan-retirement.md).
## Historical simulation, not a formula
Rather than assume a single average return, zfin replays your portfolio
through actual historical sequences drawn from the **Shiller dataset**
(US equity total returns and CPI back to 1871). Each simulated run uses
a real historical path of returns and inflation, so the spread of
outcomes reflects real sequences -- including bad-timing sequences like
retiring into 1929, 1973, or 2000. This is the same family of method
as FIRECalc.
## Two phases
Every projection runs the same two phases in order:
1. **Accumulation** -- contributions added each year, no spending. Its
length comes from your retirement-date input. With no input, it's
zero years (an already-retired view).
2. **Distribution** -- annual spending withdrawn (CPI-adjusted by
default), no contributions. Its length is the configured `horizon`.
[Life events](../reference/config/projections-srf.md#event-fields)
(Social Security, pensions, tuition, healthcare) adjust the cash flow
in both phases.
## How inflation is handled
Inflation isn't a fixed assumption. Each historical cycle uses that
start year's **actual CPI sequence** alongside its actual returns (the
Shiller dataset carries both), so a cycle beginning in 1966 replays
1966's stagflation while one beginning in 2009 replays low-inflation
years.
The simulation runs in **nominal dollars**, which means the output
mixes two units -- and knowing which is which is the difference between
a sensible plan and a badly misread one:
- **Flows are entered in today's dollars and inflated forward.** Your
`annual_contribution`, `target_spending`, and inflation-adjusted life
events are amounts in *today's* dollars; each simulated year the model
multiplies them by that cycle's cumulative CPI, holding their
purchasing power constant. Set `contribution_inflation_adjusted`,
`target_spending_inflation_adjusted`, or an event's
`inflation_adjusted` to `false` to pin a flow at a flat nominal amount
instead (e.g. a fixed pension with no COLA).
- **Safe-withdrawal figures are in today's dollars.** "You could spend
~$264k/yr at 99%" means ~$264k of *today's* purchasing power, with the
actual dollar amount rising each retirement year to keep pace with
inflation.
- **Portfolio and terminal values are nominal (future dollars).** The
"Median portfolio at retirement" and the
`Terminal Portfolio Value (nominal, ...)` percentiles are **not**
inflation-adjusted. A ~$244M median balance 50 years out is heavily
inflated dollars, not $244M of today's purchasing power -- judge it
against the inflated spending it has to support, never against today's
prices.
This split is deliberate and matches FIRECalc: you plan spending in real
(today's) terms while the balance compounds in nominal terms.
## Percentile bands
Across all the historical runs, zfin reports the distribution of
outcomes rather than a single number:
- **p10 (pessimistic)** -- only 10% of histories did worse.
- **p50 (median)** -- the middle outcome.
- **p90 (optimistic)** -- only 10% did better.
```
Terminal Portfolio Value (nominal, at 99% withdrawal rate)
25 Year 35 Year
Pessimistic (p10) $6,739,560.02 $11,597,557.94
Median (p50) $30,023,255.68 $66,794,741.87
Optimistic (p90) $103,184,321.05 $279,372,182.75
```
The wide spread is the point: it shows sequence-of-returns risk
honestly instead of hiding it behind an average.
## Confidence and safe withdrawal
The **Safe Withdrawal** table answers "how much could I spend and still
not run out?" at chosen confidence levels (90/95/99%). A 99% safe
withdrawal is the spending level that survived 99% of historical
sequences over that horizon -- the most conservative. Higher confidence
and longer horizons both lower the safe number.
## The earliest-retirement search
When you set a `target_spending` instead of a date, zfin inverts the
question: for each (horizon x confidence) cell it searches for the
**earliest** accumulation length (up to 50 years) that sustains your
spending, and renders the grid of answers. One cell is promoted to the
headline (see
[promotion rules](../reference/config/projections-srf.md#the-two-retirement-planning-inputs)).
If no length within the cap works, the cell is **infeasible** -- shown
honestly rather than fudged.
## The caveat that matters most
zfin states this loudly by design, and so does this page:
> The actuals overlay and evaluation views
> (`--overlay-actuals`, `--convergence`, `--return-backtest`) tell you
> whether the model was **directionally honest** -- did your real
> trajectory fall within the bands it would have drawn. They do **not**
> tell you whether a safe-withdrawal claim is **accurate**. An SWR
> claim is a 30-year claim; there is at most ~12 years of weekly
> history and a year or two of native snapshots to check it against.
> No one will have data to validate a full-retirement SWR within our
> lifetimes.
Treat the projection as a disciplined way to compare scenarios and
visualize sequence risk -- not as a promise about your specific future.
## Assumptions to keep in mind
- **Allocation** is a single stock/bond blend (`target_stock_pct`), not
your exact holdings.
- **Inflation** comes from each historical cycle's own CPI; flows are
real (today's-dollar) and balances are nominal. See
[How inflation is handled](#how-inflation-is-handled).
- **Taxes** are not modeled. Withdrawal figures are pre-tax.
- **Imported-value overlays** scale today's allocation to a historical
total when lot-level history isn't available, because a `liquid::`
row can't reconstruct past composition.
## See also
- [Plan for retirement](../guides/plan-retirement.md) -- the guided walkthrough.
- [`projections.srf` reference](../reference/config/projections-srf.md) -- every input.
- [`zfin projections`](../reference/cli/projections.md) -- flags and evaluation views.

View file

@ -0,0 +1,127 @@
# Returns and performance
zfin reports returns in a few different forms. This page explains what
each means so you can read [`perf`](../reference/cli/perf.md),
[`review`](../reference/cli/review.md), and the portfolio summary
correctly.
## Price-only vs. total return
Two columns show up throughout zfin:
- **Price only** -- the change in share price alone.
- **Total return** -- price change **plus** reinvested dividends. This
is the number that reflects what you actually earned.
```
Price Only Total Return
1-Year Return: 27.29% 28.79%
3-Year Return: 20.75% 22.22% ann.
```
Total return needs dividend history, which comes from Polygon -- so it
requires `POLYGON_API_KEY`. Without it, you still get price-only
returns. For a dividend payer like SCHD the gap between the two columns
is large; for a non-payer it's near zero.
## Annualized (CAGR)
Multi-year returns are **annualized** -- expressed as a compound annual
growth rate (the constant yearly rate that produces the same total
growth), marked `ann.` in the output. A "3-Year Return: 22.22% ann."
means the holding grew as if by 22.22% every year for three years, not
22.22% total. One-year figures are already annual, so they carry no
`ann.` tag.
## As-of vs. month-end
[`perf`](../reference/cli/perf.md) prints two tables:
- **As-of** -- returns through the latest available close. The most
current view.
- **Month-end** -- returns through the most recent calendar month-end.
This matches how mutual funds and Morningstar quote their trailing
numbers, so it's the apples-to-apples figure for comparison.
```
As-of 2026-06-04: ...
Month-end (2026-05-31): ...
```
## Risk metrics
[`perf`](../reference/cli/perf.md) and
[`review`](../reference/cli/review.md) also report risk, computed from
periodic returns:
- **Volatility** -- how much returns vary; higher means a bumpier ride.
- **Sharpe ratio** -- return earned per unit of volatility (risk-
adjusted return). Higher is better; a negative Sharpe means the
asset underperformed cash on a risk-adjusted basis over the window.
- **Max drawdown** -- the largest peak-to-trough decline over the
period; a worst-case-loss gut check.
```
3Y-Vol 10Y-Vol 3Y-SR 10Y-SR 5Y-MaxDD
13.4% 15.8% 1.29 0.90 24.8%
```
### Reading the Sharpe ratio
Sharpe divides an asset's *excess* return -- its annualized return
**minus the risk-free rate** -- by its volatility. It answers "how much
extra return did I earn for the bumpiness I took on, versus just parking
the money risk-free?"
zfin uses the average 3-month US T-bill rate (FRED series DTB3) over the
same window as the return. That rate table is **hand-maintained**: it's
refreshed about once a year, and when it falls behind zfin prints a
`T-bill risk-free rate table is overdue for refresh` warning on stderr
(also shown by [`zfin doctor`](../reference/cli/doctor.md)). It blocks
nothing -- it's a nudge. Because the same rate feeds every holding, a
slightly stale rate barely shifts comparisons between them; refresh it
when the nag appears.
**Rough guide to what's "good":**
| Sharpe | Read |
|-----------|-----------------------------------------------------------------------|
| `> 1.0` | Standout -- strong reward for the risk taken |
| `0.5-1.0` | Healthy -- where most solid long-term holdings sit |
| `0-0.5` | Mediocre -- barely beating cash once risk is accounted for |
| `< 0` | Lost to cash on a risk-adjusted basis (the negative case noted above) |
These are the bands zfin uses to color the [`review`](../reference/cli/review.md)
dashboard: green above 0.5, yellow from 0 to 0.5, red below 0.
**Prefer the longer window.** A Sharpe measured over a single year is
noisy -- one strong or weak quarter swings it. The 10-year figure
averages across market regimes and is the steadier read, which is why
zfin reports Sharpe at **3Y and 10Y** rather than 1Y. When the two
disagree, lean on the longer window to judge a holding's risk-adjusted
quality.
## Portfolio-level returns
The portfolio summary's **Historical** line and the
[`projections`](../reference/cli/projections.md) benchmark block show
your holdings' *blended* return -- each position's return weighted by
its market value. It's a quick "how have my holdings done together"
read, and it includes the effect of your allocation (a bond-heavy
portfolio will trail an all-equity one, as you'd expect).
> These returns describe the securities, not your personal money-
> weighted return. They don't account for the timing of your
> contributions. To separate new money from market movement over a
> period, use [contributions](../guides/track-contributions.md).
The market value that weights these returns reflects zfin's
covered-call cap: an in-the-money written call holds its covered shares
at the strike rather than the live price, so it doesn't price the
option contract itself. See
[covered-call valuation](../guides/read-your-portfolio.md#covered-calls).
## See also
- [`zfin perf`](../reference/cli/perf.md) / [`zfin review`](../reference/cli/review.md)
- [Read your portfolio](../guides/read-your-portfolio.md) -- interpreting the dashboards.

243
docs/getting-started.md Normal file
View file

@ -0,0 +1,243 @@
# Getting started
This guide takes you from nothing to a working zfin in about ten
minutes: install the binary, set one API key, run your first commands
on any ticker, then create your own portfolio.
By the end you will have:
- the `zfin` binary on your `PATH`,
- a free Tiingo API key configured,
- pulled live quotes and trailing returns for real tickers, and
- created a minimal portfolio of your own.
## 1. Install
### Option A: pre-built binary (macOS, Apple Silicon)
```bash
curl -L -o zfin \
https://git.lerch.org/api/packages/lobo/generic/zfin-aarch64-macos/latest/zfin-aarch64-macos
chmod +x zfin
sudo mv zfin /usr/local/bin/ # or anywhere on your PATH
```
### Option B: build from source (Linux, macOS)
Building requires **Zig 0.16.0**. If you use [mise](https://mise.jdx.dev/),
the pinned toolchain installs itself:
```bash
git clone https://git.lerch.org/lobo/zfin.git
cd zfin
mise install # installs Zig 0.16.0 from .mise.toml; skip if you have it
zig build # binary lands at zig-out/bin/zfin
```
Put `zig-out/bin/zfin` on your `PATH`, or run it by full path.
### Verify
```bash
zfin version
```
```
zfin e246d1e (built 2026-06-19)
```
## 2. Get an API key
zfin pulls data from several free-tier providers, but you only need
**one** key to get started:
- **Tiingo** (`TIINGO_API_KEY`) -- daily price history (candles). This
is the primary price source and the one key worth setting first.
Sign up free at <https://tiingo.com>.
A couple more are worth adding once you are up and running. None are
required for your first run:
| Key | Unlocks | Free signup |
|-------------------|-----------------------------------------------------------------------------|-------------------------------------|
| `POLYGON_API_KEY` | Dividends and splits (enables **total** return) | <https://polygon.io> |
| `FMP_API_KEY` | Earnings history and estimates | <https://financialmodelingprep.com> |
| `ZFIN_USER_EMAIL` | ETF profiles and `enrich` (SEC EDGAR requires a contact email -- not a key) | your own email |
Quotes (Yahoo) and options chains (CBOE) need no key at all. For the
full breakdown of who supplies what, see
[Data providers and API keys](reference/providers.md).
## 3. Configure
zfin reads keys from the environment or from a `.env` file. The `.env`
file is searched first in the binary's parent directory, then in the
current directory.
Create a `.env` (or export the variables in your shell):
```bash
TIINGO_API_KEY=your_key_here
ZFIN_USER_EMAIL=you@example.com # optional, enables ETF profiles
```
Cached data lands in `~/.cache/zfin` by default; override it with
`ZFIN_CACHE_DIR`. See [Environment variables](reference/config/environment.md)
for every setting zfin understands.
Not sure a key took, or which features you've unlocked? Run
[`zfin doctor`](reference/cli/doctor.md) -- it reports which files and
keys it found and what each one enables, and changes nothing.
## 4. Take it for a spin
You don't need a portfolio to use zfin -- point it at any ticker and it
fetches what it needs. Start with a live quote (quotes need no API key):
```bash
zfin quote SPY
```
```
SPY $746.74 (close)
========================================
Date: 2026-06-18
Open: $747.76
High: $748.23
Low: $743.86
Volume: 80,875,657
Change: +$5.78 (+0.78%)
```
(A short price chart prints below the quote. Your figures will differ
-- prices move every day.)
The per-symbol commands all work on any ticker, no portfolio required.
The first price-history fetch for a symbol populates the cache (a few
seconds); later runs are instant:
```bash
zfin perf VTI # 1y/3y/5y/10y trailing returns (needs TIINGO_API_KEY)
zfin history VTI # last 30 days of prices
zfin divs SCHD # dividend history (needs POLYGON_API_KEY)
zfin earnings MSFT # earnings + estimates (needs FMP_API_KEY)
zfin etf QQQ # ETF holdings + sectors (needs ZFIN_USER_EMAIL)
zfin options AAPL # options chain (no key)
```
`zfin perf VTI` prints Morningstar-style trailing returns:
```
Trailing Returns for VTI
========================================
Data points: 6290 (2001-05-31 to 2026-06-04)
Latest close: $373.38
As-of 2026-06-04:
Price Only Total Return
---------------------- -------------- --------------
1-Year Return: 27.29% 28.79%
3-Year Return: 20.75% 22.22% ann.
5-Year Return: 11.22% 12.80% ann.
10-Year Return: 13.19% 15.10% ann.
```
A command that needs a key you haven't set will say so and name the
key -- see the key table in [step 2](#2-get-an-api-key) or
[Data providers and API keys](reference/providers.md). Curious what the
columns mean? See
[Returns and performance](explanation/returns-and-performance.md).
### Explore the example portfolios (optional)
Portfolio features -- summaries, allocation breakdowns, retirement
projections -- need a portfolio file. To try them before building your
own, zfin ships five fictional households under `examples/`. If you
built from source you already have them; with the pre-built binary,
clone the repo to get a copy:
```bash
git clone https://git.lerch.org/lobo/zfin.git && cd zfin
```
Point `ZFIN_HOME` at any example and zfin treats it as your data:
```bash
ZFIN_HOME=examples/pre-retirement-both zfin portfolio
ZFIN_HOME=examples/pre-retirement-both zfin analysis
ZFIN_HOME=examples/pre-retirement-both zfin projections
```
```
Portfolio Summary (examples/pre-retirement-both/portfolio.srf)
========================================
Value: $1,383,137.81 Cost: $658,837.01 Gain/Loss: +$724,300.80 (109.9%)
Lots: 13 open, 0 closed Positions: 5 symbols
Historical: 1M: +3.2% 3M: +13.3% 1Y: +24.5% 3Y: +56.4% 5Y: +56.1% 10Y: +182.6%
Symbol Shares Avg Cost Price Market Value Gain/Loss Weight ...
------- -------- ---------- ---------- ---------------- -------------- -------- ...
AGG 980.0 $115.23 $98.90 $96,922.00 - $16,002.00 7.0%
...
```
The five households each demonstrate a different planning scenario. To
interpret this output see
[Read your portfolio](guides/read-your-portfolio.md); for the scenarios
themselves see [the examples tour](../examples/README.md) and
[Plan for retirement](guides/plan-retirement.md).
## 5. Create your own portfolio
A portfolio is a plain-text [`.srf`](reference/config/portfolio-srf.md)
file: one line per lot (a batch of shares bought on a date). Keep it
**outside** the repository so you never commit real holdings. Make a
private directory and a file:
```bash
mkdir -p ~/finance
$EDITOR ~/finance/portfolio.srf
```
A minimal portfolio with two positions and some cash:
```srf
#!srfv1
symbol::VTI,shares:num:100,open_date::2020-01-15,open_price:num:170.00,account::My Brokerage
symbol::SCHD,shares:num:200,open_date::2021-06-01,open_price:num:75.00,account::My Brokerage
security_type::cash,shares:num:5000.00,open_date::2026-01-01,open_price:num:1.00,account::My Brokerage
```
Point zfin at it:
```bash
ZFIN_HOME=~/finance zfin portfolio
```
zfin also auto-detects `portfolio.srf` in the current directory, so
from inside `~/finance` you can just run `zfin portfolio`.
To go further -- options, CDs, illiquid assets, manual prices, ticker
aliases, DRIP lots -- see
[Build your portfolio](guides/set-up-your-portfolio.md) and the
[`portfolio.srf` reference](reference/config/portfolio-srf.md).
## 6. Open the interactive TUI
Everything the CLI shows is also available in a multi-tab terminal UI:
```bash
ZFIN_HOME=~/finance zfin i
```
Navigate tabs with `h`/`l` (or arrow keys), move the cursor with
`j`/`k`, press `?` for the keybinding overlay, and `q` to quit. See
[The interactive TUI](reference/tui.md) for the full tour.
## Next steps
- **Understand the model:** [Core concepts](explanation/concepts.md)
- **Build out your data:** [Classify your holdings](guides/classify-holdings.md)
and [Map your accounts](guides/set-up-accounts.md)
- **Plan ahead:** [Plan for retirement](guides/plan-retirement.md)
- **Browse everything:** the [documentation home](README.md)

View file

@ -0,0 +1,244 @@
# Audit against your brokerage
**Goal:** catch drift between what zfin thinks you hold and what your
brokerage actually reports -- wrong share counts, sales you forgot to
record, missing lots, stale manual prices -- by reconciling
`portfolio.srf` against a positions export.
**You'll need:** a portfolio whose [`accounts.srf`](set-up-accounts.md)
entries carry `institution::` and `account_number::` (that's how zfin
ties an export back to your accounts -- see
[How accounts are matched](#how-accounts-are-matched)), plus an export
from a supported broker.
> Heads up: this is the most heuristic corner of zfin. The brokerage
> parsers are format-specific, account matching depends on metadata you
> maintain, and the comparison uses deliberate tolerances. It's the best
> way to keep your records honest, but expect a little setup and the
> occasional "why didn't that match?" -- this guide covers the gotchas,
> not just the happy path.
## Supported brokers and how to export
`zfin audit` reconciles against **Fidelity** and **Schwab**. (Wells
Fargo is handled by [`import`](#what-about-wells-fargo), not audit.)
| Broker | How to export | Flag |
|--------------------------|-------------------------------------------------------------------------------------------------------------|--------------------|
| **Fidelity** | *Positions* tab -> the three-dot (**⋮**) menu -> **Download** (a CSV) | `--fidelity <CSV>` |
| **Schwab** (per-account) | *Accounts -> Positions* -> **Export** (one CSV per account) | `--schwab <CSV>` |
| **Schwab** (summary) | *Accounts -> Summary*: select the accounts table and copy it ([what to copy](#schwab-summary-what-to-copy)) | `--schwab-summary` |
The two Schwab inputs differ in detail: the **per-account CSV** has full
per-position data (shares, price, value); the **summary paste** carries
only each account's cash and total value, so it reconciles *totals*, not
individual holdings. Use the summary for a quick "are my account totals
right?", the CSV for position-level checks. (Fidelity money-market rows
and Schwab "Cash & Cash Investments" rows are recognized as cash.)
The Fidelity **Download** isn't a top-level button -- it's behind the
three-dot (**⋮**) menu at the top-right of the positions panel:
![Fidelity Positions tab with the three-dot menu open, showing the Download item](../images/fidelity-positions-download.png)
*(The account list down the left side is blanked out above.)*
### Schwab summary: what to copy
The summary paste comes from Schwab's **Accounts -> Summary** page.
Scroll to the **Accounts** table, drag-select from the first account's
name through the last row, and copy. Then either save it as a `.txt`
file in your `audit/` folder (where auto-discovery will find it) or pipe
it straight in:
```bash
zfin audit --schwab-summary # paste, then Ctrl-D
```
A good paste is repeating three-line blocks -- the account name, the
"ending in" line, then a values line -- and looks about like this
(figures fictional):
```
Sample Roth IRA
Account number ending in 1234 ...1234
Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
Sample Brokerage
Account number ending in 5678 ...5678
Type Brokerage $12,500.00 $980,000.00 +$3,200.00 +0.33%
Sample Trust
Account number ending in 9012 ...9012
Type $2,000.00 $415,300.00 +$1,150.00 +0.28%
```
zfin anchors on each **"Account number ending in"** line (its trailing
digits are the account number) and reads the **first two dollar figures**
on the line below as **cash** then **total value** -- everything else on
that line is ignored, and the account-type word is optional. If your copy
looks nothing like this -- no "ending in" lines, or no dollar figures --
you grabbed the wrong region. Because it carries only cash and totals,
the summary reconciles **account totals**, not individual positions.
## Run it
**Point it at a file:**
```bash
ZFIN_HOME=~/finance zfin audit --fidelity ~/Downloads/Portfolio_Positions.csv
ZFIN_HOME=~/finance zfin audit --schwab ~/Downloads/Positions-Individual.csv
ZFIN_HOME=~/finance zfin audit --schwab-summary # then paste the page, Ctrl-D
```
**Or run it with no flags** -- `zfin audit` does a portfolio hygiene
check *and* auto-discovers and reconciles any recent exports it finds
(next two sections).
## The hygiene check
With no flags, `zfin audit` first prints a health report:
- **Stale manual prices** -- lots with a manual `price::` older than
`--stale-days` (default 3).
- **Accounts overdue for update** -- accounts past their
`update_cadence` (see [accounts.srf](set-up-accounts.md#3-tune-the-maintenance-cadence)).
- **Brokerage files** it discovered, which it then reconciles.
```
Portfolio hygiene
Stale manual prices (>3 days — --stale-days to configure)
(none)
Accounts overdue for update (weekly default — set update_cadence in accounts.srf)
Sample IRA weekly no update history found
Sample Brokerage weekly no update history found
```
"No update history found" is a nudge, not an error -- silence accounts
you don't actively track with `update_cadence::none`.
## Auto-discovery (and your download folder)
With no `--fidelity`/`--schwab` flag, zfin looks for exports in two
places:
1. **`$ZFIN_AUDIT_FILES`** -- a directory *you* set. Point it at wherever
your browser saves downloads (e.g. `~/Downloads`) so a
just-downloaded export is found with no copying or renaming. (zfin
does **not** scan it on its own -- you opt in by setting this.)
2. **`<portfolio-dir>/audit/`** -- a dedicated subfolder next to your
`portfolio.srf`, for exports you want to keep around.
What it considers:
- **Only files modified in the last 24 hours.** Your browser saves the
export to its download folder with names like
`Portfolio_Positions_Jun-19.csv`; the
recency window keeps zfin reconciling *the one you just pulled*, not
last quarter's.
- **Detected by content, not filename.** zfin sniffs the first lines --
Fidelity begins `Account Number`/`Account Name`, a Schwab CSV begins
`"Positions for ...`, a Schwab summary contains `Account number ending
in`. A renamed file still works; an unrelated CSV is skipped.
So with `ZFIN_AUDIT_FILES=~/Downloads`, the workflow collapses to
"download from your broker, run `zfin audit`, done."
## How accounts are matched
This is the part that trips people up. An export covers one or more
**accounts**, and zfin has to tie each one to an account in your
portfolio. It does that through
[`accounts.srf`](set-up-accounts.md#2-add-institution-and-account-number-for-auditing):
- The export carries an account number -- Schwab's from the "Positions
for account ...1234" title, Fidelity's from the Account Number column,
the summary's from "...ending in 1234".
- zfin finds the `accounts.srf` entry whose `institution::` (`fidelity`,
`schwab`) **and** `account_number::` match, and compares against that
account's lots.
- **No match -> the account is shown as `unmapped`** and flagged as a
discrepancy. Fix it by adding `institution::` and `account_number::`
to that account in `accounts.srf` (a placeholder number you recognize
is fine -- it just has to match what the export shows).
## Reading the report
zfin treats the **brokerage as the source of truth** and shows, per
account, your portfolio (PF) against the broker (BR). Figures below are
illustrative and fictional:
```
Portfolio Audit (brokerage is source of truth)
========================================
Sample Brokerage *1234
Symbol PF Shares BR Shares PF Price BR Price
VTI 100.000 100.000 373.38 373.38 ok
SCHD 200.000 210.000 31.86 31.86 brokerage +10.000
AGG 50.000 0.000 portfolio only
```
- **Portfolio-only** rows are lots the broker no longer shows -- a sale
you forgot to remove, or a mistyped symbol.
- **Brokerage-only** rows are holdings missing from your portfolio.
- A share or value **delta** flags a count or price mismatch.
`--verbose` prints the full comparison even when everything reconciles.
### Why "close" counts as a match
- **Cash matches to the penny.** It's an exact figure on both sides, so
any gap is real (e.g. money-market dividend accrual between updates)
and worth surfacing.
- **Securities get ~$1 of slack.** A sub-cent NAV-rounding difference on
a six-figure fund position can exceed a dollar without being
actionable, so small *value* deltas are tolerated -- but *share-count*
mismatches never are.
### Institutional share classes
If a lot is priced through a retail-ticker `ticker::` alias while the
account actually holds an institutional class (a different NAV), audit
compares against the broker's NAV and can **suggest a `price_ratio`** to
bridge the gap. Accounts flagged `direct_indexing::true` get the same
treatment to track drift. See
[price resolution](../reference/config/portfolio-srf.md#advanced-and-option-fields).
## Why it's finicky
- The parsers are **broker-specific and hardcode each export's column
layout** -- if Fidelity or Schwab changes their format, parsing can
break (Fidelity's header is validated to catch this; Schwab's is not).
They are not full RFC-4180 CSV parsers (no escaped quotes or
multi-line fields) -- fine for the real exports, not for arbitrary
CSVs.
- Matching is only as good as the `institution::` / `account_number::`
entries you keep in `accounts.srf`.
- Options and cash are reconciled separately from share counts.
None of this is a reason to skip it -- it's the single best way to keep
your records honest -- just know it expects some setup and an occasional
manual nudge.
### What about Wells Fargo?
Wells Fargo's portal has no clean positions export, so it isn't an
`audit` target. Instead, [`zfin import --wells-fargo`](../reference/cli/import.md)
rebuilds a portfolio file from a paste of the WF positions table (copy
the rendered table from the brokerage portal and save it to a file).
Fidelity and Schwab exports can be imported the same way.
> **Keep brokerage exports private.** They contain real account numbers
> and holdings. Store them outside any git repository and delete them
> when you're done reconciling.
## Next steps
- [`zfin audit` reference](../reference/cli/audit.md) -- every flag.
- [Map your accounts](set-up-accounts.md) -- the `institution` / `account_number` matching keys.
- [`zfin import`](../reference/cli/import.md) -- build a portfolio file *from* an export (including Wells Fargo).
---
[Previous: Plan for retirement](plan-retirement.md) | [Next: A periodic review](periodic-review.md) | [Documentation home](../README.md)

View file

@ -0,0 +1,114 @@
# Classify your holdings
**Goal:** create a `metadata.srf` that tells zfin the asset class,
sector, and geography of each symbol, so
[`zfin analysis`](../reference/cli/analysis.md) and
[`zfin review`](../reference/cli/review.md) -- and their TUI tabs --
can group your holdings by category and sector.
**You'll need:** a `portfolio.srf` ([build one first](set-up-your-portfolio.md)).
For the automatic path, set `ZFIN_USER_EMAIL` (SEC EDGAR requires a
contact address). Full field list:
[`metadata.srf` reference](../reference/config/metadata-srf.md).
## Why classify?
Without `metadata.srf`, zfin can value your portfolio but can't group
it. Classification feeds the Asset Category / Sector / Geographic
breakdowns in [`zfin analysis`](../reference/cli/analysis.md), and the
per-holding Sector column and grouping in
[`zfin review`](../reference/cli/review.md) -- each as a CLI command
and as its tab in the TUI. A few lines unlock all of them:
```
Asset Category
Equity ██████████████████████████▋ 89.2% $1,233,151.30
Fixed Income ██ 7.0% $96,922.00
Cash █▏ 3.8% $53,064.51
```
## Option A: bootstrap with `enrich` (recommended)
[`zfin enrich`](../reference/cli/enrich.md) queries Wikidata and SEC
EDGAR to generate classification lines for you. Point it at your
portfolio and redirect to `metadata.srf`:
```bash
ZFIN_HOME=~/finance zfin enrich portfolio.srf > ~/finance/metadata.srf
```
It writes a complete SRF file (header included) with one entry per
stock symbol. Symbols Wikidata doesn't know fall back to EDGAR's
mutual-fund map; anything that misses both is emitted as a `TODO`
line for you to fill in by hand.
To add a single symbol to an existing file, give `enrich` a symbol
instead of a file -- it prints just the classification lines (no
header), so you can append:
```bash
zfin enrich SCHD >> ~/finance/metadata.srf
```
## Option B: write it by hand
For a small portfolio, hand-writing is quick. One line per symbol:
```srf
#!srfv1
symbol::VTI,sector::Diversified,geo::US,asset_class::US Large Cap
symbol::AGG,sector::Bonds,geo::US,asset_class::Bonds
symbol::QQQ,sector::Technology,geo::US,asset_class::US Large Cap
```
The `symbol::` must match the `symbol::` (or `ticker::`) used in your
portfolio. Cash and CDs are classified as "Cash & CDs" automatically.
## Blended funds
A target-date or balanced fund spans several asset classes. Add one
line per slice with `pct:num:` weights that sum to ~100:
```srf
symbol::02315N600,asset_class::US Large Cap,pct:num:55
symbol::02315N600,asset_class::International Developed,pct:num:20
symbol::02315N600,asset_class::Bonds,pct:num:15
symbol::02315N600,asset_class::Emerging Markets,pct:num:10
```
## Fixing uninformative sectors
ETF holdings data sometimes tags everything as the generic
"Equity / Corporate," which collapses distinct holdings into one
meaningless group. When that happens, set a `bucket::` label yourself
to a grouping that actually distinguishes them -- it overrides the
auto-derived sector for concentration and dominance analysis. See the
[`bucket` field](../reference/config/metadata-srf.md#the-bucket-field).
For example, two broad funds that would both auto-bucket as
"Diversified" can be split into meaningful groups so concentration and
dominance analysis treat them separately:
```srf
#!srfv1
symbol::VTI,sector::Diversified,geo::US,asset_class::US Large Cap,bucket::US Total Market
symbol::SCHD,sector::Diversified,geo::US,asset_class::US Large Cap,bucket::US Dividend
```
## Verify
```bash
ZFIN_HOME=~/finance zfin analysis
```
If a symbol shows up under "Unclassified," it's missing a metadata
entry (or the symbol doesn't match). Add a line and re-run.
## Next steps
- [Map your accounts](set-up-accounts.md) for the tax-type breakdown.
- [Read your portfolio](read-your-portfolio.md) to interpret the analysis.
---
[Previous: Build your portfolio](set-up-your-portfolio.md) | [Next: Map your accounts](set-up-accounts.md) | [Documentation home](../README.md)

View file

@ -0,0 +1,73 @@
# Customize the TUI
**Goal:** rebind the interactive TUI's keys and recolor its interface
to your taste.
The TUI reads two optional config files from `~/.config/zfin/`:
[`keys.srf`](../reference/config/keys-srf.md) for keybindings and
[`theme.srf`](../reference/config/theme-srf.md) for colors. When a file
is absent, built-in defaults apply; when present, it is the **sole**
source for that setting.
## Rebind keys
Generate a fully-commented starting file from the current defaults,
then edit it:
```bash
mkdir -p ~/.config/zfin
zfin interactive --default-keys > ~/.config/zfin/keys.srf
$EDITOR ~/.config/zfin/keys.srf
```
Each line binds one action to one key:
```srf
action::quit,key::q
action::next_tab,key::l
action::next_tab,key::right
```
- Add modifiers with `ctrl+`, `alt+`, `shift+` (e.g. `ctrl+d`).
- Bind several keys to an action by repeating the line.
- Scope a binding to one tab with `scope::<tab>` (e.g.
`scope::options`); the `action::` then names that tab's local
action. A tab-local binding may not reuse a globally-bound key --
zfin refuses to start if you create that conflict.
Full key vocabulary and the default bindings:
[`keys.srf` reference](../reference/config/keys-srf.md).
## Recolor the interface
Same pattern for the theme:
```bash
zfin interactive --default-theme > ~/.config/zfin/theme.srf
$EDITOR ~/.config/zfin/theme.srf
```
Every value is a hex RGB string:
```srf
#!srfv1
bg::#0a0a0a
text::#eeeeee
accent::#9d7cd8
positive::#7fd88f
negative::#e06c75
```
The keys cover backgrounds, tabs, text, status line, the modal input,
gains/losses, warnings, selection, and borders -- see the
[`theme.srf` reference](../reference/config/theme-srf.md) for the full
list.
## See also
- [The interactive TUI](../reference/tui.md) -- tabs, actions, and the `?` help overlay.
- [`keys.srf`](../reference/config/keys-srf.md) / [`theme.srf`](../reference/config/theme-srf.md) references.
---
[Previous: A periodic review](periodic-review.md) | [Next: Offline use and refreshing data](offline-and-refresh.md) | [Documentation home](../README.md)

View file

@ -0,0 +1,80 @@
# Offline use and refreshing data
**Goal:** control when zfin talks to the network -- force a full
refresh, work entirely offline, or rely on normal cache freshness.
zfin caches every fetch under `~/.cache/zfin` (override with
`ZFIN_CACHE_DIR`) and reuses it until it goes stale. The global
`--refresh-data` flag overrides that policy for a single run.
## The three policies
`--refresh-data` must appear **before** the subcommand:
```bash
zfin --refresh-data=auto portfolio # default
zfin --refresh-data=force perf VTI
zfin --refresh-data=never analysis
```
| Value | Behavior |
|------------------|---------------------------------------------------------------------------------------------------------------|
| `auto` (default) | Respect each data type's cache TTL; fetch only what's stale. |
| `force` | Re-fetch every symbol regardless of freshness. Use after a market close, or when you suspect bad cached data. |
| `never` | Serve cache contents only; make no network calls. True offline mode. |
## Working offline
`--refresh-data=never` is the way to run on a plane or to get
deterministic output from already-cached data. Anything not in the
cache simply isn't shown:
```bash
ZFIN_HOME=examples/pre-retirement-both zfin --refresh-data=never analysis
```
Note that **quotes are never cached** (they're meant to be live), so
in `never` mode the [`quote`](../reference/cli/quote.md) command has
nothing to serve and reports the symbol as unavailable. Price-history
commands like [`perf`](../reference/cli/perf.md) and
[`portfolio`](../reference/cli/portfolio.md) work fine offline once
their candles are cached.
## How freshness works
In `auto` mode, each data type has its own time-to-live:
| Data | TTL |
|--------------------|------------------------------------------------|
| Daily candles | ~24 hours |
| Dividends / splits | 14 days |
| Options | 1 hour |
| Earnings | 30 days (refreshed early once a result is due) |
| ETF profiles | ~30 days |
| Quotes | never cached |
So a second `portfolio` run the same day is instant and network-free
without any flag. For the full rationale, see
[Caching and data freshness](../explanation/caching.md).
## Inspecting and clearing the cache
```bash
zfin cache stats # what's cached, sizes, and ages
zfin cache clear # delete all cached data
```
`cache clear` is safe -- everything re-fetches on the next run (subject
to provider rate limits). Reach for it only when you suspect corrupt
cached data; normal staleness is handled by `auto`. See
[`zfin cache`](../reference/cli/cache.md).
## See also
- [Caching and data freshness](../explanation/caching.md) -- the why.
- [Data providers and API keys](../reference/providers.md) -- rate limits that shape `force` runs.
- [Environment variables](../reference/config/environment.md) -- `ZFIN_CACHE_DIR`, `ZFIN_SERVER`.
---
[Previous: Customize the TUI](customize-the-tui.md) | [Documentation home](../README.md)

View file

@ -0,0 +1,177 @@
# A periodic review
**Goal:** on a regular cadence -- weekly works well -- make zfin agree
with your brokerages, see what changed since last time, and commit the
reconciled state so the *next* review has a clean baseline to compare
against.
This is the loop that ties the other guides together. Each step has its
own guide for the details; this one is the routine you actually run. It
settles into well under an hour once it's habit.
```
reconcile ──► what changed? ──► (projections) ──► commit
(audit) (compare) (optional) (baseline)
▲ │
└──────────────── next review ◄────────────────────────┘
```
## 1. Reconcile against your brokerages
Pull a fresh export from each brokerage that offers one and drop it in
your `audit/` folder (or wherever you've pointed `$ZFIN_AUDIT_FILES` --
see [Audit against your brokerage](audit-against-brokerage.md) for the
export steps and auto-discovery rules). Then:
```bash
zfin audit
```
Fix any flagged share or cash discrepancy in `portfolio.srf` and re-run
until it reconciles. A tight edit loop helps: if you have
[`watchexec`](https://github.com/watchexec/watchexec) installed,
```bash
watchexec -- zfin audit
```
re-runs the audit on every save, so the remaining-discrepancy list
shrinks live as you fix lots -- much faster than alt-tab, rerun, read,
alt-tab, edit.
**Reconcile first, for a reason.** Step 2 splits your change in value
into **contributions vs. market gains**, and that split is only as
honest as your share counts. Reconcile before you read the headline or
the attribution will lie to you.
### Blind spots the audit can't see
- **Accounts with no export** (some insurers, some 401(k)
recordkeepers) -- check the latest statement and update
`portfolio.srf` by hand.
- **Payroll-driven cash** -- e.g. ESPP contributions that haven't
purchased yet won't appear in a positions export until the purchase
posts. Reconcile those against a paystub.
- **A small, expected standing discrepancy** -- some accounts just sit a
few dollars off every week. Note it and move on rather than chasing it
each time.
- **Lagging transaction views.** A brokerage's *positions* view can
update overnight before its *transaction* view posts the dividend,
interest, or option assignment that caused the change. Trust the
positions numbers and reconcile the totals; the cause-side record
catches up later and isn't needed to get today's counts right.
## 2. The headline -- what changed since last time
One command gives you the whole "since last review" picture:
```bash
zfin compare 1W --projections --commit-before HEAD
```
- **`1W`** is the point of comparison -- the snapshot from one week ago.
Any [relative shortcut](#relative-dates) or an explicit `YYYY-MM-DD`
works.
- **`--projections`** folds in projected-return and safe-withdrawal
(SWR@99%) deltas, then vs. now. (Costs ~1-2s per endpoint for the
Monte Carlo search; add `--no-events` to exclude life events.)
- **`--commit-before HEAD`** pins the contributions/gains attribution to
your latest reconciliation commit. This matters -- see
[Attribution and commit timing](#attribution-and-commit-timing).
Read off the liquid-total delta, the contributions-vs-gains split, the
per-symbol winners and losers, and the projection deltas. For most
weeks, this single command *is* the review. See
[Snapshots and history](snapshots-and-history.md) for a full walk
through `compare` output.
## 3. (Optional) Full projections
`compare --projections` gives you the deltas but not the full benchmark
table or every scenario row. When you want the complete picture:
```bash
zfin projections # default: with life events (SS, college, ...)
zfin projections --no-events # baseline: life events excluded
zfin projections --as-of 1W # the same table as of last review
zfin projections --vs 1W # both ends of the window in one run
```
See [Plan for retirement](plan-retirement.md) for what these rows mean.
## 4. Commit -- the baseline for next time
```bash
git add portfolio.srf metadata.srf history/
git commit -m "review 2026-06-20"
```
Committing does double duty:
- It snapshots the reconciled `portfolio.srf`, and the day's snapshot
file in `history/` rides along, so future `--as-of` runs can read it.
- zfin walks the git history of `portfolio.srf` for
[contributions](track-contributions.md) analysis, treating each commit
as a reconciliation point. **Commit timing sets next review's
baseline** -- a same-day commit keeps a weekly cadence clean.
If you keep your portfolio directory in git (recommended), this is also
your backup and your audit trail.
## Attribution and commit timing
`compare` and `contributions` work out "contributions vs. gains" by
walking the git history of `portfolio.srf`. The positional date (`1W`)
picks the *snapshot* whose prices you compare against; **`--commit-before`**
picks which *commit* anchors the attribution. Those two can drift apart.
If you reconcile on Saturday but don't commit until Monday, next
Saturday's `1W` snapshot lands *before* your last commit -- so a bare
`compare 1W` would attribute **two weeks** of contributions to one week.
`--commit-before HEAD` sidesteps this by pinning attribution to your
most recent reconciliation commit regardless of the snapshot date. When
the dates line up anyway, the flag is harmless -- which is why it's
worth making a habit.
## Relative dates
Every date-accepting command takes the same shorthand, so you rarely
type a full date:
| Shortcut | Means |
|----------|-----------------|
| `1W` | one week ago |
| `3W` | three weeks ago |
| `1M` | one month ago |
| `1Q` | one quarter ago |
| `1Y` | one year ago |
`compare`, `contributions --since/--until`, `projections --as-of/--vs`,
`snapshot --as-of`, and `history --since/--until` all accept it (plus an
explicit `YYYY-MM-DD`).
## If there's no snapshot yet
The comparison needs a snapshot to compare against. If your daily
snapshot didn't run, or you're reviewing off your normal cadence, make
one first:
```bash
zfin portfolio --refresh # fresh close prices for tracked symbols
zfin snapshot # writes history/<today>-portfolio.srf
```
See [Snapshots and history](snapshots-and-history.md) for the cron setup
that automates daily snapshots.
## Next steps
- [Audit against your brokerage](audit-against-brokerage.md) -- step 1 in depth.
- [Snapshots and history](snapshots-and-history.md) -- `compare`, snapshots, and the timeline.
- [Track contributions](track-contributions.md) -- the contributions / gains attribution.
- [Plan for retirement](plan-retirement.md) -- the projection rows.
- [`compare`](../reference/cli/compare.md) and [`projections`](../reference/cli/projections.md) reference -- every flag.
---
[Previous: Audit against your brokerage](audit-against-brokerage.md) | [Next: Customize the TUI](customize-the-tui.md) | [Documentation home](../README.md)

View file

@ -0,0 +1,310 @@
# Plan for retirement
**Goal:** configure a `projections.srf` and read the output to answer
the two questions that matter -- "given my retirement date, what can I
spend?" and "given my desired spending, when can I retire?"
This guide uses the five bundled households. Each is fully configured;
run them and compare:
```bash
ZFIN_HOME=examples/pre-retirement-both zfin projections
```
For the field-by-field file format, see the
[`projections.srf` reference](../reference/config/projections-srf.md);
for how the simulation works under the hood, see
[The retirement projection model](../explanation/projections-model.md).
## Why this is the payoff
Everything you've recorded -- lots, accounts, contributions -- feeds the
question most tools answer badly: *can I actually retire, and on what?*
zfin answers it by **replaying history**. The engine implements the
[FIRECalc](https://firecalc.com/) algorithm over Robert Shiller's market
dataset going back to **1871**: it runs your portfolio through every
historical starting year as a separate retirement -- 1871, 1872, ... --
including the cohorts who retired into 1929, 1966, or 2000. The spread
of those real outcomes is exactly what becomes the percentile bands and
safe-withdrawal numbers below.
Because it's the same method over the same dataset, results should track
[FIRECalc.com](https://firecalc.com/) closely. For the full method and
its assumptions, see
[The retirement projection model](../explanation/projections-model.md).
## Keeping the Shiller data current
That historical dataset is **compiled into the binary** (from
`src/data/ie_data.csv`), not fetched at runtime -- so it's one of the
few things in zfin that needs a periodic refresh. Once each year's
final market and CPI numbers are published, the dataset should be
updated to add that year. zfin tracks this for you: once the dataset is
overdue, **every command** (CLI and TUI) prints a one-line reminder to
stderr, above its normal output -- so you don't have to go looking for
it. [`zfin doctor`](../reference/cli/doctor.md) also reports it in its
Environment section (a `WARN` instead of `OK`, with the date it was
last updated).
Because the data is embedded, refreshing it means updating the
**binary**, not clearing a cache:
- **Built from source:** pull the latest repo and `zig build` -- the
refreshed dataset recompiles in.
- **Pre-built binary:** install a newer release.
It's not urgent -- stale data just means you're missing the most recent
year of history, and projections still run -- so treat it as "refresh
when convenient."
## The two questions
zfin runs a two-phase historical Monte Carlo: an **accumulation** phase
(contributions in, no spending) followed by a **distribution** phase
(spending out, no contributions). What you put in `projections.srf`
decides which question the display answers:
| You configure | zfin answers | Try the example |
|----------------------------------------------------------|-------------------------------|---------------------------|
| A retirement **date** (`retirement_age`/`retirement_at`) | "What can I spend?" | `pre-retirement-age` |
| A target **spending** (`target_spending`) | "When can I retire?" | `pre-retirement-spending` |
| **Both** | both, side by side | `pre-retirement-both` |
| **Neither** | already-retired drawdown view | `post-retirement` |
Every projection also opens with a benchmark comparison and a
`Projected return` line -- your holdings' blended expected return,
which feeds the simulation.
## Question 1: "What can I spend?" (target date)
`pre-retirement-age` sets `retirement_age:num:65` and an
`annual_contribution`, but no target spending:
```bash
ZFIN_HOME=examples/pre-retirement-age zfin projections
```
The **Accumulation phase** block gives the dated headline and the
projected portfolio at retirement:
```
Accumulation phase:
Years until possible retirement: 19 (2046-04-12, ages 65/62)
Annual contributions: $80,000 (CPI-adjusted)
Median portfolio at retirement: $7,871,732.10
Range (10th90th percentile): $5,807,693.45 to $18,240,675.15
```
Below it, the **Safe Withdrawal** table shows the sustainable annual
spend at each horizon and confidence level (FIRECalc-style historical
simulation):
```
Safe Withdrawal (FIRECalc historical simulation)
25 Year 35 Year 50 Year
90% safe withdrawal $347,601 $311,857 $308,728
99% safe withdrawal $314,920 $293,374 $264,002
```
Read it as: "retiring in 2046 with this portfolio, I could withdraw
~$264k/yr and be 99% confident it lasts 50 years (historically)."
## Question 2: "When can I retire?" (target spending)
`pre-retirement-spending` sets `target_spending:num:80000` but no date.
zfin searches for the earliest year that sustains that spending and
renders the **Earliest retirement** grid:
```bash
ZFIN_HOME=examples/pre-retirement-spending zfin projections
```
```
Earliest retirement (target spending: $80,000/yr CPI-adjusted)
25 Year 35 Year 50 Year
90% confidence 2030-06-19 2030-06-19 2030-06-19
95% confidence 2030-06-19 2030-06-19 2030-06-19
99% confidence 2031-06-19 2031-06-19 2031-06-19
```
One cell is **promoted** to the Accumulation-phase headline. The
default rule picks the longest horizon at 99% confidence that keeps the
oldest person under age 100. Override it by annotating one horizon line
in `projections.srf`:
```srf
type::config,horizon:num:35,retirement_target:num:95
```
## Both questions at once
`pre-retirement-both` sets a date **and** a spending target, so both
blocks render back to back -- "I planned to retire in 2046; at these
confidence levels I could actually retire as early as 2030." The
configured date wins the headline; the grid is the comparison.
## When a plan isn't feasible
`pre-retirement-spending-target` sets an aggressive
`target_spending:num:2400000` and pins the headline to the
longest-horizon, highest-confidence cell -- which turns out to be
unreachable inside the 50-year search:
```bash
ZFIN_HOME=examples/pre-retirement-spending-target zfin projections
```
```
Accumulation phase:
Years until possible retirement: not feasible
Earliest retirement (target spending: $2,400,000/yr CPI-adjusted)
25 Year 35 Year 50 Year
99% confidence 2075-06-19 infeasible infeasible
```
The headline reports "not feasible" honestly, and the grid still shows
which cells *do* work so you can choose a reachable anchor.
## Already retired: the drawdown view
`post-retirement` configures neither input -- it's a distribution-only
household:
```bash
ZFIN_HOME=examples/post-retirement zfin projections
```
The accumulation block collapses to a single line, confirming no
pre-retirement growth is being modeled:
```
Accumulation phase:
Years until possible retirement: none
```
Everything else -- the median-value chart, terminal-value percentiles,
and safe-withdrawal table over the configured horizons -- behaves as a
pure drawdown projection.
## Life events
Social Security, pensions, tuition, and late-life healthcare are
modeled as `type::event` lines (positive = income, negative =
expense). They appear in the Life Events block and shift the cash-flow
math in both phases:
```
Life Events
Social Security (Pat) +$38,400/yr age 70 (in 25yr)
College Tuition -$55,000/yr age 50 (in 5yr), 4yr
```
See [event fields](../reference/config/projections-srf.md#event-fields).
## Check the model against reality
Once you have [snapshot history](snapshots-and-history.md) (or imported
back-values), zfin can grade its own past projections three ways:
- **Actuals overlay** -- plot your realized trajectory on top of the
bands the model *would have drawn* from a past date. Did reality stay
inside the envelope?
```bash
zfin projections --as-of 1Y --overlay-actuals
```
- **Convergence** (`--convergence`) -- as data accumulated, did the
model's predicted retirement date settle down, or keep drifting?
- **Return back-test** (`--return-backtest`) -- was the
expected-return assumption honest next to the realized forward
returns?
The CLI prints these as text and braille; the TUI draws them as real
charts (next section).
> A caveat zfin states loudly: these show whether the model was
> **directionally honest** -- did your actual path fall within the
> bands it drew -- **not** whether a safe-withdrawal claim holds over a
> full 30-year retirement. There isn't enough history to answer the
> latter, and won't be within our lifetimes.
## In the interactive TUI
The CLI gives you the numbers; the **Projections tab** in the TUI
(`zfin i`, then tab over to Projections) is where it comes alive, with
high-fidelity charts the plain terminal can't draw. Press `?` for the
full keymap; the projections-specific keys:
| Key | Does |
|-------|----------------------------------------------------------------------------------------------------------------|
| `v` | Show/hide the **percentile-band chart** -- the median line with the p10-p90 envelope across the horizon. |
| `d` | Set an **as-of date** -- back-date the whole projection to any past date (auto-snaps to the nearest snapshot). |
| `o` | Overlay your **realized actuals** on the bands (needs an as-of date plus snapshot/imported history). |
| `z` | Zoom the overlay's x-axis to roughly `[as-of, today + horizon]`. |
| `c` | **Convergence** chart -- the model's predicted retirement date over time. |
| `b` | **Return back-test** chart -- expected vs. realized forward returns. |
| `e` | Show/hide the life-events annotations. |
| `Esc` | Clear the as-of date, back to the live view. |
A typical what-if loop: open the Projections tab, press `d` and enter a
date a few years back, then `o` to drop your real trajectory onto the
bands the model would have drawn then -- a visual, honest check of how
the projection has held up. `c` and `b` then grade the model's
retirement-date and return assumptions over time.
Charts render as crisp Kitty graphics when your terminal supports it,
and fall back to braille otherwise (see
[`--chart`](../reference/cli/interactive.md) and
[The interactive TUI](../reference/tui.md)).
## Example: a complete `projections.srf`
This is the
[`pre-retirement-both`](../../examples/pre-retirement-both/projections.srf)
household: Pat (born 1981) and Sam (born 1983), retiring at 65 and
targeting $80k/yr, with both an accumulation and a distribution phase.
Copy it as a starting point and change the numbers to yours.
```srf
#!srfv1
# Accumulation phase (while still working)
type::config,retirement_age:num:65
type::config,annual_contribution:num:80000
type::config,contribution_inflation_adjusted:bool:true
# Distribution phase (in retirement)
type::config,target_stock_pct:num:80
type::config,target_spending:num:80000
type::config,target_spending_inflation_adjusted:bool:true
type::config,horizon:num:25
type::config,horizon:num:35
type::config,horizon_age:num:95
# The two people (drive ages, retirement_age, and life-event timing)
type::birthdate,date::1981-04-12
type::birthdate,date::1983-09-08,person:num:2
# Life events (positive = income, negative = expense)
type::event,name::Social Security (Pat),start_age:num:70,person:num:1,amount:num:38400
type::event,name::Social Security (Sam),start_age:num:70,person:num:2,amount:num:36000
type::event,name::College Tuition,start_age:num:50,person:num:1,duration:num:4,amount:num:-55000
```
Run it with `ZFIN_HOME=examples/pre-retirement-both zfin projections`;
every field is documented in the
[`projections.srf` reference](../reference/config/projections-srf.md).
## Next steps
- [`projections.srf` reference](../reference/config/projections-srf.md) -- every field.
- [The retirement projection model](../explanation/projections-model.md) -- the math and assumptions.
- [The interactive TUI](../reference/tui.md) -- the Projections tab and its charts.
- [Snapshots and history](snapshots-and-history.md) -- build the actuals the overlay needs.
---
[Previous: Snapshots and history](snapshots-and-history.md) | [Next: Audit against your brokerage](audit-against-brokerage.md) | [Documentation home](../README.md)

View file

@ -0,0 +1,190 @@
# Read your portfolio
**Goal:** make sense of what zfin shows you. This guide walks the five
commands you'll reach for most -- `portfolio`, `analysis`, `review`,
`exposure`, and `perf` -- and explains how to read each one.
Every example below runs against the bundled
[`pre-retirement-both`](../../examples/pre-retirement-both/) household,
so you can follow along verbatim:
```bash
ZFIN_HOME=examples/pre-retirement-both zfin portfolio
```
(Dollar figures depend on live prices, so yours will differ.)
## `portfolio`: positions and value
```bash
ZFIN_HOME=examples/pre-retirement-both zfin portfolio
```
```
Portfolio Summary (examples/pre-retirement-both/portfolio.srf)
========================================
Value: $1,383,137.81 Cost: $658,837.01 Gain/Loss: +$724,300.80 (109.9%)
Lots: 13 open, 0 closed Positions: 5 symbols
Historical: 1M: +3.2% 3M: +13.3% 1Y: +24.5% 3Y: +56.4% 5Y: +56.1% 10Y: +182.6%
Symbol Shares Avg Cost Price Market Value Gain/Loss Weight ...
VTI 2480.0 $138.35 $373.38 $925,982.40 + $582,874.40 66.9%
open 1100.0 $140.00 $410,718.00 + $256,718.00 2018-06-15 LT Pat 401k
...
```
How to read it:
- **The header line** is your liquid total, total cost, and aggregate
gain/loss.
- **Historical** is the portfolio's blended price return over trailing
windows -- a quick "how have my holdings done" gut check.
- **Each position** shows aggregated shares, average cost, current
price, market value, gain/loss, and weight. Indented `open` rows are
the individual lots, tagged **LT**/**ST** (long/short-term holding
period) and the account.
- **Cash** is summarized by account at the bottom.
Manual-priced rows render in warning color so you know the price may be
stale. Full output shape: [`zfin portfolio`](../reference/cli/portfolio.md).
### Covered calls
zfin values a written (short) call by **capping the covered shares at
the strike**, rather than pricing the option contract -- it never looks
up a live option quote. When the call is in-the-money (the stock trades
above the strike), the covered shares are valued at the strike, since
that's the price you'd be assigned at; an out-of-the-money call gets no
adjustment, and an expired or closed call stops capping.
This differs from your brokerage, which tracks the call as its own line
with its own mark-to-market gain/loss. Example: you hold 100 MSFT and
wrote a $500 call. If MSFT trades at $510, zfin values those shares at
**$50,000** (100 x the $500 strike), not $51,000 (100 x $510) -- the
$1,000 of upside above the strike now belongs to the call holder. A
brokerage would instead show $51,000 of stock plus a separate,
losing short-call position.
This is deliberate, and for a covered call used as an exit strategy
it's the more useful number: the cap is the value you'll actually
realize when the call assigns. Mark-to-market would understate it,
because an in-the-money call also carries time value you'd only pay to
*buy it back* -- which a let-it-assign writer never does. (At $510 the
call might mark around $14, so a brokerage nets $496/share even though
you'll realize the full $500 at assignment.) The flip side: zfin is
built for covered-call and buy-write investors, not active option
trading -- it deliberately doesn't track the live mark-to-market swings
of contracts you intend to trade rather than hold to assignment.
## `analysis`: allocation breakdowns
```bash
ZFIN_HOME=examples/pre-retirement-both zfin analysis
```
`analysis` answers "how is my money allocated?" along five axes:
**Asset Category**, **Sector**, **Geographic**, **By Account**, and
**By Tax Type**. Each bar is a share of your liquid total.
```
By Tax Type
Traditional (Pre-Tax) █████████████████▋ 58.9% $815,290.06
Taxable ██████▍ 21.6% $299,010.60
Roth (Post-Tax) █████ 16.9% $233,732.95
HSA (Triple Tax-Free) ▊ 2.5% $35,104.20
```
The Sector and Asset Category axes need [`metadata.srf`](classify-holdings.md);
the Tax Type axis needs [`accounts.srf`](set-up-accounts.md). Anything
missing classification lands under "Unclassified" / "Unknown."
### Umbrella exposure
A personal **umbrella insurance** policy covers liability -- lawsuits
and judgments -- above your auto/home limits, and you size it to the
assets you'd need to protect. The last block estimates that target: how
much of your liquid net worth is **exposed** to a civil judgment
because it sits outside judgment-protected retirement accounts:
```
Umbrella exposure
Total liquid: $1,383,137.81
Shielded (retirement accounts): $1,084,127.21
Exposed (taxable + non-shielded pre-tax): $299,010.60 (21.6%)
↑ approximate umbrella target
```
The default rule treats anything that isn't `taxable` as shielded.
Override per account with `shielded:bool:false` in
[`accounts.srf`](set-up-accounts.md#4-advanced-flags) -- IRA protection
varies by state and isn't modeled automatically.
## `review`: per-holding performance and risk
```bash
ZFIN_HOME=examples/pre-retirement-both zfin review
```
`review` is a dashboard: one row per holding with trailing returns,
volatility, Sharpe ratio, max drawdown, and a taxable-percentage
column, plus automated **findings** at the bottom.
```
Symbol Sector Wt% 1Y 3Y ... 3Y-SR 10Y-SR 5Y-MaxDD Tax%
VTI Diversified 66.9% +30.2% +23.3% ... 1.29 0.90 24.8% 10.9%
QQQ Technology 3.5% +43.1% +29.3% ... 1.41 1.16 32.6% 0.0%
Findings (2 active, 0 acked, 0 resolved)
⚠️ VTI at 66.9% of liquid (warn at 50.0%, flag at 70.0%)
❌️ Diversified sector at 85.7% (warn at 60.0%, flag at 75.0%)
```
The findings flag concentration, sector dominance, volatility
outliers, and tiny positions against configurable thresholds. The
status icons at the top summarize which checks fired. See
[`zfin review`](../reference/cli/review.md).
## `exposure`: look-through to a single symbol
```bash
ZFIN_HOME=examples/pre-retirement-both zfin exposure SPY
```
`exposure` answers "how much of *X* do I really own?" -- combining
direct holdings with look-through into the ETFs you hold (matched by
CUSIP against each ETF's latest reported holdings):
```
Exposure to SPY (examples/pre-retirement-both/portfolio.srf)
========================================
Total exposure 17.3% $238,956.80
Direct 17.3% $238,956.80
Look-through 0.0% $0.00
```
This is most interesting for an individual stock you also hold inside
broad-market ETFs (e.g. checking your true NVDA exposure across VTI,
SPY, and QQQ). See [`zfin exposure`](../reference/cli/exposure.md).
## `perf`: trailing returns for one symbol
```bash
ZFIN_HOME=examples/pre-retirement-both zfin perf VTI
```
`perf` shows Morningstar-style trailing returns (price-only **and**
total return), as-of the latest close and as-of the most recent
month-end, plus risk metrics. For what "total return," "annualized,"
and "month-end" mean, see
[Returns and performance](../explanation/returns-and-performance.md).
## Where to go next
- Track change over time: [Snapshots and history](snapshots-and-history.md)
- See money added: [Track contributions](track-contributions.md)
- Look ahead: [Plan for retirement](plan-retirement.md)
- Command details: the [CLI reference](../reference/cli/index.md)
---
[Previous: Map your accounts](set-up-accounts.md) | [Next: Track contributions](track-contributions.md) | [Documentation home](../README.md)

View file

@ -0,0 +1,101 @@
# Map your accounts
**Goal:** create an `accounts.srf` that tags each account with its tax
treatment (and, optionally, its institution and maintenance cadence).
This unlocks the **By Tax Type** breakdown, an umbrella-insurance
exposure estimate, and broker reconciliation.
**You'll need:** a `portfolio.srf` whose lots use `account::` labels
([build one first](set-up-your-portfolio.md)). Full field list:
[`accounts.srf` reference](../reference/config/accounts-srf.md).
## 1. List your accounts with a tax type
One record per account. The `account::` name must match the
`account::` value on your lots **exactly**. The minimum is a tax type:
```srf
#!srfv1
account::Pat 401k,tax_type::traditional
account::Pat Roth,tax_type::roth
account::Joint taxable,tax_type::taxable
account::Family HSA,tax_type::hsa
```
The four recognized tax types are `taxable`, `roth`, `traditional`,
and `hsa`. Run analysis to see the breakdown:
```bash
ZFIN_HOME=~/finance zfin analysis
```
```
By Tax Type
Traditional (Pre-Tax) █████████████████▋ 58.9% $815,290.06
Taxable ██████▍ 21.6% $299,010.60
Roth (Post-Tax) █████ 16.9% $233,732.95
HSA (Triple Tax-Free) ▊ 2.5% $35,104.20
```
Accounts you don't list show up as "Unknown."
## 2. Add institution and account number (for auditing)
If you plan to reconcile against brokerage exports
([audit guide](audit-against-brokerage.md)), add the institution and a
(placeholder) account number so zfin can match export files to
accounts:
```srf
account::Pat 401k,tax_type::traditional,institution::fidelity,account_number::P401
account::Joint taxable,tax_type::taxable,institution::schwab,account_number::JT01
```
Recognized institution keys include `fidelity`, `schwab`, `vanguard`,
and `wells_fargo`.
## 3. Tune the maintenance cadence
[`zfin audit`](../reference/cli/audit.md) (run with no flags) nags you
about accounts you haven't refreshed recently. The default cadence is
`weekly`; relax or silence it per account:
```srf
account::Family HSA,tax_type::hsa,update_cadence::quarterly
account::Old Rollover,tax_type::traditional,update_cadence::none
```
## 4. Advanced flags
Two flags change how analysis treats an account. Both are optional --
see the reference for details:
- **`shielded:bool:false`** -- mark a pre-tax account that is *not*
judgment-protected (deferred comp, a weak-state IRA) so it counts
toward your [umbrella-insurance exposure](read-your-portfolio.md#umbrella-exposure)
-- the slice of net worth a personal umbrella liability policy is
meant to cover.
- **`cash_is_contribution:bool:true`** -- treat cash increases on this
account as real external contributions in
[`zfin contributions`](track-contributions.md), instead of internal
noise.
## Example (from `examples/pre-retirement-both`)
```srf
#!srfv1
account::Pat 401k,tax_type::traditional,institution::fidelity,account_number::P401
account::Pat Roth,tax_type::roth,institution::fidelity,account_number::PROTH
account::Sam 401k,tax_type::traditional,institution::vanguard,account_number::S401
account::Joint taxable,tax_type::taxable,institution::schwab,account_number::JT01
account::Family HSA,tax_type::hsa,institution::fidelity,account_number::HSA01
```
## Next steps
- [Read your portfolio](read-your-portfolio.md) -- the breakdowns this unlocks.
- [Audit against your brokerage](audit-against-brokerage.md) -- put `institution`/`account_number` to work.
---
[Previous: Classify your holdings](classify-holdings.md) | [Next: Read your portfolio](read-your-portfolio.md) | [Documentation home](../README.md)

View file

@ -0,0 +1,193 @@
# Build your portfolio
**Goal:** create a `portfolio.srf` that captures your holdings -- the
shares you own, what you paid, and which account each lot lives in.
Everything else in zfin reads from this file.
**You'll need:** a text editor and a private directory outside the
repo (so you never commit real holdings). This guide builds up a file
much like the one in
[`examples/pre-retirement-both`](../../examples/pre-retirement-both/portfolio.srf);
the full field list lives in the
[`portfolio.srf` reference](../reference/config/portfolio-srf.md).
You only need this one file to begin. `accounts.srf`, `metadata.srf`,
`projections.srf`, and the rest are optional add-ons you layer on as
you need them -- zfin works without them, just with fewer breakdowns
(see [the `.srf` files overview](../explanation/concepts.md#the-srf-files)).
## Key terms
Three words show up throughout zfin, nesting from smallest to largest:
- **Share** -- one unit of a security (a stock, ETF, or fund); the atom
of ownership.
- **Lot** -- one purchase: a batch of shares of a single security,
bought on one date at one price, in one account. A lot is the unit of
a `portfolio.srf` line. zfin tracks lots rather than running totals so
it can derive each lot's cost basis, holding period (short- vs
long-term), and gain/loss.
- **Position** (or **holding**) -- all the lots of the same security
rolled together **across every account**: total shares, average cost,
and market value. Your VTI shows as a single position even when it's
spread across a 401(k), a Roth IRA, and a taxable brokerage -- a
whole-household, cross-account rollup most brokerages won't show you,
and a core reason to run zfin. It's one row in the portfolio summary,
with its lots indented beneath.
In short: you record **lots** (one per line) and zfin aggregates them
into **positions** for display.
## 1. Start the file
A portfolio is one lot per line -- one purchase. Create
`~/finance/portfolio.srf`:
```srf
#!srfv1
symbol::VTI,shares:num:1100,open_date::2018-06-15,open_price:num:140.00,account::Pat 401k
```
The `#!srfv1` header is required. Each line is comma-separated
`key::value` pairs; numbers use `key:num:value`.
Check it:
```bash
ZFIN_HOME=~/finance zfin portfolio
```
## 2. Add more lots
Add one line per purchase. Multiple lots of the same symbol aggregate
into a single position automatically, so record each buy at its own
cost basis rather than averaging by hand:
```srf
#!srfv1
symbol::VTI,shares:num:1100,open_date::2018-06-15,open_price:num:140.00,account::Pat 401k
symbol::VTI,shares:num:240,open_date::2015-01-08,open_price:num:103.40,account::Pat Roth
symbol::AGG,shares:num:600,open_date::2020-01-10,open_price:num:115.50,account::Pat 401k
symbol::QQQ,shares:num:65,open_date::2019-11-22,open_price:num:200.10,account::Pat Roth
```
zfin shows each position with its lots, market value, and gain/loss:
```
VTI 2480.0 $138.35 $373.38 $925,982.40 + $582,874.40 66.9%
open 1100.0 $140.00 $410,718.00 + $256,718.00 2018-06-15 LT Pat 401k
open 240.0 $103.40 $89,611.20 + $64,795.20 2015-01-08 LT Pat Roth
```
## 3. Record cash
Cash, money-market, and settlement balances are lots with
`security_type::cash`. They need no symbol, open date, or price:
```srf
security_type::cash,shares:num:48000.00,open_date::2026-04-30,open_price:num:1.00,account::Joint taxable
```
Cash is grouped by account in its own section of the portfolio
summary.
## 4. Use accounts consistently
The `account::` value is just a label, but it has to match **exactly**
across lots (and later in [`accounts.srf`](set-up-accounts.md)). Pick
names and reuse them verbatim: `Pat 401k`, `Joint taxable`,
`Family HSA`. Account names drive the By-Account and (with
`accounts.srf`) the By-Tax-Type breakdowns in
[`zfin analysis`](read-your-portfolio.md).
## 5. Special holdings
zfin handles more than stocks and cash. Each is one line; see the
[reference](../reference/config/portfolio-srf.md) for full field
lists:
- **Sold positions** -- add `close_date` and `close_price` to a lot.
- **Options** -- `security_type::option` with a readable symbol plus
explicit `option_type` / `underlying` / `strike` / `maturity_date`
fields; negative `shares` to write (sell) a contract. See the
covered-call example below.
- **CDs** -- `security_type::cd` with `maturity_date` and `rate`.
- **Illiquid assets** (home, vehicle) -- `security_type::illiquid`;
counted in Net Worth but not the liquid total.
- **Securities the providers don't cover** (e.g. a 401k CIT share
class) -- add a manual `price::` and `price_date::`, or a
`ticker::` alias for pricing. See
[price resolution](../reference/config/portfolio-srf.md#price-resolution).
### Example: a covered call
Options trip people up, so here's a worked one. A *covered call* is two
lots -- the shares you own, plus one call you write (sell) against them.
Say you hold 100 shares of MSFT and sell a call:
```srf
#!srfv1
# The 100 shares you own -- the "covered" part
symbol::MSFT,shares:num:100,open_date::2024-02-01,open_price:num:400.00,account::Joint taxable
# One call written against them
security_type::option,symbol::MSFT 06/19/2026 500.00 C,shares:num:-1,open_date::2026-01-15,open_price:num:6.68,option_type::call,underlying::MSFT,strike:num:500,maturity_date::2026-06-19,account::Joint taxable
```
Reading the option lot:
- **`shares:num:-1`** -- you *wrote* one contract; negative means short
(sold). Each contract covers 100 shares.
- **`open_price:num:6.68`** -- the premium you received, per share
($6.68 x 100 = $668 for the contract).
- **`option_type` / `underlying` / `strike` / `maturity_date`** define
the contract. `symbol` is just a human-readable label -- use whatever
your brokerage shows (here `MSFT 06/19/2026 500.00 C`).
- It's "covered" because the 100 MSFT shares in the same account back
the call. When it's closed or expires, add `close_date` and
`close_price` (use `0` if it expired worthless and you kept the
premium).
**zfin values this differently from your brokerage.** Your brokerage
tracks the call as its own security with its own gain/loss. zfin
doesn't price the contract at all; instead it caps the covered shares
at the strike while the call is in-the-money. So if MSFT trades at $510
here, zfin values these 100 shares at **$50,000** (the $500 strike),
not $51,000 -- the upside above the strike belongs to the call holder.
See [covered-call valuation](read-your-portfolio.md#covered-calls) for
the full rule.
## 6. Optional: split across multiple files
You can keep holdings in several files -- `portfolio.srf`,
`portfolio_401k.srf`, `portfolio_taxable.srf`. zfin union-merges every
`portfolio*.srf` in `ZFIN_HOME` by default, so the CLI and TUI both see
one combined view. Target a subset with `-p`:
```bash
zfin -p 'portfolio_*.srf' portfolio # quote the glob so the shell doesn't expand it
zfin -p portfolio.srf -p portfolio_hsa.srf portfolio
```
**A good first split: closed positions.** When you sell, move the
closed (sold) lots into a `portfolio_closed_positions.srf`. Your main
`portfolio.srf` then shows only what you currently hold, while the sold
lots still merge in -- so realized gain/loss and back-dated
(`--as-of`) snapshots stay accurate. Because the filename matches
`portfolio*.srf`, it's picked up automatically -- no flag needed.
```srf
#!srfv1
# Sold lots live here so portfolio.srf stays focused on current holdings.
symbol::AMZN,shares:num:10,open_date::2022-03-15,open_price:num:150.25,close_date::2024-01-15,close_price:num:185.50,account::Joint taxable
```
## Next steps
- [Classify your holdings](classify-holdings.md) so analysis can break
down your allocation.
- [Map your accounts](set-up-accounts.md) to unlock the tax-type view.
- [Read your portfolio](read-your-portfolio.md) to interpret the output.
---
[Next: Classify your holdings](classify-holdings.md) | [Documentation home](../README.md)

View file

@ -0,0 +1,149 @@
# Snapshots and history
**Goal:** record your portfolio's value over time and review how it has
changed -- day to day, year over year, and between any two dates.
**You'll need:** a working portfolio. The
[`post-retirement`](../../examples/post-retirement/) example ships with
a `history/` folder of snapshots, so you can explore the read side
immediately:
```bash
ZFIN_HOME=examples/post-retirement zfin history
```
## The idea
zfin doesn't track your value automatically -- it reads what you have
*right now*. To build a time series, you write a **snapshot** each day
(or week), and the history/compare commands read those snapshots back.
Snapshots live in `<portfolio-dir>/history/<date>-portfolio.srf`. Each
is an immutable record of totals, per-account values, and lot-level
state for one date.
## 1. Write a snapshot
The simplest way is to run it yourself.
[`zfin snapshot`](../reference/cli/snapshot.md) computes today's
snapshot and writes it under `history/`. Try a dry run first -- it
computes and prints the snapshot without writing anything:
```bash
zfin snapshot --dry-run # compute + print, write nothing
zfin snapshot # write history/<today>-portfolio.srf
```
Run it whenever you want a record on the books -- after a monthly
review, say. The snapshot is just a file in `history/`; nothing else is
required.
**Back-filling a past date (optional, needs git).** If you keep your
portfolio file under [git](https://git-scm.com/) version control,
`--as-of` can reconstruct a snapshot for an earlier date: it recovers
your portfolio as it was then (from git) and prices it from the cached
price history.
```bash
zfin snapshot --as-of 2025-01-02
```
If you don't use git, skip this -- just snapshot going forward.
## 2. Keep it current automatically (optional)
Most people would rather not remember to run it every day. You can hand
that off to your operating system's **task scheduler** -- a built-in
service that runs a command on a set timetable, even while you're away.
**When to schedule it.** Run it in the early morning, *after* the prior
trading day's closing prices have posted. ETF and mutual-fund values
update overnight, and while data providers say "after midnight," in
practice **3:30am US Eastern** is the first time yesterday's closing
NAVs reliably land -- schedule it earlier and you risk capturing stale
prices.
**macOS and Linux** use `cron`. Run `crontab -e` and add a line like
this (3:30am, Monday-Friday -- adjust the hour for your machine's
timezone if it isn't US Eastern):
```cron
30 3 * * 1-5 cd ~/finance && /usr/local/bin/zfin snapshot
```
The five fields are minute, hour, day-of-month, month, day-of-week
(`1-5` = Mon-Fri); the full path to `zfin` matters because cron runs
with a minimal `PATH`. macOS ships cron, though the first run may prompt
you to grant your terminal "Full Disk Access."
**Windows** has no `cron` -- use Task Scheduler to run `zfin snapshot`
daily. (zfin should run on Windows, but it isn't regularly tested
there.)
## 3. Review the timeline with `history`
Run [`zfin history`](../reference/cli/history.md) with no symbol for the
portfolio-value timeline: rolling-window changes, a braille chart, and
a recent-snapshots table.
```bash
ZFIN_HOME=examples/post-retirement zfin history
```
```
Portfolio Timeline — Liquid
========================================
Change Δ % % / yr
1 year +$230,000.00 +9.79% +9.79%
3 years +$459,059.08 +21.64% +6.72%
5 years +$686,215.45 +36.24% +6.37%
All-time +$1,073,725.79 +71.28% +6.00%
```
Useful flags: `--metric liquid|illiquid|net_worth`, `--since` /
`--until` to bound the window, and `--resolution daily|weekly|monthly`.
> The percentage change includes contributions and withdrawals, not
> just market movement. To separate new money from market gains, use
> [contributions](track-contributions.md).
## 4. Compare two points with `compare`
[`zfin compare`](../reference/cli/compare.md) diffs two dates: liquid
totals, per-symbol price moves, and -- when your portfolio is tracked
in git -- contribution attribution. Pass one date to compare against
the live portfolio, or two dates to compare historical snapshots:
```bash
ZFIN_HOME=examples/post-retirement zfin compare 2024-04-01 2025-04-01
```
```
Portfolio comparison: 2024-04-01 → 2025-04-01 (365 days)
Liquid: $2,350,000.00 → $2,580,000.00 +$230,000.00 +9.79%
```
Arguments can be given in any order; output always reads older ->
newer. On a missing snapshot date, `compare` prints the nearest
available dates and exits rather than silently snapping. Add
`--projections` to include projected-return and safe-withdrawal deltas.
## Back-history without daily snapshots
If you have historical totals from a spreadsheet but no per-day
snapshots, record them in
`history/imported_values.srf` (one `liquid::` total per date). The
history and projection-overlay tools read it as a lower-fidelity
fallback when no native snapshot covers a date. The post-retirement
example includes one spanning 2016-2024.
## Next steps
- [Track contributions](track-contributions.md) -- separate new money from gains.
- [Plan for retirement](plan-retirement.md) -- overlay actuals on projections.
- [`zfin snapshot`](../reference/cli/snapshot.md) / [`zfin history`](../reference/cli/history.md) / [`zfin compare`](../reference/cli/compare.md)
---
[Previous: Track contributions](track-contributions.md) | [Next: Plan for retirement](plan-retirement.md) | [Documentation home](../README.md)

View file

@ -0,0 +1,117 @@
# Track contributions
**Goal:** see how much new money you've added (or withdrawn) over a
period, separated from market movement -- and stop internal transfers
between your own accounts from being counted as contributions.
**You'll need:** your portfolio under **git** version control, with
commits over time. `contributions` works by diffing two revisions of
your `portfolio.srf`, so it only sees money movement you've committed.
## How it works
Market gains change your portfolio's *value*; contributions change its
*shares and lots*. [`zfin contributions`](../reference/cli/contributions.md)
diffs your portfolio file between two git revisions, attributes the
share/lot changes to contributions vs. withdrawals, and ignores price
movement.
This means the workflow is: **keep `portfolio.srf` in a git repo, and
commit it whenever you update it.** A natural cadence is a commit per
account update or per weekly review.
> **Do this bookkeeping when the market is closed -- a weekend is
> ideal.** Prices and balances have settled, your brokerage statements
> are final, and zfin's cached closing prices won't shift under you
> mid-run, so the numbers are stable and reproducible. The same applies
> to [`audit`](audit-against-brokerage.md): reconciling against an
> export lines up cleanly when both sides reflect the same settled
> close, rather than a moving intraday price.
## The default modes
With no flags, the comparison depends on your working tree:
```bash
zfin contributions
```
- **Clean working tree:** compares `HEAD~1` against `HEAD` -- i.e.
"what changed in my last commit."
- **Dirty working tree:** compares `HEAD` against the working copy --
i.e. "what have I edited but not yet committed."
Against a freshly-checked-out example (clean, nothing to diff) you'll
see:
```
Portfolio contributions report
Working tree clean — comparing HEAD~1 against HEAD
No changes detected.
```
## Choosing a window
Use `--since` (and optionally `--until`) to pick the endpoints. Dates
accept `YYYY-MM-DD` or relative shortcuts (`1W`, `1M`, `1Q`, `1Y`):
```bash
zfin contributions --since 1Y # a year ago vs. now
zfin contributions --since 2025-01-01 --until 2025-12-31
```
Each date resolves to the commit at or before it. When a review date
and its commit date diverge (you committed two days after your Sunday
review), pin commits directly with `--commit-before` / `--commit-after`
(which accept `HEAD`, `HEAD~N`, a SHA, or `working`).
## Don't double-count transfers
Moving money between two accounts you own isn't a contribution, but a
naive diff sees the receiving account gain lots and counts it as new
money. `transaction_log.srf` is how you tell zfin "this was a transfer,
not new money."
Like the other sibling files, it's **optional and additive**: you only
need it if you move money between your own accounts and want clean
attribution. Without it nothing breaks -- those transfers just show up
as contributions, inflating the total by the amount moved. If you never
shuffle money between accounts, skip the file entirely.
When you do, declare each move in
[`transaction_log.srf`](../reference/config/transaction-log-srf.md) so
it cancels out:
```srf
#!srfv1
transfer::2026-05-02,type::cash,amount:num:50000,from::Joint taxable,to::Pat Roth,dest_lot::cash
```
zfin matches the transfer against the diff and removes it from the
attribution total. Only `type::cash` is wired up today; `in_kind`
parses but isn't yet supported.
## Cash that *is* a contribution
By default, raw cash-balance increases are treated as internal noise
(interest, dividends, sweeps). For accounts whose cash movement is
dominated by real external deposits (payroll ESPP, direct 401k cash),
set `cash_is_contribution:bool:true` in
[`accounts.srf`](set-up-accounts.md#4-advanced-flags) so those increases
count.
## Related: `compare`
[`zfin compare`](../reference/cli/compare.md) shows the same
attribution alongside value and per-symbol price moves between two
dates. See [Snapshots and history](snapshots-and-history.md).
## Next steps
- [Snapshots and history](snapshots-and-history.md) -- record value over time.
- [`transaction_log.srf` reference](../reference/config/transaction-log-srf.md)
---
[Previous: Read your portfolio](read-your-portfolio.md) | [Next: Snapshots and history](snapshots-and-history.md) | [Documentation home](../README.md)

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View file

@ -0,0 +1,51 @@
# `zfin analysis`
Break your portfolio down by asset class, sector, geography, account,
and tax type.
```
Usage: zfin analysis [FILE]
```
Renders a bar breakdown along five axes, plus an umbrella-exposure
estimate. The Asset Category / Sector / Geographic axes use
[`metadata.srf`](../config/metadata-srf.md); the Tax Type axis uses
[`accounts.srf`](../config/accounts-srf.md). Unclassified holdings and
unmapped accounts are shown as such.
## Example
```bash
ZFIN_HOME=examples/pre-retirement-both zfin analysis
```
```
Asset Category
Equity ██████████████████████████▋ 89.2% $1,233,151.30
Fixed Income ██ 7.0% $96,922.00
Cash █▏ 3.8% $53,064.51
By Tax Type
Traditional (Pre-Tax) █████████████████▋ 58.9% $815,290.06
Taxable ██████▍ 21.6% $299,010.60
Roth (Post-Tax) █████ 16.9% $233,732.95
HSA (Triple Tax-Free) ▊ 2.5% $35,104.20
Umbrella exposure
Total liquid: $1,383,137.81
Shielded (retirement accounts): $1,084,127.21
Exposed (taxable + non-shielded pre-tax): $299,010.60 (21.6%)
```
The umbrella block's shielded/exposed split can be overridden per
account with `shielded:bool:false` in `accounts.srf`.
## See also
- [Read your portfolio](../../guides/read-your-portfolio.md#analysis-allocation-breakdowns)
- [Classify your holdings](../../guides/classify-holdings.md) / [Map your accounts](../../guides/set-up-accounts.md)
- [`review`](review.md) / [`exposure`](exposure.md)
---
[CLI command reference](index.md)

View file

@ -0,0 +1,58 @@
# `zfin audit`
Two modes in one command: a portfolio hygiene check, and reconciliation
against a brokerage export.
```
Usage: zfin audit [opts]
```
**Flagless** runs the hygiene check -- stale manual prices,
accounts overdue for update, and auto-discovered brokerage-file
candidates. **With brokerage flags**, it reconciles your portfolio
against the export (treating the brokerage as source of truth) and
reports discrepancies.
## Options
| Flag | Effect |
|--------------------|----------------------------------------------------------------------------|
| `--verbose` | Show full reconciliation output even when clean. |
| `--stale-days <N>` | Manual-price staleness threshold (default 3). |
| `--fidelity <CSV>` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). |
| `--schwab <CSV>` | Schwab per-account positions CSV. |
| `--schwab-summary` | Schwab account summary: paste from the summary page to stdin, then Ctrl-D. |
Reconciliation matches export accounts to yours via `institution::` and
`account_number::` in [`accounts.srf`](../config/accounts-srf.md); an
unmatched account is reported as "unmapped."
## Example (hygiene check)
```bash
ZFIN_HOME=examples/pre-retirement-both zfin audit
```
```
Portfolio hygiene
Stale manual prices (>3 days — --stale-days to configure)
(none)
Accounts overdue for update (weekly default — set update_cadence in accounts.srf)
Sam 401k weekly no update history found
Joint taxable weekly no update history found
```
> Brokerage exports contain real account numbers and holdings. Keep
> them out of any git repo and delete them after reconciling.
## See also
- [Audit against your brokerage](../../guides/audit-against-brokerage.md) -- the workflow.
- [`import`](import.md) -- build a portfolio file *from* an export instead.
- [`accounts.srf` reference](../config/accounts-srf.md) -- `update_cadence`, `institution`, `account_number`.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,35 @@
# `zfin cache`
Inspect or clear the local provider-data cache.
```
Usage: zfin cache <stats|clear>
```
| Subcommand | Does |
|------------|------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `stats` | List every cached symbol with per-data-type size, age, and freshness state. Stale entries (past TTL) are flagged. Includes `cusip_tickers.srf` if present. |
| `clear` | Delete every file under the cache directory. No confirmation; the next provider call re-fetches everything. |
The cache directory is `$ZFIN_CACHE_DIR` if set, otherwise
`~/.cache/zfin`.
## Examples
```bash
zfin cache stats
zfin cache clear # wipe; everything re-fetches on next use
```
`clear` is safe -- it only removes cached copies of public market data.
Reach for it when you suspect corrupt cached data; routine staleness is
handled automatically by the `auto` refresh policy.
## See also
- [Caching and data freshness](../../explanation/caching.md) -- TTLs and the fetch model.
- [Offline use and refreshing data](../../guides/offline-and-refresh.md) -- the `--refresh-data` flag.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,50 @@
# `zfin compare`
Compare your portfolio at two points in time: liquid totals,
per-symbol price moves, and contribution attribution.
```
Usage:
zfin compare <DATE> # compare DATE vs. live portfolio
zfin compare <DATE1> <DATE2> # compare two historical dates
```
Arguments can be given in any order; output always reads older ->
newer. Dates accept `YYYY-MM-DD` or relative shortcuts
(`1W`/`1M`/`1Q`/`1Y`). Historical dates resolve against your
`history/*-portfolio.srf` snapshots; on a missing date, `compare`
prints the nearest available dates and exits rather than snapping
silently.
## Options
| Flag | Effect |
|--------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------|
| `--projections` | Add projected-return and 99% safe-withdrawal deltas (adds ~1-2s per endpoint). |
| `--no-events` | With `--projections`, exclude life events. |
| `--snapshot-before <DATE>` / `--snapshot-after <DATE>` | Override a side's snapshot (`--snapshot-after live` for the current portfolio). |
| `--commit-before <SPEC>` / `--commit-after <SPEC>` | Pin the git commit for the attribution block (`HEAD`, `HEAD~N`, SHA, or `working`). Useful when a review date and its commit diverge. |
## Example
```bash
ZFIN_HOME=examples/post-retirement zfin compare 2024-04-01 2025-04-01
```
```
Portfolio comparison: 2024-04-01 → 2025-04-01 (365 days)
Liquid: $2,350,000.00 → $2,580,000.00 +$230,000.00 +9.79%
```
With symbols held on both dates, a per-symbol price-change table
appears, sorted by percentage move.
## See also
- [Snapshots and history](../../guides/snapshots-and-history.md)
- [`history`](history.md) -- the full timeline. [`contributions`](contributions.md) -- the attribution detail.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,55 @@
# `zfin contributions`
Show contributions, withdrawals, and lot-level changes between two
points in your portfolio's **git history**.
```
Usage: zfin contributions [opts]
```
`contributions` diffs two git revisions of your `portfolio.srf` and
attributes the share/lot changes to new money vs. market movement. Your
portfolio must be under git with commits over time.
## Modes
| Invocation | Window |
|-----------------------------|-----------------------------------------------------------|
| (no flags), dirty tree | `HEAD` vs. working copy |
| (no flags), clean tree | `HEAD~1` vs. `HEAD` (review the last commit) |
| `--since <DATE>` | commit at/before DATE vs. HEAD (or working copy if dirty) |
| `--since <D1> --until <D2>` | commit at/before D1 vs. commit at/before D2 |
`--until` alone is rejected (the window is ambiguous). Dates accept
`YYYY-MM-DD` or `1W`/`1M`/`1Q`/`1Y`.
## Options
| Flag | Effect |
|--------------------------|-------------------------------------------------------------------------------------|
| `--since <DATE>` | Earliest side (resolves to commit at/before). |
| `--until <DATE>` | Latest side (pair with `--since`). |
| `--commit-before <SPEC>` | Pin the before commit directly (same grammar as `--commit-after`, minus `working`). |
| `--commit-after <SPEC>` | Pin the after commit: `YYYY-MM-DD`, relative, `HEAD`, `HEAD~N`, SHA, or `working`. |
Pass at most one of `--since`/`--commit-before` (same axis), and at
most one of `--until`/`--commit-after`.
## Example
```bash
zfin contributions --since 1Y
```
Internal transfers between your own accounts are excluded from the
attribution total when declared in
[`transaction_log.srf`](../config/transaction-log-srf.md).
## See also
- [Track contributions](../../guides/track-contributions.md) -- the full workflow.
- [`compare`](compare.md) -- attribution alongside value and price moves.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,37 @@
# `zfin divs`
Show dividend history for a symbol, with trailing-twelve-month yield.
```
Usage: zfin divs <SYMBOL>
```
Lists each dividend's ex-date, amount, pay date, and type (regular,
special, etc.). Dividend data comes from Polygon (`POLYGON_API_KEY`),
merged with per-row dividend data from Tiingo's price series.
## Example
```bash
ZFIN_HOME=examples/pre-retirement-both zfin divs SCHD
```
```
Dividend History for SCHD
========================================
Ex-Date Amount Pay Date Type
------------ ---------- ------------ ----------
2026-03-25 0.2569 2026-03-30 regular
2025-12-10 0.2782 2025-12-15 regular
2025-09-24 0.2604 2025-09-29 regular
...
```
## See also
- [`splits`](splits.md) -- corporate splits for a symbol.
- [`perf`](perf.md) -- total return, which includes reinvested dividends.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,102 @@
# `zfin doctor`
Health-check your zfin setup -- files and environment -- without
changing anything.
```
Usage: zfin doctor
```
`doctor` answers "is my setup sane?" It is **read-only**: no provider
fetches, no cache writes, no portfolio changes. The only network call
is an optional `GET {ZFIN_SERVER}/help` to confirm the server is
reachable, so it's safe to run in CI or cron.
## What it checks
Four sections, each line tagged `OK` / `INFO` / `WARN` / `FAIL`:
- **Files** -- every config file: is it present, where was it resolved
from, and does it parse?
- **Cross-checks** -- do `accounts.srf` / `metadata.srf` /
`transaction_log.srf` reference entries that actually exist (e.g.
every account used by a lot has an `accounts.srf` entry)?
- **Environment** -- cache size, staleness of the hand-maintained data
tables, and `ZFIN_SERVER` reachability and version.
- **Capabilities** -- which API keys are set and what each enables (or
what you give up without it).
## Exit code
`0` unless a file that **exists** fails to parse (a `FAIL`), in which
case it exits `1`. Missing optional files, cross-reference gaps, stale
data, an unreachable server, and absent API keys are all non-fatal
(`INFO` / `WARN`) -- so a clean install with only `portfolio.srf` still
exits `0`.
## Example
```bash
ZFIN_HOME=examples/pre-retirement-both zfin doctor
```
```
zfin doctor
Files
[OK ] examples/pre-retirement-both/portfolio.srf: 20 lots
[OK ] accounts.srf: examples/pre-retirement-both/accounts.srf
[OK ] metadata.srf: examples/pre-retirement-both/metadata.srf
[INFO] transaction_log.srf: not present
[OK ] projections.srf: examples/pre-retirement-both/projections.srf
[INFO] history/imported_values.srf: not present
[INFO] history/ snapshots: no history/ directory
[INFO] keys.srf: not present; using built-in defaults
[INFO] theme.srf: not present; using built-in defaults
Cross-checks
[OK ] accounts.srf coverage: all referenced entries present
[OK ] metadata.srf coverage: all referenced entries present
[INFO] transaction_log.srf references: skipped (transaction_log.srf not loaded)
Environment
[OK ] Cache: 42 symbols, 168 files, 23.4 MB (~/.cache/zfin)
[OK ] T-bill risk-free rate table: current
[OK ] Shiller annual returns (ie_data.csv): current
[OK ] Review tab MaxDD color thresholds: current
[OK ] Observation engine thresholds: current
[OK ] ZFIN_SERVER: reachable: zfin-server abc1234 (https://zfin.example.com)
Capabilities
[OK ] TIINGO_API_KEY: daily candles
[OK ] POLYGON_API_KEY: dividend/split history + dividend-reinvested total return
[OK ] FMP_API_KEY: earnings history and estimates
[OK ] TWELVEDATA_API_KEY: quote fallback after Yahoo
[OK ] ZFIN_USER_EMAIL: ETF profiles and `enrich`
[OK ] OPENFIGI_API_KEY: faster CUSIP lookups (higher rate limit)
[OK ] Quotes (Yahoo): always available, no key required
[OK ] Options (CBOE): always available, no key required
Summary: 20 OK, 0 warning(s), 0 failure(s)
```
A key you haven't set is not an error -- it shows as `INFO` with what
you give up, for example:
```
[INFO] POLYGON_API_KEY: price-only returns; no dividend/split history
[INFO] FMP_API_KEY: no earnings data
```
With `ZFIN_SERVER` unset, the Environment section shows
`[INFO] ZFIN_SERVER: not set (provider fetch only; no server sync)`.
## See also
- [Environment variables](../config/environment.md) -- the keys and paths doctor inspects.
- [Data providers and API keys](../providers.md) -- where to get each key.
- [Caching and data freshness](../../explanation/caching.md) -- the cache and the `ZFIN_SERVER` tier doctor reports on.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,45 @@
# `zfin earnings`
Show earnings history (with EPS surprise) and upcoming events for a
symbol.
```
Usage: zfin earnings <SYMBOL>
```
Lists each quarter's report date, EPS estimate, actual EPS, and the
surprise (absolute and percent), plus upcoming scheduled dates. Data
comes from FMP (`FMP_API_KEY`).
ETFs, mutual funds, CUSIPs, and some dual-class shares (e.g. BRK.B)
have no earnings on FMP's free tier and show as "no earnings data" --
an expected limitation, not a bug.
## Example
```bash
zfin earnings AAPL
```
```
Earnings History for AAPL
========================================
Date Q EPS Est EPS Act Surprise Surprise %
------------ ---- ------------ ------------ ------------ ----------
2026-07-30 Q2 $1.75 -- -- --
2026-04-30 Q1 $1.95 $2.01 +$0.0600 +3.1%
2026-01-29 Q4 $2.67 $2.85 +$0.1800 +6.7%
...
```
A row with `--` in the actual column is a scheduled, not-yet-reported
quarter.
## See also
- [Why multiple data providers](../../explanation/data-providers.md) -- earnings coverage limits.
- [`etf`](etf.md) -- the profile view for funds (which have no earnings).
---
[CLI command reference](index.md)

View file

@ -0,0 +1,48 @@
# `zfin enrich`
Bootstrap a [`metadata.srf`](../config/metadata-srf.md) classification
file from public Wikidata + SEC EDGAR data.
```
Usage: zfin enrich [SYMBOL]
```
Requires `ZFIN_USER_EMAIL` (SEC EDGAR needs a contact address). Two
modes:
- **Portfolio mode** (no argument) -- classify every stock symbol in
your portfolio and write a complete SRF file to stdout. Honors the
global `-p` flag for file selection.
- **Symbol mode** (one `SYMBOL`) -- emit one appendable line for a
single symbol.
## Sources
- **Wikidata SPARQL** -- sector / industry / country / asset class,
plus a CIK lookup for the EDGAR call.
- **EDGAR XBRL company facts** -- shares outstanding, combined with the
latest cached close to derive market-cap size buckets for US stocks.
- **EDGAR mutual-fund ticker map** -- fallback when Wikidata has no
entry (open-end funds aren't exchange-listed); fills in
`geo::US,asset_class::Fund`.
## Examples
```bash
zfin enrich > metadata.srf # whole portfolio (default file)
zfin -p sample enrich > metadata.srf # whole portfolio (named file)
zfin enrich AAPL >> metadata.srf # append a single symbol
zfin enrich fagix >> metadata.srf # symbol is auto-uppercased
```
Always review the output before saving -- symbols that miss both
sources come through as `TODO` lines to complete by hand.
## See also
- [Classify your holdings](../../guides/classify-holdings.md) -- the workflow.
- [`metadata.srf` reference](../config/metadata-srf.md) -- the file this produces.
---
[CLI command reference](index.md)

47
docs/reference/cli/etf.md Normal file
View file

@ -0,0 +1,47 @@
# `zfin etf`
Show an ETF's profile: net assets, holdings count, sector allocation,
and top holdings.
```
Usage: zfin etf <SYMBOL>
```
Profile data comes from SEC EDGAR's NPORT-P filings, so it requires
`ZFIN_USER_EMAIL` (EDGAR mandates a contact address in the request).
## Example
```bash
ZFIN_HOME=examples/pre-retirement-both zfin etf SPY
```
```
ETF Profile: SPY
========================================
Net Assets: $651.6B
Total Holdings: 20
Sector Allocation:
98.1% Equity / Corporate
1.9% Equity / Other
Top Holdings:
Symbol Weight Name
-- 7.58% NVIDIA Corp
-- 6.66% Apple Inc
-- 4.91% Microsoft Corp
...
```
The same NPORT-P holdings power [`exposure`](exposure.md)'s
look-through analysis.
## See also
- [`exposure`](exposure.md) -- your true exposure to a symbol through ETFs.
- [`enrich`](enrich.md) -- uses EDGAR data to classify holdings.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,44 @@
# `zfin exposure`
Show how much of a single underlying symbol you really hold -- directly
plus look-through via the top holdings of every ETF in your portfolio.
```
Usage: zfin exposure <SYMBOL>
```
A fund worth $V that holds the symbol at weight w contributes V*w of
exposure. ETF holdings are matched to the symbol by ticker (from the
NPORT-P filing) or by resolving the holding's CUSIP to a ticker
(local cache -> `ZFIN_SERVER` -> OpenFIGI). Holdings with no ticker and
no resolvable CUSIP (bonds, derivatives) are excluded.
ETF profiles come from SEC EDGAR (cached ~90 days), so the first run on
a cold cache fetches them and can take ~15s. Requires `ZFIN_USER_EMAIL`.
## Example
```bash
ZFIN_HOME=examples/pre-retirement-both zfin exposure SPY
```
```
Exposure to SPY (examples/pre-retirement-both/portfolio.srf)
========================================
Total exposure 17.3% $238,956.80
Direct 17.3% $238,956.80
Look-through 0.0% $0.00
```
This is most useful for an individual stock you also hold inside broad
ETFs -- e.g. `zfin exposure NVDA` to see your true concentration across
VTI, SPY, and QQQ.
## See also
- [Read your portfolio](../../guides/read-your-portfolio.md#exposure-look-through-to-a-single-symbol)
- [`etf`](etf.md) -- the holdings that drive look-through.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,57 @@
# `zfin history`
Two modes in one command, selected by whether you pass a symbol.
```
Usage:
zfin history <SYMBOL> # last 30 days of candles
zfin history [flags] # portfolio-value timeline
```
## Symbol mode
With a positional symbol, shows the last 30 trading days of OHLCV
candles:
```bash
ZFIN_HOME=examples/pre-retirement-both zfin history VTI
```
```
Price History for VTI (last 30 days)
========================================
Date Open High Low Close Volume
2026-06-04 370.62 373.99 370.36 373.38 3,111,996
...
```
## Portfolio mode
With no symbol, reads your `history/*-portfolio.srf` snapshots and
renders rolling-window returns, a braille chart, and a recent-snapshots
table. (Build that history with [`snapshot`](snapshot.md).)
| Flag | Effect |
|-----------------------|-------------------------------------------------|
| `--since <DATE>` | Earliest as-of date (inclusive). |
| `--until <DATE>` | Latest as-of date (inclusive). |
| `--metric <name>` | `liquid` (default), `illiquid`, or `net_worth`. |
| `--resolution <name>` | `daily` \| `weekly` \| `monthly` \| `auto`. |
| `--limit <N>` | Cap the recent-snapshots table (default 40). |
| `--rebuild-rollup` | Regenerate `history/rollup.srf` and exit. |
Dates accept `YYYY-MM-DD` or relative shortcuts (`1W`/`1M`/`1Q`/`1Y`).
```bash
ZFIN_HOME=examples/post-retirement zfin history --metric net_worth --since 1Y
```
## See also
- [Snapshots and history](../../guides/snapshots-and-history.md) -- the workflow.
- [`compare`](compare.md) -- diff two specific dates.
- [`snapshot`](snapshot.md) -- record the data this reads.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,51 @@
# `zfin import`
Synthesize a portfolio file from a brokerage positions export. Designed
for managed accounts (direct-indexing baskets, accounts you don't track
at lot granularity).
```
Usage: zfin -p <PORTFOLIO> import (--fidelity FILE | --schwab FILE | --wells-fargo FILE [--account NAME]) [-y]
```
Each run **replaces** the target portfolio file with synthetic lots --
one per (account, symbol) -- drawn from the export. Per-buy history is
lost; git serves as the file-level history.
**Re-import merge:** when the target already exists, lots still present
in the new export keep their prior `open_date`, `open_price`, and
`note::`, so trailing-return and ST/LT classifications stay stable and
`git diff` flags only genuine brokerage changes. New positions get an
`open_date::1970-01-01` sentinel; disappeared positions are dropped.
Hand-edited fields (`price::`, `ticker::`) are **not** preserved.
## Options
| Flag | Effect |
|--------------------------|--------------------------------------------------------------------------------------|
| `-p, --portfolio <FILE>` | Target file (a single concrete path, not a glob). **Required.** |
| `--fidelity <CSV>` | Fidelity positions CSV. |
| `--schwab <CSV>` | Schwab per-account positions CSV. |
| `--wells-fargo <FILE>` | Wells Fargo positions paste (`-` for stdin). |
| `--account <NAME>` | (Wells Fargo only) account to attribute lots to; must match an `accounts.srf` entry. |
| `-y, --yes` | Don't prompt before overwriting an existing file. |
Account resolution needs an [`accounts.srf`](../config/accounts-srf.md)
next to the target with `institution::` + `account_number::` entries
matching the export; import refuses to write when an export account is
unmapped.
## Example
```bash
zfin -p portfolio_managed.srf import --fidelity ~/Downloads/Portfolio_Positions.csv
```
## See also
- [`audit`](audit.md) -- reconcile an existing portfolio against an export instead of replacing it.
- [Map your accounts](../../guides/set-up-accounts.md) -- the institution/number mapping import needs.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,88 @@
# CLI command reference
Every zfin subcommand, grouped the way [`zfin help`](version.md) groups
them. Each command has its own page with flags and sample output. For a
task-oriented path through these, see the
[user manual](../../README.md#the-user-manual-workflows).
```
zfin [global options] <command> [command options]
```
Get help at any time with `zfin help` or per command with
`zfin <command> --help`.
## Per-symbol lookups
| Command | Does |
|---------------------------|----------------------------------------------------|
| [`perf`](perf.md) | 1y/3y/5y/10y trailing returns (Morningstar-style) |
| [`quote`](quote.md) | Latest quote with a chart and 20-day history |
| [`history`](history.md) | Price history (symbol) or portfolio-value timeline |
| [`divs`](divs.md) | Dividend history with TTM yield |
| [`splits`](splits.md) | Split history |
| [`options`](options.md) | Options chain (all expirations) |
| [`earnings`](earnings.md) | Earnings history with EPS surprise, plus upcoming |
| [`etf`](etf.md) | ETF profile: holdings, sectors, AUM, inception |
## Portfolio analysis
| Command | Does |
|---------------------------------|-----------------------------------------------------------|
| [`portfolio`](portfolio.md) | Positions, valuations, and watchlist |
| [`analysis`](analysis.md) | Breakdowns by asset class, sector, geo, account, tax type |
| [`exposure`](exposure.md) | True exposure to a symbol (direct + look-through) |
| [`review`](review.md) | Per-holding performance and risk dashboard |
| [`projections`](projections.md) | Retirement projections and percentile bands |
| [`milestones`](milestones.md) | Portfolio threshold crossings ($1M, doublings, ...) |
## Time-series and journaling
| Command | Does |
|-------------------------------------|-------------------------------------------------|
| [`snapshot`](snapshot.md) | Write a portfolio snapshot to `history/` |
| [`compare`](compare.md) | Compare the portfolio at two points in time |
| [`contributions`](contributions.md) | Money added/withdrawn between two git revisions |
## Data hygiene
| Command | Does |
|-----------------------|-----------------------------------------------------|
| [`audit`](audit.md) | Reconcile against brokerage exports + hygiene check |
| [`enrich`](enrich.md) | Bootstrap `metadata.srf` from Wikidata + EDGAR |
| [`import`](import.md) | Synthesize a portfolio file from a brokerage export |
| [`lookup`](lookup.md) | Resolve a CUSIP to a ticker via OpenFIGI |
## Infrastructure
| Command | Does |
|---------------------------------|------------------------------------------------|
| [`cache`](cache.md) | Inspect or clear the local data cache |
| [`doctor`](doctor.md) | Health-check files and environment (read-only) |
| [`version`](version.md) | Show version and build info |
| [`interactive`](interactive.md) | Launch the interactive TUI (alias `i`) |
## Global options
These apply to every command and **must appear before** the
subcommand:
| Option | Effect |
|---------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `--no-color` | Disable colored output (also respects `NO_COLOR`). |
| `--refresh-data=<auto\|force\|never>` | Cache freshness policy. `auto` (default) respects TTLs; `force` re-fetches everything; `never` is offline. See [offline guide](../../guides/offline-and-refresh.md). |
| `-p, --portfolio <PATTERN>` | Portfolio file or glob (repeatable; default `portfolio*.srf`). Resolved against `ZFIN_HOME` when set, else the current directory. Quote globs to prevent shell expansion. |
| `-w, --watchlist <FILE>` | Watchlist file (default `watchlist.srf`). |
```bash
zfin --no-color --refresh-data=never -p 'portfolio_*.srf' analysis
```
`metadata.srf` and `accounts.srf` load from the same directory as the
first resolved portfolio file. See
[environment variables](../config/environment.md) for `ZFIN_HOME` and
related settings.
---
[Documentation home](../../README.md#reference)

View file

@ -0,0 +1,43 @@
# `zfin interactive`
Launch the interactive, multi-tab terminal UI.
```
Usage: zfin interactive [options]
Alias: zfin i [options]
```
The TUI is a vaxis-rendered interface for browsing your portfolio,
per-symbol data, options chains, earnings, and projections. Press `?`
inside it for the keybinding overlay, and `q` (or Ctrl-C) to quit. With
no portfolio or symbol given, it auto-loads `portfolio.srf` and opens on
the Portfolio tab.
## Options
| Flag | Effect |
|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
| `-s, --symbol <SYMBOL>` | Pre-load a symbol and open on the Quote tab. |
| `--chart <MODE>` | Chart graphics: `auto`, `braille`, or `WxH` (e.g. `80x24`). `auto` uses Kitty graphics if the terminal supports it, else braille. |
| `--default-keys` | Print the default keybindings as a `keys.srf` template and exit. |
| `--default-theme` | Print the default theme as a `theme.srf` template and exit. |
The global flags (`--no-color`, `-p`, `-w`, `--refresh-data=<value>`)
are honored.
## Examples
```bash
ZFIN_HOME=examples/pre-retirement-both zfin i
zfin i -s AAPL # open on a symbol, no portfolio
zfin i --default-keys > ~/.config/zfin/keys.srf # generate a keybinding template
```
## See also
- [The interactive TUI](../tui.md) -- the full tab-by-tab tour.
- [Customize the TUI](../../guides/customize-the-tui.md) -- `keys.srf` and `theme.srf`.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,36 @@
# `zfin lookup`
Resolve a CUSIP (9-character security identifier) to its ticker via the
OpenFIGI API.
```
Usage: zfin lookup <CUSIP>
```
Successful results are cached indefinitely in `cusip_tickers.srf`.
`OPENFIGI_API_KEY` raises the rate limit, but the unauthenticated tier
works for low volume. Mutual funds frequently have no OpenFIGI
coverage; the command says so and suggests a manual portfolio entry.
## Example
```bash
zfin lookup 037833100
```
```
037833100 → AAPL
```
Use this when a holding in your portfolio is identified by CUSIP (e.g.
a 401k share class) and you need its ticker for a `ticker::` alias. See
[ticker aliases](../config/portfolio-srf.md#ticker-aliases-and-cusips).
## See also
- [`portfolio.srf` reference](../config/portfolio-srf.md) -- using `ticker::` aliases.
- [Environment variables](../config/environment.md) -- `OPENFIGI_API_KEY`.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,52 @@
# `zfin milestones`
Find the dates your portfolio first reached each of a series of value
thresholds.
```
Usage: zfin milestones --step <expr> [--real]
```
Reads your snapshot/value history and reports the first date each
threshold was crossed. Two threshold modes:
- **Absolute dollar:** `1M` / `1m` / `1500000` / `1.5M` / `500K`
- **Relative multiplier:** `2x` / `1.5x` (each multiple of the starting value)
Rejects `%`, non-positive dollar steps, multipliers <= 1.0, and
NaN/Inf.
## Options
| Flag | Effect |
|-----------------|---------------------------------------------------------------------------------|
| `--step <expr>` | Threshold step (**required**). |
| `--real` | Deflate to the last full Shiller year first (CPI-adjusted). Default is nominal. |
Crossing dates are "first observed at," bounded by the source series
cadence (typically weekly), so they're approximate to within a week.
## Example
```bash
ZFIN_HOME=examples/post-retirement zfin milestones --step 250K
```
```
Milestones — step $250,000.00 (nominal)
Milestone Date Crossed Days Since Prev Days Since First
$1,750,000.00 2018-09-30 — 1001 days
$2,000,000.00 2021-02-14 868 days 1869 days
$2,250,000.00 2023-04-09 784 days 2653 days
$2,500,000.00 2025-04-01 723 days 3376 days
```
## See also
- [Snapshots and history](../../guides/snapshots-and-history.md) -- builds the series this reads.
- [`history`](history.md) -- the full value timeline.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,62 @@
# `zfin options`
Show the options chain (all expirations) for a symbol.
```
Usage: zfin options <SYMBOL> [--ntm <N>]
```
Lists every expiration with its call/put counts and auto-expands the
nearest monthly expiration into strike tables near the money (last/bid/
ask, volume, open interest, and implied volatility). The other
expirations stay collapsed to their counts. Data comes from CBOE -- no
API key required, cached one hour, 15-minute delayed during market
hours.
## Options
| Flag | Default | Effect |
|-------------|---------|-----------------------------------------------------------------|
| `--ntm <N>` | `8` | Show ±N strikes near the money on the auto-expanded expiration. |
`NTM` = near the money. `--ntm` only widens or narrows the strike band
on the one auto-expanded expiration; it does not expand the others.
## Example
```bash
zfin options AAPL --ntm 12
```
```
Options Chain for AAPL
========================================
Underlying: $298.62 26 expiration(s) +/- 12 strikes NTM
2026-06-18 (108 calls, 108 puts)
...
2026-07-17 (66 calls, 66 puts) [monthly]
CALLS
Strike Last Bid Ask Volume OI IV
...
```
`[monthly]` marks standard monthly expirations; the nearest one is the
table that gets expanded.
## Filtering to calls or puts
The CLI has no calls-only / puts-only flag. For that -- plus expanding
*any* expiration and changing the strike band on the fly -- use the
**Options tab** in the [interactive TUI](../tui.md): `c` and `p` toggle
all calls / all puts, and `Ctrl-1` through `Ctrl-9` set the ±N
near-the-money band.
## See also
- [`quote`](quote.md) -- the underlying's spot price.
- [`portfolio`](portfolio.md) -- option *positions* you hold (`security_type::option`).
---
[CLI command reference](index.md)

View file

@ -0,0 +1,52 @@
# `zfin perf`
Show Morningstar-style trailing returns for a symbol.
```
Usage: zfin perf <SYMBOL>
```
`perf` prints 1Y/3Y/5Y/10Y **price-only** and **total-return** CAGR
plus risk metrics (Sharpe, max drawdown, volatility), in two tables:
an **as-of** table (through the latest cached close) and a
**month-end** table (through the most recent calendar month-end, the
way funds quote their stats). Total returns require `POLYGON_API_KEY`
for dividend history; price-only returns work without it.
## Example
```bash
ZFIN_HOME=examples/pre-retirement-both zfin perf VTI
```
```
Trailing Returns for VTI
========================================
Data points: 6290 (2001-05-31 to 2026-06-04)
Latest close: $373.38
As-of 2026-06-04:
Price Only Total Return
---------------------- -------------- --------------
1-Year Return: 27.29% 28.79%
3-Year Return: 20.75% 22.22% ann.
5-Year Return: 11.22% 12.80% ann.
10-Year Return: 13.19% 15.10% ann.
Month-end (2026-05-31):
... (same shape, through last month-end)
Risk Metrics (monthly returns):
Volatility Sharpe Max DD
1-Year: 12.7% 1.65 5.5%
```
## See also
- [Returns and performance](../../explanation/returns-and-performance.md) -- what the columns mean.
- [`review`](review.md) -- the same metrics for every holding at once.
- [`quote`](quote.md) -- the latest price with a short chart.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,49 @@
# `zfin portfolio`
Load and analyze your portfolio: positions, valuations, cash, and
watchlist.
```
Usage: zfin portfolio [FILE]
```
Reads `portfolio.srf` (or the `-p` pattern / `[FILE]` argument) and
prints a summary: total value, cost, and gain/loss; trailing historical
returns; a per-position table with lot detail; and a cash-by-account
section. Watchlist symbols, if any, appear at the bottom.
## Example
```bash
ZFIN_HOME=examples/pre-retirement-both zfin portfolio
```
```
Portfolio Summary (examples/pre-retirement-both/portfolio.srf)
========================================
Value: $1,383,137.81 Cost: $658,837.01 Gain/Loss: +$724,300.80 (109.9%)
Lots: 13 open, 0 closed Positions: 5 symbols
Historical: 1M: +3.2% 3M: +13.3% 1Y: +24.5% 3Y: +56.4% 5Y: +56.1% 10Y: +182.6%
Symbol Shares Avg Cost Price Market Value Gain/Loss Weight ...
VTI 2480.0 $138.35 $373.38 $925,982.40 + $582,874.40 66.9%
open 1100.0 $140.00 $410,718.00 + $256,718.00 2018-06-15 LT Pat 401k
...
Cash
Account Balance Note
Joint taxable $48,000.00
...
```
Manual-priced rows render in warning color (the price may be stale).
## See also
- [Read your portfolio](../../guides/read-your-portfolio.md) -- how to interpret this.
- [`portfolio.srf` reference](../config/portfolio-srf.md) -- the input file.
- [`analysis`](analysis.md) -- allocation breakdowns.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,64 @@
# `zfin projections`
Retirement projections: percentile bands of your portfolio's projected
value, a benchmark comparison, and safe-withdrawal dollars at multiple
horizons and confidence levels.
```
Usage: zfin projections [opts]
```
Configuration lives in
[`projections.srf`](../config/projections-srf.md). What the output
shows (accumulation block, earliest-retirement grid, or drawdown-only)
depends on which inputs you set -- see
[Plan for retirement](../../guides/plan-retirement.md) and
[the model](../../explanation/projections-model.md).
## Sub-modes (mutually exclusive)
| Flag | View |
|---------------------|-----------------------------------------------------------------------|
| (default) | Percentile bands + benchmark + safe withdrawal. |
| `--vs <DATE>` | Side-by-side with a historical snapshot's projection. |
| `--convergence` | The model's predicted retirement date over time. |
| `--return-backtest` | Expected-return claim vs. realized forward CAGR (pair with `--real`). |
## Options
| Flag | Effect |
|-------------------------|-----------------------------------------------------------------------------------------|
| `--as-of <DATE>` | Project against a historical snapshot (auto-snaps to nearest earlier). |
| `--overlay-actuals` | Plot your realized trajectory from `--as-of` to today on the bands. Requires `--as-of`. |
| `--no-events` | Exclude life events (baseline view). |
| `--real` | With `--return-backtest`, render CPI-adjusted. |
| `--export-chart <PATH>` | Render the band chart to a 1920x1080 PNG and exit (default mode only). |
Dates accept `YYYY-MM-DD` or relative shortcuts (`1W`/`1M`/`1Q`/`1Y`).
## Example
```bash
ZFIN_HOME=examples/pre-retirement-both zfin projections
```
```
Accumulation phase:
Years until possible retirement: 19 (2046-04-12, ages 65/62)
Median portfolio at retirement: $7,871,732.10
Range (10th90th percentile): $5,807,693.45 to $18,240,675.15
Safe Withdrawal (FIRECalc historical simulation)
25 Year 35 Year 50 Year
99% safe withdrawal $314,920 $293,374 $264,002
```
## See also
- [Plan for retirement](../../guides/plan-retirement.md) -- the guided walkthrough.
- [`projections.srf` reference](../config/projections-srf.md) -- every input field.
- [The retirement projection model](../../explanation/projections-model.md) -- the method and caveats.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,46 @@
# `zfin quote`
Show the latest quote for a symbol, with a price chart and recent
history.
```
Usage: zfin quote <SYMBOL>
```
Prints the last price, the day's open/high/low, volume, and the
day-over-day change, followed by a 20-day chart. Quotes come from Yahoo
(TwelveData fallback) and are **never cached** -- so this command needs
network access and does nothing useful in `--refresh-data=never` mode.
Supports `--export-chart <PATH>` to render the chart as a 1920x1080
PNG instead of text (see [export charts](../../guides/offline-and-refresh.md)
and the projections page).
## Example
```bash
ZFIN_HOME=examples/pre-retirement-both zfin quote SPY
```
```
SPY $746.74 (close)
========================================
Date: 2026-06-18
Open: $747.76
High: $748.23
Low: $743.86
Volume: 80,875,657
Change: +$5.78 (+0.78%)
... (20-day braille chart)
```
## See also
- [`perf`](perf.md) -- trailing returns instead of a spot price.
- [`history`](history.md) -- the last 30 days as a table.
- [Caching](../../explanation/caching.md#quotes-are-never-cached) -- why quotes need the network.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,52 @@
# `zfin review`
A per-holding performance and risk dashboard, with automated findings.
```
Usage: zfin review [opts]
```
One row per holding: sector, tax status, trailing returns
(1Y/3Y/5Y/10Y month-end total return), risk metrics (3Y+10Y
volatility/Sharpe, 5Y max drawdown), and a correlation-aware totals
row. A findings section flags concentration, sector dominance,
volatility outliers, and tiny positions. Reads
[`metadata.srf`](../config/metadata-srf.md) and
[`accounts.srf`](../config/accounts-srf.md).
## Options
| Flag | Effect |
|-----------------|------------------------------------------------------------------------------------------------------------------------------|
| `--sort FIELD` | Sort by `sector`, `symbol`, `weight`, `tax`, `1y`/`3y`/`5y`/`10y`, `3y-vol`/`10y-vol`, `3y-sharpe`/`10y-sharpe`, `5y-maxdd`. |
| `--asc` | Sort ascending (default is descending for numeric fields). |
| `--checks=MODE` | Findings engine: `all` (default), `fast` (skip long checks), `none`. |
| `--show-acked` | Include already-acknowledged findings. |
Default sort groups by sector, then weight descending within each.
## Example
```bash
ZFIN_HOME=examples/pre-retirement-both zfin review
```
```
Symbol Sector Wt% 1Y 3Y ... 3Y-SR 5Y-MaxDD Tax%
VTI Diversified 66.9% +30.2% +23.3% ... 1.29 24.8% 10.9%
QQQ Technology 3.5% +43.1% +29.3% ... 1.41 32.6% 0.0%
Findings (2 active, 0 acked, 0 resolved)
⚠️ VTI at 66.9% of liquid (warn at 50.0%, flag at 70.0%)
❌️ Diversified sector at 85.7% (warn at 60.0%, flag at 75.0%)
```
## See also
- [Read your portfolio](../../guides/read-your-portfolio.md#review-per-holding-performance-and-risk)
- [Returns and performance](../../explanation/returns-and-performance.md) -- the metric definitions.
- [`analysis`](analysis.md) -- portfolio-level breakdowns.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,49 @@
# `zfin snapshot`
Compute a portfolio snapshot and write it to `history/` -- the building
block of your value time series.
```
Usage: zfin snapshot [opts]
```
By default, refreshes candles for held symbols, derives the as-of date
from the cached candle dates, prices each lot at the close on or before
that date, and writes `history/<as_of_date>-portfolio.srf` atomically.
The file is a discriminated SRF whose records start with
`kind::<meta|total|tax_type|account|lot>`.
## Options
| Flag | Effect |
|------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|
| `--dry-run` | Compute and print to stdout; write nothing. |
| `--force` | Overwrite an existing snapshot for the date. |
| `--out <path>` | Override the output path. |
| `--as-of <DATE>` | Write a snapshot for a historical date (uses git to recover state and the candle cache for pricing). Accepts `YYYY-MM-DD` or `1W`/`1M`/`1Q`/`1Y`. |
If the target file already exists and `--force` isn't passed, the run
skips with a stderr message.
## Examples
```bash
zfin snapshot --dry-run # preview today's snapshot
zfin snapshot # write history/<today>-portfolio.srf
zfin snapshot --as-of 2025-01-02 # back-fill a past date
```
Automate with cron for a self-building series:
```cron
0 18 * * 1-5 cd ~/finance && /usr/local/bin/zfin snapshot
```
## See also
- [Snapshots and history](../../guides/snapshots-and-history.md) -- the workflow.
- [`history`](history.md) / [`compare`](compare.md) -- read the snapshots back.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,41 @@
# `zfin splits`
Show stock-split history for a symbol.
```
Usage: zfin splits <SYMBOL>
```
Lists each split's effective date and ratio. Split data comes from
Polygon (`POLYGON_API_KEY`), merged with per-row split factors from
Tiingo's price series (which rescues events Polygon's reference
endpoint occasionally misses).
## Example
```bash
zfin splits AAPL
```
```
Split History for AAPL
========================================
Date Ratio
------------ ----------
2020-08-31 4:1
2014-06-09 7:1
2005-02-28 2:1
2000-06-21 2:1
1987-06-16 2:1
5 split(s)
```
## See also
- [`divs`](divs.md) -- dividend history for a symbol.
- [`history`](history.md) -- split-adjusted price history.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,36 @@
# `zfin version`
Show zfin's version and build information.
```
Usage: zfin version [-v|--verbose]
```
Prints the version and build date. With `--verbose` / `-v`, also prints
the Zig compiler version, build mode, build target, resolved
`ZFIN_HOME`, and cache directory -- handy for bug reports.
## Example
```bash
zfin version
```
```
zfin e246d1e (built 2026-06-19)
```
```bash
zfin version --verbose
```
Adds the build environment and resolved paths below the version line.
## See also
- [Documentation home](../../README.md)
- [`zfin help`](index.md) -- the grouped command list and global options.
---
[CLI command reference](index.md)

View file

@ -0,0 +1,106 @@
# `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). |
## Tax types
| Value | Display label |
|---------------|-----------------------|
| `taxable` | Taxable |
| `roth` | Roth (Post-Tax) |
| `traditional` | Traditional (Pre-Tax) |
| `hsa` | HSA (Triple Tax-Free) |
Any other value is shown as-is. Accounts missing from `accounts.srf`
appear as "Unknown".
## `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.
## Example (from `examples/pre-retirement-both`)
```srf
#!srfv1
account::Pat 401k,tax_type::traditional,institution::fidelity,account_number::P401
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)

View file

@ -0,0 +1,68 @@
# Environment variables
zfin is configured through environment variables. Set them in your
shell, or put them in a `.env` file. The `.env` file is searched first
in the binary's parent directory, then in the current directory; any
value set in the real environment is also honored.
```bash
# .env
TIINGO_API_KEY=your_key
ZFIN_USER_EMAIL=you@example.com
ZFIN_HOME=/home/you/finance
```
To see which of these zfin actually picked up -- and what each one
unlocks -- run [`zfin doctor`](../cli/doctor.md).
## API keys
| Variable | Provider | Required for | Without it |
|----------------------|-------------------------|-------------------------------------|--------------------------------------------------------------------------------|
| `TIINGO_API_KEY` | Tiingo | Daily price history (candles) | Candles fall back to Yahoo; some symbols (especially mutual funds) won't price |
| `POLYGON_API_KEY` | Polygon | Dividends and splits (total return) | No forward-looking dividends; total returns may use Tiingo's view only |
| `FMP_API_KEY` | Financial Modeling Prep | Earnings | No earnings data (tab disabled) |
| `TWELVEDATA_API_KEY` | TwelveData | Quote fallback after Yahoo | No quote fallback if Yahoo fails |
| `OPENFIGI_API_KEY` | OpenFIGI | Faster CUSIP lookups | CUSIP lookups use slower anonymous rate limits |
None are strictly required; without a given key, that data is simply
unavailable. Quotes (Yahoo) and options (CBOE) need no key. See
[Data providers and API keys](../providers.md) for signup links and
free-tier limits.
## Contact email
| Variable | Used for |
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `ZFIN_USER_EMAIL` | The contact address SEC EDGAR requires in its `User-Agent` header. Enables ETF profiles and [`zfin enrich`](../cli/enrich.md). Not a key -- just your email. Without it, ETF profiles and enrichment are unavailable. |
## Paths and directories
| Variable | Default | Purpose |
|------------------|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `ZFIN_HOME` | (current directory) | Directory holding your `portfolio.srf`, `accounts.srf`, `metadata.srf`, `watchlist.srf`, and `.env`. When set, it is consulted **exclusively** for portfolio resolution (the current directory is not also searched). |
| `ZFIN_CACHE_DIR` | `~/.cache/zfin` | Where fetched provider data is cached. |
| `XDG_CACHE_HOME` | -- | Consulted to build the default cache dir (`$XDG_CACHE_HOME/zfin`) when `ZFIN_CACHE_DIR` is unset. |
## Server sync
| Variable | Purpose |
|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `ZFIN_SERVER` | Optional URL of a remote [zfin-server](https://git.lerch.org/lobo/zfin-server) instance: a shared cache tier queried between the local cache and the providers. No-ops when unset. See [server sync](../../explanation/caching.md#server-sync-zfin_server). |
## Output
| Variable | Purpose |
|------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
| `NO_COLOR` | When set (to any value), disables colored output, per the [no-color.org](https://no-color.org) convention. Equivalent to the `--no-color` flag. |
## See also
- [Getting started](../../getting-started.md) -- the initial setup.
- [`zfin doctor`](../cli/doctor.md) -- reports which variables are set and what each enables.
- [Data providers and API keys](../providers.md) -- where to get each key.
- [Offline use and refreshing data](../../guides/offline-and-refresh.md) -- the `--refresh-data` cache policy.
---
[Documentation home](../../README.md#reference)

View file

@ -0,0 +1,72 @@
# `keys.srf` reference
`keys.srf` rebinds the interactive TUI's keys. zfin reads it from
`~/.config/zfin/keys.srf`. When the file is absent, built-in defaults
apply; when present, it is the **sole** source of bindings.
Generate a fully-commented starting file from the current defaults:
```bash
zfin interactive --default-keys > ~/.config/zfin/keys.srf
```
## File format
One binding per line:
```srf
action::ACTION_NAME,key::KEY_STRING[,scope::SCOPE]
```
- **Modifiers:** `ctrl+`, `alt+`, `shift+` (e.g. `ctrl+c`).
- **Special keys:** `tab`, `enter`, `escape`, `space`, `backspace`,
`left`, `right`, `up`, `down`, `page_up`, `page_down`, `home`,
`end`, `F1`-`F12`, `insert`, `delete`.
- **Multiple bindings:** repeat the action on several lines to bind
more than one key to it.
- **`scope`** (optional): omitted or `scope::global` is a global
binding; `scope::<tab>` (e.g. `scope::options`) is a tab-local
binding whose `action::` then names that tab's local action.
A tab-local binding may not reuse a globally-bound key; zfin refuses
to start if you create that conflict.
## Default global bindings
```srf
action::quit,key::q
action::quit,key::ctrl+c
action::refresh,key::r
action::refresh,key::F5
action::prev_tab,key::h
action::prev_tab,key::left
action::prev_tab,key::shift+tab
action::next_tab,key::l
action::next_tab,key::right
action::next_tab,key::tab
action::tab_1,key::1
action::tab_2,key::2
...
action::scroll_down,key::ctrl+d
action::scroll_up,key::ctrl+u
action::scroll_top,key::g
action::scroll_bottom,key::G
action::page_down,key::page_down
action::page_up,key::page_up
action::select_next,key::j
action::select_next,key::down
action::select_prev,key::k
```
Run `zfin interactive --default-keys` for the complete, current list
(including tab-scoped actions), each line ready to edit.
## See also
- [Customize the TUI](../../guides/customize-the-tui.md) -- the walkthrough.
- [The interactive TUI](../tui.md) -- tabs, actions, and the help overlay.
- [`theme.srf`](theme-srf.md) -- recolor the interface.
---
[Documentation home](../../README.md#reference)

View file

@ -0,0 +1,87 @@
# `metadata.srf` reference
`metadata.srf` classifies each symbol by asset class, sector, and
geography so [`zfin analysis`](../cli/analysis.md) can produce
allocation breakdowns and [`zfin review`](../cli/review.md) can group
holdings by sector. zfin loads it from the same directory as the
resolved portfolio file.
It is optional, but without it the Asset Category / Sector / Geographic
breakdowns have nothing to group by. The fastest way to create one is
[`zfin enrich`](../cli/enrich.md); see
[Classify your holdings](../../guides/classify-holdings.md).
## File format
One record per `(symbol, allocation)` pair. A single-asset-class
security needs one line; a blended fund needs several lines that sum to
~100%.
```srf
#!srfv1
symbol::VTI,sector::Diversified,geo::US,asset_class::US Large Cap
symbol::AGG,sector::Bonds,geo::US,asset_class::Bonds
```
## Fields
| Field | Type | Required | Default | Description |
|---------------|--------|----------|-----------|---------------------------------------------------------------------------------------------------------------------------|
| `symbol` | string | Yes | -- | Ticker or CUSIP. Must match `symbol::` (or `ticker::`) on a portfolio lot. |
| `name` | string | No | -- | Human-readable security name (e.g. "SPDR S&P 500 ETF Trust"). Shown where available; falls back to the symbol. |
| `asset_class` | string | No | -- | e.g. `US Large Cap`, `Bonds`, `International Developed`, `Emerging Markets`. |
| `sector` | string | No | -- | e.g. `Technology`, `Healthcare`, `Financials`, `Diversified`, `Bonds`. |
| `geo` | string | No | -- | e.g. `US`, `International Developed`, `Emerging Markets`. |
| `bucket` | string | No | (derived) | User-curated grouping label that overrides the auto-derived sector bucket for concentration/dominance checks (see below). |
| `pct` | number | No | `100` | Weight of this allocation line for the symbol. Use multiple lines for blended funds. |
Cash and CD lots are classified as "Cash & CDs" automatically -- they
need no metadata entry.
## The `bucket` field
For concentration and sector-dominance analysis, zfin needs a
meaningful grouping label. It derives one automatically, but the
upstream `sector` can be uninformative -- ETF holdings data often tags
everything as the generic "Equity / Corporate." When several distinct
holdings collapse into one meaningless bucket, set `bucket::` yourself
to a label that actually distinguishes them. When `bucket` is unset,
zfin falls back through: `sector` (if it isn't a fund-decomposition
category) -> a composite `"<geo> <asset_class>"` -> `Unclassified`.
See [Classify your holdings](../../guides/classify-holdings.md#fixing-uninformative-sectors)
for a worked `bucket` example.
## Blended funds
For a target-date or balanced fund, add one line per asset class with
`pct:num:` weights summing to ~100:
```srf
#!srfv1
symbol::02315N600,asset_class::US Large Cap,pct:num:55
symbol::02315N600,asset_class::International Developed,pct:num:20
symbol::02315N600,asset_class::Bonds,pct:num:15
symbol::02315N600,asset_class::Emerging Markets,pct:num:10
```
## Example (from `examples/pre-retirement-both`)
```srf
#!srfv1
symbol::VTI,sector::Diversified,geo::US,asset_class::US Large Cap
symbol::SPY,sector::Diversified,geo::US,asset_class::US Large Cap
symbol::QQQ,sector::Technology,geo::US,asset_class::US Large Cap
symbol::SCHD,sector::Diversified,geo::US,asset_class::US Large Cap
symbol::AGG,sector::Bonds,geo::US,asset_class::Bonds
```
## See also
- [Classify your holdings](../../guides/classify-holdings.md) -- the walkthrough, including `enrich`.
- [`zfin enrich`](../cli/enrich.md) -- bootstrap this file from Wikidata + SEC EDGAR.
- [`zfin analysis`](../cli/analysis.md) -- the breakdowns this file feeds.
- [`zfin review`](../cli/review.md) -- the per-holding Sector column and grouping this file feeds.
---
[Documentation home](../../README.md#reference)

View file

@ -0,0 +1,159 @@
# `portfolio.srf` reference
Your portfolio is a plain-text [SRF](https://git.lerch.org/lobo/srf)
file with **one lot per line**. A lot is a batch of shares of one
security bought on one date in one account. Positions are aggregated
from lots automatically.
zfin looks for `portfolio.srf` in `ZFIN_HOME` (or the current
directory). You can split holdings across several `portfolio_*.srf`
files; zfin union-merges every match (see
[Build your portfolio](../../guides/set-up-your-portfolio.md) for the
multi-file workflow).
## File format
Every SRF line is a comma-separated list of `key::value` pairs. Typed
values use a type tag:
- `key::value` -- string
- `key:num:value` -- number
- `key:bool:value` -- boolean (`true` / `false`)
The first line must be the version header `#!srfv1`. Lines beginning
with `#` are comments.
```srf
#!srfv1
# A stock/ETF lot
symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,account::Brokerage
```
## Lot fields
| Field | Type | Required | Description |
|-----------------|--------|----------|---------------------------------------------------------------------------------------|
| `symbol` | string | Yes\* | Ticker or CUSIP. \*Optional for `cash` lots. |
| `shares` | number | Yes | Share count (or face value for cash/CDs). Negative for short option positions. |
| `open_date` | string | Yes\*\* | Purchase date `YYYY-MM-DD`. \*\*Not required for `cash`/`watch`. |
| `open_price` | number | Yes\*\* | Purchase price per share. \*\*Not required for `cash`/`watch`. |
| `close_date` | string | No | Sale date. Omit for an open lot. |
| `close_price` | number | No | Sale price per share. |
| `security_type` | string | No | `stock` (default), `option`, `cd`, `cash`, `illiquid`, `watch`. |
| `account` | string | No | Account name. Should match an `account::` entry in [`accounts.srf`](accounts-srf.md). |
| `note` | string | No | Free-text note (shown in cash/CD/illiquid tables). |
| `ticker` | string | No | Ticker alias used for price fetching when `symbol` is a CUSIP. |
| `price` | number | No | Manual price override (for securities the providers don't cover). |
| `price_date` | string | No | Date of the manual price (`YYYY-MM-DD`), for staleness display. |
| `drip` | bool | No | `true` if the lot is from dividend reinvestment (summarized as ST/LT groups). |
| `maturity_date` | string | No | CD maturity or option expiry date (`YYYY-MM-DD`). |
| `rate` | number | No | CD interest rate (e.g. `5.25` = 5.25%). |
## Security types
| Type | Meaning |
|-------------------|------------------------------------------------------------------------------------------------|
| `stock` (default) | Stocks, ETFs, mutual funds. Priced from Tiingo (Yahoo fallback), aggregated by symbol. |
| `option` | Option contracts. Shown separately; `shares` is the contract count (negative = written/short). |
| `cd` | Certificates of deposit. Shown by maturity with rate and face value. |
| `cash` | Cash, money-market, settlement balances. Grouped by account. |
| `illiquid` | Real estate, vehicles, etc. Excluded from the liquid total; included in Net Worth. |
| `watch` | Watchlist item: tracks price only, no position. See [`watchlist.srf`](watchlist-srf.md). |
## Examples
Each line below is valid on its own. These mirror the bundled
[`examples/`](../../../examples/) portfolios.
```srf
#!srfv1
# Stocks / ETFs (multiple lots of the same symbol aggregate)
symbol::VTI,shares:num:1100,open_date::2018-06-15,open_price:num:140.00,account::Pat 401k
symbol::VTI,shares:num:240,open_date::2015-01-08,open_price:num:103.40,account::Pat Roth
# Cash (no symbol, no open_date/open_price needed)
security_type::cash,shares:num:48000.00,open_date::2026-04-30,open_price:num:1.00,account::Joint taxable
# Closed (sold) lot
symbol::AMZN,shares:num:10,open_date::2022-03-15,open_price:num:150.25,close_date::2024-01-15,close_price:num:185.50
# DRIP lot (dividend reinvestment; summarized as ST/LT groups)
symbol::VTI,shares:num:0.234,open_date::2024-06-15,open_price:num:267.50,drip:bool:true,account::Brokerage
# CUSIP with a ticker alias for pricing (e.g. a 401k CIT share class)
symbol::02315N600,shares:num:1200,open_date::2022-01-01,open_price:num:140.00,ticker::VTTHX,account::Fidelity 401k,note::VANGUARD TARGET 2035
# Manual price override (security with no provider coverage)
symbol::NON40OR52,shares:num:500,open_date::2023-01-01,open_price:num:155.00,price:num:163.636,price_date::2026-02-27,account::Fidelity 401k
# Option: a written (short) call. Negative shares = contracts sold.
security_type::option,symbol::AAPL 06/20/2025 200.00 C,shares:num:-2,open_date::2025-01-15,open_price:num:12.50,option_type::call,underlying::AAPL,strike:num:200,maturity_date::2025-06-20,account::Brokerage
# CD
security_type::cd,symbol::912797KR0,shares:num:10000,open_date::2024-06-01,open_price:num:10000,maturity_date::2025-06-01,rate:num:5.25,account::Brokerage,note::6-Month T-Bill
# Illiquid asset (net worth only)
security_type::illiquid,symbol::HOME,shares:num:450000,open_date::2020-06-01,open_price:num:350000,note::Primary residence
```
## Price resolution
For stock lots, the displayed price is resolved in this order:
1. **Live API** -- latest close from cached candles (Tiingo, Yahoo fallback).
2. **Manual price** -- the lot's `price` field, for securities without coverage.
3. **Average cost** -- the position's `open_price`, as a last resort.
Manual-priced rows render in warning color so you know they may be
stale; `price_date` tracks when you last updated them.
## Ticker aliases and CUSIPs
Some securities (notably 401k CIT share classes) are identified by
CUSIP but have a retail-ticker equivalent for pricing. Use `ticker::`
so `symbol::` stays the display identifier while the alias drives API
calls:
```srf
symbol::02315N600,ticker::VTTHX,...
```
If the CUSIP and retail ticker have different NAVs (common for CIT vs.
retail funds), use a manual `price::` instead of `ticker::`. To resolve
a CUSIP to a ticker, use [`zfin lookup`](../cli/lookup.md).
## Advanced and option fields
These are less common but parse on any lot:
| Field | Type | Applies to | Description |
|-----------------|--------|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `price_ratio` | number | share classes | Multiplier applied to the fetched (alias) price to get the institutional NAV. E.g. if the retail class is $27.78 and the institutional class trades at $144.04, set `price_ratio:num:5.185`. |
| `underlying` | string | options | Underlying stock symbol (e.g. `AMZN`). |
| `strike` | number | options | Strike price. |
| `maturity_date` | string | options | Expiration date (`YYYY-MM-DD`). |
| `multiplier` | number | options | Shares per contract (default `100`). |
| `option_type` | string | options | `call` (default) or `put`. |
An option lot is defined by these explicit fields, not by decoding the
symbol -- `symbol` is just a display label, so set it to whatever your
brokerage shows (e.g. `AAPL 06/20/2025 200.00 C`). For a worked
two-lot example, see the covered call in
[Build your portfolio](../../guides/set-up-your-portfolio.md#example-a-covered-call).
## DRIP lots
Lots marked `drip:bool:true` are collapsed into short-term (ST) and
long-term (LT) groups in the position detail view (split on the
1-year capital-gains threshold) rather than listed individually.
## See also
- [Build your portfolio](../../guides/set-up-your-portfolio.md) -- the task-oriented walkthrough.
- [`accounts.srf`](accounts-srf.md) -- give each `account::` a tax type.
- [`metadata.srf`](metadata-srf.md) -- classify each `symbol::` for analysis.
- [`zfin portfolio`](../cli/portfolio.md) and [`zfin import`](../cli/import.md).
---
[Documentation home](../../README.md#reference)

View file

@ -0,0 +1,142 @@
# `projections.srf` reference
`projections.srf` configures [`zfin projections`](../cli/projections.md):
the Monte-Carlo-style retirement simulation run over the Shiller
dataset (1871-present). zfin loads it from the same directory as the
portfolio. It is optional -- without it, the command runs with
sensible defaults (20/30/45-year horizons, 90/95/99% confidence,
no accumulation phase).
This page is the field reference. For how the simulation actually
works see [The retirement projection model](../../explanation/projections-model.md);
for a guided setup see [Plan for retirement](../../guides/plan-retirement.md).
## Record types
Every line starts with a `type::` discriminator:
| `type::` | Purpose |
|-------------|---------------------------------------------------------------------------------------------------|
| `config` | A single configuration field (allocation, horizon, retirement target, contributions, spending). |
| `birthdate` | A household member's birthdate (drives ages, `horizon_age`, `retirement_age`, life-event timing). |
| `event` | A life event: recurring income or expense (Social Security, pension, tuition, healthcare). |
```srf
#!srfv1
type::config,target_stock_pct:num:80
type::config,horizon:num:25
type::config,horizon_age:num:95
type::birthdate,date::1981-04-12
type::event,name::Social Security,start_age:num:70,amount:num:38400
```
## `config` fields
| Field | Type | Description |
|--------------------------------------|------|--------------------------------------------------------------------------------------------------------------------------------|
| `target_stock_pct` | num | Asset-allocation target (0-100). Sets the simulation's stock/bond blend. |
| `horizon` | num | Distribution-phase length in years. Repeat the line for multiple horizons. |
| `horizon_age` | num | Horizon expressed as an age; resolves to `target_age - oldest_current_age`. Repeatable. |
| `retirement_age` | num | Age the **oldest** configured person must reach to retire. |
| `retirement_at` | date | Absolute retirement date (`YYYY-MM-DD`). Wins over `retirement_age` if both set. |
| `annual_contribution` | num | Yearly accumulation-phase contribution, in today's dollars. |
| `contribution_inflation_adjusted` | bool | If `true` (default), contributions grow with CPI year over year. |
| `target_spending` | num | Desired retirement spending, in today's dollars. |
| `target_spending_inflation_adjusted` | bool | If `true` (default), target spending grows with CPI during distribution. |
| `retirement_target` | num | Annotation on a `horizon`/`horizon_age` line that overrides the earliest-retirement promotion rule. Allowed: `90`, `95`, `99`. |
## `birthdate` fields
| Field | Type | Description |
|----------|------|----------------------------------------------------|
| `date` | date | Birthdate (`YYYY-MM-DD`). |
| `person` | num | Household member (`1` default, `2` for a partner). |
```srf
type::birthdate,date::1981-04-12
type::birthdate,date::1983-09-08,person:num:2
```
## `event` fields
Life events modify annual cash flow in both phases. Positive amounts
are income (offset withdrawals); negative amounts are expenses (added
to withdrawals).
| Field | Type | Description |
|----------------------|--------|-----------------------------------------------------------------------------------------|
| `name` | string | Label shown in the Life Events table. |
| `amount` | num | Annual amount. Positive = income, negative = expense. |
| `start_age` | num | Age (of `person`) at which the event begins. |
| `duration` | num | Optional length in years. Omit for a permanent event. |
| `person` | num | Whose age `start_age` refers to (`1` default, `2` for a partner). |
| `inflation_adjusted` | bool | If `true` (default), the amount grows with CPI. Set `false` for a fixed nominal amount. |
```srf
# Permanent income starting at age 70
type::event,name::Social Security,start_age:num:70,amount:num:38400
# 4-year expense starting at age 50
type::event,name::College Tuition,start_age:num:50,duration:num:4,amount:num:-55000
# A partner's pension
type::event,name::Pension,start_age:num:65,person:num:2,amount:num:24000
```
## The two retirement-planning inputs
`projections.srf` answers a different question depending on which
inputs you set. This is the single most important thing to understand
about the file:
| You set... | zfin answers... | Display |
|----------------------------------------------------|-----------------------------------------|-----------------------------------------------------------------------|
| A target date (`retirement_age` / `retirement_at`) | "Given my date, what can I spend?" | Accumulation-phase block with a dated headline. |
| A target spending (`target_spending`) | "Given my spending, when can I retire?" | Earliest-retirement grid; one cell is promoted to the headline. |
| Both | Both, back to back | Configured date wins the headline; grid renders below for comparison. |
| Neither | Already-retired view | "Years until possible retirement: none". |
When `target_spending` is set, the **earliest-retirement grid** shows,
for each (horizon x confidence) pair, the earliest year that sustains
the spending. The default promotion rule picks the headline cell by
walking horizons longest-to-shortest at 99% confidence, preferring the
longest horizon that keeps the oldest person under age 100. Override it
with a `retirement_target` annotation on one horizon line:
```srf
# use the 35yr x 95% cell as the headline
type::config,horizon:num:35,retirement_target:num:95
```
At most one horizon may carry the annotation; configuring more than one
drops them all and falls back to the default rule. If the promoted cell
is infeasible (no accumulation length <= 50 years sustains the
spending), the headline reads "not feasible" and the grid still renders
so you can pick a workable anchor.
## The example configurations
The five bundled examples are fully-configured walkthroughs of each
combination:
| `examples/...` | Inputs |
|----------------------------------|---------------------------------------------------|
| `pre-retirement-age` | target date only |
| `pre-retirement-spending` | target spending only |
| `pre-retirement-spending-target` | target spending + an explicit (infeasible) anchor |
| `pre-retirement-both` | target date + target spending |
| `post-retirement` | neither (distribution-only) |
```bash
ZFIN_HOME=examples/pre-retirement-both zfin projections
```
## See also
- [Plan for retirement](../../guides/plan-retirement.md) -- guided setup.
- [The retirement projection model](../../explanation/projections-model.md) -- how the simulation works.
- [`zfin projections`](../cli/projections.md) -- command flags (`--as-of`, `--overlay-actuals`, `--convergence`, `--return-backtest`).
---
[Documentation home](../../README.md#reference)

View file

@ -0,0 +1,60 @@
# `theme.srf` reference
`theme.srf` recolors the interactive TUI. zfin reads it from
`~/.config/zfin/theme.srf`. When the file is absent, built-in defaults
(a monokai/opencode palette) apply; when present, it is the **sole**
source of colors.
Generate a commented starting file from the current defaults:
```bash
zfin interactive --default-theme > ~/.config/zfin/theme.srf
```
## File format
One `key::value` per line. Every value is a hex RGB string,
`#rrggbb`:
```srf
#!srfv1
bg::#0a0a0a
text::#eeeeee
accent::#9d7cd8
positive::#7fd88f
negative::#e06c75
```
## Color keys
| Key | Used for |
|----------------------------------------|-------------------------------------|
| `bg` | Main background |
| `bg_panel` | Panel background |
| `bg_element` | Inset element background |
| `tab_bg` / `tab_fg` | Inactive tab bar |
| `tab_active_bg` / `tab_active_fg` | Active tab |
| `text` | Primary text |
| `text_muted` | Secondary text |
| `text_dim` | De-emphasized text |
| `status_bg` / `status_fg` | Status line |
| `input_bg` / `input_fg` / `input_hint` | Modal text input |
| `accent` | Accent / highlights |
| `positive` | Gains, positive values |
| `negative` | Losses, negative values |
| `warning` | Warnings (e.g. stale manual prices) |
| `info` | Informational highlights |
| `select_bg` / `select_fg` | Selected row |
| `border` | Borders and rules |
Run `zfin interactive --default-theme` for the full key list with the
default values filled in.
## See also
- [Customize the TUI](../../guides/customize-the-tui.md) -- the walkthrough.
- [`keys.srf`](keys-srf.md) -- rebind keys.
---
[Documentation home](../../README.md#reference)

View file

@ -0,0 +1,68 @@
# `transaction_log.srf` reference
`transaction_log.srf` is an optional sibling of `portfolio.srf` that
declares real-world transactions which change how zfin interprets the
portfolio diff. In v1 it holds exactly one kind of record:
**transfers** -- money you moved between accounts you own.
## Why it exists
[`zfin contributions`](../cli/contributions.md) infers "new money" by
diffing your portfolio over time. A plain diff can't tell an external
contribution apart from an internal transfer, so moving (say) $50k from
one account to another would otherwise be **double-counted**: the
receiving account's new lots look like contributions while the sending
account's removed lots are ignored. Declaring the transfer here cancels
that out.
Missing file -> the matcher is a no-op; nothing changes.
## File format
One record per **destination**. A record pins the money to exactly one
landing spot: either a specific lot (`SYMBOL@open_date`) or the literal
token `cash`. A sweep that lands in several lots becomes several records
sharing the same `(date, from, to)` but differing in `dest_lot`.
```srf
#!srfv1
# Simple cash transfer between two accounts
transfer::2026-05-02,type::cash,amount:num:5000,from::Sample IRA,to::Sample Brokerage,dest_lot::cash
# Transfer that was invested into a single stock lot on arrival
transfer::2026-05-02,type::cash,amount:num:7000,from::Sample IRA,to::Sample Brokerage,dest_lot::VTI@2026-05-03
# Sweep into a basket plus a cash residual (two records, same date/from/to)
transfer::2026-05-02,type::cash,amount:num:145300,from::Sample IRA,to::Sample Brokerage,dest_lot::VTI@2026-05-03
transfer::2026-05-02,type::cash,amount:num:4700,from::Sample IRA,to::Sample Brokerage,dest_lot::cash
```
## Fields
| Field | Type | Required | Description |
|------------|--------|----------|---------------------------------------------------------------------|
| `transfer` | date | Yes | Transfer date (`YYYY-MM-DD`); the record key. |
| `type` | string | Yes | `cash` or `in_kind` (see v1 scope below). |
| `amount` | num | Yes | Dollar amount transferred to this destination. |
| `from` | string | Yes | Source account name (matches an `account::` in your portfolio). |
| `to` | string | Yes | Destination account name. |
| `dest_lot` | string | Yes | Where it landed: `cash`, or `SYMBOL@YYYY-MM-DD` for a specific lot. |
## v1 scope and limits
- Only `transfer::` records. Buys, sells, and dividends stay inferred
from the portfolio diff.
- Only `type::cash` is wired into the contributions classifier.
`type::in_kind` parses (the format is forward-compatible) but the
matcher rejects it with an "in-kind transfers not yet supported"
message.
- Forward-looking only -- there is no historical reconstruction.
## See also
- [Track contributions](../../guides/track-contributions.md) -- the walkthrough.
- [`zfin contributions`](../cli/contributions.md) -- the command that consumes this file.
---
[Documentation home](../../README.md#reference)

View file

@ -0,0 +1,45 @@
# `watchlist.srf` reference
A watchlist tracks the price of symbols you don't own. Watchlist
symbols appear at the bottom of the portfolio view (and the TUI
Portfolio tab) showing their latest price, with no position, cost, or
weight.
zfin loads `watchlist.srf` from `ZFIN_HOME` (or the current
directory), or from an explicit path with the `-w` / `--watchlist`
flag. It is optional.
## File format
One `symbol::` per line, under the standard `#!srfv1` header:
```srf
#!srfv1
symbol::MSFT
symbol::NVDA
symbol::TSLA
```
## Two ways to watch a symbol
| Approach | When to use |
|---------------------------------------------------------------------|---------------------------------------------------------------------------|
| A `watchlist.srf` file (above) | A standalone list you reuse across portfolios, or one you pass with `-w`. |
| A `security_type::watch` lot in [`portfolio.srf`](portfolio-srf.md) | You want the watch entry to live alongside your holdings. |
```srf
# Equivalent watch entry inside portfolio.srf
security_type::watch,symbol::NVDA
```
The two sources are merged: the effective watchlist is the union of
the watchlist file and any `watch` lots in the portfolio.
## See also
- [`portfolio.srf`](portfolio-srf.md) -- the `watch` security type.
- [`zfin quote`](../cli/quote.md) -- a one-off price check without adding to a watchlist.
---
[Documentation home](../../README.md#reference)

View file

@ -0,0 +1,70 @@
# Data providers and API keys
zfin aggregates several free-tier data sources, using each for what it
does best and caching aggressively to stay within limits. This page is
the reference for who supplies what, the free-tier ceilings, and where
to get keys. For the design rationale see
[Why multiple data providers](../explanation/data-providers.md); for
how keys are configured see
[environment variables](config/environment.md).
## Summary
| Data | Provider | Auth | Free-tier limit | Cache TTL |
|-----------------------|------------------|-------------------------------|----------------------------|------------|
| Daily candles (OHLCV) | Tiingo | `TIINGO_API_KEY` | 1,000 req/day, 50 req/hour | ~24h |
| Real-time quotes | Yahoo | none | unofficial | never |
| Quote fallback | TwelveData | `TWELVEDATA_API_KEY` | 8/min, 800/day | never |
| Dividends | Polygon | `POLYGON_API_KEY` | 5/min | 14 days |
| Splits | Polygon | `POLYGON_API_KEY` | 5/min | 14 days |
| Options chains | CBOE | none | ~30/min (self-imposed) | 1 hour |
| Earnings | FMP | `FMP_API_KEY` | 250 req/day | 30 days |
| ETF profiles | SEC EDGAR | `ZFIN_USER_EMAIL` | 10/sec | ~90 days |
| Classification | Wikidata + EDGAR | `ZFIN_USER_EMAIL` | no daily quota | long-lived |
| CUSIP lookup | OpenFIGI | `OPENFIGI_API_KEY` (optional) | higher with key | indefinite |
## Where to get a key
| Provider | Sign up | Notes |
|------------|-------------------------------------|--------------------------------------------------------------------------------------|
| Tiingo | <https://tiingo.com> | The one key worth setting first -- it's the primary price source. |
| Polygon | <https://polygon.io> | Enables total return (dividends) and forward-looking dividend dates. |
| FMP | <https://financialmodelingprep.com> | Earnings actuals + estimates. |
| TwelveData | <https://twelvedata.com> | Optional quote fallback. |
| OpenFIGI | <https://www.openfigi.com/api> | Optional; raises CUSIP-lookup rate limits. |
| SEC EDGAR | (no signup) | Set `ZFIN_USER_EMAIL` to your address; EDGAR requires a contact in its `User-Agent`. |
## Per-provider notes
- **Tiingo** -- primary for candles across stocks, ETFs, and mutual
funds. Candles are fetched from a fixed 2000-01-01 start so the cache
supports long `--as-of` projections. Its price series also carries
per-row dividend/split data that zfin merges into the Polygon view.
- **Yahoo** -- primary, keyless quote source, and the candle fallback
when Tiingo is unavailable.
- **TwelveData** -- quote fallback only. It is no longer used for
candles (its split-adjusted closes proved unreliable for return math).
- **Polygon** -- primary for dividends and splits, including
forward-looking declared events.
- **CBOE** -- keyless options chains, 15-minute delayed during market
hours, with greeks and open interest.
- **FMP** -- earnings history and estimates. ETFs, mutual funds,
CUSIPs, and some dual-class shares return no earnings on the free
tier (a documented limitation).
- **SEC EDGAR + Wikidata** -- ETF profiles (NPORT-P holdings, sector
weights, AUM) and the classification data behind
[`enrich`](cli/enrich.md). Needs `ZFIN_USER_EMAIL`, not a key.
## Rate limiting
Each provider has a client-side token-bucket limiter sized to its
free-tier ceiling. When you'd exceed the rate, zfin blocks until a
token frees up rather than firing a request that would 429 -- so a
large `--refresh-data=force` run paces itself. See
[Caching](../explanation/caching.md#rate-limiting).
## See also
- [Environment variables](config/environment.md) -- setting each key.
- [Getting started](../getting-started.md) -- the minimal first-run setup.
- [Why multiple data providers](../explanation/data-providers.md) -- fallback behavior.

76
docs/reference/tui.md Normal file
View file

@ -0,0 +1,76 @@
# The interactive TUI
```bash
zfin i # or: zfin interactive
```
The TUI is a multi-tab terminal interface over the same engine and data
files as the CLI. With no arguments it auto-loads `portfolio.srf` and
opens on the Portfolio tab; `zfin i -s AAPL` opens on the Quote tab for
a symbol. See [`zfin interactive`](cli/interactive.md) for launch flags.
## Tabs
Nine tabs, in order. Some show your whole portfolio; others show the
currently-selected symbol.
| Tab | Scope | Shows |
|-----------------|-----------|--------------------------------------------------------------------------------------------------------|
| **Portfolio** | portfolio | Positions, valuations, cash, watchlist (like [`portfolio`](cli/portfolio.md)). |
| **Analysis** | portfolio | Allocation breakdowns and umbrella exposure (like [`analysis`](cli/analysis.md)). |
| **Review** | portfolio | Per-holding performance/risk dashboard with findings (like [`review`](cli/review.md)). |
| **Projections** | portfolio | Percentile bands, safe withdrawal, and the actuals overlay (like [`projections`](cli/projections.md)). |
| **History** | portfolio | Portfolio-value timeline from snapshots (like [`history`](cli/history.md)). |
| **Quote** | symbol | Latest price and chart for the selected symbol (like [`quote`](cli/quote.md)). |
| **Performance** | symbol | Trailing returns and risk metrics for the symbol (like [`perf`](cli/perf.md)). |
| **Earnings** | symbol | Earnings history and upcoming events (like [`earnings`](cli/earnings.md)). |
| **Options** | symbol | Options chain for the symbol (like [`options`](cli/options.md)). |
The symbol-scoped tabs follow a single "current symbol"; change it from
the Portfolio tab (select a holding) or by launching with `-s`.
## Charts
Tabs with charts render high-fidelity **Kitty graphics** when your
terminal supports them, falling back to braille otherwise. Force a mode
with `--chart auto|braille|WxH`. The Projections tab is the
highest-fidelity surface for the actuals overlay and the
convergence/return-backtest views.
## Default keybindings
Press `?` any time for the in-app overlay (it always reflects your
current bindings). The global defaults:
| Key(s) | Action |
|----------------------------------------------|--------------------------------|
| `q`, `Ctrl-C` | Quit |
| `r`, `F5` | Refresh the current tab's data |
| `l` / `right` / `tab` | Next tab |
| `h` / `left` / `shift+tab` | Previous tab |
| `1`-`8` | Jump to a tab by number |
| `j` / `down`, `k` / `up` | Move the selection |
| `g` / `G` | Jump to top / bottom |
| `Ctrl-d` / `Ctrl-u` | Half-page down / up |
| `page_down` / `Ctrl-f`, `page_up` / `Ctrl-b` | Page down / up |
Individual tabs add their own actions (e.g. the Projections tab's `d`
to set an as-of date and `o` to toggle the overlay). The `?` overlay
and `zfin i --default-keys` list every binding, global and tab-scoped.
## Customizing
Both keys and colors are configurable via files in `~/.config/zfin/`:
```bash
zfin i --default-keys > ~/.config/zfin/keys.srf
zfin i --default-theme > ~/.config/zfin/theme.srf
```
See [Customize the TUI](../guides/customize-the-tui.md), the
[`keys.srf` reference](config/keys-srf.md), and the
[`theme.srf` reference](config/theme-srf.md).
---
[Documentation home](../README.md)