Statements and reporting
How to get a pro forma out of a model, put your own lines on it, and read it at the grain you report in.
For what the pieces are, see Statements.
Get a statement
There are three ways to get one: declare a statement in the model, use a
pack that ships one, or take the default — a model that declares none renders
its entity hierarchy as a statement marked default, so results always read
as the model's shape rather than a flat list of series.
The quickest is a pack. A pack classifies the streams its contracts emit, and declares the statement they roll into. Nothing else is required.
version 0.1
model "statement-walkthrough"
use pack "cre" version "0.1.0"
time calendar monthly from 2026-01 for 24
entity asset tower : CRE.Asset.RealProperty
contract cre.lease on entity asset.tower {
term 2026-01..2027-12
terms {
rent = 25000
}
}Run it with the cre pack and the results carry a statements section. In the
playground it is the Statement tab:
Base rental revenue 600,000
Effective gross income 600,000
Net operating income 600,000
Put a hand-written stream on it
A stream that carries no category is not on the statement — it appears in an
Unclassified row instead, so cash is never quietly lost. Give it a
category and it joins the right line and every subtotal above it:
stream cre.parking on entity asset.tower inflow currency USD {
schedule every month from 2026-01 to 2027-12
category operating.revenue.other
amount = 3000
}Now:
Base rental revenue 600,000
Parking 72,000
Effective gross income 672,000
Net operating income 672,000
Cash flow before debt service 672,000
Cash flow after debt service 672,000
Net cash flow to equity 672,000
The line is itemized by stream, so the parking row carries the stream's own label. Below net operating income the pack's statement subtracts capital costs to reach cash flow before debt service, then debt service to reach cash flow after it, then adds the financing and capital events to reach net cash flow to equity. This model has no capital cost, loan or sale, so each of those subtotals equals net operating income, and debt service coverage is undefined rather than a number. The rows that carry nothing are left out of the excerpt.
The stream was classified once, and it is both a visible line and part of the subtotal. There is no way to have one without the other.
Use the categories the pack declares — see pack contracts.
Without a pack, any path rooted in operating, investing or financing is
valid.
Declare your own
A model declares a statement the same way a pack does. The generated form
names a hierarchy the results already carry — the part of tree or the
dotted category path — and a depth to cut it at:
statement portfolio {
label "Portfolio by property"
structure entity
depth 2
}The rows follow from the tree: a node whose children are shown is a subtotal,
a node whose children are cut off by depth is a line carrying all of its
descendants' cash. That single rule keeps the bottom line reconciling at
every depth.
Where the tree is not the presentation — curated labels, expenses shown positive under "Less:", a coverage ratio at the bottom — a statement states its own rows instead:
slice noi { category "operating.*" }
slice debt_service { category "financing.debt.service" }
statement operating {
label "Operating statement"
line "Base rental revenue" { category "operating.revenue.base_rent" }
line "Less: operating costs" { category "operating.expense.*" display positive }
subtotal "Net operating income" { category "operating.*" }
spacer
ratio "DSCR" { of noi to debt_service display positive }
}A line or a subtotal may also select by contract: type names a
contract type or master and line one of its lines by role, so
line "Senior interest" { type Contract.Debt line interest } gathers the
interest of every debt agreement. A stream selector may be a pattern (*
for one segment, .* for everything under a prefix). A non-cash stream,
such as an accrual that moves an account and no cash, falls in no row.
A row may instead draw a published series — a fold the run already computed, such as a pack subtotal or the model's own net:
subtotal "Net cash flow (memo)" { series "model.net_cash_flow" }A series row presents that fold beside the claimed rows and claims nothing
itself: its figure stays out of the bottom line, so a memo of domain.cre.noi
never double-counts the cash it summarizes. A claim clause beside a series
is refused (E1370), and a key the run does not publish renders no values
rather than a column of zeros.
A statement is authored or generated, never both (E1369). Rows state cash
by selection, subtotals and ratios; there is no row that evaluates an
arbitrary expression. A statement the model declares replaces the pack's
statements in the results. A pack's own subtotals are folds of three kinds:
a sum, a negated sum, and a ratio. A generated
statement may also carry a slice — an orthogonal filter, so any structure
can be shown for any selection — and metrics, naming declared metrics to
publish beside it. See Statements for the
rules.
Read it annually
The CRE pack publishes a monthly statement and an annual one from the same run. Nothing is re-modeled: the annual view regroups the same ledger, and its columns are years rather than months.
Where you need a different grain than a pack offers, declare it: a model
statement carries a grain clause and reports at it. Values re-bucket into
the coarser periods; the total stays the lifetime figure; a ratio is
recomputed from the re-bucketed inputs.
statement portfolio {
structure entity
grain annual
}A coverage ratio is not averaged. Annual DSCR is annual NOI over annual debt service, recomputed from the re-bucketed inputs. The mean of twelve monthly ratios is a different number, and where the monthly denominator varies it is a badly different one.
Check the bottom line
Every statement publishes a reconciliation: its own total, the total of the
universe it reports, and the residual between them. For most statements that
universe is the model; a statement filtered by a slice reconciles against
the slice's total instead, because reporting the filter itself as a shortfall
would make a warning fire on a correct model.
reconciliation.residual = 0.0
A non-zero residual means the statement and its universe disagree, and the usual cause is a stream carrying no category — which is why the Unclassified row exists rather than the cash silently vanishing. A statement that is short by an unnoticed line looks entirely plausible, so this is published always, whether it is zero or not.
Get the numbers out
The statement is part of the results document, so anything that reads results reads it — the Python SDK, the API server, or the JSON directly.
Per-period subtotals are also published as ordinary series under
domain.<pack>.<name>, so a chart or an assertion can read domain.cre.noi
without going through the statement at all. That is how the benchmark suite
asserts published NOI and coverage figures period by period.
Related
- Statements reference — the pieces and the rules
- Domain packs — what each pack classifies and rolls up
- Reading results — the rest of the document