finance library and cli/tui
Find a file
2026-05-15 17:47:23 -07:00
.forgejo/workflows create docker image 2026-04-18 12:41:22 -07:00
build upgrade to zig 0.16.0 2026-05-09 22:40:33 -07:00
docker create docker image 2026-04-18 12:41:22 -07:00
examples adding previously ignored example files 2026-05-13 11:15:04 -07:00
src move tab-modals into tabs from global 2026-05-15 17:47:23 -07:00
.gitignore adding previously ignored example files 2026-05-13 11:15:04 -07:00
.mise.toml upgrade to zig 0.16.0 2026-05-09 22:40:33 -07:00
.pre-commit-config.yaml add ability to use imported history data 2026-05-12 10:52:43 -07:00
AGENTS.md move most tabs to new framework 2026-05-14 14:50:36 -07:00
build.zig upgrade to zig 0.16.0 2026-05-09 22:40:33 -07:00
build.zig.zon upgrade to zig 0.16.0 2026-05-09 22:40:33 -07:00
LICENSE ai generated, functional, no code review yet 2026-02-25 14:10:27 -08:00
metadata.srf add international etfs to metadata 2026-04-09 17:04:00 -07:00
README.md add overlay information to README 2026-05-13 13:07:10 -07:00
TODO.md add accumulation SWR todo 2026-05-13 13:06:19 -07:00

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

# Set at least one API key (see "API keys" below)
export TIINGO_API_KEY=your_key

# Build
zig build

# CLI usage
zig build run -- perf VTI           # trailing returns
zig build run -- quote AAPL         # real-time quote
zig build run -- options AAPL       # options chains
zig build run -- earnings MSFT      # earnings history
zig build run -- portfolio          # portfolio summary (reads portfolio.srf)
zig build run -- analysis           # portfolio analysis (reads portfolio.srf + metadata.srf)

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

Requires Zig 0.15.2 or later.

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
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 Alpha Vantage ALPHAVANTAGE_API_KEY 25 req/day 30 days

Tiingo

Used for: daily candles (primary provider for all symbols).

  • 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 10-year + 60-day lookback window for trailing return calculations.
  • Returns split-adjusted prices with adjClose for dividend-adjusted values.

TwelveData

Used for: candle fallback (when Tiingo fails), real-time quotes (fallback after Yahoo).

  • Endpoint: https://api.twelvedata.com/time_series and /quote
  • Free tier: 8 API credits per minute, 800 per day. Each symbol in a request costs 1 credit.
  • Mutual fund NAV updates can lag by a full trading day compared to Tiingo.

Polygon

Used for: dividend and stock splits information, both historical and upcoming.

  • Endpoints: https://api.polygon.io/v3/reference/dividends and /v3/reference/splits
  • Free tier: 5 requests per minute, unlimited daily. Full historical dividend/split data.
  • Dividend endpoint uses cursor-based pagination (automatically followed).
  • Provides dividend type classification (regular, special, supplemental).

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.

Alpha Vantage

Used for: ETF profiles (expense ratio, holdings, sector weights).

  • Endpoint: https://www.alphavantage.co/query?function=ETF_PROFILE
  • Free tier: 25 requests per day. Used sparingly -- ETF profiles rarely change.

API keys

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

TIINGO_API_KEY=your_key        # Required for candles (primary provider)
TWELVEDATA_API_KEY=your_key    # Candle fallback, quote fallback
POLYGON_API_KEY=your_key       # Required for dividends/splits (total returns)
FMP_API_KEY=your_key           # Required for earnings data
ALPHAVANTAGE_API_KEY=your_key  # Required for ETF profiles

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 TwelveData, then Yahoo
TWELVEDATA_API_KEY No candle fallback after Tiingo, no quote fallback after Yahoo
POLYGON_API_KEY No dividends -- trailing returns show price-only (no total return)
FMP_API_KEY No earnings data (tab disabled)
ALPHAVANTAGE_API_KEY No ETF profiles

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 (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
Alpha Vantage 25/day

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)

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 six tabs: Portfolio, Quote, Performance, Options, Earnings, and Analysis.

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.

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) when Polygon data is available. Also shows risk metrics (volatility, Sharpe ratio, max drawdown).

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.

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

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.

Keybindings

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

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

Default 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-6 Jump to tab
j, Down Select next row
k, Up Select previous row
Enter Expand/collapse (positions, expirations, calls/puts)
s Select symbol from portfolio for other tabs
/ Enter symbol search
e Edit portfolio/watchlist in $EDITOR
< Sort by previous column (portfolio tab)
> Sort by next column (portfolio tab)
o Reverse sort direction (portfolio tab)
[ Previous chart timeframe (quote tab)
] Next chart timeframe (quote tab)
c Toggle all calls collapsed/expanded (options tab)
p Toggle all puts collapsed/expanded (options tab)
Ctrl+1-Ctrl+9 Set options near-the-money filter to +/- N strikes
g Scroll to top
G Scroll to bottom
Ctrl+d Half-page down
Ctrl+u Half-page up
PageDown Page down
PageUp Page up
? Help screen

Mouse: scroll wheel navigates, left-click selects rows and switches tabs, double-click expands/collapses.

Theme

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

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 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), TwelveData, or Yahoo (fallbacks). 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/TwelveData/Yahoo)
  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:

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 Alpha Vantage company overview data:

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

Account metadata (accounts.srf)

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

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

Account fields

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

Tax types

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

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

Projections configuration (projections.srf)

The projections command runs Monte-Carlo-style historical simulations of your retirement portfolio over the Shiller dataset (1871present). Configuration lives in projections.srf next to portfolio.srf. The file is optional — without it, the command runs with sensible defaults (20/30/45-year horizons, 90/95/99% confidence levels, no accumulation phase).

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

type::config,retirement_age:num:65    # oldest person hits 65
# or
type::config,retirement_at::2046-04-12

type::config,annual_contribution:num:80000
type::config,contribution_inflation_adjusted:bool:true

When both are set, retirement_at wins. Output renders the Accumulation phase block: median portfolio at retirement, p10p90 range, and the dated headline "Years until possible retirement: N (DATE, ages A/B)" line.

Target spending

Set target_spending to anchor a desired retirement income. The display answers "given my desired spending, when can I retire?"

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:

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.

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.

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

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

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

Configuration field reference

Field Type Description
target_stock_pct num Asset-allocation target (0100). Used for sim stock/bond blend.
horizon num Distribution-phase length in years. Repeat for multiple horizons.
horizon_age num Resolves to target_age oldest_current_age. Repeat for multiple.
retirement_target (on horizon[_age]) num Override the default earliest-retirement promotion. Allowed: 90, 95, 99.
retirement_age num Years old the OLDEST configured person must be to retire.
retirement_at date Absolute calendar date for retirement. Wins over retirement_age if both set.
annual_contribution num Yearly contribution during accumulation, in today's dollars.
contribution_inflation_adjusted bool If true (default), contributions grow with CPI year-over-year.
target_spending num Target retirement spending in today's dollars.
target_spending_inflation_adjusted bool If true (default), target spending grows with CPI in the distribution phase.

See examples/pre-retirement-{age,spending,spending-target,both}/ and examples/post-retirement/ for fully-configured walkthroughs of each combination.

CLI commands

zfin <command> [args]

Commands:
  perf <SYMBOL>        Trailing returns (1yr/3yr/5yr/10yr, price + total)
  quote <SYMBOL>       Real-time quote with chart
  history <SYMBOL>     Last 30 days price history
  divs <SYMBOL>        Dividend history with TTM yield
  splits <SYMBOL>      Split history
  options <SYMBOL>     Options chains (all expirations)
  earnings <SYMBOL>    Earnings history and upcoming events
  etf <SYMBOL>         ETF profile (expense ratio, holdings, sectors)
  portfolio [FILE]     Portfolio summary (default: portfolio.srf)
  analysis [FILE]      Portfolio analysis breakdowns (default: portfolio.srf)
  snapshot [opts]      Write a daily portfolio snapshot to history/
  compare <D1> [<D2>]  Compare portfolio state across two dates
  enrich <FILE|SYMBOL>  Generate metadata.srf from Alpha Vantage
  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)
    twelvedata.zig      TwelveData: candles (fallback), quotes (fallback)
    polygon.zig         Polygon: dividends, splits
    fmp.zig             FMP: earnings (actuals + estimates)
    cboe.zig            CBOE: options chains (no API key)
    alphavantage.zig    Alpha Vantage: ETF profiles, company overview
    yahoo.zig           Yahoo Finance: quotes (primary), candles (last resort)
    openfigi.zig        OpenFIGI: CUSIP to ticker lookup
  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 Git Cache file format and portfolio/watchlist parsing
libvaxis Git (v0.5.1) Terminal UI rendering

Building

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.

License

MIT