# zfin

A financial data library, CLI, and terminal UI written in Zig. Tracks portfolios, analyzes trailing returns, displays options chains, earnings history, and more -- all from the terminal.

## Quick start

### macOS (Apple Silicon) -- pre-built binary

```bash
# Download the latest aarch64-macos binary, make it executable, drop on $PATH
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 $PATH
```

### Linux / building from source

```bash
# Requires Zig 0.16.0. With mise installed, `mise install` will pick the right
# version from .mise.toml. Otherwise grab Zig 0.16.0 manually.
zig build
# Binary lands at zig-out/bin/zfin
```

### Configure

```bash
# Set at least one API key plus your contact email (see "API keys" below).
# The email is required for ETF profiles + `enrich` because SEC EDGAR mandates
# a User-Agent contact.
export TIINGO_API_KEY=your_key
export ZFIN_USER_EMAIL=you@example.com
```

### Use it

```bash
zfin perf VTI              # trailing returns
zfin quote AAPL            # real-time quote
zfin options AAPL          # options chains
zfin earnings MSFT         # earnings history
zfin portfolio             # portfolio summary (reads portfolio.srf)
zfin analysis              # portfolio analysis (reads portfolio.srf + metadata.srf)

# Interactive TUI
zfin i                     # auto-loads portfolio.srf from cwd
zfin i -p portfolio.srf -w watchlist.srf
zfin i -s AAPL             # start with a symbol, no portfolio
```

Building from source requires Zig 0.16.0.

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

| Data type             | Provider         | Auth                 | Free-tier limit            | Cache TTL    |
|-----------------------|------------------|----------------------|----------------------------|--------------|
| Daily candles (OHLCV) | Tiingo           | `TIINGO_API_KEY`     | 1,000 req/day              | 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      |
| Splits                | Polygon          | `POLYGON_API_KEY`    | 5 req/min                  | 14 days      |
| Options chains        | CBOE             | None required        | ~30 req/min (self-imposed) | 1 hour       |
| Earnings              | FMP              | `FMP_API_KEY`        | 250 req/day                | 30 days*     |
| ETF profiles          | SEC EDGAR        | `ZFIN_USER_EMAIL`    | 10 req/sec                 | ~90 days     |
| Classification        | Wikidata + EDGAR | `ZFIN_USER_EMAIL`    | No per-day quota           | Long-lived   |

### Tiingo

**Used for:** daily candles (primary provider for all symbols), supplementary dividend and split data.

- Endpoint: `https://api.tiingo.com/tiingo/daily/{symbol}/prices`
- Free tier: 1,000 requests per day, no per-minute restriction.
- Covers stocks, ETFs, and mutual funds. Mutual fund NAVs are available after midnight ET.
- Candles are fetched with a fixed 2000-01-01 start date so the cache supports `--as-of` projections back to the earliest imported portfolio data (typically 2014) with full 10Y trailing-return windows.
- The same response carries per-row `divCash` and `splitFactor`. We extract these as a free side benefit and merge them into the dividend/split caches alongside Polygon's primary view -- this rescues entries Polygon's reference endpoints miss (e.g. SPYM's 2017-10-16 4:1 split).

### TwelveData

**Used for:** real-time quote fallback (after Yahoo).

- Endpoint: `https://api.twelvedata.com/quote`
- Free tier: 8 API credits per minute, 800 per day.
- TwelveData was previously used for candles but is no longer in the candle pipeline -- its `adj_close` values were unreliable for split-adjustment math. Yahoo is the candle fallback now.

### Polygon

**Used for:** dividend and stock split data, both historical and forward-looking.

- Endpoints: `https://api.polygon.io/v3/reference/dividends` and `/v3/reference/splits`
- Free tier: 5 requests per minute, unlimited daily. Full historical data.
- Dividend endpoint uses cursor-based pagination (automatically followed).
- Provides dividend type classification (regular, special, supplemental) and richer metadata than Tiingo (`pay_date`, `record_date`, `currency`).
- Polygon is **primary** for dividends and splits because it carries forward-looking declared events (e.g. ARCC's next ex-dividend date several months out) that Tiingo's price-series response cannot provide. Tiingo merges in supplementary entries for historical events Polygon's reference endpoints occasionally miss.

### CBOE

**Used for:** options chains.

- Endpoint: `https://cdn.cboe.com/api/global/delayed_quotes/options/{SYMBOL}.json`
- No API key required. Data is 15-minute delayed during market hours.
- Returns all expirations with full chains including greeks (delta, gamma, theta, vega), bid/ask, volume, open interest, and implied volatility.
- OCC option symbols are parsed to extract expiration, strike, and contract type.

### FMP (Financial Modeling Prep)

**Used for:** earnings history (historical actuals + analyst consensus estimates + upcoming).

- Endpoint: `https://financialmodelingprep.com/stable/earnings?symbol={SYMBOL}`
- Free tier: 250 requests per day. With the 30-day cache TTL, a 50-symbol portfolio averages ~2 requests/day.
- History depth: full — often back to the 1980s for long-listed tickers.
- Coverage: US stocks with real earnings. ETFs, mutual funds, CUSIPs, and some dual-class shares (BRK.B, GOOG) return 402 on the free tier and show up as "no earnings data" in the UI — a documented limitation, not a bug.

### SEC EDGAR + Wikidata

**Used for:** ETF profiles (NPORT-P holdings, sector weights, AUM, inception
dates) and the `enrich` flow that bootstraps `metadata.srf` (sector / industry
/ country / asset-class classification).

- Endpoints:
  - `https://data.sec.gov/...` -- XBRL company facts, NPORT-P primary
    documents, mutual-fund ticker map.
  - `https://www.wikidata.org/sparql` -- sector / industry / country
    statements.
- Free, but the SEC requires a contact email in the User-Agent header. zfin
  reads this from `ZFIN_USER_EMAIL` (`.env` or environment). Without it, ETF
  profiles and `enrich` are unavailable; everything else still works.
- The SEC caps requests at 10/sec; the rate limiter respects this. Wikidata
  has no per-day quota.
- The `enrich` command queries Wikidata first (rich classification metadata);
  when Wikidata has no entry for a symbol (common for managed funds, UITs),
  EDGAR's mutual-fund ticker map is the fallback. Symbols that miss both fall
  through as TODO entries the user fills in by hand.

## API keys

Set keys as environment variables or in a `.env` file (searched in the executable's parent directory, then cwd):

```bash
TIINGO_API_KEY=your_key          # Required for candles (primary provider)
TWELVEDATA_API_KEY=your_key      # Quote fallback (after Yahoo)
POLYGON_API_KEY=your_key         # Required for dividends/splits (total returns)
FMP_API_KEY=your_key             # Required for earnings data
ZFIN_USER_EMAIL=you@example.com  # Required for ETF profiles + `enrich`
                                 # (SEC EDGAR mandates a User-Agent contact)
```

The cache directory defaults to `~/.cache/zfin` and can be overridden with `ZFIN_CACHE_DIR`.

Not all keys are required. Without a key, the corresponding data simply won't be available:

| Key                  | Without it                                                                                |
|----------------------|-------------------------------------------------------------------------------------------|
| `TIINGO_API_KEY`     | Candles fall back to Yahoo only; some symbols (especially mutual funds) won't work        |
| `TWELVEDATA_API_KEY` | No quote fallback after Yahoo                                                             |
| `POLYGON_API_KEY`    | No forward-looking dividends; trailing total returns may use only Tiingo's view           |
| `FMP_API_KEY`        | No earnings data (tab disabled)                                                           |
| `ZFIN_USER_EMAIL`    | No ETF profiles; `enrich` cannot bootstrap metadata. (Used as the EDGAR User-Agent value) |

CBOE options require no API key.

## Caching strategy

Every data fetch follows the same pattern:

1. Check local cache (`~/.cache/zfin/{SYMBOL}/{data_type}.srf`)
2. If cached file exists and is within TTL -- deserialize and return (no network)
3. Otherwise -- fetch from provider -- serialize to cache -- return

Cache files use [SRF](https://github.com/lobo/srf) (Simple Record Format), a line-oriented key-value format. Freshness is determined by file modification time vs. the TTL for that data type.

| Data type     | TTL          | Rationale                                           |
|---------------|--------------|-----------------------------------------------------|
| Daily candles | 23h45m       | Slightly under 24h for cron jitter tolerance        |
| Dividends     | 14 days      | Declared well in advance                            |
| Splits        | 14 days      | Rare corporate events                               |
| Options       | 1 hour       | Prices change continuously during market hours      |
| Earnings      | 30 days*     | Quarterly events; smart refresh after announcements |
| ETF profiles  | 30 days      | Holdings/weights change slowly                      |
| Quotes        | Never cached | Intended for live price checks                      |

**Earnings smart refresh:** Even within the 30-day TTL, cached earnings are automatically re-fetched when an earnings date has passed but the cache still has no actual results for it. This ensures results appear promptly after an announcement without wasteful daily polling.

Manual refresh (`r` / `F5` in TUI) invalidates the cache for the current tab's data before re-fetching.

### Rate limiting

Each provider has a client-side token-bucket rate limiter that prevents exceeding free-tier limits:

| Provider      | Rate limit          |
|---------------|---------------------|
| Tiingo        | 1,000/day           |
| TwelveData    | 8/minute            |
| Polygon       | 5/minute            |
| FMP           | 250/day             |
| CBOE          | 30/minute           |
| SEC EDGAR     | 10/second           |
| Wikidata      | (no enforced limit) |

The limiter blocks until a token is available, spreading bursts of requests automatically rather than failing with 429 errors.

## CLI commands

```
zfin <command> [args]

Commands:
  perf <SYMBOL>        Trailing returns (1yr/3yr/5yr/10yr, price + total)
  quote <SYMBOL>       Real-time quote
  history <SYMBOL>     Last 30 days price history
  divs <SYMBOL>        Dividend history with TTM yield
  splits <SYMBOL>      Split history
  options <SYMBOL>     Options chains (all expirations)
  earnings <SYMBOL>    Earnings history and upcoming events
  etf <SYMBOL>         ETF profile (expense ratio, holdings, sectors)
  portfolio <FILE>     Portfolio analysis from .srf file
  snapshot [opts]      Write a daily portfolio snapshot to history/
  compare <DATE> [<DATE>]
                       Compare portfolio state across two dates
  cache stats          Show cached symbols
  cache clear          Delete all cached data
  interactive, i       Launch interactive TUI
  help                 Show usage
```

### compare

Compare the portfolio at two points in time. Useful for answering
"how am I doing since X" without the noise of the full portfolio
display.

```
zfin compare <DATE>              Compare snapshot at DATE vs current live portfolio
zfin compare <DATE1> <DATE2>     Compare two historical snapshots
```

Arguments can be given in any order — the command always displays
older → newer. Dates are `YYYY-MM-DD`. Snapshots come from
`history/YYYY-MM-DD-portfolio.srf` files produced by
`zfin snapshot` (typically run via cron).

**Output:**

- **Liquid:** raw value change — includes any contributions or
  withdrawals made between the two dates (adjusting for flows is
  out of scope).
- **Per-symbol price change:** for symbols held on *both* dates.
  Sorted by % change descending (biggest winners first). The dollar
  column uses the shares-held-throughout floor (`min(shares_then,
  shares_now)`) so newly-added shares don't inflate it and sold
  shares don't deflate it.
- **Hidden count:** positions added or removed between the two dates
  are counted but not rendered.

On a missing snapshot date, the command prints the nearest earlier
and later available dates to stderr and exits 1 — no silent
snapping.

Example output shape (values illustrative):

```
$ zfin compare 2024-01-15 2024-03-15
Portfolio comparison: 2024-01-15 → 2024-03-15 (60 days)

Liquid:  $100,000.00 → $105,000.00    +$5,000.00   +5.00%

Per-symbol price change (5 held throughout)
  FOO          $40.00 → $44.00            +10.00%      +$400.00
  BAR         $100.00 → $105.00            +5.00%      +$250.00
  ...
  BAZ          $50.00 → $48.00             -4.00%       -$80.00

(1 added, 1 removed since 2024-01-15 — hidden)
```


### Chart export (`--export-chart <PATH>`)

The `quote` and `projections` commands support `--export-chart <PATH>` to render their charts as PNG files (1920x1080) instead of emitting text. Useful for write-ups, sharing, or capturing a back-dated projection without screenshot-and-crop.

```
zfin quote AAPL --export-chart aapl.png
zfin projections --as-of 1Y --overlay-actuals --export-chart proj.png
```

The exported image uses the TUI's default theme. When `--export-chart` is set, no other text output is emitted — the command exits after writing the file. Only the default `projections` mode is supported; `--convergence`, `--return-backtest`, and `--vs` reject the flag (their charts still need PNG plumbing). The `history` command's portfolio-value chart is also not yet exportable — it uses a single-series braille format that doesn't share the z2d pipeline used by `quote` and `projections`.

### Interactive TUI flags

```
zfin i [options]

  -p, --portfolio <FILE>   Portfolio file (.srf format)
  -w, --watchlist <FILE>   Watchlist file (default: watchlist.srf if present)
  -s, --symbol <SYMBOL>    Start with a specific symbol
  --default-keys           Print default keybindings config to stdout
  --default-theme          Print default theme config to stdout
```

If no portfolio or symbol is specified and `portfolio.srf` exists in the current directory, it is loaded automatically.

## Interactive TUI

The TUI has eight tabs: Portfolio, Analysis, Projections, History, Quote, Performance, Earnings, and Options.

### Tabs

**Portfolio** -- navigable list of positions with market value, gain/loss, weight, and purchase date. Multi-lot positions can be expanded to show individual lots with per-lot gain/loss, capital gains indicator (ST/LT), and account name. Press `a` to open an account-filter picker (with `/` search).

**Analysis** -- portfolio breakdown by asset class, sector, geographic region, account, and tax type. Uses classification data from `metadata.srf` and account tax types from `accounts.srf`. Displays horizontal bar charts with sub-character precision using Unicode block elements.

**Projections** -- Monte Carlo retirement projection with percentile bands. Press `d` to set an as-of date (back-date the projection to a historical snapshot), `o` to overlay realized actuals from snapshots / `imported_values.srf` on top of the bands, `z` to toggle auto-zoom on the overlay (chart x-axis defaults to roughly `[as_of, today + actuals_span]` so a short actuals line isn't squashed into the start of a 50-year horizon), `v` to toggle the chart vs the text-only report, and `e` to toggle simulated lifecycle events (RMDs, lump-sum withdrawals). Esc clears an active as-of override.

**History** -- portfolio value over time, sourced from snapshot files in `<portfolio-dir>/history/` plus optional `imported_values.srf`. Cycle the metric column with `m` (liquid / total / contributions / etc.) and the time-bucket resolution with `t` (week / month / quarter / year). Press `s` (or space) to mark a row for compare; mark a second row, then `c` to commit a side-by-side compare against the live portfolio. Esc cancels an in-flight compare.

**Quote** -- current price, OHLCV, daily change, and a 60-day ASCII chart with recent history table.

**Performance** -- trailing returns using two methodologies (as-of-date and month-end), matching Morningstar's "Trailing Returns" and "Performance" pages respectively. Shows price-only and total return (with dividend reinvestment) using whichever dividend data is available -- Polygon (richer metadata, forward-looking entries) and Tiingo (extracted from candle responses, historical only) are merged. Also shows risk metrics (volatility, Sharpe ratio, max drawdown).

**Earnings** -- historical and upcoming earnings events with EPS estimate/actual, surprise amount and percentage. Future events are dimmed. Tab is disabled for ETFs.

**Options** -- all expirations in a navigable list. Expand any expiration to see calls and puts inline. Calls and puts sections are independently collapsible. Near-the-money filter limits strikes shown (default +/- 8, adjustable with Ctrl+1-9). ITM strikes are marked with `|`. Monthly expirations display in normal color, weeklies are dimmed.

### Keybindings

All keybindings are configurable via `~/.config/zfin/keys.srf`. Generate the default config:

```bash
zfin i --default-keys > ~/.config/zfin/keys.srf
```

The generated file has two parts: a global section (keys that work in every tab) and a per-tab section (keys that only fire when that tab is active). Tab-local bindings cannot override globally-bound keys — zfin refuses to start if your config creates that conflict.

Default global keybindings:

| Key                    | Action                                               |
|------------------------|------------------------------------------------------|
| `q`, `Ctrl+c`          | Quit                                                 |
| `r`, `F5`              | Refresh current tab (invalidates cache)              |
| `R`                    | Reload portfolio from disk (no network)              |
| `h`, Left, Shift+Tab   | Previous tab                                         |
| `l`, Right, Tab        | Next tab                                             |
| `1`-`8`                | Jump to tab N                                        |
| `j`, Down              | Select next row                                      |
| `k`, Up                | Select previous row                                  |
| `g`                    | Scroll to top                                        |
| `G`                    | Scroll to bottom                                     |
| `Ctrl+d`               | Half-page down                                       |
| `Ctrl+u`               | Half-page up                                         |
| `PageDown`, `Ctrl+f`   | Page down                                            |
| `PageUp`, `Ctrl+b`     | Page up                                              |
| `/`                    | Symbol input prompt                                  |
| `?`                    | Help screen                                          |

Default tab-local keybindings (only active on the matching tab):

| Tab         | Key                | Action                                                   |
|-------------|--------------------|----------------------------------------------------------|
| Portfolio   | `Enter`            | Expand/collapse position                                 |
| Portfolio   | `>` / `<`          | Sort by next / previous column                           |
| Portfolio   | `o`                | Reverse sort direction                                   |
| Portfolio   | `a`                | Open account-filter picker (`/` to search inside picker) |
| Portfolio   | `Esc`              | Clear active account filter                              |
| Portfolio   | `s`, `Space`       | Select symbol (sets active symbol for other tabs)        |
| Quote       | `[` / `]`          | Previous / next chart timeframe                          |
| Options     | `Enter`            | Expand/collapse expiration or section                    |
| Options     | `c` / `p`          | Toggle all calls / puts collapsed                        |
| Options     | `Ctrl+1`-`Ctrl+9`  | Set near-the-money filter to +/- N strikes               |
| History     | `Enter`            | Expand/collapse tier                                     |
| History     | `m`                | Cycle metric column                                      |
| History     | `t`                | Cycle time-bucket resolution                             |
| History     | `s`, `Space`       | Mark / unmark row for compare                            |
| History     | `c`                | Commit compare (after two rows marked)                   |
| History     | `Esc`              | Cancel in-flight compare selection                       |
| Projections | `d`                | Set as-of date prompt                                    |
| Projections | `Esc`              | Clear as-of date                                         |
| Projections | `o`                | Toggle realized-actuals overlay                          |
| Projections | `z`                | Toggle overlay auto-zoom (clamp x-axis to overlay span)  |
| Projections | `v`                | Toggle chart vs text-only report                         |
| Projections | `e`                | Toggle simulated lifecycle events                        |

Mouse: scroll wheel navigates, left-click selects rows and switches tabs.

### Theme

The TUI uses a dark theme inspired by Monokai/opencode. Customize via `~/.config/zfin/theme.srf`:

```bash
zfin i --default-theme > ~/.config/zfin/theme.srf
```

Colors are specified as `#rrggbb` hex values. The theme uses RGB colors (not terminal color indices) to work correctly with transparent terminal backgrounds.

## Portfolio format

Portfolios are [SRF](https://github.com/lobo/srf) files with one lot per line. Each lot is a comma-separated list of `key::value` pairs (numbers use `key:num:value`).

```
#!srfv1
# Stocks/ETFs
symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,account::Brokerage
symbol::AAPL,shares:num:50,open_date::2024-03-01,open_price:num:170.00,account::Roth IRA
symbol::AAPL,shares:num:25,open_date::2023-06-15,open_price:num:155.00,account::Roth IRA

# Closed lot (sold)
symbol::AMZN,shares:num:10,open_date::2022-03-15,open_price:num:150.25,close_date::2024-01-15,close_price:num:185.50

# DRIP lots (summarized as ST/LT groups in the UI)
symbol::VTI,shares:num:0.234,open_date::2024-06-15,open_price:num:267.50,drip::true,account::Brokerage

# CUSIP with ticker alias (401k CIT share class)
symbol::02315N600,shares:num:1200,open_date::2022-01-01,open_price:num:140.00,ticker::VTTHX,account::Fidelity 401k,note::VANGUARD TARGET 2035

# Manual price override (for securities without API coverage)
symbol::NON40OR52,shares:num:500,open_date::2023-01-01,open_price:num:155.00,price:num:163.636,price_date::2026-02-27,account::Fidelity 401k,note::CIT SHARE CLASS

# Options
security_type::option,symbol::AAPL 250321C00200000,shares:num:-2,open_date::2025-01-15,open_price:num:12.50,account::Brokerage

# CDs
security_type::cd,symbol::912797KR0,shares:num:10000,open_date::2024-06-01,open_price:num:10000,maturity_date::2025-06-01,rate:num:5.25,account::Brokerage,note::6-Month T-Bill

# Cash
security_type::cash,shares:num:15000,account::Brokerage
security_type::cash,shares:num:5200.50,account::Roth IRA,note::Money market settlement

# Illiquid assets
security_type::illiquid,symbol::HOME,shares:num:450000,open_date::2020-06-01,open_price:num:350000,note::Primary residence (Zillow est.)

# Watchlist (track price only, no position)
security_type::watch,symbol::NVDA
security_type::watch,symbol::TSLA
```

### Lot fields

| Field           | Type   | Required | Description                                                              |
|-----------------|--------|----------|--------------------------------------------------------------------------|
| `symbol`        | string | Yes*     | Ticker symbol or CUSIP. *Optional for `cash` lots.                       |
| `shares`        | number | Yes      | Number of shares (or face value for cash/CDs)                            |
| `open_date`     | string | Yes**    | Purchase date (YYYY-MM-DD). **Not required for cash/watch.               |
| `open_price`    | number | Yes**    | Purchase price per share. **Not required for cash/watch.                 |
| `close_date`    | string | No       | Sale date (null = open lot)                                              |
| `close_price`   | number | No       | Sale price per share                                                     |
| `security_type` | string | No       | `stock` (default), `option`, `cd`, `cash`, `illiquid`, `watch`           |
| `account`       | string | No       | Account name (e.g. "Roth IRA", "Brokerage")                              |
| `note`          | string | No       | Descriptive note (shown in cash/CD/illiquid tables)                      |
| `ticker`        | string | No       | Ticker alias for price fetching (overrides `symbol` for API calls)       |
| `price`         | number | No       | Manual price override (fallback when API has no coverage)                |
| `price_date`    | string | No       | Date of the manual price (YYYY-MM-DD, for staleness display)             |
| `drip`          | string | No       | `true` if lot is from dividend reinvestment (summarized as ST/LT groups) |
| `maturity_date` | string | No       | CD maturity date (YYYY-MM-DD)                                            |
| `rate`          | number | No       | Interest rate for CDs (e.g. 5.25 = 5.25%)                                |

### Security types

- **stock** (default) -- Stocks, ETFs, and mutual funds. Prices are fetched from Tiingo (primary) or Yahoo (candle fallback). Positions are aggregated by symbol and shown with gain/loss.
- **option** -- Option contracts. Shown in a separate "Options" section. Shares can be negative for short positions.
- **cd** -- Certificates of deposit. Shown sorted by maturity date with rate and face value.
- **cash** -- Cash, money market, and settlement balances. Shown grouped by account with optional notes.
- **illiquid** -- Illiquid assets (real estate, vehicles, etc.). Shown in a separate section. Not included in the liquid portfolio total; contributes to Net Worth.
- **watch** -- Watchlist items. No position, just tracks the price. Shown at the bottom of the portfolio tab.

### Price resolution

For stock lots, prices are resolved in this order:

1. **Live API** -- Latest close from cached candles (Tiingo, with Yahoo as candle fallback)
2. **Manual price** -- `price::` field on the lot (for securities without API coverage, e.g. 401k CIT share classes)
3. **Average cost** -- Falls back to the position's `open_price` as a last resort

Manual-priced rows are shown in warning color (yellow) so you know the price may be stale. The `price_date::` field helps you track when the price was last updated.

### Ticker aliases

Some securities (like 401k CIT share classes) use CUSIPs as identifiers but have a retail equivalent ticker for price fetching. Use `ticker::` to specify the API ticker:

```
symbol::02315N600,ticker::VTTHX,...
```

The `symbol::` is used as the display identifier and for classification lookups. The `ticker::` is used for API price fetching. If the CUSIP and retail ticker have different NAVs (common for CIT vs retail fund), use `price::` instead.

### CUSIP lookup

Use the `lookup` command to resolve CUSIPs to tickers via OpenFIGI:

```bash
zfin lookup 459200101   # -> IWM (iShares Russell 2000 ETF)
```

### DRIP lots

Lots marked with `drip::true` are summarized as ST (short-term) and LT (long-term) groups in the position detail view, rather than listing every small reinvestment lot individually. The grouping is based on the 1-year capital gains threshold.

### Watchlist

Watchlist symbols can be defined as `security_type::watch` lots in the portfolio file, or in a separate watchlist file (`-w` flag). They appear at the bottom of the portfolio tab showing the cached price.

## Classification metadata (metadata.srf)

The `metadata.srf` file provides classification data for portfolio analysis. It maps symbols to asset class, sector, and geographic region. Place it in the same directory as the portfolio file.

```
#!srfv1
# Individual stock: single classification at 100%
symbol::AMZN,sector::Technology,geo::US,asset_class::US Large Cap

# ETF: inherits sector from holdings, but classified by asset class
symbol::VTI,asset_class::US Large Cap,geo::US

# International ETF
symbol::VXUS,asset_class::International Developed,geo::International Developed

# Target date fund: blended allocation (percentages should sum to ~100)
symbol::02315N600,asset_class::US Large Cap,pct:num:55
symbol::02315N600,asset_class::International Developed,pct:num:20
symbol::02315N600,asset_class::Bonds,pct:num:15
symbol::02315N600,asset_class::Emerging Markets,pct:num:10

# BDC / REIT / specialty
symbol::ARCC,sector::Financials,geo::US,asset_class::US Large Cap
```

### Classification fields

| Field         | Type   | Required | Description                                                               |
|---------------|--------|----------|---------------------------------------------------------------------------|
| `symbol`      | string | Yes      | Ticker symbol or CUSIP (must match `symbol::` or `ticker::` in portfolio) |
| `asset_class` | string | No       | e.g. "US Large Cap", "Bonds", "Cash & CDs", "Emerging Markets"            |
| `sector`      | string | No       | e.g. "Technology", "Healthcare", "Financials"                             |
| `geo`         | string | No       | e.g. "US", "International Developed", "Emerging Markets"                  |
| `pct`         | number | No       | Percentage weight for this entry (default 100). Use for blended funds.    |

For single-asset-class securities (individual stocks, single-focus ETFs), one line at the default 100% is sufficient. For multi-asset-class funds (target date, balanced), add multiple lines for the same symbol with `pct:num:` values that sum to approximately 100.

Cash and CD lots are automatically classified as "Cash & CDs" without needing metadata entries.

### Bootstrapping metadata

Use the `enrich` command to generate a starting `metadata.srf` from Wikidata
+ SEC EDGAR data. Requires `ZFIN_USER_EMAIL` set so EDGAR will accept the
request.

```bash
# Enrich an entire portfolio (generates full metadata.srf)
zfin enrich portfolio.srf > metadata.srf

# Enrich a single symbol and append to existing metadata.srf
zfin enrich SCHD >> metadata.srf
```

When given a file path, it fetches all stock symbols and outputs a complete
SRF file with headers. When given a symbol, it outputs just the
classification lines (no header), so you can append directly with `>>`.
Wikidata is queried first; symbols not found in Wikidata fall through to
EDGAR's mutual-fund ticker map. Anything that misses both shows up as a
TODO entry to fill in by hand.

## Account metadata (accounts.srf)

The `accounts.srf` file maps account names to tax types for the tax type breakdown in portfolio analysis. Place it in the same directory as the portfolio file.

```
#!srfv1
account::Brokerage,tax_type::taxable
account::Roth IRA,tax_type::roth
account::Traditional IRA,tax_type::traditional
account::Fidelity 401k,tax_type::traditional
account::HSA,tax_type::hsa
```

### Account fields

| Field      | Type   | Required | Description                                                     |
|------------|--------|----------|-----------------------------------------------------------------|
| `account`  | string | Yes      | Account name (must match `account::` in portfolio lots exactly) |
| `tax_type` | string | Yes      | Tax classification (see below)                                  |

### Tax types

| Value         | Display label         |
|---------------|-----------------------|
| `taxable`     | Taxable               |
| `roth`        | Roth (Post-Tax)       |
| `traditional` | Traditional (Pre-Tax) |
| `hsa`         | HSA (Triple Tax-Free) |
| (other)       | Shown as-is           |

Accounts not listed in `accounts.srf` appear as "Unknown" in the tax type breakdown.

## Projections configuration (projections.srf)

The `projections` command runs Monte-Carlo-style historical
simulations of your retirement portfolio over the Shiller dataset
(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 <DATE> --overlay-actuals` to plot the
**realized portfolio trajectory** from `<DATE>` up to today on top of
the projected percentile bands. Answers the question "how accurate
were my past projections compared to reality?"

The TUI is the high-fidelity surface — open the projections tab,
press `d` to set the as-of date, then press `o` to toggle the
overlay. The CLI prints a tip pointing at the TUI; the braille chart
itself doesn't render the overlay (the resolution doesn't do justice
to a 12+ year actuals trajectory laid against percentile bands).

**The overlay reads from two sources, snapshot-precedence:**

1. **Native snapshots** in `<portfolio-dir>/history/*-portfolio.srf`
   — produced by `zfin snapshot`. Highest fidelity (full lot-level
   state, exact totals).
2. **Imported values** in `<portfolio-dir>/history/imported_values.srf`
   — a hand-maintained back-history file with one `liquid::` total
   per date. Useful for backfilling a historical record from
   spreadsheet data, statements, etc. Snapshots win on overlapping
   dates.

**As-of resolution against either source.** `--as-of <DATE>`
resolves first against the snapshot directory (nearest-earlier
`*-portfolio.srf`); if no snapshot exists at or before the date,
it falls back to `imported_values.srf`. This means you can run
`zfin projections --as-of 2018-06-01` even with zero snapshot
files, as long as the imported back-history covers that date.

When the resolution lands on an imported value, the projection
bands are computed using **today's allocations scaled to the
imported liquid total** — we can't reconstruct the historical lot
composition from just a `liquid::` row, so today's stock/bond
split is substituted as the best-available approximation. The
header line reads `Projections (as of YYYY-MM-DD, imported value)`
and a muted note flags the scaling.

**How to read the chart:**

- **Actuals line stays inside the bands** → the model was
  directionally honest.
- **Actuals line punches through the top band** → the model was
  conservative (good problem to have).
- **Actuals line punches through the bottom band** → the model was
  optimistic (bands need wider envelope, or a bear scenario was
  underweighted).
- **A thin vertical "today" line** marks where the actuals end and
  the projected future begins.

**Critical caveat (must be loud, by design):**

> This overlay shows whether the model was **directionally honest**,
> not whether the SWR claim was **accurate**. The SWR claim is a
> 30-year claim. We have at most ~12 years of weekly history
> (post-import) and 1+ years of native snapshots. The overlay tells
> you "did my actual trajectory fall within the bands the model would
> have drawn." It does **not** tell you "did the safe withdrawal rate
> hold up over a full retirement horizon." We will not have data to
> answer that within either of our lifetimes.

The TUI surfaces this caveat on a status line whenever the overlay is
active.

### Forecast-vs-actual evaluation (`--convergence`, `--return-backtest`)

Two evaluation views over the spreadsheet's historical forecasts in
`imported_values.srf`. They complement `--overlay-actuals`:

- **Overlay** answers "did the actual trajectory fall inside the
  model's bands?"
- **Convergence** answers "did the model converge on a retirement
  date as data accumulated?"
- **Return back-test** answers "was the model's expected-return
  assumption honest, in hindsight?"

```bash
zfin projections --convergence
zfin projections --return-backtest [--real]
```

`--convergence` reads each historical row's `projected_retirement`
field — the date the spreadsheet predicted you could retire — and
emits a table of `(observation_date, projected_date, years-until)`.
A flat downward slope of ~1y/year means the model was honestly
counting down. A flat-line at constant N means each year passes with
no progress (the prediction always says "N years away"). A `reached`
sentinel marks rows where the model said "you're already
retirement-ready."

`--return-backtest` reads each row's `expected_return` claim and
compares it to the realized 1y/3y/5y forward CAGR of the `liquid`
series. Useful for gut-checking whether the spreadsheet's
`min(1y,3y,5y,10y)`-weighted formula systematically over- or
under-predicted. Pair with `--real` to compare against
inflation-deflated realized returns (the expected column stays
nominal — it's a return rate the source spreadsheet captured as
nominal).

The CLI emits sampled tables (every Nth observation/anchor) for
quick scanning. The TUI projections tab renders the same data as
high-fidelity Kitty-graphics line charts; press `c` (convergence)
or `r` (return back-test) on the projections tab to switch views.

Both views are also subject to the "directional honesty, not SWR
validity" caveat: they evaluate the model's inputs and outputs over
time, not whether the underlying SWR claim will hold up over a
30-year retirement.

### Life events

Life events modify the simulation's annual cash flow. Positive
amounts are income (offset withdrawals); negative amounts are
expenses (added to withdrawals). Events are CPI-adjusted by
default; set `inflation_adjusted:bool:false` for nominal events.

```srf
# Permanent income starting at age 70
type::event,name::Social Security,start_age:num:70,amount:num:38400

# 4-year expense starting at age 50
type::event,name::College Tuition,start_age:num:50,duration:num:4,amount:num:-55000

# Per-person events (defaults to person 1)
type::event,name::Pension,start_age:num:65,person:num:2,amount:num:24000
```

### Configuration field reference

| Field                                  | Type | Description                                                                       |
| -------------------------------------- | ---- | --------------------------------------------------------------------------------- |
| `target_stock_pct`                     | num  | Asset-allocation target (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 <command> [args]

Commands:
  perf <SYMBOL>        Trailing returns (1yr/3yr/5yr/10yr, price + total)
  quote <SYMBOL>       Real-time quote with chart
  history <SYMBOL>     Last 30 days price history
  divs <SYMBOL>        Dividend history with TTM yield
  splits <SYMBOL>      Split history
  options <SYMBOL>     Options chains (all expirations)
  earnings <SYMBOL>    Earnings history and upcoming events
  etf <SYMBOL>         ETF profile (expense ratio, holdings, sectors)
  portfolio [FILE]     Portfolio summary (default: portfolio.srf)
  analysis [FILE]      Portfolio analysis breakdowns (default: portfolio.srf)
  snapshot [opts]      Write a daily portfolio snapshot to history/
  compare <D1> [<D2>]  Compare portfolio state across two dates
  enrich <FILE|SYMBOL>  Generate metadata.srf from Wikidata + SEC EDGAR
  lookup <CUSIP>       CUSIP to ticker lookup via OpenFIGI
  cache stats          Show cached symbols
  cache clear          Delete all cached data
  interactive, i       Launch interactive TUI
  help                 Show usage

Global options:
  --no-color           Disable colored output (also respects NO_COLOR env)

Portfolio options:
  --refresh            Force re-fetch all prices (ignore cache)
  -w, --watchlist <FILE>   Watchlist file
```

## Architecture

```
src/
  root.zig              Library root, exports all public types
  format.zig            Shared formatters, braille engine, ANSI helpers
  config.zig            Configuration from env vars / .env files
  service.zig           DataService: cache-check -> fetch -> cache -> return
  models/
    candle.zig          OHLCV price bars
    date.zig            Date type with arithmetic, snapping, formatting
    dividend.zig        Dividend records with type classification
    split.zig           Stock splits
    option.zig          Option contracts and chains
    earnings.zig        Earnings events with surprise calculation
    etf_profile.zig     ETF profiles with holdings and sectors
    portfolio.zig       Lots, positions, and portfolio aggregation
    classification.zig  Classification metadata parser
    quote.zig           Real-time quote data
  providers/
    tiingo.zig          Tiingo: daily candles (primary), supplementary div/split merge
    twelvedata.zig      TwelveData: quote fallback
    polygon.zig         Polygon: dividends, splits (primary, with forward-looking entries)
    fmp.zig             FMP: earnings (actuals + estimates)
    cboe.zig            CBOE: options chains (no API key)
    Edgar.zig           SEC EDGAR: ETF profiles (NPORT-P), mutual-fund ticker map, XBRL company facts
    Wikidata.zig        Wikidata SPARQL: classification metadata for `enrich`
    yahoo.zig           Yahoo Finance: quotes (primary), candles (Tiingo fallback)
    openfigi.zig        OpenFIGI: CUSIP to ticker lookup
    xml.zig             Vendored XML parser used by Edgar.zig (see "Vendored code")
  analytics/
    indicators.zig      SMA, Bollinger Bands, RSI
    performance.zig     Trailing returns (as-of-date + month-end)
    risk.zig            Volatility, Sharpe, drawdown
    valuation.zig       Portfolio summary, allocations, covered call adjustments
    analysis.zig        Portfolio analysis engine (breakdowns by class/sector/geo/account/tax)
  cache/
    store.zig           SRF file cache with TTL freshness checks
    net/
      http.zig            HTTP client with retries and server error retry
      rate_limiter.zig    Token-bucket rate limiter
  commands/
    common.zig          Shared CLI helpers (progress, formatting)
    perf.zig            Trailing returns command
    quote.zig           Quote command
    ... (14 command files)
  tui.zig               Interactive TUI application
  tui/
    chart.zig           z2d pixel chart renderer (Kitty graphics)
    keybinds.zig        Configurable keybinding system
    theme.zig           Configurable color theme
```

Data files (user-managed, in project root):
```
portfolio.srf           Portfolio lots
metadata.srf            Classification metadata for analysis
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)   |

## Building

Requires Zig 0.16.0. The canonical version is pinned in `.mise.toml`:

```bash
# Recommended: use mise to install the right Zig and ZLS automatically
mise install

# Or, with Zig 0.16.0 already on PATH:
zig build              # build the zfin binary
zig build test         # run all tests
zig build run -- <args> # build and run
```

The compiled binary is at `zig-out/bin/zfin`.

## Vendored code

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                                           |
|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|
| `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
identifying its upstream source. When updating, copy the new
upstream verbatim and re-add the header.

## License

MIT
