# 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 ```bash # Set at least one API key (see "API keys" below) export TWELVEDATA_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 | Data type | Provider | Auth | Free-tier limit | Cache TTL | |-----------------------|---------------|------------------------|----------------------------|--------------| | Daily candles (OHLCV) | TwelveData | `TWELVEDATA_API_KEY` | 8 req/min, 800/day | 24 hours | | Real-time quotes | TwelveData | `TWELVEDATA_API_KEY` | 8 req/min, 800/day | Never cached | | Dividends | Polygon | `POLYGON_API_KEY` | 5 req/min | 7 days | | Splits | Polygon | `POLYGON_API_KEY` | 5 req/min | 7 days | | Options chains | CBOE | None required | ~30 req/min (self-imposed) | 1 hour | | Earnings | Finnhub | `FINNHUB_API_KEY` | 60 req/min | 24 hours | | ETF profiles | Alpha Vantage | `ALPHAVANTAGE_API_KEY` | 25 req/day | 30 days | ### TwelveData **Used for:** daily candles and real-time quotes. - 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 (batch requests do NOT reduce credit cost). - Candles are fetched with a 10-year + 60-day lookback window for trailing return calculations. - Returns split-adjusted but NOT dividend-adjusted prices. Total returns are computed separately using Polygon dividend data. - Quotes are never cached (always a live fetch, ~15 min delay on free tier). ### Polygon **Used for:** dividend history and stock splits. - 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. ### Finnhub **Used for:** earnings calendar (historical and upcoming). - Endpoint: `https://finnhub.io/api/v1/calendar/earnings` - Free tier: 60 requests per minute. - Fetches 5 years back and 1 year forward from today. - Note: Finnhub requires TLS 1.2. Since Zig's HTTP client only supports TLS 1.3, requests to Finnhub automatically fall back to system `curl`. ### 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): ```bash TWELVEDATA_API_KEY=your_key # Required for candles and quotes POLYGON_API_KEY=your_key # Required for dividends/splits (total returns) FINNHUB_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 | |------------------------|--------------------------------------------------------------------| | `TWELVEDATA_API_KEY` | No candles, quotes, or trailing returns | | `POLYGON_API_KEY` | No dividends -- trailing returns show price-only (no total return) | | `FINNHUB_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](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 | 24 hours | Only changes once per trading day | | Dividends | 7 days | Declared well in advance | | Splits | 7 days | Rare corporate events | | Options | 1 hour | Prices change continuously during market hours | | Earnings | 24 hours | Quarterly events, estimates update periodically | | ETF profiles | 30 days | Holdings/weights change slowly | | Quotes | Never cached | Intended for live price checks | 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 | |---------------|------------| | TwelveData | 8/minute | | Polygon | 5/minute | | Finnhub | 60/minute | | 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 [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 cache stats Show cached symbols cache clear Delete all cached data interactive, i Launch interactive TUI help Show usage ``` ### 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 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: ```bash 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`: ```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 TwelveData. 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 TwelveData cached candles 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 Alpha Vantage company overview data: ```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 `>>`. ## 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. ## 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) enrich Generate metadata.srf from Alpha Vantage 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 ``` ## 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 ticker_info.zig Security metadata providers/ provider.zig Type-erased provider interface (vtable) twelvedata.zig TwelveData: candles, quotes polygon.zig Polygon: dividends, splits finnhub.zig Finnhub: earnings cboe.zig CBOE: options chains (no API key) alphavantage.zig Alpha Vantage: ETF profiles, company overview 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, portfolio summary 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 TLS 1.2 fallback rate_limiter.zig Token-bucket rate limiter cli/ main.zig CLI entry point and all commands tui/ main.zig Interactive TUI application 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://github.com/lobo/srf) | Local (`../../srf`) | Cache file format and portfolio/watchlist parsing | | [libvaxis](https://github.com/rockorager/libvaxis) | Git (v0.5.1) | Terminal UI rendering | ## Building ```bash zig build # build the zfin binary zig build test # run all tests zig build run -- # build and run ``` The compiled binary is at `zig-out/bin/zfin`. ## License MIT