zfin/docs/guides/set-up-your-portfolio.md
Emil Lerch 74fc219afd
All checks were successful
Generic zig build / build (push) Successful in 5m48s
Generic zig build / publish-macos (push) Successful in 11s
Generic zig build / deploy (push) Successful in 23s
add docs/guides
2026-06-22 14:53:53 -07:00

7.9 KiB

Build your portfolio

Goal: create a portfolio.srf that captures your holdings -- the shares you own, what you paid, and which account each lot lives in. Everything else in zfin reads from this file.

You'll need: a text editor and a private directory outside the repo (so you never commit real holdings). This guide builds up a file much like the one in examples/pre-retirement-both; the full field list lives in the portfolio.srf reference.

You only need this one file to begin. accounts.srf, metadata.srf, projections.srf, and the rest are optional add-ons you layer on as you need them -- zfin works without them, just with fewer breakdowns (see the .srf files overview).

Key terms

Three words show up throughout zfin, nesting from smallest to largest:

  • Share -- one unit of a security (a stock, ETF, or fund); the atom of ownership.
  • Lot -- one purchase: a batch of shares of a single security, bought on one date at one price, in one account. A lot is the unit of a portfolio.srf line. zfin tracks lots rather than running totals so it can derive each lot's cost basis, holding period (short- vs long-term), and gain/loss.
  • Position (or holding) -- all the lots of the same security rolled together across every account: total shares, average cost, and market value. Your VTI shows as a single position even when it's spread across a 401(k), a Roth IRA, and a taxable brokerage -- a whole-household, cross-account rollup most brokerages won't show you, and a core reason to run zfin. It's one row in the portfolio summary, with its lots indented beneath.

In short: you record lots (one per line) and zfin aggregates them into positions for display.

1. Start the file

A portfolio is one lot per line -- one purchase. Create ~/finance/portfolio.srf:

#!srfv1
symbol::VTI,shares:num:1100,open_date::2018-06-15,open_price:num:140.00,account::Pat 401k

The #!srfv1 header is required. Each line is comma-separated key::value pairs; numbers use key:num:value.

Check it:

ZFIN_HOME=~/finance zfin portfolio

2. Add more lots

Add one line per purchase. Multiple lots of the same symbol aggregate into a single position automatically, so record each buy at its own cost basis rather than averaging by hand:

#!srfv1
symbol::VTI,shares:num:1100,open_date::2018-06-15,open_price:num:140.00,account::Pat 401k
symbol::VTI,shares:num:240,open_date::2015-01-08,open_price:num:103.40,account::Pat Roth
symbol::AGG,shares:num:600,open_date::2020-01-10,open_price:num:115.50,account::Pat 401k
symbol::QQQ,shares:num:65,open_date::2019-11-22,open_price:num:200.10,account::Pat Roth

zfin shows each position with its lots, market value, and gain/loss:

  VTI       2480.0    $138.35    $373.38      $925,982.40 +  $582,874.40    66.9%
    open      1100.0    $140.00                 $410,718.00 +  $256,718.00          2018-06-15 LT Pat 401k
    open       240.0    $103.40                  $89,611.20 +   $64,795.20          2015-01-08 LT Pat Roth

3. Record cash

Cash, money-market, and settlement balances are lots with security_type::cash. They need no symbol, open date, or price:

security_type::cash,shares:num:48000.00,open_date::2026-04-30,open_price:num:1.00,account::Joint taxable

Cash is grouped by account in its own section of the portfolio summary.

4. Use accounts consistently

The account:: value is just a label, but it has to match exactly across lots (and later in accounts.srf). Pick names and reuse them verbatim: Pat 401k, Joint taxable, Family HSA. Account names drive the By-Account and (with accounts.srf) the By-Tax-Type breakdowns in zfin analysis.

5. Special holdings

zfin handles more than stocks and cash. Each is one line; see the reference for full field lists:

  • Sold positions -- add close_date and close_price to a lot.
  • Options -- security_type::option with a readable symbol plus explicit option_type / underlying / strike / maturity_date fields; negative shares to write (sell) a contract. See the covered-call example below.
  • CDs -- security_type::cd with maturity_date and rate.
  • Illiquid assets (home, vehicle) -- security_type::illiquid; counted in Net Worth but not the liquid total.
  • Securities the providers don't cover (e.g. a 401k CIT share class) -- add a manual price:: and price_date::, or a ticker:: alias for pricing. See price resolution.

Example: a covered call

Options trip people up, so here's a worked one. A covered call is two lots -- the shares you own, plus one call you write (sell) against them. Say you hold 100 shares of MSFT and sell a call:

#!srfv1
# The 100 shares you own -- the "covered" part
symbol::MSFT,shares:num:100,open_date::2024-02-01,open_price:num:400.00,account::Joint taxable
# One call written against them
security_type::option,symbol::MSFT 06/19/2026 500.00 C,shares:num:-1,open_date::2026-01-15,open_price:num:6.68,option_type::call,underlying::MSFT,strike:num:500,maturity_date::2026-06-19,account::Joint taxable

Reading the option lot:

  • shares:num:-1 -- you wrote one contract; negative means short (sold). Each contract covers 100 shares.
  • open_price:num:6.68 -- the premium you received, per share ($6.68 x 100 = $668 for the contract).
  • option_type / underlying / strike / maturity_date define the contract. symbol is just a human-readable label -- use whatever your brokerage shows (here MSFT 06/19/2026 500.00 C).
  • It's "covered" because the 100 MSFT shares in the same account back the call. When it's closed or expires, add close_date and close_price (use 0 if it expired worthless and you kept the premium).

zfin values this differently from your brokerage. Your brokerage tracks the call as its own security with its own gain/loss. zfin doesn't price the contract at all; instead it caps the covered shares at the strike while the call is in-the-money. So if MSFT trades at $510 here, zfin values these 100 shares at $50,000 (the $500 strike), not $51,000 -- the upside above the strike belongs to the call holder. See covered-call valuation for the full rule.

6. Optional: split across multiple files

You can keep holdings in several files -- portfolio.srf, portfolio_401k.srf, portfolio_taxable.srf. zfin union-merges every portfolio*.srf in ZFIN_HOME by default, so the CLI and TUI both see one combined view. Target a subset with -p:

zfin -p 'portfolio_*.srf' portfolio      # quote the glob so the shell doesn't expand it
zfin -p portfolio.srf -p portfolio_hsa.srf portfolio

A good first split: closed positions. When you sell, move the closed (sold) lots into a portfolio_closed_positions.srf. Your main portfolio.srf then shows only what you currently hold, while the sold lots still merge in -- so realized gain/loss and back-dated (--as-of) snapshots stay accurate. Because the filename matches portfolio*.srf, it's picked up automatically -- no flag needed.

#!srfv1
# Sold lots live here so portfolio.srf stays focused on current holdings.
symbol::AMZN,shares:num:10,open_date::2022-03-15,open_price:num:150.25,close_date::2024-01-15,close_price:num:185.50,account::Joint taxable

Next steps


Next: Classify your holdings | Documentation home