Skip to main content
CFDLAcademy
All chapters

Part 2 · The core language · Chapter 10 of 28

Events and options

Everything so far varies continuously along the grid — amounts grow, ramps climb, balances amortize. But deals also turn: a loan refinances, a covenant trips, a tenant exercises a renewal, an asset sells. These are discrete, once-only changes of regime, and modeling them with arithmetic — flags multiplied into amounts, if pyramids keyed to hard-coded dates — is how spreadsheets bury their most consequential logic. The language gives turning points their own constructs: events for "when this happens, change these things," and options for "someone holds the right to make this happen."

Events: when, then

version 0.1
model "events-refinance"
time calendar monthly from 2026-01 for 24

// The loan's regimes: a closed set of states, and the one move between them.
lifecycle loan {
  initial bridge
  state bridge, refinanced
  bridge -> refinanced
}

entity asset senior : Asset.Financial {
  lifecycle loan
}

// Expensive bridge debt, alive only until the refinance.
stream loan.bridge_interest on entity asset.senior outflow currency USD {
  schedule every month from 2026-01 to 2027-12
  amount = 9500
  active in state bridge
}

// Cheaper permanent debt, alive only after.
stream loan.perm_interest on entity asset.senior outflow currency USD {
  schedule every month from 2026-01 to 2027-12
  amount = 6200
  active in state refinanced
}

stream ops.noi on entity asset.senior inflow currency USD {
  schedule every month from 2026-01 to 2027-12
  amount = 21000
}

// The turning point, stated once: at month twelve, the regime changes.
event refi.trigger when time.t >= 12 {
  set entity asset.senior.status = "refinanced"
}

The regimes are a lifecycle: a closed set of states with a declared initial one, bound to the entity. The loan is in exactly one state from period 0. A stream gates on a state with active in state <name>, and the compiler checks the name against the declared states, so a misspelled state is an error rather than a guard that is false forever. The edge bridge -> refinanced states the one move the loan can make.

An event has a condition and a block of actions. The condition is an ordinary boolean expression. Here it is a date test, but it can read anything expressions read. (A condition that is a plain date reads better in the schedule language, schedule on 2027-01, which the next section shows.) "When cumulative revenue passes the threshold" and "when the balance falls below target" are conditions of the same shape.

The semantics to internalize is the occurrence: an event fires each time its conditions become true having been false, and re-arms when they fall. An event is something that happens, and nothing restricts it to happening once — a unit that defaults, cures and defaults again has had three events, and a model that could record only the first would be wrong.

So how does the refinance above fire only once? Not because the construct stops it. It fires once because the world it describes happens once: after the refinance the loan is refinanced, and the machine declares no edge from refinanced back to bridge. Once-ness is something a model declares, in one of two ways:

  • a schedule whose occurrence is singular — schedule on 2027-03 fires once because the date occurs once;
  • a topology with no way back — the refinance above, where no edge returns the loan to bridge.

If you declare a return edge, you have declared that re-firing is possible. The memory lives in the schedule or the machine — both of which a reader can see — rather than in a "has fired" flag inside the engine. The machine also checks the event: a set … status that no declared edge permits is declined and journaled, naming the move. Every transition taken is published in the results under deterministic.transitions. In the journal, a firing is one fire entry carrying the values its condition read, with the actions it took nested under it as its children; an option's exercise carries its payoff and the entity it is credited to the same way. The results' trace records every period's test as well: not scheduled, tested and false, still true, or fired — so an event that never fires is visible as a run of false, with what its condition read.

An event may also say when it is tested rather than leaving that to its condition:

version 0.1
model "events-covenant"
time calendar monthly from 2026-01 for 24

lifecycle project {
  initial operating
  state operating, trapped
  operating -> trapped
}

entity asset plant : Asset.Real {
  lifecycle project
}

stream plant.noi on entity asset.plant inflow currency USD {
  schedule every month from 2026-01 to 2027-12
  amount = if(time.t < 9, 30000, 22000)
}

stream plant.debt_service on entity asset.plant outflow currency USD {
  schedule every month from 2026-01 to 2027-12
  amount = 20000
}

// Cash to the sponsor, only while the covenant holds.
stream plant.distribution on entity asset.plant outflow currency USD {
  schedule every month from 2026-01 to 2027-12
  amount = 5000
  active in state operating
}

// Tested each quarter on the three months before the test date.
event covenant_test
  schedule every quarter from 2026-04 to 2027-10
  when series_sum("plant.noi", time.t - 3, time.t - 1)
     < 1.20 * abs(series_sum("plant.debt_service", time.t - 3, time.t - 1))
{
  set entity asset.plant.status = "trapped"
}

The schedule supplies the occasions and when filters them. The test reads series strictly backward, over periods that have already settled, and a published series is signed, which is why the debt service is read through abs. A ratio such as a pack's coverage subtotal is a metric's to read, not an event's: a condition reads streams and money subtotals over past periods. Four consecutive failing quarterly tests are four breach events, because the model declared quarterly testing — that is a different statement from "the DSCR was under trigger for a year", and the language lets you say which one you mean.

The actions are a fixed vocabulary: set writes an entity field (chapter 8's third way a field changes), activate / deactivate switch a stream on or off, and exercise fires an option (below). A stream a contract lowered is switched by its generated name, as a declared one is; a name that matches no stream the model runs is refused (E1302), checked after contracts lower. Note the pattern the example uses, because it is the idiomatic one. The event moves the lifecycle (status), and streams key their active in state guards to it. The event owns when the regime changes; each stream owns which regime it belongs to. When the refinance date moves, one condition changes and both loans follow — and the reviewer reads the deal's turning points as a short list of events rather than reverse-engineering them from flag arithmetic.

Options: the right, not the obligation

An option is a contingent claim someone holds — a renewal, an early-purchase right, an expansion. It is an agreement with an election: it says what it is written on, who holds it, its terms, when it may be exercised, what the exercise pays and what it does. An event's exercise action can fire it:

option refi.savings on entity asset.senior type Option.Refinance {
  exercise when false
  payoff 10000 - 250
  set entity asset.senior.status = "refinanced"
}

event refi.trigger when time.t >= 12 {
  exercise option refi.savings
}

The declaration, item by item:

  • What it is written on. on entity names the asset the right is over; on contract <name> names the agreement it is over, as a renewal is a right over a lease.
  • type. The kind of election: Option.Call, Option.Put, Option.Renewal and Option.Refinance need no pack, and a pack adds its own.
  • parties and terms. The roles the type declares, such as holder, and the terms, such as a strike, read in the election and the payoff as contract.strike.
  • When it may be exercised. exercise when is the election. A schedule in the body supplies the dates it may be exercised on, and exercisable 2 times in <phase> on the header states how often and in which phase.
  • What it pays. payoff is the cash the exercise produces, evaluated at exercise time, with a category saying what that cash is, as a stream's does. It is published as its own series, option.<name>, zero where the option was not exercised.
  • What it does. Actions in the body — set, activate, deactivate, exercise — run on each exercise, with the same checks an event's have. Here the exercise itself moves the loan to refinanced.

The exercise when clause can hold the trigger rule itself. Writing exercise when false and firing it from an event, as here, keeps all the deal's turning points in one place. That is a matter of style, and it is the style this course recommends once a model has more than one event.

The deliberate boundary to understand: exercise is rule-based, not optimal. The model exercises when the stated condition says so — period twelve, price above strike, whatever the deal documents say — not when a valuation engine decides exercise maximizes holder value. Optimal exercise — the American-option problem — requires a model of the decision-maker, with all the machinery and debatable assumptions that implies. It sits deliberately outside a language whose promise is that every behavior traces to a stated claim.

What this means in practice: you state the exercise policy, and policy alternatives are scenarios. That is precisely how an investment committee actually discusses a renewal — "assume they renew at year five" against "assume they walk". Chapter 12 shows the probabilistic version.

What can go wrong

A condition that is never true. The event simply never fires — not an error, because "the covenant never trips" is a legitimate outcome of a legitimate model. The trace shows it as a run of false with what the condition read each period, which is where to look first. The defense is a scenario where it does fire, run on purpose. If a turning point matters, one of your run configurations should visit it.

Two events racing. Two events, same period, both writing the same field — the model has claimed two regimes at once. Order-dependence is a smell here as it is everywhere; give the events mutually exclusive conditions and the question disappears.

A guard written as a string comparison. active when entity.status == "operating" compiles, but nothing checks the string: a misspelling is a guard that is false forever. Declare the regimes as a lifecycle with an initial state and gate with active in state operating, which the compiler checks against the declared states. The entity is then in its initial state from period 0, so the model states its starting condition instead of relying on absence.

Exercises

Exercise

One turning point, stated once

The starter pays both loans for the full two years — 15,700 a month of interest on a deal that refinances at month twelve. The lifecycle is declared; put it to work.

  1. Guard each loan stream with active in state: the bridge in bridge, the perm in refinanced.
  2. Add the event that sets the status to "refinanced" at time.t >= 12. The machine permits the write, because it declares the edge bridge -> refinanced.

Predict the saving before you run: twelve months of bridge (9,500) plus twelve of perm (6,200), against twenty-four months of both.

Then check the series:

  • The bridge stops exactly when the perm starts.
  • No month pays both loans. No month pays neither.

An off-by-one is the difference between the event firing at twelve and after twelve. The series tells you which claim you actually wrote.

Loading exercise…

Then, on your own:

  1. Move the refinance trigger from a date test to an economic one: fire when cumulative NOI passes 250,000 (series_sum("ops.noi", 0, time.t)). Predict the firing month by hand first — you know the NOI per month.
  2. Try to express a regime that flaps — refinanced, then not, then refinanced again — using free-standing events alone. You will find you can write the events, but not the constraint: nothing stops the pair from firing in the same period, or in an order the deal could not take. Write down in one sentence what a lifecycle adds that two events cannot, and name the specific thing that makes the flapping reviewable (hint: an edge that does not exist cannot be taken, and every one that is taken is journalled).