Introduction
What is saldo?
saldo is a domain-specific programming language (DSL) and command-line
interface (CLI) for creating financial forecasts.
Accounts
An account is a named balance that the simulator tracks over time. Every account that appears in an entry or assertion must be declared.
Syntax
account <path> [= <expression> @ <date>]
The path is one or more identifiers joined by colons:
account Assets:Cash
account Assets:Retirement:Jim
account Liabilities:Loan
account Income:Gross:Salary:Jim
account Expenses:Rent
Opening balance
Without = … @ …, an account starts at zero and is available for the entire
simulation. Supply an initial value and a date to give the account a known
opening balance on a specific day:
account Assets:Cash = 12_500 @ 2025-01-01
account Liabilities:Loan = -450_000 @ 2025-01-01
account Assets:Retirement:Seb = 87_340.22 @ 2024-07-01
The = value and @ date must always appear together — one without the other
is a parse error.
The expression is evaluated on the opening date. It can reference params and accounts that are already open, but not accounts that open later or leg aggregations.
Referencing accounts before their opening date
Referencing an account (in an entry posting or an expression) before its
@ date is a runtime error:
account Assets:Cash = 1000 @ 2025-06-01
// This entry fires in January — before Assets:Cash opens — and will error:
entry monthly "Paycheck" {
Assets:Cash = 500
Income:Salary
}
To avoid the error, ensure entry schedules start no earlier than the latest opening date of any account they reference, or set the opening date early enough to cover the simulation range.
Simulation start after opening date
If your simulation start is later than an account’s opening date, saldo automatically warms up the simulation from the opening date. All entries that fire during the warm-up period are processed normally — only their output (ledger transactions, CSV rows) is suppressed. The opening-balances entry in ledger output reflects the actual balance at your simulation start, after the warm-up.
account Assets:Cash = 1000 @ 2024-01-01
entry monthly "Paycheck" {
Assets:Cash = 500
Income:Salary
}
// Running from 2025-01-01: Assets:Cash opens at 1000 on 2024-01-01, then
// 12 monthly paycheck entries fire during warm-up, so the opening balance
// shown in the ledger output is 7000 (1000 + 12 × 500).
Naming conventions
saldo does not enforce any particular hierarchy, but the double-entry conventions used throughout these docs are:
| Root | Holds |
|---|---|
Assets:… | Things you own (cash, retirement, savings) |
Liabilities:… | Things you owe (loans, accrued interest) |
Income:… | Sources of income — carried as negative by convention |
Expenses:… | Spending categories |
Income accounts are negative because every paycheck entry credits income (negative posting) and debits assets (positive posting), keeping the net of all postings at zero.
Using accounts in expressions
Reference an account by its full path in any expression:
// Current balance of a liability account
Liabilities:Loan * interest_rate / 365
// Assert cash never goes below a threshold
assert that Assets:Cash >= 10_000
// Compute daily interest on the outstanding balance
entry daily "Interest accrual" {
Liabilities:AccruedInterest = Liabilities:Loan * interest_rate / 365
Expenses:Interest
}
The value of an account reference is the balance at the start of the current simulation day, before any entries fire on that day. Within a single entry the balance reflects each posting as it is applied, so a later posting in the same entry sees an updated value.
Declaration order
Accounts appear in the CSV output columns in the order they are declared. Declare them in the order you want to read them.
Complete example
account Assets:Cash = 12_500 @ 2025-01-01
account Assets:Retirement:Jim = 45_000 @ 2025-01-01
account Liabilities:Loan = -320_000 @ 2025-01-01
account Income:Gross:Salary:Jim
account Expenses:Rent
param jim_salary : usd/year = 130_000
param interest_rate = 0.065
entry monthly "Jim's paycheck" {
Assets:Cash = jim_salary / 12
Income:Gross:Salary:Jim
}
entry monthly "Rent" {
Expenses:Rent = 3_915
Assets:Cash
}
entry daily "Loan interest" {
Liabilities:Loan = Liabilities:Loan * interest_rate / 365
Expenses:Interest
}
assert that Assets:Cash >= 0
Schedules
A schedule determines when an entry fires or an assertion is checked. Schedules appear inline on entries and assertions, or they can be named and reused.
Named schedules
schedule <name> = <schedule-expression>
schedule semi_monthly = every month on the 15th, last day
schedule every_two_weeks = every second friday from 2026-01-02
A named schedule is referenced by its identifier wherever a schedule is expected.
Adverbial shortcuts
The simplest schedules are single-word adverbs. Each has a sensible default when no further detail is given.
| Keyword | Fires on |
|---|---|
daily | Every day |
weekly | Every Monday |
monthly | Last day of every month |
quarterly | Mar 31, Jun 30, Sep 30, Dec 31 |
yearly / annually | Dec 31 |
Each adverb accepts an optional on clause to override the default:
weekly on friday # every Friday
weekly on monday and wednesday # Mon and Wed each week
monthly on the 1st # first of every month
monthly on the 15th, last day # 15th and last day
yearly on jan 1st # New Year's Day
yearly on may first, jul last # May 1 and July 31 each year
The every form
The every keyword gives you full control.
every <period> [from <date>]
every <n> <period> from <date>
Periods
day — fires every day (or every n days from a start date):
every day
every 3 days from 2026-01-01
week [on <days>] — fires every week on the given day(s). Without on,
defaults to Monday:
every week
every week on thursday
every week on weekend and friday
<weekday> — shorthand for every week on <weekday>:
every friday
every monday
every weekday
month [on the <occurrences>] — fires every month. Without on the,
defaults to the last day of the month:
every month
every month on the 1st
every month on the last day
every month on the 2nd monday
every month on the 3rd thursday, 15th
<month-name> [<ordinal>] — fires once a year in the named month. Without
an ordinal, defaults to the last day of that month:
every january
every january 1st
every december last
every aug 15th
quarter — fires at the end of each quarter:
every quarter
year [on <month> <ordinal>, ...] — fires once a year. Without on,
defaults to Dec 31:
every year
every year on april 15th
every year on jan 31, jul 31
Skipping occurrences
Prefix the period with a count to fire every nth occurrence. A from date
is required to anchor the sequence:
every 2 weeks from 2026-01-05 # biweekly starting Jan 5
every second friday from 2026-01-02 # alternate Fridays
every 3 months from 2026-01-01 # quarterly with custom anchor
every 2 years from 2026-01-01 # biennially
Ordinal words (second, third, fourth, …, tenth) and numeric suffixes
(2nd, 3rd, 4th, …) are both accepted.
Literal date lists
A comma- or and-separated list of ISO dates fires on exactly those days:
2026-04-15
2026-04-15 and 2026-10-15
2026-01-01, 2026-07-04, 2026-12-25
Default behaviors summary
| Form | Default firing day |
|---|---|
weekly | Monday |
every week | Monday |
monthly | Last day of month |
every month | Last day of month |
quarterly | Quarter-end (Mar 31 / Jun 30 / Sep 30 / Dec 31) |
yearly / every year | Dec 31 |
every <month-name> | Last day of that month |
Params
A param is a named numeric value that can change over time. Params let you express things like salary, contribution limits, or interest rates in one place and reference them throughout your entries and assertions.
Constant params
param <name> [: <unit>] = <expression>
param interest_rate = 0.05
param retirement_rate = 0.16
param max_401k : usd/year = 24_500
The expression is evaluated once and the param holds that value for the entire simulation.
Time-varying params
param <name> [: <unit>] {
from <date> [to <date>] = <expression>
from <date> [to <date>] = <expression>
...
}
param salary : usd/year {
from 2025-12-31 to 2026-04-01 = 115_000
from 2026-04-01 = 130_000
}
Each interval specifies a from date (inclusive) and an optional to
date (exclusive). The simulator uses whichever interval covers the current
day. Intervals must not overlap. An interval without a to clause extends
indefinitely.
A more complete example:
param beth_salary : usd/year {
from 2026-01-01 to 2027-01-01 = 160_000
from 2027-01-01 to 2028-01-01 = 190_000
from 2028-01-01 to 2029-01-01 = 225_000
from 2029-01-01 to 2030-01-01 = 255_000
}
Units
The optional : <unit> annotation is documentation — it is not yet
enforced by the simulator. Units help readers understand what a number
represents. In future versions of saldo, units will be used to verify
type-safe calculations within the model.
param max_401k : usd/year = 24_500
param hsa_limit : usd = 8_550
param rate : % = 0.05
A unit is a single identifier (usd, %, year) or two identifiers
separated by / (usd/year).
Using params in expressions
Reference a param by name in any expression:
jim_salary / 12
interest_rate / 365
min(max_401k - retirement_contribution.ytd, salary * rate / 12)
A time-varying param automatically returns the right value for the current date, so you never need to branch on time in your expressions.
Aggregations
Named legs on entries (see Entries) accumulate into period-to-date buckets that you can read in any expression:
| Syntax | Meaning |
|---|---|
<leg>.ytd | Year-to-date total of the named leg |
<leg>.qtd | Quarter-to-date total |
<leg>.mtd | Month-to-date total |
<flow>.<leg>.ytd | Year-to-date total, scoped to a specific flow alias |
These reset automatically at the start of each year, quarter, or month.
min(max_401k - retirement_contribution.ytd, max_401k / 24)
Entries
An entry (also called a flow) is a transaction that fires on a schedule. Each time it fires, it moves money between accounts according to a list of postings. All postings in one firing must balance to zero.
Syntax
entry <schedule> "<label>" {
<account> [= <amount>] [as <leg>]
...
} [as <alias>]
Postings
Each line inside the braces is a posting: an account and an optional amount.
Fixed amount
Assets:Cash = 5_000
Expenses:Rent = 3_915.30
The account balance is increased by the given amount. Use a negative expression to decrease a balance.
Auto-balance
Omit = on exactly one posting per entry. saldo calculates the amount that
makes all postings sum to zero:
entry monthly "Jim's paycheck" {
Assets:Retirement:Jim = 1_500
Assets:Cash = 7_500
Income:Gross:Salary:Jim // auto-balanced: receives -9_000
}
Because income accounts carry a negative balance by convention, the auto-balanced posting receives the negation of the net inflow.
Clearing a balance (all)
Use = all to move the entire current balance of an account:
entry monthly "Loan payment" {
Liabilities:AccruedInterest = all // clears whatever has accrued
Liabilities:Loan = 2_000
Assets:Cash
}
Named legs
Append as <name> to a posting to give it a leg name. The leg accumulates
into period-to-date totals that can be read in later expressions within the
same simulation day:
entry semi_monthly "Seb's paycheck" {
Assets:Retirement:Seb = min(max_401k - retirement_contribution.ytd,
max_401k / 24 + 0.01) as retirement_contribution
Assets:Cash = seb_salary / 24 - retirement_contribution
Income:Gross:Salary:Seb as gross_income
} as seb_paycheck
retirement_contribution.ytd is the running year-to-date sum of every
retirement_contribution leg across all firings so far this year. Once a
leg name is established, you can reference it in the same entry on
subsequent posting lines (as retirement_contribution above, without .ytd),
which gives you the value from the current firing.
Available aggregation suffixes:
| Suffix | Resets |
|---|---|
.ytd | January 1 |
.qtd | First day of each quarter |
.mtd | First day of each month |
Flow alias
The optional as <alias> at the end of the block gives the flow a name for
use in scoped aggregations:
} as seb_paycheck
Reference a leg scoped to this flow with <alias>.<leg>.ytd:
assert that seb_paycheck.retirement_contribution.ytd <= 24_500
Without an alias, leg aggregations are unscoped and can be referenced directly by leg name.
Complete example
param max_401k : usd/year = 24_500
param jim_salary : usd/year {
from 2025-12-31 to 2026-04-01 = 115_000
from 2026-04-01 = 130_000
}
param retirement_rate = 0.16
entry monthly "Jim's paycheck" {
Assets:Retirement:Jim = min(max_401k - retirement_contribution.ytd,
jim_salary * retirement_rate / 12) as retirement_contribution
Assets:Cash = jim_salary / 12 - retirement_contribution
Income:Gross:Salary:Jim
} as jim_paycheck
entry daily "Interest accrual" {
Liabilities:AccruedInterest = Liabilities:Loan * interest_rate / 365
Expenses:Interest
}
Asserts
An assertion is a condition that must hold true on certain days. If the condition evaluates to false the simulation stops and reports the failure. Assertions are how you express financial constraints and goals.
Syntax
assert [<schedule>] that <boolean-expression>
Without a schedule, an assertion is checked every day of the simulation. With a schedule, it is checked only on days that match.
Daily assertions
assert that Assets:Cash >= 0
assert that jim_paycheck.retirement_contribution.ytd <= 24_500
These fire every day. The first ensures the cash account never goes negative. The second ensures a named leg never exceeds a limit.
Scheduled assertions
Any schedule expression can precede the condition:
assert quarterly that Assets:Retirement >= 0
assert monthly on the last day that Assets:Cash >= 10_000
assert every friday that Liabilities:AccruedInterest >= 0
Date-specific assertions
A single date (or a list of dates) acts as a schedule:
assert 2026-12-31 that Assets:Retirement:Seb == 24_500
assert 2026-12-31 that Assets:Retirement:Jess == 24_500
Expressions
Assertion expressions support the same operators as entry amounts:
| Operator | Meaning |
|---|---|
<, <= | Less than, at most |
>, >= | Greater than, at least |
== | Equal |
if … then … else … | Conditional (both then and else are required) |
min(), max() | Built-in functions |
Account references, param names, and aggregation suffixes (.ytd, .qtd,
.mtd) all work inside assertion expressions.
Examples
// Cash never goes negative
assert that Assets:Cash >= 0
// 401(k) contribution limit not breached
assert that jim_paycheck.retirement_contribution.ytd <= max_401k
// Target retirement balance hit by a specific date
assert 2026-12-31 that Assets:Retirement:Beth == 24_500
// Sanity-check every quarter
assert quarterly that Assets:Retirement:Seb >= 0
Functions
User-defined functions let you name and reuse a computation across params, entries, and assertions. They are pure — they take explicit arguments and return a value; they cannot read global params or account balances.
Syntax
fn <name>(<param>, ...) {
[let <name> = <expression>;]
...
[return] <expression>
}
The final expression is the return value. The return keyword is optional.
Defining a function
fn double(x) { x * 2 }
fn net(gross, rate) {
let tax = gross * rate;
gross - tax
}
Local bindings introduced with let are available for the rest of the body.
Calling a function
A function call looks like any other expression and can appear anywhere an expression is valid: in a param definition, an entry amount, or an assertion.
param gross = double(salary)
entry monthly "pay" {
Assets:Cash = net(gross / 12, 0.3)
Income:Salary
}
Calling built-ins
User-defined functions can call built-in functions such as min and max.
fn positive(x) { max(x, 0) }
Calling other functions
A function can call any other function defined in the file, as long as there is no cycle.
fn double(x) { x * 2 }
fn quad(x) { double(double(x)) }
Conditional expressions
if … then … else … works inside function bodies.
fn bonus(salary, target) {
return if target > 0 then salary * 0.1 else 0;
}
Time-varying inputs
Functions have no special awareness of time, but because params are evaluated as of the current simulation date before being passed in, a function automatically produces a different result on different days when its arguments are time-varying.
fn double(x) { x * 2 }
param salary {
from 2025-01-01 to 2025-07-01 = 100
from 2025-07-01 = 200
}
param doubled = double(salary)
During January doubled is 200; from July onwards it is 400. The
function definition itself never changes — only the value of salary at
the point it is called does.
Restrictions
- No global params. Function bodies can only reference their own
parameters and local
letbindings. Referencing a global param or account inside a function body is an error. - No recursion. A function cannot call itself, directly or through a cycle of calls.
- No duplicate names. Each function name must be unique within the file.
- Arity is checked. Calling a function with the wrong number of arguments is an error.
Full example
fn net(gross, rate) {
let tax = gross * rate;
gross - tax
}
param salary : usd/year {
from 2025-01-01 to 2026-01-01 = 120_000
from 2026-01-01 = 140_000
}
entry monthly "paycheck" {
Assets:Cash = net(salary / 12, 0.28)
Expenses:Tax = salary / 12 * 0.28
Income:Salary
}