Skip to main content
CFDLAcademy
All chapters

Part 2 · The core language · Chapter 5 of 28

Expressions

Every amount = you have written so far held a constant. Real claims are rarely constants — rent escalates, interest accrues on a balance, a fee is a percentage of something. The right side of amount = is an expression, evaluated once per scheduled occurrence. This chapter is the working tour of that language: what an expression can see, what it can compute, and the habits that keep a model's intelligence legible.

What an expression can see

An expression evaluates inside a sealed environment. Every name it can read belongs to one of a handful of scopes, and knowing the scopes is knowing the language:

  • time.* — the clock at this occurrence: time.t (the period index, starting at 0), time.date (the period's date), time.phase (the current phase's name), time.days_in_period (the calendar days the period spans: 31 in January, 1 on a daily grid) and time.ppy (periods per year on the model's calendar: 365, 12, 4 or 1). The workhorse: time.t is what growth compounds on.
  • inputs.* — the model's own declared assumptions (next chapter).
  • inputs.* also reaches what the run supplies: a bodiless assumption (assume sofr : cre.base_rate) is filled by full name from the run configuration or its inputs file. Also next chapter.
  • Entity fields — facts and running quantities owned by the model's entities, read by path (asset.tlb.balance), with prev. reaching the prior period's value (chapter 8).

And that is all. No file access, no wall-clock date, no environment variables, no randomness outside declared assumptions. The sealed environment is what makes a run reproducible, and it has a practical authoring consequence. If a number matters to the model, you must declare it somewhere — which is exactly where a reviewer will find it.

Arithmetic you can trust

The operators are the familiar set — + - * / %, ^ for a power (pow is its function form), comparisons, and the spelled-out and, or, not (the language has no &&) — with one property worth a paragraph: arithmetic is decimal, not binary floating point. 0.1 + 0.2 is 0.3, a cent is a cent, and a million-row sum does not accumulate float dust. This is the arithmetic a general-purpose language reserves for its money library, made the default because every number here is money or a rate on money.

Conditionals are the if function — an expression, not a statement:

// A management fee that steps down after the first year.
amount = if(time.t < 12, 12000, 9000)

Here is the environment and the arithmetic together in the most common pattern in modeling — compound growth off the clock:

version 0.1
model "expressions-growth"
time calendar monthly from 2026-01 for 36

entity asset venue : Asset.Real

// 4,000 a month, escalating 3% every 12 periods, compounded.
stream venue.rent on entity asset.venue inflow currency USD {
  schedule every month from 2026-01 to 2028-12
  amount = 4000 * pow(1.03, time.t / 12)
}

stream venue.opex on entity asset.venue outflow currency USD {
  schedule every month from 2026-01 to 2028-12
  amount = 1500
}

Read the rent amount aloud: "four thousand, growing three percent per year, compounded monthly along the way." One line, and the whole escalation policy is in it. (If the deal steps annually rather than compounding smoothly — most leases do — you want pow(1.03, round_down(time.t / 12, 0)), and the difference between those two claims is real money; chapter 9 dwells on it.)

The function library, by family

The library is small and chosen — every function earns its place in cash-flow work. What follows is the working set; the complete reference with signatures lives at cfdl.dev.

Bounds and shape. min, max, abs, clamp(x, lo, hi). clamp is the ramp-builder: clamp(time.t / 18, 0.0, 1.0) is a linear lease-up from zero to full over eighteen periods, and you will write that idiom for the rest of your career.

Growth and decay. pow, exp, ln. Compounding up (pow(1.03, …)) and decline curves down (pow(0.94, time.t) — a 6%-per-period decline).

Rounding. round, round_up, round_down take decimal places; round_to(x, step) rounds to the nearest multiple of a step — round_to(x, 0.01) is cents, round_to(x, 1) whole dollars, round_to(x, 0.25) a quarter-point. Two distinct uses: round_down(time.t / 12, 0) for step functions, and round_to(…, 0.01) for matching a source that rounds each period before summing — the reconciliation tool from chapter 3.

Dates. date, edate (shift by months, the Excel EDATE), eomonth, days_between, months_between, year_frac (day-count-aware year fractions — the bridge between "a rate per annum" and "this period's accrual"), is_business_day, add_business_days, roll.

Aggregation. sum, avg over a list; and six reductions over another stream's published series — series_sum, series_avg, series_min, series_max, series_prod, series_count, each taking a selector and an inclusive period window — plus wal, the weighted average life in years of what a series paid, for a metric over a finished projection. A selector is a stream name, or a pattern: prefix.* matches everything under the prefix, a * segment matches exactly one segment, and | separates alternatives ("fees.* | rent.*"). series_sum is the construct that lets a fee be "2% of collections" without restating collections. When a selector matches several streams, they are added together within each period first, and the fold runs over that combined series — for a sum the order never mattered, but for a maximum it decides the answer: the peak of the combined position and the largest single cell are different numbers. An empty selection returns each fold's own identity (sum 0, product 1, count 0); series_max and series_min have none and return null, which compares (==) but refuses to order or add — an absence can never quietly become a number. The family has an evaluation-order story worth understanding before you lean on it, told in the machinery chapter. Beside it sit two folds over an entity's parts rather than over time: part_sum(<entity>, <summand>) adds a field over every entity part of it, and part_count(<entity>) counts them, each with an optional state or field filter — part_sum(asset.north, rentable_area, leased) is a building's leased area.

One trap worth naming before you meet it in a review. max(series_sum("dbt.*", 0, time.t), 0) does not compute a peak anything — series_sum is one number, the lifetime net over the window, and max of one number and zero is a floor, not a peak. Peak outstanding debt is series_max over the series that carries the balance — an entity field, read under the key the results publish it under. A running total synthesized from flows alone is a scan rather than a reduction, and there is no scan. If you find yourself labeling a series_sum a "peak," the model is claiming a lifetime net and calling it a high-water mark.

(One scope note for later: a declared metric evaluates in this same expression environment, plus the keys the finished results publish — waterfall steps, account balances, entity.<name>.net_cash_flow, model.net_cash_flow. Part III introduces metrics properly.)

Domain. cpr_to_smm (annual prepayment rate to monthly, the standard mortgage conversion), macrs_rate (US tax depreciation schedules), curve_value (chapter 7).

Time value of money. The Excel TVM family with the same argument logic: pv, fv, pmt, ipmt, ppmt, nper, rate. If you can write =PMT(rate, nper, pv) you can write this — and here it earns its keep:

version 0.1
model "expressions-loan"
time calendar monthly from 2026-01 for 60

entity asset borrower : Asset.Financial

// A 250,000 loan at 7.2% annual, fully amortizing over 60 months:
// the level payment, straight from the same PMT you know from a spreadsheet.
// pmt() returns the payment with the sign convention of a payer, so wrap it
// in abs() and let the stream's declared direction carry the meaning.
stream loan.debt_service on entity asset.borrower outflow currency USD {
  schedule every month from 2026-01 to 2030-12
  amount = abs(pmt(0.072 / 12, 60, 250000))
}

// The lender's advance at January's start: `start` places the cash at
// the period's open rather than its close.
stream loan.proceeds on entity asset.borrower inflow currency USD {
  schedule every month start from 2026-01 to 2026-01
  amount = 250000
}

Run it: the payment is 4,973.92, sixty times, against 250,000 up front. ipmt and ppmt split any period's payment into interest and principal when a statement needs the split — same arguments plus the period number.

Expressions you can read aloud

The examples above share a property worth making explicit, because it is the chapter's actual lesson: each amount is one readable sentence about the deal. Three habits keep it that way.

Show the structure, not the result. 4000 * pow(1.03, time.t / 12) over 4000 * 1.0024663; 0.072 / 12 over 0.006. The reviewer checks the claim — base, rate, convention — not your calculator work.

Name what recurs. The moment 0.072 appears in two expressions it belongs in an assumption (assume loan_rate = 0.072, next chapter) — one place to change, one place to review.

Split what compounds. An expression juggling occupancy, escalation, and a fee cap is three claims in a trench coat. Three streams — or an entity field, or a contract — each carrying one claim, total the same and review utterly differently. When an expression stops reading aloud cleanly, reach for a bigger construct, not a longer line. Part III's construct-choice chapter is that decision, systematized.

What can go wrong

A name from nowhere. Misspell a clock binding — time.T — and the compiler refuses it (E1133). Misspell an assumption — inputs.growht — and the model compiles, then the run is refused before any figure is published (E5031), naming every name nothing binds. Either way, nothing resolves silently to zero.

A type that does not fit. Compare a date to a number and the model compiles, but the expression fails in every period it is evaluated, and the run is refused (E5043) rather than given something creative.

The right answer to the wrong question. The engine cannot catch a pmt given a monthly rate and annual nper — both are just numbers. Defense is the chapter-3 habit: one hand-checked payment, every model. The engine guarantees your arithmetic, never your finance.

Exercises

Exercise

The level payment

Replace the placeholder debt service with the real claim.

  1. Write the payment with pmt(): a 250,000 loan at 7.2% annual, fully amortizing over 60 monthly payments.

Anchor the number before you trust it:

  • The first month's interest is 250,000 × 0.6% = 1,500, so the payment must exceed 1,500.
  • Sixty payments must total more than 250,000. The excess is lifetime interest.

After the run, look at the NPV. The run configuration discounts at the loan's own 7.2%, so a fairly priced loan should net to zero — the result is instead about −1,356. The gap is rate conversion, live. The payment uses 7.2% divided by twelve: 0.600% a month, the loan-document convention. Discounting compounds 7.2% into 0.582% a month. Both conventions are defensible, and the difference is real. Explain which rate is higher and why the NPV comes out negative.

Loading exercise…

Then, on your own:

  1. Rewrite the growth model's rent to step 3% annually instead of compounding monthly (round_down is the tool). Predict which of the two claims produces more lifetime rent before you run — then explain the answer in one sentence about when each escalation happens.
  2. Verify one month of the loan by hand: 250,000 × 0.6% is the first month's interest; the payment minus that is the first month's principal. Then confirm with ipmt(0.072/12, 1, 60, 250000) in a scratch stream.