Documentation
hforecast.yaml reference
The hforecast.yaml file is the single configuration file for Hforecast. It defines your
financial scenario declaratively: who you are, what you earn, what you spend, what you own, and
how you want funds distributed.
Every section is optional except your age. See full.yaml for a worked example that exercises nearly every field, and defaults.yaml for the fallback values.
Top-level fields
age: 35 # Your current age (whole years)
# birthday: 1990-06 # ...or a birth year-and-month, instead of age (more precise)
endOfPlanAge: 90 # Age the simulation stops at, exclusive (optional; default: 10 years out)
Specify exactly one of age or birthday. The simulation starts on the current date and runs
until endOfPlanAge (or ten years out if omitted). endOfPlanAge is exclusive — the plan stops
when you reach that age, so endOfPlanAge: 90 simulates through age 89. All dollar amounts in the
config are in today’s dollars; the simulation inflation-adjusts them automatically to the date each
takes effect (see How amounts are inflated and grown).
The remaining top-level keys — globals, taxes, currencies, symbols, accounts,
income, expenses, incomeRules, and actions — are the
sections documented below.
Globals
The globals block sets global fallback rates. All fields are optional.
globals:
currency: $ # Default currency symbol (denominates all prices)
inflationRate: 0.025 # Annual inflation for the default currency (2.5%)
interestRate: 0.04 # Fallback interest rate for currencies (4%)
equityGrowthRate: 0.07 # Fallback price growth for equity symbols (7%)
realPropertyGrowthRate: 0.03 # Fallback price growth for real-property symbols (3%)
couponRate: 0.05 # Fallback yield for bond/treasury symbols (5%)
dividendRate: 0.02 # Fallback dividend yield for equity symbols (2%)
These rates are the source of truth for anything you don’t override more specifically. A
currency without its own entry in currencies uses inflationRate/interestRate; a symbol
without its own entry in symbols uses the growth and yield rates for its asset class.
Taxes
The taxes block controls how income is taxed. The simplest form is a single flat effective
rate:
taxes:
effectiveRate: 0.20 # Take a flat 20% of income each year
For more granularity, set per-agency rates. Each of federal, state, city, and fica may
be a flat rate (a number), a path to a bracket file, or a jurisdiction code that resolves to a
bundled bracket file:
taxes:
filingStatus: single # single | married-filing-jointly
federal: examples/taxes/federal-2024.yaml # a bracket file
state: ny # a jurisdiction code (looks up the latest bundled file)
city: nyc
fica: 0.0765 # or a flat rate
fica combines Social Security and Medicare and does not model the wage-base cap precisely. See
Known limitations for the full list of tax caveats.
Currencies
The currencies map overrides the inflation and interest rates for individual currencies. Each
key is an ISO code; both fields are optional and fall back to globals.
currencies:
USD:
interestRate: 0.04
EUR:
inflationRate: 0.02
interestRate: 0.03
The default currency is always registered automatically. A currency accrues its own interest and inflation in isolation — there are no cross-currency exchange rates (see Known limitations).
Symbols
The symbols map declares the asset class and rates for the commodity tickers you hold. Each key
is a ticker; all value fields are optional and fall back to the class-based defaults.
symbols:
VOO:
class: equity # cash | treasury | equity | bond | real-property
growthRate: 0.07 # Annual price growth
dividendRate: 0.02 # Dividend yield (equities)
TREASURY:
class: treasury
couponRate: 0.05 # Coupon yield (bonds/treasuries); alias for the yield
APT1:
class: real-property
price: 400000 # Explicit starting price (else derived from a holding's cost)
dividendRate and couponRate are aliases for the same underlying yield — use whichever reads
naturally for the asset. Tickers you hold but don’t list here are resolved from their class-based
defaults (an unlisted real ticker is assumed to be equity).
A bond or treasury symbol’s price is held flat — its entire return is the coupon, paid out as
income — so a growthRate on one does nothing and is rejected as a config error. Set the yield
with couponRate instead. (Price appreciation and a secondary bond market aren’t modeled.)
Synthetic asset-class tickers
You don’t have to name a real security to model an asset class. Four synthetic tickers are built in — one per appreciating/yielding class — so you can hold “an amount of equities” without inventing a symbol:
| Ticker | Asset class | Over time |
|---|---|---|
EQUITY |
equity | price appreciates at the equity rate; pays the dividend yield |
BOND |
bond | price held flat; pays the coupon yield as income |
TREASURY |
treasury | price held flat; pays the coupon yield as income |
PROPERTY |
real property | price appreciates at the real-property rate |
Each is a $1-NAV fund — one unit is worth $1 at the plan start — that grows and yields at its
class’s global rate, with no symbols: entry required. So a balance of 200000 EQUITY seeds $200,000
of equities, and invest: EQUITY sweeps free cash into equities (see
Accounts). Plain currency ($50000) represents cash; there is no synthetic cash ticker.
Because a synthetic ticker is priced at a round $1, it’s ideal when you care about a dollar amount in a
class rather than a share count. Use a real ticker (listed in symbols: with its own price,
growthRate, and yield) when you want a specific security’s rates or a realistic share price. Listing
a symbol whose key is EQUITY/BOND/TREASURY/PROPERTY overrides the built-in defaults for that
name.
Accounts
The accounts section lists your starting accounts and seeds them with holdings. Each account
maps to an hledger account name and is classified by type, which determines its tax treatment.
accounts:
- name: assets:bank:checking # The hledger account name (optional; derived from type if omitted)
description: Rainy-day cash # Optional label, distinct from the account name
type: savings # savings | brokerage | traditional-401k | roth-ira | hsa | property | loan
balance: $25000 # Seed holdings (see below)
- name: assets:brokerage
type: brokerage
balances: # `balances` (plural) seeds multiple holdings
- 333 VOO @ $300 # hledger @ syntax: per-share cost at the start of the simulation
- 333 VTI @@ $99900 # hledger @@ syntax: total cost at the start of the simulation
- name: assets:hsa
type: hsa
balance: $8000
- name: liabilities:auto-loan # A standalone loan (car, student, credit)
type: loan
balance: 18000 # Current balance owed (positive)
interestRate: 0.06 # Annual rate
term: 4 # Remaining years
Seed holdings are given under balance (or balances — synonyms), as one of:
- a currency amount, e.g.
$25000or25000 USD - a commodity amount, e.g.
100 VOO, optionally with an@/@@cost annotation - a list mixing any of the above
- the string
infer-from-journalto read the balance from the journal (see below)
Account types determine tax treatment:
savings,brokerage: taxable accountstraditional-401k: tax-deferred (contributions reduce taxable income, withdrawals taxed as income)roth-ira: tax-free growth (withdrawals not taxed)hsa: tax-free for medical expensesproperty: real estate, held as a priced commodity. Optionalresidence: truemarks a primary residence, andliquidate:(defaulttrue) controls whether the funding cascade may force-sell it to cover a shortfall. A mortgage links to it from the loan side (seeloanbelow), not the other way round.loan: a liability that amortizes rather than growing. Give it abalance:(a number, orinfer-from-journal) plusinterestRate:andterm:(original years), or supply the terms ashforecast-loan-rate:/hforecast-loan-term:journal tags (the tag rate is a percentage, e.g.6.5). Add ahforecast-loan-start:journal tag with the origination date to shorten the term to what remains today. A level payment posts each period until the balance reaches zero. To finance a property, addpropertyAccount:(or ahforecast-property:journal tag) naming the property account; that property’s sale pays the loan off.mortgageis an accepted alias.
If name is omitted, it defaults to assets:<type> (disambiguated with a -2/-3 suffix when
several accounts share a type). An account must specify at least a name or a type.
Automatic investing. An account with an invest: allocation converts its free cash into shares each
period — so income routed here by incomeRules (or seeded cash) becomes holdings rather than sitting
idle. The value is a single ticker (100%) or a ticker→percent map that must sum to 100. The
synthetic asset-class tickers (EQUITY, BOND, TREASURY,
PROPERTY) need no other setup, but any ticker works.
- name: assets:brokerage
type: brokerage
invest: EQUITY # all free cash -> EQUITY
- name: assets:retirement:401k
type: traditional-401k
invest: { EQUITY: 70, BOND: 30 }
sellOrder: [BOND, EQUITY] # optional: when liquidated, sell BOND before EQUITY
Percentages split incoming cash; they aren’t rebalanced targets (no selling to hit weights).
Without invest:, cash stays cash. For one-off, precise trades use actions: (purchase-stock, …).
To change an account’s allocation partway through the plan, use an update-autoinvest action (it
retunes what future cash buys; it does not sell what is already held).
Liquidation order. When the funding cascade sells an account’s holdings to cover a shortfall, it
drains accounts in the default account-type order (see Funding cascade below), and
within an account it sells the optional sellOrder: tickers first (in order), then the safest class
first (cash, treasury, bond, equity, real-property).
Inferring from a journal. Instead of literal seed holdings, set balance: infer-from-journal to
read the account’s starting holdings from the journal passed with -f (an optional class: forces
the asset class of everything held there). See Journal inference.
Income
Income defines earnings. Each entry activates at fromAge and deactivates at toAge; omit either
bound to leave that side open.
income:
- description: Salary # Optional label
type: salary # salary | social-security | general
period: annual # one-time (general only) | annual | monthly | biweekly
amount: 150000 # Amount per period in today's dollars
growthRate: 0.05 # Annual nominal growth rate (see "Amounts and growth" below)
fromAge: 30
toAge: 65
additionalSalaryOptions: # Only for salary type
retirementContribution: # Diverts part of your paycheck, pre-tax
account: assets:retirement:401k
amount: 23000 # Annual pre-tax contribution
retirementMatch: # New income from your employer into the account
account: assets:retirement:401k
amount: 3000
hsaSeed:
account: assets:hsa
amount: 0
hsaContribution:
account: assets:hsa
amount: 4150
# An untaxed general income -- a gift, family support, an annuity you aren't taxed on. Recurring
# here; use period: one-time for a single lump (fires once at fromAge).
- description: Family support
type: general
period: annual
amount: 12000
fromAge: 30
toAge: 40
Salary income automatically calculates federal, state, city, Social Security, and Medicare withholding. Social Security income is not withheld — it is reconciled annually, and is taxable under the usual provisional-income rule.
A general income is an untaxed credit: a gift, family support, or any non-wage income the
recipient isn’t taxed on. Like an expense it may be period: one-time (a single credit at fromAge)
or recurring, and it lands in income:general, which no tax query reads. Keeping it in income:
lets a recurring or scheduled gift sit alongside your other earnings; a one-off tied to an exact date
can instead be a receive-income action (see Actions).
Amounts and growth. amount is in today’s dollars. If an income starts in the future, its base
is inflated up to the start date so it keeps that purchasing power. From the start date onward a
salary or general income grows by growthRate per year — a nominal rate: inflation is not
additionally layered on during the income’s life, so growthRate must itself include any cost-of-living
increase you want (leaving it at 0 holds the income flat in nominal dollars, eroding its real value).
This differs from expenses, which are inflation-adjusted throughout. Social Security is the
exception: it tracks inflation as a cost-of-living adjustment for its whole life, so setting a
growthRate on a social-security income is rejected as a config error. See
How amounts are inflated and grown for the full model
across expenses, income, actions, and contributions.
Deriving the amount from a journal. Instead of amount, an entry may take a query (any
hledger query, expr: included) whose flow over queryPeriod (last-full-year or year-to-date)
is annualized by queryMethod (annualized). Requires -f; mutually exclusive with amount. See
Journal inference.
Expenses
Expenses define recurring costs. Like income, they activate by age.
expenses:
- description: Rent # Optional label; used in the generated transactions
type: rent # general | rent
period: monthly # one-time | annual | monthly | biweekly | daily
account: expenses:housing:rent
amount: 1800
fromAge: 35
toAge: 90
amount is in today’s dollars and, unlike income, is re-inflated every period, so a recurring expense
keeps pace with inflation throughout its life. (Mortgage and loan payments are the exception — they’re
fixed in nominal dollars from origination.) See
How amounts are inflated and grown.
The rent type is special: it’s automatically skipped once you own property (any account with
type: property in state).
An expense may also take a query (with queryPeriod/queryMethod) instead of amount to derive
its amount from a journal, exactly as income does above.
The one-time period fires the expense only once, in the year where currentAge == fromAge (toAge
does not apply). Keeping a one-off in expenses: lets you sit it right next to any recurring costs it
induces — a boat purchase above its slip fees and insurance, say:
- description: Boat
period: one-time
account: expenses:entertainment
amount: 20000
fromAge: 45
- description: Boat upkeep
period: annual
account: expenses:boat
amount: 3000
fromAge: 45
A one-off can equally be written as a dated spend-money action (see Actions) when you
want an exact date rather than an age; both are inflated from today’s dollars.
All expense amounts are inflation-adjusted from the plan start date. If expenses exceed the general account balance, the funding cascade liquidates assets from your configured funding sources to cover the shortfall.
Income rules
Income rules allocate the cash in the general account (where paychecks land, net of taxes) to your
other accounts each period, in list order — a “pay yourself first” waterfall. Each rule moves
only cash already in general; it never liquidates other accounts. If a target account has an
invest: allocation, the cash routed to it is then invested.
incomeRules:
- description: Emergency fund # fill savings to $50k as fast as income allows
strategy: build-target-balance
targetBalance: 50000
account: assets:bank:savings
fromAge: 35
toAge: 65
- strategy: contribute-continuously # a fixed annual amount
account: assets:retirement:roth-ira
amount: 7000
- strategy: contribute-remainder # sweep whatever is left over
account: assets:brokerage
Every rule is the same capped transfer — min(available, targetBalance − currentValue, amount) —
so the strategies differ only in which caps you set:
build-target-balance: needstargetBalance(compared against the account’s total value — cash + holdings at market, so it works for invested accounts too, and never overshoots). With anamountit drips that much per period; without one it fills as fast as available cash allows.contribute-continuously: needsamount; contributes it every period regardless of balance.contribute-remainder: noamountortargetBalance; sweeps everything still in general into the account. Place it last to fully drain general each period.
targetBalance and amount are in today’s dollars (inflation-adjusted at runtime). Because rules run
in order and each is capped by what’s left in general, earlier rules take priority.
Funding cascade
When the general account can’t cover a cost, hforecast automatically liquidates your other accounts to raise the cash — there is no configuration for this. Accounts are drained in a fixed order by type:
savings → brokerage → hsa → roth-ira → traditional-401k → property
A property account is only force-sold if its liquidate: flag is on (the default). Within an
account, holdings sell in the order described under Accounts (sellOrder: first, then
safest class first). Withdrawals incur the appropriate treatment — capital-gains tax on taxable
holdings, and ordinary income tax plus any early-withdrawal penalty on retirement/HSA accounts, with
tax-deferred accounts subject to the bundled retirement regulations. See
How it works.
Real estate
Real estate is not a dedicated section — it is a hub-and-spoke of the ordinary building blocks. The
property asset account is the hub; a mortgage (type: loan), its costs (expenses:), and its
buy/sell events (actions:) all reference it. This gives one source of truth and lets a property
carry more than one lien.
Property you already own is a type: property account (inferred from a journal or with literal
balances) plus a type: loan account that names it via propertyAccount: (see
Accounts and journal inference).
Property you buy during the plan is a buy-property action — its “birth certificate”, since no
account exists yet. It creates the type: property account (carrying residence/liquidate),
records the purchase, and — when liabilityAccount is given — originates a mortgage and pays closing
costs; omit liabilityAccount for a cash purchase. Only account and purchasePrice are required.
actions:
- action: buy-property
date: 2033-06-01
account: assets:real-estate:apartment # the property asset account this creates
description: Apartment
propertyCommodity: APT1 # optional; defaults to the last account segment, upper-cased
growthRate: 0.03 # optional; defaults to the real-property class rate
purchasePrice: 400000 # today's dollars
closingCostsRate: 0.03 # optional; default 0
residence: true # optional; default false
liquidate: true # optional; default true
liabilityAccount: liabilities:mortgage:apartment # omit for a cash purchase
downPaymentRate: 0.20
interestRate: 0.065
loanTerm: 30
Recurring costs are ordinary expenses: tied to the property with propertyAccount:; they are
charged only while it is owned (a sale, scheduled or forced, stops them). Use type: property-cost
for consumption (HOA, tax, insurance, maintenance) and type: property-improvement for capital
improvements — the latter are capitalized into the property’s cost basis, so they shrink the taxed
gain at sale rather than being spent outright:
expenses:
- description: Property tax
type: property-cost
period: annual
account: expenses:taxes:property
amount: 6000
propertyAccount: assets:real-estate:apartment
- description: Improvements
type: property-improvement
period: annual
account: expenses:housing:improvements
amount: 1000
propertyAccount: assets:real-estate:apartment
Purchase price and all costs are inflation-adjusted from the plan start date. A sell-property
action (below) sells at market value, records the capital gain (basis raised by any capitalized
improvements) and closing/broker fees, and pays off every linked lien.
Actions
Actions are one-off imperative events that don’t fit the declarative model — lump-sum transfers, stock trades at specific times, one-time expenses on known dates, or manual rate changes. The summary table below is the quick reference; Actions documents every action’s fields with examples.
In annual mode, actions whose date falls within a given year execute in date order after income and expenses but before growth, so action-driven balance changes are captured by the growth averaging. In daily mode, actions fire on their exact scheduled date.
The transaction is dated at the start of the period the action falls in, not at the action’s own
date: daily posts on the exact date, monthly on the 1st of the action’s month, and annual on
January 1 of the action’s year. The date’s day (and, in annual mode, month) only picks and orders
the action within its period. This is intentional — everything in a period shares its start date so
same-period events see one another (for example, a buy-property mortgage must be visible to that
period’s loan payment) — so use a finer -g/--granularity when you need actions dated exactly.
actions:
# transfer-money moves cash between your own accounts; the source must hold the funds.
# (For a one-off gift or inheritance, use a receive-income action instead.)
- action: transfer-money
date: 2030-07-01
amount: 15000
account1: assets:bank:checking
account2: assets:brokerage
description: Move savings into brokerage
- action: spend-money
date: 2035-09-01
amount: 5000
account: expenses:travel
description: Europe trip
- action: sell-all-stocks
date: 2060-01-01
ticker: VOO
account: assets:brokerage
Available actions:
| Action | Fields | Description |
|---|---|---|
transfer-money |
amount, account1, account2 |
Transfer a fixed currency amount between accounts |
transfer-all-money |
account1, account2 |
Transfer the entire currency balance between accounts |
transfer-all-commodities |
account1, account2 |
Transfer all commodity holdings between accounts |
purchase-stock |
ticker, shares, account |
Buy a specific number of shares |
purchase-stock-by-amount |
ticker, amount, account |
Buy shares worth a currency amount |
sell-stock |
ticker, shares, account |
Sell a specific number of shares |
sell-all-stocks |
ticker, account |
Sell the entire position in a commodity |
update-autoinvest |
account, invest |
Retune an auto-investing account’s allocation mid-plan |
spend-money |
amount, account |
One-time expense (funded from the general account) |
add-expense |
expenseType, period, amount, account, propertyAccount |
Add a recurring expense to the live set mid-plan |
remove-expense |
description, account |
Remove recurring expenses matching a description + account |
add-income |
incomeType, period, amount |
Add a recurring (non-growing) income mid-plan |
remove-income |
description |
Remove recurring incomes matching a description |
set-income-growth |
description, growthRate |
Retune the growth rate of incomes matching a description |
buy-property |
account, purchasePrice, … (see Real estate) |
Create a property, record the purchase, finance a mortgage |
sell-property |
account, closingCostsRate, brokersFeeRate |
Sell a property at market value and pay off its linked loans |
make-additional-loan-payment |
account, amount |
Extra principal against a loan to retire it early (one-off or recurring) |
refinance-loan |
account, interestRate, term, closingCostsRate |
Refinance a loan: re-amortize its balance at a new rate/term mid-plan |
reimburse |
amount, account1 |
Credit an amount to an account |
set-currency-rate |
currency, inflationRate, interestRate |
Change a currency’s inflation and/or interest rate mid-simulation |
set-inflation-rate |
currency, rate |
Shorthand for set-currency-rate’s inflation-only case |
register-symbol |
ticker, assetClass, growthRate, dividendRate, initialPrice |
Introduce a new symbol |
set-symbol-rate |
ticker, growthRate, dividendRate, couponRate |
Retune a known symbol’s growth and/or yield mid-simulation |
All actions require date. The repeat, until, and (where applicable) description fields are
optional.
sell-property works on any property account — one seeded from a journal (owned real estate) or one
created during the plan by a buy-property action. It sells at the current market value, records
the capital gain and the closing/broker fees, and pays off every loan linked to the property through
its propertyAccount: field / hforecast-property: tag. Because it removes the property from state,
firing it also stops that property’s costs and ends any linked loan’s amortization. Each loan also
stops on its own once its balance hits zero.