diff --git a/README.md b/README.md index 66680ef..d42248e 100644 --- a/README.md +++ b/README.md @@ -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 [args] - -Commands: - perf Trailing returns (1yr/3yr/5yr/10yr, price + total) - quote Real-time quote - history Last 30 days price history - divs Dividend history with TTM yield - splits Split history - options Options chains (all expirations) - earnings Earnings history and upcoming events - etf ETF profile (expense ratio, holdings, sectors) - portfolio Portfolio analysis from .srf file - snapshot [opts] Write a daily portfolio snapshot to history/ - compare [] - 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 Compare snapshot at DATE vs current live portfolio -zfin compare 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 `) - -The `quote` and `projections` commands support `--export-chart ` 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 Portfolio file (.srf format) - -w, --watchlist Watchlist file (default: watchlist.srf if present) - -s, --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 `/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 -(1871–present). 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, -p10–p90 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 --overlay-actuals` to plot the -**realized portfolio trajectory** from `` 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 `/history/*-portfolio.srf` - — produced by `zfin snapshot`. Highest fidelity (full lot-level - state, exact totals). -2. **Imported values** in `/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 ` -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 (0–100). 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 [args] - -Commands: - perf Trailing returns (1yr/3yr/5yr/10yr, price + total) - quote Real-time quote with chart - history Last 30 days price history - divs Dividend history with TTM yield - splits Split history - options Options chains (all expirations) - earnings Earnings history and upcoming events - etf 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 [] Compare portfolio state across two dates - enrich Generate metadata.srf from Wikidata + SEC EDGAR - lookup 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 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 diff --git a/TODO.md b/TODO.md index d60b97a..28b87ec 100644 --- a/TODO.md +++ b/TODO.md @@ -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 ` shipped for `quote` and `projections` diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..4ed2763 --- /dev/null +++ b/docs/README.md @@ -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. diff --git a/docs/explanation/caching.md b/docs/explanation/caching.md new file mode 100644 index 0000000..1a48af5 --- /dev/null +++ b/docs/explanation/caching.md @@ -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//.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}//` +(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). diff --git a/docs/explanation/concepts.md b/docs/explanation/concepts.md new file mode 100644 index 0000000..6154301 --- /dev/null +++ b/docs/explanation/concepts.md @@ -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/-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. diff --git a/docs/explanation/data-providers.md b/docs/explanation/data-providers.md new file mode 100644 index 0000000..0e0670c --- /dev/null +++ b/docs/explanation/data-providers.md @@ -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. diff --git a/docs/explanation/faq-troubleshooting.md b/docs/explanation/faq-troubleshooting.md new file mode 100644 index 0000000..a4133f7 --- /dev/null +++ b/docs/explanation/faq-troubleshooting.md @@ -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. diff --git a/docs/explanation/projections-model.md b/docs/explanation/projections-model.md new file mode 100644 index 0000000..acb28f0 --- /dev/null +++ b/docs/explanation/projections-model.md @@ -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. diff --git a/docs/explanation/returns-and-performance.md b/docs/explanation/returns-and-performance.md new file mode 100644 index 0000000..76f3a3d --- /dev/null +++ b/docs/explanation/returns-and-performance.md @@ -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. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..6ba63e0 --- /dev/null +++ b/docs/getting-started.md @@ -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 . + +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) | | +| `FMP_API_KEY` | Earnings history and estimates | | +| `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) diff --git a/docs/guides/audit-against-brokerage.md b/docs/guides/audit-against-brokerage.md new file mode 100644 index 0000000..3cc24cc --- /dev/null +++ b/docs/guides/audit-against-brokerage.md @@ -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 ` | +| **Schwab** (per-account) | *Accounts -> Positions* -> **Export** (one CSV per account) | `--schwab ` | +| **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. **`/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) diff --git a/docs/guides/classify-holdings.md b/docs/guides/classify-holdings.md new file mode 100644 index 0000000..a763614 --- /dev/null +++ b/docs/guides/classify-holdings.md @@ -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) diff --git a/docs/guides/customize-the-tui.md b/docs/guides/customize-the-tui.md new file mode 100644 index 0000000..1ba3d8b --- /dev/null +++ b/docs/guides/customize-the-tui.md @@ -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::` (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) diff --git a/docs/guides/offline-and-refresh.md b/docs/guides/offline-and-refresh.md new file mode 100644 index 0000000..1572029 --- /dev/null +++ b/docs/guides/offline-and-refresh.md @@ -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) diff --git a/docs/guides/periodic-review.md b/docs/guides/periodic-review.md new file mode 100644 index 0000000..dd9c425 --- /dev/null +++ b/docs/guides/periodic-review.md @@ -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/-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) diff --git a/docs/guides/plan-retirement.md b/docs/guides/plan-retirement.md new file mode 100644 index 0000000..7332f26 --- /dev/null +++ b/docs/guides/plan-retirement.md @@ -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 (10th–90th 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) diff --git a/docs/guides/read-your-portfolio.md b/docs/guides/read-your-portfolio.md new file mode 100644 index 0000000..38c62d9 --- /dev/null +++ b/docs/guides/read-your-portfolio.md @@ -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) diff --git a/docs/guides/set-up-accounts.md b/docs/guides/set-up-accounts.md new file mode 100644 index 0000000..f0ae3ee --- /dev/null +++ b/docs/guides/set-up-accounts.md @@ -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) diff --git a/docs/guides/set-up-your-portfolio.md b/docs/guides/set-up-your-portfolio.md new file mode 100644 index 0000000..5bb3e43 --- /dev/null +++ b/docs/guides/set-up-your-portfolio.md @@ -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) diff --git a/docs/guides/snapshots-and-history.md b/docs/guides/snapshots-and-history.md new file mode 100644 index 0000000..1a2c15d --- /dev/null +++ b/docs/guides/snapshots-and-history.md @@ -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 `/history/-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/-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) diff --git a/docs/guides/track-contributions.md b/docs/guides/track-contributions.md new file mode 100644 index 0000000..306dcbe --- /dev/null +++ b/docs/guides/track-contributions.md @@ -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) diff --git a/docs/images/fidelity-positions-download.png b/docs/images/fidelity-positions-download.png new file mode 100644 index 0000000..5c2e632 Binary files /dev/null and b/docs/images/fidelity-positions-download.png differ diff --git a/docs/reference/cli/analysis.md b/docs/reference/cli/analysis.md new file mode 100644 index 0000000..7a2d24b --- /dev/null +++ b/docs/reference/cli/analysis.md @@ -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) diff --git a/docs/reference/cli/audit.md b/docs/reference/cli/audit.md new file mode 100644 index 0000000..4875e2c --- /dev/null +++ b/docs/reference/cli/audit.md @@ -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 ` | Manual-price staleness threshold (default 3). | +| `--fidelity ` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). | +| `--schwab ` | 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) diff --git a/docs/reference/cli/cache.md b/docs/reference/cli/cache.md new file mode 100644 index 0000000..56b7ce9 --- /dev/null +++ b/docs/reference/cli/cache.md @@ -0,0 +1,35 @@ +# `zfin cache` + +Inspect or clear the local provider-data cache. + +``` +Usage: zfin cache +``` + +| 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) diff --git a/docs/reference/cli/compare.md b/docs/reference/cli/compare.md new file mode 100644 index 0000000..9fcb39c --- /dev/null +++ b/docs/reference/cli/compare.md @@ -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 # compare DATE vs. live portfolio + zfin compare # 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 ` / `--snapshot-after ` | Override a side's snapshot (`--snapshot-after live` for the current portfolio). | +| `--commit-before ` / `--commit-after ` | 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) diff --git a/docs/reference/cli/contributions.md b/docs/reference/cli/contributions.md new file mode 100644 index 0000000..5019852 --- /dev/null +++ b/docs/reference/cli/contributions.md @@ -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 ` | commit at/before DATE vs. HEAD (or working copy if dirty) | +| `--since --until ` | 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 ` | Earliest side (resolves to commit at/before). | +| `--until ` | Latest side (pair with `--since`). | +| `--commit-before ` | Pin the before commit directly (same grammar as `--commit-after`, minus `working`). | +| `--commit-after ` | 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) diff --git a/docs/reference/cli/divs.md b/docs/reference/cli/divs.md new file mode 100644 index 0000000..c84a83a --- /dev/null +++ b/docs/reference/cli/divs.md @@ -0,0 +1,37 @@ +# `zfin divs` + +Show dividend history for a symbol, with trailing-twelve-month yield. + +``` +Usage: zfin divs +``` + +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) diff --git a/docs/reference/cli/doctor.md b/docs/reference/cli/doctor.md new file mode 100644 index 0000000..3d29347 --- /dev/null +++ b/docs/reference/cli/doctor.md @@ -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) diff --git a/docs/reference/cli/earnings.md b/docs/reference/cli/earnings.md new file mode 100644 index 0000000..6e2b7a4 --- /dev/null +++ b/docs/reference/cli/earnings.md @@ -0,0 +1,45 @@ +# `zfin earnings` + +Show earnings history (with EPS surprise) and upcoming events for a +symbol. + +``` +Usage: zfin earnings +``` + +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) diff --git a/docs/reference/cli/enrich.md b/docs/reference/cli/enrich.md new file mode 100644 index 0000000..eb72f9e --- /dev/null +++ b/docs/reference/cli/enrich.md @@ -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) diff --git a/docs/reference/cli/etf.md b/docs/reference/cli/etf.md new file mode 100644 index 0000000..2974999 --- /dev/null +++ b/docs/reference/cli/etf.md @@ -0,0 +1,47 @@ +# `zfin etf` + +Show an ETF's profile: net assets, holdings count, sector allocation, +and top holdings. + +``` +Usage: zfin etf +``` + +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) diff --git a/docs/reference/cli/exposure.md b/docs/reference/cli/exposure.md new file mode 100644 index 0000000..81e1b8e --- /dev/null +++ b/docs/reference/cli/exposure.md @@ -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 +``` + +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) diff --git a/docs/reference/cli/history.md b/docs/reference/cli/history.md new file mode 100644 index 0000000..d0ac6b8 --- /dev/null +++ b/docs/reference/cli/history.md @@ -0,0 +1,57 @@ +# `zfin history` + +Two modes in one command, selected by whether you pass a symbol. + +``` +Usage: + zfin history # 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 ` | Earliest as-of date (inclusive). | +| `--until ` | Latest as-of date (inclusive). | +| `--metric ` | `liquid` (default), `illiquid`, or `net_worth`. | +| `--resolution ` | `daily` \| `weekly` \| `monthly` \| `auto`. | +| `--limit ` | 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) diff --git a/docs/reference/cli/import.md b/docs/reference/cli/import.md new file mode 100644 index 0000000..507a090 --- /dev/null +++ b/docs/reference/cli/import.md @@ -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 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 ` | Target file (a single concrete path, not a glob). **Required.** | +| `--fidelity ` | Fidelity positions CSV. | +| `--schwab ` | Schwab per-account positions CSV. | +| `--wells-fargo ` | Wells Fargo positions paste (`-` for stdin). | +| `--account ` | (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) diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md new file mode 100644 index 0000000..d1fb705 --- /dev/null +++ b/docs/reference/cli/index.md @@ -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 options] +``` + +Get help at any time with `zfin help` or per command with +`zfin --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=` | Cache freshness policy. `auto` (default) respects TTLs; `force` re-fetches everything; `never` is offline. See [offline guide](../../guides/offline-and-refresh.md). | +| `-p, --portfolio ` | 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 ` | 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) diff --git a/docs/reference/cli/interactive.md b/docs/reference/cli/interactive.md new file mode 100644 index 0000000..974e8bb --- /dev/null +++ b/docs/reference/cli/interactive.md @@ -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 ` | Pre-load a symbol and open on the Quote tab. | +| `--chart ` | 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=`) +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) diff --git a/docs/reference/cli/lookup.md b/docs/reference/cli/lookup.md new file mode 100644 index 0000000..c364d54 --- /dev/null +++ b/docs/reference/cli/lookup.md @@ -0,0 +1,36 @@ +# `zfin lookup` + +Resolve a CUSIP (9-character security identifier) to its ticker via the +OpenFIGI API. + +``` +Usage: zfin lookup +``` + +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) diff --git a/docs/reference/cli/milestones.md b/docs/reference/cli/milestones.md new file mode 100644 index 0000000..7dcea10 --- /dev/null +++ b/docs/reference/cli/milestones.md @@ -0,0 +1,52 @@ +# `zfin milestones` + +Find the dates your portfolio first reached each of a series of value +thresholds. + +``` +Usage: zfin milestones --step [--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 ` | 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) diff --git a/docs/reference/cli/options.md b/docs/reference/cli/options.md new file mode 100644 index 0000000..404ecda --- /dev/null +++ b/docs/reference/cli/options.md @@ -0,0 +1,62 @@ +# `zfin options` + +Show the options chain (all expirations) for a symbol. + +``` +Usage: zfin options [--ntm ] +``` + +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 ` | `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) diff --git a/docs/reference/cli/perf.md b/docs/reference/cli/perf.md new file mode 100644 index 0000000..bd4700d --- /dev/null +++ b/docs/reference/cli/perf.md @@ -0,0 +1,52 @@ +# `zfin perf` + +Show Morningstar-style trailing returns for a symbol. + +``` +Usage: zfin perf +``` + +`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) diff --git a/docs/reference/cli/portfolio.md b/docs/reference/cli/portfolio.md new file mode 100644 index 0000000..e3e5b56 --- /dev/null +++ b/docs/reference/cli/portfolio.md @@ -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) diff --git a/docs/reference/cli/projections.md b/docs/reference/cli/projections.md new file mode 100644 index 0000000..357d9d2 --- /dev/null +++ b/docs/reference/cli/projections.md @@ -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 ` | 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 ` | 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 ` | 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 (10th–90th 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) diff --git a/docs/reference/cli/quote.md b/docs/reference/cli/quote.md new file mode 100644 index 0000000..5b8a17a --- /dev/null +++ b/docs/reference/cli/quote.md @@ -0,0 +1,46 @@ +# `zfin quote` + +Show the latest quote for a symbol, with a price chart and recent +history. + +``` +Usage: zfin quote +``` + +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 ` 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) diff --git a/docs/reference/cli/review.md b/docs/reference/cli/review.md new file mode 100644 index 0000000..50f923b --- /dev/null +++ b/docs/reference/cli/review.md @@ -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) diff --git a/docs/reference/cli/snapshot.md b/docs/reference/cli/snapshot.md new file mode 100644 index 0000000..01b64f1 --- /dev/null +++ b/docs/reference/cli/snapshot.md @@ -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/-portfolio.srf` atomically. +The file is a discriminated SRF whose records start with +`kind::`. + +## Options + +| Flag | Effect | +|------------------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| `--dry-run` | Compute and print to stdout; write nothing. | +| `--force` | Overwrite an existing snapshot for the date. | +| `--out ` | Override the output path. | +| `--as-of ` | 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/-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) diff --git a/docs/reference/cli/splits.md b/docs/reference/cli/splits.md new file mode 100644 index 0000000..b490858 --- /dev/null +++ b/docs/reference/cli/splits.md @@ -0,0 +1,41 @@ +# `zfin splits` + +Show stock-split history for a symbol. + +``` +Usage: zfin splits +``` + +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) diff --git a/docs/reference/cli/version.md b/docs/reference/cli/version.md new file mode 100644 index 0000000..094a001 --- /dev/null +++ b/docs/reference/cli/version.md @@ -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) diff --git a/docs/reference/config/accounts-srf.md b/docs/reference/config/accounts-srf.md new file mode 100644 index 0000000..2574c08 --- /dev/null +++ b/docs/reference/config/accounts-srf.md @@ -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) diff --git a/docs/reference/config/environment.md b/docs/reference/config/environment.md new file mode 100644 index 0000000..05bf079 --- /dev/null +++ b/docs/reference/config/environment.md @@ -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) diff --git a/docs/reference/config/keys-srf.md b/docs/reference/config/keys-srf.md new file mode 100644 index 0000000..580d763 --- /dev/null +++ b/docs/reference/config/keys-srf.md @@ -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::` (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) diff --git a/docs/reference/config/metadata-srf.md b/docs/reference/config/metadata-srf.md new file mode 100644 index 0000000..b64b2f5 --- /dev/null +++ b/docs/reference/config/metadata-srf.md @@ -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 `" "` -> `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) diff --git a/docs/reference/config/portfolio-srf.md b/docs/reference/config/portfolio-srf.md new file mode 100644 index 0000000..8cd0b59 --- /dev/null +++ b/docs/reference/config/portfolio-srf.md @@ -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) diff --git a/docs/reference/config/projections-srf.md b/docs/reference/config/projections-srf.md new file mode 100644 index 0000000..a3df8a7 --- /dev/null +++ b/docs/reference/config/projections-srf.md @@ -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) diff --git a/docs/reference/config/theme-srf.md b/docs/reference/config/theme-srf.md new file mode 100644 index 0000000..9f0ba47 --- /dev/null +++ b/docs/reference/config/theme-srf.md @@ -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) diff --git a/docs/reference/config/transaction-log-srf.md b/docs/reference/config/transaction-log-srf.md new file mode 100644 index 0000000..f151efb --- /dev/null +++ b/docs/reference/config/transaction-log-srf.md @@ -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) diff --git a/docs/reference/config/watchlist-srf.md b/docs/reference/config/watchlist-srf.md new file mode 100644 index 0000000..b8be4c4 --- /dev/null +++ b/docs/reference/config/watchlist-srf.md @@ -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) diff --git a/docs/reference/providers.md b/docs/reference/providers.md new file mode 100644 index 0000000..88eb1af --- /dev/null +++ b/docs/reference/providers.md @@ -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 | | The one key worth setting first -- it's the primary price source. | +| Polygon | | Enables total return (dividends) and forward-looking dividend dates. | +| FMP | | Earnings actuals + estimates. | +| TwelveData | | Optional quote fallback. | +| OpenFIGI | | 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. diff --git a/docs/reference/tui.md b/docs/reference/tui.md new file mode 100644 index 0000000..7e6b05a --- /dev/null +++ b/docs/reference/tui.md @@ -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)