Skip to main content
CFDLAcademy
All chapters

Part 2 · The core language · Chapter 13 of 28

Uncertainty and Monte Carlo

Chapter 6 let an assumption carry a distribution and promised the payoff later. This is later. A model whose inputs are distributions can answer the question every point estimate dodges: how wrong could this be, and with what probability? The same file that produced your base case produces the distribution around it. No second model, no bolted-on add-in, no copy that drifts.

Distributions, and choosing among them

version 0.1
model "mc-lease-up"
time calendar monthly from 2026-01 for 36

entity asset building : Asset.Real

assume stabilized_rent ~ Normal(mean=85000, stdev=6000, clip=[60000, 110000])
assume months_to_stabilize ~ Triangular(min=9, mode=14, max=24)
assume exit_multiple ~ LogNormal(mu=0.05, sigma=0.18)

stream building.rent on entity asset.building inflow currency USD {
  schedule every month from 2026-01 to 2028-12
  amount = inputs.stabilized_rent * clamp(time.t / inputs.months_to_stabilize, 0.0, 1.0)
}

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

stream building.exit on entity asset.building inflow currency USD {
  schedule on 2028-12
  amount = 620000 * inputs.exit_multiple
}

Four families, and the finance judgment of when each is the right shape:

  • Normal(mean, stdev, clip=[lo, hi]) — symmetric, additive uncertainty: a rent level, a cost estimate, anything where errors up and down are equally plausible. The clip states the bounds beyond which you refuse to believe your own distribution — and note the discipline: clipping is declared in the model, not applied silently.
  • LogNormal(mu, sigma) — multiplicative, right-skewed, never negative: prices, multiples, anything that compounds. The natural home for "it can double more easily than it can halve."
  • Uniform(min, max) — pure bounds, no view inside them. The right shape for "somewhere between 40 and 60, and I refuse to pretend I know where."
  • Triangular(min, mode, max) — the expert-elicitation shape: worst, best-guess, best. The right form for timeline and cost questions where someone experienced will give you exactly those three numbers and no more.

Notice the ramp now divides by a distributed months_to_stabilize — the uncertainty flows through the same expressions the base case uses. That is the design's point: uncertainty is not a separate model, it is a wider reading of the same claims.

A distribution takes the same type and within as a point value (chapter 6): assume occupancy : fraction ~ Normal(mean=0.92, stdev=0.03, clip=[0.7, 1.0]) within [0.5, 1.0]. The two bounds do different work. clip truncates the draws; within states the range the deal admits, and a value the run supplies outside it refuses the run (E5041). A clip that could produce a value outside the type's domain or the within is refused at compile time (E2306), because its draws would break the bound.

Running trials

The model can carry its own run policy as a statement: run monte_carlo trials 2000 seed 42, with both numbers required. The run configuration can supply it instead. The configuration wins when both exist, and it is where this course keeps it:

{
  "deterministic": { "annual_discount_rate": 0.09 },
  "monte_carlo": { "trial_count": 2000, "seed": 42 }
}

Each trial draws every distributed assumption once — by its full name, so a distribution inside a set (inputs.market.renewal.rent_share) draws like any other, and an assumption derived from a drawn one follows the draw — runs the full model, and records the results. A trial is a complete deterministic run, and every metric it computed is carried — the engine's model.*, the pack's domain.*, and any metric.<name> the model declared — so a figure the deal solved for gets a distribution, not just the built-ins. A declared metric a trial could not evaluate, such as the IRR of a party that received nothing on that path, is listed under the trial's omitted with the reason, not published as a number. A metric that is text rather than a number, or that changes kind between trials, gets no summary. The output then reports the distribution of every metric alongside your deterministic case: mean, standard deviation, min and max, and percentiles from p01 to p99, computed with linear interpolation between order statistics (the same method R calls type 7), so a percentile of two thousand trials is not hostage to whichever single trial sits nearest the rank.

Each summary also states its own trials — the count of trials that published that metric, and it matters because the count is not always the trial count. model.irr exists only where the flows solve for a rate; a path whose flows never change sign publishes none, and without the count, a mean over three trials and a mean over two thousand would read identically. Per-entity distributions come the same way: every trial carries entity.<symbol>.total, and the published entity graph says what each symbol is and whose it is — the join that lets you put a distribution on one building of a portfolio.

Two properties of the machinery matter to a practitioner. The seed makes trials reproducible: same model, same configuration, same two thousand paths, byte for byte, on any machine. "The p05 was −1.2 million" is a checkable statement in a committee memo, not a weather report. And each assumption owns its own draw stream, derived from the seed and the assumption independently — so adding a new distributed assumption does not reshuffle the draws of existing ones. Add a cost distribution and your rent paths stay identical, which means the change in the output is attributable to the assumption you added. Between-run comparability of this kind is exactly what add-in Monte Carlo tools quietly fail to provide.

A named scenario is not a trial. A scenario is one deterministic run with its own overrides, and the configuration can name several beside the simulation. Each publishes a full summary under scenarios.summaries: its metrics, and also its series, its annual rollup, its inputs, its statements, and, where the model has them, its slices, its transitions and its journal. A trial publishes only its metrics and any it omitted; its acts are gathered once, across all trials, in the Monte Carlo journal. Use a scenario when the question is what a stated case does line by line; use trials when the question is how likely a range of outcomes is.

Reading a distribution like a practitioner

The deterministic case runs through the distributions' central values, so the number the committee saw is still in the report, unchanged. Around it, the checks worth making on the percentiles, in the order they usually matter:

  1. Check that the base case sits near the median. If the deterministic NPV is far from p50, your central values and your distributions disagree about the deal. The usual cause is a skewed input, such as that LogNormal, pulling the mean away from the mode. Understand why before quoting either number.
  2. Read the p05, and decide whether the deal survives it. The downside tail is the lender's and the risk committee's question. A deal with a fine mean and a fatal p05 is a different deal from one with the same mean and a boring p05; no point estimate distinguishes them.
  3. Read the fraction of trials that go negative. The run publishes it: monte_carlo.aggregates.npv.p_negative, beside the NPV's mean, median and standard deviation. "Eleven percent of paths lose money" is the sentence that changes meetings. It is concrete, it is checkable, and a base case plus a downside case cannot produce it. For acts rather than figures — a default, an exercise, a transition — the Monte Carlo journal lists each distinct act once, with the share of trials in which it occurred, and the Monte Carlo trace does the same for each decision: the share of trials in which a loan's lifecycle moved into default, and when it first did. The trials' outcome has its own ledger_hash, apart from the trial count and seed, which the published run records.
  4. Find the input that drives the spread. Widen one distribution at a time and watch the output percentiles move. Ten minutes of this identifies the assumption worth diligence money — the one whose uncertainty actually propagates.

Branching: when uncertainty is a fork, not a wobble

Real risk is often binary: the tenant renews or vacates, the permit lands or does not. A distribution on rent cannot say that — but a uniform draw through a branch can:

assume renewal_draw ~ Uniform(min=0.0, max=1.0)

// 70% of trials: renewal at market. 30%: three months dark, then a re-let
// at a discount. (The lease-up model's grid ends 2028-12; the rollover year
// is its final year, periods 24 through 35.)
stream building.rollover_rent on entity asset.building inflow currency USD {
  schedule every month from 2028-01 to 2028-12
  amount = if(inputs.renewal_draw < 0.70, 85000, if(time.t < 27, 0, 74000))
}

Each trial commits to one branch for its whole path. This is a fork in the world, not noise around a level. The output distribution goes properly bimodal, with a cluster of renewal paths and a cluster of vacancy paths. The percentiles then say what no blended-average model can: the deal's median is fine and a quarter of paths spend a quarter starved. Blending 70/30 into "expected rent of 81,700" would have hidden exactly the scenario the reserve account exists for. Deterministically, the draw resolves to 0.5 — the renewal branch — so the base case stays sensible too.

A draw per entity and period: sample

inputs.<name> is one draw per trial, the same everywhere in that trial. Some risk is not one draw per trial: each loan in a pool defaults on its own, in its own month. sample(inputs.<name>) reads a draw of the same distribution for the reader's subject entity and period:

version 0.1
model "mc-sample"
time calendar monthly from 2026-01 for 24

assume u ~ Uniform(min=0.0, max=1.0)
assume monthly_default_prob : fraction = 0.02

// Each loan, each month it is current, defaults with probability 2%.
lifecycle loan {
  initial current
  state current, defaulted
  current -> defaulted when sample(inputs.u) < inputs.monthly_default_prob
}

entity asset loan_a : Asset.Financial {
  lifecycle loan
}

entity asset loan_b : Asset.Financial {
  lifecycle loan
}

stream loan_a.interest on entity asset.loan_a inflow currency USD {
  schedule every month from 2026-01 to 2027-12
  amount = 1000
  active in state current
}

stream loan_b.interest on entity asset.loan_b inflow currency USD {
  schedule every month from 2026-01 to 2027-12
  amount = 1000
  active in state current
}
  • A draw is keyed, never sequenced. The key is the run's seed, the trial, the assumption's full name, the subject entity and the period. The same key always gives the same value, so two reads in one rule agree, and loan_a and loan_b draw independently because their keys differ.
  • Where it is legal. sample is read where a subject and a period are both bound: a field rule, a stream, a lifecycle guard and an arrival action. An event's when, an option's election, a waterfall, an account and a metric move no entity, so a sample there is refused (E2315). An assumption is evaluated before any period exists, so a sample inside one is refused too (E2313).
  • In a deterministic run both reads give the distribution's central value, so the uniform above reads 0.5, and no loan defaults in the base case.

Quantiles: dispersion inside one period

Distributions spread a value across trials. Some economics depend on a spread within a single period — a battery earns the gap between the most and least expensive hours of a month; overage rent is an option on sales above a breakpoint; a tranche absorbs losses between two attachment points. For those, the model declares the spread itself:

version 0.1
model "quantiles-battery"
time calendar monthly from 2026-01 for 12

entity asset battery : Asset.Financial

// A price duration curve: the value at each cumulative share of the
// period's hours. Interpolation is a claim here exactly as it is for a
// series — linear glides between the stated points.
assume prices = quantile linear {
  0.00:  11.0
  0.50:  28.0
  0.98: 340.0
  1.00: 512.0
}

// The battery discharges into the top 2% of hours and charges from the
// bottom half, at 85% round-trip efficiency — a dispersion payoff no
// scalar "average spread" can state.
stream battery.arbitrage on entity asset.battery inflow currency USD {
  schedule every month from 2026-01 to 2026-12
  amount = (quantile_mean(inputs.prices, 0.98, 1.0) - quantile_mean(inputs.prices, 0.0, 0.5) / 0.85) * 100.0
}

Three functions read a quantile-shaped assumption, each taking it as inputs.<name> first. quantile_mean(inputs.<name>, lo, hi) averages a slice — the top 2% of hours, the bottom half. quantile_at(inputs.<name>, share) reads one point — the median hour's price. quantile_of(inputs.<name>, value) inverts it — the share of hours below a stated threshold, which is what a breakpoint or an attachment point needs. The construct is the answer to a failure you can now name: a nonlinear payoff evaluated at a point estimate is wrong even when the point estimate is right — an option on sales evaluated at average sales returns zero whenever the average sits below the breakpoint, when the true expectation is positive. When the payoff bends, feed it the distribution it bends over.

Two clauses serve a quantile taken from a market source. by exceedance writes the points worst-first, the way a duration curve is printed — quantile linear by exceedance { 1.00: 512.0 0.98: 340.0 … } — and the compiler stores the same function either way. The type slot may name a pack observable, assume ercot_north : energy.power_price = quantile …, which gives the values their unit and lists the assumption in the compiled model's required_refs, the list of what the model needs from outside.

Quantiles and trial distributions compose: the declared curve carries the within-period shape, and a distributed assumption multiplying the read carries the across-trials uncertainty about its level.

What can go wrong

Distributions on everything. Twenty distributed assumptions produce a wide, smooth, unexplainable fan. Distribute the three to five inputs that are both genuinely uncertain and genuinely consequential (question 4 above tells you which); state the rest as the scalars they effectively are. Precision about what you are uncertain about is the whole craft.

Independence you did not mean. Draws are independent — rent and exit multiple wobble separately, though in the world they move together. Sometimes that is acceptable; when it is not, derive both from a shared draw (a market_draw assumption feeding both expressions) so the correlation is stated. Never present independent-draw tails as if they measured a correlated crash.

Quoting the mean of a skewed output. After a LogNormal exit and a branch or two, the output is not symmetric, and the mean is not the middle. Quote the median and the tails — p50, p05, p95 — and say "mean" only when you have looked at the histogram and it earned the word.

Exercises

Exercise

Un-blend the rollover

The starter averages a 70% renewal and a 30% vacancy into 81,700 a month — a rent that no future actually pays.

  1. Replace the blend with the fork: a uniform draw, and a branch that commits each trial to one world.
  2. Run.
  3. Put the deterministic result and the Monte Carlo percentiles side by side.

What to find: the deterministic case draws its central 0.5 and renews. The trials split — about seven hundred renewal paths, and about three hundred with a dark quarter and a discounted re-let. Locate the blended starter's answer relative to the two clusters. The answer sits between worlds, which is precisely the problem with blends: a reserve sized on the average is wrong in every path that occurs.

Loading exercise…

Then, on your own:

  1. In the lease-up model, tighten stabilized_rent's stdev to 1,000 and rerun: which percentiles move, and which barely? Then restore it and tighten months_to_stabilize instead. You have just performed the drives-the-spread analysis on a real model.
  2. Make the renewal branch's two arms share their world with the market: replace the fixed 85,000 renewal rent with a level driven by the same draw (74000 + inputs.renewal_draw * 20000). One draw, two consequences — the correlation is now stated in the model, which is the pattern for every "when it rains it pours" risk you will ever need to model.